From 0be6a86e9456e95f168a46c4a4c1e551e456a0cf Mon Sep 17 00:00:00 2001 From: SilenceLurker Date: Tue, 10 Mar 2026 22:07:15 +0800 Subject: [PATCH] feat: add API config system, FilePipe backend, and ConfigManager - ConfigManager: route sensitive keys (API keys) to localStorage, migrate existing values out of extension_settings on startup - ApiKeyStore: local/cloud storage modes with RSA+AES hybrid encryption - ApiProfileManager: named connection profiles (chat/embedding/rerank) with per-slot type-validated assignments - FilePipe: complete IndexedDB backend (read/write/delete/list/stat) - Amily2Bus: inject FilePipe via forPlugin() capability token - UI: api-config-panel with profile CRUD and slot assignment - TableSystemService: initial service layer scaffold - logger.js: XSS fix --- SL/bus/Amily2Bus.js | 18 +- SL/bus/file/FilePipe.js | 269 +++++++++++++-- assets/amily2-modal.html | 7 + assets/api-config-panel.html | 176 ++++++++++ core/events.js | 76 +---- core/table-system/TableSystemService.js | 122 +++++++ core/table-system/logger.js | 31 +- imports.js | 8 + index.js | 2 + ui/api-config-bindings.js | 348 ++++++++++++++++++++ ui/bindings.js | 8 +- ui/drawer.js | 6 + utils/config/ApiProfileManager.js | 304 +++++++++++++++++ utils/config/ConfigManager.js | 155 +++++++++ utils/config/api-key-store/ApiKeyStore.js | 359 +++++++++++++++++++++ utils/config/api-key-store/crypto-utils.js | 174 ++++++++++ utils/config/sensitive-keys.js | 17 + 17 files changed, 1970 insertions(+), 110 deletions(-) create mode 100644 assets/api-config-panel.html create mode 100644 core/table-system/TableSystemService.js create mode 100644 ui/api-config-bindings.js create mode 100644 utils/config/ApiProfileManager.js create mode 100644 utils/config/ConfigManager.js create mode 100644 utils/config/api-key-store/ApiKeyStore.js create mode 100644 utils/config/api-key-store/crypto-utils.js create mode 100644 utils/config/sensitive-keys.js diff --git a/SL/bus/Amily2Bus.js b/SL/bus/Amily2Bus.js index e546144..766db9c 100644 --- a/SL/bus/Amily2Bus.js +++ b/SL/bus/Amily2Bus.js @@ -124,15 +124,17 @@ class Amily2Bus { // 1. 日志能力 (绑定了身份的日志接口) log: (origin, type, message) => this.Logger.log(pluginName, origin, type, message), - // 2. 文件能力 (绑定了身份的文件接口) - file: { - read: (path) => { - return this.FilePipe ? this.FilePipe.read(pluginName, path) : null; + // 2. 文件能力 (绑定了插件身份的文件接口,后端为 IndexedDB) + file: this.FilePipe + ? this.FilePipe.forPlugin(pluginName) + : { + read: () => null, + write: () => false, + delete: () => false, + list: () => [], + clearAll: () => 0, + stat: () => null, }, - write: (path, data) => { - return this.FilePipe ? this.FilePipe.write(pluginName, path, data) : false; - } - }, // 3. 网络能力 (ModelCaller) model: { diff --git a/SL/bus/file/FilePipe.js b/SL/bus/file/FilePipe.js index aa0eddc..2b0fc1e 100644 --- a/SL/bus/file/FilePipe.js +++ b/SL/bus/file/FilePipe.js @@ -1,61 +1,260 @@ +/** + * FilePipe — 插件独立文件存储管道 + * + * 解决的问题: + * SillyTavern 的 settings.json 被所有插件共享,大型内容(prompt 模板、摘要、 + * 优化结果、缓存)写入后导致文件膨胀,且功能迭代残留的废弃 key 永久堆积。 + * + * 方案: + * 以 IndexedDB 为后端,每个插件在独立命名空间下进行读写。 + * 与 settings.json 完全隔离,不参与云同步,无体积上限约束。 + * + * 存储结构: + * DB : 'Amily2_FilePipe' + * Store: 'files' + * Key : 复合键 [plugin, path](无需为新插件升级 DB 版本) + * Entry: { plugin, path, data, updatedAt } + * + * 安全: + * - 路径禁止包含 '..'(防目录穿越) + * - 每个插件只能读写自己命名空间下的路径 + * + * 使用方式(通过 Amily2Bus capability token): + * const file = ctx.file; // Amily2Bus 注入 + * await file.write('config.json', { key: 'value' }); + * const data = await file.read('config.json'); + * await file.delete('config.json'); + * const list = await file.list(); + */ + +const DB_NAME = 'Amily2_FilePipe'; +const DB_VERSION = 1; +const STORE_NAME = 'files'; + +// ── IndexedDB 工具 ──────────────────────────────────────────────────────────── + +let _dbPromise = null; + +function _openDB() { + if (_dbPromise) return _dbPromise; + _dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + + req.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { + keyPath: ['plugin', 'path'], + }); + // 按插件名索引,方便 list() 查询 + store.createIndex('by_plugin', 'plugin', { unique: false }); + } + }; + + req.onsuccess = (e) => resolve(e.target.result); + req.onerror = (e) => { + _dbPromise = null; + reject(new Error(`[FilePipe] IndexedDB 打开失败: ${e.target.error}`)); + }; + }); + return _dbPromise; +} + +function _tx(db, mode) { + return db.transaction(STORE_NAME, mode).objectStore(STORE_NAME); +} + +function _idbRequest(req) { + return new Promise((resolve, reject) => { + req.onsuccess = (e) => resolve(e.target.result); + req.onerror = (e) => reject(e.target.error); + }); +} + +// ── FilePipe ────────────────────────────────────────────────────────────────── + class FilePipe { constructor() { - this.name = "FilePipe"; - // 模拟的根存储路径,实际环境可能对应 IndexedDB 的 Store Name 或 Node 的 baseDir - this.basePath = "/virtual_fs/"; + this.name = 'FilePipe'; } - /** - * 安全路径解析与校验 - * @param {string} plugin 插件名称(命名空间) - * @param {string} relativePath 相对路径 - * @returns {string|null} 合法的绝对路径,如果违规则返回 null - */ - _resolvePath(plugin, relativePath) { + // ── 安全路径校验 ───────────────────────────────────────────────────────── + + _safePath(plugin, path) { if (!plugin || typeof plugin !== 'string') { - console.error(`[FilePipe] Security Error: Invalid plugin identity.`); + console.error('[FilePipe] 无效的插件标识。'); return null; } - - // 简单防越权:禁止包含 ".." - if (relativePath.includes('..')) { - console.error(`[FilePipe] Security Error: Directory traversal attempt blocked for plugin '${plugin}'. Path: ${relativePath}`); + if (!path || typeof path !== 'string') { + console.error('[FilePipe] 无效的路径。'); return null; } - - // 强制限定在插件目录下 - // 格式: /virtual_fs/PluginName/filename - return `${this.basePath}${plugin}/${relativePath}`; + if (path.includes('..')) { + console.error(`[FilePipe] 安全拦截:插件 "${plugin}" 尝试目录穿越,路径: ${path}`); + return null; + } + // 规范化:去掉开头的斜杠 + return path.replace(/^\/+/, ''); } + // ── 公开 API ───────────────────────────────────────────────────────────── + /** - * 读取文件 - * @param {string} plugin 调用方插件名 - * @param {string} path 文件相对路径 + * 读取文件。 + * @param {string} plugin 插件名(命名空间) + * @param {string} path 文件路径(相对于插件根目录) + * @returns {Promise} 存储的数据,不存在时返回 null */ async read(plugin, path) { - const safePath = this._resolvePath(plugin, path); + const safePath = this._safePath(plugin, path); if (!safePath) return null; - console.log(`[FilePipe] Reading from: ${safePath}`); - // TODO: Implement actual file reading logic - return null; + try { + const db = await _openDB(); + const result = await _idbRequest(_tx(db, 'readonly').get([plugin, safePath])); + return result?.data ?? null; + } catch (e) { + console.error(`[FilePipe] read 失败 (${plugin}/${path}):`, e); + return null; + } } /** - * 写入文件 - * @param {string} plugin 调用方插件名 - * @param {string} path 文件相对路径 - * @param {any} data 数据 + * 写入文件。 + * @param {string} plugin 插件名 + * @param {string} path 文件路径 + * @param {any} data 任意可序列化数据(对象、字符串、ArrayBuffer 等) + * @returns {Promise} */ async write(plugin, path, data) { - const safePath = this._resolvePath(plugin, path); + const safePath = this._safePath(plugin, path); if (!safePath) return false; - console.log(`[FilePipe] Writing to: ${safePath}`); - // TODO: Implement actual file writing logic - return true; + try { + const db = await _openDB(); + await _idbRequest(_tx(db, 'readwrite').put({ + plugin, + path: safePath, + data, + updatedAt: new Date().toISOString(), + })); + return true; + } catch (e) { + console.error(`[FilePipe] write 失败 (${plugin}/${path}):`, e); + return false; + } + } + + /** + * 删除文件。 + * @param {string} plugin + * @param {string} path + * @returns {Promise} + */ + async delete(plugin, path) { + const safePath = this._safePath(plugin, path); + if (!safePath) return false; + + try { + const db = await _openDB(); + await _idbRequest(_tx(db, 'readwrite').delete([plugin, safePath])); + return true; + } catch (e) { + console.error(`[FilePipe] delete 失败 (${plugin}/${path}):`, e); + return false; + } + } + + /** + * 列出插件下所有文件的路径(可按前缀过滤)。 + * @param {string} plugin + * @param {string} [prefix=''] 路径前缀过滤 + * @returns {Promise} + */ + async list(plugin, prefix = '') { + if (!plugin) return []; + + try { + const db = await _openDB(); + const store = _tx(db, 'readonly'); + const index = store.index('by_plugin'); + const range = IDBKeyRange.only(plugin); + + return new Promise((resolve, reject) => { + const paths = []; + const req = index.openCursor(range); + req.onsuccess = (e) => { + const cursor = e.target.result; + if (!cursor) { resolve(paths); return; } + if (!prefix || cursor.value.path.startsWith(prefix)) { + paths.push(cursor.value.path); + } + cursor.continue(); + }; + req.onerror = (e) => reject(e.target.error); + }); + } catch (e) { + console.error(`[FilePipe] list 失败 (${plugin}):`, e); + return []; + } + } + + /** + * 清空插件下的所有文件(插件卸载/重置时调用)。 + * @param {string} plugin + * @returns {Promise} 删除的文件数量 + */ + async clearAll(plugin) { + const paths = await this.list(plugin); + let count = 0; + for (const path of paths) { + if (await this.delete(plugin, path)) count++; + } + console.info(`[FilePipe] 已清除插件 "${plugin}" 的 ${count} 个文件。`); + return count; + } + + /** + * 读取文件元数据(不含 data 本身)。 + * @param {string} plugin + * @param {string} path + * @returns {Promise<{path, updatedAt}|null>} + */ + async stat(plugin, path) { + const safePath = this._safePath(plugin, path); + if (!safePath) return null; + + try { + const db = await _openDB(); + const result = await _idbRequest(_tx(db, 'readonly').get([plugin, safePath])); + if (!result) return null; + return { path: result.path, updatedAt: result.updatedAt }; + } catch (e) { + return null; + } + } + + /** + * 生成绑定了插件名的快捷访问对象(供 Amily2Bus capability token 注入用)。 + * 使用方不需要每次传 plugin 参数。 + * + * 示例: + * const file = filePipe.forPlugin('TableSystem'); + * await file.write('presets.json', data); + * + * @param {string} plugin + * @returns {{ read, write, delete, list, clearAll, stat }} + */ + forPlugin(plugin) { + return { + read: (path) => this.read(plugin, path), + write: (path, data) => this.write(plugin, path, data), + delete: (path) => this.delete(plugin, path), + list: (prefix) => this.list(plugin, prefix), + clearAll: () => this.clearAll(plugin), + stat: (path) => this.stat(plugin, path), + }; } } -export default FilePipe; \ No newline at end of file +export default FilePipe; diff --git a/assets/amily2-modal.html b/assets/amily2-modal.html index d75be0f..054f9f2 100644 --- a/assets/amily2-modal.html +++ b/assets/amily2-modal.html @@ -235,6 +235,13 @@ +
+ 系统配置 +
+ +
+
+
diff --git a/assets/api-config-panel.html b/assets/api-config-panel.html new file mode 100644 index 0000000..a826ab0 --- /dev/null +++ b/assets/api-config-panel.html @@ -0,0 +1,176 @@ +
+
+ API 连接配置 +
+ +
+
+ + +
+ 密钥存储模式 +
+
+ + + + 本地存储:API Key 仅存于本设备浏览器,绝不上传。换设备需重新填写。 + +
+ +
+
+ + +
+ 连接配置列表 + + +
+ + + + + +
+ + +
+ +
+ 暂无连接配置,点击「新建配置」添加。 +
+
+
+ + +
+ 功能分配 + + 为每个系统功能指定使用的连接配置。选单只会显示类型匹配的配置。 + +
+ +
+
+ + + diff --git a/core/events.js b/core/events.js index 2b463c9..2f22086 100644 --- a/core/events.js +++ b/core/events.js @@ -1,75 +1,19 @@ import { getContext, extension_settings } from "/scripts/extensions.js"; -import { saveChatConditional } from "/script.js"; import { extensionName } from "../utils/settings.js"; -import * as TableManager from './table-system/manager.js'; -import * as Executor from './table-system/executor.js'; -import { renderTables } from '../ui/table-bindings.js'; -import { log } from "./table-system/logger.js"; - -async function handleTableUpdate(messageId) { - TableManager.clearHighlights(); - - const settings = extension_settings[extensionName]; - const tableSystemEnabled = settings.table_system_enabled !== false; - if (!tableSystemEnabled) { - log('【监察系统】表格系统总开关已关闭,跳过所有表格处理。', 'info'); - return; - } - - const fillingMode = settings.filling_mode || 'main-api'; - if (fillingMode === 'secondary-api' || fillingMode === 'optimized') { - log('【监察系统】检测到"分步填表"或"优化中填表"模式已启用,主API填表逻辑已自动禁用。', 'info'); - return; - } - - log(`【监察系统】接到圣旨,开始处理消息 ID: ${messageId}`, 'warn'); - const context = getContext(); - const message = context.chat[messageId]; - - if (!message) { - log(`【监察系统】错误:未找到消息 ID: ${messageId},流程中止。`, 'error'); - return; - } - if (message.is_user) { - log(`【监察系统】消息 ID: ${messageId} 是用户消息,无需处理。`, 'info'); - return; - } - - log(`【监察系统】正在处理的奏折内容: "${message.mes.substring(0, 50)}..."`, 'info'); - const initialState = TableManager.loadTables(messageId); - log(`【监察系统-步骤1】为消息 ${messageId} 加载了基准状态。`, 'info', initialState); - const { finalState, hasChanges, changes } = Executor.executeCommands(message.mes, initialState); - log(`【监察系统-步骤2】推演完毕。是否有变化: ${hasChanges}`, 'info', finalState); - if (hasChanges) { - if (changes && changes.length > 0) { - changes.forEach(change => { - TableManager.addHighlight(change.tableIndex, change.rowIndex, change.colIndex); - }); - } - - TableManager.saveStateToMessage(finalState, message); - TableManager.setMemoryState(finalState); - await saveChatConditional(); - log(`【监察系统-步骤3】检测到变化,已将新状态写入消息 ${messageId} 并保存。`, 'success'); - } else { - log(`【监察系统-步骤3】未检测到有效指令或变化,无需写入。`, 'info'); - } - if (hasChanges) { - renderTables(); - } -} - - +import { processMessageUpdate, fillWithSecondaryApi } from './table-system/TableSystemService.js'; import { processOptimization } from "./summarizer.js"; import { executeAutoHide } from './autoHideManager.js'; import { checkAndTriggerAutoSummary } from './historiographer.js'; -import { fillWithSecondaryApi } from './table-system/secondary-filler.js'; import { amilyHelper } from './tavern-helper/main.js'; +async function handleTableUpdate(messageId) { + await processMessageUpdate(messageId); +} + export async function onMessageReceived(data) { window.lastPreOptimizationResult = null; - document.dispatchEvent(new CustomEvent('preOptimizationTextUpdated')); + document.dispatchEvent(new CustomEvent('preOptimizationTextUpdated')); const context = getContext(); if ((data && data.is_user) || context.isWaitingForUserInput) { return; } @@ -81,9 +25,10 @@ export async function onMessageReceived(data) { const latestMessage = chat[chat.length - 1]; if (latestMessage.is_user) { return; } - const tableSystemEnabled = settings.table_system_enabled !== false; - + const tableSystemEnabled = settings.table_system_enabled !== false; + await executeAutoHide(); + const isOptimizationEnabled = settings.optimizationEnabled && settings.apiUrl; if (isOptimizationEnabled) { if (chat.length >= 2 && chat[chat.length - 2].is_user) { @@ -109,13 +54,14 @@ export async function onMessageReceived(data) { console.log("[Amily2号-正文优化] 检测到消息并非AI对用户的直接回复,已跳过优化。"); } } + if (tableSystemEnabled) { const fillingMode = settings.filling_mode || 'main-api'; if (fillingMode === 'secondary-api') { fillWithSecondaryApi(latestMessage); } } else { - log('[分步填表] 表格系统总开关已关闭,跳过分步填表处理。', 'info'); + console.log('[分步填表] 表格系统总开关已关闭,跳过分步填表处理。'); } (async () => { diff --git a/core/table-system/TableSystemService.js b/core/table-system/TableSystemService.js new file mode 100644 index 0000000..0ef7221 --- /dev/null +++ b/core/table-system/TableSystemService.js @@ -0,0 +1,122 @@ +/** + * TableSystemService + * 表格系统 Bus 服务 — 统一对外入口 + * + * 职责: + * 1. 将原 events.js::handleTableUpdate 的消息处理编排逻辑收归此处 + * 2. 通过 Amily2Bus 暴露稳定接口,解耦外部模块的直接依赖 + * 3. 向后兼容:保留具名导出,现有直接 import 无需立即修改 + * + * Bus 注册名:'TableSystem' + * + * 公开接口(query('TableSystem')): + * processMessageUpdate(messageId) — 处理 AI 消息的表格更新流程 + * fillWithSecondaryApi(msg) — 二次 API 填表 + * injectTableData(...) — 向提示词注入表格数据 + * generateTableContent() — 生成表格注入内容字符串 + * getMemoryState() — 读取当前表格内存状态 + * renderTables() — 强制重渲染表格 UI + */ + +import { getContext, extension_settings } from "/scripts/extensions.js"; +import { saveChatConditional } from "/script.js"; +import { extensionName } from "../../utils/settings.js"; + +// ── table-system 内部模块 ───────────────────────────────────────────────── +// manager.js / logger.js 为受限文件(不修改),此处仅引用其导出 +import * as TableManager from './manager.js'; +import { executeCommands } from './executor.js'; +import { log } from './logger.js'; + +// 可修改子模块 +import { generateTableContent, injectTableData } from './injector.js'; +import { fillWithSecondaryApi } from './secondary-filler.js'; + +// UI 层 +import { renderTables } from '../../ui/table-bindings.js'; + +// ── 核心逻辑 ───────────────────────────────────────────────────────────── + +/** + * 处理单条 AI 消息的表格更新流程。 + * 原 events.js::handleTableUpdate 的完整逻辑迁移至此。 + * + * @param {number} messageId - 消息在 context.chat 中的索引 + */ +async function processMessageUpdate(messageId) { + TableManager.clearHighlights(); + + const settings = extension_settings[extensionName]; + const tableSystemEnabled = settings.table_system_enabled !== false; + if (!tableSystemEnabled) { + log('【表格服务】表格系统总开关已关闭,跳过所有表格处理。', 'info'); + return; + } + + const fillingMode = settings.filling_mode || 'main-api'; + if (fillingMode === 'secondary-api' || fillingMode === 'optimized') { + log('【表格服务】检测到"分步填表"或"优化中填表"模式,主API填表已自动禁用。', 'info'); + return; + } + + log(`【表格服务】开始处理消息 ID: ${messageId}`, 'warn'); + const context = getContext(); + const message = context.chat[messageId]; + + if (!message) { + log(`【表格服务】错误:未找到消息 ID: ${messageId},流程中止。`, 'error'); + return; + } + if (message.is_user) { + log(`【表格服务】消息 ID: ${messageId} 是用户消息,跳过。`, 'info'); + return; + } + + log(`【表格服务】处理内容: "${message.mes.substring(0, 50)}..."`, 'info'); + const initialState = TableManager.loadTables(messageId); + log('【表格服务-步骤1】基准状态已加载。', 'info', initialState); + + const { finalState, hasChanges, changes } = executeCommands(message.mes, initialState); + log(`【表格服务-步骤2】推演完毕。是否有变化: ${hasChanges}`, 'info', finalState); + + if (hasChanges) { + changes.forEach(change => { + TableManager.addHighlight(change.tableIndex, change.rowIndex, change.colIndex); + }); + TableManager.saveStateToMessage(finalState, message); + TableManager.setMemoryState(finalState); + await saveChatConditional(); + log('【表格服务-步骤3】状态已写入并保存。', 'success'); + renderTables(); + } else { + log('【表格服务-步骤3】未检测到有效指令或变化,无需写入。', 'info'); + } +} + +// ── Bus 注册 ────────────────────────────────────────────────────────────── +// 使用 setTimeout 延迟到同步模块初始化完成后再注册, +// 确保 window.Amily2Bus 已由 SL/bus/Amily2Bus.js 完成挂载。 +setTimeout(() => { + try { + const _ctx = window.Amily2Bus?.register('TableSystem'); + if (!_ctx) { + console.warn('[TableSystem] Amily2Bus 尚未就绪,服务注册跳过。'); + return; + } + _ctx.expose({ + processMessageUpdate, + fillWithSecondaryApi, + injectTableData, + generateTableContent, + getMemoryState: () => TableManager.getMemoryState(), + renderTables, + }); + _ctx.log('TableSystemService', 'info', 'TableSystem 服务已注册到 Bus。'); + } catch (e) { + console.error('[TableSystem] Bus 注册失败:', e); + } +}, 0); + +// ── 向后兼容具名导出 ────────────────────────────────────────────────────── +// 过渡期保留,现有 import { ... } from '...TableSystemService.js' 无需修改。 +export { processMessageUpdate, fillWithSecondaryApi, generateTableContent, injectTableData }; diff --git a/core/table-system/logger.js b/core/table-system/logger.js index adfeb9e..ad25cc8 100644 --- a/core/table-system/logger.js +++ b/core/table-system/logger.js @@ -1 +1,30 @@ -const _0x352fc5=_0xb01f;(function(_0x52276c,_0x1fe640){const _0x25137c=_0xb01f,_0x322b57=_0x52276c();while(!![]){try{const _0xb9a91d=parseInt(_0x25137c(0x1d4))/0x1+-parseInt(_0x25137c(0x1d9))/0x2*(parseInt(_0x25137c(0x1c6))/0x3)+parseInt(_0x25137c(0x1c8))/0x4*(-parseInt(_0x25137c(0x1da))/0x5)+-parseInt(_0x25137c(0x1d5))/0x6+-parseInt(_0x25137c(0x1c5))/0x7+parseInt(_0x25137c(0x1c4))/0x8*(-parseInt(_0x25137c(0x1cc))/0x9)+parseInt(_0x25137c(0x1ce))/0xa;if(_0xb9a91d===_0x1fe640)break;else _0x322b57['push'](_0x322b57['shift']());}catch(_0x57e5d4){_0x322b57['push'](_0x322b57['shift']());}}}(_0x13eb,0x61073));function _0xb01f(_0x2b709c,_0x43aa7d){const _0x13eb95=_0x13eb();return _0xb01f=function(_0xb01f68,_0x3325be){_0xb01f68=_0xb01f68-0x1c4;let _0x638bf1=_0x13eb95[_0xb01f68];return _0x638bf1;},_0xb01f(_0x2b709c,_0x43aa7d);}function _0x13eb(){const _0x3421fa=['createElement','\x22>\x20','101174LOTkJv','79935LtdznB','3176dnnOAA','861679gOvdAF','33vyMqZa','fa-solid\x20fa-check-circle','28LBJaGM','scrollHeight','fa-solid\x20fa-circle-info','getElementById','7677IWXntE','[内存储司-起居注]\x20','13572940eKjSAe','fa-solid\x20fa-circle-xmark','appendChild','log','hly-log-entry\x20log-','innerHTML','182086ttYsxR','71094AjxVJw','fa-solid\x20fa-triangle-exclamation'];_0x13eb=function(){return _0x3421fa;};return _0x13eb();}const getLogContainer=()=>document[_0x352fc5(0x1cb)]('table-log-display');export function log(_0x1e7922,_0x4de68c='info',_0x2aabe2=null){const _0x17dbe1=_0x352fc5,_0x4fdf31=getLogContainer();if(!_0x4fdf31){const _0xec84ec=console[_0x4de68c]||console[_0x17dbe1(0x1d1)];_0xec84ec(_0x17dbe1(0x1cd)+_0x1e7922,_0x2aabe2||'');return;}const _0x483576={'info':_0x17dbe1(0x1ca),'success':_0x17dbe1(0x1c7),'warn':_0x17dbe1(0x1d6),'error':_0x17dbe1(0x1cf)},_0x5bed08=document[_0x17dbe1(0x1d7)]('p');_0x5bed08['className']=_0x17dbe1(0x1d2)+_0x4de68c,_0x5bed08[_0x17dbe1(0x1d3)]=' document.getElementById('table-log-display'); + +export function log(message, type = 'info', data = null) { + const container = getLogContainer(); + if (!container) { + // 在容器不可用时,静默地将日志打印到控制台,不再显示警告 + const logFunc = console[type] || console.log; + logFunc(`[内存储司-起居注] ${message}`, data || ''); + return; + } + + const iconMap = { + info: 'fa-solid fa-circle-info', + success: 'fa-solid fa-check-circle', + warn: 'fa-solid fa-triangle-exclamation', + error: 'fa-solid fa-circle-xmark', + }; + + const logEntry = document.createElement('p'); + logEntry.className = `hly-log-entry log-${type}`; + const icon = document.createElement('i'); + icon.className = iconMap[type]; + logEntry.appendChild(icon); + logEntry.appendChild(document.createTextNode(` ${message}`)); + + container.appendChild(logEntry); + + // Auto-scroll to the bottom + container.scrollTop = container.scrollHeight; +} diff --git a/imports.js b/imports.js index 6dfc6ff..89ab55c 100644 --- a/imports.js +++ b/imports.js @@ -4,6 +4,10 @@ import "./PreOptimizationViewer/index.js"; import "./WorldEditor/WorldEditor.js"; import './core/amily2-updater.js'; import './SL/bus/Amily2Bus.js' +import './utils/config/ConfigManager.js' +import './utils/config/api-key-store/ApiKeyStore.js' +import './utils/config/ApiProfileManager.js' +import './core/table-system/TableSystemService.js' // Re-exports (重新导出供 index.js 使用) export { createDrawer } from "./ui/drawer.js"; @@ -26,6 +30,10 @@ export { log } from './core/table-system/logger.js'; export { checkForUpdates, fetchMessageBoardContent } from './core/api.js'; export { setUpdateInfo, applyUpdateIndicator } from './ui/state.js'; export { pluginVersion, extensionName, defaultSettings } from './utils/settings.js'; +export { configManager } from './utils/config/ConfigManager.js'; +export { apiKeyStore } from './utils/config/api-key-store/ApiKeyStore.js'; +export { apiProfileManager, PROFILE_TYPES, SLOTS } from './utils/config/ApiProfileManager.js'; +export { bindApiConfigPanel } from './ui/api-config-bindings.js'; export { checkAuthorization, refreshUserInfo } from './utils/auth.js'; export { tableSystemDefaultSettings } from './core/table-system/settings.js'; export { manageLorebookEntriesForChat } from './core/lore.js'; diff --git a/index.js b/index.js index e90f110..ccfc02a 100644 --- a/index.js +++ b/index.js @@ -15,6 +15,7 @@ import { checkForUpdates, fetchMessageBoardContent, setUpdateInfo, applyUpdateIndicator, pluginVersion, extensionName, defaultSettings, + configManager, checkAuthorization, refreshUserInfo, tableSystemDefaultSettings, manageLorebookEntriesForChat, @@ -940,6 +941,7 @@ jQuery(async () => { registerAllApiHandlers(); initializeAmilyHelper(); mergePluginSettings(); + configManager.migrate(); // 将 extension_settings 中残留的敏感字段迁移到 localStorage let attempts = 0; const maxAttempts = 100; diff --git a/ui/api-config-bindings.js b/ui/api-config-bindings.js new file mode 100644 index 0000000..018c3ee --- /dev/null +++ b/ui/api-config-bindings.js @@ -0,0 +1,348 @@ +/** + * api-config-bindings.js — API 连接配置面板 UI 事件绑定 + * + * 依赖: + * ApiProfileManager(数据层) + * ApiKeyStore(密钥存储) + */ + +import { apiProfileManager, PROFILE_TYPES, SLOTS } from '../utils/config/ApiProfileManager.js'; +import { apiKeyStore } from '../utils/config/api-key-store/ApiKeyStore.js'; + +// ── 状态 ───────────────────────────────────────────────────────────────────── + +let _editingId = null; // 当前编辑的 Profile ID(null = 新建) +let _currentFilter = 'all'; // 当前类型筛选 + +// ── 入口:绑定整个面板 ──────────────────────────────────────────────────────── + +export function bindApiConfigPanel(container) { + const $c = $(container); + + // 存储模式 + _bindStorageMode($c); + + // 类型筛选 + $c.on('click', '.amily2_profile_type_filter', function () { + $c.find('.amily2_profile_type_filter').removeClass('active'); + $(this).addClass('active'); + _currentFilter = $(this).data('type'); + renderProfileList($c); + }); + + // 新建 Profile + $c.find('#amily2_add_profile').on('click', () => openModal($c, null)); + + // 弹窗:类型切换时显示/隐藏专有参数 + $c.find('#amily2_pf_type').on('change', function () { + _switchParamSections($c, $(this).val()); + }); + + // 弹窗:关闭 + $c.find('#amily2_profile_modal_close, #amily2_profile_modal_cancel').on('click', () => closeModal($c)); + $c.find('#amily2_profile_modal').on('click', function (e) { + if (e.target === this) closeModal($c); + }); + + // 弹窗:保存 + $c.find('#amily2_profile_modal_save').on('click', () => saveProfile($c)); + + // 初始渲染 + renderProfileList($c); + renderSlotAssignments($c); +} + +// ── 存储模式 ────────────────────────────────────────────────────────────────── + +function _bindStorageMode($c) { + const $select = $c.find('#amily2_keystore_mode'); + const $cloud = $c.find('#amily2_cloud_key_section'); + const $note = $c.find('#amily2_keystore_mode_note'); + + const MODE_NOTES = { + local: '本地存储:API Key 仅存于本设备浏览器,绝不上传服务端。换设备需重新填写。', + cloud: '加密云同步:API Key 经 RSA+AES 混合加密后随设置同步。私钥仅留在本设备,服务商只能看到密文。', + }; + + // 初始状态 + const currentMode = apiKeyStore.getMode(); + $select.val(currentMode); + $cloud.toggle(currentMode === 'cloud'); + $note.text(MODE_NOTES[currentMode]); + if (currentMode === 'cloud') _refreshFingerprint($c); + + // 切换模式 + $select.on('change', async function () { + const newMode = $(this).val(); + const confirmed = newMode === 'cloud' + ? confirm('切换到加密云同步模式:\n将自动为本设备生成 RSA 密钥对,现有 Key 会重新加密存储。\n\n确认切换?') + : confirm('切换回本地存储模式:\n已加密的 Key 将解密迁移至本地,云端密文会被清除。\n\n确认切换?'); + + if (!confirmed) { + $select.val(apiKeyStore.getMode()); + return; + } + + try { + await apiKeyStore.setMode(newMode); + $cloud.toggle(newMode === 'cloud'); + $note.text(MODE_NOTES[newMode]); + if (newMode === 'cloud') _refreshFingerprint($c); + toastr.success(`已切换为${newMode === 'cloud' ? '加密云同步' : '本地存储'}模式。`); + } catch (e) { + console.error('[ApiConfig] 模式切换失败:', e); + toastr.error('模式切换失败,请查看控制台。'); + $select.val(apiKeyStore.getMode()); + } + }); + + // 重新生成密钥对 + $c.find('#amily2_generate_keypair').on('click', async () => { + if (!confirm('重新生成密钥对后,所有已加密的 API Key 将失效,需要逐一重新输入。\n\n确认重新生成?')) return; + await apiKeyStore.generateKeyPair(); + _refreshFingerprint($c); + toastr.warning('新密钥对已生成,请重新输入各 Profile 的 API Key。'); + }); +} + +async function _refreshFingerprint($c) { + const fp = await apiKeyStore.getPublicKeyInfo(); + $c.find('#amily2_keypair_fingerprint').text(fp); +} + +// ── Profile 列表渲染 ────────────────────────────────────────────────────────── + +export function renderProfileList($c) { + const $list = $c.find('#amily2_profile_list'); + const profiles = apiProfileManager.getProfiles( + _currentFilter === 'all' ? undefined : _currentFilter + ); + + if (profiles.length === 0) { + $list.html('
暂无连接配置,点击「新建配置」添加。
'); + return; + } + + const TYPE_BADGE_COLOR = { + chat: 'var(--SmartThemeBodyColor)', + embedding: '#7eb8f7', + rerank: '#f7b07e', + }; + + const html = profiles.map(p => { + const typeInfo = PROFILE_TYPES[p.type]; + const badgeStyle = `background:${TYPE_BADGE_COLOR[p.type]}22; color:${TYPE_BADGE_COLOR[p.type]}; border:1px solid ${TYPE_BADGE_COLOR[p.type]}55; border-radius:4px; padding:1px 6px; font-size:0.78em;`; + return ` +
+ +
+
${_escapeHtml(p.name)}
+
+ ${typeInfo.label} + ${_escapeHtml(p.model || '(未设置模型)')} + ${p.apiUrl ? `${_escapeHtml(_truncateUrl(p.apiUrl))}` : ''} +
+
+
+ + +
+
`; + }).join(''); + + $list.html(html); + + // 编辑 / 删除事件 + $list.find('.amily2_edit_profile').on('click', function () { + openModal($c, $(this).data('id')); + }); + $list.find('.amily2_delete_profile').on('click', function () { + const id = $(this).data('id'); + const name = apiProfileManager.getProfile(id)?.name || id; + if (!confirm(`确认删除连接配置「${name}」?\n此操作不可撤销,存储的 API Key 将同时清除。`)) return; + apiProfileManager.deleteProfile(id); + renderProfileList($c); + renderSlotAssignments($c); + toastr.success(`已删除配置「${name}」。`); + }); +} + +// ── 功能槽分配渲染 ──────────────────────────────────────────────────────────── + +export function renderSlotAssignments($c) { + const $slots = $c.find('#amily2_slot_assignments'); + + const rows = Object.entries(SLOTS).map(([slot, slotInfo]) => { + const profiles = apiProfileManager.getProfiles(slotInfo.type); + const assigned = apiProfileManager.getAssignment(slot) || ''; + const typeInfo = PROFILE_TYPES[slotInfo.type]; + + const options = [ + ``, + ...profiles.map(p => + `` + ), + ].join(''); + + return ` +
+ ${slotInfo.label} + + ${typeInfo.label} + + +
`; + }).join(''); + + $slots.html(rows); + + $slots.find('.amily2_slot_select').on('change', function () { + const slot = $(this).data('slot'); + const id = $(this).val() || null; + if (!apiProfileManager.setAssignment(slot, id)) { + toastr.error('类型不匹配,分配失败。'); + renderSlotAssignments($c); + } + }); +} + +// ── 弹窗操作 ────────────────────────────────────────────────────────────────── + +async function openModal($c, id) { + _editingId = id; + const $modal = $c.find('#amily2_profile_modal'); + + if (id) { + // 编辑模式 + const p = apiProfileManager.getProfile(id); + if (!p) return; + $c.find('#amily2_profile_modal_title').html(' 编辑连接配置'); + $c.find('#amily2_pf_type').val(p.type).prop('disabled', true); // 不允许修改类型 + $c.find('#amily2_pf_name').val(p.name); + $c.find('#amily2_pf_provider').val(p.provider); + $c.find('#amily2_pf_url').val(p.apiUrl); + $c.find('#amily2_pf_key').val(''); // Key 不回显 + $c.find('#amily2_pf_model').val(p.model); + + if (p.type === 'chat') { + $c.find('#amily2_pf_max_tokens').val(p.maxTokens); + $c.find('#amily2_pf_temperature').val(p.temperature); + } else if (p.type === 'embedding') { + $c.find('#amily2_pf_dimensions').val(p.dimensions ?? ''); + $c.find('#amily2_pf_encoding_format').val(p.encodingFormat); + } else if (p.type === 'rerank') { + $c.find('#amily2_pf_top_n').val(p.topN); + $c.find('#amily2_pf_return_documents').prop('checked', p.returnDocuments); + } + _switchParamSections($c, p.type); + } else { + // 新建模式 + $c.find('#amily2_profile_modal_title').html(' 新建连接配置'); + $c.find('#amily2_pf_type').val('chat').prop('disabled', false); + $c.find('#amily2_pf_name, #amily2_pf_url, #amily2_pf_key, #amily2_pf_model').val(''); + $c.find('#amily2_pf_provider').val('openai'); + $c.find('#amily2_pf_max_tokens').val(65500); + $c.find('#amily2_pf_temperature').val(1.0); + $c.find('#amily2_pf_dimensions').val(''); + $c.find('#amily2_pf_encoding_format').val('float'); + $c.find('#amily2_pf_top_n').val(5); + $c.find('#amily2_pf_return_documents').prop('checked', false); + _switchParamSections($c, 'chat'); + } + + $modal.css('display', 'flex'); +} + +function closeModal($c) { + $c.find('#amily2_profile_modal').hide(); + $c.find('#amily2_pf_type').prop('disabled', false); + _editingId = null; +} + +async function saveProfile($c) { + const type = $c.find('#amily2_pf_type').val(); + const name = $c.find('#amily2_pf_name').val().trim(); + const provider = $c.find('#amily2_pf_provider').val(); + const apiUrl = $c.find('#amily2_pf_url').val().trim(); + const apiKey = $c.find('#amily2_pf_key').val(); + const model = $c.find('#amily2_pf_model').val().trim(); + + if (!name) { toastr.warning('请填写配置名称。'); return; } + + const data = { type, name, provider, apiUrl, model }; + + if (type === 'chat') { + data.maxTokens = parseInt($c.find('#amily2_pf_max_tokens').val(), 10) || 65500; + data.temperature = parseFloat($c.find('#amily2_pf_temperature').val()) || 1.0; + } else if (type === 'embedding') { + const dim = $c.find('#amily2_pf_dimensions').val(); + data.dimensions = dim ? parseInt(dim, 10) : null; + data.encodingFormat = $c.find('#amily2_pf_encoding_format').val(); + } else if (type === 'rerank') { + data.topN = parseInt($c.find('#amily2_pf_top_n').val(), 10) || 5; + data.returnDocuments = $c.find('#amily2_pf_return_documents').is(':checked'); + } + + const $btn = $c.find('#amily2_profile_modal_save').prop('disabled', true); + + try { + let profileId; + if (_editingId) { + apiProfileManager.updateProfile(_editingId, data); + profileId = _editingId; + } else { + profileId = apiProfileManager.createProfile(data); + } + + // 保存 Key(非空才写入) + if (apiKey) { + await apiProfileManager.setKey(profileId, apiKey); + } + + closeModal($c); + renderProfileList($c); + renderSlotAssignments($c); + toastr.success(`配置「${name}」已保存。`); + } catch (e) { + console.error('[ApiConfig] 保存 Profile 失败:', e); + toastr.error('保存失败,请查看控制台。'); + } finally { + $btn.prop('disabled', false); + } +} + +// ── 内部工具 ────────────────────────────────────────────────────────────────── + +function _switchParamSections($c, type) { + $c.find('#amily2_pf_chat_params').toggle(type === 'chat'); + $c.find('#amily2_pf_embedding_params').toggle(type === 'embedding'); + $c.find('#amily2_pf_rerank_params').toggle(type === 'rerank'); +} + +function _truncateUrl(url) { + try { + const u = new URL(url); + return u.host + (u.pathname.length > 1 ? u.pathname : ''); + } catch { + return url.slice(0, 30); + } +} + +function _escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/ui/bindings.js b/ui/bindings.js index 015ad75..229a19f 100644 --- a/ui/bindings.js +++ b/ui/bindings.js @@ -805,7 +805,7 @@ export function bindModalEvents() { container .off("click.amily2.chamber_nav") .on("click.amily2.chamber_nav", - "#amily2_open_text_optimization, #amily2_open_plot_optimization, #amily2_open_additional_features, #amily2_open_rag_palace, #amily2_open_memorisation_forms, #amily2_open_character_world_book, #amily2_open_world_editor, #amily2_open_glossary, #amily2_open_renderer, #amily2_open_super_memory, #amily2_open_auto_char_card, #amily2_back_to_main_settings, #amily2_back_to_main_from_hanlinyuan, #amily2_back_to_main_from_forms, #amily2_back_to_main_from_optimization, #amily2_back_to_main_from_text_optimization, #amily2_back_to_main_from_cwb, #amily2_back_to_main_from_world_editor, #amily2_back_to_main_from_glossary, #amily2_renderer_back_button, #amily2_back_to_main_from_super_memory", function () { + "#amily2_open_text_optimization, #amily2_open_plot_optimization, #amily2_open_additional_features, #amily2_open_rag_palace, #amily2_open_memorisation_forms, #amily2_open_character_world_book, #amily2_open_world_editor, #amily2_open_glossary, #amily2_open_renderer, #amily2_open_super_memory, #amily2_open_auto_char_card, #amily2_open_api_config, #amily2_back_to_main_settings, #amily2_back_to_main_from_hanlinyuan, #amily2_back_to_main_from_forms, #amily2_back_to_main_from_optimization, #amily2_back_to_main_from_text_optimization, #amily2_back_to_main_from_cwb, #amily2_back_to_main_from_world_editor, #amily2_back_to_main_from_glossary, #amily2_renderer_back_button, #amily2_back_to_main_from_super_memory, #amily2_back_to_main_from_api_config", function () { if (!pluginAuthStatus.authorized) return; const mainPanel = container.find('.plugin-features'); @@ -819,6 +819,7 @@ export function bindModalEvents() { const glossaryPanel = container.find('#amily2_glossary_panel'); const rendererPanel = container.find('#amily2_renderer_panel'); const superMemoryPanel = container.find('#amily2_super_memory_panel'); + const apiConfigPanel = container.find('#amily2_api_config_panel'); mainPanel.hide(); additionalPanel.hide(); @@ -831,6 +832,7 @@ export function bindModalEvents() { glossaryPanel.hide(); rendererPanel.hide(); superMemoryPanel.hide(); + apiConfigPanel.hide(); switch (this.id) { case 'amily2_open_text_optimization': @@ -875,6 +877,9 @@ export function bindModalEvents() { case 'amily2_open_glossary': glossaryPanel.show(); break; + case 'amily2_open_api_config': + apiConfigPanel.show(); + break; case 'amily2_back_to_main_settings': case 'amily2_back_to_main_from_hanlinyuan': case 'amily2_back_to_main_from_forms': @@ -885,6 +890,7 @@ export function bindModalEvents() { case 'amily2_back_to_main_from_glossary': case 'amily2_renderer_back_button': case 'amily2_back_to_main_from_super_memory': + case 'amily2_back_to_main_from_api_config': mainPanel.show(); break; } diff --git a/ui/drawer.js b/ui/drawer.js index d29f54c..12aeed1 100644 --- a/ui/drawer.js +++ b/ui/drawer.js @@ -21,6 +21,7 @@ import { bindTableEvents } from './table-bindings.js'; import { showContentModal } from "./page-window.js"; import { initializeRendererBindings } from "../core/tavern-helper/renderer-bindings.js"; import { bindSuperMemoryEvents } from "../core/super-memory/bindings.js"; +import { bindApiConfigPanel } from "./api-config-bindings.js"; const extensionFolderPath = `scripts/extensions/third-party/${extensionName}`; @@ -115,6 +116,10 @@ async function initializePanel(contentPanel, errorContainer) { const superMemoryPanelHtml = ``; mainContainer.append(superMemoryPanelHtml); + const apiConfigContent = await $.get(`${extensionFolderPath}/assets/api-config-panel.html`); + const apiConfigPanelHtml = ``; + mainContainer.append(apiConfigPanelHtml); + // 在面板创建后,加载世界书编辑器脚本 const worldEditorScriptId = 'world-editor-script'; if (!document.getElementById(worldEditorScriptId)) { @@ -133,6 +138,7 @@ async function initializePanel(contentPanel, errorContainer) { bindTableEvents(); initializeRendererBindings(); bindSuperMemoryEvents(); + bindApiConfigPanel(mainContainer.find('#amily2_api_config_panel')); contentPanel.data("initialized", true); console.log("[Amily-重构] 宫殿模块已按蓝图竣工。"); applyUpdateIndicator(); diff --git a/utils/config/ApiProfileManager.js b/utils/config/ApiProfileManager.js new file mode 100644 index 0000000..c1517ac --- /dev/null +++ b/utils/config/ApiProfileManager.js @@ -0,0 +1,304 @@ +/** + * ApiProfileManager — API 连接配置组管理 + * + * Profile 是一组完整的 API 连接参数,按模型类型分为三类: + * chat — 对话/补全模型(主 API、剧情优化、各子系统等) + * embedding — 向量嵌入模型(RAG 向量化) + * rerank — 重排序模型(RAG 精排) + * + * 存储分离: + * Profile 元数据(name、type、provider、url、model、params)→ extension_settings.amily2_profiles + * API Key → ApiKeyStore(local 或 cloud 加密) + * + * 功能分配(assignments): + * 记录每个系统功能当前使用哪个 Profile ID,存于 extension_settings.amily2_profile_assignments + * 选单会按功能对应的 Profile 类型进行过滤,防止类型错配。 + * + * Bus 注册名:'ApiProfiles' + * + * 公开接口: + * getProfiles(type?) — 获取全部或指定类型的 Profile 列表 + * getProfile(id) — 获取单个 Profile 元数据 + * createProfile(data) — 新建 Profile(返回新 ID) + * updateProfile(id, data) — 更新 Profile 元数据 + * deleteProfile(id) — 删除 Profile(含清理 Key) + * getKey(id) — 读取 Profile 的 API Key(异步,自动解密) + * setKey(id, value) — 写入 Profile 的 API Key(异步,自动加密) + * getAssignment(slot) — 获取功能槽当前分配的 Profile ID + * setAssignment(slot, id) — 设置功能槽的 Profile + * getAssignedProfile(slot) — 获取功能槽完整 Profile(含解密 Key) + * SLOTS — 可用功能槽清单(静态) + * PROFILE_TYPES — Profile 类型定义(静态) + */ + +import { extension_settings } from "/scripts/extensions.js"; +import { saveSettingsDebounced } from "/script.js"; +import { extensionName } from "../settings.js"; +import { apiKeyStore } from "./api-key-store/ApiKeyStore.js"; + +// ── 类型与功能槽定义 ────────────────────────────────────────────────────────── + +/** Profile 类型定义 */ +export const PROFILE_TYPES = { + chat: { + label: '对话模型', + icon: 'fa-comments', + description: '用于文本生成、对话补全的模型(Chat / Completion)', + params: ['maxTokens', 'temperature'], + }, + embedding: { + label: '向量嵌入', + icon: 'fa-project-diagram', + description: '将文本转换为向量的模型,用于 RAG 语义检索', + params: ['dimensions', 'encodingFormat'], + }, + rerank: { + label: '重排序', + icon: 'fa-sort-amount-down', + description: '对检索结果重新打分排序的模型,用于 RAG 精排', + params: ['topN', 'returnDocuments'], + }, +}; + +/** 功能槽:每个系统功能需要的 Profile 类型 */ +export const SLOTS = { + // Chat 槽 + main: { label: '主 API(正文优化)', type: 'chat' }, + plotOpt: { label: '剧情优化', type: 'chat' }, + plotOptConc: { label: '剧情优化(并发)', type: 'chat' }, + ngms: { label: 'NGMS 历史记录', type: 'chat' }, + nccs: { label: 'NCCS 并发', type: 'chat' }, + jqyh: { label: 'JQYH', type: 'chat' }, + cwb: { label: '角色卡编辑器', type: 'chat' }, + superMemory: { label: '超级记忆', type: 'chat' }, + // Embedding 槽 + ragEmbed: { label: 'RAG 向量化', type: 'embedding' }, + // Rerank 槽 + ragRerank: { label: 'RAG 重排序', type: 'rerank' }, +}; + +// extension_settings 存储 key +const EXT_PROFILES = 'amily2_profiles'; +const EXT_ASSIGNMENTS = 'amily2_profile_assignments'; + +// ── ApiProfileManager ───────────────────────────────────────────────────────── + +class ApiProfileManager { + + // ── 内部工具 ──────────────────────────────────────────────────────────── + + _settings() { + if (!extension_settings[extensionName]) extension_settings[extensionName] = {}; + return extension_settings[extensionName]; + } + + _profiles() { + const s = this._settings(); + if (!Array.isArray(s[EXT_PROFILES])) s[EXT_PROFILES] = []; + return s[EXT_PROFILES]; + } + + _assignments() { + const s = this._settings(); + if (!s[EXT_ASSIGNMENTS] || typeof s[EXT_ASSIGNMENTS] !== 'object') { + s[EXT_ASSIGNMENTS] = {}; + } + return s[EXT_ASSIGNMENTS]; + } + + _save() { + saveSettingsDebounced(); + } + + _newId() { + return `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; + } + + // ── Profile CRUD ──────────────────────────────────────────────────────── + + /** + * 获取 Profile 列表。 + * @param {'chat'|'embedding'|'rerank'} [type] 不传则返回全部 + * @returns {Array} + */ + getProfiles(type) { + const all = this._profiles(); + return type ? all.filter(p => p.type === type) : [...all]; + } + + /** + * 获取单个 Profile 元数据(不含 Key)。 + */ + getProfile(id) { + return this._profiles().find(p => p.id === id) ?? null; + } + + /** + * 新建 Profile。 + * @param {Object} data Profile 数据(不含 id、apiKey) + * @returns {string} 新 Profile 的 id + */ + createProfile(data) { + const id = this._newId(); + const profile = this._buildProfile(id, data); + this._profiles().push(profile); + this._save(); + return id; + } + + /** + * 更新 Profile 元数据(不更新 Key,Key 用 setKey())。 + */ + updateProfile(id, data) { + const list = this._profiles(); + const idx = list.findIndex(p => p.id === id); + if (idx === -1) return false; + list[idx] = this._buildProfile(id, { ...list[idx], ...data }); + this._save(); + return true; + } + + /** + * 删除 Profile(同时清理存储的 Key 和功能槽引用)。 + */ + deleteProfile(id) { + const s = this._settings(); + s[EXT_PROFILES] = this._profiles().filter(p => p.id !== id); + + // 清理功能槽引用 + const asgn = this._assignments(); + for (const slot in asgn) { + if (asgn[slot] === id) delete asgn[slot]; + } + + // 清理 Key + apiKeyStore.deleteById(id); + + this._save(); + } + + // ── Key 操作 ──────────────────────────────────────────────────────────── + + /** 读取 Profile 的 API Key(异步,自动解密) */ + async getKey(id) { + return apiKeyStore.retrieveById(id); + } + + /** 写入 Profile 的 API Key(异步,自动加密) */ + async setKey(id, value) { + return apiKeyStore.storeById(id, value); + } + + // ── 功能槽分配 ────────────────────────────────────────────────────────── + + /** 获取功能槽当前分配的 Profile ID(null = 未分配) */ + getAssignment(slot) { + return this._assignments()[slot] ?? null; + } + + /** + * 设置功能槽的 Profile。 + * 会校验 Profile 类型是否与槽类型匹配。 + */ + setAssignment(slot, profileId) { + if (!SLOTS[slot]) { + console.warn(`[ApiProfiles] 未知功能槽 "${slot}"。`); + return false; + } + if (profileId !== null) { + const profile = this.getProfile(profileId); + if (!profile) { + console.warn(`[ApiProfiles] Profile "${profileId}" 不存在。`); + return false; + } + if (profile.type !== SLOTS[slot].type) { + console.warn(`[ApiProfiles] 类型不匹配:槽 "${slot}" 需要 ${SLOTS[slot].type},Profile 类型为 ${profile.type}。`); + return false; + } + } + this._assignments()[slot] = profileId; + this._save(); + return true; + } + + /** + * 获取功能槽完整 Profile,包含解密后的 API Key。 + * @returns {Promise} + */ + async getAssignedProfile(slot) { + const id = this.getAssignment(slot); + if (!id) return null; + const profile = this.getProfile(id); + if (!profile) return null; + const apiKey = await this.getKey(id); + return { ...profile, apiKey }; + } + + // ── 内部:Profile 对象构造 ────────────────────────────────────────────── + + _buildProfile(id, data) { + const type = data.type || 'chat'; + const base = { + id, + name: data.name || '未命名配置', + type, + provider: data.provider || 'openai', + apiUrl: data.apiUrl || '', + model: data.model || '', + }; + + if (type === 'chat') { + return { + ...base, + maxTokens: data.maxTokens ?? 65500, + temperature: data.temperature ?? 1.0, + }; + } + if (type === 'embedding') { + return { + ...base, + dimensions: data.dimensions ?? null, + encodingFormat: data.encodingFormat ?? 'float', + }; + } + if (type === 'rerank') { + return { + ...base, + topN: data.topN ?? 5, + returnDocuments: data.returnDocuments ?? false, + }; + } + return base; + } +} + +// ── 单例导出 ───────────────────────────────────────────────────────────────── +export const apiProfileManager = new ApiProfileManager(); + +// ── Bus 注册 ────────────────────────────────────────────────────────────────── +setTimeout(() => { + try { + const _ctx = window.Amily2Bus?.register('ApiProfiles'); + if (!_ctx) { + console.warn('[ApiProfiles] Amily2Bus 尚未就绪,注册跳过。'); + return; + } + _ctx.expose({ + getProfiles: (type) => apiProfileManager.getProfiles(type), + getProfile: (id) => apiProfileManager.getProfile(id), + createProfile: (data) => apiProfileManager.createProfile(data), + updateProfile: (id, data) => apiProfileManager.updateProfile(id, data), + deleteProfile: (id) => apiProfileManager.deleteProfile(id), + getKey: (id) => apiProfileManager.getKey(id), + setKey: (id, val) => apiProfileManager.setKey(id, val), + getAssignment: (slot) => apiProfileManager.getAssignment(slot), + setAssignment: (slot, id) => apiProfileManager.setAssignment(slot, id), + getAssignedProfile: (slot) => apiProfileManager.getAssignedProfile(slot), + SLOTS: SLOTS, + PROFILE_TYPES: PROFILE_TYPES, + }); + _ctx.log('ApiProfiles', 'info', 'ApiProfiles 服务已注册到 Bus。'); + } catch (e) { + console.error('[ApiProfiles] Bus 注册失败:', e); + } +}, 0); diff --git a/utils/config/ConfigManager.js b/utils/config/ConfigManager.js new file mode 100644 index 0000000..95461a7 --- /dev/null +++ b/utils/config/ConfigManager.js @@ -0,0 +1,155 @@ +/** + * ConfigManager — 独立配置持久化管理模块 + * + * 解决的安全问题: + * SillyTavern 的 extension_settings 会通过 saveSettingsDebounced() 上传到 ST + * 服务端 settings.json。使用三方云服务商时,服务商可读取该文件,导致所有 + * API 密钥泄露。 + * + * 解决方案: + * 敏感字段(API Key / URL)→ localStorage(浏览器本地,绝不上传) + * 非敏感字段 → extension_settings(维持原有行为) + * + * Bus 注册名:'Config' + * + * 公开接口(query('Config')): + * get(key) — 读取配置项(自动路由) + * set(key, value) — 写入配置项(自动路由 + 触发保存) + * getSettings() — 返回完整配置对象(敏感字段从 localStorage 注入) + * migrate() — 将 extension_settings 中残留的敏感字段迁移到 localStorage + */ + +import { extension_settings } from "/scripts/extensions.js"; +import { saveSettingsDebounced } from "/script.js"; +import { extensionName } from "../settings.js"; +import { SENSITIVE_KEYS } from "./sensitive-keys.js"; + +// localStorage key 前缀,避免与其他插件冲突 +const LS_PREFIX = 'amily2_secure_'; + +// ── ConfigManager ──────────────────────────────────────────────────────────── + +class ConfigManager { + + /** + * 读取配置项。 + * 敏感字段从 localStorage 读取,其余从 extension_settings 读取。 + * @param {string} key + * @returns {*} + */ + get(key) { + if (SENSITIVE_KEYS.has(key)) { + return localStorage.getItem(LS_PREFIX + key) ?? ''; + } + return extension_settings[extensionName]?.[key]; + } + + /** + * 写入配置项并持久化。 + * 敏感字段写入 localStorage(同时从 extension_settings 清除残留)。 + * 非敏感字段写入 extension_settings 并触发 saveSettingsDebounced。 + * @param {string} key + * @param {*} value + */ + set(key, value) { + if (SENSITIVE_KEYS.has(key)) { + if (value !== null && value !== undefined && value !== '') { + localStorage.setItem(LS_PREFIX + key, value); + } else { + localStorage.removeItem(LS_PREFIX + key); + } + // 确保 extension_settings 中不保留该敏感字段 + const settings = extension_settings[extensionName]; + if (settings && Object.prototype.hasOwnProperty.call(settings, key)) { + delete settings[key]; + saveSettingsDebounced(); + } + } else { + if (!extension_settings[extensionName]) { + extension_settings[extensionName] = {}; + } + extension_settings[extensionName][key] = value; + saveSettingsDebounced(); + } + } + + /** + * 返回完整配置对象(合并视图)。 + * 以 extension_settings 为基础,将 localStorage 中的敏感字段注入覆盖。 + * + * 用途:替换现有 `const settings = extension_settings[extensionName]` 的读取点, + * 使 API 调用模块能透明地获取到敏感字段,无需感知存储层差异。 + * + * @returns {Object} + */ + getSettings() { + const base = extension_settings[extensionName] ?? {}; + const result = { ...base }; + for (const key of SENSITIVE_KEYS) { + const val = localStorage.getItem(LS_PREFIX + key); + // null 表示 localStorage 中不存在,保留 base 中原值(如有) + if (val !== null) { + result[key] = val; + } + } + return result; + } + + /** + * 迁移:将 extension_settings 中已存在的敏感字段移到 localStorage。 + * + * 应在插件初始化阶段调用一次。 + * 逻辑: + * - 若 extension_settings 有值 → 迁移到 localStorage(若 localStorage 已有值则跳过,保留用户上次输入) + * - 从 extension_settings 删除该字段 + * - 最终触发一次 saveSettingsDebounced 清洗服务端 + */ + migrate() { + const settings = extension_settings[extensionName]; + if (!settings) return; + + let needsSave = false; + + for (const key of SENSITIVE_KEYS) { + const settingsVal = settings[key]; + if (settingsVal !== undefined && settingsVal !== '') { + // localStorage 中已有值时不覆盖(优先保留用户最新输入) + if (!localStorage.getItem(LS_PREFIX + key)) { + localStorage.setItem(LS_PREFIX + key, settingsVal); + console.info(`[Amily2-Config] 已迁移敏感字段 "${key}" 到本地安全存储。`); + } + delete settings[key]; + needsSave = true; + } + } + + if (needsSave) { + saveSettingsDebounced(); + console.info('[Amily2-Config] 敏感配置迁移完成,已从云同步配置中清除密钥。'); + } + } +} + +// ── 单例导出 ───────────────────────────────────────────────────────────────── +export const configManager = new ConfigManager(); + +// ── Bus 注册 ────────────────────────────────────────────────────────────────── +// setTimeout 确保 window.Amily2Bus 在 Amily2Bus.js 模块体执行后已挂载 +setTimeout(() => { + try { + const _ctx = window.Amily2Bus?.register('Config'); + if (!_ctx) { + console.warn('[Config] Amily2Bus 尚未就绪,Config 服务注册跳过。'); + return; + } + _ctx.expose({ + get: (key) => configManager.get(key), + set: (key, value) => configManager.set(key, value), + getSettings: () => configManager.getSettings(), + migrate: () => configManager.migrate(), + }); + _ctx.log('ConfigManager', 'info', 'Config 服务已注册到 Bus。'); + } catch (e) { + console.error('[Config] Bus 注册失败:', e); + } +}, 0); diff --git a/utils/config/api-key-store/ApiKeyStore.js b/utils/config/api-key-store/ApiKeyStore.js new file mode 100644 index 0000000..7aa6317 --- /dev/null +++ b/utils/config/api-key-store/ApiKeyStore.js @@ -0,0 +1,359 @@ +/** + * ApiKeyStore — 专用 API 凭证管理模块 + * + * 存储策略(用户可选): + * + * 'local'(默认) + * API Key 明文存储在 localStorage(前缀 amily2_secure_)。 + * 不随 ST 设置上传,绝对安全,但换设备需重新填写。 + * + * 'cloud' + * API Key 使用混合加密(RSA-OAEP + AES-256-GCM)后存入 extension_settings。 + * 私钥仅保存在本设备 localStorage,服务端只能看到密文。 + * 换设备时密文跟着走,但需要在新设备上重新生成密钥对并重新输入 Key。 + * 可大幅提升技术攻击成本(被动读取 settings.json 完全无效)。 + * + * 注意:API URL 不是凭证,始终存储在 extension_settings(云同步,方便多端)。 + * + * Bus 注册名:'ApiKeyStore' + * + * 公开接口(query('ApiKeyStore')): + * getKey(field) — 读取指定凭证(自动按模式解密) + * setKey(field, value) — 写入指定凭证(自动按模式加密) + * getMode() — 返回当前存储模式 'local' | 'cloud' + * setMode(mode) — 切换存储模式(会迁移现有数据) + * isCloudReady() — cloud 模式下密钥对是否已就绪 + * generateKeyPair() — 生成新密钥对(会清除旧加密数据) + * getPublicKeyInfo() — 返回公钥摘要字符串(用于 UI 展示) + * exportEncryptedBackup() — 导出加密备份(JSON,含密文+公钥,不含私钥) + */ + +import { extension_settings } from "/scripts/extensions.js"; +import { saveSettingsDebounced } from "/script.js"; +import { extensionName } from "../../settings.js"; +import { SENSITIVE_KEYS } from "../sensitive-keys.js"; +import { + generateKeyPair, + serializeKeyPair, + importPublicKey, + importPrivateKey, + encrypt, + decrypt, +} from "./crypto-utils.js"; + +// ── 存储 key 常量 ───────────────────────────────────────────────────────────── +const LS_MODE_KEY = 'amily2_keystore_mode'; // 'local' | 'cloud' +const LS_PRIVATE_KEY = 'amily2_keypair_private'; // JWK 字符串 +const LS_PLAIN_PREFIX = 'amily2_secure_'; // local 模式明文前缀 +const EXT_PUBKEY = 'amily2_pubkey'; // extension_settings 中的公钥 +const EXT_ENC_PREFIX = 'amily2_enc_'; // extension_settings 中的密文前缀 + +// ── ApiKeyStore ─────────────────────────────────────────────────────────────── + +class ApiKeyStore { + constructor() { + this._publicKey = null; // CryptoKey(运行时缓存) + this._privateKey = null; // CryptoKey(运行时缓存) + this._keyReady = false; + this._initPromise = null; + } + + // ── 初始化 ────────────────────────────────────────────────────────────── + + /** + * 异步初始化:若为 cloud 模式则加载密钥对到内存缓存。 + * 由 Bus 注册后自动调用,也可手动 await。 + */ + async init() { + if (this.getMode() === 'cloud') { + await this._loadKeyPair(); + } + } + + // ── 公开 API ──────────────────────────────────────────────────────────── + + /** 读取指定凭证字段(SENSITIVE_KEYS 内的字段) */ + async getKey(field) { + if (!SENSITIVE_KEYS.has(field)) { + console.warn(`[ApiKeyStore] "${field}" 不是凭证字段,请用 configManager.get() 读取普通配置。`); + return undefined; + } + if (this.getMode() === 'cloud') { + return this._getCloud(field); + } + return this._getLocal(field); + } + + /** 写入指定凭证字段 */ + async setKey(field, value) { + if (!SENSITIVE_KEYS.has(field)) { + console.warn(`[ApiKeyStore] "${field}" 不是凭证字段。`); + return; + } + if (this.getMode() === 'cloud') { + await this._setCloud(field, value); + } else { + this._setLocal(field, value); + } + } + + /** 当前存储模式 */ + getMode() { + return localStorage.getItem(LS_MODE_KEY) || 'local'; + } + + /** + * 切换存储模式并迁移现有数据。 + * local → cloud:读出明文 → 加密 → 写入 extension_settings → 清除 localStorage 明文 + * cloud → local:解密 → 写入 localStorage → 清除 extension_settings 密文 + * @param {'local'|'cloud'} mode + */ + async setMode(mode) { + const current = this.getMode(); + if (current === mode) return; + + if (mode === 'cloud') { + if (!this._keyReady) { + await this.generateKeyPair(); // 首次切换自动生成密钥对 + } + await this._migrateLocalToCloud(); + } else { + await this._migrateCloudToLocal(); + } + + localStorage.setItem(LS_MODE_KEY, mode); + console.info(`[ApiKeyStore] 存储模式已切换为 "${mode}"。`); + } + + /** cloud 模式下密钥对是否已就绪 */ + isCloudReady() { + return this._keyReady; + } + + /** + * 按任意 ID 存储凭证(供 ApiProfileManager 使用,key = `profile_`)。 + * 走与 setKey 相同的加密路由。 + */ + async storeById(id, value) { + const field = `profile_${id}`; + if (this.getMode() === 'cloud') { + await this._setCloud(field, value); + } else { + this._setLocal(field, value); + } + } + + /** + * 按任意 ID 读取凭证(供 ApiProfileManager 使用)。 + */ + async retrieveById(id) { + const field = `profile_${id}`; + if (this.getMode() === 'cloud') { + return this._getCloud(field); + } + return this._getLocal(field); + } + + /** + * 删除指定 ID 的凭证(Profile 删除时调用)。 + */ + deleteById(id) { + const field = `profile_${id}`; + localStorage.removeItem(LS_PLAIN_PREFIX + field); + const settings = extension_settings[extensionName]; + if (settings?.[EXT_ENC_PREFIX + field]) { + delete settings[EXT_ENC_PREFIX + field]; + saveSettingsDebounced(); + } + } + + /** + * 生成新的 RSA 密钥对。 + * 警告:会清除所有已加密的 cloud 模式凭证(旧私钥无法解密)。 + */ + async generateKeyPair() { + const keyPair = await generateKeyPair(); + const { publicJwk, privateJwk } = await serializeKeyPair(keyPair); + + // 清除旧密文(旧私钥无法解密新密钥对加密的内容) + this._clearAllCloudCiphers(); + + // 私钥存 localStorage,公钥存 extension_settings(公钥不需要保密) + localStorage.setItem(LS_PRIVATE_KEY, privateJwk); + const settings = extension_settings[extensionName] || {}; + settings[EXT_PUBKEY] = publicJwk; + saveSettingsDebounced(); + + // 更新运行时缓存 + this._publicKey = await importPublicKey(publicJwk); + this._privateKey = await importPrivateKey(privateJwk); + this._keyReady = true; + + console.info('[ApiKeyStore] 新密钥对已生成。请重新输入所有 API Key。'); + } + + /** + * 返回公钥的简短指纹(SHA-256 前 8 字节,Base64),用于 UI 展示。 + * @returns {Promise} + */ + async getPublicKeyInfo() { + const jwkStr = extension_settings[extensionName]?.[EXT_PUBKEY]; + if (!jwkStr) return '(未生成)'; + const jwk = JSON.parse(jwkStr); + const raw = new TextEncoder().encode(jwk.n); // RSA modulus + const hash = await crypto.subtle.digest('SHA-256', raw); + const hex = Array.from(new Uint8Array(hash)).slice(0, 8) + .map(b => b.toString(16).padStart(2, '0')).join(':'); + return `RSA-2048 · ${hex}`; + } + + /** + * 导出可备份的加密摘要(包含公钥 + 所有密文,不含私钥)。 + * 仅供参考,不能用于在新设备上恢复(因为私钥不在其中)。 + * @returns {Object} + */ + exportEncryptedBackup() { + const settings = extension_settings[extensionName] || {}; + const backup = { publicKey: settings[EXT_PUBKEY], encrypted: {} }; + for (const field of SENSITIVE_KEYS) { + const cipher = settings[EXT_ENC_PREFIX + field]; + if (cipher) backup.encrypted[field] = cipher; + } + return backup; + } + + // ── 内部:local 模式 ──────────────────────────────────────────────────── + + _getLocal(field) { + return localStorage.getItem(LS_PLAIN_PREFIX + field) ?? ''; + } + + _setLocal(field, value) { + if (value !== null && value !== undefined && value !== '') { + localStorage.setItem(LS_PLAIN_PREFIX + field, value); + } else { + localStorage.removeItem(LS_PLAIN_PREFIX + field); + } + } + + // ── 内部:cloud 模式 ──────────────────────────────────────────────────── + + async _getCloud(field) { + if (!this._keyReady) { + console.warn('[ApiKeyStore] cloud 模式密钥未就绪,无法解密。'); + return ''; + } + const cipher = extension_settings[extensionName]?.[EXT_ENC_PREFIX + field]; + if (!cipher) return ''; + try { + return await decrypt(this._privateKey, cipher); + } catch (e) { + console.error(`[ApiKeyStore] 解密 "${field}" 失败(私钥不匹配?):`, e); + return ''; + } + } + + async _setCloud(field, value) { + if (!this._keyReady) { + console.warn('[ApiKeyStore] cloud 模式密钥未就绪,无法加密。'); + return; + } + const settings = extension_settings[extensionName] || {}; + if (value !== null && value !== undefined && value !== '') { + settings[EXT_ENC_PREFIX + field] = await encrypt(this._publicKey, value); + } else { + delete settings[EXT_ENC_PREFIX + field]; + } + saveSettingsDebounced(); + } + + // ── 内部:迁移 ────────────────────────────────────────────────────────── + + async _migrateLocalToCloud() { + for (const field of SENSITIVE_KEYS) { + const plain = this._getLocal(field); + if (plain) { + await this._setCloud(field, plain); + localStorage.removeItem(LS_PLAIN_PREFIX + field); + console.info(`[ApiKeyStore] "${field}" 已加密迁移至云同步。`); + } + } + } + + async _migrateCloudToLocal() { + for (const field of SENSITIVE_KEYS) { + const plain = await this._getCloud(field); + if (plain) { + this._setLocal(field, plain); + console.info(`[ApiKeyStore] "${field}" 已解密迁移至本地存储。`); + } + } + this._clearAllCloudCiphers(); + } + + _clearAllCloudCiphers() { + const settings = extension_settings[extensionName]; + if (!settings) return; + let changed = false; + for (const field of SENSITIVE_KEYS) { + if (settings[EXT_ENC_PREFIX + field]) { + delete settings[EXT_ENC_PREFIX + field]; + changed = true; + } + } + if (changed) saveSettingsDebounced(); + } + + // ── 内部:密钥加载 ────────────────────────────────────────────────────── + + async _loadKeyPair() { + const privateJwk = localStorage.getItem(LS_PRIVATE_KEY); + const publicJwk = extension_settings[extensionName]?.[EXT_PUBKEY]; + + if (!privateJwk || !publicJwk) { + console.warn('[ApiKeyStore] cloud 模式:本地未找到密钥对,请生成新密钥对。'); + this._keyReady = false; + return; + } + + try { + this._privateKey = await importPrivateKey(privateJwk); + this._publicKey = await importPublicKey(publicJwk); + this._keyReady = true; + console.info('[ApiKeyStore] cloud 模式密钥对已加载。'); + } catch (e) { + console.error('[ApiKeyStore] 密钥对加载失败(数据损坏?):', e); + this._keyReady = false; + } + } +} + +// ── 单例导出 ───────────────────────────────────────────────────────────────── +export const apiKeyStore = new ApiKeyStore(); + +// ── Bus 注册 ────────────────────────────────────────────────────────────────── +setTimeout(async () => { + try { + // 先初始化(cloud 模式下加载密钥) + await apiKeyStore.init(); + + const _ctx = window.Amily2Bus?.register('ApiKeyStore'); + if (!_ctx) { + console.warn('[ApiKeyStore] Amily2Bus 尚未就绪,注册跳过。'); + return; + } + _ctx.expose({ + getKey: (field) => apiKeyStore.getKey(field), + setKey: (field, value) => apiKeyStore.setKey(field, value), + getMode: () => apiKeyStore.getMode(), + setMode: (mode) => apiKeyStore.setMode(mode), + isCloudReady: () => apiKeyStore.isCloudReady(), + generateKeyPair: () => apiKeyStore.generateKeyPair(), + getPublicKeyInfo: () => apiKeyStore.getPublicKeyInfo(), + exportEncryptedBackup: () => apiKeyStore.exportEncryptedBackup(), + }); + _ctx.log('ApiKeyStore', 'info', 'ApiKeyStore 服务已注册到 Bus。'); + } catch (e) { + console.error('[ApiKeyStore] 初始化失败:', e); + } +}, 0); diff --git a/utils/config/api-key-store/crypto-utils.js b/utils/config/api-key-store/crypto-utils.js new file mode 100644 index 0000000..57160c3 --- /dev/null +++ b/utils/config/api-key-store/crypto-utils.js @@ -0,0 +1,174 @@ +/** + * crypto-utils.js — Web Crypto API 封装 + * + * 使用混合加密方案(Hybrid Encryption): + * - RSA-OAEP 2048 负责密钥交换(加密 AES 密钥) + * - AES-256-GCM 负责实际数据加密 + * + * 优势: + * - RSA 部分无明文长度限制(AES 密钥固定 32 字节,远小于 RSA 上限) + * - AES-GCM 提供认证加密(AEAD),防止密文篡改 + * - 全程使用 Web Crypto API,密钥操作不经过 JS 内存(SubtleCrypto 内部实现) + */ + +// ── 密钥对生成与导入导出 ──────────────────────────────────────────────────── + +/** + * 生成 RSA-OAEP 2048 密钥对。 + * 返回 { publicKey, privateKey }(均为 CryptoKey 对象) + */ +export async function generateKeyPair() { + return crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537 + hash: 'SHA-256', + }, + true, // extractable = true,以便序列化存储 + ['encrypt', 'decrypt'] + ); +} + +/** + * 将密钥对序列化为 JWK 字符串,以便存储。 + * @param {CryptoKeyPair} keyPair + * @returns {Promise<{ publicJwk: string, privateJwk: string }>} + */ +export async function serializeKeyPair(keyPair) { + const [publicJwk, privateJwk] = await Promise.all([ + crypto.subtle.exportKey('jwk', keyPair.publicKey), + crypto.subtle.exportKey('jwk', keyPair.privateKey), + ]); + return { + publicJwk: JSON.stringify(publicJwk), + privateJwk: JSON.stringify(privateJwk), + }; +} + +/** + * 从 JWK 字符串恢复公钥(用于加密)。 + * @param {string} jwkString + * @returns {Promise} + */ +export async function importPublicKey(jwkString) { + return crypto.subtle.importKey( + 'jwk', + JSON.parse(jwkString), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, // 不需要再次导出 + ['encrypt'] + ); +} + +/** + * 从 JWK 字符串恢复私钥(用于解密)。 + * @param {string} jwkString + * @returns {Promise} + */ +export async function importPrivateKey(jwkString) { + return crypto.subtle.importKey( + 'jwk', + JSON.parse(jwkString), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['decrypt'] + ); +} + +// ── 混合加密 / 解密 ────────────────────────────────────────────────────────── + +/** + * 混合加密:RSA-OAEP 包装 AES-256-GCM 密钥,AES-GCM 加密明文。 + * + * 返回的密文包 JSON 结构: + * { + * wrappedKey: "", // RSA 加密的 AES 密钥 + * iv: "", // AES-GCM 随机 IV(12 字节) + * ciphertext: "", // AES-GCM 密文(含 GCM tag) + * } + * + * @param {CryptoKey} publicKey RSA 公钥 + * @param {string} plaintext 明文字符串 + * @returns {Promise} 序列化的密文包(JSON 字符串) + */ +export async function encrypt(publicKey, plaintext) { + // 1. 生成一次性 AES-256-GCM 密钥 + const aesKey = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt'] + ); + + // 2. 生成随机 IV(12 字节是 GCM 的推荐长度) + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // 3. 用 AES-GCM 加密明文 + const plainBytes = new TextEncoder().encode(plaintext); + const ciphertextBuffer = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + aesKey, + plainBytes + ); + + // 4. 导出 AES 原始密钥字节,用 RSA 公钥包装 + const rawAesKey = await crypto.subtle.exportKey('raw', aesKey); + const wrappedKeyBuffer = await crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + publicKey, + rawAesKey + ); + + // 5. 序列化为 base64 JSON 包 + return JSON.stringify({ + wrappedKey: bufToBase64(wrappedKeyBuffer), + iv: bufToBase64(iv), + ciphertext: bufToBase64(ciphertextBuffer), + }); +} + +/** + * 混合解密:用 RSA 私钥解出 AES 密钥,再用 AES-GCM 解密密文。 + * + * @param {CryptoKey} privateKey RSA 私钥 + * @param {string} payload encrypt() 返回的 JSON 字符串 + * @returns {Promise} 原始明文字符串 + */ +export async function decrypt(privateKey, payload) { + const { wrappedKey, iv, ciphertext } = JSON.parse(payload); + + // 1. RSA 解出 AES 密钥字节 + const rawAesKey = await crypto.subtle.decrypt( + { name: 'RSA-OAEP' }, + privateKey, + base64ToBuf(wrappedKey) + ); + + // 2. 恢复 AES 密钥对象(只用于解密) + const aesKey = await crypto.subtle.importKey( + 'raw', + rawAesKey, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + + // 3. AES-GCM 解密 + const plainBuffer = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBuf(iv) }, + aesKey, + base64ToBuf(ciphertext) + ); + + return new TextDecoder().decode(plainBuffer); +} + +// ── 工具函数 ───────────────────────────────────────────────────────────────── + +function bufToBase64(buffer) { + return btoa(String.fromCharCode(...new Uint8Array(buffer))); +} + +function base64ToBuf(base64) { + return Uint8Array.from(atob(base64), c => c.charCodeAt(0)); +} diff --git a/utils/config/sensitive-keys.js b/utils/config/sensitive-keys.js new file mode 100644 index 0000000..9b39bb0 --- /dev/null +++ b/utils/config/sensitive-keys.js @@ -0,0 +1,17 @@ +/** + * 敏感配置字段清单(仅 API Key 类凭证) + * + * 只有真正的凭证(API Key)需要保护。 + * API URL 不是凭证——没有 Key 拿到 URL 也无法调用,且 URL 云同步方便多端使用。 + * + * 这些字段将被 ConfigManager / ApiKeyStore 路由到安全存储, + * 而不是 extension_settings(后者会被 saveSettingsDebounced 上传到 ST 服务端)。 + */ +export const SENSITIVE_KEYS = new Set([ + 'apiKey', + 'plotOpt_concurrentApiKey', + 'ngmsApiKey', + 'nccsApiKey', + 'jqyhApiKey', + 'cwb_api_key', +]);