chrome-devtools-frontend
Version:
Chrome DevTools UI
221 lines (188 loc) • 9.11 kB
text/typescript
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './CollapsibleAssistanceContentWidget.js';
import '../../../models/trace/insights/insights.js';
import '../../../panels/timeline/components/components.js';
import './PerformanceAgentFlameChart.js';
import * as Common from '../../../core/common/common.js';
import * as SDK from '../../../core/sdk/sdk.js';
import type * as Protocol from '../../../generated/protocol.js';
import * as GreenDev from '../../../models/greendev/greendev.js';
import * as Logs from '../../../models/logs/logs.js';
import * as NetworkTimeCalculator from '../../../models/network_time_calculator/network_time_calculator.js';
import * as Helpers from '../../../models/trace/helpers/helpers.js';
import * as Trace from '../../../models/trace/trace.js';
import type * as Marked from '../../../third_party/marked/marked.js';
import * as UI from '../../../ui/legacy/legacy.js';
import * as Lit from '../../../ui/lit/lit.js';
import * as PanelsCommon from '../../common/common.js';
import * as Network from '../../network/network.js';
import * as TimelineComponents from '../../timeline/components/components.js';
import * as Insights from '../../timeline/components/insights/insights.js';
import {MarkdownRendererWithCodeBlock} from './MarkdownRendererWithCodeBlock.js';
import type * as PerformanceAgentFlameChart from './PerformanceAgentFlameChart.js';
const {html} = Lit.StaticHtml;
const {ref, createRef} = Lit.Directives;
const {widgetConfig} = UI.Widget;
export class PerformanceAgentMarkdownRenderer extends MarkdownRendererWithCodeBlock {
#insightRenderer = new Insights.InsightRenderer.InsightRenderer();
constructor(
private mainFrameId = '',
private lookupEvent: (key: Trace.Types.File.SerializableKey) => Trace.Types.Events.Event | null = () => null,
private parsedTrace: Trace.TraceModel.ParsedTrace|null = null) {
super();
}
override templateForToken(token: Marked.Marked.MarkedToken): Lit.LitTemplate|null {
if (!this.parsedTrace) {
return null;
}
// NOTE: The custom tag handling below (e.g., <ai-insight>, <network-request-widget>)
// is part of a prototype for the GreenDev project and is only rendered when the GreenDev
// feature is enabled.
if (token.type === 'html' && GreenDev.Prototypes.instance().isEnabled('inlineWidgets')) {
if (token.text.includes('<flame-chart-widget')) {
const startMatch = token.text.match(/start="?(\d+)"?/);
const endMatch = token.text.match(/end="?(\d+)"?/);
const start = startMatch ? Number(startMatch[1]) : this.parsedTrace.data.Meta.traceBounds.min;
const end = endMatch ? Number(endMatch[1]) : this.parsedTrace.data.Meta.traceBounds.max;
return html`<devtools-performance-agent-flame-chart .data=${{
parsedTrace: this.parsedTrace,
start,
end,
} as PerformanceAgentFlameChart.PerformanceAgentFlameChartData}
}></devtools-performance-agent-flame-chart>`;
}
// Flexible regex to match the tag name and a value a.
// match[1]: tagName (e.g., 'ai-insight', 'network-request-widget')
// match[2]: value (value needed to display the widget)
const regex = /<([a-z-]+)\s+value="([^"]+)">/;
const match = token.text.match(regex);
if (!match) {
return null;
}
const tagName = match[1];
const value = match[2];
if (tagName === 'ai-insight' && value) {
const componentName = value;
const insightSet = this.parsedTrace.insights?.values().next().value;
const insightM = insightSet?.model[componentName as Trace.Insights.Types.InsightKeys];
if (!insightM) {
return null;
}
return html`<devtools-collapsible-assistance-content-widget .data=${{
headerText: `Insight - ${componentName}`
}
}>
${this.#insightRenderer.renderInsightToWidgetElement(this.parsedTrace, insightSet, insightM, componentName, {
selected: true,
isAIAssistanceContext: true
})}
</devtools-collapsible-assistance-content-widget>`;
}
if (tagName === 'network-request-widget' && value) {
const rawTraceEvent =
Helpers.SyntheticEvents.SyntheticEventsManager.getActiveManager().getRawTraceEvents().at(Number(value));
// Rendering RequestTimingView widget only works for fresh traces where the network log is in sync.
// If the trace is uploaded, we need to use the synthetic events and
// render the network request tooltip that uses the synthetic events.
if (rawTraceEvent && Trace.Types.Events.isSyntheticNetworkRequest(rawTraceEvent)) {
const rawTraceEventId = rawTraceEvent?.args?.data?.requestId;
const rawTraceEventUrl = rawTraceEvent?.args?.data?.url;
const networkRequest = rawTraceEvent ? Logs.NetworkLog.NetworkLog.instance()
.requestsForId(rawTraceEventId)
.find(r => r.url() === rawTraceEventUrl) :
null;
if (networkRequest) {
const calculator = new NetworkTimeCalculator.NetworkTimeCalculator(true);
return html`<devtools-collapsible-assistance-content-widget
.data=${{
headerText: `Network Request: ${
networkRequest.url().length > 80 ? networkRequest.url().slice(0, 80) + '...' : networkRequest.url()}`
}
}>
<devtools-widget class="actions" .widgetConfig=${
UI.Widget.widgetConfig(Network.RequestTimingView.RequestTimingView, {
request: networkRequest,
calculator,
})}></devtools-widget>
</devtools-collapsible-assistance-content-widget>`;
}
}
const syntheticRequest =
Helpers.SyntheticEvents.SyntheticEventsManager.getActiveManager().syntheticEventForRawEventIndex(
Number(value));
let networkTooltip = null;
if (syntheticRequest && Trace.Types.Events.isSyntheticNetworkRequest(syntheticRequest)) {
// clang-format off
networkTooltip = html`<devtools-widget .widgetConfig=${widgetConfig(TimelineComponents.NetworkRequestTooltip.NetworkRequestTooltip, {
networkRequest: syntheticRequest,
})}></devtools-widget>`;
// clang-format on
}
return html`<devtools-collapsible-assistance-content-widget
.data=${{
headerText: 'Network Request'
}
}>
${networkTooltip}
</devtools-collapsible-assistance-content-widget>`;
}
return null;
}
if (token.type === 'link' && token.href.startsWith('#')) {
if (token.href.startsWith('#node-')) {
const nodeId = Number(token.href.replace('#node-', '')) as Protocol.DOM.BackendNodeId;
const templateRef = createRef();
void this.#linkifyNode(nodeId, token.text).then(node => {
if (!templateRef.value || !node) {
return;
}
templateRef.value.textContent = '';
templateRef.value.append(node);
});
return html`<span ${ref(templateRef)}>${token.text}</span>`;
}
const event = this.lookupEvent(token.href.slice(1) as Trace.Types.File.SerializableKey);
if (!event) {
return html`${token.text}`;
}
let label = token.text;
let title = '';
if (Trace.Types.Events.isSyntheticNetworkRequest(event)) {
title = event.args.data.url;
} else {
label += ` (${event.name})`;
}
// eslint-disable-next-line @devtools/no-a-tags-in-lit
return html`<a href="#" draggable=false .title=${title} @click=${(e: Event) => {
e.stopPropagation();
void Common.Revealer.reveal(new SDK.TraceObject.RevealableEvent(event));
}}>${label}</a>`;
}
return super.templateForToken(token);
}
// Taken from front_end/panels/timeline/components/insights/NodeLink.ts
// Would be nice to move the above component to somewhere that allows the AI
// Assistance panel to also use it.
async #linkifyNode(backendNodeId: Protocol.DOM.BackendNodeId, label: string): Promise<Node|undefined> {
if (backendNodeId === undefined) {
return;
}
const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
const domModel = target?.model(SDK.DOMModel.DOMModel);
if (!domModel) {
return undefined;
}
const domNodesMap = await domModel.pushNodesByBackendIdsToFrontend(new Set([backendNodeId]));
const node = domNodesMap?.get(backendNodeId);
if (!node) {
return;
}
if (node.frameId() !== this.mainFrameId) {
return;
}
const linkedNode = PanelsCommon.DOMLinkifier.Linkifier.instance().linkify(node, {textContent: label});
return linkedNode;
}
}