From 942fd58eb9e0f01aceea8359b0fe239fe3455e91 Mon Sep 17 00:00:00 2001 From: Oksana Volodkevych Date: Mon, 20 Jan 2025 18:00:22 +0100 Subject: [PATCH] feat: [POC-2] Migration of chat agent UI business logic --- .vscode/launch.json | 3 +- chat-client/src/client/chat.ts | 43 +- chat-client/src/client/connector.ts | 8 + chat-client/src/client/connectorAdapter.ts | 77 ++ chat-client/src/client/mynahUi.ts | 28 +- chat-client/src/client/tabs/tabFactory.ts | 18 +- chat-client/src/index.ts | 1 + .../src/ui/apps/amazonqCommonsConnector.ts | 156 +++++ chat-client/src/ui/apps/baseConnector.ts | 298 ++++++++ chat-client/src/ui/apps/cwChatConnector.ts | 148 ++++ chat-client/src/ui/apps/docChatConnector.ts | 229 ++++++ .../src/ui/apps/featureDevChatConnector.ts | 215 ++++++ chat-client/src/ui/apps/gumbyChatConnector.ts | 191 +++++ chat-client/src/ui/apps/scanChatConnector.ts | 202 ++++++ chat-client/src/ui/apps/testChatConnector.ts | 246 +++++++ chat-client/src/ui/commands.ts | 46 ++ chat-client/src/ui/connector.ts | 655 ++++++++++++++++++ chat-client/src/ui/diffTree/actions.ts | 66 ++ chat-client/src/ui/diffTree/types.ts | 11 + chat-client/src/ui/feedback/constants.ts | 31 + chat-client/src/ui/followUps/generator.ts | 88 +++ chat-client/src/ui/followUps/handler.ts | 196 ++++++ chat-client/src/ui/followUps/model.ts | 11 + chat-client/src/ui/main.ts | 306 ++++++++ chat-client/src/ui/messages/controller.ts | 144 ++++ chat-client/src/ui/messages/handler.ts | 52 ++ chat-client/src/ui/quickActions/generator.ts | 172 +++++ chat-client/src/ui/quickActions/handler.ts | 387 +++++++++++ chat-client/src/ui/storages/tabsStorage.ts | 170 +++++ chat-client/src/ui/tabs/constants.ts | 72 ++ chat-client/src/ui/tabs/generator.ts | 69 ++ chat-client/src/ui/telemetry/actions.ts | 38 + chat-client/src/ui/texts/constants.ts | 71 ++ chat-client/src/ui/texts/disclaimer.ts | 20 + chat-client/src/ui/walkthrough/agent.ts | 201 ++++++ chat-client/src/ui/walkthrough/welcome.ts | 47 ++ client/vscode/package.json | 1 + client/vscode/src/chatActivation.ts | 110 ++- package-lock.json | 1 + 39 files changed, 4798 insertions(+), 30 deletions(-) create mode 100644 chat-client/src/client/connector.ts create mode 100644 chat-client/src/client/connectorAdapter.ts create mode 100644 chat-client/src/ui/apps/amazonqCommonsConnector.ts create mode 100644 chat-client/src/ui/apps/baseConnector.ts create mode 100644 chat-client/src/ui/apps/cwChatConnector.ts create mode 100644 chat-client/src/ui/apps/docChatConnector.ts create mode 100644 chat-client/src/ui/apps/featureDevChatConnector.ts create mode 100644 chat-client/src/ui/apps/gumbyChatConnector.ts create mode 100644 chat-client/src/ui/apps/scanChatConnector.ts create mode 100644 chat-client/src/ui/apps/testChatConnector.ts create mode 100644 chat-client/src/ui/commands.ts create mode 100644 chat-client/src/ui/connector.ts create mode 100644 chat-client/src/ui/diffTree/actions.ts create mode 100644 chat-client/src/ui/diffTree/types.ts create mode 100644 chat-client/src/ui/feedback/constants.ts create mode 100644 chat-client/src/ui/followUps/generator.ts create mode 100644 chat-client/src/ui/followUps/handler.ts create mode 100644 chat-client/src/ui/followUps/model.ts create mode 100644 chat-client/src/ui/main.ts create mode 100644 chat-client/src/ui/messages/controller.ts create mode 100644 chat-client/src/ui/messages/handler.ts create mode 100644 chat-client/src/ui/quickActions/generator.ts create mode 100644 chat-client/src/ui/quickActions/handler.ts create mode 100644 chat-client/src/ui/storages/tabsStorage.ts create mode 100644 chat-client/src/ui/tabs/constants.ts create mode 100644 chat-client/src/ui/tabs/generator.ts create mode 100644 chat-client/src/ui/telemetry/actions.ts create mode 100644 chat-client/src/ui/texts/constants.ts create mode 100644 chat-client/src/ui/texts/disclaimer.ts create mode 100644 chat-client/src/ui/walkthrough/agent.ts create mode 100644 chat-client/src/ui/walkthrough/welcome.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 3c19150d12..86b7300139 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -90,8 +90,7 @@ "ENABLE_CUSTOMIZATIONS": "true" // "HTTPS_PROXY": "http://127.0.0.1:8888", // "AWS_CA_BUNDLE": "/path/to/cert.pem" - }, - "preLaunchTask": "npm: compile" + } }, { "name": "CodeWhisperer Server IAM", diff --git a/chat-client/src/client/chat.ts b/chat-client/src/client/chat.ts index b759516a1d..97eb2c2cf4 100644 --- a/chat-client/src/client/chat.ts +++ b/chat-client/src/client/chat.ts @@ -45,6 +45,7 @@ import { ServerMessage, TELEMETRY, TelemetryParams } from '../contracts/serverCo import { Messager, OutboundChatApi } from './messager' import { InboundChatApi, createMynahUi } from './mynahUi' import { TabFactory } from './tabs/tabFactory' +import { Connector } from './connector' const DEFAULT_TAB_DATA = { tabTitle: 'Chat', @@ -57,7 +58,9 @@ type ChatClientConfig = Pick & { discla export const createChat = ( clientApi: { postMessage: (msg: UiMessage | ServerMessage) => void }, - config?: ChatClientConfig + config?: ChatClientConfig, + connectorsConfig?: ChatClientConfig, // legacy + connector?: Connector // legacy ) => { // eslint-disable-next-line semi let mynahApi: InboundChatApi @@ -67,11 +70,20 @@ export const createChat = ( } const handleMessage = (event: MessageEvent): void => { + console.log('Received message from IDE: ', event.data) + if (event.data === undefined) { return } const message = event.data + // NOTE: Route incoming events to legacy connector + if (message?.sender && connector) { + const connectorEvent = new MessageEvent('message', { data: JSON.stringify(message) }) + connector.handleMessageReceive(connectorEvent) + return + } + switch (message?.command) { case CHAT_REQUEST_METHOD: mynahApi.addChatResponse(message.params, message.tabId, message.isPartialResult) @@ -87,18 +99,13 @@ export const createChat = ( break case CHAT_OPTIONS: { const params = (message as ChatOptionsMessage).params - const chatConfig: ChatClientConfig = params?.quickActions?.quickActionsCommandGroups - ? { - quickActionCommands: params.quickActions.quickActionsCommandGroups, - disclaimerAcknowledged: config?.disclaimerAcknowledged ?? false, - } - : { disclaimerAcknowledged: config?.disclaimerAcknowledged ?? false } - - tabFactory.updateDefaultTabData(chatConfig) + if (params?.quickActions?.quickActionsCommandGroups) { + tabFactory.updateQuickActionCommands(params?.quickActions?.quickActionsCommandGroups) + } const allExistingTabs: MynahUITabStoreModel = mynahUi.getAllTabs() for (const tabId in allExistingTabs) { - mynahUi.updateStore(tabId, chatConfig) + mynahUi.updateStore(tabId, tabFactory.getDefaultTabData()) } break } @@ -164,12 +171,18 @@ export const createChat = ( } const messager = new Messager(chatApi) - const tabFactory = new TabFactory({ - ...DEFAULT_TAB_DATA, - ...(config?.quickActionCommands ? { quickActionCommands: config.quickActionCommands } : {}), - }) + const tabFactory = new TabFactory(DEFAULT_TAB_DATA, [ + ...(config?.quickActionCommands ? config.quickActionCommands : []), + ...(connectorsConfig?.quickActionCommands ? connectorsConfig.quickActionCommands : []), + ]) - const [mynahUi, api] = createMynahUi(messager, tabFactory, config?.disclaimerAcknowledged ?? false) + const [mynahUi, api] = createMynahUi( + messager, + tabFactory, + config?.disclaimerAcknowledged ?? false, + connector, + clientApi.postMessage + ) mynahApi = api diff --git a/chat-client/src/client/connector.ts b/chat-client/src/client/connector.ts new file mode 100644 index 0000000000..69ac6544e9 --- /dev/null +++ b/chat-client/src/client/connector.ts @@ -0,0 +1,8 @@ +import { ChatPrompt, MynahUI } from '@aws/mynah-ui' + +export interface Connector { + createIdeConnector(mynahUIRef: { mynahUI: MynahUI | undefined }, ideApiPostMessage: (msg: any) => void): any // TODO: return type + isSupportedTab(tabId: string): boolean + handleMessageReceive(message: MessageEvent): void + handleQuickAction(prompt: ChatPrompt, tabId: string, eventId: string | undefined): void +} diff --git a/chat-client/src/client/connectorAdapter.ts b/chat-client/src/client/connectorAdapter.ts new file mode 100644 index 0000000000..61443e3da9 --- /dev/null +++ b/chat-client/src/client/connectorAdapter.ts @@ -0,0 +1,77 @@ +import { MynahUI, MynahUIProps } from '@aws/mynah-ui' +import { Connector } from './connector' + +export const connectorAdapter = ( + mynahUiProps: MynahUIProps, + mynahUIRef: { mynahUI: MynahUI | undefined }, + ideApiPostMessage: (msg: any) => void, + connector: Connector +): MynahUIProps => { + const ideConnector = connector.createIdeConnector(mynahUIRef, ideApiPostMessage) + + const connectorMynahUiProps: MynahUIProps = { + ...mynahUiProps, + onChatPrompt(tabId, prompt, eventId) { + if (connector.isSupportedTab(tabId)) { + ideConnector.requestAnswer(tabId, { + chatMessage: prompt.prompt ?? '', + }) + return + } + + if (prompt.command?.trim() === '/transform') { + // NOTE: Just an example, needs to be extended + // getQuickActionHandler().handle(prompt, tabId, eventId) + connector.handleQuickAction(prompt, tabId, eventId) + return + } + + mynahUiProps.onChatPrompt?.(tabId, prompt, eventId) + }, + onInBodyButtonClicked(tabId, messageId, action, eventId) { + if (connector.isSupportedTab(tabId)) { + ideConnector.onCustomFormAction(tabId, messageId, action, eventId) + return + } + + mynahUiProps.onInBodyButtonClicked?.(tabId, messageId, action, eventId) + }, + onCustomFormAction(tabId, action, eventId) { + if (connector.isSupportedTab(tabId)) { + ideConnector.onCustomFormAction(tabId, undefined, action) + return + } + + mynahUiProps.onCustomFormAction?.(tabId, action, eventId) + }, + onTabRemove(tabId) { + if (connector.isSupportedTab(tabId)) { + ideConnector.onTabRemove(tabId) + return + } + + mynahUiProps.onTabRemove?.(tabId) + }, + onTabChange(tabId) { + if (connector.isSupportedTab(tabId)) { + ideConnector.onTabChange(tabId) + return + } + + mynahUiProps.onTabChange?.(tabId) + }, + onLinkClick(tabId, messageId, link, mouseEvent) { + if (connector.isSupportedTab(tabId)) { + mouseEvent?.preventDefault() + mouseEvent?.stopPropagation() + mouseEvent?.stopImmediatePropagation() + ideConnector.onResponseBodyLinkClick(tabId, messageId, link) + return + } + + mynahUiProps.onLinkClick?.(tabId, messageId, link, mouseEvent) + }, + } + + return connectorMynahUiProps +} diff --git a/chat-client/src/client/mynahUi.ts b/chat-client/src/client/mynahUi.ts index 0dcf7ba17a..33d3bd15b7 100644 --- a/chat-client/src/client/mynahUi.ts +++ b/chat-client/src/client/mynahUi.ts @@ -20,11 +20,21 @@ import { LinkClickParams, SourceLinkClickParams, } from '@aws/language-server-runtimes-types' -import { ChatItem, ChatItemType, ChatPrompt, MynahUI, MynahUIDataModel, NotificationType } from '@aws/mynah-ui' +import { + ChatItem, + ChatItemType, + ChatPrompt, + MynahUI, + MynahUIDataModel, + NotificationType, + MynahUIProps, +} from '@aws/mynah-ui' import { VoteParams } from '../contracts/telemetry' import { Messager } from './messager' import { TabFactory } from './tabs/tabFactory' import { disclaimerAcknowledgeButtonId, disclaimerCard } from './texts/disclaimer' +import { connectorAdapter } from './connectorAdapter' +import { Connector } from './connector' export interface InboundChatApi { addChatResponse(params: ChatResult, tabId: string, isPartialResult: boolean): void @@ -87,12 +97,14 @@ export const handleChatPrompt = ( export const createMynahUi = ( messager: Messager, tabFactory: TabFactory, - disclaimerAcknowledged: boolean + disclaimerAcknowledged: boolean, + connector?: Connector, + connectorsPostMessage?: (msg: any) => void ): [MynahUI, InboundChatApi] => { const initialTabId = TabFactory.generateUniqueId() let disclaimerCardActive = !disclaimerAcknowledged - const mynahUi = new MynahUI({ + let mynahUiProps: MynahUIProps = { onCodeInsertToCursorPosition( tabId, messageId, @@ -271,7 +283,15 @@ export const createMynahUi = ( maxTabs: 10, texts: uiComponentsTexts, }, - }) + } + + const mynahUiRef = { mynahUI: undefined as MynahUI | undefined } + if (connector && connectorsPostMessage) { + mynahUiProps = connectorAdapter(mynahUiProps, mynahUiRef, connectorsPostMessage, connector) + } + + const mynahUi = new MynahUI(mynahUiProps) + mynahUiRef.mynahUI = mynahUi const getTabStore = (tabId = mynahUi.getSelectedTabId()) => { return tabId ? mynahUi.getAllTabs()[tabId]?.store : undefined diff --git a/chat-client/src/client/tabs/tabFactory.ts b/chat-client/src/client/tabs/tabFactory.ts index b772ad30b1..ac2f0d7d4f 100644 --- a/chat-client/src/client/tabs/tabFactory.ts +++ b/chat-client/src/client/tabs/tabFactory.ts @@ -1,4 +1,4 @@ -import { ChatItemType, MynahUIDataModel } from '@aws/mynah-ui' +import { ChatItemType, MynahUIDataModel, QuickActionCommandGroup } from '@aws/mynah-ui' import { disclaimerCard } from '../texts/disclaimer' export type DefaultTabData = MynahUIDataModel @@ -11,11 +11,14 @@ export class TabFactory { return `000${firstPart.toString(36)}`.slice(-3) + `000${secondPart.toString(36)}`.slice(-3) } - constructor(private defaultTabData: DefaultTabData) {} + constructor( + private defaultTabData: DefaultTabData, + private quickActionCommands?: QuickActionCommandGroup[] + ) {} public createTab(needWelcomeMessages: boolean, disclaimerCardActive: boolean): MynahUIDataModel { const tabData: MynahUIDataModel = { - ...this.defaultTabData, + ...this.getDefaultTabData(), chatItems: needWelcomeMessages ? [ { @@ -35,12 +38,15 @@ export class TabFactory { return tabData } - public updateDefaultTabData(defaultTabData: DefaultTabData) { - this.defaultTabData = { ...this.defaultTabData, ...defaultTabData } + public updateQuickActionCommands(quickActionCommands: QuickActionCommandGroup[]) { + this.quickActionCommands = [...(this.quickActionCommands ?? []), ...quickActionCommands] } public getDefaultTabData(): DefaultTabData { - return this.defaultTabData + return { + ...this.defaultTabData, + ...(this.quickActionCommands ? { quickActionCommands: this.quickActionCommands } : {}), + } } private getWelcomeBlock() { diff --git a/chat-client/src/index.ts b/chat-client/src/index.ts index 45a60e7cd9..a8faa3bed7 100644 --- a/chat-client/src/index.ts +++ b/chat-client/src/index.ts @@ -1 +1,2 @@ export { createChat } from './client/chat' +export { createConnectorAdapter } from './ui/main' diff --git a/chat-client/src/ui/apps/amazonqCommonsConnector.ts b/chat-client/src/ui/apps/amazonqCommonsConnector.ts new file mode 100644 index 0000000000..fd4901654e --- /dev/null +++ b/chat-client/src/ui/apps/amazonqCommonsConnector.ts @@ -0,0 +1,156 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItem, ChatItemAction, ChatItemType, ChatPrompt } from '@aws/mynah-ui' +import { ExtensionMessage } from '../commands' +import { AuthFollowUpType } from '../followUps/generator' +import { getTabCommandFromTabType, isTabType, TabType } from '../storages/tabsStorage' +import { + docUserGuide, + userGuideURL as featureDevUserGuide, + helpMessage, + reviewGuideUrl, + testGuideUrl, +} from '../texts/constants' +// import { linkToDocsHome } from '../../../../codewhisperer/models/constants' +import { createClickTelemetry, createOpenAgentTelemetry } from '../telemetry/actions' + +export type WelcomeFollowupType = 'continue-to-chat' + +export const linkToDocsHome = 'https://docs.aws.amazon.com/amazonq/latest/aws-builder-use-ug/code-transformation.html' + +export interface ConnectorProps { + sendMessageToExtension: (message: ExtensionMessage) => void + onWelcomeFollowUpClicked: (tabID: string, welcomeFollowUpType: WelcomeFollowupType) => void + onNewTab: (tabType: TabType) => void + handleCommand: (chatPrompt: ChatPrompt, tabId: string) => void + sendStaticMessages: (tabID: string, messages: ChatItem[]) => void +} +export interface CodeReference { + licenseName?: string + repository?: string + url?: string + recommendationContentSpan?: { + start?: number + end?: number + } +} + +export class Connector { + private readonly sendMessageToExtension + private readonly onWelcomeFollowUpClicked + private readonly onNewTab + private readonly handleCommand + private readonly sendStaticMessage + + constructor(props: ConnectorProps) { + this.sendMessageToExtension = props.sendMessageToExtension + this.onWelcomeFollowUpClicked = props.onWelcomeFollowUpClicked + this.onNewTab = props.onNewTab + this.handleCommand = props.handleCommand + this.sendStaticMessage = props.sendStaticMessages + } + + followUpClicked = (tabID: string, followUp: ChatItemAction): void => { + if (followUp.type !== undefined && followUp.type === 'continue-to-chat') { + this.onWelcomeFollowUpClicked(tabID, followUp.type) + } + } + + authFollowUpClicked = (tabID: string, tabType: string, authType: AuthFollowUpType): void => { + this.sendMessageToExtension({ + command: 'auth-follow-up-was-clicked', + authType, + tabID, + tabType, + }) + } + + handleMessageReceive = async (messageData: any): Promise => { + if (messageData.command === 'showExploreAgentsView') { + this.onNewTab('agentWalkthrough') + return + } else if (messageData.command === 'review') { + this.onNewTab('review') + return + } + } + + onCustomFormAction( + tabId: string, + action: { + id: string + text?: string | undefined + formItemValues?: Record | undefined + } + ) { + const tabType = action.id.split('-')[2] + if (!isTabType(tabType)) { + return + } + + if (action.id.startsWith('user-guide-')) { + this.processUserGuideLink(tabType, action.id) + return + } + + if (action.id.startsWith('quick-start-')) { + this.handleCommand( + { + command: getTabCommandFromTabType(tabType), + }, + tabId + ) + + this.sendMessageToExtension(createOpenAgentTelemetry(tabType, 'quick-start')) + } + } + + private processUserGuideLink(tabType: TabType, actionId: string) { + let userGuideLink = '' + switch (tabType) { + case 'featuredev': + userGuideLink = featureDevUserGuide + break + case 'testgen': + userGuideLink = testGuideUrl + break + case 'review': + userGuideLink = reviewGuideUrl + break + case 'doc': + userGuideLink = docUserGuide + break + case 'gumby': + userGuideLink = linkToDocsHome + break + } + + // e.g. amazonq-explore-user-guide-featuredev + this.sendMessageToExtension(createClickTelemetry(`amazonq-explore-${actionId}`)) + + this.sendMessageToExtension({ + command: 'open-user-guide', + userGuideLink, + }) + } + + sendMessage(tabID: string, message: 'help') { + switch (message) { + case 'help': + this.sendStaticMessage(tabID, [ + { + type: ChatItemType.PROMPT, + body: 'How can Amazon Q help me?', + }, + { + type: ChatItemType.ANSWER, + body: helpMessage, + }, + ]) + break + } + } +} diff --git a/chat-client/src/ui/apps/baseConnector.ts b/chat-client/src/ui/apps/baseConnector.ts new file mode 100644 index 0000000000..e28c599d91 --- /dev/null +++ b/chat-client/src/ui/apps/baseConnector.ts @@ -0,0 +1,298 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItem, ChatItemAction, ChatItemType, FeedbackPayload } from '@aws/mynah-ui' +import { ExtensionMessage } from '../commands' +import { CodeReference } from './amazonqCommonsConnector' +import { TabOpenType, TabsStorage, TabType } from '../storages/tabsStorage' +import { FollowUpGenerator } from '../followUps/generator' +import { CWCChatItem } from '../connector' + +interface ChatPayload { + chatMessage: string + chatCommand?: string +} + +export interface BaseConnectorProps { + sendMessageToExtension: (message: ExtensionMessage) => void + onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void + onChatAnswerReceived?: (tabID: string, message: CWCChatItem | ChatItem, messageData: any) => void + onError: (tabID: string, message: string, title: string) => void + onWarning: (tabID: string, message: string, title: string) => void + onOpenSettingsMessage: (tabID: string) => void + tabsStorage: TabsStorage +} + +export abstract class BaseConnector { + protected readonly sendMessageToExtension + protected readonly onError + protected readonly onWarning + protected readonly onChatAnswerReceived + protected readonly onOpenSettingsMessage + protected readonly followUpGenerator: FollowUpGenerator + protected readonly tabsStorage + + abstract getTabType(): TabType + + constructor(props: BaseConnectorProps) { + this.sendMessageToExtension = props.sendMessageToExtension + this.onChatAnswerReceived = props.onChatAnswerReceived + this.onWarning = props.onWarning + this.onError = props.onError + this.onOpenSettingsMessage = props.onOpenSettingsMessage + this.tabsStorage = props.tabsStorage + this.followUpGenerator = new FollowUpGenerator() + } + + onResponseBodyLinkClick = (tabID: string, messageId: string, link: string): void => { + this.sendMessageToExtension({ + command: 'response-body-link-click', + tabID, + messageId, + link, + tabType: this.getTabType(), + }) + } + onInfoLinkClick = (tabID: string, link: string): void => { + this.sendMessageToExtension({ + command: 'footer-info-link-click', + tabID, + link, + tabType: this.getTabType(), + }) + } + + followUpClicked = (tabID: string, messageId: string, followUp: ChatItemAction): void => { + /** + * We've pressed on a followup button and should start watching that round trip telemetry + */ + this.sendMessageToExtension({ + command: 'start-chat-message-telemetry', + trigger: 'followUpClicked', + tabID, + traceId: messageId, + tabType: this.getTabType(), + startTime: Date.now(), + }) + this.sendMessageToExtension({ + command: 'follow-up-was-clicked', + followUp, + tabID, + messageId, + tabType: this.getTabType(), + }) + } + + onTabAdd = (tabID: string, tabOpenInteractionType?: TabOpenType): void => { + this.sendMessageToExtension({ + tabID: tabID, + command: 'new-tab-was-created', + tabType: this.getTabType(), + tabOpenInteractionType, + }) + } + + onCodeInsertToCursorPosition = ( + tabID: string, + messageId: string, + code?: string, + type?: 'selection' | 'block', + codeReference?: CodeReference[], + eventId?: string, + codeBlockIndex?: number, + totalCodeBlocks?: number, + userIntent?: string, + codeBlockLanguage?: string + ): void => { + this.sendMessageToExtension({ + tabID: tabID, + messageId, + code, + command: 'insert_code_at_cursor_position', + tabType: this.getTabType(), + insertionTargetType: type, + codeReference, + eventId, + codeBlockIndex, + totalCodeBlocks, + userIntent, + codeBlockLanguage, + }) + } + + onCopyCodeToClipboard = ( + tabID: string, + messageId: string, + code?: string, + type?: 'selection' | 'block', + codeReference?: CodeReference[], + eventId?: string, + codeBlockIndex?: number, + totalCodeBlocks?: number, + userIntent?: string, + codeBlockLanguage?: string + ): void => { + this.sendMessageToExtension({ + tabID: tabID, + messageId, + code, + command: 'code_was_copied_to_clipboard', + tabType: this.getTabType(), + insertionTargetType: type, + codeReference, + eventId, + codeBlockIndex, + totalCodeBlocks, + userIntent, + codeBlockLanguage, + }) + } + + onTabRemove = (tabID: string): void => { + this.sendMessageToExtension({ + tabID: tabID, + command: 'tab-was-removed', + tabType: this.getTabType(), + }) + } + + onTabChange = (tabID: string, prevTabID?: string) => { + this.sendMessageToExtension({ + tabID: tabID, + command: 'tab-was-changed', + tabType: this.getTabType(), + prevTabID, + }) + } + + onStopChatResponse = (tabID: string): void => { + this.sendMessageToExtension({ + tabID: tabID, + command: 'stop-response', + tabType: this.getTabType(), + }) + } + + onChatItemVoted = (tabID: string, messageId: string, vote: 'upvote' | 'downvote'): void => { + this.sendMessageToExtension({ + tabID: tabID, + command: 'chat-item-voted', + messageId, + vote, + tabType: this.getTabType(), + }) + } + onSendFeedback = (tabID: string, feedbackPayload: FeedbackPayload): void | undefined => { + this.sendMessageToExtension({ + command: 'chat-item-feedback', + ...feedbackPayload, + tabType: this.getTabType(), + tabID: tabID, + }) + } + + requestGenerativeAIAnswer = (tabID: string, messageId: string, payload: ChatPayload): Promise => { + /** + * When a user presses "enter" send an event that indicates + * we should start tracking the round trip time for this message + **/ + this.sendMessageToExtension({ + command: 'start-chat-message-telemetry', + trigger: 'onChatPrompt', + tabID, + traceId: messageId, + tabType: this.getTabType(), + startTime: Date.now(), + }) + return new Promise((resolve, reject) => { + this.sendMessageToExtension({ + tabID: tabID, + command: 'chat-prompt', + chatMessage: payload.chatMessage, + chatCommand: payload.chatCommand, + tabType: this.getTabType(), + }) + }) + } + + clearChat = (tabID: string): void => { + this.sendMessageToExtension({ + tabID: tabID, + command: 'clear', + chatMessage: '', + tabType: this.getTabType(), + }) + } + + help = (tabID: string): void => { + this.sendMessageToExtension({ + tabID: tabID, + command: 'help', + chatMessage: '', + tabType: this.getTabType(), + }) + } + + onTabOpen = (tabID: string): void => { + this.sendMessageToExtension({ + tabID, + command: 'new-tab-was-created', + tabType: this.getTabType(), + }) + } + + protected sendTriggerMessageProcessed = async (requestID: any): Promise => { + this.sendMessageToExtension({ + command: 'trigger-message-processed', + requestID: requestID, + tabType: this.getTabType(), + }) + } + + protected processAuthNeededException = async (messageData: any): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.ANSWER, + messageId: messageData.triggerID, + body: messageData.message, + followUp: this.followUpGenerator.generateAuthFollowUps(this.getTabType(), messageData.authType), + canBeVoted: false, + }, + messageData + ) + + return + } + + protected processOpenSettingsMessage = async (messageData: any): Promise => { + this.onOpenSettingsMessage(messageData.tabID) + } + + protected baseHandleMessageReceive = async (messageData: any): Promise => { + if (messageData.type === 'errorMessage') { + this.onError(messageData.tabID, messageData.message, messageData.title) + return + } + if (messageData.type === 'showInvalidTokenNotification') { + this.onWarning(messageData.tabID, messageData.message, messageData.title) + return + } + + if (messageData.type === 'authNeededException') { + await this.processAuthNeededException(messageData) + return + } + + if (messageData.type === 'openSettingsMessage') { + await this.processOpenSettingsMessage(messageData) + return + } + } +} diff --git a/chat-client/src/ui/apps/cwChatConnector.ts b/chat-client/src/ui/apps/cwChatConnector.ts new file mode 100644 index 0000000000..58f525857a --- /dev/null +++ b/chat-client/src/ui/apps/cwChatConnector.ts @@ -0,0 +1,148 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItemType } from '@aws/mynah-ui' +import { TabType } from '../storages/tabsStorage' +import { CWCChatItem } from '../connector' +import { BaseConnector, BaseConnectorProps } from './baseConnector' + +export interface ConnectorProps extends BaseConnectorProps { + onCWCContextCommandMessage: (message: CWCChatItem, command?: string) => string | undefined +} + +export class Connector extends BaseConnector { + private readonly onCWCContextCommandMessage + + override getTabType(): TabType { + return 'cwc' + } + + constructor(props: ConnectorProps) { + super(props) + this.onCWCContextCommandMessage = props.onCWCContextCommandMessage + } + + onSourceLinkClick = (tabID: string, messageId: string, link: string): void => { + this.sendMessageToExtension({ + command: 'source-link-click', + tabID, + messageId, + link, + tabType: this.getTabType(), + }) + } + + private processEditorContextCommandMessage = async (messageData: any): Promise => { + const triggerTabID = this.onCWCContextCommandMessage( + { + body: messageData.message, + type: ChatItemType.PROMPT, + }, + messageData.command + ) + await this.sendTriggerTabIDReceived( + messageData.triggerID, + triggerTabID !== undefined ? triggerTabID : 'no-available-tabs' + ) + } + + private sendTriggerTabIDReceived = async (triggerID: string, tabID: string): Promise => { + this.sendMessageToExtension({ + command: 'trigger-tabID-received', + triggerID, + tabID, + tabType: this.getTabType(), + }) + } + + private processChatMessage = async (messageData: any): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + if ( + messageData.message !== undefined || + messageData.relatedSuggestions !== undefined || + messageData.codeReference !== undefined + ) { + const followUps = + messageData.followUps !== undefined && messageData.followUps.length > 0 + ? { + text: messageData.followUpsHeader ?? 'Suggested follow up questions:', + options: messageData.followUps, + } + : undefined + + const answer: CWCChatItem = { + type: messageData.messageType, + messageId: messageData.messageID ?? messageData.triggerID, + body: messageData.message, + followUp: followUps, + canBeVoted: true, + codeReference: messageData.codeReference, + userIntent: messageData.userIntent, + codeBlockLanguage: messageData.codeBlockLanguage, + } + + // If it is not there we will not set it + if (messageData.messageType === 'answer-part' || messageData.messageType === 'answer') { + answer.canBeVoted = true + } + + if (messageData.relatedSuggestions !== undefined) { + answer.relatedContent = { + title: 'Sources', + content: messageData.relatedSuggestions, + } + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + + // Exit the function if we received an answer from AI + if ( + messageData.messageType === ChatItemType.SYSTEM_PROMPT || + messageData.messageType === ChatItemType.AI_PROMPT + ) { + await this.sendTriggerMessageProcessed(messageData.requestID) + } + + return + } + if (messageData.messageType === ChatItemType.ANSWER) { + const answer: CWCChatItem = { + type: messageData.messageType, + body: undefined, + relatedContent: undefined, + messageId: messageData.messageID, + codeReference: messageData.codeReference, + userIntent: messageData.userIntent, + codeBlockLanguage: messageData.codeBlockLanguage, + followUp: + messageData.followUps !== undefined && messageData.followUps.length > 0 + ? { + text: 'Suggested follow up questions:', + options: messageData.followUps, + } + : undefined, + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + + return + } + } + + handleMessageReceive = async (messageData: any): Promise => { + if (messageData.type === 'chatMessage') { + await this.processChatMessage(messageData) + return + } + + if (messageData.type === 'editorContextCommandMessage') { + await this.processEditorContextCommandMessage(messageData) + return + } + + // For other message types, call the base class handleMessageReceive + await this.baseHandleMessageReceive(messageData) + } +} diff --git a/chat-client/src/ui/apps/docChatConnector.ts b/chat-client/src/ui/apps/docChatConnector.ts new file mode 100644 index 0000000000..efabe2be4f --- /dev/null +++ b/chat-client/src/ui/apps/docChatConnector.ts @@ -0,0 +1,229 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItem, ChatItemType, FeedbackPayload, MynahIcons, ProgressField } from '@aws/mynah-ui' +import { TabType } from '../storages/tabsStorage' +import { DiffTreeFileInfo } from '../diffTree/types' +import { BaseConnectorProps, BaseConnector } from './baseConnector' + +export interface ConnectorProps extends BaseConnectorProps { + onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string) => void + sendFeedback?: (tabId: string, feedbackPayload: FeedbackPayload) => void | undefined + onFileComponentUpdate: ( + tabID: string, + filePaths: DiffTreeFileInfo[], + deletedFiles: DiffTreeFileInfo[], + messageId: string, + disableFileActions: boolean + ) => void + onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string) => void + onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void + onUpdatePromptProgress: (tabID: string, progressField: ProgressField) => void + onChatInputEnabled: (tabID: string, enabled: boolean) => void + onUpdateAuthentication: (featureDevEnabled: boolean, authenticatingTabIDs: string[]) => void + onNewTab: (tabType: TabType) => void +} + +export class Connector extends BaseConnector { + private readonly onFileComponentUpdate + private readonly onAsyncEventProgress + private readonly updatePlaceholder + private readonly chatInputEnabled + private readonly onUpdateAuthentication + private readonly onNewTab + private readonly updatePromptProgress + + override getTabType(): TabType { + return 'doc' + } + + constructor(props: ConnectorProps) { + super(props) + this.onFileComponentUpdate = props.onFileComponentUpdate + this.onAsyncEventProgress = props.onAsyncEventProgress + this.updatePlaceholder = props.onUpdatePlaceholder + this.chatInputEnabled = props.onChatInputEnabled + this.onUpdateAuthentication = props.onUpdateAuthentication + this.onNewTab = props.onNewTab + this.updatePromptProgress = props.onUpdatePromptProgress + } + + onOpenDiff = (tabID: string, filePath: string, deleted: boolean): void => { + this.sendMessageToExtension({ + command: 'open-diff', + tabID, + filePath, + deleted, + tabType: this.getTabType(), + }) + } + onFileActionClick = (tabID: string, messageId: string, filePath: string, actionName: string): void => { + this.sendMessageToExtension({ + command: 'file-click', + tabID, + messageId, + filePath, + actionName, + tabType: this.getTabType(), + }) + } + + private processFolderConfirmationMessage = async (messageData: any, folderPath: string): Promise => { + if (this.onChatAnswerReceived !== undefined) { + const answer: ChatItem = { + type: ChatItemType.ANSWER, + body: messageData.message ?? undefined, + messageId: messageData.messageID ?? messageData.triggerID ?? '', + fileList: { + rootFolderTitle: undefined, + fileTreeTitle: '', + filePaths: [folderPath], + details: { + [folderPath]: { + icon: MynahIcons.FOLDER, + clickable: false, + }, + }, + }, + followUp: { + text: '', + options: messageData.followUps, + }, + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + + private processChatMessage = async (messageData: any): Promise => { + if (this.onChatAnswerReceived !== undefined) { + const answer: ChatItem = { + type: messageData.messageType, + body: messageData.message ?? undefined, + messageId: messageData.messageID ?? messageData.triggerID ?? '', + relatedContent: undefined, + canBeVoted: messageData.canBeVoted, + snapToTop: messageData.snapToTop, + followUp: + messageData.followUps !== undefined && messageData.followUps.length > 0 + ? { + text: + messageData.messageType === ChatItemType.SYSTEM_PROMPT + ? '' + : 'Select one of the following...', + options: messageData.followUps, + } + : undefined, + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + + private processCodeResultMessage = async (messageData: any): Promise => { + if (this.onChatAnswerReceived !== undefined) { + const answer: ChatItem = { + type: ChatItemType.ANSWER, + relatedContent: undefined, + followUp: undefined, + canBeVoted: false, + codeReference: messageData.references, + // TODO get the backend to store a message id in addition to conversationID + messageId: + messageData.codeGenerationId ?? + messageData.messageID ?? + messageData.triggerID ?? + messageData.conversationID, + fileList: { + rootFolderTitle: 'Documentation', + fileTreeTitle: 'Documents ready', + filePaths: messageData.filePaths.map((f: DiffTreeFileInfo) => f.zipFilePath), + deletedFiles: messageData.deletedFiles.map((f: DiffTreeFileInfo) => f.zipFilePath), + }, + body: '', + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + + handleMessageReceive = async (messageData: any): Promise => { + if (messageData.type === 'updateFileComponent') { + this.onFileComponentUpdate( + messageData.tabID, + messageData.filePaths, + messageData.deletedFiles, + messageData.messageId, + messageData.disableFileActions + ) + return + } + + if (messageData.type === 'chatMessage') { + await this.processChatMessage(messageData) + return + } + + if (messageData.type === 'folderConfirmationMessage') { + await this.processFolderConfirmationMessage(messageData, messageData.folderPath) + return + } + + if (messageData.type === 'codeResultMessage') { + await this.processCodeResultMessage(messageData) + return + } + + if (messageData.type === 'asyncEventProgressMessage') { + this.onAsyncEventProgress(messageData.tabID, messageData.inProgress, messageData.message ?? undefined) + return + } + + if (messageData.type === 'updatePlaceholderMessage') { + this.updatePlaceholder(messageData.tabID, messageData.newPlaceholder) + return + } + + if (messageData.type === 'chatInputEnabledMessage') { + this.chatInputEnabled(messageData.tabID, messageData.enabled) + return + } + + if (messageData.type === 'authenticationUpdateMessage') { + this.onUpdateAuthentication(messageData.featureEnabled, messageData.authenticatingTabIDs) + return + } + + if (messageData.type === 'openNewTabMessage') { + this.onNewTab(this.getTabType()) + return + } + + if (messageData.type === 'updatePromptProgress') { + this.updatePromptProgress(messageData.tabID, messageData.progressField) + return + } + + // For other message types, call the base class handleMessageReceive + await this.baseHandleMessageReceive(messageData) + } + + onCustomFormAction( + tabId: string, + action: { + id: string + text?: string | undefined + formItemValues?: Record | undefined + } + ) { + if (action === undefined) { + return + } + this.sendMessageToExtension({ + command: 'form-action-click', + action: action.id, + formSelectedValues: action.formItemValues, + tabType: 'doc', + tabID: tabId, + }) + } +} diff --git a/chat-client/src/ui/apps/featureDevChatConnector.ts b/chat-client/src/ui/apps/featureDevChatConnector.ts new file mode 100644 index 0000000000..69eb9f1c71 --- /dev/null +++ b/chat-client/src/ui/apps/featureDevChatConnector.ts @@ -0,0 +1,215 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItem, ChatItemType, FeedbackPayload } from '@aws/mynah-ui' +import { TabType } from '../storages/tabsStorage' +import { getActions } from '../diffTree/actions' +import { DiffTreeFileInfo } from '../diffTree/types' +import { BaseConnector, BaseConnectorProps } from './baseConnector' + +export interface ConnectorProps extends BaseConnectorProps { + onAsyncEventProgress: ( + tabID: string, + inProgress: boolean, + message: string, + messageId: string | undefined, + enableStopAction: boolean + ) => void + onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void + sendFeedback?: (tabId: string, feedbackPayload: FeedbackPayload) => void | undefined + onFileComponentUpdate: ( + tabID: string, + filePaths: DiffTreeFileInfo[], + deletedFiles: DiffTreeFileInfo[], + messageId: string, + disableFileActions: boolean + ) => void + onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string) => void + onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void + onChatInputEnabled: (tabID: string, enabled: boolean) => void + onUpdateAuthentication: (featureDevEnabled: boolean, authenticatingTabIDs: string[]) => void + onNewTab: (tabType: TabType) => void +} + +export class Connector extends BaseConnector { + private readonly onFileComponentUpdate + private readonly onChatAnswerUpdated + private readonly onAsyncEventProgress + private readonly updatePlaceholder + private readonly chatInputEnabled + private readonly onUpdateAuthentication + private readonly onNewTab + + override getTabType(): TabType { + return 'featuredev' + } + + constructor(props: ConnectorProps) { + super(props) + this.onFileComponentUpdate = props.onFileComponentUpdate + this.onAsyncEventProgress = props.onAsyncEventProgress + this.updatePlaceholder = props.onUpdatePlaceholder + this.chatInputEnabled = props.onChatInputEnabled + this.onUpdateAuthentication = props.onUpdateAuthentication + this.onNewTab = props.onNewTab + this.onChatAnswerUpdated = props.onChatAnswerUpdated + } + + onOpenDiff = (tabID: string, filePath: string, deleted: boolean, messageId?: string): void => { + this.sendMessageToExtension({ + command: 'open-diff', + tabID, + filePath, + deleted, + messageId, + tabType: this.getTabType(), + }) + } + onFileActionClick = (tabID: string, messageId: string, filePath: string, actionName: string): void => { + this.sendMessageToExtension({ + command: 'file-click', + tabID, + messageId, + filePath, + actionName, + tabType: this.getTabType(), + }) + } + + private createAnswer = (messageData: any): ChatItem => { + return { + type: messageData.messageType, + body: messageData.message ?? undefined, + messageId: messageData.messageId ?? messageData.messageID ?? messageData.triggerID ?? '', + relatedContent: undefined, + canBeVoted: messageData.canBeVoted ?? undefined, + snapToTop: messageData.snapToTop ?? undefined, + followUp: + messageData.followUps !== undefined && Array.isArray(messageData.followUps) + ? { + text: + messageData.messageType === ChatItemType.SYSTEM_PROMPT || + messageData.followUps.length === 0 + ? '' + : 'Please follow up with one of these', + options: messageData.followUps, + } + : undefined, + } + } + + private processChatMessage = async (messageData: any): Promise => { + if (this.onChatAnswerReceived !== undefined) { + const answer = this.createAnswer(messageData) + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + + private processCodeResultMessage = async (messageData: any): Promise => { + if (this.onChatAnswerReceived !== undefined) { + const messageId = + messageData.codeGenerationId ?? + messageData.messageId ?? + messageData.messageID ?? + messageData.triggerID ?? + messageData.conversationID + this.sendMessageToExtension({ + tabID: messageData.tabID, + command: 'store-code-result-message-id', + messageId, + tabType: 'featuredev', + }) + const actions = getActions([...messageData.filePaths, ...messageData.deletedFiles]) + const answer: ChatItem = { + type: ChatItemType.ANSWER, + relatedContent: undefined, + followUp: undefined, + canBeVoted: true, + codeReference: messageData.references, + messageId, + fileList: { + rootFolderTitle: 'Changes', + filePaths: messageData.filePaths.map((f: DiffTreeFileInfo) => f.zipFilePath), + deletedFiles: messageData.deletedFiles.map((f: DiffTreeFileInfo) => f.zipFilePath), + actions, + }, + body: '', + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + + handleMessageReceive = async (messageData: any): Promise => { + if (messageData.type === 'updateFileComponent') { + this.onFileComponentUpdate( + messageData.tabID, + messageData.filePaths, + messageData.deletedFiles, + messageData.messageId, + messageData.disableFileActions + ) + return + } + if (messageData.type === 'updateChatAnswer') { + const answer = this.createAnswer(messageData) + this.onChatAnswerUpdated?.(messageData.tabID, answer) + return + } + + if (messageData.type === 'chatMessage') { + await this.processChatMessage(messageData) + return + } + + if (messageData.type === 'codeResultMessage') { + await this.processCodeResultMessage(messageData) + return + } + + if (messageData.type === 'asyncEventProgressMessage') { + const enableStopAction = true + this.onAsyncEventProgress( + messageData.tabID, + messageData.inProgress, + messageData.message ?? undefined, + messageData.messageId ?? undefined, + enableStopAction + ) + return + } + + if (messageData.type === 'updatePlaceholderMessage') { + this.updatePlaceholder(messageData.tabID, messageData.newPlaceholder) + return + } + + if (messageData.type === 'chatInputEnabledMessage') { + this.chatInputEnabled(messageData.tabID, messageData.enabled) + return + } + + if (messageData.type === 'authenticationUpdateMessage') { + this.onUpdateAuthentication(messageData.featureEnabled, messageData.authenticatingTabIDs) + return + } + + if (messageData.type === 'openNewTabMessage') { + this.onNewTab('featuredev') + return + } + + // For other message types, call the base class handleMessageReceive + await this.baseHandleMessageReceive(messageData) + } + + sendFeedback = (tabId: string, feedbackPayload: FeedbackPayload): void | undefined => { + this.sendMessageToExtension({ + command: 'chat-item-feedback', + ...feedbackPayload, + tabType: this.getTabType(), + tabID: tabId, + }) + } +} diff --git a/chat-client/src/ui/apps/gumbyChatConnector.ts b/chat-client/src/ui/apps/gumbyChatConnector.ts new file mode 100644 index 0000000000..5b1eb3ae40 --- /dev/null +++ b/chat-client/src/ui/apps/gumbyChatConnector.ts @@ -0,0 +1,191 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * This class is responsible for listening to and processing events + * from the webview and translating them into events to be handled by the extension, + * and events from the extension and translating them into events to be handled by the webview. + */ + +import { ChatItem, ChatItemType } from '@aws/mynah-ui' +import { TabType } from '../storages/tabsStorage' +// import { GumbyMessageType } from '../../../../amazonqGumby/chat/views/connector/connector' +import { ChatPayload } from '../connector' +import { BaseConnector, BaseConnectorProps } from './baseConnector' + +export type GumbyMessageType = + | 'errorMessage' + | 'asyncEventProgressMessage' + | 'authenticationUpdateMessage' + | 'authNeededException' + | 'chatPrompt' + | 'chatMessage' + | 'chatInputEnabledMessage' + | 'sendCommandMessage' + | 'updatePlaceholderMessage' + +export interface ConnectorProps extends BaseConnectorProps { + onAsyncEventProgress: ( + tabID: string, + inProgress: boolean, + message: string, + messageId: string, + enableStopAction: boolean + ) => void + onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void + onQuickHandlerCommand: (tabID: string, command: string, eventId?: string) => void + onUpdateAuthentication: (gumbyEnabled: boolean, authenticatingTabIDs: string[]) => void + onChatInputEnabled: (tabID: string, enabled: boolean) => void + onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void +} + +export interface MessageData { + tabID: string + type: GumbyMessageType +} + +export class Connector extends BaseConnector { + private readonly onAuthenticationUpdate + private readonly onChatAnswerUpdated + private readonly chatInputEnabled + private readonly onAsyncEventProgress + private readonly onQuickHandlerCommand + private readonly updatePlaceholder + + override getTabType(): TabType { + return 'gumby' + } + + constructor(props: ConnectorProps) { + super(props) + this.onChatAnswerUpdated = props.onChatAnswerUpdated + this.chatInputEnabled = props.onChatInputEnabled + this.onAsyncEventProgress = props.onAsyncEventProgress + this.updatePlaceholder = props.onUpdatePlaceholder + this.onQuickHandlerCommand = props.onQuickHandlerCommand + this.onAuthenticationUpdate = props.onUpdateAuthentication + } + + private processChatPrompt = async (messageData: any, tabID: string): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + + const answer: ChatItem = { + type: ChatItemType.AI_PROMPT, + body: messageData.message, + formItems: messageData.formItems, + buttons: messageData.formButtons, + followUp: undefined, + status: 'info', + canBeVoted: false, + } + + this.onChatAnswerReceived(tabID, answer, messageData) + + return + } + + private processChatMessage = async (messageData: any): Promise => { + if (this.onChatAnswerReceived === undefined || this.onChatAnswerUpdated === undefined) { + return + } + + if (messageData.message !== undefined) { + const answer: ChatItem = { + type: messageData.messageType, + messageId: messageData.messageId ?? messageData.triggerID, + body: messageData.message, + buttons: messageData.buttons ?? [], + canBeVoted: false, + } + + if (messageData.messageId !== undefined) { + this.onChatAnswerUpdated(messageData.tabID, answer) + return + } + + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + + transform = (tabID: string): void => { + this.sendMessageToExtension({ + tabID: tabID, + command: 'transform', + chatMessage: 'transform', + tabType: this.getTabType(), + }) + } + + requestAnswer = (tabID: string, payload: ChatPayload) => { + this.tabsStorage.updateTabStatus(tabID, 'busy') + this.sendMessageToExtension({ + tabID: tabID, + command: 'chat-prompt', + chatMessage: payload.chatMessage, + chatCommand: payload.chatCommand, + tabType: this.getTabType(), + }) + } + + onCustomFormAction( + tabId: string, + action: { + id: string + text?: string | undefined + formItemValues?: Record | undefined + } + ) { + if (action === undefined) { + return + } + + this.sendMessageToExtension({ + command: 'form-action-click', + action: action.id, + formSelectedValues: action.formItemValues, + tabType: this.getTabType(), + tabID: tabId, + }) + } + + private processExecuteCommand = async (messageData: any): Promise => { + this.onQuickHandlerCommand(messageData.tabID, messageData.command, messageData.eventId) + } + + // This handles messages received from the extension, to be forwarded to the webview + handleMessageReceive = async (messageData: { type: GumbyMessageType } & Record) => { + switch (messageData.type) { + case 'asyncEventProgressMessage': + this.onAsyncEventProgress( + messageData.tabID, + messageData.inProgress, + messageData.message, + messageData.messageId, + false + ) + break + case 'authenticationUpdateMessage': + this.onAuthenticationUpdate(messageData.gumbyEnabled, messageData.authenticatingTabIDs) + break + case 'chatInputEnabledMessage': + this.chatInputEnabled(messageData.tabID, messageData.enabled) + break + case 'chatMessage': + await this.processChatMessage(messageData) + break + case 'chatPrompt': + await this.processChatPrompt(messageData, messageData.tabID) + break + case 'sendCommandMessage': + await this.processExecuteCommand(messageData) + break + case 'updatePlaceholderMessage': + this.updatePlaceholder(messageData.tabID, messageData.newPlaceholder) + break + default: + await this.baseHandleMessageReceive(messageData) + } + } +} diff --git a/chat-client/src/ui/apps/scanChatConnector.ts b/chat-client/src/ui/apps/scanChatConnector.ts new file mode 100644 index 0000000000..4ab815dbe1 --- /dev/null +++ b/chat-client/src/ui/apps/scanChatConnector.ts @@ -0,0 +1,202 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * This class is responsible for listening to and processing events + * from the webview and translating them into events to be handled by the extension, + * and events from the extension and translating them into events to be handled by the webview. + */ + +import { ChatItem, ChatItemType, ProgressField } from '@aws/mynah-ui' +import { ExtensionMessage } from '../commands' +import { TabsStorage, TabType } from '../storages/tabsStorage' +// import { ScanMessageType } from '../../../../amazonqScan/connector' +import { BaseConnector, BaseConnectorProps } from './baseConnector' + +export type ScanMessageType = + | 'authenticationUpdateMessage' + | 'authNeededException' + | 'chatMessage' + | 'chatInputEnabledMessage' + | 'sendCommandMessage' + | 'updatePlaceholderMessage' + | 'updatePromptProgress' + | 'chatPrompt' + | 'errorMessage' + +export interface ConnectorProps extends BaseConnectorProps { + sendMessageToExtension: (message: ExtensionMessage) => void + onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void + onQuickHandlerCommand: (tabID: string, command: string, eventId?: string) => void + onWarning: (tabID: string, message: string, title: string) => void + onError: (tabID: string, message: string, title: string) => void + onUpdateAuthentication: (scanEnabled: boolean, authenticatingTabIDs: string[]) => void + onChatInputEnabled: (tabID: string, enabled: boolean) => void + onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void + onUpdatePromptProgress: (tabID: string, progressField: ProgressField) => void + tabsStorage: TabsStorage +} + +export interface MessageData { + tabID: string + type: ScanMessageType +} + +export class Connector extends BaseConnector { + override getTabType(): TabType { + return 'review' + } + readonly onAuthenticationUpdate + override readonly sendMessageToExtension + override readonly onError + override readonly onChatAnswerReceived + private readonly chatInputEnabled + private readonly onQuickHandlerCommand + private readonly updatePlaceholder + private readonly updatePromptProgress + + constructor(props: ConnectorProps) { + super(props) + this.sendMessageToExtension = props.sendMessageToExtension + this.onChatAnswerReceived = props.onChatAnswerReceived + this.onError = props.onError + this.chatInputEnabled = props.onChatInputEnabled + this.updatePlaceholder = props.onUpdatePlaceholder + this.updatePromptProgress = props.onUpdatePromptProgress + this.onQuickHandlerCommand = props.onQuickHandlerCommand + this.onAuthenticationUpdate = props.onUpdateAuthentication + } + + scan = (tabID: string): void => { + this.sendMessageToExtension({ + tabID: tabID, + command: 'review', + chatMessage: '', + tabType: 'review', + }) + } + + onCustomFormAction( + tabId: string, + action: { + id: string + text?: string | undefined + formItemValues?: Record | undefined + } + ) { + if (action === undefined) { + return + } + this.sendMessageToExtension({ + command: 'form-action-click', + action: action.id, + formSelectedValues: action.formItemValues, + tabType: 'review', + tabID: tabId, + }) + } + + private processChatPrompt = async (messageData: any, tabID: string): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + const answer: ChatItem = { + type: ChatItemType.PROMPT, + body: messageData.message, + followUp: undefined, + status: 'info', + canBeVoted: false, + } + this.onChatAnswerReceived(tabID, answer, messageData) + return + } + + private processExecuteCommand = async (messageData: any): Promise => { + this.onQuickHandlerCommand(messageData.tabID, messageData.command, messageData.eventId) + } + + private processChatMessage = async (messageData: any): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + + if (messageData.message !== undefined) { + const answer: ChatItem = { + type: messageData.messageType, + messageId: messageData.messageId ?? messageData.triggerID, + body: messageData.message, + buttons: messageData.buttons ?? [], + canBeVoted: messageData.canBeVoted, + followUp: + messageData.followUps !== undefined && messageData.followUps.length > 0 + ? { + text: '', + options: messageData.followUps, + } + : undefined, + informationCard: messageData.informationCard, + fileList: messageData.fileList, + } + + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + + override processAuthNeededException = async (messageData: any): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.SYSTEM_PROMPT, + body: messageData.message, + }, + messageData + ) + } + + // This handles messages received from the extension, to be forwarded to the webview + handleMessageReceive = async (messageData: { type: ScanMessageType } & Record) => { + switch (messageData.type) { + case 'authNeededException': + await this.processAuthNeededException(messageData) + break + case 'authenticationUpdateMessage': + this.onAuthenticationUpdate(messageData.scanEnabled, messageData.authenticatingTabIDs) + break + case 'chatInputEnabledMessage': + this.chatInputEnabled(messageData.tabID, messageData.enabled) + break + case 'chatMessage': + await this.processChatMessage(messageData) + break + case 'updatePlaceholderMessage': + this.updatePlaceholder(messageData.tabID, messageData.newPlaceholder) + break + case 'updatePromptProgress': + this.updatePromptProgress(messageData.tabID, messageData.progressField) + break + case 'chatPrompt': + await this.processChatPrompt(messageData, messageData.tabID) + break + case 'errorMessage': + this.onError(messageData.tabID, messageData.message, messageData.title) + break + case 'sendCommandMessage': + await this.processExecuteCommand(messageData) + break + } + } + + onFileClick = (tabID: string, filePath: string, messageId?: string) => { + this.sendMessageToExtension({ + command: 'file-click', + tabID, + messageId, + filePath, + tabType: 'review', + }) + } +} diff --git a/chat-client/src/ui/apps/testChatConnector.ts b/chat-client/src/ui/apps/testChatConnector.ts new file mode 100644 index 0000000000..cc3c2dd521 --- /dev/null +++ b/chat-client/src/ui/apps/testChatConnector.ts @@ -0,0 +1,246 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * This class is responsible for listening to and processing events + * from the webview and translating them into events to be handled by the extension, + * and events from the extension and translating them into events to be handled by the webview. + */ + +import { ChatItem, ChatItemType, MynahIcons, ProgressField } from '@aws/mynah-ui' +import { ExtensionMessage } from '../commands' +import { TabsStorage, TabType } from '../storages/tabsStorage' +// import { TestMessageType } from '../../../../amazonqTest/chat/views/connector/connector' +import { ChatPayload } from '../connector' +import { BaseConnector, BaseConnectorProps } from './baseConnector' + +export type TestMessageType = + | 'authenticationUpdateMessage' + | 'authNeededException' + | 'chatMessage' + | 'chatInputEnabledMessage' + | 'updatePlaceholderMessage' + | 'errorMessage' + | 'updatePromptProgress' + | 'chatSummaryMessage' + | 'buildProgressMessage' + +export interface ConnectorProps extends BaseConnectorProps { + sendMessageToExtension: (message: ExtensionMessage) => void + onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void + onRunTestMessageReceived?: (tabID: string, showRunTestMessage: boolean) => void + onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void + onQuickHandlerCommand: (tabID: string, command: string, eventId?: string) => void + onWarning: (tabID: string, message: string, title: string) => void + onError: (tabID: string, message: string, title: string) => void + onUpdateAuthentication: (testEnabled: boolean, authenticatingTabIDs: string[]) => void + onChatInputEnabled: (tabID: string, enabled: boolean) => void + onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void + onUpdatePromptProgress: (tabID: string, progressField: ProgressField) => void + tabsStorage: TabsStorage +} + +export interface MessageData { + tabID: string + type: TestMessageType +} +// TODO: Refactor testChatConnector, scanChatConnector and other apps connector files post RIV +export class Connector extends BaseConnector { + override getTabType(): TabType { + return 'testgen' + } + readonly onAuthenticationUpdate + override readonly sendMessageToExtension + override readonly onChatAnswerReceived + private readonly onChatAnswerUpdated + private readonly chatInputEnabled + private readonly updatePlaceholder + private readonly updatePromptProgress + override readonly onError + private readonly tabStorage + private readonly runTestMessageReceived + + constructor(props: ConnectorProps) { + super(props) + this.runTestMessageReceived = props.onRunTestMessageReceived + this.sendMessageToExtension = props.sendMessageToExtension + this.onChatAnswerReceived = props.onChatAnswerReceived + this.onChatAnswerUpdated = props.onChatAnswerUpdated + this.chatInputEnabled = props.onChatInputEnabled + this.updatePlaceholder = props.onUpdatePlaceholder + this.updatePromptProgress = props.onUpdatePromptProgress + this.onAuthenticationUpdate = props.onUpdateAuthentication + this.onError = props.onError + this.tabStorage = props.tabsStorage + } + + startTestGen(tabID: string, prompt: string) { + this.sendMessageToExtension({ + tabID: tabID, + command: 'start-test-gen', + tabType: 'testgen', + prompt, + }) + } + + requestAnswer = (tabID: string, payload: ChatPayload) => { + this.tabStorage.updateTabStatus(tabID, 'busy') + this.sendMessageToExtension({ + tabID: tabID, + command: 'chat-prompt', + chatMessage: payload.chatMessage, + chatCommand: payload.chatCommand, + tabType: 'testgen', + }) + } + + onCustomFormAction( + tabId: string, + action: { + id: string + text?: string | undefined + description?: string | undefined + formItemValues?: Record | undefined + } + ) { + if (action === undefined) { + return + } + + this.sendMessageToExtension({ + command: 'form-action-click', + action: action.id, + formSelectedValues: action.formItemValues, + tabType: 'testgen', + tabID: tabId, + description: action.description, + }) + } + + onFileDiff = (tabID: string, filePath: string, deleted: boolean, messageId?: string): void => { + // TODO: add this back once we can advance flow from here + // this.sendMessageToExtension({ + // command: 'open-diff', + // tabID, + // filePath, + // deleted, + // messageId, + // tabType: 'testgen', + // }) + } + + private processChatMessage = async (messageData: any): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + if (messageData.command === 'test' && this.runTestMessageReceived) { + this.runTestMessageReceived(messageData.tabID, true) + return + } + if (messageData.message !== undefined) { + const answer: ChatItem = { + type: messageData.messageType, + messageId: messageData.messageId ?? messageData.triggerID, + body: messageData.message, + canBeVoted: false, + informationCard: messageData.informationCard, + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + // Displays the test generation summary message in the /test Tab before generating unit tests + private processChatSummaryMessage = async (messageData: any): Promise => { + if (this.onChatAnswerUpdated === undefined) { + return + } + if (messageData.message !== undefined) { + const answer: ChatItem = { + type: messageData.messageType, + messageId: messageData.messageId ?? messageData.triggerID, + body: messageData.message, + canBeVoted: true, + footer: messageData.filePath + ? { + fileList: { + rootFolderTitle: undefined, + fileTreeTitle: '', + filePaths: [messageData.filePath], + details: { + [messageData.filePath]: { + icon: MynahIcons.FILE, + description: `Generating tests in ${messageData.filePath}`, + }, + }, + }, + } + : {}, + } + this.onChatAnswerUpdated(messageData.tabID, answer) + } + } + + override processAuthNeededException = async (messageData: any): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.SYSTEM_PROMPT, + body: messageData.message, + }, + messageData + ) + } + + private processBuildProgressMessage = async ( + messageData: { type: TestMessageType } & Record + ): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + const answer: ChatItem = { + type: messageData.messageType, + canBeVoted: messageData.canBeVoted, + messageId: messageData.messageId, + followUp: messageData.followUps, + fileList: messageData.fileList, + body: messageData.message, + codeReference: messageData.codeReference, + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + + // This handles messages received from the extension, to be forwarded to the webview + handleMessageReceive = async (messageData: { type: TestMessageType } & Record) => { + switch (messageData.type) { + case 'authNeededException': + await this.processAuthNeededException(messageData) + break + case 'authenticationUpdateMessage': + this.onAuthenticationUpdate(messageData.testEnabled, messageData.authenticatingTabIDs) + break + case 'chatInputEnabledMessage': + this.chatInputEnabled(messageData.tabID, messageData.enabled) + break + case 'chatMessage': + await this.processChatMessage(messageData) + break + case 'chatSummaryMessage': + await this.processChatSummaryMessage(messageData) + break + case 'updatePlaceholderMessage': + this.updatePlaceholder(messageData.tabID, messageData.newPlaceholder) + break + case 'buildProgressMessage': + await this.processBuildProgressMessage(messageData) + break + case 'updatePromptProgress': + this.updatePromptProgress(messageData.tabID, messageData.progressField) + break + case 'errorMessage': + this.onError(messageData.tabID, messageData.message, messageData.title) + } + } +} diff --git a/chat-client/src/ui/commands.ts b/chat-client/src/ui/commands.ts new file mode 100644 index 0000000000..d668cb5d3b --- /dev/null +++ b/chat-client/src/ui/commands.ts @@ -0,0 +1,46 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +type MessageCommand = + | 'chat-prompt' + | 'trigger-message-processed' + | 'new-tab-was-created' + | 'tab-was-removed' + | 'tab-was-changed' + | 'ui-is-ready' + | 'disclaimer-acknowledged' + | 'ui-focus' + | 'follow-up-was-clicked' + | 'auth-follow-up-was-clicked' + | 'open-diff' + | 'code_was_copied_to_clipboard' + | 'insert_code_at_cursor_position' + | 'accept_diff' + | 'view_diff' + | 'stop-response' + | 'trigger-tabID-received' + | 'clear' + | 'help' + | 'chat-item-voted' + | 'chat-item-feedback' + | 'link-was-clicked' + | 'onboarding-page-interaction' + | 'source-link-click' + | 'response-body-link-click' + | 'transform' + | 'footer-info-link-click' + | 'file-click' + | 'form-action-click' + | 'open-settings' + | 'start-chat-message-telemetry' + | 'stop-chat-message-telemetry' + | 'store-code-result-message-id' + | 'start-test-gen' + | 'review' + | 'open-user-guide' + | 'send-telemetry' + | 'update-welcome-count' + +export type ExtensionMessage = Record & { command: MessageCommand } diff --git a/chat-client/src/ui/connector.ts b/chat-client/src/ui/connector.ts new file mode 100644 index 0000000000..a0ddb355d8 --- /dev/null +++ b/chat-client/src/ui/connector.ts @@ -0,0 +1,655 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChatItem, + FeedbackPayload, + Engagement, + ChatItemAction, + CodeSelectionType, + ProgressField, + ReferenceTrackerInformation, + ChatPrompt, +} from '@aws/mynah-ui' +import { Connector as CWChatConnector } from './apps/cwChatConnector' +import { Connector as FeatureDevChatConnector } from './apps/featureDevChatConnector' +import { Connector as AmazonQCommonsConnector } from './apps/amazonqCommonsConnector' +import { Connector as GumbyChatConnector } from './apps/gumbyChatConnector' +import { Connector as ScanChatConnector } from './apps/scanChatConnector' +import { Connector as TestChatConnector } from './apps/testChatConnector' +import { Connector as docChatConnector } from './apps/docChatConnector' +import { ExtensionMessage } from './commands' +import { TabType, TabsStorage } from './storages/tabsStorage' +import { WelcomeFollowupType } from './apps/amazonqCommonsConnector' +import { AuthFollowUpType } from './followUps/generator' +import { DiffTreeFileInfo } from './diffTree/types' +import { UserIntent } from '@amzn/codewhisperer-streaming' + +export interface CodeReference { + licenseName?: string + repository?: string + url?: string + recommendationContentSpan?: { + start?: number + end?: number + } +} + +export interface UploadHistory { + [key: string]: { + uploadId: string + timestamp: number + tabId: string + filePaths: DiffTreeFileInfo[] + deletedFiles: DiffTreeFileInfo[] + } +} + +export interface ChatPayload { + chatMessage: string + chatCommand?: string +} + +// Adding userIntent param by extending ChatItem to send userIntent as part of amazonq_interactWithMessage telemetry event +export interface CWCChatItem extends ChatItem { + traceId?: string + userIntent?: UserIntent + codeBlockLanguage?: string +} + +export interface ConnectorProps { + sendMessageToExtension: (message: ExtensionMessage) => void + onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void + onRunTestMessageReceived?: (tabID: string, showRunTestMessage: boolean) => void + onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void + onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void + onWelcomeFollowUpClicked: (tabID: string, welcomeFollowUpType: WelcomeFollowupType) => void + onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string | undefined) => void + onQuickHandlerCommand: (tabID: string, command?: string, eventId?: string) => void + onCWCContextCommandMessage: (message: ChatItem, command?: string) => string | undefined + onOpenSettingsMessage: (tabID: string) => void + onError: (tabID: string, message: string, title: string) => void + onWarning: (tabID: string, message: string, title: string) => void + onFileComponentUpdate: ( + tabID: string, + filePaths: DiffTreeFileInfo[], + deletedFiles: DiffTreeFileInfo[], + messageId: string, + disableFileActions: boolean + ) => void + onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void + onUpdatePromptProgress: (tabID: string, progressField: ProgressField) => void + onChatInputEnabled: (tabID: string, enabled: boolean) => void + onUpdateAuthentication: (featureDevEnabled: boolean, authenticatingTabIDs: string[]) => void + onNewTab: (tabType: TabType) => void + onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string) => void + handleCommand: (chatPrompt: ChatPrompt, tabId: string) => void + sendStaticMessages: (tabID: string, messages: ChatItem[]) => void + tabsStorage: TabsStorage +} + +export class Connector { + private readonly sendMessageToExtension + private readonly onMessageReceived + private readonly cwChatConnector + private readonly featureDevChatConnector + private readonly gumbyChatConnector + private readonly scanChatConnector + private readonly testChatConnector + private readonly docChatConnector + private readonly tabsStorage + private readonly amazonqCommonsConnector: AmazonQCommonsConnector + + isUIReady = false + + constructor(props: ConnectorProps) { + this.sendMessageToExtension = props.sendMessageToExtension + this.onMessageReceived = props.onMessageReceived + this.cwChatConnector = new CWChatConnector(props as ConnectorProps) + this.featureDevChatConnector = new FeatureDevChatConnector(props) + this.docChatConnector = new docChatConnector(props) + this.gumbyChatConnector = new GumbyChatConnector(props) + this.scanChatConnector = new ScanChatConnector(props) + this.testChatConnector = new TestChatConnector(props) + this.amazonqCommonsConnector = new AmazonQCommonsConnector({ + sendMessageToExtension: this.sendMessageToExtension, + onWelcomeFollowUpClicked: props.onWelcomeFollowUpClicked, + onNewTab: props.onNewTab, + handleCommand: props.handleCommand, + sendStaticMessages: props.sendStaticMessages, + }) + this.tabsStorage = props.tabsStorage + } + + onSourceLinkClick = (tabID: string, messageId: string, link: string): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'cwc': + this.cwChatConnector.onSourceLinkClick(tabID, messageId, link) + break + } + } + + onResponseBodyLinkClick = (tabID: string, messageId: string, link: string): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'cwc': + this.cwChatConnector.onResponseBodyLinkClick(tabID, messageId, link) + break + case 'featuredev': + this.featureDevChatConnector.onResponseBodyLinkClick(tabID, messageId, link) + break + case 'gumby': + this.gumbyChatConnector.onResponseBodyLinkClick(tabID, messageId, link) + break + case 'review': + this.scanChatConnector.onResponseBodyLinkClick(tabID, messageId, link) + break + case 'testgen': + this.testChatConnector.onResponseBodyLinkClick(tabID, messageId, link) + break + case 'doc': + this.docChatConnector.onResponseBodyLinkClick(tabID, messageId, link) + } + } + + onInfoLinkClick = (tabID: string, link: string): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + default: + this.cwChatConnector.onInfoLinkClick(tabID, link) + break + } + } + + requestAnswer = (tabID: string, payload: ChatPayload) => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'gumby': + return this.gumbyChatConnector.requestAnswer(tabID, payload) + case 'testgen': + return this.testChatConnector.requestAnswer(tabID, payload) + } + } + + requestGenerativeAIAnswer = (tabID: string, messageId: string, payload: ChatPayload): Promise => + new Promise((resolve, reject) => { + if (this.isUIReady) { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'featuredev': + return this.featureDevChatConnector.requestGenerativeAIAnswer(tabID, messageId, payload) + case 'doc': + return this.docChatConnector.requestGenerativeAIAnswer(tabID, messageId, payload) + default: + return this.cwChatConnector.requestGenerativeAIAnswer(tabID, messageId, payload) + } + } else { + return setTimeout(() => { + return this.requestGenerativeAIAnswer(tabID, messageId, payload) + }, 2000) + } + }) + + clearChat = (tabID: string): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'cwc': + this.cwChatConnector.clearChat(tabID) + break + } + } + + help = (tabID: string): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'cwc': + /** + * TODO remove cwc helper and switch to the generic one + * that welcome uses + */ + this.cwChatConnector.help(tabID) + break + case 'welcome': + this.amazonqCommonsConnector.sendMessage(tabID, 'help') + break + } + } + + startTestGen = (tabID: string, prompt: string): void => { + this.testChatConnector.startTestGen(tabID, prompt) + } + + transform = (tabID: string): void => { + this.gumbyChatConnector.transform(tabID) + } + + scans = (tabID: string): void => { + this.scanChatConnector.scan(tabID) + } + + onStopChatResponse = (tabID: string): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'featuredev': + this.featureDevChatConnector.onStopChatResponse(tabID) + break + case 'cwc': + this.cwChatConnector.onStopChatResponse(tabID) + break + } + } + + handleMessageReceive = async (message: MessageEvent): Promise => { + if (message.data === undefined) { + return + } + // TODO: potential json parsing error exists. Need to determine the failing case. + const messageData = JSON.parse(message.data) + + if (messageData === undefined) { + return + } + + if (messageData.sender === 'CWChat') { + await this.cwChatConnector.handleMessageReceive(messageData) + } else if (messageData.sender === 'featureDevChat') { + await this.featureDevChatConnector.handleMessageReceive(messageData) + } else if (messageData.sender === 'gumbyChat') { + await this.gumbyChatConnector.handleMessageReceive(messageData) + } else if (messageData.sender === 'scanChat') { + await this.scanChatConnector.handleMessageReceive(messageData) + } else if (messageData.sender === 'testChat') { + await this.testChatConnector.handleMessageReceive(messageData) + } else if (messageData.sender === 'docChat') { + await this.docChatConnector.handleMessageReceive(messageData) + } else if (messageData.sender === 'amazonqCore') { + await this.amazonqCommonsConnector.handleMessageReceive(messageData) + } + + // Reset lastCommand after message is rendered. + this.tabsStorage.updateTabLastCommand(messageData.tabID, '') + } + + onTabAdd = (tabID: string): void => { + this.tabsStorage.addTab({ + id: tabID, + type: 'unknown', + status: 'free', + isSelected: true, + }) + } + + onUpdateTabType = (tabID: string) => { + const tab = this.tabsStorage.getTab(tabID) + switch (tab?.type) { + case 'cwc': + this.cwChatConnector.onTabAdd(tabID, tab.openInteractionType) + break + case 'gumby': + this.gumbyChatConnector.onTabAdd(tabID) + break + case 'review': + this.scanChatConnector.onTabAdd(tabID) + break + case 'testgen': + this.testChatConnector.onTabAdd(tabID) + break + } + } + + onKnownTabOpen = (tabID: string): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'featuredev': + this.featureDevChatConnector.onTabOpen(tabID) + break + case 'doc': + this.docChatConnector.onTabOpen(tabID) + break + case 'review': + this.scanChatConnector.onTabOpen(tabID) + break + } + } + + onTabChange = (tabId: string): void => { + const prevTabID = this.tabsStorage.setSelectedTab(tabId) + this.cwChatConnector.onTabChange(tabId, prevTabID) + } + + onCodeInsertToCursorPosition = ( + tabID: string, + messageId: string, + code?: string, + type?: 'selection' | 'block', + codeReference?: CodeReference[], + eventId?: string, + codeBlockIndex?: number, + totalCodeBlocks?: number, + userIntent?: string, + codeBlockLanguage?: string + ): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'cwc': + this.cwChatConnector.onCodeInsertToCursorPosition( + tabID, + messageId, + code, + type, + codeReference, + eventId, + codeBlockIndex, + totalCodeBlocks, + userIntent, + codeBlockLanguage + ) + break + case 'featuredev': + this.featureDevChatConnector.onCodeInsertToCursorPosition( + tabID, + messageId, + code, + type, + codeReference, + eventId, + codeBlockIndex, + totalCodeBlocks, + userIntent, + codeBlockLanguage + ) + break + case 'testgen': + this.testChatConnector.onCodeInsertToCursorPosition(tabID, messageId, code, type, codeReference) + break + } + } + + onAcceptDiff = ( + tabId: string, + messageId: string, + actionId: string, + data?: string, + code?: string, + type?: CodeSelectionType, + referenceTrackerInformation?: ReferenceTrackerInformation[], + eventId?: string, + codeBlockIndex?: number, + totalCodeBlocks?: number + ) => { + const tabType = this.tabsStorage.getTab(tabId)?.type + this.sendMessageToExtension({ + tabType, + tabID: tabId, + command: 'accept_diff', + messageId, + actionId, + data, + code, + type, + referenceTrackerInformation, + eventId, + codeBlockIndex, + totalCodeBlocks, + }) + } + + onViewDiff = ( + tabId: string, + messageId: string, + actionId: string, + data?: string, + code?: string, + type?: CodeSelectionType, + referenceTrackerInformation?: ReferenceTrackerInformation[], + eventId?: string, + codeBlockIndex?: number, + totalCodeBlocks?: number + ) => { + const tabType = this.tabsStorage.getTab(tabId)?.type + this.sendMessageToExtension({ + tabType, + tabID: tabId, + command: 'view_diff', + messageId, + actionId, + data, + code, + type, + referenceTrackerInformation, + eventId, + codeBlockIndex, + totalCodeBlocks, + }) + } + + onCopyCodeToClipboard = ( + tabID: string, + messageId: string, + code?: string, + type?: 'selection' | 'block', + codeReference?: CodeReference[], + eventId?: string, + codeBlockIndex?: number, + totalCodeBlocks?: number, + userIntent?: string, + codeBlockLanguage?: string + ): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'cwc': + this.cwChatConnector.onCopyCodeToClipboard( + tabID, + messageId, + code, + type, + codeReference, + eventId, + codeBlockIndex, + totalCodeBlocks, + userIntent, + codeBlockLanguage + ) + break + case 'featuredev': + this.featureDevChatConnector.onCopyCodeToClipboard( + tabID, + messageId, + code, + type, + codeReference, + eventId, + codeBlockIndex, + totalCodeBlocks, + userIntent, + codeBlockLanguage + ) + break + } + } + + onTabRemove = (tabID: string): void => { + const tab = this.tabsStorage.getTab(tabID) + this.tabsStorage.deleteTab(tabID) + switch (tab?.type) { + case 'cwc': + this.cwChatConnector.onTabRemove(tabID) + break + case 'featuredev': + this.featureDevChatConnector.onTabRemove(tabID) + break + case 'doc': + this.docChatConnector.onTabRemove(tabID) + break + case 'gumby': + this.gumbyChatConnector.onTabRemove(tabID) + break + case 'review': + this.scanChatConnector.onTabRemove(tabID) + break + case 'testgen': + this.testChatConnector.onTabRemove(tabID) + break + } + } + + uiReady = (): void => { + this.isUIReady = true + this.sendMessageToExtension({ + command: 'ui-is-ready', + }) + + if (this.onMessageReceived !== undefined) { + window.addEventListener('message', this.handleMessageReceive.bind(this)) + } + + window.addEventListener('focus', this.handleApplicationFocus) + window.addEventListener('blur', this.handleApplicationFocus) + } + + handleApplicationFocus = async (event: FocusEvent): Promise => { + this.sendMessageToExtension({ + command: 'ui-focus', + type: event.type, + tabType: 'cwc', + }) + } + + triggerSuggestionEngagement = (tabId: string, messageId: string, engagement: Engagement): void => { + // let command: string = 'hoverSuggestion' + // if ( + // engagement.engagementType === EngagementType.INTERACTION && + // engagement.selectionDistanceTraveled?.selectedText !== undefined + // ) { + // command = 'selectSuggestionText' + // } + // this.sendMessageToExtension({ + // command, + // searchId: this.searchId, + // suggestionId: engagement.suggestion.url, + // // suggestionRank: parseInt(engagement.suggestion.id), + // suggestionType: engagement.suggestion.type, + // selectedText: engagement.selectionDistanceTraveled?.selectedText, + // hoverDuration: engagement.engagementDurationTillTrigger / 1000, // seconds + // }) + } + + onAuthFollowUpClicked = (tabID: string, authType: AuthFollowUpType) => { + const tabType = this.tabsStorage.getTab(tabID)?.type + switch (tabType) { + case 'cwc': + case 'doc': + case 'featuredev': + this.amazonqCommonsConnector.authFollowUpClicked(tabID, tabType, authType) + } + } + + onFollowUpClicked = (tabID: string, messageId: string, followUp: ChatItemAction): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + // TODO: We cannot rely on the tabType here, + // It can come up at a later point depending on the future UX designs, + // We should decide it depending on the followUp.type + case 'unknown': + this.amazonqCommonsConnector.followUpClicked(tabID, followUp) + break + case 'featuredev': + this.featureDevChatConnector.followUpClicked(tabID, messageId, followUp) + break + case 'testgen': + this.testChatConnector.followUpClicked(tabID, messageId, followUp) + break + case 'review': + this.scanChatConnector.followUpClicked(tabID, messageId, followUp) + break + case 'doc': + this.docChatConnector.followUpClicked(tabID, messageId, followUp) + break + default: + this.cwChatConnector.followUpClicked(tabID, messageId, followUp) + break + } + } + + onFileActionClick = (tabID: string, messageId: string, filePath: string, actionName: string): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'featuredev': + this.featureDevChatConnector.onFileActionClick(tabID, messageId, filePath, actionName) + break + case 'doc': + this.docChatConnector.onFileActionClick(tabID, messageId, filePath, actionName) + break + } + } + + onFileClick = (tabID: string, filePath: string, deleted: boolean, messageId?: string): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'featuredev': + this.featureDevChatConnector.onOpenDiff(tabID, filePath, deleted, messageId) + break + case 'testgen': + this.testChatConnector.onFileDiff(tabID, filePath, deleted, messageId) + break + case 'review': + this.scanChatConnector.onFileClick(tabID, filePath, messageId) + break + case 'doc': + this.docChatConnector.onOpenDiff(tabID, filePath, deleted) + break + } + } + + sendFeedback = (tabId: string, feedbackPayload: FeedbackPayload): void | undefined => { + switch (this.tabsStorage.getTab(tabId)?.type) { + case 'featuredev': + this.featureDevChatConnector.sendFeedback(tabId, feedbackPayload) + break + case 'cwc': + this.cwChatConnector.onSendFeedback(tabId, feedbackPayload) + break + } + } + + onChatItemVoted = (tabId: string, messageId: string, vote: 'upvote' | 'downvote'): void | undefined => { + switch (this.tabsStorage.getTab(tabId)?.type) { + case 'cwc': + this.cwChatConnector.onChatItemVoted(tabId, messageId, vote) + break + case 'featuredev': + this.featureDevChatConnector.onChatItemVoted(tabId, messageId, vote) + break + case 'review': + this.scanChatConnector.onChatItemVoted(tabId, messageId, vote) + break + case 'testgen': + this.testChatConnector.onChatItemVoted(tabId, messageId, vote) + break + } + } + + onCustomFormAction = ( + tabId: string, + messageId: string | undefined, + action: any, + eventId: string | undefined = undefined + ): void | undefined => { + switch (this.tabsStorage.getTab(tabId)?.type) { + case 'gumby': + this.gumbyChatConnector.onCustomFormAction(tabId, action) + break + case 'testgen': + this.testChatConnector.onCustomFormAction(tabId, action) + break + case 'review': + this.scanChatConnector.onCustomFormAction(tabId, action) + break + case 'doc': + this.docChatConnector.onCustomFormAction(tabId, action) + break + case 'cwc': + if (action.id === `open-settings`) { + this.sendMessageToExtension({ + command: 'open-settings', + type: '', + tabType: 'cwc', + }) + } + break + case 'agentWalkthrough': { + this.amazonqCommonsConnector.onCustomFormAction(tabId, action) + break + } + } + } +} diff --git a/chat-client/src/ui/diffTree/actions.ts b/chat-client/src/ui/diffTree/actions.ts new file mode 100644 index 0000000000..93fa02b37d --- /dev/null +++ b/chat-client/src/ui/diffTree/actions.ts @@ -0,0 +1,66 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MynahIcons } from '@aws/mynah-ui' +import { FileNodeAction, TreeNodeDetails } from '@aws/mynah-ui/dist/static' +import { DiffTreeFileInfo } from './types' +import { uiComponentsTexts } from '../texts/constants' + +export function getDetails(filePaths: DiffTreeFileInfo[]): Record { + const details: Record = {} + for (const filePath of filePaths) { + if (filePath.changeApplied) { + details[filePath.relativePath] = { + status: 'success', + label: uiComponentsTexts.changeAccepted, + icon: MynahIcons.OK, + } + } else if (filePath.rejected) { + details[filePath.relativePath] = { + status: 'error', + label: uiComponentsTexts.changeRejected, + icon: MynahIcons.CANCEL_CIRCLE, + } + } + } + return details +} + +export function getActions(filePaths: DiffTreeFileInfo[]): Record { + const actions: Record = {} + for (const filePath of filePaths) { + if (filePath.changeApplied) { + continue + } + switch (filePath.rejected) { + case true: + actions[filePath.relativePath] = [ + { + icon: MynahIcons.REVERT, + name: 'revert-rejection', + description: uiComponentsTexts.revertRejection, + }, + ] + break + case false: + actions[filePath.relativePath] = [ + { + icon: MynahIcons.OK, + status: 'success', + name: 'accept-change', + description: uiComponentsTexts.acceptChange, + }, + { + icon: MynahIcons.CANCEL_CIRCLE, + status: 'error', + name: 'reject-change', + description: uiComponentsTexts.rejectChange, + }, + ] + break + } + } + return actions +} diff --git a/chat-client/src/ui/diffTree/types.ts b/chat-client/src/ui/diffTree/types.ts new file mode 100644 index 0000000000..ce095b1145 --- /dev/null +++ b/chat-client/src/ui/diffTree/types.ts @@ -0,0 +1,11 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export type DiffTreeFileInfo = { + zipFilePath: string + relativePath: string + rejected: boolean + changeApplied: boolean +} diff --git a/chat-client/src/ui/feedback/constants.ts b/chat-client/src/ui/feedback/constants.ts new file mode 100644 index 0000000000..377453cb59 --- /dev/null +++ b/chat-client/src/ui/feedback/constants.ts @@ -0,0 +1,31 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export const feedbackOptions = [ + { + value: 'inaccurate-response', + label: 'Inaccurate response', + }, + { + value: 'harmful-content', + label: 'Harmful content', + }, + { + value: 'incorrect-syntax', + label: 'Incorrect syntax', + }, + { + value: 'buggy-code', + label: 'Buggy code', + }, + { + value: 'low-quality', + label: 'Low quality', + }, + { + value: 'other', + label: 'Other', + }, +] diff --git a/chat-client/src/ui/followUps/generator.ts b/chat-client/src/ui/followUps/generator.ts new file mode 100644 index 0000000000..cce5726398 --- /dev/null +++ b/chat-client/src/ui/followUps/generator.ts @@ -0,0 +1,88 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MynahIcons } from '@aws/mynah-ui' +import { TabType } from '../storages/tabsStorage' +import { FollowUpsBlock } from './model' + +export type AuthFollowUpType = 'full-auth' | 're-auth' | 'missing_scopes' | 'use-supported-auth' + +export class FollowUpGenerator { + public generateAuthFollowUps(tabType: TabType, authType: AuthFollowUpType): FollowUpsBlock { + let pillText + switch (authType) { + case 'full-auth': + pillText = 'Authenticate' + break + case 'use-supported-auth': + case 'missing_scopes': + pillText = 'Enable Amazon Q' + break + case 're-auth': + pillText = 'Re-authenticate' + break + } + switch (tabType) { + default: + return { + text: '', + options: [ + { + pillText: pillText, + type: authType, + status: 'info', + icon: 'refresh' as MynahIcons, + }, + ], + } + } + } + + public generateWelcomeBlockForTab(tabType: TabType): FollowUpsBlock { + switch (tabType) { + case 'featuredev': + return { + text: 'Ask a follow up question', + options: [ + { + pillText: 'What are some examples of tasks?', + type: 'DevExamples', + }, + ], + } + case 'doc': + return { + text: 'Select one of the following...', + options: [ + { + pillText: 'Create a README', + prompt: 'Create a README', + type: 'CreateDocumentation', + }, + { + pillText: 'Update an existing README', + prompt: 'Update an existing README', + type: 'UpdateDocumentation', + }, + ], + } + default: + return { + text: 'Try Examples:', + options: [ + { + pillText: 'Explain selected code', + prompt: 'Explain selected code', + type: 'init-prompt', + }, + { + pillText: 'How can Amazon Q help me?', + type: 'help', + }, + ], + } + } + } +} diff --git a/chat-client/src/ui/followUps/handler.ts b/chat-client/src/ui/followUps/handler.ts new file mode 100644 index 0000000000..4794dfe614 --- /dev/null +++ b/chat-client/src/ui/followUps/handler.ts @@ -0,0 +1,196 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItemAction, ChatItemType, MynahIcons, MynahUI } from '@aws/mynah-ui' +import { Connector } from '../connector' +import { TabsStorage } from '../storages/tabsStorage' +import { WelcomeFollowupType } from '../apps/amazonqCommonsConnector' +import { AuthFollowUpType } from './generator' +// import { FollowUpTypes } from '../../../commons/types' + +export interface FollowUpInteractionHandlerProps { + mynahUI: MynahUI + connector: Connector + tabsStorage: TabsStorage +} + +export enum FollowUpTypes { + // UnitTestGeneration + ViewDiff = 'ViewDiff', + AcceptCode = 'AcceptCode', + RejectCode = 'RejectCode', + BuildAndExecute = 'BuildAndExecute', + ModifyCommands = 'ModifyCommands', + SkipBuildAndFinish = 'SkipBuildAndFinish', + InstallDependenciesAndContinue = 'InstallDependenciesAndContinue', + ContinueBuildAndExecute = 'ContinueBuildAndExecute', + ViewCodeDiffAfterIteration = 'ViewCodeDiffAfterIteration', + // FeatureDev + GenerateCode = 'GenerateCode', + InsertCode = 'InsertCode', + ProvideFeedbackAndRegenerateCode = 'ProvideFeedbackAndRegenerateCode', + Retry = 'Retry', + ModifyDefaultSourceFolder = 'ModifyDefaultSourceFolder', + DevExamples = 'DevExamples', + NewTask = 'NewTask', + CloseSession = 'CloseSession', + SendFeedback = 'SendFeedback', + // Doc + CreateDocumentation = 'CreateDocumentation', + ChooseFolder = 'ChooseFolder', + UpdateDocumentation = 'UpdateDocumentation', + SynchronizeDocumentation = 'SynchronizeDocumentation', + EditDocumentation = 'EditDocumentation', + AcceptChanges = 'AcceptChanges', + RejectChanges = 'RejectChanges', + MakeChanges = 'MakeChanges', + ProceedFolderSelection = 'ProceedFolderSelection', + CancelFolderSelection = 'CancelFolderSelection', +} + +export class FollowUpInteractionHandler { + private mynahUI: MynahUI + private connector: Connector + private tabsStorage: TabsStorage + + constructor(props: FollowUpInteractionHandlerProps) { + this.mynahUI = props.mynahUI + this.connector = props.connector + this.tabsStorage = props.tabsStorage + } + + public onFollowUpClicked(tabID: string, messageId: string, followUp: ChatItemAction) { + if ( + followUp.type !== undefined && + ['full-auth', 're-auth', 'missing_scopes', 'use-supported-auth'].includes(followUp.type) + ) { + this.connector.onAuthFollowUpClicked(tabID, followUp.type as AuthFollowUpType) + return + } + if (followUp.type !== undefined && followUp.type === 'help') { + this.tabsStorage.updateTabTypeFromUnknown(tabID, 'cwc') + this.connector.onUpdateTabType(tabID) + this.connector.help(tabID) + return + } + // we need to check if there is a prompt + // which will cause an api call + // then we can set the loading state to true + if (followUp.prompt !== undefined) { + this.mynahUI.updateStore(tabID, { + loadingChat: true, + cancelButtonWhenLoading: false, + promptInputDisabledState: true, + }) + this.mynahUI.addChatItem(tabID, { + type: ChatItemType.PROMPT, + body: followUp.prompt, + }) + this.mynahUI.addChatItem(tabID, { + type: ChatItemType.ANSWER_STREAM, + body: '', + }) + this.tabsStorage.updateTabStatus(tabID, 'busy') + this.tabsStorage.resetTabTimer(tabID) + + if (followUp.type !== undefined && followUp.type === 'init-prompt') { + void this.connector.requestGenerativeAIAnswer(tabID, messageId, { + chatMessage: followUp.prompt, + }) + return + } + } + + const addChatItem = (tabID: string, messageId: string, options: any[]) => { + this.mynahUI.addChatItem(tabID, { + type: ChatItemType.ANSWER_PART, + messageId, + followUp: { + text: '', + options, + }, + }) + } + + const ViewDiffOptions = [ + { + icon: MynahIcons.OK, + pillText: 'Accept', + status: 'success', + type: FollowUpTypes.AcceptCode, + }, + { + icon: MynahIcons.REVERT, + pillText: 'Reject', + status: 'error', + type: FollowUpTypes.RejectCode, + }, + ] + + const AcceptCodeOptions = [ + { + icon: MynahIcons.OK, + pillText: 'Accepted', + status: 'success', + disabled: true, + }, + ] + + const RejectCodeOptions = [ + { + icon: MynahIcons.REVERT, + pillText: 'Rejected', + status: 'error', + disabled: true, + }, + ] + + const ViewCodeDiffAfterIterationOptions = [ + { + icon: MynahIcons.OK, + pillText: 'Accept', + status: 'success', + type: FollowUpTypes.AcceptCode, + }, + { + icon: MynahIcons.REVERT, + pillText: 'Reject', + status: 'error', + type: FollowUpTypes.RejectCode, // TODO: Add new Followup Action for "Reject" + }, + ] + + if (this.tabsStorage.getTab(tabID)?.type === 'testgen') { + switch (followUp.type) { + case FollowUpTypes.ViewDiff: + addChatItem(tabID, messageId, ViewDiffOptions) + break + case FollowUpTypes.AcceptCode: + addChatItem(tabID, messageId, AcceptCodeOptions) + break + case FollowUpTypes.RejectCode: + addChatItem(tabID, messageId, RejectCodeOptions) + break + case FollowUpTypes.ViewCodeDiffAfterIteration: + addChatItem(tabID, messageId, ViewCodeDiffAfterIterationOptions) + break + } + } + + this.connector.onFollowUpClicked(tabID, messageId, followUp) + } + + public onWelcomeFollowUpClicked(tabID: string, welcomeFollowUpType: WelcomeFollowupType) { + if (welcomeFollowUpType === 'continue-to-chat') { + this.mynahUI.addChatItem(tabID, { + type: ChatItemType.ANSWER, + body: 'Ok, please write your question below.', + }) + this.tabsStorage.updateTabTypeFromUnknown(tabID, 'cwc') + this.connector.onUpdateTabType(tabID) + return + } + } +} diff --git a/chat-client/src/ui/followUps/model.ts b/chat-client/src/ui/followUps/model.ts new file mode 100644 index 0000000000..1a1e4da8ea --- /dev/null +++ b/chat-client/src/ui/followUps/model.ts @@ -0,0 +1,11 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItemAction } from '@aws/mynah-ui' + +export interface FollowUpsBlock { + text?: string + options?: ChatItemAction[] +} diff --git a/chat-client/src/ui/main.ts b/chat-client/src/ui/main.ts new file mode 100644 index 0000000000..6fd34cb066 --- /dev/null +++ b/chat-client/src/ui/main.ts @@ -0,0 +1,306 @@ +import { ChatItem, ChatItemType, ChatPrompt, MynahIcons, MynahUI, MynahUIDataModel, ProgressField } from '@aws/mynah-ui' +import { Connector, CWCChatItem } from './connector' +import { QuickActionHandler } from './quickActions/handler' +import { TabsStorage, TabType } from './storages/tabsStorage' +import { DiffTreeFileInfo } from './diffTree/types' +import { WelcomeFollowupType } from './apps/amazonqCommonsConnector' + +export const createConnectorAdapter = () => { + return new ConnectorAdapter() +} + +export class ConnectorAdapter { + tabStorage: TabsStorage + mynahUIRef?: { mynahUI: MynahUI | undefined } + connector?: Connector + + constructor() { + this.tabStorage = new TabsStorage() + } + + createIdeConnector(mynahUIRef: { mynahUI: MynahUI | undefined }, ideApiPostMessage: (msg: any) => void): any { + this.mynahUIRef = mynahUIRef + + function shouldDisplayDiff(messageData: any) { + // const tab = tabsStorage.getTab(messageData?.tabID || '') + // const allowedCommands = [ + // 'aws.amazonq.refactorCode', + // 'aws.amazonq.fixCode', + // 'aws.amazonq.optimizeCode', + // 'aws.amazonq.sendToPrompt', + // ] + // if (tab?.type === 'cwc' && allowedCommands.includes(tab.lastCommand || '')) { + // return true + // } + return false + } + + const tabsStorage = this.tabStorage + const handleQuickAction = this.handleQuickAction + this.connector = new Connector({ + tabsStorage, + handleCommand: (chatPrompt: ChatPrompt, tabId: string) => {}, + onUpdateAuthentication: (isAmazonQEnabled: boolean, authenticatingTabIDs: string[]): void => {}, + onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string): void => {}, + onQuickHandlerCommand: (tabID: string, command?: string, eventId?: string) => { + tabsStorage.updateTabLastCommand(tabID, command) + if (command === 'aws.awsq.transform') { + handleQuickAction({ command: '/transform' }, tabID, eventId) + } + }, + onCWCContextCommandMessage: (message: ChatItem, command?: string): string | undefined => { + return undefined + }, + onWelcomeFollowUpClicked: (tabID: string, welcomeFollowUpType: WelcomeFollowupType) => {}, + onChatInputEnabled: (tabID: string, enabled: boolean) => { + mynahUIRef.mynahUI!.updateStore(tabID, { + promptInputDisabledState: tabsStorage.isTabDead(tabID) || !enabled, + }) + }, + onUpdatePromptProgress(tabID: string, progressField: ProgressField) {}, + onAsyncEventProgress: ( + tabID: string, + inProgress: boolean, + message: string | undefined, + messageId: string | undefined = undefined, + enableStopAction: boolean = false + ) => { + if (inProgress) { + mynahUIRef.mynahUI!.updateStore(tabID, { + loadingChat: true, + promptInputDisabledState: true, + cancelButtonWhenLoading: enableStopAction, + }) + + if (message && messageId) { + mynahUIRef.mynahUI!.updateChatAnswerWithMessageId(tabID, messageId, { + body: message, + }) + } else if (message) { + mynahUIRef.mynahUI!.updateLastChatAnswer(tabID, { + body: message, + }) + } else { + mynahUIRef.mynahUI!.addChatItem(tabID, { + type: ChatItemType.ANSWER_STREAM, + body: '', + messageId: messageId, + }) + } + tabsStorage.updateTabStatus(tabID, 'busy') + return + } + + mynahUIRef.mynahUI!.updateStore(tabID, { + loadingChat: false, + promptInputDisabledState: tabsStorage.isTabDead(tabID), + }) + tabsStorage.updateTabStatus(tabID, 'free') + }, + sendMessageToExtension: message => { + ideApiPostMessage(message) + }, + onChatAnswerUpdated: (tabID: string, item: ChatItem) => { + if (item.messageId !== undefined) { + mynahUIRef.mynahUI!.updateChatAnswerWithMessageId(tabID, item.messageId, { + ...(item.body !== undefined ? { body: item.body } : {}), + ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), + ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), + }) + } else { + mynahUIRef.mynahUI!.updateLastChatAnswer(tabID, { + ...(item.body !== undefined ? { body: item.body } : {}), + ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), + ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), + }) + } + }, + onChatAnswerReceived: (tabID: string, item: CWCChatItem, messageData: any) => { + if (item.type === ChatItemType.ANSWER_PART || item.type === ChatItemType.CODE_RESULT) { + mynahUIRef.mynahUI!.updateLastChatAnswer(tabID, { + ...(item.messageId !== undefined ? { messageId: item.messageId } : {}), + ...(item.canBeVoted !== undefined ? { canBeVoted: item.canBeVoted } : {}), + ...(item.codeReference !== undefined ? { codeReference: item.codeReference } : {}), + ...(item.body !== undefined ? { body: item.body } : {}), + ...(item.relatedContent !== undefined ? { relatedContent: item.relatedContent } : {}), + ...(item.type === ChatItemType.CODE_RESULT + ? { type: ChatItemType.CODE_RESULT, fileList: item.fileList } + : {}), + }) + if ( + item.messageId !== undefined && + item.userIntent !== undefined && + item.codeBlockLanguage !== undefined + ) { + // TODO: Some hack below for telemetry, we would need proper way to solve it + // responseMetadata.set(item.messageId, [item.userIntent, item.codeBlockLanguage]) + } + ideApiPostMessage({ + command: 'update-chat-message-telemetry', + tabID, + tabType: tabsStorage.getTab(tabID)?.type, + time: Date.now(), + }) + return + } + + if ( + item.body !== undefined || + item.relatedContent !== undefined || + item.followUp !== undefined || + item.formItems !== undefined || + item.buttons !== undefined + ) { + mynahUIRef.mynahUI!.addChatItem(tabID, { + ...item, + messageId: item.messageId, + codeBlockActions: { + // TODO: shouldDisplayDiff is for basic chat, + // but we do not have that functionality in basic chat, so we need to migrate it + ...(shouldDisplayDiff(messageData) + ? { + 'insert-to-cursor': undefined, + accept_diff: { + id: 'accept_diff', + label: 'Apply Diff', + icon: MynahIcons.OK_CIRCLED, + data: messageData, + }, + view_diff: { + id: 'view_diff', + label: 'View Diff', + icon: MynahIcons.EYE, + data: messageData, + }, + } + : {}), + }, + }) + } + + if ( + item.type === ChatItemType.PROMPT || + item.type === ChatItemType.SYSTEM_PROMPT || + item.type === ChatItemType.AI_PROMPT + ) { + mynahUIRef.mynahUI!.updateStore(tabID, { + loadingChat: true, + cancelButtonWhenLoading: false, + promptInputDisabledState: true, + }) + + tabsStorage.updateTabStatus(tabID, 'busy') + return + } + + if (item.type === ChatItemType.ANSWER) { + mynahUIRef.mynahUI!.updateStore(tabID, { + loadingChat: false, + promptInputDisabledState: tabsStorage.isTabDead(tabID), + }) + tabsStorage.updateTabStatus(tabID, 'free') + + /** + * We've received an answer for a tabID and this message has + * completed its round trip. Send that information back to + * VSCode so we can emit a round trip event + **/ + ideApiPostMessage({ + command: 'stop-chat-message-telemetry', + tabID, + tabType: tabsStorage.getTab(tabID)?.type, + time: Date.now(), + }) + } + }, + onRunTestMessageReceived: (tabID: string, shouldRunTestMessage: boolean) => {}, + onMessageReceived: (tabID: string, messageData: MynahUIDataModel) => {}, + onFileComponentUpdate: ( + tabID: string, + filePaths: DiffTreeFileInfo[], + deletedFiles: DiffTreeFileInfo[], + messageId: string, + disableFileActions: boolean + ) => {}, + onWarning: (tabID: string, message: string, title: string) => {}, + onError: (tabID: string, message: string, title: string) => {}, + onUpdatePlaceholder(tabID: string, newPlaceholder: string) { + mynahUIRef.mynahUI!.updateStore(tabID, { + promptInputPlaceholder: newPlaceholder, + }) + }, + onNewTab(tabType: TabType) {}, + onOpenSettingsMessage(tabId: string) { + mynahUIRef.mynahUI!.addChatItem(tabId, { + type: ChatItemType.ANSWER, + body: `To add your workspace as context, enable local indexing in your IDE settings. After enabling, add @workspace to your question, and I'll generate a response using your workspace as context.`, + buttons: [ + { + id: 'open-settings', + text: 'Open settings', + icon: MynahIcons.EXTERNAL, + keepCardAfterClick: false, + status: 'info', + }, + ], + }) + tabsStorage.updateTabStatus(tabId, 'free') + mynahUIRef.mynahUI!.updateStore(tabId, { + loadingChat: false, + promptInputDisabledState: tabsStorage.isTabDead(tabId), + }) + return + }, + /** + * Helps with sending static messages that don't need to be sent through to the + * VSCode side. E.g. help messages + */ + sendStaticMessages(tabID: string, messages: ChatItem[]) {}, + }) + + return this.connector + } + + isSupportedTab(tabId: string): boolean { + // NOTE: Just an example, needs to be extended + return this.tabStorage.getTab(tabId)?.type === 'gumby' + } + + handleMessageReceive(message: MessageEvent): void { + if (this.connector) { + this.connector.handleMessageReceive(message) + } else { + console.error('Connector not initialized') + } + } + + handleQuickAction(prompt: ChatPrompt, tabId: string, eventId: string | undefined): void { + const handler = this.getQuickActionHandler() + if (handler) { + handler.handle(prompt, tabId, eventId) + } else { + console.error('Quick action handler not initialized') + } + } + + quickActionHandler?: QuickActionHandler + private getQuickActionHandler() { + const tabsStorage = this.tabStorage + const mynahUI = this.mynahUIRef?.mynahUI + const connector = this.connector + if (!this.quickActionHandler && mynahUI && connector) { + this.quickActionHandler = new QuickActionHandler({ + mynahUI, + connector, + tabsStorage, + isFeatureDevEnabled: false, // TODO: Settings need to be passed somehow + isGumbyEnabled: true, + isScanEnabled: false, + isTestEnabled: false, + isDocEnabled: false, + }) + } + + return this.quickActionHandler + } +} diff --git a/chat-client/src/ui/messages/controller.ts b/chat-client/src/ui/messages/controller.ts new file mode 100644 index 0000000000..df9e5454ce --- /dev/null +++ b/chat-client/src/ui/messages/controller.ts @@ -0,0 +1,144 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItem, ChatItemType, MynahUI, NotificationType } from '@aws/mynah-ui' +import { Connector } from '../connector' +import { TabType, TabsStorage } from '../storages/tabsStorage' +import { TabDataGenerator } from '../tabs/generator' +import { uiComponentsTexts } from '../texts/constants' + +export interface MessageControllerProps { + mynahUI: MynahUI + connector: Connector + tabsStorage: TabsStorage + isFeatureDevEnabled: boolean + isGumbyEnabled: boolean + isScanEnabled: boolean + isTestEnabled: boolean + isDocEnabled: boolean + disabledCommands?: string[] +} + +export class MessageController { + private mynahUI: MynahUI + private connector: Connector + private tabsStorage: TabsStorage + private tabDataGenerator: TabDataGenerator + + constructor(props: MessageControllerProps) { + this.mynahUI = props.mynahUI + this.connector = props.connector + this.tabsStorage = props.tabsStorage + this.tabDataGenerator = new TabDataGenerator({ + isFeatureDevEnabled: props.isFeatureDevEnabled, + isGumbyEnabled: props.isGumbyEnabled, + isScanEnabled: props.isScanEnabled, + isTestEnabled: props.isTestEnabled, + isDocEnabled: props.isDocEnabled, + disabledCommands: props.disabledCommands, + }) + } + + public sendSelectedCodeToTab(message: ChatItem, command: string = ''): string | undefined { + const selectedTab = { ...this.tabsStorage.getSelectedTab() } + if ( + selectedTab?.id === undefined || + selectedTab?.type === undefined || + ['featuredev', 'gumby', 'review', 'testgen', 'doc'].includes(selectedTab.type) + ) { + // Create a new tab if there's none + const newTabID: string | undefined = this.mynahUI.updateStore( + '', + this.tabDataGenerator.getTabData('cwc', false) + ) + if (newTabID === undefined) { + this.mynahUI.notify({ + content: uiComponentsTexts.noMoreTabsTooltip, + type: NotificationType.WARNING, + }) + return undefined + } + this.tabsStorage.addTab({ + id: newTabID, + type: 'cwc', + status: 'free', + isSelected: true, + lastCommand: command, + }) + selectedTab.id = newTabID + } + this.mynahUI.addToUserPrompt(selectedTab.id, message.body as string) + + return selectedTab.id + } + + public sendMessageToTab(message: ChatItem, tabType: TabType, command: string = ''): string | undefined { + const selectedTab = this.tabsStorage.getSelectedTab() + + if ( + selectedTab !== undefined && + [tabType, 'unknown'].includes(selectedTab.type) && + selectedTab.status === 'free' + ) { + this.tabsStorage.updateTabStatus(selectedTab.id, 'busy') + this.tabsStorage.updateTabTypeFromUnknown(selectedTab.id, tabType) + this.tabsStorage.updateTabLastCommand(selectedTab.id, command) + + this.mynahUI.updateStore(selectedTab.id, { + loadingChat: true, + cancelButtonWhenLoading: false, + promptInputDisabledState: true, + }) + this.mynahUI.addChatItem(selectedTab.id, message) + this.mynahUI.addChatItem(selectedTab.id, { + type: ChatItemType.ANSWER_STREAM, + body: '', + }) + + return selectedTab.id + } + + const newTabID: string | undefined = this.mynahUI.updateStore( + '', + this.tabDataGenerator.getTabData('cwc', false) + ) + if (newTabID === undefined) { + this.mynahUI.notify({ + content: uiComponentsTexts.noMoreTabsTooltip, + type: NotificationType.WARNING, + }) + return undefined + } else { + this.tabsStorage.updateTabLastCommand(newTabID, command) + this.mynahUI.addChatItem(newTabID, message) + this.mynahUI.addChatItem(newTabID, { + type: ChatItemType.ANSWER_STREAM, + body: '', + }) + + this.mynahUI.updateStore(newTabID, { + loadingChat: true, + cancelButtonWhenLoading: false, + promptInputDisabledState: true, + }) + + // We have race condition here with onTabAdd Ui event. This way we need to update store twice to be sure + this.tabsStorage.addTab({ + id: newTabID, + type: 'cwc', + status: 'busy', + isSelected: true, + openInteractionType: 'contextMenu', + lastCommand: command, + }) + + this.tabsStorage.updateTabTypeFromUnknown(newTabID, 'cwc') + this.connector.onUpdateTabType(newTabID) + this.tabsStorage.updateTabStatus(newTabID, 'busy') + + return newTabID + } + } +} diff --git a/chat-client/src/ui/messages/handler.ts b/chat-client/src/ui/messages/handler.ts new file mode 100644 index 0000000000..d85774d23f --- /dev/null +++ b/chat-client/src/ui/messages/handler.ts @@ -0,0 +1,52 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItemType, ChatPrompt, MynahUI } from '@aws/mynah-ui' +import { Connector } from '../connector' +import { TabsStorage } from '../storages/tabsStorage' + +export interface TextMessageHandlerProps { + mynahUI: MynahUI + connector: Connector + tabsStorage: TabsStorage +} + +export class TextMessageHandler { + private mynahUI: MynahUI + private connector: Connector + private tabsStorage: TabsStorage + + constructor(props: TextMessageHandlerProps) { + this.mynahUI = props.mynahUI + this.connector = props.connector + this.tabsStorage = props.tabsStorage + } + + public handle(chatPrompt: ChatPrompt, tabID: string, eventID: string) { + this.tabsStorage.updateTabLastCommand(tabID, chatPrompt.command) + this.tabsStorage.updateTabTypeFromUnknown(tabID, 'cwc') + this.tabsStorage.resetTabTimer(tabID) + this.connector.onUpdateTabType(tabID) + this.mynahUI.addChatItem(tabID, { + type: ChatItemType.PROMPT, + body: chatPrompt.escapedPrompt, + }) + + this.mynahUI.updateStore(tabID, { + loadingChat: true, + cancelButtonWhenLoading: false, + promptInputDisabledState: true, + }) + + this.tabsStorage.updateTabStatus(tabID, 'busy') + + void this.connector + .requestGenerativeAIAnswer(tabID, eventID, { + chatMessage: chatPrompt.prompt ?? '', + chatCommand: chatPrompt.command, + }) + .then(() => {}) + } +} diff --git a/chat-client/src/ui/quickActions/generator.ts b/chat-client/src/ui/quickActions/generator.ts new file mode 100644 index 0000000000..1ca348cd3c --- /dev/null +++ b/chat-client/src/ui/quickActions/generator.ts @@ -0,0 +1,172 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { QuickActionCommand, QuickActionCommandGroup } from '@aws/mynah-ui/dist/static' +import { TabType } from '../storages/tabsStorage' +import { MynahIcons } from '@aws/mynah-ui' + +export interface QuickActionGeneratorProps { + isFeatureDevEnabled: boolean + isGumbyEnabled: boolean + isScanEnabled: boolean + isTestEnabled: boolean + isDocEnabled: boolean + disableCommands?: string[] +} + +export class QuickActionGenerator { + public isFeatureDevEnabled: boolean + private isGumbyEnabled: boolean + private isScanEnabled: boolean + private isTestEnabled: boolean + private isDocEnabled: boolean + private disabledCommands: string[] + + constructor(props: QuickActionGeneratorProps) { + this.isFeatureDevEnabled = props.isFeatureDevEnabled + this.isGumbyEnabled = props.isGumbyEnabled + this.isScanEnabled = props.isScanEnabled + this.isTestEnabled = props.isTestEnabled + this.isDocEnabled = props.isDocEnabled + this.disabledCommands = props.disableCommands ?? [] + } + + public generateForTab(tabType: TabType): QuickActionCommandGroup[] { + // agentWalkthrough is static and doesn't have any quick actions + if (tabType === 'agentWalkthrough') { + return [] + } + + // TODO: Update acc to UX + const quickActionCommands = [ + { + groupName: `Q Developer agentic capabilities`, + commands: [ + ...(this.isFeatureDevEnabled && !this.disabledCommands.includes('/dev') + ? [ + { + command: '/dev', + icon: MynahIcons.CODE_BLOCK, + placeholder: 'Describe your task or issue in as much detail as possible', + description: 'Generate code to make a change in your project', + }, + ] + : []), + ...(this.isTestEnabled && !this.disabledCommands.includes('/test') + ? [ + { + command: '/test', + icon: MynahIcons.CHECK_LIST, + placeholder: 'Specify a function(s) in the current file (optional)', + description: 'Generate unit tests (python & java) for selected code', + }, + ] + : []), + ...(this.isScanEnabled && !this.disabledCommands.includes('/review') + ? [ + { + command: '/review', + icon: MynahIcons.BUG, + description: 'Identify and fix code issues before committing', + }, + ] + : []), + ...(this.isDocEnabled && !this.disabledCommands.includes('/doc') + ? [ + { + command: '/doc', + icon: MynahIcons.FILE, + description: 'Generate documentation', + }, + ] + : []), + ...(this.isGumbyEnabled && !this.disabledCommands.includes('/transform') + ? [ + { + command: '/transform', + description: 'Transform your Java project', + icon: MynahIcons.TRANSFORM, + }, + ] + : []), + ], + }, + { + groupName: 'Quick Actions', + commands: [ + { + command: '/help', + icon: MynahIcons.HELP, + description: 'Learn more about Amazon Q', + }, + { + command: '/clear', + icon: MynahIcons.TRASH, + description: 'Clear this session', + }, + ], + }, + ].filter(section => section.commands.length > 0) + + const commandUnavailability: Record< + Exclude, + { + description: string + unavailableItems: string[] + } + > = { + cwc: { + description: '', + unavailableItems: [], + }, + featuredev: { + description: "This command isn't available in /dev", + unavailableItems: ['/help', '/clear'], + }, + review: { + description: "This command isn't available in /review", + unavailableItems: ['/help', '/clear'], + }, + gumby: { + description: "This command isn't available in /transform", + unavailableItems: ['/dev', '/test', '/doc', '/review', '/help', '/clear'], + }, + testgen: { + description: "This command isn't available in /test", + unavailableItems: ['/help', '/clear'], + }, + doc: { + description: "This command isn't available in /doc", + unavailableItems: ['/help', '/clear'], + }, + welcome: { + description: '', + unavailableItems: ['/clear'], + }, + unknown: { + description: '', + unavailableItems: [], + }, + } + + return quickActionCommands.map(commandGroup => { + return { + groupName: commandGroup.groupName, + commands: commandGroup.commands.map((commandItem: QuickActionCommand) => { + const commandNotAvailable = commandUnavailability[tabType].unavailableItems.includes( + commandItem.command + ) + return { + ...commandItem, + disabled: commandNotAvailable, + description: commandNotAvailable + ? commandUnavailability[tabType].description + : commandItem.description, + } + }) as QuickActionCommand[], + } + }) as QuickActionCommandGroup[] + } +} diff --git a/chat-client/src/ui/quickActions/handler.ts b/chat-client/src/ui/quickActions/handler.ts new file mode 100644 index 0000000000..313437beae --- /dev/null +++ b/chat-client/src/ui/quickActions/handler.ts @@ -0,0 +1,387 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItemType, ChatPrompt, MynahUI, NotificationType, MynahIcons } from '@aws/mynah-ui' +import { TabDataGenerator } from '../tabs/generator' +import { Connector } from '../connector' +import { TabsStorage, TabType } from '../storages/tabsStorage' +import { uiComponentsTexts } from '../texts/constants' + +export interface QuickActionsHandlerProps { + mynahUI: MynahUI + connector: Connector + tabsStorage: TabsStorage + isFeatureDevEnabled: boolean + isGumbyEnabled: boolean + isScanEnabled: boolean + isTestEnabled: boolean + isDocEnabled: boolean + disabledCommands?: string[] +} + +export interface HandleCommandProps { + tabID: string + tabType: TabType + isEnabled: boolean + chatPrompt?: ChatPrompt + eventId?: string + taskName?: string +} +export class QuickActionHandler { + private mynahUI: MynahUI + private connector: Connector + private tabsStorage: TabsStorage + private tabDataGenerator: TabDataGenerator + private isFeatureDevEnabled: boolean + private isGumbyEnabled: boolean + private isScanEnabled: boolean + private isTestEnabled: boolean + private isDocEnabled: boolean + + constructor(props: QuickActionsHandlerProps) { + this.mynahUI = props.mynahUI + this.connector = props.connector + this.tabsStorage = props.tabsStorage + this.isDocEnabled = props.isDocEnabled + this.tabDataGenerator = new TabDataGenerator({ + isFeatureDevEnabled: props.isFeatureDevEnabled, + isGumbyEnabled: props.isGumbyEnabled, + isScanEnabled: props.isScanEnabled, + isTestEnabled: props.isTestEnabled, + isDocEnabled: props.isDocEnabled, + disabledCommands: props.disabledCommands, + }) + this.isFeatureDevEnabled = props.isFeatureDevEnabled + this.isGumbyEnabled = props.isGumbyEnabled + this.isScanEnabled = props.isScanEnabled + this.isTestEnabled = props.isTestEnabled + } + + /** + * Handle commands + * Inside of the welcome page commands update the current tab + * Outside of the welcome page commands create new tabs + */ + public handle(chatPrompt: ChatPrompt, tabID: string, eventId?: string) { + this.tabsStorage.resetTabTimer(tabID) + switch (chatPrompt.command) { + case '/dev': + this.handleCommand({ + chatPrompt, + tabID, + taskName: 'Q - Dev', + tabType: 'featuredev', + isEnabled: this.isFeatureDevEnabled, + }) + break + case '/help': + this.handleHelpCommand(tabID) + break + case '/transform': + this.handleGumbyCommand(tabID, eventId) + break + case '/review': + this.handleScanCommand(tabID, eventId) + break + case '/test': + this.handleTestCommand(chatPrompt, tabID, eventId) + break + case '/doc': + this.handleCommand({ + chatPrompt, + tabID, + taskName: 'Q - Doc', + tabType: 'doc', + isEnabled: this.isDocEnabled, + }) + break + case '/clear': + this.handleClearCommand(tabID) + break + } + } + + private handleScanCommand(tabID: string, eventId: string | undefined) { + if (!this.isScanEnabled) { + return + } + let scanTabId: string | undefined = undefined + + this.tabsStorage.getTabs().forEach(tab => { + if (tab.type === 'review') { + scanTabId = tab.id + } + }) + + if (scanTabId !== undefined) { + this.mynahUI.selectTab(scanTabId, eventId || '') + this.connector.onTabChange(scanTabId) + this.connector.scans(scanTabId) + return + } + + let affectedTabId: string | undefined = tabID + // if there is no scan tab, open a new one + const currentTabType = this.tabsStorage.getTab(affectedTabId)?.type + if (currentTabType !== 'unknown' && currentTabType !== 'welcome') { + affectedTabId = this.mynahUI.updateStore('', { + loadingChat: true, + }) + } + + if (affectedTabId === undefined) { + this.mynahUI.notify({ + content: uiComponentsTexts.noMoreTabsTooltip, + type: NotificationType.WARNING, + }) + return + } else { + this.tabsStorage.updateTabTypeFromUnknown(affectedTabId, 'review') + this.connector.onKnownTabOpen(affectedTabId) + this.connector.onUpdateTabType(affectedTabId) + + // reset chat history + this.mynahUI.updateStore(affectedTabId, { + chatItems: [], + }) + + this.mynahUI.updateStore(affectedTabId, this.tabDataGenerator.getTabData('review', true, undefined)) // creating a new tab and printing some title + + // disable chat prompt + this.mynahUI.updateStore(affectedTabId, { + loadingChat: true, + }) + this.connector.scans(affectedTabId) + } + } + + private handleTestCommand(chatPrompt: ChatPrompt, tabID: string, eventId: string | undefined) { + if (!this.isTestEnabled) { + return + } + const testTabId = this.tabsStorage.getTabs().find(tab => tab.type === 'testgen')?.id + const realPromptText = chatPrompt.escapedPrompt?.trim() ?? '' + + if (testTabId !== undefined) { + this.mynahUI.selectTab(testTabId, eventId || '') + this.connector.onTabChange(testTabId) + this.connector.startTestGen(testTabId, realPromptText) + return + } + + let affectedTabId: string | undefined = tabID + // if there is no test tab, open a new one + const currentTabType = this.tabsStorage.getTab(affectedTabId)?.type + if (currentTabType !== 'unknown' && currentTabType !== 'welcome') { + affectedTabId = this.mynahUI.updateStore('', { + loadingChat: true, + }) + } + + if (affectedTabId === undefined) { + this.mynahUI.notify({ + content: uiComponentsTexts.noMoreTabsTooltip, + type: NotificationType.WARNING, + }) + return + } else { + this.tabsStorage.updateTabTypeFromUnknown(affectedTabId, 'testgen') + this.connector.onKnownTabOpen(affectedTabId) + this.connector.onUpdateTabType(affectedTabId) + + // reset chat history + this.mynahUI.updateStore(affectedTabId, { + chatItems: [], + }) + + // creating a new tab and printing some title + this.mynahUI.updateStore( + affectedTabId, + this.tabDataGenerator.getTabData('testgen', realPromptText === '', 'Q - Test') + ) + + this.connector.startTestGen(affectedTabId, realPromptText) + } + } + + private handleCommand(props: HandleCommandProps) { + if (!props.isEnabled) { + return + } + + let affectedTabId: string | undefined = props.tabID + const realPromptText = props.chatPrompt?.escapedPrompt?.trim() ?? '' + const currentTabType = this.tabsStorage.getTab(affectedTabId)?.type + if (currentTabType !== 'unknown' && currentTabType !== 'welcome') { + affectedTabId = this.mynahUI.updateStore('', {}) + } + if (affectedTabId === undefined) { + this.mynahUI.notify({ + content: uiComponentsTexts.noMoreTabsTooltip, + type: NotificationType.WARNING, + }) + return + } else { + this.tabsStorage.updateTabTypeFromUnknown(affectedTabId, props.tabType) + this.connector.onKnownTabOpen(affectedTabId) + this.connector.onUpdateTabType(affectedTabId) + + this.mynahUI.updateStore(affectedTabId, { chatItems: [] }) + + if (props.tabType === 'featuredev') { + this.mynahUI.updateStore( + affectedTabId, + this.tabDataGenerator.getTabData(props.tabType, false, props.taskName) + ) + } else { + this.mynahUI.updateStore( + affectedTabId, + this.tabDataGenerator.getTabData(props.tabType, realPromptText === '', props.taskName) + ) + } + + const addInformationCard = (tabId: string) => { + if (props.tabType === 'featuredev') { + this.mynahUI.addChatItem(tabId, { + type: ChatItemType.ANSWER, + informationCard: { + title: 'Feature development', + description: 'Amazon Q Developer Agent for Software Development', + icon: MynahIcons.BUG, + content: { + body: [ + 'After you provide a task, I will:', + '1. Generate code based on your description and the code in your workspace', + '2. Provide a list of suggestions for you to review and add to your workspace', + '3. If needed, iterate based on your feedback', + 'To learn more, visit the [user guide](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/software-dev.html)', + ].join('\n'), + }, + }, + }) + } + } + if (realPromptText !== '') { + this.mynahUI.addChatItem(affectedTabId, { + type: ChatItemType.PROMPT, + body: realPromptText, + }) + addInformationCard(affectedTabId) + + this.mynahUI.updateStore(affectedTabId, { + loadingChat: true, + cancelButtonWhenLoading: false, + promptInputDisabledState: true, + }) + + void this.connector.requestGenerativeAIAnswer(affectedTabId, '', { + chatMessage: realPromptText, + }) + } else { + addInformationCard(affectedTabId) + } + } + } + + private handleGumbyCommand(tabID: string, eventId: string | undefined) { + if (!this.isGumbyEnabled) { + return + } + + let gumbyTabId: string | undefined = undefined + + this.tabsStorage.getTabs().forEach(tab => { + if (tab.type === 'gumby') { + gumbyTabId = tab.id + } + }) + + if (gumbyTabId !== undefined) { + this.mynahUI.selectTab(gumbyTabId, eventId || '') + this.connector.onTabChange(gumbyTabId) + return + } + + let affectedTabId: string | undefined = tabID + // if there is no gumby tab, open a new one + + // NOTE: The below code means the following: if the current tab was actually used (not 'unknown' or welcome'), + // create a new tab and later convert it into transform. + // This will not work with migration to Flare, we'll need to change it for all agents + // or find a way to mirror all tabs created in chat-client to 'tabsStorage'. + // Currently, chat-client knows nothing about 'tabsStorage'. + // Alternative way to achieve the same is below >> + // + // const currentTabType = this.tabsStorage.getTab(affectedTabId)?.type + // if (currentTabType !== 'unknown' && currentTabType !== 'welcome') { + // affectedTabId = this.mynahUI.updateStore('', { + // loadingChat: true, + // cancelButtonWhenLoading: false, + // }) + // } + // >> + const currTab = this.mynahUI.getAllTabs()[affectedTabId] + const currTabWasUsed = (currTab.store?.chatItems?.filter(item => item.type === 'prompt').length ?? 0) > 0 + if (currTabWasUsed) { + affectedTabId = this.mynahUI.updateStore('', { + loadingChat: true, + cancelButtonWhenLoading: false, + }) + } + + if (affectedTabId) { + this.tabsStorage.addTab({ + id: affectedTabId, + type: 'unknown', + status: 'free', + isSelected: true, + }) + } + // << + + if (affectedTabId === undefined) { + this.mynahUI.notify({ + content: uiComponentsTexts.noMoreTabsTooltip, + type: NotificationType.WARNING, + }) + return + } else { + this.tabsStorage.updateTabTypeFromUnknown(affectedTabId, 'gumby') + this.connector.onKnownTabOpen(affectedTabId) + this.connector.onUpdateTabType(affectedTabId) + + // reset chat history + this.mynahUI.updateStore(affectedTabId, { + chatItems: [], + }) + + this.mynahUI.updateStore(affectedTabId, this.tabDataGenerator.getTabData('gumby', true, undefined)) + + // disable chat prompt + this.mynahUI.updateStore(affectedTabId, { + loadingChat: true, + cancelButtonWhenLoading: false, + }) + + this.connector.transform(affectedTabId) + } + } + + private handleClearCommand(tabID: string) { + this.mynahUI.updateStore(tabID, { + chatItems: [], + }) + this.connector.clearChat(tabID) + } + + private handleHelpCommand(tabID: string) { + // User entered help action, so change the tab type to 'cwc' if it's an unknown tab + if (this.tabsStorage.getTab(tabID)?.type === 'unknown') { + this.tabsStorage.updateTabTypeFromUnknown(tabID, 'cwc') + } + + this.connector.help(tabID) + } +} diff --git a/chat-client/src/ui/storages/tabsStorage.ts b/chat-client/src/ui/storages/tabsStorage.ts new file mode 100644 index 0000000000..f9a419fed9 --- /dev/null +++ b/chat-client/src/ui/storages/tabsStorage.ts @@ -0,0 +1,170 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export type TabStatus = 'free' | 'busy' | 'dead' +const TabTypes = [ + 'cwc', + 'featuredev', + 'gumby', + 'review', + 'testgen', + 'doc', + 'agentWalkthrough', + 'welcome', + 'unknown', +] as const +export type TabType = (typeof TabTypes)[number] +export function isTabType(value: string): value is TabType { + return (TabTypes as readonly string[]).includes(value) +} + +export function getTabCommandFromTabType(tabType: TabType): string { + switch (tabType) { + case 'featuredev': + return '/dev' + case 'doc': + return '/doc' + case 'gumby': + return '/transform' + case 'review': + return '/review' + case 'testgen': + return '/test' + default: + return '' + } +} + +export type TabOpenType = 'click' | 'contextMenu' | 'hotkeys' + +const TabTimeoutDuration = 172_800_000 // 48hrs +export interface Tab { + readonly id: string + status: TabStatus + type: TabType + isSelected: boolean + openInteractionType?: TabOpenType + lastCommand?: string +} + +export class TabsStorage { + private tabs: Map = new Map() + private lastCreatedTabByType: Map = new Map() + private lastSelectedTab: Tab | undefined = undefined + private tabActivityTimers: Record> = {} + private onTabTimeout?: (tabId: string) => void + + constructor(props?: { onTabTimeout: (tabId: string) => void }) { + this.onTabTimeout = props?.onTabTimeout + } + + public addTab(tab: Tab) { + if (this.tabs.has(tab.id)) { + return + } + this.tabs.set(tab.id, tab) + this.lastCreatedTabByType.set(tab.type, tab.id) + if (tab.isSelected) { + this.setSelectedTab(tab.id) + } + } + + public deleteTab(tabID: string) { + if (this.tabActivityTimers[tabID] !== undefined) { + clearTimeout(this.tabActivityTimers[tabID]) + delete this.tabActivityTimers[tabID] + } + // Reset the last selected tab if the deleted one is selected + if (tabID === this.lastSelectedTab?.id) { + this.lastSelectedTab = undefined + } + this.tabs.delete(tabID) + } + + public getTab(tabID: string): Tab | undefined { + return this.tabs.get(tabID) + } + + public getTabs(): Tab[] { + return Array.from(this.tabs.values()) + } + + public isTabDead(tabID: string): boolean { + return this.tabs.get(tabID)?.status === 'dead' + } + + public updateTabLastCommand(tabID: string, command?: string) { + if (command === undefined) { + return + } + const currentTabValue = this.tabs.get(tabID) + if (currentTabValue === undefined || currentTabValue.status === 'dead') { + return + } + currentTabValue.lastCommand = command + this.tabs.set(tabID, currentTabValue) + } + + public updateTabStatus(tabID: string, tabStatus: TabStatus) { + const currentTabValue = this.tabs.get(tabID) + if (currentTabValue === undefined || currentTabValue.status === 'dead') { + return + } + currentTabValue.status = tabStatus + this.tabs.set(tabID, currentTabValue) + } + + public updateTabTypeFromUnknown(tabID: string, tabType: TabType) { + const currentTabValue = this.tabs.get(tabID) + if ( + currentTabValue === undefined || + (currentTabValue.type !== 'unknown' && currentTabValue.type !== 'welcome') + ) { + return + } + + currentTabValue.type = tabType + + this.tabs.set(tabID, currentTabValue) + this.lastCreatedTabByType.set(tabType, tabID) + } + + public resetTabTimer(tabID: string) { + if (this.onTabTimeout !== undefined) { + if (this.tabActivityTimers[tabID] !== undefined) { + clearTimeout(this.tabActivityTimers[tabID]) + } + this.tabActivityTimers[tabID] = setTimeout(() => { + if (this.onTabTimeout !== undefined) { + this.updateTabStatus(tabID, 'dead') + this.onTabTimeout(tabID) + } + }, TabTimeoutDuration) + } + } + + public setSelectedTab(tabID: string): string | undefined { + const prevSelectedTab = this.lastSelectedTab + const prevSelectedTabID = this.lastSelectedTab?.id + if (prevSelectedTab !== undefined) { + prevSelectedTab.isSelected = false + this.tabs.set(prevSelectedTab.id, prevSelectedTab) + } + + const newSelectedTab = this.tabs.get(tabID) + if (newSelectedTab === undefined) { + return prevSelectedTabID + } + + newSelectedTab.isSelected = true + this.tabs.set(newSelectedTab.id, newSelectedTab) + this.lastSelectedTab = newSelectedTab + return prevSelectedTabID + } + + public getSelectedTab(): Tab | undefined { + return this.lastSelectedTab + } +} diff --git a/chat-client/src/ui/tabs/constants.ts b/chat-client/src/ui/tabs/constants.ts new file mode 100644 index 0000000000..efbf700b91 --- /dev/null +++ b/chat-client/src/ui/tabs/constants.ts @@ -0,0 +1,72 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { TabType } from '../storages/tabsStorage' +import { QuickActionCommandGroup } from '@aws/mynah-ui' +import { userGuideURL } from '../texts/constants' + +export type TabTypeData = { + title: string + placeholder: string + welcome: string + contextCommands?: QuickActionCommandGroup[] +} + +const workspaceCommand: QuickActionCommandGroup = { + groupName: 'Mention code', + commands: [ + { + command: '@workspace', + description: 'Reference all code in workspace.', + }, + ], +} + +const commonTabData: TabTypeData = { + title: 'Chat', + placeholder: 'Ask a question or enter "/" for quick actions', + welcome: `Hi, I'm Amazon Q. I can answer your software development questions. + Ask me to explain, debug, or optimize your code. + You can enter \`/\` to see a list of quick actions. Add @workspace to the beginning of your message to include your entire workspace as context.`, + contextCommands: [workspaceCommand], +} + +export const TabTypeDataMap: Record, TabTypeData> = { + unknown: commonTabData, + cwc: commonTabData, + featuredev: { + title: 'Q - Dev', + placeholder: 'Describe your task or issue in as much detail as possible', + welcome: `I can generate code to accomplish a task or resolve an issue. + +After you provide a description, I will: +1. Generate code based on your description and the code in your workspace +2. Provide a list of suggestions for you to review and add to your workspace +3. If needed, iterate based on your feedback + +To learn more, visit the [User Guide](${userGuideURL}).`, + }, + gumby: { + title: 'Q - Code Transformation', + placeholder: 'Open a new tab to chat with Q', + welcome: 'Welcome to Code Transformation!', + }, + review: { + title: 'Q - Review', + placeholder: `Ask a question or enter "/" for quick actions`, + welcome: `Welcome to code reviews. I can help you identify code issues and provide suggested fixes for the active file or workspace you have opened in your IDE.`, + }, + testgen: { + title: 'Q - Test', + placeholder: `Waiting on your inputs...`, + welcome: `Welcome to unit test generation. I can help you generate unit tests for your active file.`, + }, + doc: { + title: 'Q - Doc Generation', + placeholder: 'Ask Amazon Q to generate documentation for your project', + welcome: `Welcome to doc generation! + +I can help generate documentation for your code. To get started, choose what type of doc update you'd like to make.`, + }, +} diff --git a/chat-client/src/ui/tabs/generator.ts b/chat-client/src/ui/tabs/generator.ts new file mode 100644 index 0000000000..b3263218c1 --- /dev/null +++ b/chat-client/src/ui/tabs/generator.ts @@ -0,0 +1,69 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItemType, MynahUIDataModel } from '@aws/mynah-ui' +import { TabType } from '../storages/tabsStorage' +import { FollowUpGenerator } from '../followUps/generator' +import { QuickActionGenerator } from '../quickActions/generator' +import { TabTypeDataMap } from './constants' +import { agentWalkthroughDataModel } from '../walkthrough/agent' + +export interface TabDataGeneratorProps { + isFeatureDevEnabled: boolean + isGumbyEnabled: boolean + isScanEnabled: boolean + isTestEnabled: boolean + isDocEnabled: boolean + disabledCommands?: string[] +} + +export class TabDataGenerator { + private followUpsGenerator: FollowUpGenerator + public quickActionsGenerator: QuickActionGenerator + + constructor(props: TabDataGeneratorProps) { + this.followUpsGenerator = new FollowUpGenerator() + this.quickActionsGenerator = new QuickActionGenerator({ + isFeatureDevEnabled: props.isFeatureDevEnabled, + isGumbyEnabled: props.isGumbyEnabled, + isScanEnabled: props.isScanEnabled, + isTestEnabled: props.isTestEnabled, + isDocEnabled: props.isDocEnabled, + disableCommands: props.disabledCommands, + }) + } + + public getTabData(tabType: TabType, needWelcomeMessages: boolean, taskName?: string): MynahUIDataModel { + if (tabType === 'agentWalkthrough') { + return agentWalkthroughDataModel + } + + if (tabType === 'welcome') { + return {} + } + + const tabData: MynahUIDataModel = { + tabTitle: taskName ?? TabTypeDataMap[tabType].title, + promptInputInfo: + 'Amazon Q Developer uses generative AI. You may need to verify responses. See the [AWS Responsible AI Policy](https://aws.amazon.com/machine-learning/responsible-ai/policy/).', + quickActionCommands: this.quickActionsGenerator.generateForTab(tabType), + promptInputPlaceholder: TabTypeDataMap[tabType].placeholder, + contextCommands: TabTypeDataMap[tabType].contextCommands, + chatItems: needWelcomeMessages + ? [ + { + type: ChatItemType.ANSWER, + body: TabTypeDataMap[tabType].welcome, + }, + { + type: ChatItemType.ANSWER, + followUp: this.followUpsGenerator.generateWelcomeBlockForTab(tabType), + }, + ] + : [], + } + return tabData + } +} diff --git a/chat-client/src/ui/telemetry/actions.ts b/chat-client/src/ui/telemetry/actions.ts new file mode 100644 index 0000000000..ffd65684ff --- /dev/null +++ b/chat-client/src/ui/telemetry/actions.ts @@ -0,0 +1,38 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExtensionMessage } from '../commands' +import { TabType } from '../storages/tabsStorage' + +export function createClickTelemetry(source: string): ExtensionMessage { + return { + command: 'send-telemetry', + source, + } +} +export function isClickTelemetry(message: ExtensionMessage): boolean { + return ( + message.command === 'send-telemetry' && typeof message.source === 'string' && Object.keys(message).length === 2 + ) +} + +export function createOpenAgentTelemetry(module: TabType, trigger: Trigger): ExtensionMessage { + return { + command: 'send-telemetry', + module, + trigger, + } +} + +export type Trigger = 'right-click' | 'quick-action' | 'quick-start' + +export function isOpenAgentTelemetry(message: ExtensionMessage): boolean { + return ( + message.command === 'send-telemetry' && + typeof message.module === 'string' && + typeof message.trigger === 'string' && + Object.keys(message).length === 3 + ) +} diff --git a/chat-client/src/ui/texts/constants.ts b/chat-client/src/ui/texts/constants.ts new file mode 100644 index 0000000000..d907308b8c --- /dev/null +++ b/chat-client/src/ui/texts/constants.ts @@ -0,0 +1,71 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export const uiComponentsTexts = { + mainTitle: 'Amazon Q', + copy: 'Copy', + insertAtCursorLabel: 'Insert at cursor', + feedbackFormTitle: 'Report an issue', + feedbackFormOptionsLabel: 'What type of issue would you like to report?', + feedbackFormCommentLabel: 'Description of issue (optional):', + feedbackThanks: 'Thanks for your feedback!', + feedbackReportButtonLabel: 'Report an issue', + codeSuggestions: 'Code suggestions', + files: 'file(s)', + clickFileToViewDiff: 'Click on a file to view diff.', + showMore: 'Show more', + save: 'Save', + cancel: 'Cancel', + submit: 'Submit', + stopGenerating: 'Stop', + copyToClipboard: 'Copied to clipboard', + noMoreTabsTooltip: 'You can only open ten conversation tabs at a time.', + codeSuggestionWithReferenceTitle: 'Some suggestions contain code with references.', + spinnerText: 'Generating your answer...', + changeAccepted: 'Change accepted', + changeRejected: 'Change rejected', + acceptChange: 'Accept change', + rejectChange: 'Reject change', + revertRejection: 'Revert rejection', +} +export const docUserGuide = 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/doc-generation.html' +export const userGuideURL = 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/software-dev.html' +export const manageAccessGuideURL = + 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/security_iam_manage-access-with-policies.html' +export const testGuideUrl = 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/test-generation.html' +export const reviewGuideUrl = 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/code-reviews.html' + +export const helpMessage = `I'm Amazon Q, a generative AI assistant. Learn more about me below. Your feedback will help me improve. +\n\n### What I can do: +\n\n- Answer questions about AWS +\n\n- Answer questions about general programming concepts +\n\n- Answer questions about your workspace with @workspace +\n\n- Explain what a line of code or code function does +\n\n- Write unit tests and code +\n\n- Debug and fix code +\n\n- Refactor code +\n\n### What I don't do right now: +\n\n- Answer questions in languages other than English +\n\n- Remember conversations from your previous sessions +\n\n- Have information about your AWS account or your specific AWS resources +\n\n### Examples of questions I can answer: +\n\n- When should I use ElastiCache? +\n\n- How do I create an Application Load Balancer? +\n\n- Explain the and ask clarifying questions about it. +\n\n- What is the syntax of declaring a variable in TypeScript? +\n\n### Special Commands +\n\n- /dev - Get code suggestions across files in your current project. Provide a brief prompt, such as "Implement a GET API." +\n\n- /doc - Create and update documentation for your repository. +\n\n- /review - Discover and address security and code quality issues. +\n\n- /test - Generate unit tests for a file. +\n\n- /transform - Transform your code. Use to upgrade Java code versions. +\n\n- /help - View chat topics and commands. +\n\n- /clear - Clear the conversation. +\n\n### Things to note: +\n\n- I may not always provide completely accurate or current information. +\n\n- Provide feedback by choosing the like or dislike buttons that appear below answers. +\n\n- When you use Amazon Q, AWS may, for service improvement purposes, store data about your usage and content. You can opt-out of sharing this data by following the steps in AI services opt-out policies. See here +\n\n- Do not enter any confidential, sensitive, or personal information. +\n\n*For additional help, visit the [Amazon Q User Guide](${userGuideURL}).*` diff --git a/chat-client/src/ui/texts/disclaimer.ts b/chat-client/src/ui/texts/disclaimer.ts new file mode 100644 index 0000000000..fc66679627 --- /dev/null +++ b/chat-client/src/ui/texts/disclaimer.ts @@ -0,0 +1,20 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItem, MynahIcons } from '@aws/mynah-ui' + +export const disclaimerAcknowledgeButtonId = 'amazonq-disclaimer-acknowledge-button-id' +export const disclaimerCard: Partial = { + messageId: 'amazonq-disclaimer-card', + body: 'Amazon Q Developer uses generative AI. You may need to verify responses. See the [AWS Responsible AI Policy](https://aws.amazon.com/machine-learning/responsible-ai/policy/). Amazon Q Developer processes data across all US Regions. See [here](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/cross-region-inference.html) for more info. Amazon Q may retain chats to provide and maintain the service.', + buttons: [ + { + text: 'Acknowledge', + id: disclaimerAcknowledgeButtonId, + status: 'info', + icon: MynahIcons.OK, + }, + ], +} diff --git a/chat-client/src/ui/walkthrough/agent.ts b/chat-client/src/ui/walkthrough/agent.ts new file mode 100644 index 0000000000..0b77cf4d24 --- /dev/null +++ b/chat-client/src/ui/walkthrough/agent.ts @@ -0,0 +1,201 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItemContent, ChatItemType, MynahIcons, MynahUIDataModel } from '@aws/mynah-ui' + +function createdTabbedData(examples: string[], agent: string): ChatItemContent['tabbedContent'] { + const exampleText = examples.map(example => `- ${example}`).join('\n') + return [ + { + label: 'Examples', + value: 'examples', + content: { + body: `**Example use cases:**\n${exampleText}\n\nEnter ${agent} in Q Chat to get started`, + }, + }, + ] +} + +export const agentWalkthroughDataModel: MynahUIDataModel = { + tabBackground: false, + compactMode: false, + tabTitle: 'Explore', + promptInputVisible: false, + tabHeaderDetails: { + icon: MynahIcons.ASTERISK, + title: 'Amazon Q Developer agents capabilities', + description: '', + }, + chatItems: [ + { + type: ChatItemType.ANSWER, + snapToTop: true, + hoverEffect: true, + body: `### Feature development +Implement features or make changes across your workspace, all from a single prompt. +`, + icon: MynahIcons.CODE_BLOCK, + footer: { + tabbedContent: createdTabbedData( + [ + '/dev update app.py to add a new api', + '/dev fix the error', + '/dev add a new button to sort by ', + ], + '/dev' + ), + }, + buttons: [ + { + status: 'clear', + id: `user-guide-featuredev`, + disabled: false, + text: 'Read user guide', + }, + { + status: 'main', + disabled: false, + flash: 'once', + fillState: 'hover', + icon: MynahIcons.RIGHT_OPEN, + id: 'quick-start-featuredev', + text: `Quick start with **/dev**`, + }, + ], + }, + { + type: ChatItemType.ANSWER, + hoverEffect: true, + body: `### Unit test generation +Automatically generate unit tests for your active file. +`, + icon: MynahIcons.BUG, + footer: { + tabbedContent: createdTabbedData( + ['Generate tests for specific functions', 'Generate tests for null and empty inputs'], + '/test' + ), + }, + buttons: [ + { + status: 'clear', + id: 'user-guide-testgen', + disabled: false, + text: 'Read user guide', + }, + { + status: 'main', + disabled: false, + flash: 'once', + fillState: 'hover', + icon: MynahIcons.RIGHT_OPEN, + id: 'quick-start-testgen', + text: `Quick start with **/test**`, + }, + ], + }, + { + type: ChatItemType.ANSWER, + hoverEffect: true, + body: `### Documentation generation +Create and update READMEs for better documented code. +`, + icon: MynahIcons.CHECK_LIST, + footer: { + tabbedContent: createdTabbedData( + [ + 'Generate new READMEs for your project', + 'Update existing READMEs with recent code changes', + 'Request specific changes to a README', + ], + '/doc' + ), + }, + buttons: [ + { + status: 'clear', + id: 'user-guide-doc', + disabled: false, + text: 'Read user guide', + }, + { + status: 'main', + disabled: false, + flash: 'once', + fillState: 'hover', + icon: MynahIcons.RIGHT_OPEN, + id: 'quick-start-doc', + text: `Quick start with **/doc**`, + }, + ], + }, + { + type: ChatItemType.ANSWER, + hoverEffect: true, + body: `### Code reviews +Review code for issues, then get suggestions to fix your code instantaneously. +`, + icon: MynahIcons.TRANSFORM, + footer: { + tabbedContent: createdTabbedData( + [ + 'Review code for security vulnerabilities and code quality issues', + 'Get detailed explanations about code issues', + 'Apply automatic code fixes to your files', + ], + '/review' + ), + }, + buttons: [ + { + status: 'clear', + id: 'user-guide-review', + disabled: false, + text: 'Read user guide', + }, + { + status: 'main', + disabled: false, + flash: 'once', + fillState: 'hover', + icon: MynahIcons.RIGHT_OPEN, + id: 'quick-start-review', + text: `Quick start with **/review**`, + }, + ], + }, + { + type: ChatItemType.ANSWER, + hoverEffect: true, + body: `### Transformation +Upgrade library and language versions in your codebase. +`, + icon: MynahIcons.TRANSFORM, + footer: { + tabbedContent: createdTabbedData( + ['Upgrade Java language and dependency versions', 'Convert embedded SQL code in Java apps'], + '/transform' + ), + }, + buttons: [ + { + status: 'clear', + id: 'user-guide-gumby', + disabled: false, + text: 'Read user guide', + }, + { + status: 'main', + disabled: false, + flash: 'once', + fillState: 'hover', + icon: MynahIcons.RIGHT_OPEN, + id: 'quick-start-gumby', + text: `Quick start with **/transform**`, + }, + ], + }, + ], +} diff --git a/chat-client/src/ui/walkthrough/welcome.ts b/chat-client/src/ui/walkthrough/welcome.ts new file mode 100644 index 0000000000..a1df4bb814 --- /dev/null +++ b/chat-client/src/ui/walkthrough/welcome.ts @@ -0,0 +1,47 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItemType, MynahIcons, MynahUITabStoreTab } from '@aws/mynah-ui' +import { TabDataGenerator } from '../tabs/generator' + +export const welcomeScreenTabData = (tabs: TabDataGenerator): MynahUITabStoreTab => ({ + isSelected: true, + store: { + quickActionCommands: tabs.quickActionsGenerator.generateForTab('welcome'), + contextCommands: tabs.getTabData('cwc', false).contextCommands, + tabTitle: 'Welcome to Q', + tabBackground: true, + chatItems: [ + { + type: ChatItemType.ANSWER, + icon: MynahIcons.ASTERISK, + messageId: 'new-welcome-card', + body: `#### Work on a task using agentic capabilities +_Generate code, scan for issues, and more._`, + buttons: [ + { + id: 'explore', + disabled: false, + text: 'Explore', + }, + { + id: 'quick-start', + text: 'Quick start', + disabled: false, + status: 'main', + }, + ], + }, + ], + promptInputLabel: 'Or, start a chat', + promptInputPlaceholder: 'Type your question', + compactMode: true, + tabHeaderDetails: { + title: "Hi, I'm Amazon Q.", + description: 'Where would you like to start?', + icon: MynahIcons.Q, + }, + }, +}) diff --git a/client/vscode/package.json b/client/vscode/package.json index b259d55799..1cbf5c9c32 100644 --- a/client/vscode/package.json +++ b/client/vscode/package.json @@ -267,6 +267,7 @@ "compile:chat-client": "npm run compile --prefix ../../chat-client && shx cp -R ../../chat-client/build ." }, "devDependencies": { + "@aws/mynah-ui": "^4.21.3", "@aws-sdk/credential-providers": "^3.614.0", "@aws-sdk/types": "^3.535.0", "@aws/chat-client-ui-types": "^0.1.0", diff --git a/client/vscode/src/chatActivation.ts b/client/vscode/src/chatActivation.ts index d868cda1d2..315060e4c4 100644 --- a/client/vscode/src/chatActivation.ts +++ b/client/vscode/src/chatActivation.ts @@ -55,6 +55,10 @@ export function registerChat(languageClient: LanguageClient, extensionUri: Uri, panel.webview.onDidReceiveMessage(async message => { languageClient.info(`[VSCode Client] Received ${JSON.stringify(message)} from chat`) + if (tryHandleFeatureEvent(message, panel)) { + return + } + switch (message.command) { case COPY_TO_CLIPBOARD: languageClient.info('[VSCode Client] Copy to clipboard event received') @@ -192,13 +196,26 @@ function generateJS(webView: Webview, extensionUri: Uri): string { const assetsPath = Uri.joinPath(extensionUri) const chatUri = Uri.joinPath(assetsPath, 'build', 'amazonq-ui.js') - const entrypoint = webView.asWebviewUri(chatUri) + const chatEntrypoint = webView.asWebviewUri(chatUri) return ` - + ` @@ -221,6 +238,93 @@ function getCommandTriggerType(data: any): string { return data === undefined ? 'hotkeys' : 'contextMenu' } +function tryHandleFeatureEvent(msg: any, panel: WebviewPanel): boolean { + if (!msg.tabType) { + return false + } + + switch (msg.tabType) { + case 'gumby': + handleGumbyEvent(msg, panel) + break + default: + break + } + + return true +} + +function handleGumbyEvent(msg: any, panel: WebviewPanel) { + const sender = 'gumbyChat' + switch (msg.command) { + case 'transform': + handleGumbyTransform(msg, sender, panel) + break + case 'form-action-click': + handleGumbyActionClick(msg, sender, panel) + break + case 'new-tab-was-created': + case 'tab-was-removed': + case 'auth-follow-up-was-clicked': + case 'chat-prompt': + case 'response-body-link-click': + break + } +} + +function handleGumbyActionClick(msg: any, sender: string, panel: WebviewPanel) { + if (msg.action === 'gumbyStartTransformation') { + handleGumbyClear(msg, sender, panel) + handleGumbyTransform(msg, sender, panel) + } +} + +function handleGumbyClear(msg: any, sender: string, panel: WebviewPanel) { + panel.webview.postMessage({ + command: 'aws.awsq.clearchat', + sender: sender, + tabID: msg.tabID, + type: 'sendCommandMessage', + }) +} + +function handleGumbyTransform(msg: any, sender: string, panel: WebviewPanel) { + panel.webview.postMessage({ + buttons: [], + inProgress: true, + messageType: 'answer-part', + status: 'info', + sender: sender, + tabID: msg.tabID, + type: 'asyncEventProgressMessage', + }) + panel.webview.postMessage({ + buttons: [], + inProgress: true, + messageType: 'answer-part', + message: 'I am checking for open projects that are eligible for transformation...', + status: 'info', + sender: sender, + tabID: msg.tabID, + type: 'asyncEventProgressMessage', + }) + panel.webview.postMessage({ + buttons: [ + { + id: 'gumbyStartTransformation', + keepCardAfterClick: false, + text: 'Start a new transformation', + }, + ], + messageType: 'ai-prompt', + message: "Sorry, I couldn't find a project that I can upgrade...", + status: 'info', + sender: sender, + tabID: msg.tabID, + type: 'chatMessage', + }) +} + function registerGenericCommand(commandName: string, genericCommand: string, panel: WebviewPanel) { commands.registerCommand(commandName, data => { const triggerType = getCommandTriggerType(data) diff --git a/package-lock.json b/package-lock.json index bbe3931cdf..0e35eaa62d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -247,6 +247,7 @@ "@aws-sdk/types": "^3.535.0", "@aws/chat-client-ui-types": "^0.1.0", "@aws/language-server-runtimes": "^0.2.31", + "@aws/mynah-ui": "^4.21.3", "@types/uuid": "^9.0.8", "@types/vscode": "^1.96.0", "jose": "^5.2.4",