diff --git a/README.md b/README.md index 7044a19..aeed2db 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ### 本插件实际表现出来的能力取决于配置的模型。 -### 本插件工具调用、对话模型调用模型配置已兼容OpenAI与Anthropic 格式,详情请查看下方 模型服务配置 部分。 +### 本插件所有 AI 模型配置(除 embeddingAiConfig 外)均已兼容 OpenAI 与 Anthropic 格式,详情请查看下方 模型服务配置 部分。 --- > [!TIP] @@ -862,13 +862,22 @@ const result = await pluginBridge.instance?.enqueueProactiveTask( ## 模型服务配置 ### 聊天跟踪判断模型配置 (`trackAiConfig`) + +**OpenAI 兼容格式**: ```yaml trackAiUrl: "https://api.openai.com/v1/chat/completions" trackAiModel: "gpt-4o-mini" trackAiApikey: "sk-xxxxx" ``` -> 此模型在 `strict` 模式下用作"是否在跟机器人对话"的批量判官;在 `smart` 模式下用作 Timing Gate 子代理(输出 continue/no_action/wait 三选一)。两种用途都只输出极少 token,推荐用 gpt-4o-mini / gemini-2.0-flash / claude haiku 等小模型。 +**Anthropic 格式**(自动识别): +```yaml +trackAiUrl: "https://api.anthropic.com/v1/messages" +trackAiModel: "claude-3-5-haiku-20241022" +trackAiApikey: "sk-ant-xxxxx" +``` + +> **说明**:此模型在 `strict` 模式下用作"是否在跟机器人对话"的批量判官;在 `smart` 模式下用作 Timing Gate 子代理(输出 continue/no_action/wait 三选一)。两种用途都只输出极少 token,推荐用 gpt-4o-mini / gemini-2.0-flash / claude-3-5-haiku 等小模型。插件会根据 URL 自动识别 API 格式。 ### 工具调用模型配置 (`toolsAiConfig`) @@ -907,37 +916,78 @@ chatApiKey: "sk-ant-xxxxx" > **说明**:插件会根据 URL 自动识别 API 格式。如果 URL 包含 `/v1/messages`,自动切换为 Anthropic 格式(`max_tokens` 为 16000,并默认开启自适应思考 `thinking: adaptive`;模型不支持思考时会自动去掉该参数重试,不影响使用);否则使用 OpenAI 格式。 ### 图像编辑模型配置 (`imageEditAiConfig`) + +**OpenAI 兼容格式**: ```yaml imageEditApiUrl: "https://api.openai.com/v1/chat/completions" imageEditApiModel: "gemini-3-pro-image-preview" imageEditApiKey: "sk-xxxxx" ``` +**Anthropic 格式**(自动识别): +```yaml +imageEditApiUrl: "https://api.anthropic.com/v1/messages" +imageEditApiModel: "claude-3-5-sonnet-20241022" +imageEditApiKey: "sk-ant-xxxxx" +``` + +> **说明**:用于图片编辑和文生图功能。插件会根据 URL 自动识别 API 格式,自动处理多模态消息转换(OpenAI `image_url` ↔ Anthropic `image` 格式)。 + ### 图像识别模型配置 (`analysisAiConfig`) + +**OpenAI 兼容格式**: ```yaml analysisApiUrl: "https://api.openai.com/v1/chat/completions" analysisApiModel: "gemini-3-pro-preview" analysisApiKey: "sk-xxxxx" ``` +**Anthropic 格式**(自动识别): +```yaml +analysisApiUrl: "https://api.anthropic.com/v1/messages" +analysisApiModel: "claude-3-5-sonnet-20241022" +analysisApiKey: "sk-ant-xxxxx" +``` + +> **说明**:用于图片识别和分析功能。插件会根据 URL 自动识别 API 格式,自动处理多模态消息转换。 + ### 联网搜索模型配置 (`searchAiConfig`) + +**OpenAI 兼容格式**: ```yaml searchApiUrl: "https://api.openai.com/v1/chat/completions" searchApiModel: "deepseek-r1-search" searchApiKey: "sk-xxxxx" ``` +**Anthropic 格式**(自动识别): +```yaml +searchApiUrl: "https://api.anthropic.com/v1/messages" +searchApiModel: "claude-3-5-sonnet-20241022" +searchApiKey: "sk-ant-xxxxx" +``` + +> **说明**:用于联网搜索工具。插件会根据 URL 自动识别 API 格式。 + ### 记忆提取模型配置 (`memoryAiConfig`) > ⚠️ **仅在开启 `memorySystem.enabled: true` 时需要配置** +**OpenAI 兼容格式**: ```yaml memoryAiUrl: "https://api.openai.com/v1/chat/completions" memoryAiModel: "gpt-4o-mini" # 推荐使用小模型,省钱且响应快 memoryAiApikey: "sk-xxxxx" ``` -**说明**:用户记忆会在对话结束后立即异步调用此模型;群记忆会批量整理后再调用此模型,提取值得长期保存的事实。推荐使用 `gpt-4o-mini`、`gemini-2.0-flash` 等小模型。 +**Anthropic 格式**(自动识别): +```yaml +memoryAiUrl: "https://api.anthropic.com/v1/messages" +memoryAiModel: "claude-3-5-haiku-20241022" +memoryAiApikey: "sk-ant-xxxxx" +``` + +> **说明**:用户记忆会在对话结束后立即异步调用此模型;群记忆会批量整理后再调用此模型,提取值得长期保存的事实。推荐使用 `gpt-4o-mini`、`gemini-2.0-flash`、`claude-3-5-haiku` 等小模型。插件会根据 URL 自动识别 API 格式。 ### Embedding 模型配置 (`embeddingAiConfig`) diff --git a/core/conversationTracker.js b/core/conversationTracker.js index 6148aed..eb516fc 100644 --- a/core/conversationTracker.js +++ b/core/conversationTracker.js @@ -4,6 +4,7 @@ // 禁言缓存、回复 debounce、批量"是否在和 bot 说话"判断 // 以 mixin 形式挂到插件原型上,this 指向插件实例。 import { extractChatKeywords, isQuestionMessage, isFeedbackMessage } from "./chatHeuristics.js" +import { callAI } from "../utils/apiClient.js" // 会话追踪: key: `${groupId}_${userId}`, value: { lastActiveTime, chatHistory: [], timer: null } // (handleRandomReply / handleTextResponse 也会读写,故导出) @@ -844,25 +845,21 @@ ${specialSignalsBlock} const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 15000) try { - const response = await fetch(useCfg.url, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${useCfg.apikey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - model: useCfg.model, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } - ], - temperature: 0.3 - }), - signal: controller.signal - }) - if (!response.ok) return { decision: 'no_action', reason: `http_${response.status}` } - const data = await response.json() - const raw = data?.choices?.[0]?.message?.content?.trim() || '' + const result = await callAI( + useCfg, + [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + { + temperature: 0.3, + signal: controller.signal + } + ) + + if (result.error) return { decision: 'no_action', reason: `api_error:${result.error}` } + + const raw = result?.choices?.[0]?.message?.content?.trim() || '' const jsonMatch = raw.match(/\{[\s\S]*\}/) if (!jsonMatch) return { decision: 'no_action', reason: 'no_json' } const parsed = JSON.parse(jsonMatch[0]) @@ -1029,18 +1026,16 @@ ${specialSignalsBlock} ? chatHistory.map(h => `[${h.role === 'bot' ? '机器人' : '用户'}] ${h.content}`).join('\n') : '(无历史记录)' - const response = await fetch(this.config.trackAiConfig.trackAiUrl, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.config.trackAiConfig.trackAiApikey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ + const result = await callAI( + { + url: this.config.trackAiConfig.trackAiUrl, model: this.config.trackAiConfig.trackAiModel, - messages: [ - { - role: "system", - content: `你是QQ群聊对话判断助手。机器人名字叫"${botName}",QQ号${Bot.uin}。 + apikey: this.config.trackAiConfig.trackAiApikey + }, + [ + { + role: "system", + content: `你是QQ群聊对话判断助手。机器人名字叫"${botName}",QQ号${Bot.uin}。 根据对话历史,判断用户新消息是否在继续跟机器人对话。 @@ -1057,19 +1052,17 @@ ${specialSignalsBlock} 你只回复 true 或 false,不要输出其他内容。 ` - }, - { - role: "user", - content: `【近期对话记录】\n${historyText}\n\n【用户新消息】\n${userMessage}\n\n这条新消息是在跟机器人说话吗?` - } - ] - }) - }) + }, + { + role: "user", + content: `【近期对话记录】\n${historyText}\n\n【用户新消息】\n${userMessage}\n\n这条新消息是在跟机器人说话吗?` + } + ] + ) - if (!response.ok) return false // 请求失败时默认不触发 + if (result.error) return false // 请求失败时默认不触发 - const data = await response.json() - const answer = data?.choices?.[0]?.message?.content?.toLowerCase()?.trim() + const answer = result?.choices?.[0]?.message?.content?.toLowerCase()?.trim() // logger.error(answer, historyText, userMessage, 8888) return answer === 'true' || answer?.includes('true') } catch (error) { @@ -1139,18 +1132,16 @@ ${recentHistory || '(无)'} ---` }).join('\n\n') - const response = await fetch(this.config.trackAiConfig.trackAiUrl, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.config.trackAiConfig.trackAiApikey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ + const result = await callAI( + { + url: this.config.trackAiConfig.trackAiUrl, model: this.config.trackAiConfig.trackAiModel, - messages: [ - { - role: "system", - content: `你是QQ群聊对话判断助手。机器人名字叫"${botName}"。 + apikey: this.config.trackAiConfig.trackAiApikey + }, + [ + { + role: "system", + content: `你是QQ群聊对话判断助手。机器人名字叫"${botName}"。 每条消息来自不同用户,有独立的对话历史,请分别独立判断。 @@ -1169,22 +1160,20 @@ ${recentHistory || '(无)'} 返回JSON对象,key为消息ID,value为判断结果。 示例: {"MSG_1_12345": true, "MSG_2_67890": false} 只返回JSON对象,不要其他内容。` - }, - { - role: "user", - content: `分别判断以下${batchWithIds.length}条来自不同用户的消息:\n\n${messagesText}\n\n返回JSON对象:` - } - ] - }) - }) + }, + { + role: "user", + content: `分别判断以下${batchWithIds.length}条来自不同用户的消息:\n\n${messagesText}\n\n返回JSON对象:` + } + ] + ) - if (!response.ok) { - logger.error('[批量判断] API请求失败') + if (result.error) { + logger.error('[批量判断] API请求失败:', result.error) return this.fallbackToSingleJudgment(batch) } - const data = await response.json() - let content = data?.choices?.[0]?.message?.content?.trim() || '{}' + let content = result?.choices?.[0]?.message?.content?.trim() || '{}' // 提取JSON对象 const jsonMatch = content.match(/\{[\s\S]*\}/) diff --git a/functions/functions_tools/BananaTool.js b/functions/functions_tools/BananaTool.js index 1f945d2..d04db86 100644 --- a/functions/functions_tools/BananaTool.js +++ b/functions/functions_tools/BananaTool.js @@ -1,6 +1,7 @@ import { AbstractTool } from './AbstractTool.js'; import { getBase64Image, normalizeImageUrls } from '../../utils/fileUtils.js'; import { dependencies } from "../../dependence/dependencies.js"; +import { callAI } from "../../utils/apiClient.js"; import fs from "fs"; import YAML from "yaml"; import path from "path"; @@ -46,20 +47,21 @@ export class BananaTool extends AbstractTool { const { imageEditApiUrl: apiUrl, imageEditApiKey: apiKey, imageEditApiModel: model } = config.imageEditAiConfig || {}; - const response = await fetch(apiUrl || 'https://api.openai.com/v1/chat/completions', { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey || 'sk-xxxxxx'}`, - }, - body: JSON.stringify({ + const result = await callAI( + { + url: apiUrl || 'https://api.openai.com/v1/chat/completions', model: model || "gemini-3-pro-image-preview", - messages: [{ role: "user", content: imgurls }], - stream: false, - }), - }); + apikey: apiKey || 'sk-xxxxxx' + }, + [{ role: "user", content: imgurls }], + { stream: false } + ) + + if (result.error) { + return { error: `图片生成失败: ${result.error}` } + } - const imageUrl = await this.parseResponse(response); + const imageUrl = result?.choices?.[0]?.message?.content || '' const processedUrl = this.extractImageUrl(imageUrl); if (processedUrl) { diff --git a/functions/functions_tools/GoogleAnalysisTool.js b/functions/functions_tools/GoogleAnalysisTool.js index eb9202a..d867d7f 100644 --- a/functions/functions_tools/GoogleAnalysisTool.js +++ b/functions/functions_tools/GoogleAnalysisTool.js @@ -1,6 +1,7 @@ import { AbstractTool } from './AbstractTool.js'; import { getBase64Image, normalizeImageUrls } from '../../utils/fileUtils.js'; import { dependencies } from "../../dependence/dependencies.js"; +import { callAI } from "../../utils/apiClient.js"; const { mimeTypes, axios } = dependencies; import fs from "fs"; import YAML from "yaml"; @@ -208,26 +209,25 @@ export class GoogleImageAnalysisTool extends AbstractTool { const history = [{ role: "user", content: imgurls }]; try { - const apiUrl = config.analysisAiConfig?.analysisApiUrl || 'https://api.openai.com/v1/chat/completions' const apiKey = config.analysisAiConfig?.analysisApiKey || 'sk-xxxxxx' + const apiModel = config.analysisAiConfig?.analysisApiModel || "gemini-3-pro-image-preview" - const requestData = { - model: config.analysisAiConfig?.analysisApiModel || "gemini-3-pro-image-preview", - messages: history, - stream: false - } - - const response = await fetch(apiUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, + const result = await callAI( + { + url: apiUrl, + model: apiModel, + apikey: apiKey }, - body: JSON.stringify(requestData), - }) + history, + { stream: false } + ) + + if (result.error) { + return { error: `图片分析失败: ${result.error}` } + } - const content = await this.parseResponse(response) + const content = result?.choices?.[0]?.message?.content || '无法分析图片' return { analysis: content }; diff --git a/functions/functions_tools/GoogleImageEditTool.js b/functions/functions_tools/GoogleImageEditTool.js index b82dd66..4b2ec5d 100644 --- a/functions/functions_tools/GoogleImageEditTool.js +++ b/functions/functions_tools/GoogleImageEditTool.js @@ -1,6 +1,7 @@ import { AbstractTool } from './AbstractTool.js'; import { getBase64Image, normalizeImageUrls } from '../../utils/fileUtils.js'; import { dependencies } from "../../dependence/dependencies.js"; +import { callAI } from "../../utils/apiClient.js"; import fs from "fs"; import YAML from "yaml"; import path from "path"; @@ -48,21 +49,22 @@ export class GoogleImageEditTool extends AbstractTool { // 调用API const { imageEditApiUrl, imageEditApiKey, imageEditApiModel } = config.imageEditAiConfig || {}; - const response = await fetch(imageEditApiUrl || 'https://api.openai.com/v1/chat/completions', { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${imageEditApiKey || 'sk-xxxxxx'}`, - }, - body: JSON.stringify({ + const result = await callAI( + { + url: imageEditApiUrl || 'https://api.openai.com/v1/chat/completions', model: imageEditApiModel || "gemini-3-pro-image-preview", - messages: [{ role: "user", content }], - stream: false, - }), - }); + apikey: imageEditApiKey || 'sk-xxxxxx' + }, + [{ role: "user", content }], + { stream: false } + ) + + if (result.error) { + return { error: `图片编辑失败: ${result.error}` } + } // 自动检测并处理响应 - const imageUrl = await this.parseResponse(response); + const imageUrl = result?.choices?.[0]?.message?.content || '' const processedUrl = this.extractImageUrl(imageUrl); diff --git a/functions/functions_tools/SearchInformationTool.js b/functions/functions_tools/SearchInformationTool.js index 0c001ee..074ba53 100644 --- a/functions/functions_tools/SearchInformationTool.js +++ b/functions/functions_tools/SearchInformationTool.js @@ -1,5 +1,6 @@ import { AbstractTool } from './AbstractTool.js'; import { TotalTokens } from "../../functions/tools/CalculateToken.js"; +import { callAI } from "../../utils/apiClient.js"; import fs from "fs"; import YAML from "yaml"; import path from "path"; @@ -118,22 +119,26 @@ export class SearchInformationTool extends AbstractTool { const configPath = path.join(process.cwd(), 'plugins/bl-chat-plugin/config/message.yaml'); const configFile = fs.readFileSync(configPath, 'utf8'); const config = YAML.parse(configFile).pluginSettings; - + const apiUrl = config.searchAiConfig?.searchApiUrl || 'https://api.openai.com/v1/chat/completions' const apiKey = config.searchAiConfig?.searchApiKey || 'sk-xxxxxx' + const apiModel = config.searchAiConfig?.searchApiModel || 'deepseek-r1-search' - const requestData = { "model": config.searchAiConfig?.searchApiModel || 'deepseek-r1-search', "messages": [{ "role": "user", "content": "请联网搜索:" + query }], "stream": false } - - const response = await fetch(apiUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, + const result = await callAI( + { + url: apiUrl, + model: apiModel, + apikey: apiKey }, - body: JSON.stringify(requestData), - }) + [{ role: "user", content: "请联网搜索:" + query }], + { stream: false } + ) + + if (result.error) { + return `搜索失败:${result.error}` + } - const content = await this.parseResponse(response) + const content = result?.choices?.[0]?.message?.content || '未找到相关搜索结果' return content + '\n\n提示:如果用户想基于搜索结果制作文件,可以使用 aiMindMapTool 工具继续操作。' } catch (error) { diff --git a/utils/EmojiPackManager.js b/utils/EmojiPackManager.js index 190e050..1f033df 100644 --- a/utils/EmojiPackManager.js +++ b/utils/EmojiPackManager.js @@ -4,6 +4,7 @@ import path from "path" import crypto from "crypto" import YAML from "yaml" import sharp from "sharp" +import { callAI } from "./apiClient.js" const _path = process.cwd() const CONFIG_PATH = path.join(_path, "plugins/bl-chat-plugin/config/message.yaml") @@ -338,17 +339,25 @@ export class EmojiPackManager { if (!url || !key || String(key).includes("sk-xxx")) { throw new Error(`${keyField} 未配置`) } - const finalBody = { model: cfg[modelField], ...body } - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` }, - body: JSON.stringify(finalBody) - }) - if (!response.ok) { - const text = await response.text().catch(() => "") - throw new Error(`API ${response.status}: ${text.slice(0, 120)}`) + + const result = await callAI( + { + url: url, + model: cfg[modelField] || body.model, + apikey: key + }, + body.messages, + { + maxTokens: body.max_tokens, + temperature: body.temperature + } + ) + + if (result.error) { + throw new Error(`API 调用失败: ${result.error}`) } - return await response.json() + + return result } async tagWithVLM(buffer, ext) { @@ -493,20 +502,18 @@ ${list} 仅输出 JSON(不要 markdown 代码块):{"index": <候选编号 0-${sample.length - 1}>, "reason": "简短理由"}` - const response = await fetch(cfg.toolsAiUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.toolsAiApikey}` }, - body: JSON.stringify({ + const result = await callAI( + { + url: cfg.toolsAiUrl, model: cfg.toolsAiModel || "gpt-4o-mini", - messages: [{ role: "user", content: prompt }] - }) - }) - if (!response.ok) { - const text = await response.text().catch(() => "") - throw new Error(`tools API ${response.status}: ${text.slice(0, 120)}`) + apikey: cfg.toolsAiApikey + }, + [{ role: "user", content: prompt }] + ) + if (result.error) { + throw new Error(`tools API 调用失败: ${result.error}`) } - const json = await response.json() - const text = json.choices?.[0]?.message?.content || "" + const text = result.choices?.[0]?.message?.content || "" const match = text.match(/\{[\s\S]*\}/) if (!match) throw new Error("替换决策未返回 JSON") const parsed = JSON.parse(match[0]) diff --git a/utils/ExpressionLearner.js b/utils/ExpressionLearner.js index 0a3164c..3c10e30 100644 --- a/utils/ExpressionLearner.js +++ b/utils/ExpressionLearner.js @@ -2,6 +2,8 @@ * 表达学习管理器 * 学习群友的说话风格,支持 AI 场景化学习 */ +import { callAI } from "./apiClient.js" + export class ExpressionLearner { constructor(config = {}) { this.REDIS_PREFIX = 'ytbot:expression:' @@ -247,18 +249,16 @@ export class ExpressionLearner { if (!messageSample) return - const response = await fetch(memoryAiUrl, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${memoryAiApikey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ + const result = await callAI( + { + url: memoryAiUrl, model: memoryAiModel || 'gpt-4o-mini', - messages: [ - { - role: 'system', - content: `分析以下群聊消息样本,提取群友的表达习惯。 + apikey: memoryAiApikey + }, + [ + { + role: 'system', + content: `分析以下群聊消息样本,提取群友的表达习惯。 【任务】 归纳群友在不同情境下的常用表达方式,只提取有特色的、非通用的表达。 @@ -277,24 +277,24 @@ export class ExpressionLearner { - 最多返回 5 个场景 - 无明显规律时返回 [] - 只输出 JSON,不要其他内容` - }, - { - role: 'user', - content: `群聊消息样本:\n${messageSample}` - } - ], + }, + { + role: 'user', + content: `群聊消息样本:\n${messageSample}` + } + ], + { temperature: 0.3, - max_tokens: 400 - }) - }) + maxTokens: 400 + } + ) - if (!response.ok) { - logger.error(`[表达学习] AI 请求失败: ${response.status}`) + if (result.error) { + logger.error(`[表达学习] AI 请求失败: ${result.error}`) return } - const data = await response.json() - let content = data?.choices?.[0]?.message?.content?.trim() || '[]' + let content = result?.choices?.[0]?.message?.content?.trim() || '[]' const jsonMatch = content.match(/\[[\s\S]*\]/) if (jsonMatch) { diff --git a/utils/MemoryManager.js b/utils/MemoryManager.js index f56cbea..551366b 100644 --- a/utils/MemoryManager.js +++ b/utils/MemoryManager.js @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "crypto" +import { callAI } from "./apiClient.js" const USER_CATEGORIES = ["identity", "likes", "dislikes", "relationship", "habits", "skills", "experience"] const GROUP_CATEGORIES = ["topic", "rule", "meme", "event", "member"] @@ -649,26 +650,24 @@ class MemoryExtractor { const cfg = this.config.memoryAiConfig || {} if (!cfg.memoryAiUrl || !cfg.memoryAiApikey) return "[]" - const response = await fetch(cfg.memoryAiUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${cfg.memoryAiApikey}`, - "Content-Type": "application/json" - }, - body: JSON.stringify({ + const result = await callAI( + { + url: cfg.memoryAiUrl, model: cfg.memoryAiModel || "gpt-4o-mini", - messages, - temperature: 0.2, - max_tokens: maxTokens - }) - }) + apikey: cfg.memoryAiApikey + }, + messages, + { + maxTokens, + temperature: 0.2 + } + ) - if (!response.ok) { - throw new Error(`记忆 AI 请求失败:${response.status}`) + if (result.error) { + throw new Error(`记忆 AI 请求失败:${result.error}`) } - const data = await response.json() - return data?.choices?.[0]?.message?.content?.trim() || "[]" + return result?.choices?.[0]?.message?.content?.trim() || "[]" } async createEmbedding(text) { diff --git a/utils/apiClient.js b/utils/apiClient.js index 9c7cf11..a2e43b3 100644 --- a/utils/apiClient.js +++ b/utils/apiClient.js @@ -2,6 +2,9 @@ import { dependencies } from "../dependence/dependencies.js"; import { removeToolPromptsFromMessages } from "../utils/textUtils.js" const { _path, fetch, fs, path } = dependencies; +// logger 引用(假设全局可用,否则需要 import) +const logger = global.logger || console; + /** * 生成动态的客户端版本标识(基于当前日期) * @returns {Object} 包含 anthropicVersion 和 clientVersion 的对象 @@ -452,10 +455,35 @@ function convertToAnthropicFormat(requestData, originalRequestData) { }] } } else { - // 普通消息 - convertedMsg = { - role: msg.role, - content: String(msg.content || '') + // 普通消息(可能包含多模态内容) + if (Array.isArray(msg.content)) { + // 多模态消息:转换 image_url 为 Anthropic 的 image 格式 + const convertedContent = [] + for (const block of msg.content) { + if (block.type === 'text') { + convertedContent.push({ type: 'text', text: block.text || '' }) + } else if (block.type === 'image_url') { + // OpenAI image_url -> Anthropic image + const imageUrl = block.image_url?.url || block.url || '' + const imageBlock = convertImageUrlToAnthropicFormat(imageUrl) + if (imageBlock) { + convertedContent.push(imageBlock) + } + } else { + // 其他类型保持不变 + convertedContent.push(block) + } + } + convertedMsg = { + role: msg.role, + content: convertedContent.length > 0 ? convertedContent : [{ type: 'text', text: '' }] + } + } else { + // 纯文本消息 + convertedMsg = { + role: msg.role, + content: String(msg.content || '') + } } } @@ -523,11 +551,12 @@ function convertToAnthropicFormat(requestData, originalRequestData) { * @param {Object} requestData - 请求体对象 * @returns {Promise<{response: Response, errorText: string|null}>} errorText 仅在最终响应失败时填充 */ -async function fetchWithThinkingFallback(url, headers, requestData) { +async function fetchWithThinkingFallback(url, headers, requestData, signal) { const send = (body) => fetch(url, { method: 'POST', headers, - body: JSON.stringify(body) + body: JSON.stringify(body), + signal }) const response = await send(requestData) @@ -551,6 +580,43 @@ async function fetchWithThinkingFallback(url, headers, requestData) { } } +/** + * 将 OpenAI 的 image_url 格式转换为 Anthropic 的 image 格式 + * @param {string} imageUrl - 图片 URL(支持 http(s):// 或 data:image/...;base64,... 格式) + * @returns {Object|null} Anthropic image block 或 null + */ +function convertImageUrlToAnthropicFormat(imageUrl) { + if (!imageUrl || typeof imageUrl !== 'string') return null + + // 处理 base64 data URL + if (imageUrl.startsWith('data:image/')) { + const match = imageUrl.match(/^data:image\/([^;]+);base64,(.+)$/) + if (match) { + const [, format, data] = match + // 映射常见格式到 MIME type + const mimeType = `image/${format}` + return { + type: 'image', + source: { + type: 'base64', + media_type: mimeType, + data: data + } + } + } + } + + // 处理普通 URL(Anthropic 也支持,但需要用 URL 类型) + if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { + // 注意:Anthropic API 的 image source 只支持 base64,不支持 URL + // 这里返回 null,调用方需要先下载图片转为 base64 + logger.warn('[convertImageUrlToAnthropicFormat] Anthropic 不支持直接传 URL,需要先转为 base64') + return null + } + + return null +} + /** * 将 Anthropic 响应转换为 OpenAI 格式 * @param {Object} anthropicResponse - Anthropic 格式响应 @@ -609,6 +675,174 @@ function convertFromAnthropicFormat(anthropicResponse) { return openaiResponse } +/** + * 通用 AI API 调用函数 + * 自动检测 API 格式(OpenAI/Anthropic),转换请求和响应,支持流式和非流式 + * + * @param {Object} config - API 配置 { url, model, apikey } + * @param {Array} messages - OpenAI 格式的消息数组 + * @param {Object} options - 可选参数 + * @param {number} [options.maxTokens] - 最大 token 数 + * @param {number} [options.temperature] - 温度参数 + * @param {Array} [options.tools] - 工具定义(OpenAI 格式) + * @param {boolean} [options.stream] - 是否流式响应 + * @param {AbortSignal} [options.signal] - 用于取消请求的 AbortSignal + * @param {Object} [options.additionalParams] - 其他额外的请求体参数 + * @returns {Promise} 返回 OpenAI 格式的响应对象,或 { error: string } + */ +export async function callAI(config, messages, options = {}) { + const { + maxTokens, + temperature, + tools, + stream = false, + signal, + additionalParams = {} + } = options + + // 验证配置 + if (!config?.url || !config?.model || !config?.apikey) { + return { error: 'API 配置不完整,需要 url、model、apikey' } + } + + // 检测 API 格式 + const apiFormat = detectApiFormat(config.url) + + try { + // 构建请求头 + const headers = { + 'Authorization': `Bearer ${config.apikey}`, + 'Content-Type': 'application/json' + } + + // 构建基础请求体(OpenAI 格式) + let requestData = { + model: config.model, + messages: messages, + stream: stream, + ...additionalParams + } + + if (maxTokens !== undefined) requestData.max_tokens = maxTokens + if (temperature !== undefined) requestData.temperature = temperature + if (tools && tools.length > 0) requestData.tools = tools + + // 根据格式转换请求 + if (apiFormat === 'anthropic') { + // 应用 Claude Code 请求头(伪装为官方 CLI) + applyClaudeCodeHeaders(headers) + + // 转换为 Anthropic 格式(自动添加 system 身份串、metadata、thinking 等伪装字段) + try { + requestData = convertToAnthropicFormat(requestData, requestData) + } catch (convertError) { + logger.error('[callAI] Anthropic 请求格式转换失败:', convertError) + return { error: `请求格式转换失败:${convertError.message}` } + } + } + + // 发送请求(signal 单独传递,不进 body) + const result = await fetchWithThinkingFallback(config.url, headers, requestData, signal) + const response = result.response + + if (!response.ok) { + logger.error(`[callAI] API 请求失败:${response.status} ${response.statusText} - ${result.errorText}`) + return { error: `API 请求失败:${response.status} ${response.statusText}` } + } + + // 处理流式响应 + if (stream) { + return handleStreamResponseUnified(response, apiFormat) + } + + // 处理非流式响应 + let responseData + try { + responseData = await response.json() + } catch (jsonError) { + logger.error('[callAI] 解析响应 JSON 失败:', jsonError) + return { error: `解析响应 JSON 失败:${jsonError.message}` } + } + + // 转换 Anthropic 响应为 OpenAI 格式 + if (apiFormat === 'anthropic') { + try { + responseData = convertFromAnthropicFormat(responseData) + } catch (convertError) { + logger.error('[callAI] Anthropic 响应格式转换失败:', convertError) + return { error: `响应格式转换失败:${convertError.message}` } + } + } + + return processResponse(responseData) + + } catch (error) { + logger.error('[callAI] 调用异常:', error) + return { error: `调用异常:${error.message}` } + } +} + +/** + * 统一处理流式响应(兼容 OpenAI 和 Anthropic SSE 格式) + * @param {Response} response - fetch 响应对象 + * @param {string} apiFormat - 'openai' 或 'anthropic' + * @returns {Promise} 拼接后的完整文本内容 + */ +async function handleStreamResponseUnified(response, apiFormat) { + const reader = response.body.getReader() + const decoder = new TextDecoder() + let content = '' + + try { + while (true) { + const { value, done } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + for (const line of chunk.split('\n')) { + if (!line.startsWith('data: ')) continue + const dataStr = line.slice(6).trim() + if (dataStr === '[DONE]') break + + try { + const data = JSON.parse(dataStr) + + if (apiFormat === 'anthropic') { + // Anthropic 流式格式 + if (data.type === 'content_block_delta' && data.delta?.text) { + content += data.delta.text + } + } else { + // OpenAI 流式格式 + const delta = data?.choices?.[0]?.delta?.content + if (delta) content += delta + } + } catch (parseError) { + // 跳过解析失败的行 + } + } + } + + if (!content) { + throw new Error('未接收到有效内容') + } + + // 返回 OpenAI 格式的响应对象 + return { + choices: [{ + message: { + role: 'assistant', + content: content + }, + finish_reason: 'stop' + }] + } + } catch (error) { + logger.error('[handleStreamResponseUnified] 流式响应处理失败:', error) + return { error: `流式响应处理失败:${error.message}` } + } +} + function processResponse(responseData) { // 处理数组响应(兼容某些 API 返回数组的情况) if (Array.isArray(responseData) && responseData.length > 0) {