10 Commits

Author SHA1 Message Date
1c64718391 feat(api): bridge ApiProfileManager to all AI callers, fix config split
- Add core/api/api-resolver.js: getSlotProfile(slot) + providerToApiMode()
- core/api.js: getApiSettings() async, reads 'main' slot profile first
- NccsApi.js: getNccsApiSettings() async, reads 'nccs' slot profile first
- Ngms_api.js: getNgmsApiSettings() async, reads 'ngms' slot profile first
- JqyhApi.js: getJqyhApiSettings() async, reads 'jqyh' slot profile first
- All callers updated with await; legacy DOM/extension_settings as fallback
- .gitignore: add WorkDiary.md and Structure.md (local-only files)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 20:50:08 +08:00
5a6a8b205c fix(api-config): route model fetch/test through ST backend proxy to bypass CORS
Replace direct browser fetch with /api/backends/chat-completions/status for
OpenAI-compatible providers; Google keeps direct fetch (supports CORS natively).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 23:58:20 +08:00
153c0616d2 feat(api-config): add model fetch, connection test, and Google AI Studio support
- Add "获取模型" button: fetches /v1/models and populates datalist autocomplete
- Add "测试连接" button: tries GET /models first (free), falls back to minimal
  type-specific request if endpoint unsupported; shows inline result
- Google AI Studio: auto-fill fixed endpoint URL, hide URL field, fetch models
  via native v1beta/models?key= API, test via same endpoint
- Collapse Chat/Embedding/Rerank advanced params into <details> sections
- Simplify provider options: remove ambiguous "实验性全兼容" option
- Clear test result and model datalist cache on each modal open

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 23:19:51 +08:00
0be6a86e94 feat: add API config system, FilePipe backend, and ConfigManager
- ConfigManager: route sensitive keys (API keys) to localStorage,
  migrate existing values out of extension_settings on startup
- ApiKeyStore: local/cloud storage modes with RSA+AES hybrid encryption
- ApiProfileManager: named connection profiles (chat/embedding/rerank)
  with per-slot type-validated assignments
- FilePipe: complete IndexedDB backend (read/write/delete/list/stat)
- Amily2Bus: inject FilePipe via forPlugin() capability token
- UI: api-config-panel with profile CRUD and slot assignment
- TableSystemService: initial service layer scaffold
- logger.js: XSS fix
2026-03-10 22:07:15 +08:00
ed3f52a568 update input type to number 2026-02-26 18:00:40 +08:00
96b108b482 Merge branch 'SL-Dev-2602' of github.com:Wx-2025/ST-Amily2-Chat-Optimisation into SL-Dev-2602 2026-02-13 13:08:55 +08:00
3c7318492a 2602 dev init 2026-02-13 13:06:17 +08:00
SilenceLurker
42de8b54b3 Update CodeQL workflow branches and schedule 2026-02-13 13:06:17 +08:00
0d6f26c8d9 2602 dev init 2026-02-13 13:04:39 +08:00
0edb75fe1c dev branch to new history root 2026-02-13 11:21:15 +08:00
30 changed files with 2753 additions and 174 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
WorkDiary.md
Structure.md

View File

@@ -124,15 +124,17 @@ class Amily2Bus {
// 1. 日志能力 (绑定了身份的日志接口)
log: (origin, type, message) => this.Logger.log(pluginName, origin, type, message),
// 2. 文件能力 (绑定了身份的文件接口)
file: {
read: (path) => {
return this.FilePipe ? this.FilePipe.read(pluginName, path) : null;
// 2. 文件能力 (绑定了插件身份的文件接口,后端为 IndexedDB)
file: this.FilePipe
? this.FilePipe.forPlugin(pluginName)
: {
read: () => null,
write: () => false,
delete: () => false,
list: () => [],
clearAll: () => 0,
stat: () => null,
},
write: (path, data) => {
return this.FilePipe ? this.FilePipe.write(pluginName, path, data) : false;
}
},
// 3. 网络能力 (ModelCaller)
model: {

View File

@@ -1,60 +1,259 @@
/**
* FilePipe — 插件独立文件存储管道
*
* 解决的问题:
* SillyTavern 的 settings.json 被所有插件共享大型内容prompt 模板、摘要、
* 优化结果、缓存)写入后导致文件膨胀,且功能迭代残留的废弃 key 永久堆积。
*
* 方案:
* 以 IndexedDB 为后端,每个插件在独立命名空间下进行读写。
* 与 settings.json 完全隔离,不参与云同步,无体积上限约束。
*
* 存储结构:
* DB : 'Amily2_FilePipe'
* Store: 'files'
* Key : 复合键 [plugin, path](无需为新插件升级 DB 版本)
* Entry: { plugin, path, data, updatedAt }
*
* 安全:
* - 路径禁止包含 '..'(防目录穿越)
* - 每个插件只能读写自己命名空间下的路径
*
* 使用方式(通过 Amily2Bus capability token
* const file = ctx.file; // Amily2Bus 注入
* await file.write('config.json', { key: 'value' });
* const data = await file.read('config.json');
* await file.delete('config.json');
* const list = await file.list();
*/
const DB_NAME = 'Amily2_FilePipe';
const DB_VERSION = 1;
const STORE_NAME = 'files';
// ── IndexedDB 工具 ────────────────────────────────────────────────────────────
let _dbPromise = null;
function _openDB() {
if (_dbPromise) return _dbPromise;
_dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: ['plugin', 'path'],
});
// 按插件名索引,方便 list() 查询
store.createIndex('by_plugin', 'plugin', { unique: false });
}
};
req.onsuccess = (e) => resolve(e.target.result);
req.onerror = (e) => {
_dbPromise = null;
reject(new Error(`[FilePipe] IndexedDB 打开失败: ${e.target.error}`));
};
});
return _dbPromise;
}
function _tx(db, mode) {
return db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
}
function _idbRequest(req) {
return new Promise((resolve, reject) => {
req.onsuccess = (e) => resolve(e.target.result);
req.onerror = (e) => reject(e.target.error);
});
}
// ── FilePipe ──────────────────────────────────────────────────────────────────
class FilePipe {
constructor() {
this.name = "FilePipe";
// 模拟的根存储路径,实际环境可能对应 IndexedDB 的 Store Name 或 Node 的 baseDir
this.basePath = "/virtual_fs/";
this.name = 'FilePipe';
}
/**
* 安全路径解析与校验
* @param {string} plugin 插件名称(命名空间)
* @param {string} relativePath 相对路径
* @returns {string|null} 合法的绝对路径,如果违规则返回 null
*/
_resolvePath(plugin, relativePath) {
// ── 安全路径校验 ─────────────────────────────────────────────────────────
_safePath(plugin, path) {
if (!plugin || typeof plugin !== 'string') {
console.error(`[FilePipe] Security Error: Invalid plugin identity.`);
console.error('[FilePipe] 无效的插件标识。');
return null;
}
// 简单防越权:禁止包含 ".."
if (relativePath.includes('..')) {
console.error(`[FilePipe] Security Error: Directory traversal attempt blocked for plugin '${plugin}'. Path: ${relativePath}`);
if (!path || typeof path !== 'string') {
console.error('[FilePipe] 无效的路径。');
return null;
}
// 强制限定在插件目录下
// 格式: /virtual_fs/PluginName/filename
return `${this.basePath}${plugin}/${relativePath}`;
if (path.includes('..')) {
console.error(`[FilePipe] 安全拦截:插件 "${plugin}" 尝试目录穿越,路径: ${path}`);
return null;
}
// 规范化:去掉开头的斜杠
return path.replace(/^\/+/, '');
}
// ── 公开 API ─────────────────────────────────────────────────────────────
/**
* 读取文件
* @param {string} plugin 调用方插件名
* @param {string} path 文件相对路径
* 读取文件
* @param {string} plugin 插件名(命名空间)
* @param {string} path 文件路径(相对于插件根目录)
* @returns {Promise<any>} 存储的数据,不存在时返回 null
*/
async read(plugin, path) {
const safePath = this._resolvePath(plugin, path);
const safePath = this._safePath(plugin, path);
if (!safePath) return null;
console.log(`[FilePipe] Reading from: ${safePath}`);
// TODO: Implement actual file reading logic
return null;
try {
const db = await _openDB();
const result = await _idbRequest(_tx(db, 'readonly').get([plugin, safePath]));
return result?.data ?? null;
} catch (e) {
console.error(`[FilePipe] read 失败 (${plugin}/${path}):`, e);
return null;
}
}
/**
* 写入文件
* @param {string} plugin 调用方插件名
* @param {string} path 文件相对路径
* @param {any} data 数据
* 写入文件
* @param {string} plugin 插件名
* @param {string} path 文件路径
* @param {any} data 任意可序列化数据对象、字符串、ArrayBuffer 等)
* @returns {Promise<boolean>}
*/
async write(plugin, path, data) {
const safePath = this._resolvePath(plugin, path);
const safePath = this._safePath(plugin, path);
if (!safePath) return false;
console.log(`[FilePipe] Writing to: ${safePath}`);
// TODO: Implement actual file writing logic
return true;
try {
const db = await _openDB();
await _idbRequest(_tx(db, 'readwrite').put({
plugin,
path: safePath,
data,
updatedAt: new Date().toISOString(),
}));
return true;
} catch (e) {
console.error(`[FilePipe] write 失败 (${plugin}/${path}):`, e);
return false;
}
}
/**
* 删除文件。
* @param {string} plugin
* @param {string} path
* @returns {Promise<boolean>}
*/
async delete(plugin, path) {
const safePath = this._safePath(plugin, path);
if (!safePath) return false;
try {
const db = await _openDB();
await _idbRequest(_tx(db, 'readwrite').delete([plugin, safePath]));
return true;
} catch (e) {
console.error(`[FilePipe] delete 失败 (${plugin}/${path}):`, e);
return false;
}
}
/**
* 列出插件下所有文件的路径(可按前缀过滤)。
* @param {string} plugin
* @param {string} [prefix=''] 路径前缀过滤
* @returns {Promise<string[]>}
*/
async list(plugin, prefix = '') {
if (!plugin) return [];
try {
const db = await _openDB();
const store = _tx(db, 'readonly');
const index = store.index('by_plugin');
const range = IDBKeyRange.only(plugin);
return new Promise((resolve, reject) => {
const paths = [];
const req = index.openCursor(range);
req.onsuccess = (e) => {
const cursor = e.target.result;
if (!cursor) { resolve(paths); return; }
if (!prefix || cursor.value.path.startsWith(prefix)) {
paths.push(cursor.value.path);
}
cursor.continue();
};
req.onerror = (e) => reject(e.target.error);
});
} catch (e) {
console.error(`[FilePipe] list 失败 (${plugin}):`, e);
return [];
}
}
/**
* 清空插件下的所有文件(插件卸载/重置时调用)。
* @param {string} plugin
* @returns {Promise<number>} 删除的文件数量
*/
async clearAll(plugin) {
const paths = await this.list(plugin);
let count = 0;
for (const path of paths) {
if (await this.delete(plugin, path)) count++;
}
console.info(`[FilePipe] 已清除插件 "${plugin}" 的 ${count} 个文件。`);
return count;
}
/**
* 读取文件元数据(不含 data 本身)。
* @param {string} plugin
* @param {string} path
* @returns {Promise<{path, updatedAt}|null>}
*/
async stat(plugin, path) {
const safePath = this._safePath(plugin, path);
if (!safePath) return null;
try {
const db = await _openDB();
const result = await _idbRequest(_tx(db, 'readonly').get([plugin, safePath]));
if (!result) return null;
return { path: result.path, updatedAt: result.updatedAt };
} catch (e) {
return null;
}
}
/**
* 生成绑定了插件名的快捷访问对象(供 Amily2Bus capability token 注入用)。
* 使用方不需要每次传 plugin 参数。
*
* 示例:
* const file = filePipe.forPlugin('TableSystem');
* await file.write('presets.json', data);
*
* @param {string} plugin
* @returns {{ read, write, delete, list, clearAll, stat }}
*/
forPlugin(plugin) {
return {
read: (path) => this.read(plugin, path),
write: (path, data) => this.write(plugin, path, data),
delete: (path) => this.delete(plugin, path),
list: (prefix) => this.list(plugin, prefix),
clearAll: () => this.clearAll(plugin),
stat: (path) => this.stat(plugin, path),
};
}
}

351
SL/module/Module.js Normal file
View File

@@ -0,0 +1,351 @@
export class Module {
constructor(builder) {
if (!builder || typeof builder.build !== 'function') {
throw new Error('[Module] Invalid builder.');
}
this.builder = builder;
this.config = builder.build();
this.ctx = null;
this.el = null;
this.viewEl = null;
this._bindings = [];
this._disposables = [];
this._state = { ...(this.config.state || {}) };
}
async init(ctx = {}) {
this.ctx = ctx;
await this._loadView();
this._bindAll();
return this;
}
async mount() {
if (this._isStrict('mount')) {
this._abstract('mount');
}
}
dispose() {
if (this._isStrict('dispose')) {
this._abstract('dispose');
}
this._unbindAll();
for (const d of this._disposables) {
try { d(); } catch (_) { /* noop */ }
}
this._disposables = [];
}
expose() {
return {};
}
getState() {
return { ...this._state };
}
setState(next) {
if (!next || typeof next !== 'object') return;
Object.assign(this._state, next);
this._applyStateToBindings();
}
registerDisposable(fn) {
if (typeof fn === 'function') {
this._disposables.push(fn);
}
}
_abstract(methodName) {
throw new Error(`[Module] Method not implemented: ${methodName}`);
}
_isStrict(methodName) {
if (!this.config.strict) return false;
const required = this.config.requiredMethods || [];
return required.includes(methodName);
}
async _loadView() {
const viewPath = this.config.view;
if (!viewPath) return;
const rootTarget = this._resolveRoot();
if (!rootTarget) {
throw new Error('[Module] Root element not found.');
}
const url = this._resolveViewUrl(viewPath);
const res = await fetch(url);
if (!res.ok) {
throw new Error(`[Module] Failed to load view: ${viewPath}`);
}
const html = await res.text();
const wrapper = document.createElement('div');
wrapper.className = this.config.wrapperClass || 'amily2-module';
wrapper.dataset.module = this.config.name || 'Module';
wrapper.innerHTML = html;
rootTarget.appendChild(wrapper);
this.el = wrapper;
this.viewEl = wrapper;
}
_resolveRoot() {
if (this.config.rootSelector) {
return document.querySelector(this.config.rootSelector);
}
if (this.ctx && this.ctx.root instanceof HTMLElement) {
return this.ctx.root;
}
return document.body;
}
_resolveViewUrl(viewPath) {
if (/^(https?:)?\/\//.test(viewPath) || viewPath.startsWith('/')) {
return viewPath;
}
if (this.ctx && this.ctx.baseUrl) {
const baseUrl = this.ctx.baseUrl;
const absoluteBase = /^(https?:)?\/\//.test(baseUrl)
? baseUrl
: `${window.location.origin}/${String(baseUrl).replace(/^\/+/, '')}`;
return new URL(viewPath, absoluteBase).toString();
}
return new URL(viewPath, import.meta.url).toString();
}
_bindAll() {
this._bindVars();
this._bindEvents();
}
_bindVars() {
const bindings = this._normalizeBindings(this.config.bindVars);
for (const [selector, spec] of Object.entries(bindings)) {
const el = this._query(selector);
if (!el) continue;
const normalized = this._normalizeVarSpec(spec);
const { key, attr, event, parser, formatter } = normalized;
const applyValue = () => {
const value = formatter ? formatter(this._state[key]) : this._state[key];
if (attr === 'checked') {
el.checked = !!value;
} else if (attr in el) {
el[attr] = value ?? '';
} else {
el.setAttribute(attr, value ?? '');
}
};
const onInput = (e) => {
let value;
if (attr === 'checked') {
value = e.target.checked;
} else if (attr in e.target) {
value = e.target[attr];
} else {
value = e.target.getAttribute(attr);
}
this._state[key] = parser ? parser(value) : value;
};
applyValue();
el.addEventListener(event, onInput);
this._bindings.push(() => el.removeEventListener(event, onInput));
}
}
_bindEvents() {
const bindings = this._normalizeBindings(this.config.bindEvents);
for (const [selector, events] of Object.entries(bindings)) {
const el = this._query(selector);
if (!el) continue;
for (const [eventName, handler] of Object.entries(events)) {
const fn = typeof handler === 'function'
? handler.bind(this)
: (this[handler] ? this[handler].bind(this) : null);
if (!fn) continue;
el.addEventListener(eventName, fn);
this._bindings.push(() => el.removeEventListener(eventName, fn));
}
}
}
_applyStateToBindings() {
const bindings = this._normalizeBindings(this.config.bindVars);
for (const [selector, spec] of Object.entries(bindings)) {
const el = this._query(selector);
if (!el) continue;
const normalized = this._normalizeVarSpec(spec);
const { key, attr, formatter } = normalized;
const value = formatter ? formatter(this._state[key]) : this._state[key];
if (attr === 'checked') {
el.checked = !!value;
} else if (attr in el) {
el[attr] = value ?? '';
} else {
el.setAttribute(attr, value ?? '');
}
}
}
_normalizeVarSpec(spec) {
if (typeof spec === 'string') {
return {
key: spec,
attr: 'value',
event: 'input',
parser: null,
formatter: null,
};
}
const attr = spec.attr || (spec.type === 'checkbox' ? 'checked' : 'value');
const event = spec.event || (attr === 'checked' ? 'change' : 'input');
return {
key: spec.key,
attr,
event,
parser: spec.parser || null,
formatter: spec.formatter || null,
};
}
_normalizeBindings(bindings) {
if (!bindings) return {};
if (Array.isArray(bindings)) {
const out = {};
for (const pair of bindings) {
if (pair && typeof pair.selector === 'string') {
out[pair.selector] = pair.value;
}
}
return out;
}
if (bindings && typeof bindings === 'object') {
return bindings;
}
return {};
}
_query(selector) {
if (!selector) return null;
if (this.viewEl) {
return this.viewEl.querySelector(selector);
}
return document.querySelector(selector);
}
_unbindAll() {
for (const unbind of this._bindings) {
try { unbind(); } catch (_) { /* noop */ }
}
this._bindings = [];
}
}
export class ModuleBuilder {
constructor() {
this._config = {
name: '',
view: '',
rootSelector: '',
wrapperClass: '',
strict: false,
requiredMethods: [],
bindVars: {},
bindEvents: {},
state: {},
};
}
name(value) {
this._config.name = value;
return this;
}
view(path) {
this._config.view = path;
return this;
}
root(selector) {
this._config.rootSelector = selector;
return this;
}
wrapperClass(name) {
this._config.wrapperClass = name;
return this;
}
strict(flag = true) {
this._config.strict = !!flag;
return this;
}
required(methods = []) {
this._config.requiredMethods = Array.isArray(methods) ? methods : [];
return this;
}
state(initialState = {}) {
this._config.state = { ...initialState };
return this;
}
bindVar(map = {}) {
this._config.bindVars = this._mergeBindings(this._config.bindVars, map);
return this;
}
bindEvent(map = {}) {
this._config.bindEvents = this._mergeBindings(this._config.bindEvents, map);
return this;
}
build() {
if (!this._config.name) {
this._config.name = 'Module';
}
return { ...this._config };
}
_mergeBindings(current, next) {
const base = Array.isArray(current) ? this._pairsToObject(current) : { ...(current || {}) };
if (Array.isArray(next)) {
return { ...base, ...this._pairsToObject(next) };
}
if (next && typeof next === 'object') {
return { ...base, ...next };
}
return base;
}
_pairsToObject(pairs) {
const out = {};
for (const pair of pairs) {
if (pair && typeof pair.selector === 'string') {
out[pair.selector] = pair.value;
}
}
return out;
}
}
export default ModuleBuilder;
export class BindingPair {
constructor(selector, value) {
if (!selector || typeof selector !== 'string') {
throw new Error('[BindingPair] selector must be a string.');
}
this.selector = selector;
this.value = value;
}
}

22
SL/module/TableModule.js Normal file
View File

@@ -0,0 +1,22 @@
import { Module, ModuleBuilder } from './Module.js';
import { bindTableEvents } from '../../ui/table-bindings.js';
const builder = new ModuleBuilder()
.name('TableModule')
.view('assets/amily-data-table/Memorisation-forms.html')
.strict(true)
.required(['mount']);
export default class TableModule extends Module {
constructor() {
super(builder);
}
async mount() {
if (this.el) {
this.el.id = 'amily2_memorisation_forms_panel';
this.el.style.display = 'none';
}
bindTableEvents();
}
}

View File

@@ -103,15 +103,15 @@
<div class="amily2_settings_block">
<label for="amily2_max_tokens">最大Token数: <span id="amily2_max_tokens_value"></span></label>
<input id="amily2_max_tokens" type="range" min="100" max="100000" step="50" />
<input id="amily2_max_tokens" type="number" class="text_pole" min="100" max="100000"/>
</div>
<div class="amily2_settings_block">
<label for="amily2_temperature">思考活跃度: <span id="amily2_temperature_value"></span></label>
<input id="amily2_temperature" type="range" min="0" max="2" step="0.1" />
<input id="amily2_temperature" type="number" class="text_pole" min="0" max="2"/>
</div>
<div class="amily2_settings_block">
<label for="amily2_context_messages">上下文参考数: <span id="amily2_context_messages_value"></span></label>
<input id="amily2_context_messages" type="range" min="0" max="10" step="1" />
<input id="amily2_context_messages" type="number" class="text_pole" min="0" max="10"/>
</div>
</fieldset>

View File

@@ -73,9 +73,9 @@
</div>
<label for="amily2_jqyh_max_tokens">最大 Tokens: <span id="amily2_jqyh_max_tokens_value">4000</span></label>
<input type="range" id="amily2_jqyh_max_tokens" min="100" max="100000" step="100" value="4000">
<input type="number" class="text_pole" id="amily2_jqyh_max_tokens" min="100" max="100000" value="4000">
<label for="amily2_jqyh_temperature">温度: <span id="amily2_jqyh_temperature_value">0.7</span></label>
<input type="range" id="amily2_jqyh_temperature" min="0" max="2" step="0.1" value="0.7">
<input type="number" class="text_pole" id="amily2_jqyh_temperature" min="0" max="2" value="0.7">
</div>
</fieldset>
@@ -104,7 +104,7 @@
<select id="amily2_plotOpt_concurrentModel_select" class="text_pole" style="display: none;"></select>
</div>
<label for="amily2_plotOpt_concurrentMaxTokens">最大 Tokens: <span id="amily2_plotOpt_concurrentMaxTokens_value">8100</span></label>
<input type="range" id="amily2_plotOpt_concurrentMaxTokens" min="100" max="100000" step="100" value="8100">
<input type="number" class="text_pole" id="amily2_plotOpt_concurrentMaxTokens" min="100" max="100000" value="8100">
<div class="jqyh-button-row" style="grid-column: 1 / -1;">
<button id="amily2_plotOpt_concurrent_fetch_models" class="menu_button secondary" title="获取模型列表"><i class="fas fa-sync-alt"></i> 获取模型</button>
<button id="amily2_plotOpt_concurrent_test_connection" class="menu_button primary"><i class="fas fa-plug"></i> 测试连接</button>
@@ -142,7 +142,7 @@
</div>
<div class="inline-settings-grid">
<label for="amily2_plotOpt_concurrentWorldbookCharLimit">世界书最大字符数: <span id="amily2_plotOpt_concurrentWorldbookCharLimit_value">60000</span></label>
<input type="range" id="amily2_plotOpt_concurrentWorldbookCharLimit" min="1000" max="200000" step="1000" value="60000">
<input type="number" class="text_pole" id="amily2_plotOpt_concurrentWorldbookCharLimit" min="1000" max="200000" value="60000">
</div>
</div>
</fieldset>
@@ -236,9 +236,9 @@
<legend>上下文参数</legend>
<div class="inline-settings-grid">
<label for="amily2_opt_context_limit">上下文条数: <span id="amily2_opt_context_limit_value">10</span></label>
<input type="range" id="amily2_opt_context_limit" min="1" max="50" step="1" value="10">
<input type="number" class="text_pole" id="amily2_opt_context_limit" min="1" max="50" value="10">
<label for="amily2_opt_worldbook_char_limit">世界书最大字符数: <span id="amily2_opt_worldbook_char_limit_value">60000</span></label>
<input type="range" id="amily2_opt_worldbook_char_limit" min="1000" max="200000" step="1000" value="60000">
<input type="number" class="text_pole" id="amily2_opt_worldbook_char_limit" min="1000" max="200000" value="60000">
</div>
</fieldset>
<fieldset class="settings-group">

View File

@@ -183,11 +183,11 @@
<!-- 通用参数配置 -->
<div class="control-group">
<label for="amily2_ngms_max_tokens">最大令牌数:<span id="amily2_ngms_max_tokens_value">4000</span></label>
<input type="range" id="amily2_ngms_max_tokens" min="100" max="100000" step="100" value="4000" />
<input type="number" class="text_pole" id="amily2_ngms_max_tokens" min="100" max="100000" value="4000" />
</div>
<div class="control-group">
<label for="amily2_ngms_temperature">温度:<span id="amily2_ngms_temperature_value">0.7</span></label>
<input type="range" id="amily2_ngms_temperature" min="0" max="2" step="0.1" value="0.7" />
<input type="number" class="text_pole" id="amily2_ngms_temperature" min="0" max="2" value="0.7" />
</div>
<div class="control-group" style="display: flex; align-items: center; gap: 10px;">

View File

@@ -135,7 +135,7 @@
<div class="amily2_opt_settings_block">
<label for="table_worldbook_char_limit">世界书最大字符数: <span id="table_worldbook_char_limit_value">60000</span></label>
<input type="range" id="table_worldbook_char_limit" min="1000" max="200000" step="1000" value="60000">
<input type="number" class="text_pole" id="table_worldbook_char_limit" min="1000" max="200000" value="60000">
</div>
<hr>
@@ -328,12 +328,12 @@
<div class="amily2_opt_settings_block" style="margin-bottom: 10px;">
<label for="nccs-max-tokens">最大Token数: <span id="nccs-max-tokens-value">2000</span></label>
<input type="range" id="nccs-max-tokens" min="100" max="100000" step="100" value="2000">
<input type="number" class="text_pole" id="nccs-max-tokens" min="100" max="100000" value="2000">
</div>
<div class="amily2_opt_settings_block" style="margin-bottom: 10px;">
<label for="nccs-temperature">Temperature: <span id="nccs-temperature-value">0.7</span></label>
<input type="range" id="nccs-temperature" min="0" max="2" step="0.1" value="0.7">
<input type="number" class="text_pole" id="nccs-temperature" min="0" max="2" value="0.7">
</div>
<div class="amily2_opt_settings_block" style="margin-bottom: 10px;">

View File

@@ -76,11 +76,13 @@
<!-- 通用参数配置 -->
<div class="control-group">
<label for="amily2_sybd_max_tokens">最大令牌数:<span id="amily2_sybd_max_tokens_value">4000</span></label>
<input type="range" id="amily2_sybd_max_tokens" min="100" max="100000" step="100" value="4000" data-setting-key="sybdMaxTokens" data-type="integer" />
<input type="number" class="text_pole" id="amily2_sybd_max_tokens" min="100" max="100000" value="4000"
data-setting-key="sybdMaxTokens" data-type="integer" />
</div>
<div class="control-group">
<label for="amily2_sybd_temperature">温度:<span id="amily2_sybd_temperature_value">0.7</span></label>
<input type="range" id="amily2_sybd_temperature" min="0" max="2" step="0.1" value="0.7" data-setting-key="sybdTemperature" data-type="float" />
<input type="number" class="text_pole" id="amily2_sybd_temperature" min="0" max="2" value="0.7"
data-setting-key="sybdTemperature" data-type="float" />
</div>
<!-- 测试按钮组 - 水平排列 -->

View File

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

View File

@@ -0,0 +1,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>

View File

@@ -1,5 +1,6 @@
import { extension_settings, getContext } from "/scripts/extensions.js";
import { characters } from "/script.js";
import { getSlotProfile } from './api/api-resolver.js';
import { world_names } from "/scripts/world-info.js";
import { extensionName } from "../utils/settings.js";
import { extractContentByTag, replaceContentByTag, extractFullTagBlock } from '../utils/tagProcessor.js';
@@ -433,7 +434,22 @@ 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 apiProvider = document.getElementById('amily2_api_provider')?.value || 'openai';
@@ -441,20 +457,20 @@ export function getApiSettings() {
if (apiProvider === 'sillytavern_preset') {
const context = getContext();
const profileId = document.getElementById('amily2_preset_selector')?.value;
const profile = context.extensionSettings?.connectionManager?.profiles?.find(p => p.id === profileId);
model = profile?.openai_model || 'Preset Model';
const stProfile = context.extensionSettings?.connectionManager?.profiles?.find(p => p.id === profileId);
model = stProfile?.openai_model || 'Preset Model';
} else {
model = document.getElementById('amily2_model')?.value;
}
return {
apiProvider: apiProvider,
apiUrl: document.getElementById('amily2_api_url')?.value.trim() || '',
apiKey: document.getElementById('amily2_api_key')?.value.trim() || '',
model: model,
maxTokens: settings.maxTokens || 4000,
temperature: settings.temperature || 0.7,
tavernProfile: document.getElementById('amily2_preset_selector')?.value || ''
apiProvider,
apiUrl: document.getElementById('amily2_api_url')?.value.trim() || '',
apiKey: document.getElementById('amily2_api_key')?.value.trim() || '',
model,
maxTokens: settings.maxTokens || 4000,
temperature: settings.temperature || 0.7,
tavernProfile: document.getElementById('amily2_preset_selector')?.value || '',
};
}
@@ -468,7 +484,7 @@ export async function testApiConnection() {
$button.prop("disabled", true).html('<i class="fas fa-spinner fa-spin"></i> 测试中');
try {
const apiSettings = getApiSettings();
const apiSettings = await getApiSettings();
if (apiSettings.apiProvider === 'sillytavern_preset') {
if (!apiSettings.tavernProfile) {
@@ -518,7 +534,7 @@ export async function callAI(messages, options = {}) {
return null;
}
const apiSettings = getApiSettings();
const apiSettings = await getApiSettings();
const finalOptions = {
maxTokens: apiSettings.maxTokens,

View File

@@ -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 { extensionName } from "../../utils/settings.js";
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
let ChatCompletionService = undefined;
try {
@@ -42,15 +43,30 @@ function normalizeApiResponse(responseData) {
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 {
apiMode: extension_settings[extensionName]?.jqyhApiMode || 'openai_test',
apiUrl: extension_settings[extensionName]?.jqyhApiUrl?.trim() || '',
apiKey: extension_settings[extensionName]?.jqyhApiKey?.trim() || '',
model: extension_settings[extensionName]?.jqyhModel || '',
maxTokens: extension_settings[extensionName]?.jqyhMaxTokens || 4000,
temperature: extension_settings[extensionName]?.jqyhTemperature || 0.7,
tavernProfile: extension_settings[extensionName]?.jqyhTavernProfile || ''
apiMode: extension_settings[extensionName]?.jqyhApiMode || 'openai_test',
apiUrl: extension_settings[extensionName]?.jqyhApiUrl?.trim() || '',
apiKey: extension_settings[extensionName]?.jqyhApiKey?.trim() || '',
model: extension_settings[extensionName]?.jqyhModel || '',
maxTokens: extension_settings[extensionName]?.jqyhMaxTokens || 4000,
temperature: extension_settings[extensionName]?.jqyhTemperature || 0.7,
tavernProfile: extension_settings[extensionName]?.jqyhTavernProfile || '',
};
}
@@ -60,7 +76,7 @@ export async function callJqyhAI(messages, options = {}) {
return null;
}
const apiSettings = getJqyhApiSettings();
const apiSettings = await getJqyhApiSettings();
const finalOptions = {
maxTokens: apiSettings.maxTokens,
@@ -258,7 +274,7 @@ async function callJqyhSillyTavernPreset(messages, options) {
export async function fetchJqyhModels() {
console.log('[Amily2号-Jqyh外交部] 开始获取模型列表');
const apiSettings = getJqyhApiSettings();
const apiSettings = await getJqyhApiSettings();
try {
if (apiSettings.apiMode === 'sillytavern_preset') {
@@ -339,7 +355,7 @@ export async function fetchJqyhModels() {
export async function testJqyhApiConnection() {
console.log('[Amily2号-Jqyh外交部] 开始API连接测试');
const apiSettings = getJqyhApiSettings();
const apiSettings = await getJqyhApiSettings();
if (apiSettings.apiMode === 'sillytavern_preset') {
if (!apiSettings.tavernProfile) {

View File

@@ -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 { extensionName } from "../../utils/settings.js";
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
let ChatCompletionService = undefined;
try {
@@ -36,17 +37,34 @@ if (window.Amily2Bus) {
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 {
nccsEnabled: extension_settings[extensionName]?.nccsEnabled || false,
apiMode: extension_settings[extensionName]?.nccsApiMode || 'openai_test',
apiUrl: extension_settings[extensionName]?.nccsApiUrl?.trim() || '',
apiKey: extension_settings[extensionName]?.nccsApiKey?.trim() || '',
model: extension_settings[extensionName]?.nccsModel || '',
maxTokens: extension_settings[extensionName]?.nccsMaxTokens || 4000,
temperature: extension_settings[extensionName]?.nccsTemperature || 0.7,
nccsEnabled: extension_settings[extensionName]?.nccsEnabled || false,
apiMode: extension_settings[extensionName]?.nccsApiMode || 'openai_test',
apiUrl: extension_settings[extensionName]?.nccsApiUrl?.trim() || '',
apiKey: extension_settings[extensionName]?.nccsApiKey?.trim() || '',
model: extension_settings[extensionName]?.nccsModel || '',
maxTokens: extension_settings[extensionName]?.nccsMaxTokens || 4000,
temperature: extension_settings[extensionName]?.nccsTemperature || 0.7,
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;
}
const settings = getNccsApiSettings();
const settings = await getNccsApiSettings();
const finalOptions = {
...settings,
...options
@@ -238,7 +256,7 @@ async function callNccsSillyTavernPreset(messages, options) {
export async function fetchNccsModels() {
console.log('[Amily2号-Nccs外交部] 开始获取模型列表');
const apiSettings = getNccsApiSettings();
const apiSettings = await getNccsApiSettings();
try {
if (apiSettings.apiMode === 'sillytavern_preset') {
@@ -320,7 +338,7 @@ export async function fetchNccsModels() {
export async function testNccsApiConnection() {
console.log('[Amily2号-Nccs外交部] 开始API连接测试');
const apiSettings = getNccsApiSettings();
const apiSettings = await getNccsApiSettings();
if (apiSettings.apiMode === 'sillytavern_preset') {
if (!apiSettings.tavernProfile) {

View File

@@ -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 { extensionName } from "../../utils/settings.js";
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
let ChatCompletionService = undefined;
try {
@@ -42,16 +43,32 @@ function normalizeApiResponse(responseData) {
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 {
apiMode: extension_settings[extensionName]?.ngmsApiMode || 'openai_test',
apiUrl: extension_settings[extensionName]?.ngmsApiUrl?.trim() || '',
apiKey: extension_settings[extensionName]?.ngmsApiKey?.trim() || '',
model: extension_settings[extensionName]?.ngmsModel || '',
maxTokens: extension_settings[extensionName]?.ngmsMaxTokens || 4000,
temperature: extension_settings[extensionName]?.ngmsTemperature || 0.7,
apiMode: extension_settings[extensionName]?.ngmsApiMode || 'openai_test',
apiUrl: extension_settings[extensionName]?.ngmsApiUrl?.trim() || '',
apiKey: extension_settings[extensionName]?.ngmsApiKey?.trim() || '',
model: extension_settings[extensionName]?.ngmsModel || '',
maxTokens: extension_settings[extensionName]?.ngmsMaxTokens || 4000,
temperature: extension_settings[extensionName]?.ngmsTemperature || 0.7,
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;
}
const apiSettings = getNgmsApiSettings();
const apiSettings = await getNgmsApiSettings();
const finalOptions = {
maxTokens: apiSettings.maxTokens,
@@ -324,7 +341,7 @@ async function callNgmsSillyTavernPreset(messages, options) {
export async function fetchNgmsModels() {
console.log('[Amily2号-Ngms外交部] 开始获取模型列表');
const apiSettings = getNgmsApiSettings();
const apiSettings = await getNgmsApiSettings();
try {
if (apiSettings.apiMode === 'sillytavern_preset') {
@@ -407,7 +424,7 @@ export async function fetchNgmsModels() {
export async function testNgmsApiConnection() {
console.log('[Amily2号-Ngms外交部] 开始API连接测试');
const apiSettings = getNgmsApiSettings();
const apiSettings = await getNgmsApiSettings();
if (apiSettings.apiMode === 'sillytavern_preset') {
if (!apiSettings.tavernProfile) {

45
core/api/api-resolver.js Normal file
View 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;
}
}

View File

@@ -1,72 +1,16 @@
import { getContext, extension_settings } from "/scripts/extensions.js";
import { saveChatConditional } from "/script.js";
import { extensionName } from "../utils/settings.js";
import * as TableManager from './table-system/manager.js';
import * as Executor from './table-system/executor.js';
import { renderTables } from '../ui/table-bindings.js';
import { log } from "./table-system/logger.js";
async function handleTableUpdate(messageId) {
TableManager.clearHighlights();
const settings = extension_settings[extensionName];
const tableSystemEnabled = settings.table_system_enabled !== false;
if (!tableSystemEnabled) {
log('【监察系统】表格系统总开关已关闭,跳过所有表格处理。', 'info');
return;
}
const fillingMode = settings.filling_mode || 'main-api';
if (fillingMode === 'secondary-api' || fillingMode === 'optimized') {
log('【监察系统】检测到"分步填表"或"优化中填表"模式已启用主API填表逻辑已自动禁用。', 'info');
return;
}
log(`【监察系统】接到圣旨,开始处理消息 ID: ${messageId}`, 'warn');
const context = getContext();
const message = context.chat[messageId];
if (!message) {
log(`【监察系统】错误:未找到消息 ID: ${messageId},流程中止。`, 'error');
return;
}
if (message.is_user) {
log(`【监察系统】消息 ID: ${messageId} 是用户消息,无需处理。`, 'info');
return;
}
log(`【监察系统】正在处理的奏折内容: "${message.mes.substring(0, 50)}..."`, 'info');
const initialState = TableManager.loadTables(messageId);
log(`【监察系统-步骤1】为消息 ${messageId} 加载了基准状态。`, 'info', initialState);
const { finalState, hasChanges, changes } = Executor.executeCommands(message.mes, initialState);
log(`【监察系统-步骤2】推演完毕。是否有变化: ${hasChanges}`, 'info', finalState);
if (hasChanges) {
if (changes && changes.length > 0) {
changes.forEach(change => {
TableManager.addHighlight(change.tableIndex, change.rowIndex, change.colIndex);
});
}
TableManager.saveStateToMessage(finalState, message);
TableManager.setMemoryState(finalState);
await saveChatConditional();
log(`【监察系统-步骤3】检测到变化已将新状态写入消息 ${messageId} 并保存。`, 'success');
} else {
log(`【监察系统-步骤3】未检测到有效指令或变化无需写入。`, 'info');
}
if (hasChanges) {
renderTables();
}
}
import { processMessageUpdate, fillWithSecondaryApi } from './table-system/TableSystemService.js';
import { processOptimization } from "./summarizer.js";
import { executeAutoHide } from './autoHideManager.js';
import { checkAndTriggerAutoSummary } from './historiographer.js';
import { fillWithSecondaryApi } from './table-system/secondary-filler.js';
import { amilyHelper } from './tavern-helper/main.js';
async function handleTableUpdate(messageId) {
await processMessageUpdate(messageId);
}
export async function onMessageReceived(data) {
window.lastPreOptimizationResult = null;
document.dispatchEvent(new CustomEvent('preOptimizationTextUpdated'));
@@ -84,6 +28,7 @@ export async function onMessageReceived(data) {
const tableSystemEnabled = settings.table_system_enabled !== false;
await executeAutoHide();
const isOptimizationEnabled = settings.optimizationEnabled && settings.apiUrl;
if (isOptimizationEnabled) {
if (chat.length >= 2 && chat[chat.length - 2].is_user) {
@@ -109,13 +54,14 @@ export async function onMessageReceived(data) {
console.log("[Amily2号-正文优化] 检测到消息并非AI对用户的直接回复已跳过优化。");
}
}
if (tableSystemEnabled) {
const fillingMode = settings.filling_mode || 'main-api';
if (fillingMode === 'secondary-api') {
fillWithSecondaryApi(latestMessage);
}
} else {
log('[分步填表] 表格系统总开关已关闭,跳过分步填表处理。', 'info');
console.log('[分步填表] 表格系统总开关已关闭,跳过分步填表处理。');
}
(async () => {

View File

@@ -0,0 +1,122 @@
/**
* TableSystemService
* 表格系统 Bus 服务 — 统一对外入口
*
* 职责:
* 1. 将原 events.js::handleTableUpdate 的消息处理编排逻辑收归此处
* 2. 通过 Amily2Bus 暴露稳定接口,解耦外部模块的直接依赖
* 3. 向后兼容:保留具名导出,现有直接 import 无需立即修改
*
* Bus 注册名:'TableSystem'
*
* 公开接口query('TableSystem')
* processMessageUpdate(messageId) — 处理 AI 消息的表格更新流程
* fillWithSecondaryApi(msg) — 二次 API 填表
* injectTableData(...) — 向提示词注入表格数据
* generateTableContent() — 生成表格注入内容字符串
* getMemoryState() — 读取当前表格内存状态
* renderTables() — 强制重渲染表格 UI
*/
import { getContext, extension_settings } from "/scripts/extensions.js";
import { saveChatConditional } from "/script.js";
import { extensionName } from "../../utils/settings.js";
// ── table-system 内部模块 ─────────────────────────────────────────────────
// manager.js / logger.js 为受限文件(不修改),此处仅引用其导出
import * as TableManager from './manager.js';
import { executeCommands } from './executor.js';
import { log } from './logger.js';
// 可修改子模块
import { generateTableContent, injectTableData } from './injector.js';
import { fillWithSecondaryApi } from './secondary-filler.js';
// UI 层
import { renderTables } from '../../ui/table-bindings.js';
// ── 核心逻辑 ─────────────────────────────────────────────────────────────
/**
* 处理单条 AI 消息的表格更新流程。
* 原 events.js::handleTableUpdate 的完整逻辑迁移至此。
*
* @param {number} messageId - 消息在 context.chat 中的索引
*/
async function processMessageUpdate(messageId) {
TableManager.clearHighlights();
const settings = extension_settings[extensionName];
const tableSystemEnabled = settings.table_system_enabled !== false;
if (!tableSystemEnabled) {
log('【表格服务】表格系统总开关已关闭,跳过所有表格处理。', 'info');
return;
}
const fillingMode = settings.filling_mode || 'main-api';
if (fillingMode === 'secondary-api' || fillingMode === 'optimized') {
log('【表格服务】检测到"分步填表"或"优化中填表"模式主API填表已自动禁用。', 'info');
return;
}
log(`【表格服务】开始处理消息 ID: ${messageId}`, 'warn');
const context = getContext();
const message = context.chat[messageId];
if (!message) {
log(`【表格服务】错误:未找到消息 ID: ${messageId},流程中止。`, 'error');
return;
}
if (message.is_user) {
log(`【表格服务】消息 ID: ${messageId} 是用户消息,跳过。`, 'info');
return;
}
log(`【表格服务】处理内容: "${message.mes.substring(0, 50)}..."`, 'info');
const initialState = TableManager.loadTables(messageId);
log('【表格服务-步骤1】基准状态已加载。', 'info', initialState);
const { finalState, hasChanges, changes } = executeCommands(message.mes, initialState);
log(`【表格服务-步骤2】推演完毕。是否有变化: ${hasChanges}`, 'info', finalState);
if (hasChanges) {
changes.forEach(change => {
TableManager.addHighlight(change.tableIndex, change.rowIndex, change.colIndex);
});
TableManager.saveStateToMessage(finalState, message);
TableManager.setMemoryState(finalState);
await saveChatConditional();
log('【表格服务-步骤3】状态已写入并保存。', 'success');
renderTables();
} else {
log('【表格服务-步骤3】未检测到有效指令或变化无需写入。', 'info');
}
}
// ── Bus 注册 ──────────────────────────────────────────────────────────────
// 使用 setTimeout 延迟到同步模块初始化完成后再注册,
// 确保 window.Amily2Bus 已由 SL/bus/Amily2Bus.js 完成挂载。
setTimeout(() => {
try {
const _ctx = window.Amily2Bus?.register('TableSystem');
if (!_ctx) {
console.warn('[TableSystem] Amily2Bus 尚未就绪,服务注册跳过。');
return;
}
_ctx.expose({
processMessageUpdate,
fillWithSecondaryApi,
injectTableData,
generateTableContent,
getMemoryState: () => TableManager.getMemoryState(),
renderTables,
});
_ctx.log('TableSystemService', 'info', 'TableSystem 服务已注册到 Bus。');
} catch (e) {
console.error('[TableSystem] Bus 注册失败:', e);
}
}, 0);
// ── 向后兼容具名导出 ──────────────────────────────────────────────────────
// 过渡期保留,现有 import { ... } from '...TableSystemService.js' 无需修改。
export { processMessageUpdate, fillWithSecondaryApi, generateTableContent, injectTableData };

View File

@@ -1 +1,30 @@
const _0x352fc5=_0xb01f;(function(_0x52276c,_0x1fe640){const _0x25137c=_0xb01f,_0x322b57=_0x52276c();while(!![]){try{const _0xb9a91d=parseInt(_0x25137c(0x1d4))/0x1+-parseInt(_0x25137c(0x1d9))/0x2*(parseInt(_0x25137c(0x1c6))/0x3)+parseInt(_0x25137c(0x1c8))/0x4*(-parseInt(_0x25137c(0x1da))/0x5)+-parseInt(_0x25137c(0x1d5))/0x6+-parseInt(_0x25137c(0x1c5))/0x7+parseInt(_0x25137c(0x1c4))/0x8*(-parseInt(_0x25137c(0x1cc))/0x9)+parseInt(_0x25137c(0x1ce))/0xa;if(_0xb9a91d===_0x1fe640)break;else _0x322b57['push'](_0x322b57['shift']());}catch(_0x57e5d4){_0x322b57['push'](_0x322b57['shift']());}}}(_0x13eb,0x61073));function _0xb01f(_0x2b709c,_0x43aa7d){const _0x13eb95=_0x13eb();return _0xb01f=function(_0xb01f68,_0x3325be){_0xb01f68=_0xb01f68-0x1c4;let _0x638bf1=_0x13eb95[_0xb01f68];return _0x638bf1;},_0xb01f(_0x2b709c,_0x43aa7d);}function _0x13eb(){const _0x3421fa=['createElement','\x22></i>\x20','101174LOTkJv','79935LtdznB','3176dnnOAA','861679gOvdAF','33vyMqZa','fa-solid\x20fa-check-circle','28LBJaGM','scrollHeight','fa-solid\x20fa-circle-info','getElementById','7677IWXntE','[内存储司-起居注]\x20','13572940eKjSAe','fa-solid\x20fa-circle-xmark','appendChild','log','hly-log-entry\x20log-','innerHTML','182086ttYsxR','71094AjxVJw','fa-solid\x20fa-triangle-exclamation'];_0x13eb=function(){return _0x3421fa;};return _0x13eb();}const getLogContainer=()=>document[_0x352fc5(0x1cb)]('table-log-display');export function log(_0x1e7922,_0x4de68c='info',_0x2aabe2=null){const _0x17dbe1=_0x352fc5,_0x4fdf31=getLogContainer();if(!_0x4fdf31){const _0xec84ec=console[_0x4de68c]||console[_0x17dbe1(0x1d1)];_0xec84ec(_0x17dbe1(0x1cd)+_0x1e7922,_0x2aabe2||'');return;}const _0x483576={'info':_0x17dbe1(0x1ca),'success':_0x17dbe1(0x1c7),'warn':_0x17dbe1(0x1d6),'error':_0x17dbe1(0x1cf)},_0x5bed08=document[_0x17dbe1(0x1d7)]('p');_0x5bed08['className']=_0x17dbe1(0x1d2)+_0x4de68c,_0x5bed08[_0x17dbe1(0x1d3)]='<i\x20class=\x22'+_0x483576[_0x4de68c]+_0x17dbe1(0x1d8)+_0x1e7922,_0x4fdf31[_0x17dbe1(0x1d0)](_0x5bed08),_0x4fdf31['scrollTop']=_0x4fdf31[_0x17dbe1(0x1c9)];}
const getLogContainer = () => document.getElementById('table-log-display');
export function log(message, type = 'info', data = null) {
const container = getLogContainer();
if (!container) {
// 在容器不可用时,静默地将日志打印到控制台,不再显示警告
const logFunc = console[type] || console.log;
logFunc(`[内存储司-起居注] ${message}`, data || '');
return;
}
const iconMap = {
info: 'fa-solid fa-circle-info',
success: 'fa-solid fa-check-circle',
warn: 'fa-solid fa-triangle-exclamation',
error: 'fa-solid fa-circle-xmark',
};
const logEntry = document.createElement('p');
logEntry.className = `hly-log-entry log-${type}`;
const icon = document.createElement('i');
icon.className = iconMap[type];
logEntry.appendChild(icon);
logEntry.appendChild(document.createTextNode(` ${message}`));
container.appendChild(logEntry);
// Auto-scroll to the bottom
container.scrollTop = container.scrollHeight;
}

View File

@@ -4,6 +4,10 @@ import "./PreOptimizationViewer/index.js";
import "./WorldEditor/WorldEditor.js";
import './core/amily2-updater.js';
import './SL/bus/Amily2Bus.js'
import './utils/config/ConfigManager.js'
import './utils/config/api-key-store/ApiKeyStore.js'
import './utils/config/ApiProfileManager.js'
import './core/table-system/TableSystemService.js'
// Re-exports (重新导出供 index.js 使用)
export { createDrawer } from "./ui/drawer.js";
@@ -26,6 +30,10 @@ export { log } from './core/table-system/logger.js';
export { checkForUpdates, fetchMessageBoardContent } from './core/api.js';
export { setUpdateInfo, applyUpdateIndicator } from './ui/state.js';
export { pluginVersion, extensionName, defaultSettings } from './utils/settings.js';
export { configManager } from './utils/config/ConfigManager.js';
export { apiKeyStore } from './utils/config/api-key-store/ApiKeyStore.js';
export { apiProfileManager, PROFILE_TYPES, SLOTS } from './utils/config/ApiProfileManager.js';
export { bindApiConfigPanel } from './ui/api-config-bindings.js';
export { checkAuthorization, refreshUserInfo } from './utils/auth.js';
export { tableSystemDefaultSettings } from './core/table-system/settings.js';
export { manageLorebookEntriesForChat } from './core/lore.js';

View File

@@ -15,6 +15,7 @@ import {
checkForUpdates, fetchMessageBoardContent,
setUpdateInfo, applyUpdateIndicator,
pluginVersion, extensionName, defaultSettings,
configManager,
checkAuthorization, refreshUserInfo,
tableSystemDefaultSettings,
manageLorebookEntriesForChat,
@@ -940,6 +941,7 @@ jQuery(async () => {
registerAllApiHandlers();
initializeAmilyHelper();
mergePluginSettings();
configManager.migrate(); // 将 extension_settings 中残留的敏感字段迁移到 localStorage
let attempts = 0;
const maxAttempts = 100;

535
ui/api-config-bindings.js Normal file
View 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 IDnull = 新建)
let _currentFilter = 'all'; // 当前类型筛选
// ── 入口:绑定整个面板 ────────────────────────────────────────────────────────
export function bindApiConfigPanel(container) {
const $c = $(container);
// 存储模式
_bindStorageMode($c);
// 类型筛选
$c.on('click', '.amily2_profile_type_filter', function () {
$c.find('.amily2_profile_type_filter').removeClass('active');
$(this).addClass('active');
_currentFilter = $(this).data('type');
renderProfileList($c);
});
// 新建 Profile
$c.find('#amily2_add_profile').on('click', () => openModal($c, null));
// 弹窗:类型切换时显示/隐藏专有参数
$c.find('#amily2_pf_type').on('change', function () {
_switchParamSections($c, $(this).val());
});
// 弹窗接口类型切换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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -805,7 +805,7 @@ export function bindModalEvents() {
container
.off("click.amily2.chamber_nav")
.on("click.amily2.chamber_nav",
"#amily2_open_text_optimization, #amily2_open_plot_optimization, #amily2_open_additional_features, #amily2_open_rag_palace, #amily2_open_memorisation_forms, #amily2_open_character_world_book, #amily2_open_world_editor, #amily2_open_glossary, #amily2_open_renderer, #amily2_open_super_memory, #amily2_open_auto_char_card, #amily2_back_to_main_settings, #amily2_back_to_main_from_hanlinyuan, #amily2_back_to_main_from_forms, #amily2_back_to_main_from_optimization, #amily2_back_to_main_from_text_optimization, #amily2_back_to_main_from_cwb, #amily2_back_to_main_from_world_editor, #amily2_back_to_main_from_glossary, #amily2_renderer_back_button, #amily2_back_to_main_from_super_memory", function () {
"#amily2_open_text_optimization, #amily2_open_plot_optimization, #amily2_open_additional_features, #amily2_open_rag_palace, #amily2_open_memorisation_forms, #amily2_open_character_world_book, #amily2_open_world_editor, #amily2_open_glossary, #amily2_open_renderer, #amily2_open_super_memory, #amily2_open_auto_char_card, #amily2_open_api_config, #amily2_back_to_main_settings, #amily2_back_to_main_from_hanlinyuan, #amily2_back_to_main_from_forms, #amily2_back_to_main_from_optimization, #amily2_back_to_main_from_text_optimization, #amily2_back_to_main_from_cwb, #amily2_back_to_main_from_world_editor, #amily2_back_to_main_from_glossary, #amily2_renderer_back_button, #amily2_back_to_main_from_super_memory, #amily2_back_to_main_from_api_config", function () {
if (!pluginAuthStatus.authorized) return;
const mainPanel = container.find('.plugin-features');
@@ -819,6 +819,7 @@ export function bindModalEvents() {
const glossaryPanel = container.find('#amily2_glossary_panel');
const rendererPanel = container.find('#amily2_renderer_panel');
const superMemoryPanel = container.find('#amily2_super_memory_panel');
const apiConfigPanel = container.find('#amily2_api_config_panel');
mainPanel.hide();
additionalPanel.hide();
@@ -831,6 +832,7 @@ export function bindModalEvents() {
glossaryPanel.hide();
rendererPanel.hide();
superMemoryPanel.hide();
apiConfigPanel.hide();
switch (this.id) {
case 'amily2_open_text_optimization':
@@ -875,6 +877,9 @@ export function bindModalEvents() {
case 'amily2_open_glossary':
glossaryPanel.show();
break;
case 'amily2_open_api_config':
apiConfigPanel.show();
break;
case 'amily2_back_to_main_settings':
case 'amily2_back_to_main_from_hanlinyuan':
case 'amily2_back_to_main_from_forms':
@@ -885,6 +890,7 @@ export function bindModalEvents() {
case 'amily2_back_to_main_from_glossary':
case 'amily2_renderer_back_button':
case 'amily2_back_to_main_from_super_memory':
case 'amily2_back_to_main_from_api_config':
mainPanel.show();
break;
}

View File

@@ -21,6 +21,7 @@ import { bindTableEvents } from './table-bindings.js';
import { showContentModal } from "./page-window.js";
import { initializeRendererBindings } from "../core/tavern-helper/renderer-bindings.js";
import { bindSuperMemoryEvents } from "../core/super-memory/bindings.js";
import { bindApiConfigPanel } from "./api-config-bindings.js";
const extensionFolderPath = `scripts/extensions/third-party/${extensionName}`;
@@ -115,6 +116,10 @@ async function initializePanel(contentPanel, errorContainer) {
const superMemoryPanelHtml = `<div id="amily2_super_memory_panel" style="display: none;">${superMemoryContent}</div>`;
mainContainer.append(superMemoryPanelHtml);
const apiConfigContent = await $.get(`${extensionFolderPath}/assets/api-config-panel.html`);
const apiConfigPanelHtml = `<div id="amily2_api_config_panel" style="display: none;">${apiConfigContent}</div>`;
mainContainer.append(apiConfigPanelHtml);
// 在面板创建后,加载世界书编辑器脚本
const worldEditorScriptId = 'world-editor-script';
if (!document.getElementById(worldEditorScriptId)) {
@@ -133,6 +138,7 @@ async function initializePanel(contentPanel, errorContainer) {
bindTableEvents();
initializeRendererBindings();
bindSuperMemoryEvents();
bindApiConfigPanel(mainContainer.find('#amily2_api_config_panel'));
contentPanel.data("initialized", true);
console.log("[Amily-重构] 宫殿模块已按蓝图竣工。");
applyUpdateIndicator();

View File

@@ -0,0 +1,304 @@
/**
* ApiProfileManager — API 连接配置组管理
*
* Profile 是一组完整的 API 连接参数,按模型类型分为三类:
* chat — 对话/补全模型(主 API、剧情优化、各子系统等
* embedding — 向量嵌入模型RAG 向量化)
* rerank — 重排序模型RAG 精排)
*
* 存储分离:
* Profile 元数据name、type、provider、url、model、params→ extension_settings.amily2_profiles
* API Key → ApiKeyStorelocal 或 cloud 加密)
*
* 功能分配assignments
* 记录每个系统功能当前使用哪个 Profile ID存于 extension_settings.amily2_profile_assignments
* 选单会按功能对应的 Profile 类型进行过滤,防止类型错配。
*
* Bus 注册名:'ApiProfiles'
*
* 公开接口:
* getProfiles(type?) — 获取全部或指定类型的 Profile 列表
* getProfile(id) — 获取单个 Profile 元数据
* createProfile(data) — 新建 Profile返回新 ID
* updateProfile(id, data) — 更新 Profile 元数据
* deleteProfile(id) — 删除 Profile含清理 Key
* getKey(id) — 读取 Profile 的 API Key异步自动解密
* setKey(id, value) — 写入 Profile 的 API Key异步自动加密
* getAssignment(slot) — 获取功能槽当前分配的 Profile ID
* setAssignment(slot, id) — 设置功能槽的 Profile
* getAssignedProfile(slot) — 获取功能槽完整 Profile含解密 Key
* SLOTS — 可用功能槽清单(静态)
* PROFILE_TYPES — Profile 类型定义(静态)
*/
import { extension_settings } from "/scripts/extensions.js";
import { saveSettingsDebounced } from "/script.js";
import { extensionName } from "../settings.js";
import { apiKeyStore } from "./api-key-store/ApiKeyStore.js";
// ── 类型与功能槽定义 ──────────────────────────────────────────────────────────
/** Profile 类型定义 */
export const PROFILE_TYPES = {
chat: {
label: '对话模型',
icon: 'fa-comments',
description: '用于文本生成、对话补全的模型Chat / Completion',
params: ['maxTokens', 'temperature'],
},
embedding: {
label: '向量嵌入',
icon: 'fa-project-diagram',
description: '将文本转换为向量的模型,用于 RAG 语义检索',
params: ['dimensions', 'encodingFormat'],
},
rerank: {
label: '重排序',
icon: 'fa-sort-amount-down',
description: '对检索结果重新打分排序的模型,用于 RAG 精排',
params: ['topN', 'returnDocuments'],
},
};
/** 功能槽:每个系统功能需要的 Profile 类型 */
export const SLOTS = {
// Chat 槽
main: { label: '主 API正文优化', type: 'chat' },
plotOpt: { label: '剧情优化', type: 'chat' },
plotOptConc: { label: '剧情优化(并发)', type: 'chat' },
ngms: { label: 'NGMS 历史记录', type: 'chat' },
nccs: { label: 'NCCS 并发', type: 'chat' },
jqyh: { label: 'JQYH', type: 'chat' },
cwb: { label: '角色卡编辑器', type: 'chat' },
superMemory: { label: '超级记忆', type: 'chat' },
// Embedding 槽
ragEmbed: { label: 'RAG 向量化', type: 'embedding' },
// Rerank 槽
ragRerank: { label: 'RAG 重排序', type: 'rerank' },
};
// extension_settings 存储 key
const EXT_PROFILES = 'amily2_profiles';
const EXT_ASSIGNMENTS = 'amily2_profile_assignments';
// ── ApiProfileManager ─────────────────────────────────────────────────────────
class ApiProfileManager {
// ── 内部工具 ────────────────────────────────────────────────────────────
_settings() {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
return extension_settings[extensionName];
}
_profiles() {
const s = this._settings();
if (!Array.isArray(s[EXT_PROFILES])) s[EXT_PROFILES] = [];
return s[EXT_PROFILES];
}
_assignments() {
const s = this._settings();
if (!s[EXT_ASSIGNMENTS] || typeof s[EXT_ASSIGNMENTS] !== 'object') {
s[EXT_ASSIGNMENTS] = {};
}
return s[EXT_ASSIGNMENTS];
}
_save() {
saveSettingsDebounced();
}
_newId() {
return `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
}
// ── Profile CRUD ────────────────────────────────────────────────────────
/**
* 获取 Profile 列表。
* @param {'chat'|'embedding'|'rerank'} [type] 不传则返回全部
* @returns {Array}
*/
getProfiles(type) {
const all = this._profiles();
return type ? all.filter(p => p.type === type) : [...all];
}
/**
* 获取单个 Profile 元数据(不含 Key
*/
getProfile(id) {
return this._profiles().find(p => p.id === id) ?? null;
}
/**
* 新建 Profile。
* @param {Object} data Profile 数据(不含 id、apiKey
* @returns {string} 新 Profile 的 id
*/
createProfile(data) {
const id = this._newId();
const profile = this._buildProfile(id, data);
this._profiles().push(profile);
this._save();
return id;
}
/**
* 更新 Profile 元数据(不更新 KeyKey 用 setKey())。
*/
updateProfile(id, data) {
const list = this._profiles();
const idx = list.findIndex(p => p.id === id);
if (idx === -1) return false;
list[idx] = this._buildProfile(id, { ...list[idx], ...data });
this._save();
return true;
}
/**
* 删除 Profile同时清理存储的 Key 和功能槽引用)。
*/
deleteProfile(id) {
const s = this._settings();
s[EXT_PROFILES] = this._profiles().filter(p => p.id !== id);
// 清理功能槽引用
const asgn = this._assignments();
for (const slot in asgn) {
if (asgn[slot] === id) delete asgn[slot];
}
// 清理 Key
apiKeyStore.deleteById(id);
this._save();
}
// ── Key 操作 ────────────────────────────────────────────────────────────
/** 读取 Profile 的 API Key异步自动解密 */
async getKey(id) {
return apiKeyStore.retrieveById(id);
}
/** 写入 Profile 的 API Key异步自动加密 */
async setKey(id, value) {
return apiKeyStore.storeById(id, value);
}
// ── 功能槽分配 ──────────────────────────────────────────────────────────
/** 获取功能槽当前分配的 Profile IDnull = 未分配) */
getAssignment(slot) {
return this._assignments()[slot] ?? null;
}
/**
* 设置功能槽的 Profile。
* 会校验 Profile 类型是否与槽类型匹配。
*/
setAssignment(slot, profileId) {
if (!SLOTS[slot]) {
console.warn(`[ApiProfiles] 未知功能槽 "${slot}"。`);
return false;
}
if (profileId !== null) {
const profile = this.getProfile(profileId);
if (!profile) {
console.warn(`[ApiProfiles] Profile "${profileId}" 不存在。`);
return false;
}
if (profile.type !== SLOTS[slot].type) {
console.warn(`[ApiProfiles] 类型不匹配:槽 "${slot}" 需要 ${SLOTS[slot].type}Profile 类型为 ${profile.type}`);
return false;
}
}
this._assignments()[slot] = profileId;
this._save();
return true;
}
/**
* 获取功能槽完整 Profile包含解密后的 API Key。
* @returns {Promise<Object|null>}
*/
async getAssignedProfile(slot) {
const id = this.getAssignment(slot);
if (!id) return null;
const profile = this.getProfile(id);
if (!profile) return null;
const apiKey = await this.getKey(id);
return { ...profile, apiKey };
}
// ── 内部Profile 对象构造 ──────────────────────────────────────────────
_buildProfile(id, data) {
const type = data.type || 'chat';
const base = {
id,
name: data.name || '未命名配置',
type,
provider: data.provider || 'openai',
apiUrl: data.apiUrl || '',
model: data.model || '',
};
if (type === 'chat') {
return {
...base,
maxTokens: data.maxTokens ?? 65500,
temperature: data.temperature ?? 1.0,
};
}
if (type === 'embedding') {
return {
...base,
dimensions: data.dimensions ?? null,
encodingFormat: data.encodingFormat ?? 'float',
};
}
if (type === 'rerank') {
return {
...base,
topN: data.topN ?? 5,
returnDocuments: data.returnDocuments ?? false,
};
}
return base;
}
}
// ── 单例导出 ─────────────────────────────────────────────────────────────────
export const apiProfileManager = new ApiProfileManager();
// ── Bus 注册 ──────────────────────────────────────────────────────────────────
setTimeout(() => {
try {
const _ctx = window.Amily2Bus?.register('ApiProfiles');
if (!_ctx) {
console.warn('[ApiProfiles] Amily2Bus 尚未就绪,注册跳过。');
return;
}
_ctx.expose({
getProfiles: (type) => apiProfileManager.getProfiles(type),
getProfile: (id) => apiProfileManager.getProfile(id),
createProfile: (data) => apiProfileManager.createProfile(data),
updateProfile: (id, data) => apiProfileManager.updateProfile(id, data),
deleteProfile: (id) => apiProfileManager.deleteProfile(id),
getKey: (id) => apiProfileManager.getKey(id),
setKey: (id, val) => apiProfileManager.setKey(id, val),
getAssignment: (slot) => apiProfileManager.getAssignment(slot),
setAssignment: (slot, id) => apiProfileManager.setAssignment(slot, id),
getAssignedProfile: (slot) => apiProfileManager.getAssignedProfile(slot),
SLOTS: SLOTS,
PROFILE_TYPES: PROFILE_TYPES,
});
_ctx.log('ApiProfiles', 'info', 'ApiProfiles 服务已注册到 Bus。');
} catch (e) {
console.error('[ApiProfiles] Bus 注册失败:', e);
}
}, 0);

View File

@@ -0,0 +1,155 @@
/**
* ConfigManager — 独立配置持久化管理模块
*
* 解决的安全问题:
* SillyTavern 的 extension_settings 会通过 saveSettingsDebounced() 上传到 ST
* 服务端 settings.json。使用三方云服务商时服务商可读取该文件导致所有
* API 密钥泄露。
*
* 解决方案:
* 敏感字段API Key / URL→ localStorage浏览器本地绝不上传
* 非敏感字段 → extension_settings维持原有行为
*
* Bus 注册名:'Config'
*
* 公开接口query('Config')
* get(key) — 读取配置项(自动路由)
* set(key, value) — 写入配置项(自动路由 + 触发保存)
* getSettings() — 返回完整配置对象(敏感字段从 localStorage 注入)
* migrate() — 将 extension_settings 中残留的敏感字段迁移到 localStorage
*/
import { extension_settings } from "/scripts/extensions.js";
import { saveSettingsDebounced } from "/script.js";
import { extensionName } from "../settings.js";
import { SENSITIVE_KEYS } from "./sensitive-keys.js";
// localStorage key 前缀,避免与其他插件冲突
const LS_PREFIX = 'amily2_secure_';
// ── ConfigManager ────────────────────────────────────────────────────────────
class ConfigManager {
/**
* 读取配置项。
* 敏感字段从 localStorage 读取,其余从 extension_settings 读取。
* @param {string} key
* @returns {*}
*/
get(key) {
if (SENSITIVE_KEYS.has(key)) {
return localStorage.getItem(LS_PREFIX + key) ?? '';
}
return extension_settings[extensionName]?.[key];
}
/**
* 写入配置项并持久化。
* 敏感字段写入 localStorage同时从 extension_settings 清除残留)。
* 非敏感字段写入 extension_settings 并触发 saveSettingsDebounced。
* @param {string} key
* @param {*} value
*/
set(key, value) {
if (SENSITIVE_KEYS.has(key)) {
if (value !== null && value !== undefined && value !== '') {
localStorage.setItem(LS_PREFIX + key, value);
} else {
localStorage.removeItem(LS_PREFIX + key);
}
// 确保 extension_settings 中不保留该敏感字段
const settings = extension_settings[extensionName];
if (settings && Object.prototype.hasOwnProperty.call(settings, key)) {
delete settings[key];
saveSettingsDebounced();
}
} else {
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
extension_settings[extensionName][key] = value;
saveSettingsDebounced();
}
}
/**
* 返回完整配置对象(合并视图)。
* 以 extension_settings 为基础,将 localStorage 中的敏感字段注入覆盖。
*
* 用途:替换现有 `const settings = extension_settings[extensionName]` 的读取点,
* 使 API 调用模块能透明地获取到敏感字段,无需感知存储层差异。
*
* @returns {Object}
*/
getSettings() {
const base = extension_settings[extensionName] ?? {};
const result = { ...base };
for (const key of SENSITIVE_KEYS) {
const val = localStorage.getItem(LS_PREFIX + key);
// null 表示 localStorage 中不存在,保留 base 中原值(如有)
if (val !== null) {
result[key] = val;
}
}
return result;
}
/**
* 迁移:将 extension_settings 中已存在的敏感字段移到 localStorage。
*
* 应在插件初始化阶段调用一次。
* 逻辑:
* - 若 extension_settings 有值 → 迁移到 localStorage若 localStorage 已有值则跳过,保留用户上次输入)
* - 从 extension_settings 删除该字段
* - 最终触发一次 saveSettingsDebounced 清洗服务端
*/
migrate() {
const settings = extension_settings[extensionName];
if (!settings) return;
let needsSave = false;
for (const key of SENSITIVE_KEYS) {
const settingsVal = settings[key];
if (settingsVal !== undefined && settingsVal !== '') {
// localStorage 中已有值时不覆盖(优先保留用户最新输入)
if (!localStorage.getItem(LS_PREFIX + key)) {
localStorage.setItem(LS_PREFIX + key, settingsVal);
console.info(`[Amily2-Config] 已迁移敏感字段 "${key}" 到本地安全存储。`);
}
delete settings[key];
needsSave = true;
}
}
if (needsSave) {
saveSettingsDebounced();
console.info('[Amily2-Config] 敏感配置迁移完成,已从云同步配置中清除密钥。');
}
}
}
// ── 单例导出 ─────────────────────────────────────────────────────────────────
export const configManager = new ConfigManager();
// ── Bus 注册 ──────────────────────────────────────────────────────────────────
// setTimeout 确保 window.Amily2Bus 在 Amily2Bus.js 模块体执行后已挂载
setTimeout(() => {
try {
const _ctx = window.Amily2Bus?.register('Config');
if (!_ctx) {
console.warn('[Config] Amily2Bus 尚未就绪Config 服务注册跳过。');
return;
}
_ctx.expose({
get: (key) => configManager.get(key),
set: (key, value) => configManager.set(key, value),
getSettings: () => configManager.getSettings(),
migrate: () => configManager.migrate(),
});
_ctx.log('ConfigManager', 'info', 'Config 服务已注册到 Bus。');
} catch (e) {
console.error('[Config] Bus 注册失败:', e);
}
}, 0);

View File

@@ -0,0 +1,359 @@
/**
* ApiKeyStore — 专用 API 凭证管理模块
*
* 存储策略(用户可选):
*
* 'local'(默认)
* API Key 明文存储在 localStorage前缀 amily2_secure_
* 不随 ST 设置上传,绝对安全,但换设备需重新填写。
*
* 'cloud'
* API Key 使用混合加密RSA-OAEP + AES-256-GCM后存入 extension_settings。
* 私钥仅保存在本设备 localStorage服务端只能看到密文。
* 换设备时密文跟着走,但需要在新设备上重新生成密钥对并重新输入 Key。
* 可大幅提升技术攻击成本(被动读取 settings.json 完全无效)。
*
* 注意API URL 不是凭证,始终存储在 extension_settings云同步方便多端
*
* Bus 注册名:'ApiKeyStore'
*
* 公开接口query('ApiKeyStore')
* getKey(field) — 读取指定凭证(自动按模式解密)
* setKey(field, value) — 写入指定凭证(自动按模式加密)
* getMode() — 返回当前存储模式 'local' | 'cloud'
* setMode(mode) — 切换存储模式(会迁移现有数据)
* isCloudReady() — cloud 模式下密钥对是否已就绪
* generateKeyPair() — 生成新密钥对(会清除旧加密数据)
* getPublicKeyInfo() — 返回公钥摘要字符串(用于 UI 展示)
* exportEncryptedBackup() — 导出加密备份JSON含密文+公钥,不含私钥)
*/
import { extension_settings } from "/scripts/extensions.js";
import { saveSettingsDebounced } from "/script.js";
import { extensionName } from "../../settings.js";
import { SENSITIVE_KEYS } from "../sensitive-keys.js";
import {
generateKeyPair,
serializeKeyPair,
importPublicKey,
importPrivateKey,
encrypt,
decrypt,
} from "./crypto-utils.js";
// ── 存储 key 常量 ─────────────────────────────────────────────────────────────
const LS_MODE_KEY = 'amily2_keystore_mode'; // 'local' | 'cloud'
const LS_PRIVATE_KEY = 'amily2_keypair_private'; // JWK 字符串
const LS_PLAIN_PREFIX = 'amily2_secure_'; // local 模式明文前缀
const EXT_PUBKEY = 'amily2_pubkey'; // extension_settings 中的公钥
const EXT_ENC_PREFIX = 'amily2_enc_'; // extension_settings 中的密文前缀
// ── ApiKeyStore ───────────────────────────────────────────────────────────────
class ApiKeyStore {
constructor() {
this._publicKey = null; // CryptoKey运行时缓存
this._privateKey = null; // CryptoKey运行时缓存
this._keyReady = false;
this._initPromise = null;
}
// ── 初始化 ──────────────────────────────────────────────────────────────
/**
* 异步初始化:若为 cloud 模式则加载密钥对到内存缓存。
* 由 Bus 注册后自动调用,也可手动 await。
*/
async init() {
if (this.getMode() === 'cloud') {
await this._loadKeyPair();
}
}
// ── 公开 API ────────────────────────────────────────────────────────────
/** 读取指定凭证字段SENSITIVE_KEYS 内的字段) */
async getKey(field) {
if (!SENSITIVE_KEYS.has(field)) {
console.warn(`[ApiKeyStore] "${field}" 不是凭证字段,请用 configManager.get() 读取普通配置。`);
return undefined;
}
if (this.getMode() === 'cloud') {
return this._getCloud(field);
}
return this._getLocal(field);
}
/** 写入指定凭证字段 */
async setKey(field, value) {
if (!SENSITIVE_KEYS.has(field)) {
console.warn(`[ApiKeyStore] "${field}" 不是凭证字段。`);
return;
}
if (this.getMode() === 'cloud') {
await this._setCloud(field, value);
} else {
this._setLocal(field, value);
}
}
/** 当前存储模式 */
getMode() {
return localStorage.getItem(LS_MODE_KEY) || 'local';
}
/**
* 切换存储模式并迁移现有数据。
* local → cloud读出明文 → 加密 → 写入 extension_settings → 清除 localStorage 明文
* cloud → local解密 → 写入 localStorage → 清除 extension_settings 密文
* @param {'local'|'cloud'} mode
*/
async setMode(mode) {
const current = this.getMode();
if (current === mode) return;
if (mode === 'cloud') {
if (!this._keyReady) {
await this.generateKeyPair(); // 首次切换自动生成密钥对
}
await this._migrateLocalToCloud();
} else {
await this._migrateCloudToLocal();
}
localStorage.setItem(LS_MODE_KEY, mode);
console.info(`[ApiKeyStore] 存储模式已切换为 "${mode}"。`);
}
/** cloud 模式下密钥对是否已就绪 */
isCloudReady() {
return this._keyReady;
}
/**
* 按任意 ID 存储凭证(供 ApiProfileManager 使用key = `profile_<id>`)。
* 走与 setKey 相同的加密路由。
*/
async storeById(id, value) {
const field = `profile_${id}`;
if (this.getMode() === 'cloud') {
await this._setCloud(field, value);
} else {
this._setLocal(field, value);
}
}
/**
* 按任意 ID 读取凭证(供 ApiProfileManager 使用)。
*/
async retrieveById(id) {
const field = `profile_${id}`;
if (this.getMode() === 'cloud') {
return this._getCloud(field);
}
return this._getLocal(field);
}
/**
* 删除指定 ID 的凭证Profile 删除时调用)。
*/
deleteById(id) {
const field = `profile_${id}`;
localStorage.removeItem(LS_PLAIN_PREFIX + field);
const settings = extension_settings[extensionName];
if (settings?.[EXT_ENC_PREFIX + field]) {
delete settings[EXT_ENC_PREFIX + field];
saveSettingsDebounced();
}
}
/**
* 生成新的 RSA 密钥对。
* 警告:会清除所有已加密的 cloud 模式凭证(旧私钥无法解密)。
*/
async generateKeyPair() {
const keyPair = await generateKeyPair();
const { publicJwk, privateJwk } = await serializeKeyPair(keyPair);
// 清除旧密文(旧私钥无法解密新密钥对加密的内容)
this._clearAllCloudCiphers();
// 私钥存 localStorage公钥存 extension_settings公钥不需要保密
localStorage.setItem(LS_PRIVATE_KEY, privateJwk);
const settings = extension_settings[extensionName] || {};
settings[EXT_PUBKEY] = publicJwk;
saveSettingsDebounced();
// 更新运行时缓存
this._publicKey = await importPublicKey(publicJwk);
this._privateKey = await importPrivateKey(privateJwk);
this._keyReady = true;
console.info('[ApiKeyStore] 新密钥对已生成。请重新输入所有 API Key。');
}
/**
* 返回公钥的简短指纹SHA-256 前 8 字节Base64用于 UI 展示。
* @returns {Promise<string>}
*/
async getPublicKeyInfo() {
const jwkStr = extension_settings[extensionName]?.[EXT_PUBKEY];
if (!jwkStr) return '(未生成)';
const jwk = JSON.parse(jwkStr);
const raw = new TextEncoder().encode(jwk.n); // RSA modulus
const hash = await crypto.subtle.digest('SHA-256', raw);
const hex = Array.from(new Uint8Array(hash)).slice(0, 8)
.map(b => b.toString(16).padStart(2, '0')).join(':');
return `RSA-2048 · ${hex}`;
}
/**
* 导出可备份的加密摘要(包含公钥 + 所有密文,不含私钥)。
* 仅供参考,不能用于在新设备上恢复(因为私钥不在其中)。
* @returns {Object}
*/
exportEncryptedBackup() {
const settings = extension_settings[extensionName] || {};
const backup = { publicKey: settings[EXT_PUBKEY], encrypted: {} };
for (const field of SENSITIVE_KEYS) {
const cipher = settings[EXT_ENC_PREFIX + field];
if (cipher) backup.encrypted[field] = cipher;
}
return backup;
}
// ── 内部local 模式 ────────────────────────────────────────────────────
_getLocal(field) {
return localStorage.getItem(LS_PLAIN_PREFIX + field) ?? '';
}
_setLocal(field, value) {
if (value !== null && value !== undefined && value !== '') {
localStorage.setItem(LS_PLAIN_PREFIX + field, value);
} else {
localStorage.removeItem(LS_PLAIN_PREFIX + field);
}
}
// ── 内部cloud 模式 ────────────────────────────────────────────────────
async _getCloud(field) {
if (!this._keyReady) {
console.warn('[ApiKeyStore] cloud 模式密钥未就绪,无法解密。');
return '';
}
const cipher = extension_settings[extensionName]?.[EXT_ENC_PREFIX + field];
if (!cipher) return '';
try {
return await decrypt(this._privateKey, cipher);
} catch (e) {
console.error(`[ApiKeyStore] 解密 "${field}" 失败(私钥不匹配?):`, e);
return '';
}
}
async _setCloud(field, value) {
if (!this._keyReady) {
console.warn('[ApiKeyStore] cloud 模式密钥未就绪,无法加密。');
return;
}
const settings = extension_settings[extensionName] || {};
if (value !== null && value !== undefined && value !== '') {
settings[EXT_ENC_PREFIX + field] = await encrypt(this._publicKey, value);
} else {
delete settings[EXT_ENC_PREFIX + field];
}
saveSettingsDebounced();
}
// ── 内部:迁移 ──────────────────────────────────────────────────────────
async _migrateLocalToCloud() {
for (const field of SENSITIVE_KEYS) {
const plain = this._getLocal(field);
if (plain) {
await this._setCloud(field, plain);
localStorage.removeItem(LS_PLAIN_PREFIX + field);
console.info(`[ApiKeyStore] "${field}" 已加密迁移至云同步。`);
}
}
}
async _migrateCloudToLocal() {
for (const field of SENSITIVE_KEYS) {
const plain = await this._getCloud(field);
if (plain) {
this._setLocal(field, plain);
console.info(`[ApiKeyStore] "${field}" 已解密迁移至本地存储。`);
}
}
this._clearAllCloudCiphers();
}
_clearAllCloudCiphers() {
const settings = extension_settings[extensionName];
if (!settings) return;
let changed = false;
for (const field of SENSITIVE_KEYS) {
if (settings[EXT_ENC_PREFIX + field]) {
delete settings[EXT_ENC_PREFIX + field];
changed = true;
}
}
if (changed) saveSettingsDebounced();
}
// ── 内部:密钥加载 ──────────────────────────────────────────────────────
async _loadKeyPair() {
const privateJwk = localStorage.getItem(LS_PRIVATE_KEY);
const publicJwk = extension_settings[extensionName]?.[EXT_PUBKEY];
if (!privateJwk || !publicJwk) {
console.warn('[ApiKeyStore] cloud 模式:本地未找到密钥对,请生成新密钥对。');
this._keyReady = false;
return;
}
try {
this._privateKey = await importPrivateKey(privateJwk);
this._publicKey = await importPublicKey(publicJwk);
this._keyReady = true;
console.info('[ApiKeyStore] cloud 模式密钥对已加载。');
} catch (e) {
console.error('[ApiKeyStore] 密钥对加载失败(数据损坏?):', e);
this._keyReady = false;
}
}
}
// ── 单例导出 ─────────────────────────────────────────────────────────────────
export const apiKeyStore = new ApiKeyStore();
// ── Bus 注册 ──────────────────────────────────────────────────────────────────
setTimeout(async () => {
try {
// 先初始化cloud 模式下加载密钥)
await apiKeyStore.init();
const _ctx = window.Amily2Bus?.register('ApiKeyStore');
if (!_ctx) {
console.warn('[ApiKeyStore] Amily2Bus 尚未就绪,注册跳过。');
return;
}
_ctx.expose({
getKey: (field) => apiKeyStore.getKey(field),
setKey: (field, value) => apiKeyStore.setKey(field, value),
getMode: () => apiKeyStore.getMode(),
setMode: (mode) => apiKeyStore.setMode(mode),
isCloudReady: () => apiKeyStore.isCloudReady(),
generateKeyPair: () => apiKeyStore.generateKeyPair(),
getPublicKeyInfo: () => apiKeyStore.getPublicKeyInfo(),
exportEncryptedBackup: () => apiKeyStore.exportEncryptedBackup(),
});
_ctx.log('ApiKeyStore', 'info', 'ApiKeyStore 服务已注册到 Bus。');
} catch (e) {
console.error('[ApiKeyStore] 初始化失败:', e);
}
}, 0);

View File

@@ -0,0 +1,174 @@
/**
* crypto-utils.js — Web Crypto API 封装
*
* 使用混合加密方案Hybrid Encryption
* - RSA-OAEP 2048 负责密钥交换(加密 AES 密钥)
* - AES-256-GCM 负责实际数据加密
*
* 优势:
* - RSA 部分无明文长度限制AES 密钥固定 32 字节,远小于 RSA 上限)
* - AES-GCM 提供认证加密AEAD防止密文篡改
* - 全程使用 Web Crypto API密钥操作不经过 JS 内存SubtleCrypto 内部实现)
*/
// ── 密钥对生成与导入导出 ────────────────────────────────────────────────────
/**
* 生成 RSA-OAEP 2048 密钥对。
* 返回 { publicKey, privateKey }(均为 CryptoKey 对象)
*/
export async function generateKeyPair() {
return crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537
hash: 'SHA-256',
},
true, // extractable = true以便序列化存储
['encrypt', 'decrypt']
);
}
/**
* 将密钥对序列化为 JWK 字符串,以便存储。
* @param {CryptoKeyPair} keyPair
* @returns {Promise<{ publicJwk: string, privateJwk: string }>}
*/
export async function serializeKeyPair(keyPair) {
const [publicJwk, privateJwk] = await Promise.all([
crypto.subtle.exportKey('jwk', keyPair.publicKey),
crypto.subtle.exportKey('jwk', keyPair.privateKey),
]);
return {
publicJwk: JSON.stringify(publicJwk),
privateJwk: JSON.stringify(privateJwk),
};
}
/**
* 从 JWK 字符串恢复公钥(用于加密)。
* @param {string} jwkString
* @returns {Promise<CryptoKey>}
*/
export async function importPublicKey(jwkString) {
return crypto.subtle.importKey(
'jwk',
JSON.parse(jwkString),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false, // 不需要再次导出
['encrypt']
);
}
/**
* 从 JWK 字符串恢复私钥(用于解密)。
* @param {string} jwkString
* @returns {Promise<CryptoKey>}
*/
export async function importPrivateKey(jwkString) {
return crypto.subtle.importKey(
'jwk',
JSON.parse(jwkString),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['decrypt']
);
}
// ── 混合加密 / 解密 ──────────────────────────────────────────────────────────
/**
* 混合加密RSA-OAEP 包装 AES-256-GCM 密钥AES-GCM 加密明文。
*
* 返回的密文包 JSON 结构:
* {
* wrappedKey: "<base64>", // RSA 加密的 AES 密钥
* iv: "<base64>", // AES-GCM 随机 IV12 字节)
* ciphertext: "<base64>", // AES-GCM 密文(含 GCM tag
* }
*
* @param {CryptoKey} publicKey RSA 公钥
* @param {string} plaintext 明文字符串
* @returns {Promise<string>} 序列化的密文包JSON 字符串)
*/
export async function encrypt(publicKey, plaintext) {
// 1. 生成一次性 AES-256-GCM 密钥
const aesKey = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt']
);
// 2. 生成随机 IV12 字节是 GCM 的推荐长度)
const iv = crypto.getRandomValues(new Uint8Array(12));
// 3. 用 AES-GCM 加密明文
const plainBytes = new TextEncoder().encode(plaintext);
const ciphertextBuffer = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
plainBytes
);
// 4. 导出 AES 原始密钥字节,用 RSA 公钥包装
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
const wrappedKeyBuffer = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
rawAesKey
);
// 5. 序列化为 base64 JSON 包
return JSON.stringify({
wrappedKey: bufToBase64(wrappedKeyBuffer),
iv: bufToBase64(iv),
ciphertext: bufToBase64(ciphertextBuffer),
});
}
/**
* 混合解密:用 RSA 私钥解出 AES 密钥,再用 AES-GCM 解密密文。
*
* @param {CryptoKey} privateKey RSA 私钥
* @param {string} payload encrypt() 返回的 JSON 字符串
* @returns {Promise<string>} 原始明文字符串
*/
export async function decrypt(privateKey, payload) {
const { wrappedKey, iv, ciphertext } = JSON.parse(payload);
// 1. RSA 解出 AES 密钥字节
const rawAesKey = await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
privateKey,
base64ToBuf(wrappedKey)
);
// 2. 恢复 AES 密钥对象(只用于解密)
const aesKey = await crypto.subtle.importKey(
'raw',
rawAesKey,
{ name: 'AES-GCM' },
false,
['decrypt']
);
// 3. AES-GCM 解密
const plainBuffer = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBuf(iv) },
aesKey,
base64ToBuf(ciphertext)
);
return new TextDecoder().decode(plainBuffer);
}
// ── 工具函数 ─────────────────────────────────────────────────────────────────
function bufToBase64(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
function base64ToBuf(base64) {
return Uint8Array.from(atob(base64), c => c.charCodeAt(0));
}

View File

@@ -0,0 +1,17 @@
/**
* 敏感配置字段清单(仅 API Key 类凭证)
*
* 只有真正的凭证API Key需要保护。
* API URL 不是凭证——没有 Key 拿到 URL 也无法调用,且 URL 云同步方便多端使用。
*
* 这些字段将被 ConfigManager / ApiKeyStore 路由到安全存储,
* 而不是 extension_settings后者会被 saveSettingsDebounced 上传到 ST 服务端)。
*/
export const SENSITIVE_KEYS = new Set([
'apiKey',
'plotOpt_concurrentApiKey',
'ngmsApiKey',
'nccsApiKey',
'jqyhApiKey',
'cwb_api_key',
]);