Files
ST-Amily2-Chat-Optimisation…/SL/bus/api/ModelCaller.js

291 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 中的关键参数,否则后端不会自动应用预设配置
// 我们需要模拟 ST 前端发送请求时的行为,把预设参数填进去
const profilePayload = {
// 基础模型参数
model: targetProfile.openai_model || targetProfile.model,
temperature: targetProfile.temperature,
frequency_penalty: targetProfile.frequency_penalty,
presence_penalty: targetProfile.presence_penalty,
top_p: targetProfile.top_p,
top_k: targetProfile.top_k,
min_p: targetProfile.min_p,
repetition_penalty: targetProfile.repetition_penalty,
// 关键OpenAI 源标记
chat_completion_source: targetProfile.chat_completion_source || 'openai',
// 代理设置 (如果预设里有)
reverse_proxy: targetProfile.reverse_proxy,
proxy_password: targetProfile.proxy_password,
// 其他可能影响生成的参数
custom_prompt_post_processing: targetProfile.custom_prompt_post_processing ?? 'strict',
};
// 合并顺序基础Payload(msg) < Profile预设 < 显式Params覆盖
// toMinimalPayload 包含: messages, stream, max_tokens, ...params
// 我们需要把 profilePayload 塞在中间,被 params 覆盖
const minimal = requestBody.toMinimalPayload();
// 剔除 minimal 中可能已经存在的 undefined 属性,避免覆盖 profile 的有效值
// 但实际上 minimal 中的 ...params 是用户强指定的,应该覆盖 profile
const finalPayload = {
...profilePayload,
...minimal, // 包含 messages, stream, max_tokens
...options.params // 再次确保显式参数优先级最高 (minimal里其实已经含了这里双保险)
};
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;
}
}