import { getRequestHeaders } from "/script.js"; import { getContext, extension_settings } 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} - 返回归一化后的文本内容 */ 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 对象中提取标准生成参数 * 严格复刻 SillyTavern 原始 Payload 逻辑 */ _buildProfilePayload(targetProfile) { const context = getContext(); // 1. 基础克隆 const payload = { ...targetProfile }; // 2. 注入运行时元数据 (这是旧版能通的关键,包含用户/角色名等) payload.user_name = context.name1 || 'User'; payload.char_name = context.name2 || 'AI'; payload.group_names = []; // 暂不处理群组 payload.use_sysprompt = true; payload.type = 'quiet'; payload.custom_prompt_post_processing = payload.custom_prompt_post_processing || 'strict'; // 3. 规范化模型字段 if (!payload.model) { payload.model = payload.openai_model || payload.claude_model || payload.mistral_model || ''; } // 4. 精准对齐 URL 映射 (解决 403/400 错误的核心) const rawUrl = payload['api-url'] || payload['api_url'] || payload.custom_url || payload.url; if (rawUrl) { // 如果 Source 是 custom,严格遵循旧版:custom_url 有值,reverse_proxy 为空 if (payload.chat_completion_source === 'custom') { payload.custom_url = rawUrl; payload.reverse_proxy = payload.reverse_proxy || ''; } else { // 如果是 openai,则填充 reverse_proxy payload.reverse_proxy = rawUrl; payload.custom_url = rawUrl; } // 兼容性修补 payload.zai_endpoint = rawUrl; payload.vertexai_region = rawUrl; } // 5. 补全采样参数 (严格对齐 UI 当前状态) const globalGenSettings = extension_settings.text_generation || {}; const fields = ['temperature', 'max_tokens', 'top_p', 'top_k', 'min_p', 'frequency_penalty', 'presence_penalty', 'repetition_penalty']; fields.forEach(field => { if (payload[field] === undefined) { payload[field] = globalGenSettings[field] ?? (field === 'temperature' ? 1 : 0); } }); // 6. 确保 Source 存在且不被错误覆盖 if (!payload.chat_completion_source) { payload.chat_completion_source = 'openai'; } return payload; } }