mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
345 lines
11 KiB
JavaScript
345 lines
11 KiB
JavaScript
/**
|
|
* @fileoverview 请求解析模块
|
|
* @description 负责解析聊天请求、提取提示词和处理图片
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import sharp from 'sharp';
|
|
import { IMAGE_POLICY } from '../../../backend/registry.js';
|
|
import { ERROR_CODES, getErrorMessage } from '../../errors.js';
|
|
|
|
/**
|
|
* 构造解析错误结果
|
|
* @param {string} code - 错误码
|
|
* @param {string} [customMessage] - 自定义消息(可选,用于包含动态参数)
|
|
* @returns {{success: false, error: {code: string, error: string}}}
|
|
*/
|
|
function parseError(code, customMessage) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code,
|
|
error: customMessage || getErrorMessage(code)
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @typedef {object} ParsedRequest
|
|
* @property {string} prompt - 提取的提示词
|
|
* @property {string[]} imagePaths - 图片临时文件路径
|
|
* @property {string|null} modelId - 解析后的模型 ID
|
|
* @property {string|null} modelName - 原始模型名称
|
|
* @property {boolean} isStreaming - 是否流式请求
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} ParseError
|
|
* @property {string} code - 错误码
|
|
* @property {string} error - 错误消息
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} ParseResult
|
|
* @property {boolean} success - 是否解析成功
|
|
* @property {ParsedRequest} [data] - 解析结果(成功时)
|
|
* @property {ParseError} [error] - 错误信息(失败时)
|
|
*/
|
|
|
|
/**
|
|
* 解析聊天请求
|
|
* @param {object} data - 请求体数据
|
|
* @param {object} options - 解析选项
|
|
* @param {string} options.tempDir - 临时目录路径
|
|
* @param {number} options.imageLimit - 图片数量限制
|
|
* @param {string} options.backendName - 后端名称
|
|
* @param {Function} options.getSupportedModels - 获取支持的模型列表函数
|
|
* @param {Function} options.getImagePolicy - 获取图片策略函数
|
|
* @param {Function} options.getModelType - 获取模型类型函数
|
|
* @param {string} options.requestId - 请求 ID
|
|
* @param {Function} options.logger - 日志函数
|
|
* @returns {Promise<ParseResult>} 解析结果
|
|
*/
|
|
export async function parseRequest(data, options) {
|
|
const {
|
|
tempDir,
|
|
imageLimit,
|
|
backendName,
|
|
getSupportedModels,
|
|
getImagePolicy,
|
|
getModelType,
|
|
requestId,
|
|
logger
|
|
} = options;
|
|
|
|
const messages = data.messages;
|
|
const isStreaming = data.stream === true;
|
|
|
|
// 验证 messages
|
|
if (!messages || messages.length === 0) {
|
|
return parseError(ERROR_CODES.NO_MESSAGES);
|
|
}
|
|
|
|
// 1. 解析模型参数与类型
|
|
let modelKey = null;
|
|
let isTextMode = false;
|
|
|
|
if (data.model) {
|
|
// 检查模型是否在支持列表中
|
|
const supportedModels = getSupportedModels();
|
|
const isSupported = supportedModels.data.some(m => m.id === data.model);
|
|
|
|
if (isSupported) {
|
|
modelKey = data.model;
|
|
logger.info('服务器', `触发模型: ${data.model}`, { id: requestId });
|
|
|
|
// 判定是否为文本模式
|
|
const type = getModelType ? getModelType(data.model) : 'image';
|
|
isTextMode = type === 'text';
|
|
|
|
if (isTextMode) {
|
|
logger.info('服务器', '解析模式: 文本对话 (虚拟上下文构建)', { id: requestId });
|
|
} else {
|
|
logger.info('服务器', '解析模式: 图像生成 (仅取最后一条)', { id: requestId });
|
|
}
|
|
|
|
} else {
|
|
return parseError(ERROR_CODES.INVALID_MODEL, `模型无效/后端 ${backendName} 不支持: ${data.model}`);
|
|
}
|
|
} else {
|
|
logger.info('服务器', '未指定模型,使用网页默认', { id: requestId });
|
|
}
|
|
|
|
// ============================================================
|
|
// 分支 A: 文本模型解析 (构建虚拟上下文)
|
|
// ============================================================
|
|
if (isTextMode) {
|
|
return await parseTextRequest(messages, tempDir, imageLimit, modelKey, isStreaming);
|
|
}
|
|
|
|
// ============================================================
|
|
// 分支 B: 生图模型解析 (原有逻辑)
|
|
// ============================================================
|
|
return await parseImageRequest(messages, tempDir, imageLimit, modelKey, isStreaming, getImagePolicy);
|
|
}
|
|
|
|
/**
|
|
* 解析文本请求 (构建虚拟上下文)
|
|
*/
|
|
async function parseTextRequest(messages, tempDir, imageLimit, modelId, isStreaming) {
|
|
let systemPrompt = '';
|
|
let historyPrompt = '';
|
|
let currentPrompt = '';
|
|
|
|
const imagePaths = [];
|
|
let globalImageCount = 0;
|
|
|
|
// 辅助函数:处理单条消息内容
|
|
async function processContent(content) {
|
|
let textBuffer = '';
|
|
if (typeof content === 'string') {
|
|
textBuffer += content;
|
|
} else if (Array.isArray(content)) {
|
|
for (const item of content) {
|
|
if (item.type === 'text') {
|
|
textBuffer += item.text;
|
|
} else if (item.type === 'image_url' && item.image_url?.url) {
|
|
globalImageCount++;
|
|
|
|
// 图片数量限制检查
|
|
if (imageLimit > 0 && globalImageCount > imageLimit) {
|
|
textBuffer += `[图片${globalImageCount} (已忽略:超过限制)]`;
|
|
continue;
|
|
}
|
|
|
|
const url = item.image_url.url;
|
|
if (url.startsWith('data:image')) {
|
|
const imagePath = await saveBase64Image(url, tempDir);
|
|
if (imagePath) {
|
|
imagePaths.push(imagePath);
|
|
// 插入占位符
|
|
textBuffer += `[图片${globalImageCount}]`;
|
|
} else {
|
|
textBuffer += `[图片${globalImageCount} (上传失败)]`;
|
|
}
|
|
} else {
|
|
textBuffer += `[图片${globalImageCount} (无效链接)]`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return textBuffer;
|
|
}
|
|
|
|
// 1. 提取 System Prompt
|
|
const systemMsg = messages.find(m => m.role === 'system');
|
|
if (systemMsg) {
|
|
const content = await processContent(systemMsg.content);
|
|
if (content) {
|
|
systemPrompt = `=== 系统指令 (永远置顶) ===\n${content}\n\n`;
|
|
}
|
|
}
|
|
|
|
// 2. 区分历史和当前消息
|
|
// 找到最后一条 user 消息的索引
|
|
let lastUserIndex = -1;
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
if (messages[i].role === 'user') {
|
|
lastUserIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (lastUserIndex === -1) {
|
|
return parseError(ERROR_CODES.NO_USER_MESSAGES);
|
|
}
|
|
|
|
// 3. 构建历史对话 (不包含 system 和 最后一条 user)
|
|
const historyMessages = messages.filter((m, index) => {
|
|
return m.role !== 'system' && index < lastUserIndex;
|
|
});
|
|
|
|
if (historyMessages.length > 0) {
|
|
historyPrompt += `=== 历史对话 (滑动窗口或摘要) ===\n`;
|
|
for (const msg of historyMessages) {
|
|
const roleName = msg.role === 'user' ? 'User' : 'AI';
|
|
const content = await processContent(msg.content);
|
|
historyPrompt += `${roleName}: ${content}\n`;
|
|
}
|
|
historyPrompt += `\n`;
|
|
}
|
|
|
|
// 4. 构建当前输入
|
|
const lastUserMsg = messages[lastUserIndex];
|
|
const currentContent = await processContent(lastUserMsg.content);
|
|
|
|
// 判断是否需要添加分割符号
|
|
const hasContext = systemPrompt || historyPrompt;
|
|
if (hasContext) {
|
|
// 有上下文,添加分割符
|
|
currentPrompt = `=== 当前输入 ===\nUser: ${currentContent}`;
|
|
} else {
|
|
// 没有上下文,直接使用内容
|
|
currentPrompt = currentContent;
|
|
}
|
|
|
|
// 5. 合并最终 Prompt
|
|
const finalPrompt = systemPrompt + historyPrompt + currentPrompt;
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
prompt: finalPrompt,
|
|
imagePaths,
|
|
modelId,
|
|
modelName: modelId,
|
|
isStreaming
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 解析生图请求 (原有逻辑)
|
|
*/
|
|
async function parseImageRequest(messages, tempDir, imageLimit, modelId, isStreaming, getImagePolicy) {
|
|
// 筛选用户消息
|
|
const userMessages = messages.filter(m => m.role === 'user');
|
|
if (userMessages.length === 0) {
|
|
return parseError(ERROR_CODES.NO_USER_MESSAGES);
|
|
}
|
|
|
|
const lastMessage = userMessages[userMessages.length - 1];
|
|
|
|
let prompt = '';
|
|
const imagePaths = [];
|
|
let imageCount = 0;
|
|
|
|
// 解析内容
|
|
if (Array.isArray(lastMessage.content)) {
|
|
for (const item of lastMessage.content) {
|
|
if (item.type === 'text') {
|
|
prompt += item.text + ' ';
|
|
} else if (item.type === 'image_url' && item.image_url?.url) {
|
|
imageCount++;
|
|
|
|
// 图片数量检查
|
|
if (imageLimit <= 10) {
|
|
if (imageCount > imageLimit) {
|
|
return parseError(ERROR_CODES.TOO_MANY_IMAGES, `图片数量超过限制(最大 ${imageLimit} 张)`);
|
|
}
|
|
} else {
|
|
// imageLimit > 10:超过浏览器硬限制时忽略
|
|
if (imageCount > 10) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// 处理 data URL
|
|
const url = item.image_url.url;
|
|
if (url.startsWith('data:image')) {
|
|
const imagePath = await saveBase64Image(url, tempDir);
|
|
if (imagePath) {
|
|
imagePaths.push(imagePath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
prompt = lastMessage.content;
|
|
}
|
|
|
|
prompt = prompt.trim();
|
|
|
|
// 图片策略校验
|
|
const hasImage = imagePaths.length > 0;
|
|
const policy = modelId ? getImagePolicy(modelId) : IMAGE_POLICY.OPTIONAL;
|
|
|
|
if (policy === IMAGE_POLICY.REQUIRED && !hasImage) {
|
|
return parseError(ERROR_CODES.IMAGE_REQUIRED, `模型 ${modelId} 需要参考图`);
|
|
}
|
|
|
|
if (policy === IMAGE_POLICY.FORBIDDEN && hasImage) {
|
|
return parseError(ERROR_CODES.IMAGE_FORBIDDEN, `模型 ${modelId} 不支持图片输入`);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
prompt,
|
|
imagePaths,
|
|
modelId,
|
|
modelName: modelId,
|
|
isStreaming
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 保存 Base64 图片到临时文件
|
|
* @param {string} dataUrl - data URL 格式的图片
|
|
* @param {string} tempDir - 临时目录
|
|
* @returns {Promise<string|null>} 保存的文件路径,失败返回 null
|
|
*/
|
|
async function saveBase64Image(dataUrl, tempDir) {
|
|
const matches = dataUrl.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
|
|
if (!matches || matches.length !== 3) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const buffer = Buffer.from(matches[2], 'base64');
|
|
// 压缩图片
|
|
const processedBuffer = await sharp(buffer)
|
|
.jpeg({ quality: 90 })
|
|
.toBuffer();
|
|
|
|
const filename = `img_${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`;
|
|
const filePath = path.join(tempDir, filename);
|
|
fs.writeFileSync(filePath, processedBuffer);
|
|
return filePath;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|