diff --git a/src/services/tools/executor.ts b/src/services/tools/executor.ts index 4d65cff..fb20e40 100644 --- a/src/services/tools/executor.ts +++ b/src/services/tools/executor.ts @@ -24,6 +24,23 @@ import { type WebFetchInput, type WebFetchResponse, } from './webFetch'; +import { + metasoSearch, + formatMetasoSearchResults, + formatMetasoSearchResultsShort, + getValidImages, + ImageValidationConfig, + type MetasoSearchInput, + type MetasoSearchResponse, + type MetasoImageResult, +} from './metasoSearch'; +import { + metasoReader, + formatMetasoReaderResult, + formatMetasoReaderResultShort, + type MetasoReaderInput, + type MetasoReaderResponse, +} from './metasoReader'; import { shouldUsePyodide, analyzeCode, type LoadingCallback } from './codeAnalyzer'; // 导出代码分析函数供外部使用 @@ -39,6 +56,8 @@ export interface ToolExecutionResult { rawData?: unknown; /** Base64 编码的图片数组(代码执行时可能产生) */ images?: string[]; + /** 搜索到的图片数组(图片搜索时产生) */ + searchImages?: MetasoImageResult[]; /** 是否需要浏览器端 Pyodide 执行 */ requiresPyodide?: boolean; /** 代码内容(当 requiresPyodide 为 true 时) */ @@ -47,18 +66,26 @@ export interface ToolExecutionResult { language?: string; } +export interface ToolExecutionOptions { + /** Pyodide 加载进度回调 */ + onProgress?: LoadingCallback; + /** 秘塔AI API Key */ + metasoApiKey?: string; +} + /** * 执行工具 * @param toolName 工具名称 * @param input 工具输入参数 - * @param onProgress Pyodide 加载进度回调(可选) + * @param options 执行选项 * @returns 执行结果(包含完整版和简短版) */ export async function executeTool( toolName: string, input: Record, - onProgress?: LoadingCallback + options?: ToolExecutionOptions ): Promise { + const { onProgress, metasoApiKey } = options || {}; try { switch (toolName) { case 'web_search': { @@ -119,6 +146,63 @@ export async function executeTool( }; } + case 'mita_search': { + const query = String(input.query || ''); + const scope = (input.scope as 'webpage' | 'image') || 'webpage'; + + // 图片搜索时请求更多图片用于验证筛选,网页搜索保持原有逻辑 + const requestSize = scope === 'image' + ? ImageValidationConfig.REQUEST_SIZE + : (input.size ? Number(input.size) : 10); + + const searchInput: MetasoSearchInput = { + query, + scope, + size: requestSize, + includeSummary: Boolean(input.includeSummary), + }; + + const response: MetasoSearchResponse = await metasoSearch(searchInput, metasoApiKey || ''); + + // 如果是图片搜索且成功,验证图片有效性 + let validatedImages: MetasoImageResult[] | undefined; + if (scope === 'image' && response.success && response.images && response.images.length > 0) { + // 验证图片,返回指定数量的有效图片 + validatedImages = await getValidImages( + response.images, + ImageValidationConfig.TARGET_COUNT, + ImageValidationConfig.CONCURRENCY + ); + + // 更新 response 中的图片为验证后的有效图片 + response.images = validatedImages; + } + + return { + success: response.success, + fullResult: formatMetasoSearchResults(response), + displayResult: formatMetasoSearchResultsShort(response, query), + rawData: response, + // 如果是图片搜索,返回验证后的图片数据 + searchImages: scope === 'image' ? validatedImages : undefined, + }; + } + + case 'mita_reader': { + const url = String(input.url || ''); + const readerInput: MetasoReaderInput = { + url, + format: 'Markdown', + }; + const response: MetasoReaderResponse = await metasoReader(readerInput, metasoApiKey || ''); + return { + success: response.success, + fullResult: formatMetasoReaderResult(response, url), + displayResult: formatMetasoReaderResultShort(response, url), + rawData: response, + }; + } + default: return { success: false, @@ -148,17 +232,23 @@ export function getAvailableTools() { description: '搜索互联网获取最新信息', icon: '🔍', }, - { - id: 'code_execution', - name: '代码执行', - description: '执行代码并返回结果', - icon: '💻', - }, { id: 'web_fetch', name: '网页获取', description: '获取指定 URL 的网页内容', icon: '🌐', }, + { + id: 'mita_search', + name: 'Metaso Search', + description: '秘塔AI智能搜索,需要配置API Key', + icon: '🔎', + }, + { + id: 'mita_reader', + name: 'Metaso Reader', + description: '秘塔AI网页读取,返回Markdown格式', + icon: '📄', + }, ]; } diff --git a/src/services/tools/metasoReader.ts b/src/services/tools/metasoReader.ts new file mode 100644 index 0000000..5a9c9a2 --- /dev/null +++ b/src/services/tools/metasoReader.ts @@ -0,0 +1,139 @@ +/** + * 秘塔AI网页读取工具服务 + * 使用秘塔AI API 读取网页内容并返回Markdown格式 + */ + +export interface MetasoReaderResponse { + success: boolean; + content?: string; + error?: string; +} + +export interface MetasoReaderInput { + url: string; + format?: 'Markdown' | 'Text'; +} + +/** + * 读取网页内容 + */ +export async function metasoReader( + input: MetasoReaderInput, + apiKey: string +): Promise { + if (!apiKey) { + return { + success: false, + error: '请先在设置中配置秘塔AI API Key', + }; + } + + // 验证URL格式 + try { + new URL(input.url); + } catch { + return { + success: false, + error: '无效的URL格式', + }; + } + + try { + const response = await fetch('https://metaso.cn/api/v1/reader', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Accept': 'text/plain', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: input.url, + format: input.format || 'Markdown', + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Metaso Reader API error:', errorText); + + if (response.status === 401) { + return { + success: false, + error: '秘塔AI API Key 无效,请检查配置', + }; + } + + if (response.status === 404) { + return { + success: false, + error: '无法访问该网页,请检查URL是否正确', + }; + } + + return { + success: false, + error: `秘塔读取API错误: ${response.status}`, + }; + } + + // 秘塔Reader返回的是文本内容 + const content = await response.text(); + + if (!content || content.trim().length === 0) { + return { + success: false, + error: '网页内容为空或无法解析', + }; + } + + return { + success: true, + content: content, + }; + } catch (error) { + console.error('Metaso reader error:', error); + return { + success: false, + error: error instanceof Error ? error.message : '秘塔读取服务异常', + }; + } +} + +/** + * 格式化读取结果为文本(完整版 - 发送给 AI) + */ +export function formatMetasoReaderResult(response: MetasoReaderResponse, url: string): string { + if (!response.success) { + return `读取网页失败: ${response.error}`; + } + + let result = `## 网页内容\n`; + result += `**来源**: ${url}\n\n`; + result += `---\n\n`; + result += response.content || '无内容'; + + return result; +} + +/** + * 格式化读取结果为简短文本(显示给用户) + */ +export function formatMetasoReaderResultShort(response: MetasoReaderResponse, url: string): string { + if (!response.success) { + return `读取失败: ${response.error}`; + } + + // 获取网站域名 + let hostname = url; + try { + hostname = new URL(url).hostname.replace('www.', ''); + } catch { + // 保持原始URL + } + + // 计算内容长度 + const contentLength = response.content?.length || 0; + const wordCount = response.content?.split(/\s+/).length || 0; + + return `> 📄 已读取 [${hostname}](${url}) 的内容\n> 📊 共 ${contentLength} 字符,约 ${wordCount} 词`; +} diff --git a/src/services/tools/metasoSearch.ts b/src/services/tools/metasoSearch.ts new file mode 100644 index 0000000..4a21190 --- /dev/null +++ b/src/services/tools/metasoSearch.ts @@ -0,0 +1,369 @@ +/** + * 秘塔AI搜索工具服务 + * 使用秘塔AI API 实现智能搜索(支持网页搜索和图片搜索) + */ + +// ============ 图片验证相关常量 ============ +const IMAGE_VALIDATION_TIMEOUT = 3000; // 单张图片验证超时时间(毫秒) +const IMAGE_VALIDATION_CONCURRENCY = 5; // 并发验证数量 +const IMAGE_REQUEST_SIZE = 15; // 图片搜索时请求的数量 +const IMAGE_TARGET_COUNT = 10; // 返回给前端的图片数量(前端会显示5张+5张备用) + +// 网页搜索结果 +export interface MetasoSearchResult { + title: string; + link: string; + snippet: string; + score: string; + position: number; + date?: string; + authors?: string[]; +} + +// 图片搜索结果 +export interface MetasoImageResult { + title: string; + imageUrl: string; + width: number; + height: number; + score: string; + position: number; + sourceUrl?: string; +} + +// 搜索响应 +export interface MetasoSearchResponse { + success: boolean; + credits?: number; + total?: number; + scope?: 'webpage' | 'image'; + // 网页搜索结果 + results?: MetasoSearchResult[]; + // 图片搜索结果 + images?: MetasoImageResult[]; + error?: string; +} + +// 搜索输入参数 +export interface MetasoSearchInput { + query: string; + scope?: 'webpage' | 'image'; + size?: number; + /** 页码,用于获取不同页的结果(从1开始) */ + page?: number; + includeSummary?: boolean; + includeRawContent?: boolean; + conciseSnippet?: boolean; +} + +/** + * 执行秘塔AI搜索 + */ +export async function metasoSearch( + input: MetasoSearchInput, + apiKey: string +): Promise { + if (!apiKey) { + return { + success: false, + error: '请先在设置中配置秘塔AI API Key', + }; + } + + const scope = input.scope || 'webpage'; + const size = input.size || (scope === 'image' ? 5 : 10); // 图片默认5张 + + try { + const response = await fetch('https://metaso.cn/api/v1/search', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + q: input.query, + scope: scope, + size: size, + includeSummary: input.includeSummary || false, + includeRawContent: input.includeRawContent || false, + conciseSnippet: input.conciseSnippet || false, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Metaso API error:', errorText); + + if (response.status === 401) { + return { + success: false, + error: '秘塔AI API Key 无效,请检查配置', + }; + } + + return { + success: false, + error: `秘塔搜索API错误: ${response.status}`, + }; + } + + const data = await response.json(); + + // 根据 scope 解析不同的结果 + if (scope === 'image') { + // 图片搜索结果 + return { + success: true, + credits: data.credits, + total: data.total, + scope: 'image', + images: data.images?.map((item: { + title: string; + imageUrl: string; + imageWidth: number; + imageHeight: number; + score: string; + position: number; + sourceUrl?: string; + }) => ({ + title: item.title, + imageUrl: item.imageUrl, + width: item.imageWidth, + height: item.imageHeight, + score: item.score, + position: item.position, + sourceUrl: item.sourceUrl, + })), + }; + } else { + // 网页搜索结果 + return { + success: true, + credits: data.credits, + total: data.total, + scope: 'webpage', + results: data.webpages?.map((item: { + title: string; + link: string; + snippet: string; + score: string; + position: number; + date?: string; + authors?: string[]; + }) => ({ + title: item.title, + link: item.link, + snippet: item.snippet, + score: item.score, + position: item.position, + date: item.date, + authors: item.authors, + })), + }; + } + } catch (error) { + console.error('Metaso search error:', error); + return { + success: false, + error: error instanceof Error ? error.message : '秘塔搜索服务异常', + }; + } +} + +/** + * 格式化网页搜索结果为文本(完整版 - 发送给 AI) + */ +export function formatMetasoSearchResults(response: MetasoSearchResponse): string { + if (!response.success) { + return `搜索失败: ${response.error}`; + } + + // 图片搜索结果 + if (response.scope === 'image' && response.images) { + let result = `## 秘塔图片搜索结果 (共${response.total}张)\n\n`; + response.images.forEach((item, index) => { + result += `${index + 1}. **${item.title}**\n`; + result += ` - 图片URL: ${item.imageUrl}\n`; + result += ` - 尺寸: ${item.width}x${item.height}\n`; + if (item.sourceUrl) { + result += ` - 来源: ${item.sourceUrl}\n`; + } + result += '\n'; + }); + return result; + } + + // 网页搜索结果 + let result = ''; + if (response.results && response.results.length > 0) { + result += `## 秘塔搜索结果 (共${response.total}条)\n\n`; + response.results.forEach((item, index) => { + result += `### ${index + 1}. ${item.title}\n`; + result += `**链接**: ${item.link}\n`; + if (item.date) { + result += `**日期**: ${item.date}\n`; + } + if (item.authors && item.authors.length > 0) { + result += `**作者**: ${item.authors.join(', ')}\n`; + } + result += `${item.snippet}\n\n`; + }); + } + + return result || '未找到相关结果'; +} + +// ============ 图片验证相关函数 ============ + +/** + * 验证单张图片 URL 是否可访问 + * 使用 GET + Range 请求模拟真实浏览器行为,比 HEAD 更可靠 + * @param url 图片 URL + * @param timeout 超时时间(毫秒) + * @returns 是否可访问 + */ +export async function validateImageUrl( + url: string, + timeout: number = IMAGE_VALIDATION_TIMEOUT +): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + // 使用 GET 请求 + Range 头部,只请求前 1KB 数据 + // 这比 HEAD 更可靠,因为某些服务器对 HEAD 和 GET 的处理不一致 + const response = await fetch(url, { + method: 'GET', + signal: controller.signal, + headers: { + // 只请求前 1024 字节,节省带宽 + 'Range': 'bytes=0-1023', + // 模拟浏览器请求,避免被某些服务器拒绝 + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8', + }, + }); + + clearTimeout(timeoutId); + + // 检查响应状态:200 OK 或 206 Partial Content 都是有效的 + if (!response.ok && response.status !== 206) { + return false; + } + + const contentType = response.headers.get('content-type'); + // 必须是图片类型 + if (contentType && !contentType.startsWith('image/')) { + return false; + } + + // 额外检查:尝试读取一些数据确保内容存在 + const buffer = await response.arrayBuffer(); + if (buffer.byteLength === 0) { + return false; + } + + return true; + } catch { + // 网络错误、超时或其他异常 + return false; + } +} + +/** + * 批量验证图片,返回指定数量的有效图片 + * @param images 原始图片列表 + * @param targetCount 目标有效图片数量 + * @param concurrency 并发验证数量 + * @returns 有效图片列表 + */ +export async function getValidImages( + images: MetasoImageResult[], + targetCount: number = IMAGE_TARGET_COUNT, + concurrency: number = IMAGE_VALIDATION_CONCURRENCY +): Promise { + const validImages: MetasoImageResult[] = []; + + // 如果图片数量不足,直接返回所有图片(不验证) + if (images.length <= targetCount) { + return images; + } + + // 分批并行验证 + for (let i = 0; i < images.length && validImages.length < targetCount; i += concurrency) { + const batch = images.slice(i, i + concurrency); + + // 并行验证当前批次的图片 + const validationResults = await Promise.all( + batch.map(async (img) => ({ + img, + valid: await validateImageUrl(img.imageUrl), + })) + ); + + // 收集有效图片 + for (const { img, valid } of validationResults) { + if (valid && validImages.length < targetCount) { + validImages.push(img); + } + } + + // 如果已经收集到足够的有效图片,提前结束 + if (validImages.length >= targetCount) { + break; + } + } + + return validImages; +} + +/** + * 导出图片验证相关常量供外部使用 + */ +export const ImageValidationConfig = { + TIMEOUT: IMAGE_VALIDATION_TIMEOUT, + CONCURRENCY: IMAGE_VALIDATION_CONCURRENCY, + REQUEST_SIZE: IMAGE_REQUEST_SIZE, + TARGET_COUNT: IMAGE_TARGET_COUNT, +}; + +/** + * 格式化搜索结果为简短文本(显示给用户) + */ +export function formatMetasoSearchResultsShort( + response: MetasoSearchResponse, + query: string +): string { + if (!response.success) { + return `搜索失败: ${response.error}`; + } + + // 图片搜索结果 + if (response.scope === 'image' && response.images) { + const imageCount = response.images.length; + return `> 🖼️ 秘塔搜索「${query}」图片,找到 ${response.total || imageCount} 张相关图片`; + } + + // 网页搜索结果 + const resultCount = response.results?.length || 0; + + // 提取来源网站并生成 Markdown 链接 + const sourceLinks = response.results?.slice(0, 3).map((r, index) => { + try { + const url = new URL(r.link); + const hostname = url.hostname.replace('www.', ''); + return `${index + 1}. [${hostname}](${r.link})`; + } catch { + return `${index + 1}. ${r.title.slice(0, 20)}`; + } + }) || []; + + let result = `> 🔍 秘塔搜索「${query}」,找到 ${response.total || resultCount} 个相关结果`; + + if (sourceLinks.length > 0) { + result += `\n\n**🔗 来源:**\n${sourceLinks.join('\n')}`; + } + + return result; +}