chrome-devtools-frontend
Version:
Chrome DevTools UI
489 lines (425 loc) • 17.2 kB
text/typescript
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as Root from '../../core/root/root.js';
import * as TextEditor from '../../ui/components/text_editor/text_editor.js';
import {debugLog} from './debug.js';
export const DELAY_BEFORE_SHOWING_RESPONSE_MS = 500;
export const AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS = 200;
// TODO(b/404796739): Remove these definitions of AgentOptions and RequestOptions and
// use the existing ones which are used for AI assistance panel agents.
interface AgentOptions {
aidaClient: Host.AidaClient.AidaClient;
serverSideLoggingEnabled?: boolean;
confirmSideEffectForTest?: typeof Promise.withResolvers;
}
interface RequestOptions {
temperature?: number;
modelId?: string;
}
interface CachedRequest {
request: Host.AidaClient.CompletionRequest;
response: Host.AidaClient.CompletionResponse;
}
/* clang-format off */
const consoleAdditionalContextFileContent = `/**
* This file describes the execution environment of the Chrome DevTools Console.
* The code is JavaScript, but with special global functions and variables.
* Top-level await is available.
* The console has direct access to the inspected page's \`window\` and \`document\`.
*/
/**
* @description Returns the value of the most recently evaluated expression.
*/
let $_;
/**
* @description A reference to the most recently selected DOM element.
* $0, $1, $2, $3, $4 can be used to reference the last five selected DOM elements.
*/
let $0;
/**
* @description A query selector alias. $$('.my-class') is equivalent to document.querySelectorAll('.my-class').
*/
function $$(selector, startNode) {}
/**
* @description An XPath selector. $x('//p') returns an array of all <p> elements.
*/
function $x(path, startNode) {}
function clear() {}
function copy(object) {}
/**
* @description Selects and reveals the specified element in the Elements panel.
*/
function inspect(object) {}
function keys(object) {}
function values(object) {}
/**
* @description When the specified function is called, the debugger is invoked.
*/
function debug(func) {}
/**
* @description Stops the debugging of the specified function.
*/
function undebug(func) {}
/**
* @description Logs a message to the console whenever the specified function is called,
* along with the arguments passed to it.
*/
function monitor(func) {}
/**
* @description Stops monitoring the specified function.
*/
function unmonitor(func) {}
/**
* @description Logs all events dispatched to the specified object to the console.
*/
function monitorEvents(object, events) {}
/**
* @description Returns an object containing all event listeners registered on the specified object.
*/
function getEventListeners(object) {}
/**
* The global \`console\` object has several helpful methods
*/
const console = {
log: (...args) => {},
warn: (...args) => {},
error: (...args) => {},
info: (...args) => {},
debug: (...args) => {},
assert: (assertion, ...args) => {},
dir: (object) => {}, // Displays an interactive property listing of an object.
dirxml: (object) => {}, // Displays an XML/HTML representation of an object.
table: (data, columns) => {}, // Displays tabular data as a table.
group: (label) => {}, // Creates a new inline collapsible group.
groupEnd: () => {},
time: (label) => {}, // Starts a timer.
timeEnd: (label) => {} // Stops a timer and logs the elapsed time.
};`;
/* clang-format on */
/**
* The AiCodeCompletion class is responsible for fetching code completion suggestions
* from the AIDA backend and displaying them in the text editor.
*
* 1. **Debouncing requests:** As the user types, we don't want to send a request
* for every keystroke. Instead, we use debouncing to schedule a request
* only after the user has paused typing for a short period
* (AIDA_REQUEST_THROTTLER_TIMEOUT_MS). This prevents spamming the backend with
* requests for intermediate typing states.
*
* 2. **Delaying suggestions:** When a suggestion is received from the AIDA
* backend, we don't show it immediately. There is a minimum delay
* (DELAY_BEFORE_SHOWING_RESPONSE_MS) from when the request was sent to when
* the suggestion is displayed.
*/
export class AiCodeCompletion extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
#editor: TextEditor.TextEditor.TextEditor;
#stopSequences: string[];
#renderingTimeout?: number;
#aidaRequestCache?: CachedRequest;
#panel: Panel;
readonly #sessionId: string = crypto.randomUUID();
readonly #aidaClient: Host.AidaClient.AidaClient;
readonly #serverSideLoggingEnabled: boolean;
constructor(opts: AgentOptions, editor: TextEditor.TextEditor.TextEditor, panel: Panel, stopSequences?: string[]) {
super();
this.#aidaClient = opts.aidaClient;
this.#serverSideLoggingEnabled = opts.serverSideLoggingEnabled ?? false;
this.#editor = editor;
this.#panel = panel;
this.#stopSequences = stopSequences ?? [];
}
#debouncedRequestAidaSuggestion = Common.Debouncer.debounce(
(prefix: string, suffix: string, cursorPositionAtRequest: number,
inferenceLanguage?: Host.AidaClient.AidaInferenceLanguage) => {
void this.#requestAidaSuggestion(
this.#buildRequest(prefix, suffix, inferenceLanguage), cursorPositionAtRequest);
},
AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS);
#buildRequest(
prefix: string, suffix: string,
inferenceLanguage: Host.AidaClient.AidaInferenceLanguage = Host.AidaClient.AidaInferenceLanguage.JAVASCRIPT):
Host.AidaClient.CompletionRequest {
const userTier = Host.AidaClient.convertToUserTierEnum(this.#userTier);
function validTemperature(temperature: number|undefined): number|undefined {
return typeof temperature === 'number' && temperature >= 0 ? temperature : undefined;
}
// As a temporary fix for b/441221870 we are prepending a newline for each prefix.
prefix = '\n' + prefix;
const additionalFiles = this.#panel === Panel.CONSOLE ? [{
path: 'devtools-console-context.js',
content: consoleAdditionalContextFileContent,
included_reason: Host.AidaClient.Reason.RELATED_FILE,
}] :
undefined;
return {
client: Host.AidaClient.CLIENT_NAME,
prefix,
suffix,
options: {
inference_language: inferenceLanguage,
temperature: validTemperature(this.#options.temperature),
model_id: this.#options.modelId || undefined,
stop_sequences: this.#stopSequences,
},
metadata: {
disable_user_content_logging: !(this.#serverSideLoggingEnabled ?? false),
string_session_id: this.#sessionId,
user_tier: userTier,
client_version: Root.Runtime.getChromeVersion(),
},
additional_files: additionalFiles,
};
}
async #completeCodeCached(request: Host.AidaClient.CompletionRequest): Promise<{
response: Host.AidaClient.CompletionResponse | null,
fromCache: boolean,
}> {
const cachedResponse = this.#checkCachedRequestForResponse(request);
if (cachedResponse) {
return {response: cachedResponse, fromCache: true};
}
const response = await this.#aidaClient.completeCode(request);
if (!response) {
return {
response: null,
fromCache: false,
};
}
this.#updateCachedRequest(request, response);
return {
response,
fromCache: false,
};
}
#pickSampleFromResponse(response: Host.AidaClient.CompletionResponse): Host.AidaClient.GenerationSample|null {
if (!response.generatedSamples.length) {
return null;
}
// `currentHint` is the portion of a standard autocomplete suggestion that the user has not yet typed.
// For example, if the user types `document.queryS` and the autocomplete suggests `document.querySelector`,
// the `currentHint` is `elector`.
const currentHintInMenu = this.#editor.editor.plugin(TextEditor.Config.showCompletionHint)?.currentHint;
// TODO(ergunsh): We should not do this check here. Instead, the AI code suggestions should be provided
// as it is to the view plugin. The view plugin should choose which one to use based on the completion hint
// and selected completion.
if (!currentHintInMenu) {
return response.generatedSamples[0];
}
// TODO(ergunsh): This does not handle looking for `selectedCompletion`. The `currentHint` is `null`
// for the Sources panel case.
// Even though there is no match, we still return the first suggestion which will be displayed
// when the traditional autocomplete menu is closed.
return response.generatedSamples.find(sample => sample.generationString.startsWith(currentHintInMenu)) ??
response.generatedSamples[0];
}
async #generateSampleForRequest(request: Host.AidaClient.CompletionRequest, cursor: number): Promise<{
suggestionText: string,
sampleId: number,
fromCache: boolean,
citations: Host.AidaClient.Citation[],
rpcGlobalId?: Host.AidaClient.RpcGlobalId,
}|null> {
const {response, fromCache} = await this.#completeCodeCached(request);
debugLog('At cursor position', cursor, {request, response, fromCache});
if (!response) {
return null;
}
const suggestionSample = this.#pickSampleFromResponse(response);
if (!suggestionSample) {
return null;
}
const shouldBlock =
suggestionSample.attributionMetadata?.attributionAction === Host.AidaClient.RecitationAction.BLOCK;
if (shouldBlock) {
return null;
}
const suggestionText = this.#trimSuggestionOverlap(suggestionSample.generationString, request);
if (suggestionText.length === 0) {
return null;
}
return {
suggestionText,
sampleId: suggestionSample.sampleId,
fromCache,
citations: suggestionSample.attributionMetadata?.citations ?? [],
rpcGlobalId: response.metadata.rpcGlobalId,
};
}
async #requestAidaSuggestion(request: Host.AidaClient.CompletionRequest, cursorPositionAtRequest: number):
Promise<void> {
const startTime = performance.now();
this.dispatchEventToListeners(Events.REQUEST_TRIGGERED, {});
// Registering AiCodeCompletionRequestTriggered metric even if the request is served from cache
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiCodeCompletionRequestTriggered);
try {
const sampleResponse = await this.#generateSampleForRequest(request, cursorPositionAtRequest);
if (!sampleResponse) {
this.dispatchEventToListeners(Events.RESPONSE_RECEIVED, {});
return;
}
const {
suggestionText,
sampleId,
fromCache,
citations,
rpcGlobalId,
} = sampleResponse;
const remainingDelay = Math.max(DELAY_BEFORE_SHOWING_RESPONSE_MS - (performance.now() - startTime), 0);
this.#renderingTimeout = window.setTimeout(() => {
const currentCursorPosition = this.#editor.editor.state.selection.main.head;
if (currentCursorPosition !== cursorPositionAtRequest) {
this.dispatchEventToListeners(Events.RESPONSE_RECEIVED, {});
return;
}
this.#editor.dispatch({
effects: TextEditor.Config.setAiAutoCompleteSuggestion.of({
text: suggestionText,
from: cursorPositionAtRequest,
rpcGlobalId,
sampleId,
startTime,
onImpression: this.#registerUserImpression.bind(this),
})
});
if (fromCache) {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiCodeCompletionResponseServedFromCache);
}
if (rpcGlobalId) {
const latency = performance.now() - startTime;
this.#registerUserImpression(rpcGlobalId, sampleId, latency);
}
debugLog('Suggestion dispatched to the editor', suggestionText, 'at cursor position', cursorPositionAtRequest);
this.dispatchEventToListeners(Events.RESPONSE_RECEIVED, {citations});
}, remainingDelay);
} catch (e) {
debugLog('Error while fetching code completion suggestions from AIDA', e);
this.dispatchEventToListeners(Events.RESPONSE_RECEIVED, {});
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiCodeCompletionError);
}
}
get #userTier(): string|undefined {
return Root.Runtime.hostConfig.devToolsAiCodeCompletion?.userTier;
}
get #options(): RequestOptions {
const temperature = Root.Runtime.hostConfig.devToolsAiCodeCompletion?.temperature;
const modelId = Root.Runtime.hostConfig.devToolsAiCodeCompletion?.modelId;
return {
temperature,
modelId,
};
}
/**
* Removes the end of a suggestion if it overlaps with the start of the suffix.
*/
#trimSuggestionOverlap(generationString: string, request: Host.AidaClient.CompletionRequest): string {
const suffix = request.suffix;
if (!suffix) {
return generationString;
}
// Iterate from the longest possible overlap down to the shortest
for (let i = Math.min(generationString.length, suffix.length); i > 0; i--) {
const overlapCandidate = suffix.substring(0, i);
if (generationString.endsWith(overlapCandidate)) {
return generationString.slice(0, -i);
}
}
return generationString;
}
#checkCachedRequestForResponse(request: Host.AidaClient.CompletionRequest): Host.AidaClient.CompletionResponse|null {
if (!this.#aidaRequestCache || this.#aidaRequestCache.request.suffix !== request.suffix ||
JSON.stringify(this.#aidaRequestCache.request.options) !== JSON.stringify(request.options)) {
return null;
}
const possibleGeneratedSamples: Host.AidaClient.GenerationSample[] = [];
for (const sample of this.#aidaRequestCache.response.generatedSamples) {
const prefixWithSample = this.#aidaRequestCache.request.prefix + sample.generationString;
if (prefixWithSample.startsWith(request.prefix)) {
possibleGeneratedSamples.push({
generationString: prefixWithSample.substring(request.prefix.length),
sampleId: sample.sampleId,
score: sample.score,
attributionMetadata: sample.attributionMetadata,
});
}
}
if (possibleGeneratedSamples.length === 0) {
return null;
}
return {generatedSamples: possibleGeneratedSamples, metadata: this.#aidaRequestCache.response.metadata};
}
#updateCachedRequest(request: Host.AidaClient.CompletionRequest, response: Host.AidaClient.CompletionResponse): void {
this.#aidaRequestCache = {request, response};
}
#registerUserImpression(rpcGlobalId: Host.AidaClient.RpcGlobalId, sampleId: number, latency: number): void {
const seconds = Math.floor(latency / 1_000);
const remainingMs = latency % 1_000;
const nanos = Math.floor(remainingMs * 1_000_000);
void this.#aidaClient.registerClientEvent({
corresponding_aida_rpc_global_id: rpcGlobalId,
disable_user_content_logging: true,
complete_code_client_event: {
user_impression: {
sample: {
sample_id: sampleId,
},
latency: {
duration: {
seconds,
nanos,
},
}
},
},
});
debugLog('Registered user impression with latency {seconds:', seconds, ', nanos:', nanos, '}');
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiCodeCompletionSuggestionDisplayed);
}
registerUserAcceptance(rpcGlobalId: Host.AidaClient.RpcGlobalId, sampleId: number): void {
void this.#aidaClient.registerClientEvent({
corresponding_aida_rpc_global_id: rpcGlobalId,
disable_user_content_logging: true,
complete_code_client_event: {
user_acceptance: {
sample: {
sample_id: sampleId,
}
},
},
});
debugLog('Registered user acceptance');
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiCodeCompletionSuggestionAccepted);
}
onTextChanged(
prefix: string, suffix: string, cursorPositionAtRequest: number,
inferenceLanguage?: Host.AidaClient.AidaInferenceLanguage): void {
this.#debouncedRequestAidaSuggestion(prefix, suffix, cursorPositionAtRequest, inferenceLanguage);
}
remove(): void {
if (this.#renderingTimeout) {
clearTimeout(this.#renderingTimeout);
this.#renderingTimeout = undefined;
}
this.#editor.dispatch({
effects: TextEditor.Config.setAiAutoCompleteSuggestion.of(null),
});
}
}
export const enum Panel {
CONSOLE = 'console',
SOURCES = 'sources',
}
export const enum Events {
RESPONSE_RECEIVED = 'ResponseReceived',
REQUEST_TRIGGERED = 'RequestTriggered',
}
export interface ResponseReceivedEvent {
citations?: Host.AidaClient.Citation[];
}
export interface EventTypes {
[Events.RESPONSE_RECEIVED]: ResponseReceivedEvent;
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
[Events.REQUEST_TRIGGERED]: {};
}