chrome-devtools-frontend
Version:
Chrome DevTools UI
1,269 lines (1,179 loc) • 47.5 kB
text/typescript
// 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;
}
}