chrome-devtools-frontend
Version:
Chrome DevTools UI
705 lines (658 loc) • 26 kB
text/typescript
// Copyright 2025 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.
import '../../ui/components/tooltips/tooltips.js';
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Root from '../../core/root/root.js';
import * as Protocol from '../../generated/protocol.js';
import * as AiAssistanceModel from '../../models/ai_assistance/ai_assistance.js';
import * as Buttons from '../../ui/components/buttons/buttons.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 PanelCommon from '../common/common.js';
import consoleInsightTeaserStyles from './consoleInsightTeaser.css.js';
import {ConsoleViewMessage} from './ConsoleViewMessage.js';
import {PromptBuilder} from './PromptBuilder.js';
const {render, html} = Lit;
const BUILT_IN_AI_DOCUMENTATION = 'https://developer.chrome.com/docs/ai/built-in';
const UIStringsNotTranslate = {
/**
* @description Link text in the disclaimer dialog, linking to a settings page containing more information
*/
learnMore: 'Learn more',
/**
* @description Link text in the Console Insights Teaser info tooltip, linking to an explainer on how data is being used in this feature
*/
learnMoreAboutAiSummaries: 'Learn more about AI summaries',
/**
* @description Description of the console insights feature
*/
freDisclaimerHeader: 'Get explanations for console warnings and errors',
/**
* @description First item in the first-run experience dialog
*/
freDisclaimerTextAiWontAlwaysGetItRight: 'This feature uses AI and won’t always get it right',
/**
* @description Explainer for which data is being sent by the console insights feature
*/
consoleInsightsSendsData:
'To generate explanations, the console message, associated stack trace, related source code, and the associated network headers are sent to Google. This data may be seen by human reviewers to improve this feature.',
/**
* @description Explainer for which data is being sent by the console insights feature
*/
consoleInsightsSendsDataNoLogging:
'To generate explanations, the console message, associated stack trace, related source code, and the associated network headers are sent to Google. This data will not be used to improve Google’s AI models. Your organization may change these settings at any time.',
/**
* @description Third item in the first-run experience dialog
*/
freDisclaimerTextUseWithCaution: 'Use generated code snippets with caution',
/**
* @description Tooltip text for the console insights teaser
*/
infoTooltipText:
'The text above has been generated with AI on your local device. Clicking the button will send the console message, stack trace, related source code, and the associated network headers to Google to generate a more detailed explanation.',
/**
* @description Header text during loading state while an AI summary is being generated
*/
summarizing: 'Summarizing…',
/**
* @description Header text during longer lasting loading state while an AI summary is being generated
*/
summarizingTakesABitLonger: 'Summarizing takes a bit longer…',
/**
* @description Label for an animation shown while an AI response is being generated
*/
loading: 'Loading',
/**
* @description Label for a button which generates a more detailed explanation
*/
tellMeMore: 'Tell me more',
/**
* @description Label for a checkbox which turns off the teaser explanation feature
*/
dontShow: 'Don’t show',
/**
* @description Aria-label for an infor-button triggering a tooltip with more info about data usage
*/
learnDataUsage: 'Learn more about how your data is used',
/**
* @description Header text if there was an error during AI summary generation
*/
summaryNotAvailable: 'Summary not available',
/**
* @description Header text informing the user that they can get an AI-generated explanation
*/
getHelpForWarning: 'Get help understanding this warning',
/**
* @description Header text informing the user that they can get an AI-generated explanation
*/
getHelpForError: 'Get help understanding this error',
/**
* @description Call to action for downloading an AI model
*/
toUseDownload: 'To use Chrome’s Built-in AI here and elsewhere, download the AI model (~4 GB).',
/**
* @description Button text to trigger model download
*/
downloadModel: 'Download model',
/**
* @description Header text while the model download is in progress
*/
downloadingAiModel: 'Downloading AI model',
/**
* @description Label for a progress bar for the AI model download
*/
progress: 'Progress',
/**
* @description Progress indicator when the progress status is unknown. If the
* progress status is known, this is replaced by a progress bar.
*/
progressUnknown: 'Progress: unknown',
} as const;
const lockedString = i18n.i18n.lockedString;
const CODE_SNIPPET_WARNING_URL = 'https://support.google.com/legal/answer/13505487';
const DATA_USAGE_URL = 'https://developer.chrome.com/docs/devtools/ai-assistance/get-started#data-use';
const EXPLAIN_TEASER_ACTION_ID = 'explain.console-message.teaser';
const SLOW_GENERATION_CUTOFF_MILLISECONDS = 3500;
const enum State {
NO_MODEL = 'no-model',
DOWNLOADING = 'downloading',
READY = 'ready',
GENERATING = 'generating', // Before receiving first chunk
PARTIAL_TEASER = 'partial-teaser', // After receiving first chunk
TEASER = 'teaser',
ERROR = 'error',
}
interface ViewInput {
onTellMeMoreClick: (event: Event) => void;
// If multiple ConsoleInsightTeasers exist, each one needs a unique id. Otherwise showing and
// hiding of the tooltip, and rendering the loading animation, does not work correctly.
uuid: string;
headerText: string;
mainText: string;
isInactive: boolean;
dontShowChanged: (e: Event) => void;
hasTellMeMoreButton: boolean;
isSlowGeneration: boolean;
onDownloadModelClick: (event: Event) => void;
downloadProgress: number|null;
state: State;
isForWarning: boolean;
}
function renderNoModel(input: ViewInput): Lit.TemplateResult {
// clang-format off
return html`
<div class="teaser-tooltip-container">
<div class="response-container">
<h2>${input.isForWarning ?
lockedString(UIStringsNotTranslate.getHelpForWarning) :
lockedString(UIStringsNotTranslate.getHelpForError)}
</h2>
<div>You can get quick answers from
<x-link
.jslog=${VisualLogging.link().track({click: true, keydown: 'Enter|Space'}).context('insights-teaser-built-in-ai-documentation')}
class="link"
href=${BUILT_IN_AI_DOCUMENTATION}
>
Chrome’s Built-in AI
</x-link>
, without any data leaving your device.
</div>
<div>${lockedString(UIStringsNotTranslate.toUseDownload)}</div>
</div>
<div class="tooltip-footer">
<devtools-button
title=${lockedString(UIStringsNotTranslate.downloadModel)}
.jslogContext=${'insights-teaser-download-model'}
.variant=${Buttons.Button.Variant.PRIMARY}
=${input.onDownloadModelClick}
=${(e: Event) => {
e.stopPropagation();
}}
>
${lockedString(UIStringsNotTranslate.downloadModel)}
</devtools-button>
${renderDontShowCheckbox(input)}
</div>
</div>
`;
// clang-format on
}
function renderDownloading(input: ViewInput): Lit.TemplateResult {
const percent = ((input.downloadProgress || 0) * 100).toFixed(0);
// clang-format off
return html`
<div class="teaser-tooltip-container">
<div class="response-container">
<h2>${lockedString(UIStringsNotTranslate.downloadingAiModel)}</h2>
<div class="progress-line">
${input.downloadProgress === null ?
html`
<div class="label">${lockedString(UIStringsNotTranslate.progressUnknown)}</div>
` : html`
<div class="label">${lockedString(UIStringsNotTranslate.progress)}</div>
<div class="indicator-container">
<div
class="indicator"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow=${percent}
style="width: ${percent}%"
></div>
</div>
`}
</div>
</div>
<div class="tooltip-footer">
<devtools-button
title=${lockedString(UIStringsNotTranslate.downloadModel)}
.jslogContext=${'insights-teaser-download-model'}
.variant=${Buttons.Button.Variant.PRIMARY}
.disabled=${true}
>
${lockedString(UIStringsNotTranslate.downloadModel)}
</devtools-button>
</div>
</div>
`;
// clang-format on
}
function renderGenerating(input: ViewInput): Lit.TemplateResult {
// clang-format off
return html`
<div class="teaser-tooltip-container">
<div class="response-container">
<h2>${input.isSlowGeneration ?
lockedString(UIStringsNotTranslate.summarizingTakesABitLonger) :
lockedString(UIStringsNotTranslate.summarizing)
}</h2>
<div
role="presentation"
aria-label=${lockedString(UIStringsNotTranslate.loading)}
class="loader"
style="clip-path: url(${'#clipPath-' + input.uuid});"
>
<svg width="100%" height="58">
<defs>
<clipPath id=${'clipPath-' + input.uuid}>
<rect x="0" y="0" width="100%" height="12" rx="8"></rect>
<rect x="0" y="20" width="100%" height="12" rx="8"></rect>
<rect x="0" y="40" width="100%" height="12" rx="8"></rect>
</clipPath>
</defs>
</svg>
</div>
</div>
${renderFooter(input)}
</div>
`;
// clang-format on
}
function renderError(input: ViewInput): Lit.TemplateResult {
// clang-format off
return html`
<div class="teaser-tooltip-container">
<h2>${lockedString(UIStringsNotTranslate.summaryNotAvailable)}</h2>
${renderFooter(input)}
</div>
`;
// clang-format on
}
function renderDontShowCheckbox(input: ViewInput): Lit.TemplateResult {
// clang-format off
return html`
<devtools-checkbox
aria-label=${lockedString(UIStringsNotTranslate.dontShow)}
=${input.dontShowChanged}
jslog=${VisualLogging.toggle('explain.teaser.dont-show').track({ change: true })}>
${lockedString(UIStringsNotTranslate.dontShow)}
</devtools-checkbox>
`;
// clang-format on
}
function renderFooter(input: ViewInput): Lit.TemplateResult {
// clang-format off
return html`
<div class="tooltip-footer">
${input.hasTellMeMoreButton ? html`
<devtools-button
title=${lockedString(UIStringsNotTranslate.tellMeMore)}
.jslogContext=${'insights-teaser-tell-me-more'}
.variant=${Buttons.Button.Variant.PRIMARY}
=${input.onTellMeMoreClick}
>
<devtools-icon class="lightbulb-icon" name="lightbulb-spark"></devtools-icon>
${lockedString(UIStringsNotTranslate.tellMeMore)}
</devtools-button>
` : Lit.nothing}
<devtools-button
.iconName=${'info'}
.variant=${Buttons.Button.Variant.ICON}
aria-details=${'teaser-info-tooltip-' + input.uuid}
.accessibleLabel=${lockedString(UIStringsNotTranslate.learnDataUsage)}
></devtools-button>
<devtools-tooltip
id=${'teaser-info-tooltip-' + input.uuid}
variant="rich"
jslogContext="teaser-info-tooltip"
trigger="both"
hover-delay=500
>
<div class="info-tooltip-text">${lockedString(UIStringsNotTranslate.infoTooltipText)}</div>
<div class="learn-more">
<x-link
class="devtools-link"
title=${lockedString(UIStringsNotTranslate.learnMoreAboutAiSummaries)}
href=${DATA_USAGE_URL}
jslog=${VisualLogging.link().track({click: true, keydown:'Enter|Space'}).context('explain.teaser.learn-more')}
>${lockedString(UIStringsNotTranslate.learnMoreAboutAiSummaries)}</x-link>
</div>
</devtools-tooltip>
${renderDontShowCheckbox(input)}
</div>
`;
// clang-format on
}
function renderTeaser(input: ViewInput): Lit.TemplateResult {
// clang-format off
return html`
<div class="teaser-tooltip-container">
<div class="response-container">
<h2>${input.headerText}</h2>
<div class="main-text">${input.mainText}</div>
</div>
${renderFooter(input)}
</div>
`;
// clang-format on
}
export const DEFAULT_VIEW = (input: ViewInput, _output: undefined, target: HTMLElement): void => {
if (input.isInactive) {
render(Lit.nothing, target);
return;
}
// clang-format off
render(html`
<style>${consoleInsightTeaserStyles}</style>
<devtools-tooltip
id=${'teaser-' + input.uuid}
hover-delay=500
variant="rich"
vertical-distance-increase=-6
prefer-span-left
jslogContext="console-insight-teaser"
>
${(() => {
switch (input.state) {
case State.NO_MODEL:
return renderNoModel(input);
case State.DOWNLOADING:
return renderDownloading(input);
case State.READY:
case State.GENERATING:
return renderGenerating(input);
case State.ERROR:
return renderError(input);
case State.PARTIAL_TEASER:
case State.TEASER:
return renderTeaser(input);
}
})()}
</devtools-tooltip>
`, target);
// clang-format on
};
export type View = typeof DEFAULT_VIEW;
export class ConsoleInsightTeaser extends UI.Widget.Widget {
#view: View;
#uuid: string;
#builtInAi: AiAssistanceModel.BuiltInAi.BuiltInAi;
#promptBuilder: PromptBuilder;
#headerText = '';
#mainText = '';
#consoleViewMessage: ConsoleViewMessage;
#isInactive = false;
#abortController: null|AbortController = null;
#isSlow = false;
#timeoutId: ReturnType<typeof setTimeout>|null = null;
#aidaAvailability?: Host.AidaClient.AidaAccessPreconditions;
#boundOnAidaAvailabilityChange: () => Promise<void>;
#boundOnDownloadProgressChange: (event: Common.EventTarget.EventTargetEvent<number>) => void;
#boundOnSessionCreation: () => void;
#downloadProgress: number|null = null;
#state: State;
#eventListeners: Common.EventTarget.EventDescriptor[] = [];
#isForWarning: boolean;
constructor(uuid: string, consoleViewMessage: ConsoleViewMessage, element?: HTMLElement, view?: View) {
super(element);
this.#view = view ?? DEFAULT_VIEW;
this.#uuid = uuid;
this.#promptBuilder = new PromptBuilder(consoleViewMessage);
this.#consoleViewMessage = consoleViewMessage;
this.#isForWarning = this.#consoleViewMessage.consoleMessage().level === Protocol.Log.LogEntryLevel.Warning;
this.#boundOnAidaAvailabilityChange = this.#onAidaAvailabilityChange.bind(this);
this.#boundOnDownloadProgressChange = this.#onDownloadProgressChange.bind(this);
this.#boundOnSessionCreation = this.#onSessionCreation.bind(this);
this.#builtInAi = AiAssistanceModel.BuiltInAi.BuiltInAi.instance();
this.#state = this.#builtInAi.hasSession() ? State.READY : State.NO_MODEL;
this.requestUpdate();
}
#getConsoleInsightsEnabledSetting(): Common.Settings.Setting<boolean>|undefined {
try {
return Common.Settings.moduleSetting('console-insights-enabled') as Common.Settings.Setting<boolean>;
} catch {
return;
}
}
#getOnboardingCompletedSetting(): Common.Settings.Setting<boolean> {
return Common.Settings.Settings.instance().createLocalSetting('console-insights-onboarding-finished', true);
}
async #onAidaAvailabilityChange(): Promise<void> {
const currentAidaAvailability = await Host.AidaClient.AidaClient.checkAccessPreconditions();
if (currentAidaAvailability !== this.#aidaAvailability) {
this.#aidaAvailability = currentAidaAvailability;
this.requestUpdate();
}
}
#executeConsoleInsightAction(): void {
UI.Context.Context.instance().setFlavor(ConsoleViewMessage, this.#consoleViewMessage);
const action = UI.ActionRegistry.ActionRegistry.instance().getAction(EXPLAIN_TEASER_ACTION_ID);
void action.execute();
}
#onTellMeMoreClick(event: Event): void {
event.stopPropagation();
if (this.#getConsoleInsightsEnabledSetting()?.getIfNotDisabled() &&
this.#getOnboardingCompletedSetting()?.getIfNotDisabled()) {
this.#executeConsoleInsightAction();
return;
}
void this.#showFreDialog();
}
#onDownloadModelClick(event: Event): void {
event.stopPropagation();
this.#state = State.DOWNLOADING;
this.#builtInAi.startDownloadingModel();
Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightTeaserModelDownloadStarted);
this.requestUpdate();
}
#onDownloadProgressChange(event: Common.EventTarget.EventTargetEvent<number>): void {
this.#downloadProgress = event.data;
this.requestUpdate();
}
#onSessionCreation(): void {
if (this.#builtInAi.hasSession() && (this.#state === State.NO_MODEL || this.#state === State.DOWNLOADING)) {
this.#state = State.READY;
Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightTeaserModelDownloadCompleted);
this.maybeGenerateTeaser();
}
}
async #showFreDialog(): Promise<void> {
const noLogging = Root.Runtime.hostConfig.aidaAvailability?.enterprisePolicyValue ===
Root.Runtime.GenAiEnterprisePolicyValue.ALLOW_WITHOUT_LOGGING;
const result = await PanelCommon.FreDialog.show({
header: {iconName: 'smart-assistant', text: lockedString(UIStringsNotTranslate.freDisclaimerHeader)},
reminderItems: [
{
iconName: 'psychiatry',
content: lockedString(UIStringsNotTranslate.freDisclaimerTextAiWontAlwaysGetItRight),
},
{
iconName: 'google',
content: noLogging ? lockedString(UIStringsNotTranslate.consoleInsightsSendsDataNoLogging) :
lockedString(UIStringsNotTranslate.consoleInsightsSendsData),
},
{
iconName: 'warning',
// clang-format off
content: html`<x-link
href=${CODE_SNIPPET_WARNING_URL}
class="link devtools-link"
jslog=${VisualLogging.link('explain.teaser.code-snippets-explainer').track({
click: true
})}
>${lockedString(UIStringsNotTranslate.freDisclaimerTextUseWithCaution)}</x-link>`,
// clang-format on
}
],
onLearnMoreClick: () => {
void UI.ViewManager.ViewManager.instance().showView('chrome-ai');
},
ariaLabel: lockedString(UIStringsNotTranslate.freDisclaimerHeader),
learnMoreButtonText: lockedString(UIStringsNotTranslate.learnMore),
});
if (result) {
this.#getConsoleInsightsEnabledSetting()?.set(true);
this.#getOnboardingCompletedSetting()?.set(true);
this.#executeConsoleInsightAction();
}
}
maybeGenerateTeaser(): void {
const startGeneratingTeaser = (): void => {
if (!this.#isInactive &&
Common.Settings.Settings.instance().moduleSetting('console-insight-teasers-enabled').get()) {
void this.#generateTeaserText();
}
};
const hasSession = this.#builtInAi.hasSession();
switch (this.#state) {
case State.NO_MODEL:
case State.DOWNLOADING:
if (hasSession) {
this.#state = State.READY;
startGeneratingTeaser();
} else {
if (this.#eventListeners.length === 0) {
this.#eventListeners = [
this.#builtInAi.addEventListener(
AiAssistanceModel.BuiltInAi.Events.DOWNLOAD_PROGRESS_CHANGED, this.#boundOnDownloadProgressChange),
this.#builtInAi.addEventListener(
AiAssistanceModel.BuiltInAi.Events.DOWNLOADED_AND_SESSION_CREATED, this.#boundOnSessionCreation),
];
}
if (this.#builtInAi.isDownloading()) {
this.#state = State.DOWNLOADING;
this.#downloadProgress = this.#builtInAi.getDownloadProgress();
}
}
this.requestUpdate();
return;
case State.READY:
startGeneratingTeaser();
this.requestUpdate();
return;
case State.GENERATING:
console.error('Trying trigger teaser generation when state is "GENERATING"');
return;
case State.PARTIAL_TEASER:
console.error('Trying trigger teaser generation when state is "PARTIAL_TEASER"');
return;
// These are terminal states. No need to update anything.
case State.TEASER:
case State.ERROR:
return;
}
}
abortTeaserGeneration(): void {
if (this.#abortController) {
this.#abortController.abort();
}
if (this.#state === State.GENERATING || this.#state === State.PARTIAL_TEASER) {
this.#mainText = '';
this.#state = State.READY;
Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightTeaserGenerationAborted);
}
if (this.#timeoutId) {
clearTimeout(this.#timeoutId);
}
Common.EventTarget.removeEventListeners(this.#eventListeners);
}
setInactive(isInactive: boolean): void {
if (this.#isInactive === isInactive) {
return;
}
this.#isInactive = isInactive;
this.requestUpdate();
}
#setSlow(): void {
this.#isSlow = true;
this.requestUpdate();
}
async #generateTeaserText(): Promise<void> {
this.#headerText = this.#consoleViewMessage.toMessageTextString().substring(0, 70);
this.#state = State.GENERATING;
Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightTeaserGenerationStarted);
this.#timeoutId = setTimeout(this.#setSlow.bind(this), SLOW_GENERATION_CUTOFF_MILLISECONDS);
const startTime = performance.now();
let teaserText = '';
let firstChunkReceived = false;
try {
for await (const chunk of this.#getOnDeviceInsight()) {
teaserText += chunk;
this.#mainText = teaserText;
this.#state = State.PARTIAL_TEASER;
this.requestUpdate();
if (!firstChunkReceived) {
firstChunkReceived = true;
Host.userMetrics.consoleInsightTeaserFirstChunkGenerated(performance.now() - startTime);
}
}
} catch (err) {
// Ignore `AbortError` errors, which are thrown on mouse leave.
if (err.name === 'AbortError') {
this.#state = State.READY;
} else {
console.error(err.name, err.message);
this.#state = State.ERROR;
Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightTeaserGenerationErrored);
}
clearTimeout(this.#timeoutId);
this.requestUpdate();
return;
}
clearTimeout(this.#timeoutId);
Host.userMetrics.consoleInsightTeaserGenerated(performance.now() - startTime);
this.#state = State.TEASER;
this.#mainText = teaserText;
Host.userMetrics.actionTaken(Host.UserMetrics.Action.InsightTeaserGenerationCompleted);
this.requestUpdate();
}
async * #getOnDeviceInsight(): AsyncGenerator<string> {
const {prompt} = await this.#promptBuilder.buildPrompt();
this.#abortController = new AbortController();
const stream = this.#builtInAi.getConsoleInsight(prompt, this.#abortController);
for await (const chunk of stream) {
yield chunk;
}
this.#abortController = null;
}
#dontShowChanged(e: Event): void {
const showTeasers = !(e.target as HTMLInputElement).checked;
Common.Settings.Settings.instance().moduleSetting('console-insight-teasers-enabled').set(showTeasers);
}
#hasTellMeMoreButton(): boolean {
if (!UI.ActionRegistry.ActionRegistry.instance().hasAction(EXPLAIN_TEASER_ACTION_ID)) {
return false;
}
if (Root.Runtime.hostConfig.aidaAvailability?.blockedByAge || Root.Runtime.hostConfig.isOffTheRecord) {
return false;
}
if (this.#aidaAvailability !== Host.AidaClient.AidaAccessPreconditions.AVAILABLE) {
return false;
}
return true;
}
override performUpdate(): Promise<void>|void {
this.#view(
{
onTellMeMoreClick: this.#onTellMeMoreClick.bind(this),
uuid: this.#uuid,
headerText: this.#headerText,
mainText: this.#mainText,
isInactive: this.#isInactive ||
!Common.Settings.Settings.instance().moduleSetting('console-insight-teasers-enabled').get(),
dontShowChanged: this.#dontShowChanged.bind(this),
hasTellMeMoreButton: this.#hasTellMeMoreButton(),
isSlowGeneration: this.#isSlow,
onDownloadModelClick: this.#onDownloadModelClick.bind(this),
downloadProgress: this.#downloadProgress,
state: this.#state,
isForWarning: this.#isForWarning,
},
undefined, this.contentElement);
}
override wasShown(): void {
super.wasShown();
Host.AidaClient.HostConfigTracker.instance().addEventListener(
Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED, this.#boundOnAidaAvailabilityChange);
void this.#onAidaAvailabilityChange();
}
override willHide(): void {
super.willHide();
Host.AidaClient.HostConfigTracker.instance().removeEventListener(
Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED, this.#boundOnAidaAvailabilityChange);
}
}