// ==UserScript==
// @name Steam AI Agent 对话智能体
// @name:en Steam AI Agent Chat
// @namespace https://github.com/steam-card-view
// @version 1.8.2
// @description Steam AI对话智能体侧边栏,支持多模型供应商(DeepSeek/GLM/Qwen/MiMo/Kimi等),精美SVG图标,游戏评价/玩家分析/购买建议/打折分析,多对话管理,对话历史,自定义智能体。支持知识库热更新。v1.8.2 新增游戏官方动态查询工具(getGameNews):问"dota2/cs2 最近更新"等具体游戏动态时,自动解析游戏名称(dota2/cs2等常见游戏内置别名直达)并拉取官方公告/版本更新(ISteamNews),无需API Key
// @description:en Steam AI agent sidebar with multi-provider support, SVG icons, game review, player analysis, purchase advice, discount analysis, conversation history, custom agents.
// @icon data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3E%3Crect%20width='24'%20height='24'%20rx='6'%20fill='%236366f1'/%3E%3Cpath%20d='M7%205.5h10a1.5%201.5%200%200%201%201.5%201.5v7a1.5%201.5%200%200%201-1.5%201.5h-4.5l-4%203.2V15.5H7a1.5%201.5%200%200%201-1.5-1.5V7A1.5%201.5%200%200%201%207%205.5z'%20fill='white'/%3E%3Cpath%20d='M12%207.5l1%202.8a2%202%200%200%200%201.1%201.1L17%2012.5l-2.9%201.1a2%202%200%200%200-1.1%201.1L12%2017.5l-1-2.8a2%202%200%200%200-1.1-1.1L7%2012.5l2.9-1.1a2%202%200%200%200%201.1-1.1L12%207.5z'%20fill='%236366f1'/%3E%3C/svg%3E
// @match https://store.steampowered.com/*
// @match https://steamcommunity.com/*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_info
// @grant unsafeWindow
// @connect store.steampowered.com
// @connect api.steampowered.com
// @connect steamcommunity.com
// @connect api.deepseek.com
// @connect open.bigmodel.cn
// @connect api.xiaomimimo.com
// @connect dashscope.aliyuncs.com
// @connect api.moonshot.cn
// @connect api.baichuan-ai.com
// @connect api.minimaxi.com
// @connect ark.cn-beijing.volces.com
// @connect api.openai.com
// @connect raw.githubusercontent.com
// @connect api.augmentedsteam.com
// @connect api.isthereanydeal.com
// @run-at document-idle
// @noframes
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const _v = typeof GM_info !== 'undefined' ? GM_info.script.version : '1.8.0';
console.log(`%c[SA-Agent] v${_v} Steam AI Agent 已启动`, 'color:#8b5cf6;font-weight:bold;font-size:13px');
// ============================================================
// 配置与常量
// ============================================================
const CONFIG = {
AI_DEFAULT_URL: 'https://api.deepseek.com/v1/chat/completions',
AI_DEFAULT_MODEL: 'deepseek-chat',
PANEL_WIDTH: 420,
MAX_ITERATIONS: 5,
TOOL_TIMEOUT: 30000,
AI_TIMEOUT: 120000,
CACHE_TTL: 30 * 60 * 1000,
TOAST_DURATION: 3000,
};
// ============================================================
// 模型供应商预设
// ============================================================
const MODEL_PROVIDERS = {
deepseek: {
name: 'DeepSeek',
icon: 'deepseek',
apiUrl: 'https://api.deepseek.com/v1/chat/completions',
models: [
{ value: 'deepseek-v4-pro', label: 'DeepSeek V4' },
{ value: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' },
],
defaultModel: 'deepseek-v4-pro',
apiKeyUrl: 'https://platform.deepseek.com/api_keys',
keyHint: '在 platform.deepseek.com 创建 API Key',
},
glm: {
name: '智谱GLM',
icon: 'glm',
apiUrl: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
models: [
{ value: 'glm-4-flash', label: 'GLM-4-Flash (免费)' },
{ value: 'glm-5', label: 'GLM-5' },
{ value: 'glm-5.3', label: 'GLM-5.3' }
],
defaultModel: 'glm-4-flash',
apiKeyUrl: 'https://open.bigmodel.cn/usercenter/apikeys',
keyHint: '在 open.bigmodel.cn 创建 API Key',
},
qwen: {
name: '通义千问',
icon: 'qwen',
apiUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
models: [
{ value: 'qwen-plus', label: 'Qwen Plus' },
{ value: 'qwen-max', label: 'Qwen Max' }
],
defaultModel: 'qwen-plus',
apiKeyUrl: 'https://dashscope.console.aliyun.com/apiKey',
keyHint: '在 dashscope.console.aliyun.com 创建 API Key',
},
mimo: {
name: '小米MiMo',
icon: 'mimo',
apiUrl: 'https://api.xiaomimimo.com/v1/chat/completions',
models: [
{ value: 'mimo-v2.5-pro', label: 'MiMo v2.5 Pro' },
{ value: 'mimo-v2.5', label: 'MiMo v2.5' },
],
defaultModel: 'mimo-v2.5-pro',
apiKeyUrl: 'https://platform.xiaomimimo.com/#/console/api-keys',
keyHint: '在 platform.xiaomimimo.com 创建 API Key',
},
moonshot: {
name: 'Kimi',
icon: 'moonshot',
apiUrl: 'https://api.moonshot.cn/v1/chat/completions',
models: [
{ value: 'kimi-k3', label: 'Kimi K3' }
],
defaultModel: 'kimi-k3',
apiKeyUrl: 'https://platform.moonshot.cn/console/api-keys',
keyHint: '在 platform.moonshot.cn 创建 API Key',
},
baichuan: {
name: '百川',
icon: 'baichuan',
apiUrl: 'https://api.baichuan-ai.com/v1/chat/completions',
models: [
{ value: 'Baichuan4-Turbo', label: 'Baichuan4 Turbo' },
{ value: 'Baichuan4', label: 'Baichuan4' },
],
defaultModel: 'Baichuan4-Turbo',
apiKeyUrl: 'https://platform.baichuan-ai.com',
keyHint: '在 platform.baichuan-ai.com 创建 API Key',
},
minimax: {
name: 'MiniMax',
icon: 'minimax',
apiUrl: 'https://api.minimaxi.com/v1/chat/completions',
models: [
{ value: 'MiniMax-M3', label: 'MiniMax M3' }
],
defaultModel: 'MiniMax-M3',
apiKeyUrl: 'https://platform.minimaxi.com',
keyHint: '在 platform.minimaxi.com 创建 API Key',
},
doubao: {
name: '豆包',
icon: 'doubao',
apiUrl: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
models: [
{ value: 'doubao-seed-2-1-pro-260628', label: 'Doubao Seed 2.1 Pro' },
{ value: 'doubao-pro-32k', label: 'Doubao Pro 32K' },
{ value: 'doubao-pro-128k', label: 'Doubao Pro 128K' },
],
defaultModel: 'doubao-seed-2-1-pro-260628',
apiKeyUrl: 'https://console.volcengine.com/ark',
keyHint: '在 console.volcengine.com/ark 创建 API Key',
},
openai: {
name: 'OpenAI',
icon: 'openai',
apiUrl: 'https://api.openai.com/v1/chat/completions',
models: [
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'gpt-4-turbo', label: 'GPT-4 Turbo' },
],
defaultModel: 'gpt-4o-mini',
apiKeyUrl: 'https://platform.openai.com/api-keys',
keyHint: '在 platform.openai.com 创建 API Key',
},
custom: {
name: '自定义',
icon: 'custom',
apiUrl: '',
models: [],
defaultModel: '',
apiKeyUrl: '',
keyHint: '输入兼容 OpenAI 格式的 API 地址',
},
};
// ============================================================
// SVG 图标库
// ============================================================
const SVG_ICONS = {
robot: ``,
gamepad: ``,
gift: ``,
bot: ``,
sparkle: ``,
stats: ``,
chat: ``,
refresh: ``,
copy: ``,
send: ``,
stop: ``,
plus: ``,
close: ``,
download: ``,
edit: ``,
trash: ``,
play: ``,
check: ``,
gear: ``,
history: ``,
key: ``,
cpu: ``,
messageSquare: ``,
lightbulb: ``,
search: ``,
news: ``,
book: ``,
zap: ``,
globe: ``,
user: ``,
// 供应商图标 - 彩色几何图形
provider_deepseek: ``,
provider_glm: ``,
provider_qwen: ``,
provider_mimo: ``,
provider_moonshot: ``,
provider_baichuan: ``,
provider_minimax: ``,
provider_doubao: ``,
provider_openai: ``,
provider_custom: ``,
};
function getIcon(name, size = 16) {
const svg = SVG_ICONS[name];
if (!svg) return '';
return svg.replace(/
');
html = html.replace(/\n/g, '
');
if (!html.match(/^<(h\d|pre|ul|ol|blockquote|p)/)) html = '
' + html + '
';
return `${html}
`;
}
function downloadFile(content, filename) {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
}
function formatTime(ts) {
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
}
function formatRelativeTime(ts) {
const now = Date.now();
const diff = now - ts;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff/60000)}分钟前`;
if (diff < 86400000) return `${Math.floor(diff/3600000)}小时前`;
if (diff < 604800000) return `${Math.floor(diff/86400000)}天前`;
return formatTime(ts).slice(0, 10);
}
function toast(msg, type = '') {
const el = document.createElement('div');
el.className = `sa-toast ${type ? 'sa-' + type : ''}`;
el.textContent = msg;
document.body.appendChild(el);
requestAnimationFrame(() => el.classList.add('sa-show'));
setTimeout(() => {
el.classList.remove('sa-show');
setTimeout(() => el.remove(), 300);
}, CONFIG.TOAST_DURATION);
}
function gmFetch(url, options = {}) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || 'GET',
url,
headers: options.headers || {},
data: options.data,
timeout: options.timeout || 20000,
onload(resp) {
if (resp.status >= 200 && resp.status < 300) {
resolve(resp.responseText);
} else {
reject(new Error(`HTTP ${resp.status}: ${resp.responseText.slice(0, 200)}`));
}
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时')),
});
});
}
function detectSteamId() {
try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.g_steamID) return String(unsafeWindow.g_steamID); } catch {}
try {
const appConfig = document.getElementById('application_config');
if (appConfig) {
const userInfo = JSON.parse(appConfig.getAttribute('data-userinfo') || '{}');
if (userInfo.steamid) return String(userInfo.steamid);
}
} catch {}
try {
const m = document.documentElement.innerHTML.match(/g_steamID\s*=\s*"(\d{17})"/);
if (m) return m[1];
} catch {}
try {
// steamLoginSecure cookie: 76561198xxx%7C%7Ctoken,第一段即 SteamID64
const m = /steamLoginSecure=([0-9]+)/.exec(document.cookie || '');
if (m && /^\d{17}$/.test(m[1])) return m[1];
} catch {}
return '';
}
function getConfiguredSteamId() {
return String(GM_getValue(SK.STEAM_ID, '') || '').trim();
}
// 统一解析 SteamID:显式参数 > 设置中配置 > 自动检测当前登录用户
function resolveSteamId(explicit) {
const fromParam = String(explicit || '').trim();
if (fromParam) return { steamId: fromParam, source: '参数' };
const configured = getConfiguredSteamId();
if (configured) return { steamId: configured, source: '设置' };
const detected = detectSteamId();
if (detected) return { steamId: detected, source: '自动检测' };
return { steamId: '', source: '' };
}
// 自定义 ID(vanity URL 名) → SteamID64,需要 Steam API Key
async function resolveVanityToId64(vanity, apiKey) {
const resp = await gmFetch(`https://api.steampowered.com/ISteamUser/ResolveVanityURL/v1/?key=${encodeURIComponent(apiKey)}&vanityurl=${encodeURIComponent(vanity)}`, { timeout: 15000 });
const data = JSON.parse(resp);
return (data?.response?.success === 1 && data?.response?.steamid) ? data.response.steamid : null;
}
function getStoreCountryCode() {
try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.g_strCountryCode) return String(unsafeWindow.g_strCountryCode); } catch {}
return 'CN';
}
function formatCents(cents, currency) {
const n = Number(cents);
if (!Number.isFinite(n) || n < 0) return '';
const val = (n / 100).toFixed(2);
const cur = String(currency || '').toUpperCase();
if (cur === 'CNY') return `¥${val}`;
if (cur === 'USD') return `US$${val}`;
if (cur === 'EUR') return `€${val}`;
if (cur === 'GBP') return `£${val}`;
if (cur === 'JPY') return `¥${val}`;
return cur ? `${val} ${cur}` : val;
}
function formatDealEnds(expirationSec) {
const ts = Number(expirationSec);
if (!Number.isFinite(ts) || ts <= 0) return '';
const diffMs = ts * 1000 - Date.now();
if (diffMs <= 0) return '已结束';
const d = new Date(ts * 1000);
const dateStr = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const hours = diffMs / 3600000;
if (hours >= 48) return `${Math.round(hours / 24)}天后结束(${dateStr}截止)`;
if (hours >= 1) return `${Math.round(hours)}小时后结束(${dateStr})`;
return `${Math.max(1, Math.round(diffMs / 60000))}分钟后结束`;
}
let _featuredCache = { data: null, ts: 0 };
async function fetchFeaturedCategories() {
if (_featuredCache.data && Date.now() - _featuredCache.ts < 5 * 60 * 1000) return _featuredCache.data;
const cc = getStoreCountryCode();
const resp = await gmFetch(`https://store.steampowered.com/api/featuredcategories?cc=${encodeURIComponent(cc)}&l=schinese`, { timeout: 15000 });
if (typeof resp === 'string' && resp.trimStart().startsWith('<')) throw new Error('Steam 返回页面而非数据(可能被限流),请稍后重试');
const data = JSON.parse(resp);
_featuredCache = { data, ts: Date.now() };
return data;
}
function findFeaturedCat(fc, catId) {
for (const key of Object.keys(fc || {})) {
const v = fc[key];
if (v && v.id === catId && Array.isArray(v.items)) return v;
}
return null;
}
// ============================================================
// ModelManager - 多模型供应商管理
// ============================================================
const ModelManager = {
_models: [],
_activeModelId: null,
init() {
try {
this._models = JSON.parse(GM_getValue(SK.MODELS, '[]'));
} catch { this._models = []; }
this._activeModelId = GM_getValue(SK.ACTIVE_MODEL, null);
if (this._models.length === 0) {
const legacyUrl = GM_getValue(SK.AI_URL, null) || GM_getValue('swe_ai_api_url', '');
const legacyKey = GM_getValue(SK.AI_KEY, null) || GM_getValue('swe_ai_api_key', '');
const legacyModel = GM_getValue(SK.AI_MODEL, null) || GM_getValue('swe_ai_model', '');
if (legacyKey) {
let provider = 'custom';
if (legacyUrl.includes('deepseek')) provider = 'deepseek';
else if (legacyUrl.includes('bigmodel')) provider = 'glm';
else if (legacyUrl.includes('dashscope')) provider = 'qwen';
else if (legacyUrl.includes('xiaomimimo')) provider = 'mimo';
else if (legacyUrl.includes('moonshot')) provider = 'moonshot';
else if (legacyUrl.includes('baichuan')) provider = 'baichuan';
else if (legacyUrl.includes('minimaxi')) provider = 'minimax';
else if (legacyUrl.includes('volces.com')) provider = 'doubao';
else if (legacyUrl.includes('openai.com')) provider = 'openai';
const model = this.createModel({
name: `${MODEL_PROVIDERS[provider]?.name || '默认'} 默认`,
provider,
apiUrl: legacyUrl || CONFIG.AI_DEFAULT_URL,
apiKey: legacyKey,
model: legacyModel || MODEL_PROVIDERS[provider]?.defaultModel || '',
});
this._activeModelId = model.id;
GM_setValue(SK.ACTIVE_MODEL, model.id);
}
}
if (!this._activeModelId || !this.getModel(this._activeModelId)) {
this._activeModelId = this._models[0]?.id || null;
if (this._activeModelId) GM_setValue(SK.ACTIVE_MODEL, this._activeModelId);
}
},
getModels() { return [...this._models]; },
getModel(id) { return this._models.find(m => m.id === id) || null; },
getActiveModel() {
return this.getModel(this._activeModelId) || this._models[0] || null;
},
getActiveModelId() { return this._activeModelId; },
setActiveModel(id) {
if (!this.getModel(id)) return false;
this._activeModelId = id;
GM_setValue(SK.ACTIVE_MODEL, id);
return true;
},
createModel(data) {
const model = {
id: `model_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
name: data.name || '未命名模型',
provider: data.provider || 'custom',
apiUrl: data.apiUrl || '',
apiKey: data.apiKey || '',
model: data.model || '',
createdAt: new Date().toISOString(),
};
this._models.push(model);
this.save();
return model;
},
updateModel(id, data) {
const idx = this._models.findIndex(m => m.id === id);
if (idx === -1) return false;
this._models[idx] = { ...this._models[idx], ...data, id };
this.save();
return true;
},
deleteModel(id) {
const idx = this._models.findIndex(m => m.id === id);
if (idx === -1) return false;
this._models.splice(idx, 1);
this.save();
if (this._activeModelId === id) {
this._activeModelId = this._models[0]?.id || null;
if (this._activeModelId) GM_setValue(SK.ACTIVE_MODEL, this._activeModelId);
}
return true;
},
getConfig() {
const model = this.getActiveModel();
if (!model) return null;
return {
apiUrl: model.apiUrl,
apiKey: model.apiKey,
model: model.model,
modelId: model.id,
modelName: model.name,
provider: model.provider,
};
},
save() {
GM_setValue(SK.MODELS, JSON.stringify(this._models));
},
};
// ============================================================
// AI 客户端
// ============================================================
function getAiConfig() {
const cfg = ModelManager.getConfig();
if (cfg && cfg.apiKey) return cfg;
return {
apiUrl: GM_getValue(SK.AI_URL, null) || CONFIG.AI_DEFAULT_URL,
apiKey: GM_getValue(SK.AI_KEY, null) || '',
model: GM_getValue(SK.AI_MODEL, null) || CONFIG.AI_DEFAULT_MODEL,
};
}
function getSteamApiKey() {
return GM_getValue(SK.STEAM_KEY, '') || GM_getValue('sglv_steam_api_key', '');
}
// ITAD Key:本脚本存储优先,未配置时复用 SGLV 的 Key(跨脚本共享)
function getItadApiKey() {
return GM_getValue(SK.ITAD_KEY, '') || GM_getValue('sglv_itad_api_key', '');
}
function isSSEResponse(text) {
return !!(text && text.trimStart().startsWith('data: '));
}
function parseSSEStream(text) {
let content = '';
let usage = null;
const lines = (text || '').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) continue;
const dataStr = trimmed.slice(6);
if (dataStr === '[DONE]') continue;
try {
const chunk = JSON.parse(dataStr);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) content += delta;
if (chunk.usage && (chunk.usage.prompt_tokens || chunk.usage.completion_tokens || chunk.usage.total_tokens)) {
usage = chunk.usage;
}
} catch {}
}
return { content, usage };
}
function parseSSEResponse(text) {
return parseSSEStream(text).content;
}
function stripThinkTags(text) {
return String(text || '').replace(/[\s\S]*?<\/think>/gi, '').replace(/^[\s]*<\/?think>[\s]*$/gim, '').trim();
}
// ============================================================
// Token 消耗统计 (KPI 卡片)
// 优先采用 API 返回的 usage 精确值,无 usage 时按字符估算
// ============================================================
function estimateTokens(text) {
if (!text) return 0;
const s = String(text);
let cjk = 0;
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if ((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3040 && c <= 0x30ff) || (c >= 0xac00 && c <= 0xd7af) || (c >= 0x3000 && c <= 0x303f) || (c >= 0xff00 && c <= 0xffef)) cjk++;
}
return Math.ceil(cjk / 1.5 + (s.length - cjk) / 4);
}
function formatTokenCount(n) {
const v = Number(n) || 0;
if (v >= 1000000) return (v / 1000000).toFixed(2) + 'M';
if (v >= 1000) return (v / 1000).toFixed(1) + 'k';
return String(v);
}
const TokenStatsManager = {
_stats: null,
init() {
this._stats = parseStored(GM_getValue(SK.TOKEN_STATS, null), null) || {
totalPromptTokens: 0, totalCompletionTokens: 0, requestCount: 0, updatedAt: '',
};
},
record({ promptTokens = 0, completionTokens = 0 }) {
if (!promptTokens && !completionTokens) return;
if (!this._stats) this.init();
this._stats.totalPromptTokens += Math.max(0, Math.round(promptTokens));
this._stats.totalCompletionTokens += Math.max(0, Math.round(completionTokens));
this._stats.requestCount += 1;
this._stats.updatedAt = new Date().toISOString();
try { GM_setValue(SK.TOKEN_STATS, JSON.stringify(this._stats)); } catch {}
},
getStats() {
if (!this._stats) this.init();
return {
totalTokens: this._stats.totalPromptTokens + this._stats.totalCompletionTokens,
totalPromptTokens: this._stats.totalPromptTokens,
totalCompletionTokens: this._stats.totalCompletionTokens,
requestCount: this._stats.requestCount,
updatedAt: this._stats.updatedAt || '',
};
},
};
async function callAiApi(prompt, opts = {}) {
const cfg = getAiConfig();
if (!cfg.apiKey) throw new Error('AI API Key 未配置,请点击设置按钮配置模型');
const messages = [];
if (opts.systemInstruction) {
messages.push({ role: 'system', content: opts.systemInstruction });
}
if (opts.history && opts.history.length) {
for (const m of opts.history) {
if (m.role === 'user' || m.role === 'assistant') {
messages.push({ role: m.role, content: m.content });
}
}
}
messages.push({ role: 'user', content: prompt });
// isolated: 独立请求(如智能体提示词生成),不注册 activeAiRequest、不受聊天停止按钮影响
const isolated = !!opts.isolated;
const aborted = () => !isolated && abortFlag;
const useStream = !!opts.onChunk;
let streamedContent = '';
let reqRef = null;
const reqBody = {
model: cfg.model,
messages,
temperature: opts.temperature ?? 0.7,
stream: useStream,
};
// 仅在显式传入正数时才发送 max_tokens,否则使用模型默认输出上限(长提示词不被截断)
if (opts.maxTokens && Number(opts.maxTokens) > 0) reqBody.max_tokens = Number(opts.maxTokens);
// token 估算基线:所有送入模型的内容
const promptTokensEstimate = estimateTokens(messages.map(m => m.content).join('\n'));
const recordTokenUsage = (usage, outputText) => {
try {
const pTok = (usage && Number(usage.prompt_tokens) > 0) ? Number(usage.prompt_tokens) : promptTokensEstimate;
const cTok = (usage && Number(usage.completion_tokens) > 0) ? Number(usage.completion_tokens) : estimateTokens(outputText);
TokenStatsManager.record({ promptTokens: pTok, completionTokens: cTok });
} catch {}
};
const p = new Promise((resolve, reject) => {
reqRef = GM_xmlhttpRequest({
method: 'POST',
url: cfg.apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${cfg.apiKey}`,
},
data: JSON.stringify(reqBody),
timeout: opts.timeout || CONFIG.AI_TIMEOUT,
onprogress(resp) {
if (!useStream || !opts.onChunk) return;
if (aborted()) return;
const text = resp.responseText || '';
if (!isSSEResponse(text)) return;
const newContent = parseSSEResponse(text);
if (newContent && newContent !== streamedContent) {
streamedContent = newContent;
opts.onChunk(newContent);
}
},
onload(resp) {
try {
const text = resp.responseText || '';
if (resp.status < 200 || resp.status >= 300) {
let errMsg;
if (isSSEResponse(text)) {
errMsg = parseSSEResponse(text) || text.slice(0, 150);
} else {
try {
const errData = JSON.parse(text);
errMsg = errData?.error?.message || text.slice(0, 150);
} catch { errMsg = text.slice(0, 150); }
}
reject(new Error(`HTTP ${resp.status}: ${errMsg}`));
return;
}
if (isSSEResponse(text)) {
const { content, usage } = parseSSEStream(text);
const cleaned = stripThinkTags(content);
if (!cleaned) { reject(new Error('AI 返回为空')); return; }
if (useStream && opts.onChunk && content !== streamedContent) {
opts.onChunk(content);
}
recordTokenUsage(usage, cleaned);
resolve(cleaned);
return;
}
const data = JSON.parse(text);
const choice = data.choices?.[0];
const msg = choice?.message;
const content = stripThinkTags(msg?.content || '');
if (!content) {
if (msg?.reasoning_content) { reject(new Error('模型仅返回了推理内容(reasoning)未产出正文,请更换为非推理模型或重试')); return; }
if (choice?.finish_reason === 'length') { reject(new Error('AI 输出被 max_tokens 截断,请重试或调大 token 上限')); return; }
reject(new Error('AI 返回为空'));
return;
}
recordTokenUsage(data.usage, content);
resolve(content);
} catch (e) {
reject(new Error('AI 响应解析失败: ' + e.message));
}
},
onerror: () => {
if (aborted()) {
if (useStream && streamedContent) resolve(stripThinkTags(streamedContent));
else reject(new Error('__SA_ABORTED__'));
return;
}
reject(new Error('AI API 网络错误'));
},
ontimeout: () => {
if (aborted()) { reject(new Error('__SA_ABORTED__')); return; }
reject(new Error('AI API 请求超时'));
},
});
if (!isolated) activeAiRequest = reqRef;
});
return p.finally(() => {
if (!isolated && activeAiRequest === reqRef) activeAiRequest = null;
});
}
async function callAiApiWithTyping(prompt, opts, onChunk) {
let gotStream = false;
const wrappedOnChunk = (text) => {
gotStream = true;
onChunk(text);
};
const fullResponse = await callAiApi(prompt, { ...opts, onChunk: wrappedOnChunk });
if (!gotStream && onChunk) {
const chunkSize = Math.max(1, Math.ceil(fullResponse.length / 60));
for (let i = 0; i < fullResponse.length; i += chunkSize) {
if (abortFlag) break;
onChunk(fullResponse.slice(0, i + chunkSize));
await new Promise(r => setTimeout(r, 12));
}
}
return fullResponse;
}
// ============================================================
// Steam 规则知识库(远程 JSON 热更新 + 内置降级快照)
// 事实来源:steam-game-docs/0-steam-rules.md
// 远程文件:script_libs/saa-rules-knowledge.json(纯数据,不执行任何远程代码)
// 解析优先级:远程JSON缓存 > 内置快照
// ============================================================
const RULES_REMOTE_URL_DEFAULT =
'https://raw.githubusercontent.com/SmallRob/steam-namespace/refs/heads/main/data/saa-rules-knowledge.json';
const RULES_SNAPSHOT = {
version: '1.1.0-snapshot',
updatedAt: '2026-08-16',
source: 'steam-game-docs/0-steam-rules.md (内置快照)',
sections: {
regionTransfer: {
id: 'regionTransfer', label: '转区购买规则',
bullets: [
'冷却期:商店国家/地区每 3 个月只能变更一次,系统强制执行',
'转区前提:需在目标区服用当地支付方式成功完成一次购买',
'合法变更条件:仅真实居住地变更、能提供护照或当地居住证明的用户,可联系客服申请提前解除限制',
'IP 校验:登录 IP 归属地需与目标区服一致,不符会驳回,多次尝试可能触发红信',
'支付方式匹配:2023 年起强制要求支付方式与地区货币一致(如土耳其信用卡、俄罗斯 MIR 卡)',
'高风险行为:虚假地址证明、频繁切换地区、黑市充值卡可能导致账号受限或永久封禁',
'转区影响:游戏库可保留下载,但部分区域限定 DLC/版本可能在新区域不可见或无法激活',
'账户余额无法跨区转移,原区域余额将被冻结',
],
},
gifting: {
id: 'gifting', label: '送礼规则',
bullets: [
'好友时长要求:送礼需双方成为 Steam 好友满 30 天,未满 30 天无法发起礼物赠送',
'跨区送礼限制:发送者与接收者必须在"允许跨区交易"的国家列表中,且价格差异不能过大',
'高价区→低价区:通常允许,但需满足价格差异限制(差价需在 15% 以内)',
'低价区→高价区:系统通常会拒绝,防止套利',
'锁区游戏:标注"仅限特定地区激活"的游戏无法跨区赠送',
'结账时若礼物存在地区限制,系统会明确标注可兑换的国家/地区',
'接收者需在对应区域内激活,VPN 伪造地区接收礼物触发风控概率极高',
'数字礼物卡可跨区赠送并自动换算当地货币,但不能用 Steam 钱包余额购买',
'替代方案:家庭共享 — 小号合规转区后主号共享游戏库(被锁区游戏无法在被锁地区账号共享)',
],
},
giftingQuota: {
id: 'giftingQuota', label: '送礼额度规则',
bullets: [
'计算公式:送礼额度 = 商店直购金额 − 退款金额;剩余额度 = 送礼额度 − 已送礼金额',
'30 天滚动窗口:已送礼金额按最近 30 天内的送礼记录统计,超过 30 天的送礼不再占用额度',
'计入项:仅商店直购(游戏/软件/DLC 购买)计入额度;内购、市场交易、钱包充值等不计入送礼额度',
'多币种:额度按账户主货币统计,其他币种按实时汇率折算汇总',
'超额后果:剩余额度为负(已超额)时无法继续送礼,需等待 30 天窗口滑出旧送礼记录或增加新的直购',
'查询工具:Steam-Spending-History-Classifier(消费历史分类器)可在账户消费历史页自动分类并计算送礼额度',
],
},
regionLock: {
id: 'regionLock', label: '游戏锁区规则',
bullets: [
'激活锁:CDKEY 只能在特定地区激活,国区专属 KEY 与海外区 KEY 互不通用',
'运行锁:部分游戏限定只能在特定地区账户下下载游玩(如《失落的方舟》欧美服)',
'商店锁:游戏在特定区域商店未上架,该地区用户无法直接购买',
'判断方法:购买前查看 Steam 商店页面"区域限制"标签,或"查看购物车中的区域限制"',
'锁区由发行商根据区域定价策略和版权保护要求设定,并非所有游戏都锁区',
],
},
pricing: {
id: 'pricing', label: '区域汇率结算规则',
bullets: [
'低价区价格可能仅为国区一半甚至三分之一:阿根廷(4-6 折,ARS)、土耳其(3.5-5.5 折,TRY)、俄罗斯(3-5 折,RUB)、印度(2.5-4 折,INR)、巴西(2-3.5 折,BRL)',
'货币匹配:结算币种必须为目标区服币种,Steam 按实时汇率转换',
'案例:《霍格沃茨之遗》阿根廷约 ¥160、土耳其约 ¥180、国区约 ¥298、美区约 ¥430,选对地区最高省 63%',
],
},
risks: {
id: 'risks', label: '风险警示',
bullets: [
'红信警告:频繁跨区、支付方式不匹配等异常行为会收到账户警告,可能导致功能受限',
'秋后算账:Valve 对违规转区可能延迟处理,且一封到底不给申诉',
'礼物失效:跨区赠送失败时礼物可能被自动退款;已购锁区 KEY 无法退换',
'合规建议:优先使用当地支付方式、保持稳定当地 IP,避免短期频繁操作',
],
},
},
faqs: [
{ q: 'Steam 转区有什么限制', a: '商店地区每 3 个月只能变更一次;需先在目标区服用当地支付方式完成一次购买;IP 归属地需与目标区服一致。' },
{ q: '可以低价区买游戏高价区玩吗', a: '不可以。低价区→高价区送礼通常被拒绝;高价区→低价区一般允许但需价格差异在 15% 以内。' },
{ q: '送礼需要什么前置条件', a: '双方需成为 Steam 好友满 30 天;游戏需在收礼人所在区可购买;锁区游戏无法赠送。' },
{ q: '送礼额度是怎么算的', a: '送礼额度 = 商店直购金额 − 退款金额;剩余额度 = 送礼额度 − 已送礼金额(按最近 30 天滚动窗口统计)。内购、市场交易、钱包充值不计入额度。' },
{ q: '送礼额度用完了怎么办', a: '剩余额度为负(超额)时无法继续送礼。需等待 30 天窗口滑出旧的送礼记录释放额度,或通过新的商店直购增加额度。' },
{ q: '怎么判断一个游戏是否锁区', a: '购买前在商店页面查看"区域限制"标签,或点击"查看购物车中的区域限制"。锁区分激活锁/运行锁/商店锁,由发行商设定。' },
{ q: '钱包余额可以跨区用吗', a: '不能。转区后原区域余额被冻结,仅限原区域消费;数字礼物卡可跨区赠送但不能用钱包余额购买。' },
{ q: '违规转区会被封号吗', a: '可能。虚假地址、频繁切换地区、黑市充值卡可能导致账号受限或永久封禁,且 Valve 可能延迟处理不给申诉。' },
],
agentSectionMap: {
agent_steam_general: ['regionTransfer', 'gifting', 'giftingQuota', 'regionLock', 'pricing', 'risks'],
agent_steam_gifting: ['gifting', 'giftingQuota', 'regionLock', 'regionTransfer', 'pricing', 'risks'],
},
};
const RulesKnowledgeProvider = {
_active: null,
getRemoteUrl() {
return GM_getValue(SK.RULES_URL, '') || RULES_REMOTE_URL_DEFAULT;
},
setRemoteUrl(url) {
GM_setValue(SK.RULES_URL, String(url || '').trim());
},
_loadRemoteCache() {
const raw = GM_getValue(SK.RULES_REMOTE, '');
if (!raw) return null;
return this._validate(parseStored(raw, null));
},
_validate(data) {
if (!data || typeof data !== 'object') return null;
if (!data.sections || typeof data.sections !== 'object' || !Array.isArray(data.faqs)) return null;
const sections = {};
for (const [id, sec] of Object.entries(data.sections)) {
if (!sec || typeof sec !== 'object' || typeof sec.label !== 'string' || !Array.isArray(sec.bullets)) continue;
sections[id] = {
id: typeof sec.id === 'string' ? sec.id : id,
label: sec.label,
bullets: sec.bullets.filter(b => typeof b === 'string'),
};
}
if (!Object.keys(sections).length) return null;
return {
version: String(data.version || 'unknown'),
updatedAt: String(data.updatedAt || ''),
source: String(data.source || '远程规则知识库'),
sections,
faqs: data.faqs
.filter(f => f && typeof f.q === 'string' && typeof f.a === 'string')
.map(f => ({ q: f.q, a: f.a })),
agentSectionMap: (data.agentSectionMap && typeof data.agentSectionMap === 'object') ? data.agentSectionMap : {},
};
},
resolve() {
if (this._active) return this._active;
const remote = this._loadRemoteCache();
if (remote) {
this._active = { ...remote, origin: 'remote' };
} else {
this._active = { ...RULES_SNAPSHOT, origin: 'snapshot' };
}
return this._active;
},
formatAsContext(agentId, opts = {}) {
const k = this.resolve();
const sectionIds = (k.agentSectionMap && k.agentSectionMap[agentId]) || Object.keys(k.sections);
const charLimit = typeof opts.charLimit === 'number' && opts.charLimit > 0 ? opts.charLimit : 2200;
const maxFaqs = typeof opts.maxFaqs === 'number' ? opts.maxFaqs : 4;
const parts = [];
let used = 0;
for (const id of sectionIds) {
const sec = k.sections[id];
if (!sec) continue;
const block = `## ${sec.label}\n${sec.bullets.map(b => `- ${b}`).join('\n')}`;
if (used + block.length > charLimit) break;
parts.push(block);
used += block.length + 1;
}
if (!parts.length) return '';
let out = `【Steam 平台规则知识】(v${k.version})\n${parts.join('\n')}`;
const topFaqs = k.faqs.slice(0, maxFaqs);
if (topFaqs.length && out.length + 400 < charLimit + 600) {
out += `\n【规则 FAQ 参考】\n${topFaqs.map(f => `Q: ${f.q}\nA: ${f.a}`).join('\n')}`;
}
return out;
},
getStatus() {
const k = this.resolve();
return {
origin: k.origin,
version: k.version,
updatedAt: k.updatedAt,
source: k.source,
sectionCount: Object.keys(k.sections).length,
faqCount: k.faqs.length,
remoteUrl: this.getRemoteUrl(),
};
},
refreshFromRemote() {
return new Promise((resolve, reject) => {
const url = this.getRemoteUrl();
if (!url) { reject(new Error('未配置规则库远程地址')); return; }
GM_xmlhttpRequest({
method: 'GET',
url,
timeout: 15000,
onload: (res) => {
if (res.status < 200 || res.status >= 300) {
reject(new Error(`HTTP ${res.status}`)); return;
}
try {
let data;
try {
data = this._validate(JSON.parse(res.responseText));
} catch { data = null; }
if (!data) throw new Error('远程内容不是有效的规则知识库 JSON');
GM_setValue(SK.RULES_REMOTE, JSON.stringify(data));
this._active = { ...data, origin: 'remote' };
resolve(this.getStatus());
} catch (e) {
reject(e);
}
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时')),
});
});
},
};
// ============================================================
// 内置智能体定义
// ============================================================
const BUILTIN_AGENTS = [
{
id: 'agent_steam_general',
name: 'Steam 通用助手',
description: '游戏评价、玩家数据分析、购买建议、打折分析',
icon: 'gamepad',
keywords: ['steam', '游戏', '评价', '推荐', '打折', '分析', '购买'],
builtin: true,
systemPrompt: `你是 Steam 游戏助手,精通 Steam 平台的游戏评价、玩家数据分析和购买建议。
你的能力:
1. 游戏评价:基于 Steam Store 数据(好评率、价格、类型)给出客观评价
2. 玩家数据分析:分析玩家的游戏库、游玩时长、消费习惯
3. 购买建议:根据用户偏好和预算推荐游戏
4. 打折分析:判断当前折扣是否值得入手
回答规范:
- 使用 Markdown 格式,清晰分段
- 数据引用时标注来源(如"根据 Steam Store 数据")
- 购买建议需考虑性价比、个人喜好匹配度
- 打折分析需对比历史价格趋势
- 保持客观中立,不吹捧不贬低
- 涉及转区、送礼、锁区、区域定价等平台规则问题时,优先依据【Steam 平台规则知识】回答,并主动提示相关风险
- 回答使用中文`,
},
{
id: 'agent_steam_gifting',
name: '跨区送礼助手',
description: '跨区送礼计算、最佳送礼推荐、差价分析',
icon: 'gift',
keywords: ['送礼', '跨区', 'gift', '差价', '推荐'],
builtin: true,
systemPrompt: `你是 Steam 跨区送礼顾问,熟悉 Steam 礼物系统各区价格差异和送礼规则。
你的能力:
1. 跨区价格对比:对比不同地区的游戏价格(优先使用 getMultiRegionPrices 工具获取多地区实时价格)
2. 送礼推荐:根据预算推荐性价比最高的游戏
3. 差价计算:计算送礼 vs 自买的价格差异(基于工具返回的 amountUsd 美元折算价计算跨区差价)
4. 区域推荐:推荐最划算的送礼区域
注意规则:
- 送礼规则、锁区限制、转区风控、送礼额度等平台规则已通过【Steam 平台规则知识】注入,回答时严格遵循
- 送礼需双方成为 Steam 好友满 30 天;高价区→低价区送礼需价格差异在 15% 以内,低价区→高价区通常被拒绝
- 送礼额度 = 商店直购 − 退款;剩余额度按 30 天滚动窗口计算,涉及额度问题参考规则知识中的送礼额度章节
- 涉及多地区价格对比时必须调用 getMultiRegionPrices 工具获取实时数据,禁止凭记忆报各区价格
- 若 getMultiRegionPrices 返回 degraded=true(降级模式),必须在回答中明确告知用户:当前价格数据准确度降低,并引导用户在设置面板配置 ITAD API Key(isthereanydeal.com/apps 免费申请)以启用增强模式
- 价格以 Steam Store 实时数据为准
- 回答使用中文`,
},
];
const BUILTIN_KNOWLEDGE = {
'agent_steam_general': {
constraints: [
'不提供盗版或破解建议',
'价格信息以 Steam Store 实时数据为准,可能存在延迟',
'游戏评价基于 Steam 好评率数据,不代表绝对质量',
],
faq: [
{ question: '如何获取打折/特惠/愿望单折扣等实时信息', answer: '必须先调用对应工具获取实时数据再回答:getOfficialDeals(官方特惠动态聚合:促销活动/每日优惠/当前特惠/官方新闻,用户问"最近有什么打折/值得入手"时优先用)、getSpecialDeals(按分类查特惠/热销榜/促销活动)、getWishlistDeals(愿望单打折)、getPriceHistory(历史低价/史低判断)、searchDiscountedGames(高级折扣检索,需 Steam Key,未配置时引导用户配置)、getGameDetail(单个游戏详情与现价)。禁止在未调用工具的情况下声称"无法获取实时折扣数据"。' },
{ question: '用户问某个游戏(dota2/cs2等)最近有什么更新/动态/公告时怎么办', answer: '必须调用 getGameNews 工具(传 game=游戏名或简称,也支持 appid),返回该游戏官方公告/版本更新/平衡性补丁/活动新闻(数据来自 Steam ISteamNews 官方动态,同游戏库插件信息面板)。常见游戏(dota2/cs2/csgo/吃鸡/老头环等)内置简称直达;未知名称会自动经商店搜索解析。禁止凭训练记忆编造版本号或更新内容。若用户问的是平台整体动态(最近有什么打折)则用 getOfficialDeals 而非本工具。' },
{ question: '怎么判断一个游戏值不值得买', answer: '综合看好评率(>80%为好评)、价格与内容量比、个人喜好匹配度、打折力度。可组合使用 getGameDetail 获取详情与好评率、getSpecialDeals 查特惠、getPriceHistory 查史低。' },
{ question: '史低是什么意思', answer: '史低(Historical Low)指游戏历史最低售价。使用 getPriceHistory 工具查询历史最低价并判断当前折扣是否为 Steam 史低。' },
{ question: '好评率怎么看', answer: 'Steam评价分为:好评如潮(>95%)、特别好评(80-95%)、多半好评(70-80%)、褒贬不一(40-70%)、多半差评(20-40%)、差评(<20%)。' },
],
domainKnowledge: 'Steam 评价系统:好评/差评/特别好评/好评如潮/褒贬不一/多半差评/差评如潮。折扣类型:常规折扣/每日优惠/特卖活动/发行商促销。Steam 区域定价:不同地区价格不同,跨区购买违反用户协议。Steam 退款政策:购买后14天内且游玩不超过2小时可退款。',
},
'agent_steam_gifting': {
constraints: [
'送礼需双方成为 Steam 好友满 30 天',
'跨区送礼需价格差异在 15% 以内,超出可能被系统拒绝',
'部分游戏有区域限制,送礼前需确认收礼人所在区可激活',
'剩余送礼额度不足(超额)时无法送礼,额度按 30 天滚动窗口计算',
],
faq: [
{ question: '怎么查一个游戏各区的价格', answer: '调用 getMultiRegionPrices 工具(传 appid),返回各地区 Steam 原币售价与 amountUsd 美元折算价。已配置 ITAD API Key 时覆盖 16 个地区且数据准确;未配置时为 AugmentedSteam 降级模式(约 7 个地区,返回 degraded=true),此时必须告知用户价格准确度降低,并引导在设置中配置 ITAD Key。' },
{ question: '送礼和自买哪个划算', answer: '当两区价格差异在 15% 以内时送礼通常可行;高价区→低价区送礼通常更划算,低价区→高价区通常被系统拒绝。价格差异用 getMultiRegionPrices 返回的 amountUsd 折算价计算。' },
{ question: '送礼额度怎么算', answer: '送礼额度 = 商店直购 − 退款;剩余额度 = 送礼额度 − 已送礼(最近 30 天)。内购、市场交易、钱包充值不计入。可用 Steam-Spending-History-Classifier 脚本查询。' },
{ question: '礼物可以退款吗', answer: '礼物在接收方接受前可申请退款,退款退回原支付方式;不能退为 Steam 钱包余额。' },
],
domainKnowledge: '平台规则(转区/锁区/送礼限制/送礼额度/汇率结算/风控)已通过外部规则知识库注入系统指令,回答时优先遵循规则知识内容。补充:礼物价格以送礼人所在区域定价;数字礼物卡不能用 Steam 钱包余额购买。',
},
};
// ============================================================
// Agent 知识管理器
// ============================================================
const AgentKnowledgeManager = {
getKnowledge(agentId) {
const builtin = BUILTIN_KNOWLEDGE[agentId];
const custom = this.getUserKnowledge(agentId);
if (!builtin && !custom) return null;
return {
agentId,
constraints: [...(builtin?.constraints || []), ...(custom?.constraints || [])],
faq: [...(builtin?.faq || []), ...(custom?.faq || [])],
domainKnowledge: [builtin?.domainKnowledge || '', custom?.domainKnowledge || ''].filter(Boolean).join('\n'),
};
},
formatAsContext(agentId) {
const k = this.getKnowledge(agentId);
if (!k) return '';
const parts = [];
if (k.constraints.length) parts.push(`【行为约束】\n${k.constraints.map(c => `- ${c}`).join('\n')}`);
if (k.faq.length) {
const top = k.faq.slice(0, 3);
parts.push(`【常见问题参考】\n${top.map(f => `Q: ${f.question}\nA: ${f.answer}`).join('\n\n')}`);
}
if (k.domainKnowledge) {
const summary = k.domainKnowledge.length > 300 ? k.domainKnowledge.slice(0, 300) + '...' : k.domainKnowledge;
parts.push(`【专业知识摘要】\n${summary}`);
}
return parts.join('\n\n');
},
getUserKnowledge(agentId) {
try {
const raw = GM_getValue(SK.AGENTS, '[]');
const agents = JSON.parse(raw);
const agent = agents.find(a => a.id === agentId);
return agent?.knowledge || null;
} catch { return null; }
},
};
// ============================================================
// Agent 管理器
// ============================================================
const AgentManager = {
_currentAgentId: null,
init() {
this._currentAgentId = GM_getValue(SK.CURRENT_AGENT, 'agent_steam_general');
},
getAgents() {
const builtin = BUILTIN_AGENTS.map(a => ({ ...a, builtin: true }));
const custom = this.getCustomAgents();
return [...builtin, ...custom];
},
getCustomAgents() {
try {
const raw = GM_getValue(SK.AGENTS, '[]');
return JSON.parse(raw).map(a => ({ ...a, builtin: false }));
} catch { return []; }
},
getAgent(id) {
const builtin = BUILTIN_AGENTS.find(a => a.id === id);
if (builtin) return { ...builtin, builtin: true };
const custom = this.getCustomAgents().find(a => a.id === id);
return custom || null;
},
getCurrentAgent() {
return this.getAgent(this._currentAgentId) || this.getAgent('agent_steam_general');
},
switchAgent(id) {
if (!this.getAgent(id)) return false;
this._currentAgentId = id;
GM_setValue(SK.CURRENT_AGENT, id);
return true;
},
createAgent(data) {
const agents = this.getCustomAgents();
const id = `agent_custom_${Date.now()}`;
const agent = {
id,
name: data.name || '自定义助手',
description: data.description || '',
icon: data.icon || 'bot',
keywords: data.keywords || [],
systemPrompt: data.systemPrompt || '你是一个 Steam 助手。',
builtin: false,
knowledge: data.knowledge || null,
createdAt: new Date().toISOString(),
};
agents.push(agent);
GM_setValue(SK.AGENTS, JSON.stringify(agents));
return agent;
},
updateAgent(id, data) {
const agents = this.getCustomAgents();
const idx = agents.findIndex(a => a.id === id);
if (idx === -1) return false;
agents[idx] = { ...agents[idx], ...data, id, builtin: false };
GM_setValue(SK.AGENTS, JSON.stringify(agents));
return true;
},
deleteAgent(id) {
const agents = this.getCustomAgents();
const filtered = agents.filter(a => a.id !== id);
if (filtered.length === agents.length) return false;
GM_setValue(SK.AGENTS, JSON.stringify(filtered));
if (this._currentAgentId === id) this.switchAgent('agent_steam_general');
return true;
},
exportAgents() {
return JSON.stringify(this.getCustomAgents(), null, 2);
},
importAgents(jsonStr) {
try {
const imported = JSON.parse(jsonStr);
if (!Array.isArray(imported)) throw new Error('格式错误');
const existing = this.getCustomAgents();
const existingIds = new Set(existing.map(a => a.id));
let added = 0;
for (const a of imported) {
if (!a.id || !a.name || !a.systemPrompt) continue;
let id = a.id;
while (existingIds.has(id)) id = `${a.id}_${Date.now()}`;
existing.push({ ...a, id, builtin: false });
existingIds.add(id);
added++;
}
GM_setValue(SK.AGENTS, JSON.stringify(existing));
return added;
} catch (e) {
throw new Error('导入失败: ' + e.message);
}
},
};
// ============================================================
// 工具注册表
// ============================================================
const ToolRegistry = {
_tools: new Map(),
register(tool) { this._tools.set(tool.name, tool); },
get(name) { return this._tools.get(name); },
getAll() { return Array.from(this._tools.values()); },
getToolsDescription() {
const tools = this.getAll();
if (!tools.length) return '(无可用工具)';
return tools.map(t =>
`【${t.name}】${t.description}\n 参数: ${JSON.stringify(t.parameters.properties)}`
).join('\n\n');
},
async execute(name, args) {
const tool = this.get(name);
if (!tool) return { success: false, error: `工具 "${name}" 不存在` };
try {
return await Promise.race([
tool.execute(args),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('工具执行超时')), CONFIG.TOOL_TIMEOUT)
),
]);
} catch (e) {
return { success: false, error: `工具执行错误: ${e.message}` };
}
},
};
ToolRegistry.register({
name: 'getGameDetail',
description: '获取 Steam 游戏详细信息(名称、价格、好评率、类型、发行日期等)',
parameters: { type: 'object', properties: { appid: { type: 'string', description: 'Steam 游戏 AppID' } }, required: ['appid'] },
category: 'knowledge',
async execute(params) {
const appid = params.appid;
if (!appid) return { success: false, error: '缺少 appid 参数' };
try {
const resp = await gmFetch(`https://store.steampowered.com/api/appdetails?appids=${appid}&l=schinese&filters=basic,price_overview`);
const data = JSON.parse(resp);
const game = data[appid]?.data;
if (!game) return { success: false, error: `未找到 AppID ${appid} 的游戏信息` };
const result = {
name: game.name,
type: game.type,
developers: game.developers,
publishers: game.publishers,
genres: game.genres?.map(g => g.description) || [],
release_date: game.release_date?.date,
price: game.price_overview?.final_formatted || '免费',
discount: game.price_overview?.discount_percent || 0,
initial_price: game.price_overview?.initial_formatted,
platforms: game.platforms,
};
return { success: true, data: result, summary: `${game.name} - ${result.price}${result.discount ? ` (-${result.discount}%)` : ''}` };
} catch (e) {
return { success: false, error: e.message };
}
},
});
ToolRegistry.register({
name: 'searchGames',
description: '搜索 Steam 游戏(支持中英文关键词)',
parameters: { type: 'object', properties: { query: { type: 'string', description: '搜索关键词' } }, required: ['query'] },
category: 'knowledge',
async execute(params) {
const query = encodeURIComponent(params.query || '');
if (!query) return { success: false, error: '缺少搜索关键词' };
try {
const resp = await gmFetch(`https://store.steampowered.com/api/storesearch?term=${query}&l=schinese&cc=cn`);
const data = JSON.parse(resp);
const items = (data.items || []).slice(0, 5).map(item => ({
appid: item.id,
name: item.name,
price: item.price?.final / 100 || 0,
discount: item.price?.discount_percent || 0,
}));
return { success: true, data: items, summary: `找到 ${items.length} 个结果` };
} catch (e) {
return { success: false, error: e.message };
}
},
});
ToolRegistry.register({
name: 'getPlayerSummary',
description: '获取 Steam 玩家个人资料摘要(昵称、等级、游戏数等)。steamId 参数可选(SteamID64 或自定义ID),不传时自动使用设置中配置的 SteamID 或当前登录用户',
parameters: { type: 'object', properties: { steamId: { type: 'string', description: 'SteamID64(17位数字)或自定义ID,可选,默认当前用户' } }, required: [] },
category: 'user',
async execute(params) {
const { steamId, source } = resolveSteamId(params.steamId ?? params.steamid);
if (!steamId) return { success: false, error: '未提供也未检测到 SteamID:请在设置中心「接口服务」中配置 SteamID(留空自动检测当前登录用户),或在参数中提供 steamId' };
try {
const isId64 = /^\d{17}$/.test(steamId);
const url = isId64
? `https://steamcommunity.com/profiles/${steamId}/?xml=1`
: `https://steamcommunity.com/id/${encodeURIComponent(steamId)}/?xml=1`;
const resp = await gmFetch(url, { timeout: 15000 });
const profile = parseSteamProfileXml(resp);
if (!profile) return { success: false, error: '无法解析玩家资料(可能资料不公开或 ID 无效)' };
return {
success: true,
data: {
steamID: profile.steamID,
steamId64: profile.steamID64,
idSource: source,
visibility: profile.visibility,
avatar: profile.avatarIcon,
memberSince: profile.memberSince,
summary: profile.summary,
},
summary: `${profile.steamID || steamid}`,
};
} catch (e) {
return { success: false, error: e.message };
}
},
});
ToolRegistry.register({
name: 'getOwnedGames',
description: '获取玩家拥有的游戏列表和游玩时长,适合分析游戏库、"吃灰"游戏等。steamId 参数可选(SteamID64 或自定义ID),不传时自动使用设置中配置的 SteamID 或当前登录用户。需要 Steam API Key:未配置时会返回配置引导',
parameters: { type: 'object', properties: { steamId: { type: 'string', description: 'SteamID64(17位数字)或自定义ID,可选,默认当前用户' } }, required: [] },
category: 'user',
async execute(params) {
const apiKey = getSteamApiKey();
if (!apiKey) {
return {
success: false,
needsApiKey: true,
error: '此工具需要 Steam Web API Key。请引导用户点击对话中的「配置 Steam API Key」按钮,或打开设置面板填写 Steam Web API Key(可在 steamcommunity.com/dev 免费申请),配置完成后用户回复"已配置",再重新调用本工具',
};
}
const { steamId, source } = resolveSteamId(params.steamId ?? params.steamid);
if (!steamId) return { success: false, error: '未提供也未检测到 SteamID:请在设置中心「接口服务」中配置 SteamID(留空自动检测当前登录用户),或在参数中提供 steamId' };
let id64 = steamId;
if (!/^\d{17}$/.test(steamId)) {
try {
id64 = await resolveVanityToId64(steamId, apiKey);
} catch (e) {
return { success: false, error: `自定义 ID 解析失败: ${e.message}` };
}
if (!id64) return { success: false, error: `自定义 ID "${steamId}" 未解析到 SteamID64(可能不存在)` };
}
try {
const resp = await gmFetch(`https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?key=${apiKey}&steamid=${id64}&include_appinfo=1&format=json`);
const data = JSON.parse(resp);
const games = (data.response.games || []).map(g => ({
appid: g.appid,
name: g.name,
playtime: Math.round((g.playtime_forever || 0) / 60 * 10) / 10,
})).sort((a, b) => b.playtime - a.playtime);
if (data.response.game_count === undefined && !games.length) {
return { success: false, error: '游戏库不公开或为空:请确认 Steam 资料中的「游戏详情」隐私设置为公开' };
}
const zeroPlay = games.filter(g => g.playtime === 0).length;
return {
success: true,
data: {
steamId: id64,
idSource: source,
total: data.response.game_count ?? games.length,
neverPlayed: zeroPlay,
topGames: games.slice(0, 20),
},
summary: `共 ${data.response.game_count ?? games.length} 个游戏(${zeroPlay} 个从未游玩),按时长降序返回前 ${Math.min(games.length, 20)}`,
};
} catch (e) {
const msg = /HTTP 403/.test(e.message) ? 'Steam Web API Key 无效或无权限,请检查设置中的 Key' : e.message;
return { success: false, error: `游戏库查询失败: ${msg}` };
}
},
});
ToolRegistry.register({
name: 'getSpecialDeals',
description: '获取 Steam 商店实时打折/特惠数据。category 可选:specials(当前特惠列表,默认)、dailydeal(今日特惠)、topsellers(热销榜)、new_releases(新品)、coming_soon(即将推出)、saleevents(大型促销活动页,如夏促/冬促)。返回游戏名、AppID、折扣百分比、原价、现价、折扣截止时间、商店链接',
parameters: { type: 'object', properties: {
category: { type: 'string', description: '分类:specials|dailydeal|topsellers|new_releases|coming_soon|saleevents,默认 specials' },
minDiscount: { type: 'number', description: '最低折扣百分比过滤,如 50 表示只看 5 折及以上,默认 0 不过滤' },
limit: { type: 'number', description: '返回条数 1-20,默认 10' },
}, required: [] },
category: 'knowledge',
async execute(params) {
const category = String(params.category || 'specials').toLowerCase();
const minDiscount = Math.max(0, Number(params.minDiscount) || 0);
const limit = Math.min(20, Math.max(1, Number(params.limit) || 10));
try {
const fc = await fetchFeaturedCategories();
if (!fc || typeof fc !== 'object') return { success: false, error: '特惠数据返回为空' };
if (category === 'saleevents') {
const events = [];
for (const key of Object.keys(fc)) {
const v = fc[key];
if (!v || !Array.isArray(v.items)) continue;
if (v.id === 'cat_spotlight' || (v.id || '').startsWith('cat_') && /促销|特卖|sale/i.test(String(v.name || ''))) {
v.items.forEach(it => {
if (!it) return;
events.push({
name: it.name || '',
description: (it.body || '').slice(0, 120),
url: it.url || '',
endsAt: formatDealEnds(it.discount_expiration),
headerImage: it.header_image || '',
});
});
}
}
if (!events.length) return { success: true, data: { events: [], note: '当前没有进行中的大型促销活动' }, summary: '当前无大型促销活动' };
return { success: true, data: { events: events.slice(0, limit) }, summary: `当前有 ${Math.min(events.length, limit)} 个促销活动(如 ${events[0].name})` };
}
let cat;
let note = '';
if (category === 'dailydeal') {
cat = findFeaturedCat(fc, 'cat_dailydeal');
if (!cat && fc.specials && Array.isArray(fc.specials.items)) {
// Steam 现行 API 已无独立"今日特惠"分类,回退为特惠中折扣即将结束(≤48h)的优惠
const now = Date.now() / 1000;
const discounted = fc.specials.items.filter(it => it && it.id && it.discounted && Number(it.discount_percent) > 0 && Number(it.discount_expiration) > 0);
const soon = discounted.filter(it => Number(it.discount_expiration) - now <= 48 * 3600);
const items = soon.length ? soon : discounted.sort((a, b) => a.discount_expiration - b.discount_expiration).slice(0, 5);
cat = { id: 'cat_dailydeal', name: '今日特惠(即将结束)', items };
note = soon.length ? 'Steam 未提供独立今日特惠分类,已返回 48 小时内结束的优惠' : 'Steam 未提供独立今日特惠分类,已返回折扣最接近结束的优惠';
}
}
else if (category === 'specials') cat = fc.specials;
else if (category === 'topsellers') cat = fc.top_sellers || fc.topsellers;
else if (category === 'new_releases') cat = fc.new_releases;
else if (category === 'coming_soon') cat = fc.coming_soon;
else return { success: false, error: `未知的 category: ${category},可选 specials|dailydeal|topsellers|new_releases|coming_soon|saleevents` };
if (!cat || !Array.isArray(cat.items) || !cat.items.length) {
return { success: true, data: { games: [], note: `「${cat?.name || category}」分类暂无数据` }, summary: '该分类暂无数据' };
}
const needDiscounted = category === 'specials' || category === 'dailydeal';
const games = cat.items
.filter(it => it && it.id)
.filter(it => needDiscounted ? (it.discounted && Number(it.discount_percent) > 0) : true)
.filter(it => (Number(it.discount_percent) || 0) >= minDiscount)
.sort((a, b) => (Number(b.discount_percent) || 0) - (Number(a.discount_percent) || 0))
.slice(0, limit)
.map(it => ({
appid: it.id,
name: it.name || '',
discount: Number(it.discount_percent) || 0,
originalPrice: formatCents(it.original_price, it.currency),
finalPrice: formatCents(it.final_price, it.currency),
endsAt: formatDealEnds(it.discount_expiration),
url: `https://store.steampowered.com/app/${it.id}`,
}));
if (!games.length) return { success: true, data: { games: [], note: minDiscount ? `没有折扣 ≥ ${minDiscount}% 的游戏` : '该分类暂无条目' }, summary: '无符合条件的游戏' };
const discounts = games.map(g => g.discount);
return {
success: true,
data: Object.assign({ category: cat.name || category, count: games.length, games }, note ? { note } : {}),
summary: `「${cat.name || category}」返回 ${games.length} 款(折扣 ${Math.min(...discounts)}%-${Math.max(...discounts)}%)`,
};
} catch (e) {
return { success: false, error: `特惠查询失败: ${e.message}` };
}
},
});
ToolRegistry.register({
name: 'getOfficialDeals',
description: '获取 Steam 官方特惠动态聚合数据(对应游戏库插件的"官方动态"面板):促销活动(夏促/冬促/系列特卖/发行商特卖等活动页及截止时间)、每日优惠、当前特惠(含折扣倒计时)、Steam 官方新闻。用户询问"最近有什么打折/特惠/促销/值得入手的游戏"时优先调用本工具,一次返回全部实时数据,无需参数',
parameters: { type: 'object', properties: {
sections: { type: 'string', description: '需要的分区,逗号分隔:specials(当前特惠)|dailydeals(每日优惠)|events(促销活动)|news(官方新闻)。默认全部' },
limit: { type: 'number', description: '每个分区最大返回条数 1-15,默认 8' },
}, required: [] },
category: 'knowledge',
async execute(params) {
const wanted = String(params.sections || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const has = s => !wanted.length || wanted.includes(s);
const limit = Math.min(15, Math.max(1, Number(params.limit) || 8));
try {
const fc = await fetchFeaturedCategories();
if (!fc || typeof fc !== 'object') return { success: false, error: '官方动态数据返回为空' };
const mapGame = it => ({
appid: it.id,
name: it.name || '',
discount: Number(it.discount_percent) || 0,
originalPrice: formatCents(it.original_price, it.currency),
finalPrice: formatCents(it.final_price, it.currency),
endsAt: formatDealEnds(it.discount_expiration),
url: `https://store.steampowered.com/app/${it.id}`,
});
let specials = [];
if (has('specials') && fc.specials && Array.isArray(fc.specials.items)) {
specials = fc.specials.items
.filter(it => it && it.id && it.discounted && Number(it.discount_percent) > 0)
.sort((a, b) => (Number(b.discount_percent) || 0) - (Number(a.discount_percent) || 0))
.slice(0, limit)
.map(mapGame);
}
const notes = [];
let dailyDeals = [];
if (has('dailydeals')) {
const dd = findFeaturedCat(fc, 'cat_dailydeal');
let items = dd && Array.isArray(dd.items) ? dd.items.filter(it => it && it.id && it.discounted) : [];
if (!items.length && fc.specials && Array.isArray(fc.specials.items)) {
const now = Date.now() / 1000;
const pool = fc.specials.items.filter(it => it && it.id && it.discounted && Number(it.discount_expiration) > 0);
const soon = pool.filter(it => Number(it.discount_expiration) - now <= 48 * 3600);
items = soon.length ? soon : pool.sort((a, b) => a.discount_expiration - b.discount_expiration).slice(0, 3);
notes.push(soon.length ? '官方未单列今日特惠,已取 48 小时内结束的优惠' : '官方未单列今日特惠,已取折扣最接近结束的优惠');
}
dailyDeals = items.slice(0, 5).map(mapGame);
}
let events = [];
if (has('events')) {
const seen = new Set();
for (const key of Object.keys(fc)) {
const v = fc[key];
if (!v || v.id !== 'cat_spotlight' || !Array.isArray(v.items)) continue;
for (const sp of v.items) {
if (!sp || !sp.url || !sp.url.includes('/sale/')) continue;
const m = sp.url.match(/\/sale\/([^\/\?]+)/);
const saleId = m ? m[1] : sp.url;
if (seen.has(saleId)) continue;
seen.add(saleId);
events.push({
name: sp.name || '促销活动',
url: sp.url,
description: (sp.body || '').slice(0, 120),
endsAt: formatDealEnds(sp.discount_expiration),
});
}
}
events = events.slice(0, limit);
}
let news = [];
if (has('news')) {
try {
// 官方 Steam 商店新闻 RSS(旧 ISteamNews 593050 实为 Dead Rising 4 DLC,返回恒空)
const raw = await gmFetch('https://store.steampowered.com/feeds/news.xml', { timeout: 12000 });
const blocks = String(raw).match(/- ][\s\S]*?<\/item>/g) || [];
const decode = s => String(s || '')
.replace(//g, '')
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
.trim();
const pickTag = (b, tag) => decode((b.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)${tag}>`)) || [])[1]);
news = blocks.slice(0, limit).map(b => {
const date = (b.match(/]*>([\s\S]*?)<\/dc:date>/) || [])[1] || '';
return {
title: pickTag(b, 'title'),
url: pickTag(b, 'link'),
date: date ? date.slice(0, 10) : '',
summary: pickTag(b, 'description').replace(/<[^>]+>/g, '').slice(0, 100),
};
}).filter(n => n.title);
} catch { notes.push('官方新闻获取失败,不影响特惠数据'); }
}
const data = { specials, dailyDeals, events, news };
if (notes.length) data.notes = notes;
const parts = [];
if (has('specials')) parts.push(`当前特惠 ${specials.length} 款`);
if (has('dailydeals')) parts.push(`每日优惠 ${dailyDeals.length} 款`);
if (has('events')) parts.push(`促销活动 ${events.length} 个`);
if (has('news')) parts.push(`官方新闻 ${news.length} 条`);
const top = specials[0] || dailyDeals[0];
return {
success: true,
data,
summary: `官方动态:${parts.join('、')}${top ? `(折扣最高 ${top.name} -${top.discount}%)` : ''}`,
};
} catch (e) {
return { success: false, error: `官方动态获取失败: ${e.message}` };
}
},
});
// ============================================================
// 游戏官方动态查询 (v1.8.2) — 数据源同游戏库插件信息面板的 ISteamNews
// ============================================================
const GAME_NEWS_ALIASES = {
'dota2': { appid: '570', name: 'Dota 2' }, 'dota': { appid: '570', name: 'Dota 2' }, '刀塔': { appid: '570', name: 'Dota 2' }, '刀塔2': { appid: '570', name: 'Dota 2' },
'cs2': { appid: '730', name: 'Counter-Strike 2' }, 'csgo': { appid: '730', name: 'Counter-Strike 2' }, 'cs': { appid: '730', name: 'Counter-Strike 2' }, '反恐精英': { appid: '730', name: 'Counter-Strike 2' }, '反恐精英2': { appid: '730', name: 'Counter-Strike 2' },
'pubg': { appid: '578080', name: 'PUBG: BATTLEGROUNDS' }, '绝地求生': { appid: '578080', name: 'PUBG: BATTLEGROUNDS' }, '吃鸡': { appid: '578080', name: 'PUBG: BATTLEGROUNDS' },
'gta': { appid: '271590', name: 'Grand Theft Auto V' }, 'gta5': { appid: '271590', name: 'Grand Theft Auto V' }, 'gtav': { appid: '271590', name: 'Grand Theft Auto V' }, 'gtaol': { appid: '271590', name: 'Grand Theft Auto V' },
'艾尔登法环': { appid: '1245620', name: 'ELDEN RING' }, '老头环': { appid: '1245620', name: 'ELDEN RING' }, '法环': { appid: '1245620', name: 'ELDEN RING' }, 'elden ring': { appid: '1245620', name: 'ELDEN RING' },
'黑神话': { appid: '2358720', name: 'Black Myth: Wukong' }, '黑神话悟空': { appid: '2358720', name: 'Black Myth: Wukong' }, '悟空': { appid: '2358720', name: 'Black Myth: Wukong' }, 'wukong': { appid: '2358720', name: 'Black Myth: Wukong' },
'rust': { appid: '252490', name: 'Rust' }, '腐蚀': { appid: '252490', name: 'Rust' },
'赛博朋克': { appid: '1091500', name: 'Cyberpunk 2077' }, '赛博朋克2077': { appid: '1091500', name: 'Cyberpunk 2077' }, 'cyberpunk': { appid: '1091500', name: 'Cyberpunk 2077' }, 'cyberpunk 2077': { appid: '1091500', name: 'Cyberpunk 2077' },
'幻兽帕鲁': { appid: '1623730', name: 'Palworld' }, '帕鲁': { appid: '1623730', name: 'Palworld' }, 'palworld': { appid: '1623730', name: 'Palworld' },
'泰拉瑞亚': { appid: '105600', name: 'Terraria' }, 'terraria': { appid: '105600', name: 'Terraria' },
'饥荒': { appid: '322330', name: "Don't Starve Together" }, "don't starve": { appid: '322330', name: "Don't Starve Together" }, 'dst': { appid: '322330', name: "Don't Starve Together" },
'流放之路': { appid: '269449', name: 'Path of Exile' }, 'poe': { appid: '269449', name: 'Path of Exile' }, '流放之路2': { appid: '238960', name: 'Path of Exile 2' }, 'poe2': { appid: '238960', name: 'Path of Exile 2' },
'warframe': { appid: '230410', name: 'Warframe' }, '星际战甲': { appid: '230410', name: 'Warframe' },
'命运2': { appid: '1085660', name: 'Destiny 2' }, 'destiny2': { appid: '1085660', name: 'Destiny 2' }, 'destiny 2': { appid: '1085660', name: 'Destiny 2' },
'荒野大镖客': { appid: '1174180', name: 'Red Dead Redemption 2' }, '荒野大镖客2': { appid: '1174180', name: 'Red Dead Redemption 2' }, 'rdr2': { appid: '1174180', name: 'Red Dead Redemption 2' }, '大表哥2': { appid: '1174180', name: 'Red Dead Redemption 2' },
'apex': { appid: '1172470', name: 'Apex Legends' }, 'apex英雄': { appid: '1172470', name: 'Apex Legends' }, 'apex legends': { appid: '1172470', name: 'Apex Legends' },
'彩虹六号': { appid: '359550', name: 'Rainbow Six Siege' }, 'r6': { appid: '359550', name: 'Rainbow Six Siege' }, 'r6s': { appid: '359550', name: 'Rainbow Six Siege' }, '围攻': { appid: '359550', name: 'Rainbow Six Siege' },
'军团要塞': { appid: '440', name: 'Team Fortress 2' }, 'tf2': { appid: '440', name: 'Team Fortress 2' },
'求生之路': { appid: '550', name: 'Left 4 Dead 2' }, 'l4d2': { appid: '550', name: 'Left 4 Dead 2' },
'雨中冒险': { appid: '632360', name: 'Risk of Rain 2' }, 'ror2': { appid: '632360', name: 'Risk of Rain 2' },
'无人深空': { appid: '275850', name: 'No Man\'s Sky' }, "no man's sky": { appid: '275850', name: 'No Man\'s Sky' },
};
const _appidResolveCache = new Map();
async function resolveGameToAppid(query) {
const raw = String(query || '').trim();
if (!raw) return null;
if (/^\d{1,7}$/.test(raw)) return { appid: raw, name: '', source: 'direct' };
const key = raw.toLowerCase();
if (GAME_NEWS_ALIASES[key]) return Object.assign({}, GAME_NEWS_ALIASES[key], { source: 'alias' });
if (_appidResolveCache.has(key)) return _appidResolveCache.get(key);
try {
const resp = await gmFetch(`https://store.steampowered.com/api/storesearch?term=${encodeURIComponent(raw)}&l=schinese&cc=cn`, { timeout: 12000 });
const data = JSON.parse(resp);
const item = (data.items || []).filter(it => it && it.id)[0];
if (item) {
const hit = { appid: String(item.id), name: item.name || raw, source: 'storesearch' };
_appidResolveCache.set(key, hit);
return hit;
}
} catch { /* fallthrough */ }
return null;
}
function cleanNewsContent(text) {
return String(text || '')
.replace(/\{STEAM_CLAN(?:_LOC)?_(?:IMAGE|VIDEO)\}\S*/gi, '')
.replace(/\[img\][\s\S]*?\[\/img\]/gi, '')
.replace(/\[url=[^\]]*\]([\s\S]*?)\[\/url\]/gi, '$1')
.replace(/\[\/?(?:b|i|u|s|quote|h[1-6]|list|olist|li|code|hr|url|img|spoiler|previewyoutube)[^\]]*\]/gi, '')
.replace(/\[\/?[a-z0-9_=#.\s:%{}|\/\\-]*\]/gi, '')
.replace(/https?:\/\/\S+/g, m => (m.length > 60 ? '' : m))
.replace(/\s{2,}/g, ' ')
.trim();
}
function classifyNewsType(title) {
const t = String(title || '').toLowerCase();
if (/patch|update|hotfix|balance|nerf|buff|fix|maintenance|更新|补丁|平衡|修复|维护|版本/.test(t)) return 'update';
if (/sale|discount|deal|off\b|特惠|促销|打折|优惠|折扣/.test(t)) return 'sale';
if (/event|tournament|major|invitational|championship|international|cup\b|league|活动|赛事|锦标赛|比赛|联赛|大赛|邀请赛/.test(t)) return 'event';
return 'announcement';
}
const _gameNewsCache = new Map();
const GAME_NEWS_TTL = 30 * 60 * 1000;
async function fetchGameNewsItems(appid) {
const cached = _gameNewsCache.get(appid);
if (cached && Date.now() - cached.ts < GAME_NEWS_TTL) return cached;
const base = `https://api.steampowered.com/ISteamNews/GetNewsForApp/v0002/?appid=${encodeURIComponent(appid)}&count=20&maxlength=800&l=schinese`;
let feedsMode = 'official';
let items = [];
try {
const resp = await gmFetch(`${base}&feeds=steam_community_announcements`, { timeout: 15000 });
items = JSON.parse(resp)?.appnews?.newsitems || [];
} catch { items = []; }
if (!items.length) {
// 官方公告源为空 → 全源重试(部分游戏经 steam_news 等其它 feed 发布)
try {
const resp = await gmFetch(base, { timeout: 15000 });
items = JSON.parse(resp)?.appnews?.newsitems || [];
if (items.length) feedsMode = 'all';
} catch { /* keep empty */ }
}
const entry = { items, ts: Date.now(), feedsMode };
_gameNewsCache.set(appid, entry);
return entry;
}
ToolRegistry.register({
name: 'getGameNews',
description: '查询某个具体游戏的官方动态/最新消息(同游戏库插件信息面板的"官方动态"):版本更新公告、平衡性补丁、运营活动、官方新闻等。用户问及"dota2/cs2/PUBG 等某个游戏的最近更新/最新动态/官方公告/补丁内容"时必须调用本工具,禁止凭训练记忆编造版本更新内容。支持游戏名称(中英文及常用简称:dota2、cs2、csgo、吃鸡、老头环等内置直达)或 AppID,无需 API Key',
parameters: { type: 'object', properties: {
game: { type: 'string', description: '游戏名称或简称(如 dota2 / cs2 / 艾尔登法环),与 appid 二选一' },
appid: { type: 'string', description: 'Steam 游戏 AppID,优先于 game' },
count: { type: 'number', description: '返回条数 1-10,默认 5' },
type: { type: 'string', description: '内容类型过滤:update(版本更新/补丁)|event(活动/赛事)|sale(促销)|all(不过滤,默认)' },
}, required: [] },
category: 'knowledge',
async execute(params) {
const appidParam = String(params.appid || '').trim();
const gameName = String(params.game || params.gameName || params.title || '').trim();
if (!appidParam && !gameName) return { success: false, error: '缺少参数:请提供 game(游戏名称,如 dota2/cs2)或 appid' };
const resolved = appidParam ? await resolveGameToAppid(appidParam) : await resolveGameToAppid(gameName);
if (!resolved || !resolved.appid) {
return { success: false, error: `未能识别游戏「${gameName || appidParam}」:建议先用 searchGames 确认 AppID 后以 appid 参数重试,或让用户提供更准确的名称` };
}
const { appid } = resolved;
// direct appid 无名称时补查游戏名(失败不阻塞)
let gameLabel = resolved.name || '';
if (!gameLabel) {
try {
const resp = await gmFetch(`https://store.steampowered.com/api/appdetails?appids=${appid}&l=schinese&filters=basic`, { timeout: 10000 });
gameLabel = JSON.parse(resp)?.[appid]?.data?.name || '';
} catch { /* ignore */ }
}
const count = Math.min(10, Math.max(1, Number(params.count) || 5));
const typeFilter = String(params.type || 'all').trim().toLowerCase();
const entry = await fetchGameNewsItems(appid);
const mapped = (entry.items || [])
.map(n => ({
title: String(n.title || '').trim(),
url: n.url || '',
date: n.date ? new Date(n.date * 1000).toISOString().slice(0, 10) : '',
type: classifyNewsType(n.title),
author: n.author || '',
feed: n.feedlabel || n.feedname || '',
contents: cleanNewsContent(n.contents).slice(0, 400),
}))
.filter(n => n.title);
if (!mapped.length) {
return {
success: true,
data: {
appid,
game: gameLabel || `AppID ${appid}`,
resolvedBy: resolved.source,
totalAvailable: 0,
returnedType: typeFilter === 'all' || !typeFilter ? 'all' : typeFilter,
news: [],
latestUpdate: null,
source: entry.feedsMode === 'all' ? 'Steam 全部动态源(官方公告源无数据,已自动回退)' : 'Steam 官方公告源(steam_community_announcements)',
},
summary: `《${gameLabel || 'AppID ' + appid}》近期无官方动态记录`,
};
}
let news = typeFilter && typeFilter !== 'all' ? mapped.filter(n => n.type === typeFilter) : mapped;
news = news.slice(0, count);
const data = {
appid,
game: gameLabel || `AppID ${appid}`,
resolvedBy: resolved.source,
totalAvailable: mapped.length,
returnedType: typeFilter === 'all' || !typeFilter ? 'all' : typeFilter,
news,
latestUpdate: mapped.find(n => n.type === 'update') || null,
source: entry.feedsMode === 'all' ? 'Steam 全部动态源(官方公告源无数据,已自动回退)' : 'Steam 官方公告源(steam_community_announcements)',
};
if (typeFilter !== 'all' && typeFilter && !news.length) {
data.note = `该游戏最近 ${mapped.length} 条动态中没有 ${typeFilter} 类型内容,建议以 type=all 重新调用查看全部动态`;
}
const latest = news[0] || mapped[0];
return {
success: true,
data,
summary: `《${data.game}》动态 ${news.length}/${mapped.length} 条,最新:${latest.title.slice(0, 40)}(${latest.date})`,
};
},
});
ToolRegistry.register({
name: 'searchDiscountedGames',
description: '高级打折检索:通过 Steam 官方 Web API(IStoreQueryService) 按好评率/最新发行等排序批量扫描商店游戏并筛选当前正在打折的,适合"找折扣大的好评游戏"类深度检索。需要 Steam Web API Key:未配置时会返回配置引导,用户在设置中配置并回复"已配置"后重新调用即可',
parameters: { type: 'object', properties: {
minDiscount: { type: 'number', description: '最低折扣百分比过滤,如 50 表示 5 折起,默认 0 不过滤' },
sort: { type: 'string', description: '扫描排序:topRated(好评率优先,默认)|newest(最新发行)|alpha(字母序)' },
count: { type: 'number', description: '扫描条数 10-500,默认 100,越大越全但越慢' },
limit: { type: 'number', description: '返回条数 1-20,默认 10' },
}, required: [] },
category: 'knowledge',
async execute(params) {
const apiKey = getSteamApiKey();
if (!apiKey) {
return {
success: false,
needsApiKey: true,
error: '此工具需要 Steam Web API Key。请引导用户点击对话中的「配置 Steam API Key」按钮,或打开设置面板填写 Steam Web API Key(可在 steamcommunity.com/dev 免费申请),配置完成后用户回复"已配置",再重新调用本工具',
};
}
const minDiscount = Math.max(0, Number(params.minDiscount) || 0);
const limit = Math.min(20, Math.max(1, Number(params.limit) || 10));
const count = Math.min(500, Math.max(10, Number(params.count) || 100));
const sortMap = { toprated: 21, newest: 20, alpha: 1 };
const sort = sortMap[String(params.sort || 'toprated').toLowerCase()] ?? 21;
const inputJson = JSON.stringify({
query: { start: 0, count, sort, filter: { released_only: true, type_filters: { include_games: true } } },
context: { language: 6, country_code: getStoreCountryCode() },
data_request: { include_basic_info: true, include_all_purchase_options: true, include_ratings: true },
});
try {
const raw = await gmFetch(`https://api.steampowered.com/IStoreQueryService/Query/v1/?key=${encodeURIComponent(apiKey)}&input_json=${encodeURIComponent(inputJson)}`, { timeout: 25000 });
const data = JSON.parse(raw);
const items = (data && data.response && (data.response.store_items || data.response.items)) || [];
const games = [];
for (const it of items) {
if (!it || !it.appid) continue;
const opts = it.all_purchase_options || it.purchase_options || [];
let best = null;
for (const o of opts) {
if (!o) continue;
const disc = Number(o.discount_percent ?? o.price_discount_percent ?? o.discount_pct) || 0;
if (disc > (best ? best.discount : -1)) {
best = {
discount: disc,
price: o.formatted_final_price || o.final_formatted_price || formatCents(o.final_price_in_cents ?? o.price_final_in_cents, o.currency),
originalPrice: o.formatted_original_price || formatCents(o.original_price_in_cents ?? o.price_original_price_in_cents, o.currency),
};
}
}
if (!best || best.discount < Math.max(1, minDiscount)) continue;
const rating = it.review_summary || it.ratings || null;
games.push({
appid: it.appid,
name: it.name || '',
discount: best.discount,
price: best.price,
originalPrice: best.originalPrice,
reviewScore: rating ? (rating.review_score_desc || rating.score_desc || '') : '',
url: `https://store.steampowered.com/app/${it.appid}`,
});
}
games.sort((a, b) => b.discount - a.discount);
if (!games.length) {
return { success: true, data: { games: [], scanned: items.length, note: `扫描 ${items.length} 款,无折扣 ≥ ${Math.max(1, minDiscount)}% 的游戏` }, summary: '无符合条件的打折游戏' };
}
return {
success: true,
data: { scanned: items.length, count: Math.min(games.length, limit), games: games.slice(0, limit) },
summary: `已通过 Steam Web API 扫描 ${items.length} 款,${games.length} 款在打折(最高 -${games[0].discount}%),返回前 ${Math.min(games.length, limit)} 款`,
};
} catch (e) {
const msg = /HTTP 403/.test(e.message) ? 'Steam Web API Key 无效或无权限,请检查设置中的 Key'
: /HTTP 400|HTTP 429/.test(e.message) ? '请求被拒绝(参数/频率限制),请减小 count 后重试'
: e.message;
return { success: false, error: `折扣检索失败: ${msg}` };
}
},
});
ToolRegistry.register({
name: 'getWishlistDeals',
description: '获取 Steam 愿望单游戏及打折情况(默认只返回正在打折的)。不传 steamId 时自动使用当前登录用户;也可查询他人的公开愿望单。返回游戏名、折扣百分比、原价、现价、愿望单优先级',
parameters: { type: 'object', properties: {
steamId: { type: 'string', description: 'SteamID64(17位数字)或自定义ID(vanity URL名),可选,默认当前登录用户' },
onlyDiscounted: { type: 'boolean', description: '是否只返回正在打折的游戏,默认 true' },
limit: { type: 'number', description: '返回条数 1-20,默认 10' },
}, required: [] },
category: 'user',
async execute(params) {
const { steamId, source } = resolveSteamId(params.steamId);
if (!steamId) return { success: false, error: '未提供也未检测到 SteamID:请在设置中心「接口服务」中配置 SteamID(留空自动检测当前登录用户),或在参数中提供 steamId' };
const onlyDiscounted = params.onlyDiscounted !== false;
const limit = Math.min(20, Math.max(1, Number(params.limit) || 10));
const isId64 = /^\d{17}$/.test(steamId);
const base = isId64
? `https://store.steampowered.com/wishlist/profiles/${steamId}/wishlistdata/`
: `https://store.steampowered.com/wishlist/id/${encodeURIComponent(steamId)}/wishlistdata/`;
try {
const all = [];
const MAX_PAGES = 12;
let truncated = false;
for (let p = 0; p < MAX_PAGES; p++) {
let text;
try {
text = await gmFetch(`${base}?p=${p}`, { timeout: 20000 });
} catch (e) {
if (p === 0) throw new Error(`愿望单请求失败: ${e.message}(可能愿望单不公开或 ID 无效)`);
break;
}
if (typeof text === 'string' && text.trimStart().startsWith('<')) {
throw new Error('愿望单返回页面而非数据(可能未公开或需登录)');
}
let entries;
try {
const data = JSON.parse(text);
entries = Array.isArray(data) ? data : Object.entries(data).map(([k, v]) => ({ appid: Number(k), ...(v || {}) }));
} catch {
if (p === 0) throw new Error('愿望单数据解析失败(可能不公开)');
break;
}
if (!entries.length) break;
all.push(...entries.filter(e => e && (e.appid || e.name)));
if (p === MAX_PAGES - 1 && entries.length > 0) truncated = all.length >= MAX_PAGES * 50;
await new Promise(r => setTimeout(r, 200));
}
if (!all.length) return { success: true, data: { games: [], note: '愿望单为空' }, summary: '愿望单为空' };
const normalized = all.map(e => {
const bpo = e.best_purchase_option || {};
const sub = (Array.isArray(e.subs) && e.subs[0]) || {};
const discount = Number(bpo.discount_pct ?? sub.discount_pct ?? 0) || 0;
const finalCents = Number(bpo.final_price_in_cents ?? sub.price ?? -1);
const originalCents = Number(bpo.original_price_in_cents ?? -1);
return {
appid: e.appid,
name: e.name || '',
discount,
finalPrice: bpo.formatted_final_price || (finalCents >= 0 ? formatCents(finalCents, 'CNY') : (e.is_free ? '免费' : '')),
originalPrice: originalCents >= 0 ? formatCents(originalCents, 'CNY') : (discount > 0 && finalCents >= 0 ? formatCents(Math.round(finalCents / (1 - discount / 100)), 'CNY') : ''),
priority: Number(e.priority) || 0,
comingSoon: !!e.is_coming_soon,
url: `https://store.steampowered.com/app/${e.appid}`,
};
});
const discounted = normalized.filter(g => g.discount > 0).sort((a, b) => b.discount - a.discount);
const result = (onlyDiscounted ? discounted : normalized).slice(0, limit);
const data = {
owner: isId64 ? `SteamID ${steamId}` : `自定义ID ${steamId}`,
totalInWishlist: normalized.length,
discountedCount: discounted.length,
games: result,
};
if (truncated) data.truncated = true;
if (onlyDiscounted && !discounted.length) {
return { success: true, data: { ...data, games: [], note: '愿望单中当前没有正在打折的游戏' }, summary: `愿望单共 ${normalized.length} 款,暂无打折` };
}
const maxD = discounted.length ? discounted[0].discount : 0;
return { success: true, data, summary: `愿望单共 ${normalized.length} 款,${discounted.length} 款打折(最高 -${maxD}%),返回 ${result.length} 款${truncated ? '(数据可能不全)' : ''}` };
} catch (e) {
return { success: false, error: e.message };
}
},
});
ToolRegistry.register({
name: 'getPriceHistory',
description: '查询游戏历史价格与历史最低价(史低),判断当前折扣是否值得入手。返回 Steam 商店现价、折扣百分比、全网历史最低价、是否为 Steam 史低。数据来自 AugmentedSteam',
parameters: { type: 'object', properties: {
appid: { type: 'string', description: 'Steam 游戏 AppID' },
}, required: ['appid'] },
category: 'knowledge',
async execute(params) {
const appid = String(params.appid || '').trim();
if (!appid) return { success: false, error: '缺少 appid 参数' };
try {
const body = JSON.stringify({ country: getStoreCountryCode(), apps: [Number(appid)] });
const resp = await gmFetch('https://api.augmentedsteam.com/prices/v2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: body,
timeout: 15000,
});
const data = JSON.parse(resp);
const entry = data?.prices?.[`app/${appid}`] || data?.prices?.[appid];
if (!entry) return { success: true, data: { appid, note: '该游戏暂无历史价格记录(可能为新作/免费游戏/未收录)' }, summary: '无历史价格记录' };
const fmt = (p) => {
if (!p || !p.price) return '';
const amt = Number(p.price.amount);
if (!Number.isFinite(amt)) return '';
const cur = String(p.price.currency || '').toUpperCase();
const sym = cur === 'CNY' ? '¥' : cur === 'USD' ? 'US$' : cur === 'EUR' ? '€' : (cur ? cur + ' ' : '');
return sym + amt.toFixed(2);
};
const current = entry.current || null;
const lowest = entry.lowest || null;
const curAmt = current ? Number(current.price?.amount) : null;
const lowAmt = lowest ? Number(lowest.price?.amount) : null;
const result = {
appid,
steamCurrent: current ? { price: fmt(current), originalPrice: fmt(current.regular) || fmt({ price: current.regular }), cut: Number(current.cut) || 0, url: current.url || '' } : null,
historicalLow: lowest ? { price: fmt(lowest), shop: lowest.shop?.name || '', cut: Number(lowest.cut) || 0, date: lowest.timestamp ? new Date(lowest.timestamp).toISOString().slice(0, 10) : '' } : null,
lowestNote: 'historicalLow 为全网各商店(含第三方 key 店)历史最低价记录;isSteamHistoricalLow=true 表示当前 Steam 售价不高于该全网最低价,即 Steam 史低',
isSteamHistoricalLow: (curAmt !== null && lowAmt !== null) ? curAmt <= lowAmt : null,
};
const cur = result.steamCurrent ? `Steam 现价 ${result.steamCurrent.price}${result.steamCurrent.cut ? ` (-${result.steamCurrent.cut}%)` : ''}` : 'Steam 无在售价';
const low = result.historicalLow ? `历史最低 ${result.historicalLow.price}(${result.historicalLow.shop})` : '无历史低价记录';
const flag = result.isSteamHistoricalLow === true ? ',【Steam 史低】' : '';
return { success: true, data: result, summary: `${cur},${low}${flag}` };
} catch (e) {
return { success: false, error: `价格历史查询失败: ${e.message}` };
}
},
});
// ============================================================
// ITAD (IsThereAnyDeal) 多地区比价客户端
// 参考 steam-game-library-viewer 的 sglv-itad.lib.js 实现内联精简版
// 注意坑位(来自 SGLV v1.0.2):不发 vouchers 参数;deals 仅 true 时发送;
// country 必须是有效 ISO 3166-1 alpha-2(EU 无效会 400)
// ============================================================
const ITAD_BASE_URL = 'https://api.isthereanydeal.com';
const ITAD_SHOP_STEAM = 61;
const ITAD_REGIONS_ENHANCED = ['CN', 'US', 'TR', 'AR', 'RU', 'IN', 'BR', 'UA', 'KZ', 'CL', 'CO', 'PL', 'MX', 'JP', 'KR', 'GB'];
const ITAD_REGIONS_FALLBACK = ['CN', 'US', 'TR', 'AR', 'RU', 'IN', 'BR'];
const ITAD_CACHE_TTL = 12 * 60 * 60 * 1000;
const REGION_NAMES = {
CN: '中国', US: '美国', TR: '土耳其', AR: '阿根廷', RU: '俄罗斯', IN: '印度', BR: '巴西',
UA: '乌克兰', KZ: '哈萨克斯坦', CL: '智利', CO: '哥伦比亚', PL: '波兰', MX: '墨西哥',
JP: '日本', KR: '韩国', GB: '英国',
};
let _itadCache = null;
function itadCacheGet(key) {
try {
if (!_itadCache) _itadCache = parseStored(GM_getValue('sa_itad_cache_v1', null), {}) || {};
const hit = _itadCache[key];
if (hit && hit.expires > Date.now()) return hit.data;
} catch {}
return null;
}
function itadCacheSet(key, data) {
try {
if (!_itadCache) _itadCache = parseStored(GM_getValue('sa_itad_cache_v1', null), {}) || {};
_itadCache[key] = { data, expires: Date.now() + ITAD_CACHE_TTL };
const entries = Object.entries(_itadCache);
if (entries.length > 120) {
for (const [k, v] of entries.sort((a, b) => a[1].expires - b[1].expires).slice(0, entries.length - 120)) {
delete _itadCache[k];
}
}
GM_setValue('sa_itad_cache_v1', JSON.stringify(_itadCache));
} catch {}
}
async function itadTestConnection(key) {
if (!key) return { success: false, error: '未填写 Key' };
try {
// shops/v1 对无效 key 也返回 200 无法校验;lookup 端点无效 key 返回 403 "Invalid or expired api key"
const resp = await gmFetch(`${ITAD_BASE_URL}/lookup/id/shop/${ITAD_SHOP_STEAM}/v1?key=${encodeURIComponent(key)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify(['app/220']),
timeout: 10000,
});
const data = JSON.parse(resp);
return { success: true, sampleUuid: data?.['app/220'] || null };
} catch (e) {
const m = String(e.message || '').match(/HTTP (\d+)/);
if (m && (m[1] === '401' || m[1] === '403')) return { success: false, error: `Key 无效 (HTTP ${m[1]})` };
return { success: false, error: e.message };
}
}
// AI 模型连通性检测:发送最小 chat 请求(max_tokens=1),校验地址/Key/模型名
async function testModelConnection(model) {
if (!model?.apiUrl) throw new Error('未填写 API 地址');
if (!model?.apiKey) throw new Error('未填写 API Key');
if (!model?.model) throw new Error('未填写模型名称');
const started = Date.now();
const resp = await gmFetch(model.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${model.apiKey}` },
data: JSON.stringify({ model: model.model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, stream: false }),
timeout: 20000,
});
const data = JSON.parse(resp);
if (data?.error) {
const msg = typeof data.error === 'string' ? data.error : (data.error.message || `API 错误码 ${data.error.code ?? ''}`.trim());
throw new Error(msg || 'API 返回错误');
}
if (!Array.isArray(data?.choices) || !data.choices.length) {
throw new Error('响应缺少 choices,请检查 API 地址是否指向 /chat/completions 端点');
}
return { latencyMs: Date.now() - started, modelEcho: data.model || model.model, usage: data.usage || null };
}
// 将 AI 请求异常翻译为用户可读的诊断文案
function describeModelError(e) {
const msg = String(e?.message || '');
const m = msg.match(/HTTP (\d+)/);
if (m) {
if (m[1] === '401' || m[1] === '403') return 'API Key 无效或无权限 (鉴权失败)';
if (m[1] === '404') return 'API 地址不存在 (404),请检查端点路径';
if (m[1] === '429') return '请求频率超限 (429),请稍后重试';
if (m[1] === '400' || m[1] === '422') return '请求被拒绝 (多为模型名称不正确)';
return `HTTP ${m[1]}: ${msg.slice(0, 120)}`;
}
if (msg.includes('超时')) return '连接超时:网络不可达,或目标域名未在脚本 @connect 中声明';
if (msg.includes('网络错误')) return '网络错误:域名无法访问或跨域未授权';
return msg || '未知错误';
}
// Steam Web API Key 检测:查询公开玩家资料验证 Key 有效性
async function testSteamApiKey(key) {
if (!key) return { success: false, error: '未填写 Key(可选配置)' };
try {
const resp = await gmFetch(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${encodeURIComponent(key)}&steamids=76561197960265728`, { timeout: 15000 });
const data = JSON.parse(resp);
const players = data?.response?.players;
if (!Array.isArray(players)) return { success: false, error: '响应格式异常,请确认 Key 来自 steamcommunity.com/dev' };
if (!players.length) return { success: true, samplePlayer: null, note: 'Key 有效(示例查询未返回玩家资料)' };
return { success: true, samplePlayer: players[0]?.personaname || null };
} catch (e) {
const msg = String(e.message || '');
const m = msg.match(/HTTP (\d+)/);
if (m) {
if (m[1] === '403') return { success: false, error: 'Key 无效 (HTTP 403),请到 steamcommunity.com/dev 重新生成' };
if (m[1] === '400') return { success: false, error: 'Key 格式错误 (HTTP 400)' };
return { success: false, error: `HTTP ${m[1]}` };
}
return { success: false, error: msg };
}
}
// Steam AppID → ITAD Game UUID
async function itadLookupId(appid) {
const cacheKey = `lookup_${appid}`;
const cached = itadCacheGet(cacheKey);
if (cached !== null) return cached;
const resp = await gmFetch(`${ITAD_BASE_URL}/lookup/id/shop/${ITAD_SHOP_STEAM}/v1?key=${encodeURIComponent(getItadApiKey())}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify([`app/${appid}`]),
timeout: 15000,
});
const data = JSON.parse(resp);
const uuid = data?.[`app/${appid}`] || null;
if (uuid) itadCacheSet(cacheKey, uuid);
return uuid;
}
// 单地区 Steam 价格(ITAD prices/v3,仅 Steam 商店)
async function itadRegionPrice(uuid, country) {
const cacheKey = `price_${uuid}_${country}`;
const cached = itadCacheGet(cacheKey);
if (cached !== null) return cached;
const resp = await gmFetch(`${ITAD_BASE_URL}/games/prices/v3?key=${encodeURIComponent(getItadApiKey())}&country=${country}&shops=${ITAD_SHOP_STEAM}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify([uuid]),
timeout: 15000,
});
const data = JSON.parse(resp);
const item = Array.isArray(data) ? data[0] : null;
const deals = item?.deals || [];
const steamDeal = deals.find(d => d?.shop?.id === ITAD_SHOP_STEAM) || deals[0] || null;
const result = steamDeal ? {
price: steamDeal.price?.amount ?? null,
currency: steamDeal.price?.currency || '',
cut: Number(steamDeal.cut) || 0,
shop: steamDeal.shop?.name || 'Steam',
} : null;
if (result) itadCacheSet(cacheKey, result);
return result;
}
// 降级模式:AugmentedSteam prices/v2 单地区查询
async function augRegionPrice(appid, country) {
try {
const resp = await gmFetch('https://api.augmentedsteam.com/prices/v2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ country, apps: [Number(appid)] }),
timeout: 15000,
});
const data = JSON.parse(resp);
const entry = data?.prices?.[`app/${appid}`] || data?.prices?.[appid];
const current = entry?.current;
if (!current?.price) return null;
return {
price: Number(current.price.amount),
currency: String(current.price.currency || '').toUpperCase(),
cut: Number(current.cut) || 0,
shop: 'Steam',
};
} catch {
return null;
}
}
// 简单并发池(mapLimit 模式)
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let idx = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (idx < items.length) {
const i = idx++;
results[i] = await fn(items[i], i).catch(() => null);
}
});
await Promise.all(workers);
return results;
}
// 汇率表(AugmentedSteam rates/v1?to=USD,响应为顶层 { 币种: { USD: 1单位该币=X美元 } },须带 to 参数否则 400),缓存 2 小时
let _usdRates = null;
async function fetchUsdRates() {
if (_usdRates && _usdRates.expires > Date.now()) return _usdRates.data;
try {
const resp = await gmFetch('https://api.augmentedsteam.com/rates/v1?to=USD', { timeout: 10000 });
const parsed = JSON.parse(resp);
const top = parsed?.data || parsed || {};
const flat = {};
for (const [cur, val] of Object.entries(top)) {
const usd = val && typeof val === 'object' ? val.USD : (typeof val === 'number' ? val : null);
if (typeof usd === 'number' && usd > 0) flat[cur] = usd;
}
if (flat.USD === undefined) flat.USD = 1;
if (Object.keys(flat).length > 5) {
_usdRates = { data: flat, expires: Date.now() + 2 * 60 * 60 * 1000 };
return flat;
}
} catch {}
return null;
}
// rates 语义:1 单位 currency = rates[currency] USD,即 usd = amount * rate
function toUsd(amount, currency, rates) {
if (!rates || amount === null || amount === undefined) return null;
const cur = String(currency || '').toUpperCase();
if (cur === 'USD') return Math.round(amount * 100) / 100;
const rate = rates[cur];
if (typeof rate !== 'number' || rate <= 0) return null;
return Math.round(amount * rate * 100) / 100;
}
function formatRegionPrice(p) {
if (!p || p.price === null || p.price === undefined) return '无在售价';
const cur = String(p.currency || '').toUpperCase();
const sym = { CNY: '¥', USD: 'US$', EUR: '€', GBP: '£', TRY: '₺', ARS: 'AR$', RUB: '₽', INR: '₹', BRL: 'R$', UAH: '₴', KZT: '〒', CLP: 'CLP$', COP: 'COL$', PLN: 'zł', MXN: 'MX$', JPY: '¥', KRW: '₩' }[cur];
const amt = (sym === '₩' || sym === '¥' && cur === 'JPY') ? Math.round(p.price) : p.price.toFixed(2);
return `${sym || cur + ' '}${amt}${p.cut ? ` (-${p.cut}%)` : ''}`;
}
ToolRegistry.register({
name: 'getMultiRegionPrices',
description: '查询游戏在多个 Steam 地区的当前售价并对比(跨区送礼/差价分析必备)。已配置 ITAD API Key 时为增强模式(16 个地区,数据准确);未配置时自动降级为 AugmentedSteam 基础模式(约 7 个地区,价格准确度降低,返回 degraded=true 需向用户说明)。返回各地区原币价格与最低价地区',
parameters: { type: 'object', properties: {
appid: { type: 'string', description: 'Steam 游戏 AppID' },
regions: { type: 'array', items: { type: 'string' }, description: '可选,ISO 3166-1 alpha-2 地区代码列表(如 ["CN","US","TR"]),不传则查询全部支持地区' },
}, required: ['appid'] },
category: 'knowledge',
async execute(params) {
const appid = String(params.appid || '').trim();
if (!appid) return { success: false, error: '缺少 appid 参数' };
const itadKey = getItadApiKey();
const userRegions = Array.isArray(params.regions) && params.regions.length
? params.regions.map(r => String(r).trim().toUpperCase()).filter(r => /^[A-Z]{2}$/.test(r))
: null;
let source = 'augmentedsteam';
let regions = userRegions || ITAD_REGIONS_FALLBACK;
let regionPrices = {};
if (itadKey) {
source = 'itad';
regions = userRegions || ITAD_REGIONS_ENHANCED;
try {
const uuid = await itadLookupId(appid);
if (!uuid) throw new Error('ITAD 未收录该游戏');
const results = await mapLimit(regions, 3, cc => itadRegionPrice(uuid, cc));
regions.forEach((cc, i) => {
if (results[i]) regionPrices[cc] = results[i];
else regionPrices[cc] = null;
});
} catch (e) {
// ITAD 增强失败 → 降级 AugmentedSteam
source = 'augmentedsteam';
regions = userRegions || ITAD_REGIONS_FALLBACK;
regionPrices = {};
}
}
if (source === 'augmentedsteam') {
const results = await mapLimit(regions, 3, cc => augRegionPrice(appid, cc));
regions.forEach((cc, i) => { regionPrices[cc] = results[i]; });
}
// 汇率换算(用于跨币种比较最低价区),失败时保留原币并提示
const rates = await fetchUsdRates();
const rows = Object.entries(regionPrices)
.map(([cc, p]) => {
const usd = p ? toUsd(p.price, p.currency, rates) : null;
return {
region: `${cc}(${REGION_NAMES[cc] || cc})`,
available: !!p,
price: p ? formatRegionPrice(p) : null,
currency: p?.currency || null,
amount: p?.price ?? null,
amountUsd: usd,
cut: p?.cut || 0,
};
});
// 有换算数据时按 USD 升序(最低价在前),否则保持地区顺序
if (rates) {
rows.sort((a, b) => (a.amountUsd === null) - (b.amountUsd === null) || (a.amountUsd ?? 0) - (b.amountUsd ?? 0));
}
const validRows = rows.filter(r => r.available);
if (!validRows.length) {
return { success: true, data: { appid, regions: rows, source, note: '所有查询地区均无在售价记录(可能为新作/免费游戏/未收录)' }, summary: '多地区均无在售价' };
}
const cheapest = rates && validRows[0]?.amountUsd !== null && validRows[0]?.amountUsd !== undefined
? { region: validRows[0].region, price: validRows[0].price, currency: validRows[0].currency, amountUsd: validRows[0].amountUsd }
: null;
const data = {
appid,
source,
sourceLabel: source === 'itad' ? 'ITAD 增强模式(16 地区,价格准确)' : 'AugmentedSteam 降级模式(地区较少,价格准确度降低)',
regions: rows,
cheapest,
ratesAvailable: !!rates,
currentStoreRegion: getStoreCountryCode(),
note: source === 'itad'
? `价格为各区域 Steam 商店原币售价${rates ? ',amountUsd 为按当前汇率折算的美元参考价(用于跨区比价,15% 送礼规则计算请以此为准)' : ',未能获取汇率,暂无美元折算,跨区差价请用户自行换算'}`
: '未配置 ITAD API Key,当前为降级模式:仅覆盖少数地区且部分区域可能查询失败,价格准确度降低。建议在设置面板配置 ITAD API Key(isthereanydeal.com/apps 免费申请)启用 16 地区增强模式',
};
if (source !== 'itad') {
data.degraded = true;
data.needsItadKey = true;
}
const cheapText = cheapest ? `,USD 折算最低为 ${cheapest.region}(约 $${cheapest.amountUsd})` : '';
const summary = `${source === 'itad' ? 'ITAD 增强' : '降级'}模式查询 ${regions.length} 地区,${validRows.length} 区有价${cheapText}${source !== 'itad' ? ';未配置 ITAD Key,价格准确度降低,建议配置后重试' : ''}`;
return { success: true, data, summary };
},
});
function parseSteamProfileXml(xmlText) {
try {
const get = (tag) => {
const m = xmlText.match(new RegExp(`<${tag}>${tag}>`));
if (m) return m[1];
const m2 = xmlText.match(new RegExp(`<${tag}>([^<]*)${tag}>`));
return m2 ? m2[1] : '';
};
return {
steamID: get('steamID'),
steamID64: get('steamID64'),
visibility: get('visibilityState'),
avatarIcon: get('avatarIcon'),
memberSince: get('memberSince'),
summary: get('summary'),
};
} catch { return null; }
}
// ============================================================
// ReAct 循环引擎
// ============================================================
function parseToolCalls(response) {
const calls = [];
const text = String(response || '');
// 宽松标签匹配:兼容 /、大小写、带属性、未闭合截断
const tagRegex = /]*>([\s\S]*?)(?:<\/tool_calls?>|$)/gi;
let match;
while ((match = tagRegex.exec(text)) !== null) {
const call = parseToolCallJson(match[1]);
if (call) calls.push(call);
}
// 兜底:整段响应就是一个裸 JSON(模型直接输出函数调用对象)
if (!calls.length) {
const stripped = text.replace(/```(?:json)?/gi, '').trim();
if (/^\{[\s\S]*\}$/.test(stripped)) {
const call = parseToolCallJson(stripped);
if (call) calls.push(call);
}
}
return calls;
}
// 修复模型常见的 JSON 瑕疵:代码围栏、尾逗号
function repairToolCallJson(raw) {
return String(raw || '')
.replace(/^[\s\S]*?```(?:json)?\s*/i, m => (m.includes('```') ? '' : m))
.replace(/\s*```[\s\S]*$/, m => (m.includes('```') ? '' : m))
.replace(/,\s*([}\]])/g, '$1')
.trim();
}
function tryJson(s) { try { return JSON.parse(s); } catch { return null; } }
// 生成中途被截断时,按括号计数补齐未闭合的 { [ 和字符串引号
function salvageTruncatedJson(s) {
const stack = [];
let inStr = false, esc = false;
for (const ch of s) {
if (esc) { esc = false; continue; }
if (ch === '\\') { esc = true; continue; }
if (ch === '"') { inStr = !inStr; continue; }
if (inStr) continue;
if (ch === '{' || ch === '[') stack.push(ch);
else if (ch === '}' || ch === ']') stack.pop();
}
let out = inStr ? s + '"' : s;
out = out.replace(/,\s*$/, '');
while (stack.length) out += stack.pop() === '{' ? '}' : ']';
return out;
}
function parseToolCallJson(raw) {
const repaired = repairToolCallJson(raw);
let parsed = tryJson(repaired) || tryJson(salvageTruncatedJson(repaired));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const name = parsed.name || parsed.tool || (typeof parsed.function === 'object' ? parsed.function?.name : null);
if (!name || typeof name !== 'string') return null;
let args = parsed.arguments ?? parsed.parameters ?? parsed.args ?? parsed.input ?? {};
if (typeof args === 'string') {
const argsRepaired = repairToolCallJson(args);
args = tryJson(argsRepaired) || tryJson(salvageTruncatedJson(argsRepaired)) || {};
}
if (!args || typeof args !== 'object' || Array.isArray(args)) args = {};
return { name: name.trim(), arguments: args };
}
function cleanToolCallMarkers(text) {
return String(text || '')
.replace(/]*>[\s\S]*?(?:<\/tool_calls?>|$)/gi, '')
.replace(/[\s\S]*?<\/tool_result>/g, '')
.trim();
}
const TOOL_DISPLAY = {
getGameDetail: { label: '游戏详情', icon: 'gamepad' },
searchGames: { label: '游戏搜索', icon: 'search' },
getPlayerSummary: { label: '玩家资料', icon: 'stats' },
getOwnedGames: { label: '游戏库查询', icon: 'gamepad' },
getSpecialDeals: { label: '特惠查询', icon: 'search' },
getOfficialDeals: { label: '官方特惠动态', icon: 'search' },
getGameNews: { label: '游戏动态', icon: 'news' },
searchDiscountedGames: { label: '折扣检索', icon: 'search' },
getWishlistDeals: { label: '愿望单折扣', icon: 'gamepad' },
getPriceHistory: { label: '价格历史', icon: 'stats' },
getMultiRegionPrices: { label: '跨区比价', icon: 'globe' },
};
function getToolDisplay(name) {
return TOOL_DISPLAY[name] || { label: name, icon: 'cpu' };
}
function formatToolArgs(args = {}) {
const values = Object.values(args).filter(v => v !== undefined && v !== null && v !== '');
if (!values.length) return '';
return values.slice(0, 2).map(v => String(v).slice(0, 24)).join(' · ');
}
function renderToolChip(name, args, running, failed) {
const d = getToolDisplay(name);
const argText = formatToolArgs(args);
return `
${getIcon(d.icon, 12)}
${escapeHtml(d.label)}
${argText ? `${escapeHtml(argText)}` : ''}
${running ? '' : failed ? getIcon('close', 11) : getIcon('check', 11)}
`;
}
function buildToolCallsHtml(toolCalls, runningIndex = -1) {
if (!toolCalls || !toolCalls.length) return '';
const needsKey = toolCalls.some(tc => tc && tc.result && tc.result.needsApiKey);
const keyAction = needsKey
? `steamcommunity.com/dev 免费申请 · 配置后回复「已配置」继续
`
: '';
const needsItad = toolCalls.some(tc => tc && tc.result && tc.result.needsItadKey);
const itadAction = needsItad
? `isthereanydeal.com/apps 免费申请 · 配置后跨区比价升级为增强模式
`
: '';
return `${toolCalls.map((tc, i) =>
renderToolChip(tc.name, tc.arguments, i === runningIndex, !!(tc.result && tc.result.success === false))
).join('')}${keyAction}${itadAction}
`;
}
function buildSystemInstruction(agent) {
const parts = [agent.systemPrompt];
const rulesCtx = RulesKnowledgeProvider.formatAsContext(agent.id);
if (rulesCtx) parts.push(rulesCtx);
const kContext = AgentKnowledgeManager.formatAsContext(agent.id);
if (kContext) parts.push(kContext);
return parts.join('\n\n');
}
// ============================================================
// 多轮对话上下文管理(智能窗口 + 完整性验证)
// 目标:长会话/大工具结果不丢失关键上下文,也不撑爆模型输入
// ============================================================
const CONTEXT_HISTORY_BUDGET = 9000; // 历史区域字符预算
const CONTEXT_MSG_MAX_CHARS = 1600; // 单条普通消息上限
const CONTEXT_TOOL_RESULT_MAX_CHARS = 2600; // 单条工具结果上限
const CONTEXT_ANCHOR_MAX_CHARS = 300; // 首条用户消息(主题锚点)上限
function truncateMiddle(text, maxLen) {
const s = String(text || '');
if (s.length <= maxLen) return s;
const head = Math.ceil(maxLen * 0.6);
const tail = Math.floor(maxLen * 0.3);
return s.slice(0, head) + `\n…(中间省略约 ${s.length - head - tail} 字符)…\n` + s.slice(-tail);
}
function dedupeHistory(history) {
const out = [];
for (const m of history) {
const prev = out[out.length - 1];
if (prev && prev.role === m.role && (prev.content || '') === (m.content || '')) continue;
out.push(m);
}
return out;
}
// 构建上下文窗口:预算内保留尽可能多的最近消息 + 首条用户消息主题锚点
function buildContextWindow(history) {
const cleaned = dedupeHistory(history || []);
if (!cleaned.length) return { messages: [], anchor: null, omittedCount: 0 };
const items = cleaned.map(m => {
const isToolResult = m.role === 'system' && String(m.content || '').includes('tool_result');
const limit = isToolResult ? CONTEXT_TOOL_RESULT_MAX_CHARS : CONTEXT_MSG_MAX_CHARS;
return { role: m.role, content: truncateMiddle(String(m.content || ''), limit), rawRef: m };
});
const picked = [];
let used = 0;
for (let i = items.length - 1; i >= 0; i--) {
const len = (items[i].content || '').length + 8;
if (picked.length && used + len > CONTEXT_HISTORY_BUDGET) break;
picked.unshift(items[i]);
used += len;
}
// 边界对齐:窗口不以 assistant 开头,避免回答缺失对应问题
while (picked.length && picked[0].role === 'assistant') picked.shift();
// 工具结果收敛:仅保留窗口内最后 2 条完整工具结果,更早的替换为占位符
let toolKept = 0;
for (let i = picked.length - 1; i >= 0; i--) {
const isTool = picked[i].role === 'system' && picked[i].content.includes('tool_result');
if (!isTool) continue;
toolKept++;
if (toolKept > 2) picked[i].content = '(更早的工具结果已省略以节省上下文)';
}
// 主题锚点:首条用户消息未进入窗口时单独注入(截断)
const firstUser = items.find(m => m.role === 'user');
let anchor = null;
if (firstUser && !picked.includes(firstUser)) {
anchor = truncateMiddle(firstUser.content, CONTEXT_ANCHOR_MAX_CHARS);
}
return { messages: picked, anchor, omittedCount: cleaned.length - picked.length };
}
// 上下文完整性验证:当前用户消息必须在 prompt 中,历史窗口结构与预期一致
function verifyContextIntegrity(prompt, userMessage, ctxWindow) {
const issues = [];
if (!prompt || typeof prompt !== 'string') issues.push('prompt 为空');
if (!String(userMessage || '').trim() || !prompt.includes(userMessage)) issues.push('当前用户消息未注入 prompt');
if (!prompt.includes('## 当前任务')) issues.push('缺少当前任务区块');
if (ctxWindow && ctxWindow.messages.length && !prompt.includes('## 对话历史')) issues.push('历史窗口未渲染');
return issues;
}
function buildReActPrompt(userMessage, history, toolsDesc, currentIter, maxIter) {
const ctx = buildContextWindow(history);
const historyText = ctx.messages
.filter(m => m.role !== 'system' || !m.content.includes('tool_result'))
.map(m => {
const label = m.role === 'user' ? '用户' : m.role === 'assistant' ? '助手' : '系统';
let content = m.content;
if (m.role === 'assistant') content = cleanToolCallMarkers(content);
return `${label}: ${content}`;
}).join('\n');
const toolHistory = ctx.messages
.filter(m => m.role === 'system' && m.content.includes('tool_result'))
.map(m => m.content).join('\n');
const contextNotes = [];
if (ctx.anchor) contextNotes.push(`本轮会话最初的话题:「${ctx.anchor}」(较早的中间历史已省略,回答时以此为背景延续话题,如需中间细节可请用户补充)`);
if (ctx.omittedCount > 0) contextNotes.push(`已省略 ${ctx.omittedCount} 条较早的历史消息以控制上下文长度,最近的消息均完整保留`);
contextNotes.push(`本窗口共包含 ${ctx.messages.length} 条历史消息${history.length > ctx.messages.length ? '' : '(完整历史,无裁剪)'}`);
let prompt = `(提示:你的角色设定已在系统指令中给出,请严格遵循。)
## 可用工具
${toolsDesc || '(无可用工具)'}
## 对话历史
${historyText || '(无历史记录)'}
${toolHistory ? '## 工具调用结果\n' + toolHistory + '\n' : ''}${contextNotes.length ? `## 上下文说明\n${contextNotes.map(t => '- ' + t).join('\n')}\n\n` : ''}## 当前任务
用户消息: ${userMessage}
## 执行指令
你正在执行第 ${currentIter}/${maxIter} 次迭代。
在回答之前,请先思考是否需要使用工具:
- 如果需要调用工具,必须严格使用以下格式(一段完整合法的 JSON,必须以 闭合):
{"name":"工具名称","arguments":{"字符串参数":"值","数值参数":123}}
- 如果不需要工具,直接回答用户问题
工具调用格式要求:
1. JSON 必须合法:键与字符串值一律用双引号,无注释、无尾逗号,不要用 markdown 代码围栏(\`\`\`)包裹
2. 参数对象的键名固定为 "arguments",不要写成 parameters/args/input
3. 每次迭代最多调用一个工具;决定调用时只输出一段 ...,不要附加任何其它文本
4. 工具调用完成后,系统会把结果加入对话历史,你再继续下一轮
5. 只有获得所有必要信息后才给出最终回答
6. 若收到 formatError 的工具结果,说明你上一次的调用格式有误,请严格按上述格式重新输出一次
其他规则:
7. 涉及打折/特惠/促销/价格/"值得买"等平台级问题时,必须优先调用实时数据工具(getOfficialDeals/getSpecialDeals/searchDiscountedGames/getWishlistDeals/getPriceHistory/getMultiRegionPrices),禁止凭训练记忆虚构当前价格或声称"无法获取实时折扣数据"
8. 用户问及某个具体游戏(如 dota2、cs2、绝地求生、艾尔登法环等)的最新动态/版本更新/平衡补丁/官方公告/运营活动时,必须调用 getGameNews(传 game=游戏名或简称,未知 AppID 时可先用 searchGames 确认),禁止凭训练记忆编造版本号或更新内容;若结果为空则如实告知该游戏近期无官方动态
9. 若工具结果包含 "needsApiKey": true,表示需要 Steam Web API Key:请在回答中引导用户点击对话中的「配置 Steam API Key」按钮(或在设置面板填写 Key,steamcommunity.com/dev 可免费申请),并告知配置完成后回复"已配置",届时重新调用该工具即可。注意:若用户已回复"已配置",直接重新调用工具,不要重复引导配置
10. 查询玩家自身数据("我的游戏库/我的资料/我的愿望单"等)时不要向用户索要 SteamID:直接调用工具即可,工具会自动使用设置中配置或当前登录用户的身份;仅当工具明确返回"未提供也未检测到 SteamID"的错误时,才引导用户在设置中心「接口服务」配置 SteamID 或直接提供
11. 跨区送礼/多地区比价问题优先调用 getMultiRegionPrices 获取多地区实时价格;若其结果标记 degraded=true(未配置 ITAD Key 的降级模式),须在回答中提示用户价格准确度降低,并引导在设置中配置 ITAD API Key 以启用增强模式
12. 工具返回 success:false 时(Key 未配置、超时、参数错误等),不要机械重复同一调用:向用户简述失败原因并给出下一步建议(配置 Key、缩小检索范围、稍后再试),或改用其他可用工具完成任务
13. 多轮对话中若上下文说明提示"中间历史已省略",不要臆测被省略的内容,可结合话题锚点回答或请用户确认
14. 最终回答使用中文,使用 Markdown 格式`;
const issues = verifyContextIntegrity(prompt, userMessage, ctx);
if (issues.length) {
console.warn('[SA-Agent] 上下文完整性验证未通过,降级为仅当前消息:', issues);
prompt = prompt.replace(/## 对话历史\n[\s\S]*?(?=\n## 工具调用结果|\n## 上下文说明|\n## 当前任务)/, '## 对话历史\n(上下文异常,已重置,请基于当前消息回答)');
const finalCheck = prompt.includes(userMessage);
if (!finalCheck) {
prompt = `${prompt}\n(系统注:用户消息即为本轮唯一上下文)`;
}
}
return prompt;
}
async function runAgentLoop(userMessage, history, agentId, onChunk, onToolCall) {
const agent = AgentManager.getAgent(agentId);
if (!agent) return { response: '未找到智能体', completed: false, iterations: 0 };
const toolsDesc = ToolRegistry.getToolsDescription();
const systemInstruction = buildSystemInstruction(agent);
const parsedSettings = parseStored(GM_getValue(SK.SETTINGS, null), {});
const maxIter = parsedSettings.maxIterations || CONFIG.MAX_ITERATIONS;
let currentHistory = [...history];
const toolCallLog = [];
for (let i = 0; i < maxIter; i++) {
if (abortFlag) {
const lastAi = [...currentHistory].reverse().find(m => m.role === 'assistant');
return { response: lastAi ? cleanToolCallMarkers(lastAi.content) : '', completed: false, aborted: true, iterations: i, toolCalls: toolCallLog };
}
const prompt = buildReActPrompt(userMessage, currentHistory, toolsDesc, i + 1, maxIter);
let aiResponse;
try {
if (i === 0 && onChunk) {
aiResponse = await callAiApiWithTyping(prompt, { systemInstruction, history: [], temperature: 0.7 }, onChunk);
} else {
aiResponse = await callAiApi(prompt, { systemInstruction, history: [], temperature: 0.7 });
}
} catch (e) {
if (abortFlag) {
const lastAi = [...currentHistory].reverse().find(m => m.role === 'assistant');
return { response: lastAi ? cleanToolCallMarkers(lastAi.content) : '', completed: false, aborted: true, iterations: i + 1, toolCalls: toolCallLog };
}
return { response: `AI 调用失败: ${e.message}`, completed: false, iterations: i + 1, error: e.message };
}
if (abortFlag) {
const partial = cleanToolCallMarkers(aiResponse || '');
return { response: partial, completed: false, aborted: true, iterations: i + 1, toolCalls: toolCallLog };
}
currentHistory.push({ id: `ai_${Date.now()}`, role: 'assistant', content: aiResponse, timestamp: Date.now() });
const toolCalls = parseToolCalls(aiResponse);
if (toolCalls.length === 0) {
const cleanResponse = cleanToolCallMarkers(aiResponse);
const attemptedToolCall = /${JSON.stringify({
success: false,
formatError: true,
error: '上一次输出包含工具调用标签,但内容不是合法的工具调用(JSON 解析失败、缺少 name 或标签未闭合)。请重新输出一段合法调用:仅输出 {"name":"工具名称","arguments":{"参数名":"值"}},JSON 用双引号、键名固定为 arguments、以 闭合、不要附加其它文本;若已无需调用工具,请直接给出最终回答',
})}`,
timestamp: Date.now(),
});
console.warn('[SA-Agent] 工具调用格式解析失败,已回灌纠错提示:', String(aiResponse).slice(0, 200));
continue;
}
const finalResponse = cleanResponse.trim() || '(本轮未能生成有效回复:工具调用格式异常,请重试或换个问法)';
return { response: finalResponse, completed: true, iterations: i + 1, toolCalls: toolCallLog };
}
const toolCall = toolCalls[0];
if (onChunk) onChunk('');
if (onToolCall) onToolCall(toolCall.name, toolCall.arguments, 'start');
const toolStatusEl = document.querySelector('.sa-tool-status');
if (toolStatusEl) {
toolStatusEl.querySelector('.sa-tool-text').textContent = `正在调用智能体工具 · ${getToolDisplay(toolCall.name).label}…`;
toolStatusEl.classList.add('sa-active');
}
const result = await ToolRegistry.execute(toolCall.name, toolCall.arguments);
toolCallLog.push({ name: toolCall.name, arguments: toolCall.arguments, result });
if (onToolCall) onToolCall(toolCall.name, toolCall.arguments, 'done');
if (toolStatusEl) toolStatusEl.classList.remove('sa-active');
currentHistory.push({
id: `tool_${Date.now()}`,
role: 'system',
content: `${JSON.stringify(result)}`,
timestamp: Date.now(),
});
}
return { response: '抱歉,处理该问题需要更多步骤,请尝试缩小问题范围。', completed: false, iterations: maxIter, toolCalls: toolCallLog };
}
// ============================================================
// 对话管理器 (多对话历史支持)
// ============================================================
const ConversationManager = {
_conversations: {},
_currentConvId: null,
init() {
try {
this._conversations = JSON.parse(GM_getValue(SK.CONVERSATIONS_V2, '{}'));
} catch { this._conversations = {}; }
this._currentConvId = GM_getValue(SK.CURRENT_CONV, null);
this._migrateOldConversations();
if (!this._currentConvId || !this._conversations[this._currentConvId]) {
this._currentConvId = null;
}
},
_migrateOldConversations() {
try {
const oldData = JSON.parse(GM_getValue(SK.CONVERSATIONS, '{}'));
if (Object.keys(oldData).length === 0) return;
let migrated = false;
for (const [agentId, session] of Object.entries(oldData)) {
if (session.messages && session.messages.length > 0) {
const convId = `conv_migrated_${agentId}`;
if (!this._conversations[convId]) {
const firstMsg = session.messages.find(m => m.role === 'user');
this._conversations[convId] = {
id: convId,
title: firstMsg?.content?.slice(0, 30) || '导入的对话',
agentId,
modelId: null,
messages: session.messages,
createdAt: session.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
migrated = true;
}
}
}
if (migrated) this.save();
} catch {}
},
createConversation(agentId) {
const convId = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
this._conversations[convId] = {
id: convId,
title: '新对话',
agentId: agentId || AgentManager._currentAgentId,
modelId: ModelManager.getActiveModelId(),
messages: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
this._currentConvId = convId;
GM_setValue(SK.CURRENT_CONV, convId);
this.save();
return this._conversations[convId];
},
getCurrentConversation() {
if (!this._currentConvId || !this._conversations[this._currentConvId]) {
return this.createConversation(AgentManager._currentAgentId);
}
return this._conversations[this._currentConvId];
},
getCurrentId() { return this._currentConvId; },
getConversation(id) {
return this._conversations[id] || null;
},
getAllConversations() {
return Object.values(this._conversations).sort((a, b) =>
new Date(b.updatedAt) - new Date(a.updatedAt)
);
},
switchTo(id) {
if (!this._conversations[id]) return false;
this._currentConvId = id;
GM_setValue(SK.CURRENT_CONV, id);
const conv = this._conversations[id];
if (conv.agentId) {
AgentManager.switchAgent(conv.agentId);
}
if (conv.modelId && ModelManager.getModel(conv.modelId)) {
ModelManager.setActiveModel(conv.modelId);
}
return true;
},
deleteConversation(id) {
delete this._conversations[id];
if (this._currentConvId === id) {
this._currentConvId = null;
GM_setValue(SK.CURRENT_CONV, '');
}
this.save();
},
renameConversation(id, title) {
if (!this._conversations[id]) return false;
this._conversations[id].title = title;
this._conversations[id].updatedAt = new Date().toISOString();
this.save();
return true;
},
addMessage(role, content, extra = {}) {
const conv = this.getCurrentConversation();
const msg = {
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role, content,
timestamp: Date.now(),
...extra,
};
conv.messages.push(msg);
conv.updatedAt = new Date().toISOString();
if (conv.title === '新对话' && role === 'user') {
conv.title = content.slice(0, 30) + (content.length > 30 ? '...' : '');
}
this.save();
return msg;
},
getCurrentMessages() {
const conv = this.getCurrentConversation();
return conv.messages;
},
clearCurrent() {
const conv = this.getCurrentConversation();
conv.messages = [];
conv.title = '新对话';
conv.updatedAt = new Date().toISOString();
this.save();
},
updateCurrentModel(modelId) {
const conv = this.getCurrentConversation();
conv.modelId = modelId;
this.save();
},
save() {
try {
GM_setValue(SK.CONVERSATIONS_V2, JSON.stringify(this._conversations));
} catch (e) {
console.error('[SA-Agent] 对话保存失败:', e);
toast('对话保存失败,可能超出存储配额,请导出并清理旧对话', 'error');
}
},
exportAsJson(convId) {
const conv = this.getConversation(convId) || this.getCurrentConversation();
const agent = AgentManager.getAgent(conv.agentId);
const model = ModelManager.getModel(conv.modelId);
const data = {
conversation: conv.title,
agent: agent?.name || conv.agentId,
agentId: conv.agentId,
model: model?.name || conv.modelId,
modelId: conv.modelId,
createdAt: conv.createdAt,
exportedAt: new Date().toISOString(),
messages: conv.messages.map(m => ({
role: m.role, content: m.content,
timestamp: new Date(m.timestamp).toISOString(),
toolCalls: m.toolCalls || [],
})),
};
downloadFile(JSON.stringify(data, null, 2), `steam-chat-${conv.id}-${Date.now()}.json`);
},
exportAsMarkdown(convId) {
const conv = this.getConversation(convId) || this.getCurrentConversation();
const agent = AgentManager.getAgent(conv.agentId);
const lines = [
`# Steam AI 对话记录`,
``,
`**智能体:** ${agent?.name || conv.agentId}`,
`**创建时间:** ${conv.createdAt}`,
`**导出时间:** ${new Date().toISOString()}`,
`---`,
``,
];
for (const m of conv.messages) {
if (m.role === 'user') {
lines.push(`## 用户`, ``, m.content, ``);
} else if (m.role === 'assistant') {
lines.push(`## ${agent?.name || 'AI'}`, ``, cleanToolCallMarkers(m.content), ``);
} else if (m.role === 'system' && m.content.includes('tool_result')) {
lines.push(`> 工具调用结果`, ``, m.content, ``);
}
}
downloadFile(lines.join('\n'), `steam-chat-${conv.id}-${Date.now()}.md`);
},
};
// ============================================================
// 事件总线
// ============================================================
const EventBus = {
_handlers: {},
on(event, cb) {
if (!this._handlers[event]) this._handlers[event] = [];
this._handlers[event].push(cb);
},
off(event, cb) {
if (!this._handlers[event]) return;
this._handlers[event] = this._handlers[event].filter(h => h !== cb);
},
emit(event, data) {
if (!this._handlers[event]) return;
for (const cb of this._handlers[event]) {
try { cb(data); } catch (e) { console.warn('[SA-Agent] Event handler error:', e); }
}
},
};
// ============================================================
// UI 状态
// ============================================================
let panelEl = null;
let fabEl = null;
let messagesEl = null;
let inputEl = null;
let sendBtn = null;
let modelSelectEl = null;
let historyPanelEl = null;
let isProcessing = false;
let abortFlag = false;
let activeAiRequest = null;
let prefillMessage = null;
let panelSide = 'right';
// ============================================================
// UI: 浮动按钮
// ============================================================
const FAB_ICON = ``;
function createFloatingButton() {
if (fabEl) {
if (!document.contains(fabEl) && document.body) document.body.appendChild(fabEl);
return;
}
if (!document.body) return;
fabEl = document.createElement('div');
fabEl.id = 'sa-fab';
fabEl.innerHTML = FAB_ICON + '';
document.body.appendChild(fabEl);
const pos = parseStored(GM_getValue(SK.FAB_POS, null), null);
if (pos && pos.side) {
panelSide = pos.side === 'left' ? 'left' : 'right';
if (pos.side === 'left') {
fabEl.style.left = '0px';
fabEl.style.top = (pos.y || 200) + 'px';
fabEl.style.right = 'auto';
fabEl.style.bottom = 'auto';
} else {
fabEl.style.right = '0px';
fabEl.style.top = (pos.y || 200) + 'px';
fabEl.style.left = 'auto';
fabEl.style.bottom = 'auto';
}
} else {
fabEl.style.bottom = '80px';
fabEl.style.right = '24px';
}
const cfg = getAiConfig();
if (cfg.apiKey) fabEl.classList.add('sa-has-key');
makeFabDraggable(fabEl);
}
function makeFabDraggable(fab) {
let isDragging = false, hasMoved = false, startX, startY, origX, origY;
const onDown = (e) => {
isDragging = true; hasMoved = false;
const pt = e.touches ? e.touches[0] : e;
startX = pt.clientX; startY = pt.clientY;
const rect = fab.getBoundingClientRect();
origX = rect.left; origY = rect.top;
e.preventDefault();
e.stopPropagation();
};
const onMove = (e) => {
if (!isDragging) return;
const pt = e.touches ? e.touches[0] : e;
const dx = pt.clientX - startX, dy = pt.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) hasMoved = true;
fab.style.left = (origX + dx) + 'px';
fab.style.top = Math.max(0, Math.min(window.innerHeight - 52, origY + dy)) + 'px';
fab.style.right = 'auto';
fab.style.bottom = 'auto';
};
const onUp = () => {
if (!isDragging) return;
isDragging = false;
if (hasMoved) {
const rect = fab.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const winCenterX = window.innerWidth / 2;
const newY = Math.max(0, Math.min(window.innerHeight - 52, rect.top));
if (centerX < winCenterX) {
fab.style.left = '0px';
fab.style.right = 'auto';
panelSide = 'left';
} else {
fab.style.right = '0px';
fab.style.left = 'auto';
panelSide = 'right';
}
fab.style.top = newY + 'px';
GM_setValue(SK.FAB_POS, JSON.stringify({ side: panelSide, y: newY }));
GM_setValue(SK.PANEL_SIDE, panelSide);
if (panelEl) {
if (panelSide === 'left') panelEl.classList.add('sa-left-side');
else panelEl.classList.remove('sa-left-side');
}
} else {
toggleSidebar();
}
};
fab.addEventListener('mousedown', onDown);
fab.addEventListener('touchstart', onDown, { passive: false });
document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
document.addEventListener('touchend', onUp);
}
// ============================================================
// UI: 侧边栏
// ============================================================
function createSidebar() {
if (panelEl) return;
panelEl = document.createElement('div');
panelEl.id = 'sa-panel';
if (panelSide === 'left') panelEl.classList.add('sa-left-side');
panelEl.innerHTML = `
${getIcon('sparkle', 13)}
正在思考…
`;
document.body.appendChild(panelEl);
messagesEl = panelEl.querySelector('#sa-messages');
inputEl = panelEl.querySelector('#sa-input');
sendBtn = panelEl.querySelector('#sa-send-btn');
modelSelectEl = panelEl.querySelector('#sa-model-select');
historyPanelEl = panelEl.querySelector('#sa-history-panel');
const select = panelEl.querySelector('#sa-agent-select');
function refreshAgentSelect() {
const agents = AgentManager.getAgents();
select.innerHTML = agents.map(a =>
``
).join('');
}
refreshAgentSelect();
select.addEventListener('change', () => {
AgentManager.switchAgent(select.value);
ConversationManager.getCurrentConversation().agentId = select.value;
ConversationManager.save();
renderMessages();
});
panelEl.querySelector('#sa-btn-history').addEventListener('click', () => toggleHistoryPanel());
panelEl.querySelector('#sa-btn-new').addEventListener('click', () => {
ConversationManager.createConversation(AgentManager._currentAgentId);
renderMessages();
toast('已新建会话', 'success');
});
panelEl.querySelector('#sa-btn-export').addEventListener('click', () => {
const conv = ConversationManager.getCurrentConversation();
if (!conv.messages.length) { toast('暂无对话记录', 'error'); return; }
openSettings({ tab: 'data' });
});
panelEl.querySelector('#sa-btn-settings').addEventListener('click', () => openSettings());
panelEl.querySelector('#sa-btn-close').addEventListener('click', () => closeSidebar());
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
});
inputEl.addEventListener('input', () => {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(120, inputEl.scrollHeight) + 'px';
});
sendBtn.addEventListener('click', handleSend);
panelEl.addEventListener('click', (e) => {
const keyBtn = e.target.closest('.sa-key-btn');
if (keyBtn) {
openSettings({ tab: 'api', focus: '#sa-cfg-steamkey' });
return;
}
const itadBtn = e.target.closest('.sa-itadkey-btn');
if (itadBtn) {
openSettings({ tab: 'api', focus: '#sa-cfg-itadkey' });
return;
}
const copyBtn = e.target.closest('.sa-msg-copy');
if (copyBtn) {
const content = msgCopyRegistry[Number(copyBtn.dataset.copyId)];
if (!content) { toast('未找到可复制的消息内容', 'error'); return; }
copyTextToClipboard(content).then(ok => {
if (ok) {
copyBtn.classList.add('sa-copied');
copyBtn.innerHTML = `${getIcon('check', 11)} 已复制`;
toast('回复已复制到剪贴板', 'success');
setTimeout(() => {
copyBtn.classList.remove('sa-copied');
copyBtn.innerHTML = `${getIcon('copy', 11)} 复制`;
}, 1500);
} else {
toast('复制失败,请手动选择复制', 'error');
}
});
}
});
refreshModelSelector();
modelSelectEl.addEventListener('change', () => {
const modelId = modelSelectEl.value;
if (modelId) {
ModelManager.setActiveModel(modelId);
ConversationManager.updateCurrentModel(modelId);
toast(`已切换到 ${ModelManager.getModel(modelId)?.name}`, 'success');
}
});
panelEl._refreshAgentSelect = refreshAgentSelect;
panelEl._refreshModelSelector = refreshModelSelector;
renderMessages();
}
function refreshModelSelector() {
if (!modelSelectEl) return;
const models = ModelManager.getModels();
const activeId = ModelManager.getActiveModelId();
if (models.length === 0) {
modelSelectEl.innerHTML = ``;
modelSelectEl.style.color = '#f87171';
} else {
modelSelectEl.style.color = '';
modelSelectEl.innerHTML = models.map(m => {
const provider = MODEL_PROVIDERS[m.provider];
const providerName = provider?.name || '自定义';
return ``;
}).join('');
}
}
// ============================================================
// UI: 对话历史面板
// ============================================================
function toggleHistoryPanel() {
if (!historyPanelEl) return;
if (historyPanelEl.classList.contains('sa-show')) {
historyPanelEl.classList.remove('sa-show');
} else {
renderHistoryPanel();
historyPanelEl.classList.add('sa-show');
}
}
function renderHistoryPanel() {
if (!historyPanelEl) return;
const conversations = ConversationManager.getAllConversations();
const currentId = ConversationManager.getCurrentId();
historyPanelEl.innerHTML = `
${conversations.length === 0 ? `
暂无对话记录
点击 + 开始新对话
` : conversations.map(conv => {
const agent = AgentManager.getAgent(conv.agentId);
const model = ModelManager.getModel(conv.modelId);
const provider = model ? MODEL_PROVIDERS[model.provider] : null;
const msgCount = conv.messages.filter(m => m.role === 'user' || m.role === 'assistant').length;
return `
${escapeHtml(conv.title)}
${agent ? `${agentIconHtml(agent, 11)} ${escapeHtml(agent.name)}` : ''}
${provider ? `${providerIconHtml(model.provider, 11)} ${escapeHtml(model.name)}` : ''}
${msgCount} 条
${formatRelativeTime(new Date(conv.updatedAt).getTime())}
`;
}).join('')}
`;
historyPanelEl.querySelector('#sa-history-new').addEventListener('click', () => {
ConversationManager.createConversation(AgentManager._currentAgentId);
renderMessages();
renderHistoryPanel();
historyPanelEl.classList.remove('sa-show');
toast('已新建会话', 'success');
});
historyPanelEl.querySelector('#sa-history-close').addEventListener('click', () => {
historyPanelEl.classList.remove('sa-show');
});
historyPanelEl.querySelectorAll('.sa-history-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.sa-history-del')) return;
const id = item.dataset.id;
if (ConversationManager.switchTo(id)) {
renderMessages();
refreshModelSelector();
if (panelEl._refreshAgentSelect) panelEl._refreshAgentSelect();
historyPanelEl.classList.remove('sa-show');
toast('已切换对话', 'success');
}
});
});
historyPanelEl.querySelectorAll('.sa-history-del').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const id = btn.dataset.id;
const conv = ConversationManager.getConversation(id);
if (confirm(`确认删除对话 "${conv?.title || ''}"?`)) {
const wasCurrent = id === ConversationManager.getCurrentId();
ConversationManager.deleteConversation(id);
if (wasCurrent) {
renderMessages();
refreshModelSelector();
}
renderHistoryPanel();
toast('已删除', 'success');
}
});
});
}
// ============================================================
// UI: 侧边栏控制
// ============================================================
function openSidebar(options = {}) {
if (!panelEl) createSidebar();
panelEl.classList.add('sa-open');
if (options.agentId) {
AgentManager.switchAgent(options.agentId);
if (panelEl._refreshAgentSelect) panelEl._refreshAgentSelect();
}
renderMessages();
if (options.message) {
prefillMessage = options.message;
inputEl.value = options.message;
inputEl.focus();
} else {
setTimeout(() => inputEl?.focus(), 100);
}
EventBus.emit('open', { agentId: options.agentId, message: options.message });
}
function closeSidebar() {
if (panelEl) panelEl.classList.remove('sa-open');
if (historyPanelEl) historyPanelEl.classList.remove('sa-show');
EventBus.emit('close', {});
}
function toggleSidebar() {
if (panelEl?.classList.contains('sa-open')) closeSidebar();
else openSidebar();
}
// ============================================================
// UI: 欢迎屏幕数据
// ============================================================
const SUGGESTED_QUESTIONS = [
'帮我分析一下 Steam 上最近有什么值得入手的打折游戏',
'如何评价《赛博朋克2077》现在的状态,值得买吗?',
'我预算200元,推荐几款性价比高的 Steam 游戏',
'Steam 好评如潮的游戏有哪些?帮我筛选一下',
'帮我查一下 AppID 570 (Dota 2) 的游戏信息',
'Steam 打折什么时候力度最大?特卖时间表是什么?',
'怎么判断一个游戏是不是史低价?',
'推荐一些适合和朋友联机的 Steam 游戏',
'Steam 退款政策是什么?怎么申请退款?',
'帮我分析一下我的 Steam 游戏库,哪些游戏吃灰了',
'跨区送礼有什么注意事项?哪个区最划算?',
'Steam 上的独立游戏有什么值得推荐的?',
];
function getGreeting() {
const hour = new Date().getHours();
if (hour < 6) return '夜深了';
if (hour < 9) return '早上好';
if (hour < 12) return '上午好';
if (hour < 14) return '中午好';
if (hour < 18) return '下午好';
if (hour < 22) return '晚上好';
return '夜深了';
}
function getChatStats() {
const conversations = ConversationManager.getAllConversations();
let totalMessages = 0;
for (const conv of conversations) {
totalMessages += conv.messages.filter(m => m.role === 'user' || m.role === 'assistant').length;
}
const tokenStats = TokenStatsManager.getStats();
return {
totalConversations: conversations.length,
totalMessages,
tokenStats,
};
}
function getSuggestedQuestions(count = 5) {
const shuffled = [...SUGGESTED_QUESTIONS].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
}
function renderWelcomeScreen() {
const agent = AgentManager.getCurrentAgent();
const stats = getChatStats();
const questions = getSuggestedQuestions(5);
const ts = stats.tokenStats;
const tokenTitle = `Token 消耗统计\n总计: ${ts.totalTokens}\n输入: ${ts.totalPromptTokens} / 输出: ${ts.totalCompletionTokens}\nAI 请求数: ${ts.requestCount}\n注: 优先采用 API 返回的精确 usage,无 usage 时按字符估算`;
messagesEl.innerHTML = `
${getGreeting()}${getIcon('sparkle', 18)}
我是 ${escapeHtml(agent?.name || 'AI 助手')},${escapeHtml(agent?.description || '有什么可以帮你的吗?')}
${getIcon('chat', 20)}
${stats.totalConversations}
累计会话
${getIcon('messageSquare', 20)}
${stats.totalMessages}
消息总数
${getIcon('zap', 20)}
${escapeHtml(formatTokenCount(stats.tokenStats.totalTokens))}
Token消耗
${agentIconHtml(agent, 20)}
${getIcon('sparkle', 12)} 当前智能体
${escapeHtml(agent?.name || 'AI 助手')}
${escapeHtml(agent?.description || '')}
${questions.map(q => `
${escapeHtml(q)}
`).join('')}
`;
const refreshBtn = messagesEl.querySelector('#sa-sug-refresh');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
refreshBtn.classList.add('sa-spin');
setTimeout(() => refreshBtn.classList.remove('sa-spin'), 600);
const newQuestions = getSuggestedQuestions(5);
const listEl = messagesEl.querySelector('#sa-sug-list');
if (listEl) {
listEl.innerHTML = newQuestions.map(q => `
${escapeHtml(q)}
`).join('');
bindSuggestionClicks();
}
});
}
function bindSuggestionClicks() {
messagesEl.querySelectorAll('.sa-suggestion-item').forEach(item => {
item.addEventListener('click', () => {
const q = item.dataset.question;
if (q && inputEl) {
if (isProcessing) { toast('AI 正在回复中,请等待完成或点击停止按钮', 'error'); return; }
inputEl.value = q;
inputEl.focus();
handleSend();
}
});
});
}
bindSuggestionClicks();
}
// ============================================================
// UI: 消息渲染
// ============================================================
// 复制源注册表:渲染/追加 AI 消息时登记 Markdown 原文,按钮通过 data-copy-id 取回
let msgCopyRegistry = [];
function buildMsgFooterHtml(content) {
if (!content) return '';
const id = msgCopyRegistry.push(content) - 1;
return ``;
}
function appendMsgFooterEl(msgEl, content) {
if (!msgEl || !content) return;
const wrap = document.createElement('div');
wrap.innerHTML = buildMsgFooterHtml(content).trim();
const footer = wrap.firstElementChild;
if (footer) msgEl.appendChild(footer);
}
async function copyTextToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const tmp = document.createElement('textarea');
tmp.value = text;
tmp.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';
document.body.appendChild(tmp);
tmp.select();
const ok = document.execCommand('copy');
tmp.remove();
return ok;
} catch { return false; }
}
}
function renderMessages() {
if (!messagesEl) return;
const conv = ConversationManager.getCurrentConversation();
if (!conv.messages.length) {
renderWelcomeScreen();
return;
}
msgCopyRegistry = [];
messagesEl.innerHTML = conv.messages.map(m => {
if (m.role === 'user') {
return `${escapeHtml(m.content)}
`;
} else if (m.role === 'assistant') {
const clean = cleanToolCallMarkers(m.content);
const toolsHtml = buildToolCallsHtml(m.toolCalls);
const bodyHtml = clean ? renderMarkdown(clean) : '';
return `${toolsHtml}${bodyHtml}${buildMsgFooterHtml(clean)}
`;
} else if (m.role === 'system') {
return '';
}
return '';
}).join('');
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function appendMessageEl(role, content) {
if (!messagesEl) return;
const welcome = messagesEl.querySelector('.sa-welcome');
if (welcome) welcome.remove();
const empty = messagesEl.querySelector('.sa-empty');
if (empty) empty.remove();
const msgEl = document.createElement('div');
if (role === 'user') {
msgEl.className = 'sa-msg sa-msg-user';
msgEl.textContent = content;
} else {
msgEl.className = 'sa-msg sa-msg-ai';
msgEl.innerHTML = renderMarkdown(cleanToolCallMarkers(content));
}
messagesEl.appendChild(msgEl);
messagesEl.scrollTop = messagesEl.scrollHeight;
return msgEl;
}
function setSendState(state) {
if (!sendBtn) return;
sendBtn.classList.toggle('sa-stop', state === 'generating');
sendBtn.classList.toggle('sa-stopping', state === 'stopping');
sendBtn.disabled = state === 'stopping';
if (state === 'idle') {
sendBtn.title = '发送';
sendBtn.setAttribute('aria-label', '发送消息');
sendBtn.innerHTML = getIcon('send', 18);
} else if (state === 'stopping') {
sendBtn.title = '停止中…';
sendBtn.setAttribute('aria-label', '正在停止生成');
sendBtn.innerHTML = getIcon('stop', 18);
} else {
sendBtn.title = '停止生成';
sendBtn.setAttribute('aria-label', '停止生成');
sendBtn.innerHTML = getIcon('stop', 18);
}
if (inputEl) {
inputEl.placeholder = state === 'idle'
? '输入消息… (Enter 发送, Shift+Enter 换行)'
: 'AI 正在回复中… 点击红色按钮可停止';
}
}
async function handleSend() {
if (isProcessing) {
if (abortFlag) return;
abortFlag = true;
setSendState('stopping');
try { activeAiRequest && activeAiRequest.abort(); } catch {}
const st = panelEl?.querySelector('.sa-tool-status');
if (st) {
st.querySelector('.sa-tool-text').textContent = '正在停止…';
st.classList.add('sa-active');
}
return;
}
const text = inputEl.value.trim();
if (!text) return;
const cfg = getAiConfig();
if (!cfg.apiKey) {
toast('请先配置模型和 API Key', 'error');
openSettings({ tab: 'ai' });
return;
}
ConversationManager.addMessage('user', text);
appendMessageEl('user', text);
inputEl.value = '';
inputEl.style.height = 'auto';
isProcessing = true;
abortFlag = false;
activeAiRequest = null;
setSendState('generating');
inputEl.disabled = true;
const aiMsgEl = appendMessageEl('assistant', '');
let displayedText = '';
let liveToolCalls = [];
const updateAiBubble = (runningIndex = -1) => {
const cleanText = cleanToolCallMarkers(displayedText);
const toolsHtml = buildToolCallsHtml(liveToolCalls, runningIndex);
const bodyHtml = cleanText ? renderMarkdown(cleanText) : '';
aiMsgEl.innerHTML = toolsHtml + bodyHtml;
messagesEl.scrollTop = messagesEl.scrollHeight;
};
const toolStatusEl = panelEl?.querySelector('.sa-tool-status');
if (toolStatusEl) {
toolStatusEl.querySelector('.sa-tool-text').textContent = '正在思考…';
toolStatusEl.classList.add('sa-active');
}
try {
const messages = ConversationManager.getCurrentMessages();
const history = messages.slice(0, -1);
const result = await runAgentLoop(text, history, AgentManager._currentAgentId, (partial) => {
if (abortFlag) return;
if (partial) {
displayedText = partial;
updateAiBubble();
}
}, (name, args, phase) => {
if (abortFlag) return;
if (phase === 'start') {
liveToolCalls.push({ name, arguments: args });
updateAiBubble(liveToolCalls.length - 1);
} else {
updateAiBubble();
}
});
if (toolStatusEl) toolStatusEl.classList.remove('sa-active');
liveToolCalls = result.toolCalls || liveToolCalls;
if (result.aborted) {
const partial = result.response || cleanToolCallMarkers(displayedText);
aiMsgEl.innerHTML = buildToolCallsHtml(liveToolCalls) + (partial ? renderMarkdown(partial) : '已停止生成');
appendMsgFooterEl(aiMsgEl, partial);
messagesEl.scrollTop = messagesEl.scrollHeight;
if (partial) ConversationManager.addMessage('assistant', partial, { toolCalls: liveToolCalls });
} else {
displayedText = result.response;
aiMsgEl.innerHTML = buildToolCallsHtml(liveToolCalls) + renderMarkdown(result.response);
appendMsgFooterEl(aiMsgEl, result.response);
messagesEl.scrollTop = messagesEl.scrollHeight;
ConversationManager.addMessage('assistant', result.response, {
toolCalls: result.toolCalls || [],
});
}
} catch (e) {
if (toolStatusEl) toolStatusEl.classList.remove('sa-active');
aiMsgEl.innerHTML = `错误: ${escapeHtml(e.message)}`;
ConversationManager.addMessage('assistant', `错误: ${e.message}`);
} finally {
isProcessing = false;
abortFlag = false;
setSendState('idle');
inputEl.disabled = false;
inputEl.focus();
}
}
// ============================================================
// UI: 综合设置中心(单浮窗多标签,消除二级/三级弹窗)
// ============================================================
const SETTINGS_TABS = [
{ key: 'global', label: '全局设置', icon: 'gear' },
{ key: 'ai', label: 'AI 设置', icon: 'cpu' },
{ key: 'agents', label: '智能体', icon: 'bot' },
{ key: 'api', label: '接口服务', icon: 'globe' },
{ key: 'data', label: '数据管理', icon: 'download' },
];
function renderCheckItem(state, title, detail) {
const cls = state === 'ok' ? 'sa-ok' : state === 'err' ? 'sa-err' : 'sa-warn';
const mark = state === 'ok' ? `${getIcon('check', 11)} 正常` : state === 'err' ? `${getIcon('close', 11)} 失败` : `${getIcon('zap', 11)} 提示`;
return `${mark}${escapeHtml(title)}${detail ? '
' + escapeHtml(detail) : ''}
`;
}
function prependCheckResult(container, html, cap = 8) {
if (!container) return;
container.insertAdjacentHTML('afterbegin', html);
while (container.children.length > cap) container.lastElementChild.remove();
}
function openSettings(options = {}) {
const initialTab = SETTINGS_TABS.some(t => t.key === options.tab) ? options.tab : 'global';
// 单例:已打开则仅切换标签/聚焦,不重复创建
const existingModal = document.querySelector('.sa-overlay .sa-settings-modal');
if (existingModal) {
switchSettingsTab(existingModal, initialTab);
applySettingsFocus(existingModal, options.focus);
return;
}
const steamKey = getSteamApiKey();
const itadKey = getItadApiKey();
const settings = parseStored(GM_getValue(SK.SETTINGS, null), {});
const overlay = document.createElement('div');
overlay.className = 'sa-overlay sa-show';
overlay.innerHTML = `
${SETTINGS_TABS.map(t => `
`).join('')}
`;
document.body.appendChild(overlay);
const closeSettings = () => {
document.removeEventListener('keydown', onEsc);
overlay.remove();
};
const onEsc = (e) => { if (e.key === 'Escape') closeSettings(); };
document.addEventListener('keydown', onEsc);
overlay.querySelector('#sa-settings-close').addEventListener('click', closeSettings);
overlay.querySelector('#sa-settings-cancel').addEventListener('click', closeSettings);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSettings(); });
function switchSettingsTab(modalRoot, key) {
modalRoot.querySelectorAll('.sa-set-nav-btn').forEach(b => b.classList.toggle('sa-active', b.dataset.tab === key));
modalRoot.querySelectorAll('.sa-set-pane').forEach(p => p.classList.toggle('sa-active', p.dataset.pane === key));
}
function applySettingsFocus(modalRoot, selector) {
if (!selector) return;
const inp = modalRoot.querySelector(selector);
if (inp) { inp.focus(); inp.scrollIntoView({ block: 'center' }); }
}
function renderNavSummary() {
const el = overlay.querySelector('#sa-set-nav-summary');
if (!el) return;
const models = ModelManager.getModels();
const agents = AgentManager.getAgents();
const ts = TokenStatsManager.getStats();
el.innerHTML = `模型 ${models.length} 个 · 智能体 ${agents.length} 个
累计 Token ${formatTokenCount(ts.totalTokens)}`;
}
overlay.querySelectorAll('.sa-set-nav-btn').forEach(btn => {
btn.addEventListener('click', () => switchSettingsTab(overlay, btn.dataset.tab));
});
// ---------- Tab: 全局设置 ----------
function buildGlobalPane() {
const pane = overlay.querySelector('#sa-pane-global');
pane.innerHTML = `
${getIcon('cpu', 15)} 生成参数
${getIcon('book', 15)} 规则知识库
`;
const ORIGIN_LABEL = {
remote: { text: '远程最新', color: '#4ade80' },
snapshot: { text: '内置快照', color: '#fbbf24' },
};
function renderRulesStatus() {
const el = pane.querySelector('#sa-rules-status');
if (!el) return;
const s = RulesKnowledgeProvider.getStatus();
const o = ORIGIN_LABEL[s.origin] || ORIGIN_LABEL.snapshot;
el.innerHTML = `
v${escapeHtml(s.version)}
${o.text}
${s.sectionCount} 章节 · ${s.faqCount} FAQ
更新于 ${escapeHtml(s.updatedAt)}
来源: ${escapeHtml(s.source)}
`;
}
renderRulesStatus();
pane.querySelector('#sa-rules-refresh').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const urlInput = pane.querySelector('#sa-rules-url');
RulesKnowledgeProvider.setRemoteUrl(urlInput.value.trim());
btn.disabled = true;
btn.style.opacity = '0.6';
try {
const s = await RulesKnowledgeProvider.refreshFromRemote();
toast(`规则知识库已更新到 v${s.version}`, 'success');
} catch (err) {
toast(`规则库更新失败: ${err.message}(继续使用本地知识)`, 'error');
} finally {
btn.disabled = false;
btn.style.opacity = '';
renderRulesStatus();
}
});
pane.querySelector('#sa-rules-reset').addEventListener('click', () => {
GM_setValue(SK.RULES_REMOTE, '');
RulesKnowledgeProvider._active = null;
renderRulesStatus();
toast('已清除远程缓存,恢复本地知识', 'success');
});
}
// ---------- Tab: AI 设置 ----------
function buildAiPane() {
const pane = overlay.querySelector('#sa-pane-ai');
pane.innerHTML = `
`;
const listView = pane.querySelector('#sa-model-list-view');
const editorView = pane.querySelector('#sa-model-editor-view');
function renderModelList() {
const listEl = pane.querySelector('#sa-model-list');
const models = ModelManager.getModels();
const activeId = ModelManager.getActiveModelId();
if (models.length === 0) {
listEl.innerHTML = `尚未配置模型,点击下方按钮添加
`;
return;
}
listEl.innerHTML = models.map(m => {
const provider = MODEL_PROVIDERS[m.provider];
const providerName = provider?.name || '自定义';
return `
${providerIconHtml(m.provider, 18)}
${escapeHtml(m.name)} ${m.id === activeId ? '当前' : ''}
${escapeHtml(providerName)} · ${escapeHtml(m.model)} · ${m.apiKey ? '' + getIcon('check', 10) + ' 已配置' : '' + getIcon('close', 10) + ' 未配置'}
${m.id !== activeId ? `` : ''}
`;
}).join('');
listEl.querySelectorAll('.sa-model-card').forEach(card => {
const id = card.dataset.id;
card.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const action = btn.dataset.action;
if (action === 'use') {
ModelManager.setActiveModel(id);
renderModelList();
renderNavSummary();
toast(`已切换到 ${ModelManager.getModel(id)?.name}`, 'success');
} else if (action === 'edit') {
renderModelEditor(id);
} else if (action === 'del') {
if (confirm(`确认删除模型 "${ModelManager.getModel(id)?.name}"?`)) {
ModelManager.deleteModel(id);
renderModelList();
renderNavSummary();
toast('已删除', 'success');
}
} else if (action === 'check') {
const model = ModelManager.getModel(id);
if (!model) return;
btn.disabled = true;
const resultHtml = await (async () => {
try {
const r = await testModelConnection(model);
return renderCheckItem('ok', `模型「${model.name}」连接正常`, `延迟 ${r.latencyMs}ms · 服务端返回模型 ${r.modelEcho}`);
} catch (e) {
return renderCheckItem('err', `模型「${model.name}」连接失败`, describeModelError(e));
}
})();
prependCheckResult(pane.querySelector('#sa-model-check-results'), resultHtml);
btn.disabled = false;
}
});
});
});
}
function showListView() {
listView.style.display = '';
editorView.style.display = 'none';
editorView.innerHTML = '';
renderModelList();
}
// 模型编辑:标签内嵌视图(不再弹出二级窗口)
function renderModelEditor(existingId) {
const model = existingId ? ModelManager.getModel(existingId) : null;
editorView.innerHTML = `
${model ? '编辑模型(保存后立即生效)' : '添加模型(保存后立即生效)'}
`;
listView.style.display = 'none';
editorView.style.display = '';
let selectedProvider = model?.provider || 'deepseek';
function collectFormModel() {
return {
name: editorView.querySelector('#sa-me-name').value.trim(),
provider: selectedProvider,
apiUrl: editorView.querySelector('#sa-me-url').value.trim(),
apiKey: editorView.querySelector('#sa-me-key').value.trim(),
model: editorView.querySelector('#sa-me-model').value.trim(),
};
}
function applyProvider(providerKey) {
selectedProvider = providerKey;
const p = MODEL_PROVIDERS[providerKey];
const providerSelect = editorView.querySelector('#sa-me-provider');
if (providerSelect) providerSelect.value = providerKey;
const urlInput = editorView.querySelector('#sa-me-url');
if (!model || model.provider !== providerKey) {
urlInput.value = p.apiUrl;
}
const urlHint = editorView.querySelector('#sa-me-url-hint');
urlHint.textContent = p.apiUrl ? p.keyHint : `${p.keyHint};自定义地址首次请求时需在浏览器扩展弹窗中允许跨域,或在脚本头部 @connect 中声明目标域名`;
const keyHint = editorView.querySelector('#sa-me-key-hint');
if (p.apiKeyUrl) {
keyHint.innerHTML = `在 ${p.apiKeyUrl} 获取 API Key`;
} else {
keyHint.textContent = '';
}
const modelWrap = editorView.querySelector('#sa-me-model-input-wrap');
const modelHint = editorView.querySelector('#sa-me-model-hint');
if (p.models.length > 0) {
const currentModel = (model && model.provider === providerKey) ? model.model : p.defaultModel;
modelWrap.innerHTML = `
`;
modelHint.textContent = `可下拉选择或手动输入,共 ${p.models.length} 个预设模型`;
} else {
modelWrap.innerHTML = ``;
modelHint.textContent = '请手动输入模型名称';
}
const nameInput = editorView.querySelector('#sa-me-name');
if (!nameInput.value.trim()) {
nameInput.value = `${p.name} ${p.defaultModel || '自定义'}`;
}
}
editorView.querySelector('#sa-me-provider').addEventListener('change', (e) => {
applyProvider(e.target.value);
});
applyProvider(selectedProvider);
editorView.querySelector('#sa-me-back').addEventListener('click', showListView);
editorView.querySelector('#sa-me-test').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const statusEl = editorView.querySelector('#sa-me-check-status');
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = `${getIcon('refresh', 14)} 检测中…`;
statusEl.style.color = '#94a3b8';
statusEl.textContent = '正在发送最小测试请求 (max_tokens=1),请稍候…';
try {
const form = collectFormModel();
const r = await testModelConnection(form);
statusEl.style.color = '#4ade80';
statusEl.textContent = `连接正常:延迟 ${r.latencyMs}ms,服务端返回模型 ${r.modelEcho}`;
prependCheckResult(pane.querySelector('#sa-model-check-results'),
renderCheckItem('ok', `模型「${form.name || form.model}」连接正常`, `编辑表单实测 · 延迟 ${r.latencyMs}ms · 服务端模型 ${r.modelEcho}`));
} catch (err) {
statusEl.style.color = '#f87171';
statusEl.textContent = `连接失败:${describeModelError(err)}`;
} finally {
btn.disabled = false;
btn.innerHTML = originalHtml;
}
});
editorView.querySelector('#sa-me-save').addEventListener('click', () => {
const form = collectFormModel();
if (!form.apiUrl) { toast('请输入 API 地址', 'error'); return; }
if (!form.apiKey) { toast('请输入 API Key', 'error'); return; }
if (!form.model) { toast('请选择或输入模型名称', 'error'); return; }
const data = {
...form,
name: form.name || `${MODEL_PROVIDERS[selectedProvider]?.name || '自定义'} ${form.model}`,
};
if (existingId) {
ModelManager.updateModel(existingId, data);
toast('模型已更新', 'success');
} else {
const created = ModelManager.createModel(data);
if (!ModelManager.getActiveModelId()) {
ModelManager.setActiveModel(created.id);
}
toast(`已添加模型 ${created.name}`, 'success');
}
refreshModelSelector();
renderNavSummary();
showListView();
});
}
pane.querySelector('#sa-cfg-add-model').addEventListener('click', () => renderModelEditor(null));
pane.querySelector('#sa-btn-check-all-models').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const models = ModelManager.getModels();
const container = pane.querySelector('#sa-model-check-results');
if (!models.length) {
prependCheckResult(container, renderCheckItem('warn', '尚未配置模型', '点击「添加模型」先完成配置,再执行检测'));
return;
}
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = `${getIcon('refresh', 13)} 检测中 (0/${models.length})…`;
let okCount = 0;
for (let i = 0; i < models.length; i++) {
const m = models[i];
let item;
try {
const r = await testModelConnection(m);
okCount++;
item = renderCheckItem('ok', `模型「${m.name}」连接正常`, `延迟 ${r.latencyMs}ms · 服务端返回模型 ${r.modelEcho}`);
} catch (err) {
item = renderCheckItem('err', `模型「${m.name}」连接失败`, describeModelError(err));
}
prependCheckResult(container, item);
btn.innerHTML = `${getIcon('refresh', 13)} 检测中 (${i + 1}/${models.length})…`;
}
toast(`模型检测完成:${okCount}/${models.length} 个可用`, okCount === models.length ? 'success' : 'error');
btn.disabled = false;
btn.innerHTML = originalHtml;
});
renderModelList();
}
// ---------- Tab: 智能体设置 ----------
function buildAgentsPane() {
const pane = overlay.querySelector('#sa-pane-agents');
pane.innerHTML = `
`;
const listView = pane.querySelector('#sa-agent-list-view');
const editorView = pane.querySelector('#sa-agent-editor-view');
function renderAgentList() {
const listEl = pane.querySelector('#sa-am-list');
const agents = AgentManager.getAgents();
listEl.innerHTML = agents.map(a => `
${agentIconHtml(a, 18)}
${escapeHtml(a.name)} ${a.builtin ? '内置' : ''}
${escapeHtml(a.description)}
${!a.builtin ? `
` : ''}
`).join('');
listEl.querySelectorAll('.sa-agent-card').forEach(card => {
const id = card.dataset.id;
card.querySelectorAll('.sa-mini-btn').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.action;
if (action === 'use') {
AgentManager.switchAgent(id);
if (panelEl?._refreshAgentSelect) panelEl._refreshAgentSelect();
renderMessages();
toast(`已切换到 ${AgentManager.getAgent(id)?.name}`, 'success');
closeSettings();
} else if (action === 'edit') {
renderAgentEditor(id);
} else if (action === 'del') {
if (confirm(`确认删除智能体 "${AgentManager.getAgent(id)?.name}"?`)) {
AgentManager.deleteAgent(id);
toast('已删除', 'success');
renderAgentList();
renderNavSummary();
if (panelEl?._refreshAgentSelect) panelEl._refreshAgentSelect();
renderMessages();
}
}
});
});
});
}
function showListView() {
listView.style.display = '';
editorView.style.display = 'none';
editorView.innerHTML = '';
renderAgentList();
}
// 智能体编辑:标签内嵌视图(不再弹出三级窗口)
function renderAgentEditor(existingId) {
const agent = existingId ? AgentManager.getAgent(existingId) : null;
editorView.innerHTML = `
${agent ? '编辑智能体(保存后立即生效)' : '新建智能体(保存后立即生效)'}
`;
listView.style.display = 'none';
editorView.style.display = '';
editorView.querySelector('#sa-ae-back').addEventListener('click', showListView);
editorView.querySelector('#sa-ae-cancel').addEventListener('click', showListView);
editorView.querySelector('#sa-ae-copy').addEventListener('click', async () => {
const text = editorView.querySelector('#sa-ae-prompt').value.trim();
if (!text) { toast('提示词为空,暂无可复制内容', 'error'); return; }
const copyBtn = editorView.querySelector('#sa-ae-copy');
const ok = await copyTextToClipboard(text);
if (ok) {
copyBtn.innerHTML = `${getIcon('check', 13)} 已复制`;
setTimeout(() => { copyBtn.innerHTML = `${getIcon('copy', 13)} 复制`; }, 1500);
toast('系统提示词已复制到剪贴板', 'success');
} else {
toast('复制失败,请手动全选复制', 'error');
}
});
editorView.querySelector('#sa-ae-gen').addEventListener('click', async () => {
const desc = editorView.querySelector('#sa-ae-need').value.trim();
if (!desc) { toast('请输入需求描述', 'error'); return; }
const btn = editorView.querySelector('#sa-ae-gen');
const promptTa = editorView.querySelector('#sa-ae-prompt');
const cfg = getAiConfig();
if (!cfg.apiKey) { toast('请先配置模型和 API Key', 'error'); switchSettingsTab(overlay, 'ai'); return; }
btn.disabled = true;
btn.innerHTML = `${getIcon('refresh', 14)} 生成中…`;
promptTa.value = '';
promptTa.placeholder = 'AI 正在生成提示词,请稍候…';
try {
const prompt = `请根据以下需求,为一个 Steam AI 智能体编写 systemPrompt(系统提示词)。
用户需求:${desc}
要求:
1. 明确智能体的角色定位和核心能力
2. 列出回答规范和输出格式要求
3. 包含相关领域知识约束
4. 使用中文,专业且完整;内容要详尽,可分章节展开(角色定位/核心能力/回答规范/输出格式/知识约束/注意事项等),不要省略
5. 必须输出完整内容直到自然结束,不要中途截断
请直接输出 systemPrompt 内容,不要包含其他说明。`;
// 流式生成 + 实时预览到提示词输入框;isolated 隔离聊天中止状态;不限 max_tokens、5 分钟超时
const result = await callAiApiWithTyping(prompt, {
temperature: 0.7,
timeout: 300000,
isolated: true,
}, (partial) => {
promptTa.value = cleanToolCallMarkers(partial || '');
promptTa.scrollTop = promptTa.scrollHeight;
});
promptTa.value = cleanToolCallMarkers(result).trim();
toast('提示词已生成', 'success');
} catch (e) {
const msg = e.message === '__SA_ABORTED__' ? '请求已中断' : e.message;
toast('生成失败: ' + msg, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = `${getIcon('sparkle', 14)} AI 生成提示词`;
promptTa.placeholder = '定义智能体的角色、能力、回答规范...';
}
});
editorView.querySelector('#sa-ae-save').addEventListener('click', () => {
const name = editorView.querySelector('#sa-ae-name').value.trim();
const prompt = editorView.querySelector('#sa-ae-prompt').value.trim();
if (!name) { toast('请输入名称', 'error'); return; }
if (!prompt) { toast('请输入系统提示词', 'error'); return; }
const data = {
name,
description: editorView.querySelector('#sa-ae-desc').value.trim(),
icon: editorView.querySelector('#sa-ae-icon').value.trim() || 'bot',
keywords: editorView.querySelector('#sa-ae-keywords').value.split(',').map(s => s.trim()).filter(Boolean),
systemPrompt: prompt,
};
if (existingId) {
AgentManager.updateAgent(existingId, data);
toast('已更新', 'success');
} else {
const created = AgentManager.createAgent(data);
toast(`已创建 ${created.name}`, 'success');
}
if (panelEl?._refreshAgentSelect) panelEl._refreshAgentSelect();
renderNavSummary();
showListView();
});
}
pane.querySelector('#sa-am-new').addEventListener('click', () => renderAgentEditor(null));
pane.querySelector('#sa-am-export').addEventListener('click', () => {
const json = AgentManager.exportAgents();
downloadFile(json, `steam-agents-${Date.now()}.json`);
toast('已导出智能体配置', 'success');
});
pane.querySelector('#sa-am-import').addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file'; input.accept = '.json';
input.onchange = () => {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const count = AgentManager.importAgents(reader.result);
toast(`成功导入 ${count} 个智能体`, 'success');
renderAgentList();
renderNavSummary();
} catch (e) { toast(e.message, 'error'); }
};
reader.readAsText(file);
};
input.click();
});
renderAgentList();
}
// ---------- Tab: 接口服务 ----------
function buildApiPane() {
const pane = overlay.querySelector('#sa-pane-api');
const steamIdCfg = getConfiguredSteamId();
const detectedId = detectSteamId();
const idStatusText = steamIdCfg
? `已配置:玩家数据查询将使用 ${escapeHtml(steamIdCfg)}`
: (detectedId
? `未配置:将自动使用当前登录用户(${escapeHtml(detectedId)})`
: '未配置:未检测到登录用户,玩家数据查询需在对话中提供 SteamID');
pane.innerHTML = `
${getIcon('key', 15)} Steam Web API Key
${getIcon('user', 15)} SteamID(玩家数据查询身份,可选)
${getIcon('globe', 15)} ITAD API Key(跨区比价增强,可选)
`;
const resultsEl = pane.querySelector('#sa-api-check-results');
async function runSteamCheck(key, { silent = false } = {}) {
const statusEl = pane.querySelector('#sa-steam-status');
const r = await testSteamApiKey(key);
if (r.success) {
statusEl.style.color = '#4ade80';
statusEl.textContent = `Key 有效${r.samplePlayer ? `(示例查询玩家:${r.samplePlayer})` : ''}`;
prependCheckResult(resultsEl, renderCheckItem('ok', 'Steam Web API Key 有效', r.samplePlayer ? `示例查询返回玩家「${r.samplePlayer}」,接口调用正常` : (r.note || '接口调用正常')));
} else {
statusEl.style.color = '#f87171';
statusEl.textContent = `检测失败:${r.error}`;
prependCheckResult(resultsEl, renderCheckItem('err', 'Steam Web API Key 检测失败', r.error));
}
if (!silent) toast(r.success ? 'Steam API Key 检测通过' : `Steam API Key 检测失败:${r.error}`, r.success ? 'success' : 'error');
return r;
}
pane.querySelector('#sa-steamkey-test').addEventListener('click', async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = `${getIcon('refresh', 13)} 检测中…`;
try { await runSteamCheck(pane.querySelector('#sa-cfg-steamkey').value.trim()); }
finally { btn.disabled = false; btn.innerHTML = originalHtml; }
});
pane.querySelector('#sa-steamid-detect').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const input = pane.querySelector('#sa-cfg-steamid');
const statusEl = pane.querySelector('#sa-steamid-status');
const val = input.value.trim();
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = `${getIcon('refresh', 13)} 检测中…`;
try {
if (!val) {
const detected = detectSteamId();
if (detected) {
statusEl.style.color = '#4ade80';
statusEl.textContent = `自动检测成功:当前登录用户 ${detected},留空保存即可自动使用`;
prependCheckResult(resultsEl, renderCheckItem('ok', 'SteamID 自动检测成功', `当前登录用户:${detected},「我的游戏库 / 资料 / 愿望单」查询将自动使用该身份`));
} else {
statusEl.style.color = '#fbbf24';
statusEl.textContent = '未检测到登录用户:请手动填写 SteamID64(17位数字)或自定义 ID';
prependCheckResult(resultsEl, renderCheckItem('warn', 'SteamID 自动检测失败', '当前页面未检测到登录用户,请手动填写 SteamID64 或自定义 ID 后保存'));
}
} else if (/^\d{17}$/.test(val)) {
statusEl.style.color = '#4ade80';
statusEl.textContent = `格式正确:SteamID64(${val})`;
prependCheckResult(resultsEl, renderCheckItem('ok', 'SteamID 格式正确', `${val} 是标准的 17 位 SteamID64,保存后玩家数据查询将使用该身份`));
} else {
const apiKey = pane.querySelector('#sa-cfg-steamkey').value.trim() || getSteamApiKey();
if (!apiKey) {
statusEl.style.color = '#fbbf24';
statusEl.textContent = '自定义 ID 校验需 Steam Web API Key:可先保存,工具查询时会自动解析';
} else {
const id64 = await resolveVanityToId64(val, apiKey).catch(() => null);
if (id64) {
statusEl.style.color = '#4ade80';
statusEl.textContent = `自定义 ID 有效:已解析为 SteamID64 ${id64}`;
prependCheckResult(resultsEl, renderCheckItem('ok', 'SteamID 校验通过', `自定义 ID「${escapeHtml(val)}」解析为 ${id64}`));
} else {
statusEl.style.color = '#f87171';
statusEl.textContent = '自定义 ID 未解析到 SteamID64(可能不存在),可保存后在对话中验证';
}
}
}
} finally {
btn.disabled = false;
btn.innerHTML = originalHtml;
}
});
pane.querySelector('#sa-cfg-itadtest').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const keyInput = pane.querySelector('#sa-cfg-itadkey');
const statusEl = pane.querySelector('#sa-cfg-itad-status');
const key = keyInput.value.trim();
if (!key) {
statusEl.style.color = '#fbbf24';
statusEl.textContent = '未填写 Key:跨区比价将使用降级数据源,价格准确度降低';
return;
}
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = `${getIcon('refresh', 13)} 测试中…`;
try {
const result = await itadTestConnection(key);
if (result.success) {
statusEl.style.color = '#4ade80';
statusEl.textContent = '连接成功:ITAD Key 有效,跨区比价已启用 16 地区增强模式';
prependCheckResult(resultsEl, renderCheckItem('ok', 'ITAD API Key 有效', '跨区比价将使用 ITAD 16 地区增强模式'));
toast('ITAD 连接成功', 'success');
} else {
statusEl.style.color = '#f87171';
statusEl.textContent = `连接失败:${result.error}`;
prependCheckResult(resultsEl, renderCheckItem('err', 'ITAD API Key 检测失败', result.error));
toast(`ITAD 连接失败:${result.error}`, 'error');
}
} finally {
btn.disabled = false;
btn.innerHTML = originalHtml;
}
});
pane.querySelector('#sa-btn-check-all-api').addEventListener('click', async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = `${getIcon('refresh', 13)} 检测中…`;
const activeModel = ModelManager.getActiveModel();
if (activeModel) {
try {
const r = await testModelConnection(activeModel);
prependCheckResult(resultsEl, renderCheckItem('ok', `当前模型「${activeModel.name}」连接正常`, `延迟 ${r.latencyMs}ms · 服务端返回模型 ${r.modelEcho}`));
} catch (err) {
prependCheckResult(resultsEl, renderCheckItem('err', `当前模型「${activeModel.name}」连接失败`, describeModelError(err)));
}
} else {
prependCheckResult(resultsEl, renderCheckItem('warn', '未配置 AI 模型', '前往「AI 设置」标签添加模型'));
}
const steamInput = pane.querySelector('#sa-cfg-steamkey').value.trim();
if (steamInput) await runSteamCheck(steamInput, { silent: true });
else prependCheckResult(resultsEl, renderCheckItem('warn', 'Steam Web API Key 未配置', '可选配置;未填写将跳过检测'));
const itadInput = pane.querySelector('#sa-cfg-itadkey').value.trim();
if (itadInput) {
const r = await itadTestConnection(itadInput);
prependCheckResult(resultsEl, r.success
? renderCheckItem('ok', 'ITAD API Key 有效', '跨区比价将使用 16 地区增强模式')
: renderCheckItem('err', 'ITAD API Key 检测失败', r.error));
} else {
prependCheckResult(resultsEl, renderCheckItem('warn', 'ITAD API Key 未配置', '跨区比价使用降级数据源(约 7 地区),价格准确度降低'));
}
toast('接口检测完成,结果见上方列表', 'success');
btn.disabled = false;
btn.innerHTML = originalHtml;
});
}
// ---------- Tab: 数据管理 ----------
function buildDataPane() {
const pane = overlay.querySelector('#sa-pane-data');
const conv = ConversationManager.getCurrentConversation();
pane.innerHTML = `
${getIcon('download', 15)} 导出当前会话
导出文件可直接分享或备份;JSON 可再次导入恢复
${getIcon('edit', 15)} 导入会话
导入后会作为新会话追加到历史列表,不影响现有对话
`;
const convId = ConversationManager.getCurrentId();
pane.querySelector('#sa-export-json').addEventListener('click', () => {
ConversationManager.exportAsJson(convId);
toast('已导出 JSON', 'success');
});
pane.querySelector('#sa-export-md').addEventListener('click', () => {
ConversationManager.exportAsMarkdown(convId);
toast('已导出 Markdown', 'success');
});
pane.querySelector('#sa-import-json').addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file'; input.accept = '.json';
input.onchange = () => {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result);
const newConvId = `conv_imported_${Date.now()}`;
ConversationManager._conversations[newConvId] = {
id: newConvId,
title: data.conversation || data.messages?.[0]?.content?.slice(0, 30) || '导入的对话',
agentId: data.agentId || AgentManager._currentAgentId,
modelId: data.modelId || null,
messages: (data.messages || []).map(m => ({
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role: m.role, content: m.content,
timestamp: typeof m.timestamp === 'string' ? new Date(m.timestamp).getTime() : (m.timestamp || Date.now()),
toolCalls: m.toolCalls || [],
})),
createdAt: data.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
ConversationManager.save();
ConversationManager.switchTo(newConvId);
renderMessages();
toast(`导入成功,共 ${data.messages?.length || 0} 条消息`, 'success');
buildDataPane();
} catch (e) { toast('导入失败: ' + e.message, 'error'); }
};
reader.readAsText(file);
};
input.click();
});
}
// ---------- 底部保存 ----------
overlay.querySelector('#sa-cfg-save').addEventListener('click', () => {
GM_setValue(SK.STEAM_KEY, overlay.querySelector('#sa-cfg-steamkey').value.trim());
GM_setValue(SK.STEAM_ID, overlay.querySelector('#sa-cfg-steamid').value.trim());
GM_setValue(SK.ITAD_KEY, overlay.querySelector('#sa-cfg-itadkey').value.trim());
const rulesUrlEl = overlay.querySelector('#sa-rules-url');
if (rulesUrlEl) RulesKnowledgeProvider.setRemoteUrl(rulesUrlEl.value.trim());
GM_setValue(SK.SETTINGS, JSON.stringify({
...settings,
temperature: parseFloat(overlay.querySelector('#sa-cfg-temp').value) || 0.7,
maxIterations: parseInt(overlay.querySelector('#sa-cfg-iter').value) || CONFIG.MAX_ITERATIONS,
}));
toast('设置已保存', 'success');
if (fabEl) {
const newCfg = getAiConfig();
if (newCfg.apiKey) fabEl.classList.add('sa-has-key');
else fabEl.classList.remove('sa-has-key');
}
refreshModelSelector();
closeSettings();
});
buildGlobalPane();
buildAiPane();
buildAgentsPane();
buildApiPane();
buildDataPane();
renderNavSummary();
switchSettingsTab(overlay, initialTab);
applySettingsFocus(overlay, options.focus);
}
// 兼容旧入口:智能体管理按钮 → 设置中心「智能体」标签
function openAgentManager() {
openSettings({ tab: 'agents' });
}
// ============================================================
// 暴露 window.SteamAgent API
// ============================================================
function exposeAPI() {
const api = {
isInstalled: () => true,
version: _v,
open: (options = {}) => openSidebar(options),
close: () => closeSidebar(),
toggle: () => toggleSidebar(),
isOpen: () => panelEl?.classList.contains('sa-open') || false,
send: (message) => {
if (!panelEl?.classList.contains('sa-open')) openSidebar();
if (isProcessing) { toast('AI 正在回复中,请等待完成或点击停止按钮', 'error'); return; }
inputEl.value = message;
handleSend();
},
getAgents: () => AgentManager.getAgents(),
getCurrentAgent: () => AgentManager.getCurrentAgent(),
switchAgent: (agentId) => {
const result = AgentManager.switchAgent(agentId);
if (result && panelEl?._refreshAgentSelect) panelEl._refreshAgentSelect();
renderMessages();
return result;
},
getModels: () => ModelManager.getModels(),
getActiveModel: () => ModelManager.getActiveModel(),
switchModel: (modelId) => {
const result = ModelManager.setActiveModel(modelId);
if (result) {
refreshModelSelector();
ConversationManager.updateCurrentModel(modelId);
}
return result;
},
getConversations: () => ConversationManager.getAllConversations(),
getCurrentConversation: () => ConversationManager.getCurrentConversation(),
createConversation: () => {
const conv = ConversationManager.createConversation(AgentManager._currentAgentId);
renderMessages();
return conv;
},
switchConversation: (convId) => {
const result = ConversationManager.switchTo(convId);
if (result) {
renderMessages();
refreshModelSelector();
if (panelEl?._refreshAgentSelect) panelEl._refreshAgentSelect();
}
return result;
},
exportCurrentChat: () => ConversationManager.exportAsJson(ConversationManager.getCurrentId()),
on: (event, cb) => EventBus.on(event, cb),
off: (event, cb) => EventBus.off(event, cb),
};
try {
if (typeof unsafeWindow !== 'undefined') {
unsafeWindow.SteamAgent = api;
} else {
window.SteamAgent = api;
}
} catch (e) {
console.warn('[SA-Agent] Failed to expose API:', e);
}
}
// ============================================================
// 初始化
// ============================================================
function safeStep(name, fn) {
try { fn(); } catch (e) { console.error('[SA-Agent] 初始化步骤失败: ' + name, e); }
}
// Steam 新版 React 页面在水合/路由切换时可能移除脚本注入的 body 子元素,定时自愈
function startSelfHealing() {
const heal = () => {
try {
if (fabEl && !document.contains(fabEl) && document.body) {
document.body.appendChild(fabEl);
console.warn('[SA-Agent] 浮动按钮被页面移除,已自动恢复');
}
if (panelEl && !document.contains(panelEl) && document.body) {
document.body.appendChild(panelEl);
}
} catch {}
};
setInterval(heal, 5000);
try {
new MutationObserver(heal).observe(document.body, { childList: true });
} catch {}
}
function init() {
try {
if (window !== window.top) return;
} catch { return; }
safeStep('createFloatingButton', createFloatingButton);
safeStep('ModelManager.init', () => ModelManager.init());
safeStep('AgentManager.init', () => AgentManager.init());
safeStep('ConversationManager.init', () => ConversationManager.init());
safeStep('TokenStatsManager.init', () => TokenStatsManager.init());
const storedSide = GM_getValue(SK.PANEL_SIDE, null);
if (storedSide === 'left' || storedSide === 'right') panelSide = storedSide;
safeStep('createSidebar', createSidebar);
safeStep('exposeAPI', exposeAPI);
safeStep('startSelfHealing', startSelfHealing);
safeStep('EventBus.ready', () => EventBus.emit('ready', { version: _v }));
console.log('[SA-Agent] 初始化完成,SteamAgent API 已暴露');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
setTimeout(init, 100);
}
})();