mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-06 04:35:51 +00:00
ci: auto build & obfuscate [2026-04-06 00:50:28] (Jenkins #7)
This commit is contained in:
@@ -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: {
|
||||
|
||||
329
SL/bus/GUIDE.md
Normal file
329
SL/bus/GUIDE.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Amily2Bus 开发者实战指南
|
||||
|
||||
> 本文档面向 Amily2 扩展的维护者与协作开发者,介绍如何在实际业务中使用总线系统。
|
||||
> API 参考请查阅同目录下的 [README.md](./README.md)。
|
||||
|
||||
---
|
||||
|
||||
## 一、总线是什么?为什么用它?
|
||||
|
||||
Amily2Bus 是一个 **服务注册与发现** 系统。它解决的核心问题:
|
||||
|
||||
- **解耦循环依赖** — 模块之间不再需要互相 import,只需通过总线 `query()` 按名字查找
|
||||
- **身份隔离** — 每个插件注册后拿到专属上下文(Capability Token),日志自动标注来源,文件存储自动隔离
|
||||
- **可选依赖** — 查询不到服务不会崩溃,只返回 `null`,适合渐进式集成
|
||||
|
||||
**一句话理解**:`register()` = 我是谁,`expose()` = 我能做什么,`query()` = 我要找谁帮忙。
|
||||
|
||||
---
|
||||
|
||||
## 二、注册一个新服务(3 步)
|
||||
|
||||
### Step 1:注册身份
|
||||
|
||||
```javascript
|
||||
// 在你的模块顶层(文件加载时执行)
|
||||
let _ctx = null;
|
||||
|
||||
if (window.Amily2Bus) {
|
||||
try {
|
||||
_ctx = window.Amily2Bus.register('MyService');
|
||||
_ctx.log('Init', 'info', 'MyService 已上线。');
|
||||
} catch (e) {
|
||||
console.warn('[MyService] Bus 注册失败(可能是热重载导致重复注册):', e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:每个名字只能注册一次(严格锁)。热重载时会抛异常,用 try-catch 包住即可,页面刷新后会重置。
|
||||
|
||||
### Step 2:暴露能力
|
||||
|
||||
```javascript
|
||||
// 把你希望其他模块能调用的函数暴露出去
|
||||
_ctx.expose({
|
||||
doSomething, // 暴露已有函数
|
||||
getStatus: () => 'ok', // 也可以内联
|
||||
});
|
||||
```
|
||||
|
||||
暴露后的对象会被 `Object.freeze()`,外部无法篡改。
|
||||
|
||||
### Step 3:完成
|
||||
|
||||
其他模块现在可以通过 `window.Amily2Bus.query('MyService')` 找到你暴露的方法了。
|
||||
|
||||
---
|
||||
|
||||
## 三、调用其他服务
|
||||
|
||||
```javascript
|
||||
const superMemory = window.Amily2Bus.query('SuperMemory');
|
||||
if (superMemory) {
|
||||
await superMemory.awaitSync();
|
||||
}
|
||||
```
|
||||
|
||||
**关键原则**:总是做 `null` 检查。服务可能未加载、未注册、或被禁用。
|
||||
|
||||
### 项目中已注册的服务一览
|
||||
|
||||
| 服务名 | 用途 | 主要暴露方法 |
|
||||
|---|---|---|
|
||||
| `NccsApi` | NCCS 网络通道 | `call(messages, options)`, `getSettings()` |
|
||||
| `MessagePipeline` | 消息处理管线 | `execute(pipelineCtx)` |
|
||||
| `SuperMemory` | 超级记忆系统 | `initialize()`, `forceSyncAll()`, `awaitSync()`, `pushUpdate()`, `purge()` |
|
||||
| `TableSystem` | 表格系统 | `processMessageUpdate()`, `fillWithSecondaryApi()`, `generateTableContent()`, `renderTables()` |
|
||||
| `TavernHelper` | ST 操作封装 | 25+ 方法(聊天、世界书、角色卡等) |
|
||||
| `LoreService` | 世界书读写锁 | `withLoreLock()`, `loadBook()`, `ensureBook()`, `saveBook()` |
|
||||
| `Config` | 配置管理 | `get()`, `set()`, `getSettings()`, `migrate()` |
|
||||
| `ApiProfiles` | API 配置文件管理 | Profile CRUD + 密钥管理 |
|
||||
| `ApiKeyStore` | API 密钥安全存储 | `getKey()`, `setKey()` |
|
||||
| `PUBLIC` | 系统元信息 | `getAvailableModules()`, `getRegisteredPlugins()`, `ping()` |
|
||||
|
||||
> 使用 `window.Amily2Bus.query('PUBLIC').getAvailableModules()` 可在控制台实时查看所有已暴露服务。
|
||||
|
||||
---
|
||||
|
||||
## 四、使用上下文的三大能力
|
||||
|
||||
注册后拿到的 `ctx` 对象提供三种开箱即用的能力:
|
||||
|
||||
### 4.1 日志(ctx.log)
|
||||
|
||||
```javascript
|
||||
ctx.log('ModuleName', 'info', '这是一条日志');
|
||||
// 输出: [14:32:01] [MyService::ModuleName] [INFO]: 这是一条日志
|
||||
```
|
||||
|
||||
级别:`debug` / `info` / `warn` / `error`
|
||||
|
||||
调试时可在控制台动态开启某个服务的 debug 级别:
|
||||
```javascript
|
||||
window.Amily2Bus.Logger.setLevel('MyService', 'all');
|
||||
```
|
||||
|
||||
### 4.2 文件存储(ctx.file)
|
||||
|
||||
基于 IndexedDB 的虚拟文件系统,按服务名自动隔离。
|
||||
|
||||
```javascript
|
||||
await ctx.file.write('cache/data.json', { key: 'value' });
|
||||
const data = await ctx.file.read('cache/data.json');
|
||||
const files = await ctx.file.list(); // 列出本服务所有文件
|
||||
await ctx.file.delete('cache/data.json');
|
||||
await ctx.file.clearAll(); // 清空本服务所有文件
|
||||
```
|
||||
|
||||
> 路径禁止使用 `..`,系统会做安全校验。
|
||||
|
||||
### 4.3 网络请求(ctx.model)
|
||||
|
||||
统一的 AI 模型调用接口,支持直连和 ST 预设两种模式。
|
||||
|
||||
```javascript
|
||||
const { Options } = ctx.model;
|
||||
|
||||
// 直连模式
|
||||
const opt = Options.builder()
|
||||
.setMode('direct')
|
||||
.setApiUrl('https://api.example.com/v1')
|
||||
.setApiKey('sk-...')
|
||||
.setModel('claude-sonnet-4-20250514')
|
||||
.setMaxTokens(4096)
|
||||
.setTemperature(0.7)
|
||||
.setFakeStream(true) // 防 CloudFlare 524 超时
|
||||
.build();
|
||||
|
||||
const reply = await ctx.model.call(messages, opt);
|
||||
|
||||
// ST 预设模式
|
||||
const presetOpt = Options.builder()
|
||||
.setMode('preset')
|
||||
.setPresetName('MyProfile')
|
||||
.build();
|
||||
|
||||
const reply2 = await ctx.model.call(messages, presetOpt);
|
||||
```
|
||||
|
||||
> **为什么用 ctx.model 而不是直接 fetch?**
|
||||
> - 自动处理 FakeStream 防超时
|
||||
> - 自动处理 ST 后端代理路由
|
||||
> - 日志自动关联到你的服务名
|
||||
> - 统一的错误处理与响应解析
|
||||
|
||||
---
|
||||
|
||||
## 五、常见模式与最佳实践
|
||||
|
||||
### 模式 1:可选依赖(推荐)
|
||||
|
||||
```javascript
|
||||
// 好 — 查不到就跳过,不会崩溃
|
||||
const memory = window.Amily2Bus.query('SuperMemory');
|
||||
if (memory) {
|
||||
await memory.pushUpdate(charId, data);
|
||||
}
|
||||
|
||||
// 坏 — 如果 SuperMemory 没注册就直接报错
|
||||
const memory = window.Amily2Bus.query('SuperMemory');
|
||||
await memory.pushUpdate(charId, data); // TypeError: Cannot read property 'pushUpdate' of null
|
||||
```
|
||||
|
||||
### 模式 2:在 expose 中只暴露纯函数
|
||||
|
||||
```javascript
|
||||
// 好 — 暴露的是明确的功能入口
|
||||
ctx.expose({
|
||||
processMessageUpdate,
|
||||
fillWithSecondaryApi,
|
||||
});
|
||||
|
||||
// 坏 — 不要暴露整个类实例或内部状态
|
||||
ctx.expose({
|
||||
instance: this, // 泄露内部状态
|
||||
_privateHelper: helper, // 私有方法不该暴露
|
||||
});
|
||||
```
|
||||
|
||||
### 模式 3:热重载安全
|
||||
|
||||
开发中 SillyTavern 扩展可能被热重载,导致同名重复注册。始终用 try-catch:
|
||||
|
||||
```javascript
|
||||
let _ctx = null;
|
||||
if (window.Amily2Bus) {
|
||||
try {
|
||||
_ctx = window.Amily2Bus.register('MyService');
|
||||
_ctx.expose({ ... });
|
||||
} catch (e) {
|
||||
// 热重载时会走到这里,不影响功能
|
||||
console.warn('[MyService] 重复注册,跳过:', e.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 4:跨服务协作(实际例子)
|
||||
|
||||
消息管线中,`super-memory-sync` 阶段需要等待 SuperMemory 同步完成:
|
||||
|
||||
```javascript
|
||||
// core/pipeline/stages/super-memory-sync.js
|
||||
async function execute(pipelineCtx) {
|
||||
const sm = window.Amily2Bus.query('SuperMemory');
|
||||
if (!sm) return; // SuperMemory 未加载,跳过此阶段
|
||||
|
||||
await sm.awaitSync();
|
||||
// 继续管线后续逻辑...
|
||||
}
|
||||
```
|
||||
|
||||
表格系统更新后,通知 SuperMemory 同步变更:
|
||||
|
||||
```javascript
|
||||
// core/table-system/manager.js
|
||||
const sm = window.Amily2Bus.query('SuperMemory');
|
||||
if (sm?.pushUpdate) {
|
||||
await sm.pushUpdate(characterId, updatedData);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、调试技巧
|
||||
|
||||
### 控制台快速检查
|
||||
|
||||
```javascript
|
||||
// 查看所有已注册的服务
|
||||
window.Amily2Bus.query('PUBLIC').getRegisteredPlugins()
|
||||
|
||||
// 查看所有暴露了公共接口的服务
|
||||
window.Amily2Bus.query('PUBLIC').getAvailableModules()
|
||||
|
||||
// 测试某个服务是否在线
|
||||
window.Amily2Bus.query('NccsApi') // 返回对象则在线,null 则未注册
|
||||
|
||||
// 开启某服务的全部日志
|
||||
window.Amily2Bus.Logger.setLevel('TableSystem', 'all')
|
||||
|
||||
// 系统心跳
|
||||
window.Amily2Bus.query('PUBLIC').ping() // => 'pong'
|
||||
```
|
||||
|
||||
### 日志级别控制
|
||||
|
||||
日志使用位掩码,可按需组合:
|
||||
|
||||
| 级别 | 值 | 说明 |
|
||||
|---|---|---|
|
||||
| `debug` | `0x1` | 调试信息(生产环境默认关闭) |
|
||||
| `info` | `0x2` | 一般信息 |
|
||||
| `warn` | `0x4` | 警告 |
|
||||
| `error` | `0x8` | 错误 |
|
||||
| `all` | `0xF` | 全部开启 |
|
||||
|
||||
```javascript
|
||||
// 只看 warn + error
|
||||
window.Amily2Bus.Logger.setLevel('MyService', 0x4 | 0x8);
|
||||
// 或用字符串
|
||||
window.Amily2Bus.Logger.setLevel('MyService', 'warn');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、添加新功能模块的完整流程
|
||||
|
||||
假设你要新增一个「自动摘要」功能模块:
|
||||
|
||||
```
|
||||
1. 创建文件 core/auto-summary/AutoSummaryService.js
|
||||
2. 在文件中注册总线身份
|
||||
3. 实现核心逻辑
|
||||
4. 暴露需要被其他模块调用的方法
|
||||
5. 在 index.js 中 import 该文件(确保它被加载)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// core/auto-summary/AutoSummaryService.js
|
||||
import { callNccsAI } from '../api/NccsApi.js';
|
||||
|
||||
let _ctx = null;
|
||||
|
||||
export async function summarize(text, maxLength = 200) {
|
||||
const messages = [
|
||||
{ role: 'system', content: `请将以下内容压缩到${maxLength}字以内。` },
|
||||
{ role: 'user', content: text }
|
||||
];
|
||||
return await callNccsAI(messages);
|
||||
}
|
||||
|
||||
// --- 总线注册 ---
|
||||
if (window.Amily2Bus) {
|
||||
try {
|
||||
_ctx = window.Amily2Bus.register('AutoSummary');
|
||||
_ctx.expose({ summarize });
|
||||
_ctx.log('Init', 'info', 'AutoSummary 服务已就绪。');
|
||||
} catch (e) {
|
||||
console.warn('[AutoSummary] Bus 注册警告:', e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其他模块现在可以这样调用:
|
||||
```javascript
|
||||
const summary = window.Amily2Bus.query('AutoSummary');
|
||||
if (summary) {
|
||||
const result = await summary.summarize(longText);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. **名字唯一** — `register()` 的名字是全局唯一的,确认不与已有服务冲突(参考上面的服务一览表)
|
||||
2. **不要存引用** — `expose()` 的对象会被冻结,暴露的应该是函数而非可变状态
|
||||
3. **加载顺序** — 总线在 `index.js` 的 `initializeAmilyBus()` 中初始化,所有服务通过 import 自动注册。如果你的模块依赖其他服务,在运行时 `query()` 即可,不需要控制 import 顺序
|
||||
4. **`PUBLIC` 和 `Amily2` 是保留名** — 不要尝试注册这两个名字
|
||||
5. **生产与开发** — 页面刷新会重置整个总线,不需要手动清理。热重载时的重复注册异常是预期行为,不影响功能
|
||||
@@ -1,61 +1,260 @@
|
||||
/**
|
||||
* FilePipe — 插件独立文件存储管道
|
||||
*
|
||||
* 解决的问题:
|
||||
* SillyTavern 的 settings.json 被所有插件共享,大型内容(prompt 模板、摘要、
|
||||
* 优化结果、缓存)写入后导致文件膨胀,且功能迭代残留的废弃 key 永久堆积。
|
||||
*
|
||||
* 方案:
|
||||
* 以 IndexedDB 为后端,每个插件在独立命名空间下进行读写。
|
||||
* 与 settings.json 完全隔离,不参与云同步,无体积上限约束。
|
||||
*
|
||||
* 存储结构:
|
||||
* DB : 'Amily2_FilePipe'
|
||||
* Store: 'files'
|
||||
* Key : 复合键 [plugin, path](无需为新插件升级 DB 版本)
|
||||
* Entry: { plugin, path, data, updatedAt }
|
||||
*
|
||||
* 安全:
|
||||
* - 路径禁止包含 '..'(防目录穿越)
|
||||
* - 每个插件只能读写自己命名空间下的路径
|
||||
*
|
||||
* 使用方式(通过 Amily2Bus capability token):
|
||||
* const file = ctx.file; // Amily2Bus 注入
|
||||
* await file.write('config.json', { key: 'value' });
|
||||
* const data = await file.read('config.json');
|
||||
* await file.delete('config.json');
|
||||
* const list = await file.list();
|
||||
*/
|
||||
|
||||
const DB_NAME = 'Amily2_FilePipe';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'files';
|
||||
|
||||
// ── IndexedDB 工具 ────────────────────────────────────────────────────────────
|
||||
|
||||
let _dbPromise = null;
|
||||
|
||||
function _openDB() {
|
||||
if (_dbPromise) return _dbPromise;
|
||||
_dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
req.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, {
|
||||
keyPath: ['plugin', 'path'],
|
||||
});
|
||||
// 按插件名索引,方便 list() 查询
|
||||
store.createIndex('by_plugin', 'plugin', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
req.onsuccess = (e) => resolve(e.target.result);
|
||||
req.onerror = (e) => {
|
||||
_dbPromise = null;
|
||||
reject(new Error(`[FilePipe] IndexedDB 打开失败: ${e.target.error}`));
|
||||
};
|
||||
});
|
||||
return _dbPromise;
|
||||
}
|
||||
|
||||
function _tx(db, mode) {
|
||||
return db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
|
||||
}
|
||||
|
||||
function _idbRequest(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = (e) => resolve(e.target.result);
|
||||
req.onerror = (e) => reject(e.target.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ── FilePipe ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class FilePipe {
|
||||
constructor() {
|
||||
this.name = "FilePipe";
|
||||
// 模拟的根存储路径,实际环境可能对应 IndexedDB 的 Store Name 或 Node 的 baseDir
|
||||
this.basePath = "/virtual_fs/";
|
||||
this.name = 'FilePipe';
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全路径解析与校验
|
||||
* @param {string} plugin 插件名称(命名空间)
|
||||
* @param {string} relativePath 相对路径
|
||||
* @returns {string|null} 合法的绝对路径,如果违规则返回 null
|
||||
*/
|
||||
_resolvePath(plugin, relativePath) {
|
||||
// ── 安全路径校验 ─────────────────────────────────────────────────────────
|
||||
|
||||
_safePath(plugin, path) {
|
||||
if (!plugin || typeof plugin !== 'string') {
|
||||
console.error(`[FilePipe] Security Error: Invalid plugin identity.`);
|
||||
console.error('[FilePipe] 无效的插件标识。');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 简单防越权:禁止包含 ".."
|
||||
if (relativePath.includes('..')) {
|
||||
console.error(`[FilePipe] Security Error: Directory traversal attempt blocked for plugin '${plugin}'. Path: ${relativePath}`);
|
||||
if (!path || typeof path !== 'string') {
|
||||
console.error('[FilePipe] 无效的路径。');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 强制限定在插件目录下
|
||||
// 格式: /virtual_fs/PluginName/filename
|
||||
return `${this.basePath}${plugin}/${relativePath}`;
|
||||
if (path.includes('..')) {
|
||||
console.error(`[FilePipe] 安全拦截:插件 "${plugin}" 尝试目录穿越,路径: ${path}`);
|
||||
return null;
|
||||
}
|
||||
// 规范化:去掉开头的斜杠
|
||||
return path.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
// ── 公开 API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 读取文件
|
||||
* @param {string} plugin 调用方插件名
|
||||
* @param {string} path 文件相对路径
|
||||
* 读取文件。
|
||||
* @param {string} plugin 插件名(命名空间)
|
||||
* @param {string} path 文件路径(相对于插件根目录)
|
||||
* @returns {Promise<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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default FilePipe;
|
||||
export default FilePipe;
|
||||
|
||||
18
SL/module/AdditionalFeaturesModule.js
Normal file
18
SL/module/AdditionalFeaturesModule.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Module, ModuleBuilder } from './Module.js';
|
||||
|
||||
const builder = new ModuleBuilder()
|
||||
.name('AdditionalFeatures')
|
||||
.view('assets/amily-additional-features/Amily2-AdditionalFeatures.html');
|
||||
|
||||
export default class AdditionalFeaturesModule extends Module {
|
||||
constructor() {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
async mount() {
|
||||
if (this.el) {
|
||||
this.el.id = 'amily2_additional_features_panel';
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
28
SL/module/ApiConfigModule.js
Normal file
28
SL/module/ApiConfigModule.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module, ModuleBuilder } from './Module.js';
|
||||
import { bindApiConfigPanel } from '../../ui/api-config-bindings.js';
|
||||
import { syncAllSlots } from '../../ui/profile-sync.js';
|
||||
|
||||
const builder = new ModuleBuilder()
|
||||
.name('ApiConfig')
|
||||
.view('assets/api-config-panel.html')
|
||||
.strict(true)
|
||||
.required(['mount']);
|
||||
|
||||
export default class ApiConfigModule extends Module {
|
||||
constructor() {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
async mount() {
|
||||
if (this.el) {
|
||||
this.el.id = 'amily2_api_config_panel';
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
bindApiConfigPanel($(this.el));
|
||||
syncAllSlots();
|
||||
}
|
||||
|
||||
expose() {
|
||||
return { syncAllSlots };
|
||||
}
|
||||
}
|
||||
22
SL/module/CWBModule.js
Normal file
22
SL/module/CWBModule.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Module, ModuleBuilder } from './Module.js';
|
||||
import { initializeCharacterWorldBook } from '../../CharacterWorldBook/cwb_index.js';
|
||||
|
||||
const builder = new ModuleBuilder()
|
||||
.name('CharacterWorldBook')
|
||||
.view('CharacterWorldBook/cwb_settings.html')
|
||||
.strict(true)
|
||||
.required(['mount']);
|
||||
|
||||
export default class CWBModule extends Module {
|
||||
constructor() {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
async mount() {
|
||||
if (this.el) {
|
||||
this.el.id = 'amily2_character_world_book_panel';
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
await initializeCharacterWorldBook($(this.el));
|
||||
}
|
||||
}
|
||||
24
SL/module/GlossaryModule.js
Normal file
24
SL/module/GlossaryModule.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Module, ModuleBuilder } from './Module.js';
|
||||
|
||||
const builder = new ModuleBuilder()
|
||||
.name('Glossary')
|
||||
.view('assets/amily-glossary-system/amily2-glossary.html')
|
||||
.strict(true)
|
||||
.required(['mount']);
|
||||
|
||||
export default class GlossaryModule extends Module {
|
||||
constructor() {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
async mount() {
|
||||
if (this.el) {
|
||||
this.el.id = 'amily2_glossary_panel';
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
// bindGlossaryEvents 由 index.js 中 waitForGlossaryPanelAndBindEvents 轮询调用
|
||||
// 模块化后面板已就绪,可直接绑定
|
||||
const { bindGlossaryEvents } = await import('../../glossary/GT_bindings.js');
|
||||
bindGlossaryEvents();
|
||||
}
|
||||
}
|
||||
22
SL/module/HanlinyuanModule.js
Normal file
22
SL/module/HanlinyuanModule.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Module, ModuleBuilder } from './Module.js';
|
||||
import { bindHanlinyuanEvents } from '../../ui/hanlinyuan-bindings.js';
|
||||
|
||||
const builder = new ModuleBuilder()
|
||||
.name('Hanlinyuan')
|
||||
.view('assets/amily-hanlinyuan-system/hanlinyuan.html')
|
||||
.strict(true)
|
||||
.required(['mount']);
|
||||
|
||||
export default class HanlinyuanModule extends Module {
|
||||
constructor() {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
async mount() {
|
||||
if (this.el) {
|
||||
this.el.id = 'amily2_hanlinyuan_panel';
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
bindHanlinyuanEvents();
|
||||
}
|
||||
}
|
||||
22
SL/module/HistoriographyModule.js
Normal file
22
SL/module/HistoriographyModule.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Module, ModuleBuilder } from './Module.js';
|
||||
import { bindHistoriographyEvents } from '../../ui/historiography-bindings.js';
|
||||
|
||||
const builder = new ModuleBuilder()
|
||||
.name('Historiography')
|
||||
.view('assets/Amily2-TextOptimization.html')
|
||||
.strict(true)
|
||||
.required(['mount']);
|
||||
|
||||
export default class HistoriographyModule extends Module {
|
||||
constructor() {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
async mount() {
|
||||
if (this.el) {
|
||||
this.el.id = 'amily2_text_optimization_panel';
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
bindHistoriographyEvents();
|
||||
}
|
||||
}
|
||||
144
SL/module/ModuleRegistry.js
Normal file
144
SL/module/ModuleRegistry.js
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* ModuleRegistry — 模块注册中心
|
||||
*
|
||||
* 职责:
|
||||
* 1. 收集所有 Module 子类的注册信息(name → factory)
|
||||
* 2. 统一执行 init → mount 生命周期
|
||||
* 3. 向 Amily2Bus 暴露各模块的 expose() 结果,供跨模块调用
|
||||
* 4. 提供 dispose 方法用于整体卸载
|
||||
*
|
||||
* 用法:
|
||||
* import { registry } from 'SL/module/ModuleRegistry.js';
|
||||
* registry.register('Hanlinyuan', () => new HanlinyuanModule());
|
||||
* await registry.mountAll(ctx); // ctx = { baseUrl, root, ... }
|
||||
* registry.query('Hanlinyuan'); // 获取该模块 expose() 的公开 API
|
||||
*/
|
||||
|
||||
const _modules = new Map(); // name → Module instance (mounted)
|
||||
const _factories = new Map(); // name → () => Module
|
||||
|
||||
/**
|
||||
* 注册一个模块工厂。
|
||||
* @param {string} name 唯一模块名
|
||||
* @param {Function} factory 无参函数,返回 Module 实例
|
||||
*/
|
||||
export function register(name, factory) {
|
||||
if (_factories.has(name)) {
|
||||
console.warn(`[ModuleRegistry] 模块 "${name}" 已注册,将覆盖。`);
|
||||
}
|
||||
_factories.set(name, factory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化并挂载所有已注册模块。
|
||||
* @param {Object} ctx 传给 module.init(ctx) 的上下文
|
||||
* ctx.baseUrl — 插件根 URL(用于 view 路径解析)
|
||||
* ctx.root — 挂载目标 DOM 元素
|
||||
*/
|
||||
export async function mountAll(ctx = {}) {
|
||||
for (const [name, factory] of _factories) {
|
||||
if (_modules.has(name)) {
|
||||
console.warn(`[ModuleRegistry] 模块 "${name}" 已挂载,跳过。`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const mod = factory();
|
||||
await mod.init(ctx);
|
||||
await mod.mount();
|
||||
_modules.set(name, mod);
|
||||
|
||||
// 向 Bus 暴露模块公开 API
|
||||
_exposeToBus(name, mod);
|
||||
|
||||
console.log(`[ModuleRegistry] ✔ ${name}`);
|
||||
} catch (e) {
|
||||
console.error(`[ModuleRegistry] ✘ ${name} 挂载失败:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称挂载单个模块(延迟挂载场景)。
|
||||
*/
|
||||
export async function mountOne(name, ctx = {}) {
|
||||
const factory = _factories.get(name);
|
||||
if (!factory) {
|
||||
console.warn(`[ModuleRegistry] 模块 "${name}" 未注册。`);
|
||||
return null;
|
||||
}
|
||||
if (_modules.has(name)) return _modules.get(name);
|
||||
|
||||
const mod = factory();
|
||||
await mod.init(ctx);
|
||||
await mod.mount();
|
||||
_modules.set(name, mod);
|
||||
_exposeToBus(name, mod);
|
||||
return mod;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询已挂载模块的公开 API。
|
||||
*/
|
||||
export function query(name) {
|
||||
const mod = _modules.get(name);
|
||||
return mod ? mod.expose() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已挂载的模块实例(内部使用)。
|
||||
*/
|
||||
export function getInstance(name) {
|
||||
return _modules.get(name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载所有模块。
|
||||
*/
|
||||
export function disposeAll() {
|
||||
for (const [name, mod] of _modules) {
|
||||
try {
|
||||
mod.dispose();
|
||||
} catch (e) {
|
||||
console.error(`[ModuleRegistry] ${name} dispose 失败:`, e);
|
||||
}
|
||||
}
|
||||
_modules.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 已注册的模块名列表。
|
||||
*/
|
||||
export function names() {
|
||||
return [..._factories.keys()];
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────
|
||||
|
||||
function _exposeToBus(name, mod) {
|
||||
try {
|
||||
const bus = window.Amily2Bus;
|
||||
if (!bus) return;
|
||||
const exposed = mod.expose();
|
||||
if (exposed && Object.keys(exposed).length > 0) {
|
||||
const _ctx = bus.register(`Module:${name}`);
|
||||
if (_ctx) {
|
||||
_ctx.expose(exposed);
|
||||
_ctx.log(`Module:${name}`, 'info', `模块 ${name} 已注册到 Bus。`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Bus 未就绪或注册冲突,静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
export const registry = {
|
||||
register,
|
||||
mountAll,
|
||||
mountOne,
|
||||
query,
|
||||
getInstance,
|
||||
disposeAll,
|
||||
names,
|
||||
};
|
||||
|
||||
export default registry;
|
||||
22
SL/module/PlotOptModule.js
Normal file
22
SL/module/PlotOptModule.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Module, ModuleBuilder } from './Module.js';
|
||||
import { initializePlotOptimizationBindings } from '../../ui/plot-opt-bindings.js';
|
||||
|
||||
const builder = new ModuleBuilder()
|
||||
.name('PlotOptimization')
|
||||
.view('assets/Amily2-optimization.html')
|
||||
.strict(true)
|
||||
.required(['mount']);
|
||||
|
||||
export default class PlotOptModule extends Module {
|
||||
constructor() {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
async mount() {
|
||||
if (this.el) {
|
||||
this.el.id = 'amily2_plot_optimization_panel';
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
initializePlotOptimizationBindings();
|
||||
}
|
||||
}
|
||||
22
SL/module/RendererModule.js
Normal file
22
SL/module/RendererModule.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Module, ModuleBuilder } from './Module.js';
|
||||
import { initializeRendererBindings } from '../../core/tavern-helper/renderer-bindings.js';
|
||||
|
||||
const builder = new ModuleBuilder()
|
||||
.name('Renderer')
|
||||
.view('core/tavern-helper/renderer.html')
|
||||
.strict(true)
|
||||
.required(['mount']);
|
||||
|
||||
export default class RendererModule extends Module {
|
||||
constructor() {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
async mount() {
|
||||
if (this.el) {
|
||||
this.el.id = 'amily2_renderer_panel';
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
initializeRendererBindings();
|
||||
}
|
||||
}
|
||||
22
SL/module/SuperMemoryModule.js
Normal file
22
SL/module/SuperMemoryModule.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Module, ModuleBuilder } from './Module.js';
|
||||
import { bindSuperMemoryEvents } from '../../core/super-memory/bindings.js';
|
||||
|
||||
const builder = new ModuleBuilder()
|
||||
.name('SuperMemory')
|
||||
.view('core/super-memory/index.html')
|
||||
.strict(true)
|
||||
.required(['mount']);
|
||||
|
||||
export default class SuperMemoryModule extends Module {
|
||||
constructor() {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
async mount() {
|
||||
if (this.el) {
|
||||
this.el.id = 'amily2_super_memory_panel';
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
bindSuperMemoryEvents();
|
||||
}
|
||||
}
|
||||
29
SL/module/WorldEditorModule.js
Normal file
29
SL/module/WorldEditorModule.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Module, ModuleBuilder } from './Module.js';
|
||||
import { extensionName } from '../../utils/settings.js';
|
||||
|
||||
const builder = new ModuleBuilder()
|
||||
.name('WorldEditor')
|
||||
.view('WorldEditor.html');
|
||||
|
||||
export default class WorldEditorModule extends Module {
|
||||
constructor() {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
async mount() {
|
||||
if (this.el) {
|
||||
this.el.id = 'amily2_world_editor_panel';
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
// WorldEditor.js 必须作为 <script type="module"> 加载
|
||||
const scriptId = 'world-editor-script';
|
||||
if (!document.getElementById(scriptId)) {
|
||||
const extensionFolderPath = `scripts/extensions/third-party/${extensionName}`;
|
||||
const script = document.createElement('script');
|
||||
script.id = scriptId;
|
||||
script.type = 'module';
|
||||
script.src = `${extensionFolderPath}/WorldEditor/WorldEditor.js?v=${Date.now()}`;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
SL/module/register-all.js
Normal file
36
SL/module/register-all.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* register-all.js — 集中注册所有 UI 模块
|
||||
*
|
||||
* 调用 registerAllModules() 后,所有模块工厂被注册到 ModuleRegistry。
|
||||
* 随后由 drawer.js 在面板容器就绪后调用 registry.mountAll(ctx) 完成挂载。
|
||||
*
|
||||
* 注册顺序即挂载顺序 —— DOM 中面板的排列取决于此。
|
||||
*/
|
||||
|
||||
import registry from './ModuleRegistry.js';
|
||||
|
||||
import AdditionalFeaturesModule from './AdditionalFeaturesModule.js';
|
||||
import HistoriographyModule from './HistoriographyModule.js';
|
||||
import HanlinyuanModule from './HanlinyuanModule.js';
|
||||
import TableModule from './TableModule.js';
|
||||
import PlotOptModule from './PlotOptModule.js';
|
||||
import CWBModule from './CWBModule.js';
|
||||
import WorldEditorModule from './WorldEditorModule.js';
|
||||
import GlossaryModule from './GlossaryModule.js';
|
||||
import RendererModule from './RendererModule.js';
|
||||
import SuperMemoryModule from './SuperMemoryModule.js';
|
||||
import ApiConfigModule from './ApiConfigModule.js';
|
||||
|
||||
export function registerAllModules() {
|
||||
registry.register('AdditionalFeatures', () => new AdditionalFeaturesModule());
|
||||
registry.register('Historiography', () => new HistoriographyModule());
|
||||
registry.register('Hanlinyuan', () => new HanlinyuanModule());
|
||||
registry.register('Table', () => new TableModule());
|
||||
registry.register('PlotOptimization', () => new PlotOptModule());
|
||||
registry.register('CharacterWorldBook', () => new CWBModule());
|
||||
registry.register('WorldEditor', () => new WorldEditorModule());
|
||||
registry.register('Glossary', () => new GlossaryModule());
|
||||
registry.register('Renderer', () => new RendererModule());
|
||||
registry.register('SuperMemory', () => new SuperMemoryModule());
|
||||
registry.register('ApiConfig', () => new ApiConfigModule());
|
||||
}
|
||||
Reference in New Issue
Block a user