mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-06 10:25:51 +00:00
Compare commits
4 Commits
bddda1802f
...
SL-Dev-260
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c64718391 | |||
| 5a6a8b205c | |||
| 153c0616d2 | |||
| 0be6a86e94 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -0,0 +1,2 @@
|
|||||||
|
WorkDiary.md
|
||||||
|
Structure.md
|
||||||
|
|||||||
@@ -124,15 +124,17 @@ class Amily2Bus {
|
|||||||
// 1. 日志能力 (绑定了身份的日志接口)
|
// 1. 日志能力 (绑定了身份的日志接口)
|
||||||
log: (origin, type, message) => this.Logger.log(pluginName, origin, type, message),
|
log: (origin, type, message) => this.Logger.log(pluginName, origin, type, message),
|
||||||
|
|
||||||
// 2. 文件能力 (绑定了身份的文件接口)
|
// 2. 文件能力 (绑定了插件身份的文件接口,后端为 IndexedDB)
|
||||||
file: {
|
file: this.FilePipe
|
||||||
read: (path) => {
|
? this.FilePipe.forPlugin(pluginName)
|
||||||
return this.FilePipe ? this.FilePipe.read(pluginName, path) : null;
|
: {
|
||||||
|
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)
|
// 3. 网络能力 (ModelCaller)
|
||||||
model: {
|
model: {
|
||||||
|
|||||||
@@ -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 {
|
class FilePipe {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.name = "FilePipe";
|
this.name = 'FilePipe';
|
||||||
// 模拟的根存储路径,实际环境可能对应 IndexedDB 的 Store Name 或 Node 的 baseDir
|
|
||||||
this.basePath = "/virtual_fs/";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── 安全路径校验 ─────────────────────────────────────────────────────────
|
||||||
* 安全路径解析与校验
|
|
||||||
* @param {string} plugin 插件名称(命名空间)
|
_safePath(plugin, path) {
|
||||||
* @param {string} relativePath 相对路径
|
|
||||||
* @returns {string|null} 合法的绝对路径,如果违规则返回 null
|
|
||||||
*/
|
|
||||||
_resolvePath(plugin, relativePath) {
|
|
||||||
if (!plugin || typeof plugin !== 'string') {
|
if (!plugin || typeof plugin !== 'string') {
|
||||||
console.error(`[FilePipe] Security Error: Invalid plugin identity.`);
|
console.error('[FilePipe] 无效的插件标识。');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!path || typeof path !== 'string') {
|
||||||
// 简单防越权:禁止包含 ".."
|
console.error('[FilePipe] 无效的路径。');
|
||||||
if (relativePath.includes('..')) {
|
|
||||||
console.error(`[FilePipe] Security Error: Directory traversal attempt blocked for plugin '${plugin}'. Path: ${relativePath}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (path.includes('..')) {
|
||||||
// 强制限定在插件目录下
|
console.error(`[FilePipe] 安全拦截:插件 "${plugin}" 尝试目录穿越,路径: ${path}`);
|
||||||
// 格式: /virtual_fs/PluginName/filename
|
return null;
|
||||||
return `${this.basePath}${plugin}/${relativePath}`;
|
}
|
||||||
|
// 规范化:去掉开头的斜杠
|
||||||
|
return path.replace(/^\/+/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 公开 API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取文件
|
* 读取文件。
|
||||||
* @param {string} plugin 调用方插件名
|
* @param {string} plugin 插件名(命名空间)
|
||||||
* @param {string} path 文件相对路径
|
* @param {string} path 文件路径(相对于插件根目录)
|
||||||
|
* @returns {Promise<any>} 存储的数据,不存在时返回 null
|
||||||
*/
|
*/
|
||||||
async read(plugin, path) {
|
async read(plugin, path) {
|
||||||
const safePath = this._resolvePath(plugin, path);
|
const safePath = this._safePath(plugin, path);
|
||||||
if (!safePath) return null;
|
if (!safePath) return null;
|
||||||
|
|
||||||
console.log(`[FilePipe] Reading from: ${safePath}`);
|
try {
|
||||||
// TODO: Implement actual file reading logic
|
const db = await _openDB();
|
||||||
return null;
|
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} plugin 插件名
|
||||||
* @param {string} path 文件相对路径
|
* @param {string} path 文件路径
|
||||||
* @param {any} data 数据
|
* @param {any} data 任意可序列化数据(对象、字符串、ArrayBuffer 等)
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async write(plugin, path, data) {
|
async write(plugin, path, data) {
|
||||||
const safePath = this._resolvePath(plugin, path);
|
const safePath = this._safePath(plugin, path);
|
||||||
if (!safePath) return false;
|
if (!safePath) return false;
|
||||||
|
|
||||||
console.log(`[FilePipe] Writing to: ${safePath}`);
|
try {
|
||||||
// TODO: Implement actual file writing logic
|
const db = await _openDB();
|
||||||
return true;
|
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),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FilePipe;
|
export default FilePipe;
|
||||||
|
|||||||
@@ -235,6 +235,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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">
|
<hr class="header-divider">
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
219
assets/api-config-panel.html
Normal file
219
assets/api-config-panel.html
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<div id="amily2_profile_list" style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<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;">
|
||||||
|
</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(500px,94vw); max-height:88vh; 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">接口类型</label>
|
||||||
|
<select id="amily2_pf_provider" class="text_pole">
|
||||||
|
<option value="openai">OpenAI / 兼容接口(推荐)</option>
|
||||||
|
<option value="google">Google Gemini 直连</option>
|
||||||
|
<option value="sillytavern_backend">SillyTavern 后端代理</option>
|
||||||
|
<option value="sillytavern_preset">SillyTavern 预设转发</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="amily2_settings_block" id="amily2_pf_url_row">
|
||||||
|
<label for="amily2_pf_url">API 地址</label>
|
||||||
|
<input id="amily2_pf_url" type="text" class="text_pole" placeholder="https://api.example.com/v1" />
|
||||||
|
</div>
|
||||||
|
<!-- Google 专属提示(选 Google 时显示) -->
|
||||||
|
<div id="amily2_pf_google_note" style="display:none; margin-bottom:8px;">
|
||||||
|
<small class="notes" style="display:block; padding:6px 10px; background:var(--black10a); border-radius:4px; border-left:3px solid #4285f4;">
|
||||||
|
<i class="fas fa-info-circle" style="color:#4285f4;"></i>
|
||||||
|
Google AI Studio — 接口地址已自动配置,只需填写 API Key 即可。
|
||||||
|
在 <a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener" style="color:#4285f4;">aistudio.google.com</a> 生成密钥。
|
||||||
|
</small>
|
||||||
|
</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">模型</label>
|
||||||
|
<div style="display:flex; gap:6px; align-items:stretch;">
|
||||||
|
<input id="amily2_pf_model" type="text" class="text_pole"
|
||||||
|
list="amily2_pf_model_list"
|
||||||
|
placeholder="手动填写或点击「获取」"
|
||||||
|
style="flex:1;" />
|
||||||
|
<datalist id="amily2_pf_model_list"></datalist>
|
||||||
|
<button id="amily2_pf_fetch_models" class="menu_button small_button interactable" type="button" title="从 API 获取可用模型列表(需先填写地址和 Key)">
|
||||||
|
<i class="fas fa-list"></i> 获取
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 测试连接 -->
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:10px;">
|
||||||
|
<button id="amily2_pf_test_conn" class="menu_button small_button interactable" type="button">
|
||||||
|
<i class="fas fa-plug"></i> 测试连接
|
||||||
|
</button>
|
||||||
|
<span id="amily2_pf_test_result" style="font-size:0.85em;"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat 高级参数(折叠) -->
|
||||||
|
<div id="amily2_pf_chat_params">
|
||||||
|
<details class="amily2_advanced_section" style="margin-top:4px;">
|
||||||
|
<summary style="cursor:pointer; font-size:0.88em; color:var(--SmartThemeQuoteColor); user-select:none; padding:4px 0;">
|
||||||
|
<i class="fas fa-sliders-h"></i> 高级参数
|
||||||
|
</summary>
|
||||||
|
<div style="padding-top:8px;">
|
||||||
|
<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>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Embedding 高级参数(折叠) -->
|
||||||
|
<div id="amily2_pf_embedding_params" style="display:none;">
|
||||||
|
<details class="amily2_advanced_section" style="margin-top:4px;">
|
||||||
|
<summary style="cursor:pointer; font-size:0.88em; color:var(--SmartThemeQuoteColor); user-select:none; padding:4px 0;">
|
||||||
|
<i class="fas fa-sliders-h"></i> 高级参数
|
||||||
|
</summary>
|
||||||
|
<div style="padding-top:8px;">
|
||||||
|
<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>
|
||||||
|
</details>
|
||||||
|
</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>
|
||||||
|
<details class="amily2_advanced_section" style="margin-top:4px;">
|
||||||
|
<summary style="cursor:pointer; font-size:0.88em; color:var(--SmartThemeQuoteColor); user-select:none; padding:4px 0;">
|
||||||
|
<i class="fas fa-sliders-h"></i> 高级参数
|
||||||
|
</summary>
|
||||||
|
<div style="padding-top:8px;">
|
||||||
|
<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>
|
||||||
|
</details>
|
||||||
|
</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>
|
||||||
44
core/api.js
44
core/api.js
@@ -1,5 +1,6 @@
|
|||||||
import { extension_settings, getContext } from "/scripts/extensions.js";
|
import { extension_settings, getContext } from "/scripts/extensions.js";
|
||||||
import { characters } from "/script.js";
|
import { characters } from "/script.js";
|
||||||
|
import { getSlotProfile } from './api/api-resolver.js';
|
||||||
import { world_names } from "/scripts/world-info.js";
|
import { world_names } from "/scripts/world-info.js";
|
||||||
import { extensionName } from "../utils/settings.js";
|
import { extensionName } from "../utils/settings.js";
|
||||||
import { extractContentByTag, replaceContentByTag, extractFullTagBlock } from '../utils/tagProcessor.js';
|
import { extractContentByTag, replaceContentByTag, extractFullTagBlock } from '../utils/tagProcessor.js';
|
||||||
@@ -433,28 +434,43 @@ async function fetchSillyTavernPresetModels() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function getApiSettings() {
|
export async function getApiSettings() {
|
||||||
|
// 优先读取 'main' 槽位分配的 Profile
|
||||||
|
const profile = await getSlotProfile('main');
|
||||||
|
if (profile) {
|
||||||
|
return {
|
||||||
|
apiProvider: profile.provider,
|
||||||
|
apiUrl: profile.apiUrl,
|
||||||
|
apiKey: profile.apiKey ?? '',
|
||||||
|
model: profile.model,
|
||||||
|
maxTokens: profile.maxTokens ?? 65500,
|
||||||
|
temperature: profile.temperature ?? 1.0,
|
||||||
|
tavernProfile: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级:读旧 DOM 面板配置
|
||||||
const settings = extension_settings[extensionName] || {};
|
const settings = extension_settings[extensionName] || {};
|
||||||
const apiProvider = document.getElementById('amily2_api_provider')?.value || 'openai';
|
const apiProvider = document.getElementById('amily2_api_provider')?.value || 'openai';
|
||||||
|
|
||||||
let model;
|
let model;
|
||||||
if (apiProvider === 'sillytavern_preset') {
|
if (apiProvider === 'sillytavern_preset') {
|
||||||
const context = getContext();
|
const context = getContext();
|
||||||
const profileId = document.getElementById('amily2_preset_selector')?.value;
|
const profileId = document.getElementById('amily2_preset_selector')?.value;
|
||||||
const profile = context.extensionSettings?.connectionManager?.profiles?.find(p => p.id === profileId);
|
const stProfile = context.extensionSettings?.connectionManager?.profiles?.find(p => p.id === profileId);
|
||||||
model = profile?.openai_model || 'Preset Model';
|
model = stProfile?.openai_model || 'Preset Model';
|
||||||
} else {
|
} else {
|
||||||
model = document.getElementById('amily2_model')?.value;
|
model = document.getElementById('amily2_model')?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiProvider: apiProvider,
|
apiProvider,
|
||||||
apiUrl: document.getElementById('amily2_api_url')?.value.trim() || '',
|
apiUrl: document.getElementById('amily2_api_url')?.value.trim() || '',
|
||||||
apiKey: document.getElementById('amily2_api_key')?.value.trim() || '',
|
apiKey: document.getElementById('amily2_api_key')?.value.trim() || '',
|
||||||
model: model,
|
model,
|
||||||
maxTokens: settings.maxTokens || 4000,
|
maxTokens: settings.maxTokens || 4000,
|
||||||
temperature: settings.temperature || 0.7,
|
temperature: settings.temperature || 0.7,
|
||||||
tavernProfile: document.getElementById('amily2_preset_selector')?.value || ''
|
tavernProfile: document.getElementById('amily2_preset_selector')?.value || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,8 +484,8 @@ export async function testApiConnection() {
|
|||||||
$button.prop("disabled", true).html('<i class="fas fa-spinner fa-spin"></i> 测试中');
|
$button.prop("disabled", true).html('<i class="fas fa-spinner fa-spin"></i> 测试中');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiSettings = getApiSettings();
|
const apiSettings = await getApiSettings();
|
||||||
|
|
||||||
if (apiSettings.apiProvider === 'sillytavern_preset') {
|
if (apiSettings.apiProvider === 'sillytavern_preset') {
|
||||||
if (!apiSettings.tavernProfile) {
|
if (!apiSettings.tavernProfile) {
|
||||||
throw new Error("请先在下方选择一个SillyTavern预设");
|
throw new Error("请先在下方选择一个SillyTavern预设");
|
||||||
@@ -518,7 +534,7 @@ export async function callAI(messages, options = {}) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiSettings = getApiSettings();
|
const apiSettings = await getApiSettings();
|
||||||
|
|
||||||
const finalOptions = {
|
const finalOptions = {
|
||||||
maxTokens: apiSettings.maxTokens,
|
maxTokens: apiSettings.maxTokens,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { extension_settings, getContext } from "/scripts/extensions.js";
|
|||||||
import { characters, this_chid, getRequestHeaders, saveSettingsDebounced, eventSource, event_types } from "/script.js";
|
import { characters, this_chid, getRequestHeaders, saveSettingsDebounced, eventSource, event_types } from "/script.js";
|
||||||
import { extensionName } from "../../utils/settings.js";
|
import { extensionName } from "../../utils/settings.js";
|
||||||
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
||||||
|
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
|
||||||
|
|
||||||
let ChatCompletionService = undefined;
|
let ChatCompletionService = undefined;
|
||||||
try {
|
try {
|
||||||
@@ -42,15 +43,30 @@ function normalizeApiResponse(responseData) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getJqyhApiSettings() {
|
export async function getJqyhApiSettings() {
|
||||||
|
// 优先读取 'jqyh' 槽位分配的 Profile
|
||||||
|
const profile = await getSlotProfile('jqyh');
|
||||||
|
if (profile) {
|
||||||
|
return {
|
||||||
|
apiMode: providerToApiMode(profile.provider),
|
||||||
|
apiUrl: profile.apiUrl,
|
||||||
|
apiKey: profile.apiKey ?? '',
|
||||||
|
model: profile.model,
|
||||||
|
maxTokens: profile.maxTokens ?? 65500,
|
||||||
|
temperature: profile.temperature ?? 1.0,
|
||||||
|
tavernProfile: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级:读旧 extension_settings 字段
|
||||||
return {
|
return {
|
||||||
apiMode: extension_settings[extensionName]?.jqyhApiMode || 'openai_test',
|
apiMode: extension_settings[extensionName]?.jqyhApiMode || 'openai_test',
|
||||||
apiUrl: extension_settings[extensionName]?.jqyhApiUrl?.trim() || '',
|
apiUrl: extension_settings[extensionName]?.jqyhApiUrl?.trim() || '',
|
||||||
apiKey: extension_settings[extensionName]?.jqyhApiKey?.trim() || '',
|
apiKey: extension_settings[extensionName]?.jqyhApiKey?.trim() || '',
|
||||||
model: extension_settings[extensionName]?.jqyhModel || '',
|
model: extension_settings[extensionName]?.jqyhModel || '',
|
||||||
maxTokens: extension_settings[extensionName]?.jqyhMaxTokens || 4000,
|
maxTokens: extension_settings[extensionName]?.jqyhMaxTokens || 4000,
|
||||||
temperature: extension_settings[extensionName]?.jqyhTemperature || 0.7,
|
temperature: extension_settings[extensionName]?.jqyhTemperature || 0.7,
|
||||||
tavernProfile: extension_settings[extensionName]?.jqyhTavernProfile || ''
|
tavernProfile: extension_settings[extensionName]?.jqyhTavernProfile || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +76,7 @@ export async function callJqyhAI(messages, options = {}) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiSettings = getJqyhApiSettings();
|
const apiSettings = await getJqyhApiSettings();
|
||||||
|
|
||||||
const finalOptions = {
|
const finalOptions = {
|
||||||
maxTokens: apiSettings.maxTokens,
|
maxTokens: apiSettings.maxTokens,
|
||||||
@@ -258,7 +274,7 @@ async function callJqyhSillyTavernPreset(messages, options) {
|
|||||||
export async function fetchJqyhModels() {
|
export async function fetchJqyhModels() {
|
||||||
console.log('[Amily2号-Jqyh外交部] 开始获取模型列表');
|
console.log('[Amily2号-Jqyh外交部] 开始获取模型列表');
|
||||||
|
|
||||||
const apiSettings = getJqyhApiSettings();
|
const apiSettings = await getJqyhApiSettings();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (apiSettings.apiMode === 'sillytavern_preset') {
|
if (apiSettings.apiMode === 'sillytavern_preset') {
|
||||||
@@ -339,7 +355,7 @@ export async function fetchJqyhModels() {
|
|||||||
export async function testJqyhApiConnection() {
|
export async function testJqyhApiConnection() {
|
||||||
console.log('[Amily2号-Jqyh外交部] 开始API连接测试');
|
console.log('[Amily2号-Jqyh外交部] 开始API连接测试');
|
||||||
|
|
||||||
const apiSettings = getJqyhApiSettings();
|
const apiSettings = await getJqyhApiSettings();
|
||||||
|
|
||||||
if (apiSettings.apiMode === 'sillytavern_preset') {
|
if (apiSettings.apiMode === 'sillytavern_preset') {
|
||||||
if (!apiSettings.tavernProfile) {
|
if (!apiSettings.tavernProfile) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { extension_settings, getContext } from "/scripts/extensions.js";
|
|||||||
import { characters, this_chid, getRequestHeaders, saveSettingsDebounced, eventSource, event_types } from "/script.js";
|
import { characters, this_chid, getRequestHeaders, saveSettingsDebounced, eventSource, event_types } from "/script.js";
|
||||||
import { extensionName } from "../../utils/settings.js";
|
import { extensionName } from "../../utils/settings.js";
|
||||||
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
||||||
|
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
|
||||||
|
|
||||||
let ChatCompletionService = undefined;
|
let ChatCompletionService = undefined;
|
||||||
try {
|
try {
|
||||||
@@ -36,17 +37,34 @@ if (window.Amily2Bus) {
|
|||||||
toastr.error("核心组件 Amily2Bus 丢失,请检查安装。", "Nccs-System");
|
toastr.error("核心组件 Amily2Bus 丢失,请检查安装。", "Nccs-System");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNccsApiSettings() {
|
export async function getNccsApiSettings() {
|
||||||
|
// 优先读取 'nccs' 槽位分配的 Profile
|
||||||
|
const profile = await getSlotProfile('nccs');
|
||||||
|
if (profile) {
|
||||||
|
return {
|
||||||
|
nccsEnabled: true,
|
||||||
|
apiMode: providerToApiMode(profile.provider),
|
||||||
|
apiUrl: profile.apiUrl,
|
||||||
|
apiKey: profile.apiKey ?? '',
|
||||||
|
model: profile.model,
|
||||||
|
maxTokens: profile.maxTokens ?? 65500,
|
||||||
|
temperature: profile.temperature ?? 1.0,
|
||||||
|
tavernProfile: '',
|
||||||
|
useFakeStream: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级:读旧 extension_settings 字段
|
||||||
return {
|
return {
|
||||||
nccsEnabled: extension_settings[extensionName]?.nccsEnabled || false,
|
nccsEnabled: extension_settings[extensionName]?.nccsEnabled || false,
|
||||||
apiMode: extension_settings[extensionName]?.nccsApiMode || 'openai_test',
|
apiMode: extension_settings[extensionName]?.nccsApiMode || 'openai_test',
|
||||||
apiUrl: extension_settings[extensionName]?.nccsApiUrl?.trim() || '',
|
apiUrl: extension_settings[extensionName]?.nccsApiUrl?.trim() || '',
|
||||||
apiKey: extension_settings[extensionName]?.nccsApiKey?.trim() || '',
|
apiKey: extension_settings[extensionName]?.nccsApiKey?.trim() || '',
|
||||||
model: extension_settings[extensionName]?.nccsModel || '',
|
model: extension_settings[extensionName]?.nccsModel || '',
|
||||||
maxTokens: extension_settings[extensionName]?.nccsMaxTokens || 4000,
|
maxTokens: extension_settings[extensionName]?.nccsMaxTokens || 4000,
|
||||||
temperature: extension_settings[extensionName]?.nccsTemperature || 0.7,
|
temperature: extension_settings[extensionName]?.nccsTemperature || 0.7,
|
||||||
tavernProfile: extension_settings[extensionName]?.nccsTavernProfile || '',
|
tavernProfile: extension_settings[extensionName]?.nccsTavernProfile || '',
|
||||||
useFakeStream: extension_settings[extensionName]?.nccsFakeStreamEnabled || false
|
useFakeStream: extension_settings[extensionName]?.nccsFakeStreamEnabled || false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +78,7 @@ export async function callNccsAI(messages, options = {}) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = getNccsApiSettings();
|
const settings = await getNccsApiSettings();
|
||||||
const finalOptions = {
|
const finalOptions = {
|
||||||
...settings,
|
...settings,
|
||||||
...options
|
...options
|
||||||
@@ -238,7 +256,7 @@ async function callNccsSillyTavernPreset(messages, options) {
|
|||||||
export async function fetchNccsModels() {
|
export async function fetchNccsModels() {
|
||||||
console.log('[Amily2号-Nccs外交部] 开始获取模型列表');
|
console.log('[Amily2号-Nccs外交部] 开始获取模型列表');
|
||||||
|
|
||||||
const apiSettings = getNccsApiSettings();
|
const apiSettings = await getNccsApiSettings();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (apiSettings.apiMode === 'sillytavern_preset') {
|
if (apiSettings.apiMode === 'sillytavern_preset') {
|
||||||
@@ -320,7 +338,7 @@ export async function fetchNccsModels() {
|
|||||||
export async function testNccsApiConnection() {
|
export async function testNccsApiConnection() {
|
||||||
console.log('[Amily2号-Nccs外交部] 开始API连接测试');
|
console.log('[Amily2号-Nccs外交部] 开始API连接测试');
|
||||||
|
|
||||||
const apiSettings = getNccsApiSettings();
|
const apiSettings = await getNccsApiSettings();
|
||||||
|
|
||||||
if (apiSettings.apiMode === 'sillytavern_preset') {
|
if (apiSettings.apiMode === 'sillytavern_preset') {
|
||||||
if (!apiSettings.tavernProfile) {
|
if (!apiSettings.tavernProfile) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { extension_settings, getContext } from "/scripts/extensions.js";
|
|||||||
import { characters, this_chid, getRequestHeaders, saveSettingsDebounced, eventSource, event_types } from "/script.js";
|
import { characters, this_chid, getRequestHeaders, saveSettingsDebounced, eventSource, event_types } from "/script.js";
|
||||||
import { extensionName } from "../../utils/settings.js";
|
import { extensionName } from "../../utils/settings.js";
|
||||||
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
||||||
|
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
|
||||||
|
|
||||||
let ChatCompletionService = undefined;
|
let ChatCompletionService = undefined;
|
||||||
try {
|
try {
|
||||||
@@ -42,16 +43,32 @@ function normalizeApiResponse(responseData) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNgmsApiSettings() {
|
export async function getNgmsApiSettings() {
|
||||||
|
// 优先读取 'ngms' 槽位分配的 Profile
|
||||||
|
const profile = await getSlotProfile('ngms');
|
||||||
|
if (profile) {
|
||||||
|
return {
|
||||||
|
apiMode: providerToApiMode(profile.provider),
|
||||||
|
apiUrl: profile.apiUrl,
|
||||||
|
apiKey: profile.apiKey ?? '',
|
||||||
|
model: profile.model,
|
||||||
|
maxTokens: profile.maxTokens ?? 65500,
|
||||||
|
temperature: profile.temperature ?? 1.0,
|
||||||
|
tavernProfile: '',
|
||||||
|
useFakeStream: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级:读旧 extension_settings 字段
|
||||||
return {
|
return {
|
||||||
apiMode: extension_settings[extensionName]?.ngmsApiMode || 'openai_test',
|
apiMode: extension_settings[extensionName]?.ngmsApiMode || 'openai_test',
|
||||||
apiUrl: extension_settings[extensionName]?.ngmsApiUrl?.trim() || '',
|
apiUrl: extension_settings[extensionName]?.ngmsApiUrl?.trim() || '',
|
||||||
apiKey: extension_settings[extensionName]?.ngmsApiKey?.trim() || '',
|
apiKey: extension_settings[extensionName]?.ngmsApiKey?.trim() || '',
|
||||||
model: extension_settings[extensionName]?.ngmsModel || '',
|
model: extension_settings[extensionName]?.ngmsModel || '',
|
||||||
maxTokens: extension_settings[extensionName]?.ngmsMaxTokens || 4000,
|
maxTokens: extension_settings[extensionName]?.ngmsMaxTokens || 4000,
|
||||||
temperature: extension_settings[extensionName]?.ngmsTemperature || 0.7,
|
temperature: extension_settings[extensionName]?.ngmsTemperature || 0.7,
|
||||||
tavernProfile: extension_settings[extensionName]?.ngmsTavernProfile || '',
|
tavernProfile: extension_settings[extensionName]?.ngmsTavernProfile || '',
|
||||||
useFakeStream: extension_settings[extensionName]?.ngmsFakeStreamEnabled || false
|
useFakeStream: extension_settings[extensionName]?.ngmsFakeStreamEnabled || false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +78,7 @@ export async function callNgmsAI(messages, options = {}) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiSettings = getNgmsApiSettings();
|
const apiSettings = await getNgmsApiSettings();
|
||||||
|
|
||||||
const finalOptions = {
|
const finalOptions = {
|
||||||
maxTokens: apiSettings.maxTokens,
|
maxTokens: apiSettings.maxTokens,
|
||||||
@@ -324,7 +341,7 @@ async function callNgmsSillyTavernPreset(messages, options) {
|
|||||||
export async function fetchNgmsModels() {
|
export async function fetchNgmsModels() {
|
||||||
console.log('[Amily2号-Ngms外交部] 开始获取模型列表');
|
console.log('[Amily2号-Ngms外交部] 开始获取模型列表');
|
||||||
|
|
||||||
const apiSettings = getNgmsApiSettings();
|
const apiSettings = await getNgmsApiSettings();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (apiSettings.apiMode === 'sillytavern_preset') {
|
if (apiSettings.apiMode === 'sillytavern_preset') {
|
||||||
@@ -407,7 +424,7 @@ export async function fetchNgmsModels() {
|
|||||||
export async function testNgmsApiConnection() {
|
export async function testNgmsApiConnection() {
|
||||||
console.log('[Amily2号-Ngms外交部] 开始API连接测试');
|
console.log('[Amily2号-Ngms外交部] 开始API连接测试');
|
||||||
|
|
||||||
const apiSettings = getNgmsApiSettings();
|
const apiSettings = await getNgmsApiSettings();
|
||||||
|
|
||||||
if (apiSettings.apiMode === 'sillytavern_preset') {
|
if (apiSettings.apiMode === 'sillytavern_preset') {
|
||||||
if (!apiSettings.tavernProfile) {
|
if (!apiSettings.tavernProfile) {
|
||||||
|
|||||||
45
core/api/api-resolver.js
Normal file
45
core/api/api-resolver.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* api-resolver.js — API 配置槽位解析器
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 优先从 ApiProfileManager 读取功能槽分配的 Profile(含解密 Key),
|
||||||
|
* 无分配时返回 null,由调用方执行旧配置兜底。
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* const profile = await getSlotProfile('main');
|
||||||
|
* if (profile) { // 用 profile.provider / apiUrl / apiKey / model ... }
|
||||||
|
* else { // 回退到旧 DOM / extension_settings 读取 }
|
||||||
|
*
|
||||||
|
* provider → apiMode 映射(供 Nccs / Ngms / Jqyh 内部 switch 使用):
|
||||||
|
* 'openai' → 'openai_test' (经 ST 后端代理发送,规避 CORS)
|
||||||
|
* 'google' → 'openai_test' (Google OpenAI-compat 同样走代理)
|
||||||
|
* 'sillytavern_backend'→ 'openai_test'
|
||||||
|
* 'sillytavern_preset' → 'sillytavern_preset'
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiProfileManager } from '../../utils/config/ApiProfileManager.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Profile.provider 映射到子模块使用的 apiMode 字段。
|
||||||
|
* @param {string} provider
|
||||||
|
* @returns {'openai_test'|'sillytavern_preset'}
|
||||||
|
*/
|
||||||
|
export function providerToApiMode(provider) {
|
||||||
|
return provider === 'sillytavern_preset' ? 'sillytavern_preset' : 'openai_test';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取功能槽对应的完整 Profile(含解密 Key)。
|
||||||
|
* 未分配或读取失败时返回 null。
|
||||||
|
*
|
||||||
|
* @param {string} slot 功能槽名(见 ApiProfileManager.SLOTS)
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
|
export async function getSlotProfile(slot) {
|
||||||
|
try {
|
||||||
|
return await apiProfileManager.getAssignedProfile(slot);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[ApiResolver] 读取槽位 "${slot}" 失败,降级到旧配置:`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,75 +1,19 @@
|
|||||||
import { getContext, extension_settings } from "/scripts/extensions.js";
|
import { getContext, extension_settings } from "/scripts/extensions.js";
|
||||||
import { saveChatConditional } from "/script.js";
|
|
||||||
import { extensionName } from "../utils/settings.js";
|
import { extensionName } from "../utils/settings.js";
|
||||||
import * as TableManager from './table-system/manager.js';
|
import { processMessageUpdate, fillWithSecondaryApi } from './table-system/TableSystemService.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 { processOptimization } from "./summarizer.js";
|
import { processOptimization } from "./summarizer.js";
|
||||||
import { executeAutoHide } from './autoHideManager.js';
|
import { executeAutoHide } from './autoHideManager.js';
|
||||||
import { checkAndTriggerAutoSummary } from './historiographer.js';
|
import { checkAndTriggerAutoSummary } from './historiographer.js';
|
||||||
import { fillWithSecondaryApi } from './table-system/secondary-filler.js';
|
|
||||||
import { amilyHelper } from './tavern-helper/main.js';
|
import { amilyHelper } from './tavern-helper/main.js';
|
||||||
|
|
||||||
|
async function handleTableUpdate(messageId) {
|
||||||
|
await processMessageUpdate(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function onMessageReceived(data) {
|
export async function onMessageReceived(data) {
|
||||||
window.lastPreOptimizationResult = null;
|
window.lastPreOptimizationResult = null;
|
||||||
document.dispatchEvent(new CustomEvent('preOptimizationTextUpdated'));
|
document.dispatchEvent(new CustomEvent('preOptimizationTextUpdated'));
|
||||||
|
|
||||||
const context = getContext();
|
const context = getContext();
|
||||||
if ((data && data.is_user) || context.isWaitingForUserInput) { return; }
|
if ((data && data.is_user) || context.isWaitingForUserInput) { return; }
|
||||||
@@ -81,9 +25,10 @@ export async function onMessageReceived(data) {
|
|||||||
const latestMessage = chat[chat.length - 1];
|
const latestMessage = chat[chat.length - 1];
|
||||||
if (latestMessage.is_user) { return; }
|
if (latestMessage.is_user) { return; }
|
||||||
|
|
||||||
const tableSystemEnabled = settings.table_system_enabled !== false;
|
const tableSystemEnabled = settings.table_system_enabled !== false;
|
||||||
|
|
||||||
await executeAutoHide();
|
await executeAutoHide();
|
||||||
|
|
||||||
const isOptimizationEnabled = settings.optimizationEnabled && settings.apiUrl;
|
const isOptimizationEnabled = settings.optimizationEnabled && settings.apiUrl;
|
||||||
if (isOptimizationEnabled) {
|
if (isOptimizationEnabled) {
|
||||||
if (chat.length >= 2 && chat[chat.length - 2].is_user) {
|
if (chat.length >= 2 && chat[chat.length - 2].is_user) {
|
||||||
@@ -109,13 +54,14 @@ export async function onMessageReceived(data) {
|
|||||||
console.log("[Amily2号-正文优化] 检测到消息并非AI对用户的直接回复,已跳过优化。");
|
console.log("[Amily2号-正文优化] 检测到消息并非AI对用户的直接回复,已跳过优化。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tableSystemEnabled) {
|
if (tableSystemEnabled) {
|
||||||
const fillingMode = settings.filling_mode || 'main-api';
|
const fillingMode = settings.filling_mode || 'main-api';
|
||||||
if (fillingMode === 'secondary-api') {
|
if (fillingMode === 'secondary-api') {
|
||||||
fillWithSecondaryApi(latestMessage);
|
fillWithSecondaryApi(latestMessage);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log('[分步填表] 表格系统总开关已关闭,跳过分步填表处理。', 'info');
|
console.log('[分步填表] 表格系统总开关已关闭,跳过分步填表处理。');
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
122
core/table-system/TableSystemService.js
Normal file
122
core/table-system/TableSystemService.js
Normal 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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import "./PreOptimizationViewer/index.js";
|
|||||||
import "./WorldEditor/WorldEditor.js";
|
import "./WorldEditor/WorldEditor.js";
|
||||||
import './core/amily2-updater.js';
|
import './core/amily2-updater.js';
|
||||||
import './SL/bus/Amily2Bus.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 使用)
|
// Re-exports (重新导出供 index.js 使用)
|
||||||
export { createDrawer } from "./ui/drawer.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 { checkForUpdates, fetchMessageBoardContent } from './core/api.js';
|
||||||
export { setUpdateInfo, applyUpdateIndicator } from './ui/state.js';
|
export { setUpdateInfo, applyUpdateIndicator } from './ui/state.js';
|
||||||
export { pluginVersion, extensionName, defaultSettings } from './utils/settings.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 { checkAuthorization, refreshUserInfo } from './utils/auth.js';
|
||||||
export { tableSystemDefaultSettings } from './core/table-system/settings.js';
|
export { tableSystemDefaultSettings } from './core/table-system/settings.js';
|
||||||
export { manageLorebookEntriesForChat } from './core/lore.js';
|
export { manageLorebookEntriesForChat } from './core/lore.js';
|
||||||
|
|||||||
2
index.js
2
index.js
@@ -15,6 +15,7 @@ import {
|
|||||||
checkForUpdates, fetchMessageBoardContent,
|
checkForUpdates, fetchMessageBoardContent,
|
||||||
setUpdateInfo, applyUpdateIndicator,
|
setUpdateInfo, applyUpdateIndicator,
|
||||||
pluginVersion, extensionName, defaultSettings,
|
pluginVersion, extensionName, defaultSettings,
|
||||||
|
configManager,
|
||||||
checkAuthorization, refreshUserInfo,
|
checkAuthorization, refreshUserInfo,
|
||||||
tableSystemDefaultSettings,
|
tableSystemDefaultSettings,
|
||||||
manageLorebookEntriesForChat,
|
manageLorebookEntriesForChat,
|
||||||
@@ -940,6 +941,7 @@ jQuery(async () => {
|
|||||||
registerAllApiHandlers();
|
registerAllApiHandlers();
|
||||||
initializeAmilyHelper();
|
initializeAmilyHelper();
|
||||||
mergePluginSettings();
|
mergePluginSettings();
|
||||||
|
configManager.migrate(); // 将 extension_settings 中残留的敏感字段迁移到 localStorage
|
||||||
|
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = 100;
|
const maxAttempts = 100;
|
||||||
|
|||||||
535
ui/api-config-bindings.js
Normal file
535
ui/api-config-bindings.js
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
import { getRequestHeaders } from '/script.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());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 弹窗:接口类型切换(Google 自动填 URL)
|
||||||
|
$c.find('#amily2_pf_provider').on('change', function () {
|
||||||
|
_handleProviderChange($c, $(this).val());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 弹窗:获取模型列表
|
||||||
|
$c.find('#amily2_pf_fetch_models').on('click', () => _fetchModels($c));
|
||||||
|
|
||||||
|
// 弹窗:测试连接
|
||||||
|
$c.find('#amily2_pf_test_conn').on('click', () => _testConnection($c));
|
||||||
|
|
||||||
|
// 弹窗:关闭
|
||||||
|
$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);
|
||||||
|
_handleProviderChange($c, p.provider);
|
||||||
|
} 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');
|
||||||
|
_handleProviderChange($c, '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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空上次测试结果和模型列表缓存
|
||||||
|
$c.find('#amily2_pf_test_result').text('');
|
||||||
|
$c.find('#amily2_pf_model_list').empty();
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 获取模型 / 测试连接 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _fetchModels($c) {
|
||||||
|
const apiUrl = $c.find('#amily2_pf_url').val().trim();
|
||||||
|
const apiKey = $c.find('#amily2_pf_key').val().trim();
|
||||||
|
const provider = $c.find('#amily2_pf_provider').val();
|
||||||
|
|
||||||
|
if (!apiUrl) { toastr.warning('请先填写 API 地址。'); return; }
|
||||||
|
|
||||||
|
const $btn = $c.find('#amily2_pf_fetch_models').prop('disabled', true);
|
||||||
|
$btn.html('<i class="fas fa-spinner fa-spin"></i> 获取中...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let models;
|
||||||
|
|
||||||
|
if (provider === 'google') {
|
||||||
|
// Google 用原生 API,以 ?key= 传参,返回 models[] 而非 data[]
|
||||||
|
if (!apiKey) { toastr.warning('请先填写 Google API Key。'); return; }
|
||||||
|
const resp = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
const status = resp.status;
|
||||||
|
toastr.error(status === 400 ? '获取失败:API Key 格式错误。'
|
||||||
|
: status === 403 ? '获取失败:API Key 无效或无权限。'
|
||||||
|
: `获取失败:HTTP ${status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
// 只保留支持文本生成的模型
|
||||||
|
models = (data.models ?? [])
|
||||||
|
.filter(m => m.supportedGenerationMethods?.some(
|
||||||
|
method => ['generateContent', 'embedContent'].includes(method)
|
||||||
|
))
|
||||||
|
.map(m => m.name.replace(/^models\//, ''));
|
||||||
|
} else {
|
||||||
|
// OpenAI 兼容接口 — 通过 ST 后端代理,规避 CORS
|
||||||
|
const resp = await fetch('/api/backends/chat-completions/status', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
reverse_proxy: apiUrl,
|
||||||
|
proxy_password: apiKey,
|
||||||
|
chat_completion_source: 'openai',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const status = resp.status;
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
toastr.error('获取失败:API Key 无效或无权限。');
|
||||||
|
} else if (status === 404) {
|
||||||
|
toastr.warning('该接口不支持模型列表查询,请手动填写模型 ID。');
|
||||||
|
} else {
|
||||||
|
toastr.error(`获取失败:HTTP ${status}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawData = await resp.json();
|
||||||
|
// ST 返回原始数组或包含 data/models 字段的对象
|
||||||
|
const list = Array.isArray(rawData) ? rawData : (rawData.data ?? rawData.models ?? []);
|
||||||
|
models = list.map(m => m.id ?? m.name ?? m).filter(m => typeof m === 'string' && m);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
toastr.warning('未获取到模型列表,请手动填写。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $dl = $c.find('#amily2_pf_model_list');
|
||||||
|
$dl.html(models.map(m => `<option value="${_escapeHtml(m)}">`).join(''));
|
||||||
|
|
||||||
|
const $modelInput = $c.find('#amily2_pf_model');
|
||||||
|
if (!$modelInput.val()) $modelInput.val(models[0]);
|
||||||
|
|
||||||
|
toastr.success(`已获取 ${models.length} 个可用模型。`);
|
||||||
|
} catch (e) {
|
||||||
|
toastr.error(`获取失败:${e.message}`);
|
||||||
|
} finally {
|
||||||
|
$btn.prop('disabled', false).html('<i class="fas fa-list"></i> 获取');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _testConnection($c) {
|
||||||
|
const apiUrl = $c.find('#amily2_pf_url').val().trim();
|
||||||
|
const apiKey = $c.find('#amily2_pf_key').val().trim();
|
||||||
|
const provider = $c.find('#amily2_pf_provider').val();
|
||||||
|
|
||||||
|
if (!apiUrl) { toastr.warning('请先填写 API 地址。'); return; }
|
||||||
|
|
||||||
|
const $btn = $c.find('#amily2_pf_test_conn').prop('disabled', true);
|
||||||
|
const $result = $c.find('#amily2_pf_test_result').text('测试中…').css('color', 'var(--SmartThemeQuoteColor)');
|
||||||
|
$btn.html('<i class="fas fa-spinner fa-spin"></i> 测试中...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (provider === 'google') {
|
||||||
|
// Google 用原生 models 端点测试
|
||||||
|
if (!apiKey) {
|
||||||
|
$result.text('请填写 API Key').css('color', 'var(--warning-color)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resp = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`
|
||||||
|
);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
const count = (data.models ?? []).length;
|
||||||
|
$result.text(`连接成功${count ? `,${count} 个可用模型` : ''}`).css('color', 'var(--green)');
|
||||||
|
toastr.success('Google AI Studio 连接测试通过!');
|
||||||
|
} else {
|
||||||
|
const status = resp.status;
|
||||||
|
const msg = status === 400 ? 'API Key 格式错误'
|
||||||
|
: status === 403 ? 'API Key 无效或无权限'
|
||||||
|
: `HTTP ${status}`;
|
||||||
|
$result.text(`失败:${msg}`).css('color', 'var(--warning-color)');
|
||||||
|
toastr.error(`测试失败:${msg}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 兼容接口 — 通过 ST 后端代理,规避 CORS
|
||||||
|
const modelsResp = await fetch('/api/backends/chat-completions/status', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
reverse_proxy: apiUrl,
|
||||||
|
proxy_password: apiKey,
|
||||||
|
chat_completion_source: 'openai',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (modelsResp.ok) {
|
||||||
|
const rawData = await modelsResp.json();
|
||||||
|
const list = Array.isArray(rawData) ? rawData : (rawData.data ?? rawData.models ?? []);
|
||||||
|
const count = list.length;
|
||||||
|
$result.text(`连接成功${count ? `,${count} 个可用模型` : ''}`).css('color', 'var(--green)');
|
||||||
|
toastr.success('连接测试通过!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = modelsResp.status;
|
||||||
|
const errBody = await modelsResp.json().catch(() => ({}));
|
||||||
|
const msg = errBody?.error?.message
|
||||||
|
|| (status === 401 || status === 403 ? 'API Key 无效或无权限'
|
||||||
|
: status === 404 ? '接口地址不存在'
|
||||||
|
: `HTTP ${status}`);
|
||||||
|
$result.text(`失败:${msg}`).css('color', 'var(--warning-color)');
|
||||||
|
toastr.error(`测试失败:${msg}`);
|
||||||
|
} catch (e) {
|
||||||
|
$result.text(`无法连接:${e.message}`).css('color', 'var(--warning-color)');
|
||||||
|
toastr.error(`连接失败:${e.message}`);
|
||||||
|
} finally {
|
||||||
|
$btn.prop('disabled', false).html('<i class="fas fa-plug"></i> 测试连接');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Provider 切换 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GOOGLE_API_BASE = 'https://generativelanguage.googleapis.com/v1beta/openai';
|
||||||
|
|
||||||
|
function _handleProviderChange($c, provider) {
|
||||||
|
const isGoogle = provider === 'google';
|
||||||
|
$c.find('#amily2_pf_url_row').toggle(!isGoogle);
|
||||||
|
$c.find('#amily2_pf_google_note').toggle(isGoogle);
|
||||||
|
|
||||||
|
if (isGoogle) {
|
||||||
|
$c.find('#amily2_pf_url').val(GOOGLE_API_BASE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内部工具 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
@@ -805,7 +805,7 @@ export function bindModalEvents() {
|
|||||||
container
|
container
|
||||||
.off("click.amily2.chamber_nav")
|
.off("click.amily2.chamber_nav")
|
||||||
.on("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;
|
if (!pluginAuthStatus.authorized) return;
|
||||||
|
|
||||||
const mainPanel = container.find('.plugin-features');
|
const mainPanel = container.find('.plugin-features');
|
||||||
@@ -819,6 +819,7 @@ export function bindModalEvents() {
|
|||||||
const glossaryPanel = container.find('#amily2_glossary_panel');
|
const glossaryPanel = container.find('#amily2_glossary_panel');
|
||||||
const rendererPanel = container.find('#amily2_renderer_panel');
|
const rendererPanel = container.find('#amily2_renderer_panel');
|
||||||
const superMemoryPanel = container.find('#amily2_super_memory_panel');
|
const superMemoryPanel = container.find('#amily2_super_memory_panel');
|
||||||
|
const apiConfigPanel = container.find('#amily2_api_config_panel');
|
||||||
|
|
||||||
mainPanel.hide();
|
mainPanel.hide();
|
||||||
additionalPanel.hide();
|
additionalPanel.hide();
|
||||||
@@ -831,6 +832,7 @@ export function bindModalEvents() {
|
|||||||
glossaryPanel.hide();
|
glossaryPanel.hide();
|
||||||
rendererPanel.hide();
|
rendererPanel.hide();
|
||||||
superMemoryPanel.hide();
|
superMemoryPanel.hide();
|
||||||
|
apiConfigPanel.hide();
|
||||||
|
|
||||||
switch (this.id) {
|
switch (this.id) {
|
||||||
case 'amily2_open_text_optimization':
|
case 'amily2_open_text_optimization':
|
||||||
@@ -875,6 +877,9 @@ export function bindModalEvents() {
|
|||||||
case 'amily2_open_glossary':
|
case 'amily2_open_glossary':
|
||||||
glossaryPanel.show();
|
glossaryPanel.show();
|
||||||
break;
|
break;
|
||||||
|
case 'amily2_open_api_config':
|
||||||
|
apiConfigPanel.show();
|
||||||
|
break;
|
||||||
case 'amily2_back_to_main_settings':
|
case 'amily2_back_to_main_settings':
|
||||||
case 'amily2_back_to_main_from_hanlinyuan':
|
case 'amily2_back_to_main_from_hanlinyuan':
|
||||||
case 'amily2_back_to_main_from_forms':
|
case 'amily2_back_to_main_from_forms':
|
||||||
@@ -885,6 +890,7 @@ export function bindModalEvents() {
|
|||||||
case 'amily2_back_to_main_from_glossary':
|
case 'amily2_back_to_main_from_glossary':
|
||||||
case 'amily2_renderer_back_button':
|
case 'amily2_renderer_back_button':
|
||||||
case 'amily2_back_to_main_from_super_memory':
|
case 'amily2_back_to_main_from_super_memory':
|
||||||
|
case 'amily2_back_to_main_from_api_config':
|
||||||
mainPanel.show();
|
mainPanel.show();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { bindTableEvents } from './table-bindings.js';
|
|||||||
import { showContentModal } from "./page-window.js";
|
import { showContentModal } from "./page-window.js";
|
||||||
import { initializeRendererBindings } from "../core/tavern-helper/renderer-bindings.js";
|
import { initializeRendererBindings } from "../core/tavern-helper/renderer-bindings.js";
|
||||||
import { bindSuperMemoryEvents } from "../core/super-memory/bindings.js";
|
import { bindSuperMemoryEvents } from "../core/super-memory/bindings.js";
|
||||||
|
import { bindApiConfigPanel } from "./api-config-bindings.js";
|
||||||
const extensionFolderPath = `scripts/extensions/third-party/${extensionName}`;
|
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>`;
|
const superMemoryPanelHtml = `<div id="amily2_super_memory_panel" style="display: none;">${superMemoryContent}</div>`;
|
||||||
mainContainer.append(superMemoryPanelHtml);
|
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';
|
const worldEditorScriptId = 'world-editor-script';
|
||||||
if (!document.getElementById(worldEditorScriptId)) {
|
if (!document.getElementById(worldEditorScriptId)) {
|
||||||
@@ -133,6 +138,7 @@ async function initializePanel(contentPanel, errorContainer) {
|
|||||||
bindTableEvents();
|
bindTableEvents();
|
||||||
initializeRendererBindings();
|
initializeRendererBindings();
|
||||||
bindSuperMemoryEvents();
|
bindSuperMemoryEvents();
|
||||||
|
bindApiConfigPanel(mainContainer.find('#amily2_api_config_panel'));
|
||||||
contentPanel.data("initialized", true);
|
contentPanel.data("initialized", true);
|
||||||
console.log("[Amily-重构] 宫殿模块已按蓝图竣工。");
|
console.log("[Amily-重构] 宫殿模块已按蓝图竣工。");
|
||||||
applyUpdateIndicator();
|
applyUpdateIndicator();
|
||||||
|
|||||||
304
utils/config/ApiProfileManager.js
Normal file
304
utils/config/ApiProfileManager.js
Normal 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 → 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<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);
|
||||||
155
utils/config/ConfigManager.js
Normal file
155
utils/config/ConfigManager.js
Normal 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);
|
||||||
359
utils/config/api-key-store/ApiKeyStore.js
Normal file
359
utils/config/api-key-store/ApiKeyStore.js
Normal 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);
|
||||||
174
utils/config/api-key-store/crypto-utils.js
Normal file
174
utils/config/api-key-store/crypto-utils.js
Normal 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 随机 IV(12 字节)
|
||||||
|
* 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. 生成随机 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<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));
|
||||||
|
}
|
||||||
17
utils/config/sensitive-keys.js
Normal file
17
utils/config/sensitive-keys.js
Normal 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',
|
||||||
|
]);
|
||||||
Reference in New Issue
Block a user