chrome-devtools-frontend
Version:
Chrome DevTools UI
574 lines (516 loc) • 16.9 kB
text/typescript
// Copyright 2023 The Chromium Authors. All rights reserved.
// 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 {InspectorFrontendHostInstance} from './InspectorFrontendHost.js';
import type {AidaClientResult, SyncInformation} from './InspectorFrontendHostAPI.js';
import {bindOutputStream} from './ResourceLoader.js';
export enum Role {
/** Provide this role when giving a function call response */
ROLE_UNSPECIFIED = 0,
/** Tags the content came from the user */
USER = 1,
/** Tags the content came from the LLM */
MODEL = 2,
}
export const enum Rating {
// Resets the vote to null in the logs
SENTIMENT_UNSPECIFIED = 'SENTIMENT_UNSPECIFIED',
POSITIVE = 'POSITIVE',
NEGATIVE = 'NEGATIVE',
}
/**
* A `Content` represents a single turn message.
*/
export interface Content {
parts: Part[];
/** The producer of the content. */
role: Role;
}
export type Part = {
text: string,
}|{
functionCall: {
name: string,
args: Record<string, unknown>,
},
}|{
functionResponse: {
name: string,
response: Record<string, unknown>,
},
}|{
/** Inline media bytes. */
inlineData: MediaBlob,
};
export const enum ParametersTypes {
STRING = 1,
NUMBER = 2,
INTEGER = 3,
BOOLEAN = 4,
ARRAY = 5,
OBJECT = 6,
}
interface BaseFunctionParam {
description: string;
nullable?: boolean;
}
export interface FunctionPrimitiveParams extends BaseFunctionParam {
type: ParametersTypes.BOOLEAN|ParametersTypes.INTEGER|ParametersTypes.STRING|ParametersTypes.BOOLEAN;
}
interface FunctionArrayParam extends BaseFunctionParam {
type: ParametersTypes.ARRAY;
items: FunctionPrimitiveParams;
}
export interface FunctionObjectParam<T extends string|number|symbol = string> extends BaseFunctionParam {
type: ParametersTypes.OBJECT;
// TODO: this can be also be ObjectParams
properties: Record<T, FunctionPrimitiveParams|FunctionArrayParam>;
}
/**
* More about function declaration can be read at
* https://ai.google.dev/gemini-api/docs/function-calling
*/
export interface FunctionDeclaration<T extends string|number|symbol = string> {
name: string;
/**
* A description for the LLM to understand what the specific function will do once called.
*/
description: string;
parameters: FunctionObjectParam<T>;
}
// Raw media bytes.
export interface MediaBlob {
// The IANA standard MIME type of the source data.
// Currently supported types are: image/png, image/jpeg.
// Format: base64-encoded
// For reference: google3/google/x/pitchfork/aida/v1/content.proto
mimeType: string;
data: string;
}
export enum FunctionalityType {
// Unspecified functionality type.
FUNCTIONALITY_TYPE_UNSPECIFIED = 0,
// The generic AI chatbot functionality.
CHAT = 1,
// The explain error functionality.
EXPLAIN_ERROR = 2,
AGENTIC_CHAT = 5,
}
export enum ClientFeature {
// Unspecified client feature.
CLIENT_FEATURE_UNSPECIFIED = 0,
// Chrome console insights feature.
CHROME_CONSOLE_INSIGHTS = 1,
// Chrome AI Assistance Styling Agent.
CHROME_STYLING_AGENT = 2,
// Chrome AI Assistance Network Agent.
CHROME_NETWORK_AGENT = 7,
// Chrome AI Assistance Performance Agent.
CHROME_PERFORMANCE_AGENT = 8,
// Chrome AI Annotations Performance Agent
CHROME_PERFORMANCE_ANNOTATIONS_AGENT = 20,
// Chrome AI Assistance File Agent.
CHROME_FILE_AGENT = 9,
// Chrome AI Patch Agent.
CHROME_PATCH_AGENT = 12,
// Chrome AI Assistance Performance Insights Agent.
CHROME_PERFORMANCE_INSIGHTS_AGENT = 13,
}
export enum UserTier {
// Unspecified user tier.
USER_TIER_UNSPECIFIED = 0,
// Users who are internal testers.
TESTERS = 1,
// Users who are early adopters.
BETA = 2,
// Users in the general public.
PUBLIC = 3,
}
// Googlers: see the Aida `retrieval` proto; this type is based on that.
export interface RequestFactMetadata {
/**
* A description of where the fact comes from.
*/
source: string;
/**
* Optional: a score to give this fact. Used because
* if there are more facts than space in the context window,
* higher scoring facts will be prioritized.
*/
score?: number;
}
export interface RequestFact {
/**
* Content of the fact.
*/
text: string;
metadata: RequestFactMetadata;
}
export type RpcGlobalId = string|number;
/* eslint-disable @typescript-eslint/naming-convention */
export interface AidaRequest {
client: string;
current_message: Content;
preamble?: string;
historical_contexts?: Content[];
function_declarations?: FunctionDeclaration[];
facts?: RequestFact[];
options?: {
temperature?: number,
model_id?: string,
};
metadata: {
disable_user_content_logging: boolean,
client_version: string,
string_session_id?: string,
user_tier?: UserTier,
};
functionality_type?: FunctionalityType;
client_feature?: ClientFeature;
}
/* eslint-enable @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/naming-convention */
export interface AidaDoConversationClientEvent {
corresponding_aida_rpc_global_id: RpcGlobalId;
disable_user_content_logging: boolean;
do_conversation_client_event: {
user_feedback: {
sentiment?: Rating,
user_input?: {
comment?: string,
},
},
};
}
/* eslint-enable @typescript-eslint/naming-convention */
export enum RecitationAction {
ACTION_UNSPECIFIED = 'ACTION_UNSPECIFIED',
CITE = 'CITE',
BLOCK = 'BLOCK',
NO_ACTION = 'NO_ACTION',
EXEMPT_FOUND_IN_PROMPT = 'EXEMPT_FOUND_IN_PROMPT',
}
export enum CitationSourceType {
CITATION_SOURCE_TYPE_UNSPECIFIED = 'CITATION_SOURCE_TYPE_UNSPECIFIED',
TRAINING_DATA = 'TRAINING_DATA',
WORLD_FACTS = 'WORLD_FACTS',
LOCAL_FACTS = 'LOCAL_FACTS',
INDIRECT = 'INDIRECT',
}
export interface Citation {
startIndex?: number;
endIndex?: number;
uri?: string;
sourceType?: CitationSourceType;
repository?: string;
}
export interface AttributionMetadata {
attributionAction: RecitationAction;
citations: Citation[];
}
export interface AidaFunctionCallResponse {
name: string;
args: Record<string, unknown>;
}
export interface FactualityFact {
sourceUri?: string;
}
export interface FactualityMetadata {
facts: FactualityFact[];
}
export interface AidaResponseMetadata {
rpcGlobalId?: RpcGlobalId;
attributionMetadata?: AttributionMetadata;
factualityMetadata?: FactualityMetadata;
}
export interface AidaResponse {
explanation: string;
metadata: AidaResponseMetadata;
functionCalls?: [AidaFunctionCallResponse, ...AidaFunctionCallResponse[]];
completed: boolean;
}
export const enum AidaAccessPreconditions {
AVAILABLE = 'available',
NO_ACCOUNT_EMAIL = 'no-account-email',
NO_INTERNET = 'no-internet',
// This is the state (mostly enterprise) users are in, when they are automatically logged out from
// Chrome after a certain time period. For making AIDA requests, they need to log in again.
SYNC_IS_PAUSED = 'sync-is-paused',
}
const enum AidaInferenceLanguage {
CPP = 'CPP',
PYTHON = 'PYTHON',
KOTLIN = 'KOTLIN',
JAVA = 'JAVA',
JAVASCRIPT = 'JAVASCRIPT',
GO = 'GO',
TYPESCRIPT = 'TYPESCRIPT',
HTML = 'HTML',
BASH = 'BASH',
CSS = 'CSS',
DART = 'DART',
JSON = 'JSON',
MARKDOWN = 'MARKDOWN',
VUE = 'VUE',
XML = 'XML',
}
const AidaLanguageToMarkdown: Record<AidaInferenceLanguage, string> = {
CPP: 'cpp',
PYTHON: 'py',
KOTLIN: 'kt',
JAVA: 'java',
JAVASCRIPT: 'js',
GO: 'go',
TYPESCRIPT: 'ts',
HTML: 'html',
BASH: 'sh',
CSS: 'css',
DART: 'dart',
JSON: 'json',
MARKDOWN: 'md',
VUE: 'vue',
XML: 'xml',
};
export const CLIENT_NAME = 'CHROME_DEVTOOLS';
const CODE_CHUNK_SEPARATOR = (lang = ''): string => ('\n`````' + lang + '\n');
export class AidaAbortError extends Error {}
export class AidaBlockError extends Error {}
export class AidaClient {
static buildConsoleInsightsRequest(input: string): AidaRequest {
const disallowLogging = Root.Runtime.hostConfig.aidaAvailability?.disallowLogging ?? true;
const chromeVersion = Root.Runtime.getChromeVersion();
if (!chromeVersion) {
throw new Error('Cannot determine Chrome version');
}
const request: AidaRequest = {
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 * fetch(request: AidaRequest, options?: {signal?: AbortSignal}): AsyncGenerator<AidaResponse, void, void> {
if (!InspectorFrontendHostInstance.doAidaConversation) {
throw new Error('doAidaConversation is not available');
}
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);
InspectorFrontendHostInstance.doAidaConversation(JSON.stringify(request), streamId, result => {
if (result.statusCode === 403) {
stream.fail(new Error('Server responded: permission denied'));
} else if (result.error) {
stream.fail(new Error(`Cannot send request: ${result.error} ${result.detail || ''}`));
} else if (result.netErrorName === 'net::ERR_TIMED_OUT') {
stream.fail(new Error('doAidaConversation timed out'));
} else if (result.statusCode !== 200) {
stream.fail(new Error(`Request failed: ${JSON.stringify(result)}`));
} else {
void stream.close();
}
});
let chunk;
const text = [];
let inCodeChunk = false;
const functionCalls: AidaFunctionCallResponse[] = [];
let metadata: AidaResponseMetadata = {rpcGlobalId: 0};
while ((chunk = await stream.read())) {
let textUpdated = false;
// The AIDA 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) {
continue;
}
if (chunk.startsWith(',')) {
chunk = chunk.slice(1);
}
if (!chunk.startsWith('[')) {
chunk = '[' + chunk;
}
if (!chunk.endsWith(']')) {
chunk = chunk + ']';
}
let results;
try {
results = JSON.parse(chunk);
} catch (error) {
throw new Error('Cannot parse chunk: ' + chunk, {cause: error});
}
for (const result of results) {
if ('metadata' in result) {
metadata = result.metadata;
if (metadata?.attributionMetadata?.attributionAction === RecitationAction.BLOCK) {
throw new AidaBlockError();
}
}
if ('textChunk' in result) {
if (inCodeChunk) {
text.push(CODE_CHUNK_SEPARATOR());
inCodeChunk = false;
}
text.push(result.textChunk.text);
textUpdated = true;
} else if ('codeChunk' in result) {
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 ('functionCallChunk' in result) {
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,
};
}
registerClientEvent(clientEvent: AidaDoConversationClientEvent): Promise<AidaClientResult> {
const {promise, resolve} = Promise.withResolvers<AidaClientResult>();
InspectorFrontendHostInstance.registerAidaClientEvent(
JSON.stringify({
client: CLIENT_NAME,
event_time: new Date().toISOString(),
...clientEvent,
}),
resolve,
);
return promise;
}
}
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.BETA;
}
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);
}
}
private 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);
this.dispatchEventToListeners(Events.AIDA_AVAILABILITY_CHANGED);
}
}
}
export const enum Events {
AIDA_AVAILABILITY_CHANGED = 'aidaAvailabilityChanged',
}
export interface EventTypes {
[Events.AIDA_AVAILABILITY_CHANGED]: void;
}