diff --git a/src/main/browser.ts b/src/main/browser.ts index 5025de5..8672298 100644 --- a/src/main/browser.ts +++ b/src/main/browser.ts @@ -37,13 +37,35 @@ function getAgentBrowser(): BrowserWindow { return agentBrowser; } +/** 页面加载超时(毫秒) */ +const PAGE_LOAD_TIMEOUT = 30_000; + /** 打开 URL */ export async function browserOpen(url: string): Promise<{ success: boolean; title?: string; url?: string; error?: string }> { try { const win = getAgentBrowser(); sendLog('info', `🌐 browser 打开`, url.slice(0, 100)); - await win.loadURL(url); - await new Promise(r => setTimeout(r, 1000)); + + // 等待页面实际加载完成,而非固定延时 + const loadPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + win.webContents.removeListener('did-finish-load', onFinish); + reject(new Error(`页面加载超时 (${PAGE_LOAD_TIMEOUT / 1000}s)`)); + }, PAGE_LOAD_TIMEOUT); + + const onFinish = () => { + clearTimeout(timeout); + resolve(); + }; + win.webContents.once('did-finish-load', onFinish); + win.loadURL(url).catch((err) => { + clearTimeout(timeout); + win.webContents.removeListener('did-finish-load', onFinish); + reject(err); + }); + }); + + await loadPromise; const title = win.webContents.getTitle(); sendLog('success', `🌐 browser 已加载`, title); return { success: true, title, url: win.webContents.getURL() }; @@ -144,7 +166,15 @@ export async function browserClick(selector: string): Promise<{ success: boolean } const win = agentBrowser!; sendLog('info', `🌐 browser 点击`, selector); - await win.webContents.executeJavaScript(`document.querySelector('${selector.replace(/'/g, "\\'")}').click()`, true); + // 安全注入:通过 JSON.stringify 防止选择器中的 JS 注入 + await win.webContents.executeJavaScript(` + (() => { + const sel = ${JSON.stringify(selector)}; + const el = document.querySelector(sel); + if (!el) throw new Error('元素未找到: ' + sel); + el.click(); + })() + `, true); await new Promise(r => setTimeout(r, 500)); sendLog('success', `🌐 browser 点击完成`, selector); return { success: true }; @@ -161,15 +191,16 @@ export async function browserType(selector: string, text: string): Promise<{ suc return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; } const win = agentBrowser!; - const escapedSelector = selector.replace(/'/g, "\\'"); - const escapedText = text.replace(/'/g, "\\'").replace(/\n/g, "\\n"); sendLog('info', `🌐 browser 输入`, `${selector}: ${text.slice(0, 50)}`); + // 安全注入:通过 JSON.stringify 防止选择器和文本中的 JS 注入 await win.webContents.executeJavaScript(` (() => { - const el = document.querySelector('${escapedSelector}'); - if (!el) throw new Error('元素未找到: ${escapedSelector}'); + const sel = ${JSON.stringify(selector)}; + const val = ${JSON.stringify(text)}; + const el = document.querySelector(sel); + if (!el) throw new Error('元素未找到: ' + sel); el.focus(); - el.value = '${escapedText}'; + el.value = val; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })() diff --git a/src/main/tool-handlers-system.ts b/src/main/tool-handlers-system.ts index 6f4535e..86afc87 100644 --- a/src/main/tool-handlers-system.ts +++ b/src/main/tool-handlers-system.ts @@ -118,6 +118,69 @@ export function killToolProcess(): boolean { +/** HTTP 请求超时(毫秒) */ +const HTTP_TIMEOUT = 15_000; + +/** 完整 HTML 实体映射(常见实体) */ +const HTML_ENTITIES: Record = { + ' ': ' ', '<': '<', '>': '>', '&': '&', '"': '"', + ''': "'", ''': "'", ' ': ' ', ' ': ' ', + '©': '\u00A9', '®': '\u00AE', '™': '\u2122', '€': '\u20AC', + '£': '\u00A3', '¥': '\u00A5', '°': '\u00B0', '·': '\u00B7', + '…': '\u2026', '—': '\u2014', '–': '\u2013', + '‘': '\u2018', '’': '\u2019', '“': '\u201C', '”': '\u201D', + '•': '\u2022', + '×': '\u00D7', '÷': '\u00F7', '±': '\u00B1', 'µ': '\u00B5', + '¶': '\u00B6', '§': '\u00A7', '«': '\u00AB', '»': '\u00BB', + '¡': '\u00A1', '¿': '\u00BF', '¬': '\u00AC', '­': '\u00AD', + '¯': '\u00AF', '´': '\u00B4', '¸': '\u00B8', + 'Œ': '\u0152', 'œ': '\u0153', 'Š': '\u0160', 'š': '\u0161', + 'Ÿ': '\u0178', 'ˆ': '\u02C6', '˜': '\u02DC', +}; + +/** 解码 HTML 实体 */ +function decodeHTMLEntities(text: string): string { + let result = text; + for (const [entity, char] of Object.entries(HTML_ENTITIES)) { + result = result.replaceAll(entity, char); + } + // 数字实体: { 和  + result = result.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))); + result = result.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10))); + return result; +} + +/** 将 HTML 转换为可读文本(保留结构) */ +function htmlToText(html: string): string { + let text = html; + // 移除 script/style/nav/header/footer 等噪音标签及其内容 + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + // 移除 HTML 注释 + text = text.replace(//g, ''); + // 块级标签转为换行 + text = text.replace(/<\/(p|div|h[1-6]|li|tr|blockquote|section|article|pre|br|hr)[^>]*>/gi, '\n'); + text = text.replace(/<(br|hr)[^>]*\/?>/gi, '\n'); + // 表格单元用制表符分隔 + text = text.replace(/<\/(td|th)[^>]*>/gi, '\t'); + // 移除剩余标签 + text = text.replace(/<[^>]+>/g, ''); + // 解码 HTML 实体 + text = decodeHTMLEntities(text); + // 清理多余空白(保留换行结构) + text = text.replace(/[ \t]+/g, ' '); + text = text.replace(/\n\s*\n\s*\n+/g, '\n\n'); + text = text.split('\n').map(l => l.trim()).join('\n'); + return text.trim(); +} + export async function handleWebFetch(params: { url: string; max_chars?: number; extract_mode?: string }): Promise { try { const url = params.url; @@ -128,13 +191,24 @@ export async function handleWebFetch(params: { url: string; max_chars?: number; const maxChars = params.max_chars || 0; // 0 = 不截断,返回全部内容 sendLog('info', `🌐 web_fetch`, `${url} (${maxChars > 0 ? maxChars + ' chars' : '完整内容'})`); - const resp = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' - } - }); + + // 带超时的 fetch + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), HTTP_TIMEOUT); + let resp: Response; + try { + resp = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' + }, + signal: controller.signal, + redirect: 'follow', + }); + } finally { + clearTimeout(timeoutId); + } if (!resp.ok) { return { success: false, error: `HTTP ${resp.status}: ${resp.statusText}` }; @@ -145,20 +219,17 @@ export async function handleWebFetch(params: { url: string; max_chars?: number; return { success: false, error: `不支持的内容类型: ${contentType}` }; } + // 预检查内容长度,避免下载过大的页面 + const contentLength = resp.headers.get('content-length'); + if (contentLength && parseInt(contentLength, 10) > 10 * 1024 * 1024) { + return { success: false, error: `页面过大 (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB),超过 10MB 限制` }; + } + let text = await resp.text(); - // 简单 HTML → 文本提取 + // HTML → 可读文本提取 if (contentType.includes('html')) { - text = text - .replace(//gi, '') - .replace(//gi, '') - .replace(/<[^>]+>/g, ' ') - .replace(/ /g, ' ') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/\s+/g, ' ') - .trim(); + text = htmlToText(text); } const truncated = maxChars > 0 && text.length > maxChars; @@ -175,7 +246,11 @@ export async function handleWebFetch(params: { url: string; max_chars?: number; length: text.length }; } catch (err) { - return { success: false, error: (err as Error).message }; + const errMsg = (err as Error).message; + if (errMsg.includes('abort')) { + return { success: false, error: `请求超时 (${HTTP_TIMEOUT / 1000}s)` }; + } + return { success: false, error: errMsg }; } } @@ -200,16 +275,27 @@ export async function handleWebSearch(params: { query: string; max_results?: num 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' }; + /** 带超时的搜索请求 */ + async function fetchWithTimeout(url: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), HTTP_TIMEOUT); + try { + const resp = await fetch(url, { headers: searchHeaders, signal: controller.signal }); + clearTimeout(timeoutId); + return resp; + } catch { + clearTimeout(timeoutId); + return null; + } + } + // 1. Bing 搜索(首选) let results: Array<{ title: string; url: string; snippet: string }> = []; try { const bingUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${maxResults}`; - const resp = await fetch(bingUrl, { - headers: searchHeaders, - }); - - if (resp.ok) { + const resp = await fetchWithTimeout(bingUrl); + if (resp?.ok) { const html = await resp.text(); results = parseBingResults(html, maxResults); } @@ -221,11 +307,8 @@ export async function handleWebSearch(params: { query: string; max_results?: num if (results.length === 0) { try { const baiduUrl = `https://www.baidu.com/s?wd=${encodeURIComponent(query)}&rn=${maxResults}`; - const resp = await fetch(baiduUrl, { - headers: searchHeaders, - }); - - if (resp.ok) { + const resp = await fetchWithTimeout(baiduUrl); + if (resp?.ok) { const html = await resp.text(); results = parseBaiduResults(html, maxResults); } @@ -238,11 +321,8 @@ export async function handleWebSearch(params: { query: string; max_results?: num if (results.length === 0) { try { const googleUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${maxResults}`; - const resp = await fetch(googleUrl, { - headers: searchHeaders, - }); - - if (resp.ok) { + const resp = await fetchWithTimeout(googleUrl); + if (resp?.ok) { const html = await resp.text(); results = parseGoogleResults(html, maxResults); } @@ -255,20 +335,37 @@ export async function handleWebSearch(params: { query: string; max_results?: num return { success: false, error: '未找到搜索结果,请尝试其他关键词' }; } + // URL 去重:同一 URL 只保留第一个结果 + const seenUrls = new Set(); + const deduped: typeof results = []; + for (const r of results) { + const normalizedUrl = r.url.replace(/\/+$/, '').toLowerCase(); + if (!seenUrls.has(normalizedUrl)) { + seenUrls.add(normalizedUrl); + deduped.push(r); + } + } + + // 清理摘要中的残留 HTML 标签和多余空白 + for (const r of deduped) { + r.title = r.title.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); + r.snippet = r.snippet.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); + } + // 构建格式化的搜索结果(附带当前日期) const now = new Date(); const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`; - const formatted = `[当前日期: ${dateStr}(搜索时的实时日期)]\n\n` + results.map((r, i) => + const formatted = `[当前日期: ${dateStr}(搜索时的实时日期)]\n\n` + deduped.map((r, i) => `[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}` ).join('\n\n'); - sendLog('success', `🔍 web_search 完成`, `"${query}" → ${results.length} 条结果`); + sendLog('success', `🔍 web_search 完成`, `"${query}" → ${deduped.length} 条结果`); return { success: true, query, - results, - total: results.length, + results: deduped, + total: deduped.length, formatted }; } catch (err) { @@ -375,17 +472,9 @@ function parseGoogleResults(html: string, maxResults: number): Array<{ title: st return results; } -/** 简单 HTML 实体解码 */ +/** 简单 HTML 实体解码(用于搜索结果解析) */ function decodeHTML(text: string): string { - return text - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/ /g, ' ') - .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))) - .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10))); + return decodeHTMLEntities(text); } export async function handleDownloadFile(params: { url: string; destination: string }): Promise { @@ -399,7 +488,17 @@ export async function handleDownloadFile(params: { url: string; destination: str } sendLog('info', `⬇️ download_file`, `${params.url} → ${destPath}`); - const resp = await fetch(params.url); + + // 带超时的下载(大文件给更长超时) + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 60_000); + let resp: Response; + try { + resp = await fetch(params.url, { signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } + if (!resp.ok) return { success: false, error: `HTTP ${resp.status}: ${resp.statusText}` }; const buf = Buffer.from(await resp.arrayBuffer()); @@ -420,8 +519,11 @@ export async function handleDownloadFile(params: { url: string; destination: str content_type: resp.headers.get('content-type') || 'unknown' }; } catch (err) { - sendLog('error', `⬇️ download_file 失败`, (err as Error).message); - return { success: false, error: (err as Error).message }; + const errMsg = (err as Error).message; + if (errMsg.includes('abort')) { + return { success: false, error: '下载超时 (60s)' }; + } + return { success: false, error: errMsg }; } } diff --git a/src/renderer/services/tool-registry.ts b/src/renderer/services/tool-registry.ts index 143a83a..c82a987 100644 --- a/src/renderer/services/tool-registry.ts +++ b/src/renderer/services/tool-registry.ts @@ -4,7 +4,7 @@ */ import type { ToolDefinition, ToolResult } from '../types.js'; -import { logToolStart, logToolResult, logError, logInfo } from './log-service.js'; +import { logToolStart, logToolResult, logError, logInfo, logWarn } from './log-service.js'; import { getMCPToolDefinitions } from './mcp-client.js'; export const TOOL_DEFINITIONS: ToolDefinition[] = [