UNPKG

chrome-devtools-frontend

Version:
229 lines (199 loc) • 7.39 kB
// 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 Common from '../../../core/common/common.js'; import * as Platform from '../../../core/platform/platform.js'; import * as SDK from '../../../core/sdk/sdk.js'; import type * as Protocol from '../../../generated/protocol.js'; import * as AiAssistanceModel from '../../../models/ai_assistance/ai_assistance.js'; import * as Logs from '../../../models/logs/logs.js'; import * as Trace from '../../../models/trace/trace.js'; import type * as Marked from '../../../third_party/marked/marked.js'; import * as MarkdownView from '../../../ui/components/markdown_view/markdown_view.js'; import * as Lit from '../../../ui/lit/lit.js'; import * as PanelsCommon from '../../common/common.js'; const {html} = Lit.StaticHtml; const {until} = Lit.Directives; export interface AIv2MarkdownRendererOptions { mainFrameId?: string; mainDocumentURL?: Platform.DevToolsPath.UrlString; lookupTraceEvent?: (key: string) => Trace.Types.Events.Event | null; } type ParsedLink = { type: 'path', path: string, }|{ type: 'node', nodeId: Protocol.DOM.BackendNodeId, }; /** * AIv2MarkdownRenderer is currently duplicated from the agent-specific renderers * as part of the migration to the V2 architecture. It will eventually become * the only markdown renderer used by AI assistance. */ export class AIv2MarkdownRenderer extends MarkdownView.MarkdownView.MarkdownInsightRenderer { constructor(private readonly options: AIv2MarkdownRendererOptions = {}) { super(); } #isSameOrigin(node: SDK.DOMModel.DOMNode): boolean { if (!this.options.mainDocumentURL) { return true; } const nodeDocumentURL = node.ownerDocument?.documentURL ?? '' as Platform.DevToolsPath.UrlString; return AiAssistanceModel.AiUtils.isSameOrigin(this.options.mainDocumentURL, nodeDocumentURL); } #revealableLink(revealable: unknown, label: string): Lit.LitTemplate { return html`<devtools-link @click=${(e: Event) => { e.preventDefault(); e.stopPropagation(); void Common.Revealer.reveal(revealable); }}>${Platform.StringUtilities.trimEndWithMaxLength(label, 100)}</devtools-link>`; } #renderLink(href: string, text: string): Lit.LitTemplate|null { const devtoolsLink = this.#renderDevToolsLink(href, text); if (devtoolsLink) { return devtoolsLink; } if (href.startsWith('#')) { const parsed = this.#parseLink(href); if (parsed) { const resultPromise = parsed.type === 'path' ? this.#linkifyPath(parsed.path, text) : this.#linkifyNode(parsed.nodeId, text); return html`<span>${until(resultPromise.then(node => node || text), text)}</span>`; } if (this.options.lookupTraceEvent) { const event = this.options.lookupTraceEvent(href.slice(1)); if (event) { let label = 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 null; } #renderDevToolsLink( href: string, fallbackText: string, ): Lit.LitTemplate|null { if (href.startsWith('#req-')) { const request = Logs.NetworkLog.NetworkLog.instance().requests().find( req => req.requestId() === href.substring(5), ); if (request) { return this.#revealableLink(request, request.url()); } return html`${fallbackText}`; } if (href.startsWith('#file-')) { const file = AiAssistanceModel.ContextSelectionAgent.ContextSelectionAgent.getUISourceCodes().find( file => AiAssistanceModel.ContextSelectionAgent.ContextSelectionAgent.uiSourceCodeId.get(file) === Number(href.substring(6))); if (file) { return this.#revealableLink(file, file.name()); } return html`${fallbackText}`; } return null; } #parseLink(href: string): ParsedLink|null { if (href.startsWith('#path-')) { return {type: 'path', path: href.replace('#path-', '')}; } if (href.startsWith('#1,HTML')) { return {type: 'path', path: href.slice(1)}; } let nodeIdStr = ''; if (href.startsWith('#node-')) { nodeIdStr = href.replace('#node-', ''); } else if (href.startsWith('#')) { nodeIdStr = href.slice(1); } if (nodeIdStr.trim() !== '') { const nodeId = Number(nodeIdStr); if (Number.isInteger(nodeId)) { return {type: 'node', nodeId: nodeId as Protocol.DOM.BackendNodeId}; } } return null; } async #linkifyNode(backendNodeId: Protocol.DOM.BackendNodeId, label: string): Promise<Lit.LitTemplate|undefined> { 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 (this.options.mainFrameId && node.frameId() !== this.options.mainFrameId) { return; } if (!this.#isSameOrigin(node)) { return; } const linkedNode = PanelsCommon.DOMLinkifier.Linkifier.instance().linkify(node, {textContent: label}); return linkedNode; } async #linkifyPath(path: string, label: string): Promise<Lit.LitTemplate|undefined> { const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); const domModel = target?.model(SDK.DOMModel.DOMModel); if (!domModel) { return undefined; } const nodeId = await domModel.pushNodeByPathToFrontend(path); if (!nodeId) { return; } const node = domModel.nodeForId(nodeId); if (!node) { return; } if (!this.#isSameOrigin(node)) { return; } const linkedNode = PanelsCommon.DOMLinkifier.Linkifier.instance().linkify(node, {textContent: label}); return linkedNode; } override templateForToken(token: Marked.Marked.MarkedToken): Lit.LitTemplate|null { if (token.type === 'link') { const link = this.#renderLink(token.href, token.text); if (link) { return link; } } if (token.type === 'code') { const lines = (token.text).split('\n'); if (lines[0]?.trim() === 'css') { token.lang = 'css'; token.text = lines.slice(1).join('\n'); } } if (token.type === 'codespan') { // LLM likes outputting the link inside a codespan block. // Remove the codespan and render the link directly const matches = token.text.match(/^\[(.*)\]\((.+)\)$/); if (matches?.[2]) { const link = this.#renderLink( matches[2], matches[1], ); if (link) { return link; } } } return super.templateForToken(token); } }