添加 ModelCaller 和 Options 类,重构 Logger,增强 Amily2Bus 架构,更新 README 和 TODO 文件

This commit is contained in:
2026-01-18 11:40:35 +08:00
committed by Silence_Lurker
parent 2c31e1cbc8
commit 1e20ce79a1
7 changed files with 637 additions and 50 deletions

223
SL/bus/api/ModelCaller.js Normal file
View File

@@ -0,0 +1,223 @@
import { getRequestHeaders } from "/script.js";
import { getContext } from "/scripts/extensions.js";
import { amilyHelper } from '../../core/tavern-helper/main.js';
import Options from './Options.js';
import RequestBody from './RequestBody.js';
/**
* ModelCaller Service
* 负责执行 API 调用逻辑,旨在替换 NccsApi 及其他散乱的请求逻辑。
* 支持标准直连、ST预设调用、伪流式聚合(防超时)、数据归一化。
*/
export default class ModelCaller {
/**
* 构造函数注入 Logger 依赖
* @param {Object} loggerDelegate - { log: (level, msg, origin, plugin) => void }
*/
constructor(loggerDelegate) {
/** @type {Object} */
this.logger = loggerDelegate;
this.defaultHeaders = {
'Content-Type': 'application/json'
};
}
/**
* 统一调用入口
* @param {string} callerName - 调用者名称(日志用)
* @param {Array} messages - 聊天消息历史
* @param {Options} options - 配置对象实例
* @returns {Promise<string>} - 返回归一化后的文本内容
*/
async call(callerName, messages, options) {
// 1. 强制类型检查
if (!(options instanceof Options)) {
const errorMsg = `[ModelCaller] Options must be instance of Options class.`;
throw new TypeError(errorMsg);
}
// 2. 逻辑中直接使用 options 属性
this._log('info', `API Request [${options.mode}] Stream: ${options.fakeStream}`, callerName);
try {
// 统一构建请求体 DTO
const requestBody = new RequestBody(messages, options);
let result;
if (options.mode === 'preset') {
result = await this._callPreset(callerName, requestBody, options);
} else {
result = await this._callDirect(callerName, requestBody, options);
}
return this._normalize(result);
} catch (error) {
this._log('error', `Request Failed: ${error.message}`, callerName);
throw error;
}
}
// 内部日志封装
_log(level, msg, plugin) {
if (this.logger && typeof this.logger.log === 'function') {
this.logger.log(level, msg, 'ModelCaller', plugin);
}
}
// ========================================================================
// 模式一Direct (标准直连)
// 对应 NccsApi 中的 callNccsOpenAITest
// ========================================================================
async _callDirect(callerName, requestBody, options) {
// 构建标准 OpenAI 兼容 Body
// 目标通常是 ST 的后端代理接口
const url = '/api/backends/chat-completions/generate';
const payload = requestBody.toPayload(); // 使用 DTO 生成数据
const fetchOpts = {
method: 'POST',
headers: { ...getRequestHeaders(), ...this.defaultHeaders },
body: JSON.stringify(payload)
};
return options.fakeStream
? this._fetchFakeStream(url, fetchOpts)
: this._fetchStandard(url, fetchOpts);
}
// ========================================================================
// 模式二Preset (ST预设调用)
// 对应 NccsApi 中的 callNccsSillyTavernPreset
// ========================================================================
async _callPreset(callerName, requestBody, options) {
const context = getContext();
// 1. 记录并切换 Profile
const originalProfile = await amilyHelper.triggerSlash('/profile');
const targetProfile = context.extensionSettings?.connectionManager?.profiles?.find(p => p.id === options.presetId);
if (!targetProfile) throw new Error(`Preset ID ${options.presetId} not found`);
if (originalProfile !== targetProfile.name) {
this._log('info', `Switching profile: ${originalProfile} -> ${targetProfile.name}`, callerName);
const escapedName = targetProfile.name.replace(/"/g, '\\"');
await amilyHelper.triggerSlash(`/profile await=true "${escapedName}"`);
}
try {
// 2. 根据流式需求分流处理
if (options.fakeStream) {
// 【流式预设请求】
// 难点ST 的 ConnectionManagerRequestService 不暴露流。
// 策略:切换 Profile 后,手动向生成接口发送请求。
const url = '/api/backends/chat-completions/generate';
// Preset 模式下只需要最小载荷
const payload = requestBody.toMinimalPayload();
const fetchOpts = {
method: 'POST',
headers: { ...getRequestHeaders(), ...this.defaultHeaders },
body: JSON.stringify(payload)
};
return await this._fetchFakeStream(url, fetchOpts);
} else {
// 【非流式预设请求】
// 直接使用 ST 原生服务,最稳妥
if (!context.ConnectionManagerRequestService) throw new Error('ST Request Service unavailable');
return await context.ConnectionManagerRequestService.sendRequest(
targetProfile.id,
requestBody.messages,
options.maxTokens
);
}
} finally {
// 3. 恢复 Profile
if (originalProfile) {
try {
const current = await amilyHelper.triggerSlash('/profile');
if (originalProfile !== current) {
const escapedOriginal = originalProfile.replace(/"/g, '\\"');
await amilyHelper.triggerSlash(`/profile await=true "${escapedOriginal}"`);
}
} catch (e) {
this._log('warn', `Failed to restore profile: ${e.message}`, callerName);
}
}
}
}
// ========================================================================
// 网络层核心
// ========================================================================
async _fetchStandard(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
// const text = await response.text();
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
}
// 伪流式聚合:防 CloudFlare 超时
async _fetchFakeStream(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Stream HTTP ${response.status}`);
}
if (!response.body) {
return await response.json();
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let aggregated = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 持续读取保持连接活跃
aggregated += decoder.decode(value, { stream: true });
}
aggregated += decoder.decode();
try {
return JSON.parse(aggregated);
} catch (e) {
// 如果是 SSE 格式或其他非 JSON 格式,暂且返回文本
return aggregated;
}
} finally {
reader.releaseLock();
}
}
// ========================================================================
// 数据归一化
// ========================================================================
_normalize(data) {
// 如果是 JSON 字符串则解析
if (typeof data === 'string') {
try { data = JSON.parse(data); } catch (e) { return data; }
}
// 处理 OpenAI 格式
if (data?.choices?.[0]?.message?.content) {
return data.choices[0].message.content.trim();
}
// 处理常规 content 格式
if (data?.content) {
return data.content.trim();
}
// Fallback
return typeof data === 'object' ? JSON.stringify(data) : data;
}
}

98
SL/bus/api/Options.js Normal file
View File

@@ -0,0 +1,98 @@
/**
* ModelCaller 请求配置类
* 支持构造函数直接传入对象,或使用 Builder 链式调用
*/
export class Options {
constructor(config = {}) {
/** @type {'direct'|'preset'} */
this.mode = config.mode || 'direct';
/** @type {boolean} */
this.fakeStream = config.fakeStream ?? false;
/** @type {string} */
this.apiUrl = config.apiUrl || '';
/** @type {string} */
this.apiKey = config.apiKey || '';
/** @type {string} */
this.model = config.model || '';
/** @type {string} */
this.presetId = config.presetId || '';
/** @type {number} */
this.maxTokens = config.maxTokens || 4000;
/** @type {number} */
this.temperature = config.temperature || 0.7;
/** @type {Object} 额外透传参数 */
this.params = config.params || {};
}
/**
* 获取 Builder 实例
* @returns {OptionsBuilder}
*/
static builder() {
return new OptionsBuilder();
}
}
/**
* Options 构建器类
*/
class OptionsBuilder {
constructor() {
this.config = {};
}
setMode(mode) {
this.config.mode = mode;
return this;
}
setFakeStream(enabled) {
this.config.fakeStream = enabled;
return this;
}
setApiUrl(url) {
this.config.apiUrl = url;
return this;
}
setApiKey(key) {
this.config.apiKey = key;
return this;
}
setModel(model) {
this.config.model = model;
return this;
}
setPresetId(id) {
this.config.presetId = id;
return this;
}
setMaxTokens(tokens) {
this.config.maxTokens = tokens;
return this;
}
setTemperature(temp) {
this.config.temperature = temp;
return this;
}
setParams(params) {
this.config.params = { ...(this.config.params || {}), ...params };
return this;
}
/**
* 构建最终的 Options 对象
* @returns {Options}
*/
build() {
return new Options(this.config);
}
}
export default Options;

68
SL/bus/api/RequestBody.js Normal file
View File

@@ -0,0 +1,68 @@
import Options from './Options.js';
/**
* RequestBody (DTO)
* 严格约束发送给 LLM/ST 后端的请求体结构
* 类似于 Java 中的 RequestBean
*/
export class RequestBody {
/**
* @param {Array} messages
* @param {Options} options
*/
constructor(messages, options) {
if (!Array.isArray(messages)) throw new TypeError('messages must be an array');
if (!(options instanceof Options)) throw new TypeError('options must be an instance of Options');
this.messages = messages;
this.options = options;
}
/**
* 构建标准 OpenAI 兼容的 Payload
* @returns {Object} 纯净的 JSON 对象
*/
toPayload() {
const { apiUrl, apiKey, model, maxTokens, temperature, params } = this.options;
const isGoogle = apiUrl && apiUrl.includes('googleapis.com');
// 基础字段 (Base Fields)
const payload = {
chat_completion_source: 'openai',
messages: this.messages,
model: model,
reverse_proxy: apiUrl,
proxy_password: apiKey,
stream: false, // 这里的 stream 是指 ST 后端行为,始终为 false
max_tokens: maxTokens,
temperature: temperature,
// 允许 Options 中的 params 覆盖上述字段
...params
};
// 平台特定字段处理 (Platform Specific Logic)
if (!isGoogle) {
Object.assign(payload, {
custom_prompt_post_processing: 'strict',
presence_penalty: 0.12,
include_reasoning: false,
reasoning_effort: 'medium'
});
}
return payload;
}
/**
* 仅获取消息体的 Payload (用于 Preset 模式)
*/
toMinimalPayload() {
return {
messages: this.messages,
max_tokens: this.options.maxTokens,
...this.options.params
};
}
}
export default RequestBody;