chrome-devtools-frontend
Version:
Chrome DevTools UI
321 lines (284 loc) • 12 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 i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as NetworkTimeCalculator from '../network_time_calculator/network_time_calculator.js';
import {
type AiAgent,
type ExternalRequestResponse,
ExternalRequestResponseType,
ResponseType
} from './agents/AiAgent.js';
import {NetworkAgent, RequestContext} from './agents/NetworkAgent.js';
import type {PerformanceTraceContext} from './agents/PerformanceAgent.js';
import {NodeContext, StylingAgent} from './agents/StylingAgent.js';
import {AiConversation} from './AiConversation.js';
import {
ConversationType,
} from './AiHistoryStorage.js';
import {getDisabledReasons} from './AiUtils.js';
interface ExternalStylingRequestParameters {
conversationType: ConversationType.STYLING;
prompt: string;
selector?: string;
}
interface ExternalNetworkRequestParameters {
conversationType: ConversationType.NETWORK;
prompt: string;
requestUrl: string;
}
export interface ExternalPerformanceAIConversationData {
conversationHandler: ConversationHandler;
conversation: AiConversation;
selected: PerformanceTraceContext;
}
export interface ExternalPerformanceRequestParameters {
conversationType: ConversationType.PERFORMANCE;
prompt: string;
data: ExternalPerformanceAIConversationData;
}
/*
* Strings that don't need to be translated at this time.
*/
const UIStringsNotTranslate = {
/**
* @description Error message shown when AI assistance is not enabled in DevTools settings.
*/
enableInSettings: 'For AI features to be available, you need to enable AI assistance in DevTools settings.',
} as const;
const lockedString = i18n.i18n.lockedString;
function isAiAssistanceServerSideLoggingEnabled(): boolean {
return !Root.Runtime.hostConfig.aidaAvailability?.disallowLogging;
}
async function inspectElementBySelector(selector: string): Promise<SDK.DOMModel.DOMNode|null> {
const whitespaceTrimmedQuery = selector.trim();
if (!whitespaceTrimmedQuery.length) {
return null;
}
const showUAShadowDOM = Common.Settings.Settings.instance().moduleSetting('show-ua-shadow-dom').get();
const domModels = SDK.TargetManager.TargetManager.instance().models(SDK.DOMModel.DOMModel, {scoped: true});
const performSearchPromises =
domModels.map(domModel => domModel.performSearch(whitespaceTrimmedQuery, showUAShadowDOM));
const resultCounts = await Promise.all(performSearchPromises);
// If the selector matches multiple times, this returns the first match.
const index = resultCounts.findIndex(value => value > 0);
if (index >= 0) {
return await domModels[index].searchResult(0);
}
return null;
}
async function inspectNetworkRequestByUrl(selector: string): Promise<SDK.NetworkRequest.NetworkRequest|null> {
const networkManagers =
SDK.TargetManager.TargetManager.instance().models(SDK.NetworkManager.NetworkManager, {scoped: true});
const results = networkManagers
.map(networkManager => {
let request = networkManager.requestForURL(Platform.DevToolsPath.urlString`${selector}`);
if (!request && selector.at(-1) === '/') {
request =
networkManager.requestForURL(Platform.DevToolsPath.urlString`${selector.slice(0, -1)}`);
} else if (!request && selector.at(-1) !== '/') {
request = networkManager.requestForURL(Platform.DevToolsPath.urlString`${selector}/`);
}
return request;
})
.filter(req => !!req);
const request = results.at(0);
return request ?? null;
}
let conversationHandlerInstance: ConversationHandler|undefined;
export class ConversationHandler extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
#aiAssistanceEnabledSetting: Common.Settings.Setting<boolean>|undefined;
#aidaClient: Host.AidaClient.AidaClient;
#aidaAvailability?: Host.AidaClient.AidaAccessPreconditions;
private constructor(
aidaClient: Host.AidaClient.AidaClient, aidaAvailability?: Host.AidaClient.AidaAccessPreconditions) {
super();
this.#aidaClient = aidaClient;
this.#aidaAvailability = aidaAvailability;
this.#aiAssistanceEnabledSetting = this.#getAiAssistanceEnabledSetting();
}
static instance(opts?: {
aidaClient?: Host.AidaClient.AidaClient,
aidaAvailability?: Host.AidaClient.AidaAccessPreconditions,
forceNew?: boolean,
}): ConversationHandler {
if (opts?.forceNew || conversationHandlerInstance === undefined) {
const aidaClient = opts?.aidaClient ?? new Host.AidaClient.AidaClient();
conversationHandlerInstance = new ConversationHandler(aidaClient, opts?.aidaAvailability ?? undefined);
}
return conversationHandlerInstance;
}
static removeInstance(): void {
conversationHandlerInstance = undefined;
}
get aidaClient(): Host.AidaClient.AidaClient {
return this.#aidaClient;
}
#getAiAssistanceEnabledSetting(): Common.Settings.Setting<boolean>|undefined {
try {
return Common.Settings.moduleSetting('ai-assistance-enabled') as Common.Settings.Setting<boolean>;
} catch {
return;
}
}
async #getDisabledReasons(): Promise<Platform.UIString.LocalizedString[]> {
if (this.#aidaAvailability === undefined) {
this.#aidaAvailability = await Host.AidaClient.AidaClient.checkAccessPreconditions();
}
return getDisabledReasons(this.#aidaAvailability);
}
// eslint-disable-next-line require-yield
async * #generateErrorResponse(message: string): AsyncGenerator<ExternalRequestResponse, ExternalRequestResponse> {
return {
type: ExternalRequestResponseType.ERROR,
message,
};
}
/**
* Handles an external request using the given prompt and uses the
* conversation type to use the correct agent.
*/
async handleExternalRequest(
parameters: ExternalStylingRequestParameters|ExternalNetworkRequestParameters|
ExternalPerformanceRequestParameters,
): Promise<AsyncGenerator<ExternalRequestResponse, ExternalRequestResponse>> {
try {
this.dispatchEventToListeners(ConversationHandlerEvents.EXTERNAL_REQUEST_RECEIVED);
const disabledReasons = await this.#getDisabledReasons();
const aiAssistanceSetting = this.#aiAssistanceEnabledSetting?.getIfNotDisabled();
if (!aiAssistanceSetting) {
disabledReasons.push(lockedString(UIStringsNotTranslate.enableInSettings));
}
if (disabledReasons.length > 0) {
return this.#generateErrorResponse(disabledReasons.join(' '));
}
this.dispatchEventToListeners(
ConversationHandlerEvents.EXTERNAL_CONVERSATION_STARTED, parameters.conversationType);
switch (parameters.conversationType) {
case ConversationType.STYLING: {
return await this.#handleExternalStylingConversation(parameters.prompt, parameters.selector);
}
case ConversationType.PERFORMANCE:
return await this.#handleExternalPerformanceConversation(parameters.prompt, parameters.data);
case ConversationType.NETWORK:
if (!parameters.requestUrl) {
return this.#generateErrorResponse('The url is required for debugging a network request.');
}
return await this.#handleExternalNetworkConversation(parameters.prompt, parameters.requestUrl);
}
} catch (error) {
return this.#generateErrorResponse(error.message);
}
}
async * #createAndDoExternalConversation(opts: {
conversationType: ConversationType,
aiAgent: AiAgent<unknown>,
prompt: string,
selected: NodeContext|PerformanceTraceContext|RequestContext|null,
}): AsyncGenerator<ExternalRequestResponse, ExternalRequestResponse> {
const {conversationType, aiAgent, prompt, selected} = opts;
const conversation = new AiConversation(
conversationType,
[],
aiAgent.sessionId,
/* isReadOnly */ true,
this.#aidaClient,
undefined,
/* isExternal */ true,
);
return yield* this.#doExternalConversation({conversation, prompt, selected});
}
async * #doExternalConversation(opts: {
conversation: AiConversation,
prompt: string,
selected: NodeContext|PerformanceTraceContext|RequestContext|null,
}): AsyncGenerator<ExternalRequestResponse, ExternalRequestResponse> {
const {conversation, prompt, selected} = opts;
conversation.setContext(selected);
const generator = conversation.run(prompt);
const devToolsLogs: object[] = [];
for await (const data of generator) {
if (data.type !== ResponseType.ANSWER || data.complete) {
devToolsLogs.push(data);
}
if (data.type === ResponseType.CONTEXT || data.type === ResponseType.TITLE) {
yield {
type: ExternalRequestResponseType.NOTIFICATION,
message: data.title,
};
}
if (data.type === ResponseType.SIDE_EFFECT) {
data.confirm(true);
}
if (data.type === ResponseType.ANSWER && data.complete) {
return {
type: ExternalRequestResponseType.ANSWER,
message: data.text,
devToolsLogs,
};
}
}
return {
type: ExternalRequestResponseType.ERROR,
message: 'Something went wrong. No answer was generated.',
};
}
async #handleExternalStylingConversation(prompt: string, selector = 'body'):
Promise<AsyncGenerator<ExternalRequestResponse, ExternalRequestResponse>> {
const stylingAgent = new StylingAgent({
aidaClient: this.#aidaClient,
serverSideLoggingEnabled: isAiAssistanceServerSideLoggingEnabled(),
});
const node = await inspectElementBySelector(selector);
if (node) {
await node.setAsInspectedNode();
}
const selected = node ? new NodeContext(node) : null;
return this.#createAndDoExternalConversation({
conversationType: ConversationType.STYLING,
aiAgent: stylingAgent,
prompt,
selected,
});
}
async #handleExternalPerformanceConversation(prompt: string, data: ExternalPerformanceAIConversationData):
Promise<AsyncGenerator<ExternalRequestResponse, ExternalRequestResponse>> {
return this.#doExternalConversation({
conversation: data.conversation,
prompt,
selected: data.selected,
});
}
async #handleExternalNetworkConversation(prompt: string, requestUrl: string):
Promise<AsyncGenerator<ExternalRequestResponse, ExternalRequestResponse>> {
const networkAgent = new NetworkAgent({
aidaClient: this.#aidaClient,
serverSideLoggingEnabled: isAiAssistanceServerSideLoggingEnabled(),
});
const request = await inspectNetworkRequestByUrl(requestUrl);
if (!request) {
return this.#generateErrorResponse(`Can't find request with the given selector ${requestUrl}`);
}
const calculator = new NetworkTimeCalculator.NetworkTransferTimeCalculator();
calculator.updateBoundaries(request);
return this.#createAndDoExternalConversation({
conversationType: ConversationType.NETWORK,
aiAgent: networkAgent,
prompt,
selected: new RequestContext(request, calculator),
});
}
}
export const enum ConversationHandlerEvents {
EXTERNAL_REQUEST_RECEIVED = 'ExternalRequestReceived',
EXTERNAL_CONVERSATION_STARTED = 'ExternalConversationStarted',
}
export interface EventTypes {
[ConversationHandlerEvents.EXTERNAL_REQUEST_RECEIVED]: void;
[ConversationHandlerEvents.EXTERNAL_CONVERSATION_STARTED]: ConversationType;
}