mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
Merge pull request #44 from solar2ain/feat/reasoning-error-detection-arena
feat: 多适配器 reasoning 提取、智能错误检测、全尺寸原图与 Arena 域名更新
This commit is contained in:
@@ -110,12 +110,79 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
return { error: `API 返回错误: HTTP ${conversationResponse.status()}` };
|
||||
}
|
||||
|
||||
// 5.5 解析 conversation 响应,检查是否是纯文本回复(拒绝/限流场景)
|
||||
let conversationText = '';
|
||||
let isImageGenerationStarted = false;
|
||||
let conversationBody = '';
|
||||
try {
|
||||
conversationBody = await conversationResponse.text();
|
||||
|
||||
// 检查是否有图片生成相关的内容 (dalle 工具调用或 file_ 文件引用)
|
||||
// 注意:不使用 'image' 关键词,因为拒绝消息也会包含这个词
|
||||
isImageGenerationStarted = conversationBody.includes('dalle') || conversationBody.includes('file_');
|
||||
logger.debug('适配器', `isImageGenerationStarted: ${isImageGenerationStarted}`, meta);
|
||||
|
||||
// 提取文本内容
|
||||
const lines = conversationBody.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const dataStr = line.slice(6).trim();
|
||||
if (dataStr === '[DONE]') continue;
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
// 提取初始文本 (channel=final 的 assistant 消息)
|
||||
if (data.v?.message?.channel === 'final' &&
|
||||
data.v?.message?.author?.role === 'assistant' &&
|
||||
data.v?.message?.content?.parts?.length > 0) {
|
||||
const part = data.v.message.content.parts[0];
|
||||
if (typeof part === 'string') {
|
||||
conversationText = part;
|
||||
}
|
||||
}
|
||||
// patch 格式累加 (data.v 是 patch 操作数组)
|
||||
if (Array.isArray(data.v)) {
|
||||
for (const patch of data.v) {
|
||||
if (patch.o === 'append' && patch.p === '/message/content/parts/0' && patch.v) {
|
||||
conversationText += patch.v;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
logger.debug('适配器', `提取到文本 (${conversationText.length} 字符): ${conversationText.substring(0, 200)}...`, meta);
|
||||
} catch (e) {
|
||||
logger.warn('适配器', `解析 conversation 响应失败: ${e.message}`, meta);
|
||||
}
|
||||
|
||||
// 早期检测:如果文本表明是拒绝/限流,立即返回,不等待图片超时
|
||||
if (conversationText) {
|
||||
// 检查是否是速率限制错误 (不重试,同账号重试也没用)
|
||||
const isRateLimit = conversationBody.includes('RateLimitException') ||
|
||||
conversationBody.includes('rate limit') ||
|
||||
/limit.*reset/i.test(conversationText);
|
||||
|
||||
if (isRateLimit) {
|
||||
logger.warn('适配器', `早期检测到速率限制: ${conversationText.substring(0, 200)}...`, meta);
|
||||
return { error: `触发速率限制: ${conversationText.substring(0, 200)}`, retryable: false };
|
||||
}
|
||||
|
||||
// 如果没有图片生成迹象,检查是否是内容被拒绝
|
||||
if (!isImageGenerationStarted) {
|
||||
const isContentRejection = /cannot|can't|unable|sorry|policy|violat/i.test(conversationText);
|
||||
if (isContentRejection) {
|
||||
logger.warn('适配器', `早期检测到内容拒绝: ${conversationText.substring(0, 200)}...`, meta);
|
||||
return { error: `内容被拒绝: ${conversationText.substring(0, 200)}`, retryable: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('适配器', '生成中,等待图片就绪...', meta);
|
||||
|
||||
// 6. 监听文件状态接口,等待图片生成完成
|
||||
// 通过 file_name 是否包含 .part 判断是否生成完成
|
||||
// 如果 conversation 响应中没有图片生成迹象,使用较短超时
|
||||
let downloadUrl = null;
|
||||
let fileName = null;
|
||||
const imageTimeout = isImageGenerationStarted ? 120000 : 30000;
|
||||
|
||||
try {
|
||||
await page.waitForResponse(async (response) => {
|
||||
@@ -128,11 +195,6 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const fn = json.file_name;
|
||||
const dl = json.download_url;
|
||||
|
||||
// 检查是否生成完成:
|
||||
// 1. 必须有 file_name
|
||||
// 2. file_name 开头必须是 user- (生成的图片)
|
||||
// 3. file_name 不能包含 .part(表示中间状态)
|
||||
// 4. 必须有 download_url
|
||||
if (fn && fn.startsWith('user-') && !fn.includes('.part') && dl) {
|
||||
fileName = fn;
|
||||
downloadUrl = dl;
|
||||
@@ -145,8 +207,34 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { timeout: waitTimeout });
|
||||
}, { timeout: imageTimeout });
|
||||
} catch (e) {
|
||||
logger.debug('适配器', `等待图片超时, conversationText长度: ${conversationText.length}, downloadUrl: ${downloadUrl}`, meta);
|
||||
|
||||
// 超时时检查是否有 conversation 中的文本内容
|
||||
if (conversationText && !downloadUrl) {
|
||||
const isRateLimit = conversationBody.includes('RateLimitException') ||
|
||||
conversationBody.includes('rate limit') ||
|
||||
/limit.*reset/i.test(conversationText);
|
||||
|
||||
if (isRateLimit) {
|
||||
logger.warn('适配器', `触发速率限制: ${conversationText.substring(0, 200)}...`, meta);
|
||||
return { error: `触发速率限制: ${conversationText.substring(0, 200)}`, retryable: false };
|
||||
}
|
||||
|
||||
logger.warn('适配器', `模型返回文本而非图片: ${conversationText.substring(0, 200)}...`, meta);
|
||||
return { error: `模型返回文本而非图片: ${conversationText.substring(0, 200)}`, retryable: false };
|
||||
}
|
||||
|
||||
// 如果没有提取到文本,但有原始响应体,尝试用简单方式提取
|
||||
if (!conversationText && conversationBody) {
|
||||
const partsMatch = conversationBody.match(/"parts":\s*\["([^"]+)"\]/);
|
||||
if (partsMatch && partsMatch[1]) {
|
||||
logger.warn('适配器', `通过正则提取到文本: ${partsMatch[1].substring(0, 200)}...`, meta);
|
||||
return { error: `模型返回文本而非图片: ${partsMatch[1].substring(0, 200)}`, retryable: false };
|
||||
}
|
||||
}
|
||||
|
||||
const pageError = normalizePageError(e, meta);
|
||||
if (pageError) return pageError;
|
||||
throw e;
|
||||
|
||||
@@ -79,7 +79,7 @@ async function configureModel(page, modelConfig, meta = {}) {
|
||||
* @param {string[]} imgPaths - 图片路径数组 (此适配器不支持)
|
||||
* @param {string} [modelId] - 模型 ID
|
||||
* @param {object} [meta={}] - 日志元数据
|
||||
* @returns {Promise<{text?: string, error?: string}>}
|
||||
* @returns {Promise<{text?: string, reasoning?: string, error?: string}>}
|
||||
*/
|
||||
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const { page, config } = context;
|
||||
@@ -108,8 +108,10 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
logger.debug('适配器', '启动 API 监听...', meta);
|
||||
|
||||
let textContent = '';
|
||||
let thinkingContent = ''; // thinking 内容
|
||||
let isComplete = false;
|
||||
let isCollecting = false; // 当前最后一个 fragment 是否为 RESPONSE 类型
|
||||
let isCollectingThinking = false; // 是否正在收集 thinking
|
||||
|
||||
const responsePromise = page.waitForResponse(async (response) => {
|
||||
const url = response.url();
|
||||
@@ -133,14 +135,21 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
|
||||
// --- 处理 fragment 列表变更,更新 isCollecting 状态 ---
|
||||
|
||||
// 初始响应中可能已有 fragments (如 SEARCH / RESPONSE)
|
||||
// 初始响应中可能已有 fragments (如 THINK / SEARCH / RESPONSE)
|
||||
if (data.v?.response?.fragments && Array.isArray(data.v.response.fragments)) {
|
||||
for (const fragment of data.v.response.fragments) {
|
||||
if (fragment.type === 'RESPONSE') {
|
||||
isCollecting = true;
|
||||
isCollectingThinking = false;
|
||||
if (fragment.content) textContent += fragment.content;
|
||||
} else if (fragment.type === 'THINK') {
|
||||
// DeepSeek 使用 THINK (不是 THINKING)
|
||||
isCollectingThinking = true;
|
||||
isCollecting = false;
|
||||
if (fragment.content) thinkingContent += fragment.content;
|
||||
} else {
|
||||
isCollecting = false;
|
||||
isCollectingThinking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,9 +159,15 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
for (const fragment of data.v) {
|
||||
if (fragment.type === 'RESPONSE') {
|
||||
isCollecting = true;
|
||||
isCollectingThinking = false;
|
||||
if (fragment.content) textContent += fragment.content;
|
||||
} else if (fragment.type === 'THINK') {
|
||||
isCollectingThinking = true;
|
||||
isCollecting = false;
|
||||
if (fragment.content) thinkingContent += fragment.content;
|
||||
} else {
|
||||
isCollecting = false;
|
||||
isCollectingThinking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,9 +179,15 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
for (const fragment of item.v) {
|
||||
if (fragment.type === 'RESPONSE') {
|
||||
isCollecting = true;
|
||||
isCollectingThinking = false;
|
||||
if (fragment.content) textContent += fragment.content;
|
||||
} else if (fragment.type === 'THINK') {
|
||||
isCollectingThinking = true;
|
||||
isCollecting = false;
|
||||
if (fragment.content) thinkingContent += fragment.content;
|
||||
} else {
|
||||
isCollecting = false;
|
||||
isCollectingThinking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,8 +203,12 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
// 带路径的 content 操作 (如 response/fragments/-1/content)
|
||||
if (data.p && typeof data.v === 'string') {
|
||||
const match = data.p.match(/response\/fragments\/(-?\d+)\/content/);
|
||||
if (match && isCollecting) {
|
||||
textContent += data.v;
|
||||
if (match) {
|
||||
if (isCollecting) {
|
||||
textContent += data.v;
|
||||
} else if (isCollectingThinking) {
|
||||
thinkingContent += data.v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +216,8 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
if (data.v && typeof data.v === 'string' && !data.p && !data.o) {
|
||||
if (isCollecting) {
|
||||
textContent += data.v;
|
||||
} else if (isCollectingThinking) {
|
||||
thinkingContent += data.v;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +260,16 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
|
||||
logger.info('适配器', `已获取文本内容 (${textContent.length} 字符)`, meta);
|
||||
logger.info('适配器', '文本生成完成,任务完成', meta);
|
||||
return { text: textContent.trim() };
|
||||
|
||||
const trimmedThinking = thinkingContent.trim();
|
||||
const result = { text: textContent.trim() };
|
||||
|
||||
// 返回结果(如果有 thinking 则包含 reasoning)
|
||||
if (trimmedThinking) {
|
||||
logger.info('适配器', `已获取思考过程 (${trimmedThinking.length} 字符)`, meta);
|
||||
result.reasoning = trimmedThinking;
|
||||
}
|
||||
return result;
|
||||
|
||||
} catch (err) {
|
||||
// 顶层错误处理
|
||||
|
||||
@@ -175,10 +175,16 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
return { error: errorMsg };
|
||||
}
|
||||
|
||||
// 取第一张图片,追加 =s1024-rj 获取高分辨率
|
||||
const imageUrl = imageUrls[0] + '=s1024-rj';
|
||||
// 取第一张图片,追加 =d-I 获取全尺寸原图(而非 =s1024-rj 的缩略图)
|
||||
const imageUrl = imageUrls[0] + '=d-I';
|
||||
logger.info('适配器', `找到 ${imageUrls.length} 张图片,开始下载...`, meta);
|
||||
|
||||
// 提取图片生成的详细描述(thinking)
|
||||
const thinking = extractImageThinking(bodyBuffer);
|
||||
if (thinking) {
|
||||
logger.info('适配器', `提取到详细描述,长度: ${thinking.length}`, meta);
|
||||
}
|
||||
|
||||
// 使用封装的下载函数
|
||||
const imgDlCfg = config?.backend?.pool?.failover || {};
|
||||
const result = await useContextDownload(imageUrl, page, {
|
||||
@@ -190,7 +196,8 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
|
||||
logger.info('适配器', '已获取图片,任务完成', meta);
|
||||
return result;
|
||||
// 返回图片和 thinking(如果有)
|
||||
return thinking ? { ...result, reasoning: thinking } : result;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
@@ -428,4 +435,57 @@ function extractAiTextFromResponse(bodyBuffer) {
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度遍历,查找长文本描述(图片生成的 thinking/详细描述)
|
||||
* 排除 URL、base64、分类器名称等非描述性长字符串
|
||||
* @param {any} root - 要遍历的对象
|
||||
* @returns {string} 最长的描述文本,未找到则返回空字符串
|
||||
*/
|
||||
function findLongDescriptionDeep(root) {
|
||||
const candidates = [];
|
||||
const stack = [root];
|
||||
|
||||
while (stack.length) {
|
||||
const cur = stack.pop();
|
||||
if (!cur) continue;
|
||||
|
||||
if (typeof cur === 'string') {
|
||||
if (cur.length > 200 &&
|
||||
!cur.startsWith('http') &&
|
||||
!cur.startsWith('data:') &&
|
||||
!cur.includes('googleapis.com') &&
|
||||
!cur.includes('googleusercontent.com') &&
|
||||
!/^[A-Za-z0-9+/=]{100,}$/.test(cur)) {
|
||||
candidates.push(cur);
|
||||
}
|
||||
} else if (Array.isArray(cur)) {
|
||||
for (const v of cur) stack.push(v);
|
||||
} else if (typeof cur === 'object') {
|
||||
for (const v of Object.values(cur)) stack.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return '';
|
||||
return candidates.reduce((a, b) => a.length >= b.length ? a : b, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从响应体 Buffer 中提取图片生成的详细描述(thinking)
|
||||
* @param {Buffer} bodyBuffer - 响应体 Buffer
|
||||
* @returns {string} 详细描述文本,未找到则返回空字符串
|
||||
*/
|
||||
function extractImageThinking(bodyBuffer) {
|
||||
const frames = parseLenFramedResponse(bodyBuffer);
|
||||
const payloads = extractPayloads(frames);
|
||||
|
||||
let best = '';
|
||||
for (const payload of payloads) {
|
||||
const text = findLongDescriptionDeep(payload);
|
||||
if (text.length > best.length) {
|
||||
best = text;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const TARGET_URL = 'https://gemini.google.com/app?hl=en';
|
||||
* @param {string[]} imgPaths - 图片路径数组
|
||||
* @param {string} [modelId] - 模型 ID (此适配器未使用)
|
||||
* @param {object} [meta={}] - 日志元数据
|
||||
* @returns {Promise<{text?: string, error?: string}>}
|
||||
* @returns {Promise<{text?: string, reasoning?: string, error?: string}>}
|
||||
*/
|
||||
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const { page, config } = context;
|
||||
@@ -172,11 +172,11 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const bodyBuffer = await apiResponse.body();
|
||||
logger.debug('适配器', `收到响应体,字节数: ${bodyBuffer.length}`, meta);
|
||||
|
||||
const text = getFinalAiTextFromResponse(bodyBuffer);
|
||||
const { text, reasoning } = getFinalAiTextFromResponse(bodyBuffer);
|
||||
|
||||
if (text) {
|
||||
logger.info('适配器', `解析成功,文本长度: ${text.length}`, meta);
|
||||
return { text };
|
||||
logger.info('适配器', `解析成功,文本长度: ${text.length},思考长度: ${reasoning?.length || 0}`, meta);
|
||||
return reasoning ? { text, reasoning } : { text };
|
||||
} else {
|
||||
return { error: '未能从响应中提取文本' };
|
||||
}
|
||||
@@ -390,19 +390,80 @@ function collectRcTextsDeep(root) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从响应体 Buffer 中提取最终 AI 文本
|
||||
* 从单个 payload 中提取文本和 thinking 内容
|
||||
* @param {Array} payload - 解析后的 payload
|
||||
* @returns {{text: string, thinking: string}}
|
||||
*/
|
||||
function extractTextAndThinking(payload) {
|
||||
let text = '';
|
||||
let thinking = '';
|
||||
|
||||
try {
|
||||
if (!Array.isArray(payload)) return { text, thinking };
|
||||
|
||||
// 找 rc 节点 (通常在 payload[4][0])
|
||||
// 结构: payload[4][0] = ["rc_xxx", ["text..."], ..., [37]: [[thinking]]]
|
||||
let rc = null;
|
||||
if (payload[4] && Array.isArray(payload[4][0]) &&
|
||||
typeof payload[4][0][0] === 'string' && payload[4][0][0].startsWith('rc_')) {
|
||||
rc = payload[4][0];
|
||||
}
|
||||
|
||||
if (!rc) return { text, thinking };
|
||||
|
||||
// 文本在 rc[1][0]
|
||||
if (Array.isArray(rc[1]) && typeof rc[1][0] === 'string') {
|
||||
text = rc[1][0];
|
||||
}
|
||||
|
||||
// thinking 在 rc[37][0][0]
|
||||
// 结构: rc[37] = [["**Thinking Title**\n\nThinking content..."]]
|
||||
try {
|
||||
if (rc[37] && Array.isArray(rc[37]) && rc[37][0] && Array.isArray(rc[37][0])) {
|
||||
if (typeof rc[37][0][0] === 'string') {
|
||||
thinking = rc[37][0][0];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// thinking 提取失败,忽略
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return { text, thinking };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从响应体 Buffer 中提取最终 AI 文本和 thinking
|
||||
* @param {Buffer} bodyBuffer - 响应体 Buffer
|
||||
* @returns {{text: string, reasoning: string}}
|
||||
*/
|
||||
function getFinalAiTextFromResponse(bodyBuffer) {
|
||||
const frames = parseLenFramedResponse(bodyBuffer);
|
||||
const payloads = extractPayloads(frames);
|
||||
|
||||
let best = "";
|
||||
let bestText = '';
|
||||
let bestThinking = '';
|
||||
|
||||
// 遍历所有 payload,保留最长的 text 和对应的 thinking
|
||||
for (const payload of payloads) {
|
||||
const m = collectRcTextsDeep(payload);
|
||||
for (const text of m.values()) {
|
||||
if (text.length > best.length) best = text;
|
||||
const { text, thinking } = extractTextAndThinking(payload);
|
||||
if (text.length > bestText.length) {
|
||||
bestText = text;
|
||||
bestThinking = thinking;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
|
||||
// 如果 extractTextAndThinking 没找到(可能结构不匹配),fallback 到 collectRcTextsDeep
|
||||
if (!bestText) {
|
||||
for (const payload of payloads) {
|
||||
const m = collectRcTextsDeep(payload);
|
||||
for (const text of m.values()) {
|
||||
if (text.length > bestText.length) bestText = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { text: bestText, reasoning: bestThinking };
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const TARGET_URL = 'https://lmarena.ai/c/new?mode=direct&chat-modality=image';
|
||||
const TARGET_URL = 'https://arena.ai/image/direct';
|
||||
|
||||
/**
|
||||
* 从响应文本中提取图片 URL
|
||||
@@ -40,6 +40,53 @@ function extractImage(text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从响应文本中提取错误信息
|
||||
* SSE 错误格式:
|
||||
* - a3: 模型提供方错误 (如 OpenAI moderation_blocked)
|
||||
* - ae: Arena 平台错误 (如内容审核拦截)
|
||||
* @param {string} text - 响应文本内容
|
||||
* @returns {string|null} 提取到的错误信息,如果未找到则返回 null
|
||||
*/
|
||||
function extractError(text) {
|
||||
if (!text) return null;
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
// a3: 模型提供方错误
|
||||
if (line.startsWith('a3:')) {
|
||||
try {
|
||||
const errorMsg = JSON.parse(line.substring(3));
|
||||
if (typeof errorMsg === 'string') {
|
||||
// 尝试提取嵌套的 JSON 错误
|
||||
const jsonMatch = errorMsg.match(/\{[\s\S]*"error"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const nested = JSON.parse(jsonMatch[0]);
|
||||
if (nested.error?.message) {
|
||||
return `[模型错误] ${nested.error.message} (code: ${nested.error.code || 'unknown'})`;
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return `[模型错误] ${errorMsg}`;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
// ae: Arena 平台错误
|
||||
if (line.startsWith('ae:')) {
|
||||
try {
|
||||
const errorData = JSON.parse(line.substring(3));
|
||||
if (errorData?.message) {
|
||||
return `[平台错误] ${errorData.message}`;
|
||||
}
|
||||
if (typeof errorData === 'string') {
|
||||
return `[平台错误] ${errorData}`;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
@@ -146,7 +193,14 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const httpError = normalizeHttpError(response, content);
|
||||
if (httpError) {
|
||||
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
|
||||
return { error: `请求生成时返回错误: ${httpError.error}` };
|
||||
return { error: `请求生成时返回错误: ${httpError.error}`, retryable: httpError.retryable };
|
||||
}
|
||||
|
||||
// 8.5 检查 SSE 错误 (a3/ae 行)
|
||||
const sseError = extractError(content);
|
||||
if (sseError) {
|
||||
logger.warn('适配器', `SSE 错误: ${sseError}`, meta);
|
||||
return { error: sseError, retryable: false };
|
||||
}
|
||||
|
||||
// 9. 提取图片 URL
|
||||
@@ -170,7 +224,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
return result;
|
||||
} else {
|
||||
logger.warn('适配器', '未获得结果,响应中无图片数据', { ...meta, preview: content.substring(0, 150) });
|
||||
return { text: `未获得结果,响应中无图片数据: ${content}` };
|
||||
return { error: `未获得结果,响应中无图片数据: ${content.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const TARGET_URL = 'https://lmarena.ai/c/new?mode=direct&chat-modality=chat';
|
||||
const TARGET_URL_SEARCH = 'https://lmarena.ai/zh/c/new?mode=direct&chat-modality=search';
|
||||
const TARGET_URL = 'https://arena.ai/text/direct';
|
||||
const TARGET_URL_SEARCH = 'https://arena.ai/search/direct';
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
@@ -134,30 +134,48 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
|
||||
// 9. 解析文本流
|
||||
// 格式示例:
|
||||
// SSE 格式说明:
|
||||
// - a0: 回复内容(最终文本)
|
||||
// - ag: 思考过程 (thinking/reasoning) - 仅 thinking 模型有
|
||||
// - a2: 心跳 [{"type":"heartbeat"}]
|
||||
// - ad: 结束标记 {"finishReason":"stop"}
|
||||
// 示例:
|
||||
// ag:"Let me think..."
|
||||
// a0:"Hello"
|
||||
// a0:" World"
|
||||
// d:{"finishReason":"stop"}
|
||||
// ad:{"finishReason":"stop"}
|
||||
let fullText = '';
|
||||
let thinkingText = '';
|
||||
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);
|
||||
}
|
||||
} else if (line.startsWith('ag:')) {
|
||||
// 思考过程内容
|
||||
try {
|
||||
const thinkPart = JSON.parse(line.substring(3));
|
||||
thinkingText += thinkPart;
|
||||
} catch (e) {
|
||||
logger.warn('适配器', `解析思考块失败: ${line}`, meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fullText) {
|
||||
logger.info('适配器', `获取文本成功,长度: ${fullText.length}`, meta);
|
||||
return { text: fullText };
|
||||
const result = { text: fullText };
|
||||
// 如果有思考过程,添加到 reasoning 字段
|
||||
if (thinkingText.trim()) {
|
||||
logger.info('适配器', `获取思考过程,长度: ${thinkingText.length}`, meta);
|
||||
result.reasoning = thinkingText;
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
logger.warn('适配器', '未解析到有效文本内容', { ...meta, preview: content.substring(0, 150) });
|
||||
// 如果没解析到 a0,尝试直接返回原始内容防空
|
||||
|
||||
@@ -405,6 +405,7 @@ export class Worker {
|
||||
|
||||
const maxAttempts = maxRetries === 0 ? candidateTypes.length : Math.min(maxRetries + 1, candidateTypes.length);
|
||||
let lastError = null;
|
||||
let lastRetryable = undefined;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const { type, modelId: actualModelId } = candidateTypes[i];
|
||||
@@ -415,12 +416,19 @@ export class Worker {
|
||||
}
|
||||
|
||||
lastError = result.error;
|
||||
lastRetryable = result.retryable;
|
||||
|
||||
// 如果明确标记为不可重试(如内容安全问题),立即返回
|
||||
if (result.retryable === false) {
|
||||
return { error: `所有支持该模型的适配器都无法使用: ${lastError}`, retryable: false };
|
||||
}
|
||||
|
||||
if (i < maxAttempts - 1) {
|
||||
logger.warn('工作池', `[${this.name}] ${type} 失败,尝试下一个适配器...`, { error: lastError, ...meta });
|
||||
}
|
||||
}
|
||||
|
||||
return { error: `所有支持该模型的适配器都无法使用: ${lastError}` };
|
||||
return { error: `所有支持该模型的适配器都无法使用: ${lastError}`, retryable: lastRetryable };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,10 +487,13 @@ export class Worker {
|
||||
userDataDir: this.userDataDir
|
||||
};
|
||||
|
||||
// 扩展 meta,添加 adapter 和 model 信息
|
||||
const enrichedMeta = { ...meta, adapter: type, model: modelId };
|
||||
|
||||
this.busyCount++;
|
||||
try {
|
||||
// 传递原始 modelId,由适配器自己解析
|
||||
return await adapter.generate(subContext, prompt, paths, modelId, meta);
|
||||
return await adapter.generate(subContext, prompt, paths, modelId, enrichedMeta);
|
||||
} finally {
|
||||
this.busyCount--;
|
||||
}
|
||||
|
||||
@@ -66,11 +66,15 @@ export function createFailoverExecutor(options = {}) {
|
||||
// 记录错误
|
||||
lastError = result.error;
|
||||
|
||||
// 检查是否可重试
|
||||
const normalized = normalizeError(lastError);
|
||||
if (!normalized.retryable && i < maxAttempts - 1) {
|
||||
// 不可重试的错误,但还有候选,继续尝试
|
||||
logger.debug('故障转移', `不可重试错误,跳过: ${lastError}`, meta);
|
||||
// 优先使用 result 中的 retryable,否则通过 normalizeError 推断
|
||||
const retryable = result.retryable !== undefined
|
||||
? result.retryable
|
||||
: normalizeError(lastError).retryable;
|
||||
|
||||
// 不可重试的错误(如内容安全问题),直接返回,不尝试其他候选
|
||||
if (!retryable) {
|
||||
logger.debug('故障转移', `不可重试错误,停止故障转移: ${lastError}`, meta);
|
||||
return { error: lastError, code: 'NOT_RETRYABLE', retryable: false };
|
||||
}
|
||||
|
||||
// 触发重试回调
|
||||
|
||||
@@ -21,8 +21,8 @@ export function isRetryableError(errorMessage) {
|
||||
const retryablePatterns = [
|
||||
// 网络错误
|
||||
/network|net::|econnreset|econnrefused|etimedout/i,
|
||||
// 超时
|
||||
/timeout|timed out/i,
|
||||
// 超时(含中文)
|
||||
/timeout|timed out|加载超时|请求超时/i,
|
||||
// 页面崩溃
|
||||
/crashed|crash/i,
|
||||
// 5xx 服务端错误
|
||||
@@ -63,6 +63,11 @@ export function normalizePageError(err, meta = {}) {
|
||||
logger.error('适配器', timeoutMsg, meta);
|
||||
return { error: timeoutMsg, code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true };
|
||||
}
|
||||
// 页面加载超时 (gotoWithCheck 抛出的中文超时错误)
|
||||
if (err.message?.includes('页面加载超时') || err.message?.includes('页面加载失败')) {
|
||||
logger.error('适配器', err.message, meta);
|
||||
return { error: err.message, code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true };
|
||||
}
|
||||
// 兼容原生 TimeoutError (其他地方抛出的)
|
||||
if (err.name === 'TimeoutError' || err.message?.includes('Timeout')) {
|
||||
logger.error('适配器', '请求超时', meta);
|
||||
|
||||
@@ -123,8 +123,9 @@ export function createOpenAIRouter(context) {
|
||||
}
|
||||
|
||||
const { prompt, imagePaths, modelId, modelName } = parseResult.data;
|
||||
const reasoning = data.reasoning === true;
|
||||
|
||||
logger.info('服务器', `[队列] 请求入队: ${prompt.slice(0, 10)}...`, { id: requestId, images: imagePaths.length });
|
||||
logger.info('服务器', `[队列] 请求入队: ${prompt.slice(0, 100)}...`, { id: requestId, images: imagePaths.length });
|
||||
|
||||
// 加入队列
|
||||
queueManager.addTask({
|
||||
@@ -135,7 +136,8 @@ export function createOpenAIRouter(context) {
|
||||
modelId,
|
||||
modelName,
|
||||
id: requestId,
|
||||
isStreaming
|
||||
isStreaming,
|
||||
reasoning
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
|
||||
+2
-2
@@ -94,7 +94,7 @@ export function createQueueManager(queueConfig, callbacks) {
|
||||
* @param {TaskContext} task - 任务上下文
|
||||
*/
|
||||
async function processTask(task) {
|
||||
const { res, prompt, imagePaths, modelId, modelName, id, isStreaming } = task;
|
||||
const { res, prompt, imagePaths, modelId, modelName, id, isStreaming, reasoning } = task;
|
||||
const startTime = Date.now();
|
||||
|
||||
logger.info('服务器', '[队列] 开始处理任务', { id, remaining: queue.length });
|
||||
@@ -133,7 +133,7 @@ export function createQueueManager(queueConfig, callbacks) {
|
||||
}
|
||||
|
||||
// 调用核心生图逻辑 (通过 Pool 分发)
|
||||
const result = await generate(poolContext, prompt, imagePaths, modelId, { id });
|
||||
const result = await generate(poolContext, prompt, imagePaths, modelId, { id, reasoning });
|
||||
|
||||
// 清除心跳
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
|
||||
+21
-7
@@ -106,9 +106,19 @@ export function sendApiError(res, options) {
|
||||
* 构造 OpenAI 格式的聊天完成响应(非流式)
|
||||
* @param {string} content - 响应内容
|
||||
* @param {string} [modelName] - 模型名称
|
||||
* @param {string} [reasoningContent] - 思考/推理过程内容 (OpenAI o1 格式)
|
||||
* @returns {object} OpenAI 格式的响应对象
|
||||
*/
|
||||
export function buildChatCompletion(content, modelName) {
|
||||
export function buildChatCompletion(content, modelName, reasoningContent) {
|
||||
const message = {
|
||||
role: 'assistant',
|
||||
content: content
|
||||
};
|
||||
|
||||
if (reasoningContent) {
|
||||
message.reasoning_content = reasoningContent;
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'chatcmpl-' + Date.now(),
|
||||
object: 'chat.completion',
|
||||
@@ -116,10 +126,7 @@ export function buildChatCompletion(content, modelName) {
|
||||
model: modelName || 'default-model',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: content
|
||||
},
|
||||
message,
|
||||
finish_reason: 'stop'
|
||||
}]
|
||||
};
|
||||
@@ -130,9 +137,16 @@ export function buildChatCompletion(content, modelName) {
|
||||
* @param {string} content - 响应内容
|
||||
* @param {string} [modelName] - 模型名称
|
||||
* @param {string|null} [finishReason='stop'] - 完成原因
|
||||
* @param {string} [reasoningContent] - 思考/推理过程内容 (OpenAI o1 格式)
|
||||
* @returns {object} OpenAI 格式的流式响应块
|
||||
*/
|
||||
export function buildChatCompletionChunk(content, modelName, finishReason = 'stop') {
|
||||
export function buildChatCompletionChunk(content, modelName, finishReason = 'stop', reasoningContent) {
|
||||
const delta = { content };
|
||||
|
||||
if (reasoningContent) {
|
||||
delta.reasoning_content = reasoningContent;
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'chatcmpl-' + Date.now(),
|
||||
object: 'chat.completion.chunk',
|
||||
@@ -140,7 +154,7 @@ export function buildChatCompletionChunk(content, modelName, finishReason = 'sto
|
||||
model: modelName || 'default-model',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content },
|
||||
delta,
|
||||
finish_reason: finishReason
|
||||
}]
|
||||
};
|
||||
|
||||
+19
-3
@@ -112,6 +112,9 @@ function shouldLog(level) {
|
||||
return targetIndex >= effectiveEnvIndex;
|
||||
}
|
||||
|
||||
// 需要提取到前面用方括号显示的 meta 字段
|
||||
const FRONT_META_KEYS = ['id', 'adapter', 'model'];
|
||||
|
||||
export function log(level, mod, msg, meta = {}) {
|
||||
if (!shouldLog(level)) return;
|
||||
|
||||
@@ -121,10 +124,23 @@ export function log(level, mod, msg, meta = {}) {
|
||||
|
||||
// 将消息中的换行符替换为 ↵ 符号,保持日志为单行
|
||||
const sanitizedMsg = msg.replace(/\r?\n/g, ' ↵ ');
|
||||
const base = `${ts} [${levelTag}] [${mod}] ${sanitizedMsg}`;
|
||||
|
||||
const metaStr = Object.keys(meta).length
|
||||
? ' | ' + Object.entries(meta).map(([k, v]) => {
|
||||
// 提取关键字段放在前面用方括号显示
|
||||
const frontParts = [];
|
||||
const remainingMeta = {};
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
if (FRONT_META_KEYS.includes(k) && v !== undefined && v !== null) {
|
||||
frontParts.push(`[${v}]`);
|
||||
} else {
|
||||
remainingMeta[k] = v;
|
||||
}
|
||||
}
|
||||
const frontStr = frontParts.length ? ' ' + frontParts.join(' ') : '';
|
||||
|
||||
const base = `${ts} [${levelTag}] [${mod}]${frontStr} ${sanitizedMsg}`;
|
||||
|
||||
const metaStr = Object.keys(remainingMeta).length
|
||||
? ' | ' + Object.entries(remainingMeta).map(([k, v]) => {
|
||||
if (v instanceof Error) {
|
||||
return `${k}=${v.message}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user