UNPKG

chrome-devtools-frontend

Version:
921 lines (852 loc) 33.3 kB
// 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/markdown_view/markdown_view.js'; import '../../../ui/kit/kit.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 AiAssistanceModel from '../../../models/ai_assistance/ai_assistance.js'; import * as Marked from '../../../third_party/marked/marked.js'; import * as Buttons from '../../../ui/components/buttons/buttons.js'; import * as Input from '../../../ui/components/input/input.js'; import type * as MarkdownView from '../../../ui/components/markdown_view/markdown_view.js'; import type {MarkdownLitRenderer} from '../../../ui/components/markdown_view/MarkdownView.js'; import * as UIHelpers from '../../../ui/helpers/helpers.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 chatMessageStyles from './chatMessage.css.js'; const {html, Directives: {ref, ifDefined}} = Lit; const lockedString = i18n.i18n.lockedString; const REPORT_URL = 'https://crbug.com/364805393' as Platform.DevToolsPath.UrlString; const SCROLL_ROUNDING_OFFSET = 1; /* * Strings that don't need to be translated at this time. */ const UIStringsNotTranslate = { /** * @description The title of the button that allows submitting positive * feedback about the response for AI assistance. */ thumbsUp: 'Good response', /** * @description The title of the button that allows submitting negative * feedback about the response for AI assistance. */ thumbsDown: 'Bad response', /** * @description The placeholder text for the feedback input. */ provideFeedbackPlaceholder: 'Provide additional feedback', /** * @description The disclaimer text that tells the user what will be shared * and what will be stored. */ disclaimer: 'Submitted feedback will also include your conversation', /** * @description The button text for the action of submitting feedback. */ submit: 'Submit', /** * @description The header of the feedback form asking. */ whyThisRating: 'Why did you choose this rating? (optional)', /** * @description The button text for the action that hides the feedback form. */ close: 'Close', /** * @description The title of the button that opens a page to report a legal * issue with the AI assistance message. */ report: 'Report legal issue', /** * @description The title of the button for scrolling to see next suggestions */ scrollToNext: 'Scroll to next suggestions', /** * @description The title of the button for scrolling to see previous suggestions */ scrollToPrevious: 'Scroll to previous suggestions', /** * @description The title of the button that copies the AI-generated response to the clipboard. */ copyResponse: 'Copy response', /** * @description The error message when the request to the LLM failed for some reason. */ systemError: 'Something unforeseen happened and I can no longer continue. Try your request again and see if that resolves the issue. If this keeps happening, update Chrome to the latest version.', /** * @description The error message when the LLM gets stuck in a loop (max steps reached). */ 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 Gemini (do not translate) */ gemini: 'Gemini', /** * @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 cancel icon to be read by screen reader */ canceled: 'Canceled', /** * @description Alt text for the image input (displayed in the chat messages) that has been sent to the model. */ imageInputSentToTheModel: 'Image input sent to the model', /** * @description Alt text for the account avatar. */ accountAvatar: 'Account avatar', /** * @description Title for the link which wraps the image input rendered in chat messages. */ openImageInNewTab: 'Open image in a new tab', /** * @description Alt text for image when it is not available. */ imageUnavailable: 'Image unavailable', } as const; export interface Step { isLoading: boolean; thought?: string; title?: string; code?: string; output?: string; canceled?: boolean; sideEffect?: ConfirmSideEffectDialog; contextDetails?: [AiAssistanceModel.AiAgent.ContextDetail, ...AiAssistanceModel.AiAgent.ContextDetail[]]; } export interface ConfirmSideEffectDialog { onAnswer: (result: boolean) => void; } export const enum ChatMessageEntity { MODEL = 'model', USER = 'user', } export interface AnswerPart { type: 'answer'; text: string; suggestions?: [string, ...string[]]; } export interface StepPart { type: 'step'; step: Step; } export type ModelMessagePart = AnswerPart|StepPart; export interface UserChatMessage { entity: ChatMessageEntity.USER; text: string; imageInput?: Host.AidaClient.Part; } export interface ModelChatMessage { entity: ChatMessageEntity.MODEL; parts: ModelMessagePart[]; error?: AiAssistanceModel.AiAgent.ErrorType; rpcId?: Host.AidaClient.RpcGlobalId; } export type Message = UserChatMessage|ModelChatMessage; export interface RatingViewInput { currentRating?: Host.AidaClient.Rating; onRatingClick: (rating: Host.AidaClient.Rating) => void; showRateButtons: boolean; } export interface ActionViewInput { onReportClick: () => void; onCopyResponseClick: () => void; showActions: boolean; } export interface SuggestionViewInput { suggestions?: [string, ...string[]]; scrollSuggestionsScrollContainer: (direction: 'left'|'right') => void; onSuggestionsScrollOrResize: () => void; onSuggestionClick: (suggestion: string) => void; } export interface FeedbackFormViewInput { isShowingFeedbackForm: boolean; onSubmit: (event: SubmitEvent) => void; onClose: () => void; onInputChange: (input: string) => void; isSubmitButtonDisabled: boolean; } export type ChatMessageViewInput = MessageInput&RatingViewInput&ActionViewInput&SuggestionViewInput&FeedbackFormViewInput; export interface ViewOutput { suggestionsLeftScrollButtonContainer?: Element; suggestionsScrollContainer?: Element; suggestionsRightScrollButtonContainer?: Element; } export interface MessageInput { suggestions?: [string, ...string[]]; message: Message; isLoading: boolean; isReadOnly: boolean; isLastMessage: boolean; canShowFeedbackForm: boolean; userInfo: Pick<Host.InspectorFrontendHostAPI.SyncInformation, 'accountImage'|'accountFullName'|'accountGivenName'>; markdownRenderer: MarkdownLitRenderer; onSuggestionClick: (suggestion: string) => void; onFeedbackSubmit: (rpcId: Host.AidaClient.RpcGlobalId, rate: Host.AidaClient.Rating, feedback?: string) => void; onCopyResponseClick: (message: ModelChatMessage) => void; } export const DEFAULT_VIEW = (input: ChatMessageViewInput, output: ViewOutput, target: HTMLElement): void => { const message = input.message; if (message.entity === ChatMessageEntity.USER) { const givenName = AiAssistanceModel.AiUtils.isGeminiBranding() ? input.userInfo.accountGivenName : ''; const name = givenName || input.userInfo.accountFullName || lockedString(UIStringsNotTranslate.you); const image = input.userInfo.accountImage ? html`<img src="data:image/png;base64, ${input.userInfo.accountImage}" alt=${ UIStringsNotTranslate.accountAvatar} />` : html`<devtools-icon name="profile" ></devtools-icon>`; const imageInput = message.imageInput && 'inlineData' in message.imageInput ? renderImageChatMessage(message.imageInput.inlineData) : Lit.nothing; // clang-format off Lit.render(html` <style>${Input.textInputStyles}</style> <style>${chatMessageStyles}</style> <section class="chat-message query ${input.isLastMessage ? 'is-last-message' : ''}" jslog=${VisualLogging.section('question')} > <div class="message-info"> ${image} <div class="message-name"> <h2>${name}</h2> </div> </div> ${imageInput} <div class="message-content">${renderTextAsMarkdown(message.text, input.markdownRenderer)}</div> </section> `, target); // clang-format on return; } const icon = AiAssistanceModel.AiUtils.getIconName(); // clang-format off Lit.render(html` <style>${Input.textInputStyles}</style> <style>${chatMessageStyles}</style> <section class="chat-message answer ${input.isLastMessage ? 'is-last-message' : ''}" jslog=${VisualLogging.section('answer')} > <div class="message-info"> <devtools-icon name=${icon}></devtools-icon> <div class="message-name"> <h2>${AiAssistanceModel.AiUtils.isGeminiBranding() ? lockedString(UIStringsNotTranslate.gemini) : lockedString(UIStringsNotTranslate.ai)}</h2> </div> </div> ${Lit.Directives.repeat( message.parts, (_, index) => index, (part, index) => { const isLastPart = index === message.parts.length - 1; if (part.type === 'answer') { return html`<p>${renderTextAsMarkdown(part.text, input.markdownRenderer, { animate: !input.isReadOnly && input.isLoading && isLastPart && input.isLastMessage })}</p>`; } return renderStep({ step: part.step, isLoading: input.isLoading, markdownRenderer: input.markdownRenderer, isLast: isLastPart, }); }, )} ${renderError(message)} ${input.showActions ? renderActions(input, output) : Lit.nothing} </section> `, target); // clang-format on }; export type View = typeof DEFAULT_VIEW; function renderTextAsMarkdown(text: string, markdownRenderer: MarkdownLitRenderer, {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: MarkdownLitRenderer, 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 = '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: MarkdownLitRenderer, isLast: boolean, }): Lit.LitTemplate { const stepClasses = Lit.Directives.classMap({ step: true, empty: !step.thought && !step.code && !step.contextDetails && !step.sideEffect, 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 AiAssistanceModel.AiAgent.ErrorType.UNKNOWN: case AiAssistanceModel.AiAgent.ErrorType.BLOCK: errorMessage = UIStringsNotTranslate.systemError; break; case AiAssistanceModel.AiAgent.ErrorType.MAX_STEPS: errorMessage = UIStringsNotTranslate.maxStepsError; break; case AiAssistanceModel.AiAgent.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 renderImageChatMessage(inlineData: Host.AidaClient.MediaBlob): Lit.LitTemplate { if (inlineData.data === AiAssistanceModel.AiConversation.NOT_FOUND_IMAGE_DATA) { // clang-format off return html`<div class="unavailable-image" title=${UIStringsNotTranslate.imageUnavailable}> <devtools-icon name='file-image'></devtools-icon> </div>`; // clang-format on } const imageUrl = `data:${inlineData.mimeType};base64,${inlineData.data}`; // clang-format off return html`<devtools-link class="image-link" title=${UIStringsNotTranslate.openImageInNewTab} href=${imageUrl} > <img src=${imageUrl} alt=${UIStringsNotTranslate.imageInputSentToTheModel} /> </devtools-link>`; // clang-format on } function renderActions(input: ChatMessageViewInput, output: ViewOutput): Lit.LitTemplate { // clang-format off return html` <div class="ai-assistance-feedback-row"> <div class="action-buttons"> ${input.showRateButtons ? html` <devtools-button .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'thumb-up', toggledIconName: 'thumb-up-filled', toggled: input.currentRating === Host.AidaClient.Rating.POSITIVE, toggleType: Buttons.Button.ToggleType.PRIMARY, title: lockedString(UIStringsNotTranslate.thumbsUp), jslogContext: 'thumbs-up', } as Buttons.Button.ButtonData} @click=${() => input.onRatingClick(Host.AidaClient.Rating.POSITIVE)} ></devtools-button> <devtools-button .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'thumb-down', toggledIconName: 'thumb-down-filled', toggled: input.currentRating === Host.AidaClient.Rating.NEGATIVE, toggleType: Buttons.Button.ToggleType.PRIMARY, title: lockedString(UIStringsNotTranslate.thumbsDown), jslogContext: 'thumbs-down', } as Buttons.Button.ButtonData} @click=${() => input.onRatingClick(Host.AidaClient.Rating.NEGATIVE)} ></devtools-button> <div class="vertical-separator"></div> `: Lit.nothing} <devtools-button .data=${ { variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, title: lockedString(UIStringsNotTranslate.report), iconName: 'report', jslogContext: 'report', } as Buttons.Button.ButtonData } @click=${input.onReportClick} ></devtools-button> <div class="vertical-separator"></div> <devtools-button .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, title: lockedString(UIStringsNotTranslate.copyResponse), iconName: 'copy', jslogContext: 'copy-ai-response', } as Buttons.Button.ButtonData} aria-label=${lockedString(UIStringsNotTranslate.copyResponse)} @click=${input.onCopyResponseClick}></devtools-button> </div> ${input.suggestions ? html`<div class="suggestions-container"> <div class="scroll-button-container left hidden" ${ref(element => { output.suggestionsLeftScrollButtonContainer = element; } )}> <devtools-button class='scroll-button' .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'chevron-left', title: lockedString(UIStringsNotTranslate.scrollToPrevious), jslogContext: 'chevron-left', } as Buttons.Button.ButtonData} @click=${() => input.scrollSuggestionsScrollContainer('left')} ></devtools-button> </div> <div class="suggestions-scroll-container" @scroll=${input.onSuggestionsScrollOrResize} ${ref(element => { output.suggestionsScrollContainer = element; })}> ${input.suggestions.map(suggestion => html`<devtools-button class='suggestion' .data=${{ variant: Buttons.Button.Variant.OUTLINED, title: suggestion, jslogContext: 'suggestion', } as Buttons.Button.ButtonData} @click=${() => input.onSuggestionClick(suggestion)} >${suggestion}</devtools-button>`)} </div> <div class="scroll-button-container right hidden" ${ref(element => { output.suggestionsRightScrollButtonContainer = element; })}> <devtools-button class='scroll-button' .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'chevron-right', title: lockedString(UIStringsNotTranslate.scrollToNext), jslogContext: 'chevron-right', } as Buttons.Button.ButtonData} @click=${() => input.scrollSuggestionsScrollContainer('right')} ></devtools-button> </div> </div>` : Lit.nothing} </div> ${input.isShowingFeedbackForm ? html` <form class="feedback-form" @submit=${input.onSubmit}> <div class="feedback-header"> <h4 class="feedback-title">${lockedString( UIStringsNotTranslate.whyThisRating, )}</h4> <devtools-button aria-label=${lockedString(UIStringsNotTranslate.close)} @click=${input.onClose} .data=${ { variant: Buttons.Button.Variant.ICON, iconName: 'cross', size: Buttons.Button.Size.SMALL, title: lockedString(UIStringsNotTranslate.close), jslogContext: 'close', } as Buttons.Button.ButtonData } ></devtools-button> </div> <input type="text" class="devtools-text-input feedback-input" @input=${(event: KeyboardEvent) => input.onInputChange((event.target as HTMLInputElement).value)} placeholder=${lockedString( UIStringsNotTranslate.provideFeedbackPlaceholder, )} jslog=${VisualLogging.textField('feedback').track({ keydown: 'Enter' })} > <span class="feedback-disclaimer">${ lockedString(UIStringsNotTranslate.disclaimer) }</span> <div> <devtools-button aria-label=${lockedString(UIStringsNotTranslate.submit)} .data=${ { type: 'submit', disabled: input.isSubmitButtonDisabled, variant: Buttons.Button.Variant.OUTLINED, size: Buttons.Button.Size.SMALL, title: lockedString(UIStringsNotTranslate.submit), jslogContext: 'send', } as Buttons.Button.ButtonData } >${ lockedString(UIStringsNotTranslate.submit) }</devtools-button> </div> </div> </form> ` : Lit.nothing} `; // clang-format on } export class ChatMessage extends UI.Widget.Widget { message: Message = {entity: ChatMessageEntity.USER, text: ''}; isLoading = false; isReadOnly = false; canShowFeedbackForm = false; isLastMessage = false; userInfo: Pick<Host.InspectorFrontendHostAPI.SyncInformation, 'accountImage'|'accountFullName'|'accountGivenName'> = {}; markdownRenderer!: MarkdownLitRenderer; onSuggestionClick: (suggestion: string) => void = () => {}; onFeedbackSubmit: (rpcId: Host.AidaClient.RpcGlobalId, rate: Host.AidaClient.Rating, feedback?: string) => void = () => {}; onCopyResponseClick: (message: ModelChatMessage) => void = () => {}; #suggestionsResizeObserver = new ResizeObserver(() => this.#handleSuggestionsScrollOrResize()); #suggestionsEvaluateLayoutThrottler = new Common.Throttler.Throttler(50); #feedbackValue = ''; #currentRating: Host.AidaClient.Rating|undefined; #isShowingFeedbackForm = false; #isSubmitButtonDisabled = true; #view: View; #viewOutput: ViewOutput = {}; constructor(element?: HTMLElement, view?: View) { super(element); this.#view = view ?? DEFAULT_VIEW; } override wasShown(): void { super.wasShown(); void this.performUpdate(); this.#evaluateSuggestionsLayout(); if (this.#viewOutput.suggestionsScrollContainer) { this.#suggestionsResizeObserver.observe(this.#viewOutput.suggestionsScrollContainer); } } override performUpdate(): Promise<void>|void { this.#view( { message: this.message, isLoading: this.isLoading, isReadOnly: this.isReadOnly, canShowFeedbackForm: this.canShowFeedbackForm, userInfo: this.userInfo, markdownRenderer: this.markdownRenderer, isLastMessage: this.isLastMessage, onSuggestionClick: this.onSuggestionClick, onRatingClick: this.#handleRateClick.bind(this), onReportClick: () => UIHelpers.openInNewTab(REPORT_URL), onCopyResponseClick: () => { if (this.message.entity === ChatMessageEntity.MODEL) { this.onCopyResponseClick(this.message); } }, scrollSuggestionsScrollContainer: this.#scrollSuggestionsScrollContainer.bind(this), onSuggestionsScrollOrResize: this.#handleSuggestionsScrollOrResize.bind(this), onSubmit: this.#handleSubmit.bind(this), onClose: this.#handleClose.bind(this), onInputChange: this.#handleInputChange.bind(this), isSubmitButtonDisabled: this.#isSubmitButtonDisabled, // Props for actions logic showActions: !(this.isLastMessage && this.isLoading), showRateButtons: this.message.entity === ChatMessageEntity.MODEL && !!this.message.rpcId, suggestions: (this.isLastMessage && this.message.entity === ChatMessageEntity.MODEL && !this.isReadOnly && this.message.parts.at(-1)?.type === 'answer') ? (this.message.parts.at(-1) as AnswerPart).suggestions : undefined, currentRating: this.#currentRating, isShowingFeedbackForm: this.#isShowingFeedbackForm, onFeedbackSubmit: this.onFeedbackSubmit, }, this.#viewOutput, this.contentElement); } #handleInputChange(value: string): void { this.#feedbackValue = value; const disableSubmit = !value; if (disableSubmit !== this.#isSubmitButtonDisabled) { this.#isSubmitButtonDisabled = disableSubmit; void this.performUpdate(); } } #evaluateSuggestionsLayout = (): void => { const suggestionsScrollContainer = this.#viewOutput.suggestionsScrollContainer; const leftScrollButtonContainer = this.#viewOutput.suggestionsLeftScrollButtonContainer; const rightScrollButtonContainer = this.#viewOutput.suggestionsRightScrollButtonContainer; if (!suggestionsScrollContainer || !leftScrollButtonContainer || !rightScrollButtonContainer) { return; } const shouldShowLeftButton = suggestionsScrollContainer.scrollLeft > SCROLL_ROUNDING_OFFSET; const shouldShowRightButton = suggestionsScrollContainer.scrollLeft + (suggestionsScrollContainer as HTMLElement).offsetWidth + SCROLL_ROUNDING_OFFSET < suggestionsScrollContainer.scrollWidth; leftScrollButtonContainer.classList.toggle('hidden', !shouldShowLeftButton); rightScrollButtonContainer.classList.toggle('hidden', !shouldShowRightButton); }; override willHide(): void { super.willHide(); this.#suggestionsResizeObserver.disconnect(); } #handleSuggestionsScrollOrResize(): void { void this.#suggestionsEvaluateLayoutThrottler.schedule(() => { this.#evaluateSuggestionsLayout(); return Promise.resolve(); }); } #scrollSuggestionsScrollContainer(direction: 'left'|'right'): void { const suggestionsScrollContainer = this.#viewOutput.suggestionsScrollContainer; if (!suggestionsScrollContainer) { return; } suggestionsScrollContainer.scroll({ top: 0, left: direction === 'left' ? suggestionsScrollContainer.scrollLeft - suggestionsScrollContainer.clientWidth : suggestionsScrollContainer.scrollLeft + suggestionsScrollContainer.clientWidth, behavior: 'smooth', }); } #handleRateClick(rating: Host.AidaClient.Rating): void { if (this.#currentRating === rating) { this.#currentRating = undefined; this.#isShowingFeedbackForm = false; this.#isSubmitButtonDisabled = true; // This effectively reset the user rating if (this.message.entity === ChatMessageEntity.MODEL && this.message.rpcId) { this.onFeedbackSubmit(this.message.rpcId, Host.AidaClient.Rating.SENTIMENT_UNSPECIFIED); } void this.performUpdate(); return; } this.#currentRating = rating; this.#isShowingFeedbackForm = this.canShowFeedbackForm; if (this.message.entity === ChatMessageEntity.MODEL && this.message.rpcId) { this.onFeedbackSubmit(this.message.rpcId, rating); } void this.performUpdate(); } #handleClose(): void { this.#isShowingFeedbackForm = false; this.#isSubmitButtonDisabled = true; void this.performUpdate(); } #handleSubmit(ev: SubmitEvent): void { ev.preventDefault(); const input = this.#feedbackValue; if (!this.#currentRating || !input) { return; } if (this.message.entity === ChatMessageEntity.MODEL && this.message.rpcId) { this.onFeedbackSubmit(this.message.rpcId, this.#currentRating, input); } this.#isShowingFeedbackForm = false; this.#isSubmitButtonDisabled = true; void this.performUpdate(); } }