chrome-devtools-frontend
Version:
Chrome DevTools UI
359 lines (322 loc) • 13.3 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 Snackbars from '../../ui/components/snackbars/snackbars.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import * as NetworkTimeCalculator from '../network_time_calculator/network_time_calculator.js';
import {
type AiAgent,
type ExternalRequestResponse,
ExternalRequestResponseType,
type ResponseData,
ResponseType
} from './agents/AiAgent.js';
import {FileAgent} from './agents/FileAgent.js';
import {NetworkAgent, RequestContext} from './agents/NetworkAgent.js';
import {PerformanceAgent, type PerformanceTraceContext} from './agents/PerformanceAgent.js';
import {NodeContext, StylingAgent} from './agents/StylingAgent.js';
import {
Conversation,
ConversationType,
} from './AiHistoryStorage.js';
import {getDisabledReasons} from './AiUtils.js';
import type {ChangeManager} from './ChangeManager.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: Conversation;
agent: AiAgent<unknown>;
selected: PerformanceTraceContext;
}
export interface ExternalPerformanceRequestParameters {
conversationType: ConversationType.PERFORMANCE;
prompt: string;
data: ExternalPerformanceAIConversationData;
}
const UIStrings = {
/**
* @description Notification shown to the user whenever DevTools receives an external request.
*/
externalRequestReceived: '`DevTools` received an external request',
} as const;
/*
* 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 str_ = i18n.i18n.registerUIStrings('models/ai_assistance/ConversationHandler.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
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 {
#aiAssistanceEnabledSetting: Common.Settings.Setting<boolean>|undefined;
#aidaClient: Host.AidaClient.AidaClient;
#aidaAvailability?: Host.AidaClient.AidaAccessPreconditions;
private constructor(
aidaClient: Host.AidaClient.AidaClient, aidaAvailability?: Host.AidaClient.AidaAccessPreconditions) {
this.#aidaClient = aidaClient;
if (aidaAvailability) {
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;
}
#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 {
Snackbars.Snackbar.Snackbar.show({message: i18nString(UIStrings.externalRequestReceived)});
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(' '));
}
void VisualLogging.logFunctionCall(`start-conversation-${parameters.conversationType}`, 'external');
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 *
handleConversationWithHistory(
items: AsyncIterable<ResponseData, void, void>, conversation: Conversation|undefined):
AsyncGenerator<ResponseData, void, void> {
for await (const data of items) {
// We don't want to save partial responses to the conversation history.
if (data.type !== ResponseType.ANSWER || data.complete) {
void conversation?.addHistoryItem(data);
}
yield data;
}
}
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 Conversation(
conversationType,
[],
aiAgent.id,
/* isReadOnly */ true,
/* isExternal */ true,
);
return yield* this.#doExternalConversation({conversation, aiAgent, prompt, selected});
}
async * #doExternalConversation(opts: {
conversation: Conversation,
aiAgent: AiAgent<unknown>,
prompt: string,
selected: NodeContext|PerformanceTraceContext|RequestContext|null,
}): AsyncGenerator<ExternalRequestResponse, ExternalRequestResponse> {
const {conversation, aiAgent, prompt, selected} = opts;
const generator = aiAgent.run(prompt, {selected});
const generatorWithHistory = this.handleConversationWithHistory(generator, conversation);
const devToolsLogs: object[] = [];
for await (const data of generatorWithHistory) {
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 = this.createAgent(ConversationType.STYLING);
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,
aiAgent: data.agent,
prompt,
selected: data.selected,
});
}
async #handleExternalNetworkConversation(prompt: string, requestUrl: string):
Promise<AsyncGenerator<ExternalRequestResponse, ExternalRequestResponse>> {
const networkAgent = this.createAgent(ConversationType.NETWORK);
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),
});
}
createAgent(conversationType: ConversationType, changeManager?: ChangeManager): AiAgent<unknown> {
const options = {
aidaClient: this.#aidaClient,
serverSideLoggingEnabled: isAiAssistanceServerSideLoggingEnabled(),
};
let agent: AiAgent<unknown>;
switch (conversationType) {
case ConversationType.STYLING: {
agent = new StylingAgent({
...options,
changeManager,
});
break;
}
case ConversationType.NETWORK: {
agent = new NetworkAgent(options);
break;
}
case ConversationType.FILE: {
agent = new FileAgent(options);
break;
}
case ConversationType.PERFORMANCE: {
agent = new PerformanceAgent(options);
break;
}
}
return agent;
}
}