UNPKG

chrome-devtools-frontend

Version:
1,429 lines (1,330 loc) 56.9 kB
// 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/components/spinners/spinners.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 Root from '../../../core/root/root.js'; import * as Marked from '../../../third_party/marked/marked.js'; import * as Buttons from '../../../ui/components/buttons/buttons.js'; import type * as IconButton from '../../../ui/components/icon_button/icon_button.js'; import * as MarkdownView from '../../../ui/components/markdown_view/markdown_view.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 {AgentType, type ContextDetail, type ConversationContext, ErrorType} from '../agents/AiAgent.js'; import stylesRaw from './chatView.css.js'; import {MarkdownRendererWithCodeBlock} from './MarkdownRendererWithCodeBlock.js'; import {UserActionRow} from './UserActionRow.js'; // TODO(crbug.com/391381439): Fully migrate off of constructed style sheets. const styles = new CSSStyleSheet(); styles.replaceSync(stylesRaw.cssContent); const {html, Directives: {ifDefined, ref}} = Lit; const UIStrings = { /** * @description The error message when the user is not logged in into Chrome. */ notLoggedIn: 'This feature is only available when you are signed into Chrome with your Google account', /** * @description Message shown when the user is offline. */ offline: 'Check your internet connection and try again', /** *@description Disclaimer text right after the chat input. */ inputDisclaimerForEmptyState: 'This is an experimental AI feature and won\'t always get it right.', /** * @description Text for a link to Chrome DevTools Settings. */ settingsLink: 'AI assistance in Settings', /** * @description Placeholder text for an inactive text field. When active, it's used for the user's input to the GenAI assistance. */ followTheSteps: 'Follow the steps above to ask a question', /** *@description Text for asking the user to turn the AI assistance feature in settings first before they are able to use it. *@example {AI assistance in Settings} PH1 */ turnOnForStyles: 'Turn on {PH1} to get help with understanding CSS styles', /** *@description Text for asking the user to turn the AI assistance feature in settings first before they are able to use it. *@example {AI assistance in Settings} PH1 */ turnOnForStylesAndRequests: 'Turn on {PH1} to get help with styles and network requests', /** *@description Text for asking the user to turn the AI assistance feature in settings first before they are able to use it. *@example {AI assistance in Settings} PH1 */ turnOnForStylesRequestsAndFiles: 'Turn on {PH1} to get help with styles, network requests, and files', /** *@description Text for asking the user to turn the AI assistance feature in settings first before they are able to use it. *@example {AI assistance in Settings} PH1 */ turnOnForStylesRequestsPerformanceAndFiles: 'Turn on {PH1} to get help with styles, network requests, performance, and files', /** *@description The footer disclaimer that links to more information about the AI feature. */ learnAbout: 'Learn about AI in DevTools', }; /* * Strings that don't need to be translated at this time. */ const UIStringsNotTranslate = { /** *@description Disclaimer text right after the chat input. */ inputDisclaimerForFreestylerAgent: 'Chat messages and any data the inspected page can access via Web APIs are sent to Google and may be seen by human reviewers to improve this feature. This is an experimental AI feature and won’t always get it right.', /** *@description Disclaimer text right after the chat input. */ inputDisclaimerForFreestylerAgentEnterpriseNoLogging: 'Chat messages and any data the inspected page can access via Web APIs are sent to Google. The content you submit and that is generated by this feature will not be used to improve Google’s AI models. This is an experimental AI feature and won’t always get it right.', /** *@description Disclaimer text right after the chat input. */ inputDisclaimerForNetworkAgent: 'Chat messages and the selected network request are sent to Google and may be seen by human reviewers to improve this feature. This is an experimental AI feature and won’t always get it right.', /** *@description Disclaimer text right after the chat input. */ inputDisclaimerForNetworkAgentEnterpriseNoLogging: 'Chat messages and the selected network request are sent to Google. The content you submit and that is generated by this feature will not be used to improve Google’s AI models. This is an experimental AI feature and won’t always get it right.', /** *@description Disclaimer text right after the chat input. */ inputDisclaimerForFileAgent: 'Chat messages and the selected file are sent to Google and may be seen by human reviewers to improve this feature. This is an experimental AI feature and won\'t always get it right.', /** *@description Disclaimer text right after the chat input. */ inputDisclaimerForFileAgentEnterpriseNoLogging: 'Chat messages and the selected file are sent to Google. The content you submit and that is generated by this feature will not be used to improve Google’s AI models. This is an experimental AI feature and won’t always get it right.', /** *@description Disclaimer text right after the chat input. */ inputDisclaimerForPerformanceAgent: 'Chat messages and the selected call tree are sent to Google and may be seen by human reviewers to improve this feature. This is an experimental AI feature and won\'t always get it right.', /** *@description Disclaimer text right after the chat input. */ inputDisclaimerForPerformanceAgentEnterpriseNoLogging: 'Chat messages and the selected call stack are sent to Google. The content you submit and that is generated by this feature will not be used to improve Google’s AI models. This is an experimental AI feature and won’t always get it right.', /** *@description Placeholder text for the chat UI input. */ inputPlaceholderForFreestylerAgent: 'Ask a question about the selected element', /** *@description Placeholder text for the chat UI input. */ inputPlaceholderForNetworkAgent: 'Ask a question about the selected network request', /** *@description Placeholder text for the chat UI input. */ inputPlaceholderForFileAgent: 'Ask a question about the selected file', /** *@description Placeholder text for the chat UI input. */ inputPlaceholderForPerformanceAgent: 'Ask a question about the selected item and its call tree', /** *@description Placeholder text for the chat UI input when there is no context selected. */ inputPlaceholderForFreestylerAgentNoContext: 'Select an element to ask a question', /** *@description Placeholder text for the chat UI input when there is no context selected. */ inputPlaceholderForNetworkAgentNoContext: 'Select a network request to ask a question', /** *@description Placeholder text for the chat UI input when there is no context selected. */ inputPlaceholderForFileAgentNoContext: 'Select a file to ask a question', /** *@description Placeholder text for the chat UI input when there is no context selected. */ inputPlaceholderForPerformanceAgentNoContext: 'Select an item to ask a question', /** *@description Placeholder text for the chat UI input. */ inputPlaceholderForPerformanceInsightsAgent: 'Ask a question about the selected performance insight', /** *@description Placeholder text for the chat UI input. */ inputPlaceholderForPerformanceInsightsAgentNoContext: 'Select a performance insight to ask a question', /** * @description Placeholder text for the input shown when the conversation is blocked because a cross-origin context was selected. */ crossOriginError: 'To talk about data from another origin, start a new chat', /** *@description Title for the send icon button. */ sendButtonTitle: 'Send', /** *@description Title for the start new chat */ startNewChat: 'Start new chat', /** *@description Title for the cancel icon button. */ cancelButtonTitle: 'Cancel', /** *@description Label for the "select an element" button. */ selectAnElement: 'Select an element', /** *@description Label for the "select an element" button. */ noElementSelected: 'No element selected', /** *@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 AI assistance panel when there is no agent selected. */ noAgentStateText: 'Explore AI assistance', /** * @description The error message when the LLM loop is stopped for some reason (Max steps reached or request to LLM failed) */ systemError: 'Something unforeseen happened and I can no longer continue. Try your request again and see if that resolves the issue.', /** * @description The error message when the LLM loop is stopped for some reason (Max steps reached or request to LLM failed) */ maxStepsError: 'Seems like I am stuck with the investigation. It would be better if you start over.', /** *@description Displayed when the user stop the response */ stoppedResponse: 'You stopped this response', /** * @description Prompt for user to confirm code execution that may affect the page. */ sideEffectConfirmationDescription: 'This code may modify page content. Continue?', /** * @description Button text that confirm code execution that may affect the page. */ positiveSideEffectConfirmation: 'Continue', /** * @description Button text that cancels code execution that may affect the page. */ negativeSideEffectConfirmation: 'Cancel', /** *@description The generic name of the AI agent (do not translate) */ ai: 'AI', /** *@description The fallback text when we can't find the user full name */ you: 'You', /** *@description The fallback text when a step has no title yet */ investigating: 'Investigating', /** *@description Prefix to the title of each thinking step of a user action is required to continue */ paused: 'Paused', /** *@description Heading text for the code block that shows the executed code. */ codeExecuted: 'Code executed', /** *@description Heading text for the code block that shows the code to be executed after side effect confirmation. */ codeToExecute: 'Code to execute', /** *@description Heading text for the code block that shows the returned data. */ dataReturned: 'Data returned', /** *@description Aria label for the check mark icon to be read by screen reader */ completed: 'Completed', /** *@description Aria label for the loading icon to be read by screen reader */ inProgress: 'In progress', /** *@description Aria label for the cancel icon to be read by screen reader */ canceled: 'Canceled', /** *@description Text displayed when the chat input is disabled due to reading past conversation. */ pastConversation: 'You\'re viewing a past conversation.', /** *@description Text displayed for showing change summary view. */ changeSummary: 'Change summary', }; const str_ = i18n.i18n.registerUIStrings('panels/ai_assistance/components/ChatView.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const lockedString = i18n.i18n.lockedString; const SCROLL_ROUNDING_OFFSET = 1; export interface Step { isLoading: boolean; thought?: string; title?: string; code?: string; output?: string; canceled?: boolean; sideEffect?: ConfirmSideEffectDialog; contextDetails?: [ContextDetail, ...ContextDetail[]]; } interface ConfirmSideEffectDialog { onAnswer: (result: boolean) => void; } export const enum ChatMessageEntity { MODEL = 'model', USER = 'user', } export interface UserChatMessage { entity: ChatMessageEntity.USER; text: string; } export interface ModelChatMessage { entity: ChatMessageEntity.MODEL; steps: Step[]; suggestions?: [string, ...string[]]; answer?: string; error?: ErrorType; rpcId?: Host.AidaClient.RpcGlobalId; } export type ChatMessage = UserChatMessage|ModelChatMessage; export const enum State { CONSENT_VIEW = 'consent-view', CHAT_VIEW = 'chat-view', } export interface Props { onTextSubmit: (text: string) => void; onInspectElementClick: () => void; onFeedbackSubmit: (rpcId: Host.AidaClient.RpcGlobalId, rate: Host.AidaClient.Rating, feedback?: string) => void; onCancelClick: () => void; onContextClick: () => void | Promise<void>; onNewConversation: () => void; onCancelCrossOriginChat?: () => void; inspectElementToggled: boolean; state: State; aidaAvailability: Host.AidaClient.AidaAccessPreconditions; messages: ChatMessage[]; selectedContext: ConversationContext<unknown>|null; isLoading: boolean; canShowFeedbackForm: boolean; userInfo: Pick<Host.InspectorFrontendHostAPI.SyncInformation, 'accountImage'|'accountFullName'>; agentType?: AgentType; isReadOnly: boolean; blockedByCrossOrigin: boolean; stripLinks: boolean; changeSummary?: string; } export class ChatView extends HTMLElement { readonly #shadow = this.attachShadow({mode: 'open'}); #markdownRenderer = new MarkdownRendererWithCodeBlock(); #scrollTop?: number; #props: Props; #messagesContainerElement?: Element; #mainElementRef?: Lit.Directives.Ref<Element> = Lit.Directives.createRef(); #lastAnswerMarkdownView?: MarkdownView.MarkdownView.MarkdownView; #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; constructor(props: Props) { super(); this.#props = props; } set props(props: Props) { this.#markdownRenderer = new MarkdownRendererWithCodeBlock({stripLinks: props.stripLinks}); this.#props = props; this.#render(); } connectedCallback(): void { this.#shadow.adoptedStyleSheets = [styles]; 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(); } restoreScrollPosition(): void { if (this.#scrollTop === undefined) { return; } if (!this.#mainElementRef?.value) { return; } this.#mainElementRef.value.scrollTop = this.#scrollTop; } finishTextAnimations(): void { if (!this.#lastAnswerMarkdownView) { return; } this.#lastAnswerMarkdownView.finishAnimations(); } scrollToBottom(): void { if (!this.#mainElementRef?.value) { return; } this.#mainElementRef.value.scrollTop = this.#mainElementRef.value.scrollHeight; } #handleMessagesContainerResize(): void { if (!this.#pinScrollToBottom) { return; } if (!this.#mainElementRef?.value) { return; } if (this.#pinScrollToBottom) { this.#mainElementRef.value.scrollTop = this.#mainElementRef.value.scrollHeight; } } #setInputText(text: string): void { const textArea = this.#shadow.querySelector('.chat-input') as HTMLTextAreaElement; if (!textArea) { return; } textArea.value = text; } #isTextInputDisabled = (): boolean => { if (this.#props.blockedByCrossOrigin) { return true; } const isAidaAvailable = this.#props.aidaAvailability === Host.AidaClient.AidaAccessPreconditions.AVAILABLE; const isConsentView = this.#props.state === State.CONSENT_VIEW; if (!isAidaAvailable || isConsentView || !this.#props.agentType) { return true; } if (!this.#props.selectedContext) { return true; } return false; }; #handleMessageContainerRef(el: Element|undefined): void { this.#messagesContainerElement = el; if (el) { this.#messagesContainerResizeObserver.observe(el); } else { this.#pinScrollToBottom = true; this.#messagesContainerResizeObserver.disconnect(); } } #handleLastAnswerMarkdownViewRef(el: Element|undefined): void { if (!el) { this.#lastAnswerMarkdownView = undefined; return; } if (el instanceof MarkdownView.MarkdownView.MarkdownView) { this.#lastAnswerMarkdownView = el; } } #handleScroll = (ev: Event): void => { if (!ev.target || !(ev.target instanceof HTMLElement)) { return; } this.#scrollTop = ev.target.scrollTop; this.#pinScrollToBottom = ev.target.scrollTop + ev.target.clientHeight + SCROLL_ROUNDING_OFFSET > ev.target.scrollHeight; }; #handleSubmit = (ev: SubmitEvent): void => { ev.preventDefault(); const textArea = this.#shadow.querySelector('.chat-input') as HTMLTextAreaElement; if (!textArea || !textArea.value) { return; } this.#props.onTextSubmit(textArea.value); textArea.value = ''; }; #handleTextAreaKeyDown = (ev: KeyboardEvent): void => { if (!ev.target || !(ev.target instanceof HTMLTextAreaElement)) { return; } // Go to a new line only when Shift + Enter is pressed. if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); if (!ev.target || !ev.target.value) { return; } this.#props.onTextSubmit(ev.target.value); ev.target.value = ''; } }; #handleCancel = (ev: SubmitEvent): void => { ev.preventDefault(); if (!this.#props.isLoading) { return; } this.#props.onCancelClick(); }; #handleSuggestionClick = (suggestion: string): void => { this.#setInputText(suggestion); this.focusTextInput(); Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiAssistanceDynamicSuggestionClicked); }; #getEmptyStateSuggestions = (): string[] => { if (!this.#props.agentType) { return []; } switch (this.#props.agentType) { case AgentType.STYLING: return [ 'What can you help me with?', 'Why isn’t this element visible?', 'How do I center this element?', ]; case AgentType.FILE: return [ 'What does this script do?', 'Is the script optimized for performance?', 'Does the script handle user input safely?', ]; case AgentType.NETWORK: return [ 'Why is this network request taking so long?', 'Are there any security headers present?', 'Why is the request failing?', ]; case AgentType.PERFORMANCE: return [ 'Identify performance issues in this call tree', 'Where is most of the time being spent in this call tree?', 'How can I reduce the time of this call tree?', ]; case AgentType.PERFORMANCE_INSIGHT: // TODO(b/393061683): Define these. return ['Placeholder', 'Suggestions', 'For now']; case AgentType.PATCH: return [ 'What can you help me with?', 'Why isn’t this element visible?', 'How do I center this element?', ]; } }; #getInputPlaceholderString(): Platform.UIString.LocalizedString { const state = this.#props.state; const agentType = this.#props.agentType; if (state === State.CONSENT_VIEW || !agentType) { return i18nString(UIStrings.followTheSteps); } if (this.#props.blockedByCrossOrigin) { return lockedString(UIStringsNotTranslate.crossOriginError); } switch (agentType) { case AgentType.PATCH: return lockedString(UIStringsNotTranslate.inputPlaceholderForFreestylerAgent); case AgentType.STYLING: return this.#props.selectedContext ? lockedString(UIStringsNotTranslate.inputPlaceholderForFreestylerAgent) : lockedString(UIStringsNotTranslate.inputPlaceholderForFreestylerAgentNoContext); case AgentType.FILE: return this.#props.selectedContext ? lockedString(UIStringsNotTranslate.inputPlaceholderForFileAgent) : lockedString(UIStringsNotTranslate.inputPlaceholderForFileAgentNoContext); case AgentType.NETWORK: return this.#props.selectedContext ? lockedString(UIStringsNotTranslate.inputPlaceholderForNetworkAgent) : lockedString(UIStringsNotTranslate.inputPlaceholderForNetworkAgentNoContext); case AgentType.PERFORMANCE: return this.#props.selectedContext ? lockedString(UIStringsNotTranslate.inputPlaceholderForPerformanceAgent) : lockedString(UIStringsNotTranslate.inputPlaceholderForPerformanceAgentNoContext); case AgentType.PERFORMANCE_INSIGHT: return this.#props.selectedContext ? lockedString(UIStringsNotTranslate.inputPlaceholderForPerformanceInsightsAgent) : lockedString(UIStringsNotTranslate.inputPlaceholderForPerformanceInsightsAgentNoContext); } } #getDisclaimerText = (): Platform.UIString.LocalizedString => { if (this.#props.state === State.CONSENT_VIEW || !this.#props.agentType || this.#props.isReadOnly) { return i18nString(UIStrings.inputDisclaimerForEmptyState); } const noLogging = Common.Settings.Settings.instance().getHostConfig().aidaAvailability?.enterprisePolicyValue === Root.Runtime.GenAiEnterprisePolicyValue.ALLOW_WITHOUT_LOGGING; switch (this.#props.agentType) { case AgentType.PATCH: if (noLogging) { return lockedString(UIStringsNotTranslate.inputDisclaimerForFreestylerAgentEnterpriseNoLogging); } return lockedString(UIStringsNotTranslate.inputDisclaimerForFreestylerAgent); case AgentType.STYLING: if (noLogging) { return lockedString(UIStringsNotTranslate.inputDisclaimerForFreestylerAgentEnterpriseNoLogging); } return lockedString(UIStringsNotTranslate.inputDisclaimerForFreestylerAgent); case AgentType.FILE: if (noLogging) { return lockedString(UIStringsNotTranslate.inputDisclaimerForFileAgentEnterpriseNoLogging); } return lockedString(UIStringsNotTranslate.inputDisclaimerForFileAgent); case AgentType.NETWORK: if (noLogging) { return lockedString(UIStringsNotTranslate.inputDisclaimerForNetworkAgentEnterpriseNoLogging); } return lockedString(UIStringsNotTranslate.inputDisclaimerForNetworkAgent); case AgentType.PERFORMANCE: if (noLogging) { return lockedString(UIStringsNotTranslate.inputDisclaimerForPerformanceAgentEnterpriseNoLogging); } return lockedString(UIStringsNotTranslate.inputDisclaimerForPerformanceAgent); case AgentType.PERFORMANCE_INSIGHT: // TODO(b/393061683): Define these rather than reuse the existing performance agent. if (noLogging) { return lockedString(UIStringsNotTranslate.inputDisclaimerForPerformanceAgentEnterpriseNoLogging); } return lockedString(UIStringsNotTranslate.inputDisclaimerForPerformanceAgent); } }; #getConsentViewContents(): Lit.TemplateResult { const settingsLink = document.createElement('button'); settingsLink.textContent = i18nString(UIStrings.settingsLink); settingsLink.classList.add('link'); UI.ARIAUtils.markAsLink(settingsLink); settingsLink.addEventListener('click', () => { void UI.ViewManager.ViewManager.instance().showView('chrome-ai'); }); settingsLink.setAttribute('jslog', `${VisualLogging.action('open-ai-settings').track({click: true})}`); return html`${i18n.i18n.getFormatLocalizedString(str_, this.#getStringForConsentView(), {PH1: settingsLink})}`; } #getStringForConsentView(): string { const config = Common.Settings.Settings.instance().getHostConfig(); if (config.devToolsAiAssistancePerformanceAgent?.enabled) { return UIStrings.turnOnForStylesRequestsPerformanceAndFiles; } if (config.devToolsAiAssistanceFileAgent?.enabled) { return UIStrings.turnOnForStylesRequestsAndFiles; } if (config.devToolsAiAssistanceNetworkAgent?.enabled) { return UIStrings.turnOnForStylesAndRequests; } return UIStrings.turnOnForStyles; } #getUnavailableAidaAvailabilityContents( aidaAvailability: Exclude<Host.AidaClient.AidaAccessPreconditions, Host.AidaClient.AidaAccessPreconditions.AVAILABLE>): Lit.TemplateResult { switch (aidaAvailability) { case Host.AidaClient.AidaAccessPreconditions.NO_ACCOUNT_EMAIL: case Host.AidaClient.AidaAccessPreconditions.SYNC_IS_PAUSED: { return html`${i18nString(UIStrings.notLoggedIn)}`; } case Host.AidaClient.AidaAccessPreconditions.NO_INTERNET: { return html`${i18nString(UIStrings.offline)}`; } } } #render(): void { // clang-format off Lit.render(html` <div class="chat-ui"> <main @scroll=${this.#handleScroll} ${ref(this.#mainElementRef)}> ${renderMainContents({ state: this.#props.state, aidaAvailability: this.#props.aidaAvailability, messages: this.#props.messages, isLoading: this.#props.isLoading, isReadOnly: this.#props.isReadOnly, canShowFeedbackForm: this.#props.canShowFeedbackForm, isTextInputDisabled: this.#isTextInputDisabled(), suggestions: this.#getEmptyStateSuggestions(), consentViewContents: this.#getConsentViewContents(), userInfo: this.#props.userInfo, markdownRenderer: this.#markdownRenderer, agentType: this.#props.agentType, getUnavailableAidaAvailabilityContents: this.#getUnavailableAidaAvailabilityContents, onSuggestionClick: this.#handleSuggestionClick, onFeedbackSubmit: this.#props.onFeedbackSubmit, onLastAnswerMarkdownViewRef: this.#handleLastAnswerMarkdownViewRef, onMessageContainerRef: this.#handleMessageContainerRef, })} ${this.#props.isReadOnly ? renderReadOnlySection({ agentType: this.#props.agentType, onNewConversation: this.#props.onNewConversation, }) : renderChatInput({ isLoading: this.#props.isLoading, blockedByCrossOrigin: this.#props.blockedByCrossOrigin, isTextInputDisabled: this.#isTextInputDisabled(), inputPlaceholderString: this.#getInputPlaceholderString(), state: this.#props.state, selectedContext: this.#props.selectedContext, inspectElementToggled: this.#props.inspectElementToggled, agentType: this.#props.agentType, changeSummary: this.#props.changeSummary, onContextClick: this.#props.onContextClick, onInspectElementClick: this.#props.onInspectElementClick, onSubmit: this.#handleSubmit, onTextAreaKeyDown: this.#handleTextAreaKeyDown, onCancel: this.#handleCancel, onNewConversation: this.#props.onNewConversation, onCancelCrossOriginChat: this.#props.onCancelCrossOriginChat, }) } </main> <footer class="disclaimer" jslog=${VisualLogging.section('footer')}> <p class="disclaimer-text"> ${this.#getDisclaimerText()} <button class="link" role="link" jslog=${VisualLogging.link('open-ai-settings').track({ click: true, })} @click=${() => { void UI.ViewManager.ViewManager.instance().showView('chrome-ai'); }} >${i18nString(UIStrings.learnAbout)}</button> </p> </footer> </div> `, this.#shadow, {host: this}); // clang-format on } } function renderChangeSummary(changeSummary?: string): Lit.LitTemplate { if (!changeSummary) { return Lit.nothing; } return html`<details class="change-summary"> <summary> <devtools-icon class="difference-icon" .name=${'difference'} ></devtools-icon> <span class="header-text"> ${lockedString(UIStringsNotTranslate.changeSummary)} </span> <devtools-icon class="arrow" .name=${'chevron-up'} ></devtools-icon> </summary> <devtools-code-block .code=${changeSummary} .codeLang=${'css'} .displayNotice=${false} ></devtools-code-block> </details>`; } function renderTextAsMarkdown(text: string, markdownRenderer: MarkdownRendererWithCodeBlock, {animate, ref: refFn}: { animate?: boolean, ref?: (element?: Element) => void, } = {}): Lit.TemplateResult { let tokens = []; try { tokens = Marked.Marked.lexer(text); for (const token of tokens) { // Try to render all the tokens to make sure that // they all have a template defined for them. If there // isn't any template defined for a token, we'll fallback // to rendering the text as plain text instead of markdown. markdownRenderer.renderToken(token); } } catch { // The tokens were not parsed correctly or // one of the tokens are not supported, so we // continue to render this as text. return html`${text}`; } // clang-format off return html`<devtools-markdown-view .data=${{tokens, renderer: markdownRenderer, animationEnabled: animate} as MarkdownView.MarkdownView.MarkdownViewData} ${refFn ? ref(refFn) : Lit.nothing}> </devtools-markdown-view>`; // clang-format on } function renderTitle(step: Step): Lit.LitTemplate { const paused = step.sideEffect ? html`<span class="paused">${lockedString(UIStringsNotTranslate.paused)}: </span>` : Lit.nothing; const actionTitle = step.title ?? `${lockedString(UIStringsNotTranslate.investigating)}…`; return html`<span class="title">${paused}${actionTitle}</span>`; } function renderStepCode(step: Step): Lit.LitTemplate { if (!step.code && !step.output) { return Lit.nothing; } // If there is no "output" yet, it means we didn't execute the code yet (e.g. maybe it is still waiting for confirmation from the user) // thus we show "Code to execute" text rather than "Code executed" text on the heading of the code block. const codeHeadingText = (step.output && !step.canceled) ? lockedString(UIStringsNotTranslate.codeExecuted) : lockedString(UIStringsNotTranslate.codeToExecute); // If there is output, we don't show notice on this code block and instead show // it in the data returned code block. // clang-format off const code = step.code ? html`<div class="action-result"> <devtools-code-block .code=${step.code.trim()} .codeLang=${'js'} .displayNotice=${!Boolean(step.output)} .header=${codeHeadingText} .showCopyButton=${true} ></devtools-code-block> </div>` : Lit.nothing; const output = step.output ? html`<div class="js-code-output"> <devtools-code-block .code=${step.output} .codeLang=${'js'} .displayNotice=${true} .header=${lockedString(UIStringsNotTranslate.dataReturned)} .showCopyButton=${false} ></devtools-code-block> </div>` : Lit.nothing; return html`<div class="step-code">${code}${output}</div>`; // clang-format on } function renderStepDetails({ step, markdownRenderer, isLast, }: { step: Step, markdownRenderer: MarkdownRendererWithCodeBlock, isLast: boolean, }): Lit.LitTemplate { const sideEffects = isLast && step.sideEffect ? renderSideEffectConfirmationUi(step) : Lit.nothing; const thought = step.thought ? html`<p>${renderTextAsMarkdown(step.thought, markdownRenderer)}</p>` : Lit.nothing; // clang-format off const contextDetails = step.contextDetails ? html`${Lit.Directives.repeat( step.contextDetails, contextDetail => { return html`<div class="context-details"> <devtools-code-block .code=${contextDetail.text} .codeLang=${contextDetail.codeLang || ''} .displayNotice=${false} .header=${contextDetail.title} .showCopyButton=${true} ></devtools-code-block> </div>`; }, )}` : Lit.nothing; return html`<div class="step-details"> ${thought} ${renderStepCode(step)} ${sideEffects} ${contextDetails} </div>`; // clang-format on } function renderStepBadge({step, isLoading, isLast}: { step: Step, isLoading: boolean, isLast: boolean, }): Lit.LitTemplate { if (isLoading && isLast && !step.sideEffect) { return html`<devtools-spinner></devtools-spinner>`; } let iconName: string = 'checkmark'; let ariaLabel: string|undefined = lockedString(UIStringsNotTranslate.completed); let role: 'button'|undefined = 'button'; if (isLast && step.sideEffect) { role = undefined; ariaLabel = undefined; iconName = 'pause-circle'; } else if (step.canceled) { ariaLabel = lockedString(UIStringsNotTranslate.canceled); iconName = 'cross'; } return html`<devtools-icon class="indicator" role=${ifDefined(role)} aria-label=${ifDefined(ariaLabel)} .name=${iconName} ></devtools-icon>`; } function renderStep({step, isLoading, markdownRenderer, isLast}: { step: Step, isLoading: boolean, markdownRenderer: MarkdownRendererWithCodeBlock, isLast: boolean, }): Lit.LitTemplate { const stepClasses = Lit.Directives.classMap({ step: true, empty: !step.thought && !step.code && !step.contextDetails, paused: Boolean(step.sideEffect), canceled: Boolean(step.canceled), }); // clang-format off return html` <details class=${stepClasses} jslog=${VisualLogging.section('step')} .open=${Boolean(step.sideEffect)}> <summary> <div class="summary"> ${renderStepBadge({ step, isLoading, isLast })} ${renderTitle(step)} <devtools-icon class="arrow" .name=${'chevron-down'} ></devtools-icon> </div> </summary> ${renderStepDetails({step, markdownRenderer, isLast})} </details>`; // clang-format on } function renderSideEffectConfirmationUi(step: Step): Lit.LitTemplate { if (!step.sideEffect) { return Lit.nothing; } // clang-format off return html`<div class="side-effect-confirmation" jslog=${VisualLogging.section('side-effect-confirmation')} > <p>${lockedString(UIStringsNotTranslate.sideEffectConfirmationDescription)}</p> <div class="side-effect-buttons-container"> <devtools-button .data=${ { variant: Buttons.Button.Variant.OUTLINED, jslogContext: 'decline-execute-code', } as Buttons.Button.ButtonData } @click=${() => step.sideEffect?.onAnswer(false)} >${lockedString( UIStringsNotTranslate.negativeSideEffectConfirmation, )}</devtools-button> <devtools-button .data=${ { variant: Buttons.Button.Variant.PRIMARY, jslogContext: 'accept-execute-code', iconName: 'play', } as Buttons.Button.ButtonData } @click=${() => step.sideEffect?.onAnswer(true)} >${ lockedString(UIStringsNotTranslate.positiveSideEffectConfirmation) }</devtools-button> </div> </div>`; // clang-format on } function renderError(message: ModelChatMessage): Lit.LitTemplate { if (message.error) { let errorMessage; switch (message.error) { case ErrorType.UNKNOWN: case ErrorType.BLOCK: errorMessage = UIStringsNotTranslate.systemError; break; case ErrorType.MAX_STEPS: errorMessage = UIStringsNotTranslate.maxStepsError; break; case ErrorType.ABORT: return html`<p class="aborted" jslog=${VisualLogging.section('aborted')}>${ lockedString(UIStringsNotTranslate.stoppedResponse)}</p>`; } return html`<p class="error" jslog=${VisualLogging.section('error')}>${lockedString(errorMessage)}</p>`; } return Lit.nothing; } function renderChatMessage({ message, isLoading, isReadOnly, canShowFeedbackForm, isLast, userInfo, markdownRenderer, onSuggestionClick, onFeedbackSubmit, onLastAnswerMarkdownViewRef, }: { message: ChatMessage, isLoading: boolean, isReadOnly: boolean, canShowFeedbackForm: boolean, isLast: boolean, userInfo: Pick<Host.InspectorFrontendHostAPI.SyncInformation, 'accountImage'|'accountFullName'>, markdownRenderer: MarkdownRendererWithCodeBlock, onSuggestionClick: (suggestion: string) => void, onFeedbackSubmit: (rpcId: Host.AidaClient.RpcGlobalId, rate: Host.AidaClient.Rating, feedback?: string) => void, onLastAnswerMarkdownViewRef: (el: Element|undefined) => void, }): Lit.TemplateResult { if (message.entity === ChatMessageEntity.USER) { const name = userInfo.accountFullName || lockedString(UIStringsNotTranslate.you); const image = userInfo.accountImage ? html`<img src="data:image/png;base64, ${userInfo.accountImage}" alt="Account avatar" />` : html`<devtools-icon .name=${'profile'} ></devtools-icon>`; // clang-format off return html`<section class="chat-message query" jslog=${VisualLogging.section('question')} > <div class="message-info"> ${image} <div class="message-name"> <h2>${name}</h2> </div> </div> <div class="message-content">${renderTextAsMarkdown(message.text, markdownRenderer)}</div> </section>`; // clang-format on } // clang-format off return html` <section class="chat-message answer" jslog=${VisualLogging.section('answer')} > <div class="message-info"> <devtools-icon name="smart-assistant"></devtools-icon> <div class="message-name"> <h2>${lockedString(UIStringsNotTranslate.ai)}</h2> </div> </div> ${Lit.Directives.repeat( message.steps, (_, index) => index, step => { return renderStep({ step, isLoading, markdownRenderer, isLast: [...message.steps.values()].at(-1) === step && isLast, }); }, )} ${message.answer ? html`<p>${renderTextAsMarkdown(message.answer, markdownRenderer, { animate: !isReadOnly, ref: onLastAnswerMarkdownViewRef })}</p>` : Lit.nothing} ${renderError(message)} ${isLast && isLoading ? Lit.nothing : html`<devtools-widget class="actions" .widgetConfig=${UI.Widget.widgetConfig(UserActionRow, { showRateButtons: message.rpcId !== undefined, onFeedbackSubmit: (rating: Host.AidaClient.Rating, feedback: string) => { if (!message.rpcId) { return; } onFeedbackSubmit(message.rpcId, rating, feedback); }, suggestions: isLast ? message.suggestions : undefined, onSuggestionClick, canShowFeedbackForm, })}></devtools-widget>` } </section> `; // clang-format on } function renderSelection({ selectedContext, inspectElementToggled, agentType, onContextClick, onInspectElementClick, }: { selectedContext: ConversationContext<unknown>|null, inspectElementToggled: boolean, agentType?: AgentType, onContextClick: () => void | Promise<void>, onInspectElementClick: () => void, }): Lit.LitTemplate { if (!agentType) { return Lit.nothing; } // TODO: currently the picker behavior is SDKNode specific. const hasPickerBehavior = agentType === AgentType.STYLING; const resourceClass = Lit.Directives.classMap({ 'not-selected': !selectedContext, 'resource-link': true, 'allow-overflow': hasPickerBehavior, }); if (!selectedContext && !hasPickerBehavior) { return Lit.nothing; } const icon = selectedContext?.getIcon() ?? Lit.nothing; const handleKeyDown = (ev: KeyboardEvent): void => { if (ev.key === 'Enter') { void onContextClick(); } }; // clang-format off return html`<div class="select-element"> ${ hasPickerBehavior ? html` <devtools-button .data=${{ variant: Buttons.Button.Variant.ICON_TOGGLE, size: Buttons.Button.Size.REGULAR, iconName: 'select-element', toggledIconName: 'select-element', toggleType: Buttons.Button.ToggleType.PRIMARY, toggled: inspectElementToggled, title: lockedString(UIStringsNotTranslate.selectAnElement), jslogContext: 'select-element', } as Buttons.Button.ButtonData} @click=${onInspectElementClick} ></devtools-button> ` : Lit.nothing } <div role=button class=${resourceClass} tabindex=${hasPickerBehavior ? '-1' : '0'} @click=${onContextClick} @keydown=${handleKeyDown} > ${icon}${selectedContext?.getTitle() ?? html`<span>${ lockedString(UIStringsNotTranslate.noElementSelected) }</span>`} </div> </div>`; // clang-format on } function renderMessages({ messages, isLoading, isReadOnly, canShowFeedbackForm, userInfo, markdownRenderer, onSuggestionClick, onFeedbackSubmit, onLastAnswerMarkdownViewRef, onMessageContainerRef, }: { messages: ChatMessage[], isLoading: boolean, isReadOnly: boolean, canShowFeedbackForm: boolean, userInfo: Pick<Host.InspectorFrontendHostAPI.SyncInformation, 'accountImage'|'accountFullName'>, markdownRenderer: MarkdownRendererWithCodeBlock, onSuggestionClick: (suggestion: string) => void, onFeedbackSubmit: (rpcId: Host.AidaClient.RpcGlobalId, rate: Host.AidaClient.Rating, feedback?: string) => void, onLastAnswerMarkdownViewRef: (el: Element|undefined) => void, onMessageContainerRef: (el: Element|undefined) => void, }): Lit.TemplateResult { // clang-format off return html` <div class="messages-container" ${ref(onMessageContainerRef)}> ${messages.map((message, _, array) => renderChatMessage({ message, isLoading, isReadOnly, canShowFeedbackForm, isLast: array.at(-1) === message, userInfo, markdownRenderer, onSuggestionClick, onFeedbackSubmit, onLastAnswerMarkdownViewRef, }), )} </div> `; // clang-format on } function renderEmptyState({isTextInputDisabled, suggestions, onSuggestionClick}: { isTextInputDisabled: boolean, suggestions: string[], onSuggestionClick: (suggestion: string) => void, }): Lit.TemplateResult { // clang-format off return html`<div class="empty-state-container"> <div class="header"> <div class="icon"> <devtools-icon name="smart-assistant" ></devtools-icon> </div> <h1>${lockedString(UIStringsNotTranslate.emptyStateText)}</h1> </div> <div class="empty-state-content"> ${suggestions.map(suggestion => { return html`<devtools-button class="suggestion" @click=${() => onSuggestionClick(suggestion)} .data=${ { variant: Buttons.Button.Variant.OUTLINED, size: Buttons.Button.Size.REGULAR, title: suggestion, jslogContext: 'suggestion', disabled: isTextInputDisabled, } as Buttons.Button.ButtonData } >${suggestion}</devtools-button>`; })} </div> </div>`; // clang-format on } function renderReadOnlySection({onNewConversation, agentType}: { onNewConversation: () => void, agentType?: AgentType, }): Lit.LitTemplate { if (!agentType) { return Lit.nothing; } // clang-format off return html`<div class="chat-readonly-container" jslog=${VisualLogging.section('read-only')} > <span>${lockedString(UIStringsNotTranslate.pastConversation)}</span> <devtools-button aria-label=${lockedString(UIStringsNotTranslate.startNewChat)} class="chat-inline-button" @click=${onNewConversation} .data=${{ variant: Buttons.Button.Variant.TEXT, title: lockedString(UIStringsNotTranslate.startNewChat), jslogContext: 'start-new-chat', } as Buttons.Button.ButtonData} >${lockedString(UIStringsNotTranslate.startNewChat)}</devtools-button> </div>`; // clang-format on } function renderChatInputButtons( {isLoading, blockedByCrossOrigin, isTextInputDisabled, onCancel, onNewConversation, onCancelCrossOriginChat}: { isLoading: boolean, blockedByCrossOrigin: boolean, isTextInputDisabled: boolean, onCancel: (ev: SubmitEvent) => void, onNewConversation: () => void, onCancelCrossOriginChat?: () => void, }): Lit.TemplateResult { if (isLoading) { // clang-format off return html`<devtools-button class="chat-input-button" aria-label=${lockedString(UIStringsNotTranslate.cancelButtonTitle)} @click=${onCancel} .data=${ { variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.REGULAR, iconName: 'record-stop', title: lockedString(UIStringsNotTranslate.cancelButtonTitle), jslogContext: 'stop', } as Buttons.Button.ButtonData } ></devtools-button>`; // clang-format on } if (blockedByCrossOrigin) { // clang-format off return html` ${blockedByCrossOrigin && Boolean(onCancelCrossOriginChat) ? html`<devtools-button class="chat-cancel-context-button" @click=${onCancelCrossOriginChat} .data=${ { variant: Buttons.Button.Variant.TEXT, size: Buttons.Button.Size.REGULAR, jslogContext: 'cancel-cross-origin-context-chat', } as Buttons.Button.ButtonData } >${lockedString(UIStringsNotTranslate.cancelButtonTitle)}</devtools-button>` : Lit.nothing} <devtools-button class="chat-input-button" aria-label=${lockedString(UIStringsNotTranslate.startNewChat)} @click=${onNewConversation} .data=${ { variant: Buttons.Button.Variant.PRIMARY, size: Buttons.Button.Size.REGULAR, title: lockedString(UIStringsNotTranslate.startNewChat), jslogContext: 'start-new-chat', } as Buttons.Button.ButtonData } >${lockedString(UIStringsNotTranslate.startNewChat)}</devtools-button> `; // clang-format on } // clang-format off return html`<devtools-button class="chat-input-button" aria-label=${lockedString(UIStringsNotTranslate.sendButtonTitle)} .data=${ { type: 'submit', variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.REGULAR, disabled: isTextInputDisabled, iconName: 'send', title: lockedString(UIStringsNotTranslate.sendButtonTitle), jslogContext: 'send', } as Buttons.Button.ButtonData } ></devtools-button>`; } function renderChatInput({ isLoading, blockedByCrossOrigin, isTextInputDisabled, inputPlaceholderString, state, selectedContext, inspectElementToggled, agentType, changeSummary, onContextClick, onInspectElementClick, onSubmit, onTextAreaKeyDown, onCancel, onNewConversation, onCancelCrossOriginChat, }: { isLoading: boolean, blockedByCrossOrigin: boolean, isTextInputDisabled: boolean, inputPlaceholderString: Platform.UIString.LocalizedString, state: State, selectedContext: ConversationContext<unknown> | null, inspectElementToggled: boolean, agentType?: AgentType, changeSummary?: string, onContextClick: () => void | Promise<void>, onInspectElementClick: () => void, onSubmit: (ev: SubmitEvent) => void, onTextAreaKeyDown: (ev: KeyboardEvent) => void, onCancel: (ev: SubmitEvent) => void, onNewConversation: () => void, onCancelCrossOriginChat?: () => void, }): Lit.LitTemplate { if (!agentType) { return Lit.nothing; } const cls = Lit.Directives.classMap({ 'chat-input': true, 'two-big-buttons': blockedByCrossOrigin, }); // clang-format off return html` <form class="input-form" @submit=${onSubmit}> <div class="input-form-shadow-container"> <div class="input-form-shadow"></div> </div> ${state !== State.CONSENT_VIEW ? html` <div class="input-header"> <div class="header-link-container"> ${renderSelection({ selectedContext, inspectElementToggled, agentType, onContextClick, onInspe