diff --git a/.vscode/launch.json b/.vscode/launch.json index 3c19150d1..d7c7ebaf6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -90,8 +90,8 @@ "ENABLE_CUSTOMIZATIONS": "true" // "HTTPS_PROXY": "http://127.0.0.1:8888", // "AWS_CA_BUNDLE": "/path/to/cert.pem" - }, - "preLaunchTask": "npm: compile" + } + // "preLaunchTask": "npm: compile" }, { "name": "CodeWhisperer Server IAM", diff --git a/chat-client/src/client/chat.ts b/chat-client/src/client/chat.ts index db7038da4..99ba71b0a 100644 --- a/chat-client/src/client/chat.ts +++ b/chat-client/src/client/chat.ts @@ -44,6 +44,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 '../connectors/connector' const DEFAULT_TAB_DATA = { tabTitle: 'Chat', @@ -56,19 +57,29 @@ type ChatClientConfig = Pick export const createChat = ( clientApi: { postMessage: (msg: UiMessage | ServerMessage) => void }, - config?: ChatClientConfig + config?: ChatClientConfig, + supportFeaturesThroughConnectors: boolean = false, + connectorsConfig?: ChatClientConfig ) => { // eslint-disable-next-line semi let mynahApi: InboundChatApi + let connector: Connector | undefined const sendMessageToClient = (message: UiMessage | ServerMessage) => { clientApi.postMessage(message) } - const handleMessage = (event: MessageEvent): void => { + const handleMessage = async (event: MessageEvent): Promise => { + console.log('Received message from IDE: ', event.data) if (event.data === undefined) { return } + + // NOTE: 01. Route incoming messages + if (await connector?.tryHandleMessageReceive(event)) { + return + } + const message = event.data switch (message?.command) { @@ -86,15 +97,13 @@ export const createChat = ( break case CHAT_OPTIONS: { const params = (message as ChatOptionsMessage).params - const chatConfig: ChatClientConfig = params?.quickActions?.quickActionsCommandGroups - ? { quickActionCommands: params.quickActions.quickActionsCommandGroups } - : {} - - 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 } @@ -157,14 +166,22 @@ export const createChat = ( } const messager = new Messager(chatApi) - const tabFactory = new TabFactory({ - ...DEFAULT_TAB_DATA, - ...(config?.quickActionCommands ? { quickActionCommands: config.quickActionCommands } : {}), - }) - - const [mynahUi, api] = createMynahUi(messager, tabFactory) + const tabFactory = new TabFactory(DEFAULT_TAB_DATA, [ + ...(config?.quickActionCommands ? config.quickActionCommands : []), + ...(connectorsConfig?.quickActionCommands ? connectorsConfig.quickActionCommands : []), + ]) + const [mynahUi, api, featuresConnector] = createMynahUi( + messager, + tabFactory, + // NOTE 03: below is for connectors only + // messages sent to connector apps can be differentiated by `tabType` present on level 0, + // extensions will have to be aware of this + supportFeaturesThroughConnectors, + clientApi.postMessage + ) mynahApi = api + connector = featuresConnector return mynahUi } diff --git a/chat-client/src/client/mynahUi.test.ts b/chat-client/src/client/mynahUi.test.ts index 041ba57b5..3d54c10c9 100644 --- a/chat-client/src/client/mynahUi.test.ts +++ b/chat-client/src/client/mynahUi.test.ts @@ -46,7 +46,7 @@ describe('MynahUI', () => { const tabFactory = new TabFactory({}) createTabStub = sinon.stub(tabFactory, 'createTab') createTabStub.returns({}) - const mynahUiResult = createMynahUi(messager, tabFactory) + const mynahUiResult = createMynahUi(messager, tabFactory, false) mynahUi = mynahUiResult[0] inboundChatApi = mynahUiResult[1] getSelectedTabIdStub = sinon.stub(mynahUi, 'getSelectedTabId') diff --git a/chat-client/src/client/mynahUi.ts b/chat-client/src/client/mynahUi.ts index 9d44db7aa..c5e46c083 100644 --- a/chat-client/src/client/mynahUi.ts +++ b/chat-client/src/client/mynahUi.ts @@ -20,10 +20,20 @@ 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, + MynahUIProps, + NotificationType, +} from '@aws/mynah-ui' import { VoteParams } from '../contracts/telemetry' import { Messager } from './messager' import { TabFactory } from './tabs/tabFactory' +import { Connector } from '../connectors/connector' +import { connectorFactory } from '../connectors/connectorFactory' export interface InboundChatApi { addChatResponse(params: ChatResult, tabId: string, isPartialResult: boolean): void @@ -83,10 +93,15 @@ export const handleChatPrompt = ( }) } -export const createMynahUi = (messager: Messager, tabFactory: TabFactory): [MynahUI, InboundChatApi] => { +export const createMynahUi = ( + messager: Messager, + tabFactory: TabFactory, + supportFeaturesThroughConnectors: boolean, + connectorsPostMessage?: (msg: any) => void +): [MynahUI, InboundChatApi, Connector | undefined] => { const initialTabId = TabFactory.generateUniqueId() - const mynahUi = new MynahUI({ + let mynahUiProps: MynahUIProps = { onCodeInsertToCursorPosition( tabId, messageId, @@ -250,7 +265,17 @@ export const createMynahUi = (messager: Messager, tabFactory: TabFactory): [Myna maxTabs: 10, texts: uiComponentsTexts, }, - }) + } + + const mynahUiRef = { mynahUI: undefined as MynahUI | undefined } + let featuresConnector: Connector | undefined + if (supportFeaturesThroughConnectors) { + // NOTE: 00. Extend MynahUI with connector-specific handlers + ;[featuresConnector, mynahUiProps] = connectorFactory(mynahUiProps, mynahUiRef, connectorsPostMessage!) + } + + const mynahUi = new MynahUI(mynahUiProps) + mynahUiRef.mynahUI = mynahUi const getTabStore = (tabId = mynahUi.getSelectedTabId()) => { return tabId ? mynahUi.getAllTabs()[tabId]?.store : undefined @@ -385,7 +410,7 @@ ${params.message}`, showError: showError, } - return [mynahUi, api] + return [mynahUi, api, featuresConnector] } export const DEFAULT_HELP_PROMPT = 'What can Amazon Q help me with?' diff --git a/chat-client/src/client/tabs/tabFactory.ts b/chat-client/src/client/tabs/tabFactory.ts index 37bc94982..a86437767 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' export type DefaultTabData = MynahUIDataModel @@ -10,11 +10,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): MynahUIDataModel { const tabData: MynahUIDataModel = { - ...this.defaultTabData, + ...this.getDefaultTabData(), chatItems: needWelcomeMessages ? [ { @@ -33,12 +36,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/connectors/apps/baseConnector.ts b/chat-client/src/connectors/apps/baseConnector.ts new file mode 100644 index 000000000..0646a761b --- /dev/null +++ b/chat-client/src/connectors/apps/baseConnector.ts @@ -0,0 +1,290 @@ +/*! + * 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 '../connector' +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(), + }) + } + + 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/connectors/apps/gumbyChatConnector.ts b/chat-client/src/connectors/apps/gumbyChatConnector.ts new file mode 100644 index 000000000..2b31b2037 --- /dev/null +++ b/chat-client/src/connectors/apps/gumbyChatConnector.ts @@ -0,0 +1,180 @@ +/*! + * 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 '../contracts/gumbyContracts' +import { ChatPayload } from '../connector' +import { BaseConnector, BaseConnectorProps } from './baseConnector' + +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 // TODO: Requires investigation + } + + 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/connectors/commands.ts b/chat-client/src/connectors/commands.ts new file mode 100644 index 000000000..94e1fddb2 --- /dev/null +++ b/chat-client/src/connectors/commands.ts @@ -0,0 +1,40 @@ +/*! + * 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' + | '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' + +export type ExtensionMessage = Record & { command: MessageCommand } diff --git a/chat-client/src/connectors/connector.ts b/chat-client/src/connectors/connector.ts new file mode 100644 index 000000000..6057238ca --- /dev/null +++ b/chat-client/src/connectors/connector.ts @@ -0,0 +1,124 @@ +import { ChatItem } from '@aws/mynah-ui' +import { BaseConnectorProps } from './apps/baseConnector' +import { ConnectorProps as GumbyConnectorProps, Connector as GumbyChatConnector } from './apps/gumbyChatConnector' +import { TabsStorage } from './storages/tabsStorage' +import { UserIntent } from '@amzn/codewhisperer-streaming' + +export interface ChatPayload { + chatMessage: string + chatCommand?: string +} + +export interface CWCChatItem extends ChatItem { + traceId?: string + userIntent?: UserIntent + codeBlockLanguage?: string +} + +export interface CodeReference { + licenseName?: string + repository?: string + url?: string + recommendationContentSpan?: { + start?: number + end?: number + } +} + +// NOTE: This class would NOT be an exact copy, e.g.: +// - it will be adapted to the fact that it needs to pick only feature-specific incoming messages +// (handleMessageReceive -> tryHandleMessageReceive) +// - case with onTabChange and `prevTabID` below +export class Connector { + private readonly gumbyChatConnector: GumbyChatConnector + private readonly tabsStorage: TabsStorage + + constructor(tabsStorage: TabsStorage, props: BaseConnectorProps & GumbyConnectorProps) { + this.gumbyChatConnector = new GumbyChatConnector(props) + this.tabsStorage = tabsStorage + } + + tryHandleMessageReceive = async (message: MessageEvent): Promise => { + const messageData = message.data + if (!messageData?.sender) { + return false + } + + if (messageData.sender === 'gumbyChat') { + await this.gumbyChatConnector.handleMessageReceive(messageData) + } + + // Reset lastCommand after message is rendered. + this.tabsStorage.updateTabLastCommand(messageData.tabID, '') + + return true + } + + requestAnswer = (tabID: string, payload: ChatPayload) => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'gumby': + return this.gumbyChatConnector.requestAnswer(tabID, payload) + } + } + + transform = (tabID: string): void => { + this.gumbyChatConnector.transform(tabID) + } + + clearChat = (tabID: string): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'gumby': + this.gumbyChatConnector.clearChat(tabID) + break + } + } + + onCustomFormAction = (tabId: string, action: any): void | undefined => { + switch (this.tabsStorage.getTab(tabId)?.type) { + case 'gumby': + this.gumbyChatConnector.onCustomFormAction(tabId, action) + break + // NOTE: below code would need to migrate to base chat to reach feature parity + // case 'cwc': + // if (action.id === `open-settings`) { + // this.sendMessageToExtension({ + // command: 'open-settings', + // type: '', + // tabType: 'cwc', + // }) + // } + } + } + + onUpdateTabType = (tabID: string) => { + const tab = this.tabsStorage.getTab(tabID) + switch (tab?.type) { + case 'gumby': + this.gumbyChatConnector.onTabAdd(tabID) + break + } + } + + onTabRemove = (tabID: string): void => { + const tab = this.tabsStorage.getTab(tabID) + this.tabsStorage.deleteTab(tabID) + switch (tab?.type) { + case 'gumby': + this.gumbyChatConnector.onTabRemove(tabID) + break + } + } + + onTabChange = (tabId: string): void => { + // NOTE: 'prevTabID' for transform is always undefined + // const prevTabID = this.tabsStorage.setSelectedTab(tabId) + this.gumbyChatConnector.onTabChange(tabId, undefined) + } + + onResponseBodyLinkClick = (tabID: string, messageId: string, link: string): void => { + switch (this.tabsStorage.getTab(tabID)?.type) { + case 'gumby': + this.gumbyChatConnector.onResponseBodyLinkClick(tabID, messageId, link) + } + } +} diff --git a/chat-client/src/connectors/connectorFactory.ts b/chat-client/src/connectors/connectorFactory.ts new file mode 100644 index 000000000..5ad03b3ea --- /dev/null +++ b/chat-client/src/connectors/connectorFactory.ts @@ -0,0 +1,317 @@ +import { ChatItem, ChatItemType, MynahIcons, MynahUI, MynahUIProps } from '@aws/mynah-ui' +import { Connector, CWCChatItem } from './connector' +import { TabsStorage } from './storages/tabsStorage' +import { QuickActionHandler } from './quickActions/handler' + +export const connectorFactory = ( + mynahUiProps: MynahUIProps, + ref: { mynahUI: MynahUI | undefined }, + ideApiPostMessage: (msg: any) => void +): [Connector | undefined, MynahUIProps] => { + const tabsStorage = new TabsStorage() + // eslint-disable-next-line prefer-const + let quickActionHandler: QuickActionHandler + + 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 + } + function getQuickActionHandler() { + if (!quickActionHandler) { + quickActionHandler = new QuickActionHandler({ + mynahUI: ref.mynahUI!, + connector: featuresConnector, + tabsStorage, + }) + } + + return quickActionHandler + } + + // NOTE: 02. Route events from connectors (triggered by business logic / extension) to UI + const featuresConnector = new Connector( + tabsStorage, + // Defines features-specific handlers + { + tabsStorage, + onUpdatePlaceholder(tabID: string, newPlaceholder: string) { + ref.mynahUI!.updateStore(tabID, { + promptInputPlaceholder: newPlaceholder, + }) + }, + onChatInputEnabled: (tabID: string, enabled: boolean) => { + ref.mynahUI!.updateStore(tabID, { + promptInputDisabledState: tabsStorage.isTabDead(tabID) || !enabled, + }) + }, + onUpdateAuthentication: (isAmazonQEnabled: boolean, authenticatingTabIDs: string[]): void => { + // TODO + }, + onQuickHandlerCommand: (tabID: string, command?: string, eventId?: string) => { + tabsStorage.updateTabLastCommand(tabID, command) + if (command === 'aws.awsq.transform') { + quickActionHandler.handle({ command: '/transform' }, tabID, eventId) + } else if (command === 'aws.awsq.clearchat') { + quickActionHandler.handle({ command: '/clear' }, tabID) + } + }, + onChatAnswerUpdated: (tabID: string, item: ChatItem) => { + if (item.messageId !== undefined) { + ref.mynahUI!.updateChatAnswerWithMessageId(tabID, item.messageId, { + ...(item.body !== undefined ? { body: item.body } : {}), + ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), + ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), + }) + } else { + ref.mynahUI!.updateLastChatAnswer(tabID, { + ...(item.body !== undefined ? { body: item.body } : {}), + ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), + ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), + }) + } + }, + onAsyncEventProgress: ( + tabID: string, + inProgress: boolean, + message: string | undefined, + messageId: string | undefined = undefined, + enableStopAction: boolean = false + ) => { + if (inProgress) { + ref.mynahUI!.updateStore(tabID, { + loadingChat: true, + promptInputDisabledState: true, + cancelButtonWhenLoading: enableStopAction, + }) + + if (message && messageId) { + ref.mynahUI!.updateChatAnswerWithMessageId(tabID, messageId, { + body: message, + }) + } else if (message) { + ref.mynahUI!.updateLastChatAnswer(tabID, { + body: message, + }) + } else { + ref.mynahUI!.addChatItem(tabID, { + type: ChatItemType.ANSWER_STREAM, + body: '', + messageId: messageId, + }) + } + tabsStorage.updateTabStatus(tabID, 'busy') + return + } + + ref.mynahUI!.updateStore(tabID, { + loadingChat: false, + promptInputDisabledState: tabsStorage.isTabDead(tabID), + }) + tabsStorage.updateTabStatus(tabID, 'free') + }, + onOpenSettingsMessage(tabId: string) { + ref.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') + ref.mynahUI!.updateStore(tabId, { + loadingChat: false, + promptInputDisabledState: tabsStorage.isTabDead(tabId), + }) + return + }, + onError: (tabID: string, message: string, title: string) => { + // TODO + }, + onWarning: (tabID: string, message: string, title: string) => { + // TODO + }, + onChatAnswerReceived: (tabID: string, item: CWCChatItem, messageData: any) => { + if (item.type === ChatItemType.ANSWER_PART || item.type === ChatItemType.CODE_RESULT) { + ref.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 + ) { + ref.mynahUI!.addChatItem(tabID, { + ...item, + messageId: item.messageId, + codeBlockActions: { + // TODO: Requires investigation: shouldDisplayDiff is for basic chat, + // but we do not have that functionality in basic chat + ...(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 + ) { + ref.mynahUI!.updateStore(tabID, { + loadingChat: true, + cancelButtonWhenLoading: false, + promptInputDisabledState: true, + }) + + tabsStorage.updateTabStatus(tabID, 'busy') + return + } + + if (item.type === ChatItemType.ANSWER) { + ref.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(), + }) + } + }, + sendMessageToExtension: message => { + ideApiPostMessage(message) + }, + } + ) + + // NOTE: 03. Route actions in UI to connector or base chat + // Adapter: override generic MynahUI handlers with feature-specific functionality + const connectorMynahUiProps: MynahUIProps = { + ...mynahUiProps, + onChatPrompt(tabId, prompt, eventId) { + if (tabsStorage.getTab(tabId)?.type === 'gumby') { + featuresConnector.requestAnswer(tabId, { + chatMessage: prompt.prompt ?? '', + }) + return + } + + if (prompt.command?.trim() === '/transform') { + getQuickActionHandler().handle(prompt, tabId, eventId) + return + } + + mynahUiProps.onChatPrompt?.(tabId, prompt, eventId) + }, + onInBodyButtonClicked(tabId, messageId, action, eventId) { + if (tabsStorage.getTab(tabId)?.type === 'gumby') { + featuresConnector.onCustomFormAction(tabId, action) + return + } + + mynahUiProps.onInBodyButtonClicked?.(tabId, messageId, action, eventId) + }, + onCustomFormAction(tabId, action, eventId) { + if (tabsStorage.getTab(tabId)?.type === 'gumby') { + featuresConnector.onCustomFormAction(tabId, action) + return + } + + mynahUiProps.onCustomFormAction?.(tabId, action, eventId) + }, + onTabRemove(tabId) { + if (tabsStorage.getTab(tabId)?.type === 'gumby') { + featuresConnector.onTabRemove(tabId) + return + } + + mynahUiProps.onTabRemove?.(tabId) + }, + onTabChange(tabId) { + if (tabsStorage.getTab(tabId)?.type === 'gumby') { + featuresConnector.onTabChange(tabId) + return + } + + mynahUiProps.onTabChange?.(tabId) + }, + onLinkClick(tabId, messageId, link, mouseEvent) { + if (tabsStorage.getTab(tabId)?.type === 'gumby') { + mouseEvent?.preventDefault() + mouseEvent?.stopPropagation() + mouseEvent?.stopImmediatePropagation() + featuresConnector.onResponseBodyLinkClick(tabId, messageId, link) + return + } + + mynahUiProps.onLinkClick?.(tabId, messageId, link, mouseEvent) + }, + } + + return [featuresConnector, connectorMynahUiProps] +} diff --git a/chat-client/src/connectors/contracts/gumbyContracts.ts b/chat-client/src/connectors/contracts/gumbyContracts.ts new file mode 100644 index 000000000..2f63da829 --- /dev/null +++ b/chat-client/src/connectors/contracts/gumbyContracts.ts @@ -0,0 +1,10 @@ +export type GumbyMessageType = + | 'errorMessage' + | 'asyncEventProgressMessage' + | 'authenticationUpdateMessage' + | 'authNeededException' + | 'chatPrompt' + | 'chatMessage' + | 'chatInputEnabledMessage' + | 'sendCommandMessage' + | 'updatePlaceholderMessage' diff --git a/chat-client/src/connectors/followUps/generator.ts b/chat-client/src/connectors/followUps/generator.ts new file mode 100644 index 000000000..597082f3d --- /dev/null +++ b/chat-client/src/connectors/followUps/generator.ts @@ -0,0 +1,63 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MynahIcons, ChatItemAction } from '@aws/mynah-ui' +import { TabType } from '../storages/tabsStorage' + +export type AuthFollowUpType = 'full-auth' | 're-auth' | 'missing_scopes' | 'use-supported-auth' + +export interface FollowUpsBlock { + text?: string + options?: ChatItemAction[] +} + +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 { + 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/connectors/quickActions/generator.ts b/chat-client/src/connectors/quickActions/generator.ts new file mode 100644 index 000000000..085df9af1 --- /dev/null +++ b/chat-client/src/connectors/quickActions/generator.ts @@ -0,0 +1,66 @@ +/*! + * 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' + +export interface QuickActionGeneratorProps { + isFeatureDevEnabled: boolean + isGumbyEnabled: boolean + disableCommands?: string[] +} + +export class QuickActionGenerator { + constructor() {} + + public generateForTab(tabType: TabType): QuickActionCommandGroup[] { + const quickActionCommands = [ + { + commands: [ + ...[ + { + command: '/transform', + description: 'Transform your Java project', + }, + ], + ], + }, + ].filter(section => section.commands.length > 0) + + const commandUnavailability: Record< + TabType, + { + description: string + unavailableItems: string[] + } + > = { + gumby: { + description: "This command isn't available in /transform", + unavailableItems: ['/dev', '/transform'], + }, + unknown: { + description: '', + unavailableItems: [], + }, + } + + return quickActionCommands.map(commandGroup => { + return { + 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/connectors/quickActions/handler.ts b/chat-client/src/connectors/quickActions/handler.ts new file mode 100644 index 000000000..653da7a70 --- /dev/null +++ b/chat-client/src/connectors/quickActions/handler.ts @@ -0,0 +1,120 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatPrompt, MynahUI, NotificationType } from '@aws/mynah-ui' +import { TabDataGenerator } from '../tabs/generator' +import { Connector } from '../connector' +import { TabsStorage } from '../storages/tabsStorage' +import { uiComponentsTexts } from '../texts/constants' + +export interface QuickActionsHandlerProps { + mynahUI: MynahUI + connector: Connector + tabsStorage: TabsStorage +} + +export class QuickActionHandler { + private mynahUI: MynahUI + private connector: Connector + private tabsStorage: TabsStorage + private tabDataGenerator: TabDataGenerator + + constructor(props: QuickActionsHandlerProps) { + this.mynahUI = props.mynahUI + this.connector = props.connector + this.tabsStorage = props.tabsStorage + this.tabDataGenerator = new TabDataGenerator() + } + + public handle(chatPrompt: ChatPrompt, tabID: string, eventId?: string) { + this.tabsStorage.resetTabTimer(tabID) + switch (chatPrompt.command) { + case '/transform': + this.handleGumbyCommand(tabID, eventId) + break + case '/clear': + this.handleClearCommand(tabID) + break + } + } + + private handleClearCommand(tabID: string) { + this.mynahUI.updateStore(tabID, { + chatItems: [], + }) + this.connector.clearChat(tabID) + } + + private handleGumbyCommand(tabID: string, eventId: string | undefined) { + 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 || '') + // NOTE: below creates dup events, probably in vscode too? + // this.connector.onTabChange(gumbyTabId) + return + } + + let affectedTabId: string | undefined = tabID + // if there is no gumby tab, open a new one + if (this.tabsStorage.getTab(affectedTabId)?.type !== 'unknown') { + // NOTE: Workaround to reuse 'welcome' tab for gumby + // in vscode this logic is based on 'welcome' tab type + const currTab = this.mynahUI.getAllTabs()[affectedTabId] + const currTabWasUsed = (currTab.store?.chatItems?.filter(item => item.type === 'prompt').length ?? 0) > 0 + if (currTabWasUsed) { + // open new tab + affectedTabId = this.mynahUI.updateStore('', { + loadingChat: true, + cancelButtonWhenLoading: false, + }) + } + + // NOTE: Adding tab to the storage here, rather than in MynahUIProps, + // it ensures only "gumby" tabs in the legacy tabStorage + 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) // featuredev + 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) + } + } +} diff --git a/chat-client/src/connectors/storages/tabsStorage.ts b/chat-client/src/connectors/storages/tabsStorage.ts new file mode 100644 index 000000000..9fa746ee4 --- /dev/null +++ b/chat-client/src/connectors/storages/tabsStorage.ts @@ -0,0 +1,135 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export type TabStatus = 'free' | 'busy' | 'dead' +export type TabType = 'gumby' | 'unknown' +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') { + 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/connectors/tabs/generator.ts b/chat-client/src/connectors/tabs/generator.ts new file mode 100644 index 000000000..70d1299e4 --- /dev/null +++ b/chat-client/src/connectors/tabs/generator.ts @@ -0,0 +1,79 @@ +/*! + * 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 { QuickActionCommandGroup } from '@aws/mynah-ui' + +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 beginning of your message to include your entire workspace as context.`, + contextCommands: [workspaceCommand], +} + +export const TabTypeDataMap: Record = { + unknown: commonTabData, + gumby: { + title: 'Q - Code Transformation', + placeholder: 'Open a new tab to chat with Q', + welcome: 'Welcome to Code Transformation!', + }, +} + +export class TabDataGenerator { + private followUpsGenerator: FollowUpGenerator + public quickActionsGenerator: QuickActionGenerator + + constructor() { + this.followUpsGenerator = new FollowUpGenerator() + this.quickActionsGenerator = new QuickActionGenerator() + } + + public getTabData(tabType: TabType, needWelcomeMessages: boolean, taskName?: string): MynahUIDataModel { + 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/connectors/texts/constants.ts b/chat-client/src/connectors/texts/constants.ts new file mode 100644 index 000000000..673057666 --- /dev/null +++ b/chat-client/src/connectors/texts/constants.ts @@ -0,0 +1,36 @@ +/*! + * 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 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' diff --git a/client/vscode/src/chatActivation.ts b/client/vscode/src/chatActivation.ts index 3ce91f93c..33b6be9fa 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') @@ -197,8 +201,21 @@ function generateJS(webView: Webview, extensionUri: Uri): string { 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)