UNPKG

chrome-devtools-frontend

Version:
1,211 lines (1,136 loc) 45.6 kB
// Copyright 2023 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable rulesdir/no-lit-render-outside-of-view */ 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 type * as IconButton from '../../../ui/components/icon_button/icon_button.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 {type PromptBuilder, type Source, SourceType} from '../PromptBuilder.js'; import styles from './consoleInsight.css.js'; import listStyles from './consoleInsightSourcesList.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}); } } type PublicPromptBuilder = Pick<PromptBuilder, 'buildPrompt'|'getSearchQuery'>; type PublicAidaClient = Pick<Host.AidaClient.AidaClient, 'fetch'|'registerClientEvent'>; function localizeType(sourceType: SourceType): string { switch (sourceType) { case SourceType.MESSAGE: return i18nString(UIStrings.consoleMessage); case SourceType.STACKTRACE: return i18nString(UIStrings.stackTrace); case SourceType.NETWORK_REQUEST: return i18nString(UIStrings.networkRequest); case 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; 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: Source[], isPageReloadRecommended: boolean, completed: boolean, directCitationUrls: string[], timedOut?: boolean, }&Host.AidaClient.AidaResponse|{ type: State.ERROR, error: string, }|{ type: State.CONSENT_REMINDER, sources: 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: () => '', }; export class ConsoleInsight extends HTMLElement { static async create(promptBuilder: PublicPromptBuilder, aidaClient: PublicAidaClient): Promise<ConsoleInsight> { const aidaAvailability = await Host.AidaClient.AidaClient.checkAccessPreconditions(); return new ConsoleInsight(promptBuilder, aidaClient, aidaAvailability); } readonly #shadow = this.attachShadow({mode: 'open'}); disableAnimations = false; #promptBuilder: PublicPromptBuilder; #aidaClient: PublicAidaClient; #renderer: MarkdownView.MarkdownView.MarkdownInsightRenderer; // Main state. #state: StateData; #referenceDetailsRef = Lit.Directives.createRef<HTMLDetailsElement>(); #areReferenceDetailsOpen = false; // Rating sub-form state. #selectedRating?: boolean; #consoleInsightsEnabledSetting: Common.Settings.Setting<boolean>|undefined; #aidaAvailability: Host.AidaClient.AidaAccessPreconditions; #boundOnAidaAvailabilityChange: () => Promise<void>; #marked: Marked.Marked.Marked; constructor( promptBuilder: PublicPromptBuilder, aidaClient: PublicAidaClient, aidaAvailability: Host.AidaClient.AidaAccessPreconditions) { super(); this.#promptBuilder = promptBuilder; this.#aidaClient = aidaClient; this.#aidaAvailability = aidaAvailability; 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.#render(); // Stop keyboard event propagation to avoid Console acting on the events // inside the insight component. this.addEventListener('keydown', e => { e.stopPropagation(); }); this.addEventListener('keyup', e => { e.stopPropagation(); }); this.addEventListener('keypress', e => { e.stopPropagation(); }); this.addEventListener('click', e => { e.stopPropagation(); }); this.focus(); } #citationClickHandler(index: number): void { if (this.#state.type !== State.INSIGHT || !this.#referenceDetailsRef.value) { return; } const areDetailsAlreadyExpanded = this.#referenceDetailsRef.value.open; this.#areReferenceDetailsOpen = true; this.#render(); const highlightedElement = this.#shadow.querySelector(`.sources-list x-link[data-index="${index}"]`) as HTMLElement | null; if (highlightedElement) { UI.UIUtils.runCSSAnimationOnce(highlightedElement, 'highlighted'); if (areDetailsAlreadyExpanded) { highlightedElement.scrollIntoView({behavior: 'auto'}); highlightedElement.focus(); } else { // Wait for the details element to open before scrolling. this.#referenceDetailsRef.value.addEventListener('transitionend', () => { highlightedElement.scrollIntoView({behavior: 'auto'}); highlightedElement.focus(); }, {once: true}); } } } #getStateFromAidaAvailability(): StateData { switch (this.#aidaAvailability) { 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); } connectedCallback(): void { this.classList.add('opening'); 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(); } disconnectedCallback(): void { 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.#aidaAvailability) { this.#aidaAvailability = 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 { const previousState = this.#state; this.#state = newState; this.#render(); if (newState.type !== previousState.type) { this.#focusHeader(); } } 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.shadowRoot?.addEventListener('animationend', () => { this.dispatchEvent(new CloseEvent()); }, {once: true}); this.classList.add('closing'); } #onRating(event: Event): void { 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 = (event.target as HTMLElement).dataset.rating === 'true'; this.#render(); 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; void 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.AidaResponseMetadata): {explanationWithCitations: string, directCitationUrls: string[]} { const directCitationUrls: string[] = []; if (!this.#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; } } } } async #generateInsight(): Promise<void> { try { for await (const {sources, isPageReloadRecommended, explanation, metadata, completed} of this.#getInsight()) { const {explanationWithCitations, directCitationUrls} = this.#insertCitations(explanation, 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, }); } Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightGenerated); } catch (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: Source[], isPageReloadRecommended: boolean}&Host.AidaClient.AidaResponse, void, void> { const {prompt, sources, isPageReloadRecommended} = await this.#promptBuilder.buildPrompt(); try { for await ( const response of this.#aidaClient.fetch(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); } #focusHeader(): void { this.addEventListener('animationend', () => { (this.#shadow.querySelector('header h2') as HTMLElement | undefined)?.focus(); }, {once: true}); } #renderSearchButton(): Lit.TemplateResult { // clang-format off return html`<devtools-button @click=${this.#onSearch} class="search-button" .data=${ { variant: Buttons.Button.Variant.OUTLINED, jslogContext: 'search', } as Buttons.Button.ButtonData } > ${i18nString(UIStrings.search)} </devtools-button>`; // clang-format on } #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 } #maybeRenderSources(): Lit.LitTemplate { if (this.#state.type !== State.INSIGHT || !this.#state.directCitationUrls.length) { return Lit.nothing; } // clang-format off return html` <ol class="sources-list"> ${this.#state.directCitationUrls.map((url, index) => html` <li> <x-link href=${url} class="link" data-index=${index + 1} jslog=${VisualLogging.link('references.console-insights').track({click: true})} > ${url} </x-link> </li> `)} </ol> `; // clang-format on } #maybeRenderRelatedContent(): Lit.LitTemplate { if (this.#state.type !== State.INSIGHT || !this.#state.metadata.factualityMetadata?.facts.length) { return Lit.nothing; } const directCitationUrls = this.#state.directCitationUrls; const relatedUrls = this.#state.metadata.factualityMetadata.facts .filter(fact => fact.sourceUri && !directCitationUrls.includes(fact.sourceUri)) .map(fact => fact.sourceUri as string); const trainingDataUrls = this.#state.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); if (relatedUrls.length === 0) { return Lit.nothing; } // clang-format off return html` ${this.#state.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 } #isSearchRagResponse(metadata: Host.AidaClient.AidaResponseMetadata): boolean { return Boolean(metadata.factualityMetadata?.facts.length); } #onToggleReferenceDetails(): void { if (this.#referenceDetailsRef.value) { this.#areReferenceDetailsOpen = this.#referenceDetailsRef.value.open; } } #renderMain(): Lit.TemplateResult { const jslog = `${VisualLogging.section(this.#state.type).track({resize: true})}`; const noLogging = Root.Runtime.hostConfig.aidaAvailability?.enterprisePolicyValue === Root.Runtime.GenAiEnterprisePolicyValue.ALLOW_WITHOUT_LOGGING; // clang-format off switch (this.#state.type) { case State.LOADING: return html`<main jslog=${jslog}> <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> </main>`; case State.INSIGHT: return html` <main jslog=${jslog}> ${ this.#state.validMarkdown ? html`<devtools-markdown-view .data=${{tokens: this.#state.tokens, renderer: this.#renderer, animationEnabled: !this.disableAnimations} as MarkdownView.MarkdownView.MarkdownViewData}> </devtools-markdown-view>`: this.#state.explanation } ${this.#state.timedOut ? html`<p class="error-message">${i18nString(UIStrings.timedOut)}</p>` : Lit.nothing} ${this.#isSearchRagResponse(this.#state.metadata) ? html` <details class="references" ${Lit.Directives.ref(this.#referenceDetailsRef)} @toggle=${this.#onToggleReferenceDetails} jslog=${VisualLogging.expand('references').track({click: true})}> <summary>${i18nString(UIStrings.references)}</summary> ${this.#maybeRenderSources()} ${this.#maybeRenderRelatedContent()} </details> ` : Lit.nothing} <details jslog=${VisualLogging.expand('sources').track({click: true})}> <summary>${i18nString(UIStrings.inputData)}</summary> <devtools-console-insight-sources-list .sources=${this.#state.sources} .isPageReloadRecommended=${this.#state.isPageReloadRecommended}> </devtools-console-insight-sources-list> </details> <div class="buttons"> ${this.#renderSearchButton()} </div> </main>`; case State.ERROR: return html` <main jslog=${jslog}> <div class="error">${i18nString(UIStrings.errorBody)}</div> </main>`; case State.CONSENT_REMINDER: return html` <main class="reminder-container" jslog=${jslog}> <h3>Things to consider</h3> <div class="reminder-items"> <div> <devtools-icon .data=${{ iconName: 'google', width: 'var(--sys-size-8)', height: 'var(--sys-size-8)', } as IconButton.Icon.IconData}> </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 .data=${{ iconName: 'policy', width: 'var(--sys-size-8)', height: 'var(--sys-size-8)', } as IconButton.Icon.IconData}> </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 .data=${{ iconName: 'warning', width: 'var(--sys-size-8)', height: 'var(--sys-size-8)', } as IconButton.Icon.IconData}> </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> </main> `; case State.SETTING_IS_NOT_TRUE: { const settingsLink = html`<button class="link" role="link" jslog=${VisualLogging.action('open-ai-settings').track({click: true})} @click=${() => { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightsOptInTeaserSettingsLinkClicked); void UI.ViewManager.ViewManager.instance().showView('chrome-ai'); }} >${i18nString(UIStrings.settingsLink)}</button>`; return html`<main class="opt-in-teaser" jslog=${jslog}> <div class="badge"> <devtools-icon .data=${{ iconName: 'lightbulb-spark', width: 'var(--sys-size-8)', height: 'var(--sys-size-8)', } as IconButton.Icon.IconData}> </devtools-icon> </div> <div> ${i18nTemplate(UIStrings.turnOnInSettings, {PH1: settingsLink})} ${ this.#renderLearnMoreAboutInsights()} </div> </main>`; } case State.NOT_LOGGED_IN: case State.SYNC_IS_PAUSED: return html` <main jslog=${jslog}> <div class="error">${Root.Runtime.hostConfig.isOffTheRecord ? i18nString(UIStrings.notAvailableInIncognitoMode) : i18nString(UIStrings.notLoggedIn)}</div> </main>`; case State.OFFLINE: return html` <main jslog=${jslog}> <div class="error">${i18nString(UIStrings.offline)}</div> </main>`; } // clang-format on } #renderDisclaimer(): Lit.LitTemplate { const noLogging = Root.Runtime.hostConfig.aidaAvailability?.enterprisePolicyValue === Root.Runtime.GenAiEnterprisePolicyValue.ALLOW_WITHOUT_LOGGING; // 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=${() => UI.ViewManager.ViewManager.instance().showView('chrome-ai')} 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 } #renderFooter(): Lit.LitTemplate { const disclaimer = this.#renderDisclaimer(); // clang-format off switch (this.#state.type) { case State.LOADING: case State.SETTING_IS_NOT_TRUE: return Lit.nothing; case State.ERROR: case State.OFFLINE: return html`<footer jslog=${VisualLogging.section('footer')}> <div class="disclaimer"> ${disclaimer} </div> </footer>`; case State.NOT_LOGGED_IN: case State.SYNC_IS_PAUSED: if (Root.Runtime.hostConfig.isOffTheRecord) { return Lit.nothing; } return html`<footer jslog=${VisualLogging.section('footer')}> <div class="filler"></div> <div> <devtools-button @click=${this.#onGoToSignIn} .data=${ { variant: Buttons.Button.Variant.PRIMARY, jslogContext: 'update-settings', } as Buttons.Button.ButtonData } > ${UIStrings.signIn} </devtools-button> </div> </footer>`; case State.CONSENT_REMINDER: return html`<footer jslog=${VisualLogging.section('footer')}> <div class="filler"></div> <div class="buttons"> <devtools-button @click=${() => { Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightsReminderTeaserSettingsLinkClicked); void UI.ViewManager.ViewManager.instance().showView('chrome-ai'); }} .data=${ { variant: Buttons.Button.Variant.TONAL, jslogContext: 'settings', title: 'Settings', } as Buttons.Button.ButtonData } > Settings </devtools-button> <devtools-button class='continue-button' @click=${this.#onConsentReminderConfirmed} .data=${ { variant: Buttons.Button.Variant.PRIMARY, jslogContext: 'continue', title: 'continue', } as Buttons.Button.ButtonData } > Continue </devtools-button> </div> </footer>`; case State.INSIGHT: return html`<footer jslog=${VisualLogging.section('footer')}> <div class="disclaimer"> ${disclaimer} </div> <div class="filler"></div> <div class="rating"> <devtools-button data-rating=${'true'} .data=${ { variant: Buttons.Button.Variant.ICON_TOGGLE, size: Buttons.Button.Size.SMALL, iconName: 'thumb-up', toggledIconName: 'thumb-up', toggleOnClick: false, toggleType: Buttons.Button.ToggleType.PRIMARY, disabled: this.#selectedRating !== undefined, toggled: this.#selectedRating === true, title: i18nString(UIStrings.goodResponse), jslogContext: 'thumbs-up', } as Buttons.Button.ButtonData } @click=${this.#onRating} ></devtools-button> <devtools-button data-rating=${'false'} .data=${ { variant: Buttons.Button.Variant.ICON_TOGGLE, size: Buttons.Button.Size.SMALL, iconName: 'thumb-down', toggledIconName: 'thumb-down', toggleOnClick: false, toggleType: Buttons.Button.ToggleType.PRIMARY, disabled: this.#selectedRating !== undefined, toggled: this.#selectedRating === false, title: i18nString(UIStrings.badResponse), jslogContext: 'thumbs-down', } as Buttons.Button.ButtonData } @click=${this.#onRating} ></devtools-button> <devtools-button .data=${ { variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'report', title: i18nString(UIStrings.report), jslogContext: 'report', } as Buttons.Button.ButtonData } @click=${this.#onReport} ></devtools-button> </div> </footer>`; } // clang-format on } #getHeader(): string { switch (this.#state.type) { case State.NOT_LOGGED_IN: case State.SYNC_IS_PAUSED: return i18nString(UIStrings.signInToUse); case State.OFFLINE: return i18nString(UIStrings.offlineHeader); case State.LOADING: return i18nString(UIStrings.generating); case State.INSIGHT: return i18nString(UIStrings.insight); case State.ERROR: return i18nString(UIStrings.error); case State.CONSENT_REMINDER: return 'Understand console messages with AI'; case State.SETTING_IS_NOT_TRUE: return ''; // not reached } } #renderSpinner(): Lit.LitTemplate { // clang-format off if (this.#state.type === State.INSIGHT && !this.#state.completed) { return html`<devtools-spinner></devtools-spinner>`; } return Lit.nothing; // clang-format on } #renderHeader(): Lit.LitTemplate { if (this.#state.type === State.SETTING_IS_NOT_TRUE) { return Lit.nothing; } const hasIcon = this.#state.type === State.CONSENT_REMINDER; // clang-format off return html` <header> ${hasIcon ? html` <div class="header-icon-container"> <devtools-icon .data=${{ iconName: 'lightbulb-spark', width: '18px', height: '18px', } as IconButton.Icon.IconData}> </devtools-icon> </div>` : Lit.nothing} <div class="filler"> <h2 tabindex="-1"> ${this.#getHeader()} </h2> ${this.#renderSpinner()} </div> <div class="close-button"> <devtools-button .data=${ { variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'cross', title: i18nString(UIStrings.closeInsight), } as Buttons.Button.ButtonData } jslog=${VisualLogging.close().track({click: true})} @click=${this.#onClose} ></devtools-button> </div> </header> `; // clang-format on } #render(): void { // clang-format off render(html` <style>${styles}</style> <style>${Input.checkboxStyles}</style> <div class="wrapper" jslog=${VisualLogging.pane('console-insights').track({resize: true})}> <div class="animation-wrapper"> ${this.#renderHeader()} ${this.#renderMain()} ${this.#renderFooter()} </div> </div> `, this.#shadow, { host: this, }); // clang-format on if (this.#referenceDetailsRef.value) { this.#referenceDetailsRef.value.open = this.#areReferenceDetailsOpen; } } } class ConsoleInsightSourcesList extends HTMLElement { readonly #shadow = this.attachShadow({mode: 'open'}); #sources: Source[] = []; #isPageReloadRecommended = false; #render(): void { // clang-format off render(html` <style>${listStyles}</style> <style>${Input.checkboxStyles}</style> <ul> ${Directives.repeat(this.#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>`; })} ${this.#isPageReloadRecommended ? html`<li class="source-disclaimer"> <devtools-icon name="warning"></devtools-icon> ${i18nString(UIStrings.reloadRecommendation)}</li>` : Lit.nothing} </ul> `, this.#shadow, { host: this, }); // clang-format on } set sources(values: Source[]) { this.#sources = values; this.#render(); } set isPageReloadRecommended(isPageReloadRecommended: boolean) { this.#isPageReloadRecommended = isPageReloadRecommended; this.#render(); } } customElements.define('devtools-console-insight', ConsoleInsight); customElements.define('devtools-console-insight-sources-list', ConsoleInsightSourcesList); declare global { interface HTMLElementTagNameMap { 'devtools-console-insight': ConsoleInsight; 'devtools-console-insight-sources-list': ConsoleInsightSourcesList; } }