mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 初步支持文本模型
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
|
||||
import { parseRequest } from '../src/server/parseChat.js';
|
||||
|
||||
// Mock options
|
||||
const mockOptions = {
|
||||
tempDir: './data/temp',
|
||||
imageLimit: 5,
|
||||
backendName: 'test-backend',
|
||||
resolveModelId: (id) => id === 'gpt-4-text' ? 'resolved-gpt-4' : null,
|
||||
getImagePolicy: () => 'optional',
|
||||
getModelType: (id) => id === 'gpt-4-text' ? 'text' : 'image',
|
||||
requestId: 'test-req-id',
|
||||
logger: { info: console.log, error: console.error, warn: console.warn, debug: console.log }
|
||||
};
|
||||
|
||||
async function testTextParsing() {
|
||||
console.log('--- Testing Text Model Parsing ---');
|
||||
const requestData = {
|
||||
model: 'gpt-4-text',
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a cat.' },
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Meow' },
|
||||
{
|
||||
role: 'user', content: [
|
||||
{ type: 'text', text: 'Look at this ' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = await parseRequest(requestData, mockOptions);
|
||||
|
||||
if (result.success) {
|
||||
console.log('Success!');
|
||||
console.log('Prompt:\n' + result.data.prompt);
|
||||
console.log('Image Paths:', result.data.imagePaths);
|
||||
|
||||
if (result.data.prompt.includes('=== 系统指令') && result.data.prompt.includes('User: Look at this')) {
|
||||
console.log('✅ Text Parsing Verification PASSED');
|
||||
} else {
|
||||
console.error('❌ Text Parsing Verification FAILED');
|
||||
}
|
||||
|
||||
} else {
|
||||
console.error('Failed:', result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async function testImageParsing() {
|
||||
console.log('\n--- Testing Image Model Parsing (Legacy) ---');
|
||||
const requestData = {
|
||||
model: 'some-image-model',
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'user', content: 'Draw a cat' }
|
||||
]
|
||||
};
|
||||
|
||||
const options = {
|
||||
...mockOptions,
|
||||
resolveModelId: (id) => 'resolved-image-model',
|
||||
getModelType: () => 'image'
|
||||
};
|
||||
|
||||
const result = await parseRequest(requestData, options);
|
||||
|
||||
if (result.success) {
|
||||
console.log('Success!');
|
||||
console.log('Prompt:', result.data.prompt);
|
||||
if (result.data.prompt === 'Draw a cat') {
|
||||
console.log('✅ Image Parsing Verification PASSED');
|
||||
} else {
|
||||
console.error('❌ Image Parsing Verification FAILED');
|
||||
}
|
||||
} else {
|
||||
console.error('Failed:', result.error);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await testTextParsing();
|
||||
await testImageParsing();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
@@ -58,7 +58,8 @@ const {
|
||||
TEMP_DIR,
|
||||
resolveModelId,
|
||||
getModels,
|
||||
getImagePolicy
|
||||
getImagePolicy,
|
||||
getModelType
|
||||
} = backend;
|
||||
|
||||
/** @type {number} 服务器端口 */
|
||||
@@ -102,16 +103,13 @@ const queueManager = createQueueManager(
|
||||
: null
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 路由处理器:负责 API 路由分发和鉴权
|
||||
*/
|
||||
const handleRequest = createRouter({
|
||||
authToken: AUTH_TOKEN,
|
||||
backendName,
|
||||
getModels,
|
||||
resolveModelId,
|
||||
getImagePolicy,
|
||||
getModelType,
|
||||
tempDir: TEMP_DIR,
|
||||
imageLimit: IMAGE_LIMIT,
|
||||
queueManager
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @fileoverview LMArena 适配器
|
||||
*/
|
||||
|
||||
import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
waitApiResponse,
|
||||
normalizePageError,
|
||||
normalizeHttpError,
|
||||
|
||||
moveMouseAway,
|
||||
waitForInput,
|
||||
gotoWithCheck
|
||||
} from '../utils/index.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const TARGET_URL = 'https://lmarena.ai/c/new?mode=direct';
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
* @param {object} context - 浏览器上下文 { page, client }
|
||||
* @param {string} prompt - 提示词
|
||||
* @param {string[]} imgPaths - 图片路径数组
|
||||
* @param {string} [modelId] - 指定的模型 ID (可选)
|
||||
* @param {object} [meta={}] - 日志元数据
|
||||
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const { page, config } = context;
|
||||
const textareaSelector = 'textarea';
|
||||
|
||||
try {
|
||||
logger.info('适配器', '开启新会话...', meta);
|
||||
const gotoResult = await gotoWithCheck(page, TARGET_URL);
|
||||
if (gotoResult.error) return gotoResult;
|
||||
|
||||
// 1. 等待输入框加载
|
||||
await waitForInput(page, textareaSelector, { click: false });
|
||||
await sleep(1500, 2500);
|
||||
|
||||
// 2. 上传图片 (uploadImages)
|
||||
if (imgPaths && imgPaths.length > 0) {
|
||||
await pasteImages(page, textareaSelector, imgPaths);
|
||||
}
|
||||
|
||||
// 3. 填写提示词 (fillPrompt)
|
||||
await safeClick(page, textareaSelector, { bias: 'input' });
|
||||
await fillPrompt(page, textareaSelector, prompt, meta);
|
||||
|
||||
// 4. 配置请求拦截 (用于修改模型 ID)
|
||||
await page.unroute('**/*').catch(() => { });
|
||||
|
||||
if (modelId) {
|
||||
logger.debug('适配器', `准备拦截请求`, meta);
|
||||
await page.route(url => url.href.includes('/nextjs-api/stream'), async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== 'POST') return route.continue();
|
||||
|
||||
try {
|
||||
const postData = request.postDataJSON();
|
||||
if (postData && postData.modelAId) {
|
||||
logger.info('适配器', `已拦截请求并修改模型: ${postData.modelAId} -> ${modelId}`, meta);
|
||||
postData.modelAId = modelId;
|
||||
await route.continue({ postData: JSON.stringify(postData) });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('适配器', '拦截处理异常', { ...meta, error: e.message });
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 提交表单 (submit)
|
||||
logger.debug('适配器', '点击发送...', meta);
|
||||
await submit(page, {
|
||||
btnSelector: 'button[type="submit"]',
|
||||
inputTarget: textareaSelector,
|
||||
meta
|
||||
});
|
||||
|
||||
logger.info('适配器', '等待生成结果...', meta);
|
||||
|
||||
// 6. 等待 API 响应 (waitApiResponse)
|
||||
let response;
|
||||
try {
|
||||
response = await waitApiResponse(page, {
|
||||
urlMatch: '/nextjs-api/stream',
|
||||
method: 'POST',
|
||||
timeout: 120000,
|
||||
meta
|
||||
});
|
||||
} catch (e) {
|
||||
// 使用公共错误处理
|
||||
const pageError = normalizePageError(e, meta);
|
||||
if (pageError) return pageError;
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 7. 解析响应结果
|
||||
const content = await response.text();
|
||||
|
||||
// 8. 检查 HTTP 错误
|
||||
const httpError = normalizeHttpError(response, content);
|
||||
if (httpError) {
|
||||
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
|
||||
return { error: `请求生成时返回错误: ${httpError.error}` };
|
||||
}
|
||||
|
||||
// 9. 解析文本流
|
||||
// 格式示例:
|
||||
// a0:"Hello"
|
||||
// a0:" World"
|
||||
// d:{"finishReason":"stop"}
|
||||
let fullText = '';
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('a0:')) {
|
||||
try {
|
||||
// 尝试解析 JSON 字符串内容
|
||||
// line.substring(3) 应该是 JSON 字符串,如 "Hello"
|
||||
const textPart = JSON.parse(line.substring(3));
|
||||
fullText += textPart;
|
||||
} catch (e) {
|
||||
// 如果解析失败,可能是原生文本或其他格式
|
||||
logger.warn('适配器', `解析文本块失败: ${line}`, meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fullText) {
|
||||
logger.info('适配器', `获取文本成功,长度: ${fullText.length}`, meta);
|
||||
return { text: fullText };
|
||||
} else {
|
||||
logger.warn('适配器', '未解析到有效文本内容', { ...meta, preview: content.substring(0, 150) });
|
||||
// 如果没解析到 a0,尝试直接返回原始内容防空
|
||||
return { error: '未解析到有效文本内容' };
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// 顶层错误处理
|
||||
const pageError = normalizePageError(err, meta);
|
||||
if (pageError) return pageError;
|
||||
|
||||
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
||||
return { error: `生成任务失败: ${err.message}` };
|
||||
} finally {
|
||||
// 清理拦截器
|
||||
if (modelId) await page.unroute('**/*').catch(() => { });
|
||||
|
||||
// 任务结束,将鼠标移至安全区域
|
||||
await moveMouseAway(page);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 适配器 manifest
|
||||
*/
|
||||
export const manifest = {
|
||||
id: 'lmarena_text',
|
||||
displayName: 'LMArena Text',
|
||||
|
||||
// 入口 URL
|
||||
getTargetUrl(config, workerConfig) {
|
||||
return TARGET_URL;
|
||||
},
|
||||
|
||||
// 模型列表(从 models.js 迁移)
|
||||
models: [
|
||||
{ id: 'claude-opus-4-5-20251101-thinking-32k', codeName: '019ab8b2-9bcf-79b5-9fb5-149a7c67b7c0', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'claude-opus-4-5-20251101', 'codeName': '019adbec-8396-71cc-87d5-b47f8431a6a6', 'imagePolicy': 'forbidden', "type": "text" },
|
||||
{ id: 'gemini-3-pro', codeName: '019a98f7-afcd-779f-8dcb-856cc3b3f078', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'grok-4.1-thinking', codeName: '019a9389-a9d3-77a8-afbb-4fe4dd3d8630', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'grok-4.1', codeName: '019a9389-a4d8-748d-9939-b4640198302e', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'gpt-5.1-high', codeName: '019a8548-a2b1-70ce-b1be-eba096d41f58', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'gemini-2.5-pro', codeName: '0199f060-b306-7e1f-aeae-0ebb4e3f1122', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'claude-sonnet-4-5-20250929-thinking-32k', codeName: 'b0ea1407-2f92-4515-b9cc-b22a6d6c14f2', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'claude-opus-4-1-20250805-thinking-16k', codeName: 'f1a2eb6f-fc30-4806-9e00-1efd0d73cbc4', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'claude-sonnet-4-5-20250929', codeName: '019a2d13-28a5-7205-908c-0a58de904617', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'claude-opus-4-1-20250805', codeName: '96ae95fd-b70d-49c3-91cc-b58c7da1090b', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'chatgpt-4o-latest-20250326', codeName: '0199c1e0-3720-742d-91c8-787788b0a19b', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'gpt-5.1', codeName: '019a7ebf-0f3f-7518-8899-fca13e32d9dc', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'gpt-5-high', codeName: '983bc566-b783-4d28-b24c-3c8b08eb1086', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'o3-2025-04-16', codeName: 'cb0f1e24-e8e9-4745-aabc-b926ffde7475', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'qwen3-max-preview', codeName: '812c93cc-5f88-4cff-b9ca-c11a26599b0e', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'grok-4-1-fast-reasoning', codeName: '019aa41a-0a13-714a-beb1-be4a918a4b56', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'ernie-5.0-preview-1103', codeName: '019a4ca9-720d-75f5-9012-883ce8ff61df', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'kimi-k2-thinking-turbo', codeName: '019a59bc-8bb8-7933-92eb-fe143770c211', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'gpt-5-chat', codeName: '4b11c78c-08c8-461c-938e-5fc97d56a40d', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'glm-4.6', codeName: 'f595e6f1-6175-4880-a9eb-377e390819e4', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'qwen3-max-2025-09-23', codeName: '98ad8b8b-12cd-46cd-98de-99edde7e03eb', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'claude-opus-4-20250514-thinking-16k', codeName: '3b5e9593-3dc0-4492-a3da-19784c4bde75', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'qwen3-235b-a22b-instruct-2507', codeName: 'ee7cb86e-8601-4585-b1d0-7c7380f8f6f4', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'grok-4-fast-chat', codeName: 'grok-4-fast-chat', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'deepseek-v3.2-thinking', codeName: '019adb32-bb7a-77eb-882f-b8e3aaa2b2fd', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'kimi-k2-0905-preview', codeName: 'b88e983b-9459-473d-8bf1-753932f1679a', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'kimi-k2-0711-preview', codeName: '7a3626fc-4e64-4c9e-821f-b449a4b43b6a', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'deepseek-v3.2', codeName: '019adb32-b716-7591-9a2f-c6882973e340', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'qwen3-vl-235b-a22b-instruct', codeName: '716aa8ca-d729-427f-93ab-9579e4a13e98', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'mistral-large-3', codeName: '019acbac-df7c-73dc-9716-ebe040daaa4e', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'gpt-4.1-2025-04-14', codeName: '14e9311c-94d2-40c2-8c54-273947e208b0', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'claude-opus-4-20250514', codeName: 'ee116d12-64d6-48a8-88e5-b2d06325cdd2', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'mistral-medium-2508', codeName: '27035fb8-a25b-4ec9-8410-34be18328afd', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'grok-4-0709', codeName: 'b9edb8e9-4e98-49e7-8aaf-ae67e9797a11', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'glm-4.5', codeName: 'd079ef40-3b20-4c58-ab5e-243738dbada5', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'gemini-2.5-flash', codeName: '0199f059-3877-7cfe-bc80-e01b1a4a83de', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'gemini-2.5-flash-preview-09-2025', codeName: 'fc700d46-c4c1-4fec-88b5-f086876ae0bb', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'claude-haiku-4-5-20251001', codeName: '0199e8e9-01ed-73e0-96ba-cf43b286bf10', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'grok-4-fast-reasoning', codeName: '19b3730a-0369-49ba-ad9c-09e7337937f0', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'qwen3-next-80b-a3b-instruct', codeName: '351fe482-eb6c-4536-857b-909e16c0bf52', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'longcat-flash-chat', codeName: '6fcbe051-f521-4dc7-8986-c429eb6191bf', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'qwen3-235b-a22b-no-thinking', codeName: '1a400d9a-f61c-4bc2-89b4-a9b7e77dff12', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'qwen3-235b-a22b-thinking-2507', codeName: '16b8e53a-cc7b-4608-a29a-20d4dac77cf2', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'qwen3-vl-235b-a22b-thinking', codeName: '03c511f5-0d35-4751-aae6-24f918b0d49e', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'gpt-5-mini-high', codeName: '5fd3caa8-fe4c-41a5-a22c-0025b58f4b42', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'deepseek-v3-0324', codeName: '2f5253e4-75be-473c-bcfc-baeb3df0f8ad', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'hunyuan-vision-1.5-thinking', codeName: '6a3a1e04-050e-4cb4-9052-b9ac4bec0c38', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'o4-mini-2025-04-16', codeName: 'f1102bbf-34ca-468f-a9fc-14bcf63f315b', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'claude-sonnet-4-20250514', codeName: 'ac44dd10-0666-451c-b824-386ccfea7bcc', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'claude-3-7-sonnet-20250219-thinking-32k', codeName: 'be98fcfd-345c-4ae1-9a82-a19123ebf1d2', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'qwen3-coder-480b-a35b-instruct', codeName: 'af033cbd-ec6c-42cc-9afa-e227fc12efe8', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'hunyuan-t1-20250711', codeName: 'ba8c2392-4c47-42af-bfee-c6c057615a91', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'mistral-medium-2505', codeName: '27b9f8c6-3ee1-464a-9479-a8b3c2a48fd4', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'qwen3-30b-a3b-instruct-2507', codeName: 'a8d1d310-e485-4c50-8f27-4bff18292a99', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'gpt-4.1-mini-2025-04-14', codeName: '6a5437a7-c786-467b-b701-17b0bc8c8231', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'gemini-2.5-flash-lite-preview-09-2025-no-thinking', codeName: '75555628-8c14-402a-8d6e-43c19cb40116', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'gemini-2.5-flash-lite-preview-06-17-thinking', codeName: '04ec9a17-c597-49df-acf0-963da275c246', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'qwen3-235b-a22b', codeName: '2595a594-fa54-4299-97cd-2d7380d21c80', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'claude-3-5-sonnet-20241022', codeName: 'f44e280a-7914-43ca-a25d-ecfcc5d48d09', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'claude-3-7-sonnet-20250219', codeName: 'c5a11495-081a-4dc6-8d9a-64a4fd6f7bbc', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'glm-4.5-air', codeName: '7bfb254a-5d32-4ce2-b6dc-2c7faf1d5fe8', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'qwen3-next-80b-a3b-thinking', codeName: '73cf8705-98c8-4b75-8d04-e3746e1c1565', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'minimax-m1', codeName: '87e8d160-049e-4b4e-adc4-7f2511348539', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'gemma-3-27b-it', codeName: '789e245f-eafe-4c72-b563-d135e93988fc', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'grok-3-mini-high', codeName: '149619f1-f1d5-45fd-a53e-7d790f156f20', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'gemini-2.0-flash-001', codeName: '7a55108b-b997-4cff-a72f-5aa83beee918', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'grok-3-mini-beta', codeName: '7699c8d4-0742-42f9-a117-d10e84688dab', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'mistral-small-2506', codeName: 'bbad1d17-6aa5-4321-949c-d11fb6289241', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'gpt-oss-120b', codeName: '6ee9f901-17b5-4fbe-9cc2-13c16497c23b', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'glm-4.5v', codeName: '9dab0475-a0cc-4524-84a2-3fd25aa8c768', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'command-a-03-2025', codeName: '0f785ba1-efcb-472d-961e-69f7b251c7e3', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'amazon-nova-experimental-chat-10-20', codeName: '019a4c75-256c-790b-9088-4694cc63c507', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'intellect-3', codeName: '019aebfd-af0e-7f0c-8f0d-96c588e4cd3b', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'o3-mini', codeName: 'c680645e-efac-4a81-b0af-da16902b2541', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'ling-flash-2.0', codeName: '71f96ca9-4cf8-4be7-bac2-2231613930a6', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'minimax-m2', codeName: '019a27e0-e7d8-7b0b-877c-a2106c6eb87d', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'step-3', codeName: '1ea13a81-93a7-4804-bcdd-693cd72e302d', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'gpt-5-nano-high', codeName: '2dc249b3-98da-44b4-8d1e-6666346a8012', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'nova-2-lite', codeName: '019ae300-83b7-7717-a1e0-31accd1ff6fa', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'qwq-32b', codeName: '885976d3-d178-48f5-a3f4-6e13e0718872', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'llama-4-maverick-17b-128e-instruct', codeName: 'b5ad3ab7-fc56-4ecd-8921-bd56b55c1159', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'qwen3-30b-a3b', codeName: '9a066f6a-7205-4325-8d0b-d81cc4b049c0', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'claude-3-5-haiku-20241022', codeName: 'claude-3-5-haiku-20241022', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'ring-flash-2.0', codeName: '11ad4114-c868-4fed-b6e7-d535dc9c62f8', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'llama-3.3-70b-instruct', codeName: 'dcbd7897-5a37-4a34-93f1-76a24c7bb028', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'gemma-3n-e4b-it', codeName: '896a3848-ae03-4651-963b-7d8f54b61ae8', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'gpt-oss-20b', codeName: 'ec3beb4b-7229-4232-bab9-670ee52dd711', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'mercury', codeName: '019a6f77-e20d-7c1d-a7cd-8bd926e7395d', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'olmo-3-32b-think', codeName: '019ac2ef-27e1-769f-8258-d131f79e28ef', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'magistral-medium-2506', codeName: '6337f479-2fc8-4311-a76b-8c957765cd68', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'mistral-small-3.1-24b-instruct-2503', codeName: '69f5d38a-45f5-4d3a-9320-b866a4035ed9', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'ibm-granite-h-small', codeName: '4ddb69f5-391a-4f78-af92-7d7328c18ab1', imagePolicy: 'forbidden', type: 'text' },
|
||||
{ id: 'qwen3-vl-8b-thinking', codeName: '0199e3d1-a308-77b9-a650-41453e8ef2fb', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'qwen3-vl-8b-instruct', codeName: '0199e3d1-a713-7de2-a5dd-a1583cad9532', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'amazon.nova-pro-v1:0', codeName: 'a14546b5-d78d-4cf6-bb61-ab5b8510a9d6', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'glm-4.6v', codeName: '019b151a-7c3b-72a2-8811-0bf9317c2ef5', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'gpt-5.2-high', codeName: '019b1448-dafa-7f92-90c3-50e159c2263c', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'gpt-5.2', codeName: '019b1448-d548-78f4-8b98-788d72cbd057', imagePolicy: 'foptional', type: 'text' },
|
||||
{ id: 'glm-4.6v-flash', codeName: '019b1536-49c0-73b2-8d45-403b8571568d', imagePolicy: 'optional', type: 'text' },
|
||||
{ id: 'qwen3-omni-flash', codeName: '0199c9dc-e157-7458-bd49-5942363be215', imagePolicy: 'optional', type: 'text' }
|
||||
],
|
||||
|
||||
// 模型 ID 解析
|
||||
resolveModelId(modelKey) {
|
||||
const model = this.models.find(m => m.id === modelKey);
|
||||
return model ? model.codeName : null;
|
||||
},
|
||||
|
||||
// 无需导航处理器
|
||||
navigationHandlers: [],
|
||||
|
||||
// 核心生图方法
|
||||
generateImage
|
||||
};
|
||||
@@ -110,6 +110,18 @@ export function getBackend() {
|
||||
return poolManager.getImagePolicy(modelKey);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取模型类型
|
||||
* @param {string} modelKey - 模型 key
|
||||
* @returns {string} 'text' | 'image'
|
||||
*/
|
||||
getModelType: (modelKey) => {
|
||||
if (!poolManager) {
|
||||
return 'image';
|
||||
}
|
||||
return poolManager.getModelType(modelKey);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 Cookies
|
||||
* @param {string} [workerName] - Worker 名称
|
||||
|
||||
@@ -249,6 +249,18 @@ export class PoolManager {
|
||||
return 'optional';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型类型
|
||||
*/
|
||||
getModelType(modelKey) {
|
||||
for (const worker of this.workers) {
|
||||
if (worker.supports(modelKey)) {
|
||||
return worker.getModelType(modelKey);
|
||||
}
|
||||
}
|
||||
return 'image';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定实例的 Cookies
|
||||
*/
|
||||
|
||||
@@ -421,6 +421,27 @@ export class Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型类型
|
||||
*/
|
||||
getModelType(modelKey) {
|
||||
if (this.type === 'merge') {
|
||||
if (modelKey.includes('/')) {
|
||||
const [specifiedType, actualModel] = modelKey.split('/', 2);
|
||||
if (this.mergeTypes.includes(specifiedType)) {
|
||||
return registry.getModelType(specifiedType, actualModel);
|
||||
}
|
||||
}
|
||||
for (const type of this.mergeTypes) {
|
||||
const realId = registry.resolveModelId(type, modelKey);
|
||||
if (realId) return registry.getModelType(type, modelKey);
|
||||
}
|
||||
return 'image';
|
||||
} else {
|
||||
return registry.getModelType(this.type, modelKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航到监控页面(空闲时)
|
||||
*/
|
||||
|
||||
+20
-2
@@ -197,7 +197,8 @@ class AdapterRegistry {
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: id,
|
||||
image_policy: m.imagePolicy
|
||||
image_policy: m.imagePolicy,
|
||||
type: m.type || 'image' // Default to image if not specified
|
||||
}));
|
||||
|
||||
return { object: 'list', data };
|
||||
@@ -243,6 +244,22 @@ class AdapterRegistry {
|
||||
return model?.imagePolicy || IMAGE_POLICY.OPTIONAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型的类型
|
||||
* @param {string} adapterId - 适配器 ID
|
||||
* @param {string} modelKey - 模型 key
|
||||
* @returns {string} 'text' | 'image'
|
||||
*/
|
||||
getModelType(adapterId, modelKey) {
|
||||
const adapter = this.getAdapter(adapterId);
|
||||
if (!adapter || !adapter.models) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
const model = adapter.models.find(m => m.id === modelKey);
|
||||
return model?.type || 'image';
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合所有适配器的模型列表
|
||||
* @returns {object}
|
||||
@@ -258,7 +275,8 @@ class AdapterRegistry {
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: id,
|
||||
image_policy: m.imagePolicy
|
||||
image_policy: m.imagePolicy,
|
||||
type: m.type || 'image'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ function checkAuth(req, authToken) {
|
||||
* @param {Function} context.getModels - 获取模型列表函数
|
||||
* @param {Function} context.resolveModelId - 解析模型 ID 函数
|
||||
* @param {Function} context.getImagePolicy - 获取图片策略函数
|
||||
* @param {Function} context.getModelType - 获取模型类型函数
|
||||
* @param {string} context.tempDir - 临时目录
|
||||
* @param {number} context.imageLimit - 图片数量限制
|
||||
* @param {object} context.queueManager - 队列管理器
|
||||
@@ -40,6 +41,7 @@ export function createRouter(context) {
|
||||
getModels,
|
||||
resolveModelId,
|
||||
getImagePolicy,
|
||||
getModelType,
|
||||
tempDir,
|
||||
imageLimit,
|
||||
queueManager
|
||||
@@ -138,6 +140,7 @@ export function createRouter(context) {
|
||||
backendName,
|
||||
resolveModelId,
|
||||
getImagePolicy,
|
||||
getModelType,
|
||||
requestId,
|
||||
logger
|
||||
});
|
||||
|
||||
+157
-20
@@ -56,6 +56,7 @@ function parseError(code, customMessage) {
|
||||
* @param {string} options.backendName - 后端名称
|
||||
* @param {Function} options.resolveModelId - 模型 ID 解析函数
|
||||
* @param {Function} options.getImagePolicy - 获取图片策略函数
|
||||
* @param {Function} options.getModelType - 获取模型类型函数
|
||||
* @param {string} options.requestId - 请求 ID
|
||||
* @param {Function} options.logger - 日志函数
|
||||
* @returns {Promise<ParseResult>} 解析结果
|
||||
@@ -67,6 +68,7 @@ export async function parseRequest(data, options) {
|
||||
backendName,
|
||||
resolveModelId,
|
||||
getImagePolicy,
|
||||
getModelType,
|
||||
requestId,
|
||||
logger
|
||||
} = options;
|
||||
@@ -79,6 +81,156 @@ export async function parseRequest(data, options) {
|
||||
return parseError(ERROR_CODES.NO_MESSAGES);
|
||||
}
|
||||
|
||||
// 1. 解析模型参数与类型
|
||||
let modelKey = null;
|
||||
let isTextMode = false;
|
||||
|
||||
if (data.model) {
|
||||
const resolved = resolveModelId(data.model);
|
||||
if (resolved) {
|
||||
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);
|
||||
currentPrompt = `=== 当前输入 ===\nUser: ${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) {
|
||||
@@ -127,31 +279,16 @@ export async function parseRequest(data, options) {
|
||||
|
||||
prompt = prompt.trim();
|
||||
|
||||
// 解析模型参数
|
||||
let modelKey = null;
|
||||
if (data.model) {
|
||||
// 只校验模型是否支持,不解析
|
||||
const resolved = resolveModelId(data.model);
|
||||
if (resolved) {
|
||||
modelKey = data.model; // 保留原始 modelKey,由 PoolManager 自行解析
|
||||
logger.info('服务器', `触发模型: ${data.model}`, { id: requestId });
|
||||
} else {
|
||||
return parseError(ERROR_CODES.INVALID_MODEL, `模型无效/后端 ${backendName} 不支持: ${data.model}`);
|
||||
}
|
||||
} else {
|
||||
logger.info('服务器', '未指定模型,使用网页默认', { id: requestId });
|
||||
}
|
||||
|
||||
// 图片策略校验
|
||||
const hasImage = imagePaths.length > 0;
|
||||
const policy = data.model ? getImagePolicy(data.model) : IMAGE_POLICY.OPTIONAL;
|
||||
const policy = modelId ? getImagePolicy(modelId) : IMAGE_POLICY.OPTIONAL;
|
||||
|
||||
if (policy === IMAGE_POLICY.REQUIRED && !hasImage) {
|
||||
return parseError(ERROR_CODES.IMAGE_REQUIRED, `模型 ${data.model} 需要参考图`);
|
||||
return parseError(ERROR_CODES.IMAGE_REQUIRED, `模型 ${modelId} 需要参考图`);
|
||||
}
|
||||
|
||||
if (policy === IMAGE_POLICY.FORBIDDEN && hasImage) {
|
||||
return parseError(ERROR_CODES.IMAGE_FORBIDDEN, `模型 ${data.model} 不支持图片输入`);
|
||||
return parseError(ERROR_CODES.IMAGE_FORBIDDEN, `模型 ${modelId} 不支持图片输入`);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -159,8 +296,8 @@ export async function parseRequest(data, options) {
|
||||
data: {
|
||||
prompt,
|
||||
imagePaths,
|
||||
modelId: modelKey, // 返回原始 modelKey
|
||||
modelName: data.model || null,
|
||||
modelId,
|
||||
modelName: modelId,
|
||||
isStreaming
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,13 +135,16 @@ export function createQueueManager(queueConfig, callbacks) {
|
||||
}
|
||||
|
||||
// 发送成功响应
|
||||
logger.info('服务器', '准备发送响应...', { id, isStreaming, contentLength: finalContent.length });
|
||||
if (isStreaming) {
|
||||
const chunk = buildChatCompletionChunk(finalContent, modelName);
|
||||
sendSse(res, chunk);
|
||||
sendSseDone(res);
|
||||
logger.info('服务器', '流式响应已结束', { id });
|
||||
} else {
|
||||
const response = buildChatCompletion(finalContent, modelName);
|
||||
sendJson(res, 200, response);
|
||||
logger.info('服务器', 'JSON 响应已发送', { id });
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
|
||||
+3
-6
@@ -19,9 +19,6 @@ const EXAMPLE_CONFIG_PATH = path.join(process.cwd(), 'config.example.yaml');
|
||||
// 模块级缓存:确保配置只从磁盘读取一次
|
||||
let cachedConfig = null;
|
||||
|
||||
// 有效的适配器类型
|
||||
const VALID_ADAPTER_TYPES = ['lmarena', 'gemini', 'gemini_biz', 'nanobananafree_ai', 'zai_is', 'merge'];
|
||||
|
||||
/**
|
||||
* 解析用户数据目录路径
|
||||
* @param {string|undefined} userDataMark - 用户数据标记
|
||||
@@ -84,9 +81,9 @@ function validateWorker(worker, instanceName, index) {
|
||||
if (!worker.type) {
|
||||
throw new Error(`instances[${instanceName}].workers[${index}] (${worker.name}) 缺少必需字段: type`);
|
||||
}
|
||||
if (!VALID_ADAPTER_TYPES.includes(worker.type)) {
|
||||
throw new Error(`Worker "${worker.name}" 的 type "${worker.type}" 无效。有效值: ${VALID_ADAPTER_TYPES.join(', ')}`);
|
||||
}
|
||||
// 移除对 type 的硬编码校验,允许动态加载新适配器
|
||||
// if (!VALID_ADAPTER_TYPES.includes(worker.type)) { ... }
|
||||
|
||||
if (worker.type === 'merge') {
|
||||
if (!worker.mergeTypes || !Array.isArray(worker.mergeTypes) || worker.mergeTypes.length === 0) {
|
||||
throw new Error(`Worker "${worker.name}" 类型为 merge,但缺少有效的 mergeTypes 数组`);
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
这是一个非常好的架构升级思路。为了支持文本模型(Chat Model)并构建“虚拟上下文”,我们需要重构 `src/server/parseChat.js`,让它具备根据模型类型(图片 vs 文本)采用不同解析策略的能力。
|
||||
|
||||
以下是具体的修改方案:
|
||||
|
||||
### 修改思路
|
||||
|
||||
1. **区分模型类型**:我们需要知道当前请求的模型是“生图模型”还是“文本模型”。
|
||||
* *方案*:在 `registry.js` 中为模型定义增加 `type` 字段(如 `type: 'text'`),或者在 `parseChat.js` 中简单地通过判断模型 ID 是否包含特定关键字(如 `gpt-4`, `claude`)来区分。为了演示方便,我将在代码中添加一个简单的判断逻辑。
|
||||
2. **文本模型解析逻辑**:
|
||||
* 不再只取最后一条消息。
|
||||
* 遍历 `messages` 数组。
|
||||
* 提取 `system` 消息作为“系统指令”。
|
||||
* 提取之前的 `user` 和 `assistant` 消息作为“历史对话”。
|
||||
* 提取最后一条 `user` 消息作为“当前输入”。
|
||||
* **图片处理**:在遍历过程中,遇到图片不仅要保存文件,还要在文本中插入 `[图片1]` 占位符,并将图片路径收集到 `imagePaths` 数组中。
|
||||
|
||||
### 修改代码
|
||||
|
||||
你需要修改 `src/server/parseChat.js`。
|
||||
|
||||
#### 1\. 新增辅助函数 `buildTextContext`
|
||||
|
||||
这个函数负责将 OpenAI 格式的 `messages` 数组转换为你要求的“虚拟上下文”字符串格式。
|
||||
|
||||
#### 2\. 改造 `parseRequest` 主函数
|
||||
|
||||
增加分支逻辑:如果是文本模型走新逻辑,如果是生图模型走原有逻辑(只取最后一条)。
|
||||
|
||||
以下是完整的修改后的 `src/server/parseChat.js` 代码:
|
||||
|
||||
```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';
|
||||
|
||||
/**
|
||||
* 构造解析错误结果
|
||||
*/
|
||||
function parseError(code, customMessage) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
error: customMessage || getErrorMessage(code)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为文本模型 (根据模型ID特征简单判断,你可以根据 registry 里的配置来判断)
|
||||
* @param {string} modelId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isTextModel(modelId) {
|
||||
if (!modelId) return false;
|
||||
// 这里可以根据实际情况修改,比如检查 registry.getModelType(modelId)
|
||||
// 示例:如果 ID 包含 gpt, claude, llama, qwen 且不是 image 模型,则认为是文本模型
|
||||
// 现有的图片模型通常有 image 关键字,或者在 registry 中明确定义。
|
||||
// 为了简单起见,假设除了已知的生图模型外,其他都是文本模型,或者你可以维护一个列表。
|
||||
const textKeywords = ['gpt-4', 'claude', 'llama', 'qwen', 'deepseek', 'gemini-pro'];
|
||||
// 注意:gemini-3-pro-image-preview 是生图的,需排除
|
||||
if (modelId.includes('image') || modelId.includes('flux') || modelId.includes('seedream')) return false;
|
||||
|
||||
return textKeywords.some(k => modelId.includes(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析聊天请求
|
||||
*/
|
||||
export async function parseRequest(data, options) {
|
||||
const {
|
||||
tempDir,
|
||||
imageLimit,
|
||||
backendName,
|
||||
resolveModelId,
|
||||
getImagePolicy,
|
||||
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 resolved = resolveModelId(data.model);
|
||||
if (resolved) {
|
||||
modelKey = data.model;
|
||||
logger.info('服务器', `触发模型: ${data.model}`, { id: requestId });
|
||||
|
||||
// 判定是否为文本模式 (你需要根据实际情况实现 isTextModel 或传入 getModelType)
|
||||
// 这里假设如果不包含特定生图关键词就是文本模型,或者你可以扩展 options 传入判断函数
|
||||
isTextMode = isTextModel(data.model);
|
||||
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: 生图模型解析 (原有逻辑,只取最后一条 User 消息)
|
||||
// ============================================================
|
||||
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);
|
||||
currentPrompt = `=== 当前输入 ===\nUser: ${currentContent}`;
|
||||
|
||||
// 5. 合并最终 Prompt
|
||||
// 注意:这里我们告诉浏览器上传了 imagePaths 里的所有图片
|
||||
// 并且在 prompt 文本中已经用 [图片N] 标记了它们的位置
|
||||
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 {
|
||||
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 图片到临时文件
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配套修改建议
|
||||
|
||||
1. **修改 `src/backend/registry.js` (可选,推荐)**:
|
||||
在 `models` 数组中添加 `type: 'text'` 或 `type: 'image'` 字段,然后在 `getModelsForAdapter` 中暴露出来。这样 `parseChat.js` 就可以通过 `registry.getModelType(id)` 来精准判断,而不是靠猜。
|
||||
|
||||
2. **修改适配器代码**:
|
||||
目前的适配器(如 `gemini_biz.js`, `zai_is.js`)的 `generateImage` 函数接收的是:
|
||||
|
||||
```javascript
|
||||
async function generateImage(context, prompt, imgPaths, modelId, meta = {})
|
||||
```
|
||||
|
||||
对于文本模型,`prompt` 现在将是一个包含了系统指令、历史对话和当前输入的**长字符串**。`imgPaths` 将包含**所有**涉及到的图片。
|
||||
|
||||
* 适配器的逻辑通常是:先上传所有 `imgPaths`,然后在输入框填入 `prompt`,最后发送。
|
||||
* **注意**:你现有的 `gemini_biz.js` 和 `zai_is.js` 适配器逻辑是先 `pasteImages` 然后 `fillPrompt`。
|
||||
* 这对于新的文本逻辑**正好适用**!因为图片占位符 `[图片1]` 已经在 `prompt` 文本里了,而实际的图片文件通过 `imgPaths` 传给适配器上传,适配器上传后,输入框里会有图片缩略图,加上我们输入的带有 `[图片1]` 的长文本,AI 就能理解这个对应关系(或者至少作为上下文)。
|
||||
|
||||
### 效果预期
|
||||
|
||||
当客户端发送如下请求时:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-4-text",
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是一个猫娘"},
|
||||
{"role": "user", "content": "你好"},
|
||||
{"role": "assistant", "content": "喵~"},
|
||||
{"role": "user", "content": ["看看这个", {"type": "image_url", "url": "..."}]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
服务器会解析为:
|
||||
|
||||
**prompt (传递给适配器输入框的文本):**
|
||||
|
||||
```text
|
||||
=== 系统指令 (永远置顶) ===
|
||||
你是一个猫娘
|
||||
|
||||
=== 历史对话 (滑动窗口或摘要) ===
|
||||
User: 你好
|
||||
AI: 喵~
|
||||
|
||||
=== 当前输入 ===
|
||||
User: 看看这个[图片1]
|
||||
```
|
||||
|
||||
**imagePaths (传递给适配器上传的文件):**
|
||||
`['/tmp/img_xxx.jpg']`
|
||||
|
||||
这样你就成功地在一个单轮对话的网页接口上,模拟出了带上下文和多模态理解的多轮对话体验。
|
||||
Reference in New Issue
Block a user