feat(services): 添加 AI 工具服务实现
- codeExecution: 代码执行工具定义 - webFetch: 网页抓取工具定义 - webSearch: 网络搜索工具定义 - executor: 工具执行器统一处理
This commit is contained in:
parent
e4cdcc5141
commit
ab9dd5aff8
223
src/services/tools/codeExecution.ts
Normal file
223
src/services/tools/codeExecution.ts
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Code Execution 工具服务
|
||||||
|
* 使用 Piston API 实现代码执行
|
||||||
|
* Piston API 文档: https://github.com/engineer-man/piston
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CodeExecutionInput {
|
||||||
|
code: string;
|
||||||
|
language: string;
|
||||||
|
stdin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeExecutionResponse {
|
||||||
|
success: boolean;
|
||||||
|
output?: string;
|
||||||
|
error?: string;
|
||||||
|
language?: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Piston API 支持的语言映射
|
||||||
|
const LANGUAGE_MAP: Record<string, { language: string; version: string }> = {
|
||||||
|
python: { language: 'python', version: '3.10.0' },
|
||||||
|
python3: { language: 'python', version: '3.10.0' },
|
||||||
|
javascript: { language: 'javascript', version: '18.15.0' },
|
||||||
|
js: { language: 'javascript', version: '18.15.0' },
|
||||||
|
typescript: { language: 'typescript', version: '5.0.3' },
|
||||||
|
ts: { language: 'typescript', version: '5.0.3' },
|
||||||
|
java: { language: 'java', version: '15.0.2' },
|
||||||
|
c: { language: 'c', version: '10.2.0' },
|
||||||
|
cpp: { language: 'c++', version: '10.2.0' },
|
||||||
|
'c++': { language: 'c++', version: '10.2.0' },
|
||||||
|
go: { language: 'go', version: '1.16.2' },
|
||||||
|
rust: { language: 'rust', version: '1.68.2' },
|
||||||
|
ruby: { language: 'ruby', version: '3.0.1' },
|
||||||
|
php: { language: 'php', version: '8.2.3' },
|
||||||
|
swift: { language: 'swift', version: '5.3.3' },
|
||||||
|
kotlin: { language: 'kotlin', version: '1.8.20' },
|
||||||
|
scala: { language: 'scala', version: '3.2.2' },
|
||||||
|
r: { language: 'r', version: '4.1.1' },
|
||||||
|
bash: { language: 'bash', version: '5.2.0' },
|
||||||
|
shell: { language: 'bash', version: '5.2.0' },
|
||||||
|
sql: { language: 'sqlite3', version: '3.36.0' },
|
||||||
|
lua: { language: 'lua', version: '5.4.4' },
|
||||||
|
perl: { language: 'perl', version: '5.36.0' },
|
||||||
|
haskell: { language: 'haskell', version: '9.0.1' },
|
||||||
|
elixir: { language: 'elixir', version: '1.14.3' },
|
||||||
|
clojure: { language: 'clojure', version: '1.10.3' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Piston API 端点
|
||||||
|
const PISTON_API_URL = 'https://emkc.org/api/v2/piston/execute';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行代码
|
||||||
|
*/
|
||||||
|
export async function executeCode(input: CodeExecutionInput): Promise<CodeExecutionResponse> {
|
||||||
|
const { code, language, stdin } = input;
|
||||||
|
|
||||||
|
// 获取语言映射
|
||||||
|
const langConfig = LANGUAGE_MAP[language.toLowerCase()];
|
||||||
|
if (!langConfig) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `不支持的编程语言: ${language}。支持的语言: ${Object.keys(LANGUAGE_MAP).join(', ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(PISTON_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
language: langConfig.language,
|
||||||
|
version: langConfig.version,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: getFileName(langConfig.language),
|
||||||
|
content: code,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stdin: stdin || '',
|
||||||
|
args: [],
|
||||||
|
compile_timeout: 10000,
|
||||||
|
run_timeout: 5000,
|
||||||
|
compile_memory_limit: -1,
|
||||||
|
run_memory_limit: -1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Piston API error:', errorText);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `代码执行 API 错误: ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 检查编译错误
|
||||||
|
if (data.compile && data.compile.code !== 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: data.compile.stderr || data.compile.output || '编译错误',
|
||||||
|
language: langConfig.language,
|
||||||
|
version: langConfig.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查运行错误
|
||||||
|
if (data.run && data.run.code !== 0) {
|
||||||
|
return {
|
||||||
|
success: true, // 代码执行了,但有运行时错误
|
||||||
|
output: data.run.stdout || '',
|
||||||
|
error: data.run.stderr || '',
|
||||||
|
language: langConfig.language,
|
||||||
|
version: langConfig.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: data.run?.stdout || data.run?.output || '',
|
||||||
|
error: data.run?.stderr || '',
|
||||||
|
language: langConfig.language,
|
||||||
|
version: langConfig.version,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Code execution error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '未知错误',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据语言获取文件名
|
||||||
|
*/
|
||||||
|
function getFileName(language: string): string {
|
||||||
|
const fileNameMap: Record<string, string> = {
|
||||||
|
python: 'main.py',
|
||||||
|
javascript: 'main.js',
|
||||||
|
typescript: 'main.ts',
|
||||||
|
java: 'Main.java',
|
||||||
|
c: 'main.c',
|
||||||
|
'c++': 'main.cpp',
|
||||||
|
go: 'main.go',
|
||||||
|
rust: 'main.rs',
|
||||||
|
ruby: 'main.rb',
|
||||||
|
php: 'main.php',
|
||||||
|
swift: 'main.swift',
|
||||||
|
kotlin: 'Main.kt',
|
||||||
|
scala: 'Main.scala',
|
||||||
|
r: 'main.r',
|
||||||
|
bash: 'main.sh',
|
||||||
|
sqlite3: 'main.sql',
|
||||||
|
lua: 'main.lua',
|
||||||
|
perl: 'main.pl',
|
||||||
|
haskell: 'Main.hs',
|
||||||
|
elixir: 'main.exs',
|
||||||
|
clojure: 'main.clj',
|
||||||
|
};
|
||||||
|
|
||||||
|
return fileNameMap[language] || 'main.txt';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化代码执行结果为文本(完整版 - 发送给 AI)
|
||||||
|
*/
|
||||||
|
export function formatExecutionResult(response: CodeExecutionResponse): string {
|
||||||
|
if (!response.success && !response.output) {
|
||||||
|
return `❌ 执行失败: ${response.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
if (response.language && response.version) {
|
||||||
|
result += `**语言**: ${response.language} (v${response.version})\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.output) {
|
||||||
|
result += `## 输出结果\n\`\`\`\n${response.output}\n\`\`\`\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
result += `\n## 错误信息\n\`\`\`\n${response.error}\n\`\`\`\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result || '执行完成,无输出';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化代码执行结果为简短文本(显示给用户)
|
||||||
|
*/
|
||||||
|
export function formatExecutionResultShort(response: CodeExecutionResponse, language: string): string {
|
||||||
|
if (!response.success && !response.output) {
|
||||||
|
return `❌ 代码执行失败: ${response.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const langDisplay = response.language || language;
|
||||||
|
const versionDisplay = response.version ? ` (v${response.version})` : '';
|
||||||
|
|
||||||
|
if (response.error && !response.output) {
|
||||||
|
return `⚠️ ${langDisplay}${versionDisplay} 代码执行出错`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算输出行数
|
||||||
|
const outputLines = response.output?.split('\n').length || 0;
|
||||||
|
|
||||||
|
return `✅ ${langDisplay}${versionDisplay} 代码执行完成,输出 ${outputLines} 行`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取支持的语言列表
|
||||||
|
*/
|
||||||
|
export function getSupportedLanguages(): string[] {
|
||||||
|
return Object.keys(LANGUAGE_MAP);
|
||||||
|
}
|
||||||
133
src/services/tools/executor.ts
Normal file
133
src/services/tools/executor.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* 统一工具执行器
|
||||||
|
* 根据工具名称和输入参数执行对应的工具
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
webSearch,
|
||||||
|
formatSearchResults,
|
||||||
|
formatSearchResultsShort,
|
||||||
|
type WebSearchInput,
|
||||||
|
type WebSearchResponse,
|
||||||
|
} from './webSearch';
|
||||||
|
import {
|
||||||
|
executeCode,
|
||||||
|
formatExecutionResult,
|
||||||
|
formatExecutionResultShort,
|
||||||
|
type CodeExecutionInput,
|
||||||
|
type CodeExecutionResponse,
|
||||||
|
} from './codeExecution';
|
||||||
|
import {
|
||||||
|
webFetch,
|
||||||
|
formatFetchResult,
|
||||||
|
formatFetchResultShort,
|
||||||
|
type WebFetchInput,
|
||||||
|
type WebFetchResponse,
|
||||||
|
} from './webFetch';
|
||||||
|
|
||||||
|
export interface ToolExecutionResult {
|
||||||
|
success: boolean;
|
||||||
|
/** 完整结果 - 发送给 AI */
|
||||||
|
fullResult: string;
|
||||||
|
/** 简短结果 - 显示给用户 */
|
||||||
|
displayResult: string;
|
||||||
|
/** 原始数据 */
|
||||||
|
rawData?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行工具
|
||||||
|
* @param toolName 工具名称
|
||||||
|
* @param input 工具输入参数
|
||||||
|
* @returns 执行结果(包含完整版和简短版)
|
||||||
|
*/
|
||||||
|
export async function executeTool(
|
||||||
|
toolName: string,
|
||||||
|
input: Record<string, unknown>
|
||||||
|
): Promise<ToolExecutionResult> {
|
||||||
|
try {
|
||||||
|
switch (toolName) {
|
||||||
|
case 'web_search': {
|
||||||
|
const query = String(input.query || '');
|
||||||
|
const searchInput: WebSearchInput = { query };
|
||||||
|
const response: WebSearchResponse = await webSearch(searchInput);
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
fullResult: formatSearchResults(response),
|
||||||
|
displayResult: formatSearchResultsShort(response, query),
|
||||||
|
rawData: response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'code_execution': {
|
||||||
|
const language = String(input.language || 'python');
|
||||||
|
const codeInput: CodeExecutionInput = {
|
||||||
|
code: String(input.code || ''),
|
||||||
|
language,
|
||||||
|
stdin: input.stdin ? String(input.stdin) : undefined,
|
||||||
|
};
|
||||||
|
const response: CodeExecutionResponse = await executeCode(codeInput);
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
fullResult: formatExecutionResult(response),
|
||||||
|
displayResult: formatExecutionResultShort(response, language),
|
||||||
|
rawData: response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'web_fetch': {
|
||||||
|
const fetchInput: WebFetchInput = {
|
||||||
|
url: String(input.url || ''),
|
||||||
|
};
|
||||||
|
const response: WebFetchResponse = await webFetch(fetchInput);
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
fullResult: formatFetchResult(response),
|
||||||
|
displayResult: formatFetchResultShort(response),
|
||||||
|
rawData: response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
fullResult: `未知的工具: ${toolName}`,
|
||||||
|
displayResult: `❌ 未知的工具: ${toolName}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Tool execution error (${toolName}):`, error);
|
||||||
|
const errorMsg = error instanceof Error ? error.message : '未知错误';
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
fullResult: `工具执行错误: ${errorMsg}`,
|
||||||
|
displayResult: `❌ 工具执行错误: ${errorMsg}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有可用工具的信息
|
||||||
|
*/
|
||||||
|
export function getAvailableTools() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'web_search',
|
||||||
|
name: '网络搜索',
|
||||||
|
description: '搜索互联网获取最新信息',
|
||||||
|
icon: '🔍',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code_execution',
|
||||||
|
name: '代码执行',
|
||||||
|
description: '执行代码并返回结果',
|
||||||
|
icon: '💻',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web_fetch',
|
||||||
|
name: '网页获取',
|
||||||
|
description: '获取指定 URL 的网页内容',
|
||||||
|
icon: '🌐',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
8
src/services/tools/index.ts
Normal file
8
src/services/tools/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* 工具服务统一导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './webSearch';
|
||||||
|
export * from './codeExecution';
|
||||||
|
export * from './webFetch';
|
||||||
|
export * from './executor';
|
||||||
240
src/services/tools/webFetch.ts
Normal file
240
src/services/tools/webFetch.ts
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Web Fetch 工具服务
|
||||||
|
* 使用 Jina Reader API 获取网页内容
|
||||||
|
* Jina Reader API: https://r.jina.ai/{url}
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WebFetchInput {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebFetchResponse {
|
||||||
|
success: boolean;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
url?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jina Reader API 端点
|
||||||
|
const JINA_READER_API = 'https://r.jina.ai';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取网页内容
|
||||||
|
*/
|
||||||
|
export async function webFetch(input: WebFetchInput): Promise<WebFetchResponse> {
|
||||||
|
const { url } = input;
|
||||||
|
|
||||||
|
// 验证 URL
|
||||||
|
if (!isValidUrl(url)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '无效的 URL 格式',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 Jina Reader API 获取网页内容
|
||||||
|
const response = await fetch(`${JINA_READER_API}/${url}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/plain',
|
||||||
|
'X-Return-Format': 'markdown',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// 尝试使用备用方法
|
||||||
|
return await fallbackFetch(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await response.text();
|
||||||
|
|
||||||
|
// 解析标题(Jina Reader 返回的内容通常以标题开头)
|
||||||
|
const titleMatch = content.match(/^#\s*(.+)$/m);
|
||||||
|
const title = titleMatch ? titleMatch[1] : extractTitleFromUrl(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
title,
|
||||||
|
content: content.trim(),
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Web fetch error:', error);
|
||||||
|
// 尝试备用方法
|
||||||
|
return await fallbackFetch(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备用获取方法 - 直接获取 HTML 并提取文本
|
||||||
|
*/
|
||||||
|
async function fallbackFetch(url: string): Promise<WebFetchResponse> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (compatible; CCHCode-UI/1.0; +https://github.com/cchcode)',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `无法访问该网页: ${response.status} ${response.statusText}`,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
// 提取标题
|
||||||
|
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||||
|
const title = titleMatch ? titleMatch[1].trim() : extractTitleFromUrl(url);
|
||||||
|
|
||||||
|
// 简单提取正文内容(移除脚本、样式和HTML标签)
|
||||||
|
const content = extractTextFromHtml(html);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
title,
|
||||||
|
content: content.slice(0, 10000), // 限制内容长度
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fallback fetch error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '获取网页内容失败',
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 URL 格式
|
||||||
|
*/
|
||||||
|
function isValidUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return ['http:', 'https:'].includes(parsed.protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 URL 提取标题
|
||||||
|
*/
|
||||||
|
function extractTitleFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.hostname;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 HTML 提取纯文本
|
||||||
|
*/
|
||||||
|
function extractTextFromHtml(html: string): string {
|
||||||
|
// 移除脚本和样式
|
||||||
|
let text = html
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<noscript[^>]*>[\s\S]*?<\/noscript>/gi, '')
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, '');
|
||||||
|
|
||||||
|
// 保留一些结构化标签
|
||||||
|
text = text
|
||||||
|
.replace(/<h[1-6][^>]*>/gi, '\n\n## ')
|
||||||
|
.replace(/<\/h[1-6]>/gi, '\n')
|
||||||
|
.replace(/<p[^>]*>/gi, '\n')
|
||||||
|
.replace(/<\/p>/gi, '\n')
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<li[^>]*>/gi, '\n- ')
|
||||||
|
.replace(/<\/li>/gi, '')
|
||||||
|
.replace(/<[^>]+>/g, ' ');
|
||||||
|
|
||||||
|
// 清理空白字符
|
||||||
|
text = text
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/\n\s*\n/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化网页获取结果为文本(完整版 - 发送给 AI)
|
||||||
|
*/
|
||||||
|
export function formatFetchResult(response: WebFetchResponse): string {
|
||||||
|
if (!response.success) {
|
||||||
|
return `❌ 获取失败: ${response.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
if (response.title) {
|
||||||
|
result += `# ${response.title}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.url) {
|
||||||
|
result += `**来源**: ${response.url}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.content) {
|
||||||
|
result += `---\n\n${response.content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result || '获取完成,但页面无内容';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化网页获取结果为简短文本(显示给用户)
|
||||||
|
*/
|
||||||
|
export function formatFetchResultShort(response: WebFetchResponse): string {
|
||||||
|
if (!response.success) {
|
||||||
|
return `❌ 网页获取失败: ${response.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取网站域名
|
||||||
|
let domain = '';
|
||||||
|
if (response.url) {
|
||||||
|
try {
|
||||||
|
const url = new URL(response.url);
|
||||||
|
domain = url.hostname.replace('www.', '');
|
||||||
|
} catch {
|
||||||
|
domain = response.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算内容字数
|
||||||
|
const contentLength = response.content?.length || 0;
|
||||||
|
const contentDesc = contentLength > 1000
|
||||||
|
? `${Math.round(contentLength / 1000)}k 字符`
|
||||||
|
: `${contentLength} 字符`;
|
||||||
|
|
||||||
|
let result = `🌐 已获取网页内容`;
|
||||||
|
|
||||||
|
if (response.title) {
|
||||||
|
result += `\n📄 标题: ${response.title.slice(0, 50)}${response.title.length > 50 ? '...' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain) {
|
||||||
|
result += `\n📎 来源: ${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += `\n📊 内容: ${contentDesc}`;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
138
src/services/tools/webSearch.ts
Normal file
138
src/services/tools/webSearch.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Web Search 工具服务
|
||||||
|
* 使用 Tavily API 实现网络搜索
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
content: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSearchResponse {
|
||||||
|
success: boolean;
|
||||||
|
answer?: string;
|
||||||
|
results?: SearchResult[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSearchInput {
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行网络搜索
|
||||||
|
*/
|
||||||
|
export async function webSearch(input: WebSearchInput): Promise<WebSearchResponse> {
|
||||||
|
const apiKey = process.env.TAVILY_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Tavily API key not configured',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.tavily.com/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
api_key: apiKey,
|
||||||
|
query: input.query,
|
||||||
|
search_depth: 'basic',
|
||||||
|
include_answer: true,
|
||||||
|
include_raw_content: false,
|
||||||
|
max_results: 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Tavily API error:', errorText);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Search API error: ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
answer: data.answer,
|
||||||
|
results: data.results?.map((r: { title: string; url: string; content: string; score: number }) => ({
|
||||||
|
title: r.title,
|
||||||
|
url: r.url,
|
||||||
|
content: r.content,
|
||||||
|
score: r.score,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Web search error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化搜索结果为文本(完整版 - 发送给 AI)
|
||||||
|
*/
|
||||||
|
export function formatSearchResults(response: WebSearchResponse): string {
|
||||||
|
if (!response.success) {
|
||||||
|
return `搜索失败: ${response.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
if (response.answer) {
|
||||||
|
result += `## 搜索摘要\n${response.answer}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.results && response.results.length > 0) {
|
||||||
|
result += `## 搜索结果\n\n`;
|
||||||
|
response.results.forEach((item, index) => {
|
||||||
|
result += `### ${index + 1}. ${item.title}\n`;
|
||||||
|
result += `**链接**: ${item.url}\n`;
|
||||||
|
result += `${item.content}\n\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result || '未找到相关结果';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化搜索结果为简短文本(显示给用户)
|
||||||
|
*/
|
||||||
|
export function formatSearchResultsShort(response: WebSearchResponse, query: string): string {
|
||||||
|
if (!response.success) {
|
||||||
|
return `搜索失败: ${response.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultCount = response.results?.length || 0;
|
||||||
|
|
||||||
|
// 提取来源网站并生成 Markdown 链接(每个链接单独一行避免 autolink 合并)
|
||||||
|
const sourceLinks = response.results?.slice(0, 3).map((r, index) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(r.url);
|
||||||
|
const hostname = url.hostname.replace('www.', '');
|
||||||
|
// 使用序号 + Markdown 链接格式
|
||||||
|
return `${index + 1}. [${hostname}](${r.url})`;
|
||||||
|
} catch {
|
||||||
|
return `${index + 1}. ${r.title.slice(0, 20)}`;
|
||||||
|
}
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
let result = `> 🔍 已搜索「${query}」,找到 ${resultCount} 个相关结果`;
|
||||||
|
|
||||||
|
if (sourceLinks.length > 0) {
|
||||||
|
result += `\n\n**🔗 来源:**\n${sourceLinks.join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user