mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-06 19:45:50 +00:00
添加 ModelCaller 和 Options 类,重构 Logger,增强 Amily2Bus 架构,更新 README 和 TODO 文件
This commit is contained in:
223
SL/bus/api/ModelCaller.js
Normal file
223
SL/bus/api/ModelCaller.js
Normal 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
98
SL/bus/api/Options.js
Normal 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
68
SL/bus/api/RequestBody.js
Normal 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;
|
||||
Reference in New Issue
Block a user