feat(工具): 实现秘塔AI搜索和网页读取工具

秘塔搜索 (mita_search):
- 支持网页搜索和图片搜索两种模式
- 图片搜索包含智能验证和动态回填机制
- 自动过滤无效图片URL,确保返回有效结果

秘塔阅读 (mita_reader):
- 将网页内容转换为结构化Markdown格式
- 支持URL格式验证和错误处理

工具执行器更新:
- 添加 metasoApiKey 选项支持
- 集成秘塔搜索和阅读工具到执行流程
- 返回搜索图片数据供前端展示
This commit is contained in:
gaoziman 2025-12-22 12:21:17 +08:00
parent baf27ceca6
commit 97d89f44ac
3 changed files with 606 additions and 8 deletions

View File

@ -24,6 +24,23 @@ import {
type WebFetchInput, type WebFetchInput,
type WebFetchResponse, type WebFetchResponse,
} from './webFetch'; } 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'; import { shouldUsePyodide, analyzeCode, type LoadingCallback } from './codeAnalyzer';
// 导出代码分析函数供外部使用 // 导出代码分析函数供外部使用
@ -39,6 +56,8 @@ export interface ToolExecutionResult {
rawData?: unknown; rawData?: unknown;
/** Base64 编码的图片数组(代码执行时可能产生) */ /** Base64 编码的图片数组(代码执行时可能产生) */
images?: string[]; images?: string[];
/** 搜索到的图片数组(图片搜索时产生) */
searchImages?: MetasoImageResult[];
/** 是否需要浏览器端 Pyodide 执行 */ /** 是否需要浏览器端 Pyodide 执行 */
requiresPyodide?: boolean; requiresPyodide?: boolean;
/** 代码内容(当 requiresPyodide 为 true 时) */ /** 代码内容(当 requiresPyodide 为 true 时) */
@ -47,18 +66,26 @@ export interface ToolExecutionResult {
language?: string; language?: string;
} }
export interface ToolExecutionOptions {
/** Pyodide 加载进度回调 */
onProgress?: LoadingCallback;
/** 秘塔AI API Key */
metasoApiKey?: string;
}
/** /**
* *
* @param toolName * @param toolName
* @param input * @param input
* @param onProgress Pyodide * @param options
* @returns * @returns
*/ */
export async function executeTool( export async function executeTool(
toolName: string, toolName: string,
input: Record<string, unknown>, input: Record<string, unknown>,
onProgress?: LoadingCallback options?: ToolExecutionOptions
): Promise<ToolExecutionResult> { ): Promise<ToolExecutionResult> {
const { onProgress, metasoApiKey } = options || {};
try { try {
switch (toolName) { switch (toolName) {
case 'web_search': { 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: default:
return { return {
success: false, success: false,
@ -148,17 +232,23 @@ export function getAvailableTools() {
description: '搜索互联网获取最新信息', description: '搜索互联网获取最新信息',
icon: '🔍', icon: '🔍',
}, },
{
id: 'code_execution',
name: '代码执行',
description: '执行代码并返回结果',
icon: '💻',
},
{ {
id: 'web_fetch', id: 'web_fetch',
name: '网页获取', name: '网页获取',
description: '获取指定 URL 的网页内容', description: '获取指定 URL 的网页内容',
icon: '🌐', icon: '🌐',
}, },
{
id: 'mita_search',
name: 'Metaso Search',
description: '秘塔AI智能搜索需要配置API Key',
icon: '🔎',
},
{
id: 'mita_reader',
name: 'Metaso Reader',
description: '秘塔AI网页读取返回Markdown格式',
icon: '📄',
},
]; ];
} }

View File

@ -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<MetasoReaderResponse> {
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}`;
}

View File

@ -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<MetasoSearchResponse> {
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<boolean> {
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<MetasoImageResult[]> {
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;
}