chrome-devtools-frontend
Version:
Chrome DevTools UI
502 lines (452 loc) • 16.7 kB
text/typescript
// Copyright 2024 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 Platform from '../../core/platform/platform.js';
import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as LHModel from '../../models/lighthouse/lighthouse.js';
import type * as Trace from '../../models/trace/trace.js';
import * as Greendev from '../greendev/greendev.js';
import type * as NetworkTimeCalculator from '../network_time_calculator/network_time_calculator.js';
import {AccessibilityAgent, AccessibilityContext} from './agents/AccessibilityAgent.js';
import {
type AiAgent,
type ContextDetail,
type ConversationContext,
ErrorType,
type MultimodalInput,
type ResponseData,
ResponseType,
type UserQuery
} from './agents/AiAgent.js';
import {BreakpointDebuggerAgent} from './agents/BreakpointDebuggerAgent.js';
import {ContextSelectionAgent} from './agents/ContextSelectionAgent.js';
import {FileAgent, FileContext} from './agents/FileAgent.js';
import {NetworkAgent, RequestContext} from './agents/NetworkAgent.js';
import {PerformanceAgent, PerformanceTraceContext} from './agents/PerformanceAgent.js';
import {NodeContext, StylingAgent} from './agents/StylingAgent.js';
import {AiHistoryStorage, ConversationType, type SerializedConversation} from './AiHistoryStorage.js';
import type {ChangeManager} from './ChangeManager.js';
export const NOT_FOUND_IMAGE_DATA = '';
export const CONTEXT_TITLE = 'Analyzing data';
const MAX_TITLE_LENGTH = 80;
export function generateContextDetailsMarkdown(details: ContextDetail[]): string {
const detailsMarkdown: string[] = [];
for (const detail of details) {
const text = `\`\`\`\`${detail.codeLang || ''}\n${detail.text.trim()}\n\`\`\`\``;
detailsMarkdown.push(`**${detail.title}:**\n${text}`);
}
return detailsMarkdown.join('\n\n');
}
export interface AiConversationOptions {
type: ConversationType;
data?: ResponseData[];
id?: string;
isReadOnly?: boolean;
aidaClient?: Host.AidaClient.AidaClient;
changeManager?: ChangeManager;
isExternal?: boolean;
performanceRecordAndReload?: () => Promise<Trace.TraceModel.ParsedTrace>;
onInspectElement?: () => Promise<SDK.DOMModel.DOMNode|null>;
networkTimeCalculator?: NetworkTimeCalculator.NetworkTransferTimeCalculator;
lighthouseRecording?: (overrides?: LHModel.RunTypes.RunOverrides) => Promise<LHModel.ReporterTypes.ReportJSON|null>;
}
export class AiConversation {
static fromSerializedConversation(serializedConversation: SerializedConversation): AiConversation {
const history = serializedConversation.history.map(entry => {
if (entry.type === ResponseType.SIDE_EFFECT) {
return {...entry, confirm: () => {}};
}
return entry;
});
return new AiConversation({
type: serializedConversation.type,
data: history,
id: serializedConversation.id,
isReadOnly: true,
isExternal: serializedConversation.isExternal,
});
}
readonly id: string;
// Handled in #updateAgent
#type!: ConversationType;
// Handled in #updateAgent
#agent!: AiAgent<unknown>;
#isReadOnly: boolean;
readonly history: ResponseData[];
#isExternal: boolean;
#aidaClient: Host.AidaClient.AidaClient;
#changeManager: ChangeManager|undefined;
#origin?: string;
#contexts: Array<ConversationContext<unknown>> = [];
#performanceRecordAndReload?: () => Promise<Trace.TraceModel.ParsedTrace>;
#lighthouseRecording?: (overrides?: LHModel.RunTypes.RunOverrides) => Promise<LHModel.ReporterTypes.ReportJSON|null>;
#onInspectElement?: () => Promise<SDK.DOMModel.DOMNode|null>;
#networkTimeCalculator?: NetworkTimeCalculator.NetworkTransferTimeCalculator;
constructor(options: AiConversationOptions) {
const {
type,
data = [],
id = crypto.randomUUID(),
isReadOnly = true,
aidaClient = new Host.AidaClient.AidaClient(),
changeManager,
isExternal = false,
performanceRecordAndReload,
onInspectElement,
networkTimeCalculator,
lighthouseRecording,
} = options;
this.#changeManager = changeManager;
this.#aidaClient = aidaClient;
this.#performanceRecordAndReload = performanceRecordAndReload;
this.#onInspectElement = onInspectElement;
this.#networkTimeCalculator = networkTimeCalculator;
this.#lighthouseRecording = lighthouseRecording;
this.id = id;
this.#isReadOnly = isReadOnly;
this.#isExternal = isExternal;
this.history = this.#reconstructHistory(data);
// Needs to be last
this.#updateAgent(type);
}
get isReadOnly(): boolean {
return this.#isReadOnly;
}
get title(): string|undefined {
const query = this.history.find(response => response.type === ResponseType.USER_QUERY)?.query;
if (!query) {
return;
}
if (this.#isExternal) {
return `[External] ${query.substring(0, MAX_TITLE_LENGTH - 11)}${
query.length > MAX_TITLE_LENGTH - 11 ? '…' : ''}`;
}
return `${query.substring(0, MAX_TITLE_LENGTH)}${query.length > MAX_TITLE_LENGTH ? '…' : ''}`;
}
get isEmpty(): boolean {
return this.history.length === 0;
}
#setOriginIfEmpty(newOrigin: string|undefined): void {
if (!this.#origin) {
this.#origin = newOrigin;
}
}
setContext(updateContext: ConversationContext<unknown>|null): void {
if (!updateContext) {
this.#contexts = [];
if (isAiAssistanceContextSelectionAgentEnabled()) {
this.#updateAgent(ConversationType.NONE);
}
return;
}
this.#contexts = [updateContext];
if (isAiAssistanceContextSelectionAgentEnabled()) {
if (updateContext instanceof FileContext) {
this.#updateAgent(ConversationType.FILE);
} else if (updateContext instanceof NodeContext) {
this.#updateAgent(ConversationType.STYLING);
} else if (updateContext instanceof RequestContext) {
this.#updateAgent(ConversationType.NETWORK);
} else if (updateContext instanceof PerformanceTraceContext) {
this.#updateAgent(ConversationType.PERFORMANCE);
} else if (updateContext instanceof AccessibilityContext) {
this.#updateAgent(ConversationType.ACCESSIBILITY);
}
}
}
get selectedContext(): ConversationContext<unknown>|undefined {
return this.#contexts.at(0);
}
getPendingMultimodalInput(): MultimodalInput|undefined {
const greenDevEmulationEnabled = Greendev.Prototypes.instance().isEnabled('emulationCapabilities');
return greenDevEmulationEnabled ? this.#agent.popPendingMultimodalInput() : undefined;
}
#reconstructHistory(historyWithoutImages: ResponseData[]): ResponseData[] {
const imageHistory = AiHistoryStorage.instance().getImageHistory();
if (imageHistory && imageHistory.length > 0) {
const history: ResponseData[] = [];
for (const data of historyWithoutImages) {
if (data.type === ResponseType.USER_QUERY && data.imageId) {
const image = imageHistory.find(item => item.id === data.imageId);
const inlineData = image ? {data: image.data, mimeType: image.mimeType} :
{data: NOT_FOUND_IMAGE_DATA, mimeType: 'image/jpeg'};
history.push({...data, imageInput: {inlineData}});
} else {
history.push(data);
}
}
return history;
}
return historyWithoutImages;
}
getConversationMarkdown(): string {
const contentParts: string[] = [];
contentParts.push(
'# Exported Chat from Chrome DevTools AI Assistance\n\n' +
`**Export Timestamp (UTC):** ${new Date().toISOString()}\n\n` +
'---',
);
for (const item of this.history) {
switch (item.type) {
case ResponseType.USER_QUERY: {
contentParts.push(`## User\n\n${item.query}`);
if (item.imageInput) {
contentParts.push('User attached an image');
}
contentParts.push('## AI');
break;
}
case ResponseType.CONTEXT: {
contentParts.push(`### ${CONTEXT_TITLE}`);
if (item.details && item.details.length > 0) {
contentParts.push(generateContextDetailsMarkdown(item.details));
}
break;
}
case ResponseType.TITLE: {
contentParts.push(`### ${item.title}`);
break;
}
case ResponseType.THOUGHT: {
contentParts.push(`${item.thought}`);
break;
}
case ResponseType.ACTION: {
// We want to export only actions with output field
if (!item.output) {
break;
}
if (item.code) {
contentParts.push(`**Code executed:**\n\`\`\`\n${item.code.trim()}\n\`\`\``);
}
contentParts.push(`**Data returned:**\n\`\`\`\n${item.output}\n\`\`\``);
break;
}
case ResponseType.ANSWER: {
if (item.complete) {
contentParts.push(`### Answer\n\n${item.text.trim()}`);
}
break;
}
}
}
return contentParts.join('\n\n');
}
archiveConversation(): void {
this.#isReadOnly = true;
}
async addHistoryItem(item: ResponseData): Promise<void> {
this.history.push(item);
await AiHistoryStorage.instance().upsertHistoryEntry(this.serialize());
if (item.type === ResponseType.USER_QUERY) {
if (item.imageId && item.imageInput && 'inlineData' in item.imageInput) {
const inlineData = item.imageInput.inlineData;
await AiHistoryStorage.instance().upsertImage({
id: item.imageId,
data: inlineData.data,
mimeType: inlineData.mimeType,
});
}
}
}
serialize(): SerializedConversation {
return {
id: this.id,
history: this.history
.map(item => {
switch (item.type) {
case ResponseType.CONTEXT_CHANGE: {
return null;
}
case ResponseType.USER_QUERY: {
return {...item, imageInput: undefined};
}
case ResponseType.SIDE_EFFECT: {
return {...item, confirm: undefined};
}
case ResponseType.CONTEXT:
case ResponseType.ACTION: {
return {...item, widgets: undefined};
}
default:
return item;
}
})
.filter(history => !!history),
type: this.#type,
isExternal: this.#isExternal,
};
}
#updateAgent(type: ConversationType): void {
if (this.#type === type) {
return;
}
this.#type = type;
// We need to filter out the function calls
// as the LLM tries to call the existing ones.
const history =
this.#agent?.history
.map(content => {
return {
...content,
parts: content.parts.filter(part => !('functionCall' in part) && !('functionResponse' in part)),
};
})
.filter(content => content.parts.length > 0);
const options = {
aidaClient: this.#aidaClient,
serverSideLoggingEnabled: isAiAssistanceServerSideLoggingEnabled(),
sessionId: this.id,
changeManager: this.#changeManager,
performanceRecordAndReload: this.#performanceRecordAndReload,
onInspectElement: this.#onInspectElement,
networkTimeCalculator: this.#networkTimeCalculator,
lighthouseRecording: this.#lighthouseRecording,
allowedOrigin: this.allowedOrigin,
history,
};
switch (type) {
case ConversationType.STYLING: {
this.#agent = new StylingAgent(options);
break;
}
case ConversationType.NETWORK: {
this.#agent = new NetworkAgent(options);
break;
}
case ConversationType.FILE: {
this.#agent = new FileAgent(options);
break;
}
case ConversationType.PERFORMANCE: {
this.#agent = new PerformanceAgent(options);
break;
}
case ConversationType.BREAKPOINT: {
const breakpointAgentEnabled = Greendev.Prototypes.instance().isEnabled('breakpointDebuggerAgent');
if (breakpointAgentEnabled) {
this.#agent = new BreakpointDebuggerAgent(options);
}
break;
}
case ConversationType.ACCESSIBILITY: {
this.#agent = new AccessibilityAgent(options);
break;
}
case ConversationType.NONE: {
this.#agent = new ContextSelectionAgent(options);
break;
}
default:
Platform.assertNever(type, 'Unknown conversation type');
}
}
async *
run(
initialQuery: string,
options: {
signal?: AbortSignal,
multimodalInput?: MultimodalInput,
} = {},
): AsyncGenerator<ResponseData, void, void> {
if (this.isBlockedByOrigin) {
// This error should not be reached. If it happens, some
// invariants do not hold anymore.
throw new Error('cross-origin context data should not be included');
}
const userQuery: UserQuery = {
type: ResponseType.USER_QUERY,
query: initialQuery,
imageInput: options.multimodalInput?.input,
imageId: options.multimodalInput?.id,
};
void this.addHistoryItem(userQuery);
yield userQuery;
yield* this.#runAgent(initialQuery, options);
}
#getQueryAfterSelection(initialQuery: string, selection: string): string {
return `${selection}\nOriginal user query: ${initialQuery}`;
}
async *
#runAgent(
initialQuery: string,
options: {
signal?: AbortSignal,
multimodalInput?: MultimodalInput,
} = {},
): AsyncGenerator<ResponseData, void, void> {
this.#setOriginIfEmpty(this.selectedContext?.getOrigin());
if (this.isBlockedByOrigin) {
yield {
type: ResponseType.ERROR,
error: ErrorType.CROSS_ORIGIN,
};
return;
}
function shouldAddToHistory(data: ResponseData): boolean {
if (data.type === ResponseType.CONTEXT_CHANGE) {
return false;
}
// We don't want to save partial responses to the conversation history.
// TODO(crbug.com/463325400): We should save interleaved answers to the history as well.
if (data.type === ResponseType.ANSWER && !data.complete) {
return false;
}
return true;
}
for await (const data of this.#agent.run(
initialQuery,
{
signal: options.signal,
selected: this.selectedContext ?? null,
},
options.multimodalInput,
)) {
// Add to history if relevant
if (shouldAddToHistory(data)) {
void this.addHistoryItem(data);
}
// Always yield the data
yield data;
// If we change the context
// requery with the specialized agent.
if (data.type === ResponseType.CONTEXT_CHANGE) {
this.setContext(data.context);
yield* this.#runAgent(this.#getQueryAfterSelection(initialQuery, data.description), options);
return;
}
}
}
/**
* Indicates whether the new conversation context is blocked due to cross-origin restrictions.
* This happens when the conversation's context has a different
* origin than the selected context.
*/
get isBlockedByOrigin(): boolean {
return !this.#contexts.every(context => context.isOriginAllowed(this.#origin));
}
get origin(): string|undefined {
return this.#origin;
}
get type(): ConversationType {
return this.#type;
}
allowedOrigin = (): string|undefined => {
if (this.#origin) {
return this.#origin;
}
const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
const inspectedURL = target?.inspectedURL();
this.#origin = inspectedURL ? new Common.ParsedURL.ParsedURL(inspectedURL).securityOrigin() : undefined;
return this.#origin;
};
}
function isAiAssistanceServerSideLoggingEnabled(): boolean {
return !Root.Runtime.hostConfig.aidaAvailability?.disallowLogging;
}
function isAiAssistanceContextSelectionAgentEnabled(): boolean {
return Boolean(Root.Runtime.hostConfig.devToolsAiAssistanceContextSelectionAgent?.enabled);
}