mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-21 12:43:18 +08:00
feat: add performance tool
This commit is contained in:
@@ -269,20 +269,42 @@ class CloseTabsTool extends BaseBrowserToolExecutor {
|
||||
// If URL is provided, close all tabs matching that URL
|
||||
if (urlPattern) {
|
||||
console.log(`Searching for tabs with URL: ${url}`);
|
||||
if (!urlPattern.endsWith('/')) {
|
||||
urlPattern += '/*';
|
||||
try {
|
||||
// Build a proper Chrome match pattern from a concrete URL.
|
||||
// If caller already provided a match pattern with '*', use as-is.
|
||||
if (!urlPattern.includes('*')) {
|
||||
// Ignore search/hash; match by origin + pathname prefix.
|
||||
// Use URL to normalize; fallback to simple suffixing when parsing fails.
|
||||
try {
|
||||
const u = new URL(urlPattern);
|
||||
const basePath = u.pathname || '/';
|
||||
const pathWithWildcard = basePath.endsWith('/') ? `${basePath}*` : `${basePath}/*`;
|
||||
urlPattern = `${u.protocol}//${u.host}${pathWithWildcard}`;
|
||||
} catch {
|
||||
// Not a fully-qualified URL; ensure it ends with wildcard
|
||||
urlPattern = urlPattern.endsWith('/') ? `${urlPattern}*` : `${urlPattern}/*`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: ensure we have some wildcard
|
||||
urlPattern = urlPattern.endsWith('*')
|
||||
? urlPattern
|
||||
: urlPattern.endsWith('/')
|
||||
? `${urlPattern}*`
|
||||
: `${urlPattern}/*`;
|
||||
}
|
||||
const tabs = await chrome.tabs.query({ url });
|
||||
|
||||
const tabs = await chrome.tabs.query({ url: urlPattern });
|
||||
|
||||
if (!tabs || tabs.length === 0) {
|
||||
console.log(`No tabs found with URL: ${url}`);
|
||||
console.log(`No tabs found with URL pattern: ${urlPattern}`);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
message: `No tabs found with URL: ${url}`,
|
||||
message: `No tabs found with URL pattern: ${urlPattern}`,
|
||||
closedCount: 0,
|
||||
}),
|
||||
},
|
||||
@@ -291,7 +313,7 @@ class CloseTabsTool extends BaseBrowserToolExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`Found ${tabs.length} tabs with URL: ${url}`);
|
||||
console.log(`Found ${tabs.length} tabs with URL pattern: ${urlPattern}`);
|
||||
const tabIdsToClose = tabs
|
||||
.map((tab) => tab.id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
|
||||
@@ -17,3 +17,8 @@ export { readPageTool } from './read-page';
|
||||
export { computerTool } from './computer';
|
||||
export { handleDialogTool } from './dialog';
|
||||
export { userscriptTool } from './userscript';
|
||||
export {
|
||||
performanceStartTraceTool,
|
||||
performanceStopTraceTool,
|
||||
performanceAnalyzeInsightTool,
|
||||
} from './performance';
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
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';
|
||||
|
||||
type OwnerTag = 'performance';
|
||||
|
||||
interface StartTraceParams {
|
||||
reload?: boolean; // whether to reload the page after starting trace
|
||||
autoStop?: boolean; // whether to auto stop after a short duration
|
||||
durationMs?: number; // custom duration when autoStop is true (default 5000)
|
||||
}
|
||||
|
||||
interface StopTraceParams {
|
||||
saveToDownloads?: boolean; // save trace to Downloads as JSON (default true)
|
||||
filenamePrefix?: string; // filename prefix (default 'performance_trace')
|
||||
}
|
||||
|
||||
interface AnalyzeInsightParams {
|
||||
insightName?: string; // placeholder for future deep insights
|
||||
}
|
||||
|
||||
type DebuggeeEvent = (source: chrome.debugger.Debuggee, method: string, params?: any) => void;
|
||||
|
||||
interface TraceSessionState {
|
||||
recording: boolean;
|
||||
events: any[];
|
||||
startedAt: number;
|
||||
pageUrl?: string;
|
||||
listener: DebuggeeEvent;
|
||||
stopResolver?: (value: { completed: boolean }) => void;
|
||||
stopPromise?: Promise<{ completed: boolean }>;
|
||||
}
|
||||
|
||||
const sessions = new Map<number, TraceSessionState>();
|
||||
const LAST_RESULTS = new Map<
|
||||
number,
|
||||
{
|
||||
events: any[];
|
||||
startedAt: number;
|
||||
endedAt: number;
|
||||
tabUrl: string;
|
||||
saved?: { downloadId?: number; filename?: string; fullPath?: string };
|
||||
metrics?: Record<string, number>;
|
||||
}
|
||||
>();
|
||||
|
||||
function tracingCategories(): string[] {
|
||||
// Keep broadly consistent with other project
|
||||
return [
|
||||
'-*',
|
||||
'blink.console',
|
||||
'blink.user_timing',
|
||||
'devtools.timeline',
|
||||
'disabled-by-default-devtools.screenshot',
|
||||
'disabled-by-default-devtools.timeline',
|
||||
'disabled-by-default-devtools.timeline.invalidationTracking',
|
||||
'disabled-by-default-devtools.timeline.frame',
|
||||
'disabled-by-default-devtools.timeline.stack',
|
||||
'disabled-by-default-v8.cpu_profiler',
|
||||
'disabled-by-default-v8.cpu_profiler.hires',
|
||||
'latencyInfo',
|
||||
'loading',
|
||||
'disabled-by-default-lighthouse',
|
||||
'v8.execute',
|
||||
'v8',
|
||||
];
|
||||
}
|
||||
|
||||
async function enablePerformanceMetrics(tabId: number): Promise<Record<string, number>> {
|
||||
try {
|
||||
await cdpSessionManager.sendCommand(tabId, 'Performance.enable');
|
||||
const result = (await cdpSessionManager.sendCommand(tabId, 'Performance.getMetrics')) as {
|
||||
metrics: Array<{ name: string; value: number }>;
|
||||
};
|
||||
await cdpSessionManager.sendCommand(tabId, 'Performance.disable');
|
||||
const map: Record<string, number> = {};
|
||||
for (const m of result.metrics || []) map[m.name] = m.value;
|
||||
return map;
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTraceToDownloads(
|
||||
json: string,
|
||||
filenamePrefix = 'performance_trace',
|
||||
): Promise<{ downloadId?: number; filename?: string; fullPath?: string }> {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${filenamePrefix}_${timestamp}.json`;
|
||||
const dataUrl = `data:application/json;base64,${btoa(unescape(encodeURIComponent(json)))}`;
|
||||
const downloadId = await chrome.downloads.download({ url: dataUrl, filename, saveAs: false });
|
||||
// Attempt to resolve full path
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
const [item] = await chrome.downloads.search({ id: downloadId });
|
||||
return { downloadId, filename, fullPath: item?.filename };
|
||||
} catch {
|
||||
return { downloadId, filename };
|
||||
}
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTraceToNativeTemp(
|
||||
json: string,
|
||||
filenamePrefix = 'performance_trace',
|
||||
): Promise<{ filename?: string; fullPath?: string } | undefined> {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${filenamePrefix}_${timestamp}.json`;
|
||||
const base64 = btoa(unescape(encodeURIComponent(json)));
|
||||
|
||||
const requestId = `trace-temp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const timeoutMs = 30000;
|
||||
const resp = await new Promise<any>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
reject(new Error('Native temp save timed out'));
|
||||
}, timeoutMs);
|
||||
const listener = (message: any) => {
|
||||
if (
|
||||
message &&
|
||||
message.type === 'file_operation_response' &&
|
||||
message.responseToRequestId === requestId
|
||||
) {
|
||||
clearTimeout(timer);
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
resolve(message.payload);
|
||||
}
|
||||
};
|
||||
chrome.runtime.onMessage.addListener(listener);
|
||||
chrome.runtime
|
||||
.sendMessage({
|
||||
type: 'forward_to_native',
|
||||
message: {
|
||||
type: 'file_operation',
|
||||
requestId,
|
||||
payload: {
|
||||
action: 'prepareFile',
|
||||
base64Data: base64,
|
||||
fileName: filename,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
clearTimeout(timer);
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
if (resp && resp.success && resp.filePath) {
|
||||
return { filename, fullPath: resp.filePath };
|
||||
}
|
||||
} catch {
|
||||
// ignore, fallback will apply
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function cleanupNativeTempFile(filePath: string): Promise<void> {
|
||||
if (!filePath) return;
|
||||
try {
|
||||
const requestId = `trace-clean-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const timeoutMs = 10000;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
resolve(); // best-effort
|
||||
}, timeoutMs);
|
||||
const listener = (message: any) => {
|
||||
if (
|
||||
message &&
|
||||
message.type === 'file_operation_response' &&
|
||||
message.responseToRequestId === requestId
|
||||
) {
|
||||
clearTimeout(timer);
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
chrome.runtime.onMessage.addListener(listener);
|
||||
chrome.runtime
|
||||
.sendMessage({
|
||||
type: 'forward_to_native',
|
||||
message: {
|
||||
type: 'file_operation',
|
||||
requestId,
|
||||
payload: {
|
||||
action: 'cleanupFile',
|
||||
filePath,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
clearTimeout(timer);
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateStopPromise(session: TraceSessionState): Promise<{ completed: boolean }> {
|
||||
if (session.stopPromise) return session.stopPromise;
|
||||
session.stopPromise = new Promise((resolve) => {
|
||||
session.stopResolver = resolve;
|
||||
});
|
||||
return session.stopPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start performance trace
|
||||
*/
|
||||
class PerformanceStartTraceTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.PERFORMANCE_START_TRACE;
|
||||
|
||||
async execute(args: StartTraceParams): Promise<ToolResult> {
|
||||
const { reload = false, autoStop = false, durationMs = 5000 } = args || {};
|
||||
|
||||
try {
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!activeTab?.id) {
|
||||
return createErrorResponse('No active tab found');
|
||||
}
|
||||
const tabId = activeTab.id;
|
||||
const existed = sessions.get(tabId);
|
||||
if (existed?.recording) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: a performance trace is already running.' }],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
await cdpSessionManager.attach(tabId, 'performance');
|
||||
|
||||
const state: TraceSessionState = {
|
||||
recording: true,
|
||||
events: [],
|
||||
startedAt: Date.now(),
|
||||
pageUrl: activeTab.url || '',
|
||||
listener: (source, method, params) => {
|
||||
if (source.tabId !== tabId) return;
|
||||
if (method === 'Tracing.dataCollected' && params?.value) {
|
||||
try {
|
||||
state.events.push(...(params.value as any[]));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else if (method === 'Tracing.tracingComplete') {
|
||||
state.recording = false;
|
||||
state.stopResolver?.({ completed: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
chrome.debugger.onEvent.addListener(state.listener);
|
||||
sessions.set(tabId, state);
|
||||
|
||||
// Start tracing with categories
|
||||
const cats = tracingCategories().join(',');
|
||||
await cdpSessionManager.sendCommand(tabId, 'Tracing.start', {
|
||||
categories: cats,
|
||||
options: 'record-as-much-as-possible',
|
||||
transferMode: 'ReportEvents',
|
||||
});
|
||||
|
||||
if (reload) {
|
||||
try {
|
||||
await cdpSessionManager.sendCommand(tabId, 'Page.reload', { ignoreCache: true });
|
||||
} catch {
|
||||
// best effort; ignore if fails
|
||||
}
|
||||
}
|
||||
|
||||
if (autoStop) {
|
||||
setTimeout(
|
||||
async () => {
|
||||
try {
|
||||
await cdpSessionManager.sendCommand(tabId, 'Tracing.end');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
Math.max(1000, Math.min(durationMs, 60000)),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Performance trace is recording. Use performance_stop_trace to stop it.',
|
||||
reload,
|
||||
autoStop,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (e: any) {
|
||||
return createErrorResponse(`Failed to start performance trace: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop performance trace
|
||||
*/
|
||||
class PerformanceStopTraceTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.PERFORMANCE_STOP_TRACE;
|
||||
|
||||
async execute(args: StopTraceParams): Promise<ToolResult> {
|
||||
const { saveToDownloads = true, filenamePrefix } = args || {};
|
||||
try {
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!activeTab?.id) return createErrorResponse('No active tab found');
|
||||
const tabId = activeTab.id;
|
||||
const session = sessions.get(tabId);
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: 'No performance trace session found for the current tab.' },
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
let stopResult: { completed: boolean } = { completed: false };
|
||||
if (session.recording) {
|
||||
// End tracing and wait for completion signal
|
||||
await cdpSessionManager.sendCommand(tabId, 'Tracing.end');
|
||||
await getOrCreateStopPromise(session);
|
||||
stopResult = await session.stopPromise!;
|
||||
} else {
|
||||
// Already auto-stopped; proceed to finalize without waiting
|
||||
stopResult = { completed: true };
|
||||
}
|
||||
// Fetch metrics before detach
|
||||
const metrics = await enablePerformanceMetrics(tabId);
|
||||
|
||||
// Cleanup event listener and detach
|
||||
try {
|
||||
chrome.debugger.onEvent.removeListener(session.listener);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await cdpSessionManager.detach(tabId, 'performance');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const endedAt = Date.now();
|
||||
const trace = { traceEvents: session.events };
|
||||
const json = JSON.stringify(trace);
|
||||
|
||||
let saved: { downloadId?: number; filename?: string; fullPath?: string } | undefined;
|
||||
if (saveToDownloads) {
|
||||
saved = await saveTraceToDownloads(json, filenamePrefix || 'performance_trace');
|
||||
} else {
|
||||
// Persist to native temp directory so that analysis can run without Downloads permission
|
||||
const tempSaved = await saveTraceToNativeTemp(json, filenamePrefix || 'performance_trace');
|
||||
if (tempSaved) {
|
||||
saved = { ...tempSaved } as any;
|
||||
}
|
||||
}
|
||||
|
||||
LAST_RESULTS.set(tabId, {
|
||||
events: session.events,
|
||||
startedAt: session.startedAt,
|
||||
endedAt,
|
||||
tabUrl: session.pageUrl || '',
|
||||
saved,
|
||||
metrics,
|
||||
});
|
||||
|
||||
sessions.delete(tabId);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: 'The performance trace has been stopped.',
|
||||
eventCount: session.events.length,
|
||||
saved,
|
||||
metrics,
|
||||
startedAt: session.startedAt,
|
||||
endedAt,
|
||||
durationMs: endedAt - session.startedAt,
|
||||
url: session.pageUrl || '',
|
||||
tracingCompleted: stopResult?.completed === true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (e: any) {
|
||||
return createErrorResponse(`Failed to stop performance trace: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze last trace (lightweight)
|
||||
* Note: Deep insights require DevTools front-end trace engine on the native side; this is a
|
||||
* pragmatic first step returning basic metrics and a quick event histogram.
|
||||
*/
|
||||
class PerformanceAnalyzeInsightTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.PERFORMANCE_ANALYZE_INSIGHT;
|
||||
|
||||
async execute(args: AnalyzeInsightParams & { timeoutMs?: number }): Promise<ToolResult> {
|
||||
const { insightName } = args || {};
|
||||
try {
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!activeTab?.id) return createErrorResponse('No active tab found');
|
||||
const tabId = activeTab.id;
|
||||
const result = LAST_RESULTS.get(tabId);
|
||||
if (!result) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No recorded traces found. Start and stop a performance trace first.',
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Prefer native-side deep analysis when we have a saved file path
|
||||
const fullPath = (result.saved && (result.saved as any).fullPath) || undefined;
|
||||
if (fullPath) {
|
||||
try {
|
||||
const requestId = `trace-analyze-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const timeoutMs = Math.max(10000, Math.min((args as any)?.timeoutMs ?? 60000, 300000));
|
||||
const resp = await new Promise<any>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
reject(new Error('Native trace analysis timed out'));
|
||||
}, timeoutMs);
|
||||
const listener = (message: any) => {
|
||||
if (
|
||||
message &&
|
||||
message.type === 'file_operation_response' &&
|
||||
message.responseToRequestId === requestId
|
||||
) {
|
||||
clearTimeout(timer);
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
resolve(message.payload);
|
||||
}
|
||||
};
|
||||
chrome.runtime.onMessage.addListener(listener);
|
||||
chrome.runtime
|
||||
.sendMessage({
|
||||
type: 'forward_to_native',
|
||||
message: {
|
||||
type: 'file_operation',
|
||||
requestId,
|
||||
payload: { action: 'analyzeTrace', traceFilePath: fullPath, insightName },
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
clearTimeout(timer);
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
if (resp && resp.success) {
|
||||
// Best-effort cleanup for temp files (Downloads paths are ignored by native cleaner)
|
||||
await cleanupNativeTempFile(fullPath);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
url: result.tabUrl,
|
||||
startedAt: result.startedAt,
|
||||
endedAt: result.endedAt,
|
||||
durationMs: result.endedAt - result.startedAt,
|
||||
metrics: result.metrics || {},
|
||||
saved: result.saved,
|
||||
summary: resp.summary,
|
||||
insight: resp.insight,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
// If native returned error, fall through to lightweight analysis
|
||||
} catch (e) {
|
||||
// Fallback to lightweight analysis below
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight fallback (when no saved file path)
|
||||
const counts = new Map<string, number>();
|
||||
for (const ev of result.events.slice(0, 100000)) {
|
||||
const n = typeof (ev as any)?.name === 'string' ? (ev as any).name : 'unknown';
|
||||
counts.set(n, (counts.get(n) || 0) + 1);
|
||||
}
|
||||
const top = [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 20)
|
||||
.map(([name, count]) => ({ name, count }));
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
info: 'Lightweight analysis (no saved file path). Native-side deep analysis unavailable.',
|
||||
requestedInsight: insightName || null,
|
||||
url: result.tabUrl,
|
||||
startedAt: result.startedAt,
|
||||
endedAt: result.endedAt,
|
||||
durationMs: result.endedAt - result.startedAt,
|
||||
metrics: result.metrics || {},
|
||||
topEventNames: top,
|
||||
saved: result.saved,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (e: any) {
|
||||
return createErrorResponse(`Failed to analyze trace: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const performanceStartTraceTool = new PerformanceStartTraceTool();
|
||||
export const performanceStopTraceTool = new PerformanceStopTraceTool();
|
||||
export const performanceAnalyzeInsightTool = new PerformanceAnalyzeInsightTool();
|
||||
@@ -43,7 +43,8 @@
|
||||
"is-admin": "^4.0.0",
|
||||
"node-fetch": "2",
|
||||
"pino": "^9.6.0",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^11.1.0",
|
||||
"chrome-devtools-frontend": "^1.0.1299282"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.7.0",
|
||||
|
||||
@@ -22,7 +22,7 @@ export class FileHandler {
|
||||
* Handle file preparation request from the extension
|
||||
*/
|
||||
async handleFileRequest(request: any): Promise<any> {
|
||||
const { action, fileUrl, base64Data, fileName, filePath } = request;
|
||||
const { action, fileUrl, base64Data, fileName, filePath, traceFilePath, insightName } = request;
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
@@ -39,6 +39,21 @@ export class FileHandler {
|
||||
case 'cleanupFile':
|
||||
return await this.cleanupFile(filePath);
|
||||
|
||||
case 'analyzeTrace': {
|
||||
const targetPath = traceFilePath || filePath;
|
||||
if (!targetPath) {
|
||||
return { success: false, error: 'traceFilePath is required' };
|
||||
}
|
||||
try {
|
||||
// With tsconfig moduleResolution=NodeNext, relative ESM imports need explicit .js extension
|
||||
const { analyzeTraceFile } = await import('./trace-analyzer.js');
|
||||
const res = await analyzeTraceFile(targetPath, insightName);
|
||||
return { success: true, ...res };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e?.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
@@ -91,7 +106,7 @@ export class FileHandler {
|
||||
try {
|
||||
// Remove data URL prefix if present
|
||||
const base64Content = base64Data.replace(/^data:.*?;base64,/, '');
|
||||
|
||||
|
||||
// Convert base64 to buffer
|
||||
const buffer = Buffer.from(base64Content, 'base64');
|
||||
|
||||
@@ -222,4 +237,4 @@ export class FileHandler {
|
||||
}
|
||||
}
|
||||
|
||||
export default new FileHandler();
|
||||
export default new FileHandler();
|
||||
|
||||
@@ -26,7 +26,7 @@ const handleToolCall = async (name: string, args: any): Promise<CallToolResult>
|
||||
args,
|
||||
},
|
||||
NativeMessageType.CALL_TOOL,
|
||||
30000, // 30秒超时
|
||||
120000, // 延长到 120 秒,避免性能分析等长任务超时
|
||||
);
|
||||
if (response.status === 'success') {
|
||||
return response.data;
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
// Single-file shim for all deep imports from chrome-devtools-frontend.
|
||||
// This prevents TypeScript from type-checking the upstream DevTools source tree.
|
||||
// Runtime still loads the real package; this file is types-only and local to TS.
|
||||
declare module 'chrome-devtools-frontend/*' {
|
||||
const anyExport: any;
|
||||
export = anyExport;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
// Import DevTools trace engine and formatters from chrome-devtools-frontend
|
||||
// We intentionally use deep imports to match the package structure.
|
||||
// These modules are ESM and require NodeNext module resolution.
|
||||
// Types are loosely typed to minimize coupling with DevTools internals.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import * as TraceEngine from 'chrome-devtools-frontend/front_end/models/trace/trace.js';
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { PerformanceTraceFormatter } from 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js';
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { PerformanceInsightFormatter } from 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js';
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { AgentFocus } from 'chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js';
|
||||
|
||||
const engine = TraceEngine.TraceModel.Model.createWithAllHandlers();
|
||||
|
||||
function readJsonFile(path: string): any {
|
||||
const text = fs.readFileSync(path, 'utf-8');
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
export async function parseTrace(json: any): Promise<{
|
||||
parsedTrace: any;
|
||||
insights: any | null;
|
||||
}> {
|
||||
engine.resetProcessor();
|
||||
const events = Array.isArray(json) ? json : json.traceEvents;
|
||||
if (!events || !Array.isArray(events)) {
|
||||
throw new Error('Invalid trace format: expected array or {traceEvents: []}');
|
||||
}
|
||||
await engine.parse(events);
|
||||
const parsedTrace = engine.parsedTrace();
|
||||
const insights = parsedTrace?.insights ?? null;
|
||||
if (!parsedTrace) throw new Error('No parsed trace returned by engine');
|
||||
return { parsedTrace, insights };
|
||||
}
|
||||
|
||||
export function getTraceSummary(parsedTrace: any): string {
|
||||
const focus = AgentFocus.fromParsedTrace(parsedTrace);
|
||||
const formatter = new PerformanceTraceFormatter(focus);
|
||||
return formatter.formatTraceSummary();
|
||||
}
|
||||
|
||||
export function getInsightText(parsedTrace: any, insights: any, insightName: string): string {
|
||||
if (!insights) throw new Error('No insights available for this trace');
|
||||
const mainNavId = parsedTrace.data?.Meta?.mainFrameNavigations?.at(0)?.args?.data?.navigationId;
|
||||
const NO_NAV = TraceEngine.Types.Events.NO_NAVIGATION;
|
||||
const set = insights.get(mainNavId ?? NO_NAV);
|
||||
if (!set) throw new Error('No insights for selected navigation');
|
||||
const model = set.model || {};
|
||||
if (!(insightName in model)) throw new Error(`Insight not found: ${insightName}`);
|
||||
const formatter = new PerformanceInsightFormatter(
|
||||
AgentFocus.fromParsedTrace(parsedTrace),
|
||||
model[insightName],
|
||||
);
|
||||
return formatter.formatInsight();
|
||||
}
|
||||
|
||||
export async function analyzeTraceFile(
|
||||
filePath: string,
|
||||
insightName?: string,
|
||||
): Promise<{
|
||||
summary: string;
|
||||
insight?: string;
|
||||
}> {
|
||||
const json = readJsonFile(filePath);
|
||||
const { parsedTrace, insights } = await parseTrace(json);
|
||||
const summary = getTraceSummary(parsedTrace);
|
||||
if (insightName) {
|
||||
try {
|
||||
const insight = getInsightText(parsedTrace, insights, insightName);
|
||||
return { summary, insight };
|
||||
} catch {
|
||||
// If requested insight missing, still return summary
|
||||
return { summary };
|
||||
}
|
||||
}
|
||||
return { summary };
|
||||
}
|
||||
|
||||
export default { analyzeTraceFile };
|
||||
@@ -0,0 +1,28 @@
|
||||
// Minimal ambient declarations to avoid compiling chrome-devtools-frontend sources.
|
||||
// We intentionally treat these modules as `any` to keep our build lightweight and decoupled
|
||||
// from DevTools' internal TypeScript and lib targets.
|
||||
|
||||
declare module 'chrome-devtools-frontend/front_end/models/trace/trace.js' {
|
||||
// Shape used by our code: TraceModel + Types + Insights
|
||||
export const TraceModel: any;
|
||||
export const Types: any;
|
||||
export const Insights: any;
|
||||
}
|
||||
|
||||
declare module 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js' {
|
||||
export class PerformanceTraceFormatter {
|
||||
constructor(...args: any[]);
|
||||
formatTraceSummary(): string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js' {
|
||||
export class PerformanceInsightFormatter {
|
||||
constructor(...args: any[]);
|
||||
formatInsight(): string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js' {
|
||||
export const AgentFocus: any;
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2018", "DOM"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2018", "DOM"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"chrome-devtools-frontend/*": ["src/shims/devtools.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ export const TOOL_NAMES = {
|
||||
COMPUTER: 'chrome_computer',
|
||||
HANDLE_DIALOG: 'chrome_handle_dialog',
|
||||
USERSCRIPT: 'chrome_userscript',
|
||||
PERFORMANCE_START_TRACE: 'performance_start_trace',
|
||||
PERFORMANCE_STOP_TRACE: 'performance_stop_trace',
|
||||
PERFORMANCE_ANALYZE_INSIGHT: 'performance_analyze_insight',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,6 +47,69 @@ export const TOOL_SCHEMAS: Tool[] = [
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.PERFORMANCE_START_TRACE,
|
||||
description:
|
||||
'Starts a performance trace recording on the selected page. Optionally reloads the page and/or auto-stops after a short duration.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reload: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Determines if, once tracing has started, the page should be automatically reloaded (ignore cache).',
|
||||
},
|
||||
autoStop: {
|
||||
type: 'boolean',
|
||||
description: 'Determines if the trace should be automatically stopped (default false).',
|
||||
},
|
||||
durationMs: {
|
||||
type: 'number',
|
||||
description: 'Auto-stop duration in milliseconds when autoStop is true (default 5000).',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.PERFORMANCE_STOP_TRACE,
|
||||
description: 'Stops the active performance trace recording on the selected page.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
saveToDownloads: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to save the trace as a JSON file in Downloads (default true).',
|
||||
},
|
||||
filenamePrefix: {
|
||||
type: 'string',
|
||||
description: 'Optional filename prefix for the downloaded trace JSON.',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.PERFORMANCE_ANALYZE_INSIGHT,
|
||||
description:
|
||||
'Provides a lightweight summary of the last recorded trace. For deep insights (CWV, breakdowns), integrate native-side DevTools trace engine.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
insightName: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional insight name for future deep analysis (e.g., "DocumentLatency"). Currently informational only.',
|
||||
},
|
||||
timeoutMs: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Timeout for deep analysis via native host (milliseconds). Default 60000. Increase for large traces.',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.READ_PAGE,
|
||||
description:
|
||||
|
||||
Generated
+8
@@ -111,6 +111,9 @@ importers:
|
||||
chalk:
|
||||
specifier: ^5.4.1
|
||||
version: 5.4.1
|
||||
chrome-devtools-frontend:
|
||||
specifier: ^1.0.1299282
|
||||
version: 1.0.1526630
|
||||
chrome-mcp-shared:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
@@ -1563,6 +1566,9 @@ packages:
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
chrome-devtools-frontend@1.0.1526630:
|
||||
resolution: {integrity: sha512-zl2taUBtNgA0Mlz1pnL0DfXCYq5M+G9Yo9hFx9b4Y2pJ8Q/J/qepq2/6hC3I/N3tP7KnuJ/+eJcBr2qMewwoag==}
|
||||
|
||||
chrome-launcher@1.1.2:
|
||||
resolution: {integrity: sha512-YclTJey34KUm5jB1aEJCq807bSievi7Nb/TU4Gu504fUYi3jw3KCIaH6L7nFWQhdEgH3V+wCh+kKD1P5cXnfxw==}
|
||||
engines: {node: '>=12.13.0'}
|
||||
@@ -6138,6 +6144,8 @@ snapshots:
|
||||
|
||||
chownr@1.1.4: {}
|
||||
|
||||
chrome-devtools-frontend@1.0.1526630: {}
|
||||
|
||||
chrome-launcher@1.1.2:
|
||||
dependencies:
|
||||
'@types/node': 22.15.30
|
||||
|
||||
Reference in New Issue
Block a user