import { createErrorResponse, ToolResult } from '@/common/tool-handler'; import { BaseBrowserToolExecutor } from '../base-browser'; import { TOOL_NAMES } from 'chrome-mcp-shared'; import { cdpSessionManager } from '@/utils/cdp-session-manager'; import { consoleBuffer, BufferedConsoleMessage, BufferedConsoleException } from './console-buffer'; const DEFAULT_MAX_MESSAGES = 100; type ConsoleMode = 'snapshot' | 'buffer'; interface ConsoleToolParams { url?: string; tabId?: number; background?: boolean; windowId?: number; includeExceptions?: boolean; maxMessages?: number; // 新增参数 mode?: ConsoleMode; buffer?: boolean; // mode="buffer" 的别名 clear?: boolean; // 读取前清空 clearAfterRead?: boolean; // 读取后清空(mcp-tools.js 风格) pattern?: string; onlyErrors?: boolean; limit?: number; } interface ConsoleMessage { timestamp: number; level: string; text: string; args?: any[]; argsSerialized?: any[]; source?: string; url?: string; lineNumber?: number; stackTrace?: any; } interface ConsoleException { timestamp: number; text: string; url?: string; lineNumber?: number; columnNumber?: number; stackTrace?: any; } interface ConsoleResult { success: boolean; message: string; tabId: number; tabUrl: string; tabTitle: string; captureStartTime: number; captureEndTime: number; totalDurationMs: number; messages: ConsoleMessage[]; exceptions: ConsoleException[]; messageCount: number; exceptionCount: number; messageLimitReached: boolean; droppedMessageCount: number; droppedExceptionCount: number; } // 辅助函数 function normalizeLimit(value: unknown, fallback: number): number { const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback; return Math.max(0, n); } function parseRegexPattern(pattern?: string): RegExp | undefined { if (typeof pattern !== 'string') return undefined; const trimmed = pattern.trim(); if (!trimmed) return undefined; // 支持 /pattern/flags 语法 const match = trimmed.match(/^\/(.+)\/([gimsuy]*)$/); try { return match ? new RegExp(match[1], match[2]) : new RegExp(trimmed); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); throw new Error(`Invalid regex pattern: ${msg}`); } } function matchesPattern(pattern: RegExp, text: string): boolean { pattern.lastIndex = 0; return pattern.test(text); } function isErrorLevel(level?: string): boolean { const normalized = (level || '').toLowerCase(); return normalized === 'error' || normalized === 'assert'; } function applyResultFilters( result: ConsoleResult, options: { pattern?: RegExp; onlyErrors?: boolean; includeExceptions: boolean }, ): ConsoleResult { const { pattern, onlyErrors = false, includeExceptions } = options; let messages = result.messages; if (onlyErrors) { messages = messages.filter((m) => isErrorLevel(m.level)); } if (pattern) { messages = messages.filter((m) => matchesPattern(pattern, m.text || '')); } let exceptions = includeExceptions ? result.exceptions : []; if (includeExceptions && pattern) { exceptions = exceptions.filter((e) => matchesPattern(pattern, e.text || '')); } return { ...result, messages, exceptions, messageCount: messages.length, exceptionCount: exceptions.length, }; } function isDebuggerConflictError(error: unknown): boolean { const msg = (error instanceof Error ? error.message : String(error)).toLowerCase(); return msg.includes('debugger is already attached') || msg.includes('another client'); } function formatDebuggerConflictMessage(tabId: number, originalMessage: string): string { return ( `Failed to attach Chrome Debugger to tab ${tabId}: another debugger client is already attached ` + `(likely DevTools or another extension). Close DevTools for this tab or disable the conflicting extension, ` + `then retry. Original error: ${originalMessage}` ); } /** * Tool for capturing console output from browser tabs */ class ConsoleTool extends BaseBrowserToolExecutor { name = TOOL_NAMES.BROWSER.CONSOLE; async execute(args: ConsoleToolParams): Promise { const { url, tabId, windowId, background = false, includeExceptions = true, maxMessages = DEFAULT_MAX_MESSAGES, mode = 'snapshot', buffer, clear = false, clearAfterRead = false, pattern, onlyErrors = false, limit, } = args; let targetTab: chrome.tabs.Tab; let targetTabId: number | undefined; // 解析正则表达式 let compiledPattern: RegExp | undefined; try { compiledPattern = parseRegexPattern(pattern); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); return createErrorResponse(msg); } try { if (typeof tabId === 'number') { // Use explicit tab const t = await chrome.tabs.get(tabId); if (!t?.id) return createErrorResponse('Failed to identify target tab.'); targetTab = t; } else if (url) { // Navigate to the specified URL targetTab = await this.navigateToUrl(url, background === true, windowId); } else { // Use current active tab const [activeTab] = typeof windowId === 'number' ? await chrome.tabs.query({ active: true, windowId }) : await chrome.tabs.query({ active: true, currentWindow: true }); if (!activeTab?.id) { return createErrorResponse('No active tab found and no URL provided.'); } targetTab = activeTab; } if (!targetTab?.id) { return createErrorResponse('Failed to identify target tab.'); } targetTabId = targetTab.id; // 确定模式:buffer 参数是 mode="buffer" 的别名 const resolvedMode: ConsoleMode = mode === 'buffer' || buffer === true ? 'buffer' : 'snapshot'; // 计算有效的消息限制 const normalizedMaxMessages = normalizeLimit(maxMessages, DEFAULT_MAX_MESSAGES); const effectiveLimit = typeof limit === 'number' ? normalizeLimit(limit, normalizedMaxMessages) : normalizedMaxMessages; // Buffer 模式 if (resolvedMode === 'buffer') { try { await consoleBuffer.ensureStarted(targetTabId); } catch (error: unknown) { const msg = error instanceof Error ? error.message : String(error); if (isDebuggerConflictError(error)) { return createErrorResponse(formatDebuggerConflictMessage(targetTabId, msg)); } throw error; } // 处理读取前清空请求 let clearedBefore: { clearedMessages: number; clearedExceptions: number } | null = null; if (clear === true) { clearedBefore = consoleBuffer.clear(targetTabId, 'manual'); } // 读取缓冲区 const read = consoleBuffer.read(targetTabId, { pattern: compiledPattern, onlyErrors, limit: effectiveLimit, includeExceptions, }); if (!read) { return createErrorResponse('Console buffer is not available for this tab.'); } // 处理读取后清空请求(mcp-tools.js 风格,避免重复读取) let clearedAfter: { clearedMessages: number; clearedExceptions: number } | null = null; if (clearAfterRead === true) { clearedAfter = consoleBuffer.clear(targetTabId, 'manual'); } // 构建清空摘要 let clearedSummary = ''; if (clearedBefore) { clearedSummary += ` Cleared ${clearedBefore.clearedMessages} messages and ${clearedBefore.clearedExceptions} exceptions before reading.`; } if (clearedAfter) { clearedSummary += ` Cleared ${clearedAfter.clearedMessages} messages and ${clearedAfter.clearedExceptions} exceptions after reading.`; } const result: ConsoleResult = { success: true, message: `Console buffer read for tab ${targetTabId}.` + clearedSummary + ` Returned ${read.messageCount} messages and ${read.exceptionCount} exceptions.`, tabId: targetTabId, tabUrl: read.tabUrl || '', tabTitle: read.tabTitle || '', captureStartTime: read.captureStartTime, captureEndTime: read.captureEndTime, totalDurationMs: read.totalDurationMs, messages: read.messages as ConsoleMessage[], exceptions: read.exceptions as ConsoleException[], messageCount: read.messageCount, exceptionCount: read.exceptionCount, messageLimitReached: read.messageLimitReached, droppedMessageCount: read.droppedMessageCount, droppedExceptionCount: read.droppedExceptionCount, }; return { content: [{ type: 'text', text: JSON.stringify(result) }], isError: false, }; } // Snapshot 模式(一次性捕获) const result = await this.captureConsoleMessages(targetTabId, { includeExceptions, maxMessages: effectiveLimit, }); // 应用过滤器 const filtered = applyResultFilters(result, { pattern: compiledPattern, onlyErrors, includeExceptions, }); return { content: [{ type: 'text', text: JSON.stringify(filtered) }], isError: false, }; } catch (error: unknown) { console.error('ConsoleTool: Critical error during execute:', error); const msg = error instanceof Error ? error.message : String(error); if (typeof targetTabId === 'number' && isDebuggerConflictError(error)) { return createErrorResponse(formatDebuggerConflictMessage(targetTabId, msg)); } return createErrorResponse(`Error in ConsoleTool: ${msg}`); } } private async navigateToUrl( url: string, background = false, windowId?: number, ): Promise { // Check if URL is already open const existingTabs = await chrome.tabs.query({ url }); if (existingTabs.length > 0 && existingTabs[0]?.id) { const tab = existingTabs[0]; if (!background) { // Activate the existing tab await chrome.tabs.update(tab.id!, { active: true }); await chrome.windows.update(tab.windowId, { focused: true }); } return tab; } else { // Create new tab with the URL const createInfo: chrome.tabs.CreateProperties = { url, active: background ? false : true }; if (typeof windowId === 'number') createInfo.windowId = windowId; const newTab = await chrome.tabs.create(createInfo); // Wait for tab to be ready await this.waitForTabReady(newTab.id!); return newTab; } } private async waitForTabReady(tabId: number): Promise { return new Promise((resolve) => { const checkTab = async () => { try { const tab = await chrome.tabs.get(tabId); if (tab.status === 'complete') { resolve(); } else { setTimeout(checkTab, 100); } } catch (error) { // Tab might be closed, resolve anyway resolve(); } }; checkTab(); }); } private formatConsoleArgs(args: any[]): string { if (!args || args.length === 0) return ''; return args .map((arg) => { if (arg.type === 'string') { return arg.value || ''; } else if (arg.type === 'number') { return String(arg.value || ''); } else if (arg.type === 'boolean') { return String(arg.value || ''); } else if (arg.type === 'object') { return arg.description || '[Object]'; } else if (arg.type === 'undefined') { return 'undefined'; } else if (arg.type === 'function') { return arg.description || '[Function]'; } else { return arg.description || arg.value || String(arg); } }) .join(' '); } private async captureConsoleMessages( tabId: number, options: { includeExceptions: boolean; maxMessages: number; }, ): Promise { const { includeExceptions, maxMessages } = options; const startTime = Date.now(); const messages: ConsoleMessage[] = []; const exceptions: ConsoleException[] = []; let limitReached = false; try { // Get tab information const tab = await chrome.tabs.get(tabId); // Attach via shared manager await cdpSessionManager.attach(tabId, 'console'); // Set up event listener to collect messages const collectedMessages: any[] = []; const collectedExceptions: any[] = []; const eventListener = (source: chrome.debugger.Debuggee, method: string, params?: any) => { if (source.tabId !== tabId) return; if (method === 'Log.entryAdded' && params?.entry) { collectedMessages.push(params.entry); } else if (method === 'Runtime.consoleAPICalled' && params) { // Convert Runtime.consoleAPICalled to Log.entryAdded format const logEntry = { timestamp: params.timestamp, level: params.type || 'log', text: this.formatConsoleArgs(params.args || []), source: 'console-api', url: params.stackTrace?.callFrames?.[0]?.url, lineNumber: params.stackTrace?.callFrames?.[0]?.lineNumber, stackTrace: params.stackTrace, args: params.args, }; collectedMessages.push(logEntry); } else if ( method === 'Runtime.exceptionThrown' && includeExceptions && params?.exceptionDetails ) { collectedExceptions.push(params.exceptionDetails); } }; chrome.debugger.onEvent.addListener(eventListener); try { // Enable Runtime domain first to capture console API calls and exceptions await cdpSessionManager.sendCommand(tabId, 'Runtime.enable'); // Also enable Log domain to capture other log entries await cdpSessionManager.sendCommand(tabId, 'Log.enable'); // Wait for all messages to be flushed await new Promise((resolve) => setTimeout(resolve, 2000)); // Process collected messages // Helper to deeply serialize console arguments when possible const serializeArg = async (arg: any): Promise => { try { if (!arg) return arg; if (Object.prototype.hasOwnProperty.call(arg, 'unserializableValue')) { return arg.unserializableValue; } if (Object.prototype.hasOwnProperty.call(arg, 'value')) { return arg.value; } if (arg.objectId) { const resp = await cdpSessionManager.sendCommand(tabId, 'Runtime.callFunctionOn', { objectId: arg.objectId, functionDeclaration: 'function(maxDepth, maxProps){\n' + ' const seen=new WeakSet();\n' + ' function S(v,d){\n' + ' try{\n' + ' if(d<0) return "[MaxDepth]";\n' + ' if(v===null) return null;\n' + ' const t=typeof v;\n' + ' if(t!=="object"){\n' + ' if(t==="bigint") return v.toString()+"n";\n' + ' return v;\n' + ' }\n' + ' if(seen.has(v)) return "[Circular]";\n' + ' seen.add(v);\n' + ' if(Array.isArray(v)){\n' + ' const out=[];\n' + ' for(let i=0;i=maxProps){ out.push("[...truncated]"); break; }\n' + ' out.push(S(v[i], d-1));\n' + ' }\n' + ' return out;\n' + ' }\n' + ' if(v instanceof Date) return {__type:"Date", value:v.toISOString()};\n' + ' if(v instanceof RegExp) return {__type:"RegExp", value:String(v)};\n' + ' if(v instanceof Map){\n' + ' const out={__type:"Map", entries:[]}; let c=0;\n' + ' for(const [k,val] of v.entries()){\n' + ' if(c++>=maxProps){ out.entries.push(["[...truncated]","[...truncated]"]); break; }\n' + ' out.entries.push([S(k,d-1), S(val,d-1)]);\n' + ' }\n' + ' return out;\n' + ' }\n' + ' if(v instanceof Set){\n' + ' const out={__type:"Set", values:[]}; let c=0;\n' + ' for(const val of v.values()){\n' + ' if(c++>=maxProps){ out.values.push("[...truncated]"); break; }\n' + ' out.values.push(S(val,d-1));\n' + ' }\n' + ' return out;\n' + ' }\n' + ' const out={}; let c=0;\n' + ' for(const key in v){\n' + ' if(c++>=maxProps){ out.__truncated__=true; break; }\n' + ' try{ out[key]=S(v[key], d-1); }catch(e){ out[key]="[Thrown]"; }\n' + ' }\n' + ' return out;\n' + ' }catch(e){ return "[Unserializable]" }\n' + ' }\n' + ' return S(this, maxDepth);\n' + '}', arguments: [{ value: 3 }, { value: 100 }], silent: true, returnByValue: true, }); return resp?.result?.value ?? '[Unavailable]'; } return '[Unknown]'; } catch (e) { return '[SerializeError]'; } }; for (const entry of collectedMessages) { if (messages.length >= maxMessages) { limitReached = true; break; } const message: ConsoleMessage = { timestamp: entry.timestamp, level: entry.level || 'log', text: entry.text || '', source: entry.source, url: entry.url, lineNumber: entry.lineNumber, }; if (entry.stackTrace) { message.stackTrace = entry.stackTrace; } if (entry.args && Array.isArray(entry.args)) { message.args = entry.args; // Attempt deep serialization for better fidelity const serialized: any[] = []; for (const a of entry.args) { serialized.push(await serializeArg(a)); } message.argsSerialized = serialized; } messages.push(message); } // Process collected exceptions for (const exceptionDetails of collectedExceptions) { const exception: ConsoleException = { timestamp: Date.now(), text: exceptionDetails.text || exceptionDetails.exception?.description || 'Unknown exception', url: exceptionDetails.url, lineNumber: exceptionDetails.lineNumber, columnNumber: exceptionDetails.columnNumber, }; if (exceptionDetails.stackTrace) { exception.stackTrace = exceptionDetails.stackTrace; } exceptions.push(exception); } } finally { // Clean up chrome.debugger.onEvent.removeListener(eventListener); // 如果 buffer 模式正在使用这个 tab,不要关闭 Runtime/Log 域 const keepDomainsEnabled = consoleBuffer.isCapturing(tabId); if (!keepDomainsEnabled) { try { await cdpSessionManager.sendCommand(tabId, 'Runtime.disable'); } catch (e) { console.warn(`ConsoleTool: Error disabling Runtime for tab ${tabId}:`, e); } try { await cdpSessionManager.sendCommand(tabId, 'Log.disable'); } catch (e) { console.warn(`ConsoleTool: Error disabling Log for tab ${tabId}:`, e); } } try { await cdpSessionManager.detach(tabId, 'console'); } catch (e) { console.warn(`ConsoleTool: Error detaching debugger for tab ${tabId}:`, e); } } const endTime = Date.now(); // Sort messages by timestamp messages.sort((a, b) => a.timestamp - b.timestamp); exceptions.sort((a, b) => a.timestamp - b.timestamp); return { success: true, message: `Console capture completed for tab ${tabId}. ${messages.length} messages, ${exceptions.length} exceptions captured.`, tabId, tabUrl: tab.url || '', tabTitle: tab.title || '', captureStartTime: startTime, captureEndTime: endTime, totalDurationMs: endTime - startTime, messages, exceptions, messageCount: messages.length, exceptionCount: exceptions.length, messageLimitReached: limitReached, droppedMessageCount: 0, droppedExceptionCount: 0, }; } catch (error: any) { console.error(`ConsoleTool: Error capturing console messages for tab ${tabId}:`, error); throw error; } } } export const consoleTool = new ConsoleTool();