chrome-devtools-frontend
Version:
Chrome DevTools UI
487 lines (437 loc) • 16.8 kB
text/typescript
// Copyright 2023 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 '../common/common.js';
import * as Root from '../root/root.js';
import {
AidaAccessPreconditions,
type AidaChunkResponse,
type AidaFunctionCallResponse,
AidaInferenceLanguage,
type AidaRegisterClientEvent,
ClientFeature,
type CompletionRequest,
type CompletionResponse,
debugLog,
type DoConversationRequest,
type DoConversationResponse,
FunctionalityType,
type GenerateCodeRequest,
type GenerateCodeResponse,
type GenerationSample,
RecitationAction,
type ResponseMetadata,
Role,
UserTier,
} from './AidaClientTypes.js';
import {gcaChunkResponseToAidaChunkResponse} from './AidaGcaTranslation.js';
import * as DispatchHttpRequestClient from './DispatchHttpRequestClient.js';
import * as GcaClient from './GcaClient.js';
import type {GenerateContentResponse} from './GcaTypes.js';
import {InspectorFrontendHostInstance} from './InspectorFrontendHost.js';
import type {AidaClientResult, AidaCodeCompleteResult, SyncInformation} from './InspectorFrontendHostAPI.js';
import {bindOutputStream} from './ResourceLoader.js';
export * from './AidaClientTypes.js';
export const CLIENT_NAME = 'CHROME_DEVTOOLS';
export const SERVICE_NAME = 'aidaService';
const CODE_CHUNK_SEPARATOR = (lang = ''): string => ('\n`````' + lang + '\n');
const AidaLanguageToMarkdown: Record<AidaInferenceLanguage, string> = {
[AidaInferenceLanguage.CPP]: 'cpp',
[AidaInferenceLanguage.PYTHON]: 'py',
[AidaInferenceLanguage.KOTLIN]: 'kt',
[AidaInferenceLanguage.JAVA]: 'java',
[AidaInferenceLanguage.JAVASCRIPT]: 'js',
[AidaInferenceLanguage.GO]: 'go',
[AidaInferenceLanguage.TYPESCRIPT]: 'ts',
[AidaInferenceLanguage.HTML]: 'html',
[AidaInferenceLanguage.BASH]: 'sh',
[AidaInferenceLanguage.CSS]: 'css',
[AidaInferenceLanguage.DART]: 'dart',
[AidaInferenceLanguage.JSON]: 'json',
[AidaInferenceLanguage.MARKDOWN]: 'md',
[AidaInferenceLanguage.VUE]: 'vue',
[AidaInferenceLanguage.XML]: 'xml',
[AidaInferenceLanguage.UNKNOWN]: 'unknown',
};
export class AidaAbortError extends Error {}
export class AidaBlockError extends Error {}
interface AiStream {
write: (data: string) => Promise<void>;
close: () => Promise<void>;
read: () => Promise<string|null>;
fail: (e: Error) => void;
}
export class AidaClient {
// Delegate client
#gcaClient = new GcaClient.GcaClient();
static buildConsoleInsightsRequest(input: string): DoConversationRequest {
const disallowLogging = Root.Runtime.hostConfig.aidaAvailability?.disallowLogging ?? true;
const chromeVersion = Root.Runtime.getChromeVersion();
if (!chromeVersion) {
throw new Error('Cannot determine Chrome version');
}
const request: DoConversationRequest = {
current_message: {parts: [{text: input}], role: Role.USER},
client: CLIENT_NAME,
functionality_type: FunctionalityType.EXPLAIN_ERROR,
client_feature: ClientFeature.CHROME_CONSOLE_INSIGHTS,
metadata: {
disable_user_content_logging: disallowLogging,
client_version: chromeVersion,
},
};
let temperature = -1;
let modelId;
if (Root.Runtime.hostConfig.devToolsConsoleInsights?.enabled) {
temperature = Root.Runtime.hostConfig.devToolsConsoleInsights.temperature ?? -1;
modelId = Root.Runtime.hostConfig.devToolsConsoleInsights.modelId;
}
if (temperature >= 0) {
request.options ??= {};
request.options.temperature = temperature;
}
if (modelId) {
request.options ??= {};
request.options.model_id = modelId;
}
return request;
}
static async checkAccessPreconditions(): Promise<AidaAccessPreconditions> {
if (!navigator.onLine) {
return AidaAccessPreconditions.NO_INTERNET;
}
const syncInfo = await new Promise<SyncInformation>(
resolve => InspectorFrontendHostInstance.getSyncInformation(syncInfo => resolve(syncInfo)));
if (!syncInfo.accountEmail) {
return AidaAccessPreconditions.NO_ACCOUNT_EMAIL;
}
if (syncInfo.isSyncPaused) {
return AidaAccessPreconditions.SYNC_IS_PAUSED;
}
return AidaAccessPreconditions.AVAILABLE;
}
async *
doConversation(request: DoConversationRequest, options?: {signal?: AbortSignal}):
AsyncGenerator<DoConversationResponse, void, void> {
if (!InspectorFrontendHostInstance.dispatchHttpRequest) {
throw new Error('dispatchHttpRequest is not available');
}
// Disable logging for now.
// For context, see b/454563259#comment35.
// We should be able to remove this ~end of April.
if (Root.Runtime.hostConfig.devToolsGeminiRebranding?.enabled) {
request.metadata.disable_user_content_logging = true;
}
const stream = (() => {
let {promise, resolve, reject} = Promise.withResolvers<string|null>();
options?.signal?.addEventListener('abort', () => {
reject(new AidaAbortError());
}, {once: true});
return {
write: async(data: string): Promise<void> => {
resolve(data);
({promise, resolve, reject} = Promise.withResolvers<string|null>());
},
close: async(): Promise<void> => {
resolve(null);
},
read: (): Promise<string|null> => {
return promise;
},
fail: (e: Error) => reject(e),
};
})();
const streamId = bindOutputStream(stream);
let response;
if (this.#gcaClient.enabled()) {
// Inline and remove the else clause after migration
response = this.#gcaClient.conversationRequest(request, streamId, options);
} else {
response = DispatchHttpRequestClient.makeHttpRequest(
{
service: SERVICE_NAME,
path: '/v1/aida:doConversation',
method: 'POST',
body: JSON.stringify(request),
streamId,
},
options);
}
response.then(
() => {
void stream.close();
},
err => {
debugLog('doConversation failed with error:', JSON.stringify(err));
if (err instanceof DispatchHttpRequestClient.DispatchHttpRequestError && err.response) {
const result = err.response;
if (result.statusCode === 403) {
stream.fail(new Error('Server responded: permission denied'));
return;
}
if ('error' in result && result.error) {
stream.fail(new Error(`Cannot send request: ${result.error} ${result.detail || ''}`));
return;
}
if ('netErrorName' in result && result.netErrorName === 'net::ERR_TIMED_OUT') {
stream.fail(new Error('doAidaConversation timed out'));
return;
}
if (result.statusCode !== 200) {
stream.fail(new Error(`Request failed: ${JSON.stringify(result)}`));
return;
}
}
stream.fail(err);
});
await (yield* this.#handleResponseStream(stream));
}
async * #handleResponseStream(stream: AiStream): AsyncGenerator<DoConversationResponse, void, void> {
let chunk;
const text = [];
let inCodeChunk = false;
const functionCalls: AidaFunctionCallResponse[] = [];
let metadata: ResponseMetadata = {rpcGlobalId: 0};
while ((chunk = await stream.read())) {
debugLog('doConversation stream chunk:', chunk);
let textUpdated = false;
const results = this.#parseAndTranslate(chunk);
for (const result of results) {
if (result.metadata) {
metadata = result.metadata;
if (metadata?.attributionMetadata?.attributionAction === RecitationAction.BLOCK) {
throw new AidaBlockError();
}
}
if (result.textChunk) {
if (inCodeChunk) {
text.push(CODE_CHUNK_SEPARATOR());
inCodeChunk = false;
}
text.push(result.textChunk.text);
textUpdated = true;
} else if (result.codeChunk) {
if (!inCodeChunk) {
const language = AidaLanguageToMarkdown[result.codeChunk.inferenceLanguage as AidaInferenceLanguage] ?? '';
text.push(CODE_CHUNK_SEPARATOR(language));
inCodeChunk = true;
}
text.push(result.codeChunk.code);
textUpdated = true;
} else if (result.functionCallChunk) {
functionCalls.push({
name: result.functionCallChunk.functionCall.name,
args: result.functionCallChunk.functionCall.args,
});
} else if ('error' in result) {
throw new Error(`Server responded: ${JSON.stringify(result)}`);
} else {
throw new Error('Unknown chunk result');
}
}
if (textUpdated) {
yield {
explanation: text.join('') + (inCodeChunk ? CODE_CHUNK_SEPARATOR() : ''),
metadata,
completed: false,
};
}
}
yield {
explanation: text.join('') + (inCodeChunk ? CODE_CHUNK_SEPARATOR() : ''),
metadata,
functionCalls: functionCalls.length ? functionCalls as [AidaFunctionCallResponse, ...AidaFunctionCallResponse[]] :
undefined,
completed: true,
};
}
#parseAndTranslate(chunk: string): AidaChunkResponse[] {
const results: AidaChunkResponse[] = this.#parseStreamChunk(chunk);
if (this.#gcaClient.enabled()) {
return (results as GenerateContentResponse[]).flatMap(gcaChunkResponseToAidaChunkResponse);
}
return results as AidaChunkResponse[];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
#parseStreamChunk(chunk: string): any {
// The streamed response is a JSON array of objects, split at the object
// boundary. Therefore each chunk may start with `[` or `,` and possibly
// followed by `]`. Each chunk may include one or more objects, so we
// make sure that each chunk becomes a well-formed JSON array when we
// parse it by adding `[` and `]` and removing `,` where appropriate.
if (!chunk.length) {
return [];
}
if (chunk.startsWith(',')) {
chunk = chunk.slice(1);
}
if (!chunk.startsWith('[')) {
chunk = '[' + chunk;
}
if (!chunk.endsWith(']')) {
chunk = chunk + ']';
}
try {
return JSON.parse(chunk);
} catch (error) {
throw new Error('Cannot parse chunk: ' + chunk, {cause: error});
}
}
registerClientEvent(clientEvent: AidaRegisterClientEvent): Promise<AidaClientResult> {
// Disable logging for now.
// For context, see b/454563259#comment35.
// We should be able to remove this ~end of April.
if (Root.Runtime.hostConfig.devToolsGeminiRebranding?.enabled) {
clientEvent.disable_user_content_logging = true;
}
if (this.#gcaClient.enabled()) {
return this.#gcaClient.registerClientEvent(clientEvent);
}
const {promise, resolve} = Promise.withResolvers<AidaClientResult>();
InspectorFrontendHostInstance.registerAidaClientEvent(
JSON.stringify({
client: CLIENT_NAME,
event_time: new Date().toISOString(),
...clientEvent,
}),
resolve,
);
return promise;
}
async completeCode(request: CompletionRequest): Promise<CompletionResponse|null> {
if (!InspectorFrontendHostInstance.aidaCodeComplete) {
throw new Error('aidaCodeComplete is not available');
}
// Disable logging for now.
// For context, see b/454563259#comment35.
// We should be able to remove this ~end of April.
if (Root.Runtime.hostConfig.devToolsGeminiRebranding?.enabled) {
request.metadata.disable_user_content_logging = true;
}
if (this.#gcaClient.enabled()) {
return await this.#gcaClient.completeCode(request);
}
const {promise, resolve} = Promise.withResolvers<AidaCodeCompleteResult>();
InspectorFrontendHostInstance.aidaCodeComplete(JSON.stringify(request), resolve);
const completeCodeResult = await promise;
if (completeCodeResult.error) {
throw new Error(`Cannot send request: ${completeCodeResult.error} ${completeCodeResult.detail || ''}`);
}
const response = completeCodeResult.response;
if (!response?.length) {
throw new Error('Empty response');
}
let parsedResponse;
try {
parsedResponse = JSON.parse(response);
} catch (error) {
throw new Error('Cannot parse response: ' + response, {cause: error});
}
const generatedSamples: GenerationSample[] = [];
let metadata: ResponseMetadata = {rpcGlobalId: 0};
if ('metadata' in parsedResponse) {
metadata = parsedResponse.metadata;
}
if ('generatedSamples' in parsedResponse) {
for (const generatedSample of parsedResponse.generatedSamples) {
const sample: GenerationSample = {
generationString: generatedSample.generationString,
score: generatedSample.score,
sampleId: generatedSample.sampleId,
};
if ('metadata' in generatedSample && 'attributionMetadata' in generatedSample.metadata) {
sample.attributionMetadata = generatedSample.metadata.attributionMetadata;
}
generatedSamples.push(sample);
}
} else {
return null;
}
return {generatedSamples, metadata};
}
async generateCode(request: GenerateCodeRequest, options?: {signal?: AbortSignal}):
Promise<GenerateCodeResponse|null> {
// Disable logging for now.
// For context, see b/454563259#comment35.
// We should be able to remove this ~end of April.
if (Root.Runtime.hostConfig.devToolsGeminiRebranding?.enabled) {
request.metadata.disable_user_content_logging = true;
}
if (this.#gcaClient.enabled()) {
// Inline and remove the else clause after migration
return await this.#gcaClient.generateCode(request, options);
}
const response = await DispatchHttpRequestClient.makeHttpRequest<GenerateCodeResponse>(
{
service: SERVICE_NAME,
path: '/v1/aida:generateCode',
method: 'POST',
body: JSON.stringify(request),
},
options);
return response;
}
}
export function convertToUserTierEnum(userTier: string|undefined): UserTier {
if (userTier) {
switch (userTier) {
case 'TESTERS':
return UserTier.TESTERS;
case 'BETA':
return UserTier.BETA;
case 'PUBLIC':
return UserTier.PUBLIC;
}
}
return UserTier.PUBLIC;
}
let hostConfigTrackerInstance: HostConfigTracker|undefined;
export class HostConfigTracker extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
#pollTimer?: number;
#aidaAvailability?: AidaAccessPreconditions;
private constructor() {
super();
}
static instance(): HostConfigTracker {
if (!hostConfigTrackerInstance) {
hostConfigTrackerInstance = new HostConfigTracker();
}
return hostConfigTrackerInstance;
}
override addEventListener(eventType: Events, listener: Common.EventTarget.EventListener<EventTypes, Events>):
Common.EventTarget.EventDescriptor<EventTypes> {
const isFirst = !this.hasEventListeners(eventType);
const eventDescriptor = super.addEventListener(eventType, listener);
if (isFirst) {
window.clearTimeout(this.#pollTimer);
void this.pollAidaAvailability();
}
return eventDescriptor;
}
override removeEventListener(eventType: Events, listener: Common.EventTarget.EventListener<EventTypes, Events>):
void {
super.removeEventListener(eventType, listener);
if (!this.hasEventListeners(eventType)) {
window.clearTimeout(this.#pollTimer);
}
}
async pollAidaAvailability(): Promise<void> {
this.#pollTimer = window.setTimeout(() => this.pollAidaAvailability(), 2000);
const currentAidaAvailability = await AidaClient.checkAccessPreconditions();
if (currentAidaAvailability !== this.#aidaAvailability) {
this.#aidaAvailability = currentAidaAvailability;
const config =
await new Promise<Root.Runtime.HostConfig>(resolve => InspectorFrontendHostInstance.getHostConfig(resolve));
Object.assign(Root.Runtime.hostConfig, config);
// TODO(crbug.com/442545623): Send `currentAidaAvailability` to the listeners as part of the event so that
// `await AidaClient.checkAccessPreconditions()` does not need to be called again in the event handlers.
this.dispatchEventToListeners(Events.AIDA_AVAILABILITY_CHANGED);
}
}
}
export const enum Events {
AIDA_AVAILABILITY_CHANGED = 'aidaAvailabilityChanged',
}
export interface EventTypes {
[Events.AIDA_AVAILABILITY_CHANGED]: void;
}