feat(services): 添加 AI 工具服务实现

- codeExecution: 代码执行工具定义
- webFetch: 网页抓取工具定义
- webSearch: 网络搜索工具定义
- executor: 工具执行器统一处理
This commit is contained in:
gaoziman 2025-12-18 11:31:10 +08:00
parent e4cdcc5141
commit ab9dd5aff8
5 changed files with 742 additions and 0 deletions

View 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);
}

View 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: '🌐',
},
];
}

View File

@ -0,0 +1,8 @@
/**
*
*/
export * from './webSearch';
export * from './codeExecution';
export * from './webFetch';
export * from './executor';

View 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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;
}

View 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;
}