// ==UserScript== // @name Steam AI Agent 对话智能体 // @name:en Steam AI Agent Chat // @namespace https://github.com/steam-card-view // @version 1.37.0 // @author SmallRob // @description Steam 商店和社区的侧边栏 AI 助手。可以问游戏评测、值不值得买、当前折扣和史低、跨区价格、愿望单、官方动态和消费记录。也能直接读取你已安装的其他 Steam 脚本留在本机的缓存:游戏库和游玩时长、好友、家庭共享、市场、徽章、许可来源、点数和消费汇总,不额外消耗 Steam 接口配额;社区页也能搜商店页同步过来的游戏库。还可以核对这套脚本装齐了没有、版本是否过旧。支持 DeepSeek、智谱、通义、Kimi 等多模型,多段对话和自定义智能体。 // @description:en Sidebar AI for the Steam store and community. Ask about reviews, whether a game is worth buying, discounts and historic lows, regional prices, your wishlist, official news, and spending. It reads local caches from your other Steam userscripts (library, friends, family, market, badges, licenses, points, spending) without extra Steam API quota, can search that library on community pages, and checks whether the script suite is installed and up to date. Multi-model chat with history and custom agents. // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%20width%3D%22128%22%20height%3D%22128%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22a%22%20x1%3D%224%22%20y1%3D%222%22%20x2%3D%2260%22%20y2%3D%2262%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23f5d0fe%22%2F%3E%3Cstop%20offset%3D%22.18%22%20stop-color%3D%22%23a78bfa%22%2F%3E%3Cstop%20offset%3D%22.42%22%20stop-color%3D%22%236366f1%22%2F%3E%3Cstop%20offset%3D%22.66%22%20stop-color%3D%22%232563eb%22%2F%3E%3Cstop%20offset%3D%22.84%22%20stop-color%3D%22%2322d3ee%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23fb7185%22%2F%3E%3C%2FlinearGradient%3E%3CradialGradient%20id%3D%22b%22%20cx%3D%2218%22%20cy%3D%2212%22%20r%3D%2236%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23ffffff%22%20stop-opacity%3D%22.62%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23ffffff%22%20stop-opacity%3D%220%22%2F%3E%3C%2FradialGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2218%22%20fill%3D%22url(%23a)%22%2F%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2218%22%20fill%3D%22url(%23b)%22%2F%3E%3Cpath%20fill%3D%22%23ffffff%22%20fill-rule%3D%22evenodd%22%20d%3D%22M20%2016h24a4%204%200%200%201%204%204v14a4%204%200%200%201-4%204H33l-7%206.2V38h-6a4%204%200%200%201-4-4V20a4%204%200%200%201%204-4zm12%205.2%201.55%204.15c.12.32.38.58.7.7L38.4%2027.6l-4.15%201.55a1.15%201.15%200%200%200-.7.7L32%2034.1l-1.55-4.15a1.15%201.15%200%200%200-.7-.7L25.6%2027.6l4.15-1.55a1.15%201.15%200%200%200%20.7-.7L32%2021.2z%22%2F%3E%3C%2Fsvg%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 // @grant GM_getResourceText // @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 // @connect * // @connect localhost // @connect 127.0.0.1 // @run-at document-idle // @noframes // @license MIT // 游戏热点模块(源注册表/抓取/解析/中文优先排序/聚合缓存/浮窗 UI)已抽为独立库 // ⚠️ 走脚本猫库分发(发布即生效,无 CDN 缓存滞后);URL 带版本号——game-news 更新发布新版本后要同步改这里的版本号。 // v1.35.0 起热点库升到 1.3.0(新增标题栏增强搜索框):**须先把库发到脚本猫 8636 的 1.3.0**, // 否则该 @require 取不到 → 整个主脚本不执行。宿主侧已按能力降级(setSearchProviders 缺失时 // 搜索框退化为「仅本地匹配」),但 @require 指向的版本号必须真实存在。 // 备选:https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/libs/saa-game-news.lib.js // (脚本猫 8636 页面另附 sha384 校验值:发布 1.3.0 后需同步刷新此处的旧值) // @require https://scriptcat.org/lib/8636/1.3.0/Steam%20AI-%E6%B8%B8%E6%88%8F%E5%A4%B4%E6%9D%A1%E7%83%AD%E7%82%B9.js // AI 搜索浮窗(关键字匹配 + 分组结果 + 浮窗 UI)—— 独立库,主脚本只注入数据源并挂入口按钮。 // @require https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/libs/saa-search.lib.js // 插件版本清单注册用(共享宿主 v0.3.0)—— 让 Steam AI Agent 能读到本插件版本号。 // @require https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/libs/sglv-plugin-host.lib.js // 认证库(默认凭据客户端:设备私钥签名 + 会话管理)—— v1.30.2 起走 @require 分发,删除内联副本。 // ⚠️ 管「设备私钥/凭据」的库必须钉 commit hash(@main 有 12h 缓存且公开仓被改无法感知); // 换库内容 = 改 steam-namespace libs/saa-auth.lib.js → 推送 → 把下面的 hash 换成新 commit。 // sha384(6789435 版本):AzlxZQoyWl+ne2TwmV76DQDYvKSzlXjcS5EBVq6C+GNBbE7b5HQj9Y9uIE+H7lZA // @require https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@6789435/libs/saa-auth.lib.js // @resource saaSkills https://raw.githubusercontent.com/SmallRob/steam-namespace/refs/heads/main/data/saa-skills.json // ==/UserScript== (function () { 'use strict'; const _v = typeof GM_info !== 'undefined' ? GM_info.script.version : '1.10.0'; // v2.32.0 插件版本清单注册(共享宿主 v0.3.0)—— 让 Steam AI Agent 能读到本插件版本号。 // ⚠️ 共享宿主缺失时静默跳过,绝不影响本插件主流程。 try { var __PH = (typeof unsafeWindow !== 'undefined' && unsafeWindow.__PLUGIN_HOST__) || (typeof window !== 'undefined' && window.__PLUGIN_HOST__); if (__PH && typeof __PH.registerPlugin === 'function') { __PH.registerPlugin({ id: 'ai-agent', name: 'Steam AI Agent 对话智能体', family: 'ai' }); } } catch (e) { /* ignore */ } 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: 520, MAX_ITERATIONS: 5, MAX_PARALLEL_TOOLS: 4, TOOL_TEMPERATURE: 0.2, ANSWER_TEMPERATURE: 0.7, TOOL_TIMEOUT: 30000, AI_TIMEOUT: 120000, CACHE_TTL: 30 * 60 * 1000, TOAST_DURATION: 3000, // 会话存储治理:防抖落盘 + 容量裁剪(防止长对话高频全量写与 GM 配额爆炸) SAVE_DEBOUNCE_MS: 800, MAX_CONVERSATIONS: 30, MAX_MESSAGES_PER_CONV: 200, // 单次工具结果回灌给模型的 JSON 字符上限(上下文窗口层还有更细的二级裁剪) TOOL_RESULT_MAX_CHARS: 12000, // v1.13.0 长会话渲染治理:屏外历史消息降级渲染(content-visibility) // 保留最近 N 条为活跃态(不脱水);脱水用 auto 而非 hidden,以保留 Ctrl+F 可搜索性 DEHYDRATE_KEEP_RECENT: 8, // 消息数低于该阈值不启用脱水:短会话省不下什么,反而增加测量开销 DEHYDRATE_MIN_MESSAGES: 12, // 脱水的兜底高度(px):真实高度会被 --sa-h 覆盖,这里只防首帧塌陷 DEHYDRATE_FALLBACK_H: 120, // v1.16.0 联网检索/抓取:默认走 DeepSeek 官方 /v1/tools/* 接口 DS_TOOLS_DEFAULT_BASE: 'https://api.deepseek.com/v1/tools', DS_TOOLS_TIMEOUT: 45000, // search 单条 summary 裁剪上限(实测原生长度可达 2.7KB,10 条合计约 19KB, // 必须裁剪才能塞进 TOOL_RESULT_MAX_CHARS=12KB 的回灌预算) SEARCH_SUMMARY_MAX_CHARS: 900, // fetch 返回正文上限(实测部分站点可达数十 KB) FETCH_CONTENT_MAX_CHARS: 8000, // 相同 query 的检索结果缓存时长:既省 Key 额度,也抑制模型反复检索同一问题 SEARCH_RESULT_TTL: 10 * 60 * 1000, NEWS_TTL: 30 * 60 * 1000, NEWS_FETCH_TIMEOUT: 15000, NEWS_PER_SOURCE: 8, }; // ============================================================ // 模型供应商预设 // ============================================================ 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: ``, chevronDown: ``, 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(/${escapeHtml(agent.icon)}`; return getIcon('bot', size); } function providerIconHtml(providerKey, size = 18) { return getProviderIcon(providerKey, size); } const SK = { AI_URL: 'sa_ai_api_url', AI_KEY: 'sa_ai_api_key', AI_MODEL: 'sa_ai_model', STEAM_KEY: 'sa_steam_api_key', STEAM_ID: 'sa_steam_id', AGENTS: 'sa_agents_custom', CONVERSATIONS: 'sa_conversations', CONVERSATIONS_V2: 'sa_conversations_v2', CURRENT_AGENT: 'sa_current_agent', CURRENT_CONV: 'sa_current_conv', FAB_POS: 'sa_fab_position', PANEL_SIDE: 'sa_panel_side', SETTINGS: 'sa_settings', MODELS: 'sa_model_configs', ACTIVE_MODEL: 'sa_active_model', RULES_REMOTE: 'sa_rules_remote', RULES_URL: 'sa_rules_remote_url', TOKEN_STATS: 'sa_token_stats_v1', ITAD_KEY: 'sa_itad_key', // v1.16.0 联网检索/抓取工具(DeepSeek /v1/tools/*)配置 DS_TOOLS_BASE: 'sa_ds_tools_base', DS_TOOLS_KEY: 'sa_ds_tools_key', }; // ============================================================ // CSS 样式 // ============================================================ GM_addStyle(` /* ====== 浮动按钮 ====== */ #sa-fab { position: fixed !important; width: 52px; height: 52px; border-radius: 16px !important; background: radial-gradient(125% 85% at 18% 0%, rgba(255,255,255,.62), rgba(255,255,255,0) 46%) padding-box, linear-gradient(148deg, #f5d0fe 0%, #a78bfa 18%, #6366f1 42%, #2563eb 66%, #22d3ee 84%, #fb7185 100%) padding-box, conic-gradient(from 210deg, #fde68a, #f472b6, #22d3ee, #c4b5fd, #fde68a) border-box !important; cursor: grab; z-index: 1000004 !important; box-shadow: 0 8px 22px rgba(79,70,229,.42), inset 0 1px 0 rgba(255,255,255,.55) !important; transition: transform 0.2s ease, box-shadow 0.2s ease !important; display: flex !important; align-items: center; justify-content: center; user-select: none; touch-action: none; -webkit-tap-highlight-color: transparent; border: 1.5px solid transparent !important; } #sa-fab::before { content: ''; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; background: linear-gradient(180deg, rgba(255,255,255,.32), rgba(255,255,255,0) 42%); } #sa-fab:hover { transform: scale(1.08) !important; box-shadow: 0 10px 28px rgba(99,102,241,.55), inset 0 1px 0 rgba(255,255,255,.7) !important; } #sa-fab:active { cursor: grabbing; } #sa-fab svg { position: relative; z-index: 1; width: 28px !important; height: 28px !important; pointer-events: none; max-width: 28px !important; max-height: 28px !important; flex-shrink: 0 !important; } #sa-fab .sa-fab-badge { position: absolute; top: -2px; right: -2px; width: 12px; height: 12px; border-radius: 50%; background: #4ade80; border: 2px solid #0d1223; display: none; } #sa-fab.sa-has-key .sa-fab-badge { display: block; } /* ====== 侧边栏面板 ====== */ #sa-panel { position: fixed !important; top: 0 !important; right: 0 !important; width: min(${CONFIG.PANEL_WIDTH}px, 100vw) !important; height: 100vh !important; background: rgba(13, 18, 35, 0.92) !important; backdrop-filter: blur(24px) saturate(160%) !important; -webkit-backdrop-filter: blur(24px) saturate(160%) !important; z-index: 1000004 !important; transform: translateX(100%) !important; transition: transform 0.35s cubic-bezier(0.4,0,0.2,1) !important; display: flex !important; flex-direction: column !important; border-left: 1px solid rgba(139,92,246,0.25) !important; box-shadow: -16px 0 60px rgba(0,0,0,0.65) !important; font-family: "Motiva Sans", Arial, sans-serif !important; color: #e2e8f0 !important; overflow: hidden; } #sa-panel.sa-open { transform: translateX(0) !important; } #sa-panel.sa-left-side { right: auto !important; left: 0 !important; transform: translateX(-100%) !important; border-left: none !important; border-right: 1px solid rgba(139,92,246,0.25) !important; box-shadow: 16px 0 60px rgba(0,0,0,0.65) !important; } #sa-panel.sa-left-side.sa-open { transform: translateX(0) !important; } /* ====== 顶部栏 ====== */ .sa-header { display: flex !important; align-items: center; gap: 4px; padding: 6px 8px 6px 6px !important; background: transparent !important; border-bottom: 1px solid rgba(148,163,184,0.12) !important; flex-shrink: 0; } .sa-header-actions { display: flex; align-items: center; flex-shrink: 0; margin-left: auto; } .sa-header-sep { width: 1px; height: 14px; margin: 0 4px; background: rgba(148,163,184,0.22); flex-shrink: 0; } /* ====== 智能体下拉选择器 ====== */ .sa-agent-dropdown { position: relative; flex: 1 1 auto; min-width: 0; } .sa-agent-trigger { width: fit-content; max-width: 100%; display: flex; align-items: center; gap: 6px; background: transparent !important; border: none !important; color: #f1f5f9 !important; border-radius: 8px !important; padding: 6px 8px !important; font-size: 13px !important; font-weight: 600; cursor: pointer; outline: none; font-family: inherit !important; transition: background 0.15s ease; } .sa-agent-trigger:hover { background: rgba(255,255,255,0.06) !important; } .sa-agent-trigger.sa-open { background: rgba(139,92,246,0.14) !important; } .sa-agent-trigger-icon { display: flex; align-items: center; flex-shrink: 0; color: #a78bfa; } .sa-agent-trigger-name { flex: 0 1 auto; min-width: 0; text-align: left; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sa-agent-trigger-arrow { display: flex; align-items: center; flex-shrink: 0; color: #64748b; transition: transform 0.2s ease, color 0.2s ease; } .sa-agent-trigger.sa-open .sa-agent-trigger-arrow { transform: rotate(180deg); color: #a78bfa; } .sa-agent-menu { position: absolute; top: calc(100% + 6px); left: 0; width: min(340px, calc(100vw - 32px)); max-height: min(420px, 60vh); overflow-y: auto; background: rgba(13, 18, 35, 0.98) !important; backdrop-filter: blur(20px) saturate(160%) !important; -webkit-backdrop-filter: blur(20px) saturate(160%) !important; border: 1px solid rgba(139,92,246,0.25) !important; border-radius: 12px !important; box-shadow: 0 12px 40px rgba(0,0,0,0.6) !important; padding: 6px; z-index: 50; display: none; scrollbar-width: thin; scrollbar-color: rgba(139,92,246,0.3) transparent; } .sa-agent-menu.sa-show { display: block; animation: sa-agent-menu-in 0.16s cubic-bezier(0.2,0.9,0.3,1.2); } @keyframes sa-agent-menu-in { from { opacity: 0; transform: translateY(-6px) scale(0.97); } to { opacity: 1; transform: translateY(0) scale(1); } } .sa-agent-menu::-webkit-scrollbar { width: 6px; } .sa-agent-menu::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.3); border-radius: 3px; } .sa-agent-group-label { font-size: 10px; color: #64748b; font-weight: 600; letter-spacing: 0.5px; padding: 8px 10px 4px; text-transform: uppercase; } .sa-agent-item { display: flex; align-items: flex-start; gap: 10px; padding: 8px; border-radius: 9px; cursor: pointer; transition: background 0.12s ease; } .sa-agent-item:hover { background: rgba(139,92,246,0.12); } .sa-agent-item-icon { width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0; margin-top: 1px; background: rgba(139,92,246,0.12); color: #a78bfa; display: flex; align-items: center; justify-content: center; transition: background 0.12s ease, color 0.12s ease; } .sa-agent-item.sa-active .sa-agent-item-icon { background: linear-gradient(135deg, #8b5cf6, #3b82f6); color: #fff; } .sa-agent-item-info { flex: 1; min-width: 0; } .sa-agent-item-name { display: block; font-size: 13px; color: #f1f5f9; font-weight: 600; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sa-agent-item-desc { display: block; font-size: 11px; color: #64748b; margin-top: 2px; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sa-agent-item-check { flex-shrink: 0; align-self: center; color: #a78bfa; display: none; align-items: center; } .sa-agent-item.sa-active .sa-agent-item-check { display: flex; } .sa-agent-menu-footer { border-top: 1px solid rgba(139,92,246,0.12); margin-top: 6px; padding-top: 6px; } .sa-agent-manage-btn { width: 100%; display: flex; align-items: center; justify-content: center; gap: 5px; padding: 6px; border-radius: 8px; border: none; cursor: pointer; background: transparent; color: #94a3b8; font-size: 11.5px; font-family: inherit; transition: background 0.15s ease, color 0.15s ease; } .sa-agent-manage-btn:hover { background: rgba(255,255,255,0.05); color: #c4b5fd; } .sa-icon-btn { width: 32px; height: 32px; border-radius: 8px; background: transparent !important; border: none !important; color: #94a3b8 !important; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background 0.15s ease, color 0.15s ease; font-size: 14px; padding: 0; } .sa-icon-btn:hover { background: rgba(255,255,255,0.07) !important; color: #e2e8f0 !important; } .sa-icon-btn.sa-icon-close:hover { background: rgba(248,113,113,0.12) !important; color: #fda4af !important; } .sa-icon-btn svg { width: 16px; height: 16px; } /* ====== 消息列表 ====== */ .sa-messages { flex: 1; overflow-y: auto; padding: 16px 14px 14px !important; display: flex; flex-direction: column; gap: 14px; scrollbar-width: thin; scrollbar-color: rgba(139,92,246,0.3) transparent; background: radial-gradient(ellipse at top, rgba(139,92,246,0.04) 0%, transparent 50%), transparent; } .sa-messages::-webkit-scrollbar { width: 6px; } .sa-messages::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.3); border-radius: 3px; } /* ====== v1.13.0 长会话渲染治理:屏外消息脱水 ====== 问题:MAX_MESSAGES_PER_CONV=200 时 renderMessages() 单次 innerHTML 可达 2MB+, 切会话/重新渲染历史时浏览器要 parse 整棵树,长列表逐帧渲染也随之变卡。 方案:给靠前的历史消息加 content-visibility:auto,让浏览器跳过屏外元素的 布局与绘制(保留 DOM 与文本,Ctrl+F 仍可搜到;用 hidden 会不可搜索)。 高度用 --sa-h 记忆真实值配合 contain-intrinsic-size,保证滚动条不跳。 注:paint containment 只在元素真正被跳过时生效,实测不裁剪 .sa-msg-ai-badge 的顶部溢出(该徽章是 position:absolute; top:-10px),故此处不加 contain。 */ .sa-msg[data-dehydrated="1"] { content-visibility: auto; contain-intrinsic-size: auto var(--sa-h, 120px); } .sa-msg { max-width: 88%; padding: 10px 14px; border-radius: 12px; font-size: 13px; line-height: 1.6; word-wrap: break-word; } .sa-msg-user { align-self: flex-end; background: linear-gradient(135deg, #8b5cf6, #3b82f6) !important; color: #fff !important; border-bottom-right-radius: 4px; box-shadow: 0 2px 12px rgba(139,92,246,0.25) !important; } .sa-msg-ai { align-self: flex-start; background: rgba(30, 41, 59, 0.7) !important; border: 1px solid rgba(139,92,246,0.12) !important; border-bottom-left-radius: 4px; backdrop-filter: blur(8px) !important; -webkit-backdrop-filter: blur(8px) !important; position: relative; padding-top: 12px; } /* AI 消息头像徽章 */ .sa-msg-ai-badge { position: absolute; top: -10px; left: -2px; width: 26px; height: 26px; border-radius: 8px; background: linear-gradient(135deg, #8b5cf6, #7c3aed); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 10px; font-weight: 700; box-shadow: 0 2px 8px rgba(139,92,246,0.5); border: 2px solid rgba(13, 18, 35, 0.92); z-index: 2; } .sa-msg-ai-badge svg { width: 14px; height: 14px; } .sa-msg-ai .sa-md { margin-top: 2px; } .sa-msg-system { align-self: center; max-width: 95%; background: rgba(251, 191, 36, 0.08) !important; border: 1px solid rgba(251, 191, 36, 0.2) !important; color: #fbbf24 !important; font-size: 12px; text-align: center; } /* ====== AI 消息操作栏(复制按钮) ====== */ .sa-msg-footer { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin-top: 8px; padding-top: 6px; border-top: 1px dashed rgba(139, 92, 246, 0.15); } .sa-msg-copy { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 10px; border: none; cursor: pointer; background: rgba(139, 92, 246, 0.1); color: #94a3b8; font-size: 11px; line-height: 1.5; transition: all 0.15s ease; } .sa-msg-copy:hover { background: rgba(139, 92, 246, 0.25); color: #ddd6fe; } .sa-msg-copy svg { width: 11px; height: 11px; } .sa-msg-copy.sa-copied { background: rgba(74, 222, 128, 0.15); color: #4ade80; } /* ====== Markdown 内容 ====== */ .sa-md h1, .sa-md h2, .sa-md h3 { margin: 8px 0 4px; color: #f1f5f9; font-size: 15px; } .sa-md h1 { font-size: 17px; } .sa-md p { margin: 4px 0; } .sa-md ul, .sa-md ol { margin: 4px 0; padding-left: 18px; } .sa-md li { margin: 2px 0; } .sa-md strong { color: #c4b5fd; font-weight: 600; } .sa-md em { color: #93c5fd; } .sa-md code { background: rgba(0,0,0,0.3); padding: 1px 5px; border-radius: 4px; font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; color: #4ade80; } .sa-md pre { background: rgba(0,0,0,0.35); border: 1px solid rgba(139,92,246,0.15); border-radius: 8px; padding: 10px; margin: 6px 0; overflow-x: auto; } .sa-md pre code { background: none; padding: 0; color: #e2e8f0; } .sa-md a { color: #60a5fa; text-decoration: none; } .sa-md a:hover { text-decoration: underline; } .sa-md blockquote { border-left: 3px solid rgba(139,92,246,0.3); padding-left: 10px; margin: 4px 0; color: #94a3b8; } /* ====== Markdown 表格 ====== */ .sa-md-table-wrap { margin: 8px 0; overflow-x: auto; border-radius: 8px; border: 1px solid rgba(139,92,246,0.15); scrollbar-width: thin; scrollbar-color: rgba(139,92,246,0.3) transparent; } .sa-md-table-wrap::-webkit-scrollbar { height: 6px; } .sa-md-table-wrap::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.3); border-radius: 3px; } .sa-md-table { width: 100%; border-collapse: collapse; font-size: 12px; line-height: 1.5; min-width: 100%; table-layout: auto; } .sa-md-table thead th { background: rgba(30, 41, 59, 0.9); color: #c4b5fd; font-weight: 600; text-align: left; padding: 7px 10px; border-bottom: 2px solid rgba(139,92,246,0.25); white-space: nowrap; position: sticky; top: 0; } .sa-md-table tbody td { padding: 6px 10px; border-bottom: 1px solid rgba(139,92,246,0.08); color: #cbd5e1; vertical-align: top; word-break: break-word; } .sa-md-table tbody tr:last-child td { border-bottom: none; } .sa-md-table tbody tr:hover { background: rgba(139,92,246,0.06); } .sa-md-table tbody tr:nth-child(even) { background: rgba(139,92,246,0.03); } .sa-md-table tbody tr:nth-child(even):hover { background: rgba(139,92,246,0.08); } .sa-md-table code { background: rgba(0,0,0,0.3); padding: 1px 5px; border-radius: 3px; font-size: 11.5px; } .sa-md-table strong { color: #a78bfa; } /* ====== 输入区:一个输入面,模型收进底栏 ====== */ .sa-input-area { padding: 8px 12px 12px !important; background: transparent !important; border-top: 1px solid rgba(148,163,184,0.12) !important; flex-shrink: 0; } .sa-composer { display: flex; flex-direction: column; background: rgba(15, 23, 42, 0.55); border: 1px solid rgba(148,163,184,0.16); border-radius: 14px; transition: border-color 0.15s ease, box-shadow 0.15s ease; } .sa-composer:focus-within { border-color: rgba(139,92,246,0.55); box-shadow: 0 0 0 3px rgba(139,92,246,0.12); } .sa-composer-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 4px 4px 10px; } .sa-model-selector { flex: 0 1 auto; width: auto; max-width: min(260px, 72%); min-width: 0; appearance: none; -webkit-appearance: none; background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 2px center !important; border: none !important; color: #94a3b8 !important; border-radius: 6px !important; padding: 4px 16px 4px 0 !important; font-size: 12px !important; cursor: pointer; outline: none; font-family: inherit !important; text-overflow: ellipsis; } .sa-model-selector:hover { color: #e2e8f0 !important; } .sa-model-selector option { background: #1e293b !important; color: #e2e8f0 !important; } .sa-input { width: 100%; min-height: 42px; max-height: 120px; resize: none; box-sizing: border-box; background: transparent !important; border: none !important; color: #e2e8f0 !important; padding: 10px 12px 2px !important; font-size: 13.5px !important; line-height: 1.5; font-family: inherit !important; outline: none; } .sa-input::placeholder { color: #64748b; } .sa-send-btn { width: 32px; height: 32px; border-radius: 10px; background: linear-gradient(135deg, #8b5cf6, #6366f1) !important; border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: opacity 0.15s ease, transform 0.15s ease; } .sa-send-btn:hover { transform: scale(1.05); } .sa-send-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } .sa-send-btn svg { width: 16px; height: 16px; } .sa-send-btn.sa-stop { background: linear-gradient(135deg, #ef4444, #dc2626) !important; } .sa-send-btn.sa-stopping { background: linear-gradient(135deg, #64748b, #475569) !important; animation: sa-pulse 1.2s infinite; } /* ====== 工具状态 ====== */ .sa-tool-status { font-size: 11px; color: #94a3b8; padding: 5px 14px; display: none; align-items: center; gap: 7px; background: rgba(20, 27, 50, 0.75); border-bottom: 1px solid rgba(139,92,246,0.1); } .sa-tool-status.sa-active { display: flex; } .sa-tool-status .sa-tool-icon { display: flex; align-items: center; color: #a78bfa; flex-shrink: 0; animation: sa-pulse 1.2s infinite; } .sa-tool-status .sa-tool-icon svg { width: 13px; height: 13px; } .sa-tool-status .sa-tool-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @keyframes sa-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } } /* ====== 工具调用卡片(消息内嵌) ====== */ .sa-tool-calls { display: flex; flex-direction: column; gap: 5px; margin-bottom: 8px; } .sa-tool-chip { display: flex; align-items: center; gap: 7px; padding: 6px 10px; border-radius: 8px; background: rgba(139,92,246,0.08) !important; border: 1px solid rgba(139,92,246,0.2) !important; font-size: 11px; color: #94a3b8; animation: sa-fade-in 0.2s ease; } @keyframes sa-fade-in { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } } .sa-tool-chip .sa-tc-icon { width: 20px; height: 20px; border-radius: 6px; background: rgba(139,92,246,0.15); display: flex; align-items: center; justify-content: center; color: #c4b5fd; flex-shrink: 0; } .sa-tool-chip .sa-tc-icon svg { width: 12px; height: 12px; } .sa-tool-chip .sa-tc-name { color: #c4b5fd; font-weight: 600; flex-shrink: 0; } .sa-tool-chip .sa-tc-args { color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } .sa-tool-chip .sa-tc-state { flex-shrink: 0; display: flex; align-items: center; gap: 4px; color: #64748b; } .sa-tool-chip .sa-tc-state svg { width: 11px; height: 11px; } .sa-tool-chip.sa-running { border-color: rgba(139,92,246,0.45) !important; background: rgba(139,92,246,0.12) !important; } .sa-tool-chip.sa-running .sa-tc-state { color: #a78bfa; } .sa-tool-chip .sa-tc-spinner { width: 10px; height: 10px; border-radius: 50%; border: 2px solid rgba(167,139,250,0.25); border-top-color: #a78bfa; animation: sa-rotate 0.7s linear infinite; flex-shrink: 0; } @keyframes sa-rotate { to { transform: rotate(360deg); } } .sa-tool-chip.sa-done .sa-tc-state { color: #4ade80; } .sa-tool-chip.sa-tool-fail { border-color: rgba(248, 113, 113, 0.35); background: rgba(248, 113, 113, 0.08); } .sa-tool-chip.sa-tool-fail .sa-tc-name { color: #f87171; } .sa-tool-chip.sa-tool-fail .sa-tc-state { color: #f87171; } /* ====== 工具调用可视化卡片(details 可折叠:完整参数/结果/耗时) ====== */ details.sa-tool-chip { display: block; padding: 0; } details.sa-tool-chip > summary { display: flex; align-items: center; gap: 7px; padding: 6px 10px; border-radius: 8px; cursor: pointer; list-style: none; user-select: none; } details.sa-tool-chip > summary::-webkit-details-marker { display: none; } details.sa-tool-chip[open] > summary { border-radius: 8px 8px 0 0; border-bottom: 1px dashed rgba(139,92,246,0.25); } details.sa-tool-chip[open] > summary:hover { background: rgba(139,92,246,0.06); } .sa-tc-dur { color: #64748b; font-size: 10px; flex-shrink: 0; font-variant-numeric: tabular-nums; } .sa-tc-chev { color: #64748b; font-size: 10px; line-height: 1; flex-shrink: 0; display: inline-flex; transition: transform 0.15s ease; } details.sa-tool-chip[open] .sa-tc-chev { transform: rotate(90deg); color: #a78bfa; } .sa-tc-detail { margin: 0; padding: 8px 10px; font-family: Consolas, 'Courier New', monospace; font-size: 11px; line-height: 1.55; color: #94a3b8; background: rgba(2, 6, 23, 0.45); max-height: 220px; overflow: auto; white-space: pre-wrap; word-break: break-all; border-radius: 0 0 8px 8px; } /* ====== 智能体编辑器:工具子集多选 ====== */ .sa-ae-tools { display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px 10px; padding: 8px 10px; border: 1px solid rgba(139,92,246,0.2); border-radius: 8px; background: rgba(139,92,246,0.05); max-height: 168px; overflow: auto; } .sa-ae-tool-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #cbd5e1; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sa-ae-tool-item input { accent-color: #8b5cf6; flex-shrink: 0; } /* ====== Steam Key 提权操作行(工具需要 API Key 时显示) ====== */ .sa-key-action { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 2px; padding: 6px 10px; border-radius: 8px; background: rgba(245, 158, 11, 0.08); border: 1px dashed rgba(245, 158, 11, 0.35); } .sa-key-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 12px; border-radius: 7px; cursor: pointer; border: 1px solid rgba(245, 158, 11, 0.55); background: rgba(245, 158, 11, 0.15); color: #fcd34d; font-size: 12px; font-weight: 600; transition: background 0.15s ease; } .sa-key-btn:hover { background: rgba(245, 158, 11, 0.28); } .sa-key-btn svg { width: 12px; height: 12px; } .sa-key-hint { font-size: 11px; color: #94a3b8; } /* ====== 智能体编辑器:标签行 + 复制按钮 ====== */ .sa-ae-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; } .sa-ae-label-row label { margin-bottom: 0; } .sa-copy-btn { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 6px; cursor: pointer; border: 1px solid rgba(139, 92, 246, 0.45); background: rgba(139, 92, 246, 0.12); color: #c4b5fd; font-size: 11.5px; transition: background 0.15s ease, color 0.15s ease; } .sa-copy-btn:hover { background: rgba(139, 92, 246, 0.25); color: #ddd6fe; } .sa-copy-btn:disabled { opacity: 0.5; cursor: default; } .sa-copy-btn svg { width: 12px; height: 12px; } /* ====== 空状态 ====== */ .sa-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #64748b; text-align: center; padding: 40px; } .sa-empty svg { width: 48px; height: 48px; opacity: 0.3; margin-bottom: 12px; } .sa-empty h3 { color: #94a3b8; font-size: 15px; margin-bottom: 6px; } .sa-empty p { font-size: 12px; line-height: 1.6; max-width: 280px; } /* ====== 对话历史面板 ====== */ .sa-history-panel { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(13, 18, 35, 0.98) !important; z-index: 10; display: flex; flex-direction: column; transform: translateX(-100%); transition: transform 0.3s cubic-bezier(0.4,0,0.2,1); } .sa-history-panel.sa-show { transform: translateX(0); } .sa-history-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: rgba(20, 27, 50, 0.9); border-bottom: 1px solid rgba(139,92,246,0.15); flex-shrink: 0; } .sa-history-header h3 { font-size: 14px; color: #e2e8f0; margin: 0; flex: 1; } .sa-history-list { flex: 1; overflow-y: auto; padding: 8px; scrollbar-width: thin; scrollbar-color: rgba(139,92,246,0.3) transparent; } .sa-history-list::-webkit-scrollbar { width: 5px; } .sa-history-list::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.3); border-radius: 3px; } .sa-history-item { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border-radius: 10px; background: rgba(30, 41, 59, 0.4); border: 1px solid transparent; margin-bottom: 6px; cursor: pointer; transition: all 0.15s ease; position: relative; } .sa-history-item:hover { background: rgba(30, 41, 59, 0.7); border-color: rgba(139,92,246,0.2); } .sa-history-item.sa-active { background: rgba(139,92,246,0.12); border-color: rgba(139,92,246,0.3); } .sa-history-item-title { font-size: 13px; color: #e2e8f0; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 24px; } .sa-history-item-meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #64748b; } .sa-history-item-meta .sa-history-tag { padding: 1px 6px; border-radius: 4px; background: rgba(139,92,246,0.1); color: #c4b5fd; font-size: 10px; } .sa-history-del { position: absolute; top: 8px; right: 8px; width: 22px; height: 22px; border-radius: 5px; background: transparent; border: none; color: #64748b; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; opacity: 0; transition: opacity 0.15s ease; } .sa-history-item:hover .sa-history-del { opacity: 1; } .sa-history-del:hover { background: rgba(239,68,68,0.2); color: #f87171; } .sa-history-empty { text-align: center; padding: 40px 20px; color: #64748b; font-size: 13px; } /* ====== 设置面板 ====== */ .sa-overlay { position: fixed; inset: 0; z-index: 1000005 !important; background: rgba(0,0,0,0.6) !important; display: none; align-items: center; justify-content: center; backdrop-filter: blur(4px); } .sa-overlay.sa-show { display: flex; } .sa-modal { background: rgba(15, 23, 42, 0.96) !important; border: 1px solid rgba(139,92,246,0.25) !important; border-radius: 16px; width: 480px; max-width: 92vw; max-height: 85vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 24px 70px rgba(0,0,0,0.6), 0 0 0 1px rgba(139,92,246,0.08) !important; backdrop-filter: blur(20px) saturate(160%) !important; -webkit-backdrop-filter: blur(20px) saturate(160%) !important; } .sa-modal-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid rgba(139,92,246,0.15); flex-shrink: 0; background: linear-gradient(180deg, rgba(139,92,246,0.08), transparent); } .sa-modal-header h3 { font-size: 16px; color: #f1f5f9; margin: 0; display: flex; align-items: center; gap: 6px; } .sa-modal-header h3 svg { width: 18px; height: 18px; color: #c4b5fd; } .sa-modal-body { flex: 1; overflow-y: auto; padding: 16px 18px; } .sa-modal-body::-webkit-scrollbar { width: 5px; } .sa-modal-body::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.3); border-radius: 3px; } .sa-form-group { margin-bottom: 14px; } .sa-form-group label { display: block; font-size: 12px; color: #94a3b8; margin-bottom: 4px; } .sa-form-input, .sa-form-textarea, .sa-form-select { width: 100%; box-sizing: border-box; background: rgba(30, 41, 59, 0.6) !important; border: 1px solid rgba(139,92,246,0.2) !important; border-radius: 8px; color: #e2e8f0 !important; padding: 9px 12px; font-size: 13px; font-family: inherit; outline: none; transition: border-color 0.15s ease, box-shadow 0.15s ease; } .sa-form-input:focus, .sa-form-textarea:focus, .sa-form-select:focus { border-color: rgba(139,92,246,0.6) !important; box-shadow: 0 0 0 3px rgba(139,92,246,0.15) !important; } .sa-form-textarea { resize: vertical; min-height: 60px; } .sa-form-hint { font-size: 11px; color: #64748b; margin-top: 3px; } .sa-form-hint a { color: #60a5fa; text-decoration: none; } .sa-form-hint a:hover { text-decoration: underline; } .sa-btn { padding: 9px 16px; border-radius: 8px; border: none; cursor: pointer; font-size: 13px; font-family: inherit; transition: opacity 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; white-space: nowrap; } .sa-btn-primary { background: linear-gradient(135deg, #8b5cf6, #3b82f6) !important; color: #fff !important; box-shadow: 0 2px 12px rgba(139,92,246,0.3) !important; } .sa-btn-primary:hover { opacity: 0.9; box-shadow: 0 4px 18px rgba(139,92,246,0.45) !important; transform: translateY(-1px); } .sa-btn-secondary { background: rgba(30, 41, 59, 0.8) !important; color: #94a3b8 !important; border: 1px solid rgba(139,92,246,0.15) !important; } .sa-btn-secondary:hover { background: rgba(139,92,246,0.12) !important; color: #e2e8f0 !important; } .sa-btn:hover { opacity: 0.9; } .sa-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none !important; } .sa-agent-card { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; background: rgba(30, 41, 59, 0.4) !important; border: 1px solid rgba(139,92,246,0.1) !important; margin-bottom: 8px; transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease; } .sa-agent-card:hover { border-color: rgba(139,92,246,0.25) !important; background: rgba(30, 41, 59, 0.6) !important; } .sa-agent-card .sa-agent-icon { font-size: 22px; flex-shrink: 0; } .sa-agent-card .sa-agent-info { flex: 1; min-width: 0; } .sa-agent-card .sa-agent-name { font-size: 13px; color: #e2e8f0; font-weight: 600; } .sa-agent-card .sa-agent-desc { font-size: 11px; color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sa-agent-card .sa-agent-actions { display: flex; gap: 4px; flex-shrink: 0; } .sa-agent-card .sa-mini-btn { width: 26px; height: 26px; border-radius: 6px; background: rgba(30, 41, 59, 0.6); border: 1px solid rgba(139,92,246,0.1); color: #94a3b8; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; } .sa-agent-card .sa-mini-btn:hover { background: rgba(139,92,246,0.2); color: #e2e8f0; } .sa-agent-card.sa-builtin .sa-agent-actions { display: none; } /* ====== 模型管理卡片 ====== */ .sa-model-card { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; background: rgba(30, 41, 59, 0.4) !important; border: 1px solid rgba(139,92,246,0.1) !important; margin-bottom: 8px; transition: border-color 0.15s ease, background 0.15s ease; } .sa-model-card:hover { border-color: rgba(139,92,246,0.25) !important; } .sa-model-card.sa-active { border-color: rgba(139,92,246,0.45) !important; background: linear-gradient(135deg, rgba(139,92,246,0.12), rgba(59,130,246,0.08)) !important; box-shadow: 0 0 0 1px rgba(139,92,246,0.15) inset !important; } .sa-model-card .sa-model-icon { font-size: 18px; flex-shrink: 0; } .sa-model-card .sa-model-info { flex: 1; min-width: 0; } .sa-model-card .sa-model-name { font-size: 13px; color: #e2e8f0; font-weight: 600; } .sa-model-card .sa-model-detail { font-size: 11px; color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sa-model-card .sa-model-actions { display: flex; gap: 4px; flex-shrink: 0; } .sa-model-card .sa-model-badge { font-size: 9px; padding: 1px 5px; border-radius: 3px; background: rgba(74,222,128,0.15); color: #4ade80; margin-left: 4px; } /* ====== 供应商选择标签 ====== */ .sa-provider-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } .sa-provider-tag { display: flex; align-items: center; gap: 4px; padding: 5px 10px; border-radius: 8px; background: rgba(30, 41, 59, 0.6); border: 1px solid rgba(139,92,246,0.15); color: #94a3b8; font-size: 12px; cursor: pointer; transition: all 0.15s ease; } .sa-provider-tag:hover { border-color: rgba(139,92,246,0.3); color: #e2e8f0; } .sa-provider-tag.sa-selected { background: rgba(139,92,246,0.2); border-color: rgba(139,92,246,0.5); color: #c4b5fd; } .sa-provider-tag .sa-provider-tag-icon { font-size: 14px; } /* ====== 设置分区标题 ====== */ .sa-settings-section { margin-bottom: 18px; } /* v1.25.0 数据源授权列表 */ .sa-src-group{margin-bottom:12px;} .sa-src-group-title{font-size:11px;font-weight:700;color:#94a3b8;margin-bottom:5px;display:flex;align-items:center;gap:6px;} .sa-src-group-title .sa-src-count{font-size:10px;font-weight:600;color:#64748b;margin-left:auto;} .sa-src-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px; background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.04);margin-bottom:4px;transition:background .15s,border-color .15s;} .sa-src-row:hover{background:rgba(139,92,246,.07);border-color:rgba(139,92,246,.18);} .sa-src-row.is-off{opacity:.5;} .sa-src-name{font-size:12px;color:#e2e8f0;font-weight:600;white-space:nowrap;} .sa-src-ver{font-size:10px;color:#64748b;font-variant-numeric:tabular-nums;font-family:ui-monospace,Menlo,monospace;} .sa-src-badge{font-size:10px;padding:1px 6px;border-radius:99px;white-space:nowrap;font-weight:600;} .sa-src-badge.ok{background:rgba(34,197,94,.14);color:#4ade80;} .sa-src-badge.off{background:rgba(100,116,139,.18);color:#94a3b8;} .sa-src-badge.old{background:rgba(245,158,11,.15);color:#fbbf24;} .sa-src-badge.none{background:rgba(239,68,68,.13);color:#f87171;} .sa-src-switch{margin-left:auto;position:relative;width:34px;height:19px;flex-shrink:0;cursor:pointer;} .sa-src-switch input{position:absolute;opacity:0;width:0;height:0;} .sa-src-switch .sa-src-track{position:absolute;inset:0;background:rgba(100,116,139,.35);border-radius:99px;transition:background .18s;} .sa-src-switch .sa-src-thumb{position:absolute;top:2px;left:2px;width:15px;height:15px;border-radius:50%;background:#e2e8f0;transition:transform .18s;} .sa-src-switch input:checked + .sa-src-track{background:var(--sglv-accent,#a78bfa);} .sa-src-switch input:checked + .sa-src-track .sa-src-thumb{transform:translateX(15px);} .sa-src-switch input:disabled + .sa-src-track{opacity:.4;cursor:not-allowed;} .sa-settings-section-title { font-size: 13px; color: #c4b5fd; font-weight: 600; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 1px solid rgba(139,92,246,0.1); } /* ====== 综合设置中心(多标签单浮窗) ====== */ .sa-settings-modal { width: 760px; max-width: 94vw; height: 640px; max-height: 88vh; } .sa-set-body { flex: 1; display: flex; min-height: 0; overflow: hidden; } .sa-set-nav { width: 156px; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; padding: 12px 10px; border-right: 1px solid rgba(139,92,246,0.15); background: rgba(15, 23, 42, 0.35); } .sa-set-nav-btn { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border-radius: 8px; border: none; cursor: pointer; background: transparent; color: #94a3b8; font-size: 13px; font-family: inherit; text-align: left; transition: background 0.15s ease, color 0.15s ease; white-space: nowrap; } .sa-set-nav-btn svg { width: 15px; height: 15px; flex-shrink: 0; } .sa-set-nav-btn:hover { background: rgba(139,92,246,0.1); color: #e2e8f0; } .sa-set-nav-btn.sa-active { background: rgba(139,92,246,0.18); color: #c4b5fd; font-weight: 600; box-shadow: inset 2px 0 0 #8b5cf6; } .sa-set-nav-summary { margin-top: auto; padding: 8px 10px; font-size: 10px; color: #64748b; border-top: 1px solid rgba(139,92,246,0.1); line-height: 1.7; } .sa-set-panes { flex: 1; min-width: 0; overflow-y: auto; padding: 16px 18px; } .sa-set-panes::-webkit-scrollbar { width: 5px; } .sa-set-panes::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.3); border-radius: 3px; } .sa-set-pane { display: none; } .sa-set-pane.sa-active { display: block; } .sa-set-pane-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; } .sa-set-pane-title { font-size: 14px; font-weight: 600; color: #f1f5f9; } .sa-set-pane-desc { font-size: 11px; color: #64748b; margin-top: 2px; } .sa-modal-footer { flex-shrink: 0; display: flex; gap: 8px; align-items: center; padding: 12px 18px; border-top: 1px solid rgba(139,92,246,0.15); background: rgba(15, 23, 42, 0.35); } .sa-modal-footer .sa-footer-hint { flex: 1; font-size: 11px; color: #64748b; } /* 检测结果行 */ .sa-check-item { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; margin-bottom: 6px; border-radius: 8px; background: rgba(30, 41, 59, 0.5); border: 1px solid rgba(139,92,246,0.12); font-size: 12px; line-height: 1.5; } .sa-check-item .sa-check-state { flex-shrink: 0; font-weight: 600; } .sa-check-item.sa-ok { border-color: rgba(74, 222, 128, 0.35); } .sa-check-item.sa-ok .sa-check-state { color: #4ade80; } .sa-check-item.sa-err { border-color: rgba(239, 68, 68, 0.35); } .sa-check-item.sa-err .sa-check-state { color: #f87171; } .sa-check-item.sa-warn { border-color: rgba(251, 191, 36, 0.35); } .sa-check-item.sa-warn .sa-check-state { color: #fbbf24; } .sa-check-item .sa-check-detail { color: #94a3b8; flex: 1; min-width: 0; overflow-wrap: anywhere; } /* 模型/智能体内嵌编辑视图返回行 */ .sa-editor-backrow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(139,92,246,0.12); } @media (max-width: 640px) { .sa-settings-modal { width: 96vw; height: 90vh; } .sa-set-body { flex-direction: column; } .sa-set-nav { width: auto; flex-direction: row; overflow-x: auto; border-right: none; border-bottom: 1px solid rgba(139,92,246,0.15); } .sa-set-nav-summary { display: none; } } /* ====== Toast ====== */ .sa-toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000006 !important; padding: 8px 18px; border-radius: 10px; font-size: 13px; color: #fff; background: rgba(15, 23, 42, 0.95) !important; border: 1px solid rgba(139,92,246,0.3) !important; box-shadow: 0 4px 20px rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.3s ease, transform 0.3s ease; pointer-events: none; } .sa-toast.sa-show { opacity: 1; } .sa-toast.sa-error { border-color: rgba(239, 68, 68, 0.4) !important; } .sa-toast.sa-success { border-color: rgba(74, 222, 128, 0.4) !important; } /* ====== 集成按钮 ====== */ .sa-integration-btn { display: inline-flex !important; align-items: center; gap: 4px; padding: 4px 10px !important; border-radius: 6px !important; background: rgba(139, 92, 246, 0.15) !important; border: 1px solid rgba(139, 92, 246, 0.3) !important; color: #c4b5fd !important; font-size: 12px !important; cursor: pointer; transition: all 0.15s ease; font-family: inherit; } .sa-integration-btn:hover { background: rgba(139, 92, 246, 0.25) !important; color: #c4b5fd !important; } /* ====== 欢迎屏幕 ====== */ .sa-welcome { flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding: 20px 16px !important; scrollbar-width: thin; scrollbar-color: rgba(139,92,246,0.3) transparent; } .sa-welcome::-webkit-scrollbar { width: 5px; } .sa-welcome::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.3); border-radius: 3px; } .sa-welcome-greeting { margin-bottom: 18px; } .sa-welcome-greeting h2 { font-size: 20px; font-weight: 700; margin: 0 0 4px; background: linear-gradient(135deg, #c4b5fd, #a78bfa, #f0abfc); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .sa-welcome-greeting p { font-size: 12px; color: #64748b; margin: 0; } .sa-welcome-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; } .sa-stat-card { background: rgba(30, 41, 59, 0.5) !important; border: 1px solid rgba(139,92,246,0.12) !important; border-radius: 12px; padding: 12px 8px; text-align: center; backdrop-filter: blur(8px) !important; -webkit-backdrop-filter: blur(8px) !important; transition: border-color 0.2s ease, transform 0.15s ease, box-shadow 0.15s ease; } .sa-stat-card:hover { border-color: rgba(139,92,246,0.3) !important; transform: translateY(-1px); box-shadow: 0 4px 16px rgba(139,92,246,0.15) !important; } .sa-stat-card .sa-stat-icon { width: 28px; height: 28px; margin: 0 auto 6px; display: flex; align-items: center; justify-content: center; color: #a78bfa; } .sa-stat-card .sa-stat-icon svg { width: 20px; height: 20px; } .sa-stat-card .sa-stat-value { font-size: 20px; font-weight: 800; color: #f1f5f9; font-variant-numeric: tabular-nums; line-height: 1.2; } .sa-stat-card .sa-stat-label { font-size: 10px; color: #64748b; margin-top: 2px; } .sa-welcome-agent { background: linear-gradient(135deg, rgba(139,92,246,0.2), rgba(59,130,246,0.2)) !important; border: 1px solid rgba(139,92,246,0.25) !important; border-radius: 12px; padding: 12px 14px; display: flex; align-items: center; gap: 10px; margin-bottom: 16px; backdrop-filter: blur(8px) !important; -webkit-backdrop-filter: blur(8px) !important; transition: border-color 0.2s ease, box-shadow 0.2s ease; } .sa-welcome-agent:hover { border-color: rgba(139,92,246,0.4) !important; box-shadow: 0 4px 20px rgba(139,92,246,0.2) !important; } .sa-welcome-agent .sa-wa-icon { width: 36px; height: 36px; border-radius: 10px; background: linear-gradient(135deg, #8b5cf6, #3b82f6); display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: white; box-shadow: 0 2px 10px rgba(139,92,246,0.4); } .sa-welcome-agent .sa-wa-icon svg { width: 20px; height: 20px; } .sa-welcome-agent .sa-wa-info { flex: 1; min-width: 0; } .sa-welcome-agent .sa-wa-label { font-size: 10px; color: #c4b5fd; display: flex; align-items: center; gap: 3px; } .sa-welcome-agent .sa-wa-name { font-size: 14px; color: #f1f5f9; font-weight: 600; margin-top: 1px; } .sa-welcome-agent .sa-wa-desc { font-size: 11px; color: #94a3b8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sa-welcome-suggestions { margin-bottom: 8px; } .sa-welcome-suggestions-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .sa-welcome-suggestions-header span { font-size: 12px; color: #c4b5fd; font-weight: 600; display: flex; align-items: center; gap: 4px; } .sa-welcome-suggestions-header span svg { width: 14px; height: 14px; } .sa-suggestion-refresh { background: none; border: none; color: #64748b; cursor: pointer; font-size: 11px; display: flex; align-items: center; gap: 3px; padding: 2px 6px; border-radius: 6px; transition: all 0.15s ease; font-family: inherit; } .sa-suggestion-refresh:hover { color: #c4b5fd; background: rgba(139,92,246,0.1); } .sa-suggestion-refresh svg { width: 12px; height: 12px; } .sa-suggestion-refresh.sa-spin svg { animation: sa-spin 0.6s ease; } @keyframes sa-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .sa-suggestion-list { display: flex; flex-direction: column; gap: 6px; } .sa-suggestion-item { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-radius: 10px; background: rgba(30, 41, 59, 0.4) !important; border: 1px solid rgba(139,92,246,0.08) !important; cursor: pointer; transition: all 0.15s ease; backdrop-filter: blur(6px) !important; -webkit-backdrop-filter: blur(6px) !important; } .sa-suggestion-item:hover { background: rgba(30, 41, 59, 0.7) !important; border-color: rgba(139,92,246,0.3) !important; transform: translateX(2px); box-shadow: 0 2px 12px rgba(139,92,246,0.12) !important; } .sa-suggestion-item .sa-sug-text { flex: 1; font-size: 12px; color: #cbd5e1; line-height: 1.4; } .sa-suggestion-item .sa-sug-btn { width: 26px; height: 26px; border-radius: 7px; background: rgba(139,92,246,0.15); border: none; color: #c4b5fd; cursor: pointer; flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; } .sa-suggestion-item:hover .sa-sug-btn { background: linear-gradient(135deg, #8b5cf6, #3b82f6); color: white; box-shadow: 0 2px 8px rgba(139,92,246,0.4); } .sa-suggestion-item .sa-sug-btn svg { width: 13px; height: 13px; } /* ====== 设置面板增强 ====== */ .sa-settings-section-title { display: flex; align-items: center; gap: 6px; } .sa-settings-section-title svg { width: 15px; height: 15px; color: #c4b5fd; } .sa-model-card .sa-model-icon, .sa-agent-card .sa-agent-icon { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; background: rgba(139,92,246,0.1); } .sa-model-card .sa-model-icon svg, .sa-agent-card .sa-agent-icon svg { width: 18px; height: 18px; } .sa-model-card .sa-mini-btn, .sa-agent-card .sa-mini-btn { width: 26px; height: 26px; border-radius: 6px; background: rgba(30,41,59,0.6) !important; border: 1px solid rgba(139,92,246,0.1) !important; color: #94a3b8 !important; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; } .sa-model-card .sa-mini-btn:hover, .sa-agent-card .sa-mini-btn:hover { background: rgba(139,92,246,0.2) !important; color: #e2e8f0 !important; } .sa-model-card .sa-mini-btn svg, .sa-agent-card .sa-mini-btn svg { width: 13px; height: 13px; } /* ====== 供应商下拉选择 ====== */ .sa-provider-select-wrap { position: relative; } .sa-provider-select-wrap::after { content: ''; position: absolute; right: 10px; top: 50%; width: 7px; height: 7px; border-right: 2px solid #94a3b8; border-bottom: 2px solid #94a3b8; transform: translateY(-70%) rotate(45deg); pointer-events: none; } .sa-provider-select { width: 100%; box-sizing: border-box; appearance: none; -webkit-appearance: none; background: rgba(30, 41, 59, 0.6) !important; border: 1px solid rgba(139,92,246,0.2) !important; border-radius: 8px; color: #e2e8f0 !important; padding: 9px 30px 9px 12px; font-size: 13px; font-family: inherit; outline: none; cursor: pointer; transition: border-color 0.15s ease, box-shadow 0.15s ease; } .sa-provider-select:focus { border-color: rgba(139,92,246,0.6) !important; box-shadow: 0 0 0 3px rgba(139,92,246,0.15) !important; } .sa-provider-select option { background: #1e293b !important; color: #e2e8f0 !important; } /* ====== 历史面板标题SVG ====== */ .sa-history-header h3 { display: flex; align-items: center; gap: 5px; } .sa-history-header h3 svg { width: 15px; height: 15px; color: #c4b5fd; } .sa-model-selector option { background: #1e293b !important; color: #e2e8f0 !important; } /* ====== 图标按钮SVG统一 ====== */ .sa-icon-btn svg { width: 16px; height: 16px; } /* ====== 响应式 ====== */ @media (max-width: 600px) { #sa-panel { width: 100vw !important; } .sa-msg { max-width: 95%; } .sa-welcome-stats { grid-template-columns: repeat(3, 1fr); gap: 6px; } .sa-stat-card { padding: 10px 4px; } .sa-stat-card .sa-stat-value { font-size: 17px; } } `); // ============================================================ // 工具函数 // ============================================================ // 统一安全渲染入口:所有 innerHTML 动态插值(文本节点/属性值)必须经此函数转义后再拼接,防止 XSS function escapeHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function parseStored(raw, fallback) { if (raw === null || raw === undefined || raw === '') return fallback; if (typeof raw === 'object') return raw; try { const val = JSON.parse(raw); return (val === null || val === undefined) ? fallback : val; } catch { return fallback; } } function safeUrl(url) { const u = String(url || '').trim(); return /^(https?:|steam:)/i.test(u) ? u : '#'; } function sanitizeSvgIcon(svg) { try { const doc = new DOMParser().parseFromString(String(svg), 'image/svg+xml'); const root = doc.documentElement; if (!root || root.nodeName.toLowerCase() !== 'svg' || root.querySelector('parsererror')) return ''; const clean = (el) => { for (const attr of [...el.attributes]) { const name = attr.name.toLowerCase(); const val = String(attr.value || '').trim().toLowerCase(); if (name.startsWith('on') || ((name === 'href' || name === 'xlink:href') && val.startsWith('javascript:'))) { el.removeAttribute(attr.name); } } for (const child of [...el.children]) clean(child); }; clean(root); root.querySelectorAll('script, foreignObject').forEach(n => n.remove()); const out = new XMLSerializer().serializeToString(root); return out.startsWith(' { const cells = line.split('|').map(c => c.trim()); // 去掉首尾空字符串(由 | 开头和结尾导致) if (cells[0] === '') cells.shift(); if (cells[cells.length - 1] === '') cells.pop(); return cells; }); } function isTableSeparator(line) { // 匹配 | --- | --- | 或 | :--- | ---: | 等格式 const trimmed = line.trim(); if (!/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?$/.test(trimmed)) return false; // 确保至少有两个分隔列 const pipes = (trimmed.match(/\|/g) || []).length; return pipes >= 2; } function renderMarkdown(text) { if (!text) return ''; let html = escapeHtml(text); // 1. 代码块(优先处理,避免内部内容被其他规则干扰) html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (m, lang, code) => `${code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')}`); // 2. Markdown 表格(GFM 风格) // 匹配格式:表头行 + 分隔行 + 数据行(至少 1 行数据) const lines = html.split('\n'); let result = []; let i = 0; while (i < lines.length) { // 检测是否为潜在的表格起始行(包含 | 且不是代码块内) const currentLine = lines[i]; // 跳过 块内的内容 if (currentLine.includes('')) { // 找到代码块结束位置 result.push(currentLine); i++; while (i < lines.length && !lines[i].includes('')) { result.push(lines[i]); i++; } if (i < lines.length) { result.push(lines[i]); i++; } continue; } // 检测表格:当前行含 | ,下一行是分隔行 if (currentLine.includes('|') && i + 1 < lines.length && isTableSeparator(lines[i + 1])) { const headerLine = currentLine; const sepLine = lines[i + 1]; const dataLines = []; let j = i + 2; // 收集数据行(含 | 的连续行) while (j < lines.length && lines[j].trim() !== '' && lines[j].includes('|') && !lines[j].includes('= 0) { const headers = parseMdTableRows([headerLine])[0] || []; const rows = parseMdTableRows(dataLines); // 解析对齐方式 const sepCells = sepLine.split('|').map(c => c.trim()).filter(c => c !== ''); const aligns = sepCells.map(cell => { const left = cell.startsWith(':'); const right = cell.endsWith(':'); if (left && right) return 'center'; if (right) return 'right'; if (left) return 'left'; return ''; }); let tableHtml = ''; // 表头 tableHtml += ''; headers.forEach((h, idx) => { const align = aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ''; tableHtml += `${h}`; }); tableHtml += ''; // 表体 tableHtml += ''; rows.forEach(row => { tableHtml += ''; row.forEach((cell, idx) => { const align = aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ''; tableHtml += `${cell}`; }); tableHtml += ''; }); tableHtml += ''; result.push(tableHtml); i = j; continue; } } result.push(currentLine); i++; } html = result.join('\n'); // 3. 标题 html = html.replace(/^### (.+)$/gm, '$1'); html = html.replace(/^## (.+)$/gm, '$1'); html = html.replace(/^# (.+)$/gm, '$1'); // 4. 粗体、斜体 html = html.replace(/\*\*(.+?)\*\*/g, '$1'); html = html.replace(/\*(.+?)\*/g, '$1'); // 5. 行内代码 html = html.replace(/`([^`]+)`/g, '$1'); // 6. 链接 html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, text, url) => `${text}`); // 7. 引用 html = html.replace(/^> (.+)$/gm, '$1'); // 8. 无序列表 html = html.replace(/^[\-\*] (.+)$/gm, '$1'); html = html.replace(/([\s\S]*?<\/li>)(?!\s*)/g, '$1'); // 9. 有序列表 html = html.replace(/^\d+\. (.+)$/gm, '$1'); // 10. 段落与换行(跳过表格、代码块等块级元素内的换行) // 先把表格和代码块替换为占位符,避免内部换行被处理 const blockHolders = []; html = html.replace(/[\s\S]*?<\/table><\/div>/g, (m) => { blockHolders.push(m); return `__BLOCK_${blockHolders.length - 1}__`; }); html = html.replace(/[\s\S]*?<\/pre>/g, (m) => { blockHolders.push(m); return `__BLOCK_${blockHolders.length - 1}__`; }); html = html.replace(/\n\n/g, ''); html = html.replace(/\n/g, ''); // 恢复块级元素 html = html.replace(/__BLOCK_(\d+)__/g, (m, idx) => blockHolders[Number(idx)] || ''); if (!html.match(/^<(h\d|pre|ul|ol|blockquote|p|div)/)) 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); } // ============================================================ // 出站请求安全层(防御纵深) // 1. 内网/本机地址拦截(SSRF):工具数据面禁止触达私有网段 // 2. 协议白名单:仅 http(s) // 3. AI 端点防泄漏:工具请求禁止命中用户配置的 AI API 主机,防止工具流量 // 携带上下文发往模型供应商(callAiChat 自身请求不受限,用户显式配置) // ============================================================ function isPrivateHost(hostname) { const h = String(hostname || '').toLowerCase().replace(/\.$/, ''); if (!h) return true; if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.home.arpa')) return true; if (h === '0.0.0.0' || h === '[::1]' || h === '::1') return true; const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (m) { const a = Number(m[1]), b = Number(m[2]); if (a === 0 || a === 10 || a === 127) return true; if (a === 192 && b === 168) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 169 && b === 254) return true; if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT return false; } if (h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80:')) return true; // IPv6 ULA / link-local return false; } function getAiEndpointHosts() { const hosts = new Set(); try { const cfg = getAiConfig(); if (cfg && cfg.apiUrl) { const u = new URL(cfg.apiUrl); if (u.hostname) hosts.add(u.hostname.toLowerCase()); } } catch {} return hosts; } // 校验并解析出站 URL;通过则返回 URL 对象,不通过抛错。allowPrivate/allowAiEndpoint 仅供本地模型连通性测试等显式场景。 function assertSafeRequestUrl(url, opts = {}) { let u; try { u = new URL(String(url)); } catch { throw new Error('无效的请求 URL'); } if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error(`已拦截非 http(s) 协议请求: ${u.protocol}`); if (!opts.allowPrivate && isPrivateHost(u.hostname)) throw new Error(`已拦截对内网/本机地址的请求: ${u.hostname}`); if (!opts.allowAiEndpoint && getAiEndpointHosts().has(u.hostname.toLowerCase())) { throw new Error(`已拦截发往 AI 端点主机的工具请求: ${u.hostname}`); } return u; } function gmFetch(url, options = {}) { assertSafeRequestUrl(url, { allowPrivate: !!options.allowPrivate, allowAiEndpoint: !!options.allowAiEndpoint }); 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) { // withMeta:需要 finalUrl 等元信息(如判断是否被重定向到登录页) resolve(options.withMeta ? resp : 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', ''); } // ===== v1.16.0 联网检索/抓取工具配置 ===== // 端点:默认 DeepSeek 官方 /v1/tools/*;可在设置里改 Base URL 接自建/第三方同协议服务 // Key 解析优先级(严格两级,不做"any key"兜底): // 1) 设置里单独填的「联网工具 Key」(sa_ds_tools_key)——显式覆盖,任何 Base 都适用 // 2) 当前 AI 模型配置的 Key,且其端点确实是 DeepSeek ——"配了 DS 模型就直接复用" // 3) 都没有 → 返回空,由工具返回明确的配置引导 // 刻意不做第 3 种兜底("随便拿个 AI Key 试试"):那会让鉴权失败伪装成"检索无结果", // 且违反"非 DeepSeek Key 不自动挪用"的约定。 function getWebToolsConfig() { const base = String(GM_getValue(SK.DS_TOOLS_BASE, '') || '').trim() || CONFIG.DS_TOOLS_DEFAULT_BASE; let key = String(GM_getValue(SK.DS_TOOLS_KEY, '') || '').trim(); let source = key ? '专用配置' : ''; if (!key) { const ai = getAiConfig(); const host = (() => { try { return new URL(ai.apiUrl || '').hostname.toLowerCase(); } catch { return ''; } })(); if (ai.apiKey && isDeepSeekHost(host)) { key = String(ai.apiKey).trim(); source = 'AI 模型配置(DeepSeek)'; } } return { base: base.replace(/\/+$/, ''), key, source }; } function isDeepSeekHost(hostname) { return /(^|\.)deepseek\.com$/i.test(String(hostname || '')); } // ITAD Key:本脚本存储优先,未配置时复用 SGLV 的 Key(跨脚本共享) function getItadApiKey() { return GM_getValue(SK.ITAD_KEY, '') || GM_getValue('sglv_itad_api_key', ''); } function isSSEResponse(text) { return !!(text && /^\s*data:/i.test(text)); } // 把 OpenAI 兼容的 tool_calls 片段收进 bucket。delta 模式拼接字符串,full 模式整段覆盖。 function absorbToolCallPiece(bucket, tc, mode) { if (!tc || typeof tc !== 'object') return; const idx = Number.isInteger(tc.index) ? tc.index : bucket.size; const cur = bucket.get(idx) || { id: '', name: '', arguments: '' }; const fn = tc.function && typeof tc.function === 'object' ? tc.function : {}; const name = fn.name || tc.name || ''; const args = fn.arguments != null ? fn.arguments : tc.arguments; if (tc.id) cur.id = mode === 'delta' ? cur.id + tc.id : String(tc.id); if (name) cur.name = mode === 'delta' ? cur.name + name : String(name); if (args != null) { if (typeof args === 'object') cur.arguments = args; else if (mode === 'delta' && typeof cur.arguments === 'string') cur.arguments += args; else cur.arguments = args; } bucket.set(idx, cur); } // 增量 SSE 解析工厂:内部维护跨 chunk 缓冲,按空行切事件块,半截 JSON 留在缓冲等下一个 chunk, // 替代旧"每次 onprogress 对全量 responseText 重解析"的 O(n²) 模式。tool_calls 增量按 index 槽位拼接。 function createSseStreamParser() { let buf = ''; let content = ''; let reasoning = ''; let usage = null; let finished = false; let sawFullToolMessage = false; const toolBucket = new Map(); const processBlock = (block) => { for (const line of block.split('\n')) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; const dataStr = trimmed.slice(5).trim(); if (!dataStr) continue; if (dataStr === '[DONE]') { finished = true; continue; } try { const chunk = JSON.parse(dataStr); const choice = chunk.choices?.[0] || {}; const delta = choice.delta || {}; if (delta.content) content += delta.content; // 推理模型(DeepSeek R/v4、GLM-think、QwQ 等)的思维链单独分流, // 不混入正文;content 为空时可据此识别"仅推理"响应 if (delta.reasoning_content) reasoning += delta.reasoning_content; if (chunk.usage && (chunk.usage.prompt_tokens || chunk.usage.completion_tokens || chunk.usage.total_tokens)) { usage = chunk.usage; } const fullCalls = choice.message?.tool_calls; if (Array.isArray(fullCalls) && fullCalls.length) { toolBucket.clear(); sawFullToolMessage = true; fullCalls.forEach((tc, i) => absorbToolCallPiece(toolBucket, { ...tc, index: tc.index ?? i }, 'full')); } if (!sawFullToolMessage && Array.isArray(delta.tool_calls)) { delta.tool_calls.forEach(tc => absorbToolCallPiece(toolBucket, tc, 'delta')); } } catch {} } }; return { feed(text) { if (finished || !text) return; buf += text; let idx; while ((idx = buf.indexOf('\n\n')) !== -1) { const block = buf.slice(0, idx); buf = buf.slice(idx + 2); processBlock(block); } }, // 仅终态调用:把无空行结尾的残余块消化掉;流式过程中勿调(会破坏跨 chunk 边界) flushTail() { if (!buf) return; const block = buf; buf = ''; processBlock(block); }, isFinished: () => finished, snapshot: () => ({ content, reasoning, usage, toolCalls: normalizeNativeToolCalls([...toolBucket.values()]), finished }), }; } // 整段文本一次性解析(终态路径 + 兼容旧调用方/测试) function parseSSEStream(text) { const parser = createSseStreamParser(); parser.feed(String(text || '')); parser.flushTail(); const { content, reasoning, usage, toolCalls } = parser.snapshot(); return { content, reasoning, usage, toolCalls }; } 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 || '', }; }, }; // 返回 { content, toolCalls, streamed }。无 tools 且正文为空时仍按原逻辑抛错,避免提示词生成等调用方收到空串。 async function callAiChat(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') { // 防御性清洗:本脚本走 ReAct 单 prompt 协议,从不向 API 发送 tool 角色消息; // 历史只回传纯文本并剥离内嵌工具标记,杜绝孤儿 tool_calls / 残留标记触发协议 400 messages.push({ role: m.role, content: m.role === 'assistant' ? cleanToolCallMarkers(String(m.content || '')) : String(m.content || '') }); } } } messages.push({ role: 'user', content: prompt }); // isolated: 独立请求(如智能体提示词生成),不注册 activeAiRequest、不受聊天停止按钮影响 const isolated = !!opts.isolated; const aborted = () => !isolated && abortFlag; const useStream = !!opts.onChunk; const nativeTools = Array.isArray(opts.tools) && opts.tools.length ? opts.tools : null; let streamedContent = ''; let gotStream = false; let reqRef = null; // 增量流解析状态:onprogress 只把新增字节喂给 parser,终态 onload 复用同一实例 let sseParser = null; let sseConsumed = 0; const feedSseParser = (text, isFinal) => { if (!sseParser) sseParser = createSseStreamParser(); if (text.length > sseConsumed) { sseParser.feed(text.slice(sseConsumed)); sseConsumed = text.length; } if (isFinal) sseParser.flushTail(); return sseParser.snapshot(); }; const reqBody = { model: cfg.model, messages, temperature: opts.temperature ?? 0.7, stream: useStream, }; if (nativeTools) { reqBody.tools = nativeTools; reqBody.tool_choice = opts.toolChoice || 'auto'; } // 仅在显式传入正数时才发送 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') + (nativeTools ? JSON.stringify(nativeTools) : '')); 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 finish = (content, toolCalls, extraUsage) => { const calls = Array.isArray(toolCalls) ? toolCalls : []; recordTokenUsage(extraUsage, content || calls.map(c => c.name).join(',')); return { content: content || '', toolCalls: calls, streamed: gotStream }; }; const p = new Promise((resolve, reject) => { // 推理模型偶发"整轮流式只产出 reasoning_content,正文与工具调用皆空"(finish_reason=stop)。 // 策略:识别后用完全相同的请求体透明重试一次;重试仍为空则报明确的 reasoning 错误。 let reqRetried = false; const resetSseState = () => { sseParser = null; sseConsumed = 0; streamedContent = ''; gotStream = false; }; const xhrParams = { 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 { content: newContent } = feedSseParser(text, false); if (newContent && newContent !== streamedContent) { streamedContent = newContent; gotStream = true; 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, reasoning, usage, toolCalls } = feedSseParser(text, true); const cleaned = stripThinkTags(content); const calls = toolCalls || []; if (!cleaned && !calls.length) { if (!reqRetried && !aborted()) { reqRetried = true; resetSseState(); console.warn('[SA-Agent] 模型仅返回推理内容(reasoning)无正文/工具调用,透明重试一次'); reqRef = GM_xmlhttpRequest(xhrParams); if (!isolated) activeAiRequest = reqRef; return; } if (reasoning) { const err = new Error('模型仅返回了推理内容(reasoning)未产出正文,请重试或更换为非推理模型'); err.isReasoningOnly = true; reject(err); return; } reject(new Error('AI 返回为空')); return; } if (useStream && opts.onChunk && content && content !== streamedContent) { gotStream = true; opts.onChunk(content); } resolve(finish(cleaned, calls, usage)); return; } const data = JSON.parse(text); const choice = data.choices?.[0]; const msg = choice?.message; const content = stripThinkTags(msg?.content || ''); const calls = normalizeNativeToolCalls(msg?.tool_calls); if (!content && !calls.length) { if (msg?.reasoning_content) { if (!reqRetried && !aborted()) { reqRetried = true; console.warn('[SA-Agent] 模型仅返回推理内容(reasoning)无正文/工具调用,透明重试一次'); reqRef = GM_xmlhttpRequest(xhrParams); if (!isolated) activeAiRequest = reqRef; return; } const err = new Error('模型仅返回了推理内容(reasoning)未产出正文,请重试或更换为非推理模型'); err.isReasoningOnly = true; reject(err); return; } if (choice?.finish_reason === 'length') { reject(new Error('AI 输出被 max_tokens 截断,请重试或调大 token 上限')); return; } reject(new Error('AI 返回为空')); return; } resolve(finish(content, calls, data.usage)); } catch (e) { reject(new Error('AI 响应解析失败: ' + e.message)); } }, onerror: () => { if (aborted()) { if (useStream && streamedContent) resolve(finish(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 请求超时')); }, }; reqRef = GM_xmlhttpRequest(xhrParams); if (!isolated) activeAiRequest = reqRef; }); return p.finally(() => { if (!isolated && activeAiRequest === reqRef) activeAiRequest = null; }); } async function callAiApi(prompt, opts = {}) { const result = await callAiChat(prompt, opts); return result.content; } 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; } try { // 远程地址用户可自定义,同样走出站安全断言(仅拦内网与非 http(s) 协议) assertSafeRequestUrl(url); } catch (e) { reject(e); 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: '查评价、折扣和购买建议;也能读取本机其他 Steam 脚本的缓存,并核对插件是否装齐、版本是否过旧', icon: 'gamepad', keywords: ['steam', '游戏', '评价', '推荐', '打折', '分析', '购买'], builtin: true, systemPrompt: `你是 Steam 游戏助手,精通 Steam 平台的游戏评价、玩家数据分析和购买建议。 你的能力: 1. 游戏评价:基于 Steam Store 数据(好评率、价格、类型)给出客观评价 2. 玩家数据分析:分析玩家的游戏库、游玩时长、消费习惯 3. 购买建议:根据用户偏好和预算推荐游戏 4. 打折分析:判断当前折扣是否值得入手 5. 借用其他插件的本地缓存:游戏库时长、好友、家庭库、徽章、市场、许可来源、点数和消费汇总,优先调用 readPluginCache,不要先去打 Steam 接口 6. 插件安装核对:用户问装了哪些脚本、有没有装齐、版本是否过旧时,调用 getInstalledPlugins,按工具返回的「已装 / 未装 / 无法判断」口径回答,不要把无法判断说成未安装 回答规范: - 使用 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, // 工具子集授权:null/空 = 可用全部工具(未来新增工具默认可用) tools: (Array.isArray(data.tools) && data.tools.length) ? data.tools.filter(n => typeof n === 'string') : 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 }; // merge 后归一化 tools:非数组/空数组统一为 null(= 全部工具),确保"改回全部"能覆盖旧子集 if (!Array.isArray(agents[idx].tools) || !agents[idx].tools.length) agents[idx].tools = null; 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); } }, }; // ============================================================ // 工具注册表 // ============================================================ // 工具结果长度治理:超大结果(如全量游戏库)在回灌模型前截断, // 数组保留尽可能多的条目并附截断说明,防止撑爆上下文窗口。 function truncateToolResult(result) { if (result == null || typeof result !== 'object') return result; const max = CONFIG.TOOL_RESULT_MAX_CHARS; let json; try { json = JSON.stringify(result); } catch { return { success: false, error: '工具结果序列化失败' }; } if (json.length <= max) return result; const clipped = { ...result }; if (Array.isArray(result.data)) { // 数组:贪心保留条目,预留 600 字符给其余字段与截断说明 const kept = []; let used = 0; for (const item of result.data) { let len; try { len = JSON.stringify(item ?? null).length + 1; } catch { break; } if (used + len > max - 600) break; kept.push(item); used += len; } clipped.data = kept; clipped.truncated = true; clipped.originalCount = result.data.length; clipped.note = `结果共 ${result.data.length} 条,因长度限制仅保留前 ${kept.length} 条,请缩小查询范围(减少数量/增加过滤条件)后重试`; } else if (result.data != null) { clipped.data = truncateMiddle(typeof result.data === 'string' ? result.data : JSON.stringify(result.data), Math.max(500, max - 600)); clipped.truncated = true; clipped.note = `结果过长(${json.length} 字符)已截断,请缩小查询范围后重试`; } else { // 无 data 字段的杂型结果:整体截断为文本 return { success: result.success !== false, truncated: true, error: `结果过长(${json.length} 字符)已截断`, data: truncateMiddle(json, max), }; } return clipped; } 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()); }, // 工具可用性门:`enabled` 是可选谓词,false = 该工具在当前环境不可用 //(如 searchLocalDb 需要已配置远程数据中枢地址)。不可用的工具**不进 LLM 工具面**, // 避免把注定失败的工具喂给模型,再让它给用户一条走不通的引导。 _usable(t) { if (typeof t.enabled !== 'function') return true; try { return !!t.enabled(); } catch (e) { return true; } // 判定异常按可用处理(维持旧行为) }, // 过滤顺序:① 按智能体授权的工具名列表过滤(未传/为空 = 全部) // ② 过滤后为空(配置异常)回退全部 // ③ 按 enabled 裁掉当前不可用的工具(裁完可为空 = 真没工具, // 由 runAgentLoop 的 `nativeTools = openaiTools.length > 0` 自然降级文本协议) pickTools(toolNames) { const all = this.getAll(); let base = all; if (Array.isArray(toolNames) && toolNames.length) { const set = new Set(toolNames); const filtered = all.filter(t => set.has(t.name)); base = filtered.length ? filtered : all; } return base.filter(t => this._usable(t)); }, getToolsDescription(toolNames) { const tools = this.pickTools(toolNames); if (!tools.length) return '(无可用工具)'; return tools.map(t => `【${t.name}】${t.description}\n 参数: ${JSON.stringify(t.parameters.properties)}` ).join('\n\n'); }, toOpenAITools(toolNames) { return this.pickTools(toolNames).map(t => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters || { type: 'object', properties: {} }, }, })); }, async execute(name, args) { const tool = this.get(name); if (!tool) return { success: false, error: `工具 "${name}" 不存在` }; try { const result = await Promise.race([ tool.execute(args), new Promise((_, reject) => setTimeout(() => reject(new Error('工具执行超时')), CONFIG.TOOL_TIMEOUT) ), ]); return truncateToolResult(result); } 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 时自动使用当前登录用户;也可查询他人的公开愿望单。返回游戏名、折扣百分比、原价、现价、愿望单优先级。数据源:IWishlistService + IStoreBrowseService(2026-07 改版后的现行接口,单次全量无分页截断)', 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 } = 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)); try { // 1. 归一 SteamID64:17 位直用;自定义 ID 优先 ResolveVanityURL(需 Key),失败回退 community 愿望单页 HTML 提取 let id64 = steamId; if (!/^\d{17}$/.test(steamId)) { id64 = null; const apiKey = getSteamApiKey(); if (apiKey) { try { id64 = await resolveVanityToId64(steamId, apiKey); } catch {} } if (!id64) { try { const html = await gmFetch(`https://steamcommunity.com/id/${encodeURIComponent(steamId)}/wishlist`, { timeout: 15000 }); const m = String(html).match(/"steamid":"(7656\d{13})"/) || String(html).match(/g_steamID\s*=\s*"(7656\d{13})"/); if (m) id64 = m[1]; } catch {} } if (!id64) return { success: false, error: `自定义 ID "${steamId}" 未解析到 SteamID64(可能不存在)` }; } // 2. 全量愿望单:IWishlistService 匿名可用、单次返回全部(旧 wishlistdata 分页端点已于 2026-07 下线,302 到商店首页) const wlResp = await gmFetch(`https://api.steampowered.com/IWishlistService/GetWishlist/v1/?steamid=${id64}`, { timeout: 20000 }); const wlData = JSON.parse(wlResp); const wlItems = Array.isArray(wlData.response?.items) ? wlData.response.items : []; if (!wlItems.length) { return { success: true, data: { steamId: id64, totalInWishlist: 0, games: [], note: '愿望单为空,或为私密状态(两者在 API 层面不可区分;若预期有内容请确认 Steam 隐私设置中「游戏详情」为公开)' }, summary: '愿望单为空或私密', }; } // 3. IStoreBrowseService/GetItems 批量富集(100 appid/批 × 3 路并发)拿名称/价格/折扣;个别批次失败跳过 const MAX_ENRICH = 3000; const ids = wlItems.slice(0, MAX_ENRICH).map(x => ({ appid: x.appid })); const batches = []; for (let i = 0; i < ids.length; i += 100) batches.push(ids.slice(i, i + 100)); const storeItems = new Map(); let failedBatches = 0; await mapLimit(batches, 3, async (batch) => { const inputJson = JSON.stringify({ ids: batch, context: { language: 'schinese', country_code: getStoreCountryCode(), steam_realm: 1 }, data_request: { include_release: true, include_all_purchase_options: true }, }); try { const resp = await gmFetch(`https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=${encodeURIComponent(inputJson)}`, { timeout: 20000 }); const data = JSON.parse(resp); for (const it of (data.response?.store_items || [])) storeItems.set(it.appid, it); } catch { failedBatches++; } }); // 4. 归一化:折扣 = discount_pct > 0,或 active_discounts 非空(促销中但未披露百分比) const prioById = new Map(wlItems.map(x => [x.appid, Number(x.priority) || 0])); const normalized = wlItems.map(wl => { const it = storeItems.get(wl.appid); const bpo = it?.best_purchase_option || {}; const discount = Number(bpo.discount_pct ?? 0) || 0; const hasActive = Array.isArray(bpo.active_discounts) && bpo.active_discounts.length > 0; const finalCents = Number(bpo.final_price_in_cents ?? -1); const originalCents = Number(bpo.original_price_in_cents ?? -1); return { appid: wl.appid, name: it?.name || `AppID ${wl.appid}`, discount, promoWithoutPct: hasActive && discount === 0, finalPrice: (it?.is_free || finalCents === 0) ? '免费' : (bpo.formatted_final_price || (finalCents > 0 ? formatCents(finalCents, 'CNY') : '')), originalPrice: originalCents > 0 ? formatCents(originalCents, 'CNY') : (discount > 0 && finalCents > 0 ? formatCents(Math.round(finalCents / (1 - discount / 100)), 'CNY') : ''), priority: prioById.get(wl.appid) || 0, comingSoon: !!(it?.is_coming_soon || it?.release?.is_coming_soon), url: `https://store.steampowered.com/app/${wl.appid}`, }; }); const discounted = normalized .filter(g => g.discount > 0 || g.promoWithoutPct) .sort((a, b) => b.discount - a.discount || a.priority - b.priority); const list = (onlyDiscounted ? discounted : normalized).slice(0, limit); const data = { steamId: id64, totalInWishlist: normalized.length, discountedCount: discounted.length, games: list, }; if (failedBatches > 0) data.note = `${failedBatches} 个价格批次获取失败,部分条目可能缺价格信息`; if (wlItems.length > MAX_ENRICH) 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}%),返回 ${list.length} 款` }; } 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 {} } // v1.16.0 联网检索工具连通性检测:真打一次 search,拿到结果才算通 async function testWebToolsConnection() { const cfg = getWebToolsConfig(); if (!cfg.key) return { success: false, error: '未配置 Key:请填入 DeepSeek Key,或在「AI 设置」中把模型配成 DeepSeek 以自动复用' }; const r = await callWebTools('search', { query: 'Steam' }); if (!r.ok) return { success: false, error: r.error }; const n = Array.isArray(r.data && r.data.results) ? r.data.results.length : 0; return n > 0 ? { success: true, note: `检索连通正常,返回 ${n} 条结果(接口 ${cfg.base},Key 来源「${cfg.source}」)` } : { success: false, error: '接口可达但未返回任何结果,请确认该服务是否实现了 /search 端点' }; } 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 }; } } // 从 URL 中提取主机名(含端口),用于诊断提示 function extractHostFromUrl(url) { try { const u = new URL(url); return u.hostname + (u.port ? ':' + u.port : ''); } catch { return ''; } } // 判断 URL 是否指向 OpenAI 兼容的 chat/completions 端点 function looksLikeChatCompletions(url) { return /\/v\d*\/chat\/completions/i.test(url) || /\/chat\/completions/i.test(url); } // 诊断连接错误:把原始异常 + 用户填写的 URL 一起分析,返回多行诊断文案 function diagnoseConnectError(url, err) { const msg = String(err?.message || ''); const host = extractHostFromUrl(url); const lines = []; const httpMatch = msg.match(/HTTP (\d+)/); if (httpMatch) { const code = httpMatch[1]; if (code === '401' || code === '403') { lines.push('API Key 无效或无权限 (鉴权失败)'); } else if (code === '404') { lines.push('API 地址返回 404,请检查端点路径'); if (!looksLikeChatCompletions(url)) { lines.push('提示:地址未包含 /chat/completions,可能不是 OpenAI 兼容端点'); } } else if (code === '429') { lines.push('请求频率超限 (429),请稍后重试'); } else if (code === '400' || code === '422') { lines.push('请求被拒绝 (多为模型名称不正确或参数格式错误)'); } else if (code === '500' || code === '502' || code === '503') { lines.push(`服务端异常 (HTTP ${code}),目标 API 可能正在维护或过载`); } else { lines.push(`HTTP ${code}: ${msg.slice(0, 150)}`); } } else if (msg.includes('超时')) { lines.push('连接超时:网络不可达或响应太慢'); if (host) lines.push(`目标主机: ${host}`); } else if (msg.includes('网络错误')) { lines.push('请求被脚本管理器拦截(onerror)'); if (host) lines.push(`目标主机: ${host}`); lines.push('可能原因:域名未在 @connect 白名单中,或目标服务不可达'); lines.push('已添加 @connect * 后仍失败 → 检查 URL 拼写、网络连通性、目标服务是否运行'); } else if (msg.startsWith('Unexpected token') || msg.includes('JSON.parse')) { lines.push('响应体不是有效 JSON(收到 HTML 页面)'); if (host) lines.push(`目标主机: ${host}`); lines.push('可能原因:API 地址错误,返回了登录页/错误页/404 HTML'); if (!looksLikeChatCompletions(url)) { lines.push('提示:地址未包含 /chat/completions,请确认端点路径'); } } else { lines.push(msg || '未知错误'); } return lines.join('\n'); } // 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(); let resp; try { 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, // 用户显式配置的模型端点:允许本地模型(Ollama/LM Studio 等)与端点自身 allowPrivate: true, allowAiEndpoint: true, }); } catch (e) { e._diagnoseUrl = model.apiUrl; throw e; } let data; try { data = JSON.parse(resp); } catch { const preview = String(resp).slice(0, 200); const err = new Error(`Unexpected token '<', "${preview}" is not valid JSON`); err._diagnoseUrl = model.apiUrl; throw err; } 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 请求异常翻译为用户可读的诊断文案(优先使用 diagnoseConnectError 多行诊断) function describeModelError(e) { const msg = String(e?.message || ''); const url = e?._diagnoseUrl || ''; if (url) return diagnoseConnectError(url, e); 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 }; }, }); // ============================================================ // v1.12.0 插件缓存桥接(plugin-cache-bridge)阶段 1 // 读取本机其他 Steam 油猴插件写入的 IndexedDB / localStorage 缓存。 // // 设计要点(依据 docs/plugin-cache-bridge-plan.md): // 1. GM_setValue/GM_getValue 按脚本沙箱隔离,任何脚本都读不到其他脚本的 GM 存储, // 所以跨脚本复用的只有 IndexedDB 与 localStorage(同源共享)。 // 2. 全程只读:indexedDB.open(name) 一律不带版本号(避免触发 upgradeneeded 改库), // localStorage 只 getItem;不写、不删任何非本脚本的存储。 // 3. 每个缓存键自带 digest 函数做摘要裁剪,MB 级键(如 sfd_og_)禁止整读。 // 4. 域感知:store 与 community 的缓存互不可见,不匹配时明确提示而非静默返回空。 // ============================================================ const PC_DOMAIN_STORE = 'store'; const PC_DOMAIN_COMMUNITY = 'community'; // 当前页面所属域:store.steampowered.com → store;steamcommunity.com → community function pcCurrentDomain() { const h = String(location.hostname || ''); if (h === 'store.steampowered.com' || h.endsWith('.steampowered.com')) return PC_DOMAIN_STORE; if (h === 'steamcommunity.com' || h.endsWith('.steamcommunity.com')) return PC_DOMAIN_COMMUNITY; return ''; } // 把宿主插件的时间戳折算成"多少分钟前",用于提示模型声明数据新鲜度 function pcCacheAgeMinutes(value) { if (value == null || typeof value !== 'object') return null; const candidates = [value.updatedAt, value.ts, value.timestamp, value._ts, value.expires, value.cachedAt]; for (const c of candidates) { const n = Number(c); if (Number.isFinite(n) && n > 0) { const ms = n < 1e12 ? n * 1000 : n; // 兼容秒级时间戳 const diff = Date.now() - ms; if (diff < 0) continue; return Math.round(diff / 60000); } } return null; } // ---- 只读 IndexedDB 访问 ------------------------------------------------- // 不带版本号打开:若库不存在则 onsuccess 拿到空库,getAllKeys 抛错由调用方转成"缓存不存在"。 function pcIdbOpen(dbName) { return new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined' || !indexedDB) { reject(new Error('当前环境不支持 IndexedDB')); return; } let req; try { req = indexedDB.open(dbName); } catch (e) { reject(e); return; } req.onerror = () => reject(req.error || new Error('IndexedDB 打开失败')); // 刻意不设置 onupgradeneeded:不带版本号打开时不会触发升级 req.onsuccess = () => resolve(req.result); }); } function pcIdbGetAllKeys(db, storeName) { return new Promise((resolve, reject) => { try { if (!db.objectStoreNames.contains(storeName)) { resolve([]); return; } const tx = db.transaction(storeName, 'readonly'); const req = tx.objectStore(storeName).getAllKeys(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); } catch (e) { reject(e); } }); } function pcIdbGet(db, storeName, key) { return new Promise((resolve, reject) => { try { const tx = db.transaction(storeName, 'readonly'); const req = tx.objectStore(storeName).get(key); req.onsuccess = () => resolve(req.result === undefined ? null : req.result); req.onerror = () => reject(req.error); } catch (e) { reject(e); } }); } function pcIdbGetAll(db, storeName) { return new Promise((resolve, reject) => { try { if (!db.objectStoreNames.contains(storeName)) { resolve([]); return; } const tx = db.transaction(storeName, 'readonly'); const req = tx.objectStore(storeName).getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); } catch (e) { reject(e); } }); } function pcLocalGet(key) { try { const raw = localStorage.getItem(key); if (raw == null) return null; try { return JSON.parse(raw); } catch { return raw; } } catch { return null; } } // localStorage 前缀枚举(key() 逐个遍历,SME 的 sme_cache_ 有 LRU 数量有限) function pcLocalKeysWithPrefix(prefix) { const out = []; try { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(prefix)) out.push(k); } } catch {} return out; } // ---- IDB 存储信封解包(v1.14.0)------------------------------------------- // 部分宿主(如 SBC 徽章增强)写入 IDB 时套了一层 TTL 信封: // IDB.set → { key, data: <载荷>, updatedAt, expiresAt? } // IDB.get → return record.data ← 宿主自己脱掉信封 // 而本工具是用原生 objectStore.get() 只读,拿到的必然是**完整信封**。 // 若不剥掉,digest 收到的形状全对不上 → 全部 drifted。 // // 为什么用 envelope:true 显式声明,而不是按形状猜: // FA 的 fa_value_insights_cache 本身就是裸值但带 updatedAt 字段, // 靠猜必然误判。只有宿主自己知道它套没套信封。 function pcUnwrapEnvelope(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { payload: value, updatedAt: null, expiresAt: null, expired: false }; } // 信封判据:同时具备 data 载荷槽 + 至少一个宿主写入时才会带的元字段 const looksLikeEnvelope = Object.prototype.hasOwnProperty.call(value, 'data') && ('updatedAt' in value || 'expiresAt' in value || 'key' in value); if (!looksLikeEnvelope) { return { payload: value, updatedAt: null, expiresAt: null, expired: false }; } const expiresAt = Number(value.expiresAt) || 0; return { payload: value.data, updatedAt: Number(value.updatedAt) || 0, expiresAt, expired: expiresAt > 0 && Date.now() > expiresAt, }; } // 解析某键实际使用的 objectStore:优先 per-key 声明,回退插件级(向后兼容) function pcStoreOf(reg, spec) { return (spec && spec.store) || (reg.idb && reg.idb.store) || null; } // ---- digest 摘要函数 ----------------------------------------------------- // 每个 digest 接收完整值 + {topN, fields},返回裁剪后的摘要。 // 硬约束:输出必须显著小于 CONFIG.TOOL_RESULT_MAX_CHARS,MB 级键只出摘要不出明细。 function pcPick(obj, fields) { if (!fields || !Array.isArray(fields) || !fields.length || obj == null || typeof obj !== 'object') return obj; const out = {}; let hit = 0; for (const f of fields) { if (Object.prototype.hasOwnProperty.call(obj, f)) { out[f] = obj[f]; hit++; } } return hit ? out : obj; } // 通用数组摘要:总条数 + 前 topN 条(可字段裁剪) function pcGenericArrayDigest(value, ctx) { const arr = Array.isArray(value) ? value : null; if (!arr) { // 非数组:当作对象/标量,给出键列表概览 if (value && typeof value === 'object') { const keys = Object.keys(value); return { kind: 'object', keyCount: keys.length, keysSample: keys.slice(0, 30), hint: '非数组结构,如需明细请用 inspect 模式查看结构', }; } return { kind: 'scalar', value }; } const fields = (ctx.fields || '').split(',').map(s => s.trim()).filter(Boolean); return { kind: 'array', total: arr.length, items: arr.slice(0, ctx.topN).map(x => pcPick(x, fields)), }; } // 游戏库摘要:Steam OwnedGames 结构 {appid, name, playtime_forever(分钟), rtime_last_played} function pcGamesDigest(value, ctx) { const arr = Array.isArray(value) ? value : (value && Array.isArray(value.games) ? value.games : null); if (!arr) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const totalMinutes = arr.reduce((s, g) => s + (Number(g && g.playtime_forever) || 0), 0); const sorted = arr.slice().sort((a, b) => (Number(b && b.playtime_forever) || 0) - (Number(a && a.playtime_forever) || 0)); return { kind: 'ownedGames', totalGames: arr.length, totalPlaytimeHours: Math.round(totalMinutes / 6) / 10, topGames: sorted.slice(0, ctx.topN).map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtimeHours: Math.round((Number(g.playtime_forever) || 0) / 6) / 10, lastPlayed: g.rtime_last_played || 0, })), note: '按游玩时长降序返回 topN;如需某款具体游戏,可先取 topGames 再用 getGameDetail 补详情', }; } // 逐好友全库摘要(MB 级):只出总数/总时长/topN,禁止整读 function pcOwnedGamesDigest(value, ctx) { if (!value || typeof value !== 'object' || !Array.isArray(value.games)) { return { drifted: true, kind: 'unknown', sample: pcGenericArrayDigest(value, { topN: 3, fields: '' }) }; } const games = value.games; const sorted = games.slice().sort((a, b) => (Number(b && b.playtime_forever) || 0) - (Number(a && a.playtime_forever) || 0)); return { kind: 'ownedGamesFull', steamId: value.sid || null, gameCount: value.game_count ?? games.length, totalMinutes: Number(value.totalMinutes) || games.reduce((s, g) => s + (Number(g && g.playtime_forever) || 0), 0), isPrivate: value.private === true, cachedAt: value.ts || null, topGames: sorted.slice(0, Math.min(ctx.topN, 50)).map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtimeHours: Math.round((Number(g.playtime_forever) || 0) / 6) / 10, })), note: 'MB 级缓存仅返回摘要与 topN,未整读明细', }; } // 好友列表摘要 function pcFriendsDigest(value, ctx) { const arr = Array.isArray(value) ? value : (value && Array.isArray(value.friends) ? value.friends : null); if (!arr) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; return { kind: 'friends', totalFriends: arr.length, friends: arr.slice(0, ctx.topN).map(f => ({ steamId: f.steamid || f.steamId || null, name: f.personaname || f.name || null, personaname: f.personaname || f.name || null, friendSince: f.friend_since || null, avatar: f.avatarfull || f.avatarmedium || null, })), }; } // 家庭共享库摘要:{steamid, familyInfo{family_member[]}, familyGameList[], timestamp} function pcFamilyDigest(value, ctx) { if (!value || typeof value !== 'object') return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const fi = value.familyInfo || {}; const members = Array.isArray(fi.family_member) ? fi.family_member : []; const gameList = Array.isArray(value.familyGameList) ? value.familyGameList : []; const gameInfo = value.familyGameList && !Array.isArray(value.familyGameList) && typeof value.familyGameList === 'object' ? value.familyGameList : null; const detail = gameInfo ? Object.entries(gameInfo) : gameList.slice(0, ctx.topN).map(g => [g && g.appid, g]); return { kind: 'familySharedLibrary', familyGroupId: fi.family_groupid || null, memberCount: members.length, members: members.slice(0, ctx.topN).map(m => ({ steamId: m.steamid, name: m.userName || null, role: m.family_role || null })), sharedGameCount: detail.length, sharedGames: detail.slice(0, ctx.topN).map(([appid, g]) => ({ appid, name: (g && g.name) || `App ${appid}`, owners: g && Array.isArray(g.owners) ? g.owners.length : null, })), cachedAt: value.timestamp || null, }; } // 家庭价值洞察摘要:{priceData:{appid:[fp,op,disc,isFree,isComingSoon]}, playtimeData:{appid:[total,recent]}, updatedAt, ts} function pcValueInsightsDigest(value, ctx) { if (!value || typeof value !== 'object' || !value.priceData) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const price = value.priceData || {}; const pt = value.playtimeData || {}; const appids = Array.from(new Set([...Object.keys(price), ...Object.keys(pt)])); const rows = appids.map(appid => { const p = price[appid]; const t = pt[appid]; return { appid, finalPrice: Array.isArray(p) ? (Number(p[0]) || 0) / 100 : null, discountPct: Array.isArray(p) ? (Number(p[2]) || 0) : null, isFree: Array.isArray(p) ? !!p[3] : null, totalHours: Array.isArray(t) ? Math.round((Number(t[0]) || 0) / 6) / 10 : null, }; }); rows.sort((a, b) => (b.totalHours || 0) - (a.totalHours || 0)); return { kind: 'familyValueInsights', totalApps: appids.length, topByPlaytime: rows.slice(0, ctx.topN), updatedAt: value.updatedAt || value.ts || null, note: '价格单位为元(源数据为分),时长为小时', }; } // 许可指纹摘要:SLC 的 "日期||游戏名||类型" 字符串数组 function pcLicenseDigest(value, ctx) { const arr = Array.isArray(value) ? value : null; if (!arr) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const parsed = arr.map(s => { const parts = String(s).split('||'); return { date: parts[0] || '', name: parts[1] || '', type: parts[2] || '' }; }); const byType = {}; for (const p of parsed) byType[p.type || '未知'] = (byType[p.type || '未知'] || 0) + 1; const sorted = parsed.slice().sort((a, b) => String(b.date).localeCompare(String(a.date))); return { kind: 'licenseFingerprints', total: parsed.length, byType, licenses: sorted.slice(0, ctx.topN), note: '来源为 Steam 许可页本地缓存(7 天 TTL);数字键 slc_license_total 为总记录数', }; } // 标量摘要 function pcScalarDigest(value) { return { kind: 'scalar', value }; } // 市场缓存摘要:SME 的 {ts, data} 信封 function pcMarketCacheDigest(value, ctx) { if (!value || typeof value !== 'object' || !('ts' in value) || !('data' in value)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const inner = value.data; const summary = { kind: 'marketCache', cachedAt: value.ts, dataShape: Array.isArray(inner) ? `array(${inner.length})` : typeof inner }; if (Array.isArray(inner)) { summary.sample = inner.slice(0, ctx.topN); summary.total = inner.length; } else if (inner && typeof inner === 'object') { const keys = Object.keys(inner); summary.keys = keys.slice(0, 20); summary.keyCount = keys.length; } return summary; } // 库存摘要:SME 的 { [key]: {ts, data: [[k,v],...]} } function pcInventoryDigest(value, ctx) { if (!value || typeof value !== 'object') return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const groups = Object.keys(value); const rows = groups.slice(0, Math.min(ctx.topN, 10)).map(k => { const g = value[k]; const arr = g && Array.isArray(g.data) ? g.data : []; return { group: k, cachedAt: g && g.ts, itemCount: arr.length }; }); return { kind: 'inventory', groupCount: groups.length, groups: rows, note: '库存明细体积大,仅返回分组与条目数' }; } // 徽章增强(SBC)好友排行榜摘要。 // 载荷 {rows:[{steamId,badgeCount,level,xp,name,avatar,pointsItems}], fetchedAt} // badgeCount = -1 表示该好友资料私密(GetBadges 403),宿主会把这类沉底 // pointsItems= null 未抓取 / -1 私密 / 数字为点数商店装扮数(v1.10.0 新增维度) // 排序语义对齐宿主 sortFriendsLbRows:私密永远沉底,其余按指定维度降序。 const PC_LB_DIMENSIONS = { badge: { field: 'badgeCount', label: '徽章数' }, level: { field: 'level', label: '徽章等级' }, xp: { field: 'xp', label: '经验值' }, points: { field: 'pointsItems', label: '点数商店装扮数' }, }; function pcFriendsLbDigest(value, ctx) { if (!value || typeof value !== 'object' || !Array.isArray(value.rows)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const rows = value.rows; // 用户可用 fields 传 'level' / 'xp' / 'points' 指定排序维度(缺省按徽章数) const dimKey = String(ctx.fields || 'badge').trim(); const dim = PC_LB_DIMENSIONS[dimKey] || PC_LB_DIMENSIONS.badge; const val = (r) => { const v = r[dim.field]; if (dim.field === 'pointsItems' && v == null) return -2; // 未抓取沉底(对齐宿主) return Number(v) || 0; }; const sorted = rows.slice().sort((a, b) => { const ap = Number(a.badgeCount) < 0, bp = Number(b.badgeCount) < 0; if (ap !== bp) return ap ? 1 : -1; // 私密沉底 return (val(b) - val(a)) || ((Number(b.badgeCount) || 0) - (Number(a.badgeCount) || 0)); }); const normal = rows.filter(r => Number(r.badgeCount) >= 0); const priv = rows.length - normal.length; const ptsFetched = rows.filter(r => r.pointsItems != null && Number(r.pointsItems) >= 0); return { kind: 'friendsBadgeLeaderboard', sortBy: dim.label, totalFriends: rows.length, withBadges: normal.length, privateProfiles: priv, summary: { totalBadges: normal.reduce((s, r) => s + (Number(r.badgeCount) || 0), 0), avgBadges: normal.length ? Math.round(normal.reduce((s, r) => s + (Number(r.badgeCount) || 0), 0) / normal.length * 10) / 10 : 0, maxBadges: normal.length ? Math.max(...normal.map(r => Number(r.badgeCount) || 0)) : 0, maxLevel: normal.length ? Math.max(...normal.map(r => Number(r.level) || 0)) : 0, pointsShopItemsFetched: ptsFetched.length, pointsShopItemsTotal: ptsFetched.reduce((s, r) => s + (Number(r.pointsItems) || 0), 0), }, top: sorted.slice(0, ctx.topN).map(r => ({ name: r.name || `用户 ${String(r.steamId || '').slice(-6)}`, steamId: r.steamId || null, badgeCount: Number(r.badgeCount) < 0 ? '私密' : (Number(r.badgeCount) || 0), level: Number(r.level) || 0, xp: Number(r.xp) || 0, pointsItems: r.pointsItems == null ? '未抓取' : (Number(r.pointsItems) < 0 ? '私密' : Number(r.pointsItems)), })), fetchedAt: value.fetchedAt || null, note: priv > 0 ? `${priv} 位好友资料私密(徽章数不可见,已沉底)` : null + (ptsFetched.length < rows.length ? ';部分好友未抓取点数装扮数(需在插件内点「获取点数装扮」)' : ''), }; } // 卡牌数据库摘要:{data:{appid:{name,cardCount}}, timestamp} function pcCardDbDigest(value, ctx) { const db = (value && value.data && typeof value.data === 'object') ? value.data : (value && typeof value === 'object' ? value : null); if (!db) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const ids = Object.keys(db); const rows = ids.map(appid => ({ appid, name: (db[appid] && db[appid].name) || `App ${appid}`, cardCount: Number(db[appid] && db[appid].cardCount) || 0, })).sort((a, b) => b.cardCount - a.cardCount); return { kind: 'cardDatabase', totalGames: ids.length, totalCards: rows.reduce((s, r) => s + r.cardCount, 0), topByCardCount: rows.slice(0, ctx.topN), timestamp: value.timestamp || null, note: '卡牌数据库为全量游戏→卡牌数映射,此处只出 topN', }; } // 单组卡牌市场价格摘要:{appId, cards[], avgPrice, medianPrice, netIncome, ...} // ⚠️ cards/regularCards/foilCards 三数组最长各 300 张,绝不能原样返回。 function pcCardPriceDigest(value, ctx) { if (!value || typeof value !== 'object' || value.avgPrice == null) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const toYuan = (cents) => (Number(cents) || 0) / 100; const pick = (arr) => (Array.isArray(arr) ? arr : []) .filter(c => c && (c.price != null || c.sell_price != null)) .map(c => ({ name: c.name || null, price: toYuan(c.price != null ? c.price : c.sell_price), })) .sort((a, b) => b.price - a.price); const regular = pick(value.regularCards).slice(0, ctx.topN); const foil = pick(value.foilCards).slice(0, ctx.topN); return { kind: 'cardPrices', appId: value.appId || null, cardCount: Number(value.cardCount) || 0, regularCount: Number(value.regularCount) || 0, foilCount: Number(value.foilCount) || 0, pricing: { totalCost: toYuan(value.totalCost), avgPrice: toYuan(value.avgPrice), medianPrice: toYuan(value.medianPrice), avgNoMaxPrice: toYuan(value.avgNoMaxPrice), regularAvgPrice: toYuan(value.regularAvgPrice), foilAvgPrice: toYuan(value.foilAvgPrice), netIncome: toYuan(value.netIncome), priceSampleCount: Number(value.priceCount) || 0, }, topRegularByPrice: regular, topFoilByPrice: foil, updatedAt: value.updatedAt || null, note: '金额单位已由分换算为元;卡牌明细仅取单价最高的 topN,未整读 cards 数组', }; } // 热门市场物品摘要:数组(宿主已 slice 到 DISPLAY_COUNT) function pcPopularMarketDigest(value, ctx) { const arr = Array.isArray(value) ? value : null; if (!arr) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; return { kind: 'popularMarket', total: arr.length, items: arr.slice(0, ctx.topN), note: '热门市场由 /market/popular 轮询累积去重而来,反映近期社区交易热点', }; } // ---- 社交中心(Steam Social Core)/ 好友动态(Steam Activity Modern)---- // 两者的 IDB 都在 community 域、且都是**裸值**(时间戳在载荷内部,不套信封), // 因此无需 envelope:true,pcCacheAgeMinutes 可直接从 payload.updatedAt 取。 // 注意:它们各自独占一个库 → 需要两个注册项(现注册表一个插件对应一个 idb.name)。 // 好友数排行:{[steamId]: {n:好友数, p:私密(1/0), t:更新时间}} // 这是社交中心「好友数排行」页的底层数据,永久缓存 + 7 天 stale 增量刷新。 function pcFriendCountDigest(value, ctx) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const ids = Object.keys(value); if (!ids.length) return { kind: 'friendCounts', total: 0, friends: [], note: '社交中心尚未建立好友数缓存' }; const rows = ids.map(sid => { const r = value[sid] || {}; return { steamId: sid, name: null, // 该库不存昵称,需与 roster 联查 friendCount: r.p ? '私密' : (Number(r.n) || 0), private: r.p === 1 || r.p === true, updatedAt: Number(r.t) || null, }; }); // 私密沉底(与宿主榜单语义一致:0 无法区分"0 个好友"与"私密") // 注意方向:比较器返回正数表示 a 排在 b 之后,故用 (a.private - b.private) // 才能把私密项推到末尾;写成 (b.private - a.private) 会把私密顶到榜首。 rows.sort((a, b) => (a.private - b.private) || ((Number(b.friendCount) || 0) - (Number(a.friendCount) || 0))); const ages = rows.map(r => r.updatedAt).filter(Boolean); const newest = ages.length ? Math.max(...ages) : 0; return { kind: 'friendCounts', totalFriendsTracked: ids.length, privateCount: rows.filter(r => r.private).length, top: rows.slice(0, ctx.topN), dataAgeMinutes: newest ? Math.max(0, Math.round((Date.now() - newest) / 60000)) : null, note: rows.some(r => !r.name) ? '该库只存 steamId 不存昵称,可先用 key="roster" 取名对应' : null, }; } // 游玩快照(热力图数据):{[steamId]: [{d:'YYYY-MM-DD', m:累计分钟}]} // 每个好友保留最近 21 天(宿主上限),可答"好友最近两周在玩什么、时长变化如何"。 function pcPlaySnapDigest(value, ctx) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const ids = Object.keys(value).filter(sid => Array.isArray(value[sid]) && value[sid].length); if (!ids.length) return { kind: 'playSnapshots', friends: 0, note: '社交中心尚未建立游玩快照(需打开深度大盘累积)' }; const rows = ids.map(sid => { const arr = value[sid]; const first = arr[0] || {}, last = arr[arr.length - 1] || {}; const delta = (Number(last.m) || 0) - (Number(first.m) || 0); return { steamId: sid, days: arr.length, firstDay: first.d || null, lastDay: last.d || null, lastTotalMinutes: Number(last.m) || 0, deltaMinutesLastToFirst: delta, }; }).sort((a, b) => b.deltaMinutesLastToFirst - a.deltaMinutesLastToFirst); const dayKeys = Array.from(new Set(ids.flatMap(sid => value[sid].map(x => x.d)).filter(Boolean))).sort(); return { kind: 'playSnapshots', friends: ids.length, coveredDays: dayKeys.length, dayRange: dayKeys.length ? { from: dayKeys[0], to: dayKeys[dayKeys.length - 1] } : null, topByGrowth: rows.slice(0, ctx.topN), note: 'm 为该好友「累计游戏时长(分钟)」的每日快照,delta 为区间内净增长;数值不下降属正常(时长只增不减)', }; } // 好友名册:{own:{...}, friends:[{steamid, personaname, avatar, ...}], updatedAt} function pcRosterDigest(value, ctx) { if (!value || typeof value !== 'object' || !Array.isArray(value.friends)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const f = value.friends; return { kind: 'friendRoster', own: value.own ? { steamId: value.own.steamid || null, name: value.own.personaname || null } : null, totalFriends: f.length, friends: f.slice(0, ctx.topN).map(x => ({ steamId: x.steamid || null, name: x.personaname || x.name || null, level: x.level ?? null, currentGame: x.gameextrainfo || x.gameName || null, })), updatedAt: value.updatedAt || null, note: '名册含昵称/头像/等级,可与 fcount、level、snaps 联查', }; } // 好友等级:{[steamId]: level} function pcFriendLevelDigest(value, ctx) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const ids = Object.keys(value); if (!ids.length) return { kind: 'friendLevels', total: 0, note: '社交中心尚未抓取好友等级' }; // v1.31.0 兼容两种值形态:纯数字(sfd_level_cache / sac level)与 {level} 对象 const rows = ids.map(sid => { const v = value[sid]; const level = Number((v && typeof v === 'object') ? v.level : v) || 0; return { steamId: sid, name: null, level }; }).sort((a, b) => b.level - a.level); // v1.31.0 等级分桶:模型不翻全量名单也能先答「有没有超过 X 级的人」 const ge = (t) => rows.filter(r => r.level >= t).length; return { kind: 'friendLevels', total: ids.length, maxLevel: rows[0].level, avgLevel: Math.round(rows.reduce((s, r) => s + r.level, 0) / rows.length * 10) / 10, levelBuckets: { '300+': ge(300), '200+': ge(200), '100+': ge(100), '50+': ge(50) }, top: rows.slice(0, ctx.topN), note: '该库只存 steamId→等级,昵称需联查 roster(sac)或 sfd_cached_friends_data(sfd)', }; } // 好友动态:{items:[{type,timestamp,appid,steamId,textHash,dateText}], updatedAt, count} // type 枚举来自宿主 getBlockType:screenshot/game/review/workshop/favorite/wishlist/achievement/other function pcFriendActivityDigest(value, ctx) { if (!value || typeof value !== 'object' || !Array.isArray(value.items)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const items = value.items; const byType = {}; for (const it of items) byType[it.type || 'other'] = (byType[it.type || 'other'] || 0) + 1; const sorted = items.slice().sort((a, b) => (Number(b.timestamp) || 0) - (Number(a.timestamp) || 0)); return { kind: 'friendActivity', total: items.length, declaredCount: value.count ?? items.length, byType, latest: sorted.slice(0, ctx.topN).map(it => ({ steamId: it.steamId || null, type: it.type || 'other', appid: it.appid || null, dateText: it.dateText || null, timestamp: Number(it.timestamp) || 0, })), updatedAt: value.updatedAt || null, note: '来源为好友动态页 DOM 解析(Steam 未开放该数据 API),仅覆盖用户曾打开过动态页的好友', }; } // ---- 宿主插件桥(阶段 2)--------------------------------------------------- // SGLV / FA 写入 store 域 localStorage 的 __saa_bridge_*__ 键。 // 桥存在的意义:GM 存储沙箱隔离,AI 读不到 GM;但同源 localStorage 共享,故宿主主动镜像一份。 // 无数据时宿主写 {v:1, updatedAt, wishlist/rates: null},据此可区分"桥在但无数据"与"桥不存在(宿主未装/未更新)"。 // SGLV 桥摘要:rates 直接返回;names 按 topN + 前缀过滤 function pcSglvBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const out = { kind: 'sglvBridge', bridgeVersion: value.v, rates: null, names: null, namesSkipped: value.namesSkipped || null, }; // 汇率:体量小(~2KB)原样返回,AI 回答中文名/跨区比价直接可用。 // 注意:桥顶层 updatedAt 是"桥写入时刻",不等于底层 GM 数据的真实年龄, // 因此这里额外透出 ratesAgeMinutes(源自宿主 GM 的 sgis-rates-ts),由模型据此声明新鲜度。 if (value.rates && typeof value.rates === 'object' && value.rates.map) { const map = value.rates.map; const entries = Object.entries(map); const ratesAge = pcCacheAgeMinutes({ ts: value.rates.ts }); out.rates = { semantic: '1 外币 = X CNY', cachedAt: value.rates.ts || null, ratesAgeMinutes: ratesAge, ratesStale: ratesAge != null && ratesAge > 24 * 60, currencyCount: entries.length, map: entries.slice(0, 40).reduce((acc, [k, v]) => { acc[k] = v; return acc; }, {}), }; } // 中文名:可能上万条,只按 topN 切片 + 支持字段前缀过滤(ctx.fields 形如 "730,appid") if (value.names && typeof value.names === 'object') { const allIds = Object.keys(value.names); let ids = allIds; const f = String(ctx.fields || '').trim(); if (f) { const want = f.split(',').map(s => s.trim()).filter(Boolean); const matched = allIds.filter(id => want.includes(id)); // 只有当过滤确实命中才生效,避免模型误填字段名导致空结果 if (matched.length) ids = matched; } out.names = { total: allIds.length, returned: Math.min(ids.length, ctx.topN), filteredByRequest: f ? ids.length !== allIds.length : false, entries: ids.slice(0, ctx.topN).map(id => ({ appid: id, name: value.names[id] })), }; } else if (value.namesSkipped) { out.namesNote = `宿主因体积超限未镜像中文名(namesSkipped=${value.namesSkipped}),如需中文名请改用 getGameDetail(l=schinese)`; } return out; } // FA 桥摘要:家庭愿望单 GameList + GameInfo function pcFaBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const wl = value.wishlist; const out = { kind: 'faBridge', bridgeVersion: value.v, familyGroupId: value.gid || null, wishlistPresent: !!wl, }; if (!wl) { out.note = '桥已写入但家庭愿望单暂无数据(FA 插件可能未打开过家庭愿望单面板)'; return out; } const gameList = Array.isArray(wl.GameList) ? wl.GameList : []; const gameInfo = (wl.GameInfo && typeof wl.GameInfo === 'object') ? wl.GameInfo : {}; out.wishlist = { totalGames: gameList.length, infoCount: Object.keys(gameInfo).length, truncatedByBridge: wl.truncated === true, games: gameList.slice(0, ctx.topN).map(appid => { const gi = gameInfo[appid] || {}; const wishers = Array.isArray(gi.wishers) ? gi.wishers : []; return { appid, name: gi.name || null, wishers: wishers.length ? wishers : null, finalPrice: gi.finalPrice || null, originalPrice: gi.originalPrice || null, discountPct: gi.discountPct || null, onSale: gi.onSale === true, isFree: gi.isFree === true, releaseDate: gi.releaseDate || null, priority: gi.priority || null, }; }), }; if (gameList.length > ctx.topN) { out.wishlist.note = `共 ${gameList.length} 款,仅返回前 ${ctx.topN} 款,可增大 topN 重试`; } return out; } // ---- 缓存注册表 ---------------------------------------------------------- // domain:该缓存只能在该域的页面读取;digest:摘要裁剪函数 // 消费历史桥摘要(Spending 分类器 v2.3.0+ 镜像):分类聚合 + 月度趋势 + 明细切片。 // 口径与 getSpendingHistory 对齐:净支出 = 直购+内购+送礼−退款,**仅统计主货币行** // (v1.34.0 修正:旧版把外币行金额直接加进总额,跨区消费会把 CNY 总额算爆)。 // 宿主 v2.5.0 起即使明细因 localStorage 配额被丢弃,也会写 aggregate,这里直接采纳。 function pcSpendBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const rows = Array.isArray(value.rows) ? value.rows : []; const primary = value.primaryCurrency || null; const out = { kind: 'spendBridge', bridgeVersion: value.v, updatedAt: value.updatedAt || null, primaryCurrency: primary, complete: value.complete === true, rowsPresent: rows.length, totalRows: value.totalRows != null ? value.totalRows : rows.length, rowsSkipped: value.rowsSkipped || null, truncated: value.truncated === true, }; const hostAgg = value.aggregate && typeof value.aggregate === 'object' ? value.aggregate : null; // 只镜像了聚合(v2.5.0 的配额降级):聚合仍可答总额/月度,但明细与逐笔不可得 if (!rows.length) { if (hostAgg) { out.aggregate = hostAgg; out.aggregateFromHost = true; out.note = '宿主只镜像了聚合,未镜像逐笔明细' + (value.rowsSkipped === 'quota' ? '(localStorage 配额超限)' : value.rowsSkipped ? `(${value.rowsSkipped})` : '') + (out.complete ? ';页面已加载全量,聚合可直接引用' : ';页面未加载全量,聚合只覆盖已加载部分,可能低估真实总额'); return out; } out.note = value.rowsSkipped ? `宿主因 localStorage 配额未镜像明细(rowsSkipped=${value.rowsSkipped})且无聚合兜底;请改用 getSpendingHistory(v1.34.0 起会自抓并可用桥兜底)` : '桥已写入但暂无消费记录:Spending 分类器可能尚未在该浏览器打开 /account/history 页面并完成分析'; return out; } if (!out.complete) { out.note = `仅覆盖页面已加载的 ${rows.length} 条(Steam「加载更多」未点完),月度趋势以此为口径;可请用户在消费历史页点一次「加载更多」自动级联加载全量`; } const CATS = ['store', 'ingame', 'gift', 'refund', 'convert', 'market_buy', 'market_sell']; const CAT_LABEL = { store: '直购', ingame: '内购', gift: '送礼', refund: '退款', convert: '充值', market_buy: '买入', market_sell: '卖出' }; const byCat = {}, byMonth = {}; let gross = 0, refunded = 0; for (const r of rows) { const cat = CATS.includes(r.category) ? r.category : null; if (!cat) continue; const amt = Number(r.amount) || 0; const cnt = Number(r.marketCount) || 1; // 与自抓口径一致:非主货币行只计笔数、不计金额 const isPrimary = !primary || r.currency === primary; if (!byCat[cat]) byCat[cat] = { count: 0, spend: 0 }; byCat[cat].count += cnt; if (isPrimary) { if (cat === 'store' || cat === 'ingame' || cat === 'gift') { byCat[cat].spend += amt; gross += amt; } else if (cat === 'refund') { byCat[cat].spend -= amt; refunded += amt; } } const month = r.ts ? new Date(r.ts).toISOString().slice(0, 7) : null; if (month) { if (!byMonth[month]) byMonth[month] = { count: 0, net: 0 }; byMonth[month].count += cnt; if (!isPrimary) continue; if (cat === 'store' || cat === 'ingame' || cat === 'gift') byMonth[month].net += amt; else if (cat === 'refund') byMonth[month].net -= amt; } } out.aggregate = { netSpend: Math.round((gross - refunded) * 100) / 100, grossSpend: Math.round(gross * 100) / 100, refundTotal: Math.round(refunded * 100) / 100, categories: CATS.filter(c => byCat[c]).map(c => ({ id: c, label: CAT_LABEL[c], count: byCat[c].count, spend: Math.round(byCat[c].spend * 100) / 100 })), monthly: Object.keys(byMonth).sort().reverse().slice(0, 12).map(k => ({ month: k, count: byMonth[k].count, net: Math.round(byMonth[k].net * 100) / 100 })), }; const topN = Math.min(ctx.topN || 20, 50); out.recent = rows.slice(0, topN).map(r => ({ date: r.date, item: r.item, category: r.category, amount: r.amount, currency: r.currency || null, ...(r.appid ? { appid: r.appid } : {}), })); if (rows.length > topN) out.recentNote = `仅返回最近 ${topN} 笔,明细可增大 topN(上限 50)`; return out; } // 入库历史桥摘要(SLC 许可分类器 v2.6.0 镜像):来源分布 + 月度入库趋势 + 明细切片/按名过滤。 // fields 语义:游戏名过滤(包含匹配),回答「XX 是怎么获得的/什么时候入库的」时用。 function pcSlcBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const rows = Array.isArray(value.rows) ? value.rows : []; const out = { kind: 'slcBridge', bridgeVersion: value.v, updatedAt: value.updatedAt || null, total: value.total != null ? value.total : rows.length, rowsPresent: rows.length, counts: value.counts || null, rowsSkipped: value.rowsSkipped || null, truncated: value.truncated === true, }; if (value.rowsSkipped) { out.note = '宿主因 localStorage 体积超限未镜像明细(rowsSkipped=quota);来源计数仍可用 counts 字段'; return out; } if (!rows.length) { out.note = '桥已写入但暂无入库记录:SLC 可能尚未在该浏览器打开 /account/licenses 页面并完成加载'; return out; } // 月度入库趋势(ts 毫秒;无 ts 的行不计入) const byMonth = {}; for (const r of rows) { const ts = Number(r.ts); if (!ts) continue; const month = new Date(ts).toISOString().slice(0, 7); byMonth[month] = (byMonth[month] || 0) + 1; } out.monthly = Object.keys(byMonth).sort().reverse().slice(0, 12) .map(k => ({ month: k, count: byMonth[k] })); // fields:游戏名包含匹配(「XX 怎么获得的」场景),不填则返回最近入库 const topN = Math.min(ctx.topN || 20, 50); const f = String(ctx.fields || '').trim(); const CAT_LABEL = { store: '商店', retail: '零售', gift: '礼物', free: '免费' }; if (f) { const matched = rows.filter(r => (r.name || '').includes(f)); out.filteredBy = f; out.matchedTotal = matched.length; out.records = matched.slice(0, topN).map(r => ({ name: r.name, date: r.date, ts: r.ts || null, category: r.category, source: CAT_LABEL[r.category] || r.category || null, })); if (matched.length > topN) out.recordsNote = `共 ${matched.length} 条匹配,仅返回前 ${topN} 条`; } else { out.recent = rows.slice(0, topN).map(r => ({ name: r.name, date: r.date, ts: r.ts || null, category: r.category, source: CAT_LABEL[r.category] || r.category || null, })); if (rows.length > topN) out.recentNote = `仅返回最近 ${topN} 条,可增大 topN(上限 50)`; } return out; } // 点数商店桥摘要(Steam 点数商店增强 v2.3.0 镜像):余额/累计获得/退款/兑换 + 明细行切片。 // 数据源是宿主插件对 /pointssummary/ 的解析(kind: earn=获得 refund=退款返还 redeem=兑换消费 balance=余额)。 function pcPtsBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const KIND_LABEL = { earn: '获得', refund: '退款返还', redeem: '兑换消费', balance: '余额' }; const rows = Array.isArray(value.rows) ? value.rows : []; const out = { kind: 'ptsBridge', bridgeVersion: value.v, updatedAt: value.updatedAt || null, balance: value.balance != null ? value.balance : null, earned: value.earned != null ? value.earned : null, refunded: value.refunded != null ? value.refunded : null, redeemed: value.redeemed != null ? value.redeemed : null, partial: value.partial === true, rowsPresent: rows.length, rowsSkipped: value.rowsSkipped || null, }; if (out.partial) { out.note = '宿主仅从页面提取到点数余额(partial),明细缺失;可让用户打开点数商店页稍候自动重抓'; } if (value.rowsSkipped) out.rowsNote = '宿主因体积超限未镜像明细行(rowsSkipped=quota)'; if (rows.length) { out.records = rows.slice(0, Math.min(ctx.topN || 20, 50)).map(r => ({ label: r.label || null, amount: r.amount || 0, kind: r.kind || null, kindLabel: KIND_LABEL[r.kind] || r.kind || null, })); } else if (!out.partial && !value.rowsSkipped) { out.note = '桥已写入但暂无明细行:点数商店增强可能尚未完成 /pointssummary/ 解析'; } return out; } const PLUGIN_CACHE_REGISTRY = { sglv: { label: 'SGLV 游戏库展示', domain: PC_DOMAIN_STORE, idb: { name: 'sglv_kv', store: 'kv' }, keys: { sglv_cached_games: { desc: '游戏库列表+游玩时长(数组,元素含 appid/name/playtime_forever)', digest: pcGamesDigest }, sglv_family_info: { desc: '家庭共享信息', digest: pcGenericArrayDigest }, sglv_pt_daily_history: { desc: '入库/游玩时长日历史', digest: pcGenericArrayDigest }, }, }, sfd: { label: 'SFD 好友管理', domain: PC_DOMAIN_COMMUNITY, idb: { name: 'sfd_storage', store: 'kv' }, keys: { sfd_cached_friends_data: { desc: '好友列表', digest: pcFriendsDigest }, // v1.31.0 修复:这是 {steamId→level} 对象映射,之前错用数组 digest 只吐键名列表, // 「好友谁等级高」类问题拿着 162 条数据算不出答案(实测翻车)→ 换等级映射专用 digest sfd_level_cache: { desc: '好友等级映射 {steamId→level}(等级检查跑过一次后才有)', digest: pcFriendLevelDigest }, sfd_family_cache: { desc: '家庭成员+共享库', digest: pcFamilyDigest }, sfd_wishlist_cache: { desc: '家庭愿望单', digest: pcGenericArrayDigest }, sfd_family_play_activity: { desc: '按成员的游玩动态', digest: pcGenericArrayDigest }, 'sfd_og_': { desc: '逐好友完整游戏库(前缀键,需拼完整 steamid;MB 级仅出摘要)', digest: pcOwnedGamesDigest, prefix: true }, }, }, fa: { label: 'FA 家庭组分析', domain: PC_DOMAIN_STORE, idb: { name: 'sffa_kv', store: 'kv' }, keys: { fa_value_insights_cache: { desc: '家庭价格+时长价值洞察(≤2 万条)', digest: pcValueInsightsDigest }, faCooldownRegistry: { desc: '家庭借阅冷却记录', digest: pcGenericArrayDigest }, fa_bundle_cache: { desc: '数据包缓存(48h)', digest: pcGenericArrayDigest }, fa_goty_cache: { desc: 'GOTY 数据缓存(12h)', digest: pcGenericArrayDigest }, fa_dlc_db_cache: { desc: 'DLC 数据库缓存(12h)', digest: pcGenericArrayDigest }, }, }, sme: { label: 'SME 市场增强', domain: PC_DOMAIN_COMMUNITY, idb: null, keys: { 'sme_cache_': { desc: '价格历史/订单簿快照 {ts,data}(前缀键,4MB LRU)', digest: pcMarketCacheDigest, prefix: true }, 'sme_inv_': { desc: '库存缓存(前缀键,需拼 steamid)', digest: pcInventoryDigest, prefix: true }, sme_inv_prices_v1: { desc: '库存价格快照', digest: pcGenericArrayDigest }, }, }, slc: { label: 'SLC 许可分类器', domain: PC_DOMAIN_STORE, idb: null, keys: { slc_license_cache: { desc: '许可指纹数组 "日期||游戏名||类型"(7 天 TTL,ver2)', digest: pcLicenseDigest }, slc_license_total: { desc: '许可总记录数', digest: pcScalarDigest }, }, }, // v1.14.0:SBC 徽章卡片视图(community 域) // 该库是「一库七 store」,故每个键用 store 字段指定自己的 objectStore; // 且宿主 IDB.set 写入的是 {key,data,updatedAt,expiresAt} TTL 信封, // 而宿主自己的 get() 会 return record.data —— 本工具是原生只读, // 必须靠 envelope:true 显式声明并剥壳(不靠形状猜,见 pcUnwrapEnvelope 注释)。 sbc: { label: 'SBC 徽章卡片视图', domain: PC_DOMAIN_COMMUNITY, idb: { name: 'sbc_pro_cache', store: 'gameData' }, keys: { sbc_friends_leaderboard_v1: { store: 'gameData', envelope: true, desc: '好友徽章排行榜(徽章数/等级/经验/点数商店装扮数 四维度)', digest: pcFriendsLbDigest, }, cardDatabase: { store: 'gameData', envelope: true, desc: '卡牌数据库(全量游戏 → 卡牌数)', digest: pcCardDbDigest, }, 'sbc-market-': { store: 'marketPrices', envelope: true, prefix: true, desc: '单组卡牌市场价格快照(单位分,48h TTL;前缀键需拼 appid,如 sbc-market-730)', digest: pcCardPriceDigest, }, sbc_popular_market_cache: { store: 'popularMarket', envelope: true, desc: '热门市场物品排行(轮询累积,10 分钟 TTL)', digest: pcPopularMarketDigest, }, }, }, // v1.15.0:社交中心数据层(Steam Social Core)。独立 IndexedDB,裸值无信封。 sac: { label: 'SAC 社交中心数据层(好友数/游玩快照/名册/等级)', domain: PC_DOMAIN_COMMUNITY, idb: { name: 'steam_social_db', store: 'kv' }, keys: { fcount: { desc: '好友数排行底层数据 {[steamId]:{n:好友数,p:私密标记,t:更新时间}}(永久缓存+7天增量)', digest: pcFriendCountDigest }, snaps: { desc: '好友游玩时长每日快照 {[steamId]:[{d:日期,m:累计分钟}]}(每人最近21天,深度大盘热力图来源)', digest: pcPlaySnapDigest }, roster: { desc: '好友名册 {own,friends:[{steamid,personaname,level,gameextrainfo}],updatedAt}', digest: pcRosterDigest }, level: { desc: '好友等级映射 {[steamId]: level}', digest: pcFriendLevelDigest }, libpriv: { desc: '游戏库私密/拉取失败标记 {[steamId]: 1}', digest: pcGenericArrayDigest }, vac: { desc: '好友 VAC 状态快照', digest: pcGenericArrayDigest }, }, }, // v1.15.0:好友动态缓存(Steam Activity Modern 主脚本)。同样是独立 IDB + 裸值。 saf: { label: 'SAF 好友动态(动态流/好友游戏库/最近游玩/游戏比对)', domain: PC_DOMAIN_COMMUNITY, idb: { name: 'sf_activity_db', store: 'kv' }, keys: { 'act_': { desc: '好友动态条目(按 steamId 分键,前缀键需拼完整,如 act_76561198142011559)', digest: pcFriendActivityDigest, prefix: true }, 'games_': { desc: '好友完整游戏库(按 steamId 分键,前缀键)', digest: pcGenericArrayDigest, prefix: true }, 'recent_': { desc: '好友最近游玩(按 steamId 分键,前缀键)', digest: pcGenericArrayDigest, prefix: true }, 'cmp_': { desc: '游戏比对结果(与某好友的共有/独有游戏,前缀键)', digest: pcGenericArrayDigest, prefix: true }, social_: { desc: '好友社交数据(好友数+好友列表,按 steamId 分键,前缀键)', digest: pcGenericArrayDigest, prefix: true }, mygames: { desc: '自己的游戏库(好友动态页顺带抓取)', digest: pcGenericArrayDigest }, }, }, // 阶段 2 + 3:宿主插件(SGLV / FA / Spending 分类器 / SLC 许可分类器 / 点数商店增强)主动镜像到 localStorage 的桥键 bridge: { label: '宿主插件桥(SGLV 汇率/中文名、FA 家庭愿望单、Spending 消费历史、SLC 入库来源、点数商店)', domain: PC_DOMAIN_STORE, idb: null, keys: { __saa_bridge_sglv__: { desc: 'SGLV 桥:汇率(1 外币=X CNY)+ 游戏中文名对照', digest: pcSglvBridgeDigest }, __saa_bridge_fa__: { desc: 'FA 桥:家庭愿望单(GameList + GameInfo)', digest: pcFaBridgeDigest }, __saa_bridge_spend__: { desc: '消费历史桥(Spending 分类器 v2.3.0 镜像:7 类分类行+金额+币种,含聚合摘要;complete=false 表示页面历史未全部加载)', digest: pcSpendBridgeDigest }, __saa_bridge_slc__: { desc: '入库桥(SLC 许可分类器 v2.6.0 镜像:来源分类行 store/retail/gift/free + 月度趋势;fields=游戏名过滤可答「XX 怎么获得的」)', digest: pcSlcBridgeDigest }, __saa_bridge_pts__: { desc: '点数商店桥(点数商店增强 v2.3.0 镜像:点数余额/累计获得/退款返还/兑换消费 + 明细行;partial=true 表示仅余额)', digest: pcPtsBridgeDigest }, }, }, }; const PC_PLUGIN_LABEL = { sglv: PLUGIN_CACHE_REGISTRY.sglv.label, sfd: PLUGIN_CACHE_REGISTRY.sfd.label, fa: PLUGIN_CACHE_REGISTRY.fa.label, sme: PLUGIN_CACHE_REGISTRY.sme.label, slc: PLUGIN_CACHE_REGISTRY.slc.label, sbc: PLUGIN_CACHE_REGISTRY.sbc.label, sac: PLUGIN_CACHE_REGISTRY.sac.label, saf: PLUGIN_CACHE_REGISTRY.saf.label, bridge: PLUGIN_CACHE_REGISTRY.bridge.label, }; // 声明过的非前缀键(如 sme_inv_prices_v1)不能被更短的前缀(sme_inv_)抢走。 function pcIsDeclaredExactKey(reg, key) { const spec = reg && reg.keys ? reg.keys[key] : null; return !!(spec && !spec.prefix); } // 前缀实例:以 prefix 开头,但排除前缀本身和另外声明过的精确键。 function pcPrefixInstances(reg, prefix, keys) { return (keys || []).filter((key) => key.startsWith(prefix) && key !== prefix && !pcIsDeclaredExactKey(reg, key)); } // 前缀键解析:用户可能传 'sfd_og_' 或完整 'sfd_og_76561198xxx' // 精确键优先于前缀,否则 sme_inv_prices_v1 会被 sme_inv_ 当成某个好友的库存。 // 前缀声明本身(sfd_og_)仍走列出实例,不去读一个不存在的字面键。 function pcResolveKeySpec(pluginId, key) { const reg = PLUGIN_CACHE_REGISTRY[pluginId]; if (!reg) return { error: `未知插件标识 "${pluginId}",可选:${Object.keys(PLUGIN_CACHE_REGISTRY).join(' / ')}` }; const k = String(key || '').trim(); if (!k) return { error: 'inspect/query 模式必须提供 key 参数' }; if (pcIsDeclaredExactKey(reg, k)) return { reg, spec: reg.keys[k], exactKey: k }; for (const [name, spec] of Object.entries(reg.keys)) { if (spec.prefix && (k === name || k.startsWith(name))) { return { reg, spec, exactKey: k === name ? null : k, prefix: name }; } } if (reg.keys[k]) return { reg, spec: reg.keys[k], exactKey: k }; return { error: `插件 ${pluginId} 没有缓存键 "${k}",可用键:${Object.keys(reg.keys).join(' / ')}` }; } ToolRegistry.register({ name: 'readPluginCache', description: '读取本机其他 Steam 油猴插件(SGLV 游戏库 / SFD 好友管理 / FA 家庭分析 / SME 市场增强 / SLC 许可分类器 / SBC 徽章卡片视图 / SAC 社交中心 / SAF 好友动态)已缓存在本地的数据,只读、不联网、不上传。' + '适合回答"我的游戏时长""好友都在玩什么""好友谁的徽章最多""谁好友最多""好友最近两周时长变化"这类问题——' + '比走 Steam Web API 更快且不消耗 API 配额。仅能读取当前页面域名下可用的缓存:' + '商店页(store)可读 sglv/fa/slc/bridge,社区页(community)可读 sfd/sme/sbc/sac/saf。' + '中文游戏名、汇率、家庭愿望单、消费历史汇总、入库来源(某游戏怎么获得的/入库趋势)、Steam 点数余额与兑换请优先用 plugin="bridge"(宿主插件镜像键;消费桥 key="__saa_bridge_spend__"、入库桥 key="__saa_bridge_slc__"、点数桥 key="__saa_bridge_pts__",毫秒级免 Steam 请求)。' + '好友徽章排行榜用 plugin="sbc",key="sbc_friends_leaderboard_v1",可用 fields=level|xp|points 切换排序维度。' + '「好友谁等级高/等级超过 X 级/等级排行」类问题首选它:fields="level",行里自带昵称+等级(一步出结果,summary.maxLevel 先看上限);' + '备选 sac 的 level 键或 sfd 的 sfd_level_cache(都是 {steamId→level} 等级映射,query 直接返回按等级降序名单 + levelBuckets 分桶,昵称需联查 roster / sfd_cached_friends_data)。' + '社交中心数据(好友数排行/游玩快照/名册/等级)用 plugin="sac";好友动态用 plugin="saf"。' + '注意:sac/saf 的数据由用户主动打开对应页面时产生,未打开过则查不到。' + '数据来自本地缓存,可能已过期,回答时需说明数据新鲜度。', parameters: { type: 'object', properties: { plugin: { type: 'string', enum: ['sglv', 'sfd', 'fa', 'sme', 'slc', 'sbc', 'sac', 'saf', 'bridge'], description: '插件标识:sglv=游戏库 / sfd=好友管理 / fa=家庭分析 / sme=市场增强 / slc=许可分类器 / sbc=徽章卡片视图 / sac=社交中心数据层 / saf=好友动态 / bridge=宿主插件镜像桥' }, mode: { type: 'string', enum: ['list', 'inspect', 'query'], description: 'list=列出该插件可用的缓存键;inspect=查看某键的结构与条数;query=按 digest 摘要取数据(默认返回 top 20)' }, key: { type: 'string', description: '缓存键,inspect/query 必填。带 / 的前缀键请拼完整,如 sfd_og_76561198142011559、sbc-market-730、act_76561198142011559' }, topN: { type: 'number', description: 'query 模式返回条数上限,默认 20(MB 级键会被自动限制)' }, fields: { type: 'string', description: 'query 模式可选。默认是字段裁剪(如 appid,name);对 sbc 好友排行榜则是排序维度:badge(默认)/level/xp/points' }, }, required: ['plugin', 'mode'], }, category: 'knowledge', async execute(params) { const pluginId = String(params.plugin || '').trim().toLowerCase(); const mode = String(params.mode || 'list').trim().toLowerCase(); const topN = Math.max(1, Math.min(Number(params.topN) || 20, 200)); const fields = String(params.fields || '').trim(); const reg = PLUGIN_CACHE_REGISTRY[pluginId]; if (!reg) { return { success: false, error: `未知插件标识 "${params.plugin}",可选:${Object.keys(PLUGIN_CACHE_REGISTRY).join(' / ')}` }; } // 0. 授权闸门:用户显式关闭的数据源直接拒绝(先于域检查 —— // 未授权时提示「去别的页面」没有意义) if (!pcDataSourceAllowed(pluginId)) { return { success: false, error: `用户未授权读取「${reg.label}」的数据。如需启用,请在设置 → 数据源 中勾选。`, needAuth: true, plugin: pluginId, hint: '数据源授权是逐插件的;其他插件的数据不受影响', }; } // 1. 域检查:不匹配时明确引导,不静默返回空 const curDomain = pcCurrentDomain(); if (!curDomain) { return { success: false, error: `当前页面域名 ${location.hostname} 不属于 Steam 商店/社区,无法读取任何插件缓存` }; } if (reg.domain !== curDomain) { // v1.36.0:游戏库键有**跨域 GM 镜像**(同一脚本跨域共享,IndexedDB 不跨域)→ // 社区域读 sglv_cached_games 时改读镜像,其余键仍按域引导。 const mir = pluginId === 'sglv' ? pcReadLibraryMirror() : { ok: false }; const wantsLibKey = String(params.key || '').trim() === 'sglv_cached_games'; if (mir.ok && !mir.expired && wantsLibKey) { const items = mir.games.map((g) => ({ appid: g.appid, name: g.name, playtime_forever: g.playtime_forever, })); return { success: true, data: { plugin: pluginId, label: reg.label, key: 'sglv_cached_games', domain: reg.domain, source: 'cross-domain-mirror', storage: 'GM 跨域镜像', count: items.length, items, mirroredAt: mir.ts, mirrorAgeMinutes: Math.max(0, Math.round((mir.ageMs || 0) / 60000)), mirrorAgeText: pcHumanAge(mir.ageMs), stale: !!mir.stale, note: `本域(${curDomain})读不到 ${reg.label} 的 IndexedDB(按源隔离),` + `已改用商店域缓存的跨域 GM 镜像(快照 ${pcHumanAge(mir.ageMs)});` + '回答时需声明数据新鲜度,不要当成实时数据。', }, summary: `${reg.label} 游戏库 ${items.length} 条(跨域镜像,快照 ${pcHumanAge(mir.ageMs)})`, }; } const need = reg.domain === PC_DOMAIN_STORE ? 'store.steampowered.com(Steam 商店页)' : 'steamcommunity.com(Steam 社区页)'; return { success: false, error: `${reg.label} 的缓存属于 ${reg.domain} 域,请在 ${need} 打开后重试`, needDomain: reg.domain, currentDomain: curDomain, hint: 'IndexedDB / localStorage 按源隔离,跨域读不到,属正常现象' + (pluginId === 'sglv' && !mir.ok ? ';游戏库可用跨域镜像,但需要先在商店域建立过镜像(或该键本身不在镜像范围内)' : ''), }; } // 2. list 模式 if (mode === 'list') { const declared = Object.entries(reg.keys).map(([k, v]) => ({ key: k, desc: v.desc, prefix: !!v.prefix, store: pcStoreOf(reg, v), envelope: !!v.envelope, })); let existing = []; if (reg.idb) { let db = null; try { db = await pcIdbOpen(reg.idb.name); // v1.14.0:按 store 分组探测(支持一库多 store,如 SBC 的 7 个仓库) const byStore = new Map(); for (const d of declared) { if (!byStore.has(d.store)) byStore.set(d.store, []); byStore.get(d.store).push(d); } for (const [st, items] of byStore) { let allKeys = []; try { allKeys = (await pcIdbGetAllKeys(db, st)).map(String); } catch { allKeys = []; } // 该 store 不存在时保持"无数据",不抛错 const set = new Set(allKeys); for (const d of items) { const instances = d.prefix ? pcPrefixInstances(reg, d.key, allKeys) : []; d.present = set.has(d.key) || instances.length > 0; if (d.prefix) d.matchedCount = instances.length; } existing = existing.concat(allKeys.filter(k => !existing.includes(k))); } } catch (e) { console.warn('[SA-Agent] readPluginCache list 打开 IDB 失败', e); } finally { try { db && db.close(); } catch {} } } else { // 纯 localStorage 插件:直接探测声明键是否存在 declared.forEach(d => { if (d.prefix) { const hits = pcPrefixInstances(reg, d.key, pcLocalKeysWithPrefix(d.key)); d.present = hits.length > 0; d.matchedCount = hits.length; } else { let v = null; try { v = localStorage.getItem(d.key); } catch {} d.present = v != null; } }); } // 单 store 插件保持原样(IndexedDB:库名/仓库名),多 store 时标注仓库数 const distinctStores = Array.from(new Set(declared.map(d => d.store))); const storeLabel = reg.idb ? `IndexedDB:${reg.idb.name}/${distinctStores[0] || ''}` + (distinctStores.length > 1 ? ` +${distinctStores.length - 1} 个仓库` : '') : 'localStorage'; return { success: true, data: { plugin: pluginId, label: reg.label, domain: reg.domain, storage: storeLabel, declaredKeys: declared, presentKeyCount: declared.filter(d => d.present).length, allKeysSample: existing.slice(0, 40), ...(existing.length ? { allKeysTotal: existing.length } : {}), }, summary: `${reg.label} 共声明 ${declared.length} 个缓存键,其中 ${declared.filter(d => d.present).length} 个在本机有数据`, }; } // 3. inspect / query 需要 key const resolved = pcResolveKeySpec(pluginId, params.key); if (resolved.error) return { success: false, error: resolved.error }; const { reg: pReg, spec, exactKey, prefix } = resolved; const ctx = { topN, fields }; // v1.14.0:objectStore 支持 per-key 覆盖(SBC 是一库多 store) const storeName = pcStoreOf(pReg, spec); const readByIdb = async (k) => { let db = null; try { db = await pcIdbOpen(pReg.idb.name); return await pcIdbGet(db, storeName, k); } finally { try { db && db.close(); } catch {} } }; try { // 前缀键但只给了前缀 → 列出实例键供模型选择 if (prefix && !exactKey) { let keys = []; if (pReg.idb) { let db = null; try { db = await pcIdbOpen(pReg.idb.name); keys = pcPrefixInstances(pReg, prefix, (await pcIdbGetAllKeys(db, storeName)).map(String)); } catch { keys = []; } finally { try { db && db.close(); } catch {} } } else { keys = pcPrefixInstances(pReg, prefix, pcLocalKeysWithPrefix(prefix)); } return { success: true, data: { plugin: pluginId, key: prefix, matchedCount: keys.length, matchedKeys: keys.slice(0, 60), hint: `请把 key 换成上面任一完整键后再 query` }, summary: `前缀键 ${prefix} 匹配到 ${keys.length} 个实例`, }; } const finalKey = exactKey || String(params.key).trim(); const raw = pReg.idb ? await readByIdb(finalKey) : pcLocalGet(finalKey); if (raw == null) { return { success: false, error: `缓存键 "${finalKey}" 在本机无数据(${PC_PLUGIN_LABEL[pluginId]} 可能未安装、未运行过或缓存已清理)`, missingKey: finalKey, hint: '可先用 mode=list 查看实际存在的键', }; } // v1.14.0:按需脱掉宿主写入的 TTL 信封,并采纳其时间戳算新鲜度 const env = spec.envelope ? pcUnwrapEnvelope(raw) : { payload: raw, updatedAt: null, expiresAt: null, expired: false }; const exists = env.payload; if (exists == null) { return { success: false, error: `缓存键 "${finalKey}" 的载荷为空(宿主写入时 data 字段为空)`, missingKey: finalKey }; } // 信封过期:宿主自己的 get() 会返回 null,这里如实告知而不是拿陈旧数据当新鲜的答 if (env.expired) { return { success: false, error: `缓存键 "${finalKey}" 已被宿主标记为过期(expiresAt=${env.expiresAt}),不应作为当前数据引用`, expiredKey: finalKey, expiredAt: env.expiresAt, hint: '请让对应插件重新抓取后再读;或改用网络工具获取实时数据', }; } // 新鲜度:信封优先(宿主写入时刻),否则沿用载荷自身的时间戳字段 const age = pcCacheAgeMinutes(spec.envelope ? { ts: env.updatedAt || undefined } : exists); // inspect:只看结构,不出数据 if (mode === 'inspect') { const isArr = Array.isArray(exists); const summary = isArr ? { type: 'array', count: exists.length, sampleItemKeys: exists[0] && typeof exists[0] === 'object' ? Object.keys(exists[0]).slice(0, 20) : null, sampleFirst: exists[0] ?? null } : { type: typeof exists, keys: (exists && typeof exists === 'object') ? Object.keys(exists).slice(0, 30) : null, value: exists }; return { success: true, data: { plugin: pluginId, key: finalKey, desc: spec.desc, structure: summary, cacheAgeMinutes: age }, summary: `${finalKey} 结构:${summary.type}${isArr ? `(${summary.count} 项)` : ''}`, }; } // query:走 digest 摘要 let digested; try { digested = spec.digest(exists, ctx); } catch (e) { // shape 漂移/摘要异常 → 降级为 inspect 信息,不抛裸错误 digested = { drifted: true, degradeReason: e.message, structureHint: typeof exists, keys: (exists && typeof exists === 'object') ? Object.keys(exists).slice(0, 20) : null }; } return { success: true, data: { plugin: pluginId, label: pReg.label, key: finalKey, desc: spec.desc, cacheAgeMinutes: age, stale: age != null && age > 24 * 60, ...digested, }, summary: digested && digested.drifted ? `${finalKey} 结构与预期不符,已降级返回结构信息` : `${finalKey} 命中本地缓存${age != null ? `(${age} 分钟前更新)` : ''}`, }; } catch (e) { return { success: false, error: `读取缓存失败:${e.message}`, hint: '若目标插件未安装,属正常现象' }; } }, }); // ============================================================ // v1.23.0 插件版本清单(版本契约 / plugin-contract.json) // ============================================================ // 三来源合并: // ① 契约(远端 JSON) → 期望装哪些、各自最低版本、探测信号 // ② 运行时注册(IDB) → 实际装了哪些 + **精确版本号** // ③ 探测(IDB 库名/localStorage)→ 没注册但装了的老插件也能识别 // // ⚠️ 为什么必须有 ①:GM 存储跨脚本沙箱隔离 → 读不到别人的 GM 值; // 只有接入 registerPlugin 的插件才能给出精确版本。契约提供兜底。 // ⚠️ 2026-10-21:URL 钉在 commit 3ca0d82(数据源清单瘦身版)—— // 实测 jsdelivr 对 @main 的 purge 当天不稳定(purge 受理但边缘仍吐旧缓存 ≤12h), // 按契约分发说明(data/plugin-contract.json 的 distribution.cdn.pinNote)钉 SHA 立即生效。 // ★ 将来更新契约清单:改源文件 → 推 steam-namespace → 把这里的 SHA 换成新 commit(或临时换回 @main)。 const PC_CONTRACT_URL = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@6789435/data/plugin-contract.json'; // ⚠️ 缓存键带版本号(v2):契约清单瘦身(2026-10-21)后要让所有用户立即拉到新清单, // 而不是等 12h TTL 自然过期 —— 改清单时同步把这里的 v2 递增即可强制刷新一次。 const PC_CONTRACT_CACHE_KEY = 'saa_plugin_contract_v2'; const PC_CONTRACT_TTL = 12 * 60 * 60 * 1000; // 12h // ═══ HLTB 索引(发行年份 + 剧情时长)═══ // ⭐ 分片键必须是 (appid >> 1) % 8 —— Steam 全量 82,494 个 appid 中 99.90% 是偶数, // 用 appid % 8 会让一半分片永远为空(离散度 99.9%)。见 build-index.js。 // ⭐ story 必须用 Float64Array —— Float32 精度只有 ~7 位有效数字, // 3.12 会被存成 3.119999885559082,导致 SCI 分档边界错档。 const PC_HLTB_BASE = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data'; const PC_HLTB_SHARDS = 8; const PC_HLTB_STATE = { ready: false, loading: false, err: '', shards: [], manifest: null }; function pcHltbFetchJson(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout: 20000, onload: (r) => { if (r.status < 200 || r.status >= 300) { reject(new Error('HTTP ' + r.status)); return; } try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(e); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } async function pcHltbLoad() { if (PC_HLTB_STATE.ready) return true; if (PC_HLTB_STATE.loading) return false; PC_HLTB_STATE.loading = true; PC_HLTB_STATE.err = ''; try { const manifest = await pcHltbFetchJson(`${PC_HLTB_BASE}/hltb-index-v1.manifest.json`); if (!manifest || !manifest.shards) throw new Error('manifest 格式异常'); const raw = await Promise.all(Array.from({ length: PC_HLTB_SHARDS }, (_, i) => pcHltbFetchJson(`${PC_HLTB_BASE}/hltb-index-v1.${i}.json`))); PC_HLTB_STATE.shards = raw.map((f) => ({ ids: Int32Array.from(f.ids || []), years: Int32Array.from(f.years || []), story: Float64Array.from(f.story || []), })); PC_HLTB_STATE.manifest = manifest; PC_HLTB_STATE.ready = true; PC_HLTB_STATE.loading = false; console.log(`[SA-Agent] HLTB 索引就绪:${manifest.entries} 条`); return true; } catch (e) { PC_HLTB_STATE.err = e.message || String(e); PC_HLTB_STATE.loading = false; console.warn('[SA-Agent] HLTB 索引加载失败', PC_HLTB_STATE.err); return false; } } /** 按 appid 查 HLTB 记录 → { y: 发行年, m: 剧情时长(小时) } | null */ function pcHltbGet(appid) { if (!PC_HLTB_STATE.ready) return null; const id = Number(appid); if (!Number.isFinite(id)) return null; const sh = PC_HLTB_STATE.shards[(id >> 1) % PC_HLTB_SHARDS]; if (!sh || !sh.ids.length) return null; let lo = 0, hi = sh.ids.length - 1; while (lo <= hi) { const mid = (lo + hi) >> 1; if (sh.ids[mid] === id) return { y: sh.years[mid], m: sh.story[mid] }; if (sh.ids[mid] < id) lo = mid + 1; else hi = mid - 1; } return null; } // ═══ 数据源授权 ═══ // 用户可在「设置 → 数据源」逐个勾选/取消勾选某个插件的数据是否允许 AI 读取。 // 存储:GM 键 sa_data_source_auth = { v:1, sources:{ [pluginId]: true|false } } // 语义:**未出现在 sources 里的插件 = 允许**(默认全开,保持既有行为)。 // 显式 false 才是「用户关了」。 const PC_AUTH_KEY = 'sa_data_source_auth'; function pcLoadAuth() { try { const raw = GM_getValue(PC_AUTH_KEY, ''); if (!raw) return { v: 1, sources: {} }; const o = JSON.parse(raw); if (!o || typeof o !== 'object') return { v: 1, sources: {} }; return { v: o.v || 1, sources: (o.sources && typeof o.sources === 'object') ? o.sources : {} }; } catch (e) { return { v: 1, sources: {} }; } } function pcSaveAuth(auth) { try { GM_setValue(PC_AUTH_KEY, JSON.stringify(auth)); return true; } catch (e) { return false; } } /** 该插件的数据是否被允许读取(默认允许) */ function pcDataSourceAllowed(pluginId) { const auth = pcLoadAuth(); return auth.sources[pluginId] !== false; } /** 列出授权状态(设置页用):{ [id]: {allowed, explicit} } */ function pcDataSourceAuthMap() { const auth = pcLoadAuth(); const out = {}; for (const id of Object.keys(PLUGIN_CACHE_REGISTRY)) { out[id] = { allowed: auth.sources[id] !== false, explicit: Object.prototype.hasOwnProperty.call(auth.sources, id), }; } return out; } function pcContractGetCached() { try { const raw = GM_getValue(PC_CONTRACT_CACHE_KEY, ''); if (!raw) return null; const o = JSON.parse(raw); if (!o || !o.v) return null; if (Date.now() - (o.ts || 0) > PC_CONTRACT_TTL) return null; return o.data || null; } catch (e) { return null; } } function pcContractFetch() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: PC_CONTRACT_URL, timeout: 15000, onload: (r) => { if (r.status < 200 || r.status >= 300) { reject(new Error('HTTP ' + r.status)); return; } try { const data = JSON.parse(r.responseText); try { GM_setValue(PC_CONTRACT_CACHE_KEY, JSON.stringify({ v: 1, ts: Date.now(), data })); } catch (e) { /* ignore */ } resolve(data); } catch (e) { reject(e); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } /** v1.30.3 跨域注册镜像的 GM 存储键。 * 背景:插件注册写在各自运行域的 IndexedDB(按域隔离),面板/工具只读当前域 → * 「市场系/账户系此消彼长」——社区域打开看不到账户系、商店域打开看不到好友系。 * 本插件在 store / community 两域都有实例,而 GM 存储同一脚本**跨域共享** → * 把本域读到的注册表镜像进 GM 存储,另一域实例打开时合并显示。 */ const PC_REG_MIRROR_KEY = 'sa_plugin_registry_mirror_v1'; /** 镜像条目保鲜期:超过则视为「插件可能已卸载」,不再并入(本域注册表会自然回填) */ const PC_REG_MIRROR_TTL = 14 * 864e5; /** 读运行时注册表:IDB sglv_plugin_host_storage / kv / shared:plugin:*(当前域) * + GM 跨域镜像(另一域的注册)。本域数据权威优先;读取后把本域注册回写镜像。 */ async function pcReadPluginRegistry() { const out = new Map(); const curDom = pcPanelDomain(); // 'store' | 'community' | '' const localRecs = []; try { const db = await pcIdbOpen('sglv_plugin_host_storage'); const rows = await pcIdbGetAll(db, 'kv'); for (const row of (rows || [])) { const key = String(row && row.key || ''); if (key.indexOf('shared:plugin:') !== 0) continue; const v = row.value; if (v && v.id) { const rec = Object.assign({}, v, { registeredAt: row.updatedAt || 0, regDomain: curDom }); out.set(v.id, rec); localRecs.push(rec); } } try { db.close(); } catch (e) { /* ignore */ } } catch (e) { /* 库不存在 → 没插件接入注册 */ } // ① 跨域镜像合并:镜像里有、本域没有的 → 并入(viaMirror 标记,面板/工具据此区分来源) let mirror = null; try { const raw = GM_getValue(PC_REG_MIRROR_KEY, null); mirror = (raw && typeof raw === 'object' && raw.v === 1 && raw.plugins && typeof raw.plugins === 'object') ? raw : { v: 1, updatedAt: 0, plugins: {} }; } catch (e) { mirror = { v: 1, updatedAt: 0, plugins: {} }; } const now = Date.now(); for (const id of Object.keys(mirror.plugins)) { if (out.has(id)) continue; const m = mirror.plugins[id]; if (!m || m.id !== id || (now - (m.ts || 0)) > PC_REG_MIRROR_TTL) continue; out.set(id, Object.assign({}, m, { viaMirror: true, registeredAt: m.ts || 0 })); } // ② 本域注册回写镜像(同 id 取 ts 较新者;无变化不写,避免 GM 存储写放大) let dirty = false; for (const rec of localRecs) { const prev = mirror.plugins[rec.id]; if (!prev || (rec.ts || rec.registeredAt || 0) >= (prev.ts || 0)) { mirror.plugins[rec.id] = { id: rec.id, name: rec.name, family: rec.family, version: rec.version, versionSource: rec.versionSource, capabilities: rec.capabilities || null, ts: rec.ts || rec.registeredAt || now, regDomain: curDom, }; dirty = true; } } if (dirty) { mirror.updatedAt = now; try { GM_setValue(PC_REG_MIRROR_KEY, mirror); } catch (e) { /* quota 等静默降级 */ } } return out; } /** 探测:按契约里的 detect 信号判断插件「装了没」 */ async function pcDetectPlugin(sig) { if (!sig || !sig.type) return false; try { if (sig.type === 'idb') { const db = await pcIdbOpen(sig.db); const has = db.objectStoreNames.contains(sig.store); try { db.close(); } catch (e) { /* ignore */ } return has; } if (sig.type === 'localStorage') { const keys = pcLocalKeysWithPrefix(sig.prefix); return keys.length > 0; } } catch (e) { /* ignore */ } return false; } /** 版本号比较:a < b → -1;a > b → 1;相等 → 0;无法解析 → null */ function pcCompareVersion(a, b) { if (!a || !b) return null; const pa = String(a).split(/[.\-+]/).map(x => parseInt(x, 10)); const pb = String(b).split(/[.\-+]/).map(x => parseInt(x, 10)); if (!pa.length || pa.some(isNaN) || !pb.length || pb.some(isNaN)) return null; const n = Math.max(pa.length, pb.length); for (let i = 0; i < n; i++) { const x = pa[i] || 0, y = pb[i] || 0; if (x !== y) return x < y ? -1 : 1; } return 0; } /** v1.30.2 当前面板所在的域:'store'(商店域)/ 'community'(社区域)/ ''(未知)。 * 插件注册写在各自运行域的 IndexedDB(按域隔离),跨域读不到 —— * 面板用它与契约的 plugin.domain 比对,把「未注册 + 注册在其他域」标成 * 「需在对应域确认」而不是误导性的「未安装」。 */ function pcPanelDomain() { try { const h = String((typeof location !== 'undefined' && location.hostname) || ''); if (h === 'steamcommunity.com') return 'community'; if (h.indexOf('steampowered.com') !== -1) return 'store'; } catch (e) { /* ignore */ } return ''; } ToolRegistry.register({ name: 'getInstalledPlugins', description: '查询本机已安装的 Steam 油猴插件、它们的版本号,以及是否「装齐了」。' + '数据来自三处合并:① 版本契约(期望清单 + 最低版本)② 各插件通过共享宿主注册的真实版本号 ③ 本地存储探测。' + '适合回答「我的 steam 插件装齐了吗」「steamkit 系列装了哪些」「XX 插件版本过旧吗」「好友管理器装了没」这类问题——' + '不联网查商店,只看本机实际状态。' + '⚠️ 判读口径:只有「注册过」或「有本地存储探测信号」的插件才能确认状态;' + '未注册且无探测信号的会被标为 decidable=false(无法判断),' + '这类插件只是没在当前页面运行过,未必真没装——回答用户时不要把它们说成「未安装」。', parameters: { type: 'object', properties: { family: { type: 'string', description: '只看某个分组:core / social / market / account / badge / utility / ai' }, name: { type: 'string', description: '按名称/别名模糊匹配,如 "steamkit"、"市场"、"wishlist"' }, outdatedOnly: { type: 'boolean', description: '只返回版本低于契约最低要求的插件' }, missingOnly: { type: 'boolean', description: '只返回未检测到的插件(其中 decidable=false 的只是探测不到,未必真没装)' }, refresh: { type: 'boolean', description: '强制重新拉取契约(默认走 12h 缓存)' }, }, }, category: 'knowledge', async execute(params) { const family = params && params.family ? String(params.family).trim() : ''; const nameQ = params && params.name ? String(params.name).trim().toLowerCase() : ''; // ① 契约 let contract = params && params.refresh ? null : pcContractGetCached(); let contractErr = ''; if (!contract) { try { contract = await pcContractFetch(); } catch (e) { contractErr = e.message || String(e); } } if (!contract) { return { success: false, error: '版本契约不可用(无法拉取 ' + PC_CONTRACT_URL + ')' + (contractErr ? ':' + contractErr : '') + ';请检查网络或稍后重试', }; } // ② 运行时注册的真实版本 const registry = await pcReadPluginRegistry(); // ③ 逐个插件:合并「注册版本」与「探测结果」 const list = contract.plugins || []; const rows = []; for (const p of list) { if (family && p.family !== family) continue; if (nameQ && !((p.name || '').toLowerCase().includes(nameQ) || (p.id || '').includes(nameQ))) continue; const reg = registry.get(p.id) || null; // ⚠️ detect 为空 = 该插件没有任何「可跨脚本探测」的信号(要么零存储、要么只用 GM 存储—— // GM 存储跨脚本沙箱隔离,别的脚本读不到)。此时「未注册」只说明它没在本页面运行过, // 不能据此断言「未安装」,否则必然误报。故用 decidable 区分「确认未装」与「无法判断」。 const hasDetect = Array.isArray(p.detect) && p.detect.length > 0; let probed = false; if (!reg && hasDetect) { for (const sig of p.detect) { if (await pcDetectPlugin(sig)) { probed = true; break; } } } const installed = !!reg || probed; const decidable = !!reg || hasDetect; const version = reg ? (reg.version || null) : null; const cmp = (version && p.minVersion) ? pcCompareVersion(version, p.minVersion) : null; const outdated = cmp !== null && cmp < 0; rows.push({ id: p.id, name: p.name, family: p.family, required: p.required === true, installed, decidable, // v1.30.3 registry-mirror = 注册来自另一域的 GM 镜像(非本域 IndexedDB 实时读) detectedBy: reg ? (reg.viaMirror ? 'registry-mirror' : 'registry') : (probed ? 'probe' : null), regDomain: reg ? (reg.regDomain || null) : null, version: version, versionKnown: !!version, minVersion: p.minVersion || null, outdated, lastSeen: reg ? (reg.registeredAt || null) : null, note: (!version && installed) ? '已检测到但未注册版本号(该插件尚未接入 registerPlugin)' : (reg && reg.viaMirror ? ('该插件注册在' + (reg.regDomain === 'community' ? '社区' : '商店') + '域(IndexedDB 按域隔离),版本号经跨域镜像读取') : (!installed ? (decidable ? '未检测到' : '未注册且无探测信号,无法判断是否安装(该插件未在本页面运行过、或只用 GM 存储时探测不到)') : null)), }); } const installedRows = rows.filter(r => r.installed); const missingRows = rows.filter(r => !r.installed); // 保持二分语义(含「无法判断」) const absentRows = rows.filter(r => !r.installed && r.decidable); // 确认未装 const unknownRows = rows.filter(r => !r.installed && !r.decidable); // 无探测信号,判断不了 const outdatedRows = rows.filter(r => r.outdated); const byFamily = {}; for (const r of rows) { const f = r.family || '_other'; if (!byFamily[f]) byFamily[f] = { installed: 0, total: 0, missing: [], unknown: [], outdated: [] }; byFamily[f].total++; if (r.installed) byFamily[f].installed++; else if (!r.decidable) byFamily[f].unknown.push(r.id); else byFamily[f].missing.push(r.id); if (r.outdated) byFamily[f].outdated.push(r.id); } const filtered = (params && params.outdatedOnly) ? outdatedRows : (params && params.missingOnly) ? missingRows : rows; return { success: true, data: { contractVersion: contract.v, contractUpdatedAt: contract.updatedAt, summary: `契约 ${list.length} 个插件:已装 ${installedRows.length},未装 ${absentRows.length},` + `无法判断 ${unknownRows.length},版本过旧 ${outdatedRows.length}`, installed: filtered.filter(r => r.installed), missing: filtered.filter(r => !r.installed), unknown: filtered.filter(r => !r.installed && !r.decidable), outdated: outdatedRows, byFamily, versionKnownCount: installedRows.filter(r => r.versionKnown).length, }, summary: `已装 ${installedRows.length}/${list.length} 个插件(其中 ${installedRows.filter(r => r.versionKnown).length} 个能读到版本号),` + `未装 ${absentRows.length}` + (unknownRows.length ? `,另有 ${unknownRows.length} 个无法判断(未注册且无探测信号,未必真没装):${unknownRows.map(r => r.name).join('、')}` : '') + `,版本过旧 ${outdatedRows.length}` + (outdatedRows.length ? ':' + outdatedRows.map(r => r.name + ' ' + r.version + '<' + r.minVersion).join('、') : ''), }; }, }); // ============================================================ // 剧情时长(SCI)+ 远程数据中枢查询 // ============================================================ // ---- SCI 分档(口径与 SGLV PTW 分析层逐字一致)---- // SCI = (playtime_forever 分钟 / 60) / HLTB 主线时长(小时) // 序章搁浅 <0.35 / 剧情未竟 0.35-0.8 / 剧情通关 0.8-1.3 / 支线探索家 1.3-2.5 / 自由漫游 >2.5 const PC_SCI_BUCKETS = [ { key: 'stalled', max: 0.35, label: '序章搁浅' }, { key: 'unfinished', max: 0.8, label: '剧情未竟' }, { key: 'done', max: 1.3, label: '剧情通关' }, { key: 'explorer', max: 2.5, label: '支线探索家' }, { key: 'free', max: Infinity, label: '自由漫游' }, ]; function pcSciBucket(sci) { for (const b of PC_SCI_BUCKETS) if (sci < b.max) return b; return PC_SCI_BUCKETS[PC_SCI_BUCKETS.length - 1]; } // ---- 年代分桶(须与 hltb-scraper/build-index.js 的 eraOf() 逐字一致)---- // year<2004 → pre04 / year<=2014 → 04-14 / year<=2024 → 15-24 / else 25+ const PC_ERA_BUCKETS = [ { key: 'pre04', label: '2004 前' }, { key: '04-14', label: '2004-2014' }, { key: '15-24', label: '2015-2024' }, { key: '25+', label: '2025+' }, ]; function pcEraBucket(year) { const y = Number(year); if (!Number.isFinite(y) || y <= 0) return null; if (y < 2004) return PC_ERA_BUCKETS[0]; if (y <= 2014) return PC_ERA_BUCKETS[1]; if (y <= 2024) return PC_ERA_BUCKETS[2]; return PC_ERA_BUCKETS[3]; } // ============================================================ // v1.36.0 跨域镜像:游戏库 GM 镜像(社区域也能用「我的游戏库」搜索) // ============================================================ // 问题:SGLV 的游戏库缓存写在 **store 域的 IndexedDB**(IndexedDB / localStorage 按源隔离), // 在 steamcommunity.com 上那个库根本不存在 → 搜索浮窗的「我的游戏库」组直接报错不可用。 // 方案:沿用 v1.30.3「插件注册表跨域镜像」的同款机制 —— **GM 存储是同一脚本跨域共享的** // (与 IndexedDB/localStorage 按源隔离正好相反;GM 只是**按脚本**隔离)。 // 商店域读到权威缓存后写一份**紧凑镜像**(元组数组,百 KB 级)进本脚本的 GM 键; // 社区域读不到本域 IDB 时回退镜像。零 Steam API 配额、毫秒级。 // 代价:镜像可能比本域缓存旧 → 用 ts / stale 如实声明新鲜度,不假装是实时数据。 const PC_LIB_MIRROR_KEY = 'sa_sglv_library_mirror_v1'; const PC_LIB_MIRROR_TTL = 7 * 864e5; // 镜像保鲜期 7 天(超期视为无效) const PC_LIB_MIRROR_STALE_MS = 24 * 3600e3; // 超过 1 天算「偏旧」,须向用户声明 const PC_LIB_MIRROR_WRITE_INTERVAL = 30 * 60 * 1000; // 同域重复写镜像的最小间隔 const PC_LIB_MIRROR_MAX = 5000; // 条数上限,防库爆炸 /** 人话化的数据年龄(给 UI / 提示词用) */ function pcHumanAge(ms) { const mins = Math.max(0, Math.round((Number(ms) || 0) / 60000)); if (mins < 60) return `${mins} 分钟前`; const h = Math.round(mins / 60); if (h < 48) return `${h} 小时前`; return `${Math.round(h / 24)} 天前`; } /** 读跨域镜像(只判结构,TTL 由调用方决定) */ function pcReadLibraryMirror() { try { const raw = GM_getValue(PC_LIB_MIRROR_KEY, null); if (!raw || raw.v !== 1 || !Array.isArray(raw.games)) return { ok: false, reason: 'none' }; const ts = Number(raw.ts) || 0; const ageMs = Math.max(0, Date.now() - ts); const games = []; for (const t of raw.games) { if (!Array.isArray(t)) continue; const appid = Number(t[0]); if (!Number.isFinite(appid)) continue; games.push({ appid, name: String(t[1] || ''), playtime_forever: Number(t[2]) || 0 }); } return { ok: true, games, ts, ageMs, count: games.length, stale: ageMs > PC_LIB_MIRROR_STALE_MS, expired: ageMs > PC_LIB_MIRROR_TTL, }; } catch (e) { return { ok: false, reason: 'error' }; } } /** 写跨域镜像(紧凑元组 [appid, name, playtime],百 KB 级而不是原始 0.5~2MB) */ function pcWriteLibraryMirror(games) { try { const arr = Array.isArray(games) ? games : []; const tuples = []; for (const g of arr) { if (!g) continue; const appid = Number(g.appid); if (!Number.isFinite(appid)) continue; tuples.push([appid, String(g.name || '').slice(0, 120), Math.round(Number(g.playtime_forever) || 0)]); if (tuples.length >= PC_LIB_MIRROR_MAX) break; } if (!tuples.length) return false; GM_setValue(PC_LIB_MIRROR_KEY, { v: 1, ts: Date.now(), domain: PC_DOMAIN_STORE, count: tuples.length, games: tuples, }); return true; } catch (e) { return false; } } /** 节流写镜像:30 分钟内不重复写(避免每次读都往 GM 存储塞百 KB) */ function pcMaybeWriteLibraryMirror(games) { try { const cur = pcReadLibraryMirror(); if (cur.ok && cur.ageMs < PC_LIB_MIRROR_WRITE_INTERVAL) return false; return pcWriteLibraryMirror(games); } catch (e) { return false; } } /** 本域读不到时的兜底:跨域 GM 镜像(v1.36.0) */ function pcLibraryMirrorFallback(localError) { const mir = pcReadLibraryMirror(); if (mir.ok && !mir.expired) { return { ok: true, games: mir.games, viaMirror: true, mirroredAt: mir.ts, ageMs: mir.ageMs, stale: mir.stale, localError, }; } const why = mir.expired ? '跨域镜像已过期(超过 7 天)' : '没有可用的跨域镜像'; return { ok: false, error: `${localError}(${why})。` + '跨域读不到是因为 IndexedDB 按源隔离;' + '请先在 store.steampowered.com 打开一次 Steam AI Agent 面板(会自动建立镜像),之后社区页也能搜游戏库。', mirrorReason: mir.expired ? 'expired' : (mir.reason || 'none'), }; } /** * 读 SGLV 的本地游戏库缓存 → [{appid,name,playtime_forever,...}] * 数据源优先级:①本域 IndexedDB sglv_kv / kv / 'sglv_cached_games'(权威,商店域) * ②v1.36.0 跨域 GM 镜像(社区域回退用,商店域读不到时兜底) * 返回 viaMirror / mirroredAt / ageMs / stale 供调用方声明数据来源与新鲜度。 */ async function pcReadSglvLibrary() { const reg = PLUGIN_CACHE_REGISTRY.sglv; let db = null; try { db = await pcIdbOpen(reg.idb.name); if (!db.objectStoreNames || !db.objectStoreNames.contains(reg.idb.store)) { return pcLibraryMirrorFallback(`IndexedDB ${reg.idb.name} 里没有 ${reg.idb.store} 仓库 —— 「游戏库展示」插件可能从未在本域运行过`); } const raw = await pcIdbGet(db, reg.idb.store, 'sglv_cached_games'); if (raw == null) { return pcLibraryMirrorFallback('未找到游戏库缓存(sglv_cached_games)—— 请先在 Steam 商店页打开「游戏库展示」插件并刷新一次'); } const arr = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.games) ? raw.games : null); if (!arr) return pcLibraryMirrorFallback('游戏库缓存结构无法识别(既不是数组也不是 {games:[] })'); // 商店域读到本域权威缓存 → 顺带刷新跨域镜像(社区域要用) if (pcCurrentDomain() === PC_DOMAIN_STORE) pcMaybeWriteLibraryMirror(arr); return { ok: true, games: arr, viaMirror: false }; } catch (e) { return pcLibraryMirrorFallback('读取游戏库缓存失败:' + (e && e.message || e)); } finally { try { db && db.close(); } catch (e) { /* ignore */ } } } /** * v1.36.0 商店域自动刷新跨域镜像(节流 30 分钟)。 * 目的:让社区域随时能搜「我的游戏库」,而不必用户先在商店域手动触发一次搜索。 * 失败静默 —— 镜像只是加速路径,不该影响主流程。 */ async function pcAutoMirrorLibrary() { try { if (pcCurrentDomain() !== PC_DOMAIN_STORE) return; const mir = pcReadLibraryMirror(); if (mir.ok && mir.ageMs < PC_LIB_MIRROR_WRITE_INTERVAL) return; // 刚建过,不重复读 IDB const lib = await pcReadSglvLibrary(); // 内部成功即写镜像 if (lib.ok && !lib.viaMirror) { console.log(`[SA-Agent] 游戏库跨域镜像已更新(${lib.games.length} 款)`); } } catch (e) { /* ignore:镜像失败不影响主流程 */ } } ToolRegistry.register({ name: 'getStoryData', description: '查询游戏的**主线剧情时长**(HowLongToBeat 口径)与发行年份;并能把「主线时长」和「我的游玩时长」相除,算出剧情完成度 SCI。' + '适合回答「XX 要玩多久才通关」「我到底通没通关 XX」「我的库里哪些游戏买了没玩」「我买的是独立小作还是大作」这类问题——' + 'Steam Web API 结构上答不了(它没有主线时长,也没有逐日历史)。' + '两种模式:① 不传 appids = 读我的游戏库(需在 Steam 商店页打开过「游戏库展示」插件),按剧情时长/完成度/年份/游玩时长排序取前 N;' + '② 传 appids = 精确查这几款。' + '⚠️ HLTB 只能覆盖能对上的部分(实测全量覆盖 54.6%,其余是主机独占/未发售等永久缺口),' + '所以「查不到」= 该游戏没有可用的主线时长数据,**不等于 0 小时**,回答时不要把缺失说成 0。', parameters: { type: 'object', properties: { appids: { type: 'array', items: { type: 'number' }, description: '要查询的 appid 列表(数字)。不传 = 走「我的游戏库」模式' }, sort: { type: 'string', enum: ['story', 'sci', 'year', 'playtime'], description: '排序维度:story=主线时长 / sci=剧情完成度 / year=发行年份 / playtime=游玩时长(默认 story)' }, topN: { type: 'number', description: '返回条数,默认 20,最大 100' }, minStory: { type: 'number', description: '只看主线时长 ≥ N 小时的游戏(库模式有效)' }, }, }, category: 'knowledge', async execute(params) { const topN = Math.max(1, Math.min(Number(params.topN) || 20, 100)); const sortBy = String(params.sort || 'story').trim().toLowerCase(); const minStory = Number(params.minStory) || 0; // 1. 索引加载(懒加载 + 内存缓存,失败静默不抛) const ok = await pcHltbLoad(); if (!ok) { return { success: false, error: `HLTB 索引加载失败:${PC_HLTB_STATE.err || '未知原因'}。剧情时长数据暂不可用 —— 可改用 readPluginCache / getOwnedGames,或稍后重试。`, }; } const manifest = PC_HLTB_STATE.manifest || {}; const coverage = manifest.coverage || null; const eraReference = (manifest.reference && manifest.reference.eraDist) || null; // 2. 指定 appids 模式 const ids = Array.isArray(params.appids) ? params.appids.map(Number).filter((n) => Number.isFinite(n)) : []; if (ids.length) { const rows = ids.slice(0, 100).map((id) => { const h = pcHltbGet(id); const era = h ? pcEraBucket(h.y) : null; return { appid: id, name: null, year: h ? h.y : null, era: era ? era.key : null, eraLabel: era ? era.label : null, storyHours: h ? h.m : null, sci: null, sciLabel: null, playtimeHours: null, }; }); const hit = rows.filter((r) => r.storyHours != null).length; return { success: true, data: { mode: 'appids', indexEntries: manifest.entries || 0, coverage, total: rows.length, matched: hit, rows, note: hit < rows.length ? `${rows.length - hit} 款在 HLTB 索引中无记录(可能是主机独占/未发售/非游戏),不代表主线时长为 0` : null, }, summary: `查询 ${rows.length} 款游戏,${hit} 款在 HLTB 索引中命中主线时长`, }; } // 3. 库模式:授权 → 读缓存(本域 IDB 优先,读不到回退跨域 GM 镜像) if (!pcDataSourceAllowed('sglv')) { return { success: false, error: '用户未授权读取「SGLV 游戏库展示」的数据。如需启用,请在设置 → 数据源 中勾选。', needAuth: true, plugin: 'sglv', }; } // v1.36.0:不再硬拦域 —— pcReadSglvLibrary 内部会在本域读不到时回退跨域镜像 const curDomain = pcCurrentDomain(); const lib = await pcReadSglvLibrary(); if (!lib.ok) { return { success: false, error: lib.error, ...(curDomain !== PC_DOMAIN_STORE ? { needDomain: PC_DOMAIN_STORE, currentDomain: curDomain } : {}), }; } // 4. join + 算 SCI const rows = []; for (const g of lib.games) { const appid = Number(g && g.appid); if (!Number.isFinite(appid)) continue; const h = pcHltbGet(appid); const playMin = Number(g.playtime_forever) || 0; const playHours = Math.round(playMin / 6) / 10; const story = h ? h.m : null; const sci = (story && story > 0) ? (playHours / story) : null; const era = h ? pcEraBucket(h.y) : null; rows.push({ appid, name: (g.name || `App ${appid}`) + '', playtimeHours: playHours, storyHours: story, sci: sci == null ? null : Math.round(sci * 100) / 100, sciLabel: sci == null ? null : pcSciBucket(sci).label, year: h ? h.y : null, era: era ? era.key : null, eraLabel: era ? era.label : null, }); } const totalGames = rows.length; const withStory = rows.filter((r) => r.storyHours != null); const withSci = rows.filter((r) => r.sci != null); // SCI 分档分布 const sciDist = {}; for (const b of PC_SCI_BUCKETS) sciDist[b.key] = 0; for (const r of withSci) sciDist[pcSciBucket(r.sci).key]++; // 年代分布(仅已匹配部分 —— 系统性偏新,必须与人口参考对照) const eraDist = {}; for (const b of PC_ERA_BUCKETS) eraDist[b.key] = 0; for (const r of withStory) { const e = pcEraBucket(r.year); if (e) eraDist[e.key]++; } const filtered = minStory > 0 ? rows.filter((r) => r.storyHours != null && r.storyHours >= minStory) : rows; const cmp = { story: (a, b) => (b.storyHours || 0) - (a.storyHours || 0), sci: (a, b) => (b.sci || 0) - (a.sci || 0), year: (a, b) => (b.year || 0) - (a.year || 0), playtime: (a, b) => (b.playtimeHours || 0) - (a.playtimeHours || 0), }[sortBy] || ((a, b) => (b.storyHours || 0) - (a.storyHours || 0)); const sorted = filtered.slice().sort(cmp); const top = sorted.slice(0, topN); return { success: true, data: { mode: 'library', indexEntries: manifest.entries || 0, coverage, totalGames, // v1.36.0:数据来源与新鲜度(跨域镜像时必须如实声明,不能当成实时数据) source: lib.viaMirror ? 'cross-domain-mirror' : 'local-indexeddb', ...(lib.viaMirror ? { mirroredAt: lib.mirroredAt, mirrorAgeMinutes: Math.max(0, Math.round((lib.ageMs || 0) / 60000)), mirrorAgeText: pcHumanAge(lib.ageMs), stale: !!lib.stale, sourceNote: '游戏库来自商店域缓存的跨域 GM 镜像(IndexedDB 按源隔离,社区域读不到本域库);' + `快照时间 ${pcHumanAge(lib.ageMs)},可能已过期,回答时需声明数据新鲜度。`, } : {}), matched: withStory.length, matchedPct: totalGames ? Math.round((withStory.length / totalGames) * 1000) / 10 : 0, sciSample: withSci.length, sciDist, eraDist, eraReference, eraCaveat: 'eraDist 是「已匹配部分」的年代分布,**系统性偏新**(实测最大 +18.8pt);' + 'eraReference 是 HLTB 全人口参考分布,回答年代相关问题时必须两者对照,不要单看 eraDist。', sortedBy: sortBy, rows: top, truncated: sorted.length > top.length ? sorted.length - top.length : 0, }, summary: `我的库 ${totalGames} 款中 ${withStory.length} 款有主线时长(${totalGames ? Math.round((withStory.length / totalGames) * 1000) / 10 : 0}%),` + `可算 SCI 的 ${withSci.length} 款;按 ${sortBy} 排序取前 ${top.length} 款`, }; }, }); /* ==== saa-auth 认证库 v1.30.2 起改为 @require 分发(libs/saa-auth.lib.js,钉 commit 6789435)==== * 内联副本已删除 —— 双副本必然漂移(历史教训:内联块缺 b72763a 的路径归一化修复, * 带中文/空格路径的签名请求必然 E_DEVICE_SIG)。会话/设备密钥/签名全由库管理, * 主脚本经 window.SAAAuth(pcHubAuthLib)访问,接口不变。 ==== */ // ============================================================ // v1.30.0 远程数据中枢(data-hub)查询 // ============================================================ // ⚠️ 安全约束(防 SSRF / 防越权,五条硬规矩): // 1. **地址由用户在设置里配**,模型永远不能指定 URL;拼装时只认路由白名单。 // 2. 默认只允许 https;明文 http 需要用户显式打开开关(远程必须 TLS —— 设备签名依赖私钥)。 // 3. 内网/本机地址默认拦截(自建内网服务器可显式放行)。 // 4. 凭据**不再手动粘贴 token**:走「设备密钥 + Steam 身份认证」换来的会话, // 会话由 libs/saa-auth.lib.js 管理(设备私钥永不上传,服务端永远拿不到明文默认 token)。 // 5. 路由白名单:只认下面那几条;filters / sort / scope 走本地白名单解析器。 const PC_HUB_CONFIG_KEY = 'sa_hub_config_v2'; const PC_HUB_LEGACY_CONFIG_KEY = 'sa_hub_config'; // v1.26 的 {port, token} —— 主动丢弃 // ★ v1.33.0 默认地址三级回落(§3.5.1):用户显式设置 → 远程 data/hub-endpoint.json → 内置常量。 // baseUrl 为空 = 「回落默认」(用户不改即为默认);想完全不用中枢用单独的 disabled 开关。 const PC_HUB_DEFAULT_BASE_URL = 'https://hub.leansoftx.com'; const PC_HUB_ENDPOINT_REMOTE_URL = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/hub-endpoint.json'; const PC_HUB_ENDPOINT_GM_KEY = 'sa_hub_endpoint_cache_v1'; let _pcHubRemoteEndpoint = null; // { baseUrl, fetchedAt }(内存缓存,GM 侧另有一份跨会话) const PC_HUB_FILTERS = { year: 'num', steam_year: 'num', story_hours: 'num', extra_hours: 'num', price: 'num', hltb_id: 'num', type: 'str', name: 'str', name_cn: 'str', }; const PC_HUB_SORTABLE = ['year', 'steam_year', 'story_hours', 'extra_hours', 'price', 'hltb_id']; const PC_HUB_SCOPES = ['all', 'mine', 'unplayed', 'wishlist']; /** 读端点白名单(前缀匹配以支持 /app/:id 这类路径参数) */ const PC_HUB_ROUTES = [ '/health', '/search', '/app/', '/library/', '/owned/', '/profile/', '/report/', '/timeline', '/stats', '/sources', '/analysis', '/filters', '/bias', '/skills', '/accounts', '/tables', '/table/', '/columns/', '/count', '/vocab', '/link', ]; /** 写端点白名单(模型只能触发这几条;v1.33.0 加版本上报与撤销) */ const PC_HUB_WRITE_ROUTES = ['/push/agent', '/report/plugins', '/report/plugins/optout']; function pcHubConfig() { let raw = ''; try { raw = GM_getValue(PC_HUB_CONFIG_KEY, ''); } catch (e) { /* ignore */ } let o = {}; if (raw) { try { o = JSON.parse(raw) || {}; } catch (e) { o = {}; } } const disabled = !!o.disabled; const explicit = String(o.baseUrl || '').trim().replace(/\/+$/, ''); // 三级回落:显式设置 → 远程缓存(已过 https 公网校验)→ 内置常量。disabled 时一律不可用。 let baseUrl = explicit; if (!baseUrl && !disabled) { baseUrl = (_pcHubRemoteEndpoint && _pcHubRemoteEndpoint.baseUrl) || PC_HUB_DEFAULT_BASE_URL; } return { baseUrl: disabled ? '' : baseUrl, explicitBaseUrl: explicit, // 空 = 从未设置 / 已清空(回落默认) disabled, isDefaultUrl: !explicit, // 当前生效的是回落默认地址 allowHttp: !!o.allowHttp, // 允许明文 http(仅内网调试;默认地址走 https 不受影响) allowPrivate: !!o.allowPrivate, // 允许内网/本机地址(自建服务器) }; } /** 远程默认地址热更:拉 data/hub-endpoint.json(GM 缓存 24h + 失败保留旧值)。 * 为什么远程可改:它是「装上就连哪里」的唯一开关点,写死意味着换地址必须发版。 */ function pcHubResolveEndpointRemote() { return new Promise((resolve) => { let cached = null; try { const cachedRaw = GM_getValue(PC_HUB_ENDPOINT_GM_KEY, ''); cached = cachedRaw ? JSON.parse(cachedRaw) : null; } catch (e) { cached = null; } if (cached && cached.baseUrl && (Date.now() - (cached.fetchedAt || 0)) < 24 * 3600 * 1000) { _pcHubRemoteEndpoint = cached; resolve(cached.baseUrl); return; } GM_xmlhttpRequest({ method: 'GET', url: PC_HUB_ENDPOINT_REMOTE_URL, timeout: 15000, responseType: 'text', onload: (res) => { try { const j = JSON.parse(res.responseText || '{}'); const u = j && j.baseUrl ? new URL(String(j.baseUrl)) : null; // 安全校验:远程配置只接受 https 公网地址(与 assertSafeRequestUrl 默认口径一致) if (!u || u.protocol !== 'https:' || u.username || u.password) throw new Error('bad'); const rec = { baseUrl: String(j.baseUrl).replace(/\/+$/, ''), fetchedAt: Date.now() }; _pcHubRemoteEndpoint = rec; try { GM_setValue(PC_HUB_ENDPOINT_GM_KEY, JSON.stringify(rec)); } catch (e) { /* ignore */ } resolve(rec.baseUrl); } catch (e) { resolve(null); } // 载荷不合法 → 保留旧值/内置常量 }, onerror: () => resolve(null), ontimeout: () => resolve(null), }); }); } function pcSaveHubConfig(cfg) { try { GM_setValue(PC_HUB_CONFIG_KEY, JSON.stringify({ baseUrl: String(cfg.baseUrl || '').trim().replace(/\/+$/, ''), // 允许空串 = 回落默认 disabled: !!cfg.disabled, allowHttp: !!cfg.allowHttp, allowPrivate: !!cfg.allowPrivate, })); // 老配置(端口 + 手填 token)主动清掉:token 已不需要,留着是隐患 try { GM_setValue(PC_HUB_LEGACY_CONFIG_KEY, ''); } catch (e) { /* ignore */ } return true; } catch (e) { return false; } } /** 路由白名单:模型只能触发白名单里的端点 */ function pcHubRouteAllowed(path, method) { const p = String(path || ''); if (String(method || 'GET').toUpperCase() === 'POST') { return PC_HUB_WRITE_ROUTES.some((r) => p === r); } return PC_HUB_ROUTES.some((r) => ( // 以 / 结尾 = 前缀匹配(/app/、/table/…);否则精确匹配 + 其子路径(/skills、/skills/:id) r.endsWith('/') ? p.startsWith(r) : (p === r || p.startsWith(r + '/')) )); } /** 本地过滤串解析(白名单 + 数值/文本校验)——与服务端同一套口径 */ function pcHubParseFilters(str) { const out = []; if (!str) return out; for (const raw of String(str).split(/[,;]/)) { const p = raw.trim(); if (!p) continue; const m = p.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*(>=|<=|!=|=|>|<)\s*(.+)$/); if (!m) throw new Error(`无法解析过滤条件 "${p}"(示例:year>=2020, story_hours>10)`); const [, field, op, valRaw] = m; const kind = PC_HUB_FILTERS[field]; if (!kind) throw new Error(`未知过滤字段 "${field}",可用:${Object.keys(PC_HUB_FILTERS).join(' / ')}`); let val = valRaw.trim(); if (kind === 'num') { const n = Number(val); if (!Number.isFinite(n)) throw new Error(`字段 "${field}" 需要数值,收到 "${val}"`); val = n; } else if (/["'`;()\\]/.test(val)) { throw new Error(`字段 "${field}" 的值含不允许的字符:"${val}"`); } out.push({ field, op, val }); } return out; } /** 认证库(libs/saa-auth.lib.js,@require 注入)—— 缺失则远程中枢不可用 */ function pcHubAuthLib() { try { return (typeof window !== 'undefined' && window.SAAAuth) ? window.SAAAuth : null; } catch (e) { return null; } } /** 把用户配置的地址同步给认证库(地址变了要同步,否则签名的是旧 host) */ function pcHubSyncAuth() { const auth = pcHubAuthLib(); if (!auth) return null; const cfg = pcHubConfig(); if (cfg.baseUrl) { try { auth.configure({ baseUrl: cfg.baseUrl }); } catch (e) { /* ignore */ } } return auth; } /** * 已认证会话的账号(Steam OpenID 验签得来)。 * ⭐ 这是**服务端认可的归属** —— 服务端会拿它跟上报载荷的 subject 比对, * 不一致直接 E_PUSH_SUBJECT 拒收。所以本地解析出的账号 * (cookie / 设置 / 库主账号回退)只作辅助,冲突时以它为准。 * @returns {string} 空串 = 还没绑定账号 */ function pcHubSessionSubject() { try { const auth = pcHubAuthLib(); const st = auth && typeof auth.getState === 'function' ? auth.getState() : null; return (st && st.subject) ? String(st.subject) : ''; } catch (e) { return ''; } } /** * 连通性探测:直接打 /health(该端点免鉴权),**不要求先有会话**。 * 用途:用户填完地址先测通不通,再去绑账号 —— 否则「测试连接」会一直报"没会话"。 */ function pcHubPing() { const cfg = pcHubConfig(); if (!cfg.baseUrl) return Promise.reject(new Error('NO_BASE_URL')); const u = assertSafeRequestUrl(cfg.baseUrl + '/health', { allowPrivate: cfg.allowPrivate, allowAiEndpoint: true }); if (u.protocol !== 'https:' && !cfg.allowHttp) return Promise.reject(new Error('INSECURE_BASE_URL')); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: cfg.baseUrl + '/health', timeout: 15000, headers: { 'Accept': 'application/json' }, onload: (r) => { let body = null; try { body = JSON.parse(r.responseText); } catch (e) { body = null; } if (r.status < 200 || r.status >= 300) { reject(new Error((body && body.error) ? String(body.error) : ('HTTP ' + r.status))); return; } if (!body) { reject(new Error('数据中枢返回的不是合法 JSON')); return; } resolve(body); }, onerror: () => reject(new Error('无法连接远程数据中枢(服务不可达?)')), ontimeout: () => reject(new Error('远程数据中枢请求超时')), }); }); } /** * 请求远程数据中枢。 * 会话 / 设备签名 / 自动刷新 / 401 重试 全部由 saa-auth 负责; * 这里只负责:路由白名单 + 地址安全校验 + 把业务错误翻译成人话。 */ async function pcHubRequest(path, query) { const cfg = pcHubConfig(); if (!cfg.baseUrl) throw new Error('NO_BASE_URL'); if (!pcHubRouteAllowed(path, 'GET')) throw new Error('ROUTE_NOT_ALLOWED'); const auth = pcHubSyncAuth(); if (!auth) throw new Error('NO_AUTH_LIB'); const qs = new URLSearchParams(query || {}).toString(); const full = qs ? `${path}?${qs}` : path; const u = assertSafeRequestUrl(cfg.baseUrl + full, { allowPrivate: cfg.allowPrivate, allowAiEndpoint: true }); if (u.protocol !== 'https:' && !cfg.allowHttp) throw new Error('INSECURE_BASE_URL'); const res = await auth.request(full, { method: 'GET' }); if (!res || res.status < 200 || res.status >= 300) { const d = (res && res.data) || null; const code = (d && d.code) ? String(d.code) : ''; const hint = (d && d.hint) ? String(d.hint) : ''; const msg = (d && (d.error || d.message)) ? String(d.error || d.message) : ('HTTP ' + (res && res.status)); // ⭐ 与 pcHubPost 保持一致:带上 code / hint。 // 少了它们,「[E_PUSH_OWNER] owner 不匹配」这类**可操作**的错误 // 只剩一句干巴巴的文案,用户无从下手。 throw new Error((code ? '[' + code + '] ' : '') + msg + (hint ? '(' + hint + ')' : '')); } if (!res.data) throw new Error('数据中枢返回的不是合法 JSON'); return res.data; } ToolRegistry.register({ name: 'searchLocalDb', description: '查询「数据中枢」(data-hub) 的个人游戏数据资产库 —— SQLite + FTS5 全文索引,支持关键字 + 结构化过滤 + 排序。' + '适合回答「我的库里剧情超过 20 小时的游戏有哪些」「2015 年后发售的独立游戏」「买过但一次没玩的游戏」「价格低于 XX 的游戏」这类**需要跨维度筛选**的问题' + '(readPluginCache 只能整读一个缓存键,做不了筛选)。' + '⚠️ 数据来自用户自己用 data-hub 工具链沉淀的库,**不是 Steam 实时数据**;' + '本工具只在用户已配置远程数据中枢地址时才会出现在工具列表里,届时若服务不可达请改用 readPluginCache / getOwnedGames,不要编造结果。' + '过滤字段是白名单:year / steam_year / story_hours / extra_hours / price / hltb_id(数值)+ type / name / name_cn(文本);' + 'scope 取 all / mine / unplayed / wishlist。', parameters: { type: 'object', properties: { q: { type: 'string', description: '关键字(中英文均可;中文按二元组匹配,单个汉字自动降级为子串匹配)' }, scope: { type: 'string', enum: ['all', 'mine', 'unplayed', 'wishlist'], description: '范围过滤,默认 all' }, filters: { type: 'string', description: '结构化过滤,逗号分隔,如 "year>=2020, story_hours>20, type=game"' }, sort: { type: 'string', description: '排序字段(可排序:year / steam_year / story_hours / extra_hours / price / hltb_id),加 - 前缀为倒序' }, limit: { type: 'number', description: '返回条数,默认 20,最大 100' }, offset: { type: 'number', description: '分页偏移,默认 0' }, appid: { type: 'number', description: '查单款游戏的完整档案(发行年/剧情时长/价格历史/成就完成率)时用这个,不要与 q 混用' }, }, }, category: 'knowledge', // ⚠️ 门槛在「配置」而不是「服务在线」:令牌没配 = 这台机器根本没搭 data-hub,喂给模型只会换来一条死路引导; // 配了令牌但服务临时掉线仍暴露(execute 的报错可引导用户去启动服务)。 enabled: () => !!pcHubConfig().baseUrl, async execute(params) { const cfg = pcHubConfig(); if (!cfg.baseUrl) { return { success: false, error: '远程数据中枢已停用。请在「设置 → 数据源 → 远程数据中枢」勾选启用,' + '并点「绑定 Steam 账号」完成一次身份认证;或改用 readPluginCache / getOwnedGames 回答这类问题。', needConfig: true, }; } const limit = Math.max(1, Math.min(Number(params.limit) || 20, 100)); const offset = Math.max(0, Number(params.offset) || 0); // ---- 单条档案 ---- if (params.appid != null && params.appid !== '') { const appid = Number(params.appid); if (!Number.isFinite(appid)) return { success: false, error: `appid 非法:${params.appid}` }; let r; try { r = await pcHubRequest(`/app/${appid}`, {}); } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } const app = r.app || {}; return { success: true, data: { mode: 'app', appid, name: app.name || null, nameCn: app.name_cn || null, year: app.hltb_year || app.release_year || null, storyHours: app.story_hours || null, extraHours: app.extra_hours || null, priceCny: app.price_cny || null, type: app.type || null, owned: r.owned || null, priceHistory: r.priceHistory || [], rarestAchievements: r.rarestAchievements || [], }, summary: `${app.name || appid}:${app.hltb_year || app.release_year || '—'} 年,` + `主线 ${app.story_hours || '—'} 小时,支线 ${app.extra_hours || '—'} 小时`, }; } // ---- 搜索 ---- const scope = String(params.scope || 'all').trim().toLowerCase(); if (PC_HUB_SCOPES.indexOf(scope) < 0) { return { success: false, error: `未知 scope "${scope}",可选:${PC_HUB_SCOPES.join(' / ')}` }; } let sort = String(params.sort || '').trim(); if (sort) { const key = sort.charAt(0) === '-' ? sort.slice(1) : sort; if (PC_HUB_SORTABLE.indexOf(key) < 0) { return { success: false, error: `不可排序字段 "${key}",可排序:${PC_HUB_SORTABLE.join(' / ')}` }; } } let filters = ''; try { pcHubParseFilters(params.filters); filters = String(params.filters || '').trim(); } catch (e) { return { success: false, error: String(e && e.message || e) }; } const query = { q: String(params.q || '').trim(), scope, filters, sort, limit, offset }; let r; try { r = await pcHubRequest('/search', query); } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } const items = (r.items || []).map((it) => ({ appid: it.appid, name: it.name || null, nameCn: it.name_cn || null, year: it.hltb_year || it.release_year || null, storyHours: it.story_hours || null, extraHours: it.extra_hours || null, priceCny: it.price_cny || null, type: it.type || null, })); // 库里有几个 Steam 账号?(未登录时也能让模型知道 scope=all 是**全库**数据) let accInfo = null; try { const acc = await pcHubAccounts(); if (acc && acc.accounts && acc.accounts.length) { const pm = acc.accounts.find((x) => x.steamid === acc.primary) || {}; accInfo = { accountCount: acc.accounts.length, primary: acc.primary || null, primaryName: pm.displayName || null, accounts: acc.accounts.map((x) => ({ steamid: x.steamid, displayName: x.displayName || null, isPrimary: x.isPrimary })), }; } } catch (e) { /* 拿不到就算了,不影响查询本身 */ } return { success: true, data: { mode: 'search', total: r.total || 0, limit, offset, scope, q: r.q || '', matchMode: r.mode || 'fts', items, note: r.total > items.length ? `共 ${r.total} 条,本次返回 ${items.length} 条,可用 offset 翻页` : null, library: accInfo, libraryNote: (accInfo && accInfo.accountCount > 1) ? `库里共有 ${accInfo.accountCount} 个 Steam 账号(主账号:${accInfo.primaryName || accInfo.primary})。` + 'scope=all / scope=mine 都是**全库口径**(mine=库内任何人拥有),不是某个人的;' + '要按人查请用 localDbProfile,或在 localDbProfile / pushObservation 里指定 steamid。' : null, }, summary: `命中 ${r.total || 0} 条(scope=${scope}${r.q ? ',q=' + r.q : ''}),返回前 ${items.length} 条` + (accInfo && accInfo.accountCount > 1 ? `|库内 ${accInfo.accountCount} 个账号,本次为全库口径` : ''), }; }, }); /** 把中枢的底层错误翻译成人话 */ function pcHubErrorText(e, cfg) { const msg = String(e && e.message || e); const c = cfg || pcHubConfig(); if (msg === 'NO_BASE_URL') return '远程数据中枢已停用 —— 请到「设置 → 数据源 → 远程数据中枢」勾选启用(默认地址 hub.leansoftx.com)。'; if (msg === 'NO_AUTH_LIB') return '认证库未加载(saa-auth.lib.js)—— 远程数据中枢不可用,请更新插件。'; if (msg === 'ROUTE_NOT_ALLOWED') return '请求的接口不在白名单内(已拦截)。'; if (msg === 'INSECURE_BASE_URL') return '远程地址必须用 https(或在设置里显式允许明文 HTTP)。'; if (/已拦截对内网/.test(msg)) return '远程地址指向内网/本机 —— 请在设置里勾选「允许内网地址」。'; if (/已拦截非 http/.test(msg)) return '远程地址协议不合法(只支持 http/https)。'; if (/无法连接|连接失败|网络错误|请求超时/.test(msg)) { return `无法连接远程数据中枢(${c.baseUrl || '未配置'})—— 请检查地址与服务器状态。`; } if (/未授权|E_UNAUTHORIZED|E_SESSION|E_DEVICE|E_PROOF|E_REFRESH|E_STEAM/.test(msg)) { return '会话未认证或已失效 —— 请到「设置 → 数据源 → 远程数据中枢」重新「绑定 Steam 账号」。'; } if (/NEEDS_STEAM_LOGIN/.test(msg)) { return '还没有可用会话 —— 请到「设置 → 数据源 → 远程数据中枢」点「绑定 Steam 账号」完成一次身份认证。'; } // 推送侧的账号/库归属问题:给可操作的指引,别只把服务端原文丢给用户 if (/E_PUSH_SUBJECT/.test(msg)) { return '上报的账号与当前认证账号不一致,已被服务端拒绝 —— ' + '请确认浏览器登录的 Steam 账号与「绑定 Steam 账号」的账号是同一个。'; } if (/E_PUSH_OWNER/.test(msg)) { return '连到的不是插件预期的那个库(owner 不匹配)—— ' + '请在「设置 → 数据源 → 远程数据中枢」确认服务地址指向的是你的 data-hub。'; } if (/E_PUSH_RATE/.test(msg)) { return '上报过于频繁(服务端限流)—— 请稍候再试,别连续重复上报同一批结论。'; } return '数据中枢查询失败:' + msg; } // ============================================================ // v1.27.0 技能库(skills)—— agent 按需读取操作技能 // ============================================================ // ⭐ 技能的唯一事实来源是 **data-hub/skills/*/SKILL.md**,由 data-hub 自己解析并通过 // GET /skills 提供。本 agent 的取用优先级: // ① 远程 data-hub 的 /skills(最新、与服务端 schema 一致) // ② CDN 分发副本(@resource,纯 JSON,没装 data-hub 时的兜底) // ③ 内置最小索引(连 data-hub 都没有时,至少告诉模型有哪些技能) // 这样「CLI agent 用 hubq 读技能」与「浏览器 agent 用 readSkill 读技能」读到的是同一份内容。 const SKILLS_RESOURCE_NAME = 'saaSkills'; const SKILLS_RESOURCE_URL_DEFAULT = 'https://raw.githubusercontent.com/SmallRob/steam-namespace/refs/heads/main/data/saa-skills.json'; /** 内置最小索引(③ 降级):只列技能与要点,不含正文 */ const SKILLS_FALLBACK = { skills: [{ id: 'hub-skill', title: 'data-hub 查询技能(hubq)', summary: '本地 Steam 数据资产库的查询与分析技能:意图路由、where DSL、画像口径、报告产出。', version: 'fallback', sections: [], agentTools: ['readSkill', 'searchLocalDb', 'readPluginCache', 'localDbProfile', 'pushInsight', 'pushObservation'], commands: [], }], }; /** * 技能正文的回灌预算(字符)。 * ⚠️ 必须小于 CONFIG.TOOL_RESULT_MAX_CHARS(12000),否则整份正文会被 * truncateToolResult 截成 JSON 字符串(模型读到的是一坨带引号的残骸)。 * 超预算时明确告知「已截断 + 可按 section 精确读取」,不假装读全了。 */ const SKILL_CONTENT_MAX_CHARS = 6000; /** 技能缓存(同一会话内只取一次,除非 force) */ const _skillsState = { skills: null, source: null, full: false }; function skillsGetRemoteUrl() { let url = SKILLS_RESOURCE_URL_DEFAULT; try { const raw = GM_getValue('sa_skills_url', ''); if (raw && /^https?:\/\//i.test(raw)) url = String(raw).trim(); } catch (e) { /* ignore */ } return url; } /** * 拉取技能库:① data-hub → ② CDN @resource → ③ 内置索引 * ⚠️ data-hub 的 GET /skills 只返回**索引**(有小节标题、没有正文)—— * 要读正文必须再取 GET /skills/:id;CDN 副本自带正文。 */ async function loadSkills(force) { if (_skillsState.skills && !force) return _skillsState; const cfg = pcHubConfig(); // ① 远程 data-hub(返回 { ok, data: { skills, dir, count } }) if (cfg.baseUrl) { try { const r = await pcHubRequest('/skills', {}); const skills = (r && r.data && r.data.skills) || []; if (skills.length) { _skillsState.skills = skills; _skillsState.source = 'data-hub'; _skillsState.full = false; try { GM_setValue('sa_skills_cache', JSON.stringify({ skills, ts: Date.now() })); } catch (e) { /* ignore */ } return _skillsState; } } catch (e) { /* 落到下一级 */ } } // ② CDN 分发副本(纯 JSON,自带正文,不执行远程代码) try { const text = GM_getResourceText(SKILLS_RESOURCE_NAME); if (text) { const payload = JSON.parse(text); if (payload && Array.isArray(payload.skills) && payload.skills.length) { _skillsState.skills = payload.skills; _skillsState.source = 'cdn'; _skillsState.full = true; return _skillsState; } } } catch (e) { /* 落到下一级 */ } // ③ 内置最小索引(只有名字与要点,没有正文) _skillsState.skills = SKILLS_FALLBACK.skills; _skillsState.source = 'fallback'; _skillsState.full = false; return _skillsState; } /** 取技能全文(含小节正文)—— data-hub 走 /skills/:id,CDN 副本自带 */ async function loadSkillFull(id) { const st = await loadSkills(false); if (st.full) { const hit = (st.skills || []).find((s) => s.id === id); if (hit) return hit; } if (st.source === 'data-hub') { try { const r = await pcHubRequest(`/skills/${encodeURIComponent(id)}`, {}); const sk = (r && r.data) || r; if (sk && sk.id) return sk; } catch (e) { /* 落到 null */ } } return null; } function skillsFind(list, name) { const key = String(name || '').trim().toLowerCase(); if (!key) return null; return (list || []).find((s) => s.id.toLowerCase() === key) || (list || []).find((s) => s.id.toLowerCase().indexOf(key) >= 0) || (list || []).find((s) => String(s.title || '').toLowerCase().indexOf(key) >= 0) || null; } function skillsSearch(list, query, limit) { const q = String(query || '').trim().toLowerCase(); const max = Math.max(1, Math.min(Number(limit) || 8, 30)); if (!q) return []; const out = []; for (const s of (list || [])) { for (const sec of (s.sections || [])) { const body = String(sec.body || ''); if (!body) continue; // 索引态小节没有正文,跳过 const idx = body.toLowerCase().indexOf(q); if (idx < 0) continue; out.push({ id: s.id, title: s.title, sectionId: sec.id, sectionTitle: sec.title, snippet: body.slice(Math.max(0, idx - 60), Math.max(0, idx - 60) + 220).replace(/\n+/g, ' '), }); if (out.length >= max) return out; } } return out; } ToolRegistry.register({ name: 'readSkill', description: '读取本机的**操作技能库**(skills)—— 每个技能是一份「遇到这类问题该怎么用工具」的操作手册。' + '适合处理**复杂多步任务**(需要跨表筛选 + 交叉验证 + 出报告/图表):先读技能拿到意图路由与口径纪律,再按路由调用工具。' + '典型场景:问「我的游戏库是什么画像」「帮我统计我的厂商集中度」「出一份库存盘点报告」。' + '技能来源优先级:远程 data-hub 的 /skills(最新)→ CDN 分发副本 → 内置最小索引。' + '⚠️ 技能只给**操作指引与口径**,不代替事实;事实仍要靠工具查。' + '用法:不传 name = 列出全部技能;传 name = 读全文;传 name+section = 只读某一小节;传 query = 全文检索片段。', parameters: { type: 'object', properties: { name: { type: 'string', description: '技能 id(如 hub-skill)。不传 = 列出全部技能' }, section: { type: 'string', description: '小节 id 或标题(先用不传 name 的模式看小节列表)' }, query: { type: 'string', description: '在技能正文里检索关键词,返回命中片段' }, limit: { type: 'number', description: '检索结果条数,默认 8,最大 30' }, force: { type: 'boolean', description: '强制重新拉取(默认用会话缓存)' }, }, }, category: 'knowledge', async execute(params) { const p = params || {}; let st; try { st = await loadSkills(!!p.force); } catch (e) { return { success: false, error: '技能库加载失败:' + String(e && e.message || e) }; } const list = st.skills || []; // ① 列出全部(索引就够) if (!p.name && !p.query) { return { success: true, data: { source: st.source, skills: list.map((s) => ({ id: s.id, title: s.title, summary: s.summary, version: s.version, chars: s.chars, sections: (s.sections || []).map((x) => ({ id: x.id, title: x.title })), agentTools: s.agentTools || [], })), }, summary: `技能库(来源 ${st.source})共 ${list.length} 个技能:` + list.map((s) => s.id).join(' / '), }; } // ② 检索(需要正文) if (p.query && !p.name) { const fulls = []; for (const s of list) { const f = await loadSkillFull(s.id); if (f) fulls.push(f); } const hits = skillsSearch(fulls, p.query, p.limit); return { success: true, data: { source: st.source, hits }, summary: hits.length ? `命中 ${hits.length} 处:${hits.map((h) => `${h.id}/${h.sectionTitle}`).join('、')}` : `技能库里没有匹配「${p.query}」的内容`, }; } // ③ 读技能 const skill = skillsFind(list, p.name); if (!skill) { return { success: false, error: `技能 "${p.name}" 不存在`, data: { available: list.map((s) => s.id) }, }; } const full = await loadSkillFull(skill.id); if (!full) { return { success: false, error: `技能「${skill.title}」的正文暂时取不到(当前来源 ${st.source} 只有索引)—— ` + '请确认远程数据中枢地址已配置且服务可达,或稍后重试。', data: { id: skill.id, source: st.source, sections: (skill.sections || []).map((x) => ({ id: x.id, title: x.title })), }, }; } if (p.section) { const secs = full.sections || []; const sec = secs.find((x) => x.id === p.section) || secs.find((x) => x.title === p.section) || secs.find((x) => String(x.id).toLowerCase().indexOf(String(p.section).toLowerCase()) >= 0); if (!sec) { return { success: false, error: `技能 "${skill.id}" 没有小节 "${p.section}"`, data: { available: secs.map((x) => ({ id: x.id, title: x.title })) }, }; } return { success: true, data: { source: st.source, id: full.id, title: full.title, section: sec.id, sectionTitle: sec.title, content: sec.body }, summary: `${full.id} · ${sec.title}`, }; } // 正文预算:超了就说清楚,别让模型以为读全了 const raw = String(full.content || ''); const capped = raw.length > SKILL_CONTENT_MAX_CHARS; const content = capped ? raw.slice(0, SKILL_CONTENT_MAX_CHARS) : raw; const outline = (full.sections || []).map((x) => ({ id: x.id, title: x.title })); return { success: true, data: { source: st.source, id: full.id, title: full.title, summary: full.summary, content, chars: full.chars, contentChars: raw.length, truncated: capped, sections: outline, agentTools: full.agentTools || [], commands: full.commands || [], hint: capped ? `技能正文共 ${raw.length} 字符,已截断到 ${SKILL_CONTENT_MAX_CHARS} 字符。` + `需要完整内容请用 section 参数精确读取小节(可选:${outline.map((x) => x.title).join(' / ')})。` : null, }, summary: `技能「${full.title}」共 ${full.chars} 字符 / ${(full.sections || []).length} 个小节` + (capped ? `(正文已截断到 ${SKILL_CONTENT_MAX_CHARS} 字符,可按小节精确读取)` : ''), }; }, }); // ============================================================ // v1.27.0 玩家画像(走 data-hub 的 /profile 与 /report) // ============================================================ ToolRegistry.register({ name: 'localDbProfile', description: '从「数据中枢」(data-hub) 取**玩家数据画像**与画像报告。' + '画像含:未开封率、时长阶梯(7 档)、清灰倒计时、被冷落的神作、最亏/最值、每小时价值、人格称号+稀有度。' + '适合回答「我的游戏库是什么画像」「我是不是囤货党」「我买的东西值不值」「给我一份库存盘点报告」。' + '⚠️ 回答前必看 data.library.durationEvidence:为 "missing" 表示库内没有游玩时长数据,' + '正确答案是「算不了」而不是「你 100% 没玩过」;价格类指标是**当前折后价**不是实付价;' + '好评率只覆盖部分游戏,榜单要带覆盖率。' + 'format=markdown 返回可读的 Markdown 报告正文;format=html 返回报告的**落盘路径**(完整版含内联 SVG 图表、零外链,' + '适合给用户直接打开看),模型解读请依据 profile 字段。', parameters: { type: 'object', properties: { steamid: { type: 'string', description: 'SteamID64;不传则用设置里配置的账号' }, format: { type: 'string', enum: ['json', 'html', 'markdown'], description: 'json=结构化画像;html=带图表的报告;markdown=纯文本报告。默认 json' }, topN: { type: 'number', description: '榜单条数(最亏/最值/沉默库存),默认 5,最大 20' }, minReviews: { type: 'number', description: '「被冷落的神作」的最低评价数,默认 1000' }, minRating: { type: 'number', description: '「被冷落的神作」的最低好评率,默认 90' }, }, }, category: 'knowledge', enabled: () => !!pcHubConfig().baseUrl, async execute(params) { const cfg = pcHubConfig(); if (!cfg.baseUrl) { return { success: false, error: '远程数据中枢已停用。请在「设置 → 数据源 → 远程数据中枢」勾选启用,并点「绑定 Steam 账号」完成身份认证。', needConfig: true, }; } const p = params || {}; // 身份解析:显式参数 > 设置配置 > cookie 检测 > 库主账号回退(未登录也能出画像) const identity = await pcHubResolveIdentity(p.steamid); const sid = identity.steamId; if (!sid) { return { success: false, error: identity.note || '缺少 steamid —— 请让用户在问题里给出 SteamID,或在设置里配置默认账号。' }; } const format = String(p.format || 'json').toLowerCase(); let r; try { r = await pcHubRequest(`/profile/${encodeURIComponent(sid)}`, { top: p.topN || 5, minReviews: p.minReviews || 1000, minRating: p.minRating || 90, }); } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } const pf = r.profile || r.data || r; const lib = pf.library || {}; const ld = pf.ladder || {}; const cn = pf.countdown || {}; const profileData = { steamid: sid, identitySource: identity.identitySource || null, identityNote: identity.note || null, headline: pf.headline || null, persona: pf.persona || null, library: lib, ladder: (ld.tiers || []).map((t) => ({ label: t.label, count: t.count, pct: t.pct })), neverPlayedPct: ld.neverPlayedPct, durationEvidence: lib.durationEvidence || null, valuePerHourCny: (pf.value || {}).valuePerHourCny, stockValueCny: (pf.value || {}).stockValueCny, countdown: { status: cn.status, reason: cn.reason, etaDate: cn.etaDate, etaDays: cn.etaDays, dailyMinutes: cn.dailyMinutes }, neglectedCount: (pf.neglected || {}).count, worst: ((pf.ranks || {}).worst || []).map((x) => ({ appid: x.appid, name: x.name, priceCny: x.priceCny, playtimeMinutes: x.playtimeMinutes, costPerHourCny: x.costPerHourCny })), best: ((pf.ranks || {}).best || []).map((x) => ({ appid: x.appid, name: x.name, priceCny: x.priceCny, playtimeHours: x.playtimeHours, costPerHourCny: x.costPerHourCny })), disclaimer: pf.disclaimer || [], }; // ---- json:结构化画像(默认)---- if (format === 'json') { return { success: true, data: profileData, summary: `${(pf.headline || {}).text || ''}` + (identity.note ? `|⚠️ ${identity.note}` : ''), }; } // ---- 报告(markdown / html)---- try { const wantHtml = format === 'html'; const rr = await pcHubRequest(`/report/${encodeURIComponent(sid)}`, { format: wantHtml ? 'html' : 'markdown', save: wantHtml ? 1 : 0, }); const content = (rr && rr.content) || ''; if (wantHtml) { // HTML 报告是「给人看」的(内联 SVG 图表、零外链),体积大 —— // 不整份塞进模型上下文,改为落盘给路径,模型依据 profile 字段解读。 return { success: true, data: { steamid: sid, format: 'html', bytes: (rr && rr.bytes) || content.length, savedPath: (rr && rr.savedPath) || null, profile: profileData, note: 'HTML 报告是给人看的完整版(含内联 SVG 图表、零外链),已落盘;模型解读请依据 profile 字段。', }, summary: `已生成 HTML 画像报告(${(rr && rr.bytes) || content.length} 字节)` + ((rr && rr.savedPath) ? `,已存盘:${rr.savedPath}` : ''), }; } return { success: true, data: { steamid: sid, format: 'markdown', bytes: content.length, content, profile: profileData }, summary: `已生成 Markdown 画像报告(${content.length} 字符)`, }; } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } }, }); // ============================================================ // v1.29.0 身份层:未登录降级 + actor 标识 // ============================================================ /** 库内账号清单的缓存(TTL 60s —— 库可能随时被同步工具加账号) */ let _accountsCache = null; let _accountsAt = 0; let _accountsBase = ''; const ACCOUNTS_TTL_MS = 60 * 1000; /** * 取库内 Steam 账号清单(含 owner 与主账号) * @returns {object|null} { owner, primary, accounts[] } —— 服务不可达时返回 null */ async function pcHubAccounts(force) { const cfg = pcHubConfig(); if (!cfg.baseUrl) return null; // 换地址 = 换一台服务器的库 → 缓存必须失效(owner 都不一样) if (cfg.baseUrl !== _accountsBase) { _accountsCache = null; _accountsBase = cfg.baseUrl; } if (!force && _accountsCache && (Date.now() - _accountsAt) < ACCOUNTS_TTL_MS) return _accountsCache; try { const r = await pcHubRequest('/accounts', {}); const d = (r && r.data) || r; _accountsCache = { owner: d.owner || null, primary: d.primary || null, accounts: d.accounts || [], }; _accountsAt = Date.now(); return _accountsCache; } catch (e) { return null; // 服务不可达 / 版本太旧 → 走「拿不到就明说」 } } /** * 身份解析(异步,带库主账号回退) * * ⚠️ 返回对象的身份字段名是 **steamId**(大写 I),调用侧别写成 steamid。 * * @returns {object} { steamId, source, identified, identitySource, note } * identified=true → cookie 检测到当前登录用户,或用户主动声明的默认账号 * identified=false → 系统兜底猜的(库主账号)—— **不谎称**是当前用户 */ async function pcHubResolveIdentity(explicit) { const fromParam = String(explicit || '').trim(); if (fromParam) { return { steamId: fromParam, source: '参数', identified: true, identitySource: 'param', note: null }; } const configured = getConfiguredSteamId(); if (configured) { return { steamId: configured, source: '设置', identified: true, identitySource: 'settings', note: null }; } const detected = detectSteamId(); if (detected) { return { steamId: detected, source: '自动检测', identified: true, identitySource: 'cookie', note: null }; } // ⭐ 回退:库里的主账号(account.is_primary=1) const acc = await pcHubAccounts(); if (acc && acc.primary) { const name = (acc.accounts.find((x) => x.steamid === acc.primary) || {}).displayName || acc.primary; return { steamId: acc.primary, source: '库主账号', identified: false, identitySource: 'library-primary', note: '未检测到当前登录的 Steam 用户(未登录且未配置 SteamID)—— 已回退到库里的主账号 ' + name + '(' + acc.primary + ')。数据归属该账号,但发起者身份未标识。', }; } return { steamId: '', source: '', identified: false, identitySource: '', note: '未检测到当前登录的 Steam 用户(未登录且未配置 SteamID),库里也没有主账号。' + '请在「设置 → 接口服务」配置 SteamID,或在问题里直接给出账号。', }; } /** 本机 agent 实例标识(持久化 —— 区分「哪个客户端实例在调」) */ function saaInstanceId() { try { let id = String(GM_getValue('sa_agent_instance', '') || ''); if (!id) { id = pcHash8(String(Date.now()) + ':' + Math.random()).slice(0, 6); GM_setValue('sa_agent_instance', id); } return id; } catch (e) { return 'tmp'; } } /** 构造 actor 段(谁在调 / 什么身份) */ function saaActor(identity) { return { client: 'steam-ai-agent', instance: saaInstanceId(), identity: (identity && identity.identified) ? 'identified' : 'unidentified', identitySource: (identity && identity.identitySource) || null, }; } // ============================================================ // v1.27.0 结论回灌(把 agent 的洞察写回 data-hub) // ============================================================ /** 稳定 hash:同样的内容 → 同样的 runId → 幂等 */ function pcHash8(text) { let h = 0x811c9dc5; const s = String(text || ''); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; } return h.toString(16).padStart(8, '0'); } /** POST 请求(推送用)—— 与 GET 走同一套会话 + 设备签名 */ async function pcHubPost(pathname, body) { const cfg = pcHubConfig(); if (!cfg.baseUrl) throw new Error('NO_BASE_URL'); if (!pcHubRouteAllowed(pathname, 'POST')) throw new Error('ROUTE_NOT_ALLOWED'); const auth = pcHubSyncAuth(); if (!auth) throw new Error('NO_AUTH_LIB'); const u = assertSafeRequestUrl(cfg.baseUrl + pathname, { allowPrivate: cfg.allowPrivate, allowAiEndpoint: true }); if (u.protocol !== 'https:' && !cfg.allowHttp) throw new Error('INSECURE_BASE_URL'); const res = await auth.request(pathname, { method: 'POST', body: body || {} }); if (!res || res.status < 200 || res.status >= 300) { const d = (res && res.data) || null; const code = (d && d.code) ? String(d.code) : ''; const hint = (d && d.hint) ? String(d.hint) : ''; const msg = (d && (d.error || d.message)) ? String(d.error || d.message) : ('HTTP ' + (res && res.status)); throw new Error((code ? '[' + code + '] ' : '') + msg + (hint ? '(' + hint + ')' : '')); } if (!res.data) throw new Error('数据中枢返回的不是合法 JSON'); return res.data; } // ============================================================ // v1.33.0 插件版本上报(D10 运营面板数据通道,docs §3.4–§3.5) // ============================================================ // 口径纪律: // · 只报**事实**(id/version/versionKnown/detectedBy/regDomain/lastSeen), // 不放 outdated / minVersion —— 契约会变,采信客户端会让历史样本口径全错, // 过旧判定由服务端按当前契约算。 // · 版本号来自 registerPlugin 注册表(@version 同源,不漂移),不做指纹采集。 // · 每日一次(当日首次运行时),失败静默不写「已报」标记,下次补传。 // · 唯一例外:绑定 Steam 账号成功后必报一次(reason='bind',与开关无关)。 // · optout 是「失败静默降级」纪律的唯一例外:撤销失败 = 用户以为已删实际仍在, // 置 pendingOptout 重试并在设置面板可见。 const PC_REPORT_KEY = 'sa_hub_plugin_report_v1'; function pcReportState() { try { const raw = GM_getValue(PC_REPORT_KEY, ''); const o = raw ? JSON.parse(raw) : {}; return { enabled: o.enabled !== false, // 默认勾选(§3.5.3 已拍板) lastReportDay: String(o.lastReportDay || ''), pendingOptout: !!o.pendingOptout, }; } catch (e) { return { enabled: true, lastReportDay: '', pendingOptout: false }; } } function pcReportSaveState(st) { try { GM_setValue(PC_REPORT_KEY, JSON.stringify(st)); } catch (e) { /* ignore */ } } /** 本地日期串(每日一次的「当日」口径) */ function pcReportToday() { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } /** 构造上报清单:只含事实。不复用 getInstalledPlugins(那是给模型看的,含契约口径字段)。 * 来源 = pcReadPluginRegistry()(本域 IDB + GM 跨域镜像);探测型信号不进上报 * (12 插件全部已接入 registerPlugin,探测兜底只服务没有注册表的异常场景)。 */ async function pcBuildVersionManifest(reason) { const registry = await pcReadPluginRegistry(); const plugins = []; for (const [id, rec] of registry) { const version = rec && rec.version ? String(rec.version).trim() : ''; plugins.push({ id: String(id), version: version || null, // versionKnown=0 时为 null(不是空串) versionKnown: !!version, detectedBy: rec && rec.viaMirror ? 'registry-mirror' : 'registry', regDomain: (rec && rec.regDomain) || null, lastSeen: (rec && (rec.ts || rec.registeredAt)) || null, }); } return { agentVersion: _v, domain: pcPanelDomain() || null, reason: reason === 'bind' ? 'bind' : 'scheduled', plugins, }; } /** * 发送版本清单。失败抛错(调用方决定是否静默)。 * 门禁:scheduled 只在开关勾选时发;bind 不看开关(§3.5.4 排障通道)。 * scheduled 成功才写 lastReportDay(失败不写 → 下次补传);bind 不写(每次绑定都报)。 */ async function pcSendVersionReport(reason) { const st = pcReportState(); if (reason !== 'bind' && !st.enabled) return { skipped: true, reason: 'disabled' }; const manifest = await pcBuildVersionManifest(reason); if (!manifest.plugins.length) return { skipped: true, reason: 'empty-registry' }; await pcHubPost('/report/plugins', manifest); if (reason !== 'bind') { st.lastReportDay = pcReportToday(); pcReportSaveState(st); } return { ok: true, count: manifest.plugins.length }; } /** 撤销上报:删服务端该设备的全部版本记录。成功返回 true;失败置 pendingOptout。 */ async function pcOptoutVersionReport() { const st = pcReportState(); try { await pcHubPost('/report/plugins/optout', {}); st.pendingOptout = false; pcReportSaveState(st); return true; } catch (e) { st.pendingOptout = true; pcReportSaveState(st); return false; } } /** 插件启动时的上报维护(每日一次 + 撤销重试;全部静默,绝不影响面板初始化) */ async function pcReportStartup() { try { await pcHubResolveEndpointRemote(); } catch (e) { /* 回落常量 */ } const st = pcReportState(); // ① 撤销重试:用户以为已删而实际仍在,必须补送达(静默纪律的唯一例外) if (st.pendingOptout) { try { await pcOptoutVersionReport(); } catch (e) { /* 下次再试 */ } } // ② 每日周期上报:当日已报过 → 跳过;失败不写标记,下次机会补传 if (st.enabled && st.lastReportDay !== pcReportToday()) { try { await pcSendVersionReport('scheduled'); } catch (e) { /* 静默 */ } } } ToolRegistry.register({ name: 'pushInsight', description: '把你分析出来的**结论**回灌到「数据中枢」(data-hub) —— 落进 insight / analysis_* 表,' + '成为可复算、可追溯的库资产(而不是只留在聊天记录里)。' + '适合:分析出「某厂商集中度偏高」「某系列还差 3 款」「这款每小时成本极低」这类**可复用的结论**时调用。' + '⚠️ 只上报**有依据的结论**,不要上报过程性废话;' + '同一份内容重复上报是幂等的(不会重复堆积),但换措辞会被当成新结论。' + 'runId 由工具自动生成(ai.insight:<账号>:<内容指纹>),调用方不用关心;' + '指纹覆盖标题/正文/类别/强度/指标/总结,**改了任何一项都算新结论**。' + '归属以「已认证的 Steam 账号」为准 —— 服务端只认它。' + '拿不准要不要落库时先用 dryRun 预演。', parameters: { type: 'object', properties: { steamid: { type: 'string', description: '结论所属账号 SteamID64;不传则用设置里的默认账号' }, insights: { type: 'array', description: '结论列表(建议 1~10 条)', items: { type: 'object', properties: { title: { type: 'string', description: '结论标题(一句话,会进 insight 表)' }, message: { type: 'string', description: '结论正文:数据依据 + 口径说明' }, kind: { type: 'string', enum: ['value', 'warning', 'recommendation', 'library', 'playtime', 'wishlist', 'collection', 'profile', 'insight', 'risk', 'price', 'other'], description: '类别,默认 insight(取值域与服务端白名单一致)' }, severity: { type: 'string', enum: ['info', 'warn', 'strong'], description: '强度,默认 info' }, appid: { type: 'number', description: '关联的 appid(可选)' }, }, }, }, summary: { type: 'string', description: '本次结论的一句话总结(会写进 analysis_report.summary)' }, metrics: { type: 'object', description: '本次分析算出的数值指标(键值对,如 {"never_played_pct":57.5})' }, dryRun: { type: 'boolean', description: '只预览将要上报的内容(runId / 条目 / 归属),不真正落库' }, }, }, category: 'knowledge', enabled: () => !!pcHubConfig().baseUrl, async execute(params) { const cfg = pcHubConfig(); if (!cfg.baseUrl) { return { success: false, error: '远程数据中枢已停用,本工具不可用。请在「设置 → 数据源 → 远程数据中枢」勾选启用。', needConfig: true, }; } const p = params || {}; const list = Array.isArray(p.insights) ? p.insights : []; if (!list.length) { return { success: false, error: '没有要上报的结论(insights 为空)—— 没有结论就不要调用本工具' }; } // 身份解析:显式参数 > 设置配置 > cookie 检测 > 库主账号回退 const identity = await pcHubResolveIdentity(p.steamid); const acc = await pcHubAccounts(); const sid = identity.steamId || 'global'; // ⭐ 已认证会话的账号(服务端认可的归属) const sessSubject = pcHubSessionSubject(); // ⭐ 内容指纹:**覆盖全部会落库的字段**。 // 原来只算 [title, message, appid] → 改了 kind/severity、换了 metrics 或 summary, // 指纹不变 → 还是同一个 runId → 旧行被覆盖(类别/强度/指标被悄悄改写)。 // 现在 kind/severity/metrics/summary 都在指纹里:内容真变了才算新结论。 const fingerprint = JSON.stringify({ items: list.map((x) => [ String(x.title || ''), String(x.message || ''), x.appid || 0, String(x.kind || 'insight'), String(x.severity || 'info'), ]), metrics: (p.metrics && typeof p.metrics === 'object') ? p.metrics : {}, summary: p.summary ? String(p.summary) : '', }); // ⭐ 幂等键的身份维度:**会话账号优先**。 // sid 兜底成 'global' 时所有账号的同名结论共用一个 runId // → 共享库里后写的覆盖先写的(跨用户覆盖)。 const idKey = sessSubject || (/^\d{17}$/.test(sid) ? sid : 'global'); const runId = `ai.insight:${idKey}:${pcHash8(fingerprint)}`; // payload.subject 以会话账号为准(服务端会拿它再比一次,必须一致) const subject = sessSubject || (/^\d{17}$/.test(sid) ? sid : null); const items = list.map((x) => ({ kind: x.kind || 'insight', severity: x.severity || 'info', appid: x.appid || null, title: String(x.title || '').slice(0, 200), message: String(x.message || '').slice(0, 4000), })); const metrics = (p.metrics && typeof p.metrics === 'object') ? p.metrics : {}; const report = p.summary ? { summary: String(p.summary).slice(0, 2000), payload: { source: 'steam-ai-agent' } } : null; // 身份冲突提示:本地解析出的账号与会话账号不一致时如实说明(不静默改写归属) const identityNote = identity.note || (sessSubject && /^\d{17}$/.test(sid) && sessSubject !== sid ? `本地识别到的账号 ${sid} 与已认证会话账号 ${sessSubject} 不一致 —— 已按已认证账号归属上报` : null); if (p.dryRun) { return { success: true, data: { dryRun: true, runId, subject, items, metrics, report, identitySource: identity.identitySource || null, identityNote: identityNote || null, }, summary: `预演:将上报 ${items.length} 条结论(run ${runId})` + (identityNote ? `|⚠️ ${identityNote}` : ''), }; } const payload = { runId, kind: 'ai.insight', subject, source: 'steam-ai-agent', agentVersion: _v, actor: saaActor(identity), owner: (acc && acc.owner) || null, items, metrics, report, }; try { const r = await pcHubPost('/push/agent', payload); return { success: true, data: { runId: r.runId, inserted: r.inserted, replaced: !!r.replaced, source: r.source, subject: payload.subject, identitySource: identity.identitySource || null, identityNote: identityNote || null, }, summary: `已回灌 ${payload.items.length} 条结论(run ${r.runId})` + (r.replaced ? ',同名 run 已覆盖(幂等)' : '') + (identityNote ? `|⚠️ ${identityNote}` : ''), }; } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } }, }); // ============================================================ // v1.28.0 观测上报(关键指标 + 游玩时长明细 → data-hub) // ============================================================ // ⭐ 数据来源是 **Steam 官方 API**,不是从库里搬: // · playtime_forever / playtime_2weeks 取原始分钟,零精度损失; // · 库里缺的 playtime_2weeks(实测覆盖 0%)这里能补上; // · 「未知」不会被折算成 0 —— 官方给了才算数。 ToolRegistry.register({ name: 'pushObservation', description: '把**游玩时长明细 + 关键指标**上报到「数据中枢」(data-hub) —— 落进 analysis_app / ' + 'library_metric_daily(可复算、可追溯的库资产),供后续分析与趋势回看。' + '数据取自 Steam 官方 API(GetOwnedGames),因此是**当前最权威的时长**。' + '适合:用户说「把这次的时长/指标也存下来」「之后按周回看我的游玩变化」「导一份我的时长明细进库」。' + '⚠️ scope=partial(默认)只落分析产物区,安全;scope=full 才会尝试刷新事实表,' + '但服务端会校验「条目数不得少于库内已有快照」,数据不全时会自动降级并说明原因。' + '上报是幂等的:幂等键 = **内容指纹**(不含时间戳)—— 同一份时长数据重复上报走同一个 runId,' + '覆盖而不堆积;时长真的变了才会新增一行。' + '⚠️ lastPlayed 一律传「未知」(GetOwnedGames 不提供该字段),服务端会沿用库里的历史值,' + '不会把「从未游玩」写进事实表。', parameters: { type: 'object', properties: { steamid: { type: 'string', description: '账号 SteamID64;不传则用当前登录用户' }, scope: { type: 'string', enum: ['partial', 'full'], description: 'partial=只落分析产物区(默认,安全);full=尝试刷新事实表 owned_game(需通过服务端护栏)' }, includeProfile: { type: 'boolean', description: '是否顺带上报画像级标量(未开封率/人格称号等,走 metrics → analysis_result)。默认 true' }, dryRun: { type: 'boolean', description: '只预览将要上报的内容,不真正落库' }, }, }, category: 'knowledge', enabled: () => !!pcHubConfig().baseUrl, async execute(params) { const cfg = pcHubConfig(); if (!cfg.baseUrl) { return { success: false, error: '远程数据中枢已停用,本工具不可用。请在「设置 → 数据源 → 远程数据中枢」勾选启用。', needConfig: true, }; } const p = params || {}; const scope = String(p.scope || 'partial').toLowerCase() === 'full' ? 'full' : 'partial'; // ---- 取 Steam 官方时长(权威来源)---- const apiKey = getSteamApiKey(); if (!apiKey) { return { success: false, needsApiKey: true, error: '此工具需要 Steam Web API Key。请引导用户点击对话中的「配置 Steam API Key」按钮,或打开设置面板填写 Key(可在 steamcommunity.com/dev 免费申请),配置完成后用户回复"已配置",再重新调用本工具', }; } // 身份解析:显式参数 > 设置配置 > cookie 检测 > **库主账号回退** // ⭐ GetOwnedGames 是**公开 API**(只需 API Key,不需要浏览器登录态), // 所以回退到库主账号后照样能拉官方时长。 const identity = await pcHubResolveIdentity(p.steamid); const steamId = identity.steamId; if (!steamId) { return { success: false, error: identity.note || '未提供也未检测到 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 && e.message || e) }; } if (!id64) return { success: false, error: '自定义 ID "' + steamId + '" 解析失败(可能不存在)' }; } let games = []; try { const resp = await gmFetch('https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?key=' + apiKey + '&steamid=' + id64 + '&include_appinfo=0&format=json'); const data = JSON.parse(resp); games = (data && data.response && data.response.games) || []; } catch (e) { return { success: false, error: '拉取 Steam 官方游戏列表失败:' + String(e && e.message || e) }; } if (!games.length) { return { success: false, error: 'Steam 官方返回的游戏列表为空 —— 可能是资料隐私设置为「不公开游戏详情」。' + '请到 Steam 资料 → 隐私设置 → 游戏详情改为「公开」后重试。', }; } // ---- 组装观测段 ---- const acc = await pcHubAccounts(); const capturedAt = Date.now(); const playtime = games.map((g) => ({ appid: Number(g.appid), playtimeMin: Math.max(0, Math.round(Number(g.playtime_forever) || 0)), playtime2weeksMin: Math.max(0, Math.round(Number(g.playtime_2weeks) || 0)), // ⭐ lastPlayed = null(**未知**),不是 0。 // GetOwnedGames 不返回 last_played(那是 GetRecentlyPlayedGames); // 而 0 在 data-hub 里有真实语义「从未游玩」—— // 写 0 等于凭空造一个事实,覆盖事实表时还会把库里真实的 last_played 冲掉。 // 服务端收到 null 会**沿用历史值**(不冲列)。 lastPlayed: null, })).filter((g) => Number.isInteger(g.appid) && g.appid > 0); const totalMin = playtime.reduce((a, g) => a + g.playtimeMin, 0); const twoWeeksMin = playtime.reduce((a, g) => a + g.playtime2weeksMin, 0); const playedCount = playtime.filter((g) => g.playtimeMin > 0).length; const obsMetrics = { games_owned: playtime.length, games_played: playedCount, playtime_min_total: totalMin, playtime_min_2weeks: twoWeeksMin, }; // ---- 画像级标量(走既有 metrics → analysis_result)---- let profileMetrics = {}; let profileNote = null; if (p.includeProfile !== false) { try { const pr = await pcHubRequest('/profile/' + encodeURIComponent(id64), {}); const pf = pr.profile || pr.data || pr; const lib = pf.library || {}; const ld = pf.ladder || {}; profileMetrics = { profile_never_played_pct: ld.neverPlayedPct === undefined ? null : ld.neverPlayedPct, profile_duration_evidence: lib.durationEvidence || null, profile_games_measured: ld.measuredGames === undefined ? null : ld.measuredGames, profile_total_hours: lib.totalHours === undefined ? null : lib.totalHours, profile_value_per_hour_cny: (pf.value || {}).valuePerHourCny === undefined ? null : (pf.value || {}).valuePerHourCny, }; if (lib.durationEvidence === 'missing') { profileNote = '库内没有游玩时长数据(durationEvidence=missing)—— 未开封率类指标不可算,' + '本次上报的时长来自 Steam 官方 API,是可信的'; } } catch (e) { profileNote = '画像标量拉取失败(不影响时长上报):' + String(e && e.message || e); } } if (p.dryRun) { return { success: true, data: { dryRun: true, steamid: id64, scope, playtimeItems: playtime.length, obsMetrics, profileMetrics, profileNote, identitySource: identity.identitySource || null, identityNote: identity.note || null, sample: playtime.slice(0, 5), }, summary: '预演:将上报 ' + playtime.length + ' 款游戏的时长明细 + ' + Object.keys(obsMetrics).length + ' 个关键指标(scope=' + scope + ')' + (identity.note ? `|⚠️ ${identity.note}` : ''), }; } // ---- 上报 ---- const observations = { scope, steamid: id64, capturedAt, itemCount: playtime.length, metrics: obsMetrics, playtime, }; // ⭐ 幂等键 = **内容指纹**,**必须剔除 capturedAt**: // 含了它,每次调用都是新 runId → analysis_run / analysis_app 每次新增一行, // 「重复上报不增长行数」就是假的(上一版正是这个坑,表会一路膨胀)。 // 剔除后:同一份时长重复上报 → 同一 runId → 覆盖而非堆积; // 时长真的变了 → 指纹变了 → 新 runId(新一次观测,确实该新行)。 const obsFingerprint = JSON.stringify({ scope, steamid: id64, itemCount: observations.itemCount, metrics: obsMetrics, playtime, }); const payload = { runId: 'ai.observation:' + id64 + ':' + pcHash8(obsFingerprint), kind: 'ai.observation', subject: id64, source: 'steam-ai-agent', agentVersion: _v, actor: saaActor(identity), owner: (acc && acc.owner) || null, items: [], metrics: profileMetrics, observations, }; try { const r = await pcHubPost('/push/agent', payload); const ob = r.observations || {}; const facts = ob.facts || {}; return { success: true, data: { runId: r.runId, playtimeItems: ob.apps || 0, metricsApplied: ob.metrics || 0, factsApplied: !!facts.applied, factsRows: facts.rows || 0, factsReason: facts.reason || null, profileNote, identitySource: identity.identitySource || null, identityNote: identity.note || null, }, summary: '已上报 ' + (ob.apps || 0) + ' 条时长明细 + ' + (ob.metrics || 0) + ' 个关键指标' + (facts.applied ? ',并刷新了事实表(' + facts.rows + ' 行)' : '(事实表未刷新:' + (facts.reason || '未声明 full scope') + ')') + (identity.note ? `|⚠️ ${identity.note}` : ''), }; } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } }, }); // ============================================================ // v1.22.0 Steam 消费记录自抓(插件缓存计划 阶段3 / docs §5) // v1.34.0 修正取数链路(旧实现致命错误,见下) // ============================================================ // ⚠️ v1.22.0 的旧实现以为存在 GET /account/history/?ajax=1 这个 JSON 接口: // 实际 Steam 从来没有该路由,请求只会拿回整页 HTML → JSON.parse 必然失败 → // 被当成 SPEND_NOT_LOGIN,于是「明明已登录」也提示去重新登录,工具 100% 不可用。 // v1.34.0 改为 Steam 页面自身使用的真实链路(与 Steam-Account-History-Export 一致): // ① 首屏:GET /account/history/?l=schinese → 整页 HTML,解析首屏 // 行 + g_historyCursor + g_sessionID; // ② 翻页:POST /account/AjaxLoadMoreHistory/ // body=cursor=&sessionid=(表单编码)→ {html, cursor}, // 循环直到 cursor 为空 / 空页 / 达到 maxPages。 // ③ 登录态:页面含 g_sessionID / g_steamID / wallet_history_table 视为已登录; // 只有被重定向到 /login/ 或出现登录表单时才判定未登录(不再把接口异常算作未登录)。 // 分类规则移植自 Steam-Spending-History-Classifier v2.2.0(其 L24-32 类别常量、L303-320 // classifyRow、L423-431 parseNumber、L793 导出 schema)。该插件跑在已渲染 DOM 上, // 本工具解析 HTML 片段——改用正则(片段规整、td 不嵌套),Node 测试零 DOM 依赖。 // 缓存:sa_spending_cache(7 天 TTL,force 重抓),仅落本机 GM 存储,不上传。 const SPEND_CACHE_KEY = 'sa_spending_cache'; const SPEND_TTL = 7 * 24 * 60 * 60 * 1000; const SPEND_ORIGIN = 'https://store.steampowered.com'; const SPEND_HISTORY_URL = `${SPEND_ORIGIN}/account/history/?l=schinese`; const SPEND_MORE_URL = `${SPEND_ORIGIN}/account/AjaxLoadMoreHistory/`; const SPEND_BRIDGE_KEY = '__saa_bridge_spend__'; const SPEND_CATS = [ { id: 'store', label: '直购' }, { id: 'ingame', label: '内购' }, { id: 'gift', label: '送礼' }, { id: 'refund', label: '退款' }, { id: 'convert', label: '充值' }, { id: 'market_buy', label: '买入' }, { id: 'market_sell', label: '卖出' }, ]; const SPEND_CATS_SET = new Set(['store', 'gift', 'ingame']); // 计入净支出(同分类器 SPENDING_CATS) const SPEND_LABEL = SPEND_CATS.reduce((m, c) => (m[c.id] = c.label, m), { other: '其他' }); const SPEND_RE = { refund: /退款|Refund/i, convert: /转换|轉換|Convert/i, ingame: /游戏内购买|遊戲內物品購買|In-Game\s*Purchase/i, market: /市场交易|市集交易|Market\s*Transaction/i, gift: /礼物购买|禮物購買|Gift\s*Purchase/i, purchase: /购买|購買|Purchase/i, wallet: /钱包资金|錢包資金|Wallet/i, }; // 币种表(移植分类器 CURRENCIES,去 SVG 旗帜;dc=小数逗号制)。[id, 中文, 符号, 识别正则, dc] const SPEND_CURRENCIES = [ ['HKD', '港币', 'HK$', /HK\$\s*[\d,. ]+/, 0], ['TWD', '新台币', 'NT$', /NT\$\s*[\d,. ]+/, 0], ['AUD', '澳元', 'A$', /A\$\s*[\d,. ]+/, 0], ['CAD', '加元', 'CDN$', /CDN\$\s*[\d,. ]+/, 0], ['NZD', '新西兰元', 'NZ$', /NZ\$\s*[\d,. ]+/, 0], ['ARS', '阿根廷比索', 'ARS$', /ARS\$\s*[\d,. ]+/, 1], ['SGD', '新加坡元', 'S$', /(? ({ id, label, symbol, match, dc: !!dc })); const spendResolveYen = text => /\.\d+/.test(text) ? 'CNY' : 'JPY'; /** 金额解析(同分类器 parseNumber:处理 "1.234,56" 与 "1,234.56" 两种体系) */ function spendParseNum(text, decimalComma) { const m = String(text || '').match(/[\d]+(?:[ ,.]\d+)*/); if (!m) return 0; let s = m[0].replace(/ /g, ''); const li = s.lastIndexOf('.'), ci = s.lastIndexOf(','); if (decimalComma) { s = ci >= 0 ? s.replace(/\./g, '').replace(',', '.') : s.replace(/\./g, ''); } else if (li > 0 && ci > 0) { s = li > ci ? s.replace(/,/g, '') : s.replace(/\./g, '').replace(',', '.'); } else { s = s.replace(/,/g, ''); } return parseFloat(s) || 0; } /** HTML 片段 → 纯文本(实体解码 + 折叠空白,保留 为换行) */ function spendText(html) { return String(html || '') .replace(//gi, '\n') .replace(/<[^>]*>/g, ' ') .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') .replace(/?39;|'/g, "'").replace(/ /g, ' ').replace(/&/g, '&') .replace(/(\d+);/g, (_, d) => String.fromCharCode(Number(d))) .replace(/[ \t]+/g, ' ').replace(/ ?\n ?/g, '\n').trim(); } /** 分类(同分类器 classifyRow 的文本版:typeText 驱动,market 按钱包变更正负分买卖) */ function spendClassify(typeText, walletChangeText, itemsText) { const text = String(typeText || '').trim(); if (!text) return 'other'; if (SPEND_RE.refund.test(text)) return 'refund'; if (SPEND_RE.convert.test(text)) return 'convert'; if (SPEND_RE.ingame.test(text)) return 'ingame'; if (SPEND_RE.market.test(text)) { const ct = String(walletChangeText || ''); return ct.includes('+') ? 'market_sell' : ct.includes('-') ? 'market_buy' : 'other'; } if (SPEND_RE.gift.test(text)) return 'gift'; if (SPEND_RE.purchase.test(text)) return SPEND_RE.wallet.test(String(itemsText || '')) ? 'convert' : 'store'; return 'other'; } /** 单行币种识别:total → wallet_change → wallet_balance 依次找符号(CNY/JPY 靠小数区分) */ function spendDetectRowCurrency(cells) { const texts = [cells.total, cells.wallet_change, cells.wallet_balance] .map(h => spendText(h)).filter(t => t && t !== '--'); const yen = texts.find(t => /¥/.test(t) && !/JP¥|JPY/i.test(t)); if (yen) return spendResolveYen(yen); const all = texts.join(' '); for (const cur of SPEND_CURRENCIES) { if (cur.id === 'CNY') continue; if (cur.match.test(all)) return cur.id; } return null; // 交由调用方回退主货币 } /** ajax html 片段 → 行记录数组(正则解析;Steam 片段规整、td 互不嵌套) */ function spendParseRows(html) { const out = []; const rowRe = /]*class="[^"]*wallet_table_row[^"]*"[^>]*>([\s\S]*?)<\/tr>/g; let m; while ((m = rowRe.exec(String(html || ''))) !== null) { const openTag = m[0].slice(0, m[0].indexOf('>')); const rowHtml = m[1]; const cells = {}; const tdRe = /]*>([\s\S]*?)<\/td>/g; let t; while ((t = tdRe.exec(rowHtml)) !== null) { if (cells[t[1]] === undefined) cells[t[1]] = t[2]; } if (cells.type === undefined && cells.items === undefined) continue; // 非数据行(th 表头等) const typeHtml = cells.type || ''; const typeText = spendText(typeHtml); const itemsHtml = cells.items || ''; const firstDiv = /]*>([\s\S]*?)<\/div>/.exec(itemsHtml); const item = spendText(firstDiv ? firstDiv[1] : itemsHtml).split('\n')[0].slice(0, 160); const walletChangeText = spendText(cells.wallet_change || ''); const category = spendClassify(typeText, walletChangeText, spendText(itemsHtml)); const dt = /data-datetime="(\d{9,13})"/.exec(openTag); const ts = dt ? (Number(dt[1]) < 1e12 ? Number(dt[1]) * 1000 : Number(dt[1])) : null; const onclick = /onclick="([^"]*)"/.exec(m[0]); const appid = onclick ? (/appid=(\d+)/.exec(onclick[1]) || [])[1] || null : null; // 市场行数量:type 单元格第一个 div 的数字(同分类器 marketCount),items 的「已售出/购买 N 件」兜底 let marketCount = 1; if (category === 'market_buy' || category === 'market_sell') { const md = /]*>([\s\S]*?)<\/div>/.exec(typeHtml); marketCount = parseInt(spendText(md ? md[1] : ''), 10) || 1; if (marketCount === 1) { const im = /已(?:售出|购买)\s*(\d+)/.exec(spendText(itemsHtml)); if (im) marketCount = parseInt(im[1], 10) || 1; } } // 支付方式:wth_payment 块内的子 div 序列(每个 div = 一种支付及其金额,跳过纯金额段) let payment = ''; const pmSegStart = typeHtml.indexOf('wth_payment'); if (pmSegStart >= 0) { const pmSeg = typeHtml.slice(pmSegStart).replace(/<\/td>[\s\S]*$/, ''); const pmDivs = []; const pmRe = /]*>([\s\S]*?)<\/div>/g; let p; while ((p = pmRe.exec(pmSeg)) !== null) { const txt = spendText(p[1]); if (txt && !/^[-+]/.test(txt)) pmDivs.push(txt); } payment = pmDivs.length ? pmDivs.join(' + ') : ''; } const totalText = spendText(cells.total || ''); const currency = spendDetectRowCurrency(cells); const dcCur = SPEND_CURRENCIES.find(c => c.id === currency); let amount = 0; if (category === 'ingame' && cells.wallet_change !== undefined) { amount = spendParseNum(walletChangeText, dcCur && dcCur.dc); } if (!amount) amount = spendParseNum(totalText, dcCur && dcCur.dc); const dateText = spendText(cells.date || '').replace(/\s+/g, ' '); out.push({ key: `${dt ? dt[1] : dateText}|${totalText}|${item.slice(0, 60)}`, ts, date: dateText, item, type: typeText.split('\n')[0].slice(0, 60), category, currency: currency || null, amount, total: totalText, walletChange: walletChangeText, payment, marketCount, appid, }); } return out; } /** 主货币检测(同分类器 detectPrimaryCurrency:跳过 market/refund 行,看第一笔常规交易) */ function spendDetectPrimaryCurrency(rows) { for (const r of rows) { if (r.category === 'market_buy' || r.category === 'market_sell' || r.category === 'refund') continue; const text = [r.total, r.walletChange].find(t => t && t !== '--') || ''; if (!text) continue; if (/¥/.test(text) && !/JP¥|JPY/i.test(text)) return spendResolveYen(text); for (const cur of SPEND_CURRENCIES) { if (cur.match.test(text)) return cur.id; } return null; } return null; } /** 聚合:分类统计 + 净支出 + 月度趋势(口径同分类器 calcTotal:spending 为正、refund 抵扣) */ function spendAggregate(rows, primaryId) { const primary = SPEND_CURRENCIES.find(c => c.id === primaryId); const byCat = {}; // cat → {count, spend} const byMonth = {}; // 'YYYY-MM' → {count, net} let gross = 0, refunded = 0; for (const r of rows) { const isPrimary = !primaryId || r.currency === primaryId; const amt = isPrimary ? (Number(r.amount) || 0) : 0; const cat = r.category; if (!byCat[cat]) byCat[cat] = { count: 0, spend: 0 }; byCat[cat].count += r.marketCount || 1; if (SPEND_CATS_SET.has(cat)) { byCat[cat].spend += amt; gross += amt; } else if (cat === 'refund') { byCat[cat].spend -= amt; refunded += amt; } const month = r.ts ? new Date(r.ts).toISOString().slice(0, 7) : null; if (month) { if (!byMonth[month]) byMonth[month] = { count: 0, net: 0 }; byMonth[month].count += r.marketCount || 1; if (SPEND_CATS_SET.has(cat)) byMonth[month].net += amt; else if (cat === 'refund') byMonth[month].net -= amt; } } const monthly = Object.keys(byMonth).sort().reverse().slice(0, 12) .map(k => ({ month: k, count: byMonth[k].count, net: Math.round(byMonth[k].net * 100) / 100 })); const categories = SPEND_CATS .filter(c => byCat[c.id]) .map(c => ({ id: c.id, label: c.label, count: byCat[c.id].count, spend: Math.round(byCat[c.id].spend * 100) / 100 })); const otherCount = byCat.other ? byCat.other.count : 0; return { currency: primary ? { id: primary.id, label: primary.label, symbol: primary.symbol } : null, netSpend: Math.round((gross - refunded) * 100) / 100, grossSpend: Math.round(gross * 100) / 100, refundTotal: Math.round(refunded * 100) / 100, categories, otherCount, monthly, }; } function spendCacheGet() { try { const raw = GM_getValue(SPEND_CACHE_KEY, null); if (!raw) return null; const obj = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!obj || !Array.isArray(obj.rows) || !obj.rows.length) return null; // 未拉全的缓存只保鲜 30 分钟:避免一次翻页失败把「只有 10 条」锁死 7 天 const ttl = obj.complete === false ? 30 * 60 * 1000 : SPEND_TTL; if (Date.now() - obj.at > ttl) return null; return obj; } catch { return null; } } function spendCacheSave(cache) { try { GM_setValue(SPEND_CACHE_KEY, JSON.stringify(cache)); } catch {} } // ---- 登录态与历史页解析(v1.34.0)------------------------------------------ function spendExtractSteamId(html) { const m = /g_steamID\s*=\s*"(\d{17})"/.exec(String(html || '')); return m ? m[1] : ''; } function spendExtractSessionId(html) { const m = /g_sessionID\s*=\s*"([^"]+)"/.exec(String(html || '')); if (m) return m[1]; // 兜底:store 域的 sessionid 不是 HttpOnly,页面脚本可读 try { const cm = /(?:^|;\s*)sessionid=([^;]+)/.exec(document.cookie || ''); return cm ? decodeURIComponent(cm[1]) : ''; } catch { return ''; } } function spendExtractCursor(html) { const m = /g_historyCursor\s*=\s*(null|"(?:[^"\\]|\\.)*")/.exec(String(html || '')); if (!m || m[1] === 'null') return null; try { return JSON.parse(m[1]); } catch { return null; } } /** 历史页正向标记:g_sessionID / 17 位 g_steamID / 钱包历史表,任一存在即视为已登录 */ function spendPageHasHistory(html) { const h = String(html || ''); return /g_sessionID\s*=\s*"[^"]+"/.test(h) || /g_steamID\s*=\s*"\d{17}"/.test(h) || /wallet_history_table/.test(h); } /** 登录墙标记:被重定向到 /login/ 或页面出现登录表单(不与「页面结构变更」混为一谈) */ function spendPageLooksLoginWall(html, finalUrl) { if (/\/login\/?(\?|$)/.test(String(finalUrl || ''))) return true; return /id="login_form"|class="[^"]*loginbox|newlogindialog/i.test(String(html || '')); } /** 本地(免请求)登录证据:账号页/商店页都有 g_steamID 或 application_config */ function spendLocalLoginEvidence() { const evidence = []; let steamId = ''; try { steamId = String(unsafeWindow.g_steamID || ''); } catch {} if (/^\d{17}$/.test(steamId)) evidence.push('window.g_steamID'); try { if (unsafeWindow.g_sessionID) evidence.push('window.g_sessionID'); } catch {} try { const cfg = document.getElementById('application_config'); const info = cfg ? JSON.parse(cfg.getAttribute('data-userinfo') || '{}') : null; if (info && info.steamid) { steamId = steamId || String(info.steamid); evidence.push('application_config.data-userinfo'); } } catch {} if (!steamId) { const detected = detectSteamId(); if (detected) { steamId = detected; if (!evidence.length) evidence.push('detectSteamId'); } } return { steamId, evidence }; } // 60s 缓存:避免连续对话反复拉历史页 let _spendLoginCache = null; async function spendLoginState(force) { if (!force && _spendLoginCache && Date.now() - _spendLoginCache.at < 60000) return _spendLoginCache.state; const local = spendLocalLoginEvidence(); let state = { loggedIn: local.evidence.length ? true : null, steamId: local.steamId || '', evidence: local.evidence.slice(), source: local.evidence.length ? 'page' : '', checkedAt: Date.now(), }; if (state.loggedIn !== true) { try { const resp = await gmFetch(SPEND_HISTORY_URL, { timeout: 15000, withMeta: true }); const html = String(resp.responseText || ''); const finalUrl = String(resp.finalUrl || SPEND_HISTORY_URL); const has = spendPageHasHistory(html); const wall = spendPageLooksLoginWall(html, finalUrl); state = { loggedIn: has ? true : (wall ? false : null), steamId: state.steamId || spendExtractSteamId(html) || detectSteamId(), evidence: has ? ['history page: 已登录标记'] : (wall ? ['history page: 被重定向到登录页'] : ['history page: 未发现登录标记']), source: 'history-probe', finalUrl, checkedAt: Date.now(), }; } catch (e) { state.probeError = e.message; state.note = '无法访问消费历史页,登录态无法判定(不是「未登录」)'; } } _spendLoginCache = { at: Date.now(), state }; return state; } /** * 拉全量:首屏整页 HTML → AjaxLoadMoreHistory 表单翻页。 * 失败时抛出的 Error 带 loginState,便于上层如实回答「未登录 / 页面异常」。 */ async function spendFetchAll(maxPages) { const rows = []; const seen = new Set(); const pageErrors = []; let pages = 0, complete = false; // ① 首屏(整页 HTML) const first = await gmFetch(SPEND_HISTORY_URL, { timeout: 25000, withMeta: true }); const firstHtml = String(first.responseText || ''); const finalUrl = String(first.finalUrl || SPEND_HISTORY_URL); if (!spendPageHasHistory(firstHtml)) { const isLoginWall = spendPageLooksLoginWall(firstHtml, finalUrl); const err = new Error(isLoginWall ? 'SPEND_NOT_LOGIN' : 'SPEND_PAGE_UNEXPECTED'); err.diagnosis = { finalUrl, htmlLength: firstHtml.length, hasLoginForm: /id="login_form"|class="[^"]*loginbox/i.test(firstHtml), }; err.loginState = { loggedIn: isLoginWall ? false : null, steamId: spendLocalLoginEvidence().steamId, evidence: [isLoginWall ? '请求被重定向到登录页' : '历史页未返回钱包历史表(页面结构可能变更)'], source: 'history-probe', finalUrl, checkedAt: Date.now(), }; throw err; } pages = 1; for (const r of spendParseRows(firstHtml)) { if (seen.has(r.key)) continue; seen.add(r.key); rows.push(r); } // ② 翻页(Steam 页面自身使用的 POST 接口) // 工具执行有 30s 上限:留出余量,超时前主动收尾成「部分结果」,不要被整体掐断后一无所有 const deadline = Date.now() + 22000; const sessionId = spendExtractSessionId(firstHtml); let cursor = spendExtractCursor(firstHtml); if (!cursor) complete = true; while (cursor && pages < maxPages) { if (Date.now() > deadline) { pageErrors.push(`已用满 22s 时间预算,停在 ${pages} 页;可用 maxPages 调小或稍后重试`); break; } const body = 'cursor=' + encodeURIComponent(String(cursor)) + '&sessionid=' + encodeURIComponent(sessionId); let text; try { text = await gmFetch(SPEND_MORE_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', }, data: body, timeout: 25000, }); } catch (e) { pageErrors.push(`第 ${pages + 1} 页请求失败:${e.message}`); break; } let json; try { json = JSON.parse(text); } catch { // 首屏已证明登录态:这里只可能是接口异常,不能反过来判「未登录」 pageErrors.push(`第 ${pages + 1} 页返回非 JSON:${text.slice(0, 120).replace(/\s+/g, ' ')}`); break; } if (!json || json.success === 0 || json.success === false) { pageErrors.push((json && (json.message || json.error)) || '翻页接口 success=0'); break; } const html = String(json.html || ''); const parsed = spendParseRows(html); let added = 0; for (const r of parsed) { if (seen.has(r.key)) continue; seen.add(r.key); rows.push(r); added++; } pages++; const next = json.cursor || null; if (!next) { complete = true; break; } if (!html.trim() || !added) break; // 空页/重复页:保守停止,complete 保持 false cursor = next; await new Promise(r => setTimeout(r, 200)); // 翻页限速 } return { rows, pages, complete, pageErrors, sessionId }; } // ---- 宿主桥镜像(Steam-Spending-History-Classifier 写入 localStorage)--------- // 仅 store 域同源可读:community 域是另一份 localStorage(按源隔离),读不到不是 bug。 function spendBridgeRead() { if (pcCurrentDomain() !== PC_DOMAIN_STORE) return null; let raw = null; try { raw = pcLocalGet(SPEND_BRIDGE_KEY); } catch { return null; } if (!raw || typeof raw !== 'object' || raw.v == null) return null; const rows = Array.isArray(raw.rows) ? raw.rows.filter(r => r && r.category && r.category !== 'other').map(r => ({ key: `${r.ts || r.date}|${r.total || ''}|${String(r.item || '').slice(0, 60)}`, ts: Number(r.ts) || null, date: r.date || '', item: r.item || '', type: r.type || '', category: r.category, currency: r.currency || null, amount: Number(r.amount) || 0, total: r.total || '', walletChange: r.walletChange || '', payment: r.payment || '', appid: r.appid || null, marketCount: Number(r.marketCount) || 1, })) : []; return { at: Number(raw.updatedAt) || 0, rows, complete: raw.complete === true, totalRows: Number(raw.totalRows) || rows.length, rowsSkipped: raw.rowsSkipped || null, truncated: raw.truncated === true, primaryCurrency: raw.primaryCurrency || null, aggregate: raw.aggregate && typeof raw.aggregate === 'object' ? raw.aggregate : null, }; } /** 宿主只镜像了聚合(quota 降级)时的口径适配 */ function spendBridgeAggregate(bridge) { const a = bridge && bridge.aggregate; if (!a) return null; const cur = SPEND_CURRENCIES.find(c => c.id === bridge.primaryCurrency) || null; return { currency: cur ? { id: cur.id, label: cur.label, symbol: cur.symbol } : null, netSpend: Math.round((Number(a.netSpend) || 0) * 100) / 100, grossSpend: Math.round((Number(a.grossSpend) || 0) * 100) / 100, refundTotal: Math.round((Number(a.refundTotal) || 0) * 100) / 100, categories: Array.isArray(a.categories) ? a.categories : [], otherCount: Number(a.otherCount) || 0, monthly: Array.isArray(a.monthly) ? a.monthly : [], }; } ToolRegistry.register({ name: 'getSpendingHistory', description: '获取当前登录 Steam 账号的消费历史(/account/history,走浏览器登录态,无需 Steam Web API Key),按 直购/内购/送礼/退款/充值/市场买入/市场卖出 七类分类统计。' + '内部三级取数:本机 GM 缓存 → 宿主 Spending 分类器桥镜像(毫秒级)→ 消费历史页自抓(首屏整页 + POST /account/AjaxLoadMoreHistory/ 表单翻页,每页 10 条,默认上限 50 页)。' + '返回 data.source(cache/bridge/fetch)与 data.coverage 说明数据来源与覆盖范围,回答时需据此声明。' + '适合回答"我在 Steam 上花了多少钱""最近哪个月买游戏最多""退款了多少""市场买卖流水""钱包充值记录"这类消费分析问题。' + '自抓失败但桥里有数据时会自动降级返回桥数据(degraded/partial 标记),此时不要改口说"拿不到数据"。' + '只有确证浏览器未登录时才返回 needLogin=true,并附带 loginState(含证据);result 为 null 时不要断言未登录。数据仅存本机,不上传。', parameters: { type: 'object', properties: { mode: { type: 'string', enum: ['summary', 'detail'], description: 'summary=聚合摘要(分类总额+月度趋势+最近N笔,默认);detail=逐笔明细(可用 category 过滤)' }, category: { type: 'string', description: 'detail 模式可选过滤:store/ingame/gift/refund/convert/market_buy/market_sell' }, topN: { type: 'number', description: '返回的最近笔数上限,summary 默认 12、detail 默认 40,最大 100' }, months: { type: 'number', description: 'summary 月度趋势的月份数,默认 12,最大 24' }, maxPages: { type: 'number', description: '首次抓取的翻页上限(每页 10 条),默认 50(约 500 条),最大 300' }, force: { type: 'boolean', description: '跳过 7 天缓存强制重新抓取(默认 false)' }, }, required: [], }, category: 'knowledge', async execute(params) { const mode = params.mode === 'detail' ? 'detail' : 'summary'; const topN = Math.max(1, Math.min(Number(params.topN) || (mode === 'detail' ? 40 : 12), 100)); const months = Math.max(1, Math.min(Number(params.months) || 12, 24)); const maxPages = Math.max(1, Math.min(Number(params.maxPages) || 50, 300)); const category = params.category && SPEND_LABEL[params.category] ? params.category : null; // ---- 三级取数:GM 缓存 → 宿主桥镜像 → 历史页自抓(失败再回退桥) ---- const bridge = spendBridgeRead(); let cache = params.force === true ? null : spendCacheGet(); let source = cache ? 'cache' : ''; let aggOverride = null; let bridgeOnly = false; // 桥已加载全量时可直接当缓存(毫秒级、与自抓同口径) if (!cache && params.force !== true && bridge && bridge.rows.length && bridge.complete) { cache = { v: 1, at: bridge.at || Date.now(), rows: bridge.rows, pages: 0, complete: true }; source = 'bridge'; } if (!cache) { let fetched = null; let fetchError = null; try { fetched = await spendFetchAll(maxPages); } catch (e) { fetchError = e; } if (fetched) { if (!fetched.rows.length) { return { success: false, error: '已登录但未解析到任何消费记录(账号可能确实没有消费历史,或 Steam 页面结构变更)', loginState: { loggedIn: true, steamId: spendLocalLoginEvidence().steamId, evidence: ['history page: 已登录标记'], source: 'history-probe', }, pagesFetched: fetched.pages, ...(fetched.pageErrors.length ? { pageErrors: fetched.pageErrors.slice(0, 3) } : {}), }; } cache = { v: 1, at: Date.now(), rows: fetched.rows, pages: fetched.pages, complete: fetched.complete }; spendCacheSave(cache); source = 'fetch'; } else { // 自抓失败:桥里有明细用明细,只有聚合用聚合(不把配额降级的空明细当失败) if (bridge && bridge.rows.length) { cache = { v: 1, at: bridge.at || Date.now(), rows: bridge.rows, pages: 0, complete: bridge.complete === true }; source = 'bridge'; } else if (bridge && bridge.aggregate) { aggOverride = spendBridgeAggregate(bridge); cache = { v: 1, at: bridge.at || Date.now(), rows: [], pages: 0, complete: bridge.complete === true }; bridgeOnly = true; source = 'bridge'; } if (!cache) { if (fetchError && (fetchError.message === 'SPEND_NOT_LOGIN' || fetchError.message === 'SPEND_PAGE_UNEXPECTED')) { const notLogin = fetchError.message === 'SPEND_NOT_LOGIN'; return { success: false, error: notLogin ? '消费历史页被重定向到 Steam 登录页:当前浏览器确实没有登录态。请在同一浏览器登录 store.steampowered.com 后重试。' : '消费历史页返回了无法识别的页面(可能是页面结构变更或访问被拦截),无法确认登录态。', needLogin: notLogin, loginState: fetchError.loginState || await spendLoginState(true), diagnosis: fetchError.diagnosis || null, ...(bridge ? { bridgeState: { rowsPresent: bridge.rows.length, rowsSkipped: bridge.rowsSkipped, complete: bridge.complete } } : { bridgeMissing: true }), }; } return { success: false, error: `抓取失败:${fetchError ? fetchError.message : '未知错误'}` }; } } } const primaryId = bridgeOnly ? (bridge.primaryCurrency || null) : spendDetectPrimaryCurrency(cache.rows); const agg = aggOverride || spendAggregate(cache.rows, primaryId); const ageMin = Math.max(0, Math.round((Date.now() - cache.at) / 60000)); const coverage = bridgeOnly ? `宿主插件只镜像了聚合(明细因 localStorage 配额被跳过);页面${cache.complete ? '已加载全量' : '未加载全量,聚合可能低于真实总额'}` : source === 'bridge' ? `来自宿主插件镜像:消费历史页已解析 ${cache.rows.length} 条${cache.complete ? '(已全量)' : '(「加载更多」未点完,可能不完整)'}` : cache.complete ? '已覆盖全部消费记录' : `仅覆盖最近 ${cache.pages} 页(${cache.rows.length} 条),可用 maxPages 调大或 force 重抓`; if (mode === 'detail') { if (!cache.rows.length) { return { success: false, error: '宿主桥只有聚合、没有逐笔明细(localStorage 配额降级),无法列出逐笔记录;可改用 mode=summary 看总额,或 force=true 走自抓。', source, }; } let rows = cache.rows; if (category) rows = rows.filter(r => r.category === category); const slim = rows.slice(0, topN).map(r => ({ date: r.date, item: r.item, type: r.type, category: r.category, currency: r.currency, amount: r.amount, total: r.total, ...(r.walletChange ? { walletChange: r.walletChange } : {}), ...(r.payment ? { payment: r.payment } : {}), ...(r.appid ? { appid: r.appid } : {}), ...(r.marketCount > 1 ? { marketCount: r.marketCount } : {}), })); return { success: true, data: { fetchedAt: cache.at, cacheAgeMinutes: ageMin, fromCache: source === 'cache', source, totalRows: cache.rows.length, matchedRows: rows.length, filteredBy: category, coverage, records: slim, ...(rows.length > topN ? { note: `仅返回最近 ${topN} 笔(共 ${rows.length} 笔匹配),可用 topN 调大` } : {}), }, summary: `消费明细 ${slim.length} 笔${category ? `(过滤:${SPEND_LABEL[category]})` : ''},共 ${cache.rows.length} 条记录`, }; } const recent = cache.rows.slice(0, topN).map(r => ({ date: r.date, item: r.item, category: r.category, total: r.total, ...(r.currency && r.currency !== primaryId ? { currency: r.currency } : {}), })); return { success: true, data: { fetchedAt: cache.at, cacheAgeMinutes: ageMin, fromCache: source === 'cache', source, totalRows: cache.rows.length, ...(cache.pages ? { pagesFetched: cache.pages } : {}), coverage, currency: agg.currency, netSpend: agg.netSpend, grossSpend: agg.grossSpend, refundTotal: agg.refundTotal, categories: agg.categories, ...(agg.otherCount ? { otherCount: agg.otherCount } : {}), monthlyTrend: agg.monthly.slice(0, months), recent, note: '净支出 = 直购+内购+送礼−退款(仅统计主货币行);市场买卖与充值不计入净支出。跨币种消费未折算。', ...(bridgeOnly ? { degraded: true, degradedReason: 'bridge-aggregate-only' } : {}), ...(source === 'bridge' && !cache.complete ? { partial: true } : {}), }, summary: `共 ${bridgeOnly ? (bridge.totalRows || 0) : cache.rows.length} 条消费记录,净支出 ${agg.currency ? agg.currency.symbol : ''}${agg.netSpend}` + (source === 'cache' ? `(缓存于 ${ageMin} 分钟前)` : source === 'bridge' ? `(宿主桥镜像${cache.complete ? '' : ',未全量'})` : `(刚抓取 ${cache.pages} 页)`), }; }, }); ToolRegistry.register({ name: 'getSteamLoginStatus', description: '检测当前浏览器是否已登录 Steam(只读、不消耗 Steam API 配额、不读取任何凭据)。' + '返回 loggedIn: true=已登录 / false=未登录 / null=无法判定(网络或页面结构问题,此时不得断言「未登录」)、steamId、证据列表与请求最终地址。' + '用于回答「我登录了吗」「为什么消费历史说我没登录」「要不要重新登录」这类问题,或在使用强登录依赖的工具前自检。结果缓存 60 秒。', parameters: { type: 'object', properties: { force: { type: 'boolean', description: '跳过 60 秒缓存强制重新探测(默认 false)' }, }, required: [], }, category: 'knowledge', async execute(params) { const state = await spendLoginState(params.force === true); const verdict = state.loggedIn === true ? '已登录' : state.loggedIn === false ? '未登录' : '无法判定'; return { success: true, data: { loggedIn: state.loggedIn, verdict, steamId: state.steamId || null, evidence: state.evidence, source: state.source || null, ...(state.finalUrl ? { finalUrl: state.finalUrl } : {}), checkedAt: state.checkedAt, cached: Date.now() - state.checkedAt > 500, ...(state.probeError ? { probeError: state.probeError } : {}), hint: state.loggedIn === true ? '浏览器已持有 Steam 登录态;若其他工具仍报未登录,问题在工具取数链路而非账号。' : state.loggedIn === false ? '当前浏览器没有 Steam 登录态(历史页被重定向到登录页)。登录 store.steampowered.com 后即可重试。' : '既没有本地登录标记、历史页也没有明确登录墙 —— 可能是网络/页面结构问题,请如实告知用户「无法判定」,不要断言未登录。', }, summary: `${verdict}${state.steamId ? `(SteamID ${state.steamId})` : ''}`, }; }, }); // ============================================================ // v1.16.0 联网检索 / 抓取(DeepSeek /v1/tools/*) // ============================================================ // 实测结论(2026-10-19,DeepSeek 官方接口): // search:稳定。固定返回 10 条,字段 title/site_name/authority/rerank_score/ // date/url/summary/snippet/content;summary 单条最长约 2.7KB、 // 10 条合计约 19KB(超 12KB 回灌预算,必须裁剪);content 实测恒为空。 // fetch :不稳定。store.steampowered.com 与 steamcommunity.com 实测均返回 // "failed to fetch URL"(被目标站拒绝抓取);部分大站会 gateway timeout; // 少数站点能返回但抽取质量差(只抓到导航栏)。故必须分类报错并引导改用 search。 // 统一错误分类:把网关/抓取失败翻译成模型能据此行动的提示 function classifyWebToolsError(msg, kind) { const m = String(msg || ''); if (/failed to fetch URL/i.test(m)) { // 实测(2026-10-19):该抓取服务成功率偏低,且**不止 Steam**—— // Wikipedia / BBC / HackerNews / Reuters / counter-strike.net / Steam 商店与社区 // 均返回 "failed to fetch URL",仅 GitHub、help 子站等少数站点成功 // (且成功时也可能只抽到导航栏)。故这里不暗示"换个域名就行"。 return kind === 'fetch' ? '该网址无法被抓取:目标站点拒绝或屏蔽了服务端抓取。这是抓取接口的已知限制,并非本机或网络问题——实测 Wikipedia/BBC/HackerNews/Reuters 以及 Steam 商店与社区站点均被拒绝,仅 GitHub 等少数站点可抓。' + '建议:① 直接用 webSearch 获取该页面的摘要(成功率很高);② 若必须读全文,改用 Steam 专用工具获取站内数据。' : '检索服务无法访问该来源,请更换关键词或改用其他工具。'; } if (/timeout|gateway_timeout/i.test(m)) { return '目标站点响应超时(站点过大或网络不通)。建议改用 webSearch,或换一个更轻量的页面。'; } if (/401|unauthorized|invalid.*key|authentication/i.test(m)) { return '联网工具鉴权失败:API Key 无效或未配置。请在设置「接口服务 → 联网检索工具」配置 Key。'; } if (/429|rate.?limit|too many/i.test(m)) { return '请求过于频繁或额度不足,已被服务端限流。请稍后重试。'; } return m; } async function callWebTools(path, payload) { const { base, key } = getWebToolsConfig(); if (!key) { return { ok: false, needKey: true, error: '未配置联网工具 API Key:请在设置「接口服务 → 联网检索工具」填写,或在「AI 设置」中配置 DeepSeek 模型(配置后本工具会自动复用其 Key)' }; } const url = `${base}/${path}`; try { const resp = await gmFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` }, data: JSON.stringify(payload), timeout: CONFIG.DS_TOOLS_TIMEOUT, // 放行 AI 端点主机:联网检索接口与对话接口同主机(默认都是 api.deepseek.com), // 这是本工具存在的目的。v1.11.0 的默认拦截是为了防止工具把 Key 外泄到 AI 主机, // 而这里的目标地址来自用户显式配置的 Base URL,不接受模型传入的任意地址, // 因此只开 allowAiEndpoint,**不放行 allowPrivate**(内网/回环仍一律拦截,SSRF 防护不变)。 allowAiEndpoint: true, }); let json; try { json = JSON.parse(resp); } catch { return { ok: false, error: `接口返回非 JSON:${String(resp).slice(0, 200)}` }; } if (json && json.error) { return { ok: false, error: classifyWebToolsError(json.error.message || json.error.type, path === 'fetch' ? 'fetch' : 'search'), rawError: json.error }; } return { ok: true, data: json }; } catch (e) { return { ok: false, error: classifyWebToolsError(e.message, path === 'fetch' ? 'fetch' : 'search') }; } } // ===== v1.16.0 检索结果缓存(抑制重复检索)===== // 动机:实测模型会为同一个问题连调 5 次 webSearch(自己拼信息), // 既烧 Key 额度又无增益。这里按 query 缓存一段时间,命中即返回并标注 fromCache。 const _searchResultCache = new Map(); const _searchCacheIndex = new Map(); // query → key(LRU 顺序) const SEARCH_CACHE_MAX = 24; function searchCacheGet(query) { const hit = _searchResultCache.get(query); if (!hit) return null; if (Date.now() - hit.at > CONFIG.SEARCH_RESULT_TTL) { _searchResultCache.delete(query); _searchCacheIndex.delete(query); return null; } // 触碰即移到末尾(LRU) _searchCacheIndex.delete(query); _searchCacheIndex.set(query, hit.at); return hit; } function searchCacheSet(query, data) { if (_searchResultCache.size >= SEARCH_CACHE_MAX && !_searchResultCache.has(query)) { const oldest = _searchCacheIndex.keys().next().value; if (oldest !== undefined) { _searchCacheIndex.delete(oldest); _searchResultCache.delete(oldest); } } const at = Date.now(); _searchResultCache.set(query, { at, data }); _searchCacheIndex.delete(query); _searchCacheIndex.set(query, at); } // 归一化 query:小写 + 压缩空白,让"CS2 更新"与"cs2 更新"命中同一条缓存 function normalizeSearchQuery(q) { return String(q || '').toLowerCase().replace(/\s+/g, ' ').trim(); } // 检索结果统一格式化(缓存命中与首次检索共用,保证两条路径输出完全一致) function formatSearchResult(it, rank) { return { rank, title: it.title || null, site: it.site_name || null, url: it.url || null, date: it.date || null, authority: Number(it.authority) || null, rerankScore: typeof it.rerank_score === 'number' ? Math.round(it.rerank_score * 1000) / 1000 : null, summary: String(it.summary || it.snippet || '').slice(0, CONFIG.SEARCH_SUMMARY_MAX_CHARS), }; } ToolRegistry.register({ name: 'webSearch', description: '联网搜索网页,返回结构化检索结果(标题/来源站/日期/URL/摘要),由 DeepSeek 官方检索接口提供。' + '适合回答"最近有什么新闻/更新""XX 现在多少钱/什么时候发售""某个事件最新进展"等需要实时或站外信息的问题。' + '注意:这是通用网页搜索,不含 Steam 站内私有数据——查游戏价格/好友/家庭库请优先用 Steam 专用工具。', parameters: { type: 'object', properties: { query: { type: 'string', description: '搜索关键词或自然语言问题(建议用与站点相关的具体词汇)' }, topN: { type: 'number', description: '返回条数,默认 5,最大 10(接口固定返回 10 条,此处只做裁剪)' }, }, required: ['query'], }, category: 'knowledge', async execute(params) { const query = String(params.query || '').trim(); if (!query) return { success: false, error: '缺少 query 参数' }; const topN = Math.max(1, Math.min(Number(params.topN) || 5, 10)); // v1.16.0:同 query 直接命中缓存,不重复消耗 Key 额度 const ck = normalizeSearchQuery(query); const cached = searchCacheGet(ck); if (cached) { const d = cached.data; const results = d.list.slice(0, topN).map((it, i) => formatSearchResult(it, i + 1)); return { success: true, data: { query, totalAvailable: d.total, returned: results.length, results, fromCache: true, cacheAgeMinutes: Math.max(0, Math.round((Date.now() - cached.at) / 60000)), note: `本次结果来自本地缓存(${Math.max(0, Math.round((Date.now() - cached.at) / 60000))} 分钟前检索的同一 query)。请直接基于它作答,不要用相近关键词重复检索。`, }, summary: `检索「${query}」命中缓存(${d.total} 条,取前 ${results.length} 条)`, }; } const r = await callWebTools('search', { query }); if (!r.ok) { return r.needKey ? { success: false, needsApiKey: true, needsWebToolsKey: true, error: r.error } : { success: false, error: r.error, rawError: r.rawError || null }; } const list = Array.isArray(r.data && r.data.results) ? r.data.results : []; if (!list.length) { return { success: true, data: { query, total: 0, results: [] }, summary: `未检索到「${query}」的相关结果` }; } searchCacheSet(ck, { list, total: list.length, at: Date.now() }); // 裁剪:summary 单条截到 900 字符(实测原生可达 2.7KB),并丢弃恒为空的 content 字段 const results = list.slice(0, topN).map((it, i) => formatSearchResult(it, i + 1)); const clipped = list.some((it, i) => i < topN && String(it.summary || '').length > CONFIG.SEARCH_SUMMARY_MAX_CHARS); return { success: true, data: { query, totalAvailable: list.length, returned: results.length, results, ...(clipped ? { summaryTruncated: true, note: `摘要已截断至 ${CONFIG.SEARCH_SUMMARY_MAX_CHARS} 字符;需要完整内容可对感兴趣的结果 URL 调用 webFetch` } : {}), }, summary: `检索「${query}」得到 ${results.length}/${list.length} 条结果:${results.slice(0, 3).map(r => r.title || r.url).filter(Boolean).join(';')}`, }; }, }); ToolRegistry.register({ name: 'webFetch', description: '抓取单个网页并以 Markdown 文本返回正文内容(由 DeepSeek 官方抓取接口提供)。' + '⚠️ 实测该接口成功率偏低:多数站点(Wikipedia/BBC/HackerNews/Reuters/Steam 商店与社区)会返回"无法抓取",' + '只有 GitHub 等少数站点可抓,且成功时也可能只抽到导航栏。因此**优先用 webSearch**(成功率很高),' + '仅当确实需要某页全文、且该 URL 大概率可抓时再用本工具。' + 'Steam 站内的私有数据(好友/家庭库/个人游戏库/许可来源)不要用本工具,必须走 Steam 专用工具或 readPluginCache。', parameters: { type: 'object', properties: { url: { type: 'string', description: '要抓取的网页 URL(http/https)' }, maxChars: { type: 'number', description: '正文返回上限,默认 8000 字符,最大 20000' }, }, required: ['url'], }, category: 'knowledge', async execute(params) { const url = String(params.url || '').trim(); if (!url) return { success: false, error: '缺少 url 参数' }; let parsed = null; try { parsed = new URL(url); } catch { return { success: false, error: `URL 格式无效:${url}` }; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return { success: false, error: `仅支持 http/https 协议,收到 ${parsed.protocol}` }; } const maxChars = Math.max(500, Math.min(Number(params.maxChars) || CONFIG.FETCH_CONTENT_MAX_CHARS, 20000)); const r = await callWebTools('fetch', { url }); if (!r.ok) { return r.needKey ? { success: false, needsApiKey: true, needsWebToolsKey: true, error: r.error } : { success: false, error: r.error, url, rawError: r.rawError || null }; } const content = String((r.data && r.data.content) || ''); if (!content) { return { success: false, error: `抓取成功但未解析出正文内容(可能是纯前端渲染页或导航栏结构):${url}`, url }; } const clipped = content.length > maxChars; return { success: true, data: { url, fetchedAt: Date.now(), contentChars: content.length, content: clipped ? content.slice(0, maxChars) : content, ...(clipped ? { truncated: true, note: `正文被截断至 ${maxChars} 字符(原始 ${content.length} 字符)` } : {}), }, summary: `已抓取 ${url},正文 ${content.length} 字符${clipped ? `(返回前 ${maxChars} 字符)` : ''}`, }; }, }); // ============================================================ // 游戏热点模块(薄接入层) // ============================================================ // 全部实现都在独立库 libs/saa-game-news.lib.js(window.SAAGameNews): // 源注册表 / RSS+HTML 解析 / 中文优先排序 / 聚合缓存 / 浮窗 UI+CSS 都在库里。 // 主脚本只做三件事:解析库 → 注册一个薄工具 → 挂载入口按钮。 // 这样新增/调整资讯源不需要动 AI Agent 主逻辑,改库即可。 // // 库通过 @require 加载(发布到仓库后由 CDN 提供)。**库缺失时优雅降级**: // 不注册工具、不挂按钮、其余功能完全不受影响。 const GameNews = (typeof window !== 'undefined' && window.SAAGameNews) || null; if (GameNews) { GameNews.loadCustom((k, d) => GM_getValue(k, d)); // 注入模型侧抓取能力:库里 type='dsfetch' 的源(如小黑盒,站点是 SPA、 // 直连只能拿到 3.4KB 空壳)走 webFetch —— 它会执行 JS,能拿到渲染后的正文。 // 该源在库里单独走 24 小时缓存,实现「每天抓几条、不定期 fetch」。 GameNews.setFetcher(async (url, timeout) => { const r = await callWebTools('fetch', { url }); if (!r.ok) return { ok: false, error: r.error }; return { ok: true, text: String((r.data && r.data.content) || '') }; }); // v1.35.0 注入检索能力(浮窗顶部标题栏右侧的增强搜索框用): // webSearch → POST /v1/tools/search(联网检索:实时新闻 / 目标页面) // chat → callAiChat(模型 API)。**由库负责拼提示词**,并把 webSearch 的 // 检索结果当上下文喂进去 —— 这样模型是在"读"检索结果做总结, // 而不是凭记忆瞎编链接(库里还会再做一次 http(s) 白名单校验兜底)。 // // ⚠️ 按能力降级:宿主可能仍在跑旧版热点库(没有 setSearchProviders)—— // 先判能力再注入,别让「库版本不一致」把整个热点模块搞没。 if (typeof GameNews.setSearchProviders === 'function') { GameNews.setSearchProviders({ webSearch: async (query) => { const r = await callWebTools('search', { query }); if (!r.ok) return { ok: false, error: r.error }; const list = Array.isArray(r.data && r.data.results) ? r.data.results : []; return { ok: true, items: list.map((it) => ({ title: it.title || '', url: it.url || '', site: it.site_name || '', date: it.date || '', summary: String(it.summary || it.snippet || ''), })), }; }, chat: async (prompt) => { const r = await callAiChat(prompt, { systemInstruction: '你是游戏资讯检索助手。只输出一个 JSON 对象,不要解释文字,不要用代码围栏。', isolated: true, // 独立请求:不占用聊天通道,也不受「停止生成」影响 temperature: 0.3, // 检索总结要稳,别发散 maxTokens: 2000, // JSON + 摘要,防止被截断成半截 JSON timeout: 45000, }); return { ok: true, text: r.content || '' }; }, }); } else { console.warn('[SA-Agent] 游戏热点库为旧版(无 setSearchProviders),标题栏搜索框退化为「仅本地匹配」'); } ToolRegistry.register({ name: 'getGameNewsDigest', description: '聚合专业游戏资讯站的最新头条(机核 / Steam 官方 / 3DM / 游民星空),一次调用拿到多来源热点汇总,30 分钟内走本地缓存。' + '适合回答"最近有什么游戏新闻""业界有什么新动态""现在大家都在关注什么游戏"这类**跨来源热点汇总**问题——' + '比自己用 webSearch 反复检索更省额度、覆盖面更广。条目按中文优先排序,Steam 官方源为中英混合。' + '注意:这里只给标题与链接(部分源含摘要),要读某篇全文需再对具体 URL 调用 webFetch(多数站点抓取会失败)。', parameters: { type: 'object', properties: { sources: { type: 'string', description: '可选,逗号分隔的来源过滤:gcores(机核) / steam(Steam官方) / 3dm / gamersky(游民星空)。不填=全部' }, perSource: { type: 'number', description: '每个来源取几条,默认 8,最大 15' }, force: { type: 'boolean', description: '可选,跳过 30 分钟缓存强制重新抓取(默认 false)' }, chineseOnly: { type: 'boolean', description: '可选,只保留中文标题的条目(默认 false)。实测 Steam 官方源以英文为主、机核/3DM/游民星空 全中文,若用户只要中文可开启此参数' }, }, required: [], }, category: 'knowledge', async execute(params) { const sources = GameNews.sources; const perSource = Math.max(1, Math.min(Number(params.perSource) || 8, 15)); const chineseOnly = params.chineseOnly === true; const want = String(params.sources || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean); if (want.length) { const unknown = want.filter(w => !sources.some(s => s.id === w)); if (unknown.length) { return { success: false, error: `未知来源:${unknown.join(',')};可选:${sources.map(s => s.id).join(' / ')}` }; } } const bundle = await GameNews.getDigest({ force: !!params.force, perSource }); const results = []; const failed = []; for (const got of bundle.sources) { if (want.length && want.indexOf(got.id) < 0) continue; if (!got.items || !got.items.length) { failed.push(`${got.label}(${got.error || '无数据'})`); continue; } if (got.error) failed.push(`${got.label}(部分失败:${got.error})`); let items = GameNews.sortChineseFirst(got.items).slice(0, perSource); if (chineseOnly) items = items.filter(it => GameNews.isChinese(it.title)); // chineseOnly 过滤后可能为空,此时不静默丢弃——记为该源无中文条目 if (!items.length) continue; results.push({ source: got.id, label: got.label, items: items.map(it => ({ ...it, lang: GameNews.isChinese(it.title) ? 'zh' : 'other' })), chineseCount: items.filter(it => GameNews.isChinese(it.title)).length, }); } const total = results.reduce((n, r) => n + r.items.length, 0); if (!total) { return { success: false, error: `全部来源都没取到内容(${failed.join('、') || '未知原因'})。可能是网络受限或站点结构变更;可稍后重试或改用 webSearch。`, sources: failed, }; } const ageMin = Math.max(0, Math.round((Date.now() - bundle.at) / 60000)); return { success: true, data: { fetchedAt: bundle.at, cacheAgeMinutes: ageMin, fromCache: bundle.cached === true, total, chineseTotal: results.reduce((n, r) => n + r.chineseCount, 0), sources: results, ...(failed.length ? { partialFailures: failed } : {}), ...(chineseOnly ? { chineseOnly: true } : {}), note: chineseOnly ? '已过滤为仅中文条目;各站独立抓取,单站无中文不影响其他来源。' : '条目已按中文优先排序(lang 字段标注 zh/other);各站独立抓取,单站失败不影响其他来源。', }, summary: `聚合 ${results.length} 个来源共 ${total} 条游戏资讯头条` + (bundle.cached ? `(缓存于 ${ageMin} 分钟前)` : '') + (failed.length ? `;${failed.length} 个源部分失败` : ''), }; }, }); } else { console.warn('[SA-Agent] 未加载游戏热点库(libs/saa-game-news.lib.js),getGameNewsDigest 与热点入口已跳过'); } // ============================================================ // AI 搜索浮窗(薄接入层) // ============================================================ // 全部实现都在独立库 libs/saa-search.lib.js(window.SAASearch): // 关键字匹配 / 分组结果 / 浮窗 UI + CSS 都在库里。 // 主脚本只做三件事:解析库 → 注入数据源 provider → 挂载入口按钮。 // // Phase 1 试点两个源(不依赖 data-hub,库没装也能用): // ① 我的游戏库 → SGLV 的 IDB 缓存(sglv_cached_games),顺带 join HLTB 发行年/主线时长 // ② 新闻热点 → SAAGameNews.getDigest()(走热点库 30 分钟聚合缓存,不重复抓) const Search = (typeof window !== 'undefined' && window.SAASearch) || null; if (Search) { Search.setProviders({ getLibrary: async () => { if (!pcDataSourceAllowed('sglv')) { throw new Error('未授权读取「SGLV 游戏库展示」的数据(设置 → 数据源)'); } // v1.36.0:不再硬拦域 —— pcReadSglvLibrary 本域读不到时自动回退跨域 GM 镜像, // 社区域照样能搜游戏库(数据来自商店域快照,会带 note 声明新鲜度)。 const lib = await pcReadSglvLibrary(); if (!lib.ok) throw new Error(lib.error); // 顺带带上 HLTB 的发行年 / 主线时长(索引加载失败时静默留空,不阻断搜索) const hltbReady = await pcHltbLoad(); const games = lib.games.map((g) => { const appid = Number(g && g.appid); const h = hltbReady ? pcHltbGet(appid) : null; return { appid, name: (g && g.name) || `App ${appid}`, playtime_forever: (g && g.playtime_forever) || 0, year: h ? h.y : null, storyHours: h ? h.m : null, }; }); if (!lib.viaMirror) return games; // 镜像来源:交给库渲染成分组级提示(一条说明即可,不必每行都标) return { games, note: `跨域镜像 · 快照 ${pcHumanAge(lib.ageMs)}${lib.stale ? '(可能已过期)' : ''}` + ' —— 商店域缓存同步而来,社区页可直接搜索', }; }, getNews: async () => { if (!GameNews) throw new Error('未加载游戏热点库'); const bundle = await GameNews.getDigest({ perSource: 8 }); const out = []; for (const got of (bundle.sources || [])) { for (const it of (got.items || [])) { out.push({ title: it.title, url: it.url || it.link || '', source: got.label, date: it.pubDate || it.date || '', summary: it.summary || it.description || '', }); } } return out; }, }); // 「用 AI 深入分析」→ 把选中条目带进对话,模型一次成稿。 // ⚠️ 回调注册在模块级(而非 createSidebar 内):面板创建失败时搜索入口仍可闭环。 Search.setHandlers({ onAnalyze: (payload) => { const title = (payload && payload.title) || ''; const text = payload && payload.kind === 'library' ? `请分析我游戏库里的《${title}》(appid ${payload.appid}):主线剧情要多久、我玩了多少(剧情完成度)、还剩多少没玩、值不值得继续玩。` : `请深入分析这条资讯:《${title}》,说清讲了什么、对玩家意味着什么。`; if (Search.closeWindow) Search.closeWindow(); openSidebar({ message: text }); // 按钮语义就是「用 AI 深入分析」→ 直接发出去,不让用户再按一次回车 setTimeout(() => { try { handleSend(); } catch (e) { /* ignore */ } }, 60); }, }); } else { console.warn('[SA-Agent] 未加载搜索浮窗库(libs/saa-search.lib.js),搜索入口已跳过'); } 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) { calls.push(...parseToolPayload(match[1])); } // 兜底:整段响应就是一个裸 JSON(模型直接输出函数调用对象或数组) if (!calls.length) { const stripped = text.replace(/```(?:json)?/gi, '').trim(); if (/^[\[{][\s\S]*[\]}]$/.test(stripped)) { calls.push(...parseToolPayload(stripped)); } } 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 coerceToolCallObject(parsed) { 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 ?? parsed.function?.arguments ?? {}; if (typeof args === 'string') { const argsRepaired = repairToolCallJson(args); args = tryJson(argsRepaired) || tryJson(salvageTruncatedJson(argsRepaired)) || {}; } if (!args || typeof args !== 'object' || Array.isArray(args)) args = {}; return { id: parsed.id || '', name: name.trim(), arguments: args }; } function parseToolPayload(raw) { const repaired = repairToolCallJson(raw); const parsed = tryJson(repaired) || tryJson(salvageTruncatedJson(repaired)); if (!parsed || typeof parsed !== 'object') return []; const items = Array.isArray(parsed) ? parsed : [parsed]; const calls = []; for (const item of items) { const call = coerceToolCallObject(item); if (call) calls.push(call); } return calls; } function parseToolCallJson(raw) { return parseToolPayload(raw)[0] || null; } // 原生 tool_calls 优先;没有时再从正文里的文本协议解析。同一轮只走一条路径,避免执行两遍。 function mergeToolCalls(nativeCalls, text) { const native = normalizeNativeToolCalls(nativeCalls); if (native.length) return native; return parseToolCalls(text); } function normalizeNativeToolCalls(list) { if (!Array.isArray(list)) return []; const out = []; for (const tc of list) { const call = coerceToolCallObject(tc && tc.function ? { id: tc.id, ...tc, name: tc.function.name, arguments: tc.function.arguments } : tc); if (call) out.push(call); } return out; } function isNativeToolsRejected(err) { const msg = String(err?.message || ''); if (!/HTTP 4\d\d/.test(msg)) return false; return /tool_choice|\btools\b|function[_\s-]?call/i.test(msg); } // 流式正文还像工具调用时先不画到气泡里,避免把 JSON 闪给用户。 function shouldHoldStream(text) { const s = String(text || '').trim(); if (!s) return false; if (/[\s\S]*?<\/think>/gi, '').trim(); const head = noThink.slice(0, 80); if (/^ setTimeout(r, 12)); } return clean; } 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' }, getSpendingHistory: { label: '消费历史', icon: 'stats' }, getSteamLoginStatus: { label: '登录状态', icon: 'user' }, readPluginCache: { label: '插件缓存', icon: 'cpu' }, getInstalledPlugins: { label: '插件清单', icon: 'gear' }, getStoryData: { label: '剧情时长', icon: 'gamepad' }, searchLocalDb: { label: '本地数据库', icon: 'search' }, readSkill: { label: '操作技能', icon: 'book' }, localDbProfile: { label: '库画像', icon: 'stats' }, pushInsight: { label: '结论回灌', icon: 'zap' }, pushObservation: { label: '时长上报', icon: 'zap' }, webSearch: { label: '网页检索', icon: 'search' }, webFetch: { label: '网页抓取', icon: 'globe' }, getGameNewsDigest: { label: '游戏热点', icon: 'news' }, }; 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 formatToolDuration(ms) { const n = Number(ms); if (!Number.isFinite(n) || n < 0) return ''; return n >= 1000 ? (n / 1000).toFixed(1) + 's' : Math.round(n) + 'ms'; } // 展开区内容:完整参数 + 结果(超长截断到 4000 字符,仅展示层,不影响回灌模型的数据) function formatToolChipDetail(args, result) { const lines = []; try { lines.push('参数: ' + JSON.stringify(args ?? {}, null, 2)); } catch { lines.push('参数: (无法序列化)'); } if (result) { let payload; if (result.error && result.success === false) payload = { success: false, error: result.error }; else payload = result.data !== undefined ? result.data : result; let text; try { text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2); } catch { text = '(无法序列化)'; } if (text.length > 4000) text = text.slice(0, 4000) + `\n…(展示已截断,原始长度 ${text.length} 字符)`; lines.push('结果: ' + text); } return lines.join('\n'); } function renderToolChip(name, args, running, failed, meta = {}) { const d = getToolDisplay(name); const argText = formatToolArgs(args); const state = running ? '' : failed ? getIcon('close', 11) : getIcon('check', 11); const head = `${getIcon(d.icon, 12)}` + `${escapeHtml(d.label)}` + (argText ? `${escapeHtml(argText)}` : '') + (!running && meta.durationMs != null ? `${formatToolDuration(meta.durationMs)}` : '') + `${state}` + (running ? '' : '▸'); // 运行中用普通 div(避免 details 折叠动画干扰 spinner);完成后升级为可折叠 details 卡片 if (running) { return `${head}`; } return `` + `${head}` + `${escapeHtml(formatToolChipDetail(args, meta.result))}` + ``; } function buildToolCallsHtml(toolCalls, runningIndex = -1) { if (!toolCalls || !toolCalls.length) return ''; const needsKey = toolCalls.some(tc => tc && tc.result && tc.result.needsApiKey); const keyAction = needsKey ? `${getIcon('gear', 13)} 配置 Steam API Keysteamcommunity.com/dev 免费申请 · 配置后回复「已配置」继续` : ''; const needsItad = toolCalls.some(tc => tc && tc.result && tc.result.needsItadKey); const itadAction = needsItad ? `${getIcon('globe', 13)} 配置 ITAD API Keyisthereanydeal.com/apps 免费申请 · 配置后跨区比价升级为增强模式` : ''; return `${toolCalls.map((tc, i) => renderToolChip(tc.name, tc.arguments, tc.running === true || i === runningIndex, !!(tc.result && tc.result.success === false), { durationMs: tc.durationMs, result: tc.result, }) ).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 raw = String(m.content || ''); const toolCount = (raw.match(//g) || []).length; const isToolResult = m.role === 'system' && toolCount > 0; // 并行工具合成一条消息,按条数放宽上限,避免中间结果被腰斩 const limit = !isToolResult ? CONTEXT_MSG_MAX_CHARS : Math.min(CONTEXT_TOOL_RESULT_MAX_CHARS * Math.max(toolCount, 1), 8000); return { role: m.role, content: truncateMiddle(raw, 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 pageText(doc, selector) { try { const el = doc && doc.querySelector ? doc.querySelector(selector) : null; return el ? String(el.textContent || '').trim() : ''; } catch { return ''; } } function pageMeta(doc, selector) { try { const el = doc && doc.querySelector ? doc.querySelector(selector) : null; if (!el) return ''; return String((el.getAttribute && el.getAttribute('content')) || el.content || '').trim(); } catch { return ''; } } // 从当前 URL / DOM 读取用户正在看的游戏或个人资料。loc、doc 可注入,便于测试。 function readPageContext(loc, doc) { const locationRef = loc || (typeof location !== 'undefined' ? location : { href: '', pathname: '' }); const documentRef = doc || (typeof document !== 'undefined' ? document : null); const href = String(locationRef.href || ''); const path = String(locationRef.pathname || ''); const ctx = { kind: 'unknown', url: href.split('?')[0], title: '' }; try { ctx.title = String((documentRef && documentRef.title) || '').trim(); } catch { ctx.title = ''; } const appMatch = href.match(/(?:store\.steampowered\.com|steamcommunity\.com)\/app\/(\d+)/i); const profileIdMatch = href.match(/steamcommunity\.com\/profiles\/(\d{17})/i); const vanityMatch = href.match(/steamcommunity\.com\/id\/([^/?#]+)/i); const onFriends = /\/friends(?:\/|$)/i.test(path); if (appMatch) { ctx.kind = 'store_app'; ctx.appid = appMatch[1]; const domName = pageText(documentRef, '.apphub_AppName') || pageText(documentRef, '#appHubAppName'); const og = pageMeta(documentRef, 'meta[property="og:title"]').replace(/\s+on Steam$/i, ''); const slug = (href.match(/\/app\/\d+\/([^/?#]+)/i) || [])[1]; ctx.name = domName || og || (slug ? decodeURIComponent(slug).replace(/_/g, ' ') : '') || ctx.title.replace(/\s+on Steam$/i, ''); } else if (profileIdMatch || vanityMatch) { ctx.kind = onFriends ? 'friends' : 'profile'; try { ctx.steamId = profileIdMatch ? profileIdMatch[1] : decodeURIComponent(vanityMatch[1]); } catch { ctx.steamId = profileIdMatch ? profileIdMatch[1] : vanityMatch[1]; } ctx.vanity = !profileIdMatch; ctx.name = pageText(documentRef, '.actual_persona_name') || pageText(documentRef, '.persona_name') || ctx.title; } else if (onFriends) { ctx.kind = 'friends'; } if (ctx.kind === 'friends' || onFriends) { ctx.onFriends = true; const viewer = resolveSteamId(''); if (viewer.steamId) { ctx.viewerSteamId = viewer.steamId; ctx.viewerSource = viewer.source; } } return ctx; } function formatPageContext(ctx) { if (!ctx || ctx.kind === 'unknown') return ''; const lines = ['## 当前页面', '用户正在浏览 Steam 页面。指代「这个游戏」「这款」「当前页」「这个人」「我的好友」时,直接使用下面的标识,不要再索要 AppID 或 SteamID。']; if (ctx.kind === 'store_app') lines.push(ctx.url.includes('steamcommunity.com') ? '- 类型:社区游戏页' : '- 类型:商店游戏页'); else if (ctx.kind === 'friends') lines.push('- 类型:好友列表页'); else if (ctx.kind === 'profile') lines.push('- 类型:个人资料页'); if (ctx.appid) lines.push(`- AppID:${ctx.appid}`); if (ctx.name) lines.push(`- 名称:${ctx.name}`); if (ctx.steamId) lines.push(`- 页面用户:${ctx.steamId}${ctx.vanity ? '(自定义 ID)' : ''}`); if (ctx.viewerSteamId) lines.push(`- 当前登录用户 SteamID64:${ctx.viewerSteamId}(来源:${ctx.viewerSource || '自动检测'})`); if (ctx.url) lines.push(`- URL:${ctx.url.slice(0, 180)}`); return lines.join('\n'); } 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 ? '' : '(完整历史,无裁剪)'}`); const pageBlock = formatPageContext(readPageContext()); let prompt = `(提示:你的角色设定已在系统指令中给出,请严格遵循。) ## 可用工具 ${toolsDesc || '(无可用工具)'} ## 对话历史 ${historyText || '(无历史记录)'} ${toolHistory ? '## 工具调用结果\n' + toolHistory + '\n' : ''}${contextNotes.length ? `## 上下文说明\n${contextNotes.map(t => '- ' + t).join('\n')}\n\n` : ''}${pageBlock ? pageBlock + '\n\n' : ''}## 当前任务 用户消息: ${userMessage} ## 执行指令 你正在执行第 ${currentIter}/${maxIter} 次迭代。${currentIter >= maxIter ? '\n\n**注意:这是最后一轮迭代,已无剩余工具调用机会。禁止再调用任何工具,必须基于上方已获取的工具结果直接输出最终中文回答;数据不足的部分如实说明即可。**' : ''} 在回答之前,请先思考是否需要使用工具: - 如果需要调用工具,必须严格使用以下格式(一段完整合法的 JSON,必须以 闭合): {"name":"工具名称","arguments":{"字符串参数":"值","数值参数":123}} - 如果不需要工具,直接回答用户问题 工具调用格式要求: 1. JSON 必须合法:键与字符串值一律用双引号,无注释、无尾逗号,不要用 markdown 代码围栏(\`\`\`)包裹 2. 参数对象的键名固定为 "arguments",不要写成 parameters/args/input 3. 一次可调用多个互不依赖的只读工具,单轮最多 ${CONFIG.MAX_PARALLEL_TOOLS} 个。优先使用接口提供的原生工具调用;若只能输出文本,可连续输出多段 {"name":"工具名称","arguments":{"参数名":"值"}},或在一个标签里输出 JSON 数组。决定调用工具时不要同时写最终回答 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 格式 15. 「当前页面」里已有 AppID 或 SteamID 时,用户用「这个游戏 / 这款 / 当前页 / 这个人 / 我的好友」指代,必须直接使用这些标识调用工具,不要再向用户索要 16. 涉及"我的"本地数据(我的游戏库/我的游玩时长/我的好友在玩什么/家庭共享库有哪些游戏/家庭愿望单/某游戏我是怎么获得的/市场价格快照)时,优先调用 readPluginCache 读本机插件缓存(毫秒级、不消耗 Steam API 配额),缓存未命中再用网络工具。该工具返回 cacheAgeMinutes,超过 24 小时(stale=true)时必须在回答中声明"数据来自本地缓存,可能已过期"。若返回 needDomain,提示用户切换到对应页面(商店页 store / 社区页 community)后重试 17. 需要**站外或实时信息**(最新新闻/版本动态/当前售价/发售时间/某事件进展)时,用 webSearch 检索。注意三点:①**优先用 webSearch,不要先试 webFetch**——实测抓取接口成功率很低(Wikipedia/BBC/HackerNews/Reuters/Steam 商店与社区均被拒绝),webFetch 只在确实需要某页全文且该站大概率可抓时使用,且失败一次就不要再重试同一 URL;②Steam 站内的私有数据(好友/家庭库/个人游戏库/许可来源)不要用这两个工具,必须走 Steam 专用工具或 readPluginCache;③引用检索结果时保留来源站点与日期 18. webSearch/webFetch 返回 needsWebToolsKey 或鉴权失败时,引导用户在设置「接口服务 → 联网检索工具」配置 Key(或说明「AI 设置」里配 DeepSeek 模型可自动复用),不要重复调用同一工具 19. 问「最近有什么游戏新闻/业界热点/大家在玩什么新游戏」这类**跨来源游戏热点汇总**时,优先调用 getGameNewsDigest(一次拿机核/Steam/3DM/游民星空四站头条,30 分钟缓存),比反复 webSearch 更省额度、覆盖更全;若结果带 fromCache=true,直接基于它作答,不要用相近措辞重复检索 20. 涉及「消费/花了多少钱/充值/退款/市场买卖流水/月度消费趋势」类问题时,**直接调用 getSpendingHistory**(v1.34.0 起它内部已按「本机 GM 缓存 → 宿主消费桥镜像 → 消费历史页自抓」三级取数,桥的聚合/明细都会被采纳并标注 source;不要再先单独调 readPluginCache 判空——桥的配额降级空明细会把你误导成「拿不到数据」)。回答时读 data.source 与 data.coverage:source=bridge/cache 要声明数据来自本地缓存/插件镜像;partial 或 degraded=true 要说明只覆盖页面已加载部分、可能低于真实总额;金额必须带币种并说明「净支出=直购+内购+送礼−退款(仅主货币行)」。仅当返回 needLogin=true 且 loginState.loggedIn===false 时才提示用户登录;loginState.loggedIn 为 null 时如实说「无法判定」并建议稍后重试,绝不能让用户白白重新登录一次 21. 问「我的 steam 油猴插件装齐了吗」「steamkit 系列装了哪些」「XX 插件版本过旧吗」「装了哪些游戏库增强」类问题时,调用 getInstalledPlugins(本地只读,不联网查商店)。它合并三个来源:①版本契约 data/plugin-contract.json(期望清单 + 最低版本)②各插件经共享宿主 registerPlugin 注册的真实版本号 ③本地存储探测(识别尚未接入注册的老插件)。回答要点:装了几个/缺几个/哪些版本过旧,并说明「版本号」来自插件自身注册(精确)还是仅探测到存在(版本未知)。若工具返回「版本契约不可用」,提示用户检查网络后重试 22. 问「XX 要玩多久才通关」「我到底通没通关 XX」「主线剧情多长」「我买的这些游戏是独立小作还是大作」「我的库里哪些游戏买了没玩」类问题时,调用 getStoryData。它从 HLTB 索引取主线时长与发行年份,并结合我的游玩时长算剧情完成度 SCI(分档:序章搁浅<0.35 / 剧情未竟0.35-0.8 / 剧情通关0.8-1.3 / 支线探索家1.3-2.5 / 自由漫游>2.5)。不传 appids 时走「我的游戏库」模式(需在 Steam 商店页打开过游戏库展示插件)。⚠️ HLTB 只能覆盖能对上的部分(实测 54.6%),「查不到」= 无数据,不是 0 小时,回答时必须说明缺失比例,不要把没命中的说成时长为 0 23. 需要**跨维度筛选**(如「剧情超 20 小时」「2015 年后」「价格低于 XX」「买了没玩过」)时优先调用 searchLocalDb(远程 data-hub 个人数据资产库,SQLite + FTS5)。readPluginCache 只能整读一个缓存键、做不了筛选,所以这类问题别用 readPluginCache 硬凑。若工具返回「未配置地址」或「无法连接」,说明远程数据中枢没配地址/服务不可达(或还没绑定 Steam 账号),改用 readPluginCache + getOwnedGames,并把筛选条件说清楚 24. 涉及「我的库里有哪些 X」这类**关键词检索**时,可以引导用户用面板头部的「搜索」浮窗(同时搜我的游戏库 + 新闻热点),点「用 AI 深入分析」即可把选中条目带进对话 25. 处理**复杂多步任务**(需要跨表筛选 + 交叉验证 + 出报告/图表)时,**先调 readSkill** 读操作技能库,拿到意图路由与口径纪律后再按路由调工具。技能给的是「遇到这类问题该怎么用工具」的**操作指引与口径**,不给事实——事实仍要靠工具查。若 readSkill 返回「正文暂时取不到」,就按常规方式用 searchLocalDb 查即可 26. 问「我的游戏库是什么画像」「我是不是囤货党」「我买的东西值不值」「给我一份库存盘点报告」这类**画像类**问题时,优先调 localDbProfile(走 data-hub 的 /profile 与 /report),不要自己用 searchLocalDb 拼凑画像:它已按统一口径算好未开封率、时长阶梯、清灰倒计时、被冷落的神作、最亏/最值、每小时价值、人格称号+稀有度。⚠️ 必看 data.library.durationEvidence:为 "missing" 说明库里没有游玩时长数据,正确答案是「算不了」而不是「你 100% 没玩过」;价格类指标是**当前折后价**不是实付价;好评率只覆盖部分游戏,榜单要带覆盖率。format=html 可直接拿到带内联 SVG 图表的报告正文 27. 分析出**有依据、可复用**的结论后(如「某厂商集中度偏高」「某系列还差 3 款」「这款每小时成本极低」),用 pushInsight 把结论回灌到数据中枢,落成可追溯的库资产。只报结论、不报过程性废话;同一份内容重复上报是幂等的,但换措辞会被当成新结论,所以同一结论的措辞尽量保持稳定。⚠️ **测试性/一次性的快问快答不上报**(如用户说「测试一下」「帮我随便查个数据」)——测试流量不写库;纯查询汇总(消费记录/好友名单/时长榜单)本身也不是「可复用结论」,不要为报而报 28. 用户说「把时长/指标也存下来」「之后按周回看我的游玩变化」「导一份我的时长明细进库」这类**要沉淀数据本身**(而不只是结论)时,用 pushObservation —— 它从 Steam 官方 API 取当前最权威的游玩时长,连库里缺的近两周时长也能补上。默认 scope=partial(只落分析产物区,安全);只有用户明确说「全量/整份覆盖」时才用 scope=full,而且服务端会校验「条目数不得少于库内已有快照」,数据不全时会自动降级并说明原因 —— 这时要把降级原因如实告诉用户,别谎称已更新 29. **身份与降级必须说实话**(v1.29.0):localDbProfile / pushInsight / pushObservation 会自动解析「当前是谁」,优先级是「用户显式给的 steamid > 设置里配置的 SteamID > 浏览器登录态检测 > 库里的主账号回退」。前三种是确知身份;最后一种是**系统兜底猜的**,工具返回里会带 identitySource 与 identityNote —— identitySource=library-primary(或 identity=unidentified)时,**必须把「未能确认当前登录用户,数据按库主账号 X 统计」如实告诉用户**,绝不能默认这就是用户本人。库里有多个 Steam 账号时,searchLocalDb 返回的 library.accountCount > 1,说明 scope=all / scope=mine 都是**全库口径**(mine=库内任何人拥有),不是某个人的;用户问「我的」而库里有多账号时,可提示确认是哪个账号,或用 localDbProfile 按账号查 30. 用户问「我登录 Steam 了吗 / 为什么说我没登录 / 要不要重新登录」时,先调 getSteamLoginStatus(只读、60 秒缓存)。loggedIn=true → 明确告知已登录,问题在取数链路,不要让用户重新登录;false → 说明依据是「消费历史页被重定向到登录页」,再引导登录;null → 只能答「无法判定」,给出下一步(稍后重试 / 手动打开消费历史页)。⚠️ 不要用「读不到 steamLoginSecure Cookie / 拿不到 SteamID」推断未登录——该 Cookie 是 HttpOnly,JS 读不到属正常`; const issues = verifyContextIntegrity(prompt, userMessage, ctx); if (issues.length) { console.warn('[SA-Agent] 上下文完整性验证未通过,降级为仅当前消息:', issues); prompt = prompt.replace(/## 对话历史[\s\S]*?(?=## 工具调用结果|## 上下文说明|## 当前任务)/, `## 对话历史 (上下文异常,已重置,请基于当前消息回答)`); const finalCheck = prompt.includes(userMessage); if (!finalCheck) { prompt = `${prompt}\n(系统注:用户消息即为本轮唯一上下文)`; } } return prompt; } function setToolStatus(text, active) { const toolStatusEl = document.querySelector('.sa-tool-status'); if (!toolStatusEl) return; const label = toolStatusEl.querySelector('.sa-tool-text'); if (label && text) label.textContent = text; toolStatusEl.classList.toggle('sa-active', !!active); } function abortedLoopResult(currentHistory, iterations, toolCallLog) { const lastAi = [...currentHistory].reverse().find(m => m.role === 'assistant'); return { response: lastAi ? cleanToolCallMarkers(stripThinkTags(lastAi.content)) : '', completed: false, aborted: true, iterations, toolCalls: toolCallLog, }; } // 对话循环: // 1. 还没跑过工具时用 0.2,并带上原生 tools;供应商拒绝 tools 时本轮对话降回文本协议 // 2. 一轮里的只读工具并行执行,结果合成一条 tool_result 再交给模型 // 3. 已经拿到工具结果后的轮次用 0.7,正文经流式门控输出,工具 JSON 不进气泡 async function runAgentLoop(userMessage, history, agentId, onChunk, onToolCall) { const agent = AgentManager.getAgent(agentId); if (!agent) return { response: '未找到智能体', completed: false, iterations: 0 }; // 智能体工具子集授权:null = 全部;提示词描述与原生 tools 同步过滤, // 执行期二次校验(防模型幻觉调用未授权工具) const allowedTools = (Array.isArray(agent.tools) && agent.tools.length) ? agent.tools : null; const toolsDesc = ToolRegistry.getToolsDescription(allowedTools); const openaiTools = ToolRegistry.toOpenAITools(allowedTools); const systemInstruction = buildSystemInstruction(agent); const parsedSettings = parseStored(GM_getValue(SK.SETTINGS, null), {}); const maxIter = parsedSettings.maxIterations || CONFIG.MAX_ITERATIONS; let currentHistory = [...history]; const toolCallLog = []; let usedTools = false; let nativeTools = openaiTools.length > 0; let repairTemp = false; const gate = createStreamGate(onChunk); for (let i = 0; i < maxIter; i++) { if (abortFlag) return abortedLoopResult(currentHistory, i, toolCallLog); const temperature = (usedTools && !repairTemp) ? CONFIG.ANSWER_TEMPERATURE : CONFIG.TOOL_TEMPERATURE; repairTemp = false; const prompt = buildReActPrompt(userMessage, currentHistory, toolsDesc, i + 1, maxIter); setToolStatus(usedTools ? '正在整理回答…' : '正在思考…', true); let turn; const lastRound = i + 1 >= maxIter; const chatOpts = () => ({ systemInstruction, history: [], temperature, // 最后一轮不再提供工具:强制模型基于已获取的结果收尾,避免迭代耗尽时浪费全部抓取数据 tools: (nativeTools && !lastRound) ? openaiTools : undefined, onChunk: (full) => gate.push(full), }); try { turn = await callAiChat(prompt, chatOpts()); } catch (e) { if (nativeTools && isNativeToolsRejected(e) && !abortFlag) { nativeTools = false; console.warn('[SA-Agent] 当前模型不接受原生 tools,本轮对话改用文本协议'); try { turn = await callAiChat(prompt, chatOpts()); } catch (e2) { if (abortFlag) return abortedLoopResult(currentHistory, i + 1, toolCallLog); setToolStatus('', false); return { response: `AI 调用失败: ${e2.message}`, completed: false, iterations: i + 1, error: e2.message, toolCalls: toolCallLog }; } } else if (nativeTools && e.isReasoningOnly && !abortFlag) { // 推理模型怪癖(DeepSeek v4-pro 实测):思考中明确要调用工具,却不产出 tool_calls 即停止。 // 降级为文本协议(不带 tools 参数,靠 ReAct 提示词里的 纯文本格式)绕开原生调用路径 nativeTools = false; repairTemp = true; console.warn('[SA-Agent] 推理模型仅思考未产出工具调用,本轮降级文本协议重试'); try { turn = await callAiChat(prompt, chatOpts()); } catch (e2) { if (abortFlag) return abortedLoopResult(currentHistory, i + 1, toolCallLog); setToolStatus('', false); return { response: `AI 调用失败: ${e2.message}`, completed: false, iterations: i + 1, error: e2.message, toolCalls: toolCallLog }; } } else if (abortFlag) { return abortedLoopResult(currentHistory, i + 1, toolCallLog); } else { setToolStatus('', false); return { response: `AI 调用失败: ${e.message}`, completed: false, iterations: i + 1, error: e.message, toolCalls: toolCallLog }; } } if (abortFlag) { const partial = cleanToolCallMarkers(stripThinkTags(turn?.content || '')); return { response: partial, completed: false, aborted: true, iterations: i + 1, toolCalls: toolCallLog }; } const parsedCalls = mergeToolCalls(turn.toolCalls, turn.content); const calls = parsedCalls.slice(0, CONFIG.MAX_PARALLEL_TOOLS); const assistantVisible = cleanToolCallMarkers(stripThinkTags(turn.content || '')); currentHistory.push({ id: `ai_${Date.now()}_${i}`, role: 'assistant', content: (calls.length && !lastRound) ? (assistantVisible || `(调用工具:${calls.map(c => c.name).join('、')})`) : (turn.content || ''), timestamp: Date.now(), }); // 最后一轮即使模型仍尝试调用工具,也不再执行:强制基于已获取的工具结果收尾 if (!calls.length || lastRound) { const raw = String(turn.content || ''); const attemptedToolCall = /${JSON.stringify({ success: false, formatError: true, error: '上一次输出像是工具调用,但没有解析出合法的 name 与 arguments。请改用原生工具调用,或输出 {"name":"工具名称","arguments":{"参数名":"值"}}。多个工具可输出多段标签,或在一个标签里输出 JSON 数组。若已无需调用工具,请直接给出最终回答', })}`, timestamp: Date.now(), }); console.warn('[SA-Agent] 工具调用格式解析失败,已回灌纠错提示:', raw.slice(0, 200)); continue; } let finalResponse = assistantVisible.trim(); if (!finalResponse) { finalResponse = lastRound ? `抱歉,本次任务在 ${maxIter} 轮迭代内未能完成完整分析(已展示获取到的数据)。请尝试缩小问题范围后重试。` : '(本轮未能生成有效回复:工具调用格式异常,请重试或换个问法)'; } setToolStatus('', false); await revealAnswer(finalResponse, onChunk, !!turn.streamed && !shouldHoldStream(raw)); return { response: finalResponse, completed: true, iterations: i + 1, toolCalls: toolCallLog }; } gate.clear(); if (onChunk) onChunk(''); const batch = calls.map(c => ({ name: c.name, arguments: c.arguments || {}, running: true })); batch.forEach((tc, idx) => { if (onToolCall) onToolCall(tc.name, tc.arguments, 'start', idx); }); const labels = batch.map(tc => getToolDisplay(tc.name).label).join('、'); setToolStatus(batch.length > 1 ? `正在并行调用 ${batch.length} 个工具 · ${labels}…` : `正在调用智能体工具 · ${labels}…`, true); await mapLimit(batch, CONFIG.MAX_PARALLEL_TOOLS, async (tc, idx) => { const t0 = Date.now(); if (abortFlag) { tc.result = { success: false, error: '已停止' }; } else if (allowedTools && !allowedTools.includes(tc.name)) { tc.result = { success: false, error: `工具 "${tc.name}" 未授权给当前智能体,请改用该智能体可用工具完成任务` }; } else { tc.result = await ToolRegistry.execute(tc.name, tc.arguments); } tc.durationMs = Date.now() - t0; tc.running = false; if (onToolCall) onToolCall(tc.name, tc.arguments, 'done', idx, tc.result); }); setToolStatus('', false); if (abortFlag) return abortedLoopResult(currentHistory, i + 1, toolCallLog); batch.forEach(tc => toolCallLog.push({ name: tc.name, arguments: tc.arguments, result: tc.result, durationMs: tc.durationMs })); usedTools = true; const dropped = parsedCalls.length - calls.length; currentHistory.push({ id: `tool_${Date.now()}_${i}`, role: 'system', content: batch.map(tc => `${JSON.stringify({ name: tc.name, arguments: tc.arguments, ...(tc.result || {}), })}`).join('\n') + (dropped > 0 ? `\n${JSON.stringify({ success: false, error: `本轮最多执行 ${CONFIG.MAX_PARALLEL_TOOLS} 个工具,已忽略其余 ${dropped} 个` })}` : ''), timestamp: Date.now(), }); } setToolStatus('', false); return { response: '抱歉,处理该问题需要更多步骤,请尝试缩小问题范围。', completed: false, iterations: maxIter, toolCalls: toolCallLog }; } // ============================================================ // 对话管理器 (多对话历史支持) // ============================================================ const ConversationManager = { _conversations: {}, _currentConvId: null, _saveTimer: 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.saveNow(); } 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.saveNow(); }, 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.saveNow(); }, updateCurrentModel(modelId) { const conv = this.getCurrentConversation(); conv.modelId = modelId; this.save(); }, // 防抖保存:addMessage 等高频操作只标脏,800ms 尾沿统一落盘; // beforeunload/hidden 时 flushSave 兜底冲刷 save() { if (this._saveTimer) clearTimeout(this._saveTimer); this._saveTimer = setTimeout(() => { this._saveTimer = null; this.saveNow(); }, CONFIG.SAVE_DEBOUNCE_MS); }, // 容量治理:每会话消息保尾部上限,会话总数按 updatedAt 保最近 N 个(当前会话永不删除) prune() { const maxMsgs = CONFIG.MAX_MESSAGES_PER_CONV; for (const conv of Object.values(this._conversations)) { if (Array.isArray(conv.messages) && conv.messages.length > maxMsgs) { conv.messages = conv.messages.slice(-maxMsgs); } } const ids = Object.keys(this._conversations); if (ids.length > CONFIG.MAX_CONVERSATIONS) { const sorted = ids .map(id => this._conversations[id]) .sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0)); const keep = new Set(sorted.slice(0, CONFIG.MAX_CONVERSATIONS).map(c => c.id)); if (this._currentConvId) keep.add(this._currentConvId); for (const id of ids) if (!keep.has(id)) delete this._conversations[id]; } }, saveNow() { if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; } try { this.prune(); GM_setValue(SK.CONVERSATIONS_V2, JSON.stringify(this._conversations)); } catch (e) { console.error('[SA-Agent] 对话保存失败:', e); toast('对话保存失败,可能超出存储配额,请导出并清理旧对话', 'error'); } }, flushSave() { this.saveNow(); }, 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('chevronDown', 14)} ${getIcon('sparkle', 13)} 正在思考… ${getIcon('send', 16)} `; 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 agentDropdownEl = panelEl.querySelector('#sa-agent-dropdown'); const agentTriggerEl = panelEl.querySelector('#sa-agent-trigger'); const agentTriggerIconEl = panelEl.querySelector('#sa-agent-trigger-icon'); const agentTriggerNameEl = panelEl.querySelector('#sa-agent-trigger-name'); const agentMenuEl = panelEl.querySelector('#sa-agent-menu'); function renderAgentMenu() { const agents = AgentManager.getAgents(); const currentId = AgentManager._currentAgentId; const itemHtml = (a) => ` ${agentIconHtml(a, 16)} ${escapeHtml(a.name)} ${a.description ? `${escapeHtml(a.description)}` : ''} ${getIcon('check', 14)} `; const builtinItems = agents.filter(a => a.builtin).map(itemHtml).join(''); const customItems = agents.filter(a => !a.builtin).map(itemHtml).join(''); agentMenuEl.innerHTML = ` ${builtinItems ? `内置智能体${builtinItems}` : ''} ${customItems ? `自定义智能体${customItems}` : ''} `; } function setAgentMenuOpen(open) { const isOpen = agentMenuEl.classList.toggle('sa-show', open); agentTriggerEl.classList.toggle('sa-open', open); agentTriggerEl.setAttribute('aria-expanded', String(isOpen)); if (open) renderAgentMenu(); } function refreshAgentSelect() { const agent = AgentManager.getCurrentAgent(); agentTriggerIconEl.innerHTML = agentIconHtml(agent, 15); agentTriggerNameEl.textContent = agent ? agent.name : '选择智能体'; if (agentMenuEl.classList.contains('sa-show')) renderAgentMenu(); } refreshAgentSelect(); agentTriggerEl.addEventListener('click', (e) => { e.stopPropagation(); setAgentMenuOpen(!agentMenuEl.classList.contains('sa-show')); }); agentMenuEl.addEventListener('click', (e) => { e.stopPropagation(); if (e.target.closest('#sa-agent-manage')) { setAgentMenuOpen(false); openSettings({ tab: 'agents' }); return; } const item = e.target.closest('.sa-agent-item'); if (!item) return; const id = item.dataset.id; if (id && id !== AgentManager._currentAgentId) { AgentManager.switchAgent(id); ConversationManager.getCurrentConversation().agentId = id; ConversationManager.save(); renderMessages(); } setAgentMenuOpen(false); refreshAgentSelect(); }); document.addEventListener('click', (e) => { if (!agentDropdownEl.contains(e.target)) setAgentMenuOpen(false); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && agentMenuEl.classList.contains('sa-show')) setAgentMenuOpen(false); }); // 热点入口(面板头部右上角按钮组):仅库加载成功时显示 const newsBtnEl = panelEl.querySelector('#sa-btn-news'); if (GameNews) { newsBtnEl.style.display = ''; newsBtnEl.title = '游戏热点 · ' + GameNews.sources.map(s => s.label).join('/'); newsBtnEl.addEventListener('click', () => GameNews.openWindow()); } else { newsBtnEl.remove(); } // 搜索入口(热点按钮左侧):仅库加载成功时显示 const searchBtnEl = panelEl.querySelector('#sa-btn-search'); if (Search) { searchBtnEl.style.display = ''; searchBtnEl.title = '搜索 · 我的游戏库 / 新闻热点'; searchBtnEl.addEventListener('click', () => Search.openWindow()); } else { searchBtnEl.remove(); } 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-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); // v1.13.0 P0-1:滚动时更新脱水消息的高度记忆(rAF 节流,避免滚动中反复强制布局) if (messagesEl) { let hydrateRaf = false; messagesEl.addEventListener('scroll', () => { if (hydrateRaf) return; hydrateRaf = true; requestAnimationFrame(() => { hydrateRaf = false; hydrateMessagesInView(); }); }, { passive: true }); } 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 || '自定义'; const name = String(m.name || '').trim(); const model = String(m.model || '').trim(); const label = (name && name !== model && !name.includes(model)) ? `${name} · ${model}` : `${providerName} · ${model || name}`; return `${escapeHtml(label)}`; }).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 = ` ${getIcon('history', 15)} 对话历史 ${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 脚本装齐了吗?有没有版本过旧的?', '用本地插件缓存看看我游戏库里玩得最多的几款', '帮我分析一下 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 || '')} ${getIcon('lightbulb', 14)} 建议问题 ${getIcon('refresh', 12)} 换一批 ${questions.map(q => ` ${escapeHtml(q)} ${getIcon('send', 13)} `).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)} ${getIcon('send', 13)} `).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 = []; // v1.13.0 P0-1:长会话渲染治理 —— 屏外历史消息降级渲染。 // // 背景:MAX_MESSAGES_PER_CONV=200 时 renderMessages() 单次 innerHTML 可达 2MB+, // 浏览器要 parse 整棵子树并对每条消息做布局,长列表滚动/切换会话明显变卡。 // 方案:给靠前的历史消息加 content-visibility:auto,屏外跳过布局与绘制。 // - 用 auto 而非 hidden:hidden 会让内容无法被 Ctrl+F 搜到,auto 保留可搜索性。 // - 高度记忆:脱水前先量一次真实高度写进 --sa-h,配合 contain-intrinsic-size // 让滚动条长度稳定、不因脱水而跳变;已脱水的元素绝不重复测量(否则量到的 // 是 intrinsic 兜底值,会把高度越改越小)。 // - 读写分离:先批量读高度,再批量写属性,避免 read/write 交替触发强制同步布局。 function clearMessageDehydration(el) { if (!el || el.dataset.dehydrated !== '1') return; el.dataset.dehydrated = '0'; // 故意保留 --sa-h 记忆值:下次再脱水时可直接复用,省一次测量 } function applyMessageDehydration() { if (!messagesEl) return; const nodes = messagesEl.querySelectorAll('.sa-msg'); const total = nodes.length; if (total < CONFIG.DEHYDRATE_MIN_MESSAGES) { // 短会话不启用:省不下什么,反而白付一次测量成本 for (const el of nodes) clearMessageDehydration(el); return; } // 末尾 KEEP_RECENT 条保持活跃(用户正在看的 + 刚生成的) const cutoff = total - CONFIG.DEHYDRATE_KEEP_RECENT; // ---- 读阶段:只对"需要新脱水且尚无记忆高度"的元素测高 ---- const targets = []; const measured = []; for (let i = 0; i < cutoff; i++) { const el = nodes[i]; if (el.dataset.dehydrated === '1') continue; if (el.style.getPropertyValue('--sa-h')) continue; // 已有记忆高度则复用 targets.push(el); measured.push(el.offsetHeight || 0); } // ---- 写阶段:批量写样式/属性 ---- for (let i = 0; i < cutoff; i++) { const el = nodes[i]; if (el.dataset.dehydrated === '1') continue; if (!el.style.getPropertyValue('--sa-h')) { const h = measured[targets.indexOf(el)] || CONFIG.DEHYDRATE_FALLBACK_H; el.style.setProperty('--sa-h', h + 'px'); } el.dataset.dehydrated = '1'; } for (let i = cutoff; i < total; i++) clearMessageDehydration(nodes[i]); } // 滚动时按需更新高度记忆:auto 元素滚入视口后浏览器会真实布局, // 此时量到的是真高度,回写 --sa-h 让后续脱水时的滚动条长度更准。 function hydrateMessagesInView() { if (!messagesEl) return; const list = messagesEl.querySelectorAll('.sa-msg[data-dehydrated="1"]'); if (!list.length) return; const top = messagesEl.scrollTop; const bottom = top + messagesEl.clientHeight; for (const el of list) { const t = el.offsetTop; if (t > bottom + 200 || t + (el.offsetHeight || 0) < top - 200) continue; const real = Math.round(el.getBoundingClientRect().height); if (real > 0) el.style.setProperty('--sa-h', real + 'px'); } } function buildMsgFooterHtml(content) { if (!content) return ''; const id = msgCopyRegistry.push(content) - 1; return ``; } function buildAiBadgeHtml() { return `${getIcon('sparkle', 14)}`; } 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 `${buildAiBadgeHtml()}${toolsHtml}${bodyHtml}${buildMsgFooterHtml(clean)}`; } else if (m.role === 'system') { return ''; } return ''; }).join(''); messagesEl.scrollTop = messagesEl.scrollHeight; // v1.13.0 P0-1:历史消息整体重建后,立刻脱水屏外部分(读测量已在写阶段之前完成) applyMessageDehydration(); } 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 = buildAiBadgeHtml() + renderMarkdown(cleanToolCallMarkers(content)); } messagesEl.appendChild(msgEl); messagesEl.scrollTop = messagesEl.scrollHeight; // v1.13.0 P0-1:新消息进来后,把更早的消息挤进脱水区(末尾 KEEP_RECENT 条始终活跃) applyMessageDehydration(); 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 = '发送 (Enter)'; sendBtn.setAttribute('aria-label', '发送消息'); sendBtn.innerHTML = getIcon('send', 16); } 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 发送' : '正在回复,可点右侧按钮停止'; } } 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 = []; // v1.13.0 P0-2:把气泡拆成 badge / tools / body 三个常驻子节点。 // 旧实现每帧 `aiMsgEl.innerHTML = badge + toolsHtml + bodyHtml`,其中 toolsHtml // 在正文流式增长期间**内容不变却被反复 parse**(每帧一次,单个工具卡可达数 KB)。 // 现改为:正文每帧更新(本来就要),工具仅在结构签名变化时更新。 // 宿主结构无变化,故无需 withSelfMutation 守卫——本子树没有 MutationObserver 监听 // (L7712 的观察器是 {childList:true} 且无 subtree,只看 body 直接子级)。 // 若日后给消息区加观察器,必须同步引入自变更守卫,否则会自触发死循环。 const badgeHost = document.createElement('div'); badgeHost.innerHTML = buildAiBadgeHtml(); const badgeNode = badgeHost.firstElementChild; const toolsEl = document.createElement('div'); const bodyEl = document.createElement('div'); if (badgeNode) aiMsgEl.appendChild(badgeNode); aiMsgEl.appendChild(toolsEl); aiMsgEl.appendChild(bodyEl); // 工具区结构签名:只取「会影响渲染」的字段(数量/运行态/耗时/结果是否就绪), // 避免为了判重而每帧都跑一遍 buildToolCallsHtml(含 getIcon 的 SVG 构造)。 const toolSigOf = (calls, runningIndex) => { let s = calls.length + ':' + runningIndex; for (const tc of calls) { s += '|' + (tc.running ? '1' : '0') + (tc.durationMs != null ? 'd' : '') + (tc.result ? 'r' : ''); } return s; }; let lastToolsSig = null; // 流式渲染节流:rAF 合并高频重绘 let bubbleRafPending = false; let bubblePendingIndex = -1; let bubbleSettled = false; const paintAiBubble = (runningIndex) => { if (bubbleSettled) return; const cleanText = cleanToolCallMarkers(displayedText); bodyEl.innerHTML = cleanText ? renderMarkdown(cleanText) : ''; // 工具区:签名未变则整段跳过(含展开状态保留——DOM 没被重建,展开态自然留存) const sig = toolSigOf(liveToolCalls, runningIndex); if (sig !== lastToolsSig) { const prevOpen = Array.from(toolsEl.querySelectorAll('details.sa-tool-chip')).map(d => d.open); toolsEl.innerHTML = buildToolCallsHtml(liveToolCalls, runningIndex); if (prevOpen.length) { toolsEl.querySelectorAll('details.sa-tool-chip').forEach((d, i) => { if (prevOpen[i]) d.open = true; }); } lastToolsSig = sig; } messagesEl.scrollTop = messagesEl.scrollHeight; }; const updateAiBubble = (runningIndex = -1) => { if (bubbleRafPending) { bubblePendingIndex = runningIndex; return; } bubbleRafPending = true; bubblePendingIndex = runningIndex; if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => { bubbleRafPending = false; if (!bubbleSettled) paintAiBubble(bubblePendingIndex); }); } else { bubbleRafPending = false; paintAiBubble(bubblePendingIndex); } }; // 终态(完整回答/停止/错误)直接 innerHTML 定稿后,丢弃还在排队的 rAF 重绘, // 防止无 footer 的中间态覆盖带复制按钮的终态 const settleBubble = () => { bubbleSettled = true; bubbleRafPending = false; }; 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, index, toolResult) => { if (abortFlag) return; if (phase === 'start') { liveToolCalls.push({ name, arguments: args, running: true, _t0: Date.now() }); updateAiBubble(); } else { const tc = (Number.isInteger(index) && liveToolCalls[index]) || liveToolCalls.find(t => t.running && t.name === name); if (tc) { tc.running = false; if (tc._t0) tc.durationMs = Date.now() - tc._t0; if (toolResult) tc.result = toolResult; } updateAiBubble(); } }); if (toolStatusEl) toolStatusEl.classList.remove('sa-active'); liveToolCalls = result.toolCalls || liveToolCalls; settleBubble(); // v1.13.0:终态写回常驻节点(toolsEl/bodyEl),不再整块 innerHTML 重建, // 这样流式态与终态的 DOM 结构保持一致,脱水高度测量也不会被打断 if (result.aborted) { const partial = result.response || cleanToolCallMarkers(displayedText); toolsEl.innerHTML = buildToolCallsHtml(liveToolCalls); bodyEl.innerHTML = partial ? renderMarkdown(partial) : '已停止生成'; appendMsgFooterEl(aiMsgEl, partial); messagesEl.scrollTop = messagesEl.scrollHeight; if (partial) ConversationManager.addMessage('assistant', partial, { toolCalls: liveToolCalls }); } else { displayedText = result.response; toolsEl.innerHTML = buildToolCallsHtml(liveToolCalls); bodyEl.innerHTML = renderMarkdown(result.response); appendMsgFooterEl(aiMsgEl, result.response); messagesEl.scrollTop = messagesEl.scrollHeight; ConversationManager.addMessage('assistant', result.response, { toolCalls: result.toolCalls || [], }); } // 刚生成的消息要保持活跃(新脱水阈值刚把它推入活跃区,无需处理); // 但更早的消息可能因 cutoff 前移而脱水,重算一次 applyMessageDehydration(); } catch (e) { if (toolStatusEl) toolStatusEl.classList.remove('sa-active'); settleBubble(); toolsEl.innerHTML = ''; bodyEl.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: 'sources', label: '数据源', icon: 'stats' }, { 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)} 提示`; const detailHtml = detail ? '' + escapeHtml(detail).replace(/\n/g, '') : ''; return `${mark}${escapeHtml(title)}${detailHtml}`; } 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(); // v1.16.0 联网检索工具配置 const webToolsBase = String(GM_getValue(SK.DS_TOOLS_BASE, '') || '').trim(); const webToolsKey = String(GM_getValue(SK.DS_TOOLS_KEY, '') || '').trim(); const webToolsEffective = getWebToolsConfig(); const webToolsStatus = webToolsEffective.key ? { color: '', text: `已就绪:Key 来源「${webToolsEffective.source}」,接口 ${webToolsEffective.base}` } : { color: 'color:#fbbf24;', text: '未配置:webSearch / webFetch 将返回配置引导。可填入 DeepSeek Key,或在「AI 设置」中把模型配成 DeepSeek 以自动复用' }; const settings = parseStored(GM_getValue(SK.SETTINGS, null), {}); const overlay = document.createElement('div'); overlay.className = 'sa-overlay sa-show'; overlay.innerHTML = ` ${getIcon('gear', 18)} 智能体设置中心 v${escapeHtml(_v)} ${getIcon('close', 16)} ${SETTINGS_TABS.map(t => `${getIcon(t.icon, 15)} ${t.label}`).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)} 生成参数 温度 (0-2) 值越低回答越稳定,越高越发散;推荐 0.5-0.8 最大迭代次数 ReAct 循环最大工具调用次数 ${getIcon('book', 15)} 规则知识库 远程知识库地址 指向 saa-rules-knowledge.json 的原始链接(纯数据 JSON,不执行代码);更新知识只需改 JSON 文件并推送,无需更新主脚本 ${getIcon('refresh', 14)} 检查更新 ${getIcon('trash', 14)} 清除远程缓存 `; 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} ${escapeHtml(s.sectionCount)} 章节 · ${escapeHtml(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 = ` AI 模型配置 选择供应商预设可自动填充 API 地址和模型列表,只需填入 API Key 即可使用。支持配置多个模型。 ${getIcon('zap', 13)} 一键检测全部模型 ${getIcon('plus', 14)} 添加模型 `; 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 ? `${getIcon('play', 13)}` : ''} ${getIcon('zap', 13)} ${getIcon('edit', 13)} ${getIcon('trash', 13)} `; }).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 = ` ${getIcon('close', 12)} 返回列表 ${model ? '编辑模型(保存后立即生效)' : '添加模型(保存后立即生效)'} 选择供应商 ${Object.entries(MODEL_PROVIDERS).map(([key, p]) => ` ${escapeHtml(p.name)} `).join('')} 显示名称 API 地址 API Key 模型名称 ${getIcon('zap', 14)} 测试连接 保存 `; 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};输入兼容 OpenAI 格式的 API 地址(需包含 /v1/chat/completions 路径)`; const keyHint = editorView.querySelector('#sa-me-key-hint'); if (p.apiKeyUrl) { keyHint.innerHTML = `在 ${escapeHtml(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 = ` ${p.models.map(m => `${escapeHtml(m.label)}`).join('')} `; 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); // URL 自动补全:失焦时补全协议和路径 const urlInputEl = editorView.querySelector('#sa-me-url'); urlInputEl.addEventListener('blur', () => { let v = urlInputEl.value.trim(); if (!v) return; if (!/^https?:\/\//i.test(v)) v = 'https://' + v; try { const u = new URL(v); if (!u.pathname || u.pathname === '/') u.pathname = '/v1/chat/completions'; const normalized = u.toString().replace(/\/$/, ''); if (normalized !== urlInputEl.value.trim()) { urlInputEl.value = normalized; } } catch {} }); 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.innerHTML = `连接失败:${escapeHtml(describeModelError(err)).replace(/\n/g, '')}`; } 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 = ` 智能体管理 新建、编辑、切换自定义智能体;支持 AI 辅助生成系统提示词与配置导入导出。 ${getIcon('plus', 14)} 新建智能体 ${getIcon('download', 14)} 导出 ${getIcon('edit', 14)} 导入 `; 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)} ${getIcon('play', 13)} ${!a.builtin ? ` ${getIcon('edit', 13)} ${getIcon('trash', 13)} ` : ''} `).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 = ` ${getIcon('close', 12)} 返回列表 ${agent ? '编辑智能体(保存后立即生效)' : '新建智能体(保存后立即生效)'} 图标 (emoji 或 SVG 图标名) 可用: bot, gamepad, gift, sparkle, stats, chat 或输入 emoji 名称 描述 关键词 (逗号分隔) 可用工具 (不勾选任何工具 = 使用全部工具) ${ToolRegistry.getAll().map(t => { const enabled = !(agent?.tools && agent.tools.length) || agent.tools.includes(t.name); return ` ${escapeHtml((TOOL_DISPLAY[t.name] || {}).label || t.name)}`; }).join('')} 限定工具子集可减少系统提示词长度并提升工具选择准确率;至少保留一个工具 系统提示词 (System Prompt) ${getIcon('copy', 13)} 复制 ${escapeHtml(agent?.systemPrompt || '')} 需求描述 (AI 辅助生成提示词) ${getIcon('sparkle', 14)} AI 生成提示词 保存 取消 `; 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, // 工具子集:全部勾选或全不勾 = null(全部);部分勾选 = 名单 tools: (() => { const checked = [...editorView.querySelectorAll('.sa-ae-tool:checked')].map(c => c.value); const total = ToolRegistry.getAll().length; return (checked.length > 0 && checked.length < total) ? checked : null; })(), }; 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 = ` 接口服务 配置并检测 Steam / ITAD 第三方接口;AI 模型接口在「AI 设置」标签中检测。 ${getIcon('zap', 13)} 一键检测全部接口 ${getIcon('key', 15)} Steam Web API Key ${getIcon('zap', 13)} 检测 ${steamKey ? '已配置:点击「检测」验证 Key 有效性' : '未配置:游戏库查询(getOwnedGames)、高级折扣检索(searchDiscountedGames) 将不可用'} 在 steamcommunity.com/dev 申请,用于 getOwnedGames / searchDiscountedGames 等工具 ${getIcon('user', 15)} SteamID(玩家数据查询身份,可选) ${getIcon('zap', 13)} 检测 ${idStatusText} 用于「我的游戏库 / 资料 / 愿望单」类查询;留空时自动识别当前登录用户(依据页面登录态),无需手动填写 ${getIcon('globe', 15)} ITAD API Key(跨区比价增强,可选) ${getIcon('zap', 13)} 测试 ${itadKey ? '已配置:getMultiRegionPrices 工具将以 ITAD 增强模式查询 16 个地区的实时价格' : '未配置:跨区比价工具将使用降级数据源(AugmentedSteam,约 7 个地区),价格准确度降低'} 申请步骤:① isthereanydeal.com 注册并验证邮箱 ② 进入 isthereanydeal.com/apps 点击 Register App ③ 填写应用名提交 ④ 复制 32 位密钥粘贴到上方 ⑤ 点击「测试」验证后保存。已安装 SGLV 2.9.79+ 时可自动复用其配置 ${getIcon('search', 15)} 联网检索工具(webSearch / webFetch) 服务 Base URL(留空即用默认 ${escapeHtml(CONFIG.DS_TOOLS_DEFAULT_BASE)})。需兼容 DeepSeek 的 /search 与 /fetch 两个 POST 端点,可换成自建网关。 ${getIcon('zap', 13)} 测试 ${escapeHtml(webToolsStatus.text)} 提供 webSearch(实时网页检索)与 webFetch(网页正文抓取)能力。留空时若「AI 设置」用的是 DeepSeek 模型会自动复用其 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 地区),价格准确度降低')); } // v1.16.0 联网检索工具连通性检测 const wtInput = pane.querySelector('#sa-cfg-webtools-key').value.trim(); const wtBase = pane.querySelector('#sa-cfg-webtools-base').value.trim(); if (wtInput) GM_setValue(SK.DS_TOOLS_KEY, wtInput); if (wtBase) GM_setValue(SK.DS_TOOLS_BASE, wtBase); else GM_deleteValue(SK.DS_TOOLS_BASE); const wt = await testWebToolsConnection(); prependCheckResult(resultsEl, wt.success ? renderCheckItem('ok', '联网检索工具可用', wt.note) : renderCheckItem('err', '联网检索工具检测失败', wt.error)); toast('接口检测完成,结果见上方列表', 'success'); btn.disabled = false; btn.innerHTML = originalHtml; }); } // ---------- Tab: 数据管理 ---------- // ═══ v1.25.0 数据源授权 ═══ // 逐插件勾选 AI 可读取哪些插件的本地缓存;默认全开,用户可随时关闭。 // 关闭后 readPluginCache 返回 needAuth,模型会引导用户回这里开启。 async function buildSourcesPane() { const pane = overlay.querySelector('#sa-pane-sources'); if (!pane) return; pane.innerHTML = ` 数据源授权 选择 AI 可以读取哪些插件的本地缓存。数据仅存于本机,不做任何上传。 正在检测已安装插件… `; // 契约(期望清单 + minVersion)+ 运行时注册(精确版本号) let contract = pcContractGetCached(); if (!contract) { try { contract = await pcContractFetch(); } catch (e) { contract = null; } } const registry = await pcReadPluginRegistry(); const authMap = pcDataSourceAuthMap(); if (!contract) { pane.innerHTML = ` 数据源授权 选择 AI 可以读取哪些插件的本地缓存。 版本契约加载失败(无法获取插件清单)。请检查网络后重试 —— 授权状态仍会保留。 `; return; } const famLabels = contract.families || {}; const byFamily = new Map(); for (const p of (contract.plugins || [])) { const f = p.family || '_other'; if (!byFamily.has(f)) byFamily.set(f, []); byFamily.get(f).push(p); } const render = () => { const am = pcDataSourceAuthMap(); // v1.30.2 域感知:注册表按域隔离,跨域读不到是正常现象而非「未安装」 const curDom = pcPanelDomain(); const domChip = curDom ? `当前面板:${curDom === 'store' ? '商店域 store' : '社区域 community'}` : ''; let html = ` 数据源授权${domChip} 选择 AI 可以读取哪些插件的本地缓存。数据仅存于本机,不做任何上传。 `; // 套件完整性 let famHtml = ''; for (const [fam, list] of byFamily) { const inst = list.filter(p => registry.has(p.id)).length; const okAll = inst === list.length; famHtml += ` ${escapeHtml((famLabels[fam] && famLabels[fam].label) || fam)} ${inst}/${list.length} ${okAll ? '已装齐' : '未装齐'} `; } html += ` ${getIcon('stats', 15)} 套件完整性 ${famHtml} 「已装齐」按插件注册表的实际版本判定;未注册的插件只能探测到「存在」,版本显示为 —。注册表按域隔离(账户系/核心系在商店域、好友系/徽章系在社区域),跨域注册经 GM 存储镜像自动合并显示为「来自 XX 域 · 镜像」。 `; // 插件列表 html += ` ${getIcon('gamepad', 15)} 插件数据源 全部共享 全部关闭 `; for (const [fam, list] of byFamily) { html += ` ${escapeHtml((famLabels[fam] && famLabels[fam].label) || fam)} ${list.filter(p => registry.has(p.id)).length}/${list.length} 已装 `; for (const p of list) { const reg = registry.get(p.id); const installed = !!reg; const viaMirror = !!(reg && reg.viaMirror); const version = reg ? reg.version : null; const outdated = version && p.minVersion ? pcCompareVersion(version, p.minVersion) < 0 : false; const allowed = am[p.id] ? am[p.id].allowed : true; let badge = ''; if (installed && viaMirror) { // v1.30.3 跨域镜像:注册来自另一域的 IndexedDB,经 GM 存储镜像合并显示 const dn = reg.regDomain === 'community' ? '社区域' : (reg.regDomain === 'store' ? '商店域' : '他域'); badge = `来自${dn} · 镜像`; } else if (!installed) { // v1.30.2 域感知:未注册且契约标明注册在其他域 → 提示去对应域确认,不误报「未安装」 const pd = p.domain; if (pd && pd !== 'both' && curDom && pd !== curDom) { const domName = pd === 'store' ? '商店域' : '社区域'; badge = `需在${domName}确认`; } else { badge = '未安装'; } } else if (outdated) badge = `版本过旧(需 ${escapeHtml(p.minVersion)})`; else if (version) badge = '已启用'; else badge = '已检测 · 版本未知'; html += ` ${escapeHtml(p.name)} ${version ? 'v' + escapeHtml(version) : '—'} ${badge} `; } html += ''; } html += `关闭后 AI 读取该插件数据会被明确拒绝并提示你回来开启;其他插件不受影响。 `; // ═══ v1.30.0 远程数据中枢(data-hub)═══ // v1.26 的做法是「端口 + 手填 token」(token 从 C:/sqlite-data/token 复制粘贴)—— // 远程托管后这个体验不合理:地址填一次即可,身份与凭据由插件自己搞定 // (设备密钥 + Steam 身份认证 → 换会话),**不再让用户接触任何 token**。 const hub = pcHubConfig(); const hubReport = (function () { try { return pcReportState(); } catch (e) { return { enabled: true, pendingOptout: false }; } })(); const hubState = (function () { try { const a = pcHubAuthLib(); return a ? a.getState() : null; } catch (e) { return null; } })(); html += ` ${getIcon('download', 15)} 远程数据中枢(data-hub) 启用远程数据中枢(默认开启,使用默认地址) 服务地址 ${hub.isDefaultUrl ? `当前使用默认地址 ${escapeHtml(hub.baseUrl)}(留空即为默认;默认地址支持远程热更)` : `当前使用自定义地址 ${escapeHtml(hub.baseUrl)}`}。填一次即可;不再需要手动复制令牌 —— 插件会用设备密钥 + Steam 身份认证自动换取会话。 允许明文 HTTP(仅内网调试) 允许内网 / 本机地址(自建服务器) 上报插件版本信息(默认开启) 开启后每日上报一次本机已装插件的版本号,用于统计版本分布与排查问题。关闭后不再每日上报,服务端已存的记录会一并删除。但每次绑定 Steam 账号时仍会附带一次版本清单,用于绑定问题的排查。 ${hubReport.pendingOptout ? `⚠ 服务端版本记录尚未删除(上次撤销未送达),下次启动自动重试` : ''} 保存 测试连接 恢复默认地址 绑定 Steam 账号 账号状态:${hubState && hubState.subject ? `已绑定 ${escapeHtml(hubState.subject)}` : '未绑定(点「绑定 Steam 账号」走一次 Steam 登录)'} `; pane.innerHTML = html; // 事件:勾选即写 GM pane.querySelectorAll('input[data-src]').forEach((input) => { input.addEventListener('change', () => { const auth = pcLoadAuth(); const id = input.getAttribute('data-src'); auth.sources[id] = input.checked; pcSaveAuth(auth); const row = input.closest('.sa-src-row'); if (row) row.classList.toggle('is-off', !input.checked); toast(input.checked ? '已允许共享该插件数据' : '已关闭该插件数据共享', 'success'); }); }); const setAll = (on) => { const auth = pcLoadAuth(); for (const p of (contract.plugins || [])) auth.sources[p.id] = on; pcSaveAuth(auth); render(); toast(on ? '已允许全部插件数据共享' : '已关闭全部插件数据共享', 'success'); }; const allOn = pane.querySelector('#sa-src-all-on'); const allOff = pane.querySelector('#sa-src-all-off'); if (allOn) allOn.addEventListener('click', () => setAll(true)); if (allOff) allOff.addEventListener('click', () => setAll(false)); // ---- 远程数据中枢:保存 + 测试连接 + 绑定 Steam 账号 ---- const hubUrlEl = pane.querySelector('#sa-hub-url'); const hubEnabledEl = pane.querySelector('#sa-hub-enabled'); const hubHttpEl = pane.querySelector('#sa-hub-allow-http'); const hubPrivateEl = pane.querySelector('#sa-hub-allow-private'); const hubReportEl = pane.querySelector('#sa-hub-report'); const hubStatusEl = pane.querySelector('#sa-hub-status'); const hubSaveBtn = pane.querySelector('#sa-hub-save'); const hubTestBtn = pane.querySelector('#sa-hub-test'); const hubResetBtn = pane.querySelector('#sa-hub-reset'); const hubBindBtn = pane.querySelector('#sa-hub-bind'); const readHubForm = () => ({ baseUrl: String(hubUrlEl && hubUrlEl.value || '').trim(), disabled: !(hubEnabledEl && hubEnabledEl.checked), allowHttp: !!(hubHttpEl && hubHttpEl.checked), allowPrivate: !!(hubPrivateEl && hubPrivateEl.checked), report: !!(hubReportEl && hubReportEl.checked), }); const hubStatus = (text, color) => { if (hubStatusEl) hubStatusEl.innerHTML = `${escapeHtml(text)}`; }; /** 地址合法性(保存前先挡一道,别等请求时才炸)。空地址合法 = 回落默认。 */ const validateHubUrl = (form) => { if (form.disabled) return ''; // 整体停用:地址随便填都不生效 if (!form.baseUrl) return ''; // 空 = 回落默认地址(§3.5.1「不改」与「显式清空」同义) let u; try { u = new URL(form.baseUrl); } catch (e) { return '地址不合法(要带 https://)'; } if (u.protocol !== 'https:' && u.protocol !== 'http:') return '只支持 http / https'; if (u.protocol === 'http:' && !form.allowHttp) return '明文 HTTP 需要先勾选「允许明文 HTTP」'; if (u.username || u.password) return '地址里不要带账号密码'; if (/\/(search|app|push|ingest|query|table)\b/.test(u.pathname)) return '地址只填到根路径(不要带接口路径)'; return ''; }; /** 上报开关变化处理:关闭 → 立即发 optout(撤销必须送达,失败置 pendingOptout) */ const applyReportToggle = async (on) => { const st = pcReportState(); st.enabled = !!on; pcReportSaveState(st); if (!on) { hubStatus('正在删除服务端版本记录…', '#94a3b8'); const ok = await pcOptoutVersionReport(); hubStatus(ok ? '已停止每日上报,服务端记录已删除(绑定时仍会附带一次)' : '撤销请求失败:服务端记录尚未删除,下次启动自动重试', ok ? '#4ade80' : '#fbbf24'); } else { hubStatus('已开启每日版本上报', '#4ade80'); } }; if (hubSaveBtn) { hubSaveBtn.addEventListener('click', async () => { const form = readHubForm(); const bad = validateHubUrl(form); if (bad) { hubStatus(bad, '#fbbf24'); return; } pcSaveHubConfig(form); pcHubSyncAuth(); // 上报开关独立保存(不随地址保存失败而丢) const prev = pcReportState(); if (prev.enabled !== form.report) await applyReportToggle(form.report); const eff = pcHubConfig(); hubStatus(eff.disabled ? '已停用远程数据中枢' : `已保存(当前生效:${eff.baseUrl}${eff.isDefaultUrl ? ' · 默认地址' : ''})`, '#4ade80'); toast('远程数据中枢配置已保存', 'success'); }); } if (hubResetBtn) { hubResetBtn.addEventListener('click', () => { // 恢复默认 = 清空显式地址(回落默认:远程热更 → 内置常量) if (hubUrlEl) hubUrlEl.value = ''; const form = readHubForm(); pcSaveHubConfig(form); pcHubSyncAuth(); const eff = pcHubConfig(); hubStatus(`已恢复默认地址(${eff.baseUrl})`, '#4ade80'); }); } if (hubReportEl) { // 上报开关即时生效:关闭 → 立即发 optout(撤销必须送达,失败置 pendingOptout) hubReportEl.addEventListener('change', () => { applyReportToggle(hubReportEl.checked).catch(() => { /* 静默 */ }); }); } if (hubTestBtn) { hubTestBtn.addEventListener('click', async () => { const form = readHubForm(); const bad = validateHubUrl(form); if (bad) { hubStatus(bad, '#fbbf24'); return; } pcSaveHubConfig(form); hubStatus('正在连接…', '#94a3b8'); try { // 用免鉴权的 /health 探测:还没绑账号也能先确认地址通不通 const r = await pcHubPing(); const hubSt = (function () { try { const a = pcHubAuthLib(); return a ? a.getState() : null; } catch (e) { return null; } })(); hubStatus(`连接正常:schema v${r.schemaVersion}${hubSt && hubSt.subject ? ` · 已绑定 ${hubSt.subject},链路与账号状态均正常` : '(地址连通;还没绑定账号,点「绑定 Steam 账号」完成认证)'}`, '#4ade80'); } catch (e) { hubStatus(pcHubErrorText(e, form), '#fbbf24'); } }); } if (hubBindBtn) { hubBindBtn.addEventListener('click', async () => { const form = readHubForm(); const bad = validateHubUrl(form); if (bad) { hubStatus(bad, '#fbbf24'); return; } pcSaveHubConfig(form); const auth = pcHubSyncAuth(); if (!auth) { hubStatus('认证库未加载(saa-auth.lib.js)—— 请更新插件', '#fbbf24'); return; } hubStatus('请在弹窗里登录 Steam…', '#94a3b8'); try { const r = await auth.loginWithSteam({ onStatus: (st) => { if (st === 'waiting-steam') hubStatus('请在弹窗里完成 Steam 登录…', '#94a3b8'); else if (st === 'exchanging') hubStatus('正在换取会话…', '#94a3b8'); }, }); hubStatus(`已绑定 Steam 账号 ${r.steamid}`, '#4ade80'); toast('Steam 账号绑定成功', 'success'); // ★ v1.33.0 绑定时附带一次版本清单(reason='bind'):与开关状态无关, // 绑定是用户明确发起的中枢交互,附版本快照用于绑定排障;静默失败不影响绑定结果。 try { pcSendVersionReport('bind').catch(() => {}); } catch (e) { /* ignore */ } try { render(); } catch (e) { /* ignore */ } } catch (e) { hubStatus(pcHubErrorText(e, form), '#fbbf24'); } }); } }; render(); } function buildDataPane() { const pane = overlay.querySelector('#sa-pane-data'); const conv = ConversationManager.getCurrentConversation(); pane.innerHTML = ` 会话数据管理 当前会话:「${escapeHtml(conv?.title || '新会话')}」 · ${conv?.messages?.length || 0} 条消息 ${getIcon('download', 15)} 导出当前会话 导出为 JSON 导出为 Markdown 导出文件可直接分享或备份;JSON 可再次导入恢复 ${getIcon('edit', 15)} 导入会话 从 JSON 文件导入会话 导入后会作为新会话追加到历史列表,不影响现有对话 `; 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()); // v1.16.0 联网检索工具 const wtBaseEl = overlay.querySelector('#sa-cfg-webtools-base'); const wtKeyEl = overlay.querySelector('#sa-cfg-webtools-key'); if (wtBaseEl) { const v = wtBaseEl.value.trim(); if (v) GM_setValue(SK.DS_TOOLS_BASE, v); else GM_deleteValue(SK.DS_TOOLS_BASE); } if (wtKeyEl) { const v = wtKeyEl.value.trim(); if (v) GM_setValue(SK.DS_TOOLS_KEY, v); else GM_deleteValue(SK.DS_TOOLS_KEY); } 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(); // v1.25.0 数据源授权 pane(异步:需拉契约 + 读运行时注册表) buildSourcesPane(); 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), // v1.25.0 暴露设置入口:便于其他脚本/工具直接跳到指定设置页(如数据源授权) openSettings: (options = {}) => openSettings(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); } } // 会话防抖保存的兜底冲刷:页面关闭/隐藏时立即落盘,避免 800ms 窗口内丢消息 function registerConversationFlush() { try { window.addEventListener('beforeunload', () => ConversationManager.flushSave()); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') ConversationManager.flushSave(); }); } catch {} } // 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('bindGameNewsEsc', () => { if (GameNews) GameNews.bindEsc(); }); // v1.36.0:商店域自动刷新游戏库跨域镜像(社区域搜索游戏库用;节流 30 分钟,失败静默) safeStep('pcAutoMirrorLibrary', () => { pcAutoMirrorLibrary().catch(() => {}); }); 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('registerConversationFlush', registerConversationFlush); // ★ v1.33.0 版本上报维护(远程默认地址热更 / optout 重试 / 每日一次上报)——全部静默 safeStep('pcReportStartup', () => { try { pcReportStartup().catch(() => {}); } catch (e) { /* ignore */ } }); safeStep('EventBus.ready', () => EventBus.emit('ready', { version: _v })); console.log('[SA-Agent] 初始化完成,SteamAgent API 已暴露'); } try { if (typeof globalThis !== 'undefined' && globalThis.__SAA_TEST_ENABLE__) { globalThis.__SAA_TEST__ = { // v1.25.0 暴露设置入口(供测试渲染「数据源授权」pane) openSettings, parseToolCalls, parseSSEStream, createSseStreamParser, readPageContext, formatPageContext, normalizeNativeToolCalls, mergeToolCalls, shouldHoldStream, isNativeToolsRejected, buildReActPrompt, runAgentLoop, callAiChat, isPrivateHost, assertSafeRequestUrl, truncateToolResult, ConversationManager, AgentManager, ModelManager, ToolRegistry, PLUGIN_CACHE_REGISTRY, CONFIG, // v1.29.0 身份层:账号清单缓存(测试可强制刷新) pcHubAccounts, bustAccountsCache: () => { _accountsCache = null; _accountsAt = 0; _accountsBase = ''; }, // v1.30.0 远程数据中枢:配置 / 请求 / 路由白名单(供测试) pcHubConfig, pcSaveHubConfig, pcHubRequest, pcHubPost, pcHubRouteAllowed, pcHubErrorText, pcHubAuthLib, pcHubPing, // v1.30.3 跨域注册镜像(测试:镜像合并/回写) pcReadPluginRegistry, // v1.33.0 版本上报(测试:清单构造 / 开关门禁 / 状态机) pcBuildVersionManifest, pcSendVersionReport, pcOptoutVersionReport, pcReportState, pcReportSaveState, pcReportToday, pcHubResolveEndpointRemote, PC_HUB_DEFAULT_BASE_URL, // v1.34.0 消费/登录链路(测试:登录态探测 / 宿主桥读取) spendLoginState, spendBridgeRead, // v1.36.0 游戏库跨域镜像(测试:镜像读写 / 回退 / 年龄格式化) pcReadLibraryMirror, pcWriteLibraryMirror, pcMaybeWriteLibraryMirror, pcLibraryMirrorFallback, pcHumanAge, pcReadSglvLibrary, pcAutoMirrorLibrary, PC_LIB_MIRROR_KEY, }; } } catch {} if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { setTimeout(init, 100); } })();
${code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')}
块内的内容 if (currentLine.includes('')) { // 找到代码块结束位置 result.push(currentLine); i++; while (i < lines.length && !lines[i].includes('')) { result.push(lines[i]); i++; } if (i < lines.length) { result.push(lines[i]); i++; } continue; } // 检测表格:当前行含 | ,下一行是分隔行 if (currentLine.includes('|') && i + 1 < lines.length && isTableSeparator(lines[i + 1])) { const headerLine = currentLine; const sepLine = lines[i + 1]; const dataLines = []; let j = i + 2; // 收集数据行(含 | 的连续行) while (j < lines.length && lines[j].trim() !== '' && lines[j].includes('|') && !lines[j].includes('= 0) { const headers = parseMdTableRows([headerLine])[0] || []; const rows = parseMdTableRows(dataLines); // 解析对齐方式 const sepCells = sepLine.split('|').map(c => c.trim()).filter(c => c !== ''); const aligns = sepCells.map(cell => { const left = cell.startsWith(':'); const right = cell.endsWith(':'); if (left && right) return 'center'; if (right) return 'right'; if (left) return 'left'; return ''; }); let tableHtml = ''; // 表头 tableHtml += ''; headers.forEach((h, idx) => { const align = aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ''; tableHtml += `${h}`; }); tableHtml += ''; // 表体 tableHtml += ''; rows.forEach(row => { tableHtml += ''; row.forEach((cell, idx) => { const align = aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ''; tableHtml += `${cell}`; }); tableHtml += ''; }); tableHtml += ''; result.push(tableHtml); i = j; continue; } } result.push(currentLine); i++; } html = result.join('\n'); // 3. 标题 html = html.replace(/^### (.+)$/gm, '$1'); html = html.replace(/^## (.+)$/gm, '$1'); html = html.replace(/^# (.+)$/gm, '$1'); // 4. 粗体、斜体 html = html.replace(/\*\*(.+?)\*\*/g, '$1'); html = html.replace(/\*(.+?)\*/g, '$1'); // 5. 行内代码 html = html.replace(/`([^`]+)`/g, '$1'); // 6. 链接 html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, text, url) => `${text}`); // 7. 引用 html = html.replace(/^> (.+)$/gm, '$1'); // 8. 无序列表 html = html.replace(/^[\-\*] (.+)$/gm, '$1'); html = html.replace(/([\s\S]*?<\/li>)(?!\s*)/g, '$1'); // 9. 有序列表 html = html.replace(/^\d+\. (.+)$/gm, '$1'); // 10. 段落与换行(跳过表格、代码块等块级元素内的换行) // 先把表格和代码块替换为占位符,避免内部换行被处理 const blockHolders = []; html = html.replace(/[\s\S]*?<\/table><\/div>/g, (m) => { blockHolders.push(m); return `__BLOCK_${blockHolders.length - 1}__`; }); html = html.replace(/[\s\S]*?<\/pre>/g, (m) => { blockHolders.push(m); return `__BLOCK_${blockHolders.length - 1}__`; }); html = html.replace(/\n\n/g, ''); html = html.replace(/\n/g, ''); // 恢复块级元素 html = html.replace(/__BLOCK_(\d+)__/g, (m, idx) => blockHolders[Number(idx)] || ''); if (!html.match(/^<(h\d|pre|ul|ol|blockquote|p|div)/)) 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); } // ============================================================ // 出站请求安全层(防御纵深) // 1. 内网/本机地址拦截(SSRF):工具数据面禁止触达私有网段 // 2. 协议白名单:仅 http(s) // 3. AI 端点防泄漏:工具请求禁止命中用户配置的 AI API 主机,防止工具流量 // 携带上下文发往模型供应商(callAiChat 自身请求不受限,用户显式配置) // ============================================================ function isPrivateHost(hostname) { const h = String(hostname || '').toLowerCase().replace(/\.$/, ''); if (!h) return true; if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.home.arpa')) return true; if (h === '0.0.0.0' || h === '[::1]' || h === '::1') return true; const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (m) { const a = Number(m[1]), b = Number(m[2]); if (a === 0 || a === 10 || a === 127) return true; if (a === 192 && b === 168) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 169 && b === 254) return true; if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT return false; } if (h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80:')) return true; // IPv6 ULA / link-local return false; } function getAiEndpointHosts() { const hosts = new Set(); try { const cfg = getAiConfig(); if (cfg && cfg.apiUrl) { const u = new URL(cfg.apiUrl); if (u.hostname) hosts.add(u.hostname.toLowerCase()); } } catch {} return hosts; } // 校验并解析出站 URL;通过则返回 URL 对象,不通过抛错。allowPrivate/allowAiEndpoint 仅供本地模型连通性测试等显式场景。 function assertSafeRequestUrl(url, opts = {}) { let u; try { u = new URL(String(url)); } catch { throw new Error('无效的请求 URL'); } if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error(`已拦截非 http(s) 协议请求: ${u.protocol}`); if (!opts.allowPrivate && isPrivateHost(u.hostname)) throw new Error(`已拦截对内网/本机地址的请求: ${u.hostname}`); if (!opts.allowAiEndpoint && getAiEndpointHosts().has(u.hostname.toLowerCase())) { throw new Error(`已拦截发往 AI 端点主机的工具请求: ${u.hostname}`); } return u; } function gmFetch(url, options = {}) { assertSafeRequestUrl(url, { allowPrivate: !!options.allowPrivate, allowAiEndpoint: !!options.allowAiEndpoint }); 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) { // withMeta:需要 finalUrl 等元信息(如判断是否被重定向到登录页) resolve(options.withMeta ? resp : 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', ''); } // ===== v1.16.0 联网检索/抓取工具配置 ===== // 端点:默认 DeepSeek 官方 /v1/tools/*;可在设置里改 Base URL 接自建/第三方同协议服务 // Key 解析优先级(严格两级,不做"any key"兜底): // 1) 设置里单独填的「联网工具 Key」(sa_ds_tools_key)——显式覆盖,任何 Base 都适用 // 2) 当前 AI 模型配置的 Key,且其端点确实是 DeepSeek ——"配了 DS 模型就直接复用" // 3) 都没有 → 返回空,由工具返回明确的配置引导 // 刻意不做第 3 种兜底("随便拿个 AI Key 试试"):那会让鉴权失败伪装成"检索无结果", // 且违反"非 DeepSeek Key 不自动挪用"的约定。 function getWebToolsConfig() { const base = String(GM_getValue(SK.DS_TOOLS_BASE, '') || '').trim() || CONFIG.DS_TOOLS_DEFAULT_BASE; let key = String(GM_getValue(SK.DS_TOOLS_KEY, '') || '').trim(); let source = key ? '专用配置' : ''; if (!key) { const ai = getAiConfig(); const host = (() => { try { return new URL(ai.apiUrl || '').hostname.toLowerCase(); } catch { return ''; } })(); if (ai.apiKey && isDeepSeekHost(host)) { key = String(ai.apiKey).trim(); source = 'AI 模型配置(DeepSeek)'; } } return { base: base.replace(/\/+$/, ''), key, source }; } function isDeepSeekHost(hostname) { return /(^|\.)deepseek\.com$/i.test(String(hostname || '')); } // ITAD Key:本脚本存储优先,未配置时复用 SGLV 的 Key(跨脚本共享) function getItadApiKey() { return GM_getValue(SK.ITAD_KEY, '') || GM_getValue('sglv_itad_api_key', ''); } function isSSEResponse(text) { return !!(text && /^\s*data:/i.test(text)); } // 把 OpenAI 兼容的 tool_calls 片段收进 bucket。delta 模式拼接字符串,full 模式整段覆盖。 function absorbToolCallPiece(bucket, tc, mode) { if (!tc || typeof tc !== 'object') return; const idx = Number.isInteger(tc.index) ? tc.index : bucket.size; const cur = bucket.get(idx) || { id: '', name: '', arguments: '' }; const fn = tc.function && typeof tc.function === 'object' ? tc.function : {}; const name = fn.name || tc.name || ''; const args = fn.arguments != null ? fn.arguments : tc.arguments; if (tc.id) cur.id = mode === 'delta' ? cur.id + tc.id : String(tc.id); if (name) cur.name = mode === 'delta' ? cur.name + name : String(name); if (args != null) { if (typeof args === 'object') cur.arguments = args; else if (mode === 'delta' && typeof cur.arguments === 'string') cur.arguments += args; else cur.arguments = args; } bucket.set(idx, cur); } // 增量 SSE 解析工厂:内部维护跨 chunk 缓冲,按空行切事件块,半截 JSON 留在缓冲等下一个 chunk, // 替代旧"每次 onprogress 对全量 responseText 重解析"的 O(n²) 模式。tool_calls 增量按 index 槽位拼接。 function createSseStreamParser() { let buf = ''; let content = ''; let reasoning = ''; let usage = null; let finished = false; let sawFullToolMessage = false; const toolBucket = new Map(); const processBlock = (block) => { for (const line of block.split('\n')) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; const dataStr = trimmed.slice(5).trim(); if (!dataStr) continue; if (dataStr === '[DONE]') { finished = true; continue; } try { const chunk = JSON.parse(dataStr); const choice = chunk.choices?.[0] || {}; const delta = choice.delta || {}; if (delta.content) content += delta.content; // 推理模型(DeepSeek R/v4、GLM-think、QwQ 等)的思维链单独分流, // 不混入正文;content 为空时可据此识别"仅推理"响应 if (delta.reasoning_content) reasoning += delta.reasoning_content; if (chunk.usage && (chunk.usage.prompt_tokens || chunk.usage.completion_tokens || chunk.usage.total_tokens)) { usage = chunk.usage; } const fullCalls = choice.message?.tool_calls; if (Array.isArray(fullCalls) && fullCalls.length) { toolBucket.clear(); sawFullToolMessage = true; fullCalls.forEach((tc, i) => absorbToolCallPiece(toolBucket, { ...tc, index: tc.index ?? i }, 'full')); } if (!sawFullToolMessage && Array.isArray(delta.tool_calls)) { delta.tool_calls.forEach(tc => absorbToolCallPiece(toolBucket, tc, 'delta')); } } catch {} } }; return { feed(text) { if (finished || !text) return; buf += text; let idx; while ((idx = buf.indexOf('\n\n')) !== -1) { const block = buf.slice(0, idx); buf = buf.slice(idx + 2); processBlock(block); } }, // 仅终态调用:把无空行结尾的残余块消化掉;流式过程中勿调(会破坏跨 chunk 边界) flushTail() { if (!buf) return; const block = buf; buf = ''; processBlock(block); }, isFinished: () => finished, snapshot: () => ({ content, reasoning, usage, toolCalls: normalizeNativeToolCalls([...toolBucket.values()]), finished }), }; } // 整段文本一次性解析(终态路径 + 兼容旧调用方/测试) function parseSSEStream(text) { const parser = createSseStreamParser(); parser.feed(String(text || '')); parser.flushTail(); const { content, reasoning, usage, toolCalls } = parser.snapshot(); return { content, reasoning, usage, toolCalls }; } 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 || '', }; }, }; // 返回 { content, toolCalls, streamed }。无 tools 且正文为空时仍按原逻辑抛错,避免提示词生成等调用方收到空串。 async function callAiChat(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') { // 防御性清洗:本脚本走 ReAct 单 prompt 协议,从不向 API 发送 tool 角色消息; // 历史只回传纯文本并剥离内嵌工具标记,杜绝孤儿 tool_calls / 残留标记触发协议 400 messages.push({ role: m.role, content: m.role === 'assistant' ? cleanToolCallMarkers(String(m.content || '')) : String(m.content || '') }); } } } messages.push({ role: 'user', content: prompt }); // isolated: 独立请求(如智能体提示词生成),不注册 activeAiRequest、不受聊天停止按钮影响 const isolated = !!opts.isolated; const aborted = () => !isolated && abortFlag; const useStream = !!opts.onChunk; const nativeTools = Array.isArray(opts.tools) && opts.tools.length ? opts.tools : null; let streamedContent = ''; let gotStream = false; let reqRef = null; // 增量流解析状态:onprogress 只把新增字节喂给 parser,终态 onload 复用同一实例 let sseParser = null; let sseConsumed = 0; const feedSseParser = (text, isFinal) => { if (!sseParser) sseParser = createSseStreamParser(); if (text.length > sseConsumed) { sseParser.feed(text.slice(sseConsumed)); sseConsumed = text.length; } if (isFinal) sseParser.flushTail(); return sseParser.snapshot(); }; const reqBody = { model: cfg.model, messages, temperature: opts.temperature ?? 0.7, stream: useStream, }; if (nativeTools) { reqBody.tools = nativeTools; reqBody.tool_choice = opts.toolChoice || 'auto'; } // 仅在显式传入正数时才发送 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') + (nativeTools ? JSON.stringify(nativeTools) : '')); 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 finish = (content, toolCalls, extraUsage) => { const calls = Array.isArray(toolCalls) ? toolCalls : []; recordTokenUsage(extraUsage, content || calls.map(c => c.name).join(',')); return { content: content || '', toolCalls: calls, streamed: gotStream }; }; const p = new Promise((resolve, reject) => { // 推理模型偶发"整轮流式只产出 reasoning_content,正文与工具调用皆空"(finish_reason=stop)。 // 策略:识别后用完全相同的请求体透明重试一次;重试仍为空则报明确的 reasoning 错误。 let reqRetried = false; const resetSseState = () => { sseParser = null; sseConsumed = 0; streamedContent = ''; gotStream = false; }; const xhrParams = { 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 { content: newContent } = feedSseParser(text, false); if (newContent && newContent !== streamedContent) { streamedContent = newContent; gotStream = true; 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, reasoning, usage, toolCalls } = feedSseParser(text, true); const cleaned = stripThinkTags(content); const calls = toolCalls || []; if (!cleaned && !calls.length) { if (!reqRetried && !aborted()) { reqRetried = true; resetSseState(); console.warn('[SA-Agent] 模型仅返回推理内容(reasoning)无正文/工具调用,透明重试一次'); reqRef = GM_xmlhttpRequest(xhrParams); if (!isolated) activeAiRequest = reqRef; return; } if (reasoning) { const err = new Error('模型仅返回了推理内容(reasoning)未产出正文,请重试或更换为非推理模型'); err.isReasoningOnly = true; reject(err); return; } reject(new Error('AI 返回为空')); return; } if (useStream && opts.onChunk && content && content !== streamedContent) { gotStream = true; opts.onChunk(content); } resolve(finish(cleaned, calls, usage)); return; } const data = JSON.parse(text); const choice = data.choices?.[0]; const msg = choice?.message; const content = stripThinkTags(msg?.content || ''); const calls = normalizeNativeToolCalls(msg?.tool_calls); if (!content && !calls.length) { if (msg?.reasoning_content) { if (!reqRetried && !aborted()) { reqRetried = true; console.warn('[SA-Agent] 模型仅返回推理内容(reasoning)无正文/工具调用,透明重试一次'); reqRef = GM_xmlhttpRequest(xhrParams); if (!isolated) activeAiRequest = reqRef; return; } const err = new Error('模型仅返回了推理内容(reasoning)未产出正文,请重试或更换为非推理模型'); err.isReasoningOnly = true; reject(err); return; } if (choice?.finish_reason === 'length') { reject(new Error('AI 输出被 max_tokens 截断,请重试或调大 token 上限')); return; } reject(new Error('AI 返回为空')); return; } resolve(finish(content, calls, data.usage)); } catch (e) { reject(new Error('AI 响应解析失败: ' + e.message)); } }, onerror: () => { if (aborted()) { if (useStream && streamedContent) resolve(finish(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 请求超时')); }, }; reqRef = GM_xmlhttpRequest(xhrParams); if (!isolated) activeAiRequest = reqRef; }); return p.finally(() => { if (!isolated && activeAiRequest === reqRef) activeAiRequest = null; }); } async function callAiApi(prompt, opts = {}) { const result = await callAiChat(prompt, opts); return result.content; } 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; } try { // 远程地址用户可自定义,同样走出站安全断言(仅拦内网与非 http(s) 协议) assertSafeRequestUrl(url); } catch (e) { reject(e); 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: '查评价、折扣和购买建议;也能读取本机其他 Steam 脚本的缓存,并核对插件是否装齐、版本是否过旧', icon: 'gamepad', keywords: ['steam', '游戏', '评价', '推荐', '打折', '分析', '购买'], builtin: true, systemPrompt: `你是 Steam 游戏助手,精通 Steam 平台的游戏评价、玩家数据分析和购买建议。 你的能力: 1. 游戏评价:基于 Steam Store 数据(好评率、价格、类型)给出客观评价 2. 玩家数据分析:分析玩家的游戏库、游玩时长、消费习惯 3. 购买建议:根据用户偏好和预算推荐游戏 4. 打折分析:判断当前折扣是否值得入手 5. 借用其他插件的本地缓存:游戏库时长、好友、家庭库、徽章、市场、许可来源、点数和消费汇总,优先调用 readPluginCache,不要先去打 Steam 接口 6. 插件安装核对:用户问装了哪些脚本、有没有装齐、版本是否过旧时,调用 getInstalledPlugins,按工具返回的「已装 / 未装 / 无法判断」口径回答,不要把无法判断说成未安装 回答规范: - 使用 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, // 工具子集授权:null/空 = 可用全部工具(未来新增工具默认可用) tools: (Array.isArray(data.tools) && data.tools.length) ? data.tools.filter(n => typeof n === 'string') : 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 }; // merge 后归一化 tools:非数组/空数组统一为 null(= 全部工具),确保"改回全部"能覆盖旧子集 if (!Array.isArray(agents[idx].tools) || !agents[idx].tools.length) agents[idx].tools = null; 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); } }, }; // ============================================================ // 工具注册表 // ============================================================ // 工具结果长度治理:超大结果(如全量游戏库)在回灌模型前截断, // 数组保留尽可能多的条目并附截断说明,防止撑爆上下文窗口。 function truncateToolResult(result) { if (result == null || typeof result !== 'object') return result; const max = CONFIG.TOOL_RESULT_MAX_CHARS; let json; try { json = JSON.stringify(result); } catch { return { success: false, error: '工具结果序列化失败' }; } if (json.length <= max) return result; const clipped = { ...result }; if (Array.isArray(result.data)) { // 数组:贪心保留条目,预留 600 字符给其余字段与截断说明 const kept = []; let used = 0; for (const item of result.data) { let len; try { len = JSON.stringify(item ?? null).length + 1; } catch { break; } if (used + len > max - 600) break; kept.push(item); used += len; } clipped.data = kept; clipped.truncated = true; clipped.originalCount = result.data.length; clipped.note = `结果共 ${result.data.length} 条,因长度限制仅保留前 ${kept.length} 条,请缩小查询范围(减少数量/增加过滤条件)后重试`; } else if (result.data != null) { clipped.data = truncateMiddle(typeof result.data === 'string' ? result.data : JSON.stringify(result.data), Math.max(500, max - 600)); clipped.truncated = true; clipped.note = `结果过长(${json.length} 字符)已截断,请缩小查询范围后重试`; } else { // 无 data 字段的杂型结果:整体截断为文本 return { success: result.success !== false, truncated: true, error: `结果过长(${json.length} 字符)已截断`, data: truncateMiddle(json, max), }; } return clipped; } 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()); }, // 工具可用性门:`enabled` 是可选谓词,false = 该工具在当前环境不可用 //(如 searchLocalDb 需要已配置远程数据中枢地址)。不可用的工具**不进 LLM 工具面**, // 避免把注定失败的工具喂给模型,再让它给用户一条走不通的引导。 _usable(t) { if (typeof t.enabled !== 'function') return true; try { return !!t.enabled(); } catch (e) { return true; } // 判定异常按可用处理(维持旧行为) }, // 过滤顺序:① 按智能体授权的工具名列表过滤(未传/为空 = 全部) // ② 过滤后为空(配置异常)回退全部 // ③ 按 enabled 裁掉当前不可用的工具(裁完可为空 = 真没工具, // 由 runAgentLoop 的 `nativeTools = openaiTools.length > 0` 自然降级文本协议) pickTools(toolNames) { const all = this.getAll(); let base = all; if (Array.isArray(toolNames) && toolNames.length) { const set = new Set(toolNames); const filtered = all.filter(t => set.has(t.name)); base = filtered.length ? filtered : all; } return base.filter(t => this._usable(t)); }, getToolsDescription(toolNames) { const tools = this.pickTools(toolNames); if (!tools.length) return '(无可用工具)'; return tools.map(t => `【${t.name}】${t.description}\n 参数: ${JSON.stringify(t.parameters.properties)}` ).join('\n\n'); }, toOpenAITools(toolNames) { return this.pickTools(toolNames).map(t => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters || { type: 'object', properties: {} }, }, })); }, async execute(name, args) { const tool = this.get(name); if (!tool) return { success: false, error: `工具 "${name}" 不存在` }; try { const result = await Promise.race([ tool.execute(args), new Promise((_, reject) => setTimeout(() => reject(new Error('工具执行超时')), CONFIG.TOOL_TIMEOUT) ), ]); return truncateToolResult(result); } 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 时自动使用当前登录用户;也可查询他人的公开愿望单。返回游戏名、折扣百分比、原价、现价、愿望单优先级。数据源:IWishlistService + IStoreBrowseService(2026-07 改版后的现行接口,单次全量无分页截断)', 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 } = 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)); try { // 1. 归一 SteamID64:17 位直用;自定义 ID 优先 ResolveVanityURL(需 Key),失败回退 community 愿望单页 HTML 提取 let id64 = steamId; if (!/^\d{17}$/.test(steamId)) { id64 = null; const apiKey = getSteamApiKey(); if (apiKey) { try { id64 = await resolveVanityToId64(steamId, apiKey); } catch {} } if (!id64) { try { const html = await gmFetch(`https://steamcommunity.com/id/${encodeURIComponent(steamId)}/wishlist`, { timeout: 15000 }); const m = String(html).match(/"steamid":"(7656\d{13})"/) || String(html).match(/g_steamID\s*=\s*"(7656\d{13})"/); if (m) id64 = m[1]; } catch {} } if (!id64) return { success: false, error: `自定义 ID "${steamId}" 未解析到 SteamID64(可能不存在)` }; } // 2. 全量愿望单:IWishlistService 匿名可用、单次返回全部(旧 wishlistdata 分页端点已于 2026-07 下线,302 到商店首页) const wlResp = await gmFetch(`https://api.steampowered.com/IWishlistService/GetWishlist/v1/?steamid=${id64}`, { timeout: 20000 }); const wlData = JSON.parse(wlResp); const wlItems = Array.isArray(wlData.response?.items) ? wlData.response.items : []; if (!wlItems.length) { return { success: true, data: { steamId: id64, totalInWishlist: 0, games: [], note: '愿望单为空,或为私密状态(两者在 API 层面不可区分;若预期有内容请确认 Steam 隐私设置中「游戏详情」为公开)' }, summary: '愿望单为空或私密', }; } // 3. IStoreBrowseService/GetItems 批量富集(100 appid/批 × 3 路并发)拿名称/价格/折扣;个别批次失败跳过 const MAX_ENRICH = 3000; const ids = wlItems.slice(0, MAX_ENRICH).map(x => ({ appid: x.appid })); const batches = []; for (let i = 0; i < ids.length; i += 100) batches.push(ids.slice(i, i + 100)); const storeItems = new Map(); let failedBatches = 0; await mapLimit(batches, 3, async (batch) => { const inputJson = JSON.stringify({ ids: batch, context: { language: 'schinese', country_code: getStoreCountryCode(), steam_realm: 1 }, data_request: { include_release: true, include_all_purchase_options: true }, }); try { const resp = await gmFetch(`https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=${encodeURIComponent(inputJson)}`, { timeout: 20000 }); const data = JSON.parse(resp); for (const it of (data.response?.store_items || [])) storeItems.set(it.appid, it); } catch { failedBatches++; } }); // 4. 归一化:折扣 = discount_pct > 0,或 active_discounts 非空(促销中但未披露百分比) const prioById = new Map(wlItems.map(x => [x.appid, Number(x.priority) || 0])); const normalized = wlItems.map(wl => { const it = storeItems.get(wl.appid); const bpo = it?.best_purchase_option || {}; const discount = Number(bpo.discount_pct ?? 0) || 0; const hasActive = Array.isArray(bpo.active_discounts) && bpo.active_discounts.length > 0; const finalCents = Number(bpo.final_price_in_cents ?? -1); const originalCents = Number(bpo.original_price_in_cents ?? -1); return { appid: wl.appid, name: it?.name || `AppID ${wl.appid}`, discount, promoWithoutPct: hasActive && discount === 0, finalPrice: (it?.is_free || finalCents === 0) ? '免费' : (bpo.formatted_final_price || (finalCents > 0 ? formatCents(finalCents, 'CNY') : '')), originalPrice: originalCents > 0 ? formatCents(originalCents, 'CNY') : (discount > 0 && finalCents > 0 ? formatCents(Math.round(finalCents / (1 - discount / 100)), 'CNY') : ''), priority: prioById.get(wl.appid) || 0, comingSoon: !!(it?.is_coming_soon || it?.release?.is_coming_soon), url: `https://store.steampowered.com/app/${wl.appid}`, }; }); const discounted = normalized .filter(g => g.discount > 0 || g.promoWithoutPct) .sort((a, b) => b.discount - a.discount || a.priority - b.priority); const list = (onlyDiscounted ? discounted : normalized).slice(0, limit); const data = { steamId: id64, totalInWishlist: normalized.length, discountedCount: discounted.length, games: list, }; if (failedBatches > 0) data.note = `${failedBatches} 个价格批次获取失败,部分条目可能缺价格信息`; if (wlItems.length > MAX_ENRICH) 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}%),返回 ${list.length} 款` }; } 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 {} } // v1.16.0 联网检索工具连通性检测:真打一次 search,拿到结果才算通 async function testWebToolsConnection() { const cfg = getWebToolsConfig(); if (!cfg.key) return { success: false, error: '未配置 Key:请填入 DeepSeek Key,或在「AI 设置」中把模型配成 DeepSeek 以自动复用' }; const r = await callWebTools('search', { query: 'Steam' }); if (!r.ok) return { success: false, error: r.error }; const n = Array.isArray(r.data && r.data.results) ? r.data.results.length : 0; return n > 0 ? { success: true, note: `检索连通正常,返回 ${n} 条结果(接口 ${cfg.base},Key 来源「${cfg.source}」)` } : { success: false, error: '接口可达但未返回任何结果,请确认该服务是否实现了 /search 端点' }; } 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 }; } } // 从 URL 中提取主机名(含端口),用于诊断提示 function extractHostFromUrl(url) { try { const u = new URL(url); return u.hostname + (u.port ? ':' + u.port : ''); } catch { return ''; } } // 判断 URL 是否指向 OpenAI 兼容的 chat/completions 端点 function looksLikeChatCompletions(url) { return /\/v\d*\/chat\/completions/i.test(url) || /\/chat\/completions/i.test(url); } // 诊断连接错误:把原始异常 + 用户填写的 URL 一起分析,返回多行诊断文案 function diagnoseConnectError(url, err) { const msg = String(err?.message || ''); const host = extractHostFromUrl(url); const lines = []; const httpMatch = msg.match(/HTTP (\d+)/); if (httpMatch) { const code = httpMatch[1]; if (code === '401' || code === '403') { lines.push('API Key 无效或无权限 (鉴权失败)'); } else if (code === '404') { lines.push('API 地址返回 404,请检查端点路径'); if (!looksLikeChatCompletions(url)) { lines.push('提示:地址未包含 /chat/completions,可能不是 OpenAI 兼容端点'); } } else if (code === '429') { lines.push('请求频率超限 (429),请稍后重试'); } else if (code === '400' || code === '422') { lines.push('请求被拒绝 (多为模型名称不正确或参数格式错误)'); } else if (code === '500' || code === '502' || code === '503') { lines.push(`服务端异常 (HTTP ${code}),目标 API 可能正在维护或过载`); } else { lines.push(`HTTP ${code}: ${msg.slice(0, 150)}`); } } else if (msg.includes('超时')) { lines.push('连接超时:网络不可达或响应太慢'); if (host) lines.push(`目标主机: ${host}`); } else if (msg.includes('网络错误')) { lines.push('请求被脚本管理器拦截(onerror)'); if (host) lines.push(`目标主机: ${host}`); lines.push('可能原因:域名未在 @connect 白名单中,或目标服务不可达'); lines.push('已添加 @connect * 后仍失败 → 检查 URL 拼写、网络连通性、目标服务是否运行'); } else if (msg.startsWith('Unexpected token') || msg.includes('JSON.parse')) { lines.push('响应体不是有效 JSON(收到 HTML 页面)'); if (host) lines.push(`目标主机: ${host}`); lines.push('可能原因:API 地址错误,返回了登录页/错误页/404 HTML'); if (!looksLikeChatCompletions(url)) { lines.push('提示:地址未包含 /chat/completions,请确认端点路径'); } } else { lines.push(msg || '未知错误'); } return lines.join('\n'); } // 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(); let resp; try { 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, // 用户显式配置的模型端点:允许本地模型(Ollama/LM Studio 等)与端点自身 allowPrivate: true, allowAiEndpoint: true, }); } catch (e) { e._diagnoseUrl = model.apiUrl; throw e; } let data; try { data = JSON.parse(resp); } catch { const preview = String(resp).slice(0, 200); const err = new Error(`Unexpected token '<', "${preview}" is not valid JSON`); err._diagnoseUrl = model.apiUrl; throw err; } 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 请求异常翻译为用户可读的诊断文案(优先使用 diagnoseConnectError 多行诊断) function describeModelError(e) { const msg = String(e?.message || ''); const url = e?._diagnoseUrl || ''; if (url) return diagnoseConnectError(url, e); 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 }; }, }); // ============================================================ // v1.12.0 插件缓存桥接(plugin-cache-bridge)阶段 1 // 读取本机其他 Steam 油猴插件写入的 IndexedDB / localStorage 缓存。 // // 设计要点(依据 docs/plugin-cache-bridge-plan.md): // 1. GM_setValue/GM_getValue 按脚本沙箱隔离,任何脚本都读不到其他脚本的 GM 存储, // 所以跨脚本复用的只有 IndexedDB 与 localStorage(同源共享)。 // 2. 全程只读:indexedDB.open(name) 一律不带版本号(避免触发 upgradeneeded 改库), // localStorage 只 getItem;不写、不删任何非本脚本的存储。 // 3. 每个缓存键自带 digest 函数做摘要裁剪,MB 级键(如 sfd_og_)禁止整读。 // 4. 域感知:store 与 community 的缓存互不可见,不匹配时明确提示而非静默返回空。 // ============================================================ const PC_DOMAIN_STORE = 'store'; const PC_DOMAIN_COMMUNITY = 'community'; // 当前页面所属域:store.steampowered.com → store;steamcommunity.com → community function pcCurrentDomain() { const h = String(location.hostname || ''); if (h === 'store.steampowered.com' || h.endsWith('.steampowered.com')) return PC_DOMAIN_STORE; if (h === 'steamcommunity.com' || h.endsWith('.steamcommunity.com')) return PC_DOMAIN_COMMUNITY; return ''; } // 把宿主插件的时间戳折算成"多少分钟前",用于提示模型声明数据新鲜度 function pcCacheAgeMinutes(value) { if (value == null || typeof value !== 'object') return null; const candidates = [value.updatedAt, value.ts, value.timestamp, value._ts, value.expires, value.cachedAt]; for (const c of candidates) { const n = Number(c); if (Number.isFinite(n) && n > 0) { const ms = n < 1e12 ? n * 1000 : n; // 兼容秒级时间戳 const diff = Date.now() - ms; if (diff < 0) continue; return Math.round(diff / 60000); } } return null; } // ---- 只读 IndexedDB 访问 ------------------------------------------------- // 不带版本号打开:若库不存在则 onsuccess 拿到空库,getAllKeys 抛错由调用方转成"缓存不存在"。 function pcIdbOpen(dbName) { return new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined' || !indexedDB) { reject(new Error('当前环境不支持 IndexedDB')); return; } let req; try { req = indexedDB.open(dbName); } catch (e) { reject(e); return; } req.onerror = () => reject(req.error || new Error('IndexedDB 打开失败')); // 刻意不设置 onupgradeneeded:不带版本号打开时不会触发升级 req.onsuccess = () => resolve(req.result); }); } function pcIdbGetAllKeys(db, storeName) { return new Promise((resolve, reject) => { try { if (!db.objectStoreNames.contains(storeName)) { resolve([]); return; } const tx = db.transaction(storeName, 'readonly'); const req = tx.objectStore(storeName).getAllKeys(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); } catch (e) { reject(e); } }); } function pcIdbGet(db, storeName, key) { return new Promise((resolve, reject) => { try { const tx = db.transaction(storeName, 'readonly'); const req = tx.objectStore(storeName).get(key); req.onsuccess = () => resolve(req.result === undefined ? null : req.result); req.onerror = () => reject(req.error); } catch (e) { reject(e); } }); } function pcIdbGetAll(db, storeName) { return new Promise((resolve, reject) => { try { if (!db.objectStoreNames.contains(storeName)) { resolve([]); return; } const tx = db.transaction(storeName, 'readonly'); const req = tx.objectStore(storeName).getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); } catch (e) { reject(e); } }); } function pcLocalGet(key) { try { const raw = localStorage.getItem(key); if (raw == null) return null; try { return JSON.parse(raw); } catch { return raw; } } catch { return null; } } // localStorage 前缀枚举(key() 逐个遍历,SME 的 sme_cache_ 有 LRU 数量有限) function pcLocalKeysWithPrefix(prefix) { const out = []; try { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(prefix)) out.push(k); } } catch {} return out; } // ---- IDB 存储信封解包(v1.14.0)------------------------------------------- // 部分宿主(如 SBC 徽章增强)写入 IDB 时套了一层 TTL 信封: // IDB.set → { key, data: <载荷>, updatedAt, expiresAt? } // IDB.get → return record.data ← 宿主自己脱掉信封 // 而本工具是用原生 objectStore.get() 只读,拿到的必然是**完整信封**。 // 若不剥掉,digest 收到的形状全对不上 → 全部 drifted。 // // 为什么用 envelope:true 显式声明,而不是按形状猜: // FA 的 fa_value_insights_cache 本身就是裸值但带 updatedAt 字段, // 靠猜必然误判。只有宿主自己知道它套没套信封。 function pcUnwrapEnvelope(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { payload: value, updatedAt: null, expiresAt: null, expired: false }; } // 信封判据:同时具备 data 载荷槽 + 至少一个宿主写入时才会带的元字段 const looksLikeEnvelope = Object.prototype.hasOwnProperty.call(value, 'data') && ('updatedAt' in value || 'expiresAt' in value || 'key' in value); if (!looksLikeEnvelope) { return { payload: value, updatedAt: null, expiresAt: null, expired: false }; } const expiresAt = Number(value.expiresAt) || 0; return { payload: value.data, updatedAt: Number(value.updatedAt) || 0, expiresAt, expired: expiresAt > 0 && Date.now() > expiresAt, }; } // 解析某键实际使用的 objectStore:优先 per-key 声明,回退插件级(向后兼容) function pcStoreOf(reg, spec) { return (spec && spec.store) || (reg.idb && reg.idb.store) || null; } // ---- digest 摘要函数 ----------------------------------------------------- // 每个 digest 接收完整值 + {topN, fields},返回裁剪后的摘要。 // 硬约束:输出必须显著小于 CONFIG.TOOL_RESULT_MAX_CHARS,MB 级键只出摘要不出明细。 function pcPick(obj, fields) { if (!fields || !Array.isArray(fields) || !fields.length || obj == null || typeof obj !== 'object') return obj; const out = {}; let hit = 0; for (const f of fields) { if (Object.prototype.hasOwnProperty.call(obj, f)) { out[f] = obj[f]; hit++; } } return hit ? out : obj; } // 通用数组摘要:总条数 + 前 topN 条(可字段裁剪) function pcGenericArrayDigest(value, ctx) { const arr = Array.isArray(value) ? value : null; if (!arr) { // 非数组:当作对象/标量,给出键列表概览 if (value && typeof value === 'object') { const keys = Object.keys(value); return { kind: 'object', keyCount: keys.length, keysSample: keys.slice(0, 30), hint: '非数组结构,如需明细请用 inspect 模式查看结构', }; } return { kind: 'scalar', value }; } const fields = (ctx.fields || '').split(',').map(s => s.trim()).filter(Boolean); return { kind: 'array', total: arr.length, items: arr.slice(0, ctx.topN).map(x => pcPick(x, fields)), }; } // 游戏库摘要:Steam OwnedGames 结构 {appid, name, playtime_forever(分钟), rtime_last_played} function pcGamesDigest(value, ctx) { const arr = Array.isArray(value) ? value : (value && Array.isArray(value.games) ? value.games : null); if (!arr) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const totalMinutes = arr.reduce((s, g) => s + (Number(g && g.playtime_forever) || 0), 0); const sorted = arr.slice().sort((a, b) => (Number(b && b.playtime_forever) || 0) - (Number(a && a.playtime_forever) || 0)); return { kind: 'ownedGames', totalGames: arr.length, totalPlaytimeHours: Math.round(totalMinutes / 6) / 10, topGames: sorted.slice(0, ctx.topN).map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtimeHours: Math.round((Number(g.playtime_forever) || 0) / 6) / 10, lastPlayed: g.rtime_last_played || 0, })), note: '按游玩时长降序返回 topN;如需某款具体游戏,可先取 topGames 再用 getGameDetail 补详情', }; } // 逐好友全库摘要(MB 级):只出总数/总时长/topN,禁止整读 function pcOwnedGamesDigest(value, ctx) { if (!value || typeof value !== 'object' || !Array.isArray(value.games)) { return { drifted: true, kind: 'unknown', sample: pcGenericArrayDigest(value, { topN: 3, fields: '' }) }; } const games = value.games; const sorted = games.slice().sort((a, b) => (Number(b && b.playtime_forever) || 0) - (Number(a && a.playtime_forever) || 0)); return { kind: 'ownedGamesFull', steamId: value.sid || null, gameCount: value.game_count ?? games.length, totalMinutes: Number(value.totalMinutes) || games.reduce((s, g) => s + (Number(g && g.playtime_forever) || 0), 0), isPrivate: value.private === true, cachedAt: value.ts || null, topGames: sorted.slice(0, Math.min(ctx.topN, 50)).map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtimeHours: Math.round((Number(g.playtime_forever) || 0) / 6) / 10, })), note: 'MB 级缓存仅返回摘要与 topN,未整读明细', }; } // 好友列表摘要 function pcFriendsDigest(value, ctx) { const arr = Array.isArray(value) ? value : (value && Array.isArray(value.friends) ? value.friends : null); if (!arr) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; return { kind: 'friends', totalFriends: arr.length, friends: arr.slice(0, ctx.topN).map(f => ({ steamId: f.steamid || f.steamId || null, name: f.personaname || f.name || null, personaname: f.personaname || f.name || null, friendSince: f.friend_since || null, avatar: f.avatarfull || f.avatarmedium || null, })), }; } // 家庭共享库摘要:{steamid, familyInfo{family_member[]}, familyGameList[], timestamp} function pcFamilyDigest(value, ctx) { if (!value || typeof value !== 'object') return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const fi = value.familyInfo || {}; const members = Array.isArray(fi.family_member) ? fi.family_member : []; const gameList = Array.isArray(value.familyGameList) ? value.familyGameList : []; const gameInfo = value.familyGameList && !Array.isArray(value.familyGameList) && typeof value.familyGameList === 'object' ? value.familyGameList : null; const detail = gameInfo ? Object.entries(gameInfo) : gameList.slice(0, ctx.topN).map(g => [g && g.appid, g]); return { kind: 'familySharedLibrary', familyGroupId: fi.family_groupid || null, memberCount: members.length, members: members.slice(0, ctx.topN).map(m => ({ steamId: m.steamid, name: m.userName || null, role: m.family_role || null })), sharedGameCount: detail.length, sharedGames: detail.slice(0, ctx.topN).map(([appid, g]) => ({ appid, name: (g && g.name) || `App ${appid}`, owners: g && Array.isArray(g.owners) ? g.owners.length : null, })), cachedAt: value.timestamp || null, }; } // 家庭价值洞察摘要:{priceData:{appid:[fp,op,disc,isFree,isComingSoon]}, playtimeData:{appid:[total,recent]}, updatedAt, ts} function pcValueInsightsDigest(value, ctx) { if (!value || typeof value !== 'object' || !value.priceData) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const price = value.priceData || {}; const pt = value.playtimeData || {}; const appids = Array.from(new Set([...Object.keys(price), ...Object.keys(pt)])); const rows = appids.map(appid => { const p = price[appid]; const t = pt[appid]; return { appid, finalPrice: Array.isArray(p) ? (Number(p[0]) || 0) / 100 : null, discountPct: Array.isArray(p) ? (Number(p[2]) || 0) : null, isFree: Array.isArray(p) ? !!p[3] : null, totalHours: Array.isArray(t) ? Math.round((Number(t[0]) || 0) / 6) / 10 : null, }; }); rows.sort((a, b) => (b.totalHours || 0) - (a.totalHours || 0)); return { kind: 'familyValueInsights', totalApps: appids.length, topByPlaytime: rows.slice(0, ctx.topN), updatedAt: value.updatedAt || value.ts || null, note: '价格单位为元(源数据为分),时长为小时', }; } // 许可指纹摘要:SLC 的 "日期||游戏名||类型" 字符串数组 function pcLicenseDigest(value, ctx) { const arr = Array.isArray(value) ? value : null; if (!arr) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const parsed = arr.map(s => { const parts = String(s).split('||'); return { date: parts[0] || '', name: parts[1] || '', type: parts[2] || '' }; }); const byType = {}; for (const p of parsed) byType[p.type || '未知'] = (byType[p.type || '未知'] || 0) + 1; const sorted = parsed.slice().sort((a, b) => String(b.date).localeCompare(String(a.date))); return { kind: 'licenseFingerprints', total: parsed.length, byType, licenses: sorted.slice(0, ctx.topN), note: '来源为 Steam 许可页本地缓存(7 天 TTL);数字键 slc_license_total 为总记录数', }; } // 标量摘要 function pcScalarDigest(value) { return { kind: 'scalar', value }; } // 市场缓存摘要:SME 的 {ts, data} 信封 function pcMarketCacheDigest(value, ctx) { if (!value || typeof value !== 'object' || !('ts' in value) || !('data' in value)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const inner = value.data; const summary = { kind: 'marketCache', cachedAt: value.ts, dataShape: Array.isArray(inner) ? `array(${inner.length})` : typeof inner }; if (Array.isArray(inner)) { summary.sample = inner.slice(0, ctx.topN); summary.total = inner.length; } else if (inner && typeof inner === 'object') { const keys = Object.keys(inner); summary.keys = keys.slice(0, 20); summary.keyCount = keys.length; } return summary; } // 库存摘要:SME 的 { [key]: {ts, data: [[k,v],...]} } function pcInventoryDigest(value, ctx) { if (!value || typeof value !== 'object') return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const groups = Object.keys(value); const rows = groups.slice(0, Math.min(ctx.topN, 10)).map(k => { const g = value[k]; const arr = g && Array.isArray(g.data) ? g.data : []; return { group: k, cachedAt: g && g.ts, itemCount: arr.length }; }); return { kind: 'inventory', groupCount: groups.length, groups: rows, note: '库存明细体积大,仅返回分组与条目数' }; } // 徽章增强(SBC)好友排行榜摘要。 // 载荷 {rows:[{steamId,badgeCount,level,xp,name,avatar,pointsItems}], fetchedAt} // badgeCount = -1 表示该好友资料私密(GetBadges 403),宿主会把这类沉底 // pointsItems= null 未抓取 / -1 私密 / 数字为点数商店装扮数(v1.10.0 新增维度) // 排序语义对齐宿主 sortFriendsLbRows:私密永远沉底,其余按指定维度降序。 const PC_LB_DIMENSIONS = { badge: { field: 'badgeCount', label: '徽章数' }, level: { field: 'level', label: '徽章等级' }, xp: { field: 'xp', label: '经验值' }, points: { field: 'pointsItems', label: '点数商店装扮数' }, }; function pcFriendsLbDigest(value, ctx) { if (!value || typeof value !== 'object' || !Array.isArray(value.rows)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const rows = value.rows; // 用户可用 fields 传 'level' / 'xp' / 'points' 指定排序维度(缺省按徽章数) const dimKey = String(ctx.fields || 'badge').trim(); const dim = PC_LB_DIMENSIONS[dimKey] || PC_LB_DIMENSIONS.badge; const val = (r) => { const v = r[dim.field]; if (dim.field === 'pointsItems' && v == null) return -2; // 未抓取沉底(对齐宿主) return Number(v) || 0; }; const sorted = rows.slice().sort((a, b) => { const ap = Number(a.badgeCount) < 0, bp = Number(b.badgeCount) < 0; if (ap !== bp) return ap ? 1 : -1; // 私密沉底 return (val(b) - val(a)) || ((Number(b.badgeCount) || 0) - (Number(a.badgeCount) || 0)); }); const normal = rows.filter(r => Number(r.badgeCount) >= 0); const priv = rows.length - normal.length; const ptsFetched = rows.filter(r => r.pointsItems != null && Number(r.pointsItems) >= 0); return { kind: 'friendsBadgeLeaderboard', sortBy: dim.label, totalFriends: rows.length, withBadges: normal.length, privateProfiles: priv, summary: { totalBadges: normal.reduce((s, r) => s + (Number(r.badgeCount) || 0), 0), avgBadges: normal.length ? Math.round(normal.reduce((s, r) => s + (Number(r.badgeCount) || 0), 0) / normal.length * 10) / 10 : 0, maxBadges: normal.length ? Math.max(...normal.map(r => Number(r.badgeCount) || 0)) : 0, maxLevel: normal.length ? Math.max(...normal.map(r => Number(r.level) || 0)) : 0, pointsShopItemsFetched: ptsFetched.length, pointsShopItemsTotal: ptsFetched.reduce((s, r) => s + (Number(r.pointsItems) || 0), 0), }, top: sorted.slice(0, ctx.topN).map(r => ({ name: r.name || `用户 ${String(r.steamId || '').slice(-6)}`, steamId: r.steamId || null, badgeCount: Number(r.badgeCount) < 0 ? '私密' : (Number(r.badgeCount) || 0), level: Number(r.level) || 0, xp: Number(r.xp) || 0, pointsItems: r.pointsItems == null ? '未抓取' : (Number(r.pointsItems) < 0 ? '私密' : Number(r.pointsItems)), })), fetchedAt: value.fetchedAt || null, note: priv > 0 ? `${priv} 位好友资料私密(徽章数不可见,已沉底)` : null + (ptsFetched.length < rows.length ? ';部分好友未抓取点数装扮数(需在插件内点「获取点数装扮」)' : ''), }; } // 卡牌数据库摘要:{data:{appid:{name,cardCount}}, timestamp} function pcCardDbDigest(value, ctx) { const db = (value && value.data && typeof value.data === 'object') ? value.data : (value && typeof value === 'object' ? value : null); if (!db) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const ids = Object.keys(db); const rows = ids.map(appid => ({ appid, name: (db[appid] && db[appid].name) || `App ${appid}`, cardCount: Number(db[appid] && db[appid].cardCount) || 0, })).sort((a, b) => b.cardCount - a.cardCount); return { kind: 'cardDatabase', totalGames: ids.length, totalCards: rows.reduce((s, r) => s + r.cardCount, 0), topByCardCount: rows.slice(0, ctx.topN), timestamp: value.timestamp || null, note: '卡牌数据库为全量游戏→卡牌数映射,此处只出 topN', }; } // 单组卡牌市场价格摘要:{appId, cards[], avgPrice, medianPrice, netIncome, ...} // ⚠️ cards/regularCards/foilCards 三数组最长各 300 张,绝不能原样返回。 function pcCardPriceDigest(value, ctx) { if (!value || typeof value !== 'object' || value.avgPrice == null) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const toYuan = (cents) => (Number(cents) || 0) / 100; const pick = (arr) => (Array.isArray(arr) ? arr : []) .filter(c => c && (c.price != null || c.sell_price != null)) .map(c => ({ name: c.name || null, price: toYuan(c.price != null ? c.price : c.sell_price), })) .sort((a, b) => b.price - a.price); const regular = pick(value.regularCards).slice(0, ctx.topN); const foil = pick(value.foilCards).slice(0, ctx.topN); return { kind: 'cardPrices', appId: value.appId || null, cardCount: Number(value.cardCount) || 0, regularCount: Number(value.regularCount) || 0, foilCount: Number(value.foilCount) || 0, pricing: { totalCost: toYuan(value.totalCost), avgPrice: toYuan(value.avgPrice), medianPrice: toYuan(value.medianPrice), avgNoMaxPrice: toYuan(value.avgNoMaxPrice), regularAvgPrice: toYuan(value.regularAvgPrice), foilAvgPrice: toYuan(value.foilAvgPrice), netIncome: toYuan(value.netIncome), priceSampleCount: Number(value.priceCount) || 0, }, topRegularByPrice: regular, topFoilByPrice: foil, updatedAt: value.updatedAt || null, note: '金额单位已由分换算为元;卡牌明细仅取单价最高的 topN,未整读 cards 数组', }; } // 热门市场物品摘要:数组(宿主已 slice 到 DISPLAY_COUNT) function pcPopularMarketDigest(value, ctx) { const arr = Array.isArray(value) ? value : null; if (!arr) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; return { kind: 'popularMarket', total: arr.length, items: arr.slice(0, ctx.topN), note: '热门市场由 /market/popular 轮询累积去重而来,反映近期社区交易热点', }; } // ---- 社交中心(Steam Social Core)/ 好友动态(Steam Activity Modern)---- // 两者的 IDB 都在 community 域、且都是**裸值**(时间戳在载荷内部,不套信封), // 因此无需 envelope:true,pcCacheAgeMinutes 可直接从 payload.updatedAt 取。 // 注意:它们各自独占一个库 → 需要两个注册项(现注册表一个插件对应一个 idb.name)。 // 好友数排行:{[steamId]: {n:好友数, p:私密(1/0), t:更新时间}} // 这是社交中心「好友数排行」页的底层数据,永久缓存 + 7 天 stale 增量刷新。 function pcFriendCountDigest(value, ctx) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const ids = Object.keys(value); if (!ids.length) return { kind: 'friendCounts', total: 0, friends: [], note: '社交中心尚未建立好友数缓存' }; const rows = ids.map(sid => { const r = value[sid] || {}; return { steamId: sid, name: null, // 该库不存昵称,需与 roster 联查 friendCount: r.p ? '私密' : (Number(r.n) || 0), private: r.p === 1 || r.p === true, updatedAt: Number(r.t) || null, }; }); // 私密沉底(与宿主榜单语义一致:0 无法区分"0 个好友"与"私密") // 注意方向:比较器返回正数表示 a 排在 b 之后,故用 (a.private - b.private) // 才能把私密项推到末尾;写成 (b.private - a.private) 会把私密顶到榜首。 rows.sort((a, b) => (a.private - b.private) || ((Number(b.friendCount) || 0) - (Number(a.friendCount) || 0))); const ages = rows.map(r => r.updatedAt).filter(Boolean); const newest = ages.length ? Math.max(...ages) : 0; return { kind: 'friendCounts', totalFriendsTracked: ids.length, privateCount: rows.filter(r => r.private).length, top: rows.slice(0, ctx.topN), dataAgeMinutes: newest ? Math.max(0, Math.round((Date.now() - newest) / 60000)) : null, note: rows.some(r => !r.name) ? '该库只存 steamId 不存昵称,可先用 key="roster" 取名对应' : null, }; } // 游玩快照(热力图数据):{[steamId]: [{d:'YYYY-MM-DD', m:累计分钟}]} // 每个好友保留最近 21 天(宿主上限),可答"好友最近两周在玩什么、时长变化如何"。 function pcPlaySnapDigest(value, ctx) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const ids = Object.keys(value).filter(sid => Array.isArray(value[sid]) && value[sid].length); if (!ids.length) return { kind: 'playSnapshots', friends: 0, note: '社交中心尚未建立游玩快照(需打开深度大盘累积)' }; const rows = ids.map(sid => { const arr = value[sid]; const first = arr[0] || {}, last = arr[arr.length - 1] || {}; const delta = (Number(last.m) || 0) - (Number(first.m) || 0); return { steamId: sid, days: arr.length, firstDay: first.d || null, lastDay: last.d || null, lastTotalMinutes: Number(last.m) || 0, deltaMinutesLastToFirst: delta, }; }).sort((a, b) => b.deltaMinutesLastToFirst - a.deltaMinutesLastToFirst); const dayKeys = Array.from(new Set(ids.flatMap(sid => value[sid].map(x => x.d)).filter(Boolean))).sort(); return { kind: 'playSnapshots', friends: ids.length, coveredDays: dayKeys.length, dayRange: dayKeys.length ? { from: dayKeys[0], to: dayKeys[dayKeys.length - 1] } : null, topByGrowth: rows.slice(0, ctx.topN), note: 'm 为该好友「累计游戏时长(分钟)」的每日快照,delta 为区间内净增长;数值不下降属正常(时长只增不减)', }; } // 好友名册:{own:{...}, friends:[{steamid, personaname, avatar, ...}], updatedAt} function pcRosterDigest(value, ctx) { if (!value || typeof value !== 'object' || !Array.isArray(value.friends)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const f = value.friends; return { kind: 'friendRoster', own: value.own ? { steamId: value.own.steamid || null, name: value.own.personaname || null } : null, totalFriends: f.length, friends: f.slice(0, ctx.topN).map(x => ({ steamId: x.steamid || null, name: x.personaname || x.name || null, level: x.level ?? null, currentGame: x.gameextrainfo || x.gameName || null, })), updatedAt: value.updatedAt || null, note: '名册含昵称/头像/等级,可与 fcount、level、snaps 联查', }; } // 好友等级:{[steamId]: level} function pcFriendLevelDigest(value, ctx) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const ids = Object.keys(value); if (!ids.length) return { kind: 'friendLevels', total: 0, note: '社交中心尚未抓取好友等级' }; // v1.31.0 兼容两种值形态:纯数字(sfd_level_cache / sac level)与 {level} 对象 const rows = ids.map(sid => { const v = value[sid]; const level = Number((v && typeof v === 'object') ? v.level : v) || 0; return { steamId: sid, name: null, level }; }).sort((a, b) => b.level - a.level); // v1.31.0 等级分桶:模型不翻全量名单也能先答「有没有超过 X 级的人」 const ge = (t) => rows.filter(r => r.level >= t).length; return { kind: 'friendLevels', total: ids.length, maxLevel: rows[0].level, avgLevel: Math.round(rows.reduce((s, r) => s + r.level, 0) / rows.length * 10) / 10, levelBuckets: { '300+': ge(300), '200+': ge(200), '100+': ge(100), '50+': ge(50) }, top: rows.slice(0, ctx.topN), note: '该库只存 steamId→等级,昵称需联查 roster(sac)或 sfd_cached_friends_data(sfd)', }; } // 好友动态:{items:[{type,timestamp,appid,steamId,textHash,dateText}], updatedAt, count} // type 枚举来自宿主 getBlockType:screenshot/game/review/workshop/favorite/wishlist/achievement/other function pcFriendActivityDigest(value, ctx) { if (!value || typeof value !== 'object' || !Array.isArray(value.items)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const items = value.items; const byType = {}; for (const it of items) byType[it.type || 'other'] = (byType[it.type || 'other'] || 0) + 1; const sorted = items.slice().sort((a, b) => (Number(b.timestamp) || 0) - (Number(a.timestamp) || 0)); return { kind: 'friendActivity', total: items.length, declaredCount: value.count ?? items.length, byType, latest: sorted.slice(0, ctx.topN).map(it => ({ steamId: it.steamId || null, type: it.type || 'other', appid: it.appid || null, dateText: it.dateText || null, timestamp: Number(it.timestamp) || 0, })), updatedAt: value.updatedAt || null, note: '来源为好友动态页 DOM 解析(Steam 未开放该数据 API),仅覆盖用户曾打开过动态页的好友', }; } // ---- 宿主插件桥(阶段 2)--------------------------------------------------- // SGLV / FA 写入 store 域 localStorage 的 __saa_bridge_*__ 键。 // 桥存在的意义:GM 存储沙箱隔离,AI 读不到 GM;但同源 localStorage 共享,故宿主主动镜像一份。 // 无数据时宿主写 {v:1, updatedAt, wishlist/rates: null},据此可区分"桥在但无数据"与"桥不存在(宿主未装/未更新)"。 // SGLV 桥摘要:rates 直接返回;names 按 topN + 前缀过滤 function pcSglvBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const out = { kind: 'sglvBridge', bridgeVersion: value.v, rates: null, names: null, namesSkipped: value.namesSkipped || null, }; // 汇率:体量小(~2KB)原样返回,AI 回答中文名/跨区比价直接可用。 // 注意:桥顶层 updatedAt 是"桥写入时刻",不等于底层 GM 数据的真实年龄, // 因此这里额外透出 ratesAgeMinutes(源自宿主 GM 的 sgis-rates-ts),由模型据此声明新鲜度。 if (value.rates && typeof value.rates === 'object' && value.rates.map) { const map = value.rates.map; const entries = Object.entries(map); const ratesAge = pcCacheAgeMinutes({ ts: value.rates.ts }); out.rates = { semantic: '1 外币 = X CNY', cachedAt: value.rates.ts || null, ratesAgeMinutes: ratesAge, ratesStale: ratesAge != null && ratesAge > 24 * 60, currencyCount: entries.length, map: entries.slice(0, 40).reduce((acc, [k, v]) => { acc[k] = v; return acc; }, {}), }; } // 中文名:可能上万条,只按 topN 切片 + 支持字段前缀过滤(ctx.fields 形如 "730,appid") if (value.names && typeof value.names === 'object') { const allIds = Object.keys(value.names); let ids = allIds; const f = String(ctx.fields || '').trim(); if (f) { const want = f.split(',').map(s => s.trim()).filter(Boolean); const matched = allIds.filter(id => want.includes(id)); // 只有当过滤确实命中才生效,避免模型误填字段名导致空结果 if (matched.length) ids = matched; } out.names = { total: allIds.length, returned: Math.min(ids.length, ctx.topN), filteredByRequest: f ? ids.length !== allIds.length : false, entries: ids.slice(0, ctx.topN).map(id => ({ appid: id, name: value.names[id] })), }; } else if (value.namesSkipped) { out.namesNote = `宿主因体积超限未镜像中文名(namesSkipped=${value.namesSkipped}),如需中文名请改用 getGameDetail(l=schinese)`; } return out; } // FA 桥摘要:家庭愿望单 GameList + GameInfo function pcFaBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const wl = value.wishlist; const out = { kind: 'faBridge', bridgeVersion: value.v, familyGroupId: value.gid || null, wishlistPresent: !!wl, }; if (!wl) { out.note = '桥已写入但家庭愿望单暂无数据(FA 插件可能未打开过家庭愿望单面板)'; return out; } const gameList = Array.isArray(wl.GameList) ? wl.GameList : []; const gameInfo = (wl.GameInfo && typeof wl.GameInfo === 'object') ? wl.GameInfo : {}; out.wishlist = { totalGames: gameList.length, infoCount: Object.keys(gameInfo).length, truncatedByBridge: wl.truncated === true, games: gameList.slice(0, ctx.topN).map(appid => { const gi = gameInfo[appid] || {}; const wishers = Array.isArray(gi.wishers) ? gi.wishers : []; return { appid, name: gi.name || null, wishers: wishers.length ? wishers : null, finalPrice: gi.finalPrice || null, originalPrice: gi.originalPrice || null, discountPct: gi.discountPct || null, onSale: gi.onSale === true, isFree: gi.isFree === true, releaseDate: gi.releaseDate || null, priority: gi.priority || null, }; }), }; if (gameList.length > ctx.topN) { out.wishlist.note = `共 ${gameList.length} 款,仅返回前 ${ctx.topN} 款,可增大 topN 重试`; } return out; } // ---- 缓存注册表 ---------------------------------------------------------- // domain:该缓存只能在该域的页面读取;digest:摘要裁剪函数 // 消费历史桥摘要(Spending 分类器 v2.3.0+ 镜像):分类聚合 + 月度趋势 + 明细切片。 // 口径与 getSpendingHistory 对齐:净支出 = 直购+内购+送礼−退款,**仅统计主货币行** // (v1.34.0 修正:旧版把外币行金额直接加进总额,跨区消费会把 CNY 总额算爆)。 // 宿主 v2.5.0 起即使明细因 localStorage 配额被丢弃,也会写 aggregate,这里直接采纳。 function pcSpendBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const rows = Array.isArray(value.rows) ? value.rows : []; const primary = value.primaryCurrency || null; const out = { kind: 'spendBridge', bridgeVersion: value.v, updatedAt: value.updatedAt || null, primaryCurrency: primary, complete: value.complete === true, rowsPresent: rows.length, totalRows: value.totalRows != null ? value.totalRows : rows.length, rowsSkipped: value.rowsSkipped || null, truncated: value.truncated === true, }; const hostAgg = value.aggregate && typeof value.aggregate === 'object' ? value.aggregate : null; // 只镜像了聚合(v2.5.0 的配额降级):聚合仍可答总额/月度,但明细与逐笔不可得 if (!rows.length) { if (hostAgg) { out.aggregate = hostAgg; out.aggregateFromHost = true; out.note = '宿主只镜像了聚合,未镜像逐笔明细' + (value.rowsSkipped === 'quota' ? '(localStorage 配额超限)' : value.rowsSkipped ? `(${value.rowsSkipped})` : '') + (out.complete ? ';页面已加载全量,聚合可直接引用' : ';页面未加载全量,聚合只覆盖已加载部分,可能低估真实总额'); return out; } out.note = value.rowsSkipped ? `宿主因 localStorage 配额未镜像明细(rowsSkipped=${value.rowsSkipped})且无聚合兜底;请改用 getSpendingHistory(v1.34.0 起会自抓并可用桥兜底)` : '桥已写入但暂无消费记录:Spending 分类器可能尚未在该浏览器打开 /account/history 页面并完成分析'; return out; } if (!out.complete) { out.note = `仅覆盖页面已加载的 ${rows.length} 条(Steam「加载更多」未点完),月度趋势以此为口径;可请用户在消费历史页点一次「加载更多」自动级联加载全量`; } const CATS = ['store', 'ingame', 'gift', 'refund', 'convert', 'market_buy', 'market_sell']; const CAT_LABEL = { store: '直购', ingame: '内购', gift: '送礼', refund: '退款', convert: '充值', market_buy: '买入', market_sell: '卖出' }; const byCat = {}, byMonth = {}; let gross = 0, refunded = 0; for (const r of rows) { const cat = CATS.includes(r.category) ? r.category : null; if (!cat) continue; const amt = Number(r.amount) || 0; const cnt = Number(r.marketCount) || 1; // 与自抓口径一致:非主货币行只计笔数、不计金额 const isPrimary = !primary || r.currency === primary; if (!byCat[cat]) byCat[cat] = { count: 0, spend: 0 }; byCat[cat].count += cnt; if (isPrimary) { if (cat === 'store' || cat === 'ingame' || cat === 'gift') { byCat[cat].spend += amt; gross += amt; } else if (cat === 'refund') { byCat[cat].spend -= amt; refunded += amt; } } const month = r.ts ? new Date(r.ts).toISOString().slice(0, 7) : null; if (month) { if (!byMonth[month]) byMonth[month] = { count: 0, net: 0 }; byMonth[month].count += cnt; if (!isPrimary) continue; if (cat === 'store' || cat === 'ingame' || cat === 'gift') byMonth[month].net += amt; else if (cat === 'refund') byMonth[month].net -= amt; } } out.aggregate = { netSpend: Math.round((gross - refunded) * 100) / 100, grossSpend: Math.round(gross * 100) / 100, refundTotal: Math.round(refunded * 100) / 100, categories: CATS.filter(c => byCat[c]).map(c => ({ id: c, label: CAT_LABEL[c], count: byCat[c].count, spend: Math.round(byCat[c].spend * 100) / 100 })), monthly: Object.keys(byMonth).sort().reverse().slice(0, 12).map(k => ({ month: k, count: byMonth[k].count, net: Math.round(byMonth[k].net * 100) / 100 })), }; const topN = Math.min(ctx.topN || 20, 50); out.recent = rows.slice(0, topN).map(r => ({ date: r.date, item: r.item, category: r.category, amount: r.amount, currency: r.currency || null, ...(r.appid ? { appid: r.appid } : {}), })); if (rows.length > topN) out.recentNote = `仅返回最近 ${topN} 笔,明细可增大 topN(上限 50)`; return out; } // 入库历史桥摘要(SLC 许可分类器 v2.6.0 镜像):来源分布 + 月度入库趋势 + 明细切片/按名过滤。 // fields 语义:游戏名过滤(包含匹配),回答「XX 是怎么获得的/什么时候入库的」时用。 function pcSlcBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const rows = Array.isArray(value.rows) ? value.rows : []; const out = { kind: 'slcBridge', bridgeVersion: value.v, updatedAt: value.updatedAt || null, total: value.total != null ? value.total : rows.length, rowsPresent: rows.length, counts: value.counts || null, rowsSkipped: value.rowsSkipped || null, truncated: value.truncated === true, }; if (value.rowsSkipped) { out.note = '宿主因 localStorage 体积超限未镜像明细(rowsSkipped=quota);来源计数仍可用 counts 字段'; return out; } if (!rows.length) { out.note = '桥已写入但暂无入库记录:SLC 可能尚未在该浏览器打开 /account/licenses 页面并完成加载'; return out; } // 月度入库趋势(ts 毫秒;无 ts 的行不计入) const byMonth = {}; for (const r of rows) { const ts = Number(r.ts); if (!ts) continue; const month = new Date(ts).toISOString().slice(0, 7); byMonth[month] = (byMonth[month] || 0) + 1; } out.monthly = Object.keys(byMonth).sort().reverse().slice(0, 12) .map(k => ({ month: k, count: byMonth[k] })); // fields:游戏名包含匹配(「XX 怎么获得的」场景),不填则返回最近入库 const topN = Math.min(ctx.topN || 20, 50); const f = String(ctx.fields || '').trim(); const CAT_LABEL = { store: '商店', retail: '零售', gift: '礼物', free: '免费' }; if (f) { const matched = rows.filter(r => (r.name || '').includes(f)); out.filteredBy = f; out.matchedTotal = matched.length; out.records = matched.slice(0, topN).map(r => ({ name: r.name, date: r.date, ts: r.ts || null, category: r.category, source: CAT_LABEL[r.category] || r.category || null, })); if (matched.length > topN) out.recordsNote = `共 ${matched.length} 条匹配,仅返回前 ${topN} 条`; } else { out.recent = rows.slice(0, topN).map(r => ({ name: r.name, date: r.date, ts: r.ts || null, category: r.category, source: CAT_LABEL[r.category] || r.category || null, })); if (rows.length > topN) out.recentNote = `仅返回最近 ${topN} 条,可增大 topN(上限 50)`; } return out; } // 点数商店桥摘要(Steam 点数商店增强 v2.3.0 镜像):余额/累计获得/退款/兑换 + 明细行切片。 // 数据源是宿主插件对 /pointssummary/ 的解析(kind: earn=获得 refund=退款返还 redeem=兑换消费 balance=余额)。 function pcPtsBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const KIND_LABEL = { earn: '获得', refund: '退款返还', redeem: '兑换消费', balance: '余额' }; const rows = Array.isArray(value.rows) ? value.rows : []; const out = { kind: 'ptsBridge', bridgeVersion: value.v, updatedAt: value.updatedAt || null, balance: value.balance != null ? value.balance : null, earned: value.earned != null ? value.earned : null, refunded: value.refunded != null ? value.refunded : null, redeemed: value.redeemed != null ? value.redeemed : null, partial: value.partial === true, rowsPresent: rows.length, rowsSkipped: value.rowsSkipped || null, }; if (out.partial) { out.note = '宿主仅从页面提取到点数余额(partial),明细缺失;可让用户打开点数商店页稍候自动重抓'; } if (value.rowsSkipped) out.rowsNote = '宿主因体积超限未镜像明细行(rowsSkipped=quota)'; if (rows.length) { out.records = rows.slice(0, Math.min(ctx.topN || 20, 50)).map(r => ({ label: r.label || null, amount: r.amount || 0, kind: r.kind || null, kindLabel: KIND_LABEL[r.kind] || r.kind || null, })); } else if (!out.partial && !value.rowsSkipped) { out.note = '桥已写入但暂无明细行:点数商店增强可能尚未完成 /pointssummary/ 解析'; } return out; } const PLUGIN_CACHE_REGISTRY = { sglv: { label: 'SGLV 游戏库展示', domain: PC_DOMAIN_STORE, idb: { name: 'sglv_kv', store: 'kv' }, keys: { sglv_cached_games: { desc: '游戏库列表+游玩时长(数组,元素含 appid/name/playtime_forever)', digest: pcGamesDigest }, sglv_family_info: { desc: '家庭共享信息', digest: pcGenericArrayDigest }, sglv_pt_daily_history: { desc: '入库/游玩时长日历史', digest: pcGenericArrayDigest }, }, }, sfd: { label: 'SFD 好友管理', domain: PC_DOMAIN_COMMUNITY, idb: { name: 'sfd_storage', store: 'kv' }, keys: { sfd_cached_friends_data: { desc: '好友列表', digest: pcFriendsDigest }, // v1.31.0 修复:这是 {steamId→level} 对象映射,之前错用数组 digest 只吐键名列表, // 「好友谁等级高」类问题拿着 162 条数据算不出答案(实测翻车)→ 换等级映射专用 digest sfd_level_cache: { desc: '好友等级映射 {steamId→level}(等级检查跑过一次后才有)', digest: pcFriendLevelDigest }, sfd_family_cache: { desc: '家庭成员+共享库', digest: pcFamilyDigest }, sfd_wishlist_cache: { desc: '家庭愿望单', digest: pcGenericArrayDigest }, sfd_family_play_activity: { desc: '按成员的游玩动态', digest: pcGenericArrayDigest }, 'sfd_og_': { desc: '逐好友完整游戏库(前缀键,需拼完整 steamid;MB 级仅出摘要)', digest: pcOwnedGamesDigest, prefix: true }, }, }, fa: { label: 'FA 家庭组分析', domain: PC_DOMAIN_STORE, idb: { name: 'sffa_kv', store: 'kv' }, keys: { fa_value_insights_cache: { desc: '家庭价格+时长价值洞察(≤2 万条)', digest: pcValueInsightsDigest }, faCooldownRegistry: { desc: '家庭借阅冷却记录', digest: pcGenericArrayDigest }, fa_bundle_cache: { desc: '数据包缓存(48h)', digest: pcGenericArrayDigest }, fa_goty_cache: { desc: 'GOTY 数据缓存(12h)', digest: pcGenericArrayDigest }, fa_dlc_db_cache: { desc: 'DLC 数据库缓存(12h)', digest: pcGenericArrayDigest }, }, }, sme: { label: 'SME 市场增强', domain: PC_DOMAIN_COMMUNITY, idb: null, keys: { 'sme_cache_': { desc: '价格历史/订单簿快照 {ts,data}(前缀键,4MB LRU)', digest: pcMarketCacheDigest, prefix: true }, 'sme_inv_': { desc: '库存缓存(前缀键,需拼 steamid)', digest: pcInventoryDigest, prefix: true }, sme_inv_prices_v1: { desc: '库存价格快照', digest: pcGenericArrayDigest }, }, }, slc: { label: 'SLC 许可分类器', domain: PC_DOMAIN_STORE, idb: null, keys: { slc_license_cache: { desc: '许可指纹数组 "日期||游戏名||类型"(7 天 TTL,ver2)', digest: pcLicenseDigest }, slc_license_total: { desc: '许可总记录数', digest: pcScalarDigest }, }, }, // v1.14.0:SBC 徽章卡片视图(community 域) // 该库是「一库七 store」,故每个键用 store 字段指定自己的 objectStore; // 且宿主 IDB.set 写入的是 {key,data,updatedAt,expiresAt} TTL 信封, // 而宿主自己的 get() 会 return record.data —— 本工具是原生只读, // 必须靠 envelope:true 显式声明并剥壳(不靠形状猜,见 pcUnwrapEnvelope 注释)。 sbc: { label: 'SBC 徽章卡片视图', domain: PC_DOMAIN_COMMUNITY, idb: { name: 'sbc_pro_cache', store: 'gameData' }, keys: { sbc_friends_leaderboard_v1: { store: 'gameData', envelope: true, desc: '好友徽章排行榜(徽章数/等级/经验/点数商店装扮数 四维度)', digest: pcFriendsLbDigest, }, cardDatabase: { store: 'gameData', envelope: true, desc: '卡牌数据库(全量游戏 → 卡牌数)', digest: pcCardDbDigest, }, 'sbc-market-': { store: 'marketPrices', envelope: true, prefix: true, desc: '单组卡牌市场价格快照(单位分,48h TTL;前缀键需拼 appid,如 sbc-market-730)', digest: pcCardPriceDigest, }, sbc_popular_market_cache: { store: 'popularMarket', envelope: true, desc: '热门市场物品排行(轮询累积,10 分钟 TTL)', digest: pcPopularMarketDigest, }, }, }, // v1.15.0:社交中心数据层(Steam Social Core)。独立 IndexedDB,裸值无信封。 sac: { label: 'SAC 社交中心数据层(好友数/游玩快照/名册/等级)', domain: PC_DOMAIN_COMMUNITY, idb: { name: 'steam_social_db', store: 'kv' }, keys: { fcount: { desc: '好友数排行底层数据 {[steamId]:{n:好友数,p:私密标记,t:更新时间}}(永久缓存+7天增量)', digest: pcFriendCountDigest }, snaps: { desc: '好友游玩时长每日快照 {[steamId]:[{d:日期,m:累计分钟}]}(每人最近21天,深度大盘热力图来源)', digest: pcPlaySnapDigest }, roster: { desc: '好友名册 {own,friends:[{steamid,personaname,level,gameextrainfo}],updatedAt}', digest: pcRosterDigest }, level: { desc: '好友等级映射 {[steamId]: level}', digest: pcFriendLevelDigest }, libpriv: { desc: '游戏库私密/拉取失败标记 {[steamId]: 1}', digest: pcGenericArrayDigest }, vac: { desc: '好友 VAC 状态快照', digest: pcGenericArrayDigest }, }, }, // v1.15.0:好友动态缓存(Steam Activity Modern 主脚本)。同样是独立 IDB + 裸值。 saf: { label: 'SAF 好友动态(动态流/好友游戏库/最近游玩/游戏比对)', domain: PC_DOMAIN_COMMUNITY, idb: { name: 'sf_activity_db', store: 'kv' }, keys: { 'act_': { desc: '好友动态条目(按 steamId 分键,前缀键需拼完整,如 act_76561198142011559)', digest: pcFriendActivityDigest, prefix: true }, 'games_': { desc: '好友完整游戏库(按 steamId 分键,前缀键)', digest: pcGenericArrayDigest, prefix: true }, 'recent_': { desc: '好友最近游玩(按 steamId 分键,前缀键)', digest: pcGenericArrayDigest, prefix: true }, 'cmp_': { desc: '游戏比对结果(与某好友的共有/独有游戏,前缀键)', digest: pcGenericArrayDigest, prefix: true }, social_: { desc: '好友社交数据(好友数+好友列表,按 steamId 分键,前缀键)', digest: pcGenericArrayDigest, prefix: true }, mygames: { desc: '自己的游戏库(好友动态页顺带抓取)', digest: pcGenericArrayDigest }, }, }, // 阶段 2 + 3:宿主插件(SGLV / FA / Spending 分类器 / SLC 许可分类器 / 点数商店增强)主动镜像到 localStorage 的桥键 bridge: { label: '宿主插件桥(SGLV 汇率/中文名、FA 家庭愿望单、Spending 消费历史、SLC 入库来源、点数商店)', domain: PC_DOMAIN_STORE, idb: null, keys: { __saa_bridge_sglv__: { desc: 'SGLV 桥:汇率(1 外币=X CNY)+ 游戏中文名对照', digest: pcSglvBridgeDigest }, __saa_bridge_fa__: { desc: 'FA 桥:家庭愿望单(GameList + GameInfo)', digest: pcFaBridgeDigest }, __saa_bridge_spend__: { desc: '消费历史桥(Spending 分类器 v2.3.0 镜像:7 类分类行+金额+币种,含聚合摘要;complete=false 表示页面历史未全部加载)', digest: pcSpendBridgeDigest }, __saa_bridge_slc__: { desc: '入库桥(SLC 许可分类器 v2.6.0 镜像:来源分类行 store/retail/gift/free + 月度趋势;fields=游戏名过滤可答「XX 怎么获得的」)', digest: pcSlcBridgeDigest }, __saa_bridge_pts__: { desc: '点数商店桥(点数商店增强 v2.3.0 镜像:点数余额/累计获得/退款返还/兑换消费 + 明细行;partial=true 表示仅余额)', digest: pcPtsBridgeDigest }, }, }, }; const PC_PLUGIN_LABEL = { sglv: PLUGIN_CACHE_REGISTRY.sglv.label, sfd: PLUGIN_CACHE_REGISTRY.sfd.label, fa: PLUGIN_CACHE_REGISTRY.fa.label, sme: PLUGIN_CACHE_REGISTRY.sme.label, slc: PLUGIN_CACHE_REGISTRY.slc.label, sbc: PLUGIN_CACHE_REGISTRY.sbc.label, sac: PLUGIN_CACHE_REGISTRY.sac.label, saf: PLUGIN_CACHE_REGISTRY.saf.label, bridge: PLUGIN_CACHE_REGISTRY.bridge.label, }; // 声明过的非前缀键(如 sme_inv_prices_v1)不能被更短的前缀(sme_inv_)抢走。 function pcIsDeclaredExactKey(reg, key) { const spec = reg && reg.keys ? reg.keys[key] : null; return !!(spec && !spec.prefix); } // 前缀实例:以 prefix 开头,但排除前缀本身和另外声明过的精确键。 function pcPrefixInstances(reg, prefix, keys) { return (keys || []).filter((key) => key.startsWith(prefix) && key !== prefix && !pcIsDeclaredExactKey(reg, key)); } // 前缀键解析:用户可能传 'sfd_og_' 或完整 'sfd_og_76561198xxx' // 精确键优先于前缀,否则 sme_inv_prices_v1 会被 sme_inv_ 当成某个好友的库存。 // 前缀声明本身(sfd_og_)仍走列出实例,不去读一个不存在的字面键。 function pcResolveKeySpec(pluginId, key) { const reg = PLUGIN_CACHE_REGISTRY[pluginId]; if (!reg) return { error: `未知插件标识 "${pluginId}",可选:${Object.keys(PLUGIN_CACHE_REGISTRY).join(' / ')}` }; const k = String(key || '').trim(); if (!k) return { error: 'inspect/query 模式必须提供 key 参数' }; if (pcIsDeclaredExactKey(reg, k)) return { reg, spec: reg.keys[k], exactKey: k }; for (const [name, spec] of Object.entries(reg.keys)) { if (spec.prefix && (k === name || k.startsWith(name))) { return { reg, spec, exactKey: k === name ? null : k, prefix: name }; } } if (reg.keys[k]) return { reg, spec: reg.keys[k], exactKey: k }; return { error: `插件 ${pluginId} 没有缓存键 "${k}",可用键:${Object.keys(reg.keys).join(' / ')}` }; } ToolRegistry.register({ name: 'readPluginCache', description: '读取本机其他 Steam 油猴插件(SGLV 游戏库 / SFD 好友管理 / FA 家庭分析 / SME 市场增强 / SLC 许可分类器 / SBC 徽章卡片视图 / SAC 社交中心 / SAF 好友动态)已缓存在本地的数据,只读、不联网、不上传。' + '适合回答"我的游戏时长""好友都在玩什么""好友谁的徽章最多""谁好友最多""好友最近两周时长变化"这类问题——' + '比走 Steam Web API 更快且不消耗 API 配额。仅能读取当前页面域名下可用的缓存:' + '商店页(store)可读 sglv/fa/slc/bridge,社区页(community)可读 sfd/sme/sbc/sac/saf。' + '中文游戏名、汇率、家庭愿望单、消费历史汇总、入库来源(某游戏怎么获得的/入库趋势)、Steam 点数余额与兑换请优先用 plugin="bridge"(宿主插件镜像键;消费桥 key="__saa_bridge_spend__"、入库桥 key="__saa_bridge_slc__"、点数桥 key="__saa_bridge_pts__",毫秒级免 Steam 请求)。' + '好友徽章排行榜用 plugin="sbc",key="sbc_friends_leaderboard_v1",可用 fields=level|xp|points 切换排序维度。' + '「好友谁等级高/等级超过 X 级/等级排行」类问题首选它:fields="level",行里自带昵称+等级(一步出结果,summary.maxLevel 先看上限);' + '备选 sac 的 level 键或 sfd 的 sfd_level_cache(都是 {steamId→level} 等级映射,query 直接返回按等级降序名单 + levelBuckets 分桶,昵称需联查 roster / sfd_cached_friends_data)。' + '社交中心数据(好友数排行/游玩快照/名册/等级)用 plugin="sac";好友动态用 plugin="saf"。' + '注意:sac/saf 的数据由用户主动打开对应页面时产生,未打开过则查不到。' + '数据来自本地缓存,可能已过期,回答时需说明数据新鲜度。', parameters: { type: 'object', properties: { plugin: { type: 'string', enum: ['sglv', 'sfd', 'fa', 'sme', 'slc', 'sbc', 'sac', 'saf', 'bridge'], description: '插件标识:sglv=游戏库 / sfd=好友管理 / fa=家庭分析 / sme=市场增强 / slc=许可分类器 / sbc=徽章卡片视图 / sac=社交中心数据层 / saf=好友动态 / bridge=宿主插件镜像桥' }, mode: { type: 'string', enum: ['list', 'inspect', 'query'], description: 'list=列出该插件可用的缓存键;inspect=查看某键的结构与条数;query=按 digest 摘要取数据(默认返回 top 20)' }, key: { type: 'string', description: '缓存键,inspect/query 必填。带 / 的前缀键请拼完整,如 sfd_og_76561198142011559、sbc-market-730、act_76561198142011559' }, topN: { type: 'number', description: 'query 模式返回条数上限,默认 20(MB 级键会被自动限制)' }, fields: { type: 'string', description: 'query 模式可选。默认是字段裁剪(如 appid,name);对 sbc 好友排行榜则是排序维度:badge(默认)/level/xp/points' }, }, required: ['plugin', 'mode'], }, category: 'knowledge', async execute(params) { const pluginId = String(params.plugin || '').trim().toLowerCase(); const mode = String(params.mode || 'list').trim().toLowerCase(); const topN = Math.max(1, Math.min(Number(params.topN) || 20, 200)); const fields = String(params.fields || '').trim(); const reg = PLUGIN_CACHE_REGISTRY[pluginId]; if (!reg) { return { success: false, error: `未知插件标识 "${params.plugin}",可选:${Object.keys(PLUGIN_CACHE_REGISTRY).join(' / ')}` }; } // 0. 授权闸门:用户显式关闭的数据源直接拒绝(先于域检查 —— // 未授权时提示「去别的页面」没有意义) if (!pcDataSourceAllowed(pluginId)) { return { success: false, error: `用户未授权读取「${reg.label}」的数据。如需启用,请在设置 → 数据源 中勾选。`, needAuth: true, plugin: pluginId, hint: '数据源授权是逐插件的;其他插件的数据不受影响', }; } // 1. 域检查:不匹配时明确引导,不静默返回空 const curDomain = pcCurrentDomain(); if (!curDomain) { return { success: false, error: `当前页面域名 ${location.hostname} 不属于 Steam 商店/社区,无法读取任何插件缓存` }; } if (reg.domain !== curDomain) { // v1.36.0:游戏库键有**跨域 GM 镜像**(同一脚本跨域共享,IndexedDB 不跨域)→ // 社区域读 sglv_cached_games 时改读镜像,其余键仍按域引导。 const mir = pluginId === 'sglv' ? pcReadLibraryMirror() : { ok: false }; const wantsLibKey = String(params.key || '').trim() === 'sglv_cached_games'; if (mir.ok && !mir.expired && wantsLibKey) { const items = mir.games.map((g) => ({ appid: g.appid, name: g.name, playtime_forever: g.playtime_forever, })); return { success: true, data: { plugin: pluginId, label: reg.label, key: 'sglv_cached_games', domain: reg.domain, source: 'cross-domain-mirror', storage: 'GM 跨域镜像', count: items.length, items, mirroredAt: mir.ts, mirrorAgeMinutes: Math.max(0, Math.round((mir.ageMs || 0) / 60000)), mirrorAgeText: pcHumanAge(mir.ageMs), stale: !!mir.stale, note: `本域(${curDomain})读不到 ${reg.label} 的 IndexedDB(按源隔离),` + `已改用商店域缓存的跨域 GM 镜像(快照 ${pcHumanAge(mir.ageMs)});` + '回答时需声明数据新鲜度,不要当成实时数据。', }, summary: `${reg.label} 游戏库 ${items.length} 条(跨域镜像,快照 ${pcHumanAge(mir.ageMs)})`, }; } const need = reg.domain === PC_DOMAIN_STORE ? 'store.steampowered.com(Steam 商店页)' : 'steamcommunity.com(Steam 社区页)'; return { success: false, error: `${reg.label} 的缓存属于 ${reg.domain} 域,请在 ${need} 打开后重试`, needDomain: reg.domain, currentDomain: curDomain, hint: 'IndexedDB / localStorage 按源隔离,跨域读不到,属正常现象' + (pluginId === 'sglv' && !mir.ok ? ';游戏库可用跨域镜像,但需要先在商店域建立过镜像(或该键本身不在镜像范围内)' : ''), }; } // 2. list 模式 if (mode === 'list') { const declared = Object.entries(reg.keys).map(([k, v]) => ({ key: k, desc: v.desc, prefix: !!v.prefix, store: pcStoreOf(reg, v), envelope: !!v.envelope, })); let existing = []; if (reg.idb) { let db = null; try { db = await pcIdbOpen(reg.idb.name); // v1.14.0:按 store 分组探测(支持一库多 store,如 SBC 的 7 个仓库) const byStore = new Map(); for (const d of declared) { if (!byStore.has(d.store)) byStore.set(d.store, []); byStore.get(d.store).push(d); } for (const [st, items] of byStore) { let allKeys = []; try { allKeys = (await pcIdbGetAllKeys(db, st)).map(String); } catch { allKeys = []; } // 该 store 不存在时保持"无数据",不抛错 const set = new Set(allKeys); for (const d of items) { const instances = d.prefix ? pcPrefixInstances(reg, d.key, allKeys) : []; d.present = set.has(d.key) || instances.length > 0; if (d.prefix) d.matchedCount = instances.length; } existing = existing.concat(allKeys.filter(k => !existing.includes(k))); } } catch (e) { console.warn('[SA-Agent] readPluginCache list 打开 IDB 失败', e); } finally { try { db && db.close(); } catch {} } } else { // 纯 localStorage 插件:直接探测声明键是否存在 declared.forEach(d => { if (d.prefix) { const hits = pcPrefixInstances(reg, d.key, pcLocalKeysWithPrefix(d.key)); d.present = hits.length > 0; d.matchedCount = hits.length; } else { let v = null; try { v = localStorage.getItem(d.key); } catch {} d.present = v != null; } }); } // 单 store 插件保持原样(IndexedDB:库名/仓库名),多 store 时标注仓库数 const distinctStores = Array.from(new Set(declared.map(d => d.store))); const storeLabel = reg.idb ? `IndexedDB:${reg.idb.name}/${distinctStores[0] || ''}` + (distinctStores.length > 1 ? ` +${distinctStores.length - 1} 个仓库` : '') : 'localStorage'; return { success: true, data: { plugin: pluginId, label: reg.label, domain: reg.domain, storage: storeLabel, declaredKeys: declared, presentKeyCount: declared.filter(d => d.present).length, allKeysSample: existing.slice(0, 40), ...(existing.length ? { allKeysTotal: existing.length } : {}), }, summary: `${reg.label} 共声明 ${declared.length} 个缓存键,其中 ${declared.filter(d => d.present).length} 个在本机有数据`, }; } // 3. inspect / query 需要 key const resolved = pcResolveKeySpec(pluginId, params.key); if (resolved.error) return { success: false, error: resolved.error }; const { reg: pReg, spec, exactKey, prefix } = resolved; const ctx = { topN, fields }; // v1.14.0:objectStore 支持 per-key 覆盖(SBC 是一库多 store) const storeName = pcStoreOf(pReg, spec); const readByIdb = async (k) => { let db = null; try { db = await pcIdbOpen(pReg.idb.name); return await pcIdbGet(db, storeName, k); } finally { try { db && db.close(); } catch {} } }; try { // 前缀键但只给了前缀 → 列出实例键供模型选择 if (prefix && !exactKey) { let keys = []; if (pReg.idb) { let db = null; try { db = await pcIdbOpen(pReg.idb.name); keys = pcPrefixInstances(pReg, prefix, (await pcIdbGetAllKeys(db, storeName)).map(String)); } catch { keys = []; } finally { try { db && db.close(); } catch {} } } else { keys = pcPrefixInstances(pReg, prefix, pcLocalKeysWithPrefix(prefix)); } return { success: true, data: { plugin: pluginId, key: prefix, matchedCount: keys.length, matchedKeys: keys.slice(0, 60), hint: `请把 key 换成上面任一完整键后再 query` }, summary: `前缀键 ${prefix} 匹配到 ${keys.length} 个实例`, }; } const finalKey = exactKey || String(params.key).trim(); const raw = pReg.idb ? await readByIdb(finalKey) : pcLocalGet(finalKey); if (raw == null) { return { success: false, error: `缓存键 "${finalKey}" 在本机无数据(${PC_PLUGIN_LABEL[pluginId]} 可能未安装、未运行过或缓存已清理)`, missingKey: finalKey, hint: '可先用 mode=list 查看实际存在的键', }; } // v1.14.0:按需脱掉宿主写入的 TTL 信封,并采纳其时间戳算新鲜度 const env = spec.envelope ? pcUnwrapEnvelope(raw) : { payload: raw, updatedAt: null, expiresAt: null, expired: false }; const exists = env.payload; if (exists == null) { return { success: false, error: `缓存键 "${finalKey}" 的载荷为空(宿主写入时 data 字段为空)`, missingKey: finalKey }; } // 信封过期:宿主自己的 get() 会返回 null,这里如实告知而不是拿陈旧数据当新鲜的答 if (env.expired) { return { success: false, error: `缓存键 "${finalKey}" 已被宿主标记为过期(expiresAt=${env.expiresAt}),不应作为当前数据引用`, expiredKey: finalKey, expiredAt: env.expiresAt, hint: '请让对应插件重新抓取后再读;或改用网络工具获取实时数据', }; } // 新鲜度:信封优先(宿主写入时刻),否则沿用载荷自身的时间戳字段 const age = pcCacheAgeMinutes(spec.envelope ? { ts: env.updatedAt || undefined } : exists); // inspect:只看结构,不出数据 if (mode === 'inspect') { const isArr = Array.isArray(exists); const summary = isArr ? { type: 'array', count: exists.length, sampleItemKeys: exists[0] && typeof exists[0] === 'object' ? Object.keys(exists[0]).slice(0, 20) : null, sampleFirst: exists[0] ?? null } : { type: typeof exists, keys: (exists && typeof exists === 'object') ? Object.keys(exists).slice(0, 30) : null, value: exists }; return { success: true, data: { plugin: pluginId, key: finalKey, desc: spec.desc, structure: summary, cacheAgeMinutes: age }, summary: `${finalKey} 结构:${summary.type}${isArr ? `(${summary.count} 项)` : ''}`, }; } // query:走 digest 摘要 let digested; try { digested = spec.digest(exists, ctx); } catch (e) { // shape 漂移/摘要异常 → 降级为 inspect 信息,不抛裸错误 digested = { drifted: true, degradeReason: e.message, structureHint: typeof exists, keys: (exists && typeof exists === 'object') ? Object.keys(exists).slice(0, 20) : null }; } return { success: true, data: { plugin: pluginId, label: pReg.label, key: finalKey, desc: spec.desc, cacheAgeMinutes: age, stale: age != null && age > 24 * 60, ...digested, }, summary: digested && digested.drifted ? `${finalKey} 结构与预期不符,已降级返回结构信息` : `${finalKey} 命中本地缓存${age != null ? `(${age} 分钟前更新)` : ''}`, }; } catch (e) { return { success: false, error: `读取缓存失败:${e.message}`, hint: '若目标插件未安装,属正常现象' }; } }, }); // ============================================================ // v1.23.0 插件版本清单(版本契约 / plugin-contract.json) // ============================================================ // 三来源合并: // ① 契约(远端 JSON) → 期望装哪些、各自最低版本、探测信号 // ② 运行时注册(IDB) → 实际装了哪些 + **精确版本号** // ③ 探测(IDB 库名/localStorage)→ 没注册但装了的老插件也能识别 // // ⚠️ 为什么必须有 ①:GM 存储跨脚本沙箱隔离 → 读不到别人的 GM 值; // 只有接入 registerPlugin 的插件才能给出精确版本。契约提供兜底。 // ⚠️ 2026-10-21:URL 钉在 commit 3ca0d82(数据源清单瘦身版)—— // 实测 jsdelivr 对 @main 的 purge 当天不稳定(purge 受理但边缘仍吐旧缓存 ≤12h), // 按契约分发说明(data/plugin-contract.json 的 distribution.cdn.pinNote)钉 SHA 立即生效。 // ★ 将来更新契约清单:改源文件 → 推 steam-namespace → 把这里的 SHA 换成新 commit(或临时换回 @main)。 const PC_CONTRACT_URL = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@6789435/data/plugin-contract.json'; // ⚠️ 缓存键带版本号(v2):契约清单瘦身(2026-10-21)后要让所有用户立即拉到新清单, // 而不是等 12h TTL 自然过期 —— 改清单时同步把这里的 v2 递增即可强制刷新一次。 const PC_CONTRACT_CACHE_KEY = 'saa_plugin_contract_v2'; const PC_CONTRACT_TTL = 12 * 60 * 60 * 1000; // 12h // ═══ HLTB 索引(发行年份 + 剧情时长)═══ // ⭐ 分片键必须是 (appid >> 1) % 8 —— Steam 全量 82,494 个 appid 中 99.90% 是偶数, // 用 appid % 8 会让一半分片永远为空(离散度 99.9%)。见 build-index.js。 // ⭐ story 必须用 Float64Array —— Float32 精度只有 ~7 位有效数字, // 3.12 会被存成 3.119999885559082,导致 SCI 分档边界错档。 const PC_HLTB_BASE = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data'; const PC_HLTB_SHARDS = 8; const PC_HLTB_STATE = { ready: false, loading: false, err: '', shards: [], manifest: null }; function pcHltbFetchJson(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout: 20000, onload: (r) => { if (r.status < 200 || r.status >= 300) { reject(new Error('HTTP ' + r.status)); return; } try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(e); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } async function pcHltbLoad() { if (PC_HLTB_STATE.ready) return true; if (PC_HLTB_STATE.loading) return false; PC_HLTB_STATE.loading = true; PC_HLTB_STATE.err = ''; try { const manifest = await pcHltbFetchJson(`${PC_HLTB_BASE}/hltb-index-v1.manifest.json`); if (!manifest || !manifest.shards) throw new Error('manifest 格式异常'); const raw = await Promise.all(Array.from({ length: PC_HLTB_SHARDS }, (_, i) => pcHltbFetchJson(`${PC_HLTB_BASE}/hltb-index-v1.${i}.json`))); PC_HLTB_STATE.shards = raw.map((f) => ({ ids: Int32Array.from(f.ids || []), years: Int32Array.from(f.years || []), story: Float64Array.from(f.story || []), })); PC_HLTB_STATE.manifest = manifest; PC_HLTB_STATE.ready = true; PC_HLTB_STATE.loading = false; console.log(`[SA-Agent] HLTB 索引就绪:${manifest.entries} 条`); return true; } catch (e) { PC_HLTB_STATE.err = e.message || String(e); PC_HLTB_STATE.loading = false; console.warn('[SA-Agent] HLTB 索引加载失败', PC_HLTB_STATE.err); return false; } } /** 按 appid 查 HLTB 记录 → { y: 发行年, m: 剧情时长(小时) } | null */ function pcHltbGet(appid) { if (!PC_HLTB_STATE.ready) return null; const id = Number(appid); if (!Number.isFinite(id)) return null; const sh = PC_HLTB_STATE.shards[(id >> 1) % PC_HLTB_SHARDS]; if (!sh || !sh.ids.length) return null; let lo = 0, hi = sh.ids.length - 1; while (lo <= hi) { const mid = (lo + hi) >> 1; if (sh.ids[mid] === id) return { y: sh.years[mid], m: sh.story[mid] }; if (sh.ids[mid] < id) lo = mid + 1; else hi = mid - 1; } return null; } // ═══ 数据源授权 ═══ // 用户可在「设置 → 数据源」逐个勾选/取消勾选某个插件的数据是否允许 AI 读取。 // 存储:GM 键 sa_data_source_auth = { v:1, sources:{ [pluginId]: true|false } } // 语义:**未出现在 sources 里的插件 = 允许**(默认全开,保持既有行为)。 // 显式 false 才是「用户关了」。 const PC_AUTH_KEY = 'sa_data_source_auth'; function pcLoadAuth() { try { const raw = GM_getValue(PC_AUTH_KEY, ''); if (!raw) return { v: 1, sources: {} }; const o = JSON.parse(raw); if (!o || typeof o !== 'object') return { v: 1, sources: {} }; return { v: o.v || 1, sources: (o.sources && typeof o.sources === 'object') ? o.sources : {} }; } catch (e) { return { v: 1, sources: {} }; } } function pcSaveAuth(auth) { try { GM_setValue(PC_AUTH_KEY, JSON.stringify(auth)); return true; } catch (e) { return false; } } /** 该插件的数据是否被允许读取(默认允许) */ function pcDataSourceAllowed(pluginId) { const auth = pcLoadAuth(); return auth.sources[pluginId] !== false; } /** 列出授权状态(设置页用):{ [id]: {allowed, explicit} } */ function pcDataSourceAuthMap() { const auth = pcLoadAuth(); const out = {}; for (const id of Object.keys(PLUGIN_CACHE_REGISTRY)) { out[id] = { allowed: auth.sources[id] !== false, explicit: Object.prototype.hasOwnProperty.call(auth.sources, id), }; } return out; } function pcContractGetCached() { try { const raw = GM_getValue(PC_CONTRACT_CACHE_KEY, ''); if (!raw) return null; const o = JSON.parse(raw); if (!o || !o.v) return null; if (Date.now() - (o.ts || 0) > PC_CONTRACT_TTL) return null; return o.data || null; } catch (e) { return null; } } function pcContractFetch() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: PC_CONTRACT_URL, timeout: 15000, onload: (r) => { if (r.status < 200 || r.status >= 300) { reject(new Error('HTTP ' + r.status)); return; } try { const data = JSON.parse(r.responseText); try { GM_setValue(PC_CONTRACT_CACHE_KEY, JSON.stringify({ v: 1, ts: Date.now(), data })); } catch (e) { /* ignore */ } resolve(data); } catch (e) { reject(e); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } /** v1.30.3 跨域注册镜像的 GM 存储键。 * 背景:插件注册写在各自运行域的 IndexedDB(按域隔离),面板/工具只读当前域 → * 「市场系/账户系此消彼长」——社区域打开看不到账户系、商店域打开看不到好友系。 * 本插件在 store / community 两域都有实例,而 GM 存储同一脚本**跨域共享** → * 把本域读到的注册表镜像进 GM 存储,另一域实例打开时合并显示。 */ const PC_REG_MIRROR_KEY = 'sa_plugin_registry_mirror_v1'; /** 镜像条目保鲜期:超过则视为「插件可能已卸载」,不再并入(本域注册表会自然回填) */ const PC_REG_MIRROR_TTL = 14 * 864e5; /** 读运行时注册表:IDB sglv_plugin_host_storage / kv / shared:plugin:*(当前域) * + GM 跨域镜像(另一域的注册)。本域数据权威优先;读取后把本域注册回写镜像。 */ async function pcReadPluginRegistry() { const out = new Map(); const curDom = pcPanelDomain(); // 'store' | 'community' | '' const localRecs = []; try { const db = await pcIdbOpen('sglv_plugin_host_storage'); const rows = await pcIdbGetAll(db, 'kv'); for (const row of (rows || [])) { const key = String(row && row.key || ''); if (key.indexOf('shared:plugin:') !== 0) continue; const v = row.value; if (v && v.id) { const rec = Object.assign({}, v, { registeredAt: row.updatedAt || 0, regDomain: curDom }); out.set(v.id, rec); localRecs.push(rec); } } try { db.close(); } catch (e) { /* ignore */ } } catch (e) { /* 库不存在 → 没插件接入注册 */ } // ① 跨域镜像合并:镜像里有、本域没有的 → 并入(viaMirror 标记,面板/工具据此区分来源) let mirror = null; try { const raw = GM_getValue(PC_REG_MIRROR_KEY, null); mirror = (raw && typeof raw === 'object' && raw.v === 1 && raw.plugins && typeof raw.plugins === 'object') ? raw : { v: 1, updatedAt: 0, plugins: {} }; } catch (e) { mirror = { v: 1, updatedAt: 0, plugins: {} }; } const now = Date.now(); for (const id of Object.keys(mirror.plugins)) { if (out.has(id)) continue; const m = mirror.plugins[id]; if (!m || m.id !== id || (now - (m.ts || 0)) > PC_REG_MIRROR_TTL) continue; out.set(id, Object.assign({}, m, { viaMirror: true, registeredAt: m.ts || 0 })); } // ② 本域注册回写镜像(同 id 取 ts 较新者;无变化不写,避免 GM 存储写放大) let dirty = false; for (const rec of localRecs) { const prev = mirror.plugins[rec.id]; if (!prev || (rec.ts || rec.registeredAt || 0) >= (prev.ts || 0)) { mirror.plugins[rec.id] = { id: rec.id, name: rec.name, family: rec.family, version: rec.version, versionSource: rec.versionSource, capabilities: rec.capabilities || null, ts: rec.ts || rec.registeredAt || now, regDomain: curDom, }; dirty = true; } } if (dirty) { mirror.updatedAt = now; try { GM_setValue(PC_REG_MIRROR_KEY, mirror); } catch (e) { /* quota 等静默降级 */ } } return out; } /** 探测:按契约里的 detect 信号判断插件「装了没」 */ async function pcDetectPlugin(sig) { if (!sig || !sig.type) return false; try { if (sig.type === 'idb') { const db = await pcIdbOpen(sig.db); const has = db.objectStoreNames.contains(sig.store); try { db.close(); } catch (e) { /* ignore */ } return has; } if (sig.type === 'localStorage') { const keys = pcLocalKeysWithPrefix(sig.prefix); return keys.length > 0; } } catch (e) { /* ignore */ } return false; } /** 版本号比较:a < b → -1;a > b → 1;相等 → 0;无法解析 → null */ function pcCompareVersion(a, b) { if (!a || !b) return null; const pa = String(a).split(/[.\-+]/).map(x => parseInt(x, 10)); const pb = String(b).split(/[.\-+]/).map(x => parseInt(x, 10)); if (!pa.length || pa.some(isNaN) || !pb.length || pb.some(isNaN)) return null; const n = Math.max(pa.length, pb.length); for (let i = 0; i < n; i++) { const x = pa[i] || 0, y = pb[i] || 0; if (x !== y) return x < y ? -1 : 1; } return 0; } /** v1.30.2 当前面板所在的域:'store'(商店域)/ 'community'(社区域)/ ''(未知)。 * 插件注册写在各自运行域的 IndexedDB(按域隔离),跨域读不到 —— * 面板用它与契约的 plugin.domain 比对,把「未注册 + 注册在其他域」标成 * 「需在对应域确认」而不是误导性的「未安装」。 */ function pcPanelDomain() { try { const h = String((typeof location !== 'undefined' && location.hostname) || ''); if (h === 'steamcommunity.com') return 'community'; if (h.indexOf('steampowered.com') !== -1) return 'store'; } catch (e) { /* ignore */ } return ''; } ToolRegistry.register({ name: 'getInstalledPlugins', description: '查询本机已安装的 Steam 油猴插件、它们的版本号,以及是否「装齐了」。' + '数据来自三处合并:① 版本契约(期望清单 + 最低版本)② 各插件通过共享宿主注册的真实版本号 ③ 本地存储探测。' + '适合回答「我的 steam 插件装齐了吗」「steamkit 系列装了哪些」「XX 插件版本过旧吗」「好友管理器装了没」这类问题——' + '不联网查商店,只看本机实际状态。' + '⚠️ 判读口径:只有「注册过」或「有本地存储探测信号」的插件才能确认状态;' + '未注册且无探测信号的会被标为 decidable=false(无法判断),' + '这类插件只是没在当前页面运行过,未必真没装——回答用户时不要把它们说成「未安装」。', parameters: { type: 'object', properties: { family: { type: 'string', description: '只看某个分组:core / social / market / account / badge / utility / ai' }, name: { type: 'string', description: '按名称/别名模糊匹配,如 "steamkit"、"市场"、"wishlist"' }, outdatedOnly: { type: 'boolean', description: '只返回版本低于契约最低要求的插件' }, missingOnly: { type: 'boolean', description: '只返回未检测到的插件(其中 decidable=false 的只是探测不到,未必真没装)' }, refresh: { type: 'boolean', description: '强制重新拉取契约(默认走 12h 缓存)' }, }, }, category: 'knowledge', async execute(params) { const family = params && params.family ? String(params.family).trim() : ''; const nameQ = params && params.name ? String(params.name).trim().toLowerCase() : ''; // ① 契约 let contract = params && params.refresh ? null : pcContractGetCached(); let contractErr = ''; if (!contract) { try { contract = await pcContractFetch(); } catch (e) { contractErr = e.message || String(e); } } if (!contract) { return { success: false, error: '版本契约不可用(无法拉取 ' + PC_CONTRACT_URL + ')' + (contractErr ? ':' + contractErr : '') + ';请检查网络或稍后重试', }; } // ② 运行时注册的真实版本 const registry = await pcReadPluginRegistry(); // ③ 逐个插件:合并「注册版本」与「探测结果」 const list = contract.plugins || []; const rows = []; for (const p of list) { if (family && p.family !== family) continue; if (nameQ && !((p.name || '').toLowerCase().includes(nameQ) || (p.id || '').includes(nameQ))) continue; const reg = registry.get(p.id) || null; // ⚠️ detect 为空 = 该插件没有任何「可跨脚本探测」的信号(要么零存储、要么只用 GM 存储—— // GM 存储跨脚本沙箱隔离,别的脚本读不到)。此时「未注册」只说明它没在本页面运行过, // 不能据此断言「未安装」,否则必然误报。故用 decidable 区分「确认未装」与「无法判断」。 const hasDetect = Array.isArray(p.detect) && p.detect.length > 0; let probed = false; if (!reg && hasDetect) { for (const sig of p.detect) { if (await pcDetectPlugin(sig)) { probed = true; break; } } } const installed = !!reg || probed; const decidable = !!reg || hasDetect; const version = reg ? (reg.version || null) : null; const cmp = (version && p.minVersion) ? pcCompareVersion(version, p.minVersion) : null; const outdated = cmp !== null && cmp < 0; rows.push({ id: p.id, name: p.name, family: p.family, required: p.required === true, installed, decidable, // v1.30.3 registry-mirror = 注册来自另一域的 GM 镜像(非本域 IndexedDB 实时读) detectedBy: reg ? (reg.viaMirror ? 'registry-mirror' : 'registry') : (probed ? 'probe' : null), regDomain: reg ? (reg.regDomain || null) : null, version: version, versionKnown: !!version, minVersion: p.minVersion || null, outdated, lastSeen: reg ? (reg.registeredAt || null) : null, note: (!version && installed) ? '已检测到但未注册版本号(该插件尚未接入 registerPlugin)' : (reg && reg.viaMirror ? ('该插件注册在' + (reg.regDomain === 'community' ? '社区' : '商店') + '域(IndexedDB 按域隔离),版本号经跨域镜像读取') : (!installed ? (decidable ? '未检测到' : '未注册且无探测信号,无法判断是否安装(该插件未在本页面运行过、或只用 GM 存储时探测不到)') : null)), }); } const installedRows = rows.filter(r => r.installed); const missingRows = rows.filter(r => !r.installed); // 保持二分语义(含「无法判断」) const absentRows = rows.filter(r => !r.installed && r.decidable); // 确认未装 const unknownRows = rows.filter(r => !r.installed && !r.decidable); // 无探测信号,判断不了 const outdatedRows = rows.filter(r => r.outdated); const byFamily = {}; for (const r of rows) { const f = r.family || '_other'; if (!byFamily[f]) byFamily[f] = { installed: 0, total: 0, missing: [], unknown: [], outdated: [] }; byFamily[f].total++; if (r.installed) byFamily[f].installed++; else if (!r.decidable) byFamily[f].unknown.push(r.id); else byFamily[f].missing.push(r.id); if (r.outdated) byFamily[f].outdated.push(r.id); } const filtered = (params && params.outdatedOnly) ? outdatedRows : (params && params.missingOnly) ? missingRows : rows; return { success: true, data: { contractVersion: contract.v, contractUpdatedAt: contract.updatedAt, summary: `契约 ${list.length} 个插件:已装 ${installedRows.length},未装 ${absentRows.length},` + `无法判断 ${unknownRows.length},版本过旧 ${outdatedRows.length}`, installed: filtered.filter(r => r.installed), missing: filtered.filter(r => !r.installed), unknown: filtered.filter(r => !r.installed && !r.decidable), outdated: outdatedRows, byFamily, versionKnownCount: installedRows.filter(r => r.versionKnown).length, }, summary: `已装 ${installedRows.length}/${list.length} 个插件(其中 ${installedRows.filter(r => r.versionKnown).length} 个能读到版本号),` + `未装 ${absentRows.length}` + (unknownRows.length ? `,另有 ${unknownRows.length} 个无法判断(未注册且无探测信号,未必真没装):${unknownRows.map(r => r.name).join('、')}` : '') + `,版本过旧 ${outdatedRows.length}` + (outdatedRows.length ? ':' + outdatedRows.map(r => r.name + ' ' + r.version + '<' + r.minVersion).join('、') : ''), }; }, }); // ============================================================ // 剧情时长(SCI)+ 远程数据中枢查询 // ============================================================ // ---- SCI 分档(口径与 SGLV PTW 分析层逐字一致)---- // SCI = (playtime_forever 分钟 / 60) / HLTB 主线时长(小时) // 序章搁浅 <0.35 / 剧情未竟 0.35-0.8 / 剧情通关 0.8-1.3 / 支线探索家 1.3-2.5 / 自由漫游 >2.5 const PC_SCI_BUCKETS = [ { key: 'stalled', max: 0.35, label: '序章搁浅' }, { key: 'unfinished', max: 0.8, label: '剧情未竟' }, { key: 'done', max: 1.3, label: '剧情通关' }, { key: 'explorer', max: 2.5, label: '支线探索家' }, { key: 'free', max: Infinity, label: '自由漫游' }, ]; function pcSciBucket(sci) { for (const b of PC_SCI_BUCKETS) if (sci < b.max) return b; return PC_SCI_BUCKETS[PC_SCI_BUCKETS.length - 1]; } // ---- 年代分桶(须与 hltb-scraper/build-index.js 的 eraOf() 逐字一致)---- // year<2004 → pre04 / year<=2014 → 04-14 / year<=2024 → 15-24 / else 25+ const PC_ERA_BUCKETS = [ { key: 'pre04', label: '2004 前' }, { key: '04-14', label: '2004-2014' }, { key: '15-24', label: '2015-2024' }, { key: '25+', label: '2025+' }, ]; function pcEraBucket(year) { const y = Number(year); if (!Number.isFinite(y) || y <= 0) return null; if (y < 2004) return PC_ERA_BUCKETS[0]; if (y <= 2014) return PC_ERA_BUCKETS[1]; if (y <= 2024) return PC_ERA_BUCKETS[2]; return PC_ERA_BUCKETS[3]; } // ============================================================ // v1.36.0 跨域镜像:游戏库 GM 镜像(社区域也能用「我的游戏库」搜索) // ============================================================ // 问题:SGLV 的游戏库缓存写在 **store 域的 IndexedDB**(IndexedDB / localStorage 按源隔离), // 在 steamcommunity.com 上那个库根本不存在 → 搜索浮窗的「我的游戏库」组直接报错不可用。 // 方案:沿用 v1.30.3「插件注册表跨域镜像」的同款机制 —— **GM 存储是同一脚本跨域共享的** // (与 IndexedDB/localStorage 按源隔离正好相反;GM 只是**按脚本**隔离)。 // 商店域读到权威缓存后写一份**紧凑镜像**(元组数组,百 KB 级)进本脚本的 GM 键; // 社区域读不到本域 IDB 时回退镜像。零 Steam API 配额、毫秒级。 // 代价:镜像可能比本域缓存旧 → 用 ts / stale 如实声明新鲜度,不假装是实时数据。 const PC_LIB_MIRROR_KEY = 'sa_sglv_library_mirror_v1'; const PC_LIB_MIRROR_TTL = 7 * 864e5; // 镜像保鲜期 7 天(超期视为无效) const PC_LIB_MIRROR_STALE_MS = 24 * 3600e3; // 超过 1 天算「偏旧」,须向用户声明 const PC_LIB_MIRROR_WRITE_INTERVAL = 30 * 60 * 1000; // 同域重复写镜像的最小间隔 const PC_LIB_MIRROR_MAX = 5000; // 条数上限,防库爆炸 /** 人话化的数据年龄(给 UI / 提示词用) */ function pcHumanAge(ms) { const mins = Math.max(0, Math.round((Number(ms) || 0) / 60000)); if (mins < 60) return `${mins} 分钟前`; const h = Math.round(mins / 60); if (h < 48) return `${h} 小时前`; return `${Math.round(h / 24)} 天前`; } /** 读跨域镜像(只判结构,TTL 由调用方决定) */ function pcReadLibraryMirror() { try { const raw = GM_getValue(PC_LIB_MIRROR_KEY, null); if (!raw || raw.v !== 1 || !Array.isArray(raw.games)) return { ok: false, reason: 'none' }; const ts = Number(raw.ts) || 0; const ageMs = Math.max(0, Date.now() - ts); const games = []; for (const t of raw.games) { if (!Array.isArray(t)) continue; const appid = Number(t[0]); if (!Number.isFinite(appid)) continue; games.push({ appid, name: String(t[1] || ''), playtime_forever: Number(t[2]) || 0 }); } return { ok: true, games, ts, ageMs, count: games.length, stale: ageMs > PC_LIB_MIRROR_STALE_MS, expired: ageMs > PC_LIB_MIRROR_TTL, }; } catch (e) { return { ok: false, reason: 'error' }; } } /** 写跨域镜像(紧凑元组 [appid, name, playtime],百 KB 级而不是原始 0.5~2MB) */ function pcWriteLibraryMirror(games) { try { const arr = Array.isArray(games) ? games : []; const tuples = []; for (const g of arr) { if (!g) continue; const appid = Number(g.appid); if (!Number.isFinite(appid)) continue; tuples.push([appid, String(g.name || '').slice(0, 120), Math.round(Number(g.playtime_forever) || 0)]); if (tuples.length >= PC_LIB_MIRROR_MAX) break; } if (!tuples.length) return false; GM_setValue(PC_LIB_MIRROR_KEY, { v: 1, ts: Date.now(), domain: PC_DOMAIN_STORE, count: tuples.length, games: tuples, }); return true; } catch (e) { return false; } } /** 节流写镜像:30 分钟内不重复写(避免每次读都往 GM 存储塞百 KB) */ function pcMaybeWriteLibraryMirror(games) { try { const cur = pcReadLibraryMirror(); if (cur.ok && cur.ageMs < PC_LIB_MIRROR_WRITE_INTERVAL) return false; return pcWriteLibraryMirror(games); } catch (e) { return false; } } /** 本域读不到时的兜底:跨域 GM 镜像(v1.36.0) */ function pcLibraryMirrorFallback(localError) { const mir = pcReadLibraryMirror(); if (mir.ok && !mir.expired) { return { ok: true, games: mir.games, viaMirror: true, mirroredAt: mir.ts, ageMs: mir.ageMs, stale: mir.stale, localError, }; } const why = mir.expired ? '跨域镜像已过期(超过 7 天)' : '没有可用的跨域镜像'; return { ok: false, error: `${localError}(${why})。` + '跨域读不到是因为 IndexedDB 按源隔离;' + '请先在 store.steampowered.com 打开一次 Steam AI Agent 面板(会自动建立镜像),之后社区页也能搜游戏库。', mirrorReason: mir.expired ? 'expired' : (mir.reason || 'none'), }; } /** * 读 SGLV 的本地游戏库缓存 → [{appid,name,playtime_forever,...}] * 数据源优先级:①本域 IndexedDB sglv_kv / kv / 'sglv_cached_games'(权威,商店域) * ②v1.36.0 跨域 GM 镜像(社区域回退用,商店域读不到时兜底) * 返回 viaMirror / mirroredAt / ageMs / stale 供调用方声明数据来源与新鲜度。 */ async function pcReadSglvLibrary() { const reg = PLUGIN_CACHE_REGISTRY.sglv; let db = null; try { db = await pcIdbOpen(reg.idb.name); if (!db.objectStoreNames || !db.objectStoreNames.contains(reg.idb.store)) { return pcLibraryMirrorFallback(`IndexedDB ${reg.idb.name} 里没有 ${reg.idb.store} 仓库 —— 「游戏库展示」插件可能从未在本域运行过`); } const raw = await pcIdbGet(db, reg.idb.store, 'sglv_cached_games'); if (raw == null) { return pcLibraryMirrorFallback('未找到游戏库缓存(sglv_cached_games)—— 请先在 Steam 商店页打开「游戏库展示」插件并刷新一次'); } const arr = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.games) ? raw.games : null); if (!arr) return pcLibraryMirrorFallback('游戏库缓存结构无法识别(既不是数组也不是 {games:[] })'); // 商店域读到本域权威缓存 → 顺带刷新跨域镜像(社区域要用) if (pcCurrentDomain() === PC_DOMAIN_STORE) pcMaybeWriteLibraryMirror(arr); return { ok: true, games: arr, viaMirror: false }; } catch (e) { return pcLibraryMirrorFallback('读取游戏库缓存失败:' + (e && e.message || e)); } finally { try { db && db.close(); } catch (e) { /* ignore */ } } } /** * v1.36.0 商店域自动刷新跨域镜像(节流 30 分钟)。 * 目的:让社区域随时能搜「我的游戏库」,而不必用户先在商店域手动触发一次搜索。 * 失败静默 —— 镜像只是加速路径,不该影响主流程。 */ async function pcAutoMirrorLibrary() { try { if (pcCurrentDomain() !== PC_DOMAIN_STORE) return; const mir = pcReadLibraryMirror(); if (mir.ok && mir.ageMs < PC_LIB_MIRROR_WRITE_INTERVAL) return; // 刚建过,不重复读 IDB const lib = await pcReadSglvLibrary(); // 内部成功即写镜像 if (lib.ok && !lib.viaMirror) { console.log(`[SA-Agent] 游戏库跨域镜像已更新(${lib.games.length} 款)`); } } catch (e) { /* ignore:镜像失败不影响主流程 */ } } ToolRegistry.register({ name: 'getStoryData', description: '查询游戏的**主线剧情时长**(HowLongToBeat 口径)与发行年份;并能把「主线时长」和「我的游玩时长」相除,算出剧情完成度 SCI。' + '适合回答「XX 要玩多久才通关」「我到底通没通关 XX」「我的库里哪些游戏买了没玩」「我买的是独立小作还是大作」这类问题——' + 'Steam Web API 结构上答不了(它没有主线时长,也没有逐日历史)。' + '两种模式:① 不传 appids = 读我的游戏库(需在 Steam 商店页打开过「游戏库展示」插件),按剧情时长/完成度/年份/游玩时长排序取前 N;' + '② 传 appids = 精确查这几款。' + '⚠️ HLTB 只能覆盖能对上的部分(实测全量覆盖 54.6%,其余是主机独占/未发售等永久缺口),' + '所以「查不到」= 该游戏没有可用的主线时长数据,**不等于 0 小时**,回答时不要把缺失说成 0。', parameters: { type: 'object', properties: { appids: { type: 'array', items: { type: 'number' }, description: '要查询的 appid 列表(数字)。不传 = 走「我的游戏库」模式' }, sort: { type: 'string', enum: ['story', 'sci', 'year', 'playtime'], description: '排序维度:story=主线时长 / sci=剧情完成度 / year=发行年份 / playtime=游玩时长(默认 story)' }, topN: { type: 'number', description: '返回条数,默认 20,最大 100' }, minStory: { type: 'number', description: '只看主线时长 ≥ N 小时的游戏(库模式有效)' }, }, }, category: 'knowledge', async execute(params) { const topN = Math.max(1, Math.min(Number(params.topN) || 20, 100)); const sortBy = String(params.sort || 'story').trim().toLowerCase(); const minStory = Number(params.minStory) || 0; // 1. 索引加载(懒加载 + 内存缓存,失败静默不抛) const ok = await pcHltbLoad(); if (!ok) { return { success: false, error: `HLTB 索引加载失败:${PC_HLTB_STATE.err || '未知原因'}。剧情时长数据暂不可用 —— 可改用 readPluginCache / getOwnedGames,或稍后重试。`, }; } const manifest = PC_HLTB_STATE.manifest || {}; const coverage = manifest.coverage || null; const eraReference = (manifest.reference && manifest.reference.eraDist) || null; // 2. 指定 appids 模式 const ids = Array.isArray(params.appids) ? params.appids.map(Number).filter((n) => Number.isFinite(n)) : []; if (ids.length) { const rows = ids.slice(0, 100).map((id) => { const h = pcHltbGet(id); const era = h ? pcEraBucket(h.y) : null; return { appid: id, name: null, year: h ? h.y : null, era: era ? era.key : null, eraLabel: era ? era.label : null, storyHours: h ? h.m : null, sci: null, sciLabel: null, playtimeHours: null, }; }); const hit = rows.filter((r) => r.storyHours != null).length; return { success: true, data: { mode: 'appids', indexEntries: manifest.entries || 0, coverage, total: rows.length, matched: hit, rows, note: hit < rows.length ? `${rows.length - hit} 款在 HLTB 索引中无记录(可能是主机独占/未发售/非游戏),不代表主线时长为 0` : null, }, summary: `查询 ${rows.length} 款游戏,${hit} 款在 HLTB 索引中命中主线时长`, }; } // 3. 库模式:授权 → 读缓存(本域 IDB 优先,读不到回退跨域 GM 镜像) if (!pcDataSourceAllowed('sglv')) { return { success: false, error: '用户未授权读取「SGLV 游戏库展示」的数据。如需启用,请在设置 → 数据源 中勾选。', needAuth: true, plugin: 'sglv', }; } // v1.36.0:不再硬拦域 —— pcReadSglvLibrary 内部会在本域读不到时回退跨域镜像 const curDomain = pcCurrentDomain(); const lib = await pcReadSglvLibrary(); if (!lib.ok) { return { success: false, error: lib.error, ...(curDomain !== PC_DOMAIN_STORE ? { needDomain: PC_DOMAIN_STORE, currentDomain: curDomain } : {}), }; } // 4. join + 算 SCI const rows = []; for (const g of lib.games) { const appid = Number(g && g.appid); if (!Number.isFinite(appid)) continue; const h = pcHltbGet(appid); const playMin = Number(g.playtime_forever) || 0; const playHours = Math.round(playMin / 6) / 10; const story = h ? h.m : null; const sci = (story && story > 0) ? (playHours / story) : null; const era = h ? pcEraBucket(h.y) : null; rows.push({ appid, name: (g.name || `App ${appid}`) + '', playtimeHours: playHours, storyHours: story, sci: sci == null ? null : Math.round(sci * 100) / 100, sciLabel: sci == null ? null : pcSciBucket(sci).label, year: h ? h.y : null, era: era ? era.key : null, eraLabel: era ? era.label : null, }); } const totalGames = rows.length; const withStory = rows.filter((r) => r.storyHours != null); const withSci = rows.filter((r) => r.sci != null); // SCI 分档分布 const sciDist = {}; for (const b of PC_SCI_BUCKETS) sciDist[b.key] = 0; for (const r of withSci) sciDist[pcSciBucket(r.sci).key]++; // 年代分布(仅已匹配部分 —— 系统性偏新,必须与人口参考对照) const eraDist = {}; for (const b of PC_ERA_BUCKETS) eraDist[b.key] = 0; for (const r of withStory) { const e = pcEraBucket(r.year); if (e) eraDist[e.key]++; } const filtered = minStory > 0 ? rows.filter((r) => r.storyHours != null && r.storyHours >= minStory) : rows; const cmp = { story: (a, b) => (b.storyHours || 0) - (a.storyHours || 0), sci: (a, b) => (b.sci || 0) - (a.sci || 0), year: (a, b) => (b.year || 0) - (a.year || 0), playtime: (a, b) => (b.playtimeHours || 0) - (a.playtimeHours || 0), }[sortBy] || ((a, b) => (b.storyHours || 0) - (a.storyHours || 0)); const sorted = filtered.slice().sort(cmp); const top = sorted.slice(0, topN); return { success: true, data: { mode: 'library', indexEntries: manifest.entries || 0, coverage, totalGames, // v1.36.0:数据来源与新鲜度(跨域镜像时必须如实声明,不能当成实时数据) source: lib.viaMirror ? 'cross-domain-mirror' : 'local-indexeddb', ...(lib.viaMirror ? { mirroredAt: lib.mirroredAt, mirrorAgeMinutes: Math.max(0, Math.round((lib.ageMs || 0) / 60000)), mirrorAgeText: pcHumanAge(lib.ageMs), stale: !!lib.stale, sourceNote: '游戏库来自商店域缓存的跨域 GM 镜像(IndexedDB 按源隔离,社区域读不到本域库);' + `快照时间 ${pcHumanAge(lib.ageMs)},可能已过期,回答时需声明数据新鲜度。`, } : {}), matched: withStory.length, matchedPct: totalGames ? Math.round((withStory.length / totalGames) * 1000) / 10 : 0, sciSample: withSci.length, sciDist, eraDist, eraReference, eraCaveat: 'eraDist 是「已匹配部分」的年代分布,**系统性偏新**(实测最大 +18.8pt);' + 'eraReference 是 HLTB 全人口参考分布,回答年代相关问题时必须两者对照,不要单看 eraDist。', sortedBy: sortBy, rows: top, truncated: sorted.length > top.length ? sorted.length - top.length : 0, }, summary: `我的库 ${totalGames} 款中 ${withStory.length} 款有主线时长(${totalGames ? Math.round((withStory.length / totalGames) * 1000) / 10 : 0}%),` + `可算 SCI 的 ${withSci.length} 款;按 ${sortBy} 排序取前 ${top.length} 款`, }; }, }); /* ==== saa-auth 认证库 v1.30.2 起改为 @require 分发(libs/saa-auth.lib.js,钉 commit 6789435)==== * 内联副本已删除 —— 双副本必然漂移(历史教训:内联块缺 b72763a 的路径归一化修复, * 带中文/空格路径的签名请求必然 E_DEVICE_SIG)。会话/设备密钥/签名全由库管理, * 主脚本经 window.SAAAuth(pcHubAuthLib)访问,接口不变。 ==== */ // ============================================================ // v1.30.0 远程数据中枢(data-hub)查询 // ============================================================ // ⚠️ 安全约束(防 SSRF / 防越权,五条硬规矩): // 1. **地址由用户在设置里配**,模型永远不能指定 URL;拼装时只认路由白名单。 // 2. 默认只允许 https;明文 http 需要用户显式打开开关(远程必须 TLS —— 设备签名依赖私钥)。 // 3. 内网/本机地址默认拦截(自建内网服务器可显式放行)。 // 4. 凭据**不再手动粘贴 token**:走「设备密钥 + Steam 身份认证」换来的会话, // 会话由 libs/saa-auth.lib.js 管理(设备私钥永不上传,服务端永远拿不到明文默认 token)。 // 5. 路由白名单:只认下面那几条;filters / sort / scope 走本地白名单解析器。 const PC_HUB_CONFIG_KEY = 'sa_hub_config_v2'; const PC_HUB_LEGACY_CONFIG_KEY = 'sa_hub_config'; // v1.26 的 {port, token} —— 主动丢弃 // ★ v1.33.0 默认地址三级回落(§3.5.1):用户显式设置 → 远程 data/hub-endpoint.json → 内置常量。 // baseUrl 为空 = 「回落默认」(用户不改即为默认);想完全不用中枢用单独的 disabled 开关。 const PC_HUB_DEFAULT_BASE_URL = 'https://hub.leansoftx.com'; const PC_HUB_ENDPOINT_REMOTE_URL = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/hub-endpoint.json'; const PC_HUB_ENDPOINT_GM_KEY = 'sa_hub_endpoint_cache_v1'; let _pcHubRemoteEndpoint = null; // { baseUrl, fetchedAt }(内存缓存,GM 侧另有一份跨会话) const PC_HUB_FILTERS = { year: 'num', steam_year: 'num', story_hours: 'num', extra_hours: 'num', price: 'num', hltb_id: 'num', type: 'str', name: 'str', name_cn: 'str', }; const PC_HUB_SORTABLE = ['year', 'steam_year', 'story_hours', 'extra_hours', 'price', 'hltb_id']; const PC_HUB_SCOPES = ['all', 'mine', 'unplayed', 'wishlist']; /** 读端点白名单(前缀匹配以支持 /app/:id 这类路径参数) */ const PC_HUB_ROUTES = [ '/health', '/search', '/app/', '/library/', '/owned/', '/profile/', '/report/', '/timeline', '/stats', '/sources', '/analysis', '/filters', '/bias', '/skills', '/accounts', '/tables', '/table/', '/columns/', '/count', '/vocab', '/link', ]; /** 写端点白名单(模型只能触发这几条;v1.33.0 加版本上报与撤销) */ const PC_HUB_WRITE_ROUTES = ['/push/agent', '/report/plugins', '/report/plugins/optout']; function pcHubConfig() { let raw = ''; try { raw = GM_getValue(PC_HUB_CONFIG_KEY, ''); } catch (e) { /* ignore */ } let o = {}; if (raw) { try { o = JSON.parse(raw) || {}; } catch (e) { o = {}; } } const disabled = !!o.disabled; const explicit = String(o.baseUrl || '').trim().replace(/\/+$/, ''); // 三级回落:显式设置 → 远程缓存(已过 https 公网校验)→ 内置常量。disabled 时一律不可用。 let baseUrl = explicit; if (!baseUrl && !disabled) { baseUrl = (_pcHubRemoteEndpoint && _pcHubRemoteEndpoint.baseUrl) || PC_HUB_DEFAULT_BASE_URL; } return { baseUrl: disabled ? '' : baseUrl, explicitBaseUrl: explicit, // 空 = 从未设置 / 已清空(回落默认) disabled, isDefaultUrl: !explicit, // 当前生效的是回落默认地址 allowHttp: !!o.allowHttp, // 允许明文 http(仅内网调试;默认地址走 https 不受影响) allowPrivate: !!o.allowPrivate, // 允许内网/本机地址(自建服务器) }; } /** 远程默认地址热更:拉 data/hub-endpoint.json(GM 缓存 24h + 失败保留旧值)。 * 为什么远程可改:它是「装上就连哪里」的唯一开关点,写死意味着换地址必须发版。 */ function pcHubResolveEndpointRemote() { return new Promise((resolve) => { let cached = null; try { const cachedRaw = GM_getValue(PC_HUB_ENDPOINT_GM_KEY, ''); cached = cachedRaw ? JSON.parse(cachedRaw) : null; } catch (e) { cached = null; } if (cached && cached.baseUrl && (Date.now() - (cached.fetchedAt || 0)) < 24 * 3600 * 1000) { _pcHubRemoteEndpoint = cached; resolve(cached.baseUrl); return; } GM_xmlhttpRequest({ method: 'GET', url: PC_HUB_ENDPOINT_REMOTE_URL, timeout: 15000, responseType: 'text', onload: (res) => { try { const j = JSON.parse(res.responseText || '{}'); const u = j && j.baseUrl ? new URL(String(j.baseUrl)) : null; // 安全校验:远程配置只接受 https 公网地址(与 assertSafeRequestUrl 默认口径一致) if (!u || u.protocol !== 'https:' || u.username || u.password) throw new Error('bad'); const rec = { baseUrl: String(j.baseUrl).replace(/\/+$/, ''), fetchedAt: Date.now() }; _pcHubRemoteEndpoint = rec; try { GM_setValue(PC_HUB_ENDPOINT_GM_KEY, JSON.stringify(rec)); } catch (e) { /* ignore */ } resolve(rec.baseUrl); } catch (e) { resolve(null); } // 载荷不合法 → 保留旧值/内置常量 }, onerror: () => resolve(null), ontimeout: () => resolve(null), }); }); } function pcSaveHubConfig(cfg) { try { GM_setValue(PC_HUB_CONFIG_KEY, JSON.stringify({ baseUrl: String(cfg.baseUrl || '').trim().replace(/\/+$/, ''), // 允许空串 = 回落默认 disabled: !!cfg.disabled, allowHttp: !!cfg.allowHttp, allowPrivate: !!cfg.allowPrivate, })); // 老配置(端口 + 手填 token)主动清掉:token 已不需要,留着是隐患 try { GM_setValue(PC_HUB_LEGACY_CONFIG_KEY, ''); } catch (e) { /* ignore */ } return true; } catch (e) { return false; } } /** 路由白名单:模型只能触发白名单里的端点 */ function pcHubRouteAllowed(path, method) { const p = String(path || ''); if (String(method || 'GET').toUpperCase() === 'POST') { return PC_HUB_WRITE_ROUTES.some((r) => p === r); } return PC_HUB_ROUTES.some((r) => ( // 以 / 结尾 = 前缀匹配(/app/、/table/…);否则精确匹配 + 其子路径(/skills、/skills/:id) r.endsWith('/') ? p.startsWith(r) : (p === r || p.startsWith(r + '/')) )); } /** 本地过滤串解析(白名单 + 数值/文本校验)——与服务端同一套口径 */ function pcHubParseFilters(str) { const out = []; if (!str) return out; for (const raw of String(str).split(/[,;]/)) { const p = raw.trim(); if (!p) continue; const m = p.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*(>=|<=|!=|=|>|<)\s*(.+)$/); if (!m) throw new Error(`无法解析过滤条件 "${p}"(示例:year>=2020, story_hours>10)`); const [, field, op, valRaw] = m; const kind = PC_HUB_FILTERS[field]; if (!kind) throw new Error(`未知过滤字段 "${field}",可用:${Object.keys(PC_HUB_FILTERS).join(' / ')}`); let val = valRaw.trim(); if (kind === 'num') { const n = Number(val); if (!Number.isFinite(n)) throw new Error(`字段 "${field}" 需要数值,收到 "${val}"`); val = n; } else if (/["'`;()\\]/.test(val)) { throw new Error(`字段 "${field}" 的值含不允许的字符:"${val}"`); } out.push({ field, op, val }); } return out; } /** 认证库(libs/saa-auth.lib.js,@require 注入)—— 缺失则远程中枢不可用 */ function pcHubAuthLib() { try { return (typeof window !== 'undefined' && window.SAAAuth) ? window.SAAAuth : null; } catch (e) { return null; } } /** 把用户配置的地址同步给认证库(地址变了要同步,否则签名的是旧 host) */ function pcHubSyncAuth() { const auth = pcHubAuthLib(); if (!auth) return null; const cfg = pcHubConfig(); if (cfg.baseUrl) { try { auth.configure({ baseUrl: cfg.baseUrl }); } catch (e) { /* ignore */ } } return auth; } /** * 已认证会话的账号(Steam OpenID 验签得来)。 * ⭐ 这是**服务端认可的归属** —— 服务端会拿它跟上报载荷的 subject 比对, * 不一致直接 E_PUSH_SUBJECT 拒收。所以本地解析出的账号 * (cookie / 设置 / 库主账号回退)只作辅助,冲突时以它为准。 * @returns {string} 空串 = 还没绑定账号 */ function pcHubSessionSubject() { try { const auth = pcHubAuthLib(); const st = auth && typeof auth.getState === 'function' ? auth.getState() : null; return (st && st.subject) ? String(st.subject) : ''; } catch (e) { return ''; } } /** * 连通性探测:直接打 /health(该端点免鉴权),**不要求先有会话**。 * 用途:用户填完地址先测通不通,再去绑账号 —— 否则「测试连接」会一直报"没会话"。 */ function pcHubPing() { const cfg = pcHubConfig(); if (!cfg.baseUrl) return Promise.reject(new Error('NO_BASE_URL')); const u = assertSafeRequestUrl(cfg.baseUrl + '/health', { allowPrivate: cfg.allowPrivate, allowAiEndpoint: true }); if (u.protocol !== 'https:' && !cfg.allowHttp) return Promise.reject(new Error('INSECURE_BASE_URL')); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: cfg.baseUrl + '/health', timeout: 15000, headers: { 'Accept': 'application/json' }, onload: (r) => { let body = null; try { body = JSON.parse(r.responseText); } catch (e) { body = null; } if (r.status < 200 || r.status >= 300) { reject(new Error((body && body.error) ? String(body.error) : ('HTTP ' + r.status))); return; } if (!body) { reject(new Error('数据中枢返回的不是合法 JSON')); return; } resolve(body); }, onerror: () => reject(new Error('无法连接远程数据中枢(服务不可达?)')), ontimeout: () => reject(new Error('远程数据中枢请求超时')), }); }); } /** * 请求远程数据中枢。 * 会话 / 设备签名 / 自动刷新 / 401 重试 全部由 saa-auth 负责; * 这里只负责:路由白名单 + 地址安全校验 + 把业务错误翻译成人话。 */ async function pcHubRequest(path, query) { const cfg = pcHubConfig(); if (!cfg.baseUrl) throw new Error('NO_BASE_URL'); if (!pcHubRouteAllowed(path, 'GET')) throw new Error('ROUTE_NOT_ALLOWED'); const auth = pcHubSyncAuth(); if (!auth) throw new Error('NO_AUTH_LIB'); const qs = new URLSearchParams(query || {}).toString(); const full = qs ? `${path}?${qs}` : path; const u = assertSafeRequestUrl(cfg.baseUrl + full, { allowPrivate: cfg.allowPrivate, allowAiEndpoint: true }); if (u.protocol !== 'https:' && !cfg.allowHttp) throw new Error('INSECURE_BASE_URL'); const res = await auth.request(full, { method: 'GET' }); if (!res || res.status < 200 || res.status >= 300) { const d = (res && res.data) || null; const code = (d && d.code) ? String(d.code) : ''; const hint = (d && d.hint) ? String(d.hint) : ''; const msg = (d && (d.error || d.message)) ? String(d.error || d.message) : ('HTTP ' + (res && res.status)); // ⭐ 与 pcHubPost 保持一致:带上 code / hint。 // 少了它们,「[E_PUSH_OWNER] owner 不匹配」这类**可操作**的错误 // 只剩一句干巴巴的文案,用户无从下手。 throw new Error((code ? '[' + code + '] ' : '') + msg + (hint ? '(' + hint + ')' : '')); } if (!res.data) throw new Error('数据中枢返回的不是合法 JSON'); return res.data; } ToolRegistry.register({ name: 'searchLocalDb', description: '查询「数据中枢」(data-hub) 的个人游戏数据资产库 —— SQLite + FTS5 全文索引,支持关键字 + 结构化过滤 + 排序。' + '适合回答「我的库里剧情超过 20 小时的游戏有哪些」「2015 年后发售的独立游戏」「买过但一次没玩的游戏」「价格低于 XX 的游戏」这类**需要跨维度筛选**的问题' + '(readPluginCache 只能整读一个缓存键,做不了筛选)。' + '⚠️ 数据来自用户自己用 data-hub 工具链沉淀的库,**不是 Steam 实时数据**;' + '本工具只在用户已配置远程数据中枢地址时才会出现在工具列表里,届时若服务不可达请改用 readPluginCache / getOwnedGames,不要编造结果。' + '过滤字段是白名单:year / steam_year / story_hours / extra_hours / price / hltb_id(数值)+ type / name / name_cn(文本);' + 'scope 取 all / mine / unplayed / wishlist。', parameters: { type: 'object', properties: { q: { type: 'string', description: '关键字(中英文均可;中文按二元组匹配,单个汉字自动降级为子串匹配)' }, scope: { type: 'string', enum: ['all', 'mine', 'unplayed', 'wishlist'], description: '范围过滤,默认 all' }, filters: { type: 'string', description: '结构化过滤,逗号分隔,如 "year>=2020, story_hours>20, type=game"' }, sort: { type: 'string', description: '排序字段(可排序:year / steam_year / story_hours / extra_hours / price / hltb_id),加 - 前缀为倒序' }, limit: { type: 'number', description: '返回条数,默认 20,最大 100' }, offset: { type: 'number', description: '分页偏移,默认 0' }, appid: { type: 'number', description: '查单款游戏的完整档案(发行年/剧情时长/价格历史/成就完成率)时用这个,不要与 q 混用' }, }, }, category: 'knowledge', // ⚠️ 门槛在「配置」而不是「服务在线」:令牌没配 = 这台机器根本没搭 data-hub,喂给模型只会换来一条死路引导; // 配了令牌但服务临时掉线仍暴露(execute 的报错可引导用户去启动服务)。 enabled: () => !!pcHubConfig().baseUrl, async execute(params) { const cfg = pcHubConfig(); if (!cfg.baseUrl) { return { success: false, error: '远程数据中枢已停用。请在「设置 → 数据源 → 远程数据中枢」勾选启用,' + '并点「绑定 Steam 账号」完成一次身份认证;或改用 readPluginCache / getOwnedGames 回答这类问题。', needConfig: true, }; } const limit = Math.max(1, Math.min(Number(params.limit) || 20, 100)); const offset = Math.max(0, Number(params.offset) || 0); // ---- 单条档案 ---- if (params.appid != null && params.appid !== '') { const appid = Number(params.appid); if (!Number.isFinite(appid)) return { success: false, error: `appid 非法:${params.appid}` }; let r; try { r = await pcHubRequest(`/app/${appid}`, {}); } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } const app = r.app || {}; return { success: true, data: { mode: 'app', appid, name: app.name || null, nameCn: app.name_cn || null, year: app.hltb_year || app.release_year || null, storyHours: app.story_hours || null, extraHours: app.extra_hours || null, priceCny: app.price_cny || null, type: app.type || null, owned: r.owned || null, priceHistory: r.priceHistory || [], rarestAchievements: r.rarestAchievements || [], }, summary: `${app.name || appid}:${app.hltb_year || app.release_year || '—'} 年,` + `主线 ${app.story_hours || '—'} 小时,支线 ${app.extra_hours || '—'} 小时`, }; } // ---- 搜索 ---- const scope = String(params.scope || 'all').trim().toLowerCase(); if (PC_HUB_SCOPES.indexOf(scope) < 0) { return { success: false, error: `未知 scope "${scope}",可选:${PC_HUB_SCOPES.join(' / ')}` }; } let sort = String(params.sort || '').trim(); if (sort) { const key = sort.charAt(0) === '-' ? sort.slice(1) : sort; if (PC_HUB_SORTABLE.indexOf(key) < 0) { return { success: false, error: `不可排序字段 "${key}",可排序:${PC_HUB_SORTABLE.join(' / ')}` }; } } let filters = ''; try { pcHubParseFilters(params.filters); filters = String(params.filters || '').trim(); } catch (e) { return { success: false, error: String(e && e.message || e) }; } const query = { q: String(params.q || '').trim(), scope, filters, sort, limit, offset }; let r; try { r = await pcHubRequest('/search', query); } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } const items = (r.items || []).map((it) => ({ appid: it.appid, name: it.name || null, nameCn: it.name_cn || null, year: it.hltb_year || it.release_year || null, storyHours: it.story_hours || null, extraHours: it.extra_hours || null, priceCny: it.price_cny || null, type: it.type || null, })); // 库里有几个 Steam 账号?(未登录时也能让模型知道 scope=all 是**全库**数据) let accInfo = null; try { const acc = await pcHubAccounts(); if (acc && acc.accounts && acc.accounts.length) { const pm = acc.accounts.find((x) => x.steamid === acc.primary) || {}; accInfo = { accountCount: acc.accounts.length, primary: acc.primary || null, primaryName: pm.displayName || null, accounts: acc.accounts.map((x) => ({ steamid: x.steamid, displayName: x.displayName || null, isPrimary: x.isPrimary })), }; } } catch (e) { /* 拿不到就算了,不影响查询本身 */ } return { success: true, data: { mode: 'search', total: r.total || 0, limit, offset, scope, q: r.q || '', matchMode: r.mode || 'fts', items, note: r.total > items.length ? `共 ${r.total} 条,本次返回 ${items.length} 条,可用 offset 翻页` : null, library: accInfo, libraryNote: (accInfo && accInfo.accountCount > 1) ? `库里共有 ${accInfo.accountCount} 个 Steam 账号(主账号:${accInfo.primaryName || accInfo.primary})。` + 'scope=all / scope=mine 都是**全库口径**(mine=库内任何人拥有),不是某个人的;' + '要按人查请用 localDbProfile,或在 localDbProfile / pushObservation 里指定 steamid。' : null, }, summary: `命中 ${r.total || 0} 条(scope=${scope}${r.q ? ',q=' + r.q : ''}),返回前 ${items.length} 条` + (accInfo && accInfo.accountCount > 1 ? `|库内 ${accInfo.accountCount} 个账号,本次为全库口径` : ''), }; }, }); /** 把中枢的底层错误翻译成人话 */ function pcHubErrorText(e, cfg) { const msg = String(e && e.message || e); const c = cfg || pcHubConfig(); if (msg === 'NO_BASE_URL') return '远程数据中枢已停用 —— 请到「设置 → 数据源 → 远程数据中枢」勾选启用(默认地址 hub.leansoftx.com)。'; if (msg === 'NO_AUTH_LIB') return '认证库未加载(saa-auth.lib.js)—— 远程数据中枢不可用,请更新插件。'; if (msg === 'ROUTE_NOT_ALLOWED') return '请求的接口不在白名单内(已拦截)。'; if (msg === 'INSECURE_BASE_URL') return '远程地址必须用 https(或在设置里显式允许明文 HTTP)。'; if (/已拦截对内网/.test(msg)) return '远程地址指向内网/本机 —— 请在设置里勾选「允许内网地址」。'; if (/已拦截非 http/.test(msg)) return '远程地址协议不合法(只支持 http/https)。'; if (/无法连接|连接失败|网络错误|请求超时/.test(msg)) { return `无法连接远程数据中枢(${c.baseUrl || '未配置'})—— 请检查地址与服务器状态。`; } if (/未授权|E_UNAUTHORIZED|E_SESSION|E_DEVICE|E_PROOF|E_REFRESH|E_STEAM/.test(msg)) { return '会话未认证或已失效 —— 请到「设置 → 数据源 → 远程数据中枢」重新「绑定 Steam 账号」。'; } if (/NEEDS_STEAM_LOGIN/.test(msg)) { return '还没有可用会话 —— 请到「设置 → 数据源 → 远程数据中枢」点「绑定 Steam 账号」完成一次身份认证。'; } // 推送侧的账号/库归属问题:给可操作的指引,别只把服务端原文丢给用户 if (/E_PUSH_SUBJECT/.test(msg)) { return '上报的账号与当前认证账号不一致,已被服务端拒绝 —— ' + '请确认浏览器登录的 Steam 账号与「绑定 Steam 账号」的账号是同一个。'; } if (/E_PUSH_OWNER/.test(msg)) { return '连到的不是插件预期的那个库(owner 不匹配)—— ' + '请在「设置 → 数据源 → 远程数据中枢」确认服务地址指向的是你的 data-hub。'; } if (/E_PUSH_RATE/.test(msg)) { return '上报过于频繁(服务端限流)—— 请稍候再试,别连续重复上报同一批结论。'; } return '数据中枢查询失败:' + msg; } // ============================================================ // v1.27.0 技能库(skills)—— agent 按需读取操作技能 // ============================================================ // ⭐ 技能的唯一事实来源是 **data-hub/skills/*/SKILL.md**,由 data-hub 自己解析并通过 // GET /skills 提供。本 agent 的取用优先级: // ① 远程 data-hub 的 /skills(最新、与服务端 schema 一致) // ② CDN 分发副本(@resource,纯 JSON,没装 data-hub 时的兜底) // ③ 内置最小索引(连 data-hub 都没有时,至少告诉模型有哪些技能) // 这样「CLI agent 用 hubq 读技能」与「浏览器 agent 用 readSkill 读技能」读到的是同一份内容。 const SKILLS_RESOURCE_NAME = 'saaSkills'; const SKILLS_RESOURCE_URL_DEFAULT = 'https://raw.githubusercontent.com/SmallRob/steam-namespace/refs/heads/main/data/saa-skills.json'; /** 内置最小索引(③ 降级):只列技能与要点,不含正文 */ const SKILLS_FALLBACK = { skills: [{ id: 'hub-skill', title: 'data-hub 查询技能(hubq)', summary: '本地 Steam 数据资产库的查询与分析技能:意图路由、where DSL、画像口径、报告产出。', version: 'fallback', sections: [], agentTools: ['readSkill', 'searchLocalDb', 'readPluginCache', 'localDbProfile', 'pushInsight', 'pushObservation'], commands: [], }], }; /** * 技能正文的回灌预算(字符)。 * ⚠️ 必须小于 CONFIG.TOOL_RESULT_MAX_CHARS(12000),否则整份正文会被 * truncateToolResult 截成 JSON 字符串(模型读到的是一坨带引号的残骸)。 * 超预算时明确告知「已截断 + 可按 section 精确读取」,不假装读全了。 */ const SKILL_CONTENT_MAX_CHARS = 6000; /** 技能缓存(同一会话内只取一次,除非 force) */ const _skillsState = { skills: null, source: null, full: false }; function skillsGetRemoteUrl() { let url = SKILLS_RESOURCE_URL_DEFAULT; try { const raw = GM_getValue('sa_skills_url', ''); if (raw && /^https?:\/\//i.test(raw)) url = String(raw).trim(); } catch (e) { /* ignore */ } return url; } /** * 拉取技能库:① data-hub → ② CDN @resource → ③ 内置索引 * ⚠️ data-hub 的 GET /skills 只返回**索引**(有小节标题、没有正文)—— * 要读正文必须再取 GET /skills/:id;CDN 副本自带正文。 */ async function loadSkills(force) { if (_skillsState.skills && !force) return _skillsState; const cfg = pcHubConfig(); // ① 远程 data-hub(返回 { ok, data: { skills, dir, count } }) if (cfg.baseUrl) { try { const r = await pcHubRequest('/skills', {}); const skills = (r && r.data && r.data.skills) || []; if (skills.length) { _skillsState.skills = skills; _skillsState.source = 'data-hub'; _skillsState.full = false; try { GM_setValue('sa_skills_cache', JSON.stringify({ skills, ts: Date.now() })); } catch (e) { /* ignore */ } return _skillsState; } } catch (e) { /* 落到下一级 */ } } // ② CDN 分发副本(纯 JSON,自带正文,不执行远程代码) try { const text = GM_getResourceText(SKILLS_RESOURCE_NAME); if (text) { const payload = JSON.parse(text); if (payload && Array.isArray(payload.skills) && payload.skills.length) { _skillsState.skills = payload.skills; _skillsState.source = 'cdn'; _skillsState.full = true; return _skillsState; } } } catch (e) { /* 落到下一级 */ } // ③ 内置最小索引(只有名字与要点,没有正文) _skillsState.skills = SKILLS_FALLBACK.skills; _skillsState.source = 'fallback'; _skillsState.full = false; return _skillsState; } /** 取技能全文(含小节正文)—— data-hub 走 /skills/:id,CDN 副本自带 */ async function loadSkillFull(id) { const st = await loadSkills(false); if (st.full) { const hit = (st.skills || []).find((s) => s.id === id); if (hit) return hit; } if (st.source === 'data-hub') { try { const r = await pcHubRequest(`/skills/${encodeURIComponent(id)}`, {}); const sk = (r && r.data) || r; if (sk && sk.id) return sk; } catch (e) { /* 落到 null */ } } return null; } function skillsFind(list, name) { const key = String(name || '').trim().toLowerCase(); if (!key) return null; return (list || []).find((s) => s.id.toLowerCase() === key) || (list || []).find((s) => s.id.toLowerCase().indexOf(key) >= 0) || (list || []).find((s) => String(s.title || '').toLowerCase().indexOf(key) >= 0) || null; } function skillsSearch(list, query, limit) { const q = String(query || '').trim().toLowerCase(); const max = Math.max(1, Math.min(Number(limit) || 8, 30)); if (!q) return []; const out = []; for (const s of (list || [])) { for (const sec of (s.sections || [])) { const body = String(sec.body || ''); if (!body) continue; // 索引态小节没有正文,跳过 const idx = body.toLowerCase().indexOf(q); if (idx < 0) continue; out.push({ id: s.id, title: s.title, sectionId: sec.id, sectionTitle: sec.title, snippet: body.slice(Math.max(0, idx - 60), Math.max(0, idx - 60) + 220).replace(/\n+/g, ' '), }); if (out.length >= max) return out; } } return out; } ToolRegistry.register({ name: 'readSkill', description: '读取本机的**操作技能库**(skills)—— 每个技能是一份「遇到这类问题该怎么用工具」的操作手册。' + '适合处理**复杂多步任务**(需要跨表筛选 + 交叉验证 + 出报告/图表):先读技能拿到意图路由与口径纪律,再按路由调用工具。' + '典型场景:问「我的游戏库是什么画像」「帮我统计我的厂商集中度」「出一份库存盘点报告」。' + '技能来源优先级:远程 data-hub 的 /skills(最新)→ CDN 分发副本 → 内置最小索引。' + '⚠️ 技能只给**操作指引与口径**,不代替事实;事实仍要靠工具查。' + '用法:不传 name = 列出全部技能;传 name = 读全文;传 name+section = 只读某一小节;传 query = 全文检索片段。', parameters: { type: 'object', properties: { name: { type: 'string', description: '技能 id(如 hub-skill)。不传 = 列出全部技能' }, section: { type: 'string', description: '小节 id 或标题(先用不传 name 的模式看小节列表)' }, query: { type: 'string', description: '在技能正文里检索关键词,返回命中片段' }, limit: { type: 'number', description: '检索结果条数,默认 8,最大 30' }, force: { type: 'boolean', description: '强制重新拉取(默认用会话缓存)' }, }, }, category: 'knowledge', async execute(params) { const p = params || {}; let st; try { st = await loadSkills(!!p.force); } catch (e) { return { success: false, error: '技能库加载失败:' + String(e && e.message || e) }; } const list = st.skills || []; // ① 列出全部(索引就够) if (!p.name && !p.query) { return { success: true, data: { source: st.source, skills: list.map((s) => ({ id: s.id, title: s.title, summary: s.summary, version: s.version, chars: s.chars, sections: (s.sections || []).map((x) => ({ id: x.id, title: x.title })), agentTools: s.agentTools || [], })), }, summary: `技能库(来源 ${st.source})共 ${list.length} 个技能:` + list.map((s) => s.id).join(' / '), }; } // ② 检索(需要正文) if (p.query && !p.name) { const fulls = []; for (const s of list) { const f = await loadSkillFull(s.id); if (f) fulls.push(f); } const hits = skillsSearch(fulls, p.query, p.limit); return { success: true, data: { source: st.source, hits }, summary: hits.length ? `命中 ${hits.length} 处:${hits.map((h) => `${h.id}/${h.sectionTitle}`).join('、')}` : `技能库里没有匹配「${p.query}」的内容`, }; } // ③ 读技能 const skill = skillsFind(list, p.name); if (!skill) { return { success: false, error: `技能 "${p.name}" 不存在`, data: { available: list.map((s) => s.id) }, }; } const full = await loadSkillFull(skill.id); if (!full) { return { success: false, error: `技能「${skill.title}」的正文暂时取不到(当前来源 ${st.source} 只有索引)—— ` + '请确认远程数据中枢地址已配置且服务可达,或稍后重试。', data: { id: skill.id, source: st.source, sections: (skill.sections || []).map((x) => ({ id: x.id, title: x.title })), }, }; } if (p.section) { const secs = full.sections || []; const sec = secs.find((x) => x.id === p.section) || secs.find((x) => x.title === p.section) || secs.find((x) => String(x.id).toLowerCase().indexOf(String(p.section).toLowerCase()) >= 0); if (!sec) { return { success: false, error: `技能 "${skill.id}" 没有小节 "${p.section}"`, data: { available: secs.map((x) => ({ id: x.id, title: x.title })) }, }; } return { success: true, data: { source: st.source, id: full.id, title: full.title, section: sec.id, sectionTitle: sec.title, content: sec.body }, summary: `${full.id} · ${sec.title}`, }; } // 正文预算:超了就说清楚,别让模型以为读全了 const raw = String(full.content || ''); const capped = raw.length > SKILL_CONTENT_MAX_CHARS; const content = capped ? raw.slice(0, SKILL_CONTENT_MAX_CHARS) : raw; const outline = (full.sections || []).map((x) => ({ id: x.id, title: x.title })); return { success: true, data: { source: st.source, id: full.id, title: full.title, summary: full.summary, content, chars: full.chars, contentChars: raw.length, truncated: capped, sections: outline, agentTools: full.agentTools || [], commands: full.commands || [], hint: capped ? `技能正文共 ${raw.length} 字符,已截断到 ${SKILL_CONTENT_MAX_CHARS} 字符。` + `需要完整内容请用 section 参数精确读取小节(可选:${outline.map((x) => x.title).join(' / ')})。` : null, }, summary: `技能「${full.title}」共 ${full.chars} 字符 / ${(full.sections || []).length} 个小节` + (capped ? `(正文已截断到 ${SKILL_CONTENT_MAX_CHARS} 字符,可按小节精确读取)` : ''), }; }, }); // ============================================================ // v1.27.0 玩家画像(走 data-hub 的 /profile 与 /report) // ============================================================ ToolRegistry.register({ name: 'localDbProfile', description: '从「数据中枢」(data-hub) 取**玩家数据画像**与画像报告。' + '画像含:未开封率、时长阶梯(7 档)、清灰倒计时、被冷落的神作、最亏/最值、每小时价值、人格称号+稀有度。' + '适合回答「我的游戏库是什么画像」「我是不是囤货党」「我买的东西值不值」「给我一份库存盘点报告」。' + '⚠️ 回答前必看 data.library.durationEvidence:为 "missing" 表示库内没有游玩时长数据,' + '正确答案是「算不了」而不是「你 100% 没玩过」;价格类指标是**当前折后价**不是实付价;' + '好评率只覆盖部分游戏,榜单要带覆盖率。' + 'format=markdown 返回可读的 Markdown 报告正文;format=html 返回报告的**落盘路径**(完整版含内联 SVG 图表、零外链,' + '适合给用户直接打开看),模型解读请依据 profile 字段。', parameters: { type: 'object', properties: { steamid: { type: 'string', description: 'SteamID64;不传则用设置里配置的账号' }, format: { type: 'string', enum: ['json', 'html', 'markdown'], description: 'json=结构化画像;html=带图表的报告;markdown=纯文本报告。默认 json' }, topN: { type: 'number', description: '榜单条数(最亏/最值/沉默库存),默认 5,最大 20' }, minReviews: { type: 'number', description: '「被冷落的神作」的最低评价数,默认 1000' }, minRating: { type: 'number', description: '「被冷落的神作」的最低好评率,默认 90' }, }, }, category: 'knowledge', enabled: () => !!pcHubConfig().baseUrl, async execute(params) { const cfg = pcHubConfig(); if (!cfg.baseUrl) { return { success: false, error: '远程数据中枢已停用。请在「设置 → 数据源 → 远程数据中枢」勾选启用,并点「绑定 Steam 账号」完成身份认证。', needConfig: true, }; } const p = params || {}; // 身份解析:显式参数 > 设置配置 > cookie 检测 > 库主账号回退(未登录也能出画像) const identity = await pcHubResolveIdentity(p.steamid); const sid = identity.steamId; if (!sid) { return { success: false, error: identity.note || '缺少 steamid —— 请让用户在问题里给出 SteamID,或在设置里配置默认账号。' }; } const format = String(p.format || 'json').toLowerCase(); let r; try { r = await pcHubRequest(`/profile/${encodeURIComponent(sid)}`, { top: p.topN || 5, minReviews: p.minReviews || 1000, minRating: p.minRating || 90, }); } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } const pf = r.profile || r.data || r; const lib = pf.library || {}; const ld = pf.ladder || {}; const cn = pf.countdown || {}; const profileData = { steamid: sid, identitySource: identity.identitySource || null, identityNote: identity.note || null, headline: pf.headline || null, persona: pf.persona || null, library: lib, ladder: (ld.tiers || []).map((t) => ({ label: t.label, count: t.count, pct: t.pct })), neverPlayedPct: ld.neverPlayedPct, durationEvidence: lib.durationEvidence || null, valuePerHourCny: (pf.value || {}).valuePerHourCny, stockValueCny: (pf.value || {}).stockValueCny, countdown: { status: cn.status, reason: cn.reason, etaDate: cn.etaDate, etaDays: cn.etaDays, dailyMinutes: cn.dailyMinutes }, neglectedCount: (pf.neglected || {}).count, worst: ((pf.ranks || {}).worst || []).map((x) => ({ appid: x.appid, name: x.name, priceCny: x.priceCny, playtimeMinutes: x.playtimeMinutes, costPerHourCny: x.costPerHourCny })), best: ((pf.ranks || {}).best || []).map((x) => ({ appid: x.appid, name: x.name, priceCny: x.priceCny, playtimeHours: x.playtimeHours, costPerHourCny: x.costPerHourCny })), disclaimer: pf.disclaimer || [], }; // ---- json:结构化画像(默认)---- if (format === 'json') { return { success: true, data: profileData, summary: `${(pf.headline || {}).text || ''}` + (identity.note ? `|⚠️ ${identity.note}` : ''), }; } // ---- 报告(markdown / html)---- try { const wantHtml = format === 'html'; const rr = await pcHubRequest(`/report/${encodeURIComponent(sid)}`, { format: wantHtml ? 'html' : 'markdown', save: wantHtml ? 1 : 0, }); const content = (rr && rr.content) || ''; if (wantHtml) { // HTML 报告是「给人看」的(内联 SVG 图表、零外链),体积大 —— // 不整份塞进模型上下文,改为落盘给路径,模型依据 profile 字段解读。 return { success: true, data: { steamid: sid, format: 'html', bytes: (rr && rr.bytes) || content.length, savedPath: (rr && rr.savedPath) || null, profile: profileData, note: 'HTML 报告是给人看的完整版(含内联 SVG 图表、零外链),已落盘;模型解读请依据 profile 字段。', }, summary: `已生成 HTML 画像报告(${(rr && rr.bytes) || content.length} 字节)` + ((rr && rr.savedPath) ? `,已存盘:${rr.savedPath}` : ''), }; } return { success: true, data: { steamid: sid, format: 'markdown', bytes: content.length, content, profile: profileData }, summary: `已生成 Markdown 画像报告(${content.length} 字符)`, }; } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } }, }); // ============================================================ // v1.29.0 身份层:未登录降级 + actor 标识 // ============================================================ /** 库内账号清单的缓存(TTL 60s —— 库可能随时被同步工具加账号) */ let _accountsCache = null; let _accountsAt = 0; let _accountsBase = ''; const ACCOUNTS_TTL_MS = 60 * 1000; /** * 取库内 Steam 账号清单(含 owner 与主账号) * @returns {object|null} { owner, primary, accounts[] } —— 服务不可达时返回 null */ async function pcHubAccounts(force) { const cfg = pcHubConfig(); if (!cfg.baseUrl) return null; // 换地址 = 换一台服务器的库 → 缓存必须失效(owner 都不一样) if (cfg.baseUrl !== _accountsBase) { _accountsCache = null; _accountsBase = cfg.baseUrl; } if (!force && _accountsCache && (Date.now() - _accountsAt) < ACCOUNTS_TTL_MS) return _accountsCache; try { const r = await pcHubRequest('/accounts', {}); const d = (r && r.data) || r; _accountsCache = { owner: d.owner || null, primary: d.primary || null, accounts: d.accounts || [], }; _accountsAt = Date.now(); return _accountsCache; } catch (e) { return null; // 服务不可达 / 版本太旧 → 走「拿不到就明说」 } } /** * 身份解析(异步,带库主账号回退) * * ⚠️ 返回对象的身份字段名是 **steamId**(大写 I),调用侧别写成 steamid。 * * @returns {object} { steamId, source, identified, identitySource, note } * identified=true → cookie 检测到当前登录用户,或用户主动声明的默认账号 * identified=false → 系统兜底猜的(库主账号)—— **不谎称**是当前用户 */ async function pcHubResolveIdentity(explicit) { const fromParam = String(explicit || '').trim(); if (fromParam) { return { steamId: fromParam, source: '参数', identified: true, identitySource: 'param', note: null }; } const configured = getConfiguredSteamId(); if (configured) { return { steamId: configured, source: '设置', identified: true, identitySource: 'settings', note: null }; } const detected = detectSteamId(); if (detected) { return { steamId: detected, source: '自动检测', identified: true, identitySource: 'cookie', note: null }; } // ⭐ 回退:库里的主账号(account.is_primary=1) const acc = await pcHubAccounts(); if (acc && acc.primary) { const name = (acc.accounts.find((x) => x.steamid === acc.primary) || {}).displayName || acc.primary; return { steamId: acc.primary, source: '库主账号', identified: false, identitySource: 'library-primary', note: '未检测到当前登录的 Steam 用户(未登录且未配置 SteamID)—— 已回退到库里的主账号 ' + name + '(' + acc.primary + ')。数据归属该账号,但发起者身份未标识。', }; } return { steamId: '', source: '', identified: false, identitySource: '', note: '未检测到当前登录的 Steam 用户(未登录且未配置 SteamID),库里也没有主账号。' + '请在「设置 → 接口服务」配置 SteamID,或在问题里直接给出账号。', }; } /** 本机 agent 实例标识(持久化 —— 区分「哪个客户端实例在调」) */ function saaInstanceId() { try { let id = String(GM_getValue('sa_agent_instance', '') || ''); if (!id) { id = pcHash8(String(Date.now()) + ':' + Math.random()).slice(0, 6); GM_setValue('sa_agent_instance', id); } return id; } catch (e) { return 'tmp'; } } /** 构造 actor 段(谁在调 / 什么身份) */ function saaActor(identity) { return { client: 'steam-ai-agent', instance: saaInstanceId(), identity: (identity && identity.identified) ? 'identified' : 'unidentified', identitySource: (identity && identity.identitySource) || null, }; } // ============================================================ // v1.27.0 结论回灌(把 agent 的洞察写回 data-hub) // ============================================================ /** 稳定 hash:同样的内容 → 同样的 runId → 幂等 */ function pcHash8(text) { let h = 0x811c9dc5; const s = String(text || ''); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; } return h.toString(16).padStart(8, '0'); } /** POST 请求(推送用)—— 与 GET 走同一套会话 + 设备签名 */ async function pcHubPost(pathname, body) { const cfg = pcHubConfig(); if (!cfg.baseUrl) throw new Error('NO_BASE_URL'); if (!pcHubRouteAllowed(pathname, 'POST')) throw new Error('ROUTE_NOT_ALLOWED'); const auth = pcHubSyncAuth(); if (!auth) throw new Error('NO_AUTH_LIB'); const u = assertSafeRequestUrl(cfg.baseUrl + pathname, { allowPrivate: cfg.allowPrivate, allowAiEndpoint: true }); if (u.protocol !== 'https:' && !cfg.allowHttp) throw new Error('INSECURE_BASE_URL'); const res = await auth.request(pathname, { method: 'POST', body: body || {} }); if (!res || res.status < 200 || res.status >= 300) { const d = (res && res.data) || null; const code = (d && d.code) ? String(d.code) : ''; const hint = (d && d.hint) ? String(d.hint) : ''; const msg = (d && (d.error || d.message)) ? String(d.error || d.message) : ('HTTP ' + (res && res.status)); throw new Error((code ? '[' + code + '] ' : '') + msg + (hint ? '(' + hint + ')' : '')); } if (!res.data) throw new Error('数据中枢返回的不是合法 JSON'); return res.data; } // ============================================================ // v1.33.0 插件版本上报(D10 运营面板数据通道,docs §3.4–§3.5) // ============================================================ // 口径纪律: // · 只报**事实**(id/version/versionKnown/detectedBy/regDomain/lastSeen), // 不放 outdated / minVersion —— 契约会变,采信客户端会让历史样本口径全错, // 过旧判定由服务端按当前契约算。 // · 版本号来自 registerPlugin 注册表(@version 同源,不漂移),不做指纹采集。 // · 每日一次(当日首次运行时),失败静默不写「已报」标记,下次补传。 // · 唯一例外:绑定 Steam 账号成功后必报一次(reason='bind',与开关无关)。 // · optout 是「失败静默降级」纪律的唯一例外:撤销失败 = 用户以为已删实际仍在, // 置 pendingOptout 重试并在设置面板可见。 const PC_REPORT_KEY = 'sa_hub_plugin_report_v1'; function pcReportState() { try { const raw = GM_getValue(PC_REPORT_KEY, ''); const o = raw ? JSON.parse(raw) : {}; return { enabled: o.enabled !== false, // 默认勾选(§3.5.3 已拍板) lastReportDay: String(o.lastReportDay || ''), pendingOptout: !!o.pendingOptout, }; } catch (e) { return { enabled: true, lastReportDay: '', pendingOptout: false }; } } function pcReportSaveState(st) { try { GM_setValue(PC_REPORT_KEY, JSON.stringify(st)); } catch (e) { /* ignore */ } } /** 本地日期串(每日一次的「当日」口径) */ function pcReportToday() { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } /** 构造上报清单:只含事实。不复用 getInstalledPlugins(那是给模型看的,含契约口径字段)。 * 来源 = pcReadPluginRegistry()(本域 IDB + GM 跨域镜像);探测型信号不进上报 * (12 插件全部已接入 registerPlugin,探测兜底只服务没有注册表的异常场景)。 */ async function pcBuildVersionManifest(reason) { const registry = await pcReadPluginRegistry(); const plugins = []; for (const [id, rec] of registry) { const version = rec && rec.version ? String(rec.version).trim() : ''; plugins.push({ id: String(id), version: version || null, // versionKnown=0 时为 null(不是空串) versionKnown: !!version, detectedBy: rec && rec.viaMirror ? 'registry-mirror' : 'registry', regDomain: (rec && rec.regDomain) || null, lastSeen: (rec && (rec.ts || rec.registeredAt)) || null, }); } return { agentVersion: _v, domain: pcPanelDomain() || null, reason: reason === 'bind' ? 'bind' : 'scheduled', plugins, }; } /** * 发送版本清单。失败抛错(调用方决定是否静默)。 * 门禁:scheduled 只在开关勾选时发;bind 不看开关(§3.5.4 排障通道)。 * scheduled 成功才写 lastReportDay(失败不写 → 下次补传);bind 不写(每次绑定都报)。 */ async function pcSendVersionReport(reason) { const st = pcReportState(); if (reason !== 'bind' && !st.enabled) return { skipped: true, reason: 'disabled' }; const manifest = await pcBuildVersionManifest(reason); if (!manifest.plugins.length) return { skipped: true, reason: 'empty-registry' }; await pcHubPost('/report/plugins', manifest); if (reason !== 'bind') { st.lastReportDay = pcReportToday(); pcReportSaveState(st); } return { ok: true, count: manifest.plugins.length }; } /** 撤销上报:删服务端该设备的全部版本记录。成功返回 true;失败置 pendingOptout。 */ async function pcOptoutVersionReport() { const st = pcReportState(); try { await pcHubPost('/report/plugins/optout', {}); st.pendingOptout = false; pcReportSaveState(st); return true; } catch (e) { st.pendingOptout = true; pcReportSaveState(st); return false; } } /** 插件启动时的上报维护(每日一次 + 撤销重试;全部静默,绝不影响面板初始化) */ async function pcReportStartup() { try { await pcHubResolveEndpointRemote(); } catch (e) { /* 回落常量 */ } const st = pcReportState(); // ① 撤销重试:用户以为已删而实际仍在,必须补送达(静默纪律的唯一例外) if (st.pendingOptout) { try { await pcOptoutVersionReport(); } catch (e) { /* 下次再试 */ } } // ② 每日周期上报:当日已报过 → 跳过;失败不写标记,下次机会补传 if (st.enabled && st.lastReportDay !== pcReportToday()) { try { await pcSendVersionReport('scheduled'); } catch (e) { /* 静默 */ } } } ToolRegistry.register({ name: 'pushInsight', description: '把你分析出来的**结论**回灌到「数据中枢」(data-hub) —— 落进 insight / analysis_* 表,' + '成为可复算、可追溯的库资产(而不是只留在聊天记录里)。' + '适合:分析出「某厂商集中度偏高」「某系列还差 3 款」「这款每小时成本极低」这类**可复用的结论**时调用。' + '⚠️ 只上报**有依据的结论**,不要上报过程性废话;' + '同一份内容重复上报是幂等的(不会重复堆积),但换措辞会被当成新结论。' + 'runId 由工具自动生成(ai.insight:<账号>:<内容指纹>),调用方不用关心;' + '指纹覆盖标题/正文/类别/强度/指标/总结,**改了任何一项都算新结论**。' + '归属以「已认证的 Steam 账号」为准 —— 服务端只认它。' + '拿不准要不要落库时先用 dryRun 预演。', parameters: { type: 'object', properties: { steamid: { type: 'string', description: '结论所属账号 SteamID64;不传则用设置里的默认账号' }, insights: { type: 'array', description: '结论列表(建议 1~10 条)', items: { type: 'object', properties: { title: { type: 'string', description: '结论标题(一句话,会进 insight 表)' }, message: { type: 'string', description: '结论正文:数据依据 + 口径说明' }, kind: { type: 'string', enum: ['value', 'warning', 'recommendation', 'library', 'playtime', 'wishlist', 'collection', 'profile', 'insight', 'risk', 'price', 'other'], description: '类别,默认 insight(取值域与服务端白名单一致)' }, severity: { type: 'string', enum: ['info', 'warn', 'strong'], description: '强度,默认 info' }, appid: { type: 'number', description: '关联的 appid(可选)' }, }, }, }, summary: { type: 'string', description: '本次结论的一句话总结(会写进 analysis_report.summary)' }, metrics: { type: 'object', description: '本次分析算出的数值指标(键值对,如 {"never_played_pct":57.5})' }, dryRun: { type: 'boolean', description: '只预览将要上报的内容(runId / 条目 / 归属),不真正落库' }, }, }, category: 'knowledge', enabled: () => !!pcHubConfig().baseUrl, async execute(params) { const cfg = pcHubConfig(); if (!cfg.baseUrl) { return { success: false, error: '远程数据中枢已停用,本工具不可用。请在「设置 → 数据源 → 远程数据中枢」勾选启用。', needConfig: true, }; } const p = params || {}; const list = Array.isArray(p.insights) ? p.insights : []; if (!list.length) { return { success: false, error: '没有要上报的结论(insights 为空)—— 没有结论就不要调用本工具' }; } // 身份解析:显式参数 > 设置配置 > cookie 检测 > 库主账号回退 const identity = await pcHubResolveIdentity(p.steamid); const acc = await pcHubAccounts(); const sid = identity.steamId || 'global'; // ⭐ 已认证会话的账号(服务端认可的归属) const sessSubject = pcHubSessionSubject(); // ⭐ 内容指纹:**覆盖全部会落库的字段**。 // 原来只算 [title, message, appid] → 改了 kind/severity、换了 metrics 或 summary, // 指纹不变 → 还是同一个 runId → 旧行被覆盖(类别/强度/指标被悄悄改写)。 // 现在 kind/severity/metrics/summary 都在指纹里:内容真变了才算新结论。 const fingerprint = JSON.stringify({ items: list.map((x) => [ String(x.title || ''), String(x.message || ''), x.appid || 0, String(x.kind || 'insight'), String(x.severity || 'info'), ]), metrics: (p.metrics && typeof p.metrics === 'object') ? p.metrics : {}, summary: p.summary ? String(p.summary) : '', }); // ⭐ 幂等键的身份维度:**会话账号优先**。 // sid 兜底成 'global' 时所有账号的同名结论共用一个 runId // → 共享库里后写的覆盖先写的(跨用户覆盖)。 const idKey = sessSubject || (/^\d{17}$/.test(sid) ? sid : 'global'); const runId = `ai.insight:${idKey}:${pcHash8(fingerprint)}`; // payload.subject 以会话账号为准(服务端会拿它再比一次,必须一致) const subject = sessSubject || (/^\d{17}$/.test(sid) ? sid : null); const items = list.map((x) => ({ kind: x.kind || 'insight', severity: x.severity || 'info', appid: x.appid || null, title: String(x.title || '').slice(0, 200), message: String(x.message || '').slice(0, 4000), })); const metrics = (p.metrics && typeof p.metrics === 'object') ? p.metrics : {}; const report = p.summary ? { summary: String(p.summary).slice(0, 2000), payload: { source: 'steam-ai-agent' } } : null; // 身份冲突提示:本地解析出的账号与会话账号不一致时如实说明(不静默改写归属) const identityNote = identity.note || (sessSubject && /^\d{17}$/.test(sid) && sessSubject !== sid ? `本地识别到的账号 ${sid} 与已认证会话账号 ${sessSubject} 不一致 —— 已按已认证账号归属上报` : null); if (p.dryRun) { return { success: true, data: { dryRun: true, runId, subject, items, metrics, report, identitySource: identity.identitySource || null, identityNote: identityNote || null, }, summary: `预演:将上报 ${items.length} 条结论(run ${runId})` + (identityNote ? `|⚠️ ${identityNote}` : ''), }; } const payload = { runId, kind: 'ai.insight', subject, source: 'steam-ai-agent', agentVersion: _v, actor: saaActor(identity), owner: (acc && acc.owner) || null, items, metrics, report, }; try { const r = await pcHubPost('/push/agent', payload); return { success: true, data: { runId: r.runId, inserted: r.inserted, replaced: !!r.replaced, source: r.source, subject: payload.subject, identitySource: identity.identitySource || null, identityNote: identityNote || null, }, summary: `已回灌 ${payload.items.length} 条结论(run ${r.runId})` + (r.replaced ? ',同名 run 已覆盖(幂等)' : '') + (identityNote ? `|⚠️ ${identityNote}` : ''), }; } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } }, }); // ============================================================ // v1.28.0 观测上报(关键指标 + 游玩时长明细 → data-hub) // ============================================================ // ⭐ 数据来源是 **Steam 官方 API**,不是从库里搬: // · playtime_forever / playtime_2weeks 取原始分钟,零精度损失; // · 库里缺的 playtime_2weeks(实测覆盖 0%)这里能补上; // · 「未知」不会被折算成 0 —— 官方给了才算数。 ToolRegistry.register({ name: 'pushObservation', description: '把**游玩时长明细 + 关键指标**上报到「数据中枢」(data-hub) —— 落进 analysis_app / ' + 'library_metric_daily(可复算、可追溯的库资产),供后续分析与趋势回看。' + '数据取自 Steam 官方 API(GetOwnedGames),因此是**当前最权威的时长**。' + '适合:用户说「把这次的时长/指标也存下来」「之后按周回看我的游玩变化」「导一份我的时长明细进库」。' + '⚠️ scope=partial(默认)只落分析产物区,安全;scope=full 才会尝试刷新事实表,' + '但服务端会校验「条目数不得少于库内已有快照」,数据不全时会自动降级并说明原因。' + '上报是幂等的:幂等键 = **内容指纹**(不含时间戳)—— 同一份时长数据重复上报走同一个 runId,' + '覆盖而不堆积;时长真的变了才会新增一行。' + '⚠️ lastPlayed 一律传「未知」(GetOwnedGames 不提供该字段),服务端会沿用库里的历史值,' + '不会把「从未游玩」写进事实表。', parameters: { type: 'object', properties: { steamid: { type: 'string', description: '账号 SteamID64;不传则用当前登录用户' }, scope: { type: 'string', enum: ['partial', 'full'], description: 'partial=只落分析产物区(默认,安全);full=尝试刷新事实表 owned_game(需通过服务端护栏)' }, includeProfile: { type: 'boolean', description: '是否顺带上报画像级标量(未开封率/人格称号等,走 metrics → analysis_result)。默认 true' }, dryRun: { type: 'boolean', description: '只预览将要上报的内容,不真正落库' }, }, }, category: 'knowledge', enabled: () => !!pcHubConfig().baseUrl, async execute(params) { const cfg = pcHubConfig(); if (!cfg.baseUrl) { return { success: false, error: '远程数据中枢已停用,本工具不可用。请在「设置 → 数据源 → 远程数据中枢」勾选启用。', needConfig: true, }; } const p = params || {}; const scope = String(p.scope || 'partial').toLowerCase() === 'full' ? 'full' : 'partial'; // ---- 取 Steam 官方时长(权威来源)---- const apiKey = getSteamApiKey(); if (!apiKey) { return { success: false, needsApiKey: true, error: '此工具需要 Steam Web API Key。请引导用户点击对话中的「配置 Steam API Key」按钮,或打开设置面板填写 Key(可在 steamcommunity.com/dev 免费申请),配置完成后用户回复"已配置",再重新调用本工具', }; } // 身份解析:显式参数 > 设置配置 > cookie 检测 > **库主账号回退** // ⭐ GetOwnedGames 是**公开 API**(只需 API Key,不需要浏览器登录态), // 所以回退到库主账号后照样能拉官方时长。 const identity = await pcHubResolveIdentity(p.steamid); const steamId = identity.steamId; if (!steamId) { return { success: false, error: identity.note || '未提供也未检测到 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 && e.message || e) }; } if (!id64) return { success: false, error: '自定义 ID "' + steamId + '" 解析失败(可能不存在)' }; } let games = []; try { const resp = await gmFetch('https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?key=' + apiKey + '&steamid=' + id64 + '&include_appinfo=0&format=json'); const data = JSON.parse(resp); games = (data && data.response && data.response.games) || []; } catch (e) { return { success: false, error: '拉取 Steam 官方游戏列表失败:' + String(e && e.message || e) }; } if (!games.length) { return { success: false, error: 'Steam 官方返回的游戏列表为空 —— 可能是资料隐私设置为「不公开游戏详情」。' + '请到 Steam 资料 → 隐私设置 → 游戏详情改为「公开」后重试。', }; } // ---- 组装观测段 ---- const acc = await pcHubAccounts(); const capturedAt = Date.now(); const playtime = games.map((g) => ({ appid: Number(g.appid), playtimeMin: Math.max(0, Math.round(Number(g.playtime_forever) || 0)), playtime2weeksMin: Math.max(0, Math.round(Number(g.playtime_2weeks) || 0)), // ⭐ lastPlayed = null(**未知**),不是 0。 // GetOwnedGames 不返回 last_played(那是 GetRecentlyPlayedGames); // 而 0 在 data-hub 里有真实语义「从未游玩」—— // 写 0 等于凭空造一个事实,覆盖事实表时还会把库里真实的 last_played 冲掉。 // 服务端收到 null 会**沿用历史值**(不冲列)。 lastPlayed: null, })).filter((g) => Number.isInteger(g.appid) && g.appid > 0); const totalMin = playtime.reduce((a, g) => a + g.playtimeMin, 0); const twoWeeksMin = playtime.reduce((a, g) => a + g.playtime2weeksMin, 0); const playedCount = playtime.filter((g) => g.playtimeMin > 0).length; const obsMetrics = { games_owned: playtime.length, games_played: playedCount, playtime_min_total: totalMin, playtime_min_2weeks: twoWeeksMin, }; // ---- 画像级标量(走既有 metrics → analysis_result)---- let profileMetrics = {}; let profileNote = null; if (p.includeProfile !== false) { try { const pr = await pcHubRequest('/profile/' + encodeURIComponent(id64), {}); const pf = pr.profile || pr.data || pr; const lib = pf.library || {}; const ld = pf.ladder || {}; profileMetrics = { profile_never_played_pct: ld.neverPlayedPct === undefined ? null : ld.neverPlayedPct, profile_duration_evidence: lib.durationEvidence || null, profile_games_measured: ld.measuredGames === undefined ? null : ld.measuredGames, profile_total_hours: lib.totalHours === undefined ? null : lib.totalHours, profile_value_per_hour_cny: (pf.value || {}).valuePerHourCny === undefined ? null : (pf.value || {}).valuePerHourCny, }; if (lib.durationEvidence === 'missing') { profileNote = '库内没有游玩时长数据(durationEvidence=missing)—— 未开封率类指标不可算,' + '本次上报的时长来自 Steam 官方 API,是可信的'; } } catch (e) { profileNote = '画像标量拉取失败(不影响时长上报):' + String(e && e.message || e); } } if (p.dryRun) { return { success: true, data: { dryRun: true, steamid: id64, scope, playtimeItems: playtime.length, obsMetrics, profileMetrics, profileNote, identitySource: identity.identitySource || null, identityNote: identity.note || null, sample: playtime.slice(0, 5), }, summary: '预演:将上报 ' + playtime.length + ' 款游戏的时长明细 + ' + Object.keys(obsMetrics).length + ' 个关键指标(scope=' + scope + ')' + (identity.note ? `|⚠️ ${identity.note}` : ''), }; } // ---- 上报 ---- const observations = { scope, steamid: id64, capturedAt, itemCount: playtime.length, metrics: obsMetrics, playtime, }; // ⭐ 幂等键 = **内容指纹**,**必须剔除 capturedAt**: // 含了它,每次调用都是新 runId → analysis_run / analysis_app 每次新增一行, // 「重复上报不增长行数」就是假的(上一版正是这个坑,表会一路膨胀)。 // 剔除后:同一份时长重复上报 → 同一 runId → 覆盖而非堆积; // 时长真的变了 → 指纹变了 → 新 runId(新一次观测,确实该新行)。 const obsFingerprint = JSON.stringify({ scope, steamid: id64, itemCount: observations.itemCount, metrics: obsMetrics, playtime, }); const payload = { runId: 'ai.observation:' + id64 + ':' + pcHash8(obsFingerprint), kind: 'ai.observation', subject: id64, source: 'steam-ai-agent', agentVersion: _v, actor: saaActor(identity), owner: (acc && acc.owner) || null, items: [], metrics: profileMetrics, observations, }; try { const r = await pcHubPost('/push/agent', payload); const ob = r.observations || {}; const facts = ob.facts || {}; return { success: true, data: { runId: r.runId, playtimeItems: ob.apps || 0, metricsApplied: ob.metrics || 0, factsApplied: !!facts.applied, factsRows: facts.rows || 0, factsReason: facts.reason || null, profileNote, identitySource: identity.identitySource || null, identityNote: identity.note || null, }, summary: '已上报 ' + (ob.apps || 0) + ' 条时长明细 + ' + (ob.metrics || 0) + ' 个关键指标' + (facts.applied ? ',并刷新了事实表(' + facts.rows + ' 行)' : '(事实表未刷新:' + (facts.reason || '未声明 full scope') + ')') + (identity.note ? `|⚠️ ${identity.note}` : ''), }; } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } }, }); // ============================================================ // v1.22.0 Steam 消费记录自抓(插件缓存计划 阶段3 / docs §5) // v1.34.0 修正取数链路(旧实现致命错误,见下) // ============================================================ // ⚠️ v1.22.0 的旧实现以为存在 GET /account/history/?ajax=1 这个 JSON 接口: // 实际 Steam 从来没有该路由,请求只会拿回整页 HTML → JSON.parse 必然失败 → // 被当成 SPEND_NOT_LOGIN,于是「明明已登录」也提示去重新登录,工具 100% 不可用。 // v1.34.0 改为 Steam 页面自身使用的真实链路(与 Steam-Account-History-Export 一致): // ① 首屏:GET /account/history/?l=schinese → 整页 HTML,解析首屏 // 行 + g_historyCursor + g_sessionID; // ② 翻页:POST /account/AjaxLoadMoreHistory/ // body=cursor=&sessionid=(表单编码)→ {html, cursor}, // 循环直到 cursor 为空 / 空页 / 达到 maxPages。 // ③ 登录态:页面含 g_sessionID / g_steamID / wallet_history_table 视为已登录; // 只有被重定向到 /login/ 或出现登录表单时才判定未登录(不再把接口异常算作未登录)。 // 分类规则移植自 Steam-Spending-History-Classifier v2.2.0(其 L24-32 类别常量、L303-320 // classifyRow、L423-431 parseNumber、L793 导出 schema)。该插件跑在已渲染 DOM 上, // 本工具解析 HTML 片段——改用正则(片段规整、td 不嵌套),Node 测试零 DOM 依赖。 // 缓存:sa_spending_cache(7 天 TTL,force 重抓),仅落本机 GM 存储,不上传。 const SPEND_CACHE_KEY = 'sa_spending_cache'; const SPEND_TTL = 7 * 24 * 60 * 60 * 1000; const SPEND_ORIGIN = 'https://store.steampowered.com'; const SPEND_HISTORY_URL = `${SPEND_ORIGIN}/account/history/?l=schinese`; const SPEND_MORE_URL = `${SPEND_ORIGIN}/account/AjaxLoadMoreHistory/`; const SPEND_BRIDGE_KEY = '__saa_bridge_spend__'; const SPEND_CATS = [ { id: 'store', label: '直购' }, { id: 'ingame', label: '内购' }, { id: 'gift', label: '送礼' }, { id: 'refund', label: '退款' }, { id: 'convert', label: '充值' }, { id: 'market_buy', label: '买入' }, { id: 'market_sell', label: '卖出' }, ]; const SPEND_CATS_SET = new Set(['store', 'gift', 'ingame']); // 计入净支出(同分类器 SPENDING_CATS) const SPEND_LABEL = SPEND_CATS.reduce((m, c) => (m[c.id] = c.label, m), { other: '其他' }); const SPEND_RE = { refund: /退款|Refund/i, convert: /转换|轉換|Convert/i, ingame: /游戏内购买|遊戲內物品購買|In-Game\s*Purchase/i, market: /市场交易|市集交易|Market\s*Transaction/i, gift: /礼物购买|禮物購買|Gift\s*Purchase/i, purchase: /购买|購買|Purchase/i, wallet: /钱包资金|錢包資金|Wallet/i, }; // 币种表(移植分类器 CURRENCIES,去 SVG 旗帜;dc=小数逗号制)。[id, 中文, 符号, 识别正则, dc] const SPEND_CURRENCIES = [ ['HKD', '港币', 'HK$', /HK\$\s*[\d,. ]+/, 0], ['TWD', '新台币', 'NT$', /NT\$\s*[\d,. ]+/, 0], ['AUD', '澳元', 'A$', /A\$\s*[\d,. ]+/, 0], ['CAD', '加元', 'CDN$', /CDN\$\s*[\d,. ]+/, 0], ['NZD', '新西兰元', 'NZ$', /NZ\$\s*[\d,. ]+/, 0], ['ARS', '阿根廷比索', 'ARS$', /ARS\$\s*[\d,. ]+/, 1], ['SGD', '新加坡元', 'S$', /(? ({ id, label, symbol, match, dc: !!dc })); const spendResolveYen = text => /\.\d+/.test(text) ? 'CNY' : 'JPY'; /** 金额解析(同分类器 parseNumber:处理 "1.234,56" 与 "1,234.56" 两种体系) */ function spendParseNum(text, decimalComma) { const m = String(text || '').match(/[\d]+(?:[ ,.]\d+)*/); if (!m) return 0; let s = m[0].replace(/ /g, ''); const li = s.lastIndexOf('.'), ci = s.lastIndexOf(','); if (decimalComma) { s = ci >= 0 ? s.replace(/\./g, '').replace(',', '.') : s.replace(/\./g, ''); } else if (li > 0 && ci > 0) { s = li > ci ? s.replace(/,/g, '') : s.replace(/\./g, '').replace(',', '.'); } else { s = s.replace(/,/g, ''); } return parseFloat(s) || 0; } /** HTML 片段 → 纯文本(实体解码 + 折叠空白,保留 为换行) */ function spendText(html) { return String(html || '') .replace(//gi, '\n') .replace(/<[^>]*>/g, ' ') .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') .replace(/?39;|'/g, "'").replace(/ /g, ' ').replace(/&/g, '&') .replace(/(\d+);/g, (_, d) => String.fromCharCode(Number(d))) .replace(/[ \t]+/g, ' ').replace(/ ?\n ?/g, '\n').trim(); } /** 分类(同分类器 classifyRow 的文本版:typeText 驱动,market 按钱包变更正负分买卖) */ function spendClassify(typeText, walletChangeText, itemsText) { const text = String(typeText || '').trim(); if (!text) return 'other'; if (SPEND_RE.refund.test(text)) return 'refund'; if (SPEND_RE.convert.test(text)) return 'convert'; if (SPEND_RE.ingame.test(text)) return 'ingame'; if (SPEND_RE.market.test(text)) { const ct = String(walletChangeText || ''); return ct.includes('+') ? 'market_sell' : ct.includes('-') ? 'market_buy' : 'other'; } if (SPEND_RE.gift.test(text)) return 'gift'; if (SPEND_RE.purchase.test(text)) return SPEND_RE.wallet.test(String(itemsText || '')) ? 'convert' : 'store'; return 'other'; } /** 单行币种识别:total → wallet_change → wallet_balance 依次找符号(CNY/JPY 靠小数区分) */ function spendDetectRowCurrency(cells) { const texts = [cells.total, cells.wallet_change, cells.wallet_balance] .map(h => spendText(h)).filter(t => t && t !== '--'); const yen = texts.find(t => /¥/.test(t) && !/JP¥|JPY/i.test(t)); if (yen) return spendResolveYen(yen); const all = texts.join(' '); for (const cur of SPEND_CURRENCIES) { if (cur.id === 'CNY') continue; if (cur.match.test(all)) return cur.id; } return null; // 交由调用方回退主货币 } /** ajax html 片段 → 行记录数组(正则解析;Steam 片段规整、td 互不嵌套) */ function spendParseRows(html) { const out = []; const rowRe = /]*class="[^"]*wallet_table_row[^"]*"[^>]*>([\s\S]*?)<\/tr>/g; let m; while ((m = rowRe.exec(String(html || ''))) !== null) { const openTag = m[0].slice(0, m[0].indexOf('>')); const rowHtml = m[1]; const cells = {}; const tdRe = /]*>([\s\S]*?)<\/td>/g; let t; while ((t = tdRe.exec(rowHtml)) !== null) { if (cells[t[1]] === undefined) cells[t[1]] = t[2]; } if (cells.type === undefined && cells.items === undefined) continue; // 非数据行(th 表头等) const typeHtml = cells.type || ''; const typeText = spendText(typeHtml); const itemsHtml = cells.items || ''; const firstDiv = /]*>([\s\S]*?)<\/div>/.exec(itemsHtml); const item = spendText(firstDiv ? firstDiv[1] : itemsHtml).split('\n')[0].slice(0, 160); const walletChangeText = spendText(cells.wallet_change || ''); const category = spendClassify(typeText, walletChangeText, spendText(itemsHtml)); const dt = /data-datetime="(\d{9,13})"/.exec(openTag); const ts = dt ? (Number(dt[1]) < 1e12 ? Number(dt[1]) * 1000 : Number(dt[1])) : null; const onclick = /onclick="([^"]*)"/.exec(m[0]); const appid = onclick ? (/appid=(\d+)/.exec(onclick[1]) || [])[1] || null : null; // 市场行数量:type 单元格第一个 div 的数字(同分类器 marketCount),items 的「已售出/购买 N 件」兜底 let marketCount = 1; if (category === 'market_buy' || category === 'market_sell') { const md = /]*>([\s\S]*?)<\/div>/.exec(typeHtml); marketCount = parseInt(spendText(md ? md[1] : ''), 10) || 1; if (marketCount === 1) { const im = /已(?:售出|购买)\s*(\d+)/.exec(spendText(itemsHtml)); if (im) marketCount = parseInt(im[1], 10) || 1; } } // 支付方式:wth_payment 块内的子 div 序列(每个 div = 一种支付及其金额,跳过纯金额段) let payment = ''; const pmSegStart = typeHtml.indexOf('wth_payment'); if (pmSegStart >= 0) { const pmSeg = typeHtml.slice(pmSegStart).replace(/<\/td>[\s\S]*$/, ''); const pmDivs = []; const pmRe = /]*>([\s\S]*?)<\/div>/g; let p; while ((p = pmRe.exec(pmSeg)) !== null) { const txt = spendText(p[1]); if (txt && !/^[-+]/.test(txt)) pmDivs.push(txt); } payment = pmDivs.length ? pmDivs.join(' + ') : ''; } const totalText = spendText(cells.total || ''); const currency = spendDetectRowCurrency(cells); const dcCur = SPEND_CURRENCIES.find(c => c.id === currency); let amount = 0; if (category === 'ingame' && cells.wallet_change !== undefined) { amount = spendParseNum(walletChangeText, dcCur && dcCur.dc); } if (!amount) amount = spendParseNum(totalText, dcCur && dcCur.dc); const dateText = spendText(cells.date || '').replace(/\s+/g, ' '); out.push({ key: `${dt ? dt[1] : dateText}|${totalText}|${item.slice(0, 60)}`, ts, date: dateText, item, type: typeText.split('\n')[0].slice(0, 60), category, currency: currency || null, amount, total: totalText, walletChange: walletChangeText, payment, marketCount, appid, }); } return out; } /** 主货币检测(同分类器 detectPrimaryCurrency:跳过 market/refund 行,看第一笔常规交易) */ function spendDetectPrimaryCurrency(rows) { for (const r of rows) { if (r.category === 'market_buy' || r.category === 'market_sell' || r.category === 'refund') continue; const text = [r.total, r.walletChange].find(t => t && t !== '--') || ''; if (!text) continue; if (/¥/.test(text) && !/JP¥|JPY/i.test(text)) return spendResolveYen(text); for (const cur of SPEND_CURRENCIES) { if (cur.match.test(text)) return cur.id; } return null; } return null; } /** 聚合:分类统计 + 净支出 + 月度趋势(口径同分类器 calcTotal:spending 为正、refund 抵扣) */ function spendAggregate(rows, primaryId) { const primary = SPEND_CURRENCIES.find(c => c.id === primaryId); const byCat = {}; // cat → {count, spend} const byMonth = {}; // 'YYYY-MM' → {count, net} let gross = 0, refunded = 0; for (const r of rows) { const isPrimary = !primaryId || r.currency === primaryId; const amt = isPrimary ? (Number(r.amount) || 0) : 0; const cat = r.category; if (!byCat[cat]) byCat[cat] = { count: 0, spend: 0 }; byCat[cat].count += r.marketCount || 1; if (SPEND_CATS_SET.has(cat)) { byCat[cat].spend += amt; gross += amt; } else if (cat === 'refund') { byCat[cat].spend -= amt; refunded += amt; } const month = r.ts ? new Date(r.ts).toISOString().slice(0, 7) : null; if (month) { if (!byMonth[month]) byMonth[month] = { count: 0, net: 0 }; byMonth[month].count += r.marketCount || 1; if (SPEND_CATS_SET.has(cat)) byMonth[month].net += amt; else if (cat === 'refund') byMonth[month].net -= amt; } } const monthly = Object.keys(byMonth).sort().reverse().slice(0, 12) .map(k => ({ month: k, count: byMonth[k].count, net: Math.round(byMonth[k].net * 100) / 100 })); const categories = SPEND_CATS .filter(c => byCat[c.id]) .map(c => ({ id: c.id, label: c.label, count: byCat[c.id].count, spend: Math.round(byCat[c.id].spend * 100) / 100 })); const otherCount = byCat.other ? byCat.other.count : 0; return { currency: primary ? { id: primary.id, label: primary.label, symbol: primary.symbol } : null, netSpend: Math.round((gross - refunded) * 100) / 100, grossSpend: Math.round(gross * 100) / 100, refundTotal: Math.round(refunded * 100) / 100, categories, otherCount, monthly, }; } function spendCacheGet() { try { const raw = GM_getValue(SPEND_CACHE_KEY, null); if (!raw) return null; const obj = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!obj || !Array.isArray(obj.rows) || !obj.rows.length) return null; // 未拉全的缓存只保鲜 30 分钟:避免一次翻页失败把「只有 10 条」锁死 7 天 const ttl = obj.complete === false ? 30 * 60 * 1000 : SPEND_TTL; if (Date.now() - obj.at > ttl) return null; return obj; } catch { return null; } } function spendCacheSave(cache) { try { GM_setValue(SPEND_CACHE_KEY, JSON.stringify(cache)); } catch {} } // ---- 登录态与历史页解析(v1.34.0)------------------------------------------ function spendExtractSteamId(html) { const m = /g_steamID\s*=\s*"(\d{17})"/.exec(String(html || '')); return m ? m[1] : ''; } function spendExtractSessionId(html) { const m = /g_sessionID\s*=\s*"([^"]+)"/.exec(String(html || '')); if (m) return m[1]; // 兜底:store 域的 sessionid 不是 HttpOnly,页面脚本可读 try { const cm = /(?:^|;\s*)sessionid=([^;]+)/.exec(document.cookie || ''); return cm ? decodeURIComponent(cm[1]) : ''; } catch { return ''; } } function spendExtractCursor(html) { const m = /g_historyCursor\s*=\s*(null|"(?:[^"\\]|\\.)*")/.exec(String(html || '')); if (!m || m[1] === 'null') return null; try { return JSON.parse(m[1]); } catch { return null; } } /** 历史页正向标记:g_sessionID / 17 位 g_steamID / 钱包历史表,任一存在即视为已登录 */ function spendPageHasHistory(html) { const h = String(html || ''); return /g_sessionID\s*=\s*"[^"]+"/.test(h) || /g_steamID\s*=\s*"\d{17}"/.test(h) || /wallet_history_table/.test(h); } /** 登录墙标记:被重定向到 /login/ 或页面出现登录表单(不与「页面结构变更」混为一谈) */ function spendPageLooksLoginWall(html, finalUrl) { if (/\/login\/?(\?|$)/.test(String(finalUrl || ''))) return true; return /id="login_form"|class="[^"]*loginbox|newlogindialog/i.test(String(html || '')); } /** 本地(免请求)登录证据:账号页/商店页都有 g_steamID 或 application_config */ function spendLocalLoginEvidence() { const evidence = []; let steamId = ''; try { steamId = String(unsafeWindow.g_steamID || ''); } catch {} if (/^\d{17}$/.test(steamId)) evidence.push('window.g_steamID'); try { if (unsafeWindow.g_sessionID) evidence.push('window.g_sessionID'); } catch {} try { const cfg = document.getElementById('application_config'); const info = cfg ? JSON.parse(cfg.getAttribute('data-userinfo') || '{}') : null; if (info && info.steamid) { steamId = steamId || String(info.steamid); evidence.push('application_config.data-userinfo'); } } catch {} if (!steamId) { const detected = detectSteamId(); if (detected) { steamId = detected; if (!evidence.length) evidence.push('detectSteamId'); } } return { steamId, evidence }; } // 60s 缓存:避免连续对话反复拉历史页 let _spendLoginCache = null; async function spendLoginState(force) { if (!force && _spendLoginCache && Date.now() - _spendLoginCache.at < 60000) return _spendLoginCache.state; const local = spendLocalLoginEvidence(); let state = { loggedIn: local.evidence.length ? true : null, steamId: local.steamId || '', evidence: local.evidence.slice(), source: local.evidence.length ? 'page' : '', checkedAt: Date.now(), }; if (state.loggedIn !== true) { try { const resp = await gmFetch(SPEND_HISTORY_URL, { timeout: 15000, withMeta: true }); const html = String(resp.responseText || ''); const finalUrl = String(resp.finalUrl || SPEND_HISTORY_URL); const has = spendPageHasHistory(html); const wall = spendPageLooksLoginWall(html, finalUrl); state = { loggedIn: has ? true : (wall ? false : null), steamId: state.steamId || spendExtractSteamId(html) || detectSteamId(), evidence: has ? ['history page: 已登录标记'] : (wall ? ['history page: 被重定向到登录页'] : ['history page: 未发现登录标记']), source: 'history-probe', finalUrl, checkedAt: Date.now(), }; } catch (e) { state.probeError = e.message; state.note = '无法访问消费历史页,登录态无法判定(不是「未登录」)'; } } _spendLoginCache = { at: Date.now(), state }; return state; } /** * 拉全量:首屏整页 HTML → AjaxLoadMoreHistory 表单翻页。 * 失败时抛出的 Error 带 loginState,便于上层如实回答「未登录 / 页面异常」。 */ async function spendFetchAll(maxPages) { const rows = []; const seen = new Set(); const pageErrors = []; let pages = 0, complete = false; // ① 首屏(整页 HTML) const first = await gmFetch(SPEND_HISTORY_URL, { timeout: 25000, withMeta: true }); const firstHtml = String(first.responseText || ''); const finalUrl = String(first.finalUrl || SPEND_HISTORY_URL); if (!spendPageHasHistory(firstHtml)) { const isLoginWall = spendPageLooksLoginWall(firstHtml, finalUrl); const err = new Error(isLoginWall ? 'SPEND_NOT_LOGIN' : 'SPEND_PAGE_UNEXPECTED'); err.diagnosis = { finalUrl, htmlLength: firstHtml.length, hasLoginForm: /id="login_form"|class="[^"]*loginbox/i.test(firstHtml), }; err.loginState = { loggedIn: isLoginWall ? false : null, steamId: spendLocalLoginEvidence().steamId, evidence: [isLoginWall ? '请求被重定向到登录页' : '历史页未返回钱包历史表(页面结构可能变更)'], source: 'history-probe', finalUrl, checkedAt: Date.now(), }; throw err; } pages = 1; for (const r of spendParseRows(firstHtml)) { if (seen.has(r.key)) continue; seen.add(r.key); rows.push(r); } // ② 翻页(Steam 页面自身使用的 POST 接口) // 工具执行有 30s 上限:留出余量,超时前主动收尾成「部分结果」,不要被整体掐断后一无所有 const deadline = Date.now() + 22000; const sessionId = spendExtractSessionId(firstHtml); let cursor = spendExtractCursor(firstHtml); if (!cursor) complete = true; while (cursor && pages < maxPages) { if (Date.now() > deadline) { pageErrors.push(`已用满 22s 时间预算,停在 ${pages} 页;可用 maxPages 调小或稍后重试`); break; } const body = 'cursor=' + encodeURIComponent(String(cursor)) + '&sessionid=' + encodeURIComponent(sessionId); let text; try { text = await gmFetch(SPEND_MORE_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', }, data: body, timeout: 25000, }); } catch (e) { pageErrors.push(`第 ${pages + 1} 页请求失败:${e.message}`); break; } let json; try { json = JSON.parse(text); } catch { // 首屏已证明登录态:这里只可能是接口异常,不能反过来判「未登录」 pageErrors.push(`第 ${pages + 1} 页返回非 JSON:${text.slice(0, 120).replace(/\s+/g, ' ')}`); break; } if (!json || json.success === 0 || json.success === false) { pageErrors.push((json && (json.message || json.error)) || '翻页接口 success=0'); break; } const html = String(json.html || ''); const parsed = spendParseRows(html); let added = 0; for (const r of parsed) { if (seen.has(r.key)) continue; seen.add(r.key); rows.push(r); added++; } pages++; const next = json.cursor || null; if (!next) { complete = true; break; } if (!html.trim() || !added) break; // 空页/重复页:保守停止,complete 保持 false cursor = next; await new Promise(r => setTimeout(r, 200)); // 翻页限速 } return { rows, pages, complete, pageErrors, sessionId }; } // ---- 宿主桥镜像(Steam-Spending-History-Classifier 写入 localStorage)--------- // 仅 store 域同源可读:community 域是另一份 localStorage(按源隔离),读不到不是 bug。 function spendBridgeRead() { if (pcCurrentDomain() !== PC_DOMAIN_STORE) return null; let raw = null; try { raw = pcLocalGet(SPEND_BRIDGE_KEY); } catch { return null; } if (!raw || typeof raw !== 'object' || raw.v == null) return null; const rows = Array.isArray(raw.rows) ? raw.rows.filter(r => r && r.category && r.category !== 'other').map(r => ({ key: `${r.ts || r.date}|${r.total || ''}|${String(r.item || '').slice(0, 60)}`, ts: Number(r.ts) || null, date: r.date || '', item: r.item || '', type: r.type || '', category: r.category, currency: r.currency || null, amount: Number(r.amount) || 0, total: r.total || '', walletChange: r.walletChange || '', payment: r.payment || '', appid: r.appid || null, marketCount: Number(r.marketCount) || 1, })) : []; return { at: Number(raw.updatedAt) || 0, rows, complete: raw.complete === true, totalRows: Number(raw.totalRows) || rows.length, rowsSkipped: raw.rowsSkipped || null, truncated: raw.truncated === true, primaryCurrency: raw.primaryCurrency || null, aggregate: raw.aggregate && typeof raw.aggregate === 'object' ? raw.aggregate : null, }; } /** 宿主只镜像了聚合(quota 降级)时的口径适配 */ function spendBridgeAggregate(bridge) { const a = bridge && bridge.aggregate; if (!a) return null; const cur = SPEND_CURRENCIES.find(c => c.id === bridge.primaryCurrency) || null; return { currency: cur ? { id: cur.id, label: cur.label, symbol: cur.symbol } : null, netSpend: Math.round((Number(a.netSpend) || 0) * 100) / 100, grossSpend: Math.round((Number(a.grossSpend) || 0) * 100) / 100, refundTotal: Math.round((Number(a.refundTotal) || 0) * 100) / 100, categories: Array.isArray(a.categories) ? a.categories : [], otherCount: Number(a.otherCount) || 0, monthly: Array.isArray(a.monthly) ? a.monthly : [], }; } ToolRegistry.register({ name: 'getSpendingHistory', description: '获取当前登录 Steam 账号的消费历史(/account/history,走浏览器登录态,无需 Steam Web API Key),按 直购/内购/送礼/退款/充值/市场买入/市场卖出 七类分类统计。' + '内部三级取数:本机 GM 缓存 → 宿主 Spending 分类器桥镜像(毫秒级)→ 消费历史页自抓(首屏整页 + POST /account/AjaxLoadMoreHistory/ 表单翻页,每页 10 条,默认上限 50 页)。' + '返回 data.source(cache/bridge/fetch)与 data.coverage 说明数据来源与覆盖范围,回答时需据此声明。' + '适合回答"我在 Steam 上花了多少钱""最近哪个月买游戏最多""退款了多少""市场买卖流水""钱包充值记录"这类消费分析问题。' + '自抓失败但桥里有数据时会自动降级返回桥数据(degraded/partial 标记),此时不要改口说"拿不到数据"。' + '只有确证浏览器未登录时才返回 needLogin=true,并附带 loginState(含证据);result 为 null 时不要断言未登录。数据仅存本机,不上传。', parameters: { type: 'object', properties: { mode: { type: 'string', enum: ['summary', 'detail'], description: 'summary=聚合摘要(分类总额+月度趋势+最近N笔,默认);detail=逐笔明细(可用 category 过滤)' }, category: { type: 'string', description: 'detail 模式可选过滤:store/ingame/gift/refund/convert/market_buy/market_sell' }, topN: { type: 'number', description: '返回的最近笔数上限,summary 默认 12、detail 默认 40,最大 100' }, months: { type: 'number', description: 'summary 月度趋势的月份数,默认 12,最大 24' }, maxPages: { type: 'number', description: '首次抓取的翻页上限(每页 10 条),默认 50(约 500 条),最大 300' }, force: { type: 'boolean', description: '跳过 7 天缓存强制重新抓取(默认 false)' }, }, required: [], }, category: 'knowledge', async execute(params) { const mode = params.mode === 'detail' ? 'detail' : 'summary'; const topN = Math.max(1, Math.min(Number(params.topN) || (mode === 'detail' ? 40 : 12), 100)); const months = Math.max(1, Math.min(Number(params.months) || 12, 24)); const maxPages = Math.max(1, Math.min(Number(params.maxPages) || 50, 300)); const category = params.category && SPEND_LABEL[params.category] ? params.category : null; // ---- 三级取数:GM 缓存 → 宿主桥镜像 → 历史页自抓(失败再回退桥) ---- const bridge = spendBridgeRead(); let cache = params.force === true ? null : spendCacheGet(); let source = cache ? 'cache' : ''; let aggOverride = null; let bridgeOnly = false; // 桥已加载全量时可直接当缓存(毫秒级、与自抓同口径) if (!cache && params.force !== true && bridge && bridge.rows.length && bridge.complete) { cache = { v: 1, at: bridge.at || Date.now(), rows: bridge.rows, pages: 0, complete: true }; source = 'bridge'; } if (!cache) { let fetched = null; let fetchError = null; try { fetched = await spendFetchAll(maxPages); } catch (e) { fetchError = e; } if (fetched) { if (!fetched.rows.length) { return { success: false, error: '已登录但未解析到任何消费记录(账号可能确实没有消费历史,或 Steam 页面结构变更)', loginState: { loggedIn: true, steamId: spendLocalLoginEvidence().steamId, evidence: ['history page: 已登录标记'], source: 'history-probe', }, pagesFetched: fetched.pages, ...(fetched.pageErrors.length ? { pageErrors: fetched.pageErrors.slice(0, 3) } : {}), }; } cache = { v: 1, at: Date.now(), rows: fetched.rows, pages: fetched.pages, complete: fetched.complete }; spendCacheSave(cache); source = 'fetch'; } else { // 自抓失败:桥里有明细用明细,只有聚合用聚合(不把配额降级的空明细当失败) if (bridge && bridge.rows.length) { cache = { v: 1, at: bridge.at || Date.now(), rows: bridge.rows, pages: 0, complete: bridge.complete === true }; source = 'bridge'; } else if (bridge && bridge.aggregate) { aggOverride = spendBridgeAggregate(bridge); cache = { v: 1, at: bridge.at || Date.now(), rows: [], pages: 0, complete: bridge.complete === true }; bridgeOnly = true; source = 'bridge'; } if (!cache) { if (fetchError && (fetchError.message === 'SPEND_NOT_LOGIN' || fetchError.message === 'SPEND_PAGE_UNEXPECTED')) { const notLogin = fetchError.message === 'SPEND_NOT_LOGIN'; return { success: false, error: notLogin ? '消费历史页被重定向到 Steam 登录页:当前浏览器确实没有登录态。请在同一浏览器登录 store.steampowered.com 后重试。' : '消费历史页返回了无法识别的页面(可能是页面结构变更或访问被拦截),无法确认登录态。', needLogin: notLogin, loginState: fetchError.loginState || await spendLoginState(true), diagnosis: fetchError.diagnosis || null, ...(bridge ? { bridgeState: { rowsPresent: bridge.rows.length, rowsSkipped: bridge.rowsSkipped, complete: bridge.complete } } : { bridgeMissing: true }), }; } return { success: false, error: `抓取失败:${fetchError ? fetchError.message : '未知错误'}` }; } } } const primaryId = bridgeOnly ? (bridge.primaryCurrency || null) : spendDetectPrimaryCurrency(cache.rows); const agg = aggOverride || spendAggregate(cache.rows, primaryId); const ageMin = Math.max(0, Math.round((Date.now() - cache.at) / 60000)); const coverage = bridgeOnly ? `宿主插件只镜像了聚合(明细因 localStorage 配额被跳过);页面${cache.complete ? '已加载全量' : '未加载全量,聚合可能低于真实总额'}` : source === 'bridge' ? `来自宿主插件镜像:消费历史页已解析 ${cache.rows.length} 条${cache.complete ? '(已全量)' : '(「加载更多」未点完,可能不完整)'}` : cache.complete ? '已覆盖全部消费记录' : `仅覆盖最近 ${cache.pages} 页(${cache.rows.length} 条),可用 maxPages 调大或 force 重抓`; if (mode === 'detail') { if (!cache.rows.length) { return { success: false, error: '宿主桥只有聚合、没有逐笔明细(localStorage 配额降级),无法列出逐笔记录;可改用 mode=summary 看总额,或 force=true 走自抓。', source, }; } let rows = cache.rows; if (category) rows = rows.filter(r => r.category === category); const slim = rows.slice(0, topN).map(r => ({ date: r.date, item: r.item, type: r.type, category: r.category, currency: r.currency, amount: r.amount, total: r.total, ...(r.walletChange ? { walletChange: r.walletChange } : {}), ...(r.payment ? { payment: r.payment } : {}), ...(r.appid ? { appid: r.appid } : {}), ...(r.marketCount > 1 ? { marketCount: r.marketCount } : {}), })); return { success: true, data: { fetchedAt: cache.at, cacheAgeMinutes: ageMin, fromCache: source === 'cache', source, totalRows: cache.rows.length, matchedRows: rows.length, filteredBy: category, coverage, records: slim, ...(rows.length > topN ? { note: `仅返回最近 ${topN} 笔(共 ${rows.length} 笔匹配),可用 topN 调大` } : {}), }, summary: `消费明细 ${slim.length} 笔${category ? `(过滤:${SPEND_LABEL[category]})` : ''},共 ${cache.rows.length} 条记录`, }; } const recent = cache.rows.slice(0, topN).map(r => ({ date: r.date, item: r.item, category: r.category, total: r.total, ...(r.currency && r.currency !== primaryId ? { currency: r.currency } : {}), })); return { success: true, data: { fetchedAt: cache.at, cacheAgeMinutes: ageMin, fromCache: source === 'cache', source, totalRows: cache.rows.length, ...(cache.pages ? { pagesFetched: cache.pages } : {}), coverage, currency: agg.currency, netSpend: agg.netSpend, grossSpend: agg.grossSpend, refundTotal: agg.refundTotal, categories: agg.categories, ...(agg.otherCount ? { otherCount: agg.otherCount } : {}), monthlyTrend: agg.monthly.slice(0, months), recent, note: '净支出 = 直购+内购+送礼−退款(仅统计主货币行);市场买卖与充值不计入净支出。跨币种消费未折算。', ...(bridgeOnly ? { degraded: true, degradedReason: 'bridge-aggregate-only' } : {}), ...(source === 'bridge' && !cache.complete ? { partial: true } : {}), }, summary: `共 ${bridgeOnly ? (bridge.totalRows || 0) : cache.rows.length} 条消费记录,净支出 ${agg.currency ? agg.currency.symbol : ''}${agg.netSpend}` + (source === 'cache' ? `(缓存于 ${ageMin} 分钟前)` : source === 'bridge' ? `(宿主桥镜像${cache.complete ? '' : ',未全量'})` : `(刚抓取 ${cache.pages} 页)`), }; }, }); ToolRegistry.register({ name: 'getSteamLoginStatus', description: '检测当前浏览器是否已登录 Steam(只读、不消耗 Steam API 配额、不读取任何凭据)。' + '返回 loggedIn: true=已登录 / false=未登录 / null=无法判定(网络或页面结构问题,此时不得断言「未登录」)、steamId、证据列表与请求最终地址。' + '用于回答「我登录了吗」「为什么消费历史说我没登录」「要不要重新登录」这类问题,或在使用强登录依赖的工具前自检。结果缓存 60 秒。', parameters: { type: 'object', properties: { force: { type: 'boolean', description: '跳过 60 秒缓存强制重新探测(默认 false)' }, }, required: [], }, category: 'knowledge', async execute(params) { const state = await spendLoginState(params.force === true); const verdict = state.loggedIn === true ? '已登录' : state.loggedIn === false ? '未登录' : '无法判定'; return { success: true, data: { loggedIn: state.loggedIn, verdict, steamId: state.steamId || null, evidence: state.evidence, source: state.source || null, ...(state.finalUrl ? { finalUrl: state.finalUrl } : {}), checkedAt: state.checkedAt, cached: Date.now() - state.checkedAt > 500, ...(state.probeError ? { probeError: state.probeError } : {}), hint: state.loggedIn === true ? '浏览器已持有 Steam 登录态;若其他工具仍报未登录,问题在工具取数链路而非账号。' : state.loggedIn === false ? '当前浏览器没有 Steam 登录态(历史页被重定向到登录页)。登录 store.steampowered.com 后即可重试。' : '既没有本地登录标记、历史页也没有明确登录墙 —— 可能是网络/页面结构问题,请如实告知用户「无法判定」,不要断言未登录。', }, summary: `${verdict}${state.steamId ? `(SteamID ${state.steamId})` : ''}`, }; }, }); // ============================================================ // v1.16.0 联网检索 / 抓取(DeepSeek /v1/tools/*) // ============================================================ // 实测结论(2026-10-19,DeepSeek 官方接口): // search:稳定。固定返回 10 条,字段 title/site_name/authority/rerank_score/ // date/url/summary/snippet/content;summary 单条最长约 2.7KB、 // 10 条合计约 19KB(超 12KB 回灌预算,必须裁剪);content 实测恒为空。 // fetch :不稳定。store.steampowered.com 与 steamcommunity.com 实测均返回 // "failed to fetch URL"(被目标站拒绝抓取);部分大站会 gateway timeout; // 少数站点能返回但抽取质量差(只抓到导航栏)。故必须分类报错并引导改用 search。 // 统一错误分类:把网关/抓取失败翻译成模型能据此行动的提示 function classifyWebToolsError(msg, kind) { const m = String(msg || ''); if (/failed to fetch URL/i.test(m)) { // 实测(2026-10-19):该抓取服务成功率偏低,且**不止 Steam**—— // Wikipedia / BBC / HackerNews / Reuters / counter-strike.net / Steam 商店与社区 // 均返回 "failed to fetch URL",仅 GitHub、help 子站等少数站点成功 // (且成功时也可能只抽到导航栏)。故这里不暗示"换个域名就行"。 return kind === 'fetch' ? '该网址无法被抓取:目标站点拒绝或屏蔽了服务端抓取。这是抓取接口的已知限制,并非本机或网络问题——实测 Wikipedia/BBC/HackerNews/Reuters 以及 Steam 商店与社区站点均被拒绝,仅 GitHub 等少数站点可抓。' + '建议:① 直接用 webSearch 获取该页面的摘要(成功率很高);② 若必须读全文,改用 Steam 专用工具获取站内数据。' : '检索服务无法访问该来源,请更换关键词或改用其他工具。'; } if (/timeout|gateway_timeout/i.test(m)) { return '目标站点响应超时(站点过大或网络不通)。建议改用 webSearch,或换一个更轻量的页面。'; } if (/401|unauthorized|invalid.*key|authentication/i.test(m)) { return '联网工具鉴权失败:API Key 无效或未配置。请在设置「接口服务 → 联网检索工具」配置 Key。'; } if (/429|rate.?limit|too many/i.test(m)) { return '请求过于频繁或额度不足,已被服务端限流。请稍后重试。'; } return m; } async function callWebTools(path, payload) { const { base, key } = getWebToolsConfig(); if (!key) { return { ok: false, needKey: true, error: '未配置联网工具 API Key:请在设置「接口服务 → 联网检索工具」填写,或在「AI 设置」中配置 DeepSeek 模型(配置后本工具会自动复用其 Key)' }; } const url = `${base}/${path}`; try { const resp = await gmFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` }, data: JSON.stringify(payload), timeout: CONFIG.DS_TOOLS_TIMEOUT, // 放行 AI 端点主机:联网检索接口与对话接口同主机(默认都是 api.deepseek.com), // 这是本工具存在的目的。v1.11.0 的默认拦截是为了防止工具把 Key 外泄到 AI 主机, // 而这里的目标地址来自用户显式配置的 Base URL,不接受模型传入的任意地址, // 因此只开 allowAiEndpoint,**不放行 allowPrivate**(内网/回环仍一律拦截,SSRF 防护不变)。 allowAiEndpoint: true, }); let json; try { json = JSON.parse(resp); } catch { return { ok: false, error: `接口返回非 JSON:${String(resp).slice(0, 200)}` }; } if (json && json.error) { return { ok: false, error: classifyWebToolsError(json.error.message || json.error.type, path === 'fetch' ? 'fetch' : 'search'), rawError: json.error }; } return { ok: true, data: json }; } catch (e) { return { ok: false, error: classifyWebToolsError(e.message, path === 'fetch' ? 'fetch' : 'search') }; } } // ===== v1.16.0 检索结果缓存(抑制重复检索)===== // 动机:实测模型会为同一个问题连调 5 次 webSearch(自己拼信息), // 既烧 Key 额度又无增益。这里按 query 缓存一段时间,命中即返回并标注 fromCache。 const _searchResultCache = new Map(); const _searchCacheIndex = new Map(); // query → key(LRU 顺序) const SEARCH_CACHE_MAX = 24; function searchCacheGet(query) { const hit = _searchResultCache.get(query); if (!hit) return null; if (Date.now() - hit.at > CONFIG.SEARCH_RESULT_TTL) { _searchResultCache.delete(query); _searchCacheIndex.delete(query); return null; } // 触碰即移到末尾(LRU) _searchCacheIndex.delete(query); _searchCacheIndex.set(query, hit.at); return hit; } function searchCacheSet(query, data) { if (_searchResultCache.size >= SEARCH_CACHE_MAX && !_searchResultCache.has(query)) { const oldest = _searchCacheIndex.keys().next().value; if (oldest !== undefined) { _searchCacheIndex.delete(oldest); _searchResultCache.delete(oldest); } } const at = Date.now(); _searchResultCache.set(query, { at, data }); _searchCacheIndex.delete(query); _searchCacheIndex.set(query, at); } // 归一化 query:小写 + 压缩空白,让"CS2 更新"与"cs2 更新"命中同一条缓存 function normalizeSearchQuery(q) { return String(q || '').toLowerCase().replace(/\s+/g, ' ').trim(); } // 检索结果统一格式化(缓存命中与首次检索共用,保证两条路径输出完全一致) function formatSearchResult(it, rank) { return { rank, title: it.title || null, site: it.site_name || null, url: it.url || null, date: it.date || null, authority: Number(it.authority) || null, rerankScore: typeof it.rerank_score === 'number' ? Math.round(it.rerank_score * 1000) / 1000 : null, summary: String(it.summary || it.snippet || '').slice(0, CONFIG.SEARCH_SUMMARY_MAX_CHARS), }; } ToolRegistry.register({ name: 'webSearch', description: '联网搜索网页,返回结构化检索结果(标题/来源站/日期/URL/摘要),由 DeepSeek 官方检索接口提供。' + '适合回答"最近有什么新闻/更新""XX 现在多少钱/什么时候发售""某个事件最新进展"等需要实时或站外信息的问题。' + '注意:这是通用网页搜索,不含 Steam 站内私有数据——查游戏价格/好友/家庭库请优先用 Steam 专用工具。', parameters: { type: 'object', properties: { query: { type: 'string', description: '搜索关键词或自然语言问题(建议用与站点相关的具体词汇)' }, topN: { type: 'number', description: '返回条数,默认 5,最大 10(接口固定返回 10 条,此处只做裁剪)' }, }, required: ['query'], }, category: 'knowledge', async execute(params) { const query = String(params.query || '').trim(); if (!query) return { success: false, error: '缺少 query 参数' }; const topN = Math.max(1, Math.min(Number(params.topN) || 5, 10)); // v1.16.0:同 query 直接命中缓存,不重复消耗 Key 额度 const ck = normalizeSearchQuery(query); const cached = searchCacheGet(ck); if (cached) { const d = cached.data; const results = d.list.slice(0, topN).map((it, i) => formatSearchResult(it, i + 1)); return { success: true, data: { query, totalAvailable: d.total, returned: results.length, results, fromCache: true, cacheAgeMinutes: Math.max(0, Math.round((Date.now() - cached.at) / 60000)), note: `本次结果来自本地缓存(${Math.max(0, Math.round((Date.now() - cached.at) / 60000))} 分钟前检索的同一 query)。请直接基于它作答,不要用相近关键词重复检索。`, }, summary: `检索「${query}」命中缓存(${d.total} 条,取前 ${results.length} 条)`, }; } const r = await callWebTools('search', { query }); if (!r.ok) { return r.needKey ? { success: false, needsApiKey: true, needsWebToolsKey: true, error: r.error } : { success: false, error: r.error, rawError: r.rawError || null }; } const list = Array.isArray(r.data && r.data.results) ? r.data.results : []; if (!list.length) { return { success: true, data: { query, total: 0, results: [] }, summary: `未检索到「${query}」的相关结果` }; } searchCacheSet(ck, { list, total: list.length, at: Date.now() }); // 裁剪:summary 单条截到 900 字符(实测原生可达 2.7KB),并丢弃恒为空的 content 字段 const results = list.slice(0, topN).map((it, i) => formatSearchResult(it, i + 1)); const clipped = list.some((it, i) => i < topN && String(it.summary || '').length > CONFIG.SEARCH_SUMMARY_MAX_CHARS); return { success: true, data: { query, totalAvailable: list.length, returned: results.length, results, ...(clipped ? { summaryTruncated: true, note: `摘要已截断至 ${CONFIG.SEARCH_SUMMARY_MAX_CHARS} 字符;需要完整内容可对感兴趣的结果 URL 调用 webFetch` } : {}), }, summary: `检索「${query}」得到 ${results.length}/${list.length} 条结果:${results.slice(0, 3).map(r => r.title || r.url).filter(Boolean).join(';')}`, }; }, }); ToolRegistry.register({ name: 'webFetch', description: '抓取单个网页并以 Markdown 文本返回正文内容(由 DeepSeek 官方抓取接口提供)。' + '⚠️ 实测该接口成功率偏低:多数站点(Wikipedia/BBC/HackerNews/Reuters/Steam 商店与社区)会返回"无法抓取",' + '只有 GitHub 等少数站点可抓,且成功时也可能只抽到导航栏。因此**优先用 webSearch**(成功率很高),' + '仅当确实需要某页全文、且该 URL 大概率可抓时再用本工具。' + 'Steam 站内的私有数据(好友/家庭库/个人游戏库/许可来源)不要用本工具,必须走 Steam 专用工具或 readPluginCache。', parameters: { type: 'object', properties: { url: { type: 'string', description: '要抓取的网页 URL(http/https)' }, maxChars: { type: 'number', description: '正文返回上限,默认 8000 字符,最大 20000' }, }, required: ['url'], }, category: 'knowledge', async execute(params) { const url = String(params.url || '').trim(); if (!url) return { success: false, error: '缺少 url 参数' }; let parsed = null; try { parsed = new URL(url); } catch { return { success: false, error: `URL 格式无效:${url}` }; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return { success: false, error: `仅支持 http/https 协议,收到 ${parsed.protocol}` }; } const maxChars = Math.max(500, Math.min(Number(params.maxChars) || CONFIG.FETCH_CONTENT_MAX_CHARS, 20000)); const r = await callWebTools('fetch', { url }); if (!r.ok) { return r.needKey ? { success: false, needsApiKey: true, needsWebToolsKey: true, error: r.error } : { success: false, error: r.error, url, rawError: r.rawError || null }; } const content = String((r.data && r.data.content) || ''); if (!content) { return { success: false, error: `抓取成功但未解析出正文内容(可能是纯前端渲染页或导航栏结构):${url}`, url }; } const clipped = content.length > maxChars; return { success: true, data: { url, fetchedAt: Date.now(), contentChars: content.length, content: clipped ? content.slice(0, maxChars) : content, ...(clipped ? { truncated: true, note: `正文被截断至 ${maxChars} 字符(原始 ${content.length} 字符)` } : {}), }, summary: `已抓取 ${url},正文 ${content.length} 字符${clipped ? `(返回前 ${maxChars} 字符)` : ''}`, }; }, }); // ============================================================ // 游戏热点模块(薄接入层) // ============================================================ // 全部实现都在独立库 libs/saa-game-news.lib.js(window.SAAGameNews): // 源注册表 / RSS+HTML 解析 / 中文优先排序 / 聚合缓存 / 浮窗 UI+CSS 都在库里。 // 主脚本只做三件事:解析库 → 注册一个薄工具 → 挂载入口按钮。 // 这样新增/调整资讯源不需要动 AI Agent 主逻辑,改库即可。 // // 库通过 @require 加载(发布到仓库后由 CDN 提供)。**库缺失时优雅降级**: // 不注册工具、不挂按钮、其余功能完全不受影响。 const GameNews = (typeof window !== 'undefined' && window.SAAGameNews) || null; if (GameNews) { GameNews.loadCustom((k, d) => GM_getValue(k, d)); // 注入模型侧抓取能力:库里 type='dsfetch' 的源(如小黑盒,站点是 SPA、 // 直连只能拿到 3.4KB 空壳)走 webFetch —— 它会执行 JS,能拿到渲染后的正文。 // 该源在库里单独走 24 小时缓存,实现「每天抓几条、不定期 fetch」。 GameNews.setFetcher(async (url, timeout) => { const r = await callWebTools('fetch', { url }); if (!r.ok) return { ok: false, error: r.error }; return { ok: true, text: String((r.data && r.data.content) || '') }; }); // v1.35.0 注入检索能力(浮窗顶部标题栏右侧的增强搜索框用): // webSearch → POST /v1/tools/search(联网检索:实时新闻 / 目标页面) // chat → callAiChat(模型 API)。**由库负责拼提示词**,并把 webSearch 的 // 检索结果当上下文喂进去 —— 这样模型是在"读"检索结果做总结, // 而不是凭记忆瞎编链接(库里还会再做一次 http(s) 白名单校验兜底)。 // // ⚠️ 按能力降级:宿主可能仍在跑旧版热点库(没有 setSearchProviders)—— // 先判能力再注入,别让「库版本不一致」把整个热点模块搞没。 if (typeof GameNews.setSearchProviders === 'function') { GameNews.setSearchProviders({ webSearch: async (query) => { const r = await callWebTools('search', { query }); if (!r.ok) return { ok: false, error: r.error }; const list = Array.isArray(r.data && r.data.results) ? r.data.results : []; return { ok: true, items: list.map((it) => ({ title: it.title || '', url: it.url || '', site: it.site_name || '', date: it.date || '', summary: String(it.summary || it.snippet || ''), })), }; }, chat: async (prompt) => { const r = await callAiChat(prompt, { systemInstruction: '你是游戏资讯检索助手。只输出一个 JSON 对象,不要解释文字,不要用代码围栏。', isolated: true, // 独立请求:不占用聊天通道,也不受「停止生成」影响 temperature: 0.3, // 检索总结要稳,别发散 maxTokens: 2000, // JSON + 摘要,防止被截断成半截 JSON timeout: 45000, }); return { ok: true, text: r.content || '' }; }, }); } else { console.warn('[SA-Agent] 游戏热点库为旧版(无 setSearchProviders),标题栏搜索框退化为「仅本地匹配」'); } ToolRegistry.register({ name: 'getGameNewsDigest', description: '聚合专业游戏资讯站的最新头条(机核 / Steam 官方 / 3DM / 游民星空),一次调用拿到多来源热点汇总,30 分钟内走本地缓存。' + '适合回答"最近有什么游戏新闻""业界有什么新动态""现在大家都在关注什么游戏"这类**跨来源热点汇总**问题——' + '比自己用 webSearch 反复检索更省额度、覆盖面更广。条目按中文优先排序,Steam 官方源为中英混合。' + '注意:这里只给标题与链接(部分源含摘要),要读某篇全文需再对具体 URL 调用 webFetch(多数站点抓取会失败)。', parameters: { type: 'object', properties: { sources: { type: 'string', description: '可选,逗号分隔的来源过滤:gcores(机核) / steam(Steam官方) / 3dm / gamersky(游民星空)。不填=全部' }, perSource: { type: 'number', description: '每个来源取几条,默认 8,最大 15' }, force: { type: 'boolean', description: '可选,跳过 30 分钟缓存强制重新抓取(默认 false)' }, chineseOnly: { type: 'boolean', description: '可选,只保留中文标题的条目(默认 false)。实测 Steam 官方源以英文为主、机核/3DM/游民星空 全中文,若用户只要中文可开启此参数' }, }, required: [], }, category: 'knowledge', async execute(params) { const sources = GameNews.sources; const perSource = Math.max(1, Math.min(Number(params.perSource) || 8, 15)); const chineseOnly = params.chineseOnly === true; const want = String(params.sources || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean); if (want.length) { const unknown = want.filter(w => !sources.some(s => s.id === w)); if (unknown.length) { return { success: false, error: `未知来源:${unknown.join(',')};可选:${sources.map(s => s.id).join(' / ')}` }; } } const bundle = await GameNews.getDigest({ force: !!params.force, perSource }); const results = []; const failed = []; for (const got of bundle.sources) { if (want.length && want.indexOf(got.id) < 0) continue; if (!got.items || !got.items.length) { failed.push(`${got.label}(${got.error || '无数据'})`); continue; } if (got.error) failed.push(`${got.label}(部分失败:${got.error})`); let items = GameNews.sortChineseFirst(got.items).slice(0, perSource); if (chineseOnly) items = items.filter(it => GameNews.isChinese(it.title)); // chineseOnly 过滤后可能为空,此时不静默丢弃——记为该源无中文条目 if (!items.length) continue; results.push({ source: got.id, label: got.label, items: items.map(it => ({ ...it, lang: GameNews.isChinese(it.title) ? 'zh' : 'other' })), chineseCount: items.filter(it => GameNews.isChinese(it.title)).length, }); } const total = results.reduce((n, r) => n + r.items.length, 0); if (!total) { return { success: false, error: `全部来源都没取到内容(${failed.join('、') || '未知原因'})。可能是网络受限或站点结构变更;可稍后重试或改用 webSearch。`, sources: failed, }; } const ageMin = Math.max(0, Math.round((Date.now() - bundle.at) / 60000)); return { success: true, data: { fetchedAt: bundle.at, cacheAgeMinutes: ageMin, fromCache: bundle.cached === true, total, chineseTotal: results.reduce((n, r) => n + r.chineseCount, 0), sources: results, ...(failed.length ? { partialFailures: failed } : {}), ...(chineseOnly ? { chineseOnly: true } : {}), note: chineseOnly ? '已过滤为仅中文条目;各站独立抓取,单站无中文不影响其他来源。' : '条目已按中文优先排序(lang 字段标注 zh/other);各站独立抓取,单站失败不影响其他来源。', }, summary: `聚合 ${results.length} 个来源共 ${total} 条游戏资讯头条` + (bundle.cached ? `(缓存于 ${ageMin} 分钟前)` : '') + (failed.length ? `;${failed.length} 个源部分失败` : ''), }; }, }); } else { console.warn('[SA-Agent] 未加载游戏热点库(libs/saa-game-news.lib.js),getGameNewsDigest 与热点入口已跳过'); } // ============================================================ // AI 搜索浮窗(薄接入层) // ============================================================ // 全部实现都在独立库 libs/saa-search.lib.js(window.SAASearch): // 关键字匹配 / 分组结果 / 浮窗 UI + CSS 都在库里。 // 主脚本只做三件事:解析库 → 注入数据源 provider → 挂载入口按钮。 // // Phase 1 试点两个源(不依赖 data-hub,库没装也能用): // ① 我的游戏库 → SGLV 的 IDB 缓存(sglv_cached_games),顺带 join HLTB 发行年/主线时长 // ② 新闻热点 → SAAGameNews.getDigest()(走热点库 30 分钟聚合缓存,不重复抓) const Search = (typeof window !== 'undefined' && window.SAASearch) || null; if (Search) { Search.setProviders({ getLibrary: async () => { if (!pcDataSourceAllowed('sglv')) { throw new Error('未授权读取「SGLV 游戏库展示」的数据(设置 → 数据源)'); } // v1.36.0:不再硬拦域 —— pcReadSglvLibrary 本域读不到时自动回退跨域 GM 镜像, // 社区域照样能搜游戏库(数据来自商店域快照,会带 note 声明新鲜度)。 const lib = await pcReadSglvLibrary(); if (!lib.ok) throw new Error(lib.error); // 顺带带上 HLTB 的发行年 / 主线时长(索引加载失败时静默留空,不阻断搜索) const hltbReady = await pcHltbLoad(); const games = lib.games.map((g) => { const appid = Number(g && g.appid); const h = hltbReady ? pcHltbGet(appid) : null; return { appid, name: (g && g.name) || `App ${appid}`, playtime_forever: (g && g.playtime_forever) || 0, year: h ? h.y : null, storyHours: h ? h.m : null, }; }); if (!lib.viaMirror) return games; // 镜像来源:交给库渲染成分组级提示(一条说明即可,不必每行都标) return { games, note: `跨域镜像 · 快照 ${pcHumanAge(lib.ageMs)}${lib.stale ? '(可能已过期)' : ''}` + ' —— 商店域缓存同步而来,社区页可直接搜索', }; }, getNews: async () => { if (!GameNews) throw new Error('未加载游戏热点库'); const bundle = await GameNews.getDigest({ perSource: 8 }); const out = []; for (const got of (bundle.sources || [])) { for (const it of (got.items || [])) { out.push({ title: it.title, url: it.url || it.link || '', source: got.label, date: it.pubDate || it.date || '', summary: it.summary || it.description || '', }); } } return out; }, }); // 「用 AI 深入分析」→ 把选中条目带进对话,模型一次成稿。 // ⚠️ 回调注册在模块级(而非 createSidebar 内):面板创建失败时搜索入口仍可闭环。 Search.setHandlers({ onAnalyze: (payload) => { const title = (payload && payload.title) || ''; const text = payload && payload.kind === 'library' ? `请分析我游戏库里的《${title}》(appid ${payload.appid}):主线剧情要多久、我玩了多少(剧情完成度)、还剩多少没玩、值不值得继续玩。` : `请深入分析这条资讯:《${title}》,说清讲了什么、对玩家意味着什么。`; if (Search.closeWindow) Search.closeWindow(); openSidebar({ message: text }); // 按钮语义就是「用 AI 深入分析」→ 直接发出去,不让用户再按一次回车 setTimeout(() => { try { handleSend(); } catch (e) { /* ignore */ } }, 60); }, }); } else { console.warn('[SA-Agent] 未加载搜索浮窗库(libs/saa-search.lib.js),搜索入口已跳过'); } 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) { calls.push(...parseToolPayload(match[1])); } // 兜底:整段响应就是一个裸 JSON(模型直接输出函数调用对象或数组) if (!calls.length) { const stripped = text.replace(/```(?:json)?/gi, '').trim(); if (/^[\[{][\s\S]*[\]}]$/.test(stripped)) { calls.push(...parseToolPayload(stripped)); } } 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 coerceToolCallObject(parsed) { 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 ?? parsed.function?.arguments ?? {}; if (typeof args === 'string') { const argsRepaired = repairToolCallJson(args); args = tryJson(argsRepaired) || tryJson(salvageTruncatedJson(argsRepaired)) || {}; } if (!args || typeof args !== 'object' || Array.isArray(args)) args = {}; return { id: parsed.id || '', name: name.trim(), arguments: args }; } function parseToolPayload(raw) { const repaired = repairToolCallJson(raw); const parsed = tryJson(repaired) || tryJson(salvageTruncatedJson(repaired)); if (!parsed || typeof parsed !== 'object') return []; const items = Array.isArray(parsed) ? parsed : [parsed]; const calls = []; for (const item of items) { const call = coerceToolCallObject(item); if (call) calls.push(call); } return calls; } function parseToolCallJson(raw) { return parseToolPayload(raw)[0] || null; } // 原生 tool_calls 优先;没有时再从正文里的文本协议解析。同一轮只走一条路径,避免执行两遍。 function mergeToolCalls(nativeCalls, text) { const native = normalizeNativeToolCalls(nativeCalls); if (native.length) return native; return parseToolCalls(text); } function normalizeNativeToolCalls(list) { if (!Array.isArray(list)) return []; const out = []; for (const tc of list) { const call = coerceToolCallObject(tc && tc.function ? { id: tc.id, ...tc, name: tc.function.name, arguments: tc.function.arguments } : tc); if (call) out.push(call); } return out; } function isNativeToolsRejected(err) { const msg = String(err?.message || ''); if (!/HTTP 4\d\d/.test(msg)) return false; return /tool_choice|\btools\b|function[_\s-]?call/i.test(msg); } // 流式正文还像工具调用时先不画到气泡里,避免把 JSON 闪给用户。 function shouldHoldStream(text) { const s = String(text || '').trim(); if (!s) return false; if (/[\s\S]*?<\/think>/gi, '').trim(); const head = noThink.slice(0, 80); if (/^ setTimeout(r, 12)); } return clean; } 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' }, getSpendingHistory: { label: '消费历史', icon: 'stats' }, getSteamLoginStatus: { label: '登录状态', icon: 'user' }, readPluginCache: { label: '插件缓存', icon: 'cpu' }, getInstalledPlugins: { label: '插件清单', icon: 'gear' }, getStoryData: { label: '剧情时长', icon: 'gamepad' }, searchLocalDb: { label: '本地数据库', icon: 'search' }, readSkill: { label: '操作技能', icon: 'book' }, localDbProfile: { label: '库画像', icon: 'stats' }, pushInsight: { label: '结论回灌', icon: 'zap' }, pushObservation: { label: '时长上报', icon: 'zap' }, webSearch: { label: '网页检索', icon: 'search' }, webFetch: { label: '网页抓取', icon: 'globe' }, getGameNewsDigest: { label: '游戏热点', icon: 'news' }, }; 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 formatToolDuration(ms) { const n = Number(ms); if (!Number.isFinite(n) || n < 0) return ''; return n >= 1000 ? (n / 1000).toFixed(1) + 's' : Math.round(n) + 'ms'; } // 展开区内容:完整参数 + 结果(超长截断到 4000 字符,仅展示层,不影响回灌模型的数据) function formatToolChipDetail(args, result) { const lines = []; try { lines.push('参数: ' + JSON.stringify(args ?? {}, null, 2)); } catch { lines.push('参数: (无法序列化)'); } if (result) { let payload; if (result.error && result.success === false) payload = { success: false, error: result.error }; else payload = result.data !== undefined ? result.data : result; let text; try { text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2); } catch { text = '(无法序列化)'; } if (text.length > 4000) text = text.slice(0, 4000) + `\n…(展示已截断,原始长度 ${text.length} 字符)`; lines.push('结果: ' + text); } return lines.join('\n'); } function renderToolChip(name, args, running, failed, meta = {}) { const d = getToolDisplay(name); const argText = formatToolArgs(args); const state = running ? '' : failed ? getIcon('close', 11) : getIcon('check', 11); const head = `${getIcon(d.icon, 12)}` + `${escapeHtml(d.label)}` + (argText ? `${escapeHtml(argText)}` : '') + (!running && meta.durationMs != null ? `${formatToolDuration(meta.durationMs)}` : '') + `${state}` + (running ? '' : '▸'); // 运行中用普通 div(避免 details 折叠动画干扰 spinner);完成后升级为可折叠 details 卡片 if (running) { return `${head}`; } return `` + `${head}` + `${escapeHtml(formatToolChipDetail(args, meta.result))}` + ``; } function buildToolCallsHtml(toolCalls, runningIndex = -1) { if (!toolCalls || !toolCalls.length) return ''; const needsKey = toolCalls.some(tc => tc && tc.result && tc.result.needsApiKey); const keyAction = needsKey ? `${getIcon('gear', 13)} 配置 Steam API Keysteamcommunity.com/dev 免费申请 · 配置后回复「已配置」继续` : ''; const needsItad = toolCalls.some(tc => tc && tc.result && tc.result.needsItadKey); const itadAction = needsItad ? `${getIcon('globe', 13)} 配置 ITAD API Keyisthereanydeal.com/apps 免费申请 · 配置后跨区比价升级为增强模式` : ''; return `${toolCalls.map((tc, i) => renderToolChip(tc.name, tc.arguments, tc.running === true || i === runningIndex, !!(tc.result && tc.result.success === false), { durationMs: tc.durationMs, result: tc.result, }) ).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 raw = String(m.content || ''); const toolCount = (raw.match(//g) || []).length; const isToolResult = m.role === 'system' && toolCount > 0; // 并行工具合成一条消息,按条数放宽上限,避免中间结果被腰斩 const limit = !isToolResult ? CONTEXT_MSG_MAX_CHARS : Math.min(CONTEXT_TOOL_RESULT_MAX_CHARS * Math.max(toolCount, 1), 8000); return { role: m.role, content: truncateMiddle(raw, 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 pageText(doc, selector) { try { const el = doc && doc.querySelector ? doc.querySelector(selector) : null; return el ? String(el.textContent || '').trim() : ''; } catch { return ''; } } function pageMeta(doc, selector) { try { const el = doc && doc.querySelector ? doc.querySelector(selector) : null; if (!el) return ''; return String((el.getAttribute && el.getAttribute('content')) || el.content || '').trim(); } catch { return ''; } } // 从当前 URL / DOM 读取用户正在看的游戏或个人资料。loc、doc 可注入,便于测试。 function readPageContext(loc, doc) { const locationRef = loc || (typeof location !== 'undefined' ? location : { href: '', pathname: '' }); const documentRef = doc || (typeof document !== 'undefined' ? document : null); const href = String(locationRef.href || ''); const path = String(locationRef.pathname || ''); const ctx = { kind: 'unknown', url: href.split('?')[0], title: '' }; try { ctx.title = String((documentRef && documentRef.title) || '').trim(); } catch { ctx.title = ''; } const appMatch = href.match(/(?:store\.steampowered\.com|steamcommunity\.com)\/app\/(\d+)/i); const profileIdMatch = href.match(/steamcommunity\.com\/profiles\/(\d{17})/i); const vanityMatch = href.match(/steamcommunity\.com\/id\/([^/?#]+)/i); const onFriends = /\/friends(?:\/|$)/i.test(path); if (appMatch) { ctx.kind = 'store_app'; ctx.appid = appMatch[1]; const domName = pageText(documentRef, '.apphub_AppName') || pageText(documentRef, '#appHubAppName'); const og = pageMeta(documentRef, 'meta[property="og:title"]').replace(/\s+on Steam$/i, ''); const slug = (href.match(/\/app\/\d+\/([^/?#]+)/i) || [])[1]; ctx.name = domName || og || (slug ? decodeURIComponent(slug).replace(/_/g, ' ') : '') || ctx.title.replace(/\s+on Steam$/i, ''); } else if (profileIdMatch || vanityMatch) { ctx.kind = onFriends ? 'friends' : 'profile'; try { ctx.steamId = profileIdMatch ? profileIdMatch[1] : decodeURIComponent(vanityMatch[1]); } catch { ctx.steamId = profileIdMatch ? profileIdMatch[1] : vanityMatch[1]; } ctx.vanity = !profileIdMatch; ctx.name = pageText(documentRef, '.actual_persona_name') || pageText(documentRef, '.persona_name') || ctx.title; } else if (onFriends) { ctx.kind = 'friends'; } if (ctx.kind === 'friends' || onFriends) { ctx.onFriends = true; const viewer = resolveSteamId(''); if (viewer.steamId) { ctx.viewerSteamId = viewer.steamId; ctx.viewerSource = viewer.source; } } return ctx; } function formatPageContext(ctx) { if (!ctx || ctx.kind === 'unknown') return ''; const lines = ['## 当前页面', '用户正在浏览 Steam 页面。指代「这个游戏」「这款」「当前页」「这个人」「我的好友」时,直接使用下面的标识,不要再索要 AppID 或 SteamID。']; if (ctx.kind === 'store_app') lines.push(ctx.url.includes('steamcommunity.com') ? '- 类型:社区游戏页' : '- 类型:商店游戏页'); else if (ctx.kind === 'friends') lines.push('- 类型:好友列表页'); else if (ctx.kind === 'profile') lines.push('- 类型:个人资料页'); if (ctx.appid) lines.push(`- AppID:${ctx.appid}`); if (ctx.name) lines.push(`- 名称:${ctx.name}`); if (ctx.steamId) lines.push(`- 页面用户:${ctx.steamId}${ctx.vanity ? '(自定义 ID)' : ''}`); if (ctx.viewerSteamId) lines.push(`- 当前登录用户 SteamID64:${ctx.viewerSteamId}(来源:${ctx.viewerSource || '自动检测'})`); if (ctx.url) lines.push(`- URL:${ctx.url.slice(0, 180)}`); return lines.join('\n'); } 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 ? '' : '(完整历史,无裁剪)'}`); const pageBlock = formatPageContext(readPageContext()); let prompt = `(提示:你的角色设定已在系统指令中给出,请严格遵循。) ## 可用工具 ${toolsDesc || '(无可用工具)'} ## 对话历史 ${historyText || '(无历史记录)'} ${toolHistory ? '## 工具调用结果\n' + toolHistory + '\n' : ''}${contextNotes.length ? `## 上下文说明\n${contextNotes.map(t => '- ' + t).join('\n')}\n\n` : ''}${pageBlock ? pageBlock + '\n\n' : ''}## 当前任务 用户消息: ${userMessage} ## 执行指令 你正在执行第 ${currentIter}/${maxIter} 次迭代。${currentIter >= maxIter ? '\n\n**注意:这是最后一轮迭代,已无剩余工具调用机会。禁止再调用任何工具,必须基于上方已获取的工具结果直接输出最终中文回答;数据不足的部分如实说明即可。**' : ''} 在回答之前,请先思考是否需要使用工具: - 如果需要调用工具,必须严格使用以下格式(一段完整合法的 JSON,必须以 闭合): {"name":"工具名称","arguments":{"字符串参数":"值","数值参数":123}} - 如果不需要工具,直接回答用户问题 工具调用格式要求: 1. JSON 必须合法:键与字符串值一律用双引号,无注释、无尾逗号,不要用 markdown 代码围栏(\`\`\`)包裹 2. 参数对象的键名固定为 "arguments",不要写成 parameters/args/input 3. 一次可调用多个互不依赖的只读工具,单轮最多 ${CONFIG.MAX_PARALLEL_TOOLS} 个。优先使用接口提供的原生工具调用;若只能输出文本,可连续输出多段 {"name":"工具名称","arguments":{"参数名":"值"}},或在一个标签里输出 JSON 数组。决定调用工具时不要同时写最终回答 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 格式 15. 「当前页面」里已有 AppID 或 SteamID 时,用户用「这个游戏 / 这款 / 当前页 / 这个人 / 我的好友」指代,必须直接使用这些标识调用工具,不要再向用户索要 16. 涉及"我的"本地数据(我的游戏库/我的游玩时长/我的好友在玩什么/家庭共享库有哪些游戏/家庭愿望单/某游戏我是怎么获得的/市场价格快照)时,优先调用 readPluginCache 读本机插件缓存(毫秒级、不消耗 Steam API 配额),缓存未命中再用网络工具。该工具返回 cacheAgeMinutes,超过 24 小时(stale=true)时必须在回答中声明"数据来自本地缓存,可能已过期"。若返回 needDomain,提示用户切换到对应页面(商店页 store / 社区页 community)后重试 17. 需要**站外或实时信息**(最新新闻/版本动态/当前售价/发售时间/某事件进展)时,用 webSearch 检索。注意三点:①**优先用 webSearch,不要先试 webFetch**——实测抓取接口成功率很低(Wikipedia/BBC/HackerNews/Reuters/Steam 商店与社区均被拒绝),webFetch 只在确实需要某页全文且该站大概率可抓时使用,且失败一次就不要再重试同一 URL;②Steam 站内的私有数据(好友/家庭库/个人游戏库/许可来源)不要用这两个工具,必须走 Steam 专用工具或 readPluginCache;③引用检索结果时保留来源站点与日期 18. webSearch/webFetch 返回 needsWebToolsKey 或鉴权失败时,引导用户在设置「接口服务 → 联网检索工具」配置 Key(或说明「AI 设置」里配 DeepSeek 模型可自动复用),不要重复调用同一工具 19. 问「最近有什么游戏新闻/业界热点/大家在玩什么新游戏」这类**跨来源游戏热点汇总**时,优先调用 getGameNewsDigest(一次拿机核/Steam/3DM/游民星空四站头条,30 分钟缓存),比反复 webSearch 更省额度、覆盖更全;若结果带 fromCache=true,直接基于它作答,不要用相近措辞重复检索 20. 涉及「消费/花了多少钱/充值/退款/市场买卖流水/月度消费趋势」类问题时,**直接调用 getSpendingHistory**(v1.34.0 起它内部已按「本机 GM 缓存 → 宿主消费桥镜像 → 消费历史页自抓」三级取数,桥的聚合/明细都会被采纳并标注 source;不要再先单独调 readPluginCache 判空——桥的配额降级空明细会把你误导成「拿不到数据」)。回答时读 data.source 与 data.coverage:source=bridge/cache 要声明数据来自本地缓存/插件镜像;partial 或 degraded=true 要说明只覆盖页面已加载部分、可能低于真实总额;金额必须带币种并说明「净支出=直购+内购+送礼−退款(仅主货币行)」。仅当返回 needLogin=true 且 loginState.loggedIn===false 时才提示用户登录;loginState.loggedIn 为 null 时如实说「无法判定」并建议稍后重试,绝不能让用户白白重新登录一次 21. 问「我的 steam 油猴插件装齐了吗」「steamkit 系列装了哪些」「XX 插件版本过旧吗」「装了哪些游戏库增强」类问题时,调用 getInstalledPlugins(本地只读,不联网查商店)。它合并三个来源:①版本契约 data/plugin-contract.json(期望清单 + 最低版本)②各插件经共享宿主 registerPlugin 注册的真实版本号 ③本地存储探测(识别尚未接入注册的老插件)。回答要点:装了几个/缺几个/哪些版本过旧,并说明「版本号」来自插件自身注册(精确)还是仅探测到存在(版本未知)。若工具返回「版本契约不可用」,提示用户检查网络后重试 22. 问「XX 要玩多久才通关」「我到底通没通关 XX」「主线剧情多长」「我买的这些游戏是独立小作还是大作」「我的库里哪些游戏买了没玩」类问题时,调用 getStoryData。它从 HLTB 索引取主线时长与发行年份,并结合我的游玩时长算剧情完成度 SCI(分档:序章搁浅<0.35 / 剧情未竟0.35-0.8 / 剧情通关0.8-1.3 / 支线探索家1.3-2.5 / 自由漫游>2.5)。不传 appids 时走「我的游戏库」模式(需在 Steam 商店页打开过游戏库展示插件)。⚠️ HLTB 只能覆盖能对上的部分(实测 54.6%),「查不到」= 无数据,不是 0 小时,回答时必须说明缺失比例,不要把没命中的说成时长为 0 23. 需要**跨维度筛选**(如「剧情超 20 小时」「2015 年后」「价格低于 XX」「买了没玩过」)时优先调用 searchLocalDb(远程 data-hub 个人数据资产库,SQLite + FTS5)。readPluginCache 只能整读一个缓存键、做不了筛选,所以这类问题别用 readPluginCache 硬凑。若工具返回「未配置地址」或「无法连接」,说明远程数据中枢没配地址/服务不可达(或还没绑定 Steam 账号),改用 readPluginCache + getOwnedGames,并把筛选条件说清楚 24. 涉及「我的库里有哪些 X」这类**关键词检索**时,可以引导用户用面板头部的「搜索」浮窗(同时搜我的游戏库 + 新闻热点),点「用 AI 深入分析」即可把选中条目带进对话 25. 处理**复杂多步任务**(需要跨表筛选 + 交叉验证 + 出报告/图表)时,**先调 readSkill** 读操作技能库,拿到意图路由与口径纪律后再按路由调工具。技能给的是「遇到这类问题该怎么用工具」的**操作指引与口径**,不给事实——事实仍要靠工具查。若 readSkill 返回「正文暂时取不到」,就按常规方式用 searchLocalDb 查即可 26. 问「我的游戏库是什么画像」「我是不是囤货党」「我买的东西值不值」「给我一份库存盘点报告」这类**画像类**问题时,优先调 localDbProfile(走 data-hub 的 /profile 与 /report),不要自己用 searchLocalDb 拼凑画像:它已按统一口径算好未开封率、时长阶梯、清灰倒计时、被冷落的神作、最亏/最值、每小时价值、人格称号+稀有度。⚠️ 必看 data.library.durationEvidence:为 "missing" 说明库里没有游玩时长数据,正确答案是「算不了」而不是「你 100% 没玩过」;价格类指标是**当前折后价**不是实付价;好评率只覆盖部分游戏,榜单要带覆盖率。format=html 可直接拿到带内联 SVG 图表的报告正文 27. 分析出**有依据、可复用**的结论后(如「某厂商集中度偏高」「某系列还差 3 款」「这款每小时成本极低」),用 pushInsight 把结论回灌到数据中枢,落成可追溯的库资产。只报结论、不报过程性废话;同一份内容重复上报是幂等的,但换措辞会被当成新结论,所以同一结论的措辞尽量保持稳定。⚠️ **测试性/一次性的快问快答不上报**(如用户说「测试一下」「帮我随便查个数据」)——测试流量不写库;纯查询汇总(消费记录/好友名单/时长榜单)本身也不是「可复用结论」,不要为报而报 28. 用户说「把时长/指标也存下来」「之后按周回看我的游玩变化」「导一份我的时长明细进库」这类**要沉淀数据本身**(而不只是结论)时,用 pushObservation —— 它从 Steam 官方 API 取当前最权威的游玩时长,连库里缺的近两周时长也能补上。默认 scope=partial(只落分析产物区,安全);只有用户明确说「全量/整份覆盖」时才用 scope=full,而且服务端会校验「条目数不得少于库内已有快照」,数据不全时会自动降级并说明原因 —— 这时要把降级原因如实告诉用户,别谎称已更新 29. **身份与降级必须说实话**(v1.29.0):localDbProfile / pushInsight / pushObservation 会自动解析「当前是谁」,优先级是「用户显式给的 steamid > 设置里配置的 SteamID > 浏览器登录态检测 > 库里的主账号回退」。前三种是确知身份;最后一种是**系统兜底猜的**,工具返回里会带 identitySource 与 identityNote —— identitySource=library-primary(或 identity=unidentified)时,**必须把「未能确认当前登录用户,数据按库主账号 X 统计」如实告诉用户**,绝不能默认这就是用户本人。库里有多个 Steam 账号时,searchLocalDb 返回的 library.accountCount > 1,说明 scope=all / scope=mine 都是**全库口径**(mine=库内任何人拥有),不是某个人的;用户问「我的」而库里有多账号时,可提示确认是哪个账号,或用 localDbProfile 按账号查 30. 用户问「我登录 Steam 了吗 / 为什么说我没登录 / 要不要重新登录」时,先调 getSteamLoginStatus(只读、60 秒缓存)。loggedIn=true → 明确告知已登录,问题在取数链路,不要让用户重新登录;false → 说明依据是「消费历史页被重定向到登录页」,再引导登录;null → 只能答「无法判定」,给出下一步(稍后重试 / 手动打开消费历史页)。⚠️ 不要用「读不到 steamLoginSecure Cookie / 拿不到 SteamID」推断未登录——该 Cookie 是 HttpOnly,JS 读不到属正常`; const issues = verifyContextIntegrity(prompt, userMessage, ctx); if (issues.length) { console.warn('[SA-Agent] 上下文完整性验证未通过,降级为仅当前消息:', issues); prompt = prompt.replace(/## 对话历史[\s\S]*?(?=## 工具调用结果|## 上下文说明|## 当前任务)/, `## 对话历史 (上下文异常,已重置,请基于当前消息回答)`); const finalCheck = prompt.includes(userMessage); if (!finalCheck) { prompt = `${prompt}\n(系统注:用户消息即为本轮唯一上下文)`; } } return prompt; } function setToolStatus(text, active) { const toolStatusEl = document.querySelector('.sa-tool-status'); if (!toolStatusEl) return; const label = toolStatusEl.querySelector('.sa-tool-text'); if (label && text) label.textContent = text; toolStatusEl.classList.toggle('sa-active', !!active); } function abortedLoopResult(currentHistory, iterations, toolCallLog) { const lastAi = [...currentHistory].reverse().find(m => m.role === 'assistant'); return { response: lastAi ? cleanToolCallMarkers(stripThinkTags(lastAi.content)) : '', completed: false, aborted: true, iterations, toolCalls: toolCallLog, }; } // 对话循环: // 1. 还没跑过工具时用 0.2,并带上原生 tools;供应商拒绝 tools 时本轮对话降回文本协议 // 2. 一轮里的只读工具并行执行,结果合成一条 tool_result 再交给模型 // 3. 已经拿到工具结果后的轮次用 0.7,正文经流式门控输出,工具 JSON 不进气泡 async function runAgentLoop(userMessage, history, agentId, onChunk, onToolCall) { const agent = AgentManager.getAgent(agentId); if (!agent) return { response: '未找到智能体', completed: false, iterations: 0 }; // 智能体工具子集授权:null = 全部;提示词描述与原生 tools 同步过滤, // 执行期二次校验(防模型幻觉调用未授权工具) const allowedTools = (Array.isArray(agent.tools) && agent.tools.length) ? agent.tools : null; const toolsDesc = ToolRegistry.getToolsDescription(allowedTools); const openaiTools = ToolRegistry.toOpenAITools(allowedTools); const systemInstruction = buildSystemInstruction(agent); const parsedSettings = parseStored(GM_getValue(SK.SETTINGS, null), {}); const maxIter = parsedSettings.maxIterations || CONFIG.MAX_ITERATIONS; let currentHistory = [...history]; const toolCallLog = []; let usedTools = false; let nativeTools = openaiTools.length > 0; let repairTemp = false; const gate = createStreamGate(onChunk); for (let i = 0; i < maxIter; i++) { if (abortFlag) return abortedLoopResult(currentHistory, i, toolCallLog); const temperature = (usedTools && !repairTemp) ? CONFIG.ANSWER_TEMPERATURE : CONFIG.TOOL_TEMPERATURE; repairTemp = false; const prompt = buildReActPrompt(userMessage, currentHistory, toolsDesc, i + 1, maxIter); setToolStatus(usedTools ? '正在整理回答…' : '正在思考…', true); let turn; const lastRound = i + 1 >= maxIter; const chatOpts = () => ({ systemInstruction, history: [], temperature, // 最后一轮不再提供工具:强制模型基于已获取的结果收尾,避免迭代耗尽时浪费全部抓取数据 tools: (nativeTools && !lastRound) ? openaiTools : undefined, onChunk: (full) => gate.push(full), }); try { turn = await callAiChat(prompt, chatOpts()); } catch (e) { if (nativeTools && isNativeToolsRejected(e) && !abortFlag) { nativeTools = false; console.warn('[SA-Agent] 当前模型不接受原生 tools,本轮对话改用文本协议'); try { turn = await callAiChat(prompt, chatOpts()); } catch (e2) { if (abortFlag) return abortedLoopResult(currentHistory, i + 1, toolCallLog); setToolStatus('', false); return { response: `AI 调用失败: ${e2.message}`, completed: false, iterations: i + 1, error: e2.message, toolCalls: toolCallLog }; } } else if (nativeTools && e.isReasoningOnly && !abortFlag) { // 推理模型怪癖(DeepSeek v4-pro 实测):思考中明确要调用工具,却不产出 tool_calls 即停止。 // 降级为文本协议(不带 tools 参数,靠 ReAct 提示词里的 纯文本格式)绕开原生调用路径 nativeTools = false; repairTemp = true; console.warn('[SA-Agent] 推理模型仅思考未产出工具调用,本轮降级文本协议重试'); try { turn = await callAiChat(prompt, chatOpts()); } catch (e2) { if (abortFlag) return abortedLoopResult(currentHistory, i + 1, toolCallLog); setToolStatus('', false); return { response: `AI 调用失败: ${e2.message}`, completed: false, iterations: i + 1, error: e2.message, toolCalls: toolCallLog }; } } else if (abortFlag) { return abortedLoopResult(currentHistory, i + 1, toolCallLog); } else { setToolStatus('', false); return { response: `AI 调用失败: ${e.message}`, completed: false, iterations: i + 1, error: e.message, toolCalls: toolCallLog }; } } if (abortFlag) { const partial = cleanToolCallMarkers(stripThinkTags(turn?.content || '')); return { response: partial, completed: false, aborted: true, iterations: i + 1, toolCalls: toolCallLog }; } const parsedCalls = mergeToolCalls(turn.toolCalls, turn.content); const calls = parsedCalls.slice(0, CONFIG.MAX_PARALLEL_TOOLS); const assistantVisible = cleanToolCallMarkers(stripThinkTags(turn.content || '')); currentHistory.push({ id: `ai_${Date.now()}_${i}`, role: 'assistant', content: (calls.length && !lastRound) ? (assistantVisible || `(调用工具:${calls.map(c => c.name).join('、')})`) : (turn.content || ''), timestamp: Date.now(), }); // 最后一轮即使模型仍尝试调用工具,也不再执行:强制基于已获取的工具结果收尾 if (!calls.length || lastRound) { const raw = String(turn.content || ''); const attemptedToolCall = /${JSON.stringify({ success: false, formatError: true, error: '上一次输出像是工具调用,但没有解析出合法的 name 与 arguments。请改用原生工具调用,或输出 {"name":"工具名称","arguments":{"参数名":"值"}}。多个工具可输出多段标签,或在一个标签里输出 JSON 数组。若已无需调用工具,请直接给出最终回答', })}`, timestamp: Date.now(), }); console.warn('[SA-Agent] 工具调用格式解析失败,已回灌纠错提示:', raw.slice(0, 200)); continue; } let finalResponse = assistantVisible.trim(); if (!finalResponse) { finalResponse = lastRound ? `抱歉,本次任务在 ${maxIter} 轮迭代内未能完成完整分析(已展示获取到的数据)。请尝试缩小问题范围后重试。` : '(本轮未能生成有效回复:工具调用格式异常,请重试或换个问法)'; } setToolStatus('', false); await revealAnswer(finalResponse, onChunk, !!turn.streamed && !shouldHoldStream(raw)); return { response: finalResponse, completed: true, iterations: i + 1, toolCalls: toolCallLog }; } gate.clear(); if (onChunk) onChunk(''); const batch = calls.map(c => ({ name: c.name, arguments: c.arguments || {}, running: true })); batch.forEach((tc, idx) => { if (onToolCall) onToolCall(tc.name, tc.arguments, 'start', idx); }); const labels = batch.map(tc => getToolDisplay(tc.name).label).join('、'); setToolStatus(batch.length > 1 ? `正在并行调用 ${batch.length} 个工具 · ${labels}…` : `正在调用智能体工具 · ${labels}…`, true); await mapLimit(batch, CONFIG.MAX_PARALLEL_TOOLS, async (tc, idx) => { const t0 = Date.now(); if (abortFlag) { tc.result = { success: false, error: '已停止' }; } else if (allowedTools && !allowedTools.includes(tc.name)) { tc.result = { success: false, error: `工具 "${tc.name}" 未授权给当前智能体,请改用该智能体可用工具完成任务` }; } else { tc.result = await ToolRegistry.execute(tc.name, tc.arguments); } tc.durationMs = Date.now() - t0; tc.running = false; if (onToolCall) onToolCall(tc.name, tc.arguments, 'done', idx, tc.result); }); setToolStatus('', false); if (abortFlag) return abortedLoopResult(currentHistory, i + 1, toolCallLog); batch.forEach(tc => toolCallLog.push({ name: tc.name, arguments: tc.arguments, result: tc.result, durationMs: tc.durationMs })); usedTools = true; const dropped = parsedCalls.length - calls.length; currentHistory.push({ id: `tool_${Date.now()}_${i}`, role: 'system', content: batch.map(tc => `${JSON.stringify({ name: tc.name, arguments: tc.arguments, ...(tc.result || {}), })}`).join('\n') + (dropped > 0 ? `\n${JSON.stringify({ success: false, error: `本轮最多执行 ${CONFIG.MAX_PARALLEL_TOOLS} 个工具,已忽略其余 ${dropped} 个` })}` : ''), timestamp: Date.now(), }); } setToolStatus('', false); return { response: '抱歉,处理该问题需要更多步骤,请尝试缩小问题范围。', completed: false, iterations: maxIter, toolCalls: toolCallLog }; } // ============================================================ // 对话管理器 (多对话历史支持) // ============================================================ const ConversationManager = { _conversations: {}, _currentConvId: null, _saveTimer: 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.saveNow(); } 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.saveNow(); }, 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.saveNow(); }, updateCurrentModel(modelId) { const conv = this.getCurrentConversation(); conv.modelId = modelId; this.save(); }, // 防抖保存:addMessage 等高频操作只标脏,800ms 尾沿统一落盘; // beforeunload/hidden 时 flushSave 兜底冲刷 save() { if (this._saveTimer) clearTimeout(this._saveTimer); this._saveTimer = setTimeout(() => { this._saveTimer = null; this.saveNow(); }, CONFIG.SAVE_DEBOUNCE_MS); }, // 容量治理:每会话消息保尾部上限,会话总数按 updatedAt 保最近 N 个(当前会话永不删除) prune() { const maxMsgs = CONFIG.MAX_MESSAGES_PER_CONV; for (const conv of Object.values(this._conversations)) { if (Array.isArray(conv.messages) && conv.messages.length > maxMsgs) { conv.messages = conv.messages.slice(-maxMsgs); } } const ids = Object.keys(this._conversations); if (ids.length > CONFIG.MAX_CONVERSATIONS) { const sorted = ids .map(id => this._conversations[id]) .sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0)); const keep = new Set(sorted.slice(0, CONFIG.MAX_CONVERSATIONS).map(c => c.id)); if (this._currentConvId) keep.add(this._currentConvId); for (const id of ids) if (!keep.has(id)) delete this._conversations[id]; } }, saveNow() { if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; } try { this.prune(); GM_setValue(SK.CONVERSATIONS_V2, JSON.stringify(this._conversations)); } catch (e) { console.error('[SA-Agent] 对话保存失败:', e); toast('对话保存失败,可能超出存储配额,请导出并清理旧对话', 'error'); } }, flushSave() { this.saveNow(); }, 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('chevronDown', 14)} ${getIcon('sparkle', 13)} 正在思考… ${getIcon('send', 16)} `; 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 agentDropdownEl = panelEl.querySelector('#sa-agent-dropdown'); const agentTriggerEl = panelEl.querySelector('#sa-agent-trigger'); const agentTriggerIconEl = panelEl.querySelector('#sa-agent-trigger-icon'); const agentTriggerNameEl = panelEl.querySelector('#sa-agent-trigger-name'); const agentMenuEl = panelEl.querySelector('#sa-agent-menu'); function renderAgentMenu() { const agents = AgentManager.getAgents(); const currentId = AgentManager._currentAgentId; const itemHtml = (a) => ` ${agentIconHtml(a, 16)} ${escapeHtml(a.name)} ${a.description ? `${escapeHtml(a.description)}` : ''} ${getIcon('check', 14)} `; const builtinItems = agents.filter(a => a.builtin).map(itemHtml).join(''); const customItems = agents.filter(a => !a.builtin).map(itemHtml).join(''); agentMenuEl.innerHTML = ` ${builtinItems ? `内置智能体${builtinItems}` : ''} ${customItems ? `自定义智能体${customItems}` : ''} `; } function setAgentMenuOpen(open) { const isOpen = agentMenuEl.classList.toggle('sa-show', open); agentTriggerEl.classList.toggle('sa-open', open); agentTriggerEl.setAttribute('aria-expanded', String(isOpen)); if (open) renderAgentMenu(); } function refreshAgentSelect() { const agent = AgentManager.getCurrentAgent(); agentTriggerIconEl.innerHTML = agentIconHtml(agent, 15); agentTriggerNameEl.textContent = agent ? agent.name : '选择智能体'; if (agentMenuEl.classList.contains('sa-show')) renderAgentMenu(); } refreshAgentSelect(); agentTriggerEl.addEventListener('click', (e) => { e.stopPropagation(); setAgentMenuOpen(!agentMenuEl.classList.contains('sa-show')); }); agentMenuEl.addEventListener('click', (e) => { e.stopPropagation(); if (e.target.closest('#sa-agent-manage')) { setAgentMenuOpen(false); openSettings({ tab: 'agents' }); return; } const item = e.target.closest('.sa-agent-item'); if (!item) return; const id = item.dataset.id; if (id && id !== AgentManager._currentAgentId) { AgentManager.switchAgent(id); ConversationManager.getCurrentConversation().agentId = id; ConversationManager.save(); renderMessages(); } setAgentMenuOpen(false); refreshAgentSelect(); }); document.addEventListener('click', (e) => { if (!agentDropdownEl.contains(e.target)) setAgentMenuOpen(false); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && agentMenuEl.classList.contains('sa-show')) setAgentMenuOpen(false); }); // 热点入口(面板头部右上角按钮组):仅库加载成功时显示 const newsBtnEl = panelEl.querySelector('#sa-btn-news'); if (GameNews) { newsBtnEl.style.display = ''; newsBtnEl.title = '游戏热点 · ' + GameNews.sources.map(s => s.label).join('/'); newsBtnEl.addEventListener('click', () => GameNews.openWindow()); } else { newsBtnEl.remove(); } // 搜索入口(热点按钮左侧):仅库加载成功时显示 const searchBtnEl = panelEl.querySelector('#sa-btn-search'); if (Search) { searchBtnEl.style.display = ''; searchBtnEl.title = '搜索 · 我的游戏库 / 新闻热点'; searchBtnEl.addEventListener('click', () => Search.openWindow()); } else { searchBtnEl.remove(); } 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-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); // v1.13.0 P0-1:滚动时更新脱水消息的高度记忆(rAF 节流,避免滚动中反复强制布局) if (messagesEl) { let hydrateRaf = false; messagesEl.addEventListener('scroll', () => { if (hydrateRaf) return; hydrateRaf = true; requestAnimationFrame(() => { hydrateRaf = false; hydrateMessagesInView(); }); }, { passive: true }); } 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 || '自定义'; const name = String(m.name || '').trim(); const model = String(m.model || '').trim(); const label = (name && name !== model && !name.includes(model)) ? `${name} · ${model}` : `${providerName} · ${model || name}`; return `${escapeHtml(label)}`; }).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 = ` ${getIcon('history', 15)} 对话历史 ${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 脚本装齐了吗?有没有版本过旧的?', '用本地插件缓存看看我游戏库里玩得最多的几款', '帮我分析一下 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 || '')} ${getIcon('lightbulb', 14)} 建议问题 ${getIcon('refresh', 12)} 换一批 ${questions.map(q => ` ${escapeHtml(q)} ${getIcon('send', 13)} `).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)} ${getIcon('send', 13)} `).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 = []; // v1.13.0 P0-1:长会话渲染治理 —— 屏外历史消息降级渲染。 // // 背景:MAX_MESSAGES_PER_CONV=200 时 renderMessages() 单次 innerHTML 可达 2MB+, // 浏览器要 parse 整棵子树并对每条消息做布局,长列表滚动/切换会话明显变卡。 // 方案:给靠前的历史消息加 content-visibility:auto,屏外跳过布局与绘制。 // - 用 auto 而非 hidden:hidden 会让内容无法被 Ctrl+F 搜到,auto 保留可搜索性。 // - 高度记忆:脱水前先量一次真实高度写进 --sa-h,配合 contain-intrinsic-size // 让滚动条长度稳定、不因脱水而跳变;已脱水的元素绝不重复测量(否则量到的 // 是 intrinsic 兜底值,会把高度越改越小)。 // - 读写分离:先批量读高度,再批量写属性,避免 read/write 交替触发强制同步布局。 function clearMessageDehydration(el) { if (!el || el.dataset.dehydrated !== '1') return; el.dataset.dehydrated = '0'; // 故意保留 --sa-h 记忆值:下次再脱水时可直接复用,省一次测量 } function applyMessageDehydration() { if (!messagesEl) return; const nodes = messagesEl.querySelectorAll('.sa-msg'); const total = nodes.length; if (total < CONFIG.DEHYDRATE_MIN_MESSAGES) { // 短会话不启用:省不下什么,反而白付一次测量成本 for (const el of nodes) clearMessageDehydration(el); return; } // 末尾 KEEP_RECENT 条保持活跃(用户正在看的 + 刚生成的) const cutoff = total - CONFIG.DEHYDRATE_KEEP_RECENT; // ---- 读阶段:只对"需要新脱水且尚无记忆高度"的元素测高 ---- const targets = []; const measured = []; for (let i = 0; i < cutoff; i++) { const el = nodes[i]; if (el.dataset.dehydrated === '1') continue; if (el.style.getPropertyValue('--sa-h')) continue; // 已有记忆高度则复用 targets.push(el); measured.push(el.offsetHeight || 0); } // ---- 写阶段:批量写样式/属性 ---- for (let i = 0; i < cutoff; i++) { const el = nodes[i]; if (el.dataset.dehydrated === '1') continue; if (!el.style.getPropertyValue('--sa-h')) { const h = measured[targets.indexOf(el)] || CONFIG.DEHYDRATE_FALLBACK_H; el.style.setProperty('--sa-h', h + 'px'); } el.dataset.dehydrated = '1'; } for (let i = cutoff; i < total; i++) clearMessageDehydration(nodes[i]); } // 滚动时按需更新高度记忆:auto 元素滚入视口后浏览器会真实布局, // 此时量到的是真高度,回写 --sa-h 让后续脱水时的滚动条长度更准。 function hydrateMessagesInView() { if (!messagesEl) return; const list = messagesEl.querySelectorAll('.sa-msg[data-dehydrated="1"]'); if (!list.length) return; const top = messagesEl.scrollTop; const bottom = top + messagesEl.clientHeight; for (const el of list) { const t = el.offsetTop; if (t > bottom + 200 || t + (el.offsetHeight || 0) < top - 200) continue; const real = Math.round(el.getBoundingClientRect().height); if (real > 0) el.style.setProperty('--sa-h', real + 'px'); } } function buildMsgFooterHtml(content) { if (!content) return ''; const id = msgCopyRegistry.push(content) - 1; return ``; } function buildAiBadgeHtml() { return `${getIcon('sparkle', 14)}`; } 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 `${buildAiBadgeHtml()}${toolsHtml}${bodyHtml}${buildMsgFooterHtml(clean)}`; } else if (m.role === 'system') { return ''; } return ''; }).join(''); messagesEl.scrollTop = messagesEl.scrollHeight; // v1.13.0 P0-1:历史消息整体重建后,立刻脱水屏外部分(读测量已在写阶段之前完成) applyMessageDehydration(); } 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 = buildAiBadgeHtml() + renderMarkdown(cleanToolCallMarkers(content)); } messagesEl.appendChild(msgEl); messagesEl.scrollTop = messagesEl.scrollHeight; // v1.13.0 P0-1:新消息进来后,把更早的消息挤进脱水区(末尾 KEEP_RECENT 条始终活跃) applyMessageDehydration(); 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 = '发送 (Enter)'; sendBtn.setAttribute('aria-label', '发送消息'); sendBtn.innerHTML = getIcon('send', 16); } 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 发送' : '正在回复,可点右侧按钮停止'; } } 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 = []; // v1.13.0 P0-2:把气泡拆成 badge / tools / body 三个常驻子节点。 // 旧实现每帧 `aiMsgEl.innerHTML = badge + toolsHtml + bodyHtml`,其中 toolsHtml // 在正文流式增长期间**内容不变却被反复 parse**(每帧一次,单个工具卡可达数 KB)。 // 现改为:正文每帧更新(本来就要),工具仅在结构签名变化时更新。 // 宿主结构无变化,故无需 withSelfMutation 守卫——本子树没有 MutationObserver 监听 // (L7712 的观察器是 {childList:true} 且无 subtree,只看 body 直接子级)。 // 若日后给消息区加观察器,必须同步引入自变更守卫,否则会自触发死循环。 const badgeHost = document.createElement('div'); badgeHost.innerHTML = buildAiBadgeHtml(); const badgeNode = badgeHost.firstElementChild; const toolsEl = document.createElement('div'); const bodyEl = document.createElement('div'); if (badgeNode) aiMsgEl.appendChild(badgeNode); aiMsgEl.appendChild(toolsEl); aiMsgEl.appendChild(bodyEl); // 工具区结构签名:只取「会影响渲染」的字段(数量/运行态/耗时/结果是否就绪), // 避免为了判重而每帧都跑一遍 buildToolCallsHtml(含 getIcon 的 SVG 构造)。 const toolSigOf = (calls, runningIndex) => { let s = calls.length + ':' + runningIndex; for (const tc of calls) { s += '|' + (tc.running ? '1' : '0') + (tc.durationMs != null ? 'd' : '') + (tc.result ? 'r' : ''); } return s; }; let lastToolsSig = null; // 流式渲染节流:rAF 合并高频重绘 let bubbleRafPending = false; let bubblePendingIndex = -1; let bubbleSettled = false; const paintAiBubble = (runningIndex) => { if (bubbleSettled) return; const cleanText = cleanToolCallMarkers(displayedText); bodyEl.innerHTML = cleanText ? renderMarkdown(cleanText) : ''; // 工具区:签名未变则整段跳过(含展开状态保留——DOM 没被重建,展开态自然留存) const sig = toolSigOf(liveToolCalls, runningIndex); if (sig !== lastToolsSig) { const prevOpen = Array.from(toolsEl.querySelectorAll('details.sa-tool-chip')).map(d => d.open); toolsEl.innerHTML = buildToolCallsHtml(liveToolCalls, runningIndex); if (prevOpen.length) { toolsEl.querySelectorAll('details.sa-tool-chip').forEach((d, i) => { if (prevOpen[i]) d.open = true; }); } lastToolsSig = sig; } messagesEl.scrollTop = messagesEl.scrollHeight; }; const updateAiBubble = (runningIndex = -1) => { if (bubbleRafPending) { bubblePendingIndex = runningIndex; return; } bubbleRafPending = true; bubblePendingIndex = runningIndex; if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => { bubbleRafPending = false; if (!bubbleSettled) paintAiBubble(bubblePendingIndex); }); } else { bubbleRafPending = false; paintAiBubble(bubblePendingIndex); } }; // 终态(完整回答/停止/错误)直接 innerHTML 定稿后,丢弃还在排队的 rAF 重绘, // 防止无 footer 的中间态覆盖带复制按钮的终态 const settleBubble = () => { bubbleSettled = true; bubbleRafPending = false; }; 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, index, toolResult) => { if (abortFlag) return; if (phase === 'start') { liveToolCalls.push({ name, arguments: args, running: true, _t0: Date.now() }); updateAiBubble(); } else { const tc = (Number.isInteger(index) && liveToolCalls[index]) || liveToolCalls.find(t => t.running && t.name === name); if (tc) { tc.running = false; if (tc._t0) tc.durationMs = Date.now() - tc._t0; if (toolResult) tc.result = toolResult; } updateAiBubble(); } }); if (toolStatusEl) toolStatusEl.classList.remove('sa-active'); liveToolCalls = result.toolCalls || liveToolCalls; settleBubble(); // v1.13.0:终态写回常驻节点(toolsEl/bodyEl),不再整块 innerHTML 重建, // 这样流式态与终态的 DOM 结构保持一致,脱水高度测量也不会被打断 if (result.aborted) { const partial = result.response || cleanToolCallMarkers(displayedText); toolsEl.innerHTML = buildToolCallsHtml(liveToolCalls); bodyEl.innerHTML = partial ? renderMarkdown(partial) : '已停止生成'; appendMsgFooterEl(aiMsgEl, partial); messagesEl.scrollTop = messagesEl.scrollHeight; if (partial) ConversationManager.addMessage('assistant', partial, { toolCalls: liveToolCalls }); } else { displayedText = result.response; toolsEl.innerHTML = buildToolCallsHtml(liveToolCalls); bodyEl.innerHTML = renderMarkdown(result.response); appendMsgFooterEl(aiMsgEl, result.response); messagesEl.scrollTop = messagesEl.scrollHeight; ConversationManager.addMessage('assistant', result.response, { toolCalls: result.toolCalls || [], }); } // 刚生成的消息要保持活跃(新脱水阈值刚把它推入活跃区,无需处理); // 但更早的消息可能因 cutoff 前移而脱水,重算一次 applyMessageDehydration(); } catch (e) { if (toolStatusEl) toolStatusEl.classList.remove('sa-active'); settleBubble(); toolsEl.innerHTML = ''; bodyEl.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: 'sources', label: '数据源', icon: 'stats' }, { 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)} 提示`; const detailHtml = detail ? '' + escapeHtml(detail).replace(/\n/g, '') : ''; return `${mark}${escapeHtml(title)}${detailHtml}`; } 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(); // v1.16.0 联网检索工具配置 const webToolsBase = String(GM_getValue(SK.DS_TOOLS_BASE, '') || '').trim(); const webToolsKey = String(GM_getValue(SK.DS_TOOLS_KEY, '') || '').trim(); const webToolsEffective = getWebToolsConfig(); const webToolsStatus = webToolsEffective.key ? { color: '', text: `已就绪:Key 来源「${webToolsEffective.source}」,接口 ${webToolsEffective.base}` } : { color: 'color:#fbbf24;', text: '未配置:webSearch / webFetch 将返回配置引导。可填入 DeepSeek Key,或在「AI 设置」中把模型配成 DeepSeek 以自动复用' }; const settings = parseStored(GM_getValue(SK.SETTINGS, null), {}); const overlay = document.createElement('div'); overlay.className = 'sa-overlay sa-show'; overlay.innerHTML = ` ${getIcon('gear', 18)} 智能体设置中心 v${escapeHtml(_v)} ${getIcon('close', 16)} ${SETTINGS_TABS.map(t => `${getIcon(t.icon, 15)} ${t.label}`).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)} 生成参数 温度 (0-2) 值越低回答越稳定,越高越发散;推荐 0.5-0.8 最大迭代次数 ReAct 循环最大工具调用次数 ${getIcon('book', 15)} 规则知识库 远程知识库地址 指向 saa-rules-knowledge.json 的原始链接(纯数据 JSON,不执行代码);更新知识只需改 JSON 文件并推送,无需更新主脚本 ${getIcon('refresh', 14)} 检查更新 ${getIcon('trash', 14)} 清除远程缓存 `; 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} ${escapeHtml(s.sectionCount)} 章节 · ${escapeHtml(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 = ` AI 模型配置 选择供应商预设可自动填充 API 地址和模型列表,只需填入 API Key 即可使用。支持配置多个模型。 ${getIcon('zap', 13)} 一键检测全部模型 ${getIcon('plus', 14)} 添加模型 `; 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 ? `${getIcon('play', 13)}` : ''} ${getIcon('zap', 13)} ${getIcon('edit', 13)} ${getIcon('trash', 13)} `; }).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 = ` ${getIcon('close', 12)} 返回列表 ${model ? '编辑模型(保存后立即生效)' : '添加模型(保存后立即生效)'} 选择供应商 ${Object.entries(MODEL_PROVIDERS).map(([key, p]) => ` ${escapeHtml(p.name)} `).join('')} 显示名称 API 地址 API Key 模型名称 ${getIcon('zap', 14)} 测试连接 保存 `; 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};输入兼容 OpenAI 格式的 API 地址(需包含 /v1/chat/completions 路径)`; const keyHint = editorView.querySelector('#sa-me-key-hint'); if (p.apiKeyUrl) { keyHint.innerHTML = `在 ${escapeHtml(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 = ` ${p.models.map(m => `${escapeHtml(m.label)}`).join('')} `; 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); // URL 自动补全:失焦时补全协议和路径 const urlInputEl = editorView.querySelector('#sa-me-url'); urlInputEl.addEventListener('blur', () => { let v = urlInputEl.value.trim(); if (!v) return; if (!/^https?:\/\//i.test(v)) v = 'https://' + v; try { const u = new URL(v); if (!u.pathname || u.pathname === '/') u.pathname = '/v1/chat/completions'; const normalized = u.toString().replace(/\/$/, ''); if (normalized !== urlInputEl.value.trim()) { urlInputEl.value = normalized; } } catch {} }); 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.innerHTML = `连接失败:${escapeHtml(describeModelError(err)).replace(/\n/g, '')}`; } 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 = ` 智能体管理 新建、编辑、切换自定义智能体;支持 AI 辅助生成系统提示词与配置导入导出。 ${getIcon('plus', 14)} 新建智能体 ${getIcon('download', 14)} 导出 ${getIcon('edit', 14)} 导入 `; 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)} ${getIcon('play', 13)} ${!a.builtin ? ` ${getIcon('edit', 13)} ${getIcon('trash', 13)} ` : ''} `).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 = ` ${getIcon('close', 12)} 返回列表 ${agent ? '编辑智能体(保存后立即生效)' : '新建智能体(保存后立即生效)'} 图标 (emoji 或 SVG 图标名) 可用: bot, gamepad, gift, sparkle, stats, chat 或输入 emoji 名称 描述 关键词 (逗号分隔) 可用工具 (不勾选任何工具 = 使用全部工具) ${ToolRegistry.getAll().map(t => { const enabled = !(agent?.tools && agent.tools.length) || agent.tools.includes(t.name); return ` ${escapeHtml((TOOL_DISPLAY[t.name] || {}).label || t.name)}`; }).join('')} 限定工具子集可减少系统提示词长度并提升工具选择准确率;至少保留一个工具 系统提示词 (System Prompt) ${getIcon('copy', 13)} 复制 ${escapeHtml(agent?.systemPrompt || '')} 需求描述 (AI 辅助生成提示词) ${getIcon('sparkle', 14)} AI 生成提示词 保存 取消 `; 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, // 工具子集:全部勾选或全不勾 = null(全部);部分勾选 = 名单 tools: (() => { const checked = [...editorView.querySelectorAll('.sa-ae-tool:checked')].map(c => c.value); const total = ToolRegistry.getAll().length; return (checked.length > 0 && checked.length < total) ? checked : null; })(), }; 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 = ` 接口服务 配置并检测 Steam / ITAD 第三方接口;AI 模型接口在「AI 设置」标签中检测。 ${getIcon('zap', 13)} 一键检测全部接口 ${getIcon('key', 15)} Steam Web API Key ${getIcon('zap', 13)} 检测 ${steamKey ? '已配置:点击「检测」验证 Key 有效性' : '未配置:游戏库查询(getOwnedGames)、高级折扣检索(searchDiscountedGames) 将不可用'} 在 steamcommunity.com/dev 申请,用于 getOwnedGames / searchDiscountedGames 等工具 ${getIcon('user', 15)} SteamID(玩家数据查询身份,可选) ${getIcon('zap', 13)} 检测 ${idStatusText} 用于「我的游戏库 / 资料 / 愿望单」类查询;留空时自动识别当前登录用户(依据页面登录态),无需手动填写 ${getIcon('globe', 15)} ITAD API Key(跨区比价增强,可选) ${getIcon('zap', 13)} 测试 ${itadKey ? '已配置:getMultiRegionPrices 工具将以 ITAD 增强模式查询 16 个地区的实时价格' : '未配置:跨区比价工具将使用降级数据源(AugmentedSteam,约 7 个地区),价格准确度降低'} 申请步骤:① isthereanydeal.com 注册并验证邮箱 ② 进入 isthereanydeal.com/apps 点击 Register App ③ 填写应用名提交 ④ 复制 32 位密钥粘贴到上方 ⑤ 点击「测试」验证后保存。已安装 SGLV 2.9.79+ 时可自动复用其配置 ${getIcon('search', 15)} 联网检索工具(webSearch / webFetch) 服务 Base URL(留空即用默认 ${escapeHtml(CONFIG.DS_TOOLS_DEFAULT_BASE)})。需兼容 DeepSeek 的 /search 与 /fetch 两个 POST 端点,可换成自建网关。 ${getIcon('zap', 13)} 测试 ${escapeHtml(webToolsStatus.text)} 提供 webSearch(实时网页检索)与 webFetch(网页正文抓取)能力。留空时若「AI 设置」用的是 DeepSeek 模型会自动复用其 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 地区),价格准确度降低')); } // v1.16.0 联网检索工具连通性检测 const wtInput = pane.querySelector('#sa-cfg-webtools-key').value.trim(); const wtBase = pane.querySelector('#sa-cfg-webtools-base').value.trim(); if (wtInput) GM_setValue(SK.DS_TOOLS_KEY, wtInput); if (wtBase) GM_setValue(SK.DS_TOOLS_BASE, wtBase); else GM_deleteValue(SK.DS_TOOLS_BASE); const wt = await testWebToolsConnection(); prependCheckResult(resultsEl, wt.success ? renderCheckItem('ok', '联网检索工具可用', wt.note) : renderCheckItem('err', '联网检索工具检测失败', wt.error)); toast('接口检测完成,结果见上方列表', 'success'); btn.disabled = false; btn.innerHTML = originalHtml; }); } // ---------- Tab: 数据管理 ---------- // ═══ v1.25.0 数据源授权 ═══ // 逐插件勾选 AI 可读取哪些插件的本地缓存;默认全开,用户可随时关闭。 // 关闭后 readPluginCache 返回 needAuth,模型会引导用户回这里开启。 async function buildSourcesPane() { const pane = overlay.querySelector('#sa-pane-sources'); if (!pane) return; pane.innerHTML = ` 数据源授权 选择 AI 可以读取哪些插件的本地缓存。数据仅存于本机,不做任何上传。 正在检测已安装插件… `; // 契约(期望清单 + minVersion)+ 运行时注册(精确版本号) let contract = pcContractGetCached(); if (!contract) { try { contract = await pcContractFetch(); } catch (e) { contract = null; } } const registry = await pcReadPluginRegistry(); const authMap = pcDataSourceAuthMap(); if (!contract) { pane.innerHTML = ` 数据源授权 选择 AI 可以读取哪些插件的本地缓存。 版本契约加载失败(无法获取插件清单)。请检查网络后重试 —— 授权状态仍会保留。 `; return; } const famLabels = contract.families || {}; const byFamily = new Map(); for (const p of (contract.plugins || [])) { const f = p.family || '_other'; if (!byFamily.has(f)) byFamily.set(f, []); byFamily.get(f).push(p); } const render = () => { const am = pcDataSourceAuthMap(); // v1.30.2 域感知:注册表按域隔离,跨域读不到是正常现象而非「未安装」 const curDom = pcPanelDomain(); const domChip = curDom ? `当前面板:${curDom === 'store' ? '商店域 store' : '社区域 community'}` : ''; let html = ` 数据源授权${domChip} 选择 AI 可以读取哪些插件的本地缓存。数据仅存于本机,不做任何上传。 `; // 套件完整性 let famHtml = ''; for (const [fam, list] of byFamily) { const inst = list.filter(p => registry.has(p.id)).length; const okAll = inst === list.length; famHtml += ` ${escapeHtml((famLabels[fam] && famLabels[fam].label) || fam)} ${inst}/${list.length} ${okAll ? '已装齐' : '未装齐'} `; } html += ` ${getIcon('stats', 15)} 套件完整性 ${famHtml} 「已装齐」按插件注册表的实际版本判定;未注册的插件只能探测到「存在」,版本显示为 —。注册表按域隔离(账户系/核心系在商店域、好友系/徽章系在社区域),跨域注册经 GM 存储镜像自动合并显示为「来自 XX 域 · 镜像」。 `; // 插件列表 html += ` ${getIcon('gamepad', 15)} 插件数据源 全部共享 全部关闭 `; for (const [fam, list] of byFamily) { html += ` ${escapeHtml((famLabels[fam] && famLabels[fam].label) || fam)} ${list.filter(p => registry.has(p.id)).length}/${list.length} 已装 `; for (const p of list) { const reg = registry.get(p.id); const installed = !!reg; const viaMirror = !!(reg && reg.viaMirror); const version = reg ? reg.version : null; const outdated = version && p.minVersion ? pcCompareVersion(version, p.minVersion) < 0 : false; const allowed = am[p.id] ? am[p.id].allowed : true; let badge = ''; if (installed && viaMirror) { // v1.30.3 跨域镜像:注册来自另一域的 IndexedDB,经 GM 存储镜像合并显示 const dn = reg.regDomain === 'community' ? '社区域' : (reg.regDomain === 'store' ? '商店域' : '他域'); badge = `来自${dn} · 镜像`; } else if (!installed) { // v1.30.2 域感知:未注册且契约标明注册在其他域 → 提示去对应域确认,不误报「未安装」 const pd = p.domain; if (pd && pd !== 'both' && curDom && pd !== curDom) { const domName = pd === 'store' ? '商店域' : '社区域'; badge = `需在${domName}确认`; } else { badge = '未安装'; } } else if (outdated) badge = `版本过旧(需 ${escapeHtml(p.minVersion)})`; else if (version) badge = '已启用'; else badge = '已检测 · 版本未知'; html += ` ${escapeHtml(p.name)} ${version ? 'v' + escapeHtml(version) : '—'} ${badge} `; } html += ''; } html += `关闭后 AI 读取该插件数据会被明确拒绝并提示你回来开启;其他插件不受影响。 `; // ═══ v1.30.0 远程数据中枢(data-hub)═══ // v1.26 的做法是「端口 + 手填 token」(token 从 C:/sqlite-data/token 复制粘贴)—— // 远程托管后这个体验不合理:地址填一次即可,身份与凭据由插件自己搞定 // (设备密钥 + Steam 身份认证 → 换会话),**不再让用户接触任何 token**。 const hub = pcHubConfig(); const hubReport = (function () { try { return pcReportState(); } catch (e) { return { enabled: true, pendingOptout: false }; } })(); const hubState = (function () { try { const a = pcHubAuthLib(); return a ? a.getState() : null; } catch (e) { return null; } })(); html += ` ${getIcon('download', 15)} 远程数据中枢(data-hub) 启用远程数据中枢(默认开启,使用默认地址) 服务地址 ${hub.isDefaultUrl ? `当前使用默认地址 ${escapeHtml(hub.baseUrl)}(留空即为默认;默认地址支持远程热更)` : `当前使用自定义地址 ${escapeHtml(hub.baseUrl)}`}。填一次即可;不再需要手动复制令牌 —— 插件会用设备密钥 + Steam 身份认证自动换取会话。 允许明文 HTTP(仅内网调试) 允许内网 / 本机地址(自建服务器) 上报插件版本信息(默认开启) 开启后每日上报一次本机已装插件的版本号,用于统计版本分布与排查问题。关闭后不再每日上报,服务端已存的记录会一并删除。但每次绑定 Steam 账号时仍会附带一次版本清单,用于绑定问题的排查。 ${hubReport.pendingOptout ? `⚠ 服务端版本记录尚未删除(上次撤销未送达),下次启动自动重试` : ''} 保存 测试连接 恢复默认地址 绑定 Steam 账号 账号状态:${hubState && hubState.subject ? `已绑定 ${escapeHtml(hubState.subject)}` : '未绑定(点「绑定 Steam 账号」走一次 Steam 登录)'} `; pane.innerHTML = html; // 事件:勾选即写 GM pane.querySelectorAll('input[data-src]').forEach((input) => { input.addEventListener('change', () => { const auth = pcLoadAuth(); const id = input.getAttribute('data-src'); auth.sources[id] = input.checked; pcSaveAuth(auth); const row = input.closest('.sa-src-row'); if (row) row.classList.toggle('is-off', !input.checked); toast(input.checked ? '已允许共享该插件数据' : '已关闭该插件数据共享', 'success'); }); }); const setAll = (on) => { const auth = pcLoadAuth(); for (const p of (contract.plugins || [])) auth.sources[p.id] = on; pcSaveAuth(auth); render(); toast(on ? '已允许全部插件数据共享' : '已关闭全部插件数据共享', 'success'); }; const allOn = pane.querySelector('#sa-src-all-on'); const allOff = pane.querySelector('#sa-src-all-off'); if (allOn) allOn.addEventListener('click', () => setAll(true)); if (allOff) allOff.addEventListener('click', () => setAll(false)); // ---- 远程数据中枢:保存 + 测试连接 + 绑定 Steam 账号 ---- const hubUrlEl = pane.querySelector('#sa-hub-url'); const hubEnabledEl = pane.querySelector('#sa-hub-enabled'); const hubHttpEl = pane.querySelector('#sa-hub-allow-http'); const hubPrivateEl = pane.querySelector('#sa-hub-allow-private'); const hubReportEl = pane.querySelector('#sa-hub-report'); const hubStatusEl = pane.querySelector('#sa-hub-status'); const hubSaveBtn = pane.querySelector('#sa-hub-save'); const hubTestBtn = pane.querySelector('#sa-hub-test'); const hubResetBtn = pane.querySelector('#sa-hub-reset'); const hubBindBtn = pane.querySelector('#sa-hub-bind'); const readHubForm = () => ({ baseUrl: String(hubUrlEl && hubUrlEl.value || '').trim(), disabled: !(hubEnabledEl && hubEnabledEl.checked), allowHttp: !!(hubHttpEl && hubHttpEl.checked), allowPrivate: !!(hubPrivateEl && hubPrivateEl.checked), report: !!(hubReportEl && hubReportEl.checked), }); const hubStatus = (text, color) => { if (hubStatusEl) hubStatusEl.innerHTML = `${escapeHtml(text)}`; }; /** 地址合法性(保存前先挡一道,别等请求时才炸)。空地址合法 = 回落默认。 */ const validateHubUrl = (form) => { if (form.disabled) return ''; // 整体停用:地址随便填都不生效 if (!form.baseUrl) return ''; // 空 = 回落默认地址(§3.5.1「不改」与「显式清空」同义) let u; try { u = new URL(form.baseUrl); } catch (e) { return '地址不合法(要带 https://)'; } if (u.protocol !== 'https:' && u.protocol !== 'http:') return '只支持 http / https'; if (u.protocol === 'http:' && !form.allowHttp) return '明文 HTTP 需要先勾选「允许明文 HTTP」'; if (u.username || u.password) return '地址里不要带账号密码'; if (/\/(search|app|push|ingest|query|table)\b/.test(u.pathname)) return '地址只填到根路径(不要带接口路径)'; return ''; }; /** 上报开关变化处理:关闭 → 立即发 optout(撤销必须送达,失败置 pendingOptout) */ const applyReportToggle = async (on) => { const st = pcReportState(); st.enabled = !!on; pcReportSaveState(st); if (!on) { hubStatus('正在删除服务端版本记录…', '#94a3b8'); const ok = await pcOptoutVersionReport(); hubStatus(ok ? '已停止每日上报,服务端记录已删除(绑定时仍会附带一次)' : '撤销请求失败:服务端记录尚未删除,下次启动自动重试', ok ? '#4ade80' : '#fbbf24'); } else { hubStatus('已开启每日版本上报', '#4ade80'); } }; if (hubSaveBtn) { hubSaveBtn.addEventListener('click', async () => { const form = readHubForm(); const bad = validateHubUrl(form); if (bad) { hubStatus(bad, '#fbbf24'); return; } pcSaveHubConfig(form); pcHubSyncAuth(); // 上报开关独立保存(不随地址保存失败而丢) const prev = pcReportState(); if (prev.enabled !== form.report) await applyReportToggle(form.report); const eff = pcHubConfig(); hubStatus(eff.disabled ? '已停用远程数据中枢' : `已保存(当前生效:${eff.baseUrl}${eff.isDefaultUrl ? ' · 默认地址' : ''})`, '#4ade80'); toast('远程数据中枢配置已保存', 'success'); }); } if (hubResetBtn) { hubResetBtn.addEventListener('click', () => { // 恢复默认 = 清空显式地址(回落默认:远程热更 → 内置常量) if (hubUrlEl) hubUrlEl.value = ''; const form = readHubForm(); pcSaveHubConfig(form); pcHubSyncAuth(); const eff = pcHubConfig(); hubStatus(`已恢复默认地址(${eff.baseUrl})`, '#4ade80'); }); } if (hubReportEl) { // 上报开关即时生效:关闭 → 立即发 optout(撤销必须送达,失败置 pendingOptout) hubReportEl.addEventListener('change', () => { applyReportToggle(hubReportEl.checked).catch(() => { /* 静默 */ }); }); } if (hubTestBtn) { hubTestBtn.addEventListener('click', async () => { const form = readHubForm(); const bad = validateHubUrl(form); if (bad) { hubStatus(bad, '#fbbf24'); return; } pcSaveHubConfig(form); hubStatus('正在连接…', '#94a3b8'); try { // 用免鉴权的 /health 探测:还没绑账号也能先确认地址通不通 const r = await pcHubPing(); const hubSt = (function () { try { const a = pcHubAuthLib(); return a ? a.getState() : null; } catch (e) { return null; } })(); hubStatus(`连接正常:schema v${r.schemaVersion}${hubSt && hubSt.subject ? ` · 已绑定 ${hubSt.subject},链路与账号状态均正常` : '(地址连通;还没绑定账号,点「绑定 Steam 账号」完成认证)'}`, '#4ade80'); } catch (e) { hubStatus(pcHubErrorText(e, form), '#fbbf24'); } }); } if (hubBindBtn) { hubBindBtn.addEventListener('click', async () => { const form = readHubForm(); const bad = validateHubUrl(form); if (bad) { hubStatus(bad, '#fbbf24'); return; } pcSaveHubConfig(form); const auth = pcHubSyncAuth(); if (!auth) { hubStatus('认证库未加载(saa-auth.lib.js)—— 请更新插件', '#fbbf24'); return; } hubStatus('请在弹窗里登录 Steam…', '#94a3b8'); try { const r = await auth.loginWithSteam({ onStatus: (st) => { if (st === 'waiting-steam') hubStatus('请在弹窗里完成 Steam 登录…', '#94a3b8'); else if (st === 'exchanging') hubStatus('正在换取会话…', '#94a3b8'); }, }); hubStatus(`已绑定 Steam 账号 ${r.steamid}`, '#4ade80'); toast('Steam 账号绑定成功', 'success'); // ★ v1.33.0 绑定时附带一次版本清单(reason='bind'):与开关状态无关, // 绑定是用户明确发起的中枢交互,附版本快照用于绑定排障;静默失败不影响绑定结果。 try { pcSendVersionReport('bind').catch(() => {}); } catch (e) { /* ignore */ } try { render(); } catch (e) { /* ignore */ } } catch (e) { hubStatus(pcHubErrorText(e, form), '#fbbf24'); } }); } }; render(); } function buildDataPane() { const pane = overlay.querySelector('#sa-pane-data'); const conv = ConversationManager.getCurrentConversation(); pane.innerHTML = ` 会话数据管理 当前会话:「${escapeHtml(conv?.title || '新会话')}」 · ${conv?.messages?.length || 0} 条消息 ${getIcon('download', 15)} 导出当前会话 导出为 JSON 导出为 Markdown 导出文件可直接分享或备份;JSON 可再次导入恢复 ${getIcon('edit', 15)} 导入会话 从 JSON 文件导入会话 导入后会作为新会话追加到历史列表,不影响现有对话 `; 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()); // v1.16.0 联网检索工具 const wtBaseEl = overlay.querySelector('#sa-cfg-webtools-base'); const wtKeyEl = overlay.querySelector('#sa-cfg-webtools-key'); if (wtBaseEl) { const v = wtBaseEl.value.trim(); if (v) GM_setValue(SK.DS_TOOLS_BASE, v); else GM_deleteValue(SK.DS_TOOLS_BASE); } if (wtKeyEl) { const v = wtKeyEl.value.trim(); if (v) GM_setValue(SK.DS_TOOLS_KEY, v); else GM_deleteValue(SK.DS_TOOLS_KEY); } 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(); // v1.25.0 数据源授权 pane(异步:需拉契约 + 读运行时注册表) buildSourcesPane(); 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), // v1.25.0 暴露设置入口:便于其他脚本/工具直接跳到指定设置页(如数据源授权) openSettings: (options = {}) => openSettings(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); } } // 会话防抖保存的兜底冲刷:页面关闭/隐藏时立即落盘,避免 800ms 窗口内丢消息 function registerConversationFlush() { try { window.addEventListener('beforeunload', () => ConversationManager.flushSave()); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') ConversationManager.flushSave(); }); } catch {} } // 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('bindGameNewsEsc', () => { if (GameNews) GameNews.bindEsc(); }); // v1.36.0:商店域自动刷新游戏库跨域镜像(社区域搜索游戏库用;节流 30 分钟,失败静默) safeStep('pcAutoMirrorLibrary', () => { pcAutoMirrorLibrary().catch(() => {}); }); 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('registerConversationFlush', registerConversationFlush); // ★ v1.33.0 版本上报维护(远程默认地址热更 / optout 重试 / 每日一次上报)——全部静默 safeStep('pcReportStartup', () => { try { pcReportStartup().catch(() => {}); } catch (e) { /* ignore */ } }); safeStep('EventBus.ready', () => EventBus.emit('ready', { version: _v })); console.log('[SA-Agent] 初始化完成,SteamAgent API 已暴露'); } try { if (typeof globalThis !== 'undefined' && globalThis.__SAA_TEST_ENABLE__) { globalThis.__SAA_TEST__ = { // v1.25.0 暴露设置入口(供测试渲染「数据源授权」pane) openSettings, parseToolCalls, parseSSEStream, createSseStreamParser, readPageContext, formatPageContext, normalizeNativeToolCalls, mergeToolCalls, shouldHoldStream, isNativeToolsRejected, buildReActPrompt, runAgentLoop, callAiChat, isPrivateHost, assertSafeRequestUrl, truncateToolResult, ConversationManager, AgentManager, ModelManager, ToolRegistry, PLUGIN_CACHE_REGISTRY, CONFIG, // v1.29.0 身份层:账号清单缓存(测试可强制刷新) pcHubAccounts, bustAccountsCache: () => { _accountsCache = null; _accountsAt = 0; _accountsBase = ''; }, // v1.30.0 远程数据中枢:配置 / 请求 / 路由白名单(供测试) pcHubConfig, pcSaveHubConfig, pcHubRequest, pcHubPost, pcHubRouteAllowed, pcHubErrorText, pcHubAuthLib, pcHubPing, // v1.30.3 跨域注册镜像(测试:镜像合并/回写) pcReadPluginRegistry, // v1.33.0 版本上报(测试:清单构造 / 开关门禁 / 状态机) pcBuildVersionManifest, pcSendVersionReport, pcOptoutVersionReport, pcReportState, pcReportSaveState, pcReportToday, pcHubResolveEndpointRemote, PC_HUB_DEFAULT_BASE_URL, // v1.34.0 消费/登录链路(测试:登录态探测 / 宿主桥读取) spendLoginState, spendBridgeRead, // v1.36.0 游戏库跨域镜像(测试:镜像读写 / 回退 / 年龄格式化) pcReadLibraryMirror, pcWriteLibraryMirror, pcMaybeWriteLibraryMirror, pcLibraryMirrorFallback, pcHumanAge, pcReadSglvLibrary, pcAutoMirrorLibrary, PC_LIB_MIRROR_KEY, }; } } catch {} if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { setTimeout(init, 100); } })();
')) { // 找到代码块结束位置 result.push(currentLine); i++; while (i < lines.length && !lines[i].includes('
= 0) { const headers = parseMdTableRows([headerLine])[0] || []; const rows = parseMdTableRows(dataLines); // 解析对齐方式 const sepCells = sepLine.split('|').map(c => c.trim()).filter(c => c !== ''); const aligns = sepCells.map(cell => { const left = cell.startsWith(':'); const right = cell.endsWith(':'); if (left && right) return 'center'; if (right) return 'right'; if (left) return 'left'; return ''; }); let tableHtml = ''; // 表头 tableHtml += ''; headers.forEach((h, idx) => { const align = aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ''; tableHtml += `${h}`; }); tableHtml += ''; // 表体 tableHtml += ''; rows.forEach(row => { tableHtml += ''; row.forEach((cell, idx) => { const align = aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ''; tableHtml += `${cell}`; }); tableHtml += ''; }); tableHtml += ''; result.push(tableHtml); i = j; continue; } } result.push(currentLine); i++; } html = result.join('\n'); // 3. 标题 html = html.replace(/^### (.+)$/gm, '$1'); html = html.replace(/^## (.+)$/gm, '$1'); html = html.replace(/^# (.+)$/gm, '$1'); // 4. 粗体、斜体 html = html.replace(/\*\*(.+?)\*\*/g, '$1'); html = html.replace(/\*(.+?)\*/g, '$1'); // 5. 行内代码 html = html.replace(/`([^`]+)`/g, '$1'); // 6. 链接 html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, text, url) => `${text}`); // 7. 引用 html = html.replace(/^> (.+)$/gm, '$1'); // 8. 无序列表 html = html.replace(/^[\-\*] (.+)$/gm, '
$1
[\s\S]*?<\/pre>/g, (m) => { blockHolders.push(m); return `__BLOCK_${blockHolders.length - 1}__`; }); html = html.replace(/\n\n/g, ''); html = html.replace(/\n/g, ''); // 恢复块级元素 html = html.replace(/__BLOCK_(\d+)__/g, (m, idx) => blockHolders[Number(idx)] || ''); if (!html.match(/^<(h\d|pre|ul|ol|blockquote|p|div)/)) 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); } // ============================================================ // 出站请求安全层(防御纵深) // 1. 内网/本机地址拦截(SSRF):工具数据面禁止触达私有网段 // 2. 协议白名单:仅 http(s) // 3. AI 端点防泄漏:工具请求禁止命中用户配置的 AI API 主机,防止工具流量 // 携带上下文发往模型供应商(callAiChat 自身请求不受限,用户显式配置) // ============================================================ function isPrivateHost(hostname) { const h = String(hostname || '').toLowerCase().replace(/\.$/, ''); if (!h) return true; if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.home.arpa')) return true; if (h === '0.0.0.0' || h === '[::1]' || h === '::1') return true; const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (m) { const a = Number(m[1]), b = Number(m[2]); if (a === 0 || a === 10 || a === 127) return true; if (a === 192 && b === 168) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 169 && b === 254) return true; if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT return false; } if (h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80:')) return true; // IPv6 ULA / link-local return false; } function getAiEndpointHosts() { const hosts = new Set(); try { const cfg = getAiConfig(); if (cfg && cfg.apiUrl) { const u = new URL(cfg.apiUrl); if (u.hostname) hosts.add(u.hostname.toLowerCase()); } } catch {} return hosts; } // 校验并解析出站 URL;通过则返回 URL 对象,不通过抛错。allowPrivate/allowAiEndpoint 仅供本地模型连通性测试等显式场景。 function assertSafeRequestUrl(url, opts = {}) { let u; try { u = new URL(String(url)); } catch { throw new Error('无效的请求 URL'); } if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error(`已拦截非 http(s) 协议请求: ${u.protocol}`); if (!opts.allowPrivate && isPrivateHost(u.hostname)) throw new Error(`已拦截对内网/本机地址的请求: ${u.hostname}`); if (!opts.allowAiEndpoint && getAiEndpointHosts().has(u.hostname.toLowerCase())) { throw new Error(`已拦截发往 AI 端点主机的工具请求: ${u.hostname}`); } return u; } function gmFetch(url, options = {}) { assertSafeRequestUrl(url, { allowPrivate: !!options.allowPrivate, allowAiEndpoint: !!options.allowAiEndpoint }); 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) { // withMeta:需要 finalUrl 等元信息(如判断是否被重定向到登录页) resolve(options.withMeta ? resp : 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', ''); } // ===== v1.16.0 联网检索/抓取工具配置 ===== // 端点:默认 DeepSeek 官方 /v1/tools/*;可在设置里改 Base URL 接自建/第三方同协议服务 // Key 解析优先级(严格两级,不做"any key"兜底): // 1) 设置里单独填的「联网工具 Key」(sa_ds_tools_key)——显式覆盖,任何 Base 都适用 // 2) 当前 AI 模型配置的 Key,且其端点确实是 DeepSeek ——"配了 DS 模型就直接复用" // 3) 都没有 → 返回空,由工具返回明确的配置引导 // 刻意不做第 3 种兜底("随便拿个 AI Key 试试"):那会让鉴权失败伪装成"检索无结果", // 且违反"非 DeepSeek Key 不自动挪用"的约定。 function getWebToolsConfig() { const base = String(GM_getValue(SK.DS_TOOLS_BASE, '') || '').trim() || CONFIG.DS_TOOLS_DEFAULT_BASE; let key = String(GM_getValue(SK.DS_TOOLS_KEY, '') || '').trim(); let source = key ? '专用配置' : ''; if (!key) { const ai = getAiConfig(); const host = (() => { try { return new URL(ai.apiUrl || '').hostname.toLowerCase(); } catch { return ''; } })(); if (ai.apiKey && isDeepSeekHost(host)) { key = String(ai.apiKey).trim(); source = 'AI 模型配置(DeepSeek)'; } } return { base: base.replace(/\/+$/, ''), key, source }; } function isDeepSeekHost(hostname) { return /(^|\.)deepseek\.com$/i.test(String(hostname || '')); } // ITAD Key:本脚本存储优先,未配置时复用 SGLV 的 Key(跨脚本共享) function getItadApiKey() { return GM_getValue(SK.ITAD_KEY, '') || GM_getValue('sglv_itad_api_key', ''); } function isSSEResponse(text) { return !!(text && /^\s*data:/i.test(text)); } // 把 OpenAI 兼容的 tool_calls 片段收进 bucket。delta 模式拼接字符串,full 模式整段覆盖。 function absorbToolCallPiece(bucket, tc, mode) { if (!tc || typeof tc !== 'object') return; const idx = Number.isInteger(tc.index) ? tc.index : bucket.size; const cur = bucket.get(idx) || { id: '', name: '', arguments: '' }; const fn = tc.function && typeof tc.function === 'object' ? tc.function : {}; const name = fn.name || tc.name || ''; const args = fn.arguments != null ? fn.arguments : tc.arguments; if (tc.id) cur.id = mode === 'delta' ? cur.id + tc.id : String(tc.id); if (name) cur.name = mode === 'delta' ? cur.name + name : String(name); if (args != null) { if (typeof args === 'object') cur.arguments = args; else if (mode === 'delta' && typeof cur.arguments === 'string') cur.arguments += args; else cur.arguments = args; } bucket.set(idx, cur); } // 增量 SSE 解析工厂:内部维护跨 chunk 缓冲,按空行切事件块,半截 JSON 留在缓冲等下一个 chunk, // 替代旧"每次 onprogress 对全量 responseText 重解析"的 O(n²) 模式。tool_calls 增量按 index 槽位拼接。 function createSseStreamParser() { let buf = ''; let content = ''; let reasoning = ''; let usage = null; let finished = false; let sawFullToolMessage = false; const toolBucket = new Map(); const processBlock = (block) => { for (const line of block.split('\n')) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; const dataStr = trimmed.slice(5).trim(); if (!dataStr) continue; if (dataStr === '[DONE]') { finished = true; continue; } try { const chunk = JSON.parse(dataStr); const choice = chunk.choices?.[0] || {}; const delta = choice.delta || {}; if (delta.content) content += delta.content; // 推理模型(DeepSeek R/v4、GLM-think、QwQ 等)的思维链单独分流, // 不混入正文;content 为空时可据此识别"仅推理"响应 if (delta.reasoning_content) reasoning += delta.reasoning_content; if (chunk.usage && (chunk.usage.prompt_tokens || chunk.usage.completion_tokens || chunk.usage.total_tokens)) { usage = chunk.usage; } const fullCalls = choice.message?.tool_calls; if (Array.isArray(fullCalls) && fullCalls.length) { toolBucket.clear(); sawFullToolMessage = true; fullCalls.forEach((tc, i) => absorbToolCallPiece(toolBucket, { ...tc, index: tc.index ?? i }, 'full')); } if (!sawFullToolMessage && Array.isArray(delta.tool_calls)) { delta.tool_calls.forEach(tc => absorbToolCallPiece(toolBucket, tc, 'delta')); } } catch {} } }; return { feed(text) { if (finished || !text) return; buf += text; let idx; while ((idx = buf.indexOf('\n\n')) !== -1) { const block = buf.slice(0, idx); buf = buf.slice(idx + 2); processBlock(block); } }, // 仅终态调用:把无空行结尾的残余块消化掉;流式过程中勿调(会破坏跨 chunk 边界) flushTail() { if (!buf) return; const block = buf; buf = ''; processBlock(block); }, isFinished: () => finished, snapshot: () => ({ content, reasoning, usage, toolCalls: normalizeNativeToolCalls([...toolBucket.values()]), finished }), }; } // 整段文本一次性解析(终态路径 + 兼容旧调用方/测试) function parseSSEStream(text) { const parser = createSseStreamParser(); parser.feed(String(text || '')); parser.flushTail(); const { content, reasoning, usage, toolCalls } = parser.snapshot(); return { content, reasoning, usage, toolCalls }; } 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 || '', }; }, }; // 返回 { content, toolCalls, streamed }。无 tools 且正文为空时仍按原逻辑抛错,避免提示词生成等调用方收到空串。 async function callAiChat(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') { // 防御性清洗:本脚本走 ReAct 单 prompt 协议,从不向 API 发送 tool 角色消息; // 历史只回传纯文本并剥离内嵌工具标记,杜绝孤儿 tool_calls / 残留标记触发协议 400 messages.push({ role: m.role, content: m.role === 'assistant' ? cleanToolCallMarkers(String(m.content || '')) : String(m.content || '') }); } } } messages.push({ role: 'user', content: prompt }); // isolated: 独立请求(如智能体提示词生成),不注册 activeAiRequest、不受聊天停止按钮影响 const isolated = !!opts.isolated; const aborted = () => !isolated && abortFlag; const useStream = !!opts.onChunk; const nativeTools = Array.isArray(opts.tools) && opts.tools.length ? opts.tools : null; let streamedContent = ''; let gotStream = false; let reqRef = null; // 增量流解析状态:onprogress 只把新增字节喂给 parser,终态 onload 复用同一实例 let sseParser = null; let sseConsumed = 0; const feedSseParser = (text, isFinal) => { if (!sseParser) sseParser = createSseStreamParser(); if (text.length > sseConsumed) { sseParser.feed(text.slice(sseConsumed)); sseConsumed = text.length; } if (isFinal) sseParser.flushTail(); return sseParser.snapshot(); }; const reqBody = { model: cfg.model, messages, temperature: opts.temperature ?? 0.7, stream: useStream, }; if (nativeTools) { reqBody.tools = nativeTools; reqBody.tool_choice = opts.toolChoice || 'auto'; } // 仅在显式传入正数时才发送 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') + (nativeTools ? JSON.stringify(nativeTools) : '')); 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 finish = (content, toolCalls, extraUsage) => { const calls = Array.isArray(toolCalls) ? toolCalls : []; recordTokenUsage(extraUsage, content || calls.map(c => c.name).join(',')); return { content: content || '', toolCalls: calls, streamed: gotStream }; }; const p = new Promise((resolve, reject) => { // 推理模型偶发"整轮流式只产出 reasoning_content,正文与工具调用皆空"(finish_reason=stop)。 // 策略:识别后用完全相同的请求体透明重试一次;重试仍为空则报明确的 reasoning 错误。 let reqRetried = false; const resetSseState = () => { sseParser = null; sseConsumed = 0; streamedContent = ''; gotStream = false; }; const xhrParams = { 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 { content: newContent } = feedSseParser(text, false); if (newContent && newContent !== streamedContent) { streamedContent = newContent; gotStream = true; 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, reasoning, usage, toolCalls } = feedSseParser(text, true); const cleaned = stripThinkTags(content); const calls = toolCalls || []; if (!cleaned && !calls.length) { if (!reqRetried && !aborted()) { reqRetried = true; resetSseState(); console.warn('[SA-Agent] 模型仅返回推理内容(reasoning)无正文/工具调用,透明重试一次'); reqRef = GM_xmlhttpRequest(xhrParams); if (!isolated) activeAiRequest = reqRef; return; } if (reasoning) { const err = new Error('模型仅返回了推理内容(reasoning)未产出正文,请重试或更换为非推理模型'); err.isReasoningOnly = true; reject(err); return; } reject(new Error('AI 返回为空')); return; } if (useStream && opts.onChunk && content && content !== streamedContent) { gotStream = true; opts.onChunk(content); } resolve(finish(cleaned, calls, usage)); return; } const data = JSON.parse(text); const choice = data.choices?.[0]; const msg = choice?.message; const content = stripThinkTags(msg?.content || ''); const calls = normalizeNativeToolCalls(msg?.tool_calls); if (!content && !calls.length) { if (msg?.reasoning_content) { if (!reqRetried && !aborted()) { reqRetried = true; console.warn('[SA-Agent] 模型仅返回推理内容(reasoning)无正文/工具调用,透明重试一次'); reqRef = GM_xmlhttpRequest(xhrParams); if (!isolated) activeAiRequest = reqRef; return; } const err = new Error('模型仅返回了推理内容(reasoning)未产出正文,请重试或更换为非推理模型'); err.isReasoningOnly = true; reject(err); return; } if (choice?.finish_reason === 'length') { reject(new Error('AI 输出被 max_tokens 截断,请重试或调大 token 上限')); return; } reject(new Error('AI 返回为空')); return; } resolve(finish(content, calls, data.usage)); } catch (e) { reject(new Error('AI 响应解析失败: ' + e.message)); } }, onerror: () => { if (aborted()) { if (useStream && streamedContent) resolve(finish(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 请求超时')); }, }; reqRef = GM_xmlhttpRequest(xhrParams); if (!isolated) activeAiRequest = reqRef; }); return p.finally(() => { if (!isolated && activeAiRequest === reqRef) activeAiRequest = null; }); } async function callAiApi(prompt, opts = {}) { const result = await callAiChat(prompt, opts); return result.content; } 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; } try { // 远程地址用户可自定义,同样走出站安全断言(仅拦内网与非 http(s) 协议) assertSafeRequestUrl(url); } catch (e) { reject(e); 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: '查评价、折扣和购买建议;也能读取本机其他 Steam 脚本的缓存,并核对插件是否装齐、版本是否过旧', icon: 'gamepad', keywords: ['steam', '游戏', '评价', '推荐', '打折', '分析', '购买'], builtin: true, systemPrompt: `你是 Steam 游戏助手,精通 Steam 平台的游戏评价、玩家数据分析和购买建议。 你的能力: 1. 游戏评价:基于 Steam Store 数据(好评率、价格、类型)给出客观评价 2. 玩家数据分析:分析玩家的游戏库、游玩时长、消费习惯 3. 购买建议:根据用户偏好和预算推荐游戏 4. 打折分析:判断当前折扣是否值得入手 5. 借用其他插件的本地缓存:游戏库时长、好友、家庭库、徽章、市场、许可来源、点数和消费汇总,优先调用 readPluginCache,不要先去打 Steam 接口 6. 插件安装核对:用户问装了哪些脚本、有没有装齐、版本是否过旧时,调用 getInstalledPlugins,按工具返回的「已装 / 未装 / 无法判断」口径回答,不要把无法判断说成未安装 回答规范: - 使用 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, // 工具子集授权:null/空 = 可用全部工具(未来新增工具默认可用) tools: (Array.isArray(data.tools) && data.tools.length) ? data.tools.filter(n => typeof n === 'string') : 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 }; // merge 后归一化 tools:非数组/空数组统一为 null(= 全部工具),确保"改回全部"能覆盖旧子集 if (!Array.isArray(agents[idx].tools) || !agents[idx].tools.length) agents[idx].tools = null; 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); } }, }; // ============================================================ // 工具注册表 // ============================================================ // 工具结果长度治理:超大结果(如全量游戏库)在回灌模型前截断, // 数组保留尽可能多的条目并附截断说明,防止撑爆上下文窗口。 function truncateToolResult(result) { if (result == null || typeof result !== 'object') return result; const max = CONFIG.TOOL_RESULT_MAX_CHARS; let json; try { json = JSON.stringify(result); } catch { return { success: false, error: '工具结果序列化失败' }; } if (json.length <= max) return result; const clipped = { ...result }; if (Array.isArray(result.data)) { // 数组:贪心保留条目,预留 600 字符给其余字段与截断说明 const kept = []; let used = 0; for (const item of result.data) { let len; try { len = JSON.stringify(item ?? null).length + 1; } catch { break; } if (used + len > max - 600) break; kept.push(item); used += len; } clipped.data = kept; clipped.truncated = true; clipped.originalCount = result.data.length; clipped.note = `结果共 ${result.data.length} 条,因长度限制仅保留前 ${kept.length} 条,请缩小查询范围(减少数量/增加过滤条件)后重试`; } else if (result.data != null) { clipped.data = truncateMiddle(typeof result.data === 'string' ? result.data : JSON.stringify(result.data), Math.max(500, max - 600)); clipped.truncated = true; clipped.note = `结果过长(${json.length} 字符)已截断,请缩小查询范围后重试`; } else { // 无 data 字段的杂型结果:整体截断为文本 return { success: result.success !== false, truncated: true, error: `结果过长(${json.length} 字符)已截断`, data: truncateMiddle(json, max), }; } return clipped; } 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()); }, // 工具可用性门:`enabled` 是可选谓词,false = 该工具在当前环境不可用 //(如 searchLocalDb 需要已配置远程数据中枢地址)。不可用的工具**不进 LLM 工具面**, // 避免把注定失败的工具喂给模型,再让它给用户一条走不通的引导。 _usable(t) { if (typeof t.enabled !== 'function') return true; try { return !!t.enabled(); } catch (e) { return true; } // 判定异常按可用处理(维持旧行为) }, // 过滤顺序:① 按智能体授权的工具名列表过滤(未传/为空 = 全部) // ② 过滤后为空(配置异常)回退全部 // ③ 按 enabled 裁掉当前不可用的工具(裁完可为空 = 真没工具, // 由 runAgentLoop 的 `nativeTools = openaiTools.length > 0` 自然降级文本协议) pickTools(toolNames) { const all = this.getAll(); let base = all; if (Array.isArray(toolNames) && toolNames.length) { const set = new Set(toolNames); const filtered = all.filter(t => set.has(t.name)); base = filtered.length ? filtered : all; } return base.filter(t => this._usable(t)); }, getToolsDescription(toolNames) { const tools = this.pickTools(toolNames); if (!tools.length) return '(无可用工具)'; return tools.map(t => `【${t.name}】${t.description}\n 参数: ${JSON.stringify(t.parameters.properties)}` ).join('\n\n'); }, toOpenAITools(toolNames) { return this.pickTools(toolNames).map(t => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters || { type: 'object', properties: {} }, }, })); }, async execute(name, args) { const tool = this.get(name); if (!tool) return { success: false, error: `工具 "${name}" 不存在` }; try { const result = await Promise.race([ tool.execute(args), new Promise((_, reject) => setTimeout(() => reject(new Error('工具执行超时')), CONFIG.TOOL_TIMEOUT) ), ]); return truncateToolResult(result); } 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 时自动使用当前登录用户;也可查询他人的公开愿望单。返回游戏名、折扣百分比、原价、现价、愿望单优先级。数据源:IWishlistService + IStoreBrowseService(2026-07 改版后的现行接口,单次全量无分页截断)', 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 } = 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)); try { // 1. 归一 SteamID64:17 位直用;自定义 ID 优先 ResolveVanityURL(需 Key),失败回退 community 愿望单页 HTML 提取 let id64 = steamId; if (!/^\d{17}$/.test(steamId)) { id64 = null; const apiKey = getSteamApiKey(); if (apiKey) { try { id64 = await resolveVanityToId64(steamId, apiKey); } catch {} } if (!id64) { try { const html = await gmFetch(`https://steamcommunity.com/id/${encodeURIComponent(steamId)}/wishlist`, { timeout: 15000 }); const m = String(html).match(/"steamid":"(7656\d{13})"/) || String(html).match(/g_steamID\s*=\s*"(7656\d{13})"/); if (m) id64 = m[1]; } catch {} } if (!id64) return { success: false, error: `自定义 ID "${steamId}" 未解析到 SteamID64(可能不存在)` }; } // 2. 全量愿望单:IWishlistService 匿名可用、单次返回全部(旧 wishlistdata 分页端点已于 2026-07 下线,302 到商店首页) const wlResp = await gmFetch(`https://api.steampowered.com/IWishlistService/GetWishlist/v1/?steamid=${id64}`, { timeout: 20000 }); const wlData = JSON.parse(wlResp); const wlItems = Array.isArray(wlData.response?.items) ? wlData.response.items : []; if (!wlItems.length) { return { success: true, data: { steamId: id64, totalInWishlist: 0, games: [], note: '愿望单为空,或为私密状态(两者在 API 层面不可区分;若预期有内容请确认 Steam 隐私设置中「游戏详情」为公开)' }, summary: '愿望单为空或私密', }; } // 3. IStoreBrowseService/GetItems 批量富集(100 appid/批 × 3 路并发)拿名称/价格/折扣;个别批次失败跳过 const MAX_ENRICH = 3000; const ids = wlItems.slice(0, MAX_ENRICH).map(x => ({ appid: x.appid })); const batches = []; for (let i = 0; i < ids.length; i += 100) batches.push(ids.slice(i, i + 100)); const storeItems = new Map(); let failedBatches = 0; await mapLimit(batches, 3, async (batch) => { const inputJson = JSON.stringify({ ids: batch, context: { language: 'schinese', country_code: getStoreCountryCode(), steam_realm: 1 }, data_request: { include_release: true, include_all_purchase_options: true }, }); try { const resp = await gmFetch(`https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=${encodeURIComponent(inputJson)}`, { timeout: 20000 }); const data = JSON.parse(resp); for (const it of (data.response?.store_items || [])) storeItems.set(it.appid, it); } catch { failedBatches++; } }); // 4. 归一化:折扣 = discount_pct > 0,或 active_discounts 非空(促销中但未披露百分比) const prioById = new Map(wlItems.map(x => [x.appid, Number(x.priority) || 0])); const normalized = wlItems.map(wl => { const it = storeItems.get(wl.appid); const bpo = it?.best_purchase_option || {}; const discount = Number(bpo.discount_pct ?? 0) || 0; const hasActive = Array.isArray(bpo.active_discounts) && bpo.active_discounts.length > 0; const finalCents = Number(bpo.final_price_in_cents ?? -1); const originalCents = Number(bpo.original_price_in_cents ?? -1); return { appid: wl.appid, name: it?.name || `AppID ${wl.appid}`, discount, promoWithoutPct: hasActive && discount === 0, finalPrice: (it?.is_free || finalCents === 0) ? '免费' : (bpo.formatted_final_price || (finalCents > 0 ? formatCents(finalCents, 'CNY') : '')), originalPrice: originalCents > 0 ? formatCents(originalCents, 'CNY') : (discount > 0 && finalCents > 0 ? formatCents(Math.round(finalCents / (1 - discount / 100)), 'CNY') : ''), priority: prioById.get(wl.appid) || 0, comingSoon: !!(it?.is_coming_soon || it?.release?.is_coming_soon), url: `https://store.steampowered.com/app/${wl.appid}`, }; }); const discounted = normalized .filter(g => g.discount > 0 || g.promoWithoutPct) .sort((a, b) => b.discount - a.discount || a.priority - b.priority); const list = (onlyDiscounted ? discounted : normalized).slice(0, limit); const data = { steamId: id64, totalInWishlist: normalized.length, discountedCount: discounted.length, games: list, }; if (failedBatches > 0) data.note = `${failedBatches} 个价格批次获取失败,部分条目可能缺价格信息`; if (wlItems.length > MAX_ENRICH) 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}%),返回 ${list.length} 款` }; } 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 {} } // v1.16.0 联网检索工具连通性检测:真打一次 search,拿到结果才算通 async function testWebToolsConnection() { const cfg = getWebToolsConfig(); if (!cfg.key) return { success: false, error: '未配置 Key:请填入 DeepSeek Key,或在「AI 设置」中把模型配成 DeepSeek 以自动复用' }; const r = await callWebTools('search', { query: 'Steam' }); if (!r.ok) return { success: false, error: r.error }; const n = Array.isArray(r.data && r.data.results) ? r.data.results.length : 0; return n > 0 ? { success: true, note: `检索连通正常,返回 ${n} 条结果(接口 ${cfg.base},Key 来源「${cfg.source}」)` } : { success: false, error: '接口可达但未返回任何结果,请确认该服务是否实现了 /search 端点' }; } 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 }; } } // 从 URL 中提取主机名(含端口),用于诊断提示 function extractHostFromUrl(url) { try { const u = new URL(url); return u.hostname + (u.port ? ':' + u.port : ''); } catch { return ''; } } // 判断 URL 是否指向 OpenAI 兼容的 chat/completions 端点 function looksLikeChatCompletions(url) { return /\/v\d*\/chat\/completions/i.test(url) || /\/chat\/completions/i.test(url); } // 诊断连接错误:把原始异常 + 用户填写的 URL 一起分析,返回多行诊断文案 function diagnoseConnectError(url, err) { const msg = String(err?.message || ''); const host = extractHostFromUrl(url); const lines = []; const httpMatch = msg.match(/HTTP (\d+)/); if (httpMatch) { const code = httpMatch[1]; if (code === '401' || code === '403') { lines.push('API Key 无效或无权限 (鉴权失败)'); } else if (code === '404') { lines.push('API 地址返回 404,请检查端点路径'); if (!looksLikeChatCompletions(url)) { lines.push('提示:地址未包含 /chat/completions,可能不是 OpenAI 兼容端点'); } } else if (code === '429') { lines.push('请求频率超限 (429),请稍后重试'); } else if (code === '400' || code === '422') { lines.push('请求被拒绝 (多为模型名称不正确或参数格式错误)'); } else if (code === '500' || code === '502' || code === '503') { lines.push(`服务端异常 (HTTP ${code}),目标 API 可能正在维护或过载`); } else { lines.push(`HTTP ${code}: ${msg.slice(0, 150)}`); } } else if (msg.includes('超时')) { lines.push('连接超时:网络不可达或响应太慢'); if (host) lines.push(`目标主机: ${host}`); } else if (msg.includes('网络错误')) { lines.push('请求被脚本管理器拦截(onerror)'); if (host) lines.push(`目标主机: ${host}`); lines.push('可能原因:域名未在 @connect 白名单中,或目标服务不可达'); lines.push('已添加 @connect * 后仍失败 → 检查 URL 拼写、网络连通性、目标服务是否运行'); } else if (msg.startsWith('Unexpected token') || msg.includes('JSON.parse')) { lines.push('响应体不是有效 JSON(收到 HTML 页面)'); if (host) lines.push(`目标主机: ${host}`); lines.push('可能原因:API 地址错误,返回了登录页/错误页/404 HTML'); if (!looksLikeChatCompletions(url)) { lines.push('提示:地址未包含 /chat/completions,请确认端点路径'); } } else { lines.push(msg || '未知错误'); } return lines.join('\n'); } // 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(); let resp; try { 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, // 用户显式配置的模型端点:允许本地模型(Ollama/LM Studio 等)与端点自身 allowPrivate: true, allowAiEndpoint: true, }); } catch (e) { e._diagnoseUrl = model.apiUrl; throw e; } let data; try { data = JSON.parse(resp); } catch { const preview = String(resp).slice(0, 200); const err = new Error(`Unexpected token '<', "${preview}" is not valid JSON`); err._diagnoseUrl = model.apiUrl; throw err; } 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 请求异常翻译为用户可读的诊断文案(优先使用 diagnoseConnectError 多行诊断) function describeModelError(e) { const msg = String(e?.message || ''); const url = e?._diagnoseUrl || ''; if (url) return diagnoseConnectError(url, e); 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 }; }, }); // ============================================================ // v1.12.0 插件缓存桥接(plugin-cache-bridge)阶段 1 // 读取本机其他 Steam 油猴插件写入的 IndexedDB / localStorage 缓存。 // // 设计要点(依据 docs/plugin-cache-bridge-plan.md): // 1. GM_setValue/GM_getValue 按脚本沙箱隔离,任何脚本都读不到其他脚本的 GM 存储, // 所以跨脚本复用的只有 IndexedDB 与 localStorage(同源共享)。 // 2. 全程只读:indexedDB.open(name) 一律不带版本号(避免触发 upgradeneeded 改库), // localStorage 只 getItem;不写、不删任何非本脚本的存储。 // 3. 每个缓存键自带 digest 函数做摘要裁剪,MB 级键(如 sfd_og_)禁止整读。 // 4. 域感知:store 与 community 的缓存互不可见,不匹配时明确提示而非静默返回空。 // ============================================================ const PC_DOMAIN_STORE = 'store'; const PC_DOMAIN_COMMUNITY = 'community'; // 当前页面所属域:store.steampowered.com → store;steamcommunity.com → community function pcCurrentDomain() { const h = String(location.hostname || ''); if (h === 'store.steampowered.com' || h.endsWith('.steampowered.com')) return PC_DOMAIN_STORE; if (h === 'steamcommunity.com' || h.endsWith('.steamcommunity.com')) return PC_DOMAIN_COMMUNITY; return ''; } // 把宿主插件的时间戳折算成"多少分钟前",用于提示模型声明数据新鲜度 function pcCacheAgeMinutes(value) { if (value == null || typeof value !== 'object') return null; const candidates = [value.updatedAt, value.ts, value.timestamp, value._ts, value.expires, value.cachedAt]; for (const c of candidates) { const n = Number(c); if (Number.isFinite(n) && n > 0) { const ms = n < 1e12 ? n * 1000 : n; // 兼容秒级时间戳 const diff = Date.now() - ms; if (diff < 0) continue; return Math.round(diff / 60000); } } return null; } // ---- 只读 IndexedDB 访问 ------------------------------------------------- // 不带版本号打开:若库不存在则 onsuccess 拿到空库,getAllKeys 抛错由调用方转成"缓存不存在"。 function pcIdbOpen(dbName) { return new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined' || !indexedDB) { reject(new Error('当前环境不支持 IndexedDB')); return; } let req; try { req = indexedDB.open(dbName); } catch (e) { reject(e); return; } req.onerror = () => reject(req.error || new Error('IndexedDB 打开失败')); // 刻意不设置 onupgradeneeded:不带版本号打开时不会触发升级 req.onsuccess = () => resolve(req.result); }); } function pcIdbGetAllKeys(db, storeName) { return new Promise((resolve, reject) => { try { if (!db.objectStoreNames.contains(storeName)) { resolve([]); return; } const tx = db.transaction(storeName, 'readonly'); const req = tx.objectStore(storeName).getAllKeys(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); } catch (e) { reject(e); } }); } function pcIdbGet(db, storeName, key) { return new Promise((resolve, reject) => { try { const tx = db.transaction(storeName, 'readonly'); const req = tx.objectStore(storeName).get(key); req.onsuccess = () => resolve(req.result === undefined ? null : req.result); req.onerror = () => reject(req.error); } catch (e) { reject(e); } }); } function pcIdbGetAll(db, storeName) { return new Promise((resolve, reject) => { try { if (!db.objectStoreNames.contains(storeName)) { resolve([]); return; } const tx = db.transaction(storeName, 'readonly'); const req = tx.objectStore(storeName).getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); } catch (e) { reject(e); } }); } function pcLocalGet(key) { try { const raw = localStorage.getItem(key); if (raw == null) return null; try { return JSON.parse(raw); } catch { return raw; } } catch { return null; } } // localStorage 前缀枚举(key() 逐个遍历,SME 的 sme_cache_ 有 LRU 数量有限) function pcLocalKeysWithPrefix(prefix) { const out = []; try { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(prefix)) out.push(k); } } catch {} return out; } // ---- IDB 存储信封解包(v1.14.0)------------------------------------------- // 部分宿主(如 SBC 徽章增强)写入 IDB 时套了一层 TTL 信封: // IDB.set → { key, data: <载荷>, updatedAt, expiresAt? } // IDB.get → return record.data ← 宿主自己脱掉信封 // 而本工具是用原生 objectStore.get() 只读,拿到的必然是**完整信封**。 // 若不剥掉,digest 收到的形状全对不上 → 全部 drifted。 // // 为什么用 envelope:true 显式声明,而不是按形状猜: // FA 的 fa_value_insights_cache 本身就是裸值但带 updatedAt 字段, // 靠猜必然误判。只有宿主自己知道它套没套信封。 function pcUnwrapEnvelope(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { payload: value, updatedAt: null, expiresAt: null, expired: false }; } // 信封判据:同时具备 data 载荷槽 + 至少一个宿主写入时才会带的元字段 const looksLikeEnvelope = Object.prototype.hasOwnProperty.call(value, 'data') && ('updatedAt' in value || 'expiresAt' in value || 'key' in value); if (!looksLikeEnvelope) { return { payload: value, updatedAt: null, expiresAt: null, expired: false }; } const expiresAt = Number(value.expiresAt) || 0; return { payload: value.data, updatedAt: Number(value.updatedAt) || 0, expiresAt, expired: expiresAt > 0 && Date.now() > expiresAt, }; } // 解析某键实际使用的 objectStore:优先 per-key 声明,回退插件级(向后兼容) function pcStoreOf(reg, spec) { return (spec && spec.store) || (reg.idb && reg.idb.store) || null; } // ---- digest 摘要函数 ----------------------------------------------------- // 每个 digest 接收完整值 + {topN, fields},返回裁剪后的摘要。 // 硬约束:输出必须显著小于 CONFIG.TOOL_RESULT_MAX_CHARS,MB 级键只出摘要不出明细。 function pcPick(obj, fields) { if (!fields || !Array.isArray(fields) || !fields.length || obj == null || typeof obj !== 'object') return obj; const out = {}; let hit = 0; for (const f of fields) { if (Object.prototype.hasOwnProperty.call(obj, f)) { out[f] = obj[f]; hit++; } } return hit ? out : obj; } // 通用数组摘要:总条数 + 前 topN 条(可字段裁剪) function pcGenericArrayDigest(value, ctx) { const arr = Array.isArray(value) ? value : null; if (!arr) { // 非数组:当作对象/标量,给出键列表概览 if (value && typeof value === 'object') { const keys = Object.keys(value); return { kind: 'object', keyCount: keys.length, keysSample: keys.slice(0, 30), hint: '非数组结构,如需明细请用 inspect 模式查看结构', }; } return { kind: 'scalar', value }; } const fields = (ctx.fields || '').split(',').map(s => s.trim()).filter(Boolean); return { kind: 'array', total: arr.length, items: arr.slice(0, ctx.topN).map(x => pcPick(x, fields)), }; } // 游戏库摘要:Steam OwnedGames 结构 {appid, name, playtime_forever(分钟), rtime_last_played} function pcGamesDigest(value, ctx) { const arr = Array.isArray(value) ? value : (value && Array.isArray(value.games) ? value.games : null); if (!arr) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const totalMinutes = arr.reduce((s, g) => s + (Number(g && g.playtime_forever) || 0), 0); const sorted = arr.slice().sort((a, b) => (Number(b && b.playtime_forever) || 0) - (Number(a && a.playtime_forever) || 0)); return { kind: 'ownedGames', totalGames: arr.length, totalPlaytimeHours: Math.round(totalMinutes / 6) / 10, topGames: sorted.slice(0, ctx.topN).map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtimeHours: Math.round((Number(g.playtime_forever) || 0) / 6) / 10, lastPlayed: g.rtime_last_played || 0, })), note: '按游玩时长降序返回 topN;如需某款具体游戏,可先取 topGames 再用 getGameDetail 补详情', }; } // 逐好友全库摘要(MB 级):只出总数/总时长/topN,禁止整读 function pcOwnedGamesDigest(value, ctx) { if (!value || typeof value !== 'object' || !Array.isArray(value.games)) { return { drifted: true, kind: 'unknown', sample: pcGenericArrayDigest(value, { topN: 3, fields: '' }) }; } const games = value.games; const sorted = games.slice().sort((a, b) => (Number(b && b.playtime_forever) || 0) - (Number(a && a.playtime_forever) || 0)); return { kind: 'ownedGamesFull', steamId: value.sid || null, gameCount: value.game_count ?? games.length, totalMinutes: Number(value.totalMinutes) || games.reduce((s, g) => s + (Number(g && g.playtime_forever) || 0), 0), isPrivate: value.private === true, cachedAt: value.ts || null, topGames: sorted.slice(0, Math.min(ctx.topN, 50)).map(g => ({ appid: g.appid, name: g.name || `App ${g.appid}`, playtimeHours: Math.round((Number(g.playtime_forever) || 0) / 6) / 10, })), note: 'MB 级缓存仅返回摘要与 topN,未整读明细', }; } // 好友列表摘要 function pcFriendsDigest(value, ctx) { const arr = Array.isArray(value) ? value : (value && Array.isArray(value.friends) ? value.friends : null); if (!arr) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; return { kind: 'friends', totalFriends: arr.length, friends: arr.slice(0, ctx.topN).map(f => ({ steamId: f.steamid || f.steamId || null, name: f.personaname || f.name || null, personaname: f.personaname || f.name || null, friendSince: f.friend_since || null, avatar: f.avatarfull || f.avatarmedium || null, })), }; } // 家庭共享库摘要:{steamid, familyInfo{family_member[]}, familyGameList[], timestamp} function pcFamilyDigest(value, ctx) { if (!value || typeof value !== 'object') return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const fi = value.familyInfo || {}; const members = Array.isArray(fi.family_member) ? fi.family_member : []; const gameList = Array.isArray(value.familyGameList) ? value.familyGameList : []; const gameInfo = value.familyGameList && !Array.isArray(value.familyGameList) && typeof value.familyGameList === 'object' ? value.familyGameList : null; const detail = gameInfo ? Object.entries(gameInfo) : gameList.slice(0, ctx.topN).map(g => [g && g.appid, g]); return { kind: 'familySharedLibrary', familyGroupId: fi.family_groupid || null, memberCount: members.length, members: members.slice(0, ctx.topN).map(m => ({ steamId: m.steamid, name: m.userName || null, role: m.family_role || null })), sharedGameCount: detail.length, sharedGames: detail.slice(0, ctx.topN).map(([appid, g]) => ({ appid, name: (g && g.name) || `App ${appid}`, owners: g && Array.isArray(g.owners) ? g.owners.length : null, })), cachedAt: value.timestamp || null, }; } // 家庭价值洞察摘要:{priceData:{appid:[fp,op,disc,isFree,isComingSoon]}, playtimeData:{appid:[total,recent]}, updatedAt, ts} function pcValueInsightsDigest(value, ctx) { if (!value || typeof value !== 'object' || !value.priceData) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const price = value.priceData || {}; const pt = value.playtimeData || {}; const appids = Array.from(new Set([...Object.keys(price), ...Object.keys(pt)])); const rows = appids.map(appid => { const p = price[appid]; const t = pt[appid]; return { appid, finalPrice: Array.isArray(p) ? (Number(p[0]) || 0) / 100 : null, discountPct: Array.isArray(p) ? (Number(p[2]) || 0) : null, isFree: Array.isArray(p) ? !!p[3] : null, totalHours: Array.isArray(t) ? Math.round((Number(t[0]) || 0) / 6) / 10 : null, }; }); rows.sort((a, b) => (b.totalHours || 0) - (a.totalHours || 0)); return { kind: 'familyValueInsights', totalApps: appids.length, topByPlaytime: rows.slice(0, ctx.topN), updatedAt: value.updatedAt || value.ts || null, note: '价格单位为元(源数据为分),时长为小时', }; } // 许可指纹摘要:SLC 的 "日期||游戏名||类型" 字符串数组 function pcLicenseDigest(value, ctx) { const arr = Array.isArray(value) ? value : null; if (!arr) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const parsed = arr.map(s => { const parts = String(s).split('||'); return { date: parts[0] || '', name: parts[1] || '', type: parts[2] || '' }; }); const byType = {}; for (const p of parsed) byType[p.type || '未知'] = (byType[p.type || '未知'] || 0) + 1; const sorted = parsed.slice().sort((a, b) => String(b.date).localeCompare(String(a.date))); return { kind: 'licenseFingerprints', total: parsed.length, byType, licenses: sorted.slice(0, ctx.topN), note: '来源为 Steam 许可页本地缓存(7 天 TTL);数字键 slc_license_total 为总记录数', }; } // 标量摘要 function pcScalarDigest(value) { return { kind: 'scalar', value }; } // 市场缓存摘要:SME 的 {ts, data} 信封 function pcMarketCacheDigest(value, ctx) { if (!value || typeof value !== 'object' || !('ts' in value) || !('data' in value)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const inner = value.data; const summary = { kind: 'marketCache', cachedAt: value.ts, dataShape: Array.isArray(inner) ? `array(${inner.length})` : typeof inner }; if (Array.isArray(inner)) { summary.sample = inner.slice(0, ctx.topN); summary.total = inner.length; } else if (inner && typeof inner === 'object') { const keys = Object.keys(inner); summary.keys = keys.slice(0, 20); summary.keyCount = keys.length; } return summary; } // 库存摘要:SME 的 { [key]: {ts, data: [[k,v],...]} } function pcInventoryDigest(value, ctx) { if (!value || typeof value !== 'object') return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const groups = Object.keys(value); const rows = groups.slice(0, Math.min(ctx.topN, 10)).map(k => { const g = value[k]; const arr = g && Array.isArray(g.data) ? g.data : []; return { group: k, cachedAt: g && g.ts, itemCount: arr.length }; }); return { kind: 'inventory', groupCount: groups.length, groups: rows, note: '库存明细体积大,仅返回分组与条目数' }; } // 徽章增强(SBC)好友排行榜摘要。 // 载荷 {rows:[{steamId,badgeCount,level,xp,name,avatar,pointsItems}], fetchedAt} // badgeCount = -1 表示该好友资料私密(GetBadges 403),宿主会把这类沉底 // pointsItems= null 未抓取 / -1 私密 / 数字为点数商店装扮数(v1.10.0 新增维度) // 排序语义对齐宿主 sortFriendsLbRows:私密永远沉底,其余按指定维度降序。 const PC_LB_DIMENSIONS = { badge: { field: 'badgeCount', label: '徽章数' }, level: { field: 'level', label: '徽章等级' }, xp: { field: 'xp', label: '经验值' }, points: { field: 'pointsItems', label: '点数商店装扮数' }, }; function pcFriendsLbDigest(value, ctx) { if (!value || typeof value !== 'object' || !Array.isArray(value.rows)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const rows = value.rows; // 用户可用 fields 传 'level' / 'xp' / 'points' 指定排序维度(缺省按徽章数) const dimKey = String(ctx.fields || 'badge').trim(); const dim = PC_LB_DIMENSIONS[dimKey] || PC_LB_DIMENSIONS.badge; const val = (r) => { const v = r[dim.field]; if (dim.field === 'pointsItems' && v == null) return -2; // 未抓取沉底(对齐宿主) return Number(v) || 0; }; const sorted = rows.slice().sort((a, b) => { const ap = Number(a.badgeCount) < 0, bp = Number(b.badgeCount) < 0; if (ap !== bp) return ap ? 1 : -1; // 私密沉底 return (val(b) - val(a)) || ((Number(b.badgeCount) || 0) - (Number(a.badgeCount) || 0)); }); const normal = rows.filter(r => Number(r.badgeCount) >= 0); const priv = rows.length - normal.length; const ptsFetched = rows.filter(r => r.pointsItems != null && Number(r.pointsItems) >= 0); return { kind: 'friendsBadgeLeaderboard', sortBy: dim.label, totalFriends: rows.length, withBadges: normal.length, privateProfiles: priv, summary: { totalBadges: normal.reduce((s, r) => s + (Number(r.badgeCount) || 0), 0), avgBadges: normal.length ? Math.round(normal.reduce((s, r) => s + (Number(r.badgeCount) || 0), 0) / normal.length * 10) / 10 : 0, maxBadges: normal.length ? Math.max(...normal.map(r => Number(r.badgeCount) || 0)) : 0, maxLevel: normal.length ? Math.max(...normal.map(r => Number(r.level) || 0)) : 0, pointsShopItemsFetched: ptsFetched.length, pointsShopItemsTotal: ptsFetched.reduce((s, r) => s + (Number(r.pointsItems) || 0), 0), }, top: sorted.slice(0, ctx.topN).map(r => ({ name: r.name || `用户 ${String(r.steamId || '').slice(-6)}`, steamId: r.steamId || null, badgeCount: Number(r.badgeCount) < 0 ? '私密' : (Number(r.badgeCount) || 0), level: Number(r.level) || 0, xp: Number(r.xp) || 0, pointsItems: r.pointsItems == null ? '未抓取' : (Number(r.pointsItems) < 0 ? '私密' : Number(r.pointsItems)), })), fetchedAt: value.fetchedAt || null, note: priv > 0 ? `${priv} 位好友资料私密(徽章数不可见,已沉底)` : null + (ptsFetched.length < rows.length ? ';部分好友未抓取点数装扮数(需在插件内点「获取点数装扮」)' : ''), }; } // 卡牌数据库摘要:{data:{appid:{name,cardCount}}, timestamp} function pcCardDbDigest(value, ctx) { const db = (value && value.data && typeof value.data === 'object') ? value.data : (value && typeof value === 'object' ? value : null); if (!db) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; const ids = Object.keys(db); const rows = ids.map(appid => ({ appid, name: (db[appid] && db[appid].name) || `App ${appid}`, cardCount: Number(db[appid] && db[appid].cardCount) || 0, })).sort((a, b) => b.cardCount - a.cardCount); return { kind: 'cardDatabase', totalGames: ids.length, totalCards: rows.reduce((s, r) => s + r.cardCount, 0), topByCardCount: rows.slice(0, ctx.topN), timestamp: value.timestamp || null, note: '卡牌数据库为全量游戏→卡牌数映射,此处只出 topN', }; } // 单组卡牌市场价格摘要:{appId, cards[], avgPrice, medianPrice, netIncome, ...} // ⚠️ cards/regularCards/foilCards 三数组最长各 300 张,绝不能原样返回。 function pcCardPriceDigest(value, ctx) { if (!value || typeof value !== 'object' || value.avgPrice == null) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const toYuan = (cents) => (Number(cents) || 0) / 100; const pick = (arr) => (Array.isArray(arr) ? arr : []) .filter(c => c && (c.price != null || c.sell_price != null)) .map(c => ({ name: c.name || null, price: toYuan(c.price != null ? c.price : c.sell_price), })) .sort((a, b) => b.price - a.price); const regular = pick(value.regularCards).slice(0, ctx.topN); const foil = pick(value.foilCards).slice(0, ctx.topN); return { kind: 'cardPrices', appId: value.appId || null, cardCount: Number(value.cardCount) || 0, regularCount: Number(value.regularCount) || 0, foilCount: Number(value.foilCount) || 0, pricing: { totalCost: toYuan(value.totalCost), avgPrice: toYuan(value.avgPrice), medianPrice: toYuan(value.medianPrice), avgNoMaxPrice: toYuan(value.avgNoMaxPrice), regularAvgPrice: toYuan(value.regularAvgPrice), foilAvgPrice: toYuan(value.foilAvgPrice), netIncome: toYuan(value.netIncome), priceSampleCount: Number(value.priceCount) || 0, }, topRegularByPrice: regular, topFoilByPrice: foil, updatedAt: value.updatedAt || null, note: '金额单位已由分换算为元;卡牌明细仅取单价最高的 topN,未整读 cards 数组', }; } // 热门市场物品摘要:数组(宿主已 slice 到 DISPLAY_COUNT) function pcPopularMarketDigest(value, ctx) { const arr = Array.isArray(value) ? value : null; if (!arr) return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; return { kind: 'popularMarket', total: arr.length, items: arr.slice(0, ctx.topN), note: '热门市场由 /market/popular 轮询累积去重而来,反映近期社区交易热点', }; } // ---- 社交中心(Steam Social Core)/ 好友动态(Steam Activity Modern)---- // 两者的 IDB 都在 community 域、且都是**裸值**(时间戳在载荷内部,不套信封), // 因此无需 envelope:true,pcCacheAgeMinutes 可直接从 payload.updatedAt 取。 // 注意:它们各自独占一个库 → 需要两个注册项(现注册表一个插件对应一个 idb.name)。 // 好友数排行:{[steamId]: {n:好友数, p:私密(1/0), t:更新时间}} // 这是社交中心「好友数排行」页的底层数据,永久缓存 + 7 天 stale 增量刷新。 function pcFriendCountDigest(value, ctx) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const ids = Object.keys(value); if (!ids.length) return { kind: 'friendCounts', total: 0, friends: [], note: '社交中心尚未建立好友数缓存' }; const rows = ids.map(sid => { const r = value[sid] || {}; return { steamId: sid, name: null, // 该库不存昵称,需与 roster 联查 friendCount: r.p ? '私密' : (Number(r.n) || 0), private: r.p === 1 || r.p === true, updatedAt: Number(r.t) || null, }; }); // 私密沉底(与宿主榜单语义一致:0 无法区分"0 个好友"与"私密") // 注意方向:比较器返回正数表示 a 排在 b 之后,故用 (a.private - b.private) // 才能把私密项推到末尾;写成 (b.private - a.private) 会把私密顶到榜首。 rows.sort((a, b) => (a.private - b.private) || ((Number(b.friendCount) || 0) - (Number(a.friendCount) || 0))); const ages = rows.map(r => r.updatedAt).filter(Boolean); const newest = ages.length ? Math.max(...ages) : 0; return { kind: 'friendCounts', totalFriendsTracked: ids.length, privateCount: rows.filter(r => r.private).length, top: rows.slice(0, ctx.topN), dataAgeMinutes: newest ? Math.max(0, Math.round((Date.now() - newest) / 60000)) : null, note: rows.some(r => !r.name) ? '该库只存 steamId 不存昵称,可先用 key="roster" 取名对应' : null, }; } // 游玩快照(热力图数据):{[steamId]: [{d:'YYYY-MM-DD', m:累计分钟}]} // 每个好友保留最近 21 天(宿主上限),可答"好友最近两周在玩什么、时长变化如何"。 function pcPlaySnapDigest(value, ctx) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const ids = Object.keys(value).filter(sid => Array.isArray(value[sid]) && value[sid].length); if (!ids.length) return { kind: 'playSnapshots', friends: 0, note: '社交中心尚未建立游玩快照(需打开深度大盘累积)' }; const rows = ids.map(sid => { const arr = value[sid]; const first = arr[0] || {}, last = arr[arr.length - 1] || {}; const delta = (Number(last.m) || 0) - (Number(first.m) || 0); return { steamId: sid, days: arr.length, firstDay: first.d || null, lastDay: last.d || null, lastTotalMinutes: Number(last.m) || 0, deltaMinutesLastToFirst: delta, }; }).sort((a, b) => b.deltaMinutesLastToFirst - a.deltaMinutesLastToFirst); const dayKeys = Array.from(new Set(ids.flatMap(sid => value[sid].map(x => x.d)).filter(Boolean))).sort(); return { kind: 'playSnapshots', friends: ids.length, coveredDays: dayKeys.length, dayRange: dayKeys.length ? { from: dayKeys[0], to: dayKeys[dayKeys.length - 1] } : null, topByGrowth: rows.slice(0, ctx.topN), note: 'm 为该好友「累计游戏时长(分钟)」的每日快照,delta 为区间内净增长;数值不下降属正常(时长只增不减)', }; } // 好友名册:{own:{...}, friends:[{steamid, personaname, avatar, ...}], updatedAt} function pcRosterDigest(value, ctx) { if (!value || typeof value !== 'object' || !Array.isArray(value.friends)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const f = value.friends; return { kind: 'friendRoster', own: value.own ? { steamId: value.own.steamid || null, name: value.own.personaname || null } : null, totalFriends: f.length, friends: f.slice(0, ctx.topN).map(x => ({ steamId: x.steamid || null, name: x.personaname || x.name || null, level: x.level ?? null, currentGame: x.gameextrainfo || x.gameName || null, })), updatedAt: value.updatedAt || null, note: '名册含昵称/头像/等级,可与 fcount、level、snaps 联查', }; } // 好友等级:{[steamId]: level} function pcFriendLevelDigest(value, ctx) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const ids = Object.keys(value); if (!ids.length) return { kind: 'friendLevels', total: 0, note: '社交中心尚未抓取好友等级' }; // v1.31.0 兼容两种值形态:纯数字(sfd_level_cache / sac level)与 {level} 对象 const rows = ids.map(sid => { const v = value[sid]; const level = Number((v && typeof v === 'object') ? v.level : v) || 0; return { steamId: sid, name: null, level }; }).sort((a, b) => b.level - a.level); // v1.31.0 等级分桶:模型不翻全量名单也能先答「有没有超过 X 级的人」 const ge = (t) => rows.filter(r => r.level >= t).length; return { kind: 'friendLevels', total: ids.length, maxLevel: rows[0].level, avgLevel: Math.round(rows.reduce((s, r) => s + r.level, 0) / rows.length * 10) / 10, levelBuckets: { '300+': ge(300), '200+': ge(200), '100+': ge(100), '50+': ge(50) }, top: rows.slice(0, ctx.topN), note: '该库只存 steamId→等级,昵称需联查 roster(sac)或 sfd_cached_friends_data(sfd)', }; } // 好友动态:{items:[{type,timestamp,appid,steamId,textHash,dateText}], updatedAt, count} // type 枚举来自宿主 getBlockType:screenshot/game/review/workshop/favorite/wishlist/achievement/other function pcFriendActivityDigest(value, ctx) { if (!value || typeof value !== 'object' || !Array.isArray(value.items)) { return { drifted: true, ...pcGenericArrayDigest(value, ctx) }; } const items = value.items; const byType = {}; for (const it of items) byType[it.type || 'other'] = (byType[it.type || 'other'] || 0) + 1; const sorted = items.slice().sort((a, b) => (Number(b.timestamp) || 0) - (Number(a.timestamp) || 0)); return { kind: 'friendActivity', total: items.length, declaredCount: value.count ?? items.length, byType, latest: sorted.slice(0, ctx.topN).map(it => ({ steamId: it.steamId || null, type: it.type || 'other', appid: it.appid || null, dateText: it.dateText || null, timestamp: Number(it.timestamp) || 0, })), updatedAt: value.updatedAt || null, note: '来源为好友动态页 DOM 解析(Steam 未开放该数据 API),仅覆盖用户曾打开过动态页的好友', }; } // ---- 宿主插件桥(阶段 2)--------------------------------------------------- // SGLV / FA 写入 store 域 localStorage 的 __saa_bridge_*__ 键。 // 桥存在的意义:GM 存储沙箱隔离,AI 读不到 GM;但同源 localStorage 共享,故宿主主动镜像一份。 // 无数据时宿主写 {v:1, updatedAt, wishlist/rates: null},据此可区分"桥在但无数据"与"桥不存在(宿主未装/未更新)"。 // SGLV 桥摘要:rates 直接返回;names 按 topN + 前缀过滤 function pcSglvBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const out = { kind: 'sglvBridge', bridgeVersion: value.v, rates: null, names: null, namesSkipped: value.namesSkipped || null, }; // 汇率:体量小(~2KB)原样返回,AI 回答中文名/跨区比价直接可用。 // 注意:桥顶层 updatedAt 是"桥写入时刻",不等于底层 GM 数据的真实年龄, // 因此这里额外透出 ratesAgeMinutes(源自宿主 GM 的 sgis-rates-ts),由模型据此声明新鲜度。 if (value.rates && typeof value.rates === 'object' && value.rates.map) { const map = value.rates.map; const entries = Object.entries(map); const ratesAge = pcCacheAgeMinutes({ ts: value.rates.ts }); out.rates = { semantic: '1 外币 = X CNY', cachedAt: value.rates.ts || null, ratesAgeMinutes: ratesAge, ratesStale: ratesAge != null && ratesAge > 24 * 60, currencyCount: entries.length, map: entries.slice(0, 40).reduce((acc, [k, v]) => { acc[k] = v; return acc; }, {}), }; } // 中文名:可能上万条,只按 topN 切片 + 支持字段前缀过滤(ctx.fields 形如 "730,appid") if (value.names && typeof value.names === 'object') { const allIds = Object.keys(value.names); let ids = allIds; const f = String(ctx.fields || '').trim(); if (f) { const want = f.split(',').map(s => s.trim()).filter(Boolean); const matched = allIds.filter(id => want.includes(id)); // 只有当过滤确实命中才生效,避免模型误填字段名导致空结果 if (matched.length) ids = matched; } out.names = { total: allIds.length, returned: Math.min(ids.length, ctx.topN), filteredByRequest: f ? ids.length !== allIds.length : false, entries: ids.slice(0, ctx.topN).map(id => ({ appid: id, name: value.names[id] })), }; } else if (value.namesSkipped) { out.namesNote = `宿主因体积超限未镜像中文名(namesSkipped=${value.namesSkipped}),如需中文名请改用 getGameDetail(l=schinese)`; } return out; } // FA 桥摘要:家庭愿望单 GameList + GameInfo function pcFaBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const wl = value.wishlist; const out = { kind: 'faBridge', bridgeVersion: value.v, familyGroupId: value.gid || null, wishlistPresent: !!wl, }; if (!wl) { out.note = '桥已写入但家庭愿望单暂无数据(FA 插件可能未打开过家庭愿望单面板)'; return out; } const gameList = Array.isArray(wl.GameList) ? wl.GameList : []; const gameInfo = (wl.GameInfo && typeof wl.GameInfo === 'object') ? wl.GameInfo : {}; out.wishlist = { totalGames: gameList.length, infoCount: Object.keys(gameInfo).length, truncatedByBridge: wl.truncated === true, games: gameList.slice(0, ctx.topN).map(appid => { const gi = gameInfo[appid] || {}; const wishers = Array.isArray(gi.wishers) ? gi.wishers : []; return { appid, name: gi.name || null, wishers: wishers.length ? wishers : null, finalPrice: gi.finalPrice || null, originalPrice: gi.originalPrice || null, discountPct: gi.discountPct || null, onSale: gi.onSale === true, isFree: gi.isFree === true, releaseDate: gi.releaseDate || null, priority: gi.priority || null, }; }), }; if (gameList.length > ctx.topN) { out.wishlist.note = `共 ${gameList.length} 款,仅返回前 ${ctx.topN} 款,可增大 topN 重试`; } return out; } // ---- 缓存注册表 ---------------------------------------------------------- // domain:该缓存只能在该域的页面读取;digest:摘要裁剪函数 // 消费历史桥摘要(Spending 分类器 v2.3.0+ 镜像):分类聚合 + 月度趋势 + 明细切片。 // 口径与 getSpendingHistory 对齐:净支出 = 直购+内购+送礼−退款,**仅统计主货币行** // (v1.34.0 修正:旧版把外币行金额直接加进总额,跨区消费会把 CNY 总额算爆)。 // 宿主 v2.5.0 起即使明细因 localStorage 配额被丢弃,也会写 aggregate,这里直接采纳。 function pcSpendBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const rows = Array.isArray(value.rows) ? value.rows : []; const primary = value.primaryCurrency || null; const out = { kind: 'spendBridge', bridgeVersion: value.v, updatedAt: value.updatedAt || null, primaryCurrency: primary, complete: value.complete === true, rowsPresent: rows.length, totalRows: value.totalRows != null ? value.totalRows : rows.length, rowsSkipped: value.rowsSkipped || null, truncated: value.truncated === true, }; const hostAgg = value.aggregate && typeof value.aggregate === 'object' ? value.aggregate : null; // 只镜像了聚合(v2.5.0 的配额降级):聚合仍可答总额/月度,但明细与逐笔不可得 if (!rows.length) { if (hostAgg) { out.aggregate = hostAgg; out.aggregateFromHost = true; out.note = '宿主只镜像了聚合,未镜像逐笔明细' + (value.rowsSkipped === 'quota' ? '(localStorage 配额超限)' : value.rowsSkipped ? `(${value.rowsSkipped})` : '') + (out.complete ? ';页面已加载全量,聚合可直接引用' : ';页面未加载全量,聚合只覆盖已加载部分,可能低估真实总额'); return out; } out.note = value.rowsSkipped ? `宿主因 localStorage 配额未镜像明细(rowsSkipped=${value.rowsSkipped})且无聚合兜底;请改用 getSpendingHistory(v1.34.0 起会自抓并可用桥兜底)` : '桥已写入但暂无消费记录:Spending 分类器可能尚未在该浏览器打开 /account/history 页面并完成分析'; return out; } if (!out.complete) { out.note = `仅覆盖页面已加载的 ${rows.length} 条(Steam「加载更多」未点完),月度趋势以此为口径;可请用户在消费历史页点一次「加载更多」自动级联加载全量`; } const CATS = ['store', 'ingame', 'gift', 'refund', 'convert', 'market_buy', 'market_sell']; const CAT_LABEL = { store: '直购', ingame: '内购', gift: '送礼', refund: '退款', convert: '充值', market_buy: '买入', market_sell: '卖出' }; const byCat = {}, byMonth = {}; let gross = 0, refunded = 0; for (const r of rows) { const cat = CATS.includes(r.category) ? r.category : null; if (!cat) continue; const amt = Number(r.amount) || 0; const cnt = Number(r.marketCount) || 1; // 与自抓口径一致:非主货币行只计笔数、不计金额 const isPrimary = !primary || r.currency === primary; if (!byCat[cat]) byCat[cat] = { count: 0, spend: 0 }; byCat[cat].count += cnt; if (isPrimary) { if (cat === 'store' || cat === 'ingame' || cat === 'gift') { byCat[cat].spend += amt; gross += amt; } else if (cat === 'refund') { byCat[cat].spend -= amt; refunded += amt; } } const month = r.ts ? new Date(r.ts).toISOString().slice(0, 7) : null; if (month) { if (!byMonth[month]) byMonth[month] = { count: 0, net: 0 }; byMonth[month].count += cnt; if (!isPrimary) continue; if (cat === 'store' || cat === 'ingame' || cat === 'gift') byMonth[month].net += amt; else if (cat === 'refund') byMonth[month].net -= amt; } } out.aggregate = { netSpend: Math.round((gross - refunded) * 100) / 100, grossSpend: Math.round(gross * 100) / 100, refundTotal: Math.round(refunded * 100) / 100, categories: CATS.filter(c => byCat[c]).map(c => ({ id: c, label: CAT_LABEL[c], count: byCat[c].count, spend: Math.round(byCat[c].spend * 100) / 100 })), monthly: Object.keys(byMonth).sort().reverse().slice(0, 12).map(k => ({ month: k, count: byMonth[k].count, net: Math.round(byMonth[k].net * 100) / 100 })), }; const topN = Math.min(ctx.topN || 20, 50); out.recent = rows.slice(0, topN).map(r => ({ date: r.date, item: r.item, category: r.category, amount: r.amount, currency: r.currency || null, ...(r.appid ? { appid: r.appid } : {}), })); if (rows.length > topN) out.recentNote = `仅返回最近 ${topN} 笔,明细可增大 topN(上限 50)`; return out; } // 入库历史桥摘要(SLC 许可分类器 v2.6.0 镜像):来源分布 + 月度入库趋势 + 明细切片/按名过滤。 // fields 语义:游戏名过滤(包含匹配),回答「XX 是怎么获得的/什么时候入库的」时用。 function pcSlcBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const rows = Array.isArray(value.rows) ? value.rows : []; const out = { kind: 'slcBridge', bridgeVersion: value.v, updatedAt: value.updatedAt || null, total: value.total != null ? value.total : rows.length, rowsPresent: rows.length, counts: value.counts || null, rowsSkipped: value.rowsSkipped || null, truncated: value.truncated === true, }; if (value.rowsSkipped) { out.note = '宿主因 localStorage 体积超限未镜像明细(rowsSkipped=quota);来源计数仍可用 counts 字段'; return out; } if (!rows.length) { out.note = '桥已写入但暂无入库记录:SLC 可能尚未在该浏览器打开 /account/licenses 页面并完成加载'; return out; } // 月度入库趋势(ts 毫秒;无 ts 的行不计入) const byMonth = {}; for (const r of rows) { const ts = Number(r.ts); if (!ts) continue; const month = new Date(ts).toISOString().slice(0, 7); byMonth[month] = (byMonth[month] || 0) + 1; } out.monthly = Object.keys(byMonth).sort().reverse().slice(0, 12) .map(k => ({ month: k, count: byMonth[k] })); // fields:游戏名包含匹配(「XX 怎么获得的」场景),不填则返回最近入库 const topN = Math.min(ctx.topN || 20, 50); const f = String(ctx.fields || '').trim(); const CAT_LABEL = { store: '商店', retail: '零售', gift: '礼物', free: '免费' }; if (f) { const matched = rows.filter(r => (r.name || '').includes(f)); out.filteredBy = f; out.matchedTotal = matched.length; out.records = matched.slice(0, topN).map(r => ({ name: r.name, date: r.date, ts: r.ts || null, category: r.category, source: CAT_LABEL[r.category] || r.category || null, })); if (matched.length > topN) out.recordsNote = `共 ${matched.length} 条匹配,仅返回前 ${topN} 条`; } else { out.recent = rows.slice(0, topN).map(r => ({ name: r.name, date: r.date, ts: r.ts || null, category: r.category, source: CAT_LABEL[r.category] || r.category || null, })); if (rows.length > topN) out.recentNote = `仅返回最近 ${topN} 条,可增大 topN(上限 50)`; } return out; } // 点数商店桥摘要(Steam 点数商店增强 v2.3.0 镜像):余额/累计获得/退款/兑换 + 明细行切片。 // 数据源是宿主插件对 /pointssummary/ 的解析(kind: earn=获得 refund=退款返还 redeem=兑换消费 balance=余额)。 function pcPtsBridgeDigest(value, ctx) { if (!value || typeof value !== 'object' || value.v == null) { return { drifted: true, bridgeFormat: 'unknown', keys: value && typeof value === 'object' ? Object.keys(value) : null }; } const KIND_LABEL = { earn: '获得', refund: '退款返还', redeem: '兑换消费', balance: '余额' }; const rows = Array.isArray(value.rows) ? value.rows : []; const out = { kind: 'ptsBridge', bridgeVersion: value.v, updatedAt: value.updatedAt || null, balance: value.balance != null ? value.balance : null, earned: value.earned != null ? value.earned : null, refunded: value.refunded != null ? value.refunded : null, redeemed: value.redeemed != null ? value.redeemed : null, partial: value.partial === true, rowsPresent: rows.length, rowsSkipped: value.rowsSkipped || null, }; if (out.partial) { out.note = '宿主仅从页面提取到点数余额(partial),明细缺失;可让用户打开点数商店页稍候自动重抓'; } if (value.rowsSkipped) out.rowsNote = '宿主因体积超限未镜像明细行(rowsSkipped=quota)'; if (rows.length) { out.records = rows.slice(0, Math.min(ctx.topN || 20, 50)).map(r => ({ label: r.label || null, amount: r.amount || 0, kind: r.kind || null, kindLabel: KIND_LABEL[r.kind] || r.kind || null, })); } else if (!out.partial && !value.rowsSkipped) { out.note = '桥已写入但暂无明细行:点数商店增强可能尚未完成 /pointssummary/ 解析'; } return out; } const PLUGIN_CACHE_REGISTRY = { sglv: { label: 'SGLV 游戏库展示', domain: PC_DOMAIN_STORE, idb: { name: 'sglv_kv', store: 'kv' }, keys: { sglv_cached_games: { desc: '游戏库列表+游玩时长(数组,元素含 appid/name/playtime_forever)', digest: pcGamesDigest }, sglv_family_info: { desc: '家庭共享信息', digest: pcGenericArrayDigest }, sglv_pt_daily_history: { desc: '入库/游玩时长日历史', digest: pcGenericArrayDigest }, }, }, sfd: { label: 'SFD 好友管理', domain: PC_DOMAIN_COMMUNITY, idb: { name: 'sfd_storage', store: 'kv' }, keys: { sfd_cached_friends_data: { desc: '好友列表', digest: pcFriendsDigest }, // v1.31.0 修复:这是 {steamId→level} 对象映射,之前错用数组 digest 只吐键名列表, // 「好友谁等级高」类问题拿着 162 条数据算不出答案(实测翻车)→ 换等级映射专用 digest sfd_level_cache: { desc: '好友等级映射 {steamId→level}(等级检查跑过一次后才有)', digest: pcFriendLevelDigest }, sfd_family_cache: { desc: '家庭成员+共享库', digest: pcFamilyDigest }, sfd_wishlist_cache: { desc: '家庭愿望单', digest: pcGenericArrayDigest }, sfd_family_play_activity: { desc: '按成员的游玩动态', digest: pcGenericArrayDigest }, 'sfd_og_': { desc: '逐好友完整游戏库(前缀键,需拼完整 steamid;MB 级仅出摘要)', digest: pcOwnedGamesDigest, prefix: true }, }, }, fa: { label: 'FA 家庭组分析', domain: PC_DOMAIN_STORE, idb: { name: 'sffa_kv', store: 'kv' }, keys: { fa_value_insights_cache: { desc: '家庭价格+时长价值洞察(≤2 万条)', digest: pcValueInsightsDigest }, faCooldownRegistry: { desc: '家庭借阅冷却记录', digest: pcGenericArrayDigest }, fa_bundle_cache: { desc: '数据包缓存(48h)', digest: pcGenericArrayDigest }, fa_goty_cache: { desc: 'GOTY 数据缓存(12h)', digest: pcGenericArrayDigest }, fa_dlc_db_cache: { desc: 'DLC 数据库缓存(12h)', digest: pcGenericArrayDigest }, }, }, sme: { label: 'SME 市场增强', domain: PC_DOMAIN_COMMUNITY, idb: null, keys: { 'sme_cache_': { desc: '价格历史/订单簿快照 {ts,data}(前缀键,4MB LRU)', digest: pcMarketCacheDigest, prefix: true }, 'sme_inv_': { desc: '库存缓存(前缀键,需拼 steamid)', digest: pcInventoryDigest, prefix: true }, sme_inv_prices_v1: { desc: '库存价格快照', digest: pcGenericArrayDigest }, }, }, slc: { label: 'SLC 许可分类器', domain: PC_DOMAIN_STORE, idb: null, keys: { slc_license_cache: { desc: '许可指纹数组 "日期||游戏名||类型"(7 天 TTL,ver2)', digest: pcLicenseDigest }, slc_license_total: { desc: '许可总记录数', digest: pcScalarDigest }, }, }, // v1.14.0:SBC 徽章卡片视图(community 域) // 该库是「一库七 store」,故每个键用 store 字段指定自己的 objectStore; // 且宿主 IDB.set 写入的是 {key,data,updatedAt,expiresAt} TTL 信封, // 而宿主自己的 get() 会 return record.data —— 本工具是原生只读, // 必须靠 envelope:true 显式声明并剥壳(不靠形状猜,见 pcUnwrapEnvelope 注释)。 sbc: { label: 'SBC 徽章卡片视图', domain: PC_DOMAIN_COMMUNITY, idb: { name: 'sbc_pro_cache', store: 'gameData' }, keys: { sbc_friends_leaderboard_v1: { store: 'gameData', envelope: true, desc: '好友徽章排行榜(徽章数/等级/经验/点数商店装扮数 四维度)', digest: pcFriendsLbDigest, }, cardDatabase: { store: 'gameData', envelope: true, desc: '卡牌数据库(全量游戏 → 卡牌数)', digest: pcCardDbDigest, }, 'sbc-market-': { store: 'marketPrices', envelope: true, prefix: true, desc: '单组卡牌市场价格快照(单位分,48h TTL;前缀键需拼 appid,如 sbc-market-730)', digest: pcCardPriceDigest, }, sbc_popular_market_cache: { store: 'popularMarket', envelope: true, desc: '热门市场物品排行(轮询累积,10 分钟 TTL)', digest: pcPopularMarketDigest, }, }, }, // v1.15.0:社交中心数据层(Steam Social Core)。独立 IndexedDB,裸值无信封。 sac: { label: 'SAC 社交中心数据层(好友数/游玩快照/名册/等级)', domain: PC_DOMAIN_COMMUNITY, idb: { name: 'steam_social_db', store: 'kv' }, keys: { fcount: { desc: '好友数排行底层数据 {[steamId]:{n:好友数,p:私密标记,t:更新时间}}(永久缓存+7天增量)', digest: pcFriendCountDigest }, snaps: { desc: '好友游玩时长每日快照 {[steamId]:[{d:日期,m:累计分钟}]}(每人最近21天,深度大盘热力图来源)', digest: pcPlaySnapDigest }, roster: { desc: '好友名册 {own,friends:[{steamid,personaname,level,gameextrainfo}],updatedAt}', digest: pcRosterDigest }, level: { desc: '好友等级映射 {[steamId]: level}', digest: pcFriendLevelDigest }, libpriv: { desc: '游戏库私密/拉取失败标记 {[steamId]: 1}', digest: pcGenericArrayDigest }, vac: { desc: '好友 VAC 状态快照', digest: pcGenericArrayDigest }, }, }, // v1.15.0:好友动态缓存(Steam Activity Modern 主脚本)。同样是独立 IDB + 裸值。 saf: { label: 'SAF 好友动态(动态流/好友游戏库/最近游玩/游戏比对)', domain: PC_DOMAIN_COMMUNITY, idb: { name: 'sf_activity_db', store: 'kv' }, keys: { 'act_': { desc: '好友动态条目(按 steamId 分键,前缀键需拼完整,如 act_76561198142011559)', digest: pcFriendActivityDigest, prefix: true }, 'games_': { desc: '好友完整游戏库(按 steamId 分键,前缀键)', digest: pcGenericArrayDigest, prefix: true }, 'recent_': { desc: '好友最近游玩(按 steamId 分键,前缀键)', digest: pcGenericArrayDigest, prefix: true }, 'cmp_': { desc: '游戏比对结果(与某好友的共有/独有游戏,前缀键)', digest: pcGenericArrayDigest, prefix: true }, social_: { desc: '好友社交数据(好友数+好友列表,按 steamId 分键,前缀键)', digest: pcGenericArrayDigest, prefix: true }, mygames: { desc: '自己的游戏库(好友动态页顺带抓取)', digest: pcGenericArrayDigest }, }, }, // 阶段 2 + 3:宿主插件(SGLV / FA / Spending 分类器 / SLC 许可分类器 / 点数商店增强)主动镜像到 localStorage 的桥键 bridge: { label: '宿主插件桥(SGLV 汇率/中文名、FA 家庭愿望单、Spending 消费历史、SLC 入库来源、点数商店)', domain: PC_DOMAIN_STORE, idb: null, keys: { __saa_bridge_sglv__: { desc: 'SGLV 桥:汇率(1 外币=X CNY)+ 游戏中文名对照', digest: pcSglvBridgeDigest }, __saa_bridge_fa__: { desc: 'FA 桥:家庭愿望单(GameList + GameInfo)', digest: pcFaBridgeDigest }, __saa_bridge_spend__: { desc: '消费历史桥(Spending 分类器 v2.3.0 镜像:7 类分类行+金额+币种,含聚合摘要;complete=false 表示页面历史未全部加载)', digest: pcSpendBridgeDigest }, __saa_bridge_slc__: { desc: '入库桥(SLC 许可分类器 v2.6.0 镜像:来源分类行 store/retail/gift/free + 月度趋势;fields=游戏名过滤可答「XX 怎么获得的」)', digest: pcSlcBridgeDigest }, __saa_bridge_pts__: { desc: '点数商店桥(点数商店增强 v2.3.0 镜像:点数余额/累计获得/退款返还/兑换消费 + 明细行;partial=true 表示仅余额)', digest: pcPtsBridgeDigest }, }, }, }; const PC_PLUGIN_LABEL = { sglv: PLUGIN_CACHE_REGISTRY.sglv.label, sfd: PLUGIN_CACHE_REGISTRY.sfd.label, fa: PLUGIN_CACHE_REGISTRY.fa.label, sme: PLUGIN_CACHE_REGISTRY.sme.label, slc: PLUGIN_CACHE_REGISTRY.slc.label, sbc: PLUGIN_CACHE_REGISTRY.sbc.label, sac: PLUGIN_CACHE_REGISTRY.sac.label, saf: PLUGIN_CACHE_REGISTRY.saf.label, bridge: PLUGIN_CACHE_REGISTRY.bridge.label, }; // 声明过的非前缀键(如 sme_inv_prices_v1)不能被更短的前缀(sme_inv_)抢走。 function pcIsDeclaredExactKey(reg, key) { const spec = reg && reg.keys ? reg.keys[key] : null; return !!(spec && !spec.prefix); } // 前缀实例:以 prefix 开头,但排除前缀本身和另外声明过的精确键。 function pcPrefixInstances(reg, prefix, keys) { return (keys || []).filter((key) => key.startsWith(prefix) && key !== prefix && !pcIsDeclaredExactKey(reg, key)); } // 前缀键解析:用户可能传 'sfd_og_' 或完整 'sfd_og_76561198xxx' // 精确键优先于前缀,否则 sme_inv_prices_v1 会被 sme_inv_ 当成某个好友的库存。 // 前缀声明本身(sfd_og_)仍走列出实例,不去读一个不存在的字面键。 function pcResolveKeySpec(pluginId, key) { const reg = PLUGIN_CACHE_REGISTRY[pluginId]; if (!reg) return { error: `未知插件标识 "${pluginId}",可选:${Object.keys(PLUGIN_CACHE_REGISTRY).join(' / ')}` }; const k = String(key || '').trim(); if (!k) return { error: 'inspect/query 模式必须提供 key 参数' }; if (pcIsDeclaredExactKey(reg, k)) return { reg, spec: reg.keys[k], exactKey: k }; for (const [name, spec] of Object.entries(reg.keys)) { if (spec.prefix && (k === name || k.startsWith(name))) { return { reg, spec, exactKey: k === name ? null : k, prefix: name }; } } if (reg.keys[k]) return { reg, spec: reg.keys[k], exactKey: k }; return { error: `插件 ${pluginId} 没有缓存键 "${k}",可用键:${Object.keys(reg.keys).join(' / ')}` }; } ToolRegistry.register({ name: 'readPluginCache', description: '读取本机其他 Steam 油猴插件(SGLV 游戏库 / SFD 好友管理 / FA 家庭分析 / SME 市场增强 / SLC 许可分类器 / SBC 徽章卡片视图 / SAC 社交中心 / SAF 好友动态)已缓存在本地的数据,只读、不联网、不上传。' + '适合回答"我的游戏时长""好友都在玩什么""好友谁的徽章最多""谁好友最多""好友最近两周时长变化"这类问题——' + '比走 Steam Web API 更快且不消耗 API 配额。仅能读取当前页面域名下可用的缓存:' + '商店页(store)可读 sglv/fa/slc/bridge,社区页(community)可读 sfd/sme/sbc/sac/saf。' + '中文游戏名、汇率、家庭愿望单、消费历史汇总、入库来源(某游戏怎么获得的/入库趋势)、Steam 点数余额与兑换请优先用 plugin="bridge"(宿主插件镜像键;消费桥 key="__saa_bridge_spend__"、入库桥 key="__saa_bridge_slc__"、点数桥 key="__saa_bridge_pts__",毫秒级免 Steam 请求)。' + '好友徽章排行榜用 plugin="sbc",key="sbc_friends_leaderboard_v1",可用 fields=level|xp|points 切换排序维度。' + '「好友谁等级高/等级超过 X 级/等级排行」类问题首选它:fields="level",行里自带昵称+等级(一步出结果,summary.maxLevel 先看上限);' + '备选 sac 的 level 键或 sfd 的 sfd_level_cache(都是 {steamId→level} 等级映射,query 直接返回按等级降序名单 + levelBuckets 分桶,昵称需联查 roster / sfd_cached_friends_data)。' + '社交中心数据(好友数排行/游玩快照/名册/等级)用 plugin="sac";好友动态用 plugin="saf"。' + '注意:sac/saf 的数据由用户主动打开对应页面时产生,未打开过则查不到。' + '数据来自本地缓存,可能已过期,回答时需说明数据新鲜度。', parameters: { type: 'object', properties: { plugin: { type: 'string', enum: ['sglv', 'sfd', 'fa', 'sme', 'slc', 'sbc', 'sac', 'saf', 'bridge'], description: '插件标识:sglv=游戏库 / sfd=好友管理 / fa=家庭分析 / sme=市场增强 / slc=许可分类器 / sbc=徽章卡片视图 / sac=社交中心数据层 / saf=好友动态 / bridge=宿主插件镜像桥' }, mode: { type: 'string', enum: ['list', 'inspect', 'query'], description: 'list=列出该插件可用的缓存键;inspect=查看某键的结构与条数;query=按 digest 摘要取数据(默认返回 top 20)' }, key: { type: 'string', description: '缓存键,inspect/query 必填。带 / 的前缀键请拼完整,如 sfd_og_76561198142011559、sbc-market-730、act_76561198142011559' }, topN: { type: 'number', description: 'query 模式返回条数上限,默认 20(MB 级键会被自动限制)' }, fields: { type: 'string', description: 'query 模式可选。默认是字段裁剪(如 appid,name);对 sbc 好友排行榜则是排序维度:badge(默认)/level/xp/points' }, }, required: ['plugin', 'mode'], }, category: 'knowledge', async execute(params) { const pluginId = String(params.plugin || '').trim().toLowerCase(); const mode = String(params.mode || 'list').trim().toLowerCase(); const topN = Math.max(1, Math.min(Number(params.topN) || 20, 200)); const fields = String(params.fields || '').trim(); const reg = PLUGIN_CACHE_REGISTRY[pluginId]; if (!reg) { return { success: false, error: `未知插件标识 "${params.plugin}",可选:${Object.keys(PLUGIN_CACHE_REGISTRY).join(' / ')}` }; } // 0. 授权闸门:用户显式关闭的数据源直接拒绝(先于域检查 —— // 未授权时提示「去别的页面」没有意义) if (!pcDataSourceAllowed(pluginId)) { return { success: false, error: `用户未授权读取「${reg.label}」的数据。如需启用,请在设置 → 数据源 中勾选。`, needAuth: true, plugin: pluginId, hint: '数据源授权是逐插件的;其他插件的数据不受影响', }; } // 1. 域检查:不匹配时明确引导,不静默返回空 const curDomain = pcCurrentDomain(); if (!curDomain) { return { success: false, error: `当前页面域名 ${location.hostname} 不属于 Steam 商店/社区,无法读取任何插件缓存` }; } if (reg.domain !== curDomain) { // v1.36.0:游戏库键有**跨域 GM 镜像**(同一脚本跨域共享,IndexedDB 不跨域)→ // 社区域读 sglv_cached_games 时改读镜像,其余键仍按域引导。 const mir = pluginId === 'sglv' ? pcReadLibraryMirror() : { ok: false }; const wantsLibKey = String(params.key || '').trim() === 'sglv_cached_games'; if (mir.ok && !mir.expired && wantsLibKey) { const items = mir.games.map((g) => ({ appid: g.appid, name: g.name, playtime_forever: g.playtime_forever, })); return { success: true, data: { plugin: pluginId, label: reg.label, key: 'sglv_cached_games', domain: reg.domain, source: 'cross-domain-mirror', storage: 'GM 跨域镜像', count: items.length, items, mirroredAt: mir.ts, mirrorAgeMinutes: Math.max(0, Math.round((mir.ageMs || 0) / 60000)), mirrorAgeText: pcHumanAge(mir.ageMs), stale: !!mir.stale, note: `本域(${curDomain})读不到 ${reg.label} 的 IndexedDB(按源隔离),` + `已改用商店域缓存的跨域 GM 镜像(快照 ${pcHumanAge(mir.ageMs)});` + '回答时需声明数据新鲜度,不要当成实时数据。', }, summary: `${reg.label} 游戏库 ${items.length} 条(跨域镜像,快照 ${pcHumanAge(mir.ageMs)})`, }; } const need = reg.domain === PC_DOMAIN_STORE ? 'store.steampowered.com(Steam 商店页)' : 'steamcommunity.com(Steam 社区页)'; return { success: false, error: `${reg.label} 的缓存属于 ${reg.domain} 域,请在 ${need} 打开后重试`, needDomain: reg.domain, currentDomain: curDomain, hint: 'IndexedDB / localStorage 按源隔离,跨域读不到,属正常现象' + (pluginId === 'sglv' && !mir.ok ? ';游戏库可用跨域镜像,但需要先在商店域建立过镜像(或该键本身不在镜像范围内)' : ''), }; } // 2. list 模式 if (mode === 'list') { const declared = Object.entries(reg.keys).map(([k, v]) => ({ key: k, desc: v.desc, prefix: !!v.prefix, store: pcStoreOf(reg, v), envelope: !!v.envelope, })); let existing = []; if (reg.idb) { let db = null; try { db = await pcIdbOpen(reg.idb.name); // v1.14.0:按 store 分组探测(支持一库多 store,如 SBC 的 7 个仓库) const byStore = new Map(); for (const d of declared) { if (!byStore.has(d.store)) byStore.set(d.store, []); byStore.get(d.store).push(d); } for (const [st, items] of byStore) { let allKeys = []; try { allKeys = (await pcIdbGetAllKeys(db, st)).map(String); } catch { allKeys = []; } // 该 store 不存在时保持"无数据",不抛错 const set = new Set(allKeys); for (const d of items) { const instances = d.prefix ? pcPrefixInstances(reg, d.key, allKeys) : []; d.present = set.has(d.key) || instances.length > 0; if (d.prefix) d.matchedCount = instances.length; } existing = existing.concat(allKeys.filter(k => !existing.includes(k))); } } catch (e) { console.warn('[SA-Agent] readPluginCache list 打开 IDB 失败', e); } finally { try { db && db.close(); } catch {} } } else { // 纯 localStorage 插件:直接探测声明键是否存在 declared.forEach(d => { if (d.prefix) { const hits = pcPrefixInstances(reg, d.key, pcLocalKeysWithPrefix(d.key)); d.present = hits.length > 0; d.matchedCount = hits.length; } else { let v = null; try { v = localStorage.getItem(d.key); } catch {} d.present = v != null; } }); } // 单 store 插件保持原样(IndexedDB:库名/仓库名),多 store 时标注仓库数 const distinctStores = Array.from(new Set(declared.map(d => d.store))); const storeLabel = reg.idb ? `IndexedDB:${reg.idb.name}/${distinctStores[0] || ''}` + (distinctStores.length > 1 ? ` +${distinctStores.length - 1} 个仓库` : '') : 'localStorage'; return { success: true, data: { plugin: pluginId, label: reg.label, domain: reg.domain, storage: storeLabel, declaredKeys: declared, presentKeyCount: declared.filter(d => d.present).length, allKeysSample: existing.slice(0, 40), ...(existing.length ? { allKeysTotal: existing.length } : {}), }, summary: `${reg.label} 共声明 ${declared.length} 个缓存键,其中 ${declared.filter(d => d.present).length} 个在本机有数据`, }; } // 3. inspect / query 需要 key const resolved = pcResolveKeySpec(pluginId, params.key); if (resolved.error) return { success: false, error: resolved.error }; const { reg: pReg, spec, exactKey, prefix } = resolved; const ctx = { topN, fields }; // v1.14.0:objectStore 支持 per-key 覆盖(SBC 是一库多 store) const storeName = pcStoreOf(pReg, spec); const readByIdb = async (k) => { let db = null; try { db = await pcIdbOpen(pReg.idb.name); return await pcIdbGet(db, storeName, k); } finally { try { db && db.close(); } catch {} } }; try { // 前缀键但只给了前缀 → 列出实例键供模型选择 if (prefix && !exactKey) { let keys = []; if (pReg.idb) { let db = null; try { db = await pcIdbOpen(pReg.idb.name); keys = pcPrefixInstances(pReg, prefix, (await pcIdbGetAllKeys(db, storeName)).map(String)); } catch { keys = []; } finally { try { db && db.close(); } catch {} } } else { keys = pcPrefixInstances(pReg, prefix, pcLocalKeysWithPrefix(prefix)); } return { success: true, data: { plugin: pluginId, key: prefix, matchedCount: keys.length, matchedKeys: keys.slice(0, 60), hint: `请把 key 换成上面任一完整键后再 query` }, summary: `前缀键 ${prefix} 匹配到 ${keys.length} 个实例`, }; } const finalKey = exactKey || String(params.key).trim(); const raw = pReg.idb ? await readByIdb(finalKey) : pcLocalGet(finalKey); if (raw == null) { return { success: false, error: `缓存键 "${finalKey}" 在本机无数据(${PC_PLUGIN_LABEL[pluginId]} 可能未安装、未运行过或缓存已清理)`, missingKey: finalKey, hint: '可先用 mode=list 查看实际存在的键', }; } // v1.14.0:按需脱掉宿主写入的 TTL 信封,并采纳其时间戳算新鲜度 const env = spec.envelope ? pcUnwrapEnvelope(raw) : { payload: raw, updatedAt: null, expiresAt: null, expired: false }; const exists = env.payload; if (exists == null) { return { success: false, error: `缓存键 "${finalKey}" 的载荷为空(宿主写入时 data 字段为空)`, missingKey: finalKey }; } // 信封过期:宿主自己的 get() 会返回 null,这里如实告知而不是拿陈旧数据当新鲜的答 if (env.expired) { return { success: false, error: `缓存键 "${finalKey}" 已被宿主标记为过期(expiresAt=${env.expiresAt}),不应作为当前数据引用`, expiredKey: finalKey, expiredAt: env.expiresAt, hint: '请让对应插件重新抓取后再读;或改用网络工具获取实时数据', }; } // 新鲜度:信封优先(宿主写入时刻),否则沿用载荷自身的时间戳字段 const age = pcCacheAgeMinutes(spec.envelope ? { ts: env.updatedAt || undefined } : exists); // inspect:只看结构,不出数据 if (mode === 'inspect') { const isArr = Array.isArray(exists); const summary = isArr ? { type: 'array', count: exists.length, sampleItemKeys: exists[0] && typeof exists[0] === 'object' ? Object.keys(exists[0]).slice(0, 20) : null, sampleFirst: exists[0] ?? null } : { type: typeof exists, keys: (exists && typeof exists === 'object') ? Object.keys(exists).slice(0, 30) : null, value: exists }; return { success: true, data: { plugin: pluginId, key: finalKey, desc: spec.desc, structure: summary, cacheAgeMinutes: age }, summary: `${finalKey} 结构:${summary.type}${isArr ? `(${summary.count} 项)` : ''}`, }; } // query:走 digest 摘要 let digested; try { digested = spec.digest(exists, ctx); } catch (e) { // shape 漂移/摘要异常 → 降级为 inspect 信息,不抛裸错误 digested = { drifted: true, degradeReason: e.message, structureHint: typeof exists, keys: (exists && typeof exists === 'object') ? Object.keys(exists).slice(0, 20) : null }; } return { success: true, data: { plugin: pluginId, label: pReg.label, key: finalKey, desc: spec.desc, cacheAgeMinutes: age, stale: age != null && age > 24 * 60, ...digested, }, summary: digested && digested.drifted ? `${finalKey} 结构与预期不符,已降级返回结构信息` : `${finalKey} 命中本地缓存${age != null ? `(${age} 分钟前更新)` : ''}`, }; } catch (e) { return { success: false, error: `读取缓存失败:${e.message}`, hint: '若目标插件未安装,属正常现象' }; } }, }); // ============================================================ // v1.23.0 插件版本清单(版本契约 / plugin-contract.json) // ============================================================ // 三来源合并: // ① 契约(远端 JSON) → 期望装哪些、各自最低版本、探测信号 // ② 运行时注册(IDB) → 实际装了哪些 + **精确版本号** // ③ 探测(IDB 库名/localStorage)→ 没注册但装了的老插件也能识别 // // ⚠️ 为什么必须有 ①:GM 存储跨脚本沙箱隔离 → 读不到别人的 GM 值; // 只有接入 registerPlugin 的插件才能给出精确版本。契约提供兜底。 // ⚠️ 2026-10-21:URL 钉在 commit 3ca0d82(数据源清单瘦身版)—— // 实测 jsdelivr 对 @main 的 purge 当天不稳定(purge 受理但边缘仍吐旧缓存 ≤12h), // 按契约分发说明(data/plugin-contract.json 的 distribution.cdn.pinNote)钉 SHA 立即生效。 // ★ 将来更新契约清单:改源文件 → 推 steam-namespace → 把这里的 SHA 换成新 commit(或临时换回 @main)。 const PC_CONTRACT_URL = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@6789435/data/plugin-contract.json'; // ⚠️ 缓存键带版本号(v2):契约清单瘦身(2026-10-21)后要让所有用户立即拉到新清单, // 而不是等 12h TTL 自然过期 —— 改清单时同步把这里的 v2 递增即可强制刷新一次。 const PC_CONTRACT_CACHE_KEY = 'saa_plugin_contract_v2'; const PC_CONTRACT_TTL = 12 * 60 * 60 * 1000; // 12h // ═══ HLTB 索引(发行年份 + 剧情时长)═══ // ⭐ 分片键必须是 (appid >> 1) % 8 —— Steam 全量 82,494 个 appid 中 99.90% 是偶数, // 用 appid % 8 会让一半分片永远为空(离散度 99.9%)。见 build-index.js。 // ⭐ story 必须用 Float64Array —— Float32 精度只有 ~7 位有效数字, // 3.12 会被存成 3.119999885559082,导致 SCI 分档边界错档。 const PC_HLTB_BASE = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data'; const PC_HLTB_SHARDS = 8; const PC_HLTB_STATE = { ready: false, loading: false, err: '', shards: [], manifest: null }; function pcHltbFetchJson(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout: 20000, onload: (r) => { if (r.status < 200 || r.status >= 300) { reject(new Error('HTTP ' + r.status)); return; } try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(e); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } async function pcHltbLoad() { if (PC_HLTB_STATE.ready) return true; if (PC_HLTB_STATE.loading) return false; PC_HLTB_STATE.loading = true; PC_HLTB_STATE.err = ''; try { const manifest = await pcHltbFetchJson(`${PC_HLTB_BASE}/hltb-index-v1.manifest.json`); if (!manifest || !manifest.shards) throw new Error('manifest 格式异常'); const raw = await Promise.all(Array.from({ length: PC_HLTB_SHARDS }, (_, i) => pcHltbFetchJson(`${PC_HLTB_BASE}/hltb-index-v1.${i}.json`))); PC_HLTB_STATE.shards = raw.map((f) => ({ ids: Int32Array.from(f.ids || []), years: Int32Array.from(f.years || []), story: Float64Array.from(f.story || []), })); PC_HLTB_STATE.manifest = manifest; PC_HLTB_STATE.ready = true; PC_HLTB_STATE.loading = false; console.log(`[SA-Agent] HLTB 索引就绪:${manifest.entries} 条`); return true; } catch (e) { PC_HLTB_STATE.err = e.message || String(e); PC_HLTB_STATE.loading = false; console.warn('[SA-Agent] HLTB 索引加载失败', PC_HLTB_STATE.err); return false; } } /** 按 appid 查 HLTB 记录 → { y: 发行年, m: 剧情时长(小时) } | null */ function pcHltbGet(appid) { if (!PC_HLTB_STATE.ready) return null; const id = Number(appid); if (!Number.isFinite(id)) return null; const sh = PC_HLTB_STATE.shards[(id >> 1) % PC_HLTB_SHARDS]; if (!sh || !sh.ids.length) return null; let lo = 0, hi = sh.ids.length - 1; while (lo <= hi) { const mid = (lo + hi) >> 1; if (sh.ids[mid] === id) return { y: sh.years[mid], m: sh.story[mid] }; if (sh.ids[mid] < id) lo = mid + 1; else hi = mid - 1; } return null; } // ═══ 数据源授权 ═══ // 用户可在「设置 → 数据源」逐个勾选/取消勾选某个插件的数据是否允许 AI 读取。 // 存储:GM 键 sa_data_source_auth = { v:1, sources:{ [pluginId]: true|false } } // 语义:**未出现在 sources 里的插件 = 允许**(默认全开,保持既有行为)。 // 显式 false 才是「用户关了」。 const PC_AUTH_KEY = 'sa_data_source_auth'; function pcLoadAuth() { try { const raw = GM_getValue(PC_AUTH_KEY, ''); if (!raw) return { v: 1, sources: {} }; const o = JSON.parse(raw); if (!o || typeof o !== 'object') return { v: 1, sources: {} }; return { v: o.v || 1, sources: (o.sources && typeof o.sources === 'object') ? o.sources : {} }; } catch (e) { return { v: 1, sources: {} }; } } function pcSaveAuth(auth) { try { GM_setValue(PC_AUTH_KEY, JSON.stringify(auth)); return true; } catch (e) { return false; } } /** 该插件的数据是否被允许读取(默认允许) */ function pcDataSourceAllowed(pluginId) { const auth = pcLoadAuth(); return auth.sources[pluginId] !== false; } /** 列出授权状态(设置页用):{ [id]: {allowed, explicit} } */ function pcDataSourceAuthMap() { const auth = pcLoadAuth(); const out = {}; for (const id of Object.keys(PLUGIN_CACHE_REGISTRY)) { out[id] = { allowed: auth.sources[id] !== false, explicit: Object.prototype.hasOwnProperty.call(auth.sources, id), }; } return out; } function pcContractGetCached() { try { const raw = GM_getValue(PC_CONTRACT_CACHE_KEY, ''); if (!raw) return null; const o = JSON.parse(raw); if (!o || !o.v) return null; if (Date.now() - (o.ts || 0) > PC_CONTRACT_TTL) return null; return o.data || null; } catch (e) { return null; } } function pcContractFetch() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: PC_CONTRACT_URL, timeout: 15000, onload: (r) => { if (r.status < 200 || r.status >= 300) { reject(new Error('HTTP ' + r.status)); return; } try { const data = JSON.parse(r.responseText); try { GM_setValue(PC_CONTRACT_CACHE_KEY, JSON.stringify({ v: 1, ts: Date.now(), data })); } catch (e) { /* ignore */ } resolve(data); } catch (e) { reject(e); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } /** v1.30.3 跨域注册镜像的 GM 存储键。 * 背景:插件注册写在各自运行域的 IndexedDB(按域隔离),面板/工具只读当前域 → * 「市场系/账户系此消彼长」——社区域打开看不到账户系、商店域打开看不到好友系。 * 本插件在 store / community 两域都有实例,而 GM 存储同一脚本**跨域共享** → * 把本域读到的注册表镜像进 GM 存储,另一域实例打开时合并显示。 */ const PC_REG_MIRROR_KEY = 'sa_plugin_registry_mirror_v1'; /** 镜像条目保鲜期:超过则视为「插件可能已卸载」,不再并入(本域注册表会自然回填) */ const PC_REG_MIRROR_TTL = 14 * 864e5; /** 读运行时注册表:IDB sglv_plugin_host_storage / kv / shared:plugin:*(当前域) * + GM 跨域镜像(另一域的注册)。本域数据权威优先;读取后把本域注册回写镜像。 */ async function pcReadPluginRegistry() { const out = new Map(); const curDom = pcPanelDomain(); // 'store' | 'community' | '' const localRecs = []; try { const db = await pcIdbOpen('sglv_plugin_host_storage'); const rows = await pcIdbGetAll(db, 'kv'); for (const row of (rows || [])) { const key = String(row && row.key || ''); if (key.indexOf('shared:plugin:') !== 0) continue; const v = row.value; if (v && v.id) { const rec = Object.assign({}, v, { registeredAt: row.updatedAt || 0, regDomain: curDom }); out.set(v.id, rec); localRecs.push(rec); } } try { db.close(); } catch (e) { /* ignore */ } } catch (e) { /* 库不存在 → 没插件接入注册 */ } // ① 跨域镜像合并:镜像里有、本域没有的 → 并入(viaMirror 标记,面板/工具据此区分来源) let mirror = null; try { const raw = GM_getValue(PC_REG_MIRROR_KEY, null); mirror = (raw && typeof raw === 'object' && raw.v === 1 && raw.plugins && typeof raw.plugins === 'object') ? raw : { v: 1, updatedAt: 0, plugins: {} }; } catch (e) { mirror = { v: 1, updatedAt: 0, plugins: {} }; } const now = Date.now(); for (const id of Object.keys(mirror.plugins)) { if (out.has(id)) continue; const m = mirror.plugins[id]; if (!m || m.id !== id || (now - (m.ts || 0)) > PC_REG_MIRROR_TTL) continue; out.set(id, Object.assign({}, m, { viaMirror: true, registeredAt: m.ts || 0 })); } // ② 本域注册回写镜像(同 id 取 ts 较新者;无变化不写,避免 GM 存储写放大) let dirty = false; for (const rec of localRecs) { const prev = mirror.plugins[rec.id]; if (!prev || (rec.ts || rec.registeredAt || 0) >= (prev.ts || 0)) { mirror.plugins[rec.id] = { id: rec.id, name: rec.name, family: rec.family, version: rec.version, versionSource: rec.versionSource, capabilities: rec.capabilities || null, ts: rec.ts || rec.registeredAt || now, regDomain: curDom, }; dirty = true; } } if (dirty) { mirror.updatedAt = now; try { GM_setValue(PC_REG_MIRROR_KEY, mirror); } catch (e) { /* quota 等静默降级 */ } } return out; } /** 探测:按契约里的 detect 信号判断插件「装了没」 */ async function pcDetectPlugin(sig) { if (!sig || !sig.type) return false; try { if (sig.type === 'idb') { const db = await pcIdbOpen(sig.db); const has = db.objectStoreNames.contains(sig.store); try { db.close(); } catch (e) { /* ignore */ } return has; } if (sig.type === 'localStorage') { const keys = pcLocalKeysWithPrefix(sig.prefix); return keys.length > 0; } } catch (e) { /* ignore */ } return false; } /** 版本号比较:a < b → -1;a > b → 1;相等 → 0;无法解析 → null */ function pcCompareVersion(a, b) { if (!a || !b) return null; const pa = String(a).split(/[.\-+]/).map(x => parseInt(x, 10)); const pb = String(b).split(/[.\-+]/).map(x => parseInt(x, 10)); if (!pa.length || pa.some(isNaN) || !pb.length || pb.some(isNaN)) return null; const n = Math.max(pa.length, pb.length); for (let i = 0; i < n; i++) { const x = pa[i] || 0, y = pb[i] || 0; if (x !== y) return x < y ? -1 : 1; } return 0; } /** v1.30.2 当前面板所在的域:'store'(商店域)/ 'community'(社区域)/ ''(未知)。 * 插件注册写在各自运行域的 IndexedDB(按域隔离),跨域读不到 —— * 面板用它与契约的 plugin.domain 比对,把「未注册 + 注册在其他域」标成 * 「需在对应域确认」而不是误导性的「未安装」。 */ function pcPanelDomain() { try { const h = String((typeof location !== 'undefined' && location.hostname) || ''); if (h === 'steamcommunity.com') return 'community'; if (h.indexOf('steampowered.com') !== -1) return 'store'; } catch (e) { /* ignore */ } return ''; } ToolRegistry.register({ name: 'getInstalledPlugins', description: '查询本机已安装的 Steam 油猴插件、它们的版本号,以及是否「装齐了」。' + '数据来自三处合并:① 版本契约(期望清单 + 最低版本)② 各插件通过共享宿主注册的真实版本号 ③ 本地存储探测。' + '适合回答「我的 steam 插件装齐了吗」「steamkit 系列装了哪些」「XX 插件版本过旧吗」「好友管理器装了没」这类问题——' + '不联网查商店,只看本机实际状态。' + '⚠️ 判读口径:只有「注册过」或「有本地存储探测信号」的插件才能确认状态;' + '未注册且无探测信号的会被标为 decidable=false(无法判断),' + '这类插件只是没在当前页面运行过,未必真没装——回答用户时不要把它们说成「未安装」。', parameters: { type: 'object', properties: { family: { type: 'string', description: '只看某个分组:core / social / market / account / badge / utility / ai' }, name: { type: 'string', description: '按名称/别名模糊匹配,如 "steamkit"、"市场"、"wishlist"' }, outdatedOnly: { type: 'boolean', description: '只返回版本低于契约最低要求的插件' }, missingOnly: { type: 'boolean', description: '只返回未检测到的插件(其中 decidable=false 的只是探测不到,未必真没装)' }, refresh: { type: 'boolean', description: '强制重新拉取契约(默认走 12h 缓存)' }, }, }, category: 'knowledge', async execute(params) { const family = params && params.family ? String(params.family).trim() : ''; const nameQ = params && params.name ? String(params.name).trim().toLowerCase() : ''; // ① 契约 let contract = params && params.refresh ? null : pcContractGetCached(); let contractErr = ''; if (!contract) { try { contract = await pcContractFetch(); } catch (e) { contractErr = e.message || String(e); } } if (!contract) { return { success: false, error: '版本契约不可用(无法拉取 ' + PC_CONTRACT_URL + ')' + (contractErr ? ':' + contractErr : '') + ';请检查网络或稍后重试', }; } // ② 运行时注册的真实版本 const registry = await pcReadPluginRegistry(); // ③ 逐个插件:合并「注册版本」与「探测结果」 const list = contract.plugins || []; const rows = []; for (const p of list) { if (family && p.family !== family) continue; if (nameQ && !((p.name || '').toLowerCase().includes(nameQ) || (p.id || '').includes(nameQ))) continue; const reg = registry.get(p.id) || null; // ⚠️ detect 为空 = 该插件没有任何「可跨脚本探测」的信号(要么零存储、要么只用 GM 存储—— // GM 存储跨脚本沙箱隔离,别的脚本读不到)。此时「未注册」只说明它没在本页面运行过, // 不能据此断言「未安装」,否则必然误报。故用 decidable 区分「确认未装」与「无法判断」。 const hasDetect = Array.isArray(p.detect) && p.detect.length > 0; let probed = false; if (!reg && hasDetect) { for (const sig of p.detect) { if (await pcDetectPlugin(sig)) { probed = true; break; } } } const installed = !!reg || probed; const decidable = !!reg || hasDetect; const version = reg ? (reg.version || null) : null; const cmp = (version && p.minVersion) ? pcCompareVersion(version, p.minVersion) : null; const outdated = cmp !== null && cmp < 0; rows.push({ id: p.id, name: p.name, family: p.family, required: p.required === true, installed, decidable, // v1.30.3 registry-mirror = 注册来自另一域的 GM 镜像(非本域 IndexedDB 实时读) detectedBy: reg ? (reg.viaMirror ? 'registry-mirror' : 'registry') : (probed ? 'probe' : null), regDomain: reg ? (reg.regDomain || null) : null, version: version, versionKnown: !!version, minVersion: p.minVersion || null, outdated, lastSeen: reg ? (reg.registeredAt || null) : null, note: (!version && installed) ? '已检测到但未注册版本号(该插件尚未接入 registerPlugin)' : (reg && reg.viaMirror ? ('该插件注册在' + (reg.regDomain === 'community' ? '社区' : '商店') + '域(IndexedDB 按域隔离),版本号经跨域镜像读取') : (!installed ? (decidable ? '未检测到' : '未注册且无探测信号,无法判断是否安装(该插件未在本页面运行过、或只用 GM 存储时探测不到)') : null)), }); } const installedRows = rows.filter(r => r.installed); const missingRows = rows.filter(r => !r.installed); // 保持二分语义(含「无法判断」) const absentRows = rows.filter(r => !r.installed && r.decidable); // 确认未装 const unknownRows = rows.filter(r => !r.installed && !r.decidable); // 无探测信号,判断不了 const outdatedRows = rows.filter(r => r.outdated); const byFamily = {}; for (const r of rows) { const f = r.family || '_other'; if (!byFamily[f]) byFamily[f] = { installed: 0, total: 0, missing: [], unknown: [], outdated: [] }; byFamily[f].total++; if (r.installed) byFamily[f].installed++; else if (!r.decidable) byFamily[f].unknown.push(r.id); else byFamily[f].missing.push(r.id); if (r.outdated) byFamily[f].outdated.push(r.id); } const filtered = (params && params.outdatedOnly) ? outdatedRows : (params && params.missingOnly) ? missingRows : rows; return { success: true, data: { contractVersion: contract.v, contractUpdatedAt: contract.updatedAt, summary: `契约 ${list.length} 个插件:已装 ${installedRows.length},未装 ${absentRows.length},` + `无法判断 ${unknownRows.length},版本过旧 ${outdatedRows.length}`, installed: filtered.filter(r => r.installed), missing: filtered.filter(r => !r.installed), unknown: filtered.filter(r => !r.installed && !r.decidable), outdated: outdatedRows, byFamily, versionKnownCount: installedRows.filter(r => r.versionKnown).length, }, summary: `已装 ${installedRows.length}/${list.length} 个插件(其中 ${installedRows.filter(r => r.versionKnown).length} 个能读到版本号),` + `未装 ${absentRows.length}` + (unknownRows.length ? `,另有 ${unknownRows.length} 个无法判断(未注册且无探测信号,未必真没装):${unknownRows.map(r => r.name).join('、')}` : '') + `,版本过旧 ${outdatedRows.length}` + (outdatedRows.length ? ':' + outdatedRows.map(r => r.name + ' ' + r.version + '<' + r.minVersion).join('、') : ''), }; }, }); // ============================================================ // 剧情时长(SCI)+ 远程数据中枢查询 // ============================================================ // ---- SCI 分档(口径与 SGLV PTW 分析层逐字一致)---- // SCI = (playtime_forever 分钟 / 60) / HLTB 主线时长(小时) // 序章搁浅 <0.35 / 剧情未竟 0.35-0.8 / 剧情通关 0.8-1.3 / 支线探索家 1.3-2.5 / 自由漫游 >2.5 const PC_SCI_BUCKETS = [ { key: 'stalled', max: 0.35, label: '序章搁浅' }, { key: 'unfinished', max: 0.8, label: '剧情未竟' }, { key: 'done', max: 1.3, label: '剧情通关' }, { key: 'explorer', max: 2.5, label: '支线探索家' }, { key: 'free', max: Infinity, label: '自由漫游' }, ]; function pcSciBucket(sci) { for (const b of PC_SCI_BUCKETS) if (sci < b.max) return b; return PC_SCI_BUCKETS[PC_SCI_BUCKETS.length - 1]; } // ---- 年代分桶(须与 hltb-scraper/build-index.js 的 eraOf() 逐字一致)---- // year<2004 → pre04 / year<=2014 → 04-14 / year<=2024 → 15-24 / else 25+ const PC_ERA_BUCKETS = [ { key: 'pre04', label: '2004 前' }, { key: '04-14', label: '2004-2014' }, { key: '15-24', label: '2015-2024' }, { key: '25+', label: '2025+' }, ]; function pcEraBucket(year) { const y = Number(year); if (!Number.isFinite(y) || y <= 0) return null; if (y < 2004) return PC_ERA_BUCKETS[0]; if (y <= 2014) return PC_ERA_BUCKETS[1]; if (y <= 2024) return PC_ERA_BUCKETS[2]; return PC_ERA_BUCKETS[3]; } // ============================================================ // v1.36.0 跨域镜像:游戏库 GM 镜像(社区域也能用「我的游戏库」搜索) // ============================================================ // 问题:SGLV 的游戏库缓存写在 **store 域的 IndexedDB**(IndexedDB / localStorage 按源隔离), // 在 steamcommunity.com 上那个库根本不存在 → 搜索浮窗的「我的游戏库」组直接报错不可用。 // 方案:沿用 v1.30.3「插件注册表跨域镜像」的同款机制 —— **GM 存储是同一脚本跨域共享的** // (与 IndexedDB/localStorage 按源隔离正好相反;GM 只是**按脚本**隔离)。 // 商店域读到权威缓存后写一份**紧凑镜像**(元组数组,百 KB 级)进本脚本的 GM 键; // 社区域读不到本域 IDB 时回退镜像。零 Steam API 配额、毫秒级。 // 代价:镜像可能比本域缓存旧 → 用 ts / stale 如实声明新鲜度,不假装是实时数据。 const PC_LIB_MIRROR_KEY = 'sa_sglv_library_mirror_v1'; const PC_LIB_MIRROR_TTL = 7 * 864e5; // 镜像保鲜期 7 天(超期视为无效) const PC_LIB_MIRROR_STALE_MS = 24 * 3600e3; // 超过 1 天算「偏旧」,须向用户声明 const PC_LIB_MIRROR_WRITE_INTERVAL = 30 * 60 * 1000; // 同域重复写镜像的最小间隔 const PC_LIB_MIRROR_MAX = 5000; // 条数上限,防库爆炸 /** 人话化的数据年龄(给 UI / 提示词用) */ function pcHumanAge(ms) { const mins = Math.max(0, Math.round((Number(ms) || 0) / 60000)); if (mins < 60) return `${mins} 分钟前`; const h = Math.round(mins / 60); if (h < 48) return `${h} 小时前`; return `${Math.round(h / 24)} 天前`; } /** 读跨域镜像(只判结构,TTL 由调用方决定) */ function pcReadLibraryMirror() { try { const raw = GM_getValue(PC_LIB_MIRROR_KEY, null); if (!raw || raw.v !== 1 || !Array.isArray(raw.games)) return { ok: false, reason: 'none' }; const ts = Number(raw.ts) || 0; const ageMs = Math.max(0, Date.now() - ts); const games = []; for (const t of raw.games) { if (!Array.isArray(t)) continue; const appid = Number(t[0]); if (!Number.isFinite(appid)) continue; games.push({ appid, name: String(t[1] || ''), playtime_forever: Number(t[2]) || 0 }); } return { ok: true, games, ts, ageMs, count: games.length, stale: ageMs > PC_LIB_MIRROR_STALE_MS, expired: ageMs > PC_LIB_MIRROR_TTL, }; } catch (e) { return { ok: false, reason: 'error' }; } } /** 写跨域镜像(紧凑元组 [appid, name, playtime],百 KB 级而不是原始 0.5~2MB) */ function pcWriteLibraryMirror(games) { try { const arr = Array.isArray(games) ? games : []; const tuples = []; for (const g of arr) { if (!g) continue; const appid = Number(g.appid); if (!Number.isFinite(appid)) continue; tuples.push([appid, String(g.name || '').slice(0, 120), Math.round(Number(g.playtime_forever) || 0)]); if (tuples.length >= PC_LIB_MIRROR_MAX) break; } if (!tuples.length) return false; GM_setValue(PC_LIB_MIRROR_KEY, { v: 1, ts: Date.now(), domain: PC_DOMAIN_STORE, count: tuples.length, games: tuples, }); return true; } catch (e) { return false; } } /** 节流写镜像:30 分钟内不重复写(避免每次读都往 GM 存储塞百 KB) */ function pcMaybeWriteLibraryMirror(games) { try { const cur = pcReadLibraryMirror(); if (cur.ok && cur.ageMs < PC_LIB_MIRROR_WRITE_INTERVAL) return false; return pcWriteLibraryMirror(games); } catch (e) { return false; } } /** 本域读不到时的兜底:跨域 GM 镜像(v1.36.0) */ function pcLibraryMirrorFallback(localError) { const mir = pcReadLibraryMirror(); if (mir.ok && !mir.expired) { return { ok: true, games: mir.games, viaMirror: true, mirroredAt: mir.ts, ageMs: mir.ageMs, stale: mir.stale, localError, }; } const why = mir.expired ? '跨域镜像已过期(超过 7 天)' : '没有可用的跨域镜像'; return { ok: false, error: `${localError}(${why})。` + '跨域读不到是因为 IndexedDB 按源隔离;' + '请先在 store.steampowered.com 打开一次 Steam AI Agent 面板(会自动建立镜像),之后社区页也能搜游戏库。', mirrorReason: mir.expired ? 'expired' : (mir.reason || 'none'), }; } /** * 读 SGLV 的本地游戏库缓存 → [{appid,name,playtime_forever,...}] * 数据源优先级:①本域 IndexedDB sglv_kv / kv / 'sglv_cached_games'(权威,商店域) * ②v1.36.0 跨域 GM 镜像(社区域回退用,商店域读不到时兜底) * 返回 viaMirror / mirroredAt / ageMs / stale 供调用方声明数据来源与新鲜度。 */ async function pcReadSglvLibrary() { const reg = PLUGIN_CACHE_REGISTRY.sglv; let db = null; try { db = await pcIdbOpen(reg.idb.name); if (!db.objectStoreNames || !db.objectStoreNames.contains(reg.idb.store)) { return pcLibraryMirrorFallback(`IndexedDB ${reg.idb.name} 里没有 ${reg.idb.store} 仓库 —— 「游戏库展示」插件可能从未在本域运行过`); } const raw = await pcIdbGet(db, reg.idb.store, 'sglv_cached_games'); if (raw == null) { return pcLibraryMirrorFallback('未找到游戏库缓存(sglv_cached_games)—— 请先在 Steam 商店页打开「游戏库展示」插件并刷新一次'); } const arr = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.games) ? raw.games : null); if (!arr) return pcLibraryMirrorFallback('游戏库缓存结构无法识别(既不是数组也不是 {games:[] })'); // 商店域读到本域权威缓存 → 顺带刷新跨域镜像(社区域要用) if (pcCurrentDomain() === PC_DOMAIN_STORE) pcMaybeWriteLibraryMirror(arr); return { ok: true, games: arr, viaMirror: false }; } catch (e) { return pcLibraryMirrorFallback('读取游戏库缓存失败:' + (e && e.message || e)); } finally { try { db && db.close(); } catch (e) { /* ignore */ } } } /** * v1.36.0 商店域自动刷新跨域镜像(节流 30 分钟)。 * 目的:让社区域随时能搜「我的游戏库」,而不必用户先在商店域手动触发一次搜索。 * 失败静默 —— 镜像只是加速路径,不该影响主流程。 */ async function pcAutoMirrorLibrary() { try { if (pcCurrentDomain() !== PC_DOMAIN_STORE) return; const mir = pcReadLibraryMirror(); if (mir.ok && mir.ageMs < PC_LIB_MIRROR_WRITE_INTERVAL) return; // 刚建过,不重复读 IDB const lib = await pcReadSglvLibrary(); // 内部成功即写镜像 if (lib.ok && !lib.viaMirror) { console.log(`[SA-Agent] 游戏库跨域镜像已更新(${lib.games.length} 款)`); } } catch (e) { /* ignore:镜像失败不影响主流程 */ } } ToolRegistry.register({ name: 'getStoryData', description: '查询游戏的**主线剧情时长**(HowLongToBeat 口径)与发行年份;并能把「主线时长」和「我的游玩时长」相除,算出剧情完成度 SCI。' + '适合回答「XX 要玩多久才通关」「我到底通没通关 XX」「我的库里哪些游戏买了没玩」「我买的是独立小作还是大作」这类问题——' + 'Steam Web API 结构上答不了(它没有主线时长,也没有逐日历史)。' + '两种模式:① 不传 appids = 读我的游戏库(需在 Steam 商店页打开过「游戏库展示」插件),按剧情时长/完成度/年份/游玩时长排序取前 N;' + '② 传 appids = 精确查这几款。' + '⚠️ HLTB 只能覆盖能对上的部分(实测全量覆盖 54.6%,其余是主机独占/未发售等永久缺口),' + '所以「查不到」= 该游戏没有可用的主线时长数据,**不等于 0 小时**,回答时不要把缺失说成 0。', parameters: { type: 'object', properties: { appids: { type: 'array', items: { type: 'number' }, description: '要查询的 appid 列表(数字)。不传 = 走「我的游戏库」模式' }, sort: { type: 'string', enum: ['story', 'sci', 'year', 'playtime'], description: '排序维度:story=主线时长 / sci=剧情完成度 / year=发行年份 / playtime=游玩时长(默认 story)' }, topN: { type: 'number', description: '返回条数,默认 20,最大 100' }, minStory: { type: 'number', description: '只看主线时长 ≥ N 小时的游戏(库模式有效)' }, }, }, category: 'knowledge', async execute(params) { const topN = Math.max(1, Math.min(Number(params.topN) || 20, 100)); const sortBy = String(params.sort || 'story').trim().toLowerCase(); const minStory = Number(params.minStory) || 0; // 1. 索引加载(懒加载 + 内存缓存,失败静默不抛) const ok = await pcHltbLoad(); if (!ok) { return { success: false, error: `HLTB 索引加载失败:${PC_HLTB_STATE.err || '未知原因'}。剧情时长数据暂不可用 —— 可改用 readPluginCache / getOwnedGames,或稍后重试。`, }; } const manifest = PC_HLTB_STATE.manifest || {}; const coverage = manifest.coverage || null; const eraReference = (manifest.reference && manifest.reference.eraDist) || null; // 2. 指定 appids 模式 const ids = Array.isArray(params.appids) ? params.appids.map(Number).filter((n) => Number.isFinite(n)) : []; if (ids.length) { const rows = ids.slice(0, 100).map((id) => { const h = pcHltbGet(id); const era = h ? pcEraBucket(h.y) : null; return { appid: id, name: null, year: h ? h.y : null, era: era ? era.key : null, eraLabel: era ? era.label : null, storyHours: h ? h.m : null, sci: null, sciLabel: null, playtimeHours: null, }; }); const hit = rows.filter((r) => r.storyHours != null).length; return { success: true, data: { mode: 'appids', indexEntries: manifest.entries || 0, coverage, total: rows.length, matched: hit, rows, note: hit < rows.length ? `${rows.length - hit} 款在 HLTB 索引中无记录(可能是主机独占/未发售/非游戏),不代表主线时长为 0` : null, }, summary: `查询 ${rows.length} 款游戏,${hit} 款在 HLTB 索引中命中主线时长`, }; } // 3. 库模式:授权 → 读缓存(本域 IDB 优先,读不到回退跨域 GM 镜像) if (!pcDataSourceAllowed('sglv')) { return { success: false, error: '用户未授权读取「SGLV 游戏库展示」的数据。如需启用,请在设置 → 数据源 中勾选。', needAuth: true, plugin: 'sglv', }; } // v1.36.0:不再硬拦域 —— pcReadSglvLibrary 内部会在本域读不到时回退跨域镜像 const curDomain = pcCurrentDomain(); const lib = await pcReadSglvLibrary(); if (!lib.ok) { return { success: false, error: lib.error, ...(curDomain !== PC_DOMAIN_STORE ? { needDomain: PC_DOMAIN_STORE, currentDomain: curDomain } : {}), }; } // 4. join + 算 SCI const rows = []; for (const g of lib.games) { const appid = Number(g && g.appid); if (!Number.isFinite(appid)) continue; const h = pcHltbGet(appid); const playMin = Number(g.playtime_forever) || 0; const playHours = Math.round(playMin / 6) / 10; const story = h ? h.m : null; const sci = (story && story > 0) ? (playHours / story) : null; const era = h ? pcEraBucket(h.y) : null; rows.push({ appid, name: (g.name || `App ${appid}`) + '', playtimeHours: playHours, storyHours: story, sci: sci == null ? null : Math.round(sci * 100) / 100, sciLabel: sci == null ? null : pcSciBucket(sci).label, year: h ? h.y : null, era: era ? era.key : null, eraLabel: era ? era.label : null, }); } const totalGames = rows.length; const withStory = rows.filter((r) => r.storyHours != null); const withSci = rows.filter((r) => r.sci != null); // SCI 分档分布 const sciDist = {}; for (const b of PC_SCI_BUCKETS) sciDist[b.key] = 0; for (const r of withSci) sciDist[pcSciBucket(r.sci).key]++; // 年代分布(仅已匹配部分 —— 系统性偏新,必须与人口参考对照) const eraDist = {}; for (const b of PC_ERA_BUCKETS) eraDist[b.key] = 0; for (const r of withStory) { const e = pcEraBucket(r.year); if (e) eraDist[e.key]++; } const filtered = minStory > 0 ? rows.filter((r) => r.storyHours != null && r.storyHours >= minStory) : rows; const cmp = { story: (a, b) => (b.storyHours || 0) - (a.storyHours || 0), sci: (a, b) => (b.sci || 0) - (a.sci || 0), year: (a, b) => (b.year || 0) - (a.year || 0), playtime: (a, b) => (b.playtimeHours || 0) - (a.playtimeHours || 0), }[sortBy] || ((a, b) => (b.storyHours || 0) - (a.storyHours || 0)); const sorted = filtered.slice().sort(cmp); const top = sorted.slice(0, topN); return { success: true, data: { mode: 'library', indexEntries: manifest.entries || 0, coverage, totalGames, // v1.36.0:数据来源与新鲜度(跨域镜像时必须如实声明,不能当成实时数据) source: lib.viaMirror ? 'cross-domain-mirror' : 'local-indexeddb', ...(lib.viaMirror ? { mirroredAt: lib.mirroredAt, mirrorAgeMinutes: Math.max(0, Math.round((lib.ageMs || 0) / 60000)), mirrorAgeText: pcHumanAge(lib.ageMs), stale: !!lib.stale, sourceNote: '游戏库来自商店域缓存的跨域 GM 镜像(IndexedDB 按源隔离,社区域读不到本域库);' + `快照时间 ${pcHumanAge(lib.ageMs)},可能已过期,回答时需声明数据新鲜度。`, } : {}), matched: withStory.length, matchedPct: totalGames ? Math.round((withStory.length / totalGames) * 1000) / 10 : 0, sciSample: withSci.length, sciDist, eraDist, eraReference, eraCaveat: 'eraDist 是「已匹配部分」的年代分布,**系统性偏新**(实测最大 +18.8pt);' + 'eraReference 是 HLTB 全人口参考分布,回答年代相关问题时必须两者对照,不要单看 eraDist。', sortedBy: sortBy, rows: top, truncated: sorted.length > top.length ? sorted.length - top.length : 0, }, summary: `我的库 ${totalGames} 款中 ${withStory.length} 款有主线时长(${totalGames ? Math.round((withStory.length / totalGames) * 1000) / 10 : 0}%),` + `可算 SCI 的 ${withSci.length} 款;按 ${sortBy} 排序取前 ${top.length} 款`, }; }, }); /* ==== saa-auth 认证库 v1.30.2 起改为 @require 分发(libs/saa-auth.lib.js,钉 commit 6789435)==== * 内联副本已删除 —— 双副本必然漂移(历史教训:内联块缺 b72763a 的路径归一化修复, * 带中文/空格路径的签名请求必然 E_DEVICE_SIG)。会话/设备密钥/签名全由库管理, * 主脚本经 window.SAAAuth(pcHubAuthLib)访问,接口不变。 ==== */ // ============================================================ // v1.30.0 远程数据中枢(data-hub)查询 // ============================================================ // ⚠️ 安全约束(防 SSRF / 防越权,五条硬规矩): // 1. **地址由用户在设置里配**,模型永远不能指定 URL;拼装时只认路由白名单。 // 2. 默认只允许 https;明文 http 需要用户显式打开开关(远程必须 TLS —— 设备签名依赖私钥)。 // 3. 内网/本机地址默认拦截(自建内网服务器可显式放行)。 // 4. 凭据**不再手动粘贴 token**:走「设备密钥 + Steam 身份认证」换来的会话, // 会话由 libs/saa-auth.lib.js 管理(设备私钥永不上传,服务端永远拿不到明文默认 token)。 // 5. 路由白名单:只认下面那几条;filters / sort / scope 走本地白名单解析器。 const PC_HUB_CONFIG_KEY = 'sa_hub_config_v2'; const PC_HUB_LEGACY_CONFIG_KEY = 'sa_hub_config'; // v1.26 的 {port, token} —— 主动丢弃 // ★ v1.33.0 默认地址三级回落(§3.5.1):用户显式设置 → 远程 data/hub-endpoint.json → 内置常量。 // baseUrl 为空 = 「回落默认」(用户不改即为默认);想完全不用中枢用单独的 disabled 开关。 const PC_HUB_DEFAULT_BASE_URL = 'https://hub.leansoftx.com'; const PC_HUB_ENDPOINT_REMOTE_URL = 'https://cdn.jsdelivr.net/gh/SmallRob/steam-namespace@main/data/hub-endpoint.json'; const PC_HUB_ENDPOINT_GM_KEY = 'sa_hub_endpoint_cache_v1'; let _pcHubRemoteEndpoint = null; // { baseUrl, fetchedAt }(内存缓存,GM 侧另有一份跨会话) const PC_HUB_FILTERS = { year: 'num', steam_year: 'num', story_hours: 'num', extra_hours: 'num', price: 'num', hltb_id: 'num', type: 'str', name: 'str', name_cn: 'str', }; const PC_HUB_SORTABLE = ['year', 'steam_year', 'story_hours', 'extra_hours', 'price', 'hltb_id']; const PC_HUB_SCOPES = ['all', 'mine', 'unplayed', 'wishlist']; /** 读端点白名单(前缀匹配以支持 /app/:id 这类路径参数) */ const PC_HUB_ROUTES = [ '/health', '/search', '/app/', '/library/', '/owned/', '/profile/', '/report/', '/timeline', '/stats', '/sources', '/analysis', '/filters', '/bias', '/skills', '/accounts', '/tables', '/table/', '/columns/', '/count', '/vocab', '/link', ]; /** 写端点白名单(模型只能触发这几条;v1.33.0 加版本上报与撤销) */ const PC_HUB_WRITE_ROUTES = ['/push/agent', '/report/plugins', '/report/plugins/optout']; function pcHubConfig() { let raw = ''; try { raw = GM_getValue(PC_HUB_CONFIG_KEY, ''); } catch (e) { /* ignore */ } let o = {}; if (raw) { try { o = JSON.parse(raw) || {}; } catch (e) { o = {}; } } const disabled = !!o.disabled; const explicit = String(o.baseUrl || '').trim().replace(/\/+$/, ''); // 三级回落:显式设置 → 远程缓存(已过 https 公网校验)→ 内置常量。disabled 时一律不可用。 let baseUrl = explicit; if (!baseUrl && !disabled) { baseUrl = (_pcHubRemoteEndpoint && _pcHubRemoteEndpoint.baseUrl) || PC_HUB_DEFAULT_BASE_URL; } return { baseUrl: disabled ? '' : baseUrl, explicitBaseUrl: explicit, // 空 = 从未设置 / 已清空(回落默认) disabled, isDefaultUrl: !explicit, // 当前生效的是回落默认地址 allowHttp: !!o.allowHttp, // 允许明文 http(仅内网调试;默认地址走 https 不受影响) allowPrivate: !!o.allowPrivate, // 允许内网/本机地址(自建服务器) }; } /** 远程默认地址热更:拉 data/hub-endpoint.json(GM 缓存 24h + 失败保留旧值)。 * 为什么远程可改:它是「装上就连哪里」的唯一开关点,写死意味着换地址必须发版。 */ function pcHubResolveEndpointRemote() { return new Promise((resolve) => { let cached = null; try { const cachedRaw = GM_getValue(PC_HUB_ENDPOINT_GM_KEY, ''); cached = cachedRaw ? JSON.parse(cachedRaw) : null; } catch (e) { cached = null; } if (cached && cached.baseUrl && (Date.now() - (cached.fetchedAt || 0)) < 24 * 3600 * 1000) { _pcHubRemoteEndpoint = cached; resolve(cached.baseUrl); return; } GM_xmlhttpRequest({ method: 'GET', url: PC_HUB_ENDPOINT_REMOTE_URL, timeout: 15000, responseType: 'text', onload: (res) => { try { const j = JSON.parse(res.responseText || '{}'); const u = j && j.baseUrl ? new URL(String(j.baseUrl)) : null; // 安全校验:远程配置只接受 https 公网地址(与 assertSafeRequestUrl 默认口径一致) if (!u || u.protocol !== 'https:' || u.username || u.password) throw new Error('bad'); const rec = { baseUrl: String(j.baseUrl).replace(/\/+$/, ''), fetchedAt: Date.now() }; _pcHubRemoteEndpoint = rec; try { GM_setValue(PC_HUB_ENDPOINT_GM_KEY, JSON.stringify(rec)); } catch (e) { /* ignore */ } resolve(rec.baseUrl); } catch (e) { resolve(null); } // 载荷不合法 → 保留旧值/内置常量 }, onerror: () => resolve(null), ontimeout: () => resolve(null), }); }); } function pcSaveHubConfig(cfg) { try { GM_setValue(PC_HUB_CONFIG_KEY, JSON.stringify({ baseUrl: String(cfg.baseUrl || '').trim().replace(/\/+$/, ''), // 允许空串 = 回落默认 disabled: !!cfg.disabled, allowHttp: !!cfg.allowHttp, allowPrivate: !!cfg.allowPrivate, })); // 老配置(端口 + 手填 token)主动清掉:token 已不需要,留着是隐患 try { GM_setValue(PC_HUB_LEGACY_CONFIG_KEY, ''); } catch (e) { /* ignore */ } return true; } catch (e) { return false; } } /** 路由白名单:模型只能触发白名单里的端点 */ function pcHubRouteAllowed(path, method) { const p = String(path || ''); if (String(method || 'GET').toUpperCase() === 'POST') { return PC_HUB_WRITE_ROUTES.some((r) => p === r); } return PC_HUB_ROUTES.some((r) => ( // 以 / 结尾 = 前缀匹配(/app/、/table/…);否则精确匹配 + 其子路径(/skills、/skills/:id) r.endsWith('/') ? p.startsWith(r) : (p === r || p.startsWith(r + '/')) )); } /** 本地过滤串解析(白名单 + 数值/文本校验)——与服务端同一套口径 */ function pcHubParseFilters(str) { const out = []; if (!str) return out; for (const raw of String(str).split(/[,;]/)) { const p = raw.trim(); if (!p) continue; const m = p.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*(>=|<=|!=|=|>|<)\s*(.+)$/); if (!m) throw new Error(`无法解析过滤条件 "${p}"(示例:year>=2020, story_hours>10)`); const [, field, op, valRaw] = m; const kind = PC_HUB_FILTERS[field]; if (!kind) throw new Error(`未知过滤字段 "${field}",可用:${Object.keys(PC_HUB_FILTERS).join(' / ')}`); let val = valRaw.trim(); if (kind === 'num') { const n = Number(val); if (!Number.isFinite(n)) throw new Error(`字段 "${field}" 需要数值,收到 "${val}"`); val = n; } else if (/["'`;()\\]/.test(val)) { throw new Error(`字段 "${field}" 的值含不允许的字符:"${val}"`); } out.push({ field, op, val }); } return out; } /** 认证库(libs/saa-auth.lib.js,@require 注入)—— 缺失则远程中枢不可用 */ function pcHubAuthLib() { try { return (typeof window !== 'undefined' && window.SAAAuth) ? window.SAAAuth : null; } catch (e) { return null; } } /** 把用户配置的地址同步给认证库(地址变了要同步,否则签名的是旧 host) */ function pcHubSyncAuth() { const auth = pcHubAuthLib(); if (!auth) return null; const cfg = pcHubConfig(); if (cfg.baseUrl) { try { auth.configure({ baseUrl: cfg.baseUrl }); } catch (e) { /* ignore */ } } return auth; } /** * 已认证会话的账号(Steam OpenID 验签得来)。 * ⭐ 这是**服务端认可的归属** —— 服务端会拿它跟上报载荷的 subject 比对, * 不一致直接 E_PUSH_SUBJECT 拒收。所以本地解析出的账号 * (cookie / 设置 / 库主账号回退)只作辅助,冲突时以它为准。 * @returns {string} 空串 = 还没绑定账号 */ function pcHubSessionSubject() { try { const auth = pcHubAuthLib(); const st = auth && typeof auth.getState === 'function' ? auth.getState() : null; return (st && st.subject) ? String(st.subject) : ''; } catch (e) { return ''; } } /** * 连通性探测:直接打 /health(该端点免鉴权),**不要求先有会话**。 * 用途:用户填完地址先测通不通,再去绑账号 —— 否则「测试连接」会一直报"没会话"。 */ function pcHubPing() { const cfg = pcHubConfig(); if (!cfg.baseUrl) return Promise.reject(new Error('NO_BASE_URL')); const u = assertSafeRequestUrl(cfg.baseUrl + '/health', { allowPrivate: cfg.allowPrivate, allowAiEndpoint: true }); if (u.protocol !== 'https:' && !cfg.allowHttp) return Promise.reject(new Error('INSECURE_BASE_URL')); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: cfg.baseUrl + '/health', timeout: 15000, headers: { 'Accept': 'application/json' }, onload: (r) => { let body = null; try { body = JSON.parse(r.responseText); } catch (e) { body = null; } if (r.status < 200 || r.status >= 300) { reject(new Error((body && body.error) ? String(body.error) : ('HTTP ' + r.status))); return; } if (!body) { reject(new Error('数据中枢返回的不是合法 JSON')); return; } resolve(body); }, onerror: () => reject(new Error('无法连接远程数据中枢(服务不可达?)')), ontimeout: () => reject(new Error('远程数据中枢请求超时')), }); }); } /** * 请求远程数据中枢。 * 会话 / 设备签名 / 自动刷新 / 401 重试 全部由 saa-auth 负责; * 这里只负责:路由白名单 + 地址安全校验 + 把业务错误翻译成人话。 */ async function pcHubRequest(path, query) { const cfg = pcHubConfig(); if (!cfg.baseUrl) throw new Error('NO_BASE_URL'); if (!pcHubRouteAllowed(path, 'GET')) throw new Error('ROUTE_NOT_ALLOWED'); const auth = pcHubSyncAuth(); if (!auth) throw new Error('NO_AUTH_LIB'); const qs = new URLSearchParams(query || {}).toString(); const full = qs ? `${path}?${qs}` : path; const u = assertSafeRequestUrl(cfg.baseUrl + full, { allowPrivate: cfg.allowPrivate, allowAiEndpoint: true }); if (u.protocol !== 'https:' && !cfg.allowHttp) throw new Error('INSECURE_BASE_URL'); const res = await auth.request(full, { method: 'GET' }); if (!res || res.status < 200 || res.status >= 300) { const d = (res && res.data) || null; const code = (d && d.code) ? String(d.code) : ''; const hint = (d && d.hint) ? String(d.hint) : ''; const msg = (d && (d.error || d.message)) ? String(d.error || d.message) : ('HTTP ' + (res && res.status)); // ⭐ 与 pcHubPost 保持一致:带上 code / hint。 // 少了它们,「[E_PUSH_OWNER] owner 不匹配」这类**可操作**的错误 // 只剩一句干巴巴的文案,用户无从下手。 throw new Error((code ? '[' + code + '] ' : '') + msg + (hint ? '(' + hint + ')' : '')); } if (!res.data) throw new Error('数据中枢返回的不是合法 JSON'); return res.data; } ToolRegistry.register({ name: 'searchLocalDb', description: '查询「数据中枢」(data-hub) 的个人游戏数据资产库 —— SQLite + FTS5 全文索引,支持关键字 + 结构化过滤 + 排序。' + '适合回答「我的库里剧情超过 20 小时的游戏有哪些」「2015 年后发售的独立游戏」「买过但一次没玩的游戏」「价格低于 XX 的游戏」这类**需要跨维度筛选**的问题' + '(readPluginCache 只能整读一个缓存键,做不了筛选)。' + '⚠️ 数据来自用户自己用 data-hub 工具链沉淀的库,**不是 Steam 实时数据**;' + '本工具只在用户已配置远程数据中枢地址时才会出现在工具列表里,届时若服务不可达请改用 readPluginCache / getOwnedGames,不要编造结果。' + '过滤字段是白名单:year / steam_year / story_hours / extra_hours / price / hltb_id(数值)+ type / name / name_cn(文本);' + 'scope 取 all / mine / unplayed / wishlist。', parameters: { type: 'object', properties: { q: { type: 'string', description: '关键字(中英文均可;中文按二元组匹配,单个汉字自动降级为子串匹配)' }, scope: { type: 'string', enum: ['all', 'mine', 'unplayed', 'wishlist'], description: '范围过滤,默认 all' }, filters: { type: 'string', description: '结构化过滤,逗号分隔,如 "year>=2020, story_hours>20, type=game"' }, sort: { type: 'string', description: '排序字段(可排序:year / steam_year / story_hours / extra_hours / price / hltb_id),加 - 前缀为倒序' }, limit: { type: 'number', description: '返回条数,默认 20,最大 100' }, offset: { type: 'number', description: '分页偏移,默认 0' }, appid: { type: 'number', description: '查单款游戏的完整档案(发行年/剧情时长/价格历史/成就完成率)时用这个,不要与 q 混用' }, }, }, category: 'knowledge', // ⚠️ 门槛在「配置」而不是「服务在线」:令牌没配 = 这台机器根本没搭 data-hub,喂给模型只会换来一条死路引导; // 配了令牌但服务临时掉线仍暴露(execute 的报错可引导用户去启动服务)。 enabled: () => !!pcHubConfig().baseUrl, async execute(params) { const cfg = pcHubConfig(); if (!cfg.baseUrl) { return { success: false, error: '远程数据中枢已停用。请在「设置 → 数据源 → 远程数据中枢」勾选启用,' + '并点「绑定 Steam 账号」完成一次身份认证;或改用 readPluginCache / getOwnedGames 回答这类问题。', needConfig: true, }; } const limit = Math.max(1, Math.min(Number(params.limit) || 20, 100)); const offset = Math.max(0, Number(params.offset) || 0); // ---- 单条档案 ---- if (params.appid != null && params.appid !== '') { const appid = Number(params.appid); if (!Number.isFinite(appid)) return { success: false, error: `appid 非法:${params.appid}` }; let r; try { r = await pcHubRequest(`/app/${appid}`, {}); } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } const app = r.app || {}; return { success: true, data: { mode: 'app', appid, name: app.name || null, nameCn: app.name_cn || null, year: app.hltb_year || app.release_year || null, storyHours: app.story_hours || null, extraHours: app.extra_hours || null, priceCny: app.price_cny || null, type: app.type || null, owned: r.owned || null, priceHistory: r.priceHistory || [], rarestAchievements: r.rarestAchievements || [], }, summary: `${app.name || appid}:${app.hltb_year || app.release_year || '—'} 年,` + `主线 ${app.story_hours || '—'} 小时,支线 ${app.extra_hours || '—'} 小时`, }; } // ---- 搜索 ---- const scope = String(params.scope || 'all').trim().toLowerCase(); if (PC_HUB_SCOPES.indexOf(scope) < 0) { return { success: false, error: `未知 scope "${scope}",可选:${PC_HUB_SCOPES.join(' / ')}` }; } let sort = String(params.sort || '').trim(); if (sort) { const key = sort.charAt(0) === '-' ? sort.slice(1) : sort; if (PC_HUB_SORTABLE.indexOf(key) < 0) { return { success: false, error: `不可排序字段 "${key}",可排序:${PC_HUB_SORTABLE.join(' / ')}` }; } } let filters = ''; try { pcHubParseFilters(params.filters); filters = String(params.filters || '').trim(); } catch (e) { return { success: false, error: String(e && e.message || e) }; } const query = { q: String(params.q || '').trim(), scope, filters, sort, limit, offset }; let r; try { r = await pcHubRequest('/search', query); } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } const items = (r.items || []).map((it) => ({ appid: it.appid, name: it.name || null, nameCn: it.name_cn || null, year: it.hltb_year || it.release_year || null, storyHours: it.story_hours || null, extraHours: it.extra_hours || null, priceCny: it.price_cny || null, type: it.type || null, })); // 库里有几个 Steam 账号?(未登录时也能让模型知道 scope=all 是**全库**数据) let accInfo = null; try { const acc = await pcHubAccounts(); if (acc && acc.accounts && acc.accounts.length) { const pm = acc.accounts.find((x) => x.steamid === acc.primary) || {}; accInfo = { accountCount: acc.accounts.length, primary: acc.primary || null, primaryName: pm.displayName || null, accounts: acc.accounts.map((x) => ({ steamid: x.steamid, displayName: x.displayName || null, isPrimary: x.isPrimary })), }; } } catch (e) { /* 拿不到就算了,不影响查询本身 */ } return { success: true, data: { mode: 'search', total: r.total || 0, limit, offset, scope, q: r.q || '', matchMode: r.mode || 'fts', items, note: r.total > items.length ? `共 ${r.total} 条,本次返回 ${items.length} 条,可用 offset 翻页` : null, library: accInfo, libraryNote: (accInfo && accInfo.accountCount > 1) ? `库里共有 ${accInfo.accountCount} 个 Steam 账号(主账号:${accInfo.primaryName || accInfo.primary})。` + 'scope=all / scope=mine 都是**全库口径**(mine=库内任何人拥有),不是某个人的;' + '要按人查请用 localDbProfile,或在 localDbProfile / pushObservation 里指定 steamid。' : null, }, summary: `命中 ${r.total || 0} 条(scope=${scope}${r.q ? ',q=' + r.q : ''}),返回前 ${items.length} 条` + (accInfo && accInfo.accountCount > 1 ? `|库内 ${accInfo.accountCount} 个账号,本次为全库口径` : ''), }; }, }); /** 把中枢的底层错误翻译成人话 */ function pcHubErrorText(e, cfg) { const msg = String(e && e.message || e); const c = cfg || pcHubConfig(); if (msg === 'NO_BASE_URL') return '远程数据中枢已停用 —— 请到「设置 → 数据源 → 远程数据中枢」勾选启用(默认地址 hub.leansoftx.com)。'; if (msg === 'NO_AUTH_LIB') return '认证库未加载(saa-auth.lib.js)—— 远程数据中枢不可用,请更新插件。'; if (msg === 'ROUTE_NOT_ALLOWED') return '请求的接口不在白名单内(已拦截)。'; if (msg === 'INSECURE_BASE_URL') return '远程地址必须用 https(或在设置里显式允许明文 HTTP)。'; if (/已拦截对内网/.test(msg)) return '远程地址指向内网/本机 —— 请在设置里勾选「允许内网地址」。'; if (/已拦截非 http/.test(msg)) return '远程地址协议不合法(只支持 http/https)。'; if (/无法连接|连接失败|网络错误|请求超时/.test(msg)) { return `无法连接远程数据中枢(${c.baseUrl || '未配置'})—— 请检查地址与服务器状态。`; } if (/未授权|E_UNAUTHORIZED|E_SESSION|E_DEVICE|E_PROOF|E_REFRESH|E_STEAM/.test(msg)) { return '会话未认证或已失效 —— 请到「设置 → 数据源 → 远程数据中枢」重新「绑定 Steam 账号」。'; } if (/NEEDS_STEAM_LOGIN/.test(msg)) { return '还没有可用会话 —— 请到「设置 → 数据源 → 远程数据中枢」点「绑定 Steam 账号」完成一次身份认证。'; } // 推送侧的账号/库归属问题:给可操作的指引,别只把服务端原文丢给用户 if (/E_PUSH_SUBJECT/.test(msg)) { return '上报的账号与当前认证账号不一致,已被服务端拒绝 —— ' + '请确认浏览器登录的 Steam 账号与「绑定 Steam 账号」的账号是同一个。'; } if (/E_PUSH_OWNER/.test(msg)) { return '连到的不是插件预期的那个库(owner 不匹配)—— ' + '请在「设置 → 数据源 → 远程数据中枢」确认服务地址指向的是你的 data-hub。'; } if (/E_PUSH_RATE/.test(msg)) { return '上报过于频繁(服务端限流)—— 请稍候再试,别连续重复上报同一批结论。'; } return '数据中枢查询失败:' + msg; } // ============================================================ // v1.27.0 技能库(skills)—— agent 按需读取操作技能 // ============================================================ // ⭐ 技能的唯一事实来源是 **data-hub/skills/*/SKILL.md**,由 data-hub 自己解析并通过 // GET /skills 提供。本 agent 的取用优先级: // ① 远程 data-hub 的 /skills(最新、与服务端 schema 一致) // ② CDN 分发副本(@resource,纯 JSON,没装 data-hub 时的兜底) // ③ 内置最小索引(连 data-hub 都没有时,至少告诉模型有哪些技能) // 这样「CLI agent 用 hubq 读技能」与「浏览器 agent 用 readSkill 读技能」读到的是同一份内容。 const SKILLS_RESOURCE_NAME = 'saaSkills'; const SKILLS_RESOURCE_URL_DEFAULT = 'https://raw.githubusercontent.com/SmallRob/steam-namespace/refs/heads/main/data/saa-skills.json'; /** 内置最小索引(③ 降级):只列技能与要点,不含正文 */ const SKILLS_FALLBACK = { skills: [{ id: 'hub-skill', title: 'data-hub 查询技能(hubq)', summary: '本地 Steam 数据资产库的查询与分析技能:意图路由、where DSL、画像口径、报告产出。', version: 'fallback', sections: [], agentTools: ['readSkill', 'searchLocalDb', 'readPluginCache', 'localDbProfile', 'pushInsight', 'pushObservation'], commands: [], }], }; /** * 技能正文的回灌预算(字符)。 * ⚠️ 必须小于 CONFIG.TOOL_RESULT_MAX_CHARS(12000),否则整份正文会被 * truncateToolResult 截成 JSON 字符串(模型读到的是一坨带引号的残骸)。 * 超预算时明确告知「已截断 + 可按 section 精确读取」,不假装读全了。 */ const SKILL_CONTENT_MAX_CHARS = 6000; /** 技能缓存(同一会话内只取一次,除非 force) */ const _skillsState = { skills: null, source: null, full: false }; function skillsGetRemoteUrl() { let url = SKILLS_RESOURCE_URL_DEFAULT; try { const raw = GM_getValue('sa_skills_url', ''); if (raw && /^https?:\/\//i.test(raw)) url = String(raw).trim(); } catch (e) { /* ignore */ } return url; } /** * 拉取技能库:① data-hub → ② CDN @resource → ③ 内置索引 * ⚠️ data-hub 的 GET /skills 只返回**索引**(有小节标题、没有正文)—— * 要读正文必须再取 GET /skills/:id;CDN 副本自带正文。 */ async function loadSkills(force) { if (_skillsState.skills && !force) return _skillsState; const cfg = pcHubConfig(); // ① 远程 data-hub(返回 { ok, data: { skills, dir, count } }) if (cfg.baseUrl) { try { const r = await pcHubRequest('/skills', {}); const skills = (r && r.data && r.data.skills) || []; if (skills.length) { _skillsState.skills = skills; _skillsState.source = 'data-hub'; _skillsState.full = false; try { GM_setValue('sa_skills_cache', JSON.stringify({ skills, ts: Date.now() })); } catch (e) { /* ignore */ } return _skillsState; } } catch (e) { /* 落到下一级 */ } } // ② CDN 分发副本(纯 JSON,自带正文,不执行远程代码) try { const text = GM_getResourceText(SKILLS_RESOURCE_NAME); if (text) { const payload = JSON.parse(text); if (payload && Array.isArray(payload.skills) && payload.skills.length) { _skillsState.skills = payload.skills; _skillsState.source = 'cdn'; _skillsState.full = true; return _skillsState; } } } catch (e) { /* 落到下一级 */ } // ③ 内置最小索引(只有名字与要点,没有正文) _skillsState.skills = SKILLS_FALLBACK.skills; _skillsState.source = 'fallback'; _skillsState.full = false; return _skillsState; } /** 取技能全文(含小节正文)—— data-hub 走 /skills/:id,CDN 副本自带 */ async function loadSkillFull(id) { const st = await loadSkills(false); if (st.full) { const hit = (st.skills || []).find((s) => s.id === id); if (hit) return hit; } if (st.source === 'data-hub') { try { const r = await pcHubRequest(`/skills/${encodeURIComponent(id)}`, {}); const sk = (r && r.data) || r; if (sk && sk.id) return sk; } catch (e) { /* 落到 null */ } } return null; } function skillsFind(list, name) { const key = String(name || '').trim().toLowerCase(); if (!key) return null; return (list || []).find((s) => s.id.toLowerCase() === key) || (list || []).find((s) => s.id.toLowerCase().indexOf(key) >= 0) || (list || []).find((s) => String(s.title || '').toLowerCase().indexOf(key) >= 0) || null; } function skillsSearch(list, query, limit) { const q = String(query || '').trim().toLowerCase(); const max = Math.max(1, Math.min(Number(limit) || 8, 30)); if (!q) return []; const out = []; for (const s of (list || [])) { for (const sec of (s.sections || [])) { const body = String(sec.body || ''); if (!body) continue; // 索引态小节没有正文,跳过 const idx = body.toLowerCase().indexOf(q); if (idx < 0) continue; out.push({ id: s.id, title: s.title, sectionId: sec.id, sectionTitle: sec.title, snippet: body.slice(Math.max(0, idx - 60), Math.max(0, idx - 60) + 220).replace(/\n+/g, ' '), }); if (out.length >= max) return out; } } return out; } ToolRegistry.register({ name: 'readSkill', description: '读取本机的**操作技能库**(skills)—— 每个技能是一份「遇到这类问题该怎么用工具」的操作手册。' + '适合处理**复杂多步任务**(需要跨表筛选 + 交叉验证 + 出报告/图表):先读技能拿到意图路由与口径纪律,再按路由调用工具。' + '典型场景:问「我的游戏库是什么画像」「帮我统计我的厂商集中度」「出一份库存盘点报告」。' + '技能来源优先级:远程 data-hub 的 /skills(最新)→ CDN 分发副本 → 内置最小索引。' + '⚠️ 技能只给**操作指引与口径**,不代替事实;事实仍要靠工具查。' + '用法:不传 name = 列出全部技能;传 name = 读全文;传 name+section = 只读某一小节;传 query = 全文检索片段。', parameters: { type: 'object', properties: { name: { type: 'string', description: '技能 id(如 hub-skill)。不传 = 列出全部技能' }, section: { type: 'string', description: '小节 id 或标题(先用不传 name 的模式看小节列表)' }, query: { type: 'string', description: '在技能正文里检索关键词,返回命中片段' }, limit: { type: 'number', description: '检索结果条数,默认 8,最大 30' }, force: { type: 'boolean', description: '强制重新拉取(默认用会话缓存)' }, }, }, category: 'knowledge', async execute(params) { const p = params || {}; let st; try { st = await loadSkills(!!p.force); } catch (e) { return { success: false, error: '技能库加载失败:' + String(e && e.message || e) }; } const list = st.skills || []; // ① 列出全部(索引就够) if (!p.name && !p.query) { return { success: true, data: { source: st.source, skills: list.map((s) => ({ id: s.id, title: s.title, summary: s.summary, version: s.version, chars: s.chars, sections: (s.sections || []).map((x) => ({ id: x.id, title: x.title })), agentTools: s.agentTools || [], })), }, summary: `技能库(来源 ${st.source})共 ${list.length} 个技能:` + list.map((s) => s.id).join(' / '), }; } // ② 检索(需要正文) if (p.query && !p.name) { const fulls = []; for (const s of list) { const f = await loadSkillFull(s.id); if (f) fulls.push(f); } const hits = skillsSearch(fulls, p.query, p.limit); return { success: true, data: { source: st.source, hits }, summary: hits.length ? `命中 ${hits.length} 处:${hits.map((h) => `${h.id}/${h.sectionTitle}`).join('、')}` : `技能库里没有匹配「${p.query}」的内容`, }; } // ③ 读技能 const skill = skillsFind(list, p.name); if (!skill) { return { success: false, error: `技能 "${p.name}" 不存在`, data: { available: list.map((s) => s.id) }, }; } const full = await loadSkillFull(skill.id); if (!full) { return { success: false, error: `技能「${skill.title}」的正文暂时取不到(当前来源 ${st.source} 只有索引)—— ` + '请确认远程数据中枢地址已配置且服务可达,或稍后重试。', data: { id: skill.id, source: st.source, sections: (skill.sections || []).map((x) => ({ id: x.id, title: x.title })), }, }; } if (p.section) { const secs = full.sections || []; const sec = secs.find((x) => x.id === p.section) || secs.find((x) => x.title === p.section) || secs.find((x) => String(x.id).toLowerCase().indexOf(String(p.section).toLowerCase()) >= 0); if (!sec) { return { success: false, error: `技能 "${skill.id}" 没有小节 "${p.section}"`, data: { available: secs.map((x) => ({ id: x.id, title: x.title })) }, }; } return { success: true, data: { source: st.source, id: full.id, title: full.title, section: sec.id, sectionTitle: sec.title, content: sec.body }, summary: `${full.id} · ${sec.title}`, }; } // 正文预算:超了就说清楚,别让模型以为读全了 const raw = String(full.content || ''); const capped = raw.length > SKILL_CONTENT_MAX_CHARS; const content = capped ? raw.slice(0, SKILL_CONTENT_MAX_CHARS) : raw; const outline = (full.sections || []).map((x) => ({ id: x.id, title: x.title })); return { success: true, data: { source: st.source, id: full.id, title: full.title, summary: full.summary, content, chars: full.chars, contentChars: raw.length, truncated: capped, sections: outline, agentTools: full.agentTools || [], commands: full.commands || [], hint: capped ? `技能正文共 ${raw.length} 字符,已截断到 ${SKILL_CONTENT_MAX_CHARS} 字符。` + `需要完整内容请用 section 参数精确读取小节(可选:${outline.map((x) => x.title).join(' / ')})。` : null, }, summary: `技能「${full.title}」共 ${full.chars} 字符 / ${(full.sections || []).length} 个小节` + (capped ? `(正文已截断到 ${SKILL_CONTENT_MAX_CHARS} 字符,可按小节精确读取)` : ''), }; }, }); // ============================================================ // v1.27.0 玩家画像(走 data-hub 的 /profile 与 /report) // ============================================================ ToolRegistry.register({ name: 'localDbProfile', description: '从「数据中枢」(data-hub) 取**玩家数据画像**与画像报告。' + '画像含:未开封率、时长阶梯(7 档)、清灰倒计时、被冷落的神作、最亏/最值、每小时价值、人格称号+稀有度。' + '适合回答「我的游戏库是什么画像」「我是不是囤货党」「我买的东西值不值」「给我一份库存盘点报告」。' + '⚠️ 回答前必看 data.library.durationEvidence:为 "missing" 表示库内没有游玩时长数据,' + '正确答案是「算不了」而不是「你 100% 没玩过」;价格类指标是**当前折后价**不是实付价;' + '好评率只覆盖部分游戏,榜单要带覆盖率。' + 'format=markdown 返回可读的 Markdown 报告正文;format=html 返回报告的**落盘路径**(完整版含内联 SVG 图表、零外链,' + '适合给用户直接打开看),模型解读请依据 profile 字段。', parameters: { type: 'object', properties: { steamid: { type: 'string', description: 'SteamID64;不传则用设置里配置的账号' }, format: { type: 'string', enum: ['json', 'html', 'markdown'], description: 'json=结构化画像;html=带图表的报告;markdown=纯文本报告。默认 json' }, topN: { type: 'number', description: '榜单条数(最亏/最值/沉默库存),默认 5,最大 20' }, minReviews: { type: 'number', description: '「被冷落的神作」的最低评价数,默认 1000' }, minRating: { type: 'number', description: '「被冷落的神作」的最低好评率,默认 90' }, }, }, category: 'knowledge', enabled: () => !!pcHubConfig().baseUrl, async execute(params) { const cfg = pcHubConfig(); if (!cfg.baseUrl) { return { success: false, error: '远程数据中枢已停用。请在「设置 → 数据源 → 远程数据中枢」勾选启用,并点「绑定 Steam 账号」完成身份认证。', needConfig: true, }; } const p = params || {}; // 身份解析:显式参数 > 设置配置 > cookie 检测 > 库主账号回退(未登录也能出画像) const identity = await pcHubResolveIdentity(p.steamid); const sid = identity.steamId; if (!sid) { return { success: false, error: identity.note || '缺少 steamid —— 请让用户在问题里给出 SteamID,或在设置里配置默认账号。' }; } const format = String(p.format || 'json').toLowerCase(); let r; try { r = await pcHubRequest(`/profile/${encodeURIComponent(sid)}`, { top: p.topN || 5, minReviews: p.minReviews || 1000, minRating: p.minRating || 90, }); } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } const pf = r.profile || r.data || r; const lib = pf.library || {}; const ld = pf.ladder || {}; const cn = pf.countdown || {}; const profileData = { steamid: sid, identitySource: identity.identitySource || null, identityNote: identity.note || null, headline: pf.headline || null, persona: pf.persona || null, library: lib, ladder: (ld.tiers || []).map((t) => ({ label: t.label, count: t.count, pct: t.pct })), neverPlayedPct: ld.neverPlayedPct, durationEvidence: lib.durationEvidence || null, valuePerHourCny: (pf.value || {}).valuePerHourCny, stockValueCny: (pf.value || {}).stockValueCny, countdown: { status: cn.status, reason: cn.reason, etaDate: cn.etaDate, etaDays: cn.etaDays, dailyMinutes: cn.dailyMinutes }, neglectedCount: (pf.neglected || {}).count, worst: ((pf.ranks || {}).worst || []).map((x) => ({ appid: x.appid, name: x.name, priceCny: x.priceCny, playtimeMinutes: x.playtimeMinutes, costPerHourCny: x.costPerHourCny })), best: ((pf.ranks || {}).best || []).map((x) => ({ appid: x.appid, name: x.name, priceCny: x.priceCny, playtimeHours: x.playtimeHours, costPerHourCny: x.costPerHourCny })), disclaimer: pf.disclaimer || [], }; // ---- json:结构化画像(默认)---- if (format === 'json') { return { success: true, data: profileData, summary: `${(pf.headline || {}).text || ''}` + (identity.note ? `|⚠️ ${identity.note}` : ''), }; } // ---- 报告(markdown / html)---- try { const wantHtml = format === 'html'; const rr = await pcHubRequest(`/report/${encodeURIComponent(sid)}`, { format: wantHtml ? 'html' : 'markdown', save: wantHtml ? 1 : 0, }); const content = (rr && rr.content) || ''; if (wantHtml) { // HTML 报告是「给人看」的(内联 SVG 图表、零外链),体积大 —— // 不整份塞进模型上下文,改为落盘给路径,模型依据 profile 字段解读。 return { success: true, data: { steamid: sid, format: 'html', bytes: (rr && rr.bytes) || content.length, savedPath: (rr && rr.savedPath) || null, profile: profileData, note: 'HTML 报告是给人看的完整版(含内联 SVG 图表、零外链),已落盘;模型解读请依据 profile 字段。', }, summary: `已生成 HTML 画像报告(${(rr && rr.bytes) || content.length} 字节)` + ((rr && rr.savedPath) ? `,已存盘:${rr.savedPath}` : ''), }; } return { success: true, data: { steamid: sid, format: 'markdown', bytes: content.length, content, profile: profileData }, summary: `已生成 Markdown 画像报告(${content.length} 字符)`, }; } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } }, }); // ============================================================ // v1.29.0 身份层:未登录降级 + actor 标识 // ============================================================ /** 库内账号清单的缓存(TTL 60s —— 库可能随时被同步工具加账号) */ let _accountsCache = null; let _accountsAt = 0; let _accountsBase = ''; const ACCOUNTS_TTL_MS = 60 * 1000; /** * 取库内 Steam 账号清单(含 owner 与主账号) * @returns {object|null} { owner, primary, accounts[] } —— 服务不可达时返回 null */ async function pcHubAccounts(force) { const cfg = pcHubConfig(); if (!cfg.baseUrl) return null; // 换地址 = 换一台服务器的库 → 缓存必须失效(owner 都不一样) if (cfg.baseUrl !== _accountsBase) { _accountsCache = null; _accountsBase = cfg.baseUrl; } if (!force && _accountsCache && (Date.now() - _accountsAt) < ACCOUNTS_TTL_MS) return _accountsCache; try { const r = await pcHubRequest('/accounts', {}); const d = (r && r.data) || r; _accountsCache = { owner: d.owner || null, primary: d.primary || null, accounts: d.accounts || [], }; _accountsAt = Date.now(); return _accountsCache; } catch (e) { return null; // 服务不可达 / 版本太旧 → 走「拿不到就明说」 } } /** * 身份解析(异步,带库主账号回退) * * ⚠️ 返回对象的身份字段名是 **steamId**(大写 I),调用侧别写成 steamid。 * * @returns {object} { steamId, source, identified, identitySource, note } * identified=true → cookie 检测到当前登录用户,或用户主动声明的默认账号 * identified=false → 系统兜底猜的(库主账号)—— **不谎称**是当前用户 */ async function pcHubResolveIdentity(explicit) { const fromParam = String(explicit || '').trim(); if (fromParam) { return { steamId: fromParam, source: '参数', identified: true, identitySource: 'param', note: null }; } const configured = getConfiguredSteamId(); if (configured) { return { steamId: configured, source: '设置', identified: true, identitySource: 'settings', note: null }; } const detected = detectSteamId(); if (detected) { return { steamId: detected, source: '自动检测', identified: true, identitySource: 'cookie', note: null }; } // ⭐ 回退:库里的主账号(account.is_primary=1) const acc = await pcHubAccounts(); if (acc && acc.primary) { const name = (acc.accounts.find((x) => x.steamid === acc.primary) || {}).displayName || acc.primary; return { steamId: acc.primary, source: '库主账号', identified: false, identitySource: 'library-primary', note: '未检测到当前登录的 Steam 用户(未登录且未配置 SteamID)—— 已回退到库里的主账号 ' + name + '(' + acc.primary + ')。数据归属该账号,但发起者身份未标识。', }; } return { steamId: '', source: '', identified: false, identitySource: '', note: '未检测到当前登录的 Steam 用户(未登录且未配置 SteamID),库里也没有主账号。' + '请在「设置 → 接口服务」配置 SteamID,或在问题里直接给出账号。', }; } /** 本机 agent 实例标识(持久化 —— 区分「哪个客户端实例在调」) */ function saaInstanceId() { try { let id = String(GM_getValue('sa_agent_instance', '') || ''); if (!id) { id = pcHash8(String(Date.now()) + ':' + Math.random()).slice(0, 6); GM_setValue('sa_agent_instance', id); } return id; } catch (e) { return 'tmp'; } } /** 构造 actor 段(谁在调 / 什么身份) */ function saaActor(identity) { return { client: 'steam-ai-agent', instance: saaInstanceId(), identity: (identity && identity.identified) ? 'identified' : 'unidentified', identitySource: (identity && identity.identitySource) || null, }; } // ============================================================ // v1.27.0 结论回灌(把 agent 的洞察写回 data-hub) // ============================================================ /** 稳定 hash:同样的内容 → 同样的 runId → 幂等 */ function pcHash8(text) { let h = 0x811c9dc5; const s = String(text || ''); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; } return h.toString(16).padStart(8, '0'); } /** POST 请求(推送用)—— 与 GET 走同一套会话 + 设备签名 */ async function pcHubPost(pathname, body) { const cfg = pcHubConfig(); if (!cfg.baseUrl) throw new Error('NO_BASE_URL'); if (!pcHubRouteAllowed(pathname, 'POST')) throw new Error('ROUTE_NOT_ALLOWED'); const auth = pcHubSyncAuth(); if (!auth) throw new Error('NO_AUTH_LIB'); const u = assertSafeRequestUrl(cfg.baseUrl + pathname, { allowPrivate: cfg.allowPrivate, allowAiEndpoint: true }); if (u.protocol !== 'https:' && !cfg.allowHttp) throw new Error('INSECURE_BASE_URL'); const res = await auth.request(pathname, { method: 'POST', body: body || {} }); if (!res || res.status < 200 || res.status >= 300) { const d = (res && res.data) || null; const code = (d && d.code) ? String(d.code) : ''; const hint = (d && d.hint) ? String(d.hint) : ''; const msg = (d && (d.error || d.message)) ? String(d.error || d.message) : ('HTTP ' + (res && res.status)); throw new Error((code ? '[' + code + '] ' : '') + msg + (hint ? '(' + hint + ')' : '')); } if (!res.data) throw new Error('数据中枢返回的不是合法 JSON'); return res.data; } // ============================================================ // v1.33.0 插件版本上报(D10 运营面板数据通道,docs §3.4–§3.5) // ============================================================ // 口径纪律: // · 只报**事实**(id/version/versionKnown/detectedBy/regDomain/lastSeen), // 不放 outdated / minVersion —— 契约会变,采信客户端会让历史样本口径全错, // 过旧判定由服务端按当前契约算。 // · 版本号来自 registerPlugin 注册表(@version 同源,不漂移),不做指纹采集。 // · 每日一次(当日首次运行时),失败静默不写「已报」标记,下次补传。 // · 唯一例外:绑定 Steam 账号成功后必报一次(reason='bind',与开关无关)。 // · optout 是「失败静默降级」纪律的唯一例外:撤销失败 = 用户以为已删实际仍在, // 置 pendingOptout 重试并在设置面板可见。 const PC_REPORT_KEY = 'sa_hub_plugin_report_v1'; function pcReportState() { try { const raw = GM_getValue(PC_REPORT_KEY, ''); const o = raw ? JSON.parse(raw) : {}; return { enabled: o.enabled !== false, // 默认勾选(§3.5.3 已拍板) lastReportDay: String(o.lastReportDay || ''), pendingOptout: !!o.pendingOptout, }; } catch (e) { return { enabled: true, lastReportDay: '', pendingOptout: false }; } } function pcReportSaveState(st) { try { GM_setValue(PC_REPORT_KEY, JSON.stringify(st)); } catch (e) { /* ignore */ } } /** 本地日期串(每日一次的「当日」口径) */ function pcReportToday() { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } /** 构造上报清单:只含事实。不复用 getInstalledPlugins(那是给模型看的,含契约口径字段)。 * 来源 = pcReadPluginRegistry()(本域 IDB + GM 跨域镜像);探测型信号不进上报 * (12 插件全部已接入 registerPlugin,探测兜底只服务没有注册表的异常场景)。 */ async function pcBuildVersionManifest(reason) { const registry = await pcReadPluginRegistry(); const plugins = []; for (const [id, rec] of registry) { const version = rec && rec.version ? String(rec.version).trim() : ''; plugins.push({ id: String(id), version: version || null, // versionKnown=0 时为 null(不是空串) versionKnown: !!version, detectedBy: rec && rec.viaMirror ? 'registry-mirror' : 'registry', regDomain: (rec && rec.regDomain) || null, lastSeen: (rec && (rec.ts || rec.registeredAt)) || null, }); } return { agentVersion: _v, domain: pcPanelDomain() || null, reason: reason === 'bind' ? 'bind' : 'scheduled', plugins, }; } /** * 发送版本清单。失败抛错(调用方决定是否静默)。 * 门禁:scheduled 只在开关勾选时发;bind 不看开关(§3.5.4 排障通道)。 * scheduled 成功才写 lastReportDay(失败不写 → 下次补传);bind 不写(每次绑定都报)。 */ async function pcSendVersionReport(reason) { const st = pcReportState(); if (reason !== 'bind' && !st.enabled) return { skipped: true, reason: 'disabled' }; const manifest = await pcBuildVersionManifest(reason); if (!manifest.plugins.length) return { skipped: true, reason: 'empty-registry' }; await pcHubPost('/report/plugins', manifest); if (reason !== 'bind') { st.lastReportDay = pcReportToday(); pcReportSaveState(st); } return { ok: true, count: manifest.plugins.length }; } /** 撤销上报:删服务端该设备的全部版本记录。成功返回 true;失败置 pendingOptout。 */ async function pcOptoutVersionReport() { const st = pcReportState(); try { await pcHubPost('/report/plugins/optout', {}); st.pendingOptout = false; pcReportSaveState(st); return true; } catch (e) { st.pendingOptout = true; pcReportSaveState(st); return false; } } /** 插件启动时的上报维护(每日一次 + 撤销重试;全部静默,绝不影响面板初始化) */ async function pcReportStartup() { try { await pcHubResolveEndpointRemote(); } catch (e) { /* 回落常量 */ } const st = pcReportState(); // ① 撤销重试:用户以为已删而实际仍在,必须补送达(静默纪律的唯一例外) if (st.pendingOptout) { try { await pcOptoutVersionReport(); } catch (e) { /* 下次再试 */ } } // ② 每日周期上报:当日已报过 → 跳过;失败不写标记,下次机会补传 if (st.enabled && st.lastReportDay !== pcReportToday()) { try { await pcSendVersionReport('scheduled'); } catch (e) { /* 静默 */ } } } ToolRegistry.register({ name: 'pushInsight', description: '把你分析出来的**结论**回灌到「数据中枢」(data-hub) —— 落进 insight / analysis_* 表,' + '成为可复算、可追溯的库资产(而不是只留在聊天记录里)。' + '适合:分析出「某厂商集中度偏高」「某系列还差 3 款」「这款每小时成本极低」这类**可复用的结论**时调用。' + '⚠️ 只上报**有依据的结论**,不要上报过程性废话;' + '同一份内容重复上报是幂等的(不会重复堆积),但换措辞会被当成新结论。' + 'runId 由工具自动生成(ai.insight:<账号>:<内容指纹>),调用方不用关心;' + '指纹覆盖标题/正文/类别/强度/指标/总结,**改了任何一项都算新结论**。' + '归属以「已认证的 Steam 账号」为准 —— 服务端只认它。' + '拿不准要不要落库时先用 dryRun 预演。', parameters: { type: 'object', properties: { steamid: { type: 'string', description: '结论所属账号 SteamID64;不传则用设置里的默认账号' }, insights: { type: 'array', description: '结论列表(建议 1~10 条)', items: { type: 'object', properties: { title: { type: 'string', description: '结论标题(一句话,会进 insight 表)' }, message: { type: 'string', description: '结论正文:数据依据 + 口径说明' }, kind: { type: 'string', enum: ['value', 'warning', 'recommendation', 'library', 'playtime', 'wishlist', 'collection', 'profile', 'insight', 'risk', 'price', 'other'], description: '类别,默认 insight(取值域与服务端白名单一致)' }, severity: { type: 'string', enum: ['info', 'warn', 'strong'], description: '强度,默认 info' }, appid: { type: 'number', description: '关联的 appid(可选)' }, }, }, }, summary: { type: 'string', description: '本次结论的一句话总结(会写进 analysis_report.summary)' }, metrics: { type: 'object', description: '本次分析算出的数值指标(键值对,如 {"never_played_pct":57.5})' }, dryRun: { type: 'boolean', description: '只预览将要上报的内容(runId / 条目 / 归属),不真正落库' }, }, }, category: 'knowledge', enabled: () => !!pcHubConfig().baseUrl, async execute(params) { const cfg = pcHubConfig(); if (!cfg.baseUrl) { return { success: false, error: '远程数据中枢已停用,本工具不可用。请在「设置 → 数据源 → 远程数据中枢」勾选启用。', needConfig: true, }; } const p = params || {}; const list = Array.isArray(p.insights) ? p.insights : []; if (!list.length) { return { success: false, error: '没有要上报的结论(insights 为空)—— 没有结论就不要调用本工具' }; } // 身份解析:显式参数 > 设置配置 > cookie 检测 > 库主账号回退 const identity = await pcHubResolveIdentity(p.steamid); const acc = await pcHubAccounts(); const sid = identity.steamId || 'global'; // ⭐ 已认证会话的账号(服务端认可的归属) const sessSubject = pcHubSessionSubject(); // ⭐ 内容指纹:**覆盖全部会落库的字段**。 // 原来只算 [title, message, appid] → 改了 kind/severity、换了 metrics 或 summary, // 指纹不变 → 还是同一个 runId → 旧行被覆盖(类别/强度/指标被悄悄改写)。 // 现在 kind/severity/metrics/summary 都在指纹里:内容真变了才算新结论。 const fingerprint = JSON.stringify({ items: list.map((x) => [ String(x.title || ''), String(x.message || ''), x.appid || 0, String(x.kind || 'insight'), String(x.severity || 'info'), ]), metrics: (p.metrics && typeof p.metrics === 'object') ? p.metrics : {}, summary: p.summary ? String(p.summary) : '', }); // ⭐ 幂等键的身份维度:**会话账号优先**。 // sid 兜底成 'global' 时所有账号的同名结论共用一个 runId // → 共享库里后写的覆盖先写的(跨用户覆盖)。 const idKey = sessSubject || (/^\d{17}$/.test(sid) ? sid : 'global'); const runId = `ai.insight:${idKey}:${pcHash8(fingerprint)}`; // payload.subject 以会话账号为准(服务端会拿它再比一次,必须一致) const subject = sessSubject || (/^\d{17}$/.test(sid) ? sid : null); const items = list.map((x) => ({ kind: x.kind || 'insight', severity: x.severity || 'info', appid: x.appid || null, title: String(x.title || '').slice(0, 200), message: String(x.message || '').slice(0, 4000), })); const metrics = (p.metrics && typeof p.metrics === 'object') ? p.metrics : {}; const report = p.summary ? { summary: String(p.summary).slice(0, 2000), payload: { source: 'steam-ai-agent' } } : null; // 身份冲突提示:本地解析出的账号与会话账号不一致时如实说明(不静默改写归属) const identityNote = identity.note || (sessSubject && /^\d{17}$/.test(sid) && sessSubject !== sid ? `本地识别到的账号 ${sid} 与已认证会话账号 ${sessSubject} 不一致 —— 已按已认证账号归属上报` : null); if (p.dryRun) { return { success: true, data: { dryRun: true, runId, subject, items, metrics, report, identitySource: identity.identitySource || null, identityNote: identityNote || null, }, summary: `预演:将上报 ${items.length} 条结论(run ${runId})` + (identityNote ? `|⚠️ ${identityNote}` : ''), }; } const payload = { runId, kind: 'ai.insight', subject, source: 'steam-ai-agent', agentVersion: _v, actor: saaActor(identity), owner: (acc && acc.owner) || null, items, metrics, report, }; try { const r = await pcHubPost('/push/agent', payload); return { success: true, data: { runId: r.runId, inserted: r.inserted, replaced: !!r.replaced, source: r.source, subject: payload.subject, identitySource: identity.identitySource || null, identityNote: identityNote || null, }, summary: `已回灌 ${payload.items.length} 条结论(run ${r.runId})` + (r.replaced ? ',同名 run 已覆盖(幂等)' : '') + (identityNote ? `|⚠️ ${identityNote}` : ''), }; } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } }, }); // ============================================================ // v1.28.0 观测上报(关键指标 + 游玩时长明细 → data-hub) // ============================================================ // ⭐ 数据来源是 **Steam 官方 API**,不是从库里搬: // · playtime_forever / playtime_2weeks 取原始分钟,零精度损失; // · 库里缺的 playtime_2weeks(实测覆盖 0%)这里能补上; // · 「未知」不会被折算成 0 —— 官方给了才算数。 ToolRegistry.register({ name: 'pushObservation', description: '把**游玩时长明细 + 关键指标**上报到「数据中枢」(data-hub) —— 落进 analysis_app / ' + 'library_metric_daily(可复算、可追溯的库资产),供后续分析与趋势回看。' + '数据取自 Steam 官方 API(GetOwnedGames),因此是**当前最权威的时长**。' + '适合:用户说「把这次的时长/指标也存下来」「之后按周回看我的游玩变化」「导一份我的时长明细进库」。' + '⚠️ scope=partial(默认)只落分析产物区,安全;scope=full 才会尝试刷新事实表,' + '但服务端会校验「条目数不得少于库内已有快照」,数据不全时会自动降级并说明原因。' + '上报是幂等的:幂等键 = **内容指纹**(不含时间戳)—— 同一份时长数据重复上报走同一个 runId,' + '覆盖而不堆积;时长真的变了才会新增一行。' + '⚠️ lastPlayed 一律传「未知」(GetOwnedGames 不提供该字段),服务端会沿用库里的历史值,' + '不会把「从未游玩」写进事实表。', parameters: { type: 'object', properties: { steamid: { type: 'string', description: '账号 SteamID64;不传则用当前登录用户' }, scope: { type: 'string', enum: ['partial', 'full'], description: 'partial=只落分析产物区(默认,安全);full=尝试刷新事实表 owned_game(需通过服务端护栏)' }, includeProfile: { type: 'boolean', description: '是否顺带上报画像级标量(未开封率/人格称号等,走 metrics → analysis_result)。默认 true' }, dryRun: { type: 'boolean', description: '只预览将要上报的内容,不真正落库' }, }, }, category: 'knowledge', enabled: () => !!pcHubConfig().baseUrl, async execute(params) { const cfg = pcHubConfig(); if (!cfg.baseUrl) { return { success: false, error: '远程数据中枢已停用,本工具不可用。请在「设置 → 数据源 → 远程数据中枢」勾选启用。', needConfig: true, }; } const p = params || {}; const scope = String(p.scope || 'partial').toLowerCase() === 'full' ? 'full' : 'partial'; // ---- 取 Steam 官方时长(权威来源)---- const apiKey = getSteamApiKey(); if (!apiKey) { return { success: false, needsApiKey: true, error: '此工具需要 Steam Web API Key。请引导用户点击对话中的「配置 Steam API Key」按钮,或打开设置面板填写 Key(可在 steamcommunity.com/dev 免费申请),配置完成后用户回复"已配置",再重新调用本工具', }; } // 身份解析:显式参数 > 设置配置 > cookie 检测 > **库主账号回退** // ⭐ GetOwnedGames 是**公开 API**(只需 API Key,不需要浏览器登录态), // 所以回退到库主账号后照样能拉官方时长。 const identity = await pcHubResolveIdentity(p.steamid); const steamId = identity.steamId; if (!steamId) { return { success: false, error: identity.note || '未提供也未检测到 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 && e.message || e) }; } if (!id64) return { success: false, error: '自定义 ID "' + steamId + '" 解析失败(可能不存在)' }; } let games = []; try { const resp = await gmFetch('https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?key=' + apiKey + '&steamid=' + id64 + '&include_appinfo=0&format=json'); const data = JSON.parse(resp); games = (data && data.response && data.response.games) || []; } catch (e) { return { success: false, error: '拉取 Steam 官方游戏列表失败:' + String(e && e.message || e) }; } if (!games.length) { return { success: false, error: 'Steam 官方返回的游戏列表为空 —— 可能是资料隐私设置为「不公开游戏详情」。' + '请到 Steam 资料 → 隐私设置 → 游戏详情改为「公开」后重试。', }; } // ---- 组装观测段 ---- const acc = await pcHubAccounts(); const capturedAt = Date.now(); const playtime = games.map((g) => ({ appid: Number(g.appid), playtimeMin: Math.max(0, Math.round(Number(g.playtime_forever) || 0)), playtime2weeksMin: Math.max(0, Math.round(Number(g.playtime_2weeks) || 0)), // ⭐ lastPlayed = null(**未知**),不是 0。 // GetOwnedGames 不返回 last_played(那是 GetRecentlyPlayedGames); // 而 0 在 data-hub 里有真实语义「从未游玩」—— // 写 0 等于凭空造一个事实,覆盖事实表时还会把库里真实的 last_played 冲掉。 // 服务端收到 null 会**沿用历史值**(不冲列)。 lastPlayed: null, })).filter((g) => Number.isInteger(g.appid) && g.appid > 0); const totalMin = playtime.reduce((a, g) => a + g.playtimeMin, 0); const twoWeeksMin = playtime.reduce((a, g) => a + g.playtime2weeksMin, 0); const playedCount = playtime.filter((g) => g.playtimeMin > 0).length; const obsMetrics = { games_owned: playtime.length, games_played: playedCount, playtime_min_total: totalMin, playtime_min_2weeks: twoWeeksMin, }; // ---- 画像级标量(走既有 metrics → analysis_result)---- let profileMetrics = {}; let profileNote = null; if (p.includeProfile !== false) { try { const pr = await pcHubRequest('/profile/' + encodeURIComponent(id64), {}); const pf = pr.profile || pr.data || pr; const lib = pf.library || {}; const ld = pf.ladder || {}; profileMetrics = { profile_never_played_pct: ld.neverPlayedPct === undefined ? null : ld.neverPlayedPct, profile_duration_evidence: lib.durationEvidence || null, profile_games_measured: ld.measuredGames === undefined ? null : ld.measuredGames, profile_total_hours: lib.totalHours === undefined ? null : lib.totalHours, profile_value_per_hour_cny: (pf.value || {}).valuePerHourCny === undefined ? null : (pf.value || {}).valuePerHourCny, }; if (lib.durationEvidence === 'missing') { profileNote = '库内没有游玩时长数据(durationEvidence=missing)—— 未开封率类指标不可算,' + '本次上报的时长来自 Steam 官方 API,是可信的'; } } catch (e) { profileNote = '画像标量拉取失败(不影响时长上报):' + String(e && e.message || e); } } if (p.dryRun) { return { success: true, data: { dryRun: true, steamid: id64, scope, playtimeItems: playtime.length, obsMetrics, profileMetrics, profileNote, identitySource: identity.identitySource || null, identityNote: identity.note || null, sample: playtime.slice(0, 5), }, summary: '预演:将上报 ' + playtime.length + ' 款游戏的时长明细 + ' + Object.keys(obsMetrics).length + ' 个关键指标(scope=' + scope + ')' + (identity.note ? `|⚠️ ${identity.note}` : ''), }; } // ---- 上报 ---- const observations = { scope, steamid: id64, capturedAt, itemCount: playtime.length, metrics: obsMetrics, playtime, }; // ⭐ 幂等键 = **内容指纹**,**必须剔除 capturedAt**: // 含了它,每次调用都是新 runId → analysis_run / analysis_app 每次新增一行, // 「重复上报不增长行数」就是假的(上一版正是这个坑,表会一路膨胀)。 // 剔除后:同一份时长重复上报 → 同一 runId → 覆盖而非堆积; // 时长真的变了 → 指纹变了 → 新 runId(新一次观测,确实该新行)。 const obsFingerprint = JSON.stringify({ scope, steamid: id64, itemCount: observations.itemCount, metrics: obsMetrics, playtime, }); const payload = { runId: 'ai.observation:' + id64 + ':' + pcHash8(obsFingerprint), kind: 'ai.observation', subject: id64, source: 'steam-ai-agent', agentVersion: _v, actor: saaActor(identity), owner: (acc && acc.owner) || null, items: [], metrics: profileMetrics, observations, }; try { const r = await pcHubPost('/push/agent', payload); const ob = r.observations || {}; const facts = ob.facts || {}; return { success: true, data: { runId: r.runId, playtimeItems: ob.apps || 0, metricsApplied: ob.metrics || 0, factsApplied: !!facts.applied, factsRows: facts.rows || 0, factsReason: facts.reason || null, profileNote, identitySource: identity.identitySource || null, identityNote: identity.note || null, }, summary: '已上报 ' + (ob.apps || 0) + ' 条时长明细 + ' + (ob.metrics || 0) + ' 个关键指标' + (facts.applied ? ',并刷新了事实表(' + facts.rows + ' 行)' : '(事实表未刷新:' + (facts.reason || '未声明 full scope') + ')') + (identity.note ? `|⚠️ ${identity.note}` : ''), }; } catch (e) { return { success: false, error: pcHubErrorText(e, cfg) }; } }, }); // ============================================================ // v1.22.0 Steam 消费记录自抓(插件缓存计划 阶段3 / docs §5) // v1.34.0 修正取数链路(旧实现致命错误,见下) // ============================================================ // ⚠️ v1.22.0 的旧实现以为存在 GET /account/history/?ajax=1 这个 JSON 接口: // 实际 Steam 从来没有该路由,请求只会拿回整页 HTML → JSON.parse 必然失败 → // 被当成 SPEND_NOT_LOGIN,于是「明明已登录」也提示去重新登录,工具 100% 不可用。 // v1.34.0 改为 Steam 页面自身使用的真实链路(与 Steam-Account-History-Export 一致): // ① 首屏:GET /account/history/?l=schinese → 整页 HTML,解析首屏 // 行 + g_historyCursor + g_sessionID; // ② 翻页:POST /account/AjaxLoadMoreHistory/ // body=cursor=&sessionid=(表单编码)→ {html, cursor}, // 循环直到 cursor 为空 / 空页 / 达到 maxPages。 // ③ 登录态:页面含 g_sessionID / g_steamID / wallet_history_table 视为已登录; // 只有被重定向到 /login/ 或出现登录表单时才判定未登录(不再把接口异常算作未登录)。 // 分类规则移植自 Steam-Spending-History-Classifier v2.2.0(其 L24-32 类别常量、L303-320 // classifyRow、L423-431 parseNumber、L793 导出 schema)。该插件跑在已渲染 DOM 上, // 本工具解析 HTML 片段——改用正则(片段规整、td 不嵌套),Node 测试零 DOM 依赖。 // 缓存:sa_spending_cache(7 天 TTL,force 重抓),仅落本机 GM 存储,不上传。 const SPEND_CACHE_KEY = 'sa_spending_cache'; const SPEND_TTL = 7 * 24 * 60 * 60 * 1000; const SPEND_ORIGIN = 'https://store.steampowered.com'; const SPEND_HISTORY_URL = `${SPEND_ORIGIN}/account/history/?l=schinese`; const SPEND_MORE_URL = `${SPEND_ORIGIN}/account/AjaxLoadMoreHistory/`; const SPEND_BRIDGE_KEY = '__saa_bridge_spend__'; const SPEND_CATS = [ { id: 'store', label: '直购' }, { id: 'ingame', label: '内购' }, { id: 'gift', label: '送礼' }, { id: 'refund', label: '退款' }, { id: 'convert', label: '充值' }, { id: 'market_buy', label: '买入' }, { id: 'market_sell', label: '卖出' }, ]; const SPEND_CATS_SET = new Set(['store', 'gift', 'ingame']); // 计入净支出(同分类器 SPENDING_CATS) const SPEND_LABEL = SPEND_CATS.reduce((m, c) => (m[c.id] = c.label, m), { other: '其他' }); const SPEND_RE = { refund: /退款|Refund/i, convert: /转换|轉換|Convert/i, ingame: /游戏内购买|遊戲內物品購買|In-Game\s*Purchase/i, market: /市场交易|市集交易|Market\s*Transaction/i, gift: /礼物购买|禮物購買|Gift\s*Purchase/i, purchase: /购买|購買|Purchase/i, wallet: /钱包资金|錢包資金|Wallet/i, }; // 币种表(移植分类器 CURRENCIES,去 SVG 旗帜;dc=小数逗号制)。[id, 中文, 符号, 识别正则, dc] const SPEND_CURRENCIES = [ ['HKD', '港币', 'HK$', /HK\$\s*[\d,. ]+/, 0], ['TWD', '新台币', 'NT$', /NT\$\s*[\d,. ]+/, 0], ['AUD', '澳元', 'A$', /A\$\s*[\d,. ]+/, 0], ['CAD', '加元', 'CDN$', /CDN\$\s*[\d,. ]+/, 0], ['NZD', '新西兰元', 'NZ$', /NZ\$\s*[\d,. ]+/, 0], ['ARS', '阿根廷比索', 'ARS$', /ARS\$\s*[\d,. ]+/, 1], ['SGD', '新加坡元', 'S$', /(? ({ id, label, symbol, match, dc: !!dc })); const spendResolveYen = text => /\.\d+/.test(text) ? 'CNY' : 'JPY'; /** 金额解析(同分类器 parseNumber:处理 "1.234,56" 与 "1,234.56" 两种体系) */ function spendParseNum(text, decimalComma) { const m = String(text || '').match(/[\d]+(?:[ ,.]\d+)*/); if (!m) return 0; let s = m[0].replace(/ /g, ''); const li = s.lastIndexOf('.'), ci = s.lastIndexOf(','); if (decimalComma) { s = ci >= 0 ? s.replace(/\./g, '').replace(',', '.') : s.replace(/\./g, ''); } else if (li > 0 && ci > 0) { s = li > ci ? s.replace(/,/g, '') : s.replace(/\./g, '').replace(',', '.'); } else { s = s.replace(/,/g, ''); } return parseFloat(s) || 0; } /** HTML 片段 → 纯文本(实体解码 + 折叠空白,保留 为换行) */ function spendText(html) { return String(html || '') .replace(//gi, '\n') .replace(/<[^>]*>/g, ' ') .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') .replace(/?39;|'/g, "'").replace(/ /g, ' ').replace(/&/g, '&') .replace(/(\d+);/g, (_, d) => String.fromCharCode(Number(d))) .replace(/[ \t]+/g, ' ').replace(/ ?\n ?/g, '\n').trim(); } /** 分类(同分类器 classifyRow 的文本版:typeText 驱动,market 按钱包变更正负分买卖) */ function spendClassify(typeText, walletChangeText, itemsText) { const text = String(typeText || '').trim(); if (!text) return 'other'; if (SPEND_RE.refund.test(text)) return 'refund'; if (SPEND_RE.convert.test(text)) return 'convert'; if (SPEND_RE.ingame.test(text)) return 'ingame'; if (SPEND_RE.market.test(text)) { const ct = String(walletChangeText || ''); return ct.includes('+') ? 'market_sell' : ct.includes('-') ? 'market_buy' : 'other'; } if (SPEND_RE.gift.test(text)) return 'gift'; if (SPEND_RE.purchase.test(text)) return SPEND_RE.wallet.test(String(itemsText || '')) ? 'convert' : 'store'; return 'other'; } /** 单行币种识别:total → wallet_change → wallet_balance 依次找符号(CNY/JPY 靠小数区分) */ function spendDetectRowCurrency(cells) { const texts = [cells.total, cells.wallet_change, cells.wallet_balance] .map(h => spendText(h)).filter(t => t && t !== '--'); const yen = texts.find(t => /¥/.test(t) && !/JP¥|JPY/i.test(t)); if (yen) return spendResolveYen(yen); const all = texts.join(' '); for (const cur of SPEND_CURRENCIES) { if (cur.id === 'CNY') continue; if (cur.match.test(all)) return cur.id; } return null; // 交由调用方回退主货币 } /** ajax html 片段 → 行记录数组(正则解析;Steam 片段规整、td 互不嵌套) */ function spendParseRows(html) { const out = []; const rowRe = /]*class="[^"]*wallet_table_row[^"]*"[^>]*>([\s\S]*?)<\/tr>/g; let m; while ((m = rowRe.exec(String(html || ''))) !== null) { const openTag = m[0].slice(0, m[0].indexOf('>')); const rowHtml = m[1]; const cells = {}; const tdRe = /]*>([\s\S]*?)<\/td>/g; let t; while ((t = tdRe.exec(rowHtml)) !== null) { if (cells[t[1]] === undefined) cells[t[1]] = t[2]; } if (cells.type === undefined && cells.items === undefined) continue; // 非数据行(th 表头等) const typeHtml = cells.type || ''; const typeText = spendText(typeHtml); const itemsHtml = cells.items || ''; const firstDiv = /]*>([\s\S]*?)<\/div>/.exec(itemsHtml); const item = spendText(firstDiv ? firstDiv[1] : itemsHtml).split('\n')[0].slice(0, 160); const walletChangeText = spendText(cells.wallet_change || ''); const category = spendClassify(typeText, walletChangeText, spendText(itemsHtml)); const dt = /data-datetime="(\d{9,13})"/.exec(openTag); const ts = dt ? (Number(dt[1]) < 1e12 ? Number(dt[1]) * 1000 : Number(dt[1])) : null; const onclick = /onclick="([^"]*)"/.exec(m[0]); const appid = onclick ? (/appid=(\d+)/.exec(onclick[1]) || [])[1] || null : null; // 市场行数量:type 单元格第一个 div 的数字(同分类器 marketCount),items 的「已售出/购买 N 件」兜底 let marketCount = 1; if (category === 'market_buy' || category === 'market_sell') { const md = /]*>([\s\S]*?)<\/div>/.exec(typeHtml); marketCount = parseInt(spendText(md ? md[1] : ''), 10) || 1; if (marketCount === 1) { const im = /已(?:售出|购买)\s*(\d+)/.exec(spendText(itemsHtml)); if (im) marketCount = parseInt(im[1], 10) || 1; } } // 支付方式:wth_payment 块内的子 div 序列(每个 div = 一种支付及其金额,跳过纯金额段) let payment = ''; const pmSegStart = typeHtml.indexOf('wth_payment'); if (pmSegStart >= 0) { const pmSeg = typeHtml.slice(pmSegStart).replace(/<\/td>[\s\S]*$/, ''); const pmDivs = []; const pmRe = /]*>([\s\S]*?)<\/div>/g; let p; while ((p = pmRe.exec(pmSeg)) !== null) { const txt = spendText(p[1]); if (txt && !/^[-+]/.test(txt)) pmDivs.push(txt); } payment = pmDivs.length ? pmDivs.join(' + ') : ''; } const totalText = spendText(cells.total || ''); const currency = spendDetectRowCurrency(cells); const dcCur = SPEND_CURRENCIES.find(c => c.id === currency); let amount = 0; if (category === 'ingame' && cells.wallet_change !== undefined) { amount = spendParseNum(walletChangeText, dcCur && dcCur.dc); } if (!amount) amount = spendParseNum(totalText, dcCur && dcCur.dc); const dateText = spendText(cells.date || '').replace(/\s+/g, ' '); out.push({ key: `${dt ? dt[1] : dateText}|${totalText}|${item.slice(0, 60)}`, ts, date: dateText, item, type: typeText.split('\n')[0].slice(0, 60), category, currency: currency || null, amount, total: totalText, walletChange: walletChangeText, payment, marketCount, appid, }); } return out; } /** 主货币检测(同分类器 detectPrimaryCurrency:跳过 market/refund 行,看第一笔常规交易) */ function spendDetectPrimaryCurrency(rows) { for (const r of rows) { if (r.category === 'market_buy' || r.category === 'market_sell' || r.category === 'refund') continue; const text = [r.total, r.walletChange].find(t => t && t !== '--') || ''; if (!text) continue; if (/¥/.test(text) && !/JP¥|JPY/i.test(text)) return spendResolveYen(text); for (const cur of SPEND_CURRENCIES) { if (cur.match.test(text)) return cur.id; } return null; } return null; } /** 聚合:分类统计 + 净支出 + 月度趋势(口径同分类器 calcTotal:spending 为正、refund 抵扣) */ function spendAggregate(rows, primaryId) { const primary = SPEND_CURRENCIES.find(c => c.id === primaryId); const byCat = {}; // cat → {count, spend} const byMonth = {}; // 'YYYY-MM' → {count, net} let gross = 0, refunded = 0; for (const r of rows) { const isPrimary = !primaryId || r.currency === primaryId; const amt = isPrimary ? (Number(r.amount) || 0) : 0; const cat = r.category; if (!byCat[cat]) byCat[cat] = { count: 0, spend: 0 }; byCat[cat].count += r.marketCount || 1; if (SPEND_CATS_SET.has(cat)) { byCat[cat].spend += amt; gross += amt; } else if (cat === 'refund') { byCat[cat].spend -= amt; refunded += amt; } const month = r.ts ? new Date(r.ts).toISOString().slice(0, 7) : null; if (month) { if (!byMonth[month]) byMonth[month] = { count: 0, net: 0 }; byMonth[month].count += r.marketCount || 1; if (SPEND_CATS_SET.has(cat)) byMonth[month].net += amt; else if (cat === 'refund') byMonth[month].net -= amt; } } const monthly = Object.keys(byMonth).sort().reverse().slice(0, 12) .map(k => ({ month: k, count: byMonth[k].count, net: Math.round(byMonth[k].net * 100) / 100 })); const categories = SPEND_CATS .filter(c => byCat[c.id]) .map(c => ({ id: c.id, label: c.label, count: byCat[c.id].count, spend: Math.round(byCat[c.id].spend * 100) / 100 })); const otherCount = byCat.other ? byCat.other.count : 0; return { currency: primary ? { id: primary.id, label: primary.label, symbol: primary.symbol } : null, netSpend: Math.round((gross - refunded) * 100) / 100, grossSpend: Math.round(gross * 100) / 100, refundTotal: Math.round(refunded * 100) / 100, categories, otherCount, monthly, }; } function spendCacheGet() { try { const raw = GM_getValue(SPEND_CACHE_KEY, null); if (!raw) return null; const obj = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!obj || !Array.isArray(obj.rows) || !obj.rows.length) return null; // 未拉全的缓存只保鲜 30 分钟:避免一次翻页失败把「只有 10 条」锁死 7 天 const ttl = obj.complete === false ? 30 * 60 * 1000 : SPEND_TTL; if (Date.now() - obj.at > ttl) return null; return obj; } catch { return null; } } function spendCacheSave(cache) { try { GM_setValue(SPEND_CACHE_KEY, JSON.stringify(cache)); } catch {} } // ---- 登录态与历史页解析(v1.34.0)------------------------------------------ function spendExtractSteamId(html) { const m = /g_steamID\s*=\s*"(\d{17})"/.exec(String(html || '')); return m ? m[1] : ''; } function spendExtractSessionId(html) { const m = /g_sessionID\s*=\s*"([^"]+)"/.exec(String(html || '')); if (m) return m[1]; // 兜底:store 域的 sessionid 不是 HttpOnly,页面脚本可读 try { const cm = /(?:^|;\s*)sessionid=([^;]+)/.exec(document.cookie || ''); return cm ? decodeURIComponent(cm[1]) : ''; } catch { return ''; } } function spendExtractCursor(html) { const m = /g_historyCursor\s*=\s*(null|"(?:[^"\\]|\\.)*")/.exec(String(html || '')); if (!m || m[1] === 'null') return null; try { return JSON.parse(m[1]); } catch { return null; } } /** 历史页正向标记:g_sessionID / 17 位 g_steamID / 钱包历史表,任一存在即视为已登录 */ function spendPageHasHistory(html) { const h = String(html || ''); return /g_sessionID\s*=\s*"[^"]+"/.test(h) || /g_steamID\s*=\s*"\d{17}"/.test(h) || /wallet_history_table/.test(h); } /** 登录墙标记:被重定向到 /login/ 或页面出现登录表单(不与「页面结构变更」混为一谈) */ function spendPageLooksLoginWall(html, finalUrl) { if (/\/login\/?(\?|$)/.test(String(finalUrl || ''))) return true; return /id="login_form"|class="[^"]*loginbox|newlogindialog/i.test(String(html || '')); } /** 本地(免请求)登录证据:账号页/商店页都有 g_steamID 或 application_config */ function spendLocalLoginEvidence() { const evidence = []; let steamId = ''; try { steamId = String(unsafeWindow.g_steamID || ''); } catch {} if (/^\d{17}$/.test(steamId)) evidence.push('window.g_steamID'); try { if (unsafeWindow.g_sessionID) evidence.push('window.g_sessionID'); } catch {} try { const cfg = document.getElementById('application_config'); const info = cfg ? JSON.parse(cfg.getAttribute('data-userinfo') || '{}') : null; if (info && info.steamid) { steamId = steamId || String(info.steamid); evidence.push('application_config.data-userinfo'); } } catch {} if (!steamId) { const detected = detectSteamId(); if (detected) { steamId = detected; if (!evidence.length) evidence.push('detectSteamId'); } } return { steamId, evidence }; } // 60s 缓存:避免连续对话反复拉历史页 let _spendLoginCache = null; async function spendLoginState(force) { if (!force && _spendLoginCache && Date.now() - _spendLoginCache.at < 60000) return _spendLoginCache.state; const local = spendLocalLoginEvidence(); let state = { loggedIn: local.evidence.length ? true : null, steamId: local.steamId || '', evidence: local.evidence.slice(), source: local.evidence.length ? 'page' : '', checkedAt: Date.now(), }; if (state.loggedIn !== true) { try { const resp = await gmFetch(SPEND_HISTORY_URL, { timeout: 15000, withMeta: true }); const html = String(resp.responseText || ''); const finalUrl = String(resp.finalUrl || SPEND_HISTORY_URL); const has = spendPageHasHistory(html); const wall = spendPageLooksLoginWall(html, finalUrl); state = { loggedIn: has ? true : (wall ? false : null), steamId: state.steamId || spendExtractSteamId(html) || detectSteamId(), evidence: has ? ['history page: 已登录标记'] : (wall ? ['history page: 被重定向到登录页'] : ['history page: 未发现登录标记']), source: 'history-probe', finalUrl, checkedAt: Date.now(), }; } catch (e) { state.probeError = e.message; state.note = '无法访问消费历史页,登录态无法判定(不是「未登录」)'; } } _spendLoginCache = { at: Date.now(), state }; return state; } /** * 拉全量:首屏整页 HTML → AjaxLoadMoreHistory 表单翻页。 * 失败时抛出的 Error 带 loginState,便于上层如实回答「未登录 / 页面异常」。 */ async function spendFetchAll(maxPages) { const rows = []; const seen = new Set(); const pageErrors = []; let pages = 0, complete = false; // ① 首屏(整页 HTML) const first = await gmFetch(SPEND_HISTORY_URL, { timeout: 25000, withMeta: true }); const firstHtml = String(first.responseText || ''); const finalUrl = String(first.finalUrl || SPEND_HISTORY_URL); if (!spendPageHasHistory(firstHtml)) { const isLoginWall = spendPageLooksLoginWall(firstHtml, finalUrl); const err = new Error(isLoginWall ? 'SPEND_NOT_LOGIN' : 'SPEND_PAGE_UNEXPECTED'); err.diagnosis = { finalUrl, htmlLength: firstHtml.length, hasLoginForm: /id="login_form"|class="[^"]*loginbox/i.test(firstHtml), }; err.loginState = { loggedIn: isLoginWall ? false : null, steamId: spendLocalLoginEvidence().steamId, evidence: [isLoginWall ? '请求被重定向到登录页' : '历史页未返回钱包历史表(页面结构可能变更)'], source: 'history-probe', finalUrl, checkedAt: Date.now(), }; throw err; } pages = 1; for (const r of spendParseRows(firstHtml)) { if (seen.has(r.key)) continue; seen.add(r.key); rows.push(r); } // ② 翻页(Steam 页面自身使用的 POST 接口) // 工具执行有 30s 上限:留出余量,超时前主动收尾成「部分结果」,不要被整体掐断后一无所有 const deadline = Date.now() + 22000; const sessionId = spendExtractSessionId(firstHtml); let cursor = spendExtractCursor(firstHtml); if (!cursor) complete = true; while (cursor && pages < maxPages) { if (Date.now() > deadline) { pageErrors.push(`已用满 22s 时间预算,停在 ${pages} 页;可用 maxPages 调小或稍后重试`); break; } const body = 'cursor=' + encodeURIComponent(String(cursor)) + '&sessionid=' + encodeURIComponent(sessionId); let text; try { text = await gmFetch(SPEND_MORE_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', }, data: body, timeout: 25000, }); } catch (e) { pageErrors.push(`第 ${pages + 1} 页请求失败:${e.message}`); break; } let json; try { json = JSON.parse(text); } catch { // 首屏已证明登录态:这里只可能是接口异常,不能反过来判「未登录」 pageErrors.push(`第 ${pages + 1} 页返回非 JSON:${text.slice(0, 120).replace(/\s+/g, ' ')}`); break; } if (!json || json.success === 0 || json.success === false) { pageErrors.push((json && (json.message || json.error)) || '翻页接口 success=0'); break; } const html = String(json.html || ''); const parsed = spendParseRows(html); let added = 0; for (const r of parsed) { if (seen.has(r.key)) continue; seen.add(r.key); rows.push(r); added++; } pages++; const next = json.cursor || null; if (!next) { complete = true; break; } if (!html.trim() || !added) break; // 空页/重复页:保守停止,complete 保持 false cursor = next; await new Promise(r => setTimeout(r, 200)); // 翻页限速 } return { rows, pages, complete, pageErrors, sessionId }; } // ---- 宿主桥镜像(Steam-Spending-History-Classifier 写入 localStorage)--------- // 仅 store 域同源可读:community 域是另一份 localStorage(按源隔离),读不到不是 bug。 function spendBridgeRead() { if (pcCurrentDomain() !== PC_DOMAIN_STORE) return null; let raw = null; try { raw = pcLocalGet(SPEND_BRIDGE_KEY); } catch { return null; } if (!raw || typeof raw !== 'object' || raw.v == null) return null; const rows = Array.isArray(raw.rows) ? raw.rows.filter(r => r && r.category && r.category !== 'other').map(r => ({ key: `${r.ts || r.date}|${r.total || ''}|${String(r.item || '').slice(0, 60)}`, ts: Number(r.ts) || null, date: r.date || '', item: r.item || '', type: r.type || '', category: r.category, currency: r.currency || null, amount: Number(r.amount) || 0, total: r.total || '', walletChange: r.walletChange || '', payment: r.payment || '', appid: r.appid || null, marketCount: Number(r.marketCount) || 1, })) : []; return { at: Number(raw.updatedAt) || 0, rows, complete: raw.complete === true, totalRows: Number(raw.totalRows) || rows.length, rowsSkipped: raw.rowsSkipped || null, truncated: raw.truncated === true, primaryCurrency: raw.primaryCurrency || null, aggregate: raw.aggregate && typeof raw.aggregate === 'object' ? raw.aggregate : null, }; } /** 宿主只镜像了聚合(quota 降级)时的口径适配 */ function spendBridgeAggregate(bridge) { const a = bridge && bridge.aggregate; if (!a) return null; const cur = SPEND_CURRENCIES.find(c => c.id === bridge.primaryCurrency) || null; return { currency: cur ? { id: cur.id, label: cur.label, symbol: cur.symbol } : null, netSpend: Math.round((Number(a.netSpend) || 0) * 100) / 100, grossSpend: Math.round((Number(a.grossSpend) || 0) * 100) / 100, refundTotal: Math.round((Number(a.refundTotal) || 0) * 100) / 100, categories: Array.isArray(a.categories) ? a.categories : [], otherCount: Number(a.otherCount) || 0, monthly: Array.isArray(a.monthly) ? a.monthly : [], }; } ToolRegistry.register({ name: 'getSpendingHistory', description: '获取当前登录 Steam 账号的消费历史(/account/history,走浏览器登录态,无需 Steam Web API Key),按 直购/内购/送礼/退款/充值/市场买入/市场卖出 七类分类统计。' + '内部三级取数:本机 GM 缓存 → 宿主 Spending 分类器桥镜像(毫秒级)→ 消费历史页自抓(首屏整页 + POST /account/AjaxLoadMoreHistory/ 表单翻页,每页 10 条,默认上限 50 页)。' + '返回 data.source(cache/bridge/fetch)与 data.coverage 说明数据来源与覆盖范围,回答时需据此声明。' + '适合回答"我在 Steam 上花了多少钱""最近哪个月买游戏最多""退款了多少""市场买卖流水""钱包充值记录"这类消费分析问题。' + '自抓失败但桥里有数据时会自动降级返回桥数据(degraded/partial 标记),此时不要改口说"拿不到数据"。' + '只有确证浏览器未登录时才返回 needLogin=true,并附带 loginState(含证据);result 为 null 时不要断言未登录。数据仅存本机,不上传。', parameters: { type: 'object', properties: { mode: { type: 'string', enum: ['summary', 'detail'], description: 'summary=聚合摘要(分类总额+月度趋势+最近N笔,默认);detail=逐笔明细(可用 category 过滤)' }, category: { type: 'string', description: 'detail 模式可选过滤:store/ingame/gift/refund/convert/market_buy/market_sell' }, topN: { type: 'number', description: '返回的最近笔数上限,summary 默认 12、detail 默认 40,最大 100' }, months: { type: 'number', description: 'summary 月度趋势的月份数,默认 12,最大 24' }, maxPages: { type: 'number', description: '首次抓取的翻页上限(每页 10 条),默认 50(约 500 条),最大 300' }, force: { type: 'boolean', description: '跳过 7 天缓存强制重新抓取(默认 false)' }, }, required: [], }, category: 'knowledge', async execute(params) { const mode = params.mode === 'detail' ? 'detail' : 'summary'; const topN = Math.max(1, Math.min(Number(params.topN) || (mode === 'detail' ? 40 : 12), 100)); const months = Math.max(1, Math.min(Number(params.months) || 12, 24)); const maxPages = Math.max(1, Math.min(Number(params.maxPages) || 50, 300)); const category = params.category && SPEND_LABEL[params.category] ? params.category : null; // ---- 三级取数:GM 缓存 → 宿主桥镜像 → 历史页自抓(失败再回退桥) ---- const bridge = spendBridgeRead(); let cache = params.force === true ? null : spendCacheGet(); let source = cache ? 'cache' : ''; let aggOverride = null; let bridgeOnly = false; // 桥已加载全量时可直接当缓存(毫秒级、与自抓同口径) if (!cache && params.force !== true && bridge && bridge.rows.length && bridge.complete) { cache = { v: 1, at: bridge.at || Date.now(), rows: bridge.rows, pages: 0, complete: true }; source = 'bridge'; } if (!cache) { let fetched = null; let fetchError = null; try { fetched = await spendFetchAll(maxPages); } catch (e) { fetchError = e; } if (fetched) { if (!fetched.rows.length) { return { success: false, error: '已登录但未解析到任何消费记录(账号可能确实没有消费历史,或 Steam 页面结构变更)', loginState: { loggedIn: true, steamId: spendLocalLoginEvidence().steamId, evidence: ['history page: 已登录标记'], source: 'history-probe', }, pagesFetched: fetched.pages, ...(fetched.pageErrors.length ? { pageErrors: fetched.pageErrors.slice(0, 3) } : {}), }; } cache = { v: 1, at: Date.now(), rows: fetched.rows, pages: fetched.pages, complete: fetched.complete }; spendCacheSave(cache); source = 'fetch'; } else { // 自抓失败:桥里有明细用明细,只有聚合用聚合(不把配额降级的空明细当失败) if (bridge && bridge.rows.length) { cache = { v: 1, at: bridge.at || Date.now(), rows: bridge.rows, pages: 0, complete: bridge.complete === true }; source = 'bridge'; } else if (bridge && bridge.aggregate) { aggOverride = spendBridgeAggregate(bridge); cache = { v: 1, at: bridge.at || Date.now(), rows: [], pages: 0, complete: bridge.complete === true }; bridgeOnly = true; source = 'bridge'; } if (!cache) { if (fetchError && (fetchError.message === 'SPEND_NOT_LOGIN' || fetchError.message === 'SPEND_PAGE_UNEXPECTED')) { const notLogin = fetchError.message === 'SPEND_NOT_LOGIN'; return { success: false, error: notLogin ? '消费历史页被重定向到 Steam 登录页:当前浏览器确实没有登录态。请在同一浏览器登录 store.steampowered.com 后重试。' : '消费历史页返回了无法识别的页面(可能是页面结构变更或访问被拦截),无法确认登录态。', needLogin: notLogin, loginState: fetchError.loginState || await spendLoginState(true), diagnosis: fetchError.diagnosis || null, ...(bridge ? { bridgeState: { rowsPresent: bridge.rows.length, rowsSkipped: bridge.rowsSkipped, complete: bridge.complete } } : { bridgeMissing: true }), }; } return { success: false, error: `抓取失败:${fetchError ? fetchError.message : '未知错误'}` }; } } } const primaryId = bridgeOnly ? (bridge.primaryCurrency || null) : spendDetectPrimaryCurrency(cache.rows); const agg = aggOverride || spendAggregate(cache.rows, primaryId); const ageMin = Math.max(0, Math.round((Date.now() - cache.at) / 60000)); const coverage = bridgeOnly ? `宿主插件只镜像了聚合(明细因 localStorage 配额被跳过);页面${cache.complete ? '已加载全量' : '未加载全量,聚合可能低于真实总额'}` : source === 'bridge' ? `来自宿主插件镜像:消费历史页已解析 ${cache.rows.length} 条${cache.complete ? '(已全量)' : '(「加载更多」未点完,可能不完整)'}` : cache.complete ? '已覆盖全部消费记录' : `仅覆盖最近 ${cache.pages} 页(${cache.rows.length} 条),可用 maxPages 调大或 force 重抓`; if (mode === 'detail') { if (!cache.rows.length) { return { success: false, error: '宿主桥只有聚合、没有逐笔明细(localStorage 配额降级),无法列出逐笔记录;可改用 mode=summary 看总额,或 force=true 走自抓。', source, }; } let rows = cache.rows; if (category) rows = rows.filter(r => r.category === category); const slim = rows.slice(0, topN).map(r => ({ date: r.date, item: r.item, type: r.type, category: r.category, currency: r.currency, amount: r.amount, total: r.total, ...(r.walletChange ? { walletChange: r.walletChange } : {}), ...(r.payment ? { payment: r.payment } : {}), ...(r.appid ? { appid: r.appid } : {}), ...(r.marketCount > 1 ? { marketCount: r.marketCount } : {}), })); return { success: true, data: { fetchedAt: cache.at, cacheAgeMinutes: ageMin, fromCache: source === 'cache', source, totalRows: cache.rows.length, matchedRows: rows.length, filteredBy: category, coverage, records: slim, ...(rows.length > topN ? { note: `仅返回最近 ${topN} 笔(共 ${rows.length} 笔匹配),可用 topN 调大` } : {}), }, summary: `消费明细 ${slim.length} 笔${category ? `(过滤:${SPEND_LABEL[category]})` : ''},共 ${cache.rows.length} 条记录`, }; } const recent = cache.rows.slice(0, topN).map(r => ({ date: r.date, item: r.item, category: r.category, total: r.total, ...(r.currency && r.currency !== primaryId ? { currency: r.currency } : {}), })); return { success: true, data: { fetchedAt: cache.at, cacheAgeMinutes: ageMin, fromCache: source === 'cache', source, totalRows: cache.rows.length, ...(cache.pages ? { pagesFetched: cache.pages } : {}), coverage, currency: agg.currency, netSpend: agg.netSpend, grossSpend: agg.grossSpend, refundTotal: agg.refundTotal, categories: agg.categories, ...(agg.otherCount ? { otherCount: agg.otherCount } : {}), monthlyTrend: agg.monthly.slice(0, months), recent, note: '净支出 = 直购+内购+送礼−退款(仅统计主货币行);市场买卖与充值不计入净支出。跨币种消费未折算。', ...(bridgeOnly ? { degraded: true, degradedReason: 'bridge-aggregate-only' } : {}), ...(source === 'bridge' && !cache.complete ? { partial: true } : {}), }, summary: `共 ${bridgeOnly ? (bridge.totalRows || 0) : cache.rows.length} 条消费记录,净支出 ${agg.currency ? agg.currency.symbol : ''}${agg.netSpend}` + (source === 'cache' ? `(缓存于 ${ageMin} 分钟前)` : source === 'bridge' ? `(宿主桥镜像${cache.complete ? '' : ',未全量'})` : `(刚抓取 ${cache.pages} 页)`), }; }, }); ToolRegistry.register({ name: 'getSteamLoginStatus', description: '检测当前浏览器是否已登录 Steam(只读、不消耗 Steam API 配额、不读取任何凭据)。' + '返回 loggedIn: true=已登录 / false=未登录 / null=无法判定(网络或页面结构问题,此时不得断言「未登录」)、steamId、证据列表与请求最终地址。' + '用于回答「我登录了吗」「为什么消费历史说我没登录」「要不要重新登录」这类问题,或在使用强登录依赖的工具前自检。结果缓存 60 秒。', parameters: { type: 'object', properties: { force: { type: 'boolean', description: '跳过 60 秒缓存强制重新探测(默认 false)' }, }, required: [], }, category: 'knowledge', async execute(params) { const state = await spendLoginState(params.force === true); const verdict = state.loggedIn === true ? '已登录' : state.loggedIn === false ? '未登录' : '无法判定'; return { success: true, data: { loggedIn: state.loggedIn, verdict, steamId: state.steamId || null, evidence: state.evidence, source: state.source || null, ...(state.finalUrl ? { finalUrl: state.finalUrl } : {}), checkedAt: state.checkedAt, cached: Date.now() - state.checkedAt > 500, ...(state.probeError ? { probeError: state.probeError } : {}), hint: state.loggedIn === true ? '浏览器已持有 Steam 登录态;若其他工具仍报未登录,问题在工具取数链路而非账号。' : state.loggedIn === false ? '当前浏览器没有 Steam 登录态(历史页被重定向到登录页)。登录 store.steampowered.com 后即可重试。' : '既没有本地登录标记、历史页也没有明确登录墙 —— 可能是网络/页面结构问题,请如实告知用户「无法判定」,不要断言未登录。', }, summary: `${verdict}${state.steamId ? `(SteamID ${state.steamId})` : ''}`, }; }, }); // ============================================================ // v1.16.0 联网检索 / 抓取(DeepSeek /v1/tools/*) // ============================================================ // 实测结论(2026-10-19,DeepSeek 官方接口): // search:稳定。固定返回 10 条,字段 title/site_name/authority/rerank_score/ // date/url/summary/snippet/content;summary 单条最长约 2.7KB、 // 10 条合计约 19KB(超 12KB 回灌预算,必须裁剪);content 实测恒为空。 // fetch :不稳定。store.steampowered.com 与 steamcommunity.com 实测均返回 // "failed to fetch URL"(被目标站拒绝抓取);部分大站会 gateway timeout; // 少数站点能返回但抽取质量差(只抓到导航栏)。故必须分类报错并引导改用 search。 // 统一错误分类:把网关/抓取失败翻译成模型能据此行动的提示 function classifyWebToolsError(msg, kind) { const m = String(msg || ''); if (/failed to fetch URL/i.test(m)) { // 实测(2026-10-19):该抓取服务成功率偏低,且**不止 Steam**—— // Wikipedia / BBC / HackerNews / Reuters / counter-strike.net / Steam 商店与社区 // 均返回 "failed to fetch URL",仅 GitHub、help 子站等少数站点成功 // (且成功时也可能只抽到导航栏)。故这里不暗示"换个域名就行"。 return kind === 'fetch' ? '该网址无法被抓取:目标站点拒绝或屏蔽了服务端抓取。这是抓取接口的已知限制,并非本机或网络问题——实测 Wikipedia/BBC/HackerNews/Reuters 以及 Steam 商店与社区站点均被拒绝,仅 GitHub 等少数站点可抓。' + '建议:① 直接用 webSearch 获取该页面的摘要(成功率很高);② 若必须读全文,改用 Steam 专用工具获取站内数据。' : '检索服务无法访问该来源,请更换关键词或改用其他工具。'; } if (/timeout|gateway_timeout/i.test(m)) { return '目标站点响应超时(站点过大或网络不通)。建议改用 webSearch,或换一个更轻量的页面。'; } if (/401|unauthorized|invalid.*key|authentication/i.test(m)) { return '联网工具鉴权失败:API Key 无效或未配置。请在设置「接口服务 → 联网检索工具」配置 Key。'; } if (/429|rate.?limit|too many/i.test(m)) { return '请求过于频繁或额度不足,已被服务端限流。请稍后重试。'; } return m; } async function callWebTools(path, payload) { const { base, key } = getWebToolsConfig(); if (!key) { return { ok: false, needKey: true, error: '未配置联网工具 API Key:请在设置「接口服务 → 联网检索工具」填写,或在「AI 设置」中配置 DeepSeek 模型(配置后本工具会自动复用其 Key)' }; } const url = `${base}/${path}`; try { const resp = await gmFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` }, data: JSON.stringify(payload), timeout: CONFIG.DS_TOOLS_TIMEOUT, // 放行 AI 端点主机:联网检索接口与对话接口同主机(默认都是 api.deepseek.com), // 这是本工具存在的目的。v1.11.0 的默认拦截是为了防止工具把 Key 外泄到 AI 主机, // 而这里的目标地址来自用户显式配置的 Base URL,不接受模型传入的任意地址, // 因此只开 allowAiEndpoint,**不放行 allowPrivate**(内网/回环仍一律拦截,SSRF 防护不变)。 allowAiEndpoint: true, }); let json; try { json = JSON.parse(resp); } catch { return { ok: false, error: `接口返回非 JSON:${String(resp).slice(0, 200)}` }; } if (json && json.error) { return { ok: false, error: classifyWebToolsError(json.error.message || json.error.type, path === 'fetch' ? 'fetch' : 'search'), rawError: json.error }; } return { ok: true, data: json }; } catch (e) { return { ok: false, error: classifyWebToolsError(e.message, path === 'fetch' ? 'fetch' : 'search') }; } } // ===== v1.16.0 检索结果缓存(抑制重复检索)===== // 动机:实测模型会为同一个问题连调 5 次 webSearch(自己拼信息), // 既烧 Key 额度又无增益。这里按 query 缓存一段时间,命中即返回并标注 fromCache。 const _searchResultCache = new Map(); const _searchCacheIndex = new Map(); // query → key(LRU 顺序) const SEARCH_CACHE_MAX = 24; function searchCacheGet(query) { const hit = _searchResultCache.get(query); if (!hit) return null; if (Date.now() - hit.at > CONFIG.SEARCH_RESULT_TTL) { _searchResultCache.delete(query); _searchCacheIndex.delete(query); return null; } // 触碰即移到末尾(LRU) _searchCacheIndex.delete(query); _searchCacheIndex.set(query, hit.at); return hit; } function searchCacheSet(query, data) { if (_searchResultCache.size >= SEARCH_CACHE_MAX && !_searchResultCache.has(query)) { const oldest = _searchCacheIndex.keys().next().value; if (oldest !== undefined) { _searchCacheIndex.delete(oldest); _searchResultCache.delete(oldest); } } const at = Date.now(); _searchResultCache.set(query, { at, data }); _searchCacheIndex.delete(query); _searchCacheIndex.set(query, at); } // 归一化 query:小写 + 压缩空白,让"CS2 更新"与"cs2 更新"命中同一条缓存 function normalizeSearchQuery(q) { return String(q || '').toLowerCase().replace(/\s+/g, ' ').trim(); } // 检索结果统一格式化(缓存命中与首次检索共用,保证两条路径输出完全一致) function formatSearchResult(it, rank) { return { rank, title: it.title || null, site: it.site_name || null, url: it.url || null, date: it.date || null, authority: Number(it.authority) || null, rerankScore: typeof it.rerank_score === 'number' ? Math.round(it.rerank_score * 1000) / 1000 : null, summary: String(it.summary || it.snippet || '').slice(0, CONFIG.SEARCH_SUMMARY_MAX_CHARS), }; } ToolRegistry.register({ name: 'webSearch', description: '联网搜索网页,返回结构化检索结果(标题/来源站/日期/URL/摘要),由 DeepSeek 官方检索接口提供。' + '适合回答"最近有什么新闻/更新""XX 现在多少钱/什么时候发售""某个事件最新进展"等需要实时或站外信息的问题。' + '注意:这是通用网页搜索,不含 Steam 站内私有数据——查游戏价格/好友/家庭库请优先用 Steam 专用工具。', parameters: { type: 'object', properties: { query: { type: 'string', description: '搜索关键词或自然语言问题(建议用与站点相关的具体词汇)' }, topN: { type: 'number', description: '返回条数,默认 5,最大 10(接口固定返回 10 条,此处只做裁剪)' }, }, required: ['query'], }, category: 'knowledge', async execute(params) { const query = String(params.query || '').trim(); if (!query) return { success: false, error: '缺少 query 参数' }; const topN = Math.max(1, Math.min(Number(params.topN) || 5, 10)); // v1.16.0:同 query 直接命中缓存,不重复消耗 Key 额度 const ck = normalizeSearchQuery(query); const cached = searchCacheGet(ck); if (cached) { const d = cached.data; const results = d.list.slice(0, topN).map((it, i) => formatSearchResult(it, i + 1)); return { success: true, data: { query, totalAvailable: d.total, returned: results.length, results, fromCache: true, cacheAgeMinutes: Math.max(0, Math.round((Date.now() - cached.at) / 60000)), note: `本次结果来自本地缓存(${Math.max(0, Math.round((Date.now() - cached.at) / 60000))} 分钟前检索的同一 query)。请直接基于它作答,不要用相近关键词重复检索。`, }, summary: `检索「${query}」命中缓存(${d.total} 条,取前 ${results.length} 条)`, }; } const r = await callWebTools('search', { query }); if (!r.ok) { return r.needKey ? { success: false, needsApiKey: true, needsWebToolsKey: true, error: r.error } : { success: false, error: r.error, rawError: r.rawError || null }; } const list = Array.isArray(r.data && r.data.results) ? r.data.results : []; if (!list.length) { return { success: true, data: { query, total: 0, results: [] }, summary: `未检索到「${query}」的相关结果` }; } searchCacheSet(ck, { list, total: list.length, at: Date.now() }); // 裁剪:summary 单条截到 900 字符(实测原生可达 2.7KB),并丢弃恒为空的 content 字段 const results = list.slice(0, topN).map((it, i) => formatSearchResult(it, i + 1)); const clipped = list.some((it, i) => i < topN && String(it.summary || '').length > CONFIG.SEARCH_SUMMARY_MAX_CHARS); return { success: true, data: { query, totalAvailable: list.length, returned: results.length, results, ...(clipped ? { summaryTruncated: true, note: `摘要已截断至 ${CONFIG.SEARCH_SUMMARY_MAX_CHARS} 字符;需要完整内容可对感兴趣的结果 URL 调用 webFetch` } : {}), }, summary: `检索「${query}」得到 ${results.length}/${list.length} 条结果:${results.slice(0, 3).map(r => r.title || r.url).filter(Boolean).join(';')}`, }; }, }); ToolRegistry.register({ name: 'webFetch', description: '抓取单个网页并以 Markdown 文本返回正文内容(由 DeepSeek 官方抓取接口提供)。' + '⚠️ 实测该接口成功率偏低:多数站点(Wikipedia/BBC/HackerNews/Reuters/Steam 商店与社区)会返回"无法抓取",' + '只有 GitHub 等少数站点可抓,且成功时也可能只抽到导航栏。因此**优先用 webSearch**(成功率很高),' + '仅当确实需要某页全文、且该 URL 大概率可抓时再用本工具。' + 'Steam 站内的私有数据(好友/家庭库/个人游戏库/许可来源)不要用本工具,必须走 Steam 专用工具或 readPluginCache。', parameters: { type: 'object', properties: { url: { type: 'string', description: '要抓取的网页 URL(http/https)' }, maxChars: { type: 'number', description: '正文返回上限,默认 8000 字符,最大 20000' }, }, required: ['url'], }, category: 'knowledge', async execute(params) { const url = String(params.url || '').trim(); if (!url) return { success: false, error: '缺少 url 参数' }; let parsed = null; try { parsed = new URL(url); } catch { return { success: false, error: `URL 格式无效:${url}` }; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return { success: false, error: `仅支持 http/https 协议,收到 ${parsed.protocol}` }; } const maxChars = Math.max(500, Math.min(Number(params.maxChars) || CONFIG.FETCH_CONTENT_MAX_CHARS, 20000)); const r = await callWebTools('fetch', { url }); if (!r.ok) { return r.needKey ? { success: false, needsApiKey: true, needsWebToolsKey: true, error: r.error } : { success: false, error: r.error, url, rawError: r.rawError || null }; } const content = String((r.data && r.data.content) || ''); if (!content) { return { success: false, error: `抓取成功但未解析出正文内容(可能是纯前端渲染页或导航栏结构):${url}`, url }; } const clipped = content.length > maxChars; return { success: true, data: { url, fetchedAt: Date.now(), contentChars: content.length, content: clipped ? content.slice(0, maxChars) : content, ...(clipped ? { truncated: true, note: `正文被截断至 ${maxChars} 字符(原始 ${content.length} 字符)` } : {}), }, summary: `已抓取 ${url},正文 ${content.length} 字符${clipped ? `(返回前 ${maxChars} 字符)` : ''}`, }; }, }); // ============================================================ // 游戏热点模块(薄接入层) // ============================================================ // 全部实现都在独立库 libs/saa-game-news.lib.js(window.SAAGameNews): // 源注册表 / RSS+HTML 解析 / 中文优先排序 / 聚合缓存 / 浮窗 UI+CSS 都在库里。 // 主脚本只做三件事:解析库 → 注册一个薄工具 → 挂载入口按钮。 // 这样新增/调整资讯源不需要动 AI Agent 主逻辑,改库即可。 // // 库通过 @require 加载(发布到仓库后由 CDN 提供)。**库缺失时优雅降级**: // 不注册工具、不挂按钮、其余功能完全不受影响。 const GameNews = (typeof window !== 'undefined' && window.SAAGameNews) || null; if (GameNews) { GameNews.loadCustom((k, d) => GM_getValue(k, d)); // 注入模型侧抓取能力:库里 type='dsfetch' 的源(如小黑盒,站点是 SPA、 // 直连只能拿到 3.4KB 空壳)走 webFetch —— 它会执行 JS,能拿到渲染后的正文。 // 该源在库里单独走 24 小时缓存,实现「每天抓几条、不定期 fetch」。 GameNews.setFetcher(async (url, timeout) => { const r = await callWebTools('fetch', { url }); if (!r.ok) return { ok: false, error: r.error }; return { ok: true, text: String((r.data && r.data.content) || '') }; }); // v1.35.0 注入检索能力(浮窗顶部标题栏右侧的增强搜索框用): // webSearch → POST /v1/tools/search(联网检索:实时新闻 / 目标页面) // chat → callAiChat(模型 API)。**由库负责拼提示词**,并把 webSearch 的 // 检索结果当上下文喂进去 —— 这样模型是在"读"检索结果做总结, // 而不是凭记忆瞎编链接(库里还会再做一次 http(s) 白名单校验兜底)。 // // ⚠️ 按能力降级:宿主可能仍在跑旧版热点库(没有 setSearchProviders)—— // 先判能力再注入,别让「库版本不一致」把整个热点模块搞没。 if (typeof GameNews.setSearchProviders === 'function') { GameNews.setSearchProviders({ webSearch: async (query) => { const r = await callWebTools('search', { query }); if (!r.ok) return { ok: false, error: r.error }; const list = Array.isArray(r.data && r.data.results) ? r.data.results : []; return { ok: true, items: list.map((it) => ({ title: it.title || '', url: it.url || '', site: it.site_name || '', date: it.date || '', summary: String(it.summary || it.snippet || ''), })), }; }, chat: async (prompt) => { const r = await callAiChat(prompt, { systemInstruction: '你是游戏资讯检索助手。只输出一个 JSON 对象,不要解释文字,不要用代码围栏。', isolated: true, // 独立请求:不占用聊天通道,也不受「停止生成」影响 temperature: 0.3, // 检索总结要稳,别发散 maxTokens: 2000, // JSON + 摘要,防止被截断成半截 JSON timeout: 45000, }); return { ok: true, text: r.content || '' }; }, }); } else { console.warn('[SA-Agent] 游戏热点库为旧版(无 setSearchProviders),标题栏搜索框退化为「仅本地匹配」'); } ToolRegistry.register({ name: 'getGameNewsDigest', description: '聚合专业游戏资讯站的最新头条(机核 / Steam 官方 / 3DM / 游民星空),一次调用拿到多来源热点汇总,30 分钟内走本地缓存。' + '适合回答"最近有什么游戏新闻""业界有什么新动态""现在大家都在关注什么游戏"这类**跨来源热点汇总**问题——' + '比自己用 webSearch 反复检索更省额度、覆盖面更广。条目按中文优先排序,Steam 官方源为中英混合。' + '注意:这里只给标题与链接(部分源含摘要),要读某篇全文需再对具体 URL 调用 webFetch(多数站点抓取会失败)。', parameters: { type: 'object', properties: { sources: { type: 'string', description: '可选,逗号分隔的来源过滤:gcores(机核) / steam(Steam官方) / 3dm / gamersky(游民星空)。不填=全部' }, perSource: { type: 'number', description: '每个来源取几条,默认 8,最大 15' }, force: { type: 'boolean', description: '可选,跳过 30 分钟缓存强制重新抓取(默认 false)' }, chineseOnly: { type: 'boolean', description: '可选,只保留中文标题的条目(默认 false)。实测 Steam 官方源以英文为主、机核/3DM/游民星空 全中文,若用户只要中文可开启此参数' }, }, required: [], }, category: 'knowledge', async execute(params) { const sources = GameNews.sources; const perSource = Math.max(1, Math.min(Number(params.perSource) || 8, 15)); const chineseOnly = params.chineseOnly === true; const want = String(params.sources || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean); if (want.length) { const unknown = want.filter(w => !sources.some(s => s.id === w)); if (unknown.length) { return { success: false, error: `未知来源:${unknown.join(',')};可选:${sources.map(s => s.id).join(' / ')}` }; } } const bundle = await GameNews.getDigest({ force: !!params.force, perSource }); const results = []; const failed = []; for (const got of bundle.sources) { if (want.length && want.indexOf(got.id) < 0) continue; if (!got.items || !got.items.length) { failed.push(`${got.label}(${got.error || '无数据'})`); continue; } if (got.error) failed.push(`${got.label}(部分失败:${got.error})`); let items = GameNews.sortChineseFirst(got.items).slice(0, perSource); if (chineseOnly) items = items.filter(it => GameNews.isChinese(it.title)); // chineseOnly 过滤后可能为空,此时不静默丢弃——记为该源无中文条目 if (!items.length) continue; results.push({ source: got.id, label: got.label, items: items.map(it => ({ ...it, lang: GameNews.isChinese(it.title) ? 'zh' : 'other' })), chineseCount: items.filter(it => GameNews.isChinese(it.title)).length, }); } const total = results.reduce((n, r) => n + r.items.length, 0); if (!total) { return { success: false, error: `全部来源都没取到内容(${failed.join('、') || '未知原因'})。可能是网络受限或站点结构变更;可稍后重试或改用 webSearch。`, sources: failed, }; } const ageMin = Math.max(0, Math.round((Date.now() - bundle.at) / 60000)); return { success: true, data: { fetchedAt: bundle.at, cacheAgeMinutes: ageMin, fromCache: bundle.cached === true, total, chineseTotal: results.reduce((n, r) => n + r.chineseCount, 0), sources: results, ...(failed.length ? { partialFailures: failed } : {}), ...(chineseOnly ? { chineseOnly: true } : {}), note: chineseOnly ? '已过滤为仅中文条目;各站独立抓取,单站无中文不影响其他来源。' : '条目已按中文优先排序(lang 字段标注 zh/other);各站独立抓取,单站失败不影响其他来源。', }, summary: `聚合 ${results.length} 个来源共 ${total} 条游戏资讯头条` + (bundle.cached ? `(缓存于 ${ageMin} 分钟前)` : '') + (failed.length ? `;${failed.length} 个源部分失败` : ''), }; }, }); } else { console.warn('[SA-Agent] 未加载游戏热点库(libs/saa-game-news.lib.js),getGameNewsDigest 与热点入口已跳过'); } // ============================================================ // AI 搜索浮窗(薄接入层) // ============================================================ // 全部实现都在独立库 libs/saa-search.lib.js(window.SAASearch): // 关键字匹配 / 分组结果 / 浮窗 UI + CSS 都在库里。 // 主脚本只做三件事:解析库 → 注入数据源 provider → 挂载入口按钮。 // // Phase 1 试点两个源(不依赖 data-hub,库没装也能用): // ① 我的游戏库 → SGLV 的 IDB 缓存(sglv_cached_games),顺带 join HLTB 发行年/主线时长 // ② 新闻热点 → SAAGameNews.getDigest()(走热点库 30 分钟聚合缓存,不重复抓) const Search = (typeof window !== 'undefined' && window.SAASearch) || null; if (Search) { Search.setProviders({ getLibrary: async () => { if (!pcDataSourceAllowed('sglv')) { throw new Error('未授权读取「SGLV 游戏库展示」的数据(设置 → 数据源)'); } // v1.36.0:不再硬拦域 —— pcReadSglvLibrary 本域读不到时自动回退跨域 GM 镜像, // 社区域照样能搜游戏库(数据来自商店域快照,会带 note 声明新鲜度)。 const lib = await pcReadSglvLibrary(); if (!lib.ok) throw new Error(lib.error); // 顺带带上 HLTB 的发行年 / 主线时长(索引加载失败时静默留空,不阻断搜索) const hltbReady = await pcHltbLoad(); const games = lib.games.map((g) => { const appid = Number(g && g.appid); const h = hltbReady ? pcHltbGet(appid) : null; return { appid, name: (g && g.name) || `App ${appid}`, playtime_forever: (g && g.playtime_forever) || 0, year: h ? h.y : null, storyHours: h ? h.m : null, }; }); if (!lib.viaMirror) return games; // 镜像来源:交给库渲染成分组级提示(一条说明即可,不必每行都标) return { games, note: `跨域镜像 · 快照 ${pcHumanAge(lib.ageMs)}${lib.stale ? '(可能已过期)' : ''}` + ' —— 商店域缓存同步而来,社区页可直接搜索', }; }, getNews: async () => { if (!GameNews) throw new Error('未加载游戏热点库'); const bundle = await GameNews.getDigest({ perSource: 8 }); const out = []; for (const got of (bundle.sources || [])) { for (const it of (got.items || [])) { out.push({ title: it.title, url: it.url || it.link || '', source: got.label, date: it.pubDate || it.date || '', summary: it.summary || it.description || '', }); } } return out; }, }); // 「用 AI 深入分析」→ 把选中条目带进对话,模型一次成稿。 // ⚠️ 回调注册在模块级(而非 createSidebar 内):面板创建失败时搜索入口仍可闭环。 Search.setHandlers({ onAnalyze: (payload) => { const title = (payload && payload.title) || ''; const text = payload && payload.kind === 'library' ? `请分析我游戏库里的《${title}》(appid ${payload.appid}):主线剧情要多久、我玩了多少(剧情完成度)、还剩多少没玩、值不值得继续玩。` : `请深入分析这条资讯:《${title}》,说清讲了什么、对玩家意味着什么。`; if (Search.closeWindow) Search.closeWindow(); openSidebar({ message: text }); // 按钮语义就是「用 AI 深入分析」→ 直接发出去,不让用户再按一次回车 setTimeout(() => { try { handleSend(); } catch (e) { /* ignore */ } }, 60); }, }); } else { console.warn('[SA-Agent] 未加载搜索浮窗库(libs/saa-search.lib.js),搜索入口已跳过'); } 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) { calls.push(...parseToolPayload(match[1])); } // 兜底:整段响应就是一个裸 JSON(模型直接输出函数调用对象或数组) if (!calls.length) { const stripped = text.replace(/```(?:json)?/gi, '').trim(); if (/^[\[{][\s\S]*[\]}]$/.test(stripped)) { calls.push(...parseToolPayload(stripped)); } } 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 coerceToolCallObject(parsed) { 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 ?? parsed.function?.arguments ?? {}; if (typeof args === 'string') { const argsRepaired = repairToolCallJson(args); args = tryJson(argsRepaired) || tryJson(salvageTruncatedJson(argsRepaired)) || {}; } if (!args || typeof args !== 'object' || Array.isArray(args)) args = {}; return { id: parsed.id || '', name: name.trim(), arguments: args }; } function parseToolPayload(raw) { const repaired = repairToolCallJson(raw); const parsed = tryJson(repaired) || tryJson(salvageTruncatedJson(repaired)); if (!parsed || typeof parsed !== 'object') return []; const items = Array.isArray(parsed) ? parsed : [parsed]; const calls = []; for (const item of items) { const call = coerceToolCallObject(item); if (call) calls.push(call); } return calls; } function parseToolCallJson(raw) { return parseToolPayload(raw)[0] || null; } // 原生 tool_calls 优先;没有时再从正文里的文本协议解析。同一轮只走一条路径,避免执行两遍。 function mergeToolCalls(nativeCalls, text) { const native = normalizeNativeToolCalls(nativeCalls); if (native.length) return native; return parseToolCalls(text); } function normalizeNativeToolCalls(list) { if (!Array.isArray(list)) return []; const out = []; for (const tc of list) { const call = coerceToolCallObject(tc && tc.function ? { id: tc.id, ...tc, name: tc.function.name, arguments: tc.function.arguments } : tc); if (call) out.push(call); } return out; } function isNativeToolsRejected(err) { const msg = String(err?.message || ''); if (!/HTTP 4\d\d/.test(msg)) return false; return /tool_choice|\btools\b|function[_\s-]?call/i.test(msg); } // 流式正文还像工具调用时先不画到气泡里,避免把 JSON 闪给用户。 function shouldHoldStream(text) { const s = String(text || '').trim(); if (!s) return false; if (/[\s\S]*?<\/think>/gi, '').trim(); const head = noThink.slice(0, 80); if (/^ setTimeout(r, 12)); } return clean; } 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' }, getSpendingHistory: { label: '消费历史', icon: 'stats' }, getSteamLoginStatus: { label: '登录状态', icon: 'user' }, readPluginCache: { label: '插件缓存', icon: 'cpu' }, getInstalledPlugins: { label: '插件清单', icon: 'gear' }, getStoryData: { label: '剧情时长', icon: 'gamepad' }, searchLocalDb: { label: '本地数据库', icon: 'search' }, readSkill: { label: '操作技能', icon: 'book' }, localDbProfile: { label: '库画像', icon: 'stats' }, pushInsight: { label: '结论回灌', icon: 'zap' }, pushObservation: { label: '时长上报', icon: 'zap' }, webSearch: { label: '网页检索', icon: 'search' }, webFetch: { label: '网页抓取', icon: 'globe' }, getGameNewsDigest: { label: '游戏热点', icon: 'news' }, }; 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 formatToolDuration(ms) { const n = Number(ms); if (!Number.isFinite(n) || n < 0) return ''; return n >= 1000 ? (n / 1000).toFixed(1) + 's' : Math.round(n) + 'ms'; } // 展开区内容:完整参数 + 结果(超长截断到 4000 字符,仅展示层,不影响回灌模型的数据) function formatToolChipDetail(args, result) { const lines = []; try { lines.push('参数: ' + JSON.stringify(args ?? {}, null, 2)); } catch { lines.push('参数: (无法序列化)'); } if (result) { let payload; if (result.error && result.success === false) payload = { success: false, error: result.error }; else payload = result.data !== undefined ? result.data : result; let text; try { text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2); } catch { text = '(无法序列化)'; } if (text.length > 4000) text = text.slice(0, 4000) + `\n…(展示已截断,原始长度 ${text.length} 字符)`; lines.push('结果: ' + text); } return lines.join('\n'); } function renderToolChip(name, args, running, failed, meta = {}) { const d = getToolDisplay(name); const argText = formatToolArgs(args); const state = running ? '' : failed ? getIcon('close', 11) : getIcon('check', 11); const head = `${getIcon(d.icon, 12)}` + `${escapeHtml(d.label)}` + (argText ? `${escapeHtml(argText)}` : '') + (!running && meta.durationMs != null ? `${formatToolDuration(meta.durationMs)}` : '') + `${state}` + (running ? '' : '▸'); // 运行中用普通 div(避免 details 折叠动画干扰 spinner);完成后升级为可折叠 details 卡片 if (running) { return `${head}`; } return `` + `${head}` + `${escapeHtml(formatToolChipDetail(args, meta.result))}` + ``; } function buildToolCallsHtml(toolCalls, runningIndex = -1) { if (!toolCalls || !toolCalls.length) return ''; const needsKey = toolCalls.some(tc => tc && tc.result && tc.result.needsApiKey); const keyAction = needsKey ? `${getIcon('gear', 13)} 配置 Steam API Keysteamcommunity.com/dev 免费申请 · 配置后回复「已配置」继续` : ''; const needsItad = toolCalls.some(tc => tc && tc.result && tc.result.needsItadKey); const itadAction = needsItad ? `${getIcon('globe', 13)} 配置 ITAD API Keyisthereanydeal.com/apps 免费申请 · 配置后跨区比价升级为增强模式` : ''; return `${toolCalls.map((tc, i) => renderToolChip(tc.name, tc.arguments, tc.running === true || i === runningIndex, !!(tc.result && tc.result.success === false), { durationMs: tc.durationMs, result: tc.result, }) ).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 raw = String(m.content || ''); const toolCount = (raw.match(//g) || []).length; const isToolResult = m.role === 'system' && toolCount > 0; // 并行工具合成一条消息,按条数放宽上限,避免中间结果被腰斩 const limit = !isToolResult ? CONTEXT_MSG_MAX_CHARS : Math.min(CONTEXT_TOOL_RESULT_MAX_CHARS * Math.max(toolCount, 1), 8000); return { role: m.role, content: truncateMiddle(raw, 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 pageText(doc, selector) { try { const el = doc && doc.querySelector ? doc.querySelector(selector) : null; return el ? String(el.textContent || '').trim() : ''; } catch { return ''; } } function pageMeta(doc, selector) { try { const el = doc && doc.querySelector ? doc.querySelector(selector) : null; if (!el) return ''; return String((el.getAttribute && el.getAttribute('content')) || el.content || '').trim(); } catch { return ''; } } // 从当前 URL / DOM 读取用户正在看的游戏或个人资料。loc、doc 可注入,便于测试。 function readPageContext(loc, doc) { const locationRef = loc || (typeof location !== 'undefined' ? location : { href: '', pathname: '' }); const documentRef = doc || (typeof document !== 'undefined' ? document : null); const href = String(locationRef.href || ''); const path = String(locationRef.pathname || ''); const ctx = { kind: 'unknown', url: href.split('?')[0], title: '' }; try { ctx.title = String((documentRef && documentRef.title) || '').trim(); } catch { ctx.title = ''; } const appMatch = href.match(/(?:store\.steampowered\.com|steamcommunity\.com)\/app\/(\d+)/i); const profileIdMatch = href.match(/steamcommunity\.com\/profiles\/(\d{17})/i); const vanityMatch = href.match(/steamcommunity\.com\/id\/([^/?#]+)/i); const onFriends = /\/friends(?:\/|$)/i.test(path); if (appMatch) { ctx.kind = 'store_app'; ctx.appid = appMatch[1]; const domName = pageText(documentRef, '.apphub_AppName') || pageText(documentRef, '#appHubAppName'); const og = pageMeta(documentRef, 'meta[property="og:title"]').replace(/\s+on Steam$/i, ''); const slug = (href.match(/\/app\/\d+\/([^/?#]+)/i) || [])[1]; ctx.name = domName || og || (slug ? decodeURIComponent(slug).replace(/_/g, ' ') : '') || ctx.title.replace(/\s+on Steam$/i, ''); } else if (profileIdMatch || vanityMatch) { ctx.kind = onFriends ? 'friends' : 'profile'; try { ctx.steamId = profileIdMatch ? profileIdMatch[1] : decodeURIComponent(vanityMatch[1]); } catch { ctx.steamId = profileIdMatch ? profileIdMatch[1] : vanityMatch[1]; } ctx.vanity = !profileIdMatch; ctx.name = pageText(documentRef, '.actual_persona_name') || pageText(documentRef, '.persona_name') || ctx.title; } else if (onFriends) { ctx.kind = 'friends'; } if (ctx.kind === 'friends' || onFriends) { ctx.onFriends = true; const viewer = resolveSteamId(''); if (viewer.steamId) { ctx.viewerSteamId = viewer.steamId; ctx.viewerSource = viewer.source; } } return ctx; } function formatPageContext(ctx) { if (!ctx || ctx.kind === 'unknown') return ''; const lines = ['## 当前页面', '用户正在浏览 Steam 页面。指代「这个游戏」「这款」「当前页」「这个人」「我的好友」时,直接使用下面的标识,不要再索要 AppID 或 SteamID。']; if (ctx.kind === 'store_app') lines.push(ctx.url.includes('steamcommunity.com') ? '- 类型:社区游戏页' : '- 类型:商店游戏页'); else if (ctx.kind === 'friends') lines.push('- 类型:好友列表页'); else if (ctx.kind === 'profile') lines.push('- 类型:个人资料页'); if (ctx.appid) lines.push(`- AppID:${ctx.appid}`); if (ctx.name) lines.push(`- 名称:${ctx.name}`); if (ctx.steamId) lines.push(`- 页面用户:${ctx.steamId}${ctx.vanity ? '(自定义 ID)' : ''}`); if (ctx.viewerSteamId) lines.push(`- 当前登录用户 SteamID64:${ctx.viewerSteamId}(来源:${ctx.viewerSource || '自动检测'})`); if (ctx.url) lines.push(`- URL:${ctx.url.slice(0, 180)}`); return lines.join('\n'); } 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 ? '' : '(完整历史,无裁剪)'}`); const pageBlock = formatPageContext(readPageContext()); let prompt = `(提示:你的角色设定已在系统指令中给出,请严格遵循。) ## 可用工具 ${toolsDesc || '(无可用工具)'} ## 对话历史 ${historyText || '(无历史记录)'} ${toolHistory ? '## 工具调用结果\n' + toolHistory + '\n' : ''}${contextNotes.length ? `## 上下文说明\n${contextNotes.map(t => '- ' + t).join('\n')}\n\n` : ''}${pageBlock ? pageBlock + '\n\n' : ''}## 当前任务 用户消息: ${userMessage} ## 执行指令 你正在执行第 ${currentIter}/${maxIter} 次迭代。${currentIter >= maxIter ? '\n\n**注意:这是最后一轮迭代,已无剩余工具调用机会。禁止再调用任何工具,必须基于上方已获取的工具结果直接输出最终中文回答;数据不足的部分如实说明即可。**' : ''} 在回答之前,请先思考是否需要使用工具: - 如果需要调用工具,必须严格使用以下格式(一段完整合法的 JSON,必须以 闭合): {"name":"工具名称","arguments":{"字符串参数":"值","数值参数":123}} - 如果不需要工具,直接回答用户问题 工具调用格式要求: 1. JSON 必须合法:键与字符串值一律用双引号,无注释、无尾逗号,不要用 markdown 代码围栏(\`\`\`)包裹 2. 参数对象的键名固定为 "arguments",不要写成 parameters/args/input 3. 一次可调用多个互不依赖的只读工具,单轮最多 ${CONFIG.MAX_PARALLEL_TOOLS} 个。优先使用接口提供的原生工具调用;若只能输出文本,可连续输出多段 {"name":"工具名称","arguments":{"参数名":"值"}},或在一个标签里输出 JSON 数组。决定调用工具时不要同时写最终回答 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 格式 15. 「当前页面」里已有 AppID 或 SteamID 时,用户用「这个游戏 / 这款 / 当前页 / 这个人 / 我的好友」指代,必须直接使用这些标识调用工具,不要再向用户索要 16. 涉及"我的"本地数据(我的游戏库/我的游玩时长/我的好友在玩什么/家庭共享库有哪些游戏/家庭愿望单/某游戏我是怎么获得的/市场价格快照)时,优先调用 readPluginCache 读本机插件缓存(毫秒级、不消耗 Steam API 配额),缓存未命中再用网络工具。该工具返回 cacheAgeMinutes,超过 24 小时(stale=true)时必须在回答中声明"数据来自本地缓存,可能已过期"。若返回 needDomain,提示用户切换到对应页面(商店页 store / 社区页 community)后重试 17. 需要**站外或实时信息**(最新新闻/版本动态/当前售价/发售时间/某事件进展)时,用 webSearch 检索。注意三点:①**优先用 webSearch,不要先试 webFetch**——实测抓取接口成功率很低(Wikipedia/BBC/HackerNews/Reuters/Steam 商店与社区均被拒绝),webFetch 只在确实需要某页全文且该站大概率可抓时使用,且失败一次就不要再重试同一 URL;②Steam 站内的私有数据(好友/家庭库/个人游戏库/许可来源)不要用这两个工具,必须走 Steam 专用工具或 readPluginCache;③引用检索结果时保留来源站点与日期 18. webSearch/webFetch 返回 needsWebToolsKey 或鉴权失败时,引导用户在设置「接口服务 → 联网检索工具」配置 Key(或说明「AI 设置」里配 DeepSeek 模型可自动复用),不要重复调用同一工具 19. 问「最近有什么游戏新闻/业界热点/大家在玩什么新游戏」这类**跨来源游戏热点汇总**时,优先调用 getGameNewsDigest(一次拿机核/Steam/3DM/游民星空四站头条,30 分钟缓存),比反复 webSearch 更省额度、覆盖更全;若结果带 fromCache=true,直接基于它作答,不要用相近措辞重复检索 20. 涉及「消费/花了多少钱/充值/退款/市场买卖流水/月度消费趋势」类问题时,**直接调用 getSpendingHistory**(v1.34.0 起它内部已按「本机 GM 缓存 → 宿主消费桥镜像 → 消费历史页自抓」三级取数,桥的聚合/明细都会被采纳并标注 source;不要再先单独调 readPluginCache 判空——桥的配额降级空明细会把你误导成「拿不到数据」)。回答时读 data.source 与 data.coverage:source=bridge/cache 要声明数据来自本地缓存/插件镜像;partial 或 degraded=true 要说明只覆盖页面已加载部分、可能低于真实总额;金额必须带币种并说明「净支出=直购+内购+送礼−退款(仅主货币行)」。仅当返回 needLogin=true 且 loginState.loggedIn===false 时才提示用户登录;loginState.loggedIn 为 null 时如实说「无法判定」并建议稍后重试,绝不能让用户白白重新登录一次 21. 问「我的 steam 油猴插件装齐了吗」「steamkit 系列装了哪些」「XX 插件版本过旧吗」「装了哪些游戏库增强」类问题时,调用 getInstalledPlugins(本地只读,不联网查商店)。它合并三个来源:①版本契约 data/plugin-contract.json(期望清单 + 最低版本)②各插件经共享宿主 registerPlugin 注册的真实版本号 ③本地存储探测(识别尚未接入注册的老插件)。回答要点:装了几个/缺几个/哪些版本过旧,并说明「版本号」来自插件自身注册(精确)还是仅探测到存在(版本未知)。若工具返回「版本契约不可用」,提示用户检查网络后重试 22. 问「XX 要玩多久才通关」「我到底通没通关 XX」「主线剧情多长」「我买的这些游戏是独立小作还是大作」「我的库里哪些游戏买了没玩」类问题时,调用 getStoryData。它从 HLTB 索引取主线时长与发行年份,并结合我的游玩时长算剧情完成度 SCI(分档:序章搁浅<0.35 / 剧情未竟0.35-0.8 / 剧情通关0.8-1.3 / 支线探索家1.3-2.5 / 自由漫游>2.5)。不传 appids 时走「我的游戏库」模式(需在 Steam 商店页打开过游戏库展示插件)。⚠️ HLTB 只能覆盖能对上的部分(实测 54.6%),「查不到」= 无数据,不是 0 小时,回答时必须说明缺失比例,不要把没命中的说成时长为 0 23. 需要**跨维度筛选**(如「剧情超 20 小时」「2015 年后」「价格低于 XX」「买了没玩过」)时优先调用 searchLocalDb(远程 data-hub 个人数据资产库,SQLite + FTS5)。readPluginCache 只能整读一个缓存键、做不了筛选,所以这类问题别用 readPluginCache 硬凑。若工具返回「未配置地址」或「无法连接」,说明远程数据中枢没配地址/服务不可达(或还没绑定 Steam 账号),改用 readPluginCache + getOwnedGames,并把筛选条件说清楚 24. 涉及「我的库里有哪些 X」这类**关键词检索**时,可以引导用户用面板头部的「搜索」浮窗(同时搜我的游戏库 + 新闻热点),点「用 AI 深入分析」即可把选中条目带进对话 25. 处理**复杂多步任务**(需要跨表筛选 + 交叉验证 + 出报告/图表)时,**先调 readSkill** 读操作技能库,拿到意图路由与口径纪律后再按路由调工具。技能给的是「遇到这类问题该怎么用工具」的**操作指引与口径**,不给事实——事实仍要靠工具查。若 readSkill 返回「正文暂时取不到」,就按常规方式用 searchLocalDb 查即可 26. 问「我的游戏库是什么画像」「我是不是囤货党」「我买的东西值不值」「给我一份库存盘点报告」这类**画像类**问题时,优先调 localDbProfile(走 data-hub 的 /profile 与 /report),不要自己用 searchLocalDb 拼凑画像:它已按统一口径算好未开封率、时长阶梯、清灰倒计时、被冷落的神作、最亏/最值、每小时价值、人格称号+稀有度。⚠️ 必看 data.library.durationEvidence:为 "missing" 说明库里没有游玩时长数据,正确答案是「算不了」而不是「你 100% 没玩过」;价格类指标是**当前折后价**不是实付价;好评率只覆盖部分游戏,榜单要带覆盖率。format=html 可直接拿到带内联 SVG 图表的报告正文 27. 分析出**有依据、可复用**的结论后(如「某厂商集中度偏高」「某系列还差 3 款」「这款每小时成本极低」),用 pushInsight 把结论回灌到数据中枢,落成可追溯的库资产。只报结论、不报过程性废话;同一份内容重复上报是幂等的,但换措辞会被当成新结论,所以同一结论的措辞尽量保持稳定。⚠️ **测试性/一次性的快问快答不上报**(如用户说「测试一下」「帮我随便查个数据」)——测试流量不写库;纯查询汇总(消费记录/好友名单/时长榜单)本身也不是「可复用结论」,不要为报而报 28. 用户说「把时长/指标也存下来」「之后按周回看我的游玩变化」「导一份我的时长明细进库」这类**要沉淀数据本身**(而不只是结论)时,用 pushObservation —— 它从 Steam 官方 API 取当前最权威的游玩时长,连库里缺的近两周时长也能补上。默认 scope=partial(只落分析产物区,安全);只有用户明确说「全量/整份覆盖」时才用 scope=full,而且服务端会校验「条目数不得少于库内已有快照」,数据不全时会自动降级并说明原因 —— 这时要把降级原因如实告诉用户,别谎称已更新 29. **身份与降级必须说实话**(v1.29.0):localDbProfile / pushInsight / pushObservation 会自动解析「当前是谁」,优先级是「用户显式给的 steamid > 设置里配置的 SteamID > 浏览器登录态检测 > 库里的主账号回退」。前三种是确知身份;最后一种是**系统兜底猜的**,工具返回里会带 identitySource 与 identityNote —— identitySource=library-primary(或 identity=unidentified)时,**必须把「未能确认当前登录用户,数据按库主账号 X 统计」如实告诉用户**,绝不能默认这就是用户本人。库里有多个 Steam 账号时,searchLocalDb 返回的 library.accountCount > 1,说明 scope=all / scope=mine 都是**全库口径**(mine=库内任何人拥有),不是某个人的;用户问「我的」而库里有多账号时,可提示确认是哪个账号,或用 localDbProfile 按账号查 30. 用户问「我登录 Steam 了吗 / 为什么说我没登录 / 要不要重新登录」时,先调 getSteamLoginStatus(只读、60 秒缓存)。loggedIn=true → 明确告知已登录,问题在取数链路,不要让用户重新登录;false → 说明依据是「消费历史页被重定向到登录页」,再引导登录;null → 只能答「无法判定」,给出下一步(稍后重试 / 手动打开消费历史页)。⚠️ 不要用「读不到 steamLoginSecure Cookie / 拿不到 SteamID」推断未登录——该 Cookie 是 HttpOnly,JS 读不到属正常`; const issues = verifyContextIntegrity(prompt, userMessage, ctx); if (issues.length) { console.warn('[SA-Agent] 上下文完整性验证未通过,降级为仅当前消息:', issues); prompt = prompt.replace(/## 对话历史[\s\S]*?(?=## 工具调用结果|## 上下文说明|## 当前任务)/, `## 对话历史 (上下文异常,已重置,请基于当前消息回答)`); const finalCheck = prompt.includes(userMessage); if (!finalCheck) { prompt = `${prompt}\n(系统注:用户消息即为本轮唯一上下文)`; } } return prompt; } function setToolStatus(text, active) { const toolStatusEl = document.querySelector('.sa-tool-status'); if (!toolStatusEl) return; const label = toolStatusEl.querySelector('.sa-tool-text'); if (label && text) label.textContent = text; toolStatusEl.classList.toggle('sa-active', !!active); } function abortedLoopResult(currentHistory, iterations, toolCallLog) { const lastAi = [...currentHistory].reverse().find(m => m.role === 'assistant'); return { response: lastAi ? cleanToolCallMarkers(stripThinkTags(lastAi.content)) : '', completed: false, aborted: true, iterations, toolCalls: toolCallLog, }; } // 对话循环: // 1. 还没跑过工具时用 0.2,并带上原生 tools;供应商拒绝 tools 时本轮对话降回文本协议 // 2. 一轮里的只读工具并行执行,结果合成一条 tool_result 再交给模型 // 3. 已经拿到工具结果后的轮次用 0.7,正文经流式门控输出,工具 JSON 不进气泡 async function runAgentLoop(userMessage, history, agentId, onChunk, onToolCall) { const agent = AgentManager.getAgent(agentId); if (!agent) return { response: '未找到智能体', completed: false, iterations: 0 }; // 智能体工具子集授权:null = 全部;提示词描述与原生 tools 同步过滤, // 执行期二次校验(防模型幻觉调用未授权工具) const allowedTools = (Array.isArray(agent.tools) && agent.tools.length) ? agent.tools : null; const toolsDesc = ToolRegistry.getToolsDescription(allowedTools); const openaiTools = ToolRegistry.toOpenAITools(allowedTools); const systemInstruction = buildSystemInstruction(agent); const parsedSettings = parseStored(GM_getValue(SK.SETTINGS, null), {}); const maxIter = parsedSettings.maxIterations || CONFIG.MAX_ITERATIONS; let currentHistory = [...history]; const toolCallLog = []; let usedTools = false; let nativeTools = openaiTools.length > 0; let repairTemp = false; const gate = createStreamGate(onChunk); for (let i = 0; i < maxIter; i++) { if (abortFlag) return abortedLoopResult(currentHistory, i, toolCallLog); const temperature = (usedTools && !repairTemp) ? CONFIG.ANSWER_TEMPERATURE : CONFIG.TOOL_TEMPERATURE; repairTemp = false; const prompt = buildReActPrompt(userMessage, currentHistory, toolsDesc, i + 1, maxIter); setToolStatus(usedTools ? '正在整理回答…' : '正在思考…', true); let turn; const lastRound = i + 1 >= maxIter; const chatOpts = () => ({ systemInstruction, history: [], temperature, // 最后一轮不再提供工具:强制模型基于已获取的结果收尾,避免迭代耗尽时浪费全部抓取数据 tools: (nativeTools && !lastRound) ? openaiTools : undefined, onChunk: (full) => gate.push(full), }); try { turn = await callAiChat(prompt, chatOpts()); } catch (e) { if (nativeTools && isNativeToolsRejected(e) && !abortFlag) { nativeTools = false; console.warn('[SA-Agent] 当前模型不接受原生 tools,本轮对话改用文本协议'); try { turn = await callAiChat(prompt, chatOpts()); } catch (e2) { if (abortFlag) return abortedLoopResult(currentHistory, i + 1, toolCallLog); setToolStatus('', false); return { response: `AI 调用失败: ${e2.message}`, completed: false, iterations: i + 1, error: e2.message, toolCalls: toolCallLog }; } } else if (nativeTools && e.isReasoningOnly && !abortFlag) { // 推理模型怪癖(DeepSeek v4-pro 实测):思考中明确要调用工具,却不产出 tool_calls 即停止。 // 降级为文本协议(不带 tools 参数,靠 ReAct 提示词里的 纯文本格式)绕开原生调用路径 nativeTools = false; repairTemp = true; console.warn('[SA-Agent] 推理模型仅思考未产出工具调用,本轮降级文本协议重试'); try { turn = await callAiChat(prompt, chatOpts()); } catch (e2) { if (abortFlag) return abortedLoopResult(currentHistory, i + 1, toolCallLog); setToolStatus('', false); return { response: `AI 调用失败: ${e2.message}`, completed: false, iterations: i + 1, error: e2.message, toolCalls: toolCallLog }; } } else if (abortFlag) { return abortedLoopResult(currentHistory, i + 1, toolCallLog); } else { setToolStatus('', false); return { response: `AI 调用失败: ${e.message}`, completed: false, iterations: i + 1, error: e.message, toolCalls: toolCallLog }; } } if (abortFlag) { const partial = cleanToolCallMarkers(stripThinkTags(turn?.content || '')); return { response: partial, completed: false, aborted: true, iterations: i + 1, toolCalls: toolCallLog }; } const parsedCalls = mergeToolCalls(turn.toolCalls, turn.content); const calls = parsedCalls.slice(0, CONFIG.MAX_PARALLEL_TOOLS); const assistantVisible = cleanToolCallMarkers(stripThinkTags(turn.content || '')); currentHistory.push({ id: `ai_${Date.now()}_${i}`, role: 'assistant', content: (calls.length && !lastRound) ? (assistantVisible || `(调用工具:${calls.map(c => c.name).join('、')})`) : (turn.content || ''), timestamp: Date.now(), }); // 最后一轮即使模型仍尝试调用工具,也不再执行:强制基于已获取的工具结果收尾 if (!calls.length || lastRound) { const raw = String(turn.content || ''); const attemptedToolCall = /${JSON.stringify({ success: false, formatError: true, error: '上一次输出像是工具调用,但没有解析出合法的 name 与 arguments。请改用原生工具调用,或输出 {"name":"工具名称","arguments":{"参数名":"值"}}。多个工具可输出多段标签,或在一个标签里输出 JSON 数组。若已无需调用工具,请直接给出最终回答', })}`, timestamp: Date.now(), }); console.warn('[SA-Agent] 工具调用格式解析失败,已回灌纠错提示:', raw.slice(0, 200)); continue; } let finalResponse = assistantVisible.trim(); if (!finalResponse) { finalResponse = lastRound ? `抱歉,本次任务在 ${maxIter} 轮迭代内未能完成完整分析(已展示获取到的数据)。请尝试缩小问题范围后重试。` : '(本轮未能生成有效回复:工具调用格式异常,请重试或换个问法)'; } setToolStatus('', false); await revealAnswer(finalResponse, onChunk, !!turn.streamed && !shouldHoldStream(raw)); return { response: finalResponse, completed: true, iterations: i + 1, toolCalls: toolCallLog }; } gate.clear(); if (onChunk) onChunk(''); const batch = calls.map(c => ({ name: c.name, arguments: c.arguments || {}, running: true })); batch.forEach((tc, idx) => { if (onToolCall) onToolCall(tc.name, tc.arguments, 'start', idx); }); const labels = batch.map(tc => getToolDisplay(tc.name).label).join('、'); setToolStatus(batch.length > 1 ? `正在并行调用 ${batch.length} 个工具 · ${labels}…` : `正在调用智能体工具 · ${labels}…`, true); await mapLimit(batch, CONFIG.MAX_PARALLEL_TOOLS, async (tc, idx) => { const t0 = Date.now(); if (abortFlag) { tc.result = { success: false, error: '已停止' }; } else if (allowedTools && !allowedTools.includes(tc.name)) { tc.result = { success: false, error: `工具 "${tc.name}" 未授权给当前智能体,请改用该智能体可用工具完成任务` }; } else { tc.result = await ToolRegistry.execute(tc.name, tc.arguments); } tc.durationMs = Date.now() - t0; tc.running = false; if (onToolCall) onToolCall(tc.name, tc.arguments, 'done', idx, tc.result); }); setToolStatus('', false); if (abortFlag) return abortedLoopResult(currentHistory, i + 1, toolCallLog); batch.forEach(tc => toolCallLog.push({ name: tc.name, arguments: tc.arguments, result: tc.result, durationMs: tc.durationMs })); usedTools = true; const dropped = parsedCalls.length - calls.length; currentHistory.push({ id: `tool_${Date.now()}_${i}`, role: 'system', content: batch.map(tc => `${JSON.stringify({ name: tc.name, arguments: tc.arguments, ...(tc.result || {}), })}`).join('\n') + (dropped > 0 ? `\n${JSON.stringify({ success: false, error: `本轮最多执行 ${CONFIG.MAX_PARALLEL_TOOLS} 个工具,已忽略其余 ${dropped} 个` })}` : ''), timestamp: Date.now(), }); } setToolStatus('', false); return { response: '抱歉,处理该问题需要更多步骤,请尝试缩小问题范围。', completed: false, iterations: maxIter, toolCalls: toolCallLog }; } // ============================================================ // 对话管理器 (多对话历史支持) // ============================================================ const ConversationManager = { _conversations: {}, _currentConvId: null, _saveTimer: 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.saveNow(); } 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.saveNow(); }, 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.saveNow(); }, updateCurrentModel(modelId) { const conv = this.getCurrentConversation(); conv.modelId = modelId; this.save(); }, // 防抖保存:addMessage 等高频操作只标脏,800ms 尾沿统一落盘; // beforeunload/hidden 时 flushSave 兜底冲刷 save() { if (this._saveTimer) clearTimeout(this._saveTimer); this._saveTimer = setTimeout(() => { this._saveTimer = null; this.saveNow(); }, CONFIG.SAVE_DEBOUNCE_MS); }, // 容量治理:每会话消息保尾部上限,会话总数按 updatedAt 保最近 N 个(当前会话永不删除) prune() { const maxMsgs = CONFIG.MAX_MESSAGES_PER_CONV; for (const conv of Object.values(this._conversations)) { if (Array.isArray(conv.messages) && conv.messages.length > maxMsgs) { conv.messages = conv.messages.slice(-maxMsgs); } } const ids = Object.keys(this._conversations); if (ids.length > CONFIG.MAX_CONVERSATIONS) { const sorted = ids .map(id => this._conversations[id]) .sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0)); const keep = new Set(sorted.slice(0, CONFIG.MAX_CONVERSATIONS).map(c => c.id)); if (this._currentConvId) keep.add(this._currentConvId); for (const id of ids) if (!keep.has(id)) delete this._conversations[id]; } }, saveNow() { if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; } try { this.prune(); GM_setValue(SK.CONVERSATIONS_V2, JSON.stringify(this._conversations)); } catch (e) { console.error('[SA-Agent] 对话保存失败:', e); toast('对话保存失败,可能超出存储配额,请导出并清理旧对话', 'error'); } }, flushSave() { this.saveNow(); }, 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('chevronDown', 14)} ${getIcon('sparkle', 13)} 正在思考… ${getIcon('send', 16)} `; 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 agentDropdownEl = panelEl.querySelector('#sa-agent-dropdown'); const agentTriggerEl = panelEl.querySelector('#sa-agent-trigger'); const agentTriggerIconEl = panelEl.querySelector('#sa-agent-trigger-icon'); const agentTriggerNameEl = panelEl.querySelector('#sa-agent-trigger-name'); const agentMenuEl = panelEl.querySelector('#sa-agent-menu'); function renderAgentMenu() { const agents = AgentManager.getAgents(); const currentId = AgentManager._currentAgentId; const itemHtml = (a) => ` ${agentIconHtml(a, 16)} ${escapeHtml(a.name)} ${a.description ? `${escapeHtml(a.description)}` : ''} ${getIcon('check', 14)} `; const builtinItems = agents.filter(a => a.builtin).map(itemHtml).join(''); const customItems = agents.filter(a => !a.builtin).map(itemHtml).join(''); agentMenuEl.innerHTML = ` ${builtinItems ? `内置智能体${builtinItems}` : ''} ${customItems ? `自定义智能体${customItems}` : ''} `; } function setAgentMenuOpen(open) { const isOpen = agentMenuEl.classList.toggle('sa-show', open); agentTriggerEl.classList.toggle('sa-open', open); agentTriggerEl.setAttribute('aria-expanded', String(isOpen)); if (open) renderAgentMenu(); } function refreshAgentSelect() { const agent = AgentManager.getCurrentAgent(); agentTriggerIconEl.innerHTML = agentIconHtml(agent, 15); agentTriggerNameEl.textContent = agent ? agent.name : '选择智能体'; if (agentMenuEl.classList.contains('sa-show')) renderAgentMenu(); } refreshAgentSelect(); agentTriggerEl.addEventListener('click', (e) => { e.stopPropagation(); setAgentMenuOpen(!agentMenuEl.classList.contains('sa-show')); }); agentMenuEl.addEventListener('click', (e) => { e.stopPropagation(); if (e.target.closest('#sa-agent-manage')) { setAgentMenuOpen(false); openSettings({ tab: 'agents' }); return; } const item = e.target.closest('.sa-agent-item'); if (!item) return; const id = item.dataset.id; if (id && id !== AgentManager._currentAgentId) { AgentManager.switchAgent(id); ConversationManager.getCurrentConversation().agentId = id; ConversationManager.save(); renderMessages(); } setAgentMenuOpen(false); refreshAgentSelect(); }); document.addEventListener('click', (e) => { if (!agentDropdownEl.contains(e.target)) setAgentMenuOpen(false); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && agentMenuEl.classList.contains('sa-show')) setAgentMenuOpen(false); }); // 热点入口(面板头部右上角按钮组):仅库加载成功时显示 const newsBtnEl = panelEl.querySelector('#sa-btn-news'); if (GameNews) { newsBtnEl.style.display = ''; newsBtnEl.title = '游戏热点 · ' + GameNews.sources.map(s => s.label).join('/'); newsBtnEl.addEventListener('click', () => GameNews.openWindow()); } else { newsBtnEl.remove(); } // 搜索入口(热点按钮左侧):仅库加载成功时显示 const searchBtnEl = panelEl.querySelector('#sa-btn-search'); if (Search) { searchBtnEl.style.display = ''; searchBtnEl.title = '搜索 · 我的游戏库 / 新闻热点'; searchBtnEl.addEventListener('click', () => Search.openWindow()); } else { searchBtnEl.remove(); } 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-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); // v1.13.0 P0-1:滚动时更新脱水消息的高度记忆(rAF 节流,避免滚动中反复强制布局) if (messagesEl) { let hydrateRaf = false; messagesEl.addEventListener('scroll', () => { if (hydrateRaf) return; hydrateRaf = true; requestAnimationFrame(() => { hydrateRaf = false; hydrateMessagesInView(); }); }, { passive: true }); } 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 || '自定义'; const name = String(m.name || '').trim(); const model = String(m.model || '').trim(); const label = (name && name !== model && !name.includes(model)) ? `${name} · ${model}` : `${providerName} · ${model || name}`; return `${escapeHtml(label)}`; }).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 = ` ${getIcon('history', 15)} 对话历史 ${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 脚本装齐了吗?有没有版本过旧的?', '用本地插件缓存看看我游戏库里玩得最多的几款', '帮我分析一下 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 || '')} ${getIcon('lightbulb', 14)} 建议问题 ${getIcon('refresh', 12)} 换一批 ${questions.map(q => ` ${escapeHtml(q)} ${getIcon('send', 13)} `).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)} ${getIcon('send', 13)} `).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 = []; // v1.13.0 P0-1:长会话渲染治理 —— 屏外历史消息降级渲染。 // // 背景:MAX_MESSAGES_PER_CONV=200 时 renderMessages() 单次 innerHTML 可达 2MB+, // 浏览器要 parse 整棵子树并对每条消息做布局,长列表滚动/切换会话明显变卡。 // 方案:给靠前的历史消息加 content-visibility:auto,屏外跳过布局与绘制。 // - 用 auto 而非 hidden:hidden 会让内容无法被 Ctrl+F 搜到,auto 保留可搜索性。 // - 高度记忆:脱水前先量一次真实高度写进 --sa-h,配合 contain-intrinsic-size // 让滚动条长度稳定、不因脱水而跳变;已脱水的元素绝不重复测量(否则量到的 // 是 intrinsic 兜底值,会把高度越改越小)。 // - 读写分离:先批量读高度,再批量写属性,避免 read/write 交替触发强制同步布局。 function clearMessageDehydration(el) { if (!el || el.dataset.dehydrated !== '1') return; el.dataset.dehydrated = '0'; // 故意保留 --sa-h 记忆值:下次再脱水时可直接复用,省一次测量 } function applyMessageDehydration() { if (!messagesEl) return; const nodes = messagesEl.querySelectorAll('.sa-msg'); const total = nodes.length; if (total < CONFIG.DEHYDRATE_MIN_MESSAGES) { // 短会话不启用:省不下什么,反而白付一次测量成本 for (const el of nodes) clearMessageDehydration(el); return; } // 末尾 KEEP_RECENT 条保持活跃(用户正在看的 + 刚生成的) const cutoff = total - CONFIG.DEHYDRATE_KEEP_RECENT; // ---- 读阶段:只对"需要新脱水且尚无记忆高度"的元素测高 ---- const targets = []; const measured = []; for (let i = 0; i < cutoff; i++) { const el = nodes[i]; if (el.dataset.dehydrated === '1') continue; if (el.style.getPropertyValue('--sa-h')) continue; // 已有记忆高度则复用 targets.push(el); measured.push(el.offsetHeight || 0); } // ---- 写阶段:批量写样式/属性 ---- for (let i = 0; i < cutoff; i++) { const el = nodes[i]; if (el.dataset.dehydrated === '1') continue; if (!el.style.getPropertyValue('--sa-h')) { const h = measured[targets.indexOf(el)] || CONFIG.DEHYDRATE_FALLBACK_H; el.style.setProperty('--sa-h', h + 'px'); } el.dataset.dehydrated = '1'; } for (let i = cutoff; i < total; i++) clearMessageDehydration(nodes[i]); } // 滚动时按需更新高度记忆:auto 元素滚入视口后浏览器会真实布局, // 此时量到的是真高度,回写 --sa-h 让后续脱水时的滚动条长度更准。 function hydrateMessagesInView() { if (!messagesEl) return; const list = messagesEl.querySelectorAll('.sa-msg[data-dehydrated="1"]'); if (!list.length) return; const top = messagesEl.scrollTop; const bottom = top + messagesEl.clientHeight; for (const el of list) { const t = el.offsetTop; if (t > bottom + 200 || t + (el.offsetHeight || 0) < top - 200) continue; const real = Math.round(el.getBoundingClientRect().height); if (real > 0) el.style.setProperty('--sa-h', real + 'px'); } } function buildMsgFooterHtml(content) { if (!content) return ''; const id = msgCopyRegistry.push(content) - 1; return ``; } function buildAiBadgeHtml() { return `${getIcon('sparkle', 14)}`; } 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 `${buildAiBadgeHtml()}${toolsHtml}${bodyHtml}${buildMsgFooterHtml(clean)}`; } else if (m.role === 'system') { return ''; } return ''; }).join(''); messagesEl.scrollTop = messagesEl.scrollHeight; // v1.13.0 P0-1:历史消息整体重建后,立刻脱水屏外部分(读测量已在写阶段之前完成) applyMessageDehydration(); } 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 = buildAiBadgeHtml() + renderMarkdown(cleanToolCallMarkers(content)); } messagesEl.appendChild(msgEl); messagesEl.scrollTop = messagesEl.scrollHeight; // v1.13.0 P0-1:新消息进来后,把更早的消息挤进脱水区(末尾 KEEP_RECENT 条始终活跃) applyMessageDehydration(); 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 = '发送 (Enter)'; sendBtn.setAttribute('aria-label', '发送消息'); sendBtn.innerHTML = getIcon('send', 16); } 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 发送' : '正在回复,可点右侧按钮停止'; } } 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 = []; // v1.13.0 P0-2:把气泡拆成 badge / tools / body 三个常驻子节点。 // 旧实现每帧 `aiMsgEl.innerHTML = badge + toolsHtml + bodyHtml`,其中 toolsHtml // 在正文流式增长期间**内容不变却被反复 parse**(每帧一次,单个工具卡可达数 KB)。 // 现改为:正文每帧更新(本来就要),工具仅在结构签名变化时更新。 // 宿主结构无变化,故无需 withSelfMutation 守卫——本子树没有 MutationObserver 监听 // (L7712 的观察器是 {childList:true} 且无 subtree,只看 body 直接子级)。 // 若日后给消息区加观察器,必须同步引入自变更守卫,否则会自触发死循环。 const badgeHost = document.createElement('div'); badgeHost.innerHTML = buildAiBadgeHtml(); const badgeNode = badgeHost.firstElementChild; const toolsEl = document.createElement('div'); const bodyEl = document.createElement('div'); if (badgeNode) aiMsgEl.appendChild(badgeNode); aiMsgEl.appendChild(toolsEl); aiMsgEl.appendChild(bodyEl); // 工具区结构签名:只取「会影响渲染」的字段(数量/运行态/耗时/结果是否就绪), // 避免为了判重而每帧都跑一遍 buildToolCallsHtml(含 getIcon 的 SVG 构造)。 const toolSigOf = (calls, runningIndex) => { let s = calls.length + ':' + runningIndex; for (const tc of calls) { s += '|' + (tc.running ? '1' : '0') + (tc.durationMs != null ? 'd' : '') + (tc.result ? 'r' : ''); } return s; }; let lastToolsSig = null; // 流式渲染节流:rAF 合并高频重绘 let bubbleRafPending = false; let bubblePendingIndex = -1; let bubbleSettled = false; const paintAiBubble = (runningIndex) => { if (bubbleSettled) return; const cleanText = cleanToolCallMarkers(displayedText); bodyEl.innerHTML = cleanText ? renderMarkdown(cleanText) : ''; // 工具区:签名未变则整段跳过(含展开状态保留——DOM 没被重建,展开态自然留存) const sig = toolSigOf(liveToolCalls, runningIndex); if (sig !== lastToolsSig) { const prevOpen = Array.from(toolsEl.querySelectorAll('details.sa-tool-chip')).map(d => d.open); toolsEl.innerHTML = buildToolCallsHtml(liveToolCalls, runningIndex); if (prevOpen.length) { toolsEl.querySelectorAll('details.sa-tool-chip').forEach((d, i) => { if (prevOpen[i]) d.open = true; }); } lastToolsSig = sig; } messagesEl.scrollTop = messagesEl.scrollHeight; }; const updateAiBubble = (runningIndex = -1) => { if (bubbleRafPending) { bubblePendingIndex = runningIndex; return; } bubbleRafPending = true; bubblePendingIndex = runningIndex; if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => { bubbleRafPending = false; if (!bubbleSettled) paintAiBubble(bubblePendingIndex); }); } else { bubbleRafPending = false; paintAiBubble(bubblePendingIndex); } }; // 终态(完整回答/停止/错误)直接 innerHTML 定稿后,丢弃还在排队的 rAF 重绘, // 防止无 footer 的中间态覆盖带复制按钮的终态 const settleBubble = () => { bubbleSettled = true; bubbleRafPending = false; }; 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, index, toolResult) => { if (abortFlag) return; if (phase === 'start') { liveToolCalls.push({ name, arguments: args, running: true, _t0: Date.now() }); updateAiBubble(); } else { const tc = (Number.isInteger(index) && liveToolCalls[index]) || liveToolCalls.find(t => t.running && t.name === name); if (tc) { tc.running = false; if (tc._t0) tc.durationMs = Date.now() - tc._t0; if (toolResult) tc.result = toolResult; } updateAiBubble(); } }); if (toolStatusEl) toolStatusEl.classList.remove('sa-active'); liveToolCalls = result.toolCalls || liveToolCalls; settleBubble(); // v1.13.0:终态写回常驻节点(toolsEl/bodyEl),不再整块 innerHTML 重建, // 这样流式态与终态的 DOM 结构保持一致,脱水高度测量也不会被打断 if (result.aborted) { const partial = result.response || cleanToolCallMarkers(displayedText); toolsEl.innerHTML = buildToolCallsHtml(liveToolCalls); bodyEl.innerHTML = partial ? renderMarkdown(partial) : '已停止生成'; appendMsgFooterEl(aiMsgEl, partial); messagesEl.scrollTop = messagesEl.scrollHeight; if (partial) ConversationManager.addMessage('assistant', partial, { toolCalls: liveToolCalls }); } else { displayedText = result.response; toolsEl.innerHTML = buildToolCallsHtml(liveToolCalls); bodyEl.innerHTML = renderMarkdown(result.response); appendMsgFooterEl(aiMsgEl, result.response); messagesEl.scrollTop = messagesEl.scrollHeight; ConversationManager.addMessage('assistant', result.response, { toolCalls: result.toolCalls || [], }); } // 刚生成的消息要保持活跃(新脱水阈值刚把它推入活跃区,无需处理); // 但更早的消息可能因 cutoff 前移而脱水,重算一次 applyMessageDehydration(); } catch (e) { if (toolStatusEl) toolStatusEl.classList.remove('sa-active'); settleBubble(); toolsEl.innerHTML = ''; bodyEl.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: 'sources', label: '数据源', icon: 'stats' }, { 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)} 提示`; const detailHtml = detail ? '' + escapeHtml(detail).replace(/\n/g, '') : ''; return `${mark}${escapeHtml(title)}${detailHtml}`; } 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(); // v1.16.0 联网检索工具配置 const webToolsBase = String(GM_getValue(SK.DS_TOOLS_BASE, '') || '').trim(); const webToolsKey = String(GM_getValue(SK.DS_TOOLS_KEY, '') || '').trim(); const webToolsEffective = getWebToolsConfig(); const webToolsStatus = webToolsEffective.key ? { color: '', text: `已就绪:Key 来源「${webToolsEffective.source}」,接口 ${webToolsEffective.base}` } : { color: 'color:#fbbf24;', text: '未配置:webSearch / webFetch 将返回配置引导。可填入 DeepSeek Key,或在「AI 设置」中把模型配成 DeepSeek 以自动复用' }; const settings = parseStored(GM_getValue(SK.SETTINGS, null), {}); const overlay = document.createElement('div'); overlay.className = 'sa-overlay sa-show'; overlay.innerHTML = ` ${getIcon('gear', 18)} 智能体设置中心 v${escapeHtml(_v)} ${getIcon('close', 16)} ${SETTINGS_TABS.map(t => `${getIcon(t.icon, 15)} ${t.label}`).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)} 生成参数 温度 (0-2) 值越低回答越稳定,越高越发散;推荐 0.5-0.8 最大迭代次数 ReAct 循环最大工具调用次数 ${getIcon('book', 15)} 规则知识库 远程知识库地址 指向 saa-rules-knowledge.json 的原始链接(纯数据 JSON,不执行代码);更新知识只需改 JSON 文件并推送,无需更新主脚本 ${getIcon('refresh', 14)} 检查更新 ${getIcon('trash', 14)} 清除远程缓存 `; 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} ${escapeHtml(s.sectionCount)} 章节 · ${escapeHtml(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 = ` AI 模型配置 选择供应商预设可自动填充 API 地址和模型列表,只需填入 API Key 即可使用。支持配置多个模型。 ${getIcon('zap', 13)} 一键检测全部模型 ${getIcon('plus', 14)} 添加模型 `; 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 ? `${getIcon('play', 13)}` : ''} ${getIcon('zap', 13)} ${getIcon('edit', 13)} ${getIcon('trash', 13)} `; }).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 = ` ${getIcon('close', 12)} 返回列表 ${model ? '编辑模型(保存后立即生效)' : '添加模型(保存后立即生效)'} 选择供应商 ${Object.entries(MODEL_PROVIDERS).map(([key, p]) => ` ${escapeHtml(p.name)} `).join('')} 显示名称 API 地址 API Key 模型名称 ${getIcon('zap', 14)} 测试连接 保存 `; 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};输入兼容 OpenAI 格式的 API 地址(需包含 /v1/chat/completions 路径)`; const keyHint = editorView.querySelector('#sa-me-key-hint'); if (p.apiKeyUrl) { keyHint.innerHTML = `在 ${escapeHtml(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 = ` ${p.models.map(m => `${escapeHtml(m.label)}`).join('')} `; 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); // URL 自动补全:失焦时补全协议和路径 const urlInputEl = editorView.querySelector('#sa-me-url'); urlInputEl.addEventListener('blur', () => { let v = urlInputEl.value.trim(); if (!v) return; if (!/^https?:\/\//i.test(v)) v = 'https://' + v; try { const u = new URL(v); if (!u.pathname || u.pathname === '/') u.pathname = '/v1/chat/completions'; const normalized = u.toString().replace(/\/$/, ''); if (normalized !== urlInputEl.value.trim()) { urlInputEl.value = normalized; } } catch {} }); 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.innerHTML = `连接失败:${escapeHtml(describeModelError(err)).replace(/\n/g, '')}`; } 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 = ` 智能体管理 新建、编辑、切换自定义智能体;支持 AI 辅助生成系统提示词与配置导入导出。 ${getIcon('plus', 14)} 新建智能体 ${getIcon('download', 14)} 导出 ${getIcon('edit', 14)} 导入 `; 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)} ${getIcon('play', 13)} ${!a.builtin ? ` ${getIcon('edit', 13)} ${getIcon('trash', 13)} ` : ''} `).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 = ` ${getIcon('close', 12)} 返回列表 ${agent ? '编辑智能体(保存后立即生效)' : '新建智能体(保存后立即生效)'} 图标 (emoji 或 SVG 图标名) 可用: bot, gamepad, gift, sparkle, stats, chat 或输入 emoji 名称 描述 关键词 (逗号分隔) 可用工具 (不勾选任何工具 = 使用全部工具) ${ToolRegistry.getAll().map(t => { const enabled = !(agent?.tools && agent.tools.length) || agent.tools.includes(t.name); return ` ${escapeHtml((TOOL_DISPLAY[t.name] || {}).label || t.name)}`; }).join('')} 限定工具子集可减少系统提示词长度并提升工具选择准确率;至少保留一个工具 系统提示词 (System Prompt) ${getIcon('copy', 13)} 复制 ${escapeHtml(agent?.systemPrompt || '')} 需求描述 (AI 辅助生成提示词) ${getIcon('sparkle', 14)} AI 生成提示词 保存 取消 `; 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, // 工具子集:全部勾选或全不勾 = null(全部);部分勾选 = 名单 tools: (() => { const checked = [...editorView.querySelectorAll('.sa-ae-tool:checked')].map(c => c.value); const total = ToolRegistry.getAll().length; return (checked.length > 0 && checked.length < total) ? checked : null; })(), }; 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 = ` 接口服务 配置并检测 Steam / ITAD 第三方接口;AI 模型接口在「AI 设置」标签中检测。 ${getIcon('zap', 13)} 一键检测全部接口 ${getIcon('key', 15)} Steam Web API Key ${getIcon('zap', 13)} 检测 ${steamKey ? '已配置:点击「检测」验证 Key 有效性' : '未配置:游戏库查询(getOwnedGames)、高级折扣检索(searchDiscountedGames) 将不可用'} 在 steamcommunity.com/dev 申请,用于 getOwnedGames / searchDiscountedGames 等工具 ${getIcon('user', 15)} SteamID(玩家数据查询身份,可选) ${getIcon('zap', 13)} 检测 ${idStatusText} 用于「我的游戏库 / 资料 / 愿望单」类查询;留空时自动识别当前登录用户(依据页面登录态),无需手动填写 ${getIcon('globe', 15)} ITAD API Key(跨区比价增强,可选) ${getIcon('zap', 13)} 测试 ${itadKey ? '已配置:getMultiRegionPrices 工具将以 ITAD 增强模式查询 16 个地区的实时价格' : '未配置:跨区比价工具将使用降级数据源(AugmentedSteam,约 7 个地区),价格准确度降低'} 申请步骤:① isthereanydeal.com 注册并验证邮箱 ② 进入 isthereanydeal.com/apps 点击 Register App ③ 填写应用名提交 ④ 复制 32 位密钥粘贴到上方 ⑤ 点击「测试」验证后保存。已安装 SGLV 2.9.79+ 时可自动复用其配置 ${getIcon('search', 15)} 联网检索工具(webSearch / webFetch) 服务 Base URL(留空即用默认 ${escapeHtml(CONFIG.DS_TOOLS_DEFAULT_BASE)})。需兼容 DeepSeek 的 /search 与 /fetch 两个 POST 端点,可换成自建网关。 ${getIcon('zap', 13)} 测试 ${escapeHtml(webToolsStatus.text)} 提供 webSearch(实时网页检索)与 webFetch(网页正文抓取)能力。留空时若「AI 设置」用的是 DeepSeek 模型会自动复用其 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 地区),价格准确度降低')); } // v1.16.0 联网检索工具连通性检测 const wtInput = pane.querySelector('#sa-cfg-webtools-key').value.trim(); const wtBase = pane.querySelector('#sa-cfg-webtools-base').value.trim(); if (wtInput) GM_setValue(SK.DS_TOOLS_KEY, wtInput); if (wtBase) GM_setValue(SK.DS_TOOLS_BASE, wtBase); else GM_deleteValue(SK.DS_TOOLS_BASE); const wt = await testWebToolsConnection(); prependCheckResult(resultsEl, wt.success ? renderCheckItem('ok', '联网检索工具可用', wt.note) : renderCheckItem('err', '联网检索工具检测失败', wt.error)); toast('接口检测完成,结果见上方列表', 'success'); btn.disabled = false; btn.innerHTML = originalHtml; }); } // ---------- Tab: 数据管理 ---------- // ═══ v1.25.0 数据源授权 ═══ // 逐插件勾选 AI 可读取哪些插件的本地缓存;默认全开,用户可随时关闭。 // 关闭后 readPluginCache 返回 needAuth,模型会引导用户回这里开启。 async function buildSourcesPane() { const pane = overlay.querySelector('#sa-pane-sources'); if (!pane) return; pane.innerHTML = ` 数据源授权 选择 AI 可以读取哪些插件的本地缓存。数据仅存于本机,不做任何上传。 正在检测已安装插件… `; // 契约(期望清单 + minVersion)+ 运行时注册(精确版本号) let contract = pcContractGetCached(); if (!contract) { try { contract = await pcContractFetch(); } catch (e) { contract = null; } } const registry = await pcReadPluginRegistry(); const authMap = pcDataSourceAuthMap(); if (!contract) { pane.innerHTML = ` 数据源授权 选择 AI 可以读取哪些插件的本地缓存。 版本契约加载失败(无法获取插件清单)。请检查网络后重试 —— 授权状态仍会保留。 `; return; } const famLabels = contract.families || {}; const byFamily = new Map(); for (const p of (contract.plugins || [])) { const f = p.family || '_other'; if (!byFamily.has(f)) byFamily.set(f, []); byFamily.get(f).push(p); } const render = () => { const am = pcDataSourceAuthMap(); // v1.30.2 域感知:注册表按域隔离,跨域读不到是正常现象而非「未安装」 const curDom = pcPanelDomain(); const domChip = curDom ? `当前面板:${curDom === 'store' ? '商店域 store' : '社区域 community'}` : ''; let html = ` 数据源授权${domChip} 选择 AI 可以读取哪些插件的本地缓存。数据仅存于本机,不做任何上传。 `; // 套件完整性 let famHtml = ''; for (const [fam, list] of byFamily) { const inst = list.filter(p => registry.has(p.id)).length; const okAll = inst === list.length; famHtml += ` ${escapeHtml((famLabels[fam] && famLabels[fam].label) || fam)} ${inst}/${list.length} ${okAll ? '已装齐' : '未装齐'} `; } html += ` ${getIcon('stats', 15)} 套件完整性 ${famHtml} 「已装齐」按插件注册表的实际版本判定;未注册的插件只能探测到「存在」,版本显示为 —。注册表按域隔离(账户系/核心系在商店域、好友系/徽章系在社区域),跨域注册经 GM 存储镜像自动合并显示为「来自 XX 域 · 镜像」。 `; // 插件列表 html += ` ${getIcon('gamepad', 15)} 插件数据源 全部共享 全部关闭 `; for (const [fam, list] of byFamily) { html += ` ${escapeHtml((famLabels[fam] && famLabels[fam].label) || fam)} ${list.filter(p => registry.has(p.id)).length}/${list.length} 已装 `; for (const p of list) { const reg = registry.get(p.id); const installed = !!reg; const viaMirror = !!(reg && reg.viaMirror); const version = reg ? reg.version : null; const outdated = version && p.minVersion ? pcCompareVersion(version, p.minVersion) < 0 : false; const allowed = am[p.id] ? am[p.id].allowed : true; let badge = ''; if (installed && viaMirror) { // v1.30.3 跨域镜像:注册来自另一域的 IndexedDB,经 GM 存储镜像合并显示 const dn = reg.regDomain === 'community' ? '社区域' : (reg.regDomain === 'store' ? '商店域' : '他域'); badge = `来自${dn} · 镜像`; } else if (!installed) { // v1.30.2 域感知:未注册且契约标明注册在其他域 → 提示去对应域确认,不误报「未安装」 const pd = p.domain; if (pd && pd !== 'both' && curDom && pd !== curDom) { const domName = pd === 'store' ? '商店域' : '社区域'; badge = `需在${domName}确认`; } else { badge = '未安装'; } } else if (outdated) badge = `版本过旧(需 ${escapeHtml(p.minVersion)})`; else if (version) badge = '已启用'; else badge = '已检测 · 版本未知'; html += ` ${escapeHtml(p.name)} ${version ? 'v' + escapeHtml(version) : '—'} ${badge} `; } html += ''; } html += `关闭后 AI 读取该插件数据会被明确拒绝并提示你回来开启;其他插件不受影响。 `; // ═══ v1.30.0 远程数据中枢(data-hub)═══ // v1.26 的做法是「端口 + 手填 token」(token 从 C:/sqlite-data/token 复制粘贴)—— // 远程托管后这个体验不合理:地址填一次即可,身份与凭据由插件自己搞定 // (设备密钥 + Steam 身份认证 → 换会话),**不再让用户接触任何 token**。 const hub = pcHubConfig(); const hubReport = (function () { try { return pcReportState(); } catch (e) { return { enabled: true, pendingOptout: false }; } })(); const hubState = (function () { try { const a = pcHubAuthLib(); return a ? a.getState() : null; } catch (e) { return null; } })(); html += ` ${getIcon('download', 15)} 远程数据中枢(data-hub) 启用远程数据中枢(默认开启,使用默认地址) 服务地址 ${hub.isDefaultUrl ? `当前使用默认地址 ${escapeHtml(hub.baseUrl)}(留空即为默认;默认地址支持远程热更)` : `当前使用自定义地址 ${escapeHtml(hub.baseUrl)}`}。填一次即可;不再需要手动复制令牌 —— 插件会用设备密钥 + Steam 身份认证自动换取会话。 允许明文 HTTP(仅内网调试) 允许内网 / 本机地址(自建服务器) 上报插件版本信息(默认开启) 开启后每日上报一次本机已装插件的版本号,用于统计版本分布与排查问题。关闭后不再每日上报,服务端已存的记录会一并删除。但每次绑定 Steam 账号时仍会附带一次版本清单,用于绑定问题的排查。 ${hubReport.pendingOptout ? `⚠ 服务端版本记录尚未删除(上次撤销未送达),下次启动自动重试` : ''} 保存 测试连接 恢复默认地址 绑定 Steam 账号 账号状态:${hubState && hubState.subject ? `已绑定 ${escapeHtml(hubState.subject)}` : '未绑定(点「绑定 Steam 账号」走一次 Steam 登录)'} `; pane.innerHTML = html; // 事件:勾选即写 GM pane.querySelectorAll('input[data-src]').forEach((input) => { input.addEventListener('change', () => { const auth = pcLoadAuth(); const id = input.getAttribute('data-src'); auth.sources[id] = input.checked; pcSaveAuth(auth); const row = input.closest('.sa-src-row'); if (row) row.classList.toggle('is-off', !input.checked); toast(input.checked ? '已允许共享该插件数据' : '已关闭该插件数据共享', 'success'); }); }); const setAll = (on) => { const auth = pcLoadAuth(); for (const p of (contract.plugins || [])) auth.sources[p.id] = on; pcSaveAuth(auth); render(); toast(on ? '已允许全部插件数据共享' : '已关闭全部插件数据共享', 'success'); }; const allOn = pane.querySelector('#sa-src-all-on'); const allOff = pane.querySelector('#sa-src-all-off'); if (allOn) allOn.addEventListener('click', () => setAll(true)); if (allOff) allOff.addEventListener('click', () => setAll(false)); // ---- 远程数据中枢:保存 + 测试连接 + 绑定 Steam 账号 ---- const hubUrlEl = pane.querySelector('#sa-hub-url'); const hubEnabledEl = pane.querySelector('#sa-hub-enabled'); const hubHttpEl = pane.querySelector('#sa-hub-allow-http'); const hubPrivateEl = pane.querySelector('#sa-hub-allow-private'); const hubReportEl = pane.querySelector('#sa-hub-report'); const hubStatusEl = pane.querySelector('#sa-hub-status'); const hubSaveBtn = pane.querySelector('#sa-hub-save'); const hubTestBtn = pane.querySelector('#sa-hub-test'); const hubResetBtn = pane.querySelector('#sa-hub-reset'); const hubBindBtn = pane.querySelector('#sa-hub-bind'); const readHubForm = () => ({ baseUrl: String(hubUrlEl && hubUrlEl.value || '').trim(), disabled: !(hubEnabledEl && hubEnabledEl.checked), allowHttp: !!(hubHttpEl && hubHttpEl.checked), allowPrivate: !!(hubPrivateEl && hubPrivateEl.checked), report: !!(hubReportEl && hubReportEl.checked), }); const hubStatus = (text, color) => { if (hubStatusEl) hubStatusEl.innerHTML = `${escapeHtml(text)}`; }; /** 地址合法性(保存前先挡一道,别等请求时才炸)。空地址合法 = 回落默认。 */ const validateHubUrl = (form) => { if (form.disabled) return ''; // 整体停用:地址随便填都不生效 if (!form.baseUrl) return ''; // 空 = 回落默认地址(§3.5.1「不改」与「显式清空」同义) let u; try { u = new URL(form.baseUrl); } catch (e) { return '地址不合法(要带 https://)'; } if (u.protocol !== 'https:' && u.protocol !== 'http:') return '只支持 http / https'; if (u.protocol === 'http:' && !form.allowHttp) return '明文 HTTP 需要先勾选「允许明文 HTTP」'; if (u.username || u.password) return '地址里不要带账号密码'; if (/\/(search|app|push|ingest|query|table)\b/.test(u.pathname)) return '地址只填到根路径(不要带接口路径)'; return ''; }; /** 上报开关变化处理:关闭 → 立即发 optout(撤销必须送达,失败置 pendingOptout) */ const applyReportToggle = async (on) => { const st = pcReportState(); st.enabled = !!on; pcReportSaveState(st); if (!on) { hubStatus('正在删除服务端版本记录…', '#94a3b8'); const ok = await pcOptoutVersionReport(); hubStatus(ok ? '已停止每日上报,服务端记录已删除(绑定时仍会附带一次)' : '撤销请求失败:服务端记录尚未删除,下次启动自动重试', ok ? '#4ade80' : '#fbbf24'); } else { hubStatus('已开启每日版本上报', '#4ade80'); } }; if (hubSaveBtn) { hubSaveBtn.addEventListener('click', async () => { const form = readHubForm(); const bad = validateHubUrl(form); if (bad) { hubStatus(bad, '#fbbf24'); return; } pcSaveHubConfig(form); pcHubSyncAuth(); // 上报开关独立保存(不随地址保存失败而丢) const prev = pcReportState(); if (prev.enabled !== form.report) await applyReportToggle(form.report); const eff = pcHubConfig(); hubStatus(eff.disabled ? '已停用远程数据中枢' : `已保存(当前生效:${eff.baseUrl}${eff.isDefaultUrl ? ' · 默认地址' : ''})`, '#4ade80'); toast('远程数据中枢配置已保存', 'success'); }); } if (hubResetBtn) { hubResetBtn.addEventListener('click', () => { // 恢复默认 = 清空显式地址(回落默认:远程热更 → 内置常量) if (hubUrlEl) hubUrlEl.value = ''; const form = readHubForm(); pcSaveHubConfig(form); pcHubSyncAuth(); const eff = pcHubConfig(); hubStatus(`已恢复默认地址(${eff.baseUrl})`, '#4ade80'); }); } if (hubReportEl) { // 上报开关即时生效:关闭 → 立即发 optout(撤销必须送达,失败置 pendingOptout) hubReportEl.addEventListener('change', () => { applyReportToggle(hubReportEl.checked).catch(() => { /* 静默 */ }); }); } if (hubTestBtn) { hubTestBtn.addEventListener('click', async () => { const form = readHubForm(); const bad = validateHubUrl(form); if (bad) { hubStatus(bad, '#fbbf24'); return; } pcSaveHubConfig(form); hubStatus('正在连接…', '#94a3b8'); try { // 用免鉴权的 /health 探测:还没绑账号也能先确认地址通不通 const r = await pcHubPing(); const hubSt = (function () { try { const a = pcHubAuthLib(); return a ? a.getState() : null; } catch (e) { return null; } })(); hubStatus(`连接正常:schema v${r.schemaVersion}${hubSt && hubSt.subject ? ` · 已绑定 ${hubSt.subject},链路与账号状态均正常` : '(地址连通;还没绑定账号,点「绑定 Steam 账号」完成认证)'}`, '#4ade80'); } catch (e) { hubStatus(pcHubErrorText(e, form), '#fbbf24'); } }); } if (hubBindBtn) { hubBindBtn.addEventListener('click', async () => { const form = readHubForm(); const bad = validateHubUrl(form); if (bad) { hubStatus(bad, '#fbbf24'); return; } pcSaveHubConfig(form); const auth = pcHubSyncAuth(); if (!auth) { hubStatus('认证库未加载(saa-auth.lib.js)—— 请更新插件', '#fbbf24'); return; } hubStatus('请在弹窗里登录 Steam…', '#94a3b8'); try { const r = await auth.loginWithSteam({ onStatus: (st) => { if (st === 'waiting-steam') hubStatus('请在弹窗里完成 Steam 登录…', '#94a3b8'); else if (st === 'exchanging') hubStatus('正在换取会话…', '#94a3b8'); }, }); hubStatus(`已绑定 Steam 账号 ${r.steamid}`, '#4ade80'); toast('Steam 账号绑定成功', 'success'); // ★ v1.33.0 绑定时附带一次版本清单(reason='bind'):与开关状态无关, // 绑定是用户明确发起的中枢交互,附版本快照用于绑定排障;静默失败不影响绑定结果。 try { pcSendVersionReport('bind').catch(() => {}); } catch (e) { /* ignore */ } try { render(); } catch (e) { /* ignore */ } } catch (e) { hubStatus(pcHubErrorText(e, form), '#fbbf24'); } }); } }; render(); } function buildDataPane() { const pane = overlay.querySelector('#sa-pane-data'); const conv = ConversationManager.getCurrentConversation(); pane.innerHTML = ` 会话数据管理 当前会话:「${escapeHtml(conv?.title || '新会话')}」 · ${conv?.messages?.length || 0} 条消息 ${getIcon('download', 15)} 导出当前会话 导出为 JSON 导出为 Markdown 导出文件可直接分享或备份;JSON 可再次导入恢复 ${getIcon('edit', 15)} 导入会话 从 JSON 文件导入会话 导入后会作为新会话追加到历史列表,不影响现有对话 `; 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()); // v1.16.0 联网检索工具 const wtBaseEl = overlay.querySelector('#sa-cfg-webtools-base'); const wtKeyEl = overlay.querySelector('#sa-cfg-webtools-key'); if (wtBaseEl) { const v = wtBaseEl.value.trim(); if (v) GM_setValue(SK.DS_TOOLS_BASE, v); else GM_deleteValue(SK.DS_TOOLS_BASE); } if (wtKeyEl) { const v = wtKeyEl.value.trim(); if (v) GM_setValue(SK.DS_TOOLS_KEY, v); else GM_deleteValue(SK.DS_TOOLS_KEY); } 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(); // v1.25.0 数据源授权 pane(异步:需拉契约 + 读运行时注册表) buildSourcesPane(); 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), // v1.25.0 暴露设置入口:便于其他脚本/工具直接跳到指定设置页(如数据源授权) openSettings: (options = {}) => openSettings(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); } } // 会话防抖保存的兜底冲刷:页面关闭/隐藏时立即落盘,避免 800ms 窗口内丢消息 function registerConversationFlush() { try { window.addEventListener('beforeunload', () => ConversationManager.flushSave()); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') ConversationManager.flushSave(); }); } catch {} } // 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('bindGameNewsEsc', () => { if (GameNews) GameNews.bindEsc(); }); // v1.36.0:商店域自动刷新游戏库跨域镜像(社区域搜索游戏库用;节流 30 分钟,失败静默) safeStep('pcAutoMirrorLibrary', () => { pcAutoMirrorLibrary().catch(() => {}); }); 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('registerConversationFlush', registerConversationFlush); // ★ v1.33.0 版本上报维护(远程默认地址热更 / optout 重试 / 每日一次上报)——全部静默 safeStep('pcReportStartup', () => { try { pcReportStartup().catch(() => {}); } catch (e) { /* ignore */ } }); safeStep('EventBus.ready', () => EventBus.emit('ready', { version: _v })); console.log('[SA-Agent] 初始化完成,SteamAgent API 已暴露'); } try { if (typeof globalThis !== 'undefined' && globalThis.__SAA_TEST_ENABLE__) { globalThis.__SAA_TEST__ = { // v1.25.0 暴露设置入口(供测试渲染「数据源授权」pane) openSettings, parseToolCalls, parseSSEStream, createSseStreamParser, readPageContext, formatPageContext, normalizeNativeToolCalls, mergeToolCalls, shouldHoldStream, isNativeToolsRejected, buildReActPrompt, runAgentLoop, callAiChat, isPrivateHost, assertSafeRequestUrl, truncateToolResult, ConversationManager, AgentManager, ModelManager, ToolRegistry, PLUGIN_CACHE_REGISTRY, CONFIG, // v1.29.0 身份层:账号清单缓存(测试可强制刷新) pcHubAccounts, bustAccountsCache: () => { _accountsCache = null; _accountsAt = 0; _accountsBase = ''; }, // v1.30.0 远程数据中枢:配置 / 请求 / 路由白名单(供测试) pcHubConfig, pcSaveHubConfig, pcHubRequest, pcHubPost, pcHubRouteAllowed, pcHubErrorText, pcHubAuthLib, pcHubPing, // v1.30.3 跨域注册镜像(测试:镜像合并/回写) pcReadPluginRegistry, // v1.33.0 版本上报(测试:清单构造 / 开关门禁 / 状态机) pcBuildVersionManifest, pcSendVersionReport, pcOptoutVersionReport, pcReportState, pcReportSaveState, pcReportToday, pcHubResolveEndpointRemote, PC_HUB_DEFAULT_BASE_URL, // v1.34.0 消费/登录链路(测试:登录态探测 / 宿主桥读取) spendLoginState, spendBridgeRead, // v1.36.0 游戏库跨域镜像(测试:镜像读写 / 回退 / 年龄格式化) pcReadLibraryMirror, pcWriteLibraryMirror, pcMaybeWriteLibraryMirror, pcLibraryMirrorFallback, pcHumanAge, pcReadSglvLibrary, pcAutoMirrorLibrary, PC_LIB_MIRROR_KEY, }; } } catch {} if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { setTimeout(init, 100); } })();
'); html = html.replace(/\n/g, ''); // 恢复块级元素 html = html.replace(/__BLOCK_(\d+)__/g, (m, idx) => blockHolders[Number(idx)] || ''); if (!html.match(/^<(h\d|pre|ul|ol|blockquote|p|div)/)) html = '
' + html + '
${escapeHtml(formatToolChipDetail(args, meta.result))}
我是 ${escapeHtml(agent?.name || 'AI 助手')},${escapeHtml(agent?.description || '有什么可以帮你的吗?')}
/search
/fetch
${escapeHtml(hub.baseUrl)}
${escapeHtml(hubState.subject)}