chrome-devtools-frontend
Version:
Chrome DevTools UI
1,091 lines (990 loc) • 41.6 kB
text/typescript
// Copyright 2024 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 '../../ui/legacy/legacy.js';
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 type * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Workspace from '../../models/workspace/workspace.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as Lit from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import * as ElementsPanel from '../elements/elements.js';
import * as NetworkForward from '../network/forward/forward.js';
import * as NetworkPanel from '../network/network.js';
import * as SourcesPanel from '../sources/sources.js';
import * as TimelinePanel from '../timeline/timeline.js';
import * as TimelineUtils from '../timeline/utils/utils.js';
import {
AgentType,
type AiAgent,
type ConversationContext,
ErrorType,
type ResponseData,
ResponseType,
} from './agents/AiAgent.js';
import {
FileAgent,
FileContext,
} from './agents/FileAgent.js';
import {
NetworkAgent,
RequestContext,
} from './agents/NetworkAgent.js';
import {CallTreeContext, PerformanceAgent} from './agents/PerformanceAgent.js';
import {InsightContext, PerformanceInsightsAgent} from './agents/PerformanceInsightsAgent.js';
import {NodeContext, StylingAgent, StylingAgentWithFunctionCalling} from './agents/StylingAgent.js';
import aiAssistancePanelStyles from './aiAssistancePanel.css.js';
import {AiHistoryStorage, Conversation, ConversationType} from './AiHistoryStorage.js';
import {ChangeManager} from './ChangeManager.js';
import {
ChatMessageEntity,
ChatView,
type ModelChatMessage,
type Props as ChatViewProps,
State as ChatViewState,
type Step,
} from './components/ChatView.js';
const {html} = Lit;
const AI_ASSISTANCE_SEND_FEEDBACK = 'https://crbug.com/364805393' as Platform.DevToolsPath.UrlString;
const AI_ASSISTANCE_HELP = 'https://goo.gle/devtools-ai-assistance' as Platform.DevToolsPath.UrlString;
const UIStrings = {
/**
*@description AI assistance UI text creating a new chat.
*/
newChat: 'New chat',
/**
*@description AI assistance UI tooltip text for the help button.
*/
help: 'Help',
/**
*@description AI assistant UI tooltip text for the settings button (gear icon).
*/
settings: 'Settings',
/**
*@description AI assistant UI tooltip sending feedback.
*/
sendFeedback: 'Send feedback',
/**
*@description Announcement text for screen readers when a new chat is created.
*/
newChatCreated: 'New chat created',
/**
*@description Announcement text for screen readers when the chat is deleted.
*/
chatDeleted: 'Chat deleted',
/**
*@description AI assistance UI text creating selecting a history entry.
*/
history: 'History',
/**
*@description AI assistance UI text deleting the current chat session from local history.
*/
deleteChat: 'Delete local chat',
/**
*@description AI assistance UI text that deletes all local history entries.
*/
clearChatHistory: 'Clear local chats',
/**
*@description AI assistance UI text explains that he user had no pas conversations.
*/
noPastConversations: 'No past conversations',
};
/*
* Strings that don't need to be translated at this time.
*/
const UIStringsNotTranslate = {
/**
*@description Announcement text for screen readers when the conversation starts.
*/
answerLoading: 'Answer loading',
/**
*@description Announcement text for screen readers when the answer comes.
*/
answerReady: 'Answer ready',
};
const str_ = i18n.i18n.registerUIStrings('panels/ai_assistance/AiAssistancePanel.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const lockedString = i18n.i18n.lockedString;
interface ViewOutput {
chatView?: ChatView;
}
type View = (input: ChatViewProps, output: ViewOutput, target: HTMLElement) => void;
function selectedElementFilter(maybeNode: SDK.DOMModel.DOMNode|null): SDK.DOMModel.DOMNode|null {
if (maybeNode) {
return maybeNode.nodeType() === Node.ELEMENT_NODE ? maybeNode : null;
}
return null;
}
function defaultView(input: ChatViewProps, output: ViewOutput, target: HTMLElement): void {
// clang-format off
Lit.render(html`
<devtools-ai-chat-view .props=${input} ${Lit.Directives.ref((el: Element|undefined) => {
if (!el || !(el instanceof ChatView)) {
return;
}
output.chatView = el;
})}></devtools-ai-chat-view>
`, target, {host: input});
// clang-format on
}
function createNodeContext(node: SDK.DOMModel.DOMNode|null): NodeContext|null {
if (!node) {
return null;
}
return new NodeContext(node);
}
function createFileContext(file: Workspace.UISourceCode.UISourceCode|null): FileContext|null {
if (!file) {
return null;
}
return new FileContext(file);
}
function createRequestContext(request: SDK.NetworkRequest.NetworkRequest|null): RequestContext|null {
if (!request) {
return null;
}
return new RequestContext(request);
}
function createCallTreeContext(callTree: TimelineUtils.AICallTree.AICallTree|null): CallTreeContext|null {
if (!callTree) {
return null;
}
return new CallTreeContext(callTree);
}
function createPerfInsightContext(insight: TimelineUtils.InsightAIContext.InsightAIContext|null): InsightContext|null {
if (!insight) {
return null;
}
return new InsightContext(insight);
}
function agentTypeToConversationType(type: AgentType): ConversationType {
switch (type) {
case AgentType.STYLING:
return ConversationType.STYLING;
case AgentType.NETWORK:
return ConversationType.NETWORK;
case AgentType.FILE:
return ConversationType.FILE;
case AgentType.PERFORMANCE:
return ConversationType.PERFORMANCE;
case AgentType.PERFORMANCE_INSIGHT:
return ConversationType.PERFORMANCE_INSIGHT;
case AgentType.PATCH:
throw new Error('PATCH AgentType does not have a corresponding ConversationType.');
}
}
let panelInstance: AiAssistancePanel;
export class AiAssistancePanel extends UI.Panel.Panel {
static panelName = 'freestyler';
#toggleSearchElementAction: UI.ActionRegistration.Action;
#contentContainer: HTMLElement;
#aidaClient: Host.AidaClient.AidaClient;
#viewProps: ChatViewProps;
#viewOutput: ViewOutput = {};
#serverSideLoggingEnabled = isAiAssistanceServerSideLoggingEnabled();
#aiAssistanceEnabledSetting: Common.Settings.Setting<boolean>|undefined;
#changeManager = new ChangeManager();
#mutex = new Common.Mutex.Mutex();
#newChatButton =
new UI.Toolbar.ToolbarButton(i18nString(UIStrings.newChat), 'plus', undefined, 'freestyler.new-chat');
#historyEntriesButton =
new UI.Toolbar.ToolbarButton(i18nString(UIStrings.history), 'history', undefined, 'freestyler.history');
#deleteHistoryEntryButton =
new UI.Toolbar.ToolbarButton(i18nString(UIStrings.deleteChat), 'bin', undefined, 'freestyler.delete');
#currentAgent?: AiAgent<unknown>;
#currentConversation?: Conversation;
#conversations: Conversation[] = [];
#previousSameOriginContext?: ConversationContext<unknown>;
#selectedFile: FileContext|null = null;
#selectedElement: NodeContext|null = null;
#selectedCallTree: CallTreeContext|null = null;
#selectedInsight: InsightContext|null = null;
#selectedRequest: RequestContext|null = null;
constructor(private view: View = defaultView, {aidaClient, aidaAvailability, syncInfo}: {
aidaClient: Host.AidaClient.AidaClient,
aidaAvailability: Host.AidaClient.AidaAccessPreconditions,
syncInfo: Host.InspectorFrontendHostAPI.SyncInformation,
}) {
super(AiAssistancePanel.panelName);
this.registerRequiredCSS(aiAssistancePanelStyles);
this.#aiAssistanceEnabledSetting = this.#getAiAssistanceEnabledSetting();
this.#createToolbar();
this.#toggleSearchElementAction =
UI.ActionRegistry.ActionRegistry.instance().getAction('elements.toggle-element-search');
this.#aidaClient = aidaClient;
this.#contentContainer = this.contentElement.createChild('div', 'chat-container');
this.#viewProps = {
state: this.#getChatUiState(),
aidaAvailability,
messages: [],
inspectElementToggled: this.#toggleSearchElementAction.toggled(),
isLoading: false,
onTextSubmit: (text: string) => {
void this.#startConversation(text);
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistanceQuerySubmitted);
},
onInspectElementClick: this.#handleSelectElementClick.bind(this),
onFeedbackSubmit: this.#handleFeedbackSubmit.bind(this),
onCancelClick: this.#cancel.bind(this),
onContextClick: this.#handleContextClick.bind(this),
onNewConversation: this.#handleNewChatRequest.bind(this),
canShowFeedbackForm: this.#serverSideLoggingEnabled,
userInfo: {
accountImage: syncInfo.accountImage,
accountFullName: syncInfo.accountFullName,
},
selectedContext: null,
blockedByCrossOrigin: false,
stripLinks: false,
isReadOnly: false,
};
this.#conversations = AiHistoryStorage.instance().getHistory().map(item => Conversation.fromSerialized(item));
}
#createToolbar(): void {
const toolbarContainer = this.contentElement.createChild('div', 'toolbar-container');
toolbarContainer.setAttribute('jslog', VisualLogging.toolbar().toString());
toolbarContainer.role = 'toolbar';
const leftToolbar = toolbarContainer.createChild('devtools-toolbar', 'freestyler-left-toolbar');
leftToolbar.role = 'presentation';
const rightToolbar = toolbarContainer.createChild('devtools-toolbar', 'freestyler-right-toolbar');
rightToolbar.role = 'presentation';
this.#newChatButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, this.#handleNewChatRequest.bind(this));
leftToolbar.appendToolbarItem(this.#newChatButton);
leftToolbar.appendSeparator();
this.#historyEntriesButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, event => {
this.#onHistoryClicked(event.data);
});
leftToolbar.appendToolbarItem(this.#historyEntriesButton);
this.#deleteHistoryEntryButton.addEventListener(
UI.Toolbar.ToolbarButton.Events.CLICK, this.#onDeleteClicked.bind(this));
leftToolbar.appendToolbarItem(this.#deleteHistoryEntryButton);
const link = UI.XLink.XLink.create(
AI_ASSISTANCE_SEND_FEEDBACK, i18nString(UIStrings.sendFeedback), undefined, undefined,
'freestyler.send-feedback');
link.style.setProperty('display', null);
link.style.setProperty('color', 'var(--sys-color-primary)');
link.style.setProperty('margin', '0 var(--sys-size-3)');
link.style.setProperty('height', 'calc(100% - 6px)');
const linkItem = new UI.Toolbar.ToolbarItem(link);
rightToolbar.appendToolbarItem(linkItem);
rightToolbar.appendSeparator();
const helpButton = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.help), 'help', undefined, 'freestyler.help');
helpButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => {
Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab(AI_ASSISTANCE_HELP);
});
rightToolbar.appendToolbarItem(helpButton);
const settingsButton =
new UI.Toolbar.ToolbarButton(i18nString(UIStrings.settings), 'gear', undefined, 'freestyler.settings');
settingsButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => {
void UI.ViewManager.ViewManager.instance().showView('chrome-ai');
});
rightToolbar.appendToolbarItem(settingsButton);
}
#getChatUiState(): ChatViewState {
const config = Common.Settings.Settings.instance().getHostConfig();
const blockedByAge = config.aidaAvailability?.blockedByAge === true;
return (this.#aiAssistanceEnabledSetting?.getIfNotDisabled() && !blockedByAge) ? ChatViewState.CHAT_VIEW :
ChatViewState.CONSENT_VIEW;
}
#getAiAssistanceEnabledSetting(): Common.Settings.Setting<boolean>|undefined {
try {
return Common.Settings.moduleSetting('ai-assistance-enabled') as Common.Settings.Setting<boolean>;
} catch {
return;
}
}
#createAgent(agentType: AgentType): AiAgent<unknown> {
const options = {
aidaClient: this.#aidaClient,
serverSideLoggingEnabled: this.#serverSideLoggingEnabled,
};
let agent: AiAgent<unknown>;
switch (agentType) {
case AgentType.STYLING: {
agent = new StylingAgent({
...options,
changeManager: this.#changeManager,
});
if (isAiAssistanceStylingWithFunctionCallingEnabled()) {
agent = new StylingAgentWithFunctionCalling({
...options,
changeManager: this.#changeManager,
});
}
break;
}
case AgentType.NETWORK: {
agent = new NetworkAgent(options);
break;
}
case AgentType.FILE: {
agent = new FileAgent(options);
break;
}
case AgentType.PERFORMANCE: {
agent = new PerformanceAgent(options);
break;
}
case AgentType.PERFORMANCE_INSIGHT: {
agent = new PerformanceInsightsAgent(options);
break;
}
case AgentType.PATCH: {
throw new Error('AI Assistance does not support direct usage of the patch agent');
}
}
return agent;
}
#updateToolbarState(): void {
this.#deleteHistoryEntryButton.setVisible(Boolean(this.#currentConversation && !this.#currentConversation.isEmpty));
}
static async instance(opts: {
forceNew: boolean|null,
}|undefined = {forceNew: null}): Promise<AiAssistancePanel> {
const {forceNew} = opts;
if (!panelInstance || forceNew) {
const aidaClient = new Host.AidaClient.AidaClient();
const syncInfoPromise = new Promise<Host.InspectorFrontendHostAPI.SyncInformation>(
resolve => Host.InspectorFrontendHost.InspectorFrontendHostInstance.getSyncInformation(resolve));
const [aidaAvailability, syncInfo] =
await Promise.all([Host.AidaClient.AidaClient.checkAccessPreconditions(), syncInfoPromise]);
panelInstance = new AiAssistancePanel(defaultView, {aidaClient, aidaAvailability, syncInfo});
}
return panelInstance;
}
// We select the default agent based on the open panels if
// there isn't any active conversation.
#selectDefaultAgentIfNeeded(): void {
// If there already is an agent and if it is not empty,
// we don't automatically change the agent. In addition to this,
// we don't change the current agent when there is a message in flight.
if ((this.#currentAgent && !this.#currentAgent.isEmpty) || this.#viewProps.isLoading) {
return;
}
const config = Common.Settings.Settings.instance().getHostConfig();
const isElementsPanelVisible =
Boolean(UI.Context.Context.instance().flavor(ElementsPanel.ElementsPanel.ElementsPanel));
const isNetworkPanelVisible = Boolean(UI.Context.Context.instance().flavor(NetworkPanel.NetworkPanel.NetworkPanel));
const isSourcesPanelVisible = Boolean(UI.Context.Context.instance().flavor(SourcesPanel.SourcesPanel.SourcesPanel));
const isPerformancePanelVisible =
Boolean(UI.Context.Context.instance().flavor(TimelinePanel.TimelinePanel.TimelinePanel));
let targetAgentType: AgentType|undefined = undefined;
if (isElementsPanelVisible && config.devToolsFreestyler?.enabled) {
targetAgentType = AgentType.STYLING;
} else if (isNetworkPanelVisible && config.devToolsAiAssistanceNetworkAgent?.enabled) {
targetAgentType = AgentType.NETWORK;
} else if (isSourcesPanelVisible && config.devToolsAiAssistanceFileAgent?.enabled) {
targetAgentType = AgentType.FILE;
} else if (isPerformancePanelVisible && config.devToolsAiAssistancePerformanceAgent?.enabled) {
targetAgentType = AgentType.PERFORMANCE;
} else if (
isPerformancePanelVisible && config.devToolsAiAssistancePerformanceAgent?.enabled &&
config.devToolsAiAssistancePerformanceAgent?.insightsEnabled) {
targetAgentType = AgentType.PERFORMANCE_INSIGHT;
}
const agent = targetAgentType ? this.#createAgent(targetAgentType) : undefined;
this.#updateAgentState(agent);
}
#updateAgentState(agent?: AiAgent<unknown>): void {
if (this.#currentAgent !== agent) {
this.#cancel();
this.#currentAgent = agent;
this.#viewProps.agentType = this.#currentAgent?.type;
this.#viewProps.messages = [];
this.#viewProps.changeSummary = undefined;
this.#viewProps.isLoading = false;
if (this.#currentAgent?.type) {
this.#currentConversation =
new Conversation(agentTypeToConversationType(this.#currentAgent.type), [], agent?.id, false);
this.#conversations.push(this.#currentConversation);
this.#viewProps.isReadOnly = false;
}
}
this.#onContextSelectionChanged();
void this.doUpdate();
}
override wasShown(): void {
super.wasShown();
this.#viewOutput.chatView?.restoreScrollPosition();
this.#viewOutput.chatView?.focusTextInput();
this.#selectDefaultAgentIfNeeded();
void this.#handleAidaAvailabilityChange();
void this
.#handleAiAssistanceEnabledSettingChanged(); // If the setting was switched on/off while the AiAssistancePanel was not shown.
this.#selectedElement =
createNodeContext(selectedElementFilter(UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode))),
this.#selectedRequest =
createRequestContext(UI.Context.Context.instance().flavor(SDK.NetworkRequest.NetworkRequest)),
this.#selectedCallTree =
createCallTreeContext(UI.Context.Context.instance().flavor(TimelineUtils.AICallTree.AICallTree)),
this.#selectedInsight =
createPerfInsightContext(UI.Context.Context.instance().flavor(TimelineUtils.InsightAIContext.InsightAIContext));
this.#selectedFile = createFileContext(UI.Context.Context.instance().flavor(Workspace.UISourceCode.UISourceCode)),
this.#viewProps = {
...this.#viewProps,
agentType: this.#currentAgent?.type,
inspectElementToggled: this.#toggleSearchElementAction.toggled(),
selectedContext: this.#getConversationContext(),
};
void this.doUpdate();
this.#aiAssistanceEnabledSetting?.addChangeListener(this.#handleAiAssistanceEnabledSettingChanged, this);
Host.AidaClient.HostConfigTracker.instance().addEventListener(
Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED, this.#handleAidaAvailabilityChange);
this.#toggleSearchElementAction.addEventListener(
UI.ActionRegistration.Events.TOGGLED, this.#handleSearchElementActionToggled);
UI.Context.Context.instance().addFlavorChangeListener(SDK.DOMModel.DOMNode, this.#handleDOMNodeFlavorChange);
UI.Context.Context.instance().addFlavorChangeListener(
SDK.NetworkRequest.NetworkRequest, this.#handleNetworkRequestFlavorChange);
UI.Context.Context.instance().addFlavorChangeListener(
TimelineUtils.AICallTree.AICallTree, this.#handleTraceEntryNodeFlavorChange);
UI.Context.Context.instance().addFlavorChangeListener(
Workspace.UISourceCode.UISourceCode, this.#handleUISourceCodeFlavorChange);
UI.Context.Context.instance().addFlavorChangeListener(
ElementsPanel.ElementsPanel.ElementsPanel, this.#selectDefaultAgentIfNeeded, this);
UI.Context.Context.instance().addFlavorChangeListener(
NetworkPanel.NetworkPanel.NetworkPanel, this.#selectDefaultAgentIfNeeded, this);
UI.Context.Context.instance().addFlavorChangeListener(
SourcesPanel.SourcesPanel.SourcesPanel, this.#selectDefaultAgentIfNeeded, this);
UI.Context.Context.instance().addFlavorChangeListener(
TimelinePanel.TimelinePanel.TimelinePanel, this.#selectDefaultAgentIfNeeded, this);
SDK.TargetManager.TargetManager.instance().addModelListener(
SDK.DOMModel.DOMModel, SDK.DOMModel.Events.AttrModified, this.#handleDOMNodeAttrChange, this);
SDK.TargetManager.TargetManager.instance().addModelListener(
SDK.DOMModel.DOMModel, SDK.DOMModel.Events.AttrRemoved, this.#handleDOMNodeAttrChange, this);
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistancePanelOpened);
}
override willHide(): void {
this.#aiAssistanceEnabledSetting?.removeChangeListener(this.#handleAiAssistanceEnabledSettingChanged, this);
Host.AidaClient.HostConfigTracker.instance().removeEventListener(
Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED, this.#handleAidaAvailabilityChange);
this.#toggleSearchElementAction.removeEventListener(
UI.ActionRegistration.Events.TOGGLED, this.#handleSearchElementActionToggled);
UI.Context.Context.instance().removeFlavorChangeListener(SDK.DOMModel.DOMNode, this.#handleDOMNodeFlavorChange);
UI.Context.Context.instance().removeFlavorChangeListener(
SDK.NetworkRequest.NetworkRequest, this.#handleNetworkRequestFlavorChange);
UI.Context.Context.instance().removeFlavorChangeListener(
TimelineUtils.AICallTree.AICallTree, this.#handleTraceEntryNodeFlavorChange);
UI.Context.Context.instance().removeFlavorChangeListener(
Workspace.UISourceCode.UISourceCode, this.#handleUISourceCodeFlavorChange);
UI.Context.Context.instance().removeFlavorChangeListener(
ElementsPanel.ElementsPanel.ElementsPanel, this.#selectDefaultAgentIfNeeded, this);
UI.Context.Context.instance().removeFlavorChangeListener(
NetworkPanel.NetworkPanel.NetworkPanel, this.#selectDefaultAgentIfNeeded, this);
UI.Context.Context.instance().removeFlavorChangeListener(
SourcesPanel.SourcesPanel.SourcesPanel, this.#selectDefaultAgentIfNeeded, this);
UI.Context.Context.instance().removeFlavorChangeListener(
TimelinePanel.TimelinePanel.TimelinePanel, this.#selectDefaultAgentIfNeeded, this);
SDK.TargetManager.TargetManager.instance().removeModelListener(
SDK.DOMModel.DOMModel,
SDK.DOMModel.Events.AttrModified,
this.#handleDOMNodeAttrChange,
this,
);
SDK.TargetManager.TargetManager.instance().removeModelListener(
SDK.DOMModel.DOMModel,
SDK.DOMModel.Events.AttrRemoved,
this.#handleDOMNodeAttrChange,
this,
);
}
#handleAidaAvailabilityChange = async(): Promise<void> => {
const currentAidaAvailability = await Host.AidaClient.AidaClient.checkAccessPreconditions();
if (currentAidaAvailability !== this.#viewProps.aidaAvailability) {
this.#viewProps.aidaAvailability = currentAidaAvailability;
const syncInfo = await new Promise<Host.InspectorFrontendHostAPI.SyncInformation>(
resolve => Host.InspectorFrontendHost.InspectorFrontendHostInstance.getSyncInformation(resolve));
this.#viewProps.userInfo = {
accountImage: syncInfo.accountImage,
accountFullName: syncInfo.accountFullName,
};
this.#viewProps.state = this.#getChatUiState();
void this.doUpdate();
}
};
#handleSearchElementActionToggled = (ev: Common.EventTarget.EventTargetEvent<boolean>): void => {
if (this.#viewProps.inspectElementToggled === ev.data) {
return;
}
this.#viewProps.inspectElementToggled = ev.data;
void this.doUpdate();
};
#handleDOMNodeFlavorChange = (ev: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void => {
if (this.#selectedElement?.getItem() === ev.data) {
return;
}
this.#selectedElement = createNodeContext(selectedElementFilter(ev.data));
this.#updateAgentState(this.#currentAgent);
};
#handleDOMNodeAttrChange =
(ev: Common.EventTarget.EventTargetEvent<{node: SDK.DOMModel.DOMNode, name: string}>): void => {
if (this.#selectedElement?.getItem() === ev.data.node) {
if (ev.data.name === 'class' || ev.data.name === 'id') {
void this.doUpdate();
}
}
};
#handleNetworkRequestFlavorChange =
(ev: Common.EventTarget.EventTargetEvent<SDK.NetworkRequest.NetworkRequest>): void => {
if (this.#selectedRequest?.getItem() === ev.data) {
return;
}
this.#selectedRequest = Boolean(ev.data) ? new RequestContext(ev.data) : null;
this.#updateAgentState(this.#currentAgent);
};
#handleTraceEntryNodeFlavorChange =
(ev: Common.EventTarget.EventTargetEvent<TimelineUtils.AICallTree.AICallTree>): void => {
if (this.#selectedCallTree?.getItem() === ev.data) {
return;
}
this.#selectedCallTree = Boolean(ev.data) ? new CallTreeContext(ev.data) : null;
this.#updateAgentState(this.#currentAgent);
};
#handleUISourceCodeFlavorChange =
(ev: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>): void => {
const newFile = ev.data;
if (!newFile) {
return;
}
if (this.#selectedFile?.getItem() === newFile) {
return;
}
this.#selectedFile = new FileContext(ev.data);
this.#updateAgentState(this.#currentAgent);
};
#handleAiAssistanceEnabledSettingChanged = (): void => {
const nextChatUiState = this.#getChatUiState();
if (this.#viewProps.state === nextChatUiState) {
return;
}
this.#viewProps.state = nextChatUiState;
void this.doUpdate();
};
async doUpdate(): Promise<void> {
this.#updateToolbarState();
this.view(this.#viewProps, this.#viewOutput, this.#contentContainer);
}
#handleSelectElementClick(): void {
void this.#toggleSearchElementAction.execute();
}
#handleFeedbackSubmit(rpcId: Host.AidaClient.RpcGlobalId, rating: Host.AidaClient.Rating, feedback?: string): void {
void this.#aidaClient.registerClientEvent({
corresponding_aida_rpc_global_id: rpcId,
disable_user_content_logging: !this.#serverSideLoggingEnabled,
do_conversation_client_event: {
user_feedback: {
sentiment: rating,
user_input: {
comment: feedback,
},
},
},
});
}
#handleContextClick(): void|Promise<void> {
const context = this.#viewProps.selectedContext;
if (context instanceof RequestContext) {
const requestLocation = NetworkForward.UIRequestLocation.UIRequestLocation.tab(
context.getItem(), NetworkForward.UIRequestLocation.UIRequestTabs.HEADERS_COMPONENT);
return Common.Revealer.reveal(requestLocation);
}
if (context instanceof FileContext) {
return Common.Revealer.reveal(context.getItem().uiLocation(0, 0));
}
if (context instanceof CallTreeContext) {
const trace = new SDK.TraceObject.RevealableEvent(context.getItem().selectedNode.event);
return Common.Revealer.reveal(trace);
}
// Node picker is using linkifier.
}
handleAction(actionId: string): void {
if (this.#viewProps.isLoading) {
// If running some queries already, focus the input with the abort
// button and do nothing.
this.#viewOutput.chatView?.focusTextInput();
return;
}
let targetAgentType: AgentType|undefined;
switch (actionId) {
case 'freestyler.elements-floating-button': {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistanceOpenedFromElementsPanelFloatingButton);
targetAgentType = AgentType.STYLING;
break;
}
case 'freestyler.element-panel-context': {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistanceOpenedFromElementsPanel);
targetAgentType = AgentType.STYLING;
break;
}
case 'drjones.network-floating-button': {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistanceOpenedFromNetworkPanelFloatingButton);
targetAgentType = AgentType.NETWORK;
break;
}
case 'drjones.network-panel-context': {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistanceOpenedFromNetworkPanel);
targetAgentType = AgentType.NETWORK;
break;
}
case 'drjones.performance-panel-context': {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistanceOpenedFromPerformancePanel);
targetAgentType = AgentType.PERFORMANCE;
break;
}
case 'drjones.sources-floating-button': {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistanceOpenedFromSourcesPanelFloatingButton);
targetAgentType = AgentType.FILE;
break;
}
case 'drjones.sources-panel-context': {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistanceOpenedFromSourcesPanel);
targetAgentType = AgentType.FILE;
break;
}
}
if (!targetAgentType) {
return;
}
let agent = this.#currentAgent;
if (!this.#currentConversation || !this.#currentAgent || this.#currentAgent.type !== targetAgentType ||
this.#currentConversation?.isEmpty || targetAgentType === AgentType.PERFORMANCE) {
agent = this.#createAgent(targetAgentType);
}
this.#updateAgentState(agent);
this.#viewOutput.chatView?.focusTextInput();
}
#onHistoryClicked(event: Event): void {
const boundingRect = this.#historyEntriesButton.element.getBoundingClientRect();
const contextMenu = new UI.ContextMenu.ContextMenu(event, {
x: boundingRect.left,
y: boundingRect.bottom,
useSoftMenu: true,
});
for (const conversation of [...this.#conversations].reverse()) {
if (conversation.isEmpty) {
continue;
}
const title = conversation.title;
if (!title) {
continue;
}
contextMenu.defaultSection().appendCheckboxItem(title, () => {
void this.#openConversation(conversation);
}, {checked: (this.#currentConversation === conversation)});
}
const historyEmpty = contextMenu.defaultSection().items.length === 0;
if (historyEmpty) {
contextMenu.defaultSection().appendItem(i18nString(UIStrings.noPastConversations), () => {}, {
disabled: true,
});
}
contextMenu.footerSection().appendItem(
i18nString(UIStrings.clearChatHistory),
() => {
this.#clearHistory();
},
{
disabled: historyEmpty,
},
);
void contextMenu.show();
}
#clearHistory(): void {
this.#conversations = [];
this.#currentConversation = undefined;
void AiHistoryStorage.instance().deleteAll();
this.#updateAgentState();
}
#onDeleteClicked(): void {
if (this.#currentAgent) {
this.#conversations = this.#conversations.filter(conversation => conversation !== this.#currentConversation);
this.#currentConversation = undefined;
void AiHistoryStorage.instance().deleteHistoryEntry(this.#currentAgent.id);
}
this.#updateAgentState();
this.#selectDefaultAgentIfNeeded();
UI.ARIAUtils.alert(i18nString(UIStrings.chatDeleted));
}
async #openConversation(conversation: Conversation): Promise<void> {
if (this.#currentConversation === conversation) {
return;
}
this.#currentConversation = conversation;
this.#viewProps.messages = [];
this.#viewProps.isReadOnly = this.#currentConversation?.isReadOnly ?? false;
await this.#doConversation(conversation.history);
}
#handleNewChatRequest(): void {
this.#updateAgentState();
this.#selectDefaultAgentIfNeeded();
UI.ARIAUtils.alert(i18nString(UIStrings.newChatCreated));
}
#handleCrossOriginChatCancellation(): void {
if (this.#previousSameOriginContext) {
this.#onContextSelectionChanged(this.#previousSameOriginContext);
void this.doUpdate();
}
}
#runAbortController = new AbortController();
#cancel(): void {
this.#runAbortController.abort();
this.#viewProps.isLoading = false;
void this.doUpdate();
}
#onContextSelectionChanged(contextToRestore?: ConversationContext<unknown>): void {
if (!this.#currentAgent) {
this.#viewProps.blockedByCrossOrigin = false;
return;
}
const currentContext = contextToRestore ?? this.#getConversationContext();
this.#viewProps.selectedContext = currentContext;
if (!currentContext) {
this.#viewProps.blockedByCrossOrigin = false;
return;
}
this.#viewProps.blockedByCrossOrigin = !currentContext.isOriginAllowed(this.#currentAgent.origin);
if (!this.#viewProps.blockedByCrossOrigin) {
this.#previousSameOriginContext = currentContext;
}
if (this.#viewProps.blockedByCrossOrigin && this.#previousSameOriginContext) {
this.#viewProps.onCancelCrossOriginChat = this.#handleCrossOriginChatCancellation.bind(this);
}
this.#viewProps.stripLinks = this.#viewProps.agentType === AgentType.PERFORMANCE;
}
#getConversationContext(): ConversationContext<unknown>|null {
if (!this.#currentAgent) {
return null;
}
let context: ConversationContext<unknown>|null;
switch (this.#currentAgent.type) {
case AgentType.STYLING:
context = this.#selectedElement;
break;
case AgentType.FILE:
context = this.#selectedFile;
break;
case AgentType.NETWORK:
context = this.#selectedRequest;
break;
case AgentType.PERFORMANCE:
context = this.#selectedCallTree;
break;
case AgentType.PERFORMANCE_INSIGHT:
context = this.#selectedInsight;
break;
case AgentType.PATCH:
throw new Error('AI Assistance does not support direct usage of the patch agent');
}
return context;
}
async #startConversation(text: string): Promise<void> {
if (!this.#currentAgent) {
return;
}
// Cancel any previous in-flight conversation.
this.#cancel();
this.#runAbortController = new AbortController();
const signal = this.#runAbortController.signal;
const context = this.#getConversationContext();
// If a different context is provided, it must be from the same origin.
if (context && !context.isOriginAllowed(this.#currentAgent.origin)) {
// 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 runner = this.#currentAgent.run(text, {
signal,
selected: context,
});
UI.ARIAUtils.alert(lockedString(UIStringsNotTranslate.answerLoading));
await this.#doConversation(this.#saveResponsesToCurrentConversation(runner));
UI.ARIAUtils.alert(lockedString(UIStringsNotTranslate.answerReady));
}
async *
#saveResponsesToCurrentConversation(items: AsyncIterable<ResponseData, void, void>):
AsyncGenerator<ResponseData, void, void> {
for await (const data of items) {
this.#currentConversation?.addHistoryItem(data);
yield data;
}
}
async #doConversation(items: Iterable<ResponseData, void, void>|AsyncIterable<ResponseData, void, void>):
Promise<void> {
const release = await this.#mutex.acquire();
try {
let systemMessage: ModelChatMessage = {
entity: ChatMessageEntity.MODEL,
steps: [],
};
let step: Step = {isLoading: true};
/**
* Commits the step to props only if necessary.
*/
function commitStep(): void {
if (systemMessage.steps.at(-1) !== step) {
systemMessage.steps.push(step);
}
}
this.#viewProps.isLoading = true;
for await (const data of items) {
step.sideEffect = undefined;
switch (data.type) {
case ResponseType.USER_QUERY: {
this.#viewProps.messages.push({
entity: ChatMessageEntity.USER,
text: data.query,
});
systemMessage = {
entity: ChatMessageEntity.MODEL,
steps: [],
};
this.#viewProps.messages.push(systemMessage);
break;
}
case ResponseType.QUERYING: {
step = {isLoading: true};
if (!systemMessage.steps.length) {
systemMessage.steps.push(step);
}
break;
}
case ResponseType.CONTEXT: {
step.title = data.title;
step.contextDetails = data.details;
step.isLoading = false;
commitStep();
break;
}
case ResponseType.TITLE: {
step.title = data.title;
commitStep();
break;
}
case ResponseType.THOUGHT: {
step.isLoading = false;
step.thought = data.thought;
commitStep();
break;
}
case ResponseType.SUGGESTIONS: {
systemMessage.suggestions = data.suggestions;
break;
}
case ResponseType.SIDE_EFFECT: {
step.isLoading = false;
step.code ??= data.code;
step.sideEffect = {
onAnswer: (result: boolean) => {
data.confirm(result);
step.sideEffect = undefined;
void this.doUpdate();
},
};
commitStep();
break;
}
case ResponseType.ACTION: {
step.isLoading = false;
step.code ??= data.code;
step.output ??= data.output;
step.canceled = data.canceled;
if (isAiAssistancePatchingEnabled() && this.#currentAgent && !this.#currentConversation?.isReadOnly) {
this.#viewProps.changeSummary = this.#changeManager.formatChanges(this.#currentAgent.id);
}
commitStep();
break;
}
case ResponseType.ANSWER: {
systemMessage.suggestions ??= data.suggestions;
systemMessage.answer = data.text;
systemMessage.rpcId = data.rpcId;
// When there is an answer without any thinking steps, we don't want to show the thinking step.
if (systemMessage.steps.length === 1 && systemMessage.steps[0].isLoading) {
systemMessage.steps.pop();
}
step.isLoading = false;
break;
}
case ResponseType.ERROR: {
systemMessage.error = data.error;
systemMessage.rpcId = undefined;
const lastStep = systemMessage.steps.at(-1);
if (lastStep) {
// Mark the last step as cancelled to make the UI feel better.
if (data.error === ErrorType.ABORT) {
lastStep.canceled = true;
// If error happens while the step is still loading remove it.
} else if (lastStep.isLoading) {
systemMessage.steps.pop();
}
}
if (data.error === ErrorType.BLOCK) {
systemMessage.answer = undefined;
}
}
}
// Commit update intermediated step when not
// in read only mode.
if (!this.#viewProps.isReadOnly) {
void this.doUpdate();
// This handles scrolling to the bottom for live conversations when:
// * User submits the query & the context step is shown.
// * There is a side effect dialog shown.
if (data.type === ResponseType.CONTEXT || data.type === ResponseType.SIDE_EFFECT) {
this.#viewOutput.chatView?.scrollToBottom();
}
}
}
this.#viewProps.isLoading = false;
this.#viewOutput.chatView?.finishTextAnimations();
void this.doUpdate();
} finally {
release();
}
}
}
export class ActionDelegate implements UI.ActionRegistration.ActionDelegate {
handleAction(
_context: UI.Context.Context,
actionId: string,
): boolean {
switch (actionId) {
case 'freestyler.elements-floating-button':
case 'freestyler.element-panel-context':
case 'drjones.network-floating-button':
case 'drjones.network-panel-context':
case 'drjones.performance-panel-context':
case 'drjones.sources-floating-button':
case 'drjones.sources-panel-context': {
void (async () => {
const view = UI.ViewManager.ViewManager.instance().view(
AiAssistancePanel.panelName,
);
if (view) {
await UI.ViewManager.ViewManager.instance().showView(
AiAssistancePanel.panelName,
);
const widget = (await view.widget()) as AiAssistancePanel;
widget.handleAction(actionId);
}
})();
return true;
}
}
return false;
}
}
function isAiAssistancePatchingEnabled(): boolean {
const config = Common.Settings.Settings.instance().getHostConfig();
return Boolean(config.devToolsFreestyler?.patching);
}
function isAiAssistanceServerSideLoggingEnabled(): boolean {
const config = Common.Settings.Settings.instance().getHostConfig();
return !config.aidaAvailability?.disallowLogging;
}
function isAiAssistanceStylingWithFunctionCallingEnabled(): boolean {
const config = Common.Settings.Settings.instance().getHostConfig();
return Boolean(config.devToolsFreestyler?.functionCalling);
}