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
This commit is contained in:
2026-03-10 22:07:15 +08:00
parent ed3f52a568
commit 0be6a86e94
17 changed files with 1970 additions and 110 deletions

View File

@@ -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: {

View File

@@ -1,60 +1,259 @@
/**
* 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<any>} 存储的数据,不存在时返回 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<boolean>}
*/
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<boolean>}
*/
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<string[]>}
*/
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<number>} 删除的文件数量
*/
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),
};
}
}

View File

@@ -235,6 +235,13 @@
</div>
</fieldset>
<fieldset class="settings-group">
<legend><i class="fas fa-shield-alt"></i> 系统配置</legend>
<div class="button-group" style="display: flex; justify-content: space-between; gap: 8px;">
<button id="amily2_open_api_config" class="menu_button wide_button"><i class="fas fa-key"></i> API 连接配置</button>
</div>
</fieldset>
<hr class="header-divider">

View File

@@ -0,0 +1,176 @@
<div class="amily2-header">
<div class="additional-features-title">
<i class="fas fa-key"></i> API 连接配置
</div>
<button id="amily2_back_to_main_from_api_config" class="menu_button secondary small_button interactable">
返回主殿 <i class="fas fa-arrow-right"></i>
</button>
</div>
<hr class="header-divider" style="margin-top: 5px; margin-bottom: 10px;">
<!-- 存储模式 -->
<fieldset class="settings-group">
<legend><i class="fas fa-shield-alt"></i> 密钥存储模式</legend>
<div class="control-pair-container" style="align-items: center; gap: 12px;">
<div class="amily2_settings_block" style="flex: 1;">
<label for="amily2_keystore_mode">存储方式</label>
<select id="amily2_keystore_mode" class="text_pole">
<option value="local">本地存储(推荐)</option>
<option value="cloud">加密云同步</option>
</select>
<small class="notes" id="amily2_keystore_mode_note">
本地存储API Key 仅存于本设备浏览器,绝不上传。换设备需重新填写。
</small>
</div>
<div class="amily2_settings_block" id="amily2_cloud_key_section" style="display:none; flex: 1;">
<label>当前密钥对指纹</label>
<div style="display:flex; gap:6px; align-items:center;">
<code id="amily2_keypair_fingerprint" style="flex:1; padding:4px 8px; background:var(--black30a); border-radius:4px; font-size:0.85em;">(未生成)</code>
<button id="amily2_generate_keypair" class="menu_button interactable small_button" title="生成新密钥对(会清除所有已加密的 Key">
<i class="fas fa-sync-alt"></i> 重新生成
</button>
</div>
<small class="notes" style="color: var(--warning-color);">
⚠️ 重新生成密钥对后,所有已加密存储的 API Key 将失效,需重新输入。
</small>
</div>
</div>
</fieldset>
<!-- Profile 列表 -->
<fieldset class="settings-group">
<legend><i class="fas fa-server"></i> 连接配置列表</legend>
<!-- 类型筛选标签 -->
<div style="display:flex; gap:6px; margin-bottom:10px; flex-wrap:wrap;">
<button class="menu_button small_button amily2_profile_type_filter active" data-type="all">全部</button>
<button class="menu_button small_button amily2_profile_type_filter" data-type="chat">
<i class="fas fa-comments"></i> 对话模型
</button>
<button class="menu_button small_button amily2_profile_type_filter" data-type="embedding">
<i class="fas fa-project-diagram"></i> 向量嵌入
</button>
<button class="menu_button small_button amily2_profile_type_filter" data-type="rerank">
<i class="fas fa-sort-amount-down"></i> 重排序
</button>
<button id="amily2_add_profile" class="menu_button small_button interactable" style="margin-left:auto;">
<i class="fas fa-plus"></i> 新建配置
</button>
</div>
<!-- Profile 卡片列表 -->
<div id="amily2_profile_list" style="display:flex; flex-direction:column; gap:8px;">
<!-- 由 JS 动态渲染 -->
<div class="amily2_profile_empty" style="color:var(--SmartThemeQuoteColor); text-align:center; padding:20px;">
暂无连接配置,点击「新建配置」添加。
</div>
</div>
</fieldset>
<!-- 功能槽分配 -->
<fieldset class="settings-group">
<legend><i class="fas fa-plug"></i> 功能分配</legend>
<small class="notes" style="display:block; margin-bottom:10px;">
为每个系统功能指定使用的连接配置。选单只会显示类型匹配的配置。
</small>
<div id="amily2_slot_assignments" style="display:flex; flex-direction:column; gap:6px;">
<!-- 由 JS 动态渲染 -->
</div>
</fieldset>
<!-- 新建/编辑 Profile 弹窗 -->
<div id="amily2_profile_modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:9999; align-items:center; justify-content:center;">
<div style="background:var(--SmartThemeBlurTintColor); border:1px solid var(--SmartThemeBorderColor); border-radius:8px; padding:20px; width:min(480px,92vw); max-height:85vh; overflow-y:auto;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:14px;">
<strong id="amily2_profile_modal_title"><i class="fas fa-key"></i> 新建连接配置</strong>
<button id="amily2_profile_modal_close" class="menu_button small_button secondary interactable"></button>
</div>
<!-- 类型选择 -->
<div class="amily2_settings_block">
<label for="amily2_pf_type">配置类型</label>
<select id="amily2_pf_type" class="text_pole">
<option value="chat">对话模型Chat</option>
<option value="embedding">向量嵌入Embedding</option>
<option value="rerank">重排序Rerank</option>
</select>
</div>
<!-- 基础字段 -->
<div class="amily2_settings_block">
<label for="amily2_pf_name">配置名称</label>
<input id="amily2_pf_name" type="text" class="text_pole" placeholder="例如:我的 DeepSeek" />
</div>
<div class="amily2_settings_block">
<label for="amily2_pf_provider">API 提供商</label>
<select id="amily2_pf_provider" class="text_pole">
<option value="openai">OpenAI 兼容</option>
<option value="openai_test">实验性全兼容</option>
<option value="google">Google 直连</option>
<option value="sillytavern_backend">SillyTavern 后端</option>
<option value="sillytavern_preset">SillyTavern 预设</option>
</select>
</div>
<div class="amily2_settings_block">
<label for="amily2_pf_url">API URL</label>
<input id="amily2_pf_url" type="text" class="text_pole" placeholder="https://api.example.com/v1" />
</div>
<div class="amily2_settings_block">
<label for="amily2_pf_key">API Key <span style="color:var(--SmartThemeQuoteColor); font-size:0.85em;">(加密存储)</span></label>
<input id="amily2_pf_key" type="password" class="text_pole" placeholder="sk-..." autocomplete="off" />
<small class="notes">留空则不修改现有 Key。</small>
</div>
<div class="amily2_settings_block">
<label for="amily2_pf_model">模型 ID</label>
<input id="amily2_pf_model" type="text" class="text_pole" placeholder="例如deepseek-r1-250528" />
</div>
<!-- Chat 专有参数 -->
<div id="amily2_pf_chat_params">
<div class="amily2_settings_block">
<label for="amily2_pf_max_tokens">最大 Token 数</label>
<input id="amily2_pf_max_tokens" type="number" class="text_pole" min="100" max="200000" value="65500" />
</div>
<div class="amily2_settings_block">
<label for="amily2_pf_temperature">温度Temperature</label>
<input id="amily2_pf_temperature" type="number" class="text_pole" min="0" max="2" step="0.1" value="1.0" />
</div>
</div>
<!-- Embedding 专有参数 -->
<div id="amily2_pf_embedding_params" style="display:none;">
<div class="amily2_settings_block">
<label for="amily2_pf_dimensions">输出维度 <span style="color:var(--SmartThemeQuoteColor); font-size:0.85em;">(留空=模型默认)</span></label>
<input id="amily2_pf_dimensions" type="number" class="text_pole" min="1" placeholder="例如1536" />
</div>
<div class="amily2_settings_block">
<label for="amily2_pf_encoding_format">编码格式</label>
<select id="amily2_pf_encoding_format" class="text_pole">
<option value="float">float默认</option>
<option value="base64">base64</option>
</select>
</div>
</div>
<!-- Rerank 专有参数 -->
<div id="amily2_pf_rerank_params" style="display:none;">
<div class="amily2_settings_block">
<label for="amily2_pf_top_n">返回结果数量Top N</label>
<input id="amily2_pf_top_n" type="number" class="text_pole" min="1" max="100" value="5" />
</div>
<div class="amily2_settings_block" style="flex-direction:row; align-items:center; gap:8px;">
<input id="amily2_pf_return_documents" type="checkbox" />
<label for="amily2_pf_return_documents">返回原始文档内容</label>
</div>
</div>
<!-- 操作按钮 -->
<div style="display:flex; gap:8px; margin-top:16px; justify-content:flex-end;">
<button id="amily2_profile_modal_cancel" class="menu_button secondary interactable">取消</button>
<button id="amily2_profile_modal_save" class="menu_button interactable">
<i class="fas fa-save"></i> 保存
</button>
</div>
</div>
</div>

View File

@@ -1,72 +1,16 @@
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'));
@@ -84,6 +28,7 @@ export async function onMessageReceived(data) {
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 () => {

View File

@@ -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 };

View File

@@ -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></i>\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)]='<i\x20class=\x22'+_0x483576[_0x4de68c]+_0x17dbe1(0x1d8)+_0x1e7922,_0x4fdf31[_0x17dbe1(0x1d0)](_0x5bed08),_0x4fdf31['scrollTop']=_0x4fdf31[_0x17dbe1(0x1c9)];}
const getLogContainer = () => 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;
}

View File

@@ -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';

View File

@@ -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;

348
ui/api-config-bindings.js Normal file
View File

@@ -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 IDnull = 新建)
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('<div class="amily2_profile_empty" style="color:var(--SmartThemeQuoteColor);text-align:center;padding:20px;">暂无连接配置,点击「新建配置」添加。</div>');
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 `
<div class="amily2_profile_card" data-id="${p.id}" style="
display:flex; align-items:center; gap:10px;
padding:8px 12px;
background:var(--black10a);
border:1px solid var(--SmartThemeBorderColor);
border-radius:6px;">
<i class="fas ${typeInfo.icon}" style="width:16px; color:var(--SmartThemeQuoteColor);"></i>
<div style="flex:1; min-width:0;">
<div style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${_escapeHtml(p.name)}</div>
<div style="font-size:0.82em; color:var(--SmartThemeQuoteColor); margin-top:2px;">
<span style="${badgeStyle}"><i class="fas ${typeInfo.icon}"></i> ${typeInfo.label}</span>
<span style="margin-left:6px;">${_escapeHtml(p.model || '(未设置模型)')}</span>
${p.apiUrl ? `<span style="margin-left:6px; opacity:0.7;">${_escapeHtml(_truncateUrl(p.apiUrl))}</span>` : ''}
</div>
</div>
<div style="display:flex; gap:4px; flex-shrink:0;">
<button class="menu_button small_button interactable amily2_edit_profile" data-id="${p.id}" title="编辑">
<i class="fas fa-edit"></i>
</button>
<button class="menu_button small_button secondary interactable amily2_delete_profile" data-id="${p.id}" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</div>`;
}).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 = [
`<option value="">— 未分配 —</option>`,
...profiles.map(p =>
`<option value="${p.id}" ${p.id === assigned ? 'selected' : ''}>${_escapeHtml(p.name)}</option>`
),
].join('');
return `
<div style="display:flex; align-items:center; gap:8px; padding:4px 0;">
<span style="width:160px; flex-shrink:0; font-size:0.9em;">${slotInfo.label}</span>
<span style="color:var(--SmartThemeQuoteColor); font-size:0.78em; width:70px; flex-shrink:0;">
<i class="fas ${typeInfo.icon}"></i> ${typeInfo.label}
</span>
<select class="text_pole amily2_slot_select" data-slot="${slot}" style="flex:1;">
${options}
</select>
</div>`;
}).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('<i class="fas fa-edit"></i> 编辑连接配置');
$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('<i class="fas fa-plus"></i> 新建连接配置');
$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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -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;
}

View File

@@ -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 = `<div id="amily2_super_memory_panel" style="display: none;">${superMemoryContent}</div>`;
mainContainer.append(superMemoryPanelHtml);
const apiConfigContent = await $.get(`${extensionFolderPath}/assets/api-config-panel.html`);
const apiConfigPanelHtml = `<div id="amily2_api_config_panel" style="display: none;">${apiConfigContent}</div>`;
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();

View File

@@ -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 → ApiKeyStorelocal 或 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 元数据(不更新 KeyKey 用 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 IDnull = 未分配) */
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<Object|null>}
*/
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);

View File

@@ -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);

View File

@@ -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_<id>`)。
* 走与 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<string>}
*/
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);

View File

@@ -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<CryptoKey>}
*/
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<CryptoKey>}
*/
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: "<base64>", // RSA 加密的 AES 密钥
* iv: "<base64>", // AES-GCM 随机 IV12 字节)
* ciphertext: "<base64>", // AES-GCM 密文(含 GCM tag
* }
*
* @param {CryptoKey} publicKey RSA 公钥
* @param {string} plaintext 明文字符串
* @returns {Promise<string>} 序列化的密文包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. 生成随机 IV12 字节是 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<string>} 原始明文字符串
*/
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));
}

View File

@@ -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',
]);