Files
ST-Amily2-Chat-Optimisation/SL/bus/api/ModelCaller.js
2026-01-20 10:36:44 +08:00

309 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}] StreamMode: ${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);
}
// 如果是流式返回result 已经是拼接好的字符串,不需要 normalize 的部分逻辑
// 但为了统一,我们还是传进去检查一下
return this._normalize(result, options.fakeStream);
} catch (error) {
this._log('error', `Request Failed: ${error.message}`, callerName);
throw error;
}
}
// 内部日志封装
_log(level, msg, plugin) {
if (this.logger?.log) {
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';
// [修复]: 手动合并 Profile 中的关键参数,否则后端不会自动应用预设配置
// 提取逻辑已封装至 _buildProfilePayload
const profilePayload = this._buildProfilePayload(targetProfile);
// 合并顺序基础Payload(msg) < Profile预设 < 显式Params覆盖
// toMinimalPayload 包含: messages, stream, max_tokens, ...params
const minimal = requestBody.toMinimalPayload();
const finalPayload = {
...profilePayload,
...minimal,
...options.params
};
const fetchOpts = {
method: 'POST',
headers: { ...getRequestHeaders(), ...this.defaultHeaders },
body: JSON.stringify(finalPayload)
};
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, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
}
// 【核心升级】:支持 SSE 解析的伪流式聚合,防 CloudFlare 超时
async _fetchFakeStream(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`Stream HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let fullContent = ""; // 用于存储最终拼接的纯文本
let buffer = ""; // 用于存储未处理完的数据片段
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 1. 解码当前数据包
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// 2. 处理 SSE 格式 (data: {...})
// 以双换行符分割每一条 SSE 消息
const lines = buffer.split('\n');
// 保留最后一个可能不完整的片段在 buffer 中
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === 'data: [DONE]') continue;
if (trimmed.startsWith('data: ')) {
try {
const jsonStr = trimmed.substring(6); // 去掉 'data: '
const json = JSON.parse(jsonStr);
// 提取 delta content
const delta = json.choices?.[0]?.delta?.content;
if (delta) {
fullContent += delta;
}
} catch (e) {
// 忽略解析错误的行,防止因为个别丢包导致整个请求失败
console.warn('[ModelCaller] SSE Parse Error:', e);
}
}
}
}
} finally {
reader.releaseLock();
}
// 如果 fullContent 是空的,说明可能服务端根本没返回 SSE 格式,而是直接返回了纯文本或 JSON
// 这种情况下尝试降级处理
if (!fullContent && buffer) {
try {
const json = JSON.parse(buffer);
return json; // 是标准 JSON
} catch {
return buffer; // 是纯文本
}
}
return fullContent;
}
// ========================================================================
// 数据归一化
// ========================================================================
_normalize(data, isFromStream = false) {
// 如果是从流式聚合来的,它已经是一个纯字符串了,直接返回
if (isFromStream && typeof data === 'string') {
return 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;
}
/**
* 辅助方法:从 Profile 对象中提取标准生成参数
* 用于在手动构造请求时模拟 ST 后端行为
*/
_buildProfilePayload(targetProfile) {
// 1. 全量继承 Profile 属性
const payload = { ...targetProfile };
// 2. 规范化关键字段
// ST 的 Profile 里模型名可能叫 openai_model
if (!payload.model) {
payload.model = payload.openai_model || payload.claude_model || payload.mistral_model || payload.text_generation_webui_model || '';
}
// 3. 强制修正 URL 映射 (解决 400 错误的核心)
// Profile 原始数据里可能是 api-url (中划线), api_url, custom_url 等
const rawUrl = payload['api-url'] || payload['api_url'] || payload.custom_url || payload.url;
if (rawUrl) {
// OpenAI 模式认 reverse_proxy
payload.reverse_proxy = rawUrl;
// Custom 模式认 custom_url (双保险)
payload.custom_url = rawUrl;
// 某些后端可能需要这个
payload.openai_reverse_proxy = rawUrl;
}
// 4. 强制指定 Source 为 OpenAI
// 我们的 ModelCaller._fetchFakeStream 是按 OpenAI SSE 格式解析的
// 只要 reverse_proxy 设置正确,后端就会按 OpenAI 协议转发
payload.chat_completion_source = 'openai';
// 5. 补充默认采样参数 (防止 undefined)
if (payload.temperature === undefined) payload.temperature = 1;
if (payload.max_tokens === undefined) payload.max_tokens = 2000;
if (payload.top_p === undefined) payload.top_p = 1;
if (payload.frequency_penalty === undefined) payload.frequency_penalty = 0;
if (payload.presence_penalty === undefined) payload.presence_penalty = 0;
return payload;
}
}