UNPKG

chrome-devtools-frontend

Version:
1,269 lines (1,179 loc) • 47.5 kB
// Copyright 2023 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/spinners/spinners.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 type * as Platform from '../../../core/platform/platform.js'; import * as Root from '../../../core/root/root.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 * as MarkdownView from '../../../ui/components/markdown_view/markdown_view.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 Console from '../../console/console.js'; import styles from './consoleInsight.css.js'; // Note: privacy and legal notices are not localized so far. const UIStrings = { /** * @description The title of the insight source "Console message". */ consoleMessage: 'Console message', /** * @description The title of the insight source "Stacktrace". */ stackTrace: 'Stacktrace', /** * @description The title of the insight source "Network request". */ networkRequest: 'Network request', /** * @description The title of the insight source "Related code". */ relatedCode: 'Related code', /** * @description The title that is shown while the insight is being generated. */ generating: 'Generating explanation…', /** * @description The header that indicates that the content shown is a console * insight. */ insight: 'Explanation', /** * @description The title of the a button that closes the insight pane. */ closeInsight: 'Close explanation', /** * @description The title of the list of source data that was used to generate the insight. */ inputData: 'Data used to understand this message', /** * @description The title of the button that allows submitting positive * feedback about the console insight. */ goodResponse: 'Good response', /** * @description The title of the button that allows submitting negative * feedback about the console insight. */ badResponse: 'Bad response', /** * @description The title of the button that opens a page to report a legal * issue with the console insight. */ report: 'Report legal issue', /** * @description The text of the header inside the console insight pane when there was an error generating an insight. */ error: 'DevTools has encountered an error', /** * @description The message shown when an error has been encountered. */ errorBody: 'Something went wrong. Try again.', /** * @description Label for screen readers that is added to the end of the link * title to indicate that the link will be opened in a new tab. */ opensInNewTab: '(opens in a new tab)', /** * @description The title of a link that allows the user to learn more about * the feature. */ learnMore: 'Learn more', /** * @description The error message when the user is not logged in into Chrome. */ notLoggedIn: 'This feature is only available when you sign into Chrome with your Google account.', /** * @description The title of a button which opens the Chrome SignIn page. */ signIn: 'Sign in', /** * @description The header shown when the internet connection is not * available. */ offlineHeader: 'DevTools can’t reach the internet', /** * @description Message shown when the user is offline. */ offline: 'Check your internet connection and try again.', /** * @description The message shown if the user is not logged in. */ signInToUse: 'Sign in to use this feature', /** * @description The title of the button that searches for the console * insight using a search engine instead of using console insights. */ search: 'Use search instead', /** * @description Shown to the user when the network request data is not * available and a page reload might populate it. */ reloadRecommendation: 'Reload the page to capture related network request data for this message in order to create a better insight.', /** * @description Shown to the user when they need to enable the console insights feature in settings in order to use it. * @example {Console insights in Settings} PH1 */ turnOnInSettings: 'Turn on {PH1} to receive AI assistance for understanding and addressing console warnings and errors.', /** * @description Text for a link to Chrome DevTools Settings. */ settingsLink: '`Console insights` in Settings', /** * @description The title of the list of references/recitations that were used to generate the insight. */ references: 'Sources and related content', /** * @description Sub-heading for a list of links to URLs which are related to the AI-generated response. */ relatedContent: 'Related content', /** * @description Error message shown when the request to get an AI response times out. */ timedOut: 'Generating a response took too long. Please try again.', /** * @description Text informing the user that AI assistance is not available in Incognito mode or Guest mode. */ notAvailableInIncognitoMode: 'AI assistance is not available in Incognito mode or Guest mode', } as const; const str_ = i18n.i18n.registerUIStrings('panels/explain/components/ConsoleInsight.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const i18nTemplate = Lit.i18nTemplate.bind(undefined, str_); const {render, html, Directives} = Lit; export class CloseEvent extends Event { static readonly eventName = 'close'; constructor() { super(CloseEvent.eventName, {composed: true, bubbles: true}); } } export type PublicPromptBuilder = Pick<Console.PromptBuilder.PromptBuilder, 'buildPrompt'|'getSearchQuery'>; export type PublicAidaClient = Pick<Host.AidaClient.AidaClient, 'doConversation'|'registerClientEvent'>; function localizeType(sourceType: Console.PromptBuilder.SourceType): string { switch (sourceType) { case Console.PromptBuilder.SourceType.MESSAGE: return i18nString(UIStrings.consoleMessage); case Console.PromptBuilder.SourceType.STACKTRACE: return i18nString(UIStrings.stackTrace); case Console.PromptBuilder.SourceType.NETWORK_REQUEST: return i18nString(UIStrings.networkRequest); case Console.PromptBuilder.SourceType.RELATED_CODE: return i18nString(UIStrings.relatedCode); } } const TERMS_OF_SERVICE_URL = 'https://policies.google.com/terms'; const PRIVACY_POLICY_URL = 'https://policies.google.com/privacy'; const CODE_SNIPPET_WARNING_URL = 'https://support.google.com/legal/answer/13505487'; const LEARN_MORE_URL = 'https://goo.gle/devtools-console-messages-ai' as Platform.DevToolsPath.UrlString; const REPORT_URL = 'https://support.google.com/legal/troubleshooter/1114905?hl=en#ts=1115658%2C13380504' as Platform.DevToolsPath.UrlString; const SIGN_IN_URL = 'https://accounts.google.com' as Platform.DevToolsPath.UrlString; export interface ViewInput { state: Extract<StateData, {type: State.INSIGHT}>|{type: Exclude<State, State.INSIGHT>}; closing: boolean; disableAnimations: boolean; renderer: MarkdownView.MarkdownView.MarkdownInsightRenderer; citationClickHandler: (index: number) => void; selectedRating?: boolean; noLogging: boolean; areReferenceDetailsOpen: boolean; highlightedCitationIndex: number; callbacks: { onClose: () => void, onAnimationEnd: () => void, onCitationAnimationEnd: () => void, onSearch: () => void, onRating: (isPositive: boolean) => Promise<Host.InspectorFrontendHostAPI.AidaClientResult>| undefined, onReport: () => void, onGoToSignIn: () => void, onConsentReminderConfirmed: () => Promise<void>, onToggleReferenceDetails: (event: Event) => void, onDisclaimerSettingsLink: () => void, onReminderSettingsLink: () => void, onEnableInsightsInSettingsLink: () => void, onReferencesOpen: () => void, }; } export interface ViewOutput { headerRef: Lit.Directives.Ref<HTMLHeadingElement>; citationLinks: HTMLElement[]; } export const enum State { INSIGHT = 'insight', LOADING = 'loading', ERROR = 'error', SETTING_IS_NOT_TRUE = 'setting-is-not-true', CONSENT_REMINDER = 'consent-reminder', NOT_LOGGED_IN = 'not-logged-in', SYNC_IS_PAUSED = 'sync-is-paused', OFFLINE = 'offline', } type StateData = { type: State.LOADING, consentOnboardingCompleted: boolean, }|{ type: State.INSIGHT, tokens: MarkdownView.MarkdownView.MarkdownViewData['tokens'], validMarkdown: boolean, sources: Console.PromptBuilder.Source[], isPageReloadRecommended: boolean, completed: boolean, directCitationUrls: string[], relatedUrls: string[], timedOut?: boolean, }&Host.AidaClient.DoConversationResponse|{ type: State.ERROR, error: string, }|{ type: State.CONSENT_REMINDER, sources: Console.PromptBuilder.Source[], isPageReloadRecommended: boolean, }|{ type: State.SETTING_IS_NOT_TRUE, }|{ type: State.NOT_LOGGED_IN, }|{ type: State.SYNC_IS_PAUSED, }|{ type: State.OFFLINE, }; const markedExtension = { name: 'citation', level: 'inline', start(src: string) { return src.match(/\[\^/)?.index; }, tokenizer(src: string) { const match = src.match(/^\[\^(\d+)\]/); if (match) { return { type: 'citation', raw: match[0], linkText: Number(match[1]), }; } return false; }, renderer: () => '', }; function isSearchRagResponse(metadata: Host.AidaClient.ResponseMetadata): boolean { return Boolean(metadata.factualityMetadata?.facts.length); } const blockPropagation = (e: Event): void => e.stopPropagation(); function renderSearchButton(onSearch: ViewInput['callbacks']['onSearch']): Lit.TemplateResult { // clang-format off return html`<devtools-button @click=${onSearch} class="search-button" .variant=${Buttons.Button.Variant.OUTLINED} .jslogContext=${'search'} > ${i18nString(UIStrings.search)} </devtools-button>`; // clang-format on } function renderLearnMoreAboutInsights(): Lit.TemplateResult { // clang-format off return html`<x-link href=${LEARN_MORE_URL} class="link" jslog=${VisualLogging.link('learn-more').track({click: true})}> ${i18nString(UIStrings.learnMore)} </x-link>`; // clang-format on } function maybeRenderSources( directCitationUrls: string[], highlightedCitationIndex: number, onCitationAnimationEnd: () => void, output: ViewOutput): Lit.LitTemplate { if (!directCitationUrls.length) { return Lit.nothing; } // clang-format off return html` <ol class="sources-list"> ${directCitationUrls.map((url, index) => html` <li> <x-link href=${url} class=${Directives.classMap({link: true, highlighted: index === highlightedCitationIndex})} jslog=${VisualLogging.link('references.console-insights').track({click: true})} ${Directives.ref(e => { output.citationLinks[index] = e as HTMLElement; })} @animationend=${onCitationAnimationEnd} > ${url} </x-link> </li> `)} </ol> `; // clang-format on } function maybeRenderRelatedContent(relatedUrls: string[], directCitationUrls: string[]): Lit.LitTemplate { if (relatedUrls.length === 0) { return Lit.nothing; } // clang-format off return html` ${directCitationUrls.length ? html`<h3>${i18nString(UIStrings.relatedContent)}</h3>` : Lit.nothing} <ul class="references-list"> ${relatedUrls.map(relatedUrl => html` <li> <x-link href=${relatedUrl} class="link" jslog=${VisualLogging.link('references.console-insights').track({click: true})} > ${relatedUrl} </x-link> </li> `)} </ul> `; // clang-format on } function renderLoading(): Lit.TemplateResult { // clang-format off return html` <div role="presentation" aria-label="Loading" class="loader" style="clip-path: url('#clipPath');"> <svg width="100%" height="64"> <clipPath id="clipPath"> <rect x="0" y="0" width="100%" height="16" rx="8"></rect> <rect x="0" y="24" width="100%" height="16" rx="8"></rect> <rect x="0" y="48" width="100%" height="16" rx="8"></rect> </clipPath> </svg> </div>`; // clang-format on } function renderInsightSourcesList( sources: Console.PromptBuilder.Source[], isPageReloadRecommended: boolean): Lit.TemplateResult { // clang-format off return html` <div class="insight-sources"> <ul> ${Directives.repeat(sources, item => item.value, item => { return html`<li><x-link class="link" title="${localizeType(item.type)} ${i18nString(UIStrings.opensInNewTab)}" href="data:text/plain;charset=utf-8,${encodeURIComponent(item.value)}" jslog=${VisualLogging.link('source-' + item.type).track({click: true})}> <devtools-icon name="open-externally"></devtools-icon> ${localizeType(item.type)} </x-link></li>`; })} ${isPageReloadRecommended ? html`<li class="source-disclaimer"> <devtools-icon name="warning"></devtools-icon> ${i18nString(UIStrings.reloadRecommendation)}</li>` : Lit.nothing} </ul> </div>`; // clang-format on } function renderInsight( insight: Extract<StateData, {type: State.INSIGHT}>, {renderer, disableAnimations, areReferenceDetailsOpen, highlightedCitationIndex, callbacks}: ViewInput, output: ViewOutput): Lit.TemplateResult { // clang-format off return html` ${ insight.validMarkdown ? html`<devtools-markdown-view .data=${{tokens: insight.tokens, renderer, animationEnabled: !disableAnimations} as MarkdownView.MarkdownView.MarkdownViewData}> </devtools-markdown-view>`: insight.explanation } ${insight.timedOut ? html`<p class="error-message">${i18nString(UIStrings.timedOut)}</p>` : Lit.nothing} ${isSearchRagResponse(insight.metadata) ? html` <details class="references" ?open=${areReferenceDetailsOpen} jslog=${VisualLogging.expand('references').track({click: true})} @toggle=${callbacks.onToggleReferenceDetails} @transitionend=${callbacks.onReferencesOpen} > <summary>${i18nString(UIStrings.references)}</summary> ${maybeRenderSources(insight.directCitationUrls, highlightedCitationIndex, callbacks.onCitationAnimationEnd, output)} ${maybeRenderRelatedContent(insight.relatedUrls, insight.directCitationUrls)} </details> ` : Lit.nothing} <details jslog=${VisualLogging.expand('sources').track({click: true})}> <summary>${i18nString(UIStrings.inputData)}</summary> ${renderInsightSourcesList(insight.sources, insight.isPageReloadRecommended)} </details> <div class="buttons"> ${renderSearchButton(callbacks.onSearch)} </div>`; // clang-format on } function renderError(message: string): Lit.TemplateResult { // clang-format off return html`<div class="error">${message}</div>`; // clang-format on } function renderConsentReminder(noLogging: boolean): Lit.TemplateResult { // clang-format off return html` <h3>Things to consider</h3> <div class="reminder-items"> <div> <devtools-icon name="google" class="medium"> </devtools-icon> </div> <div>The console message, associated stack trace, related source code, and the associated network headers are sent to Google to generate explanations. ${noLogging ? 'The content you submit and that is generated by this feature will not be used to improve Google’s AI models.' : 'This data may be seen by human reviewers to improve this feature. Avoid sharing sensitive or personal information.'} </div> <div> <devtools-icon name="policy" class="medium"> </devtools-icon> </div> <div>Use of this feature is subject to the <x-link href=${TERMS_OF_SERVICE_URL} class="link" jslog=${VisualLogging.link('terms-of-service.console-insights').track({click: true})}> Google Terms of Service </x-link> and <x-link href=${PRIVACY_POLICY_URL} class="link" jslog=${VisualLogging.link('privacy-policy.console-insights').track({click: true})}> Google Privacy Policy </x-link> </div> <div> <devtools-icon name="warning" class="medium"> </devtools-icon> </div> <div> <x-link href=${CODE_SNIPPET_WARNING_URL} class="link" jslog=${VisualLogging.link('code-snippets-explainer.console-insights').track({click: true})} >Use generated code snippets with caution</x-link> </div> </div>`; // clang-format on } function renderSettingIsNotTrue(onEnableInsightsInSettingsLink: () => void): Lit.TemplateResult { // clang-format off const settingsLink = html` <button class="link" role="link" jslog=${VisualLogging.action('open-ai-settings').track({click: true})} @click=${onEnableInsightsInSettingsLink} >${i18nString(UIStrings.settingsLink)}</button>`; return html` <div class="badge"> <devtools-icon name="lightbulb-spark" class="medium"> </devtools-icon> </div> <div> ${i18nTemplate(UIStrings.turnOnInSettings, {PH1: settingsLink})} ${ renderLearnMoreAboutInsights()} </div>`; // clang-format on } function renderNotLoggedIn(): Lit.TemplateResult { return renderError( Root.Runtime.hostConfig.isOffTheRecord ? i18nString(UIStrings.notAvailableInIncognitoMode) : i18nString(UIStrings.notLoggedIn)); } function renderDisclaimer(noLogging: boolean, onDisclaimerSettingsLink: () => void): Lit.LitTemplate { // clang-format off return html`<span> AI tools may generate inaccurate info that doesn't represent Google's views. ${noLogging ? 'The content you submit and that is generated by this feature will not be used to improve Google’s AI models.' : 'Data sent to Google may be seen by human reviewers to improve this feature.' } <button class="link" role="link" @click=${onDisclaimerSettingsLink} jslog=${VisualLogging.action('open-ai-settings').track({click: true})}> Open settings </button> or <x-link href=${LEARN_MORE_URL} class="link" jslog=${VisualLogging.link('learn-more').track({click: true})}> learn more </x-link> </span>`; // clang-format on } function renderDisclaimerFooter(noLogging: boolean, onDisclaimerSettingsLink: () => void): Lit.LitTemplate { // clang-format off return html` <div class="disclaimer"> ${renderDisclaimer(noLogging, onDisclaimerSettingsLink)} </div>`; // clang-format on } function renderSignInFooter(onGoToSignIn: () => void): Lit.LitTemplate { if (Root.Runtime.hostConfig.isOffTheRecord) { return Lit.nothing; } // clang-format off return html` <div class="filler"></div> <div> <devtools-button @click=${onGoToSignIn} .variant=${Buttons.Button.Variant.PRIMARY} .jslogContext=${'update-settings'} > ${i18nString(UIStrings.signIn)} </devtools-button> </div>`; // clang-format on } function renderConsentReminderFooter(onReminderSettingsLink: () => void, onConsentReminderConfirmed: () => void): Lit.LitTemplate { // clang-format off return html` <div class="filler"></div> <div class="buttons"> <devtools-button @click=${onReminderSettingsLink} .variant=${Buttons.Button.Variant.TONAL} .jslogContext=${'settings'} .title=${'Settings'} > Settings </devtools-button> <devtools-button class='continue-button' @click=${onConsentReminderConfirmed} .variant=${Buttons.Button.Variant.PRIMARY} .jslogContext=${'continue'} .title=${'continue'} > Continue </devtools-button> </div>`; // clang-format on } function renderInsightFooter(noLogging: ViewInput['noLogging'], selectedRating: ViewInput['selectedRating'], callbacks: ViewInput['callbacks']): Lit.LitTemplate { // clang-format off return html` <div class="disclaimer"> ${renderDisclaimer(noLogging, callbacks.onDisclaimerSettingsLink)} </div> <div class="filler"></div> <div class="rating"> <devtools-button data-rating="true" .iconName=${'thumb-up'} .toggledIconName=${'thumb-up'} .variant=${Buttons.Button.Variant.ICON_TOGGLE} .size=${Buttons.Button.Size.SMALL} .toggleOnClick=${false} .toggleType=${Buttons.Button.ToggleType.PRIMARY} .disabled=${selectedRating !== undefined} .toggled=${selectedRating === true} .title=${i18nString(UIStrings.goodResponse)} .jslogContext=${'thumbs-up'} @click=${() => callbacks.onRating(true)} ></devtools-button> <devtools-button data-rating="false" .iconName=${'thumb-down'} .toggledIconName=${'thumb-down'} .variant=${Buttons.Button.Variant.ICON_TOGGLE} .size=${Buttons.Button.Size.SMALL} .toggleOnClick=${false} .toggleType=${Buttons.Button.ToggleType.PRIMARY} .disabled=${selectedRating !== undefined} .toggled=${selectedRating === false} .title=${i18nString(UIStrings.badResponse)} .jslogContext=${'thumbs-down'} @click=${() => callbacks.onRating(false)} ></devtools-button> <devtools-button .iconName=${'report'} .variant=${Buttons.Button.Variant.ICON} .size=${Buttons.Button.Size.SMALL} .title=${i18nString(UIStrings.report)} .jslogContext=${'report'} @click=${callbacks.onReport} ></devtools-button> </div>`; // clang-format on } function renderHeaderIcon(): Lit.LitTemplate { // clang-format off return html` <div class="header-icon-container"> <devtools-icon name="lightbulb-spark" class="large"> </devtools-icon> </div>`; // clang-format on } interface HeaderInput { headerText: string; showIcon?: boolean; showSpinner?: boolean; onClose: ViewInput['callbacks']['onClose']; } function renderHeader( {headerText, showIcon = false, showSpinner = false, onClose}: HeaderInput, headerRef: Lit.Directives.Ref<HTMLHeadingElement>): Lit.LitTemplate { // clang-format off return html` <header> ${showIcon ? renderHeaderIcon() : Lit.nothing} <div class="filler"> <h2 tabindex="-1" ${Directives.ref(headerRef)}> ${headerText} </h2> ${showSpinner ? html`<devtools-spinner></devtools-spinner>` : Lit.nothing} </div> <div class="close-button"> <devtools-button .iconName=${'cross'} .variant=${Buttons.Button.Variant.ICON} .size=${Buttons.Button.Size.SMALL} .title=${i18nString(UIStrings.closeInsight)} jslog=${VisualLogging.close().track({click: true})} @click=${onClose} ></devtools-button> </div> </header> `; // clang-format on } export const DEFAULT_VIEW = (input: ViewInput, output: ViewOutput, target: HTMLElement|ShadowRoot): void => { const {state, noLogging, callbacks} = input; const {onClose, onDisclaimerSettingsLink} = callbacks; const jslog = `${VisualLogging.section(state.type).track({resize: true})}`; let header: Lit.LitTemplate = Lit.nothing; let main: Lit.LitTemplate = Lit.nothing; const mainClasses: Record<string, true> = {}; let footer: Lit.LitTemplate|undefined; switch (state.type) { case State.LOADING: header = renderHeader({headerText: i18nString(UIStrings.generating), onClose}, output.headerRef); main = renderLoading(); break; case State.INSIGHT: header = renderHeader( {headerText: i18nString(UIStrings.insight), onClose, showSpinner: !state.completed}, output.headerRef); main = renderInsight(state, input, output); footer = renderInsightFooter(noLogging, input.selectedRating, callbacks); break; case State.ERROR: header = renderHeader({headerText: i18nString(UIStrings.error), onClose}, output.headerRef); main = renderError(i18nString(UIStrings.errorBody)); footer = renderDisclaimerFooter(noLogging, onDisclaimerSettingsLink); break; case State.CONSENT_REMINDER: header = renderHeader({headerText: 'Understand console messages with AI', onClose, showIcon: true}, output.headerRef); mainClasses['reminder-container'] = true; main = renderConsentReminder(noLogging); footer = renderConsentReminderFooter(callbacks.onReminderSettingsLink, callbacks.onConsentReminderConfirmed); break; case State.SETTING_IS_NOT_TRUE: mainClasses['opt-in-teaser'] = true; main = renderSettingIsNotTrue(callbacks.onEnableInsightsInSettingsLink); break; case State.NOT_LOGGED_IN: case State.SYNC_IS_PAUSED: header = renderHeader({headerText: i18nString(UIStrings.signInToUse), onClose}, output.headerRef); main = renderNotLoggedIn(); footer = renderSignInFooter(callbacks.onGoToSignIn); break; case State.OFFLINE: header = renderHeader({headerText: i18nString(UIStrings.offlineHeader), onClose}, output.headerRef); main = renderError(i18nString(UIStrings.offline)); footer = renderDisclaimerFooter(noLogging, onDisclaimerSettingsLink); break; } // clang-format off render(html` <style>${styles}</style> <style>${Input.checkboxStyles}</style> <div class=${Directives.classMap({wrapper: true, closing: input.closing})} jslog=${VisualLogging.pane('console-insights').track({resize: true})} @animationend=${callbacks.onAnimationEnd} @keydown=${blockPropagation} @keyup=${blockPropagation} @keypress=${blockPropagation} @click=${blockPropagation} > <div class="animation-wrapper"> ${header} <main jslog=${jslog} class=${Directives.classMap(mainClasses)}> ${main} </main> ${footer?html`<footer jslog=${VisualLogging.section('footer')}> ${footer} </footer>`:Lit.nothing} </div> </div> `, target); // clang-format on }; export type ViewFunction = typeof DEFAULT_VIEW; export class ConsoleInsight extends UI.Widget.Widget { static async create(promptBuilder: PublicPromptBuilder, aidaClient: PublicAidaClient): Promise<UI.Widget.WidgetElement<ConsoleInsight>> { const aidaPreconditions = await Host.AidaClient.AidaClient.checkAccessPreconditions(); const widget = document.createElement('devtools-widget') as UI.Widget.WidgetElement<ConsoleInsight>; widget.classList.add('devtools-console-insight'); widget.widgetConfig = UI.Widget.widgetConfig( element => new ConsoleInsight(promptBuilder, aidaClient, aidaPreconditions, element), ); return widget; } disableAnimations = false; #view: ViewFunction; #promptBuilder: PublicPromptBuilder; #aidaClient: PublicAidaClient; #renderer: MarkdownView.MarkdownView.MarkdownInsightRenderer; // Main state. #state: StateData; #headerRef = Directives.createRef<HTMLHeadingElement>(); #citationLinks: HTMLElement[] = []; #highlightedCitationIndex = -1; // -1 for no highlight, 0-based index otherwise #areReferenceDetailsOpen = false; #stateChanging = false; #closing = false; // Rating sub-form state. #selectedRating?: boolean; #consoleInsightsEnabledSetting: Common.Settings.Setting<boolean>|undefined; #aidaPreconditions: Host.AidaClient.AidaAccessPreconditions; #boundOnAidaAvailabilityChange: () => Promise<void>; #marked: Marked.Marked.Marked; constructor( promptBuilder: PublicPromptBuilder, aidaClient: PublicAidaClient, aidaPreconditions: Host.AidaClient.AidaAccessPreconditions, element?: HTMLElement, view: ViewFunction = DEFAULT_VIEW, ) { super(element); this.#view = view; this.#promptBuilder = promptBuilder; this.#aidaClient = aidaClient; this.#aidaPreconditions = aidaPreconditions; this.#consoleInsightsEnabledSetting = this.#getConsoleInsightsEnabledSetting(); this.#renderer = new MarkdownView.MarkdownView.MarkdownInsightRenderer(this.#citationClickHandler.bind(this)); this.#marked = new Marked.Marked.Marked({extensions: [markedExtension]}); this.#state = this.#getStateFromAidaAvailability(); this.#boundOnAidaAvailabilityChange = this.#onAidaAvailabilityChange.bind(this); this.requestUpdate(); } #citationClickHandler(index: number): void { if (this.#state.type !== State.INSIGHT) { return; } const areDetailsAlreadyExpanded = this.#areReferenceDetailsOpen; this.#areReferenceDetailsOpen = true; // index is 1-based, #currentHighlightedCitationIndex is 0-based this.#highlightedCitationIndex = index - 1; this.requestUpdate(); // If details are open, focus and scroll to citation immediately. Otherwise wait for opening transition. if (areDetailsAlreadyExpanded) { this.#scrollToHighlightedCitation(); } } #scrollToHighlightedCitation(): void { const highlightedElement = this.#citationLinks[this.#highlightedCitationIndex]; if (highlightedElement) { highlightedElement.scrollIntoView({behavior: 'auto'}); highlightedElement.focus(); } } #getStateFromAidaAvailability(): StateData { switch (this.#aidaPreconditions) { case Host.AidaClient.AidaAccessPreconditions.AVAILABLE: { // Allows skipping the consent reminder if the user enabled the feature via settings in the current session const skipReminder = Common.Settings.Settings.instance() .createSetting('console-insights-skip-reminder', false, Common.Settings.SettingStorageType.SESSION) .get(); return { type: State.LOADING, consentOnboardingCompleted: this.#getOnboardingCompletedSetting().get() || skipReminder, }; } case Host.AidaClient.AidaAccessPreconditions.NO_ACCOUNT_EMAIL: return { type: State.NOT_LOGGED_IN, }; case Host.AidaClient.AidaAccessPreconditions.SYNC_IS_PAUSED: return { type: State.SYNC_IS_PAUSED, }; case Host.AidaClient.AidaAccessPreconditions.NO_INTERNET: return { type: State.OFFLINE, }; } } // off -> entrypoints are shown, and point to the AI setting panel where the setting can be turned on // on -> entrypoints are shown, and console insights can be generated #getConsoleInsightsEnabledSetting(): Common.Settings.Setting<boolean>|undefined { try { return Common.Settings.moduleSetting('console-insights-enabled') as Common.Settings.Setting<boolean>; } catch { return; } } // off -> consent reminder is shown, unless the 'console-insights-enabled'-setting has been enabled in the current DevTools session // on -> no consent reminder shown #getOnboardingCompletedSetting(): Common.Settings.Setting<boolean> { return Common.Settings.Settings.instance().createLocalSetting('console-insights-onboarding-finished', false); } override wasShown(): void { super.wasShown(); this.focus(); this.#consoleInsightsEnabledSetting?.addChangeListener(this.#onConsoleInsightsSettingChanged, this); const blockedByAge = Root.Runtime.hostConfig.aidaAvailability?.blockedByAge === true; if (this.#state.type === State.LOADING && this.#consoleInsightsEnabledSetting?.getIfNotDisabled() === true && !blockedByAge && this.#state.consentOnboardingCompleted) { Host.userMetrics.actionTaken(Host.UserMetrics.Action.GeneratingInsightWithoutDisclaimer); } Host.AidaClient.HostConfigTracker.instance().addEventListener( Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED, this.#boundOnAidaAvailabilityChange); // If AIDA availability has changed while the component was disconnected, we need to update. void this.#onAidaAvailabilityChange(); // The setting might have been turned on/off while the component was disconnected. // Update the state, unless the current state is already terminal (`INSIGHT` or `ERROR`). if (this.#state.type !== State.INSIGHT && this.#state.type !== State.ERROR) { this.#state = this.#getStateFromAidaAvailability(); } void this.#generateInsightIfNeeded(); } override willHide(): void { super.willHide(); this.#consoleInsightsEnabledSetting?.removeChangeListener(this.#onConsoleInsightsSettingChanged, this); Host.AidaClient.HostConfigTracker.instance().removeEventListener( Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED, this.#boundOnAidaAvailabilityChange); } async #onAidaAvailabilityChange(): Promise<void> { const currentAidaAvailability = await Host.AidaClient.AidaClient.checkAccessPreconditions(); if (currentAidaAvailability !== this.#aidaPreconditions) { this.#aidaPreconditions = currentAidaAvailability; this.#state = this.#getStateFromAidaAvailability(); void this.#generateInsightIfNeeded(); } } #onConsoleInsightsSettingChanged(): void { if (this.#consoleInsightsEnabledSetting?.getIfNotDisabled() === true) { this.#getOnboardingCompletedSetting().set(true); } if (this.#state.type === State.SETTING_IS_NOT_TRUE && this.#consoleInsightsEnabledSetting?.getIfNotDisabled() === true) { this.#transitionTo({ type: State.LOADING, consentOnboardingCompleted: true, }); Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightsOptInTeaserConfirmedInSettings); void this.#generateInsightIfNeeded(); } if (this.#state.type === State.CONSENT_REMINDER && this.#consoleInsightsEnabledSetting?.getIfNotDisabled() === false) { this.#transitionTo({ type: State.LOADING, consentOnboardingCompleted: false, }); Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightsReminderTeaserAbortedInSettings); void this.#generateInsightIfNeeded(); } } #transitionTo(newState: StateData): void { this.#stateChanging = this.#state.type !== newState.type; this.#state = newState; this.requestUpdate(); } async #generateInsightIfNeeded(): Promise<void> { if (this.#state.type !== State.LOADING) { return; } const blockedByAge = Root.Runtime.hostConfig.aidaAvailability?.blockedByAge === true; if (this.#consoleInsightsEnabledSetting?.getIfNotDisabled() !== true || blockedByAge) { this.#transitionTo({ type: State.SETTING_IS_NOT_TRUE, }); Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightsOptInTeaserShown); return; } if (!this.#state.consentOnboardingCompleted) { const {sources, isPageReloadRecommended} = await this.#promptBuilder.buildPrompt(); this.#transitionTo({ type: State.CONSENT_REMINDER, sources, isPageReloadRecommended, }); Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightsReminderTeaserShown); return; } await this.#generateInsight(); } #onClose(): void { if (this.#state.type === State.CONSENT_REMINDER) { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightsReminderTeaserCanceled); } this.#closing = true; this.requestUpdate(); } #onAnimationEnd(): void { if (this.#closing) { this.contentElement.dispatchEvent(new CloseEvent()); return; } if (this.#stateChanging) { this.#headerRef.value?.focus(); } } #onCitationAnimationEnd(): void { if (this.#highlightedCitationIndex !== -1) { this.#highlightedCitationIndex = -1; this.requestUpdate(); } } #onRating(isPositive: boolean): Promise<Host.InspectorFrontendHostAPI.AidaClientResult>|undefined { if (this.#state.type !== State.INSIGHT) { throw new Error('Unexpected state'); } if (this.#state.metadata?.rpcGlobalId === undefined) { throw new Error('RPC Id not in metadata'); } // If it was rated, do not record again. if (this.#selectedRating !== undefined) { return; } this.#selectedRating = isPositive; this.requestUpdate(); if (this.#selectedRating) { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightRatedPositive); } else { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightRatedNegative); } const disallowLogging = Root.Runtime.hostConfig.aidaAvailability?.disallowLogging ?? true; return this.#aidaClient.registerClientEvent({ corresponding_aida_rpc_global_id: this.#state.metadata.rpcGlobalId, disable_user_content_logging: disallowLogging, do_conversation_client_event: { user_feedback: { sentiment: this.#selectedRating ? Host.AidaClient.Rating.POSITIVE : Host.AidaClient.Rating.NEGATIVE, }, }, }); } #onReport(): void { Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab(REPORT_URL); } #onSearch(): void { const query = this.#promptBuilder.getSearchQuery(); Host.InspectorFrontendHost.InspectorFrontendHostInstance.openSearchResultsInNewTab(query); } async #onConsentReminderConfirmed(): Promise<void> { this.#getOnboardingCompletedSetting().set(true); this.#transitionTo({ type: State.LOADING, consentOnboardingCompleted: true, }); Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightsReminderTeaserConfirmed); await this.#generateInsight(); } #insertCitations(explanation: string, metadata: Host.AidaClient.ResponseMetadata): {explanationWithCitations: string, directCitationUrls: string[]} { const directCitationUrls: string[] = []; if (!isSearchRagResponse(metadata) || !metadata.attributionMetadata) { return {explanationWithCitations: explanation, directCitationUrls}; } const {attributionMetadata} = metadata; const sortedCitations = attributionMetadata.citations .filter(citation => citation.sourceType === Host.AidaClient.CitationSourceType.WORLD_FACTS) .sort((a, b) => (b.endIndex || 0) - (a.endIndex || 0)); let explanationWithCitations = explanation; for (const [index, citation] of sortedCitations.entries()) { // Matches optional punctuation mark followed by whitespace. // Ensures citation is placed at the end of a word. const myRegex = /[.,:;!?]*\s/g; myRegex.lastIndex = citation.endIndex || 0; const result = myRegex.exec(explanationWithCitations); if (result && citation.uri) { explanationWithCitations = explanationWithCitations.slice(0, result.index) + `[^${sortedCitations.length - index}]` + explanationWithCitations.slice(result.index); directCitationUrls.push(citation.uri); } } directCitationUrls.reverse(); return {explanationWithCitations, directCitationUrls}; } #modifyTokensToHandleCitationsInCode(tokens: Marked.Marked.TokensList): void { for (const token of tokens) { if (token.type === 'code') { // Find and remove '[^number]' from within code block const matches: String[]|null = token.text.match(/\[\^\d+\]/g); token.text = token.text.replace(/\[\^\d+\]/g, ''); // And add as a citation for the whole code block if (matches?.length) { const citations = matches.map(match => { const index = parseInt(match.slice(2, -1), 10); return { index, clickHandler: this.#citationClickHandler.bind(this, index), }; }); (token as MarkdownView.MarkdownView.CodeTokenWithCitation).citations = citations; } } } } #deriveRelatedUrls(directCitationUrls: string[], metadata: Host.AidaClient.ResponseMetadata): string[] { if (!metadata.factualityMetadata?.facts.length) { return []; } const relatedUrls = metadata.factualityMetadata.facts.filter(fact => fact.sourceUri && !directCitationUrls.includes(fact.sourceUri)) .map(fact => fact.sourceUri as string) || []; const trainingDataUrls = metadata.attributionMetadata?.citations .filter( citation => citation.sourceType === Host.AidaClient.CitationSourceType.TRAINING_DATA && (citation.uri || citation.repository)) .map(citation => citation.uri || `https://www.github.com/${citation.repository}`) || []; const dedupedTrainingDataUrls = [...new Set(trainingDataUrls.filter(url => !relatedUrls.includes(url) && !directCitationUrls.includes(url)))]; relatedUrls.push(...dedupedTrainingDataUrls); return relatedUrls; } async #generateInsight(): Promise<void> { try { for await (const {sources, isPageReloadRecommended, explanation, metadata, completed} of this.#getInsight()) { const {explanationWithCitations, directCitationUrls} = this.#insertCitations(explanation, metadata); const relatedUrls = this.#deriveRelatedUrls(directCitationUrls, metadata); const tokens = this.#validateMarkdown(explanationWithCitations); const valid = tokens !== false; if (valid) { this.#modifyTokensToHandleCitationsInCode(tokens); } this.#transitionTo({ type: State.INSIGHT, tokens: valid ? tokens : [], validMarkdown: valid, explanation, sources, metadata, isPageReloadRecommended, completed, directCitationUrls, relatedUrls, }); } Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightGenerated); } catch (err) { console.error('[ConsoleInsight] Error in #generateInsight:', err); Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightErrored); if (err.message === 'doAidaConversation timed out' && this.#state.type === State.INSIGHT) { this.#state.timedOut = true; this.#transitionTo({...this.#state, completed: true, timedOut: true}); } else { this.#transitionTo({ type: State.ERROR, error: err.message, }); } } } /** * Validates the markdown by trying to render it. */ #validateMarkdown(text: string): Marked.Marked.TokensList|false { try { const tokens = this.#marked.lexer(text); for (const token of tokens) { this.#renderer.renderToken(token); } return tokens; } catch { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightErroredMarkdown); return false; } } async * #getInsight(): AsyncGenerator< {sources: Console.PromptBuilder.Source[], isPageReloadRecommended: boolean}& Host.AidaClient.DoConversationResponse, void, void> { const {prompt, sources, isPageReloadRecommended} = await this.#promptBuilder.buildPrompt(); try { for await (const response of this.#aidaClient.doConversation( Host.AidaClient.AidaClient.buildConsoleInsightsRequest(prompt))) { yield {sources, isPageReloadRecommended, ...response}; } } catch (err) { if (err.message === 'Server responded: permission denied') { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightErroredPermissionDenied); } else if (err.message.startsWith('Cannot send request:')) { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightErroredCannotSend); } else if (err.message.startsWith('Request failed:')) { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightErroredRequestFailed); } else if (err.message.startsWith('Cannot parse chunk:')) { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightErroredCannotParseChunk); } else if (err.message === 'Unknown chunk result') { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightErroredUnknownChunk); } else if (err.message.startsWith('Server responded:')) { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightErroredApi); } else { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightErroredOther); } throw err; } } #onGoToSignIn(): void { Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab(SIGN_IN_URL); } #onToggleReferenceDetails(event: Event): void { const detailsElement = event.target as HTMLDetailsElement; if (detailsElement) { this.#areReferenceDetailsOpen = detailsElement.open; if (!detailsElement.open) { this.#highlightedCitationIndex = -1; } this.requestUpdate(); } } #onDisclaimerSettingsLink(): void { void UI.ViewManager.ViewManager.instance().showView('chrome-ai'); } #onReminderSettingsLink(): void { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightsReminderTeaserSettingsLinkClicked); void UI.ViewManager.ViewManager.instance().showView('chrome-ai'); } #onEnableInsightsInSettingsLink(): void { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightsOptInTeaserSettingsLinkClicked); void UI.ViewManager.ViewManager.instance().showView('chrome-ai'); } override performUpdate(): void { const input: ViewInput = { state: this.#state, closing: this.#closing, disableAnimations: this.disableAnimations, renderer: this.#renderer, citationClickHandler: this.#citationClickHandler.bind(this), selectedRating: this.#selectedRating, noLogging: Root.Runtime.hostConfig.aidaAvailability?.enterprisePolicyValue === Root.Runtime.GenAiEnterprisePolicyValue.ALLOW_WITHOUT_LOGGING, areReferenceDetailsOpen: this.#areReferenceDetailsOpen, highlightedCitationIndex: this.#highlightedCitationIndex, callbacks: { onClose: this.#onClose.bind(this), onAnimationEnd: this.#onAnimationEnd.bind(this), onCitationAnimationEnd: this.#onCitationAnimationEnd.bind(this), onSearch: this.#onSearch.bind(this), onRating: this.#onRating.bind(this), onReport: this.#onReport.bind(this), onGoToSignIn: this.#onGoToSignIn.bind(this), onConsentReminderConfirmed: this.#onConsentReminderConfirmed.bind(this), onToggleReferenceDetails: this.#onToggleReferenceDetails.bind(this), onDisclaimerSettingsLink: this.#onDisclaimerSettingsLink.bind(this), onReminderSettingsLink: this.#onReminderSettingsLink.bind(this), onEnableInsightsInSettingsLink: this.#onEnableInsightsInSettingsLink.bind(this), onReferencesOpen: this.#scrollToHighlightedCitation.bind(this), }, }; const output: ViewOutput = { headerRef: this.#headerRef, citationLinks: [], }; this.#view(input, output, this.contentElement); this.#citationLinks = output.citationLinks; } }