ci: auto build & obfuscate [2026-04-06 00:50:28] (Jenkins #7)

This commit is contained in:
Jenkins CI
2026-04-06 00:50:28 +08:00
parent ed3f52a568
commit 49c1fa6f60
142 changed files with 38769 additions and 29661 deletions

View File

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

329
SL/bus/GUIDE.md Normal file
View 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. **生产与开发** — 页面刷新会重置整个总线,不需要手动清理。热重载时的重复注册异常是预期行为,不影响功能

View File

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