chrome-devtools-frontend
Version:
Chrome DevTools UI
413 lines (365 loc) • 14.5 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 '../../../ui/components/spinners/spinners.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 Root from '../../../core/root/root.js';
import * as AiAssistanceModel from '../../../models/ai_assistance/ai_assistance.js';
import * as Buttons from '../../../ui/components/buttons/buttons.js';
import type {MarkdownLitRenderer} from '../../../ui/components/markdown_view/MarkdownView.js';
import * as UI from '../../../ui/legacy/legacy.js';
import {Directives, html, nothing, render} from '../../../ui/lit/lit.js';
import {PatchWidget} from '../PatchWidget.js';
import {ChatInput} from './ChatInput.js';
import {ChatMessage, type Message, type ModelChatMessage} from './ChatMessage.js';
import chatViewStyles from './chatView.css.js';
import {ExportForAgentsDialog} from './ExportForAgentsDialog.js';
export {ChatInput, type ImageInputData} from './ChatInput.js';
const {
ref,
repeat,
classMap,
} = Directives;
const {widget} = UI.Widget;
/*
* Strings that don't need to be translated at this time.
*/
const UIStringsNotTranslate = {
/**
* @description Text for the empty state of the AI assistance panel.
*/
emptyStateText: 'How can I help you?',
/**
* @description Text for the empty state of the Gemini panel.
*/
emptyStateTextGemini: 'Where should we start?',
} as const;
const lockedString = i18n.i18n.lockedString;
const SCROLL_ROUNDING_OFFSET = 1;
interface ViewOutput {
mainElement?: HTMLElement;
input?: UI.Widget.WidgetElement<ChatInput>;
}
type View = (input: ChatWidgetInput, output: ViewOutput, target: HTMLElement|ShadowRoot) => void;
export interface Props {
onTextSubmit:
(text: string, imageInput?: Host.AidaClient.Part,
multimodalInputType?: AiAssistanceModel.AiAgent.MultimodalInputType) => void;
onInspectElementClick: () => void;
onFeedbackSubmit: (rpcId: Host.AidaClient.RpcGlobalId, rate: Host.AidaClient.Rating, feedback?: string) => void;
onCancelClick: () => void;
onContextClick: () => void;
onNewConversation: () => void;
onCopyResponseClick: (message: ModelChatMessage) => void;
onContextRemoved: (() => void)|null;
onContextAdd: (() => void)|null;
conversationMarkdown: string;
onExportConversation: (() => void)|null;
changeManager: AiAssistanceModel.ChangeManager.ChangeManager;
inspectElementToggled: boolean;
messages: Message[];
context: AiAssistanceModel.AiAgent.ConversationContext<unknown>|null;
isContextSelected: boolean;
canShowFeedbackForm: boolean;
isLoading: boolean;
conversationType: AiAssistanceModel.AiHistoryStorage.ConversationType;
isReadOnly: boolean;
blockedByCrossOrigin: boolean;
changeSummary?: string;
multimodalInputEnabled?: boolean;
isTextInputDisabled: boolean;
emptyStateSuggestions: AiAssistanceModel.AiAgent.ConversationSuggestion[];
inputPlaceholder: Platform.UIString.LocalizedString;
disclaimerText: Platform.UIString.LocalizedString;
uploadImageInputEnabled?: boolean;
markdownRenderer: MarkdownLitRenderer;
generateConversationSummary: (markdown: string) => Promise<string>;
walkthrough: {
onOpen: (message: ModelChatMessage) => void,
onToggle: (isOpen: boolean, message: ModelChatMessage) => void,
isExpanded: boolean,
isInlined: boolean,
activeSidebarMessage: ModelChatMessage|null,
inlineExpandedMessages: ModelChatMessage[],
};
}
interface ChatWidgetInput extends Props {
handleScroll: (ev: Event) => void;
handleSuggestionClick: (title: string) => void;
handleMessageContainerRef: (el: Element|undefined) => void;
exportForAgentsClick: () => void;
}
const DEFAULT_VIEW: View = (input, output, target) => {
const hasAiV2 = Boolean(Root.Runtime.hostConfig.devToolsAiAssistanceV2?.enabled);
const chatUiClasses = classMap({
'chat-ui': true,
gemini: AiAssistanceModel.AiUtils.isGeminiBranding(),
'ai-v2': hasAiV2,
});
const inputWidgetClasses = classMap({
'chat-input-widget': true,
sticky: !input.isReadOnly,
});
const shouldShowPatchWidget = !hasAiV2 && !input.isLoading;
// clang-format off
render(html`
<style>${chatViewStyles}</style>
<div class=${chatUiClasses}>
<main @scroll=${input.handleScroll} ${ref(element => { output.mainElement = element as HTMLElement; } )}>
${input.messages.length > 0 ? html`
<div class="messages-container" ${ref(input.handleMessageContainerRef)}>
${repeat(input.messages, message =>
widget(ChatMessage, {
message,
isLoading: input.isLoading && input.messages.at(-1) === message,
isReadOnly: input.isReadOnly,
canShowFeedbackForm: input.canShowFeedbackForm,
markdownRenderer: input.markdownRenderer,
isLastMessage: input.messages.at(-1) === message,
isFirstMessage: input.messages.at(0) === message,
onSuggestionClick: input.handleSuggestionClick,
onFeedbackSubmit: input.onFeedbackSubmit,
onCopyResponseClick: input.onCopyResponseClick,
onExportClick: input.exportForAgentsClick,
changeSummary: input.changeSummary,
walkthrough: {
...input.walkthrough,
}
})
)}
${shouldShowPatchWidget ? widget(PatchWidget, {
changeSummary: input.changeSummary ?? '',
changeManager: input.changeManager,
}) : nothing}
</div>
` : html`
<div class="empty-state-container">
<div class="header">
<div class="icon">
<devtools-icon
name="smart-assistant"
></devtools-icon>
</div>
${AiAssistanceModel.AiUtils.isGeminiBranding() ?
html`
<h1 class='greeting'>Hello</h1>
<p class='cta'>${lockedString(UIStringsNotTranslate.emptyStateTextGemini)}</p>
` : html`<h1>${lockedString(UIStringsNotTranslate.emptyStateText)}</h1>`
}
</div>
<div class="empty-state-content">
${input.emptyStateSuggestions.map(({title, jslogContext}) => {
return html`<devtools-button
class="suggestion"
@click=${() => input.handleSuggestionClick(title)}
.data=${
{
variant: Buttons.Button.Variant.OUTLINED,
size: Buttons.Button.Size.REGULAR,
title,
jslogContext: jslogContext ?? 'suggestion',
disabled: input.isTextInputDisabled,
} as Buttons.Button.ButtonData
}
>${title}</devtools-button>`;
})}
</div>
</div>
`}
<devtools-widget class=${inputWidgetClasses} ${widget(ChatInput, {
isLoading: input.isLoading,
blockedByCrossOrigin: input.blockedByCrossOrigin,
isTextInputDisabled: input.isTextInputDisabled,
inputPlaceholder: input.inputPlaceholder,
disclaimerText: input.disclaimerText,
context: input.context,
isContextSelected: input.isContextSelected,
inspectElementToggled: input.inspectElementToggled,
multimodalInputEnabled: input.multimodalInputEnabled ?? false,
conversationType: input.conversationType,
uploadImageInputEnabled: input.uploadImageInputEnabled ?? false,
isReadOnly: input.isReadOnly,
onContextClick: input.onContextClick,
onInspectElementClick: input.onInspectElementClick,
onTextSubmit: input.onTextSubmit,
onCancelClick: input.onCancelClick,
onNewConversation: input.onNewConversation,
onContextRemoved: input.onContextRemoved,
onContextAdd: input.onContextAdd,
})} ${ref(element => { output.input = element as UI.Widget.WidgetElement<ChatInput>; } )}></devtools-widget>
</main>
</div>
`, target);
// clang-format on
};
/**
* ChatView is a web component for historical reasons and generally should not
* exist because it barely has any presenter logic and it is definitely not
* re-usable as a custom element. Instead, the template from ChatView should be
* embdedded into the AiAssistancePanel (the sole host of chat interfaces) and
* the scroll handling logic should be implemented in view functions using refs
* or re-usable custom elements. Currently, the ChatView just combines the
* interfaces of ChatMessage and ChatInput presenters and passes most of the
* properties down to those presenters as-is.
*
* @deprecated
*/
export class ChatView extends HTMLElement {
readonly #shadow = this.attachShadow({mode: 'open'});
#scrollTop?: number;
#props: Props;
#messagesContainerElement?: Element;
#output: ViewOutput = {};
#messagesContainerResizeObserver = new ResizeObserver(() => this.#handleMessagesContainerResize());
/**
* Indicates whether the chat scroll position should be pinned to the bottom.
*
* This is true when:
* - The scroll is at the very bottom, allowing new messages to push the scroll down automatically.
* - The panel is initially rendered and the user hasn't scrolled yet.
*
* It is set to false when the user scrolls up to view previous messages.
*/
#pinScrollToBottom = true;
/**
* Indicates whether the scroll event originated from code
* or a user action. When set to `true`, `handleScroll` will ignore the event,
* allowing it to only handle user-driven scrolls and correctly decide
* whether to pin the content to the bottom.
*/
#isProgrammaticScroll = false;
#view: View;
#cachedSummary: {markdown: string, summary: string}|null = null;
constructor(props: Props, view = DEFAULT_VIEW) {
super();
this.#props = props;
this.#view = view;
}
set props(props: Props) {
this.#props = props;
this.#render();
}
connectedCallback(): void {
this.#render();
if (this.#messagesContainerElement) {
this.#messagesContainerResizeObserver.observe(this.#messagesContainerElement);
}
}
disconnectedCallback(): void {
this.#messagesContainerResizeObserver.disconnect();
}
focusTextInput(): void {
const textArea = this.#shadow.querySelector('.chat-input') as HTMLTextAreaElement;
if (!textArea) {
return;
}
textArea.focus();
}
setInputValue(text: string): void {
this.#output.input?.getWidget()?.setInputValue(text);
}
restoreScrollPosition(): void {
if (this.#scrollTop === undefined) {
return;
}
if (!this.#output.mainElement) {
return;
}
this.#setMainElementScrollTop(this.#scrollTop);
}
scrollToBottom(): void {
if (!this.#output.mainElement) {
return;
}
this.#setMainElementScrollTop(this.#output.mainElement.scrollHeight);
}
#handleMessagesContainerResize(): void {
if (!this.#pinScrollToBottom) {
return;
}
if (!this.#output.mainElement) {
return;
}
if (this.#pinScrollToBottom) {
this.#setMainElementScrollTop(this.#output.mainElement.scrollHeight);
}
}
#setMainElementScrollTop(scrollTop: number): void {
if (!this.#output.mainElement) {
return;
}
this.#scrollTop = scrollTop;
this.#isProgrammaticScroll = true;
this.#output.mainElement.scrollTop = scrollTop;
}
#handleMessageContainerRef = (el: Element|undefined): void => {
this.#messagesContainerElement = el;
if (el) {
this.#messagesContainerResizeObserver.observe(el);
} else {
this.#pinScrollToBottom = true;
this.#messagesContainerResizeObserver.disconnect();
}
};
#handleScroll = (ev: Event): void => {
if (!ev.target || !(ev.target instanceof HTMLElement)) {
return;
}
// Do not handle scroll events caused by programmatically
// updating the scroll position. We want to know whether user
// did scroll the container from the user interface.
if (this.#isProgrammaticScroll) {
this.#isProgrammaticScroll = false;
return;
}
this.#scrollTop = ev.target.scrollTop;
this.#pinScrollToBottom =
ev.target.scrollTop + ev.target.clientHeight + SCROLL_ROUNDING_OFFSET > ev.target.scrollHeight;
};
#handleSuggestionClick = (suggestion: string): void => {
this.#output.input?.getWidget()?.setInputValue(suggestion);
this.#render();
this.focusTextInput();
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistanceDynamicSuggestionClicked);
};
async #getSummary(): Promise<string> {
if (this.#cachedSummary?.markdown === this.#props.conversationMarkdown) {
return this.#cachedSummary.summary;
}
try {
const summary = await this.#props.generateConversationSummary(this.#props.conversationMarkdown);
this.#cachedSummary = {markdown: this.#props.conversationMarkdown, summary};
return summary;
} catch (err) {
console.error(err);
return 'Failed to generate summary.';
}
}
async #exportForAgentsClick(): Promise<void> {
const summaryPromise = this.#getSummary();
void ExportForAgentsDialog.show({
promptText: summaryPromise,
markdownText: this.#props.conversationMarkdown,
onConversationSaveAs: this.#props.onExportConversation ?? (async () => {}),
});
}
#render(): void {
this.#view(
{
...this.#props,
handleScroll: this.#handleScroll,
handleSuggestionClick: this.#handleSuggestionClick,
handleMessageContainerRef: this.#handleMessageContainerRef,
exportForAgentsClick: this.#exportForAgentsClick.bind(this),
},
this.#output, this.#shadow);
}
}
declare global {
interface HTMLElementTagNameMap {
'devtools-ai-chat-view': ChatView;
}
}
customElements.define('devtools-ai-chat-view', ChatView);