diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 04e1730..2106e8b 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -233,6 +233,9 @@ export async function POST(request: Request) { // 解密 API Key const decryptedApiKey = decryptApiKey(settings.cchApiKey); + // 解密秘塔 API Key(如果已配置) + const decryptedMetasoApiKey = settings.metasoApiKey ? decryptApiKey(settings.metasoApiKey) : undefined; + // 获取对话信息 const conversation = await db.query.conversations.findFirst({ where: eq(conversations.conversationId, conversationId), @@ -307,6 +310,7 @@ export async function POST(request: Request) { let thinkingContent = ''; let totalInputTokens = 0; let totalOutputTokens = 0; + let usedTools: string[] = []; // 收集使用过的工具名称 // 【重要】处理器选择优先级说明: // 1. 首先检查 apiFormat === 'openai':如果用户选择了 "OpenAI 兼容" 格式, @@ -332,12 +336,14 @@ export async function POST(request: Request) { controller, encoder, images, + metasoApiKey: decryptedMetasoApiKey, }); fullContent = result.fullContent; thinkingContent = result.thinkingContent; totalInputTokens = result.inputTokens; totalOutputTokens = result.outputTokens; + usedTools = result.usedTools; } else if (isCodex) { // ==================== Codex 模型处理(使用 Codex Response API) ==================== // 仅当使用 Claude 原生格式 + Codex 模型时,才使用 /v1/responses 端点 @@ -355,11 +361,13 @@ export async function POST(request: Request) { controller, encoder, images, // 传递用户上传的图片 + metasoApiKey: decryptedMetasoApiKey, }); fullContent = result.fullContent; totalInputTokens = result.inputTokens; totalOutputTokens = result.outputTokens; + usedTools = result.usedTools; } else { // ==================== Claude 原生格式处理 ==================== console.log('[API/chat] 使用 Claude 原生格式 (/v1/messages)'); @@ -376,12 +384,14 @@ export async function POST(request: Request) { controller, encoder, images, // 传递用户上传的图片 + metasoApiKey: decryptedMetasoApiKey, }); fullContent = result.fullContent; thinkingContent = result.thinkingContent; totalInputTokens = result.inputTokens; totalOutputTokens = result.outputTokens; + usedTools = result.usedTools; } // 保存 AI 回复到数据库 @@ -391,6 +401,7 @@ export async function POST(request: Request) { role: 'assistant', content: fullContent, thinkingContent: thinkingContent || null, + usedTools: usedTools.length > 0 ? usedTools : null, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, status: 'completed', @@ -417,6 +428,7 @@ export async function POST(request: Request) { messageId: assistantMessageId, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, + usedTools: usedTools.length > 0 ? usedTools : undefined, })}\n\n`)); controller.close(); @@ -466,6 +478,8 @@ interface CodexChatParams { media_type: string; data: string; }[]; + // 秘塔 API Key + metasoApiKey?: string; } // Codex Response API 的输入项类型 @@ -491,6 +505,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{ fullContent: string; inputTokens: number; outputTokens: number; + usedTools: string[]; }> { const { cchUrl, @@ -504,6 +519,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{ controller, encoder, images, + metasoApiKey, } = params; // 构建 Codex Response API 格式的输入(过滤空内容的消息) @@ -568,6 +584,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{ let fullContent = ''; let totalInputTokens = 0; let totalOutputTokens = 0; + const usedTools: string[] = []; // 收集使用过的工具名称 let loopCount = 0; const maxLoops = 10; @@ -727,6 +744,16 @@ async function handleCodexChat(params: CodexChatParams): Promise<{ // 执行所有工具并收集结果 for (const fc of functionCalls) { + // 收集工具名称(避免重复) + if (!usedTools.includes(fc.name)) { + usedTools.push(fc.name); + // 发送实时工具使用事件 + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: 'tool_used', + toolName: fc.name, + })}\n\n`)); + } + // 发送工具执行开始事件 controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'tool_execution_start', @@ -743,7 +770,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{ } // 执行工具 - const result = await executeTool(fc.name, toolInput); + const result = await executeTool(fc.name, toolInput, { metasoApiKey }); // 发送工具执行结果事件 controller.enqueue(encoder.encode(`data: ${JSON.stringify({ @@ -755,6 +782,16 @@ async function handleCodexChat(params: CodexChatParams): Promise<{ images: result.images, })}\n\n`)); + // 如果有搜索图片结果,发送专门的图片事件 + if (result.searchImages && result.searchImages.length > 0) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: 'tool_search_images', + id: fc.call_id, + name: fc.name, + searchImages: result.searchImages, + })}\n\n`)); + } + // 将工具结果显示给用户 const toolDisplayText = `\n\n${result.displayResult}\n\n`; fullContent += toolDisplayText; @@ -783,6 +820,7 @@ async function handleCodexChat(params: CodexChatParams): Promise<{ fullContent, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, + usedTools, }; } @@ -805,6 +843,8 @@ interface ClaudeChatParams { media_type: string; data: string; }[]; + // 秘塔 API Key + metasoApiKey?: string; } async function handleClaudeChat(params: ClaudeChatParams): Promise<{ @@ -812,6 +852,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{ thinkingContent: string; inputTokens: number; outputTokens: number; + usedTools: string[]; }> { const { cchUrl, @@ -826,6 +867,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{ controller, encoder, images, + metasoApiKey, } = params; // 构建消息历史(过滤空内容的消息) @@ -890,6 +932,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{ let thinkingContent = ''; let totalInputTokens = 0; let totalOutputTokens = 0; + const usedTools: string[] = []; // 收集使用过的工具名称 let loopCount = 0; const maxLoops = 10; let hasToolResults = false; @@ -1092,13 +1135,23 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{ const toolResults: ContentBlock[] = []; for (const tc of toolCalls) { + // 收集工具名称(避免重复) + if (!usedTools.includes(tc.name)) { + usedTools.push(tc.name); + // 发送实时工具使用事件 + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: 'tool_used', + toolName: tc.name, + })}\n\n`)); + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'tool_execution_start', id: tc.id, name: tc.name, })}\n\n`)); - const result = await executeTool(tc.name, tc.input); + const result = await executeTool(tc.name, tc.input, { metasoApiKey }); if (result.requiresPyodide) { controller.enqueue(encoder.encode(`data: ${JSON.stringify({ @@ -1126,6 +1179,16 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{ images: result.images, })}\n\n`)); + // 如果有搜索图片结果,发送专门的图片事件 + if (result.searchImages && result.searchImages.length > 0) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: 'tool_search_images', + id: tc.id, + name: tc.name, + searchImages: result.searchImages, + })}\n\n`)); + } + const toolDisplayText = `\n\n${result.displayResult}\n\n`; fullContent += toolDisplayText; controller.enqueue(encoder.encode(`data: ${JSON.stringify({ @@ -1157,6 +1220,7 @@ async function handleClaudeChat(params: ClaudeChatParams): Promise<{ thinkingContent, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, + usedTools, }; } @@ -1177,6 +1241,8 @@ interface OpenAICompatibleChatParams { media_type: string; data: string; }[]; + // 秘塔 API Key + metasoApiKey?: string; } // OpenAI 消息格式 @@ -1207,6 +1273,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P thinkingContent: string; inputTokens: number; outputTokens: number; + usedTools: string[]; }> { const { cchUrl, @@ -1220,6 +1287,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P controller, encoder, images, + metasoApiKey, } = params; // 构建 OpenAI 格式的消息历史(过滤空内容的消息) @@ -1280,6 +1348,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P let thinkingContent = ''; // 用于收集 标签中的思考内容 let totalInputTokens = 0; let totalOutputTokens = 0; + const usedTools: string[] = []; // 收集使用过的工具名称 let loopCount = 0; const maxLoops = 10; @@ -1558,6 +1627,16 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P // 执行所有工具并收集结果 for (const tc of toolCalls) { + // 收集工具名称(避免重复) + if (!usedTools.includes(tc.name)) { + usedTools.push(tc.name); + // 发送实时工具使用事件 + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: 'tool_used', + toolName: tc.name, + })}\n\n`)); + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'tool_execution_start', id: tc.id, @@ -1573,7 +1652,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P } // 执行工具 - const result = await executeTool(tc.name, toolInput); + const result = await executeTool(tc.name, toolInput, { metasoApiKey }); controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'tool_execution_result', @@ -1584,6 +1663,16 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P images: result.images, })}\n\n`)); + // 如果有搜索图片结果,发送专门的图片事件 + if (result.searchImages && result.searchImages.length > 0) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: 'tool_search_images', + id: tc.id, + name: tc.name, + searchImages: result.searchImages, + })}\n\n`)); + } + // 将工具结果显示给用户 const toolDisplayText = `\n\n${result.displayResult}\n\n`; fullContent += toolDisplayText; @@ -1613,6 +1702,7 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P thinkingContent, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, + usedTools, }; } @@ -1647,6 +1737,43 @@ function buildClaudeToolDefinitions(toolIds: string[]) { required: ['url'], }, }, + mita_search: { + name: 'mita_search', + description: '秘塔AI智能搜索。支持网页搜索和图片搜索两种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索。', + input_schema: { + type: 'object', + properties: { + query: { + type: 'string', + description: '搜索查询关键词', + }, + scope: { + type: 'string', + enum: ['webpage', 'image'], + description: '搜索类型:webpage(网页搜索,默认)或 image(图片搜索)', + }, + size: { + type: 'number', + description: '返回结果数量,网页搜索默认10,图片搜索默认5', + }, + }, + required: ['query'], + }, + }, + mita_reader: { + name: 'mita_reader', + description: '秘塔AI网页读取。获取网页内容并返回结构化的Markdown格式,适合阅读长文章。', + input_schema: { + type: 'object', + properties: { + url: { + type: 'string', + description: '要读取的网页URL', + }, + }, + required: ['url'], + }, + }, }; return toolIds @@ -1691,6 +1818,49 @@ function buildOpenAIToolDefinitions(toolIds: string[]) { }, }, }, + mita_search: { + type: 'function', + function: { + name: 'mita_search', + description: '秘塔AI智能搜索。支持网页搜索和图片搜索两种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索。', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: '搜索查询关键词', + }, + scope: { + type: 'string', + enum: ['webpage', 'image'], + description: '搜索类型:webpage(网页搜索,默认)或 image(图片搜索)', + }, + size: { + type: 'number', + description: '返回结果数量,网页搜索默认10,图片搜索默认5', + }, + }, + required: ['query'], + }, + }, + }, + mita_reader: { + type: 'function', + function: { + name: 'mita_reader', + description: '秘塔AI网页读取。获取网页内容并返回结构化的Markdown格式,适合阅读长文章。', + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: '要读取的网页URL', + }, + }, + required: ['url'], + }, + }, + }, }; return toolIds @@ -1731,6 +1901,45 @@ function buildCodexToolDefinitions(toolIds: string[]) { required: ['url'], }, }, + mita_search: { + type: 'function', + name: 'mita_search', + description: '秘塔AI智能搜索。支持网页搜索和图片搜索两种模式。当需要搜索高质量的中文内容或需要更精准的搜索结果时使用网页搜索;当用户明确要求搜索图片或需要图片素材时使用图片搜索。', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: '搜索查询关键词', + }, + scope: { + type: 'string', + enum: ['webpage', 'image'], + description: '搜索类型:webpage(网页搜索,默认)或 image(图片搜索)', + }, + size: { + type: 'number', + description: '返回结果数量,网页搜索默认10,图片搜索默认5', + }, + }, + required: ['query'], + }, + }, + mita_reader: { + type: 'function', + name: 'mita_reader', + description: '秘塔AI网页读取。获取网页内容并返回结构化的Markdown格式,适合阅读长文章。', + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: '要读取的网页URL', + }, + }, + required: ['url'], + }, + }, }; return toolIds diff --git a/src/app/api/messages/[messageId]/route.ts b/src/app/api/messages/[messageId]/route.ts index 6e17fd0..853609a 100644 --- a/src/app/api/messages/[messageId]/route.ts +++ b/src/app/api/messages/[messageId]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { db } from '@/drizzle/db'; -import { messages } from '@/drizzle/schema'; +import { messages, type SearchImageData } from '@/drizzle/schema'; import { eq } from 'drizzle-orm'; interface RouteParams { @@ -9,7 +9,7 @@ interface RouteParams { /** * PATCH /api/messages/[messageId] - 更新消息 - * 主要用于更新消息的图片数据(Pyodide 执行后保存图片) + * 主要用于更新消息的图片数据(Pyodide 执行后保存图片)和搜索图片 */ export async function PATCH(request: Request, { params }: RouteParams) { try { @@ -39,6 +39,7 @@ export async function PATCH(request: Request, { params }: RouteParams) { // 构建更新数据 const updateData: { images?: string[]; + searchImages?: SearchImageData[]; content?: string; updatedAt: Date; } = { @@ -51,6 +52,12 @@ export async function PATCH(request: Request, { params }: RouteParams) { updateData.images = [...existingImages, ...body.images]; } + // 更新搜索图片(追加模式) + if (body.searchImages && Array.isArray(body.searchImages)) { + const existingSearchImages = (existingMessage.searchImages as SearchImageData[]) || []; + updateData.searchImages = [...existingSearchImages, ...body.searchImages]; + } + // 更新内容(如果提供) if (body.content !== undefined) { updateData.content = body.content; diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index cf36815..0fc3cd8 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -9,9 +9,10 @@ import { encryptApiKey } from '@/lib/crypto'; const DEFAULT_SETTINGS = { cchUrl: process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/', cchApiKeyConfigured: false, + metasoApiKeyConfigured: false, apiFormat: 'claude' as 'claude' | 'openai', // API 格式:claude(原生)| openai(兼容) defaultModel: 'claude-sonnet-4-5-20250929', - defaultTools: ['web_search', 'web_fetch'], + defaultTools: ['web_search', 'web_fetch', 'mita_search', 'mita_reader'], systemPrompt: '', temperature: '0.7', theme: 'light', @@ -30,6 +31,7 @@ function formatSettingsResponse(settings: typeof userSettings.$inferSelect | nul return { cchUrl: settings.cchUrl || DEFAULT_SETTINGS.cchUrl, cchApiKeyConfigured: settings.cchApiKeyConfigured || false, + metasoApiKeyConfigured: settings.metasoApiKeyConfigured || false, apiFormat: (settings.apiFormat as 'claude' | 'openai') || DEFAULT_SETTINGS.apiFormat, defaultModel: settings.defaultModel || DEFAULT_SETTINGS.defaultModel, defaultTools: settings.defaultTools || DEFAULT_SETTINGS.defaultTools, @@ -104,6 +106,7 @@ export async function PUT(request: Request) { const { cchUrl, cchApiKey, + metasoApiKey, apiFormat, defaultModel, defaultTools, @@ -138,6 +141,19 @@ export async function PUT(request: Request) { } } + // 如果提供了秘塔 API Key,加密后存储 + if (metasoApiKey !== undefined) { + if (metasoApiKey === '') { + // 清除秘塔 API Key + updateData.metasoApiKey = null; + updateData.metasoApiKeyConfigured = false; + } else { + // 加密存储秘塔 API Key + updateData.metasoApiKey = encryptApiKey(metasoApiKey); + updateData.metasoApiKeyConfigured = true; + } + } + // API 格式类型 if (apiFormat !== undefined) { updateData.apiFormat = apiFormat;