UNPKG

chrome-devtools-frontend

Version:
1,373 lines (1,273 loc) 59.7 kB
// Copyright 2024 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '../../../ui/components/markdown_view/markdown_view.js'; import '../../../ui/kit/kit.js'; import * as Common from '../../../core/common/common.js'; import * as Host from '../../../core/host/host.js'; import * as i18n from '../../../core/i18n/i18n.js'; import * as Platform from '../../../core/platform/platform.js'; import * as Root from '../../../core/root/root.js'; import * as SDK from '../../../core/sdk/sdk.js'; import type * as Protocol from '../../../generated/protocol.js'; import type { AiWidget, BottomUpTreeAiWidget, ComputedStyleAiWidget, CoreVitalsAiWidget, DomTreeAiWidget, LcpBreakdownAiWidget, PerformanceTraceAiWidget, StylePropertiesAiWidget, TimelineRangeSummaryAiWidget} from '../../../models/ai_assistance/agents/AiAgent.js'; import * as AiAssistanceModel from '../../../models/ai_assistance/ai_assistance.js'; import * as ComputedStyle from '../../../models/computed_style/computed_style.js'; import * as Trace from '../../../models/trace/trace.js'; import * as PanelsCommon from '../../../panels/common/common.js'; import * as Marked from '../../../third_party/marked/marked.js'; import * as Buttons from '../../../ui/components/buttons/buttons.js'; import * as Input from '../../../ui/components/input/input.js'; import type * as MarkdownView from '../../../ui/components/markdown_view/markdown_view.js'; import type {MarkdownLitRenderer} from '../../../ui/components/markdown_view/MarkdownView.js'; import * as UIHelpers from '../../../ui/helpers/helpers.js'; import * as UI from '../../../ui/legacy/legacy.js'; import * as Lit from '../../../ui/lit/lit.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import * as Elements from '../../elements/elements.js'; import * as TimelineComponents from '../../timeline/components/components.js'; import * as TimelineInsights from '../../timeline/components/insights/insights.js'; import * as Timeline from '../../timeline/timeline.js'; import * as TimelineUtils from '../../timeline/utils/utils.js'; import {PanelUtils} from '../../utils/utils.js'; import chatMessageStyles from './chatMessage.css.js'; import {walkthroughCloseTitle, walkthroughTitle, WalkthroughView} from './WalkthroughView.js'; const {html, Directives: {ref, ifDefined}} = Lit; const lockedString = i18n.i18n.lockedString; const {widget} = UI.Widget; const REPORT_URL = 'https://crbug.com/364805393' as Platform.DevToolsPath.UrlString; const SCROLL_ROUNDING_OFFSET = 1; const MAX_NUM_LINES_IN_CODEBLOCK = 11; /* * Strings that don't need to be translated at this time. */ const UIStringsNotTranslate = { /** * @description The title of the button that allows submitting positive * feedback about the response for AI assistance. */ thumbsUp: 'Good response', /** * @description The title of the button that allows submitting negative * feedback about the response for AI assistance. */ thumbsDown: 'Bad response', /** * @description The placeholder text for the feedback input. */ provideFeedbackPlaceholder: 'Provide additional feedback', /** * @description The disclaimer text that tells the user what will be shared * and what will be stored. */ disclaimer: 'Submitted feedback will also include your conversation', /** * @description The button text for the action of submitting feedback. */ submit: 'Submit', /** * @description The header of the feedback form asking. */ whyThisRating: 'Why did you choose this rating? (optional)', /** * @description The button text for the action that hides the feedback form. */ close: 'Close', /** * @description The title of the button that opens a page to report a legal * issue with the AI assistance message. */ report: 'Report legal issue', /** * @description The title of the button for scrolling to see next suggestions */ scrollToNext: 'Scroll to next suggestions', /** * @description The title of the button for scrolling to see previous suggestions */ scrollToPrevious: 'Scroll to previous suggestions', /** * @description The title of the button that copies the AI-generated response to the clipboard. */ copyResponse: 'Copy response', /** * @description The error message when the request to the LLM failed for some reason. */ systemError: 'Something unforeseen happened and I can no longer continue. Try your request again and see if that resolves the issue. If this keeps happening, update Chrome to the latest version.', /** * @description The error message when the LLM gets stuck in a loop (max steps reached). */ maxStepsError: 'Seems like I am stuck with the investigation. It would be better if you start over.', /** * @description The error message when the LLM selects context from a different origin. */ crossOriginError: 'I have selected the new context but you will have to start a new chat.', /** * @description Displayed when the user stop the response */ stoppedResponse: 'You stopped this response', /** * @description Button text that confirm code execution that may affect the page. */ confirmActionRequestApproval: 'Continue', /** * @description Button text that cancels code execution that may affect the page. */ declineActionRequestApproval: 'Cancel', /** * @description The generic name of the AI agent (do not translate) */ ai: 'AI', /** * @description Gemini (do not translate) */ gemini: 'Gemini', /** * @description The fallback text when a step has no title yet */ investigating: 'Investigating', /** * @description Prefix to the title of each thinking step of a user action is required to continue */ paused: 'Paused', /** * @description Heading text for the code block that shows the executed code. */ codeExecuted: 'Code executed', /** * @description Heading text for the code block that shows the code to be executed after side effect confirmation. */ codeToExecute: 'Code to execute', /** * @description Heading text for the code block that shows the returned data. */ dataReturned: 'Data returned', /** * @description Aria label for the check mark icon to be read by screen reader */ completed: 'Completed', /** * @description Aria label for the spinner to be read by screen reader when a step is in progress. */ inProgress: 'In progress', /** * @description Aria label for the aborted icon to be read by screen reader */ aborted: 'Aborted', /** * @description Alt text for the image input (displayed in the chat messages) that has been sent to the model. */ imageInputSentToTheModel: 'Image input sent to the model', /** * @description Title for the link which wraps the image input rendered in chat messages. */ openImageInNewTab: 'Open image in a new tab', /** * @description Alt text for image when it is not available. */ imageUnavailable: 'Image unavailable', /** * @description Title for the button that takes the user into other DevTools panels to reveal items the AI references. */ reveal: 'Reveal', /** * @description Title used for revealing the performance trace. */ revealTrace: 'Reveal trace', /** * @description Title for the core web vitals widget. */ coreVitals: 'Core Web Vitals', /** * @description Title for the LCP breakdown widget. */ lcpBreakdown: 'LCP breakdown', /** * @description Title for the LCP element widget. */ lcpElement: 'LCP element', /** * @description Title for the performance summary widget. */ performanceSummary: 'Performance summary', /** * @description The title of the button that allows exporting the conversation for agents. */ exportForAgents: 'Copy to coding agent', /** * @description Title for the bottom up thread activity widget. */ bottomUpTree: 'Bottom-up thread activity', /** * @description Accessilility label for the button that shows the walkthrough when there are no widgets in the walkthrough. */ showThinking: 'Show thinking', /** * @description Accessilility label for the button that hides the walkthrough when there are no widgets in the walkthrough. */ hideThinking: 'Hide thinking', } as const; export interface Step { isLoading: boolean; thought?: string; title?: string; code?: string; output?: string; widgets?: AiWidget[]; canceled?: boolean; requestApproval?: ConfirmSideEffectDialog; contextDetails?: [AiAssistanceModel.AiAgent.ContextDetail, ...AiAssistanceModel.AiAgent.ContextDetail[]]; } export interface ConfirmSideEffectDialog { description: string|null; onAnswer: (result: boolean) => void; } export const enum ChatMessageEntity { MODEL = 'model', USER = 'user', } export interface AnswerPart { type: 'answer'; text: string; suggestions?: [string, ...string[]]; } export interface StepPart { type: 'step'; step: Step; } export interface WidgetPart { type: 'widget'; widgets: AiWidget[]; } export type ModelMessagePart = AnswerPart|StepPart|WidgetPart; export interface UserChatMessage { entity: ChatMessageEntity.USER; text: string; imageInput?: Host.AidaClient.Part; } export interface ModelChatMessage { entity: ChatMessageEntity.MODEL; parts: ModelMessagePart[]; error?: AiAssistanceModel.AiAgent.ErrorType; rpcId?: Host.AidaClient.RpcGlobalId; } export type Message = UserChatMessage|ModelChatMessage; export interface RatingViewInput { currentRating?: Host.AidaClient.Rating; onRatingClick: (rating: Host.AidaClient.Rating) => void; showRateButtons: boolean; } export interface ActionViewInput { onReportClick: () => void; onCopyResponseClick: () => void; onExportClick?: () => void; showActions: boolean; } export interface SuggestionViewInput { suggestions?: [string, ...string[]]; scrollSuggestionsScrollContainer: (direction: 'left'|'right') => void; onSuggestionsScrollOrResize: () => void; onSuggestionClick: (suggestion: string) => void; } export interface FeedbackFormViewInput { isShowingFeedbackForm: boolean; onSubmit: (event: SubmitEvent) => void; onClose: () => void; onInputChange: (input: string) => void; isSubmitButtonDisabled: boolean; } export type ChatMessageViewInput = MessageInput&RatingViewInput&ActionViewInput&SuggestionViewInput&FeedbackFormViewInput; export interface ViewOutput { suggestionsLeftScrollButtonContainer?: Element; suggestionsScrollContainer?: Element; suggestionsRightScrollButtonContainer?: Element; } export interface MessageInput { suggestions?: [string, ...string[]]; message: Message; isLoading: boolean; isReadOnly: boolean; isLastMessage: boolean; isFirstMessage: boolean; canShowFeedbackForm: boolean; markdownRenderer: MarkdownLitRenderer; onSuggestionClick: (suggestion: string) => void; onFeedbackSubmit: (rpcId: Host.AidaClient.RpcGlobalId, rate: Host.AidaClient.Rating, feedback?: string) => void; onCopyResponseClick: (message: ModelChatMessage) => void; onExportClick?: () => void; changeSummary?: string; walkthrough: { onOpen: (message: ModelChatMessage) => void, isExpanded: boolean, onToggle: (isOpen: boolean, message: ModelChatMessage) => void, isInlined: boolean, activeSidebarMessage: ModelChatMessage|null, inlineExpandedMessages: ModelChatMessage[], }; } export const DEFAULT_VIEW = (input: ChatMessageViewInput, output: ViewOutput, target: HTMLElement): void => { const hasAiV2 = Boolean(Root.Runtime.hostConfig.devToolsAiAssistanceV2?.enabled); const message = input.message; if (message.entity === ChatMessageEntity.USER) { const imageInput = message.imageInput && 'inlineData' in message.imageInput ? renderImageChatMessage(message.imageInput.inlineData) : Lit.nothing; const messageClasses = Lit.Directives.classMap({ 'chat-message': true, query: true, 'is-last-message': input.isLastMessage, 'is-first-message': input.isFirstMessage, 'ai-v2': hasAiV2, }); const userQueryWrapperClasses = Lit.Directives.classMap({ // Don't need to style at all unless we are on the V2 flag. // Once we ship this can be removed entirely. 'user-query-wrapper': hasAiV2 }); // clang-format off Lit.render(html` <style>${Input.textInputStyles}</style> <style>${chatMessageStyles}</style> <div class=${userQueryWrapperClasses}> <section class=${messageClasses} jslog=${VisualLogging.section('question')}> ${imageInput} <div class="message-content">${renderTextAsMarkdown(message.text, input.markdownRenderer)}</div> </section> </div> `, target); // clang-format on return; } const steps = message.parts.filter(part => part.type === 'step').map(part => part.step); const icon = AiAssistanceModel.AiUtils.getIconName(); const messageClasses = Lit.Directives.classMap({ 'chat-message': true, answer: true, 'is-last-message': input.isLastMessage, 'is-first-message': input.isFirstMessage, 'ai-v2': hasAiV2, }); // clang-format off Lit.render(html` <style>${Input.textInputStyles}</style> <style>${chatMessageStyles}</style> <section class=${messageClasses} jslog=${VisualLogging.section('answer')}> ${hasAiV2 ? Lit.nothing : html` <div class="message-info"> <devtools-icon name=${icon}></devtools-icon> <div class="message-name"> <h2>${AiAssistanceModel.AiUtils.isGeminiBranding() ? lockedString(UIStringsNotTranslate.gemini) : lockedString(UIStringsNotTranslate.ai)}</h2> </div> </div>`} ${hasAiV2 ? renderWalkthroughUI(input, steps) : Lit.nothing} <div class="answer-body-wrapper"> ${Lit.Directives.repeat( message.parts, (_, index) => index, (part, index) => { const isLastPart = index === message.parts.length - 1; if (part.type === 'answer') { return html`<p>${renderTextAsMarkdown(part.text, input.markdownRenderer, { animate: !input.isReadOnly && input.isLoading && isLastPart && input.isLastMessage })}</p>`; } if (part.type === 'widget') { return html`${Lit.Directives.until(renderWidgets(part.widgets, {wrapperClass: 'main-widgets-wrapper'}))}`; } if (!hasAiV2 && part.type === 'step') { return renderStep({ step: part.step, isLoading: input.isLoading, markdownRenderer: input.markdownRenderer, isLast: isLastPart, }); } return Lit.nothing; }, )} ${renderError(message)} ${input.isLastMessage && hasAiV2 && !input.isLoading && input.changeSummary ? html` <devtools-code-block .code=${input.changeSummary} .codeLang=${'css'} .displayLimit=${MAX_NUM_LINES_IN_CODEBLOCK} .displayNotice=${true} class="ai-css-change" ></devtools-code-block> ` : Lit.nothing} ${input.showActions ? renderActions(input, output) : Lit.nothing} </div> ${hasAiV2 ? renderSideEffectStepsUI(input, steps) : Lit.nothing} </section> `, target); // clang-format on }; export type View = typeof DEFAULT_VIEW; function renderTextAsMarkdown(text: string, markdownRenderer: MarkdownLitRenderer, {animate, ref: refFn}: { animate?: boolean, ref?: (element?: Element) => void, } = {}): Lit.TemplateResult { let tokens = []; try { tokens = Marked.Marked.lexer(text); for (const token of tokens) { // Try to render all the tokens to make sure that // they all have a template defined for them. If there // isn't any template defined for a token, we'll fallback // to rendering the text as plain text instead of markdown. markdownRenderer.renderToken(token); } } catch { // The tokens were not parsed correctly or // one of the tokens are not supported, so we // continue to render this as text. return html`${text}`; } // clang-format off return html`<devtools-markdown-view .data=${{tokens, renderer: markdownRenderer, animationEnabled: animate} as MarkdownView.MarkdownView.MarkdownViewData} ${refFn ? ref(refFn) : Lit.nothing}> </devtools-markdown-view>`; // clang-format on } export function titleForStep(step: Step): string { return step.title ?? `${lockedString(UIStringsNotTranslate.investigating)}…`; } function renderTitle(step: Step): Lit.LitTemplate { const paused = step.requestApproval ? html`<span class="paused">${lockedString(UIStringsNotTranslate.paused)}: </span>` : Lit.nothing; return html`<span class="title" aria-label=${titleForStep(step)}>${paused}${titleForStep(step)}</span>`; } function renderStepCode(step: Step): Lit.LitTemplate { if (!step.code && !step.output) { return Lit.nothing; } // If there is no "output" yet, it means we didn't execute the code yet (e.g. maybe it is still waiting for confirmation from the user) // thus we show "Code to execute" text rather than "Code executed" text on the heading of the code block. const codeHeadingText = (step.output && !step.canceled) ? lockedString(UIStringsNotTranslate.codeExecuted) : lockedString(UIStringsNotTranslate.codeToExecute); // If there is output, we don't show notice on this code block and instead show // it in the data returned code block. // clang-format off const code = step.code ? html`<div class="action-result"> <devtools-code-block .code=${step.code.trim()} .codeLang=${'js'} .displayNotice=${!Boolean(step.output)} .header=${codeHeadingText} .showCopyButton=${true} ></devtools-code-block> </div>` : Lit.nothing; const output = step.output ? html`<div class="js-code-output"> <devtools-code-block .code=${step.output} .codeLang=${'js'} .displayNotice=${true} .header=${lockedString(UIStringsNotTranslate.dataReturned)} .showCopyButton=${false} ></devtools-code-block> </div>` : Lit.nothing; return html`<div class="step-code">${code}${output}</div>`; // clang-format on } function renderStepDetails({ step, markdownRenderer, isLast, }: { step: Step, markdownRenderer: MarkdownLitRenderer, isLast: boolean, }): Lit.LitTemplate { const sideEffects = isLast && step.requestApproval ? renderSideEffectConfirmationUi(step) : Lit.nothing; const thought = step.thought ? html`<p>${renderTextAsMarkdown(step.thought, markdownRenderer)}</p>` : Lit.nothing; // clang-format off const contextDetails = step.contextDetails ? html`${Lit.Directives.repeat( step.contextDetails, contextDetail => { return html`<div class="context-details"> <devtools-code-block .code=${contextDetail.text} .codeLang=${contextDetail.codeLang || ''} .displayNotice=${false} .header=${contextDetail.title} .showCopyButton=${true} ></devtools-code-block> </div>`; }, )}` : Lit.nothing; return html`<div class="step-details"> ${thought} ${renderStepCode(step)} ${sideEffects} ${contextDetails} </div>`; // clang-format on } function renderWalkthroughSidebarButton( input: ChatMessageViewInput, steps: Step[], ): Lit.LitTemplate { const {message, walkthrough} = input; const lastStep = steps.at(-1); if (walkthrough.isInlined || !lastStep) { return Lit.nothing; } const hasOneStepWithWidget = steps.some(step => step.widgets?.length); const isExpanded = walkthrough.isExpanded && input.message === input.walkthrough.activeSidebarMessage; const title = isExpanded ? walkthroughCloseTitle({hasWidgets: hasOneStepWithWidget}) : walkthroughTitle({ isLoading: input.isLoading, hasWidgets: hasOneStepWithWidget, lastStep, }); // The button should be tonal when there are widgets, but we only // want to change it visually at the end once everything has stopped // loading. const variant = hasOneStepWithWidget && !input.isLoading ? Buttons.Button.Variant.TONAL : Buttons.Button.Variant.TEXT; const icon = AiAssistanceModel.AiUtils.getIconName(); const toggleContainerClasses = Lit.Directives.classMap({ 'walkthrough-toggle-container': true, // We only apply the widget styling when loading is complete 'has-widgets': hasOneStepWithWidget && !input.isLoading, }); let accessibleLabel = title; // If the agent is still thinking we want the accessibility label to include the current step title followed by Show/Hide thinking. if (input.isLoading) { const suffix = isExpanded ? UIStringsNotTranslate.hideThinking : UIStringsNotTranslate.showThinking; accessibleLabel = `${titleForStep(lastStep)} ${i18n.i18n.lockedString(suffix)}`; } // clang-format off return html` <div class=${toggleContainerClasses}> ${input.isLoading ? html`<devtools-spinner></devtools-spinner>` : html`<devtools-icon name=${icon}></devtools-icon>`} <devtools-button .variant=${variant} .size=${Buttons.Button.Size.SMALL} .title=${lastStep.isLoading ? titleForStep(lastStep) : title} .accessibleLabel=${accessibleLabel} .jslogContext=${walkthrough.isExpanded ? 'ai-hide-walkthrough-sidebar' : 'ai-show-walkthrough-sidebar'} data-show-walkthrough @click=${() => { if(walkthrough.activeSidebarMessage === input.message && walkthrough.isExpanded) { walkthrough.onToggle(false, message as ModelChatMessage); } else { // Can't just toggle the visibility here; we need to ensure we // update the state with this message as the user could have had // the walkthrough open with an alternative message. walkthrough.onOpen(message as ModelChatMessage); } }}>${title}<devtools-icon class="chevron" .name=${isExpanded ? 'cross' : 'chevron-right'}></devtools-icon> </devtools-button> </div> `; // clang-format on } /** * Responsible for rendering the AI Walkthrough UI. This can take different * shapes and involve different parts depending on if the walkthrough is * inlined, expanded, or if we have side-effect steps. In cases where the * walkthrough is closed, side-effect steps are rendered inline in the chat. */ function renderWalkthroughUI(input: ChatMessageViewInput, steps: Step[]): Lit.LitTemplate { const lastStep = steps.at(-1); if (!lastStep) { // No steps = no walkthrough UI in the chat view. return Lit.nothing; } // If the walkthrough is in the sidebar, we render a button into the // ChatView to open it. const openWalkThroughSidebarButton = !input.walkthrough.isInlined ? renderWalkthroughSidebarButton(input, steps) : Lit.nothing; // A message's walkthrough is considered expanded if the walkthrough is // open and it is specifically targeting this message. This is necessary // because the walkthrough state is shared across all messages in the chat. const isExpanded = input.walkthrough.isInlined ? input.walkthrough.inlineExpandedMessages.includes(input.message as ModelChatMessage) : (input.walkthrough.isExpanded && input.walkthrough.activeSidebarMessage === input.message); // clang-format off const walkthroughInline = input.walkthrough.isInlined ? html` <div class="walkthrough-container"> ${widget(WalkthroughView, { message: input.message as ModelChatMessage, isLoading: input.isLoading && input.isLastMessage, markdownRenderer: input.markdownRenderer, isInlined: true, isExpanded, onToggle: input.walkthrough.onToggle, onOpen: input.walkthrough.onOpen, })} </div> ` : Lit.nothing; return html` ${openWalkThroughSidebarButton} ${walkthroughInline} `; // clang-format on } function renderSideEffectStepsUI(input: ChatMessageViewInput, steps: Step[]): Lit.LitTemplate { const sideEffectSteps = steps.filter(s => s.requestApproval); if (sideEffectSteps.length === 0) { return Lit.nothing; } // clang-format off return html` ${sideEffectSteps.map(step => html` <div class="side-effect-container"> ${renderStep({ step, isLoading: input.isLoading, markdownRenderer: input.markdownRenderer, isLast: true })} </div> `)} `; // clang-format on } function renderStepBadge({step, isLoading, isLast}: { step: Step, isLoading: boolean, isLast: boolean, }): Lit.LitTemplate { if (isLoading && isLast && !step.requestApproval) { return html`<devtools-spinner aria-label=${lockedString(UIStringsNotTranslate.inProgress)}></devtools-spinner>`; } let iconName = 'checkmark'; let ariaLabel: string|undefined = lockedString(UIStringsNotTranslate.completed); let role: 'button'|undefined = 'button'; if (isLast && step.requestApproval) { role = undefined; ariaLabel = lockedString(UIStringsNotTranslate.paused); iconName = 'pause-circle'; } else if (step.canceled) { ariaLabel = lockedString(UIStringsNotTranslate.aborted); iconName = 'cross'; } return html`<devtools-icon class="indicator" role=${ifDefined(role)} aria-label=${ifDefined(ariaLabel)} .name=${iconName} ></devtools-icon>`; } export function renderStep({step, isLoading, markdownRenderer, isLast}: { step: Step, isLoading: boolean, markdownRenderer: MarkdownLitRenderer, isLast: boolean, }): Lit.LitTemplate { const stepClasses = Lit.Directives.classMap({ step: true, empty: !step.thought && !step.code && !step.contextDetails && !step.requestApproval, paused: Boolean(step.requestApproval), canceled: Boolean(step.canceled), }); // clang-format off return html` <details class=${stepClasses} jslog=${VisualLogging.expand('step').track({click: true})} .open=${Boolean(step.requestApproval)}> <summary> <div class="summary"> ${renderStepBadge({ step, isLoading, isLast })} ${renderTitle(step)} <devtools-icon class="arrow" name="chevron-down" ></devtools-icon> </div> </summary> ${renderStepDetails({step, markdownRenderer, isLast})} </details> ${Lit.Directives.until(renderWidgets(step.widgets, {wrapperClass: 'step-widgets-wrapper'}))} `; // clang-format on } interface WidgetMakerResponse { // Can be null if the widget is only used to add the Reveal CTA. renderedWidget: Lit.LitTemplate|null; revealable: unknown; customRevealTitle?: Platform.UIString.LocalizedString; // Can be null if the widget is only used to add the Reveal CTA. title: Lit.LitTemplate|Platform.UIString.LocalizedString|null; jslogContext?: string; } const nodeCache = new Map<Protocol.DOM.BackendNodeId, SDK.DOMModel.DOMNode>(); async function resolveNode(backendNodeId: Protocol.DOM.BackendNodeId): Promise<SDK.DOMModel.DOMNode|null> { const cachedNode = nodeCache.get(backendNodeId); if (cachedNode) { return cachedNode; } const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); if (!target) { return null; } const node = new SDK.DOMModel.DeferredDOMNode(target, backendNodeId); const resolved = await node.resolvePromise(); if (resolved) { nodeCache.set(backendNodeId, resolved); } return resolved; } async function makeComputedStyleWidget(widgetData: ComputedStyleAiWidget): Promise<WidgetMakerResponse|null> { const domNodeForId = await resolveNode(widgetData.data.backendNodeId); if (!domNodeForId) { return null; } const styles = new ComputedStyle.ComputedStyleModel.ComputedStyle(domNodeForId, widgetData.data.computedStyles); let filterText: RegExp|null = null; try { filterText = new RegExp(widgetData.data.properties.join('|'), 'i'); } catch { // If the AI provides an invalid regex (e.g. "*"), we don't want to crash. // We can just skip the widget in this case. return null; } // clang-format off const renderedWidget = html`<devtools-widget class="computed-styles-widget" ${widget(Elements.ComputedStyleWidget.ComputedStyleWidget, { nodeStyle: styles, matchedStyles: widgetData.data.matchedCascade, // This disables showing the nested traces and detailed information in the widget. propertyTraces: null, allowUserControl: false, filterText, enableNarrowViewResizing: false, })}></devtools-widget>`; // clang-format on return { renderedWidget, revealable: new Elements.ElementsPanel.NodeComputedStyles(domNodeForId), // clang-format off title: html` <span class="computed-style-title-wrapper"> <span class="computed-style-title-prefix">Computed styles</span> <span class="style-class-wrapper"> (<devtools-widget ${widget(PanelsCommon.DOMLinkifier.DOMNodeLink, { node: domNodeForId, })} ></devtools-widget>) </span> </span>`, // clang-format on jslogContext: 'computed-styles', }; } async function makeCoreWebVitalsWidget(widgetData: CoreVitalsAiWidget): Promise<WidgetMakerResponse|null> { // clang-format off const renderedWidget = html`<devtools-widget class="core-vitals-widget" ${widget(TimelineComponents.CWVMetrics.CWVMetrics, {data: widgetData.data, skipBottomBorder: true})}> </devtools-widget>`; // clang-format on return { renderedWidget, revealable: new TimelineUtils.Helpers.RevealableCoreVitals(widgetData.data.insightSetKey), title: lockedString(UIStringsNotTranslate.coreVitals), jslogContext: 'core-web-vitals', }; } async function makeStylePropertiesWidget(widgetData: StylePropertiesAiWidget): Promise<WidgetMakerResponse|null> { const domNodeForId = await resolveNode(widgetData.data.backendNodeId); if (!domNodeForId) { return null; } let filter: RegExp|null = null; try { filter = widgetData.data.selector ? new RegExp(widgetData.data.selector) : null; } catch { // If the AI provides an invalid regex (e.g. "*"), we don't want to crash. // We can just skip the widget in this case. return null; } // clang-format off const renderedWidget = html`<devtools-widget class="styling-preview-widget" ${widget(Elements.StandaloneStylesContainer.StandaloneStylesContainer, { domNode: domNodeForId, filter, })}> </devtools-widget>`; // clang-format on return { renderedWidget, revealable: domNodeForId, title: html`<devtools-widget ${widget(PanelsCommon.DOMLinkifier.DOMNodeLink, { node: domNodeForId, })} ></devtools-widget>`, jslogContext: 'standalone-styles', }; } async function makeLcpBreakdownWidget(widgetData: LcpBreakdownAiWidget): Promise<WidgetMakerResponse|null> { const insight = widgetData.data.lcpData; if (!insight) { return null; } // clang-format off const renderedWidget = html`<devtools-widget class="lcp-breakdown-widget" ${widget(TimelineInsights.LCPBreakdown.LCPBreakdown, { model: insight, minimal: true, })}></devtools-widget>`; // clang-format on return { renderedWidget, revealable: new TimelineUtils.Helpers.RevealableInsight(insight), title: lockedString(UIStringsNotTranslate.lcpBreakdown), jslogContext: 'lcp-breakdown', }; } async function makeBottomUpTimelineTreeWidget(widgetData: BottomUpTreeAiWidget): Promise<WidgetMakerResponse|null> { const bottomUpRootNode = AiAssistanceModel.AIQueries.AIQueries.mainThreadActivityBottomUp( widgetData.data.bounds, widgetData.data.parsedTrace); if (!bottomUpRootNode) { return null; } const events = bottomUpRootNode.events; const startTime = Trace.Helpers.Timing.microToMilli(widgetData.data.bounds.min); const endTime = Trace.Helpers.Timing.microToMilli(widgetData.data.bounds.max); const renderedWidget = html`<devtools-widget class="bottom-up-timeline-tree-widget" ${widget(Timeline.TimelineTreeView.BottomUpTimelineTreeView, { selectedEvents: events, parsedTrace: widgetData.data.parsedTrace, startTime, endTime, compactMode: true, maxLinkLength: 15, maxRows: 10, })}></devtools-widget>`; return { renderedWidget, revealable: new TimelineUtils.Helpers.RevealableBottomUpProfile(widgetData.data.bounds), title: lockedString(UIStringsNotTranslate.bottomUpTree), jslogContext: 'bottom-up', }; } function renderWidgetResponse(response: WidgetMakerResponse|null): Lit.LitTemplate { if (response === null) { return Lit.nothing; } function onReveal(): void { if (response === null) { return; } void Common.Revealer.reveal(response?.revealable); } const classes = Lit.Directives.classMap({ 'widget-and-revealer-container': true, 'revealer-only': response.renderedWidget === null, }); const revealButton = html` <devtools-button class="widget-reveal-button" .variant=${Buttons.Button.Variant.TEXT} .accessibleLabel=${lockedString(UIStringsNotTranslate.reveal)} .jslogContext=${'reveal'} @click=${onReveal} > ${response.customRevealTitle ?? lockedString(UIStringsNotTranslate.reveal)} <devtools-icon name='tab-move'></devtools-icon> </devtools-button> `; // clang-format off return html` <div class=${classes} jslog=${ifDefined(response.jslogContext ? VisualLogging.section(response.jslogContext) : undefined)}> ${response.title ? html` <div class="widget-header"> <h3 class="widget-name">${response.title}</h3> <div class="widget-reveal-container"> ${revealButton} </div> </div> ` : Lit.nothing} ${response.renderedWidget ? html` <div class="widget-content-container"> ${response.renderedWidget} </div>` : Lit.nothing } ${!response.title ? html` <div class="widget-reveal-container"> ${revealButton} </div> ` : Lit.nothing} </div> `; // clang-format on } async function makePerformanceTraceWidget(widgetData: PerformanceTraceAiWidget): Promise<WidgetMakerResponse|null> { return { renderedWidget: null, title: null, revealable: new Timeline.TimelinePanel.ParsedTraceRevealable(widgetData.data.parsedTrace), customRevealTitle: lockedString(UIStringsNotTranslate.revealTrace), jslogContext: 'performance-trace', }; } function renderNetworkRequestPreview(networkRequest: NonNullable<DomTreeAiWidget['data']['networkRequest']>): Lit.TemplateResult { const filename = networkRequest.url.split('/').pop() || networkRequest.url; const size = i18n.ByteUtilities.bytesToString(networkRequest.size); const resourceType = Common.ResourceType.resourceTypes[networkRequest.resourceType]; const {iconName, color} = PanelUtils.iconDataForResourceType(resourceType); return html` <div class="network-request-preview"> <div class="network-request-header"> <div class="network-request-icon"> ${ resourceType.isImage() ? html`<img src=${networkRequest.imageUrl ?? networkRequest.url} alt=${filename} />` : html`<devtools-icon name=${iconName} style=${Lit.Directives.styleMap({ color: color ?? '' })}></devtools-icon>`} </div> <div class="network-request-details"> <div class="network-request-name" title=${networkRequest.url}>${filename}</div> <div class="network-request-size">${size}</div> </div> </div> </div> `; } async function makeDomTreeWidget(widgetData: DomTreeAiWidget): Promise<WidgetMakerResponse|null> { const root = widgetData.data.root; if (!(root instanceof SDK.DOMModel.DOMNodeSnapshot)) { return null; } const networkRequest = widgetData.data.networkRequest; // clang-format off const renderedWidget = html` ${networkRequest ? renderNetworkRequestPreview(networkRequest) : Lit.nothing} <devtools-widget class="dom-tree-widget" ${widget(Elements.ElementsTreeOutline.DOMTreeWidget, { maxTreeDepth: 2, enableContextMenu: false, showComments: false, showAIButton: false, disableEdits: true, expandRoot: true, rootDOMNode: root, visibleWidth: 400, wrap: true, maxRows: 10, })}></devtools-widget> `; // clang-format on return { renderedWidget, revealable: new SDK.DOMModel.DeferredDOMNode(root.domModel().target(), root.backendNodeId()), title: lockedString(UIStringsNotTranslate.lcpElement), jslogContext: 'dom-snapshot', }; } /** * Renders AI-defined UI widgets. * When a ModelChatMessage contains a WidgetPart, or a Step has widgets, * the ChatMessage component iterates through the \`widgets\` array. * For each widget, it determines the appropriate rendering logic based on * the \`widgetData.name\`. * * Currently, 'COMPUTED_STYLES', 'CORE_VITALS' and 'STYLE_PROPERTIES' widgets are supported. * For these, the corresponding \`make...Widget\` functions are called to construct the necessary * data and configuration for the UI components. The widget is then rendered using the * \`<devtools-widget>\` custom element, which dynamically instantiates and displays the * specified UI.Widget subclass with the provided configuration. * * This allows for a flexible and extensible system where new widget types * can be added to the AI responses and rendered in DevTools by adding * corresponding \`make...Widget\` functions and handling them here. */ async function renderWidgets( widgets: AiWidget[]|undefined, options: {wrapperClass?: string} = {}): Promise<Lit.LitTemplate> { if (!Root.Runtime.hostConfig.devToolsAiAssistanceV2?.enabled || !widgets || widgets.length === 0) { return Lit.nothing; } const ui = await Promise.all(widgets.map(async widgetData => { let response: WidgetMakerResponse|null = null; switch (widgetData.name) { case 'COMPUTED_STYLES': response = await makeComputedStyleWidget(widgetData); break; case 'CORE_VITALS': response = await makeCoreWebVitalsWidget(widgetData); break; case 'STYLE_PROPERTIES': response = await makeStylePropertiesWidget(widgetData); break; case 'DOM_TREE': response = await makeDomTreeWidget(widgetData); break; case 'PERFORMANCE_TRACE': response = await makePerformanceTraceWidget(widgetData); break; case 'LCP_BREAKDOWN': response = await makeLcpBreakdownWidget(widgetData); break; case 'TIMELINE_RANGE_SUMMARY': response = await makeTimelineRangeSummaryWidget(widgetData); break; case 'BOTTOM_UP_TREE': response = await makeBottomUpTimelineTreeWidget(widgetData); break; default: Platform.assertNever(widgetData, 'Unknown AiWidget name'); } return renderWidgetResponse(response); })); if (options.wrapperClass) { return html`<div class=${options.wrapperClass}>${ui}</div>`; } return html`${ui}`; } function renderSideEffectConfirmationUi(step: Step): Lit.LitTemplate { if (!step.requestApproval) { return Lit.nothing; } // clang-format off return html`<div class="side-effect-confirmation" jslog=${VisualLogging.section('side-effect-confirmation')} > ${step.requestApproval.description ? html`<p>${step.requestApproval.description}</p>` : Lit.nothing} <div class="side-effect-buttons-container"> <devtools-button .data=${ { variant: Buttons.Button.Variant.OUTLINED, jslogContext: 'decline-execute-code', } as Buttons.Button.ButtonData } @click=${() => step.requestApproval?.onAnswer(false)} >${lockedString( UIStringsNotTranslate.declineActionRequestApproval, )}</devtools-button> <devtools-button .data=${ { variant: Buttons.Button.Variant.PRIMARY, jslogContext: 'accept-execute-code', iconName: 'play', } as Buttons.Button.ButtonData } @click=${() => step.requestApproval?.onAnswer(true)} >${ lockedString(UIStringsNotTranslate.confirmActionRequestApproval) }</devtools-button> </div> </div>`; // clang-format on } function renderError(message: ModelChatMessage): Lit.LitTemplate { if (message.error) { let errorMessage; switch (message.error) { case AiAssistanceModel.AiAgent.ErrorType.UNKNOWN: case AiAssistanceModel.AiAgent.ErrorType.BLOCK: errorMessage = UIStringsNotTranslate.systemError; break; case AiAssistanceModel.AiAgent.ErrorType.MAX_STEPS: errorMessage = UIStringsNotTranslate.maxStepsError; break; case AiAssistanceModel.AiAgent.ErrorType.CROSS_ORIGIN: errorMessage = UIStringsNotTranslate.crossOriginError; break; case AiAssistanceModel.AiAgent.ErrorType.ABORT: return html`<p class="aborted" jslog=${VisualLogging.section('aborted')}>${ lockedString(UIStringsNotTranslate.stoppedResponse)}</p>`; } return html`<p class="error" jslog=${VisualLogging.section('error')}>${lockedString(errorMessage)}</p>`; } return Lit.nothing; } function renderImageChatMessage(inlineData: Host.AidaClient.MediaBlob): Lit.LitTemplate { if (inlineData.data === AiAssistanceModel.AiConversation.NOT_FOUND_IMAGE_DATA) { // clang-format off return html`<div class="unavailable-image" title=${UIStringsNotTranslate.imageUnavailable}> <devtools-icon name='file-image'></devtools-icon> </div>`; // clang-format on } const imageUrl = `data:${inlineData.mimeType};base64,${inlineData.data}`; // clang-format off return html`<devtools-link class="image-link" title=${UIStringsNotTranslate.openImageInNewTab} href=${imageUrl} > <img src=${imageUrl} alt=${UIStringsNotTranslate.imageInputSentToTheModel} /> </devtools-link>`; // clang-format on } function renderActions(input: ChatMessageViewInput, output: ViewOutput): Lit.LitTemplate { const aiAssistanceV2 = Root.Runtime.hostConfig.devToolsAiAssistanceV2?.enabled; const rowClasses = Lit.Directives.classMap({ 'ai-assistance-feedback-row': true, 'not-v2': !aiAssistanceV2, }); // clang-format off return html` <div class=${rowClasses}> <div class="action-buttons"> ${input.showRateButtons ? html` <devtools-button .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'thumb-up', toggledIconName: 'thumb-up-filled', toggled: input.currentRating === Host.AidaClient.Rating.POSITIVE, toggleType: Buttons.Button.ToggleType.PRIMARY, title: lockedString(UIStringsNotTranslate.thumbsUp), jslogContext: 'thumbs-up', } as Buttons.Button.ButtonData} @click=${() => input.onRatingClick(Host.AidaClient.Rating.POSITIVE)} ></devtools-button> <devtools-button .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'thumb-down', toggledIconName: 'thumb-down-filled', toggled: input.currentRating === Host.AidaClient.Rating.NEGATIVE, toggleType: Buttons.Button.ToggleType.PRIMARY, title: lockedString(UIStringsNotTranslate.thumbsDown), jslogContext: 'thumbs-down', } as Buttons.Button.ButtonData} @click=${() => input.onRatingClick(Host.AidaClient.Rating.NEGATIVE)} ></devtools-button> ${aiAssistanceV2 ? Lit.nothing : html`<div class="vertical-separator"></div>`} `: Lit.nothing} <devtools-button .data=${ { variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, title: lockedString(UIStringsNotTranslate.report), iconName: 'report', jslogContext: 'report', } as Buttons.Button.ButtonData } @click=${input.onReportClick} ></devtools-button> ${aiAssistanceV2 ? Lit.nothing : html` <div class="vertical-separator"></div> <devtools-button .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, title: lockedString(UIStringsNotTranslate.copyResponse), iconName: 'copy', jslogContext: 'copy-ai-response', } as Buttons.Button.ButtonData} aria-label=${lockedString(UIStringsNotTranslate.copyResponse)} @click=${input.onCopyResponseClick}></devtools-button> `} ${input.onExportClick && aiAssistanceV2 && input.isLastMessage ? html` <devtools-button class="export-for-agents-button" .jslogContext=${'ai-export-for-agents'} .variant=${Buttons.Button.Variant.OUTLINED} .iconName=${'copy'} aria-label=${lockedString(UIStringsNotTranslate.exportForAgents)} @click=${input.onExportClick} >${lockedString(UIStringsNotTranslate.exportForAgents)}</devtools-button> ${input.suggestions ? html`<div class="vertical-separator"></div>` : Lit.nothing} ` : Lit.nothing} </div> ${input.suggestions ? html`<div class="suggestions-container"> <div class="scroll-button-container left hidden" ${ref(element => { output.suggestionsLeftScrollButtonContainer = element; } )}> <devtools-button class='scroll-button' .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'chevron-left', title: lockedString(UIStringsNotTranslate.scrollToPrevious), jslogContext: 'chevron-left', } as Buttons.Button.ButtonData} @click=${() => input.scrollSuggestionsScrollContainer('left')} ></devtools-button> </div> <div class="suggestions-scroll-container" @scroll=${input.onSuggestionsScrollOrResize} ${ref(element => { output.suggestionsScrollContainer = element; })}> ${input.suggestions.map(suggestion => html`<devtools-button class='suggestion' .data=${{ variant: Buttons.Button.Variant.OUTLINED, title: suggestion, jslogContext: 'suggestion', } as Buttons.Button.ButtonData} @click=${() => input.onSuggestionClick(suggestion)} >${suggestion}</devtools-button>`)} </div> <div class="scroll-button-container right hidden" ${ref(element => { output.suggestionsRightScrollButtonContainer = element; })}> <devtools-button class='scroll-button' .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'chevron-right', title: lockedString(UIStringsNotTranslate.scrollToNext), jslogContext: 'chevron-right', } as Buttons.Button.ButtonData} @click=${() => input.scrollSuggestionsScrollContainer('right')} ></devtools-button> </div> </div>` : Lit.nothing} </div> ${input.isShowingFeedbackForm ? html` <form class="feedback-form" @submit=${input.onSubmit}> <div class="feedback-header"> <h4 class="feedback-title">${lockedString( UIStringsNotTranslate.whyThisRating, )}</h4> <devtools-button aria-label=${lockedString(UIStringsNotTranslate.close)} @click=${input.onClose} .data=${ { variant: Buttons.Button.Variant.ICON, iconName: 'cross', size: Buttons.Button.Size.SMALL, title: lockedString(UIStringsNotTranslate.close), jslogContext: 'close', } as Buttons.Button.ButtonData } ></devtools-button> </div> <input type="text" class="devtools-text-input feedback-input" @input=${(event: KeyboardEvent) => input.onInputChange((event.target as HTMLInputElement).value)} placeholder=${lockedString( UIStringsNotTranslate.provideFeedbackPlaceholder, )} jslog=${VisualLogging.textField('feedback').track({ keydown: 'Enter' })} > <span class="feedback-disclaimer">${ lockedString(UIStringsNotTranslate.disclaimer) }</span> <div> <devtools-button aria-label=${lockedString(UIStringsNotTranslate.submit)} .data=${ { type: 'submit', disabled: input.isSubmitButtonDisabled, variant: Buttons.Button.Variant.OUTLINED, size: Buttons.Button.Size.SMALL, title: lockedString(UIStringsNotTranslate.submit), jslogContext: 'send', } as Buttons.Button.ButtonData } >${ lockedString(UIStringsNotTranslate.submit) }</devtools-button> </div> </div> </form>