chrome-devtools-frontend
Version:
Chrome DevTools UI
227 lines (197 loc) • 8.22 kB
text/typescript
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Host from '../../../core/host/host.js';
import * as i18n from '../../../core/i18n/i18n.js';
import * as SDK from '../../../core/sdk/sdk.js';
import * as TextUtils from '../../../core/text_utils/text_utils.js';
import * as Logs from '../../logs/logs.js';
import * as Trace from '../../trace/trace.js';
import type {AiWidget} from '../agents/AiAgent.js';
import {PerformanceTraceContext} from '../contexts/PerformanceTraceContext.js';
import {PerformanceInsightFormatter} from '../data_formatters/PerformanceInsightFormatter.js';
import {debugLog} from '../debug.js';
import {
type BaseToolCapability,
type DataHandlerResult,
type DataTool,
MAX_FUNCTION_RESULT_BYTE_LENGTH,
type TargetCapability,
type ToolArgs,
ToolName,
} from './Tool.js';
const lockedString = i18n.i18n.lockedString;
async function getNetworkRequestImageData(
target: SDK.Target.Target|null,
lcpRequest: Trace.Types.Events.SyntheticNetworkRequest,
// eslint-disable-next-line @devtools/no-instance-of-migrated-singletons
networkLog: Logs.NetworkLog.NetworkLog = Logs.NetworkLog.NetworkLog.instance(),
): Promise<TextUtils.ContentData.ContentData|undefined> {
const networkManager = target?.model(SDK.NetworkManager.NetworkManager);
if (!target || !networkManager) {
return undefined;
}
const requestId = lcpRequest.args.data.requestId;
const sdkRequest = networkLog.requestByManagerAndId(networkManager, requestId);
if (sdkRequest?.contentType().isImage()) {
const contentData = await sdkRequest.requestContentData();
if (!TextUtils.ContentData.ContentData.isError(contentData)) {
return contentData;
}
}
return undefined;
}
export interface GetInsightDetailsArgs extends ToolArgs {
insightSetId: string;
insightName: string;
}
export class GetInsightDetailsTool implements
DataTool<GetInsightDetailsArgs, string, BaseToolCapability&TargetCapability> {
readonly name = ToolName.GET_INSIGHT_DETAILS;
readonly description =
'Returns detailed information about a specific insight of an insight set. Use this before commenting on any specific issue to get more information.';
readonly parameters: Host.AidaClient.FunctionObjectParam<keyof GetInsightDetailsArgs> = {
type: Host.AidaClient.ParametersTypes.OBJECT,
description: 'Arguments for getting insight details.',
nullable: false,
properties: {
insightSetId: {
type: Host.AidaClient.ParametersTypes.STRING,
description:
'The id for the specific insight set. Only use the ids given in the "Available insight sets" list.',
nullable: false,
},
insightName: {
type: Host.AidaClient.ParametersTypes.STRING,
description: 'The name of the insight. Only use the insight names given in the "Available insights" list.',
nullable: false,
},
},
required: ['insightSetId', 'insightName'],
};
displayInfoFromArgs(params: GetInsightDetailsArgs): {
title: string,
action: string,
} {
return {
title: lockedString(`Investigating insight ${params.insightName}`),
action: `getInsightDetails('${params.insightSetId}', '${params.insightName}')`,
};
}
async #generateDOMTreeWidget(
insight: Trace.Insights.Types.InsightModel,
insightSet: Trace.Insights.Types.InsightSet,
target: SDK.Target.Target|null,
): Promise<AiWidget|null> {
try {
if (!Trace.Insights.Models.LCPDiscovery.isLCPDiscoveryInsight(insight) &&
!Trace.Insights.Models.LCPBreakdown.isLCPBreakdownInsight(insight)) {
return null;
}
const lcpMetric = Trace.Insights.Common.getLCP(insightSet);
const lcpEvent = lcpMetric?.event;
if (!lcpEvent || !Trace.Types.Events.isAnyLargestContentfulPaintCandidate(lcpEvent)) {
return null;
}
const nodeId = lcpEvent.args.data?.nodeId;
if (!nodeId) {
return null;
}
const domModel = target?.model(SDK.DOMModel.DOMModel);
if (!domModel) {
return null;
}
const nodeMap = await domModel.pushNodesByBackendIdsToFrontend(new Set([nodeId]));
const node = nodeMap?.get(nodeId);
if (!node) {
return null;
}
const lcpSyntheticRequest = insight.lcpRequest;
const [snapshot, imageContent] = await Promise.all([
node.takeSnapshot(),
lcpSyntheticRequest ? getNetworkRequestImageData(target, lcpSyntheticRequest) : Promise.resolve(undefined),
]);
let networkRequest;
if (lcpSyntheticRequest) {
networkRequest = {
url: lcpSyntheticRequest.args.data.url,
size: lcpSyntheticRequest.args.data.decodedBodyLength ?? lcpSyntheticRequest.args.data.encodedDataLength ?? 0,
resourceType: lcpSyntheticRequest.args.data.resourceType,
mimeType: lcpSyntheticRequest.args.data.mimeType ?? '',
imageContent,
};
}
return {
name: 'DOM_TREE',
data: {
root: snapshot,
networkRequest,
title: lockedString('LCP element'),
accessibleRevealLabel: lockedString('Reveal LCP element'),
},
};
} catch (err) {
debugLog('GetInsightDetails: Failed to generate DOM tree widget', err);
return null;
}
}
async handler(
params: GetInsightDetailsArgs,
capabilities: BaseToolCapability&TargetCapability,
): Promise<DataHandlerResult<string>> {
const conversationContext = capabilities.conversationContext;
if (!conversationContext || !(conversationContext instanceof PerformanceTraceContext)) {
return {error: 'Performance trace context is not available.'};
}
if (!params.insightSetId || !params.insightName) {
return {error: 'Missing required arguments: insightSetId and insightName must be provided.'};
}
const focus = conversationContext.getItem();
const parsedTrace = focus.parsedTrace;
const insightSet = parsedTrace.insights?.get(params.insightSetId);
if (!insightSet) {
const formatter = conversationContext.createFormatter();
const valid = ([...parsedTrace.insights?.values() ?? []])
.map(insightSet => `id: ${insightSet.id}, url: ${insightSet.url}, bounds: ${
formatter.serializeBounds(insightSet.bounds)}`)
.join('; ');
return {error: `Invalid insight set id. Valid insight set ids are: ${valid || '(none)'}`};
}
if (!Trace.Insights.Common.isInsightKey(params.insightName)) {
const valid = Object.keys(insightSet.model).join(', ');
return {error: `No insight available. Valid insight names are: ${valid || '(none)'}`};
}
const insightError = insightSet.modelErrors?.[params.insightName];
if (insightError) {
return {error: `Insight "${params.insightName}" failed during trace processing: ${insightError.message}`};
}
const insight = insightSet.model[params.insightName];
if (!insight) {
const valid = Object.keys(insightSet.model).join(', ');
return {error: `No insight available. Valid insight names are: ${valid || '(none)'}`};
}
const details = new PerformanceInsightFormatter(focus, insight).formatInsight();
if (details.length > MAX_FUNCTION_RESULT_BYTE_LENGTH) {
return {
error:
'The insight details output is too large to fit in the context window. Please inspect specific events using getTraceEventByKey or getDetailedCallTree.',
};
}
const widgets: AiWidget[] = [];
const isImportedTrace = conversationContext.getOrigin().startsWith('imported-trace://');
if (!isImportedTrace) {
const domTreeWidget = await this.#generateDOMTreeWidget(insight, insightSet, capabilities.getTarget());
if (domTreeWidget) {
widgets.push(domTreeWidget);
}
}
widgets.push({
name: 'PERF_INSIGHT',
data: {
insight: params.insightName,
insightData: insight,
},
});
return {result: details, widgets};
}
}