diff --git a/ee/app/assets/javascripts/ai/components/ai_genie_chat.vue b/ee/app/assets/javascripts/ai/components/ai_genie_chat.vue index b28e3b9e7bc93f4aa01e591236af8390ae7c54d1..24ae0e0bcaf68f98e6308ba41003d7bc1452933e 100644 --- a/ee/app/assets/javascripts/ai/components/ai_genie_chat.vue +++ b/ee/app/assets/javascripts/ai/components/ai_genie_chat.vue @@ -12,11 +12,12 @@ import { GlFormText, } from '@gitlab/ui'; import { throttle } from 'lodash'; -import { renderMarkdown } from '~/notes/utils'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import { i18n, GENIE_CHAT_MODEL_ROLES } from '../constants'; +import { i18n, GENIE_CHAT_RESET_MESSAGE } from '../constants'; import AiGenieLoader from './ai_genie_loader.vue'; import AiPredefinedPrompts from './ai_predefined_prompts.vue'; +import AiGenieChatConversation from './ai_genie_chat_conversation.vue'; export default { name: 'AiGenieChat', @@ -32,10 +33,12 @@ export default { GlFormText, AiGenieLoader, AiPredefinedPrompts, + AiGenieChatConversation, }, directives: { SafeHtml, }, + mixins: [glFeatureFlagMixin()], props: { messages: { type: Array, @@ -82,27 +85,46 @@ export default { hasMessages() { return this.messages.length > 0; }, + conversations() { + if (!this.hasMessages) { + return []; + } + + let conversationIndex = 0; + const conversations = [[]]; + + this.messages.forEach((message) => { + if (message.content === GENIE_CHAT_RESET_MESSAGE) { + conversationIndex += 1; + conversations[conversationIndex] = []; + } else { + conversations[conversationIndex].push(message); + } + }); + + return conversations; + }, + resetDisabled() { + if (this.isLoading || !this.hasMessages) { + return true; + } + + const lastMessage = this.messages[this.messages.length - 1]; + return lastMessage.content === GENIE_CHAT_RESET_MESSAGE; + }, }, watch: { - async isLoading() { + isLoading() { this.isHidden = false; - await this.$nextTick(); - if (this.$refs.lastMessage?.length) { - this.$refs.lastMessage - .at(0) - .scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }); - } + this.scrollToBottom(); }, - messages() { + async messages() { + await this.$nextTick(); this.prompt = ''; }, }, - async mounted() { - await this.$nextTick(); - - if (this.$refs.drawer) { - this.$refs.drawer.scrollTop = this.$refs.drawer.scrollHeight; - } + mounted() { + this.scrollToBottom(); }, methods: { hideChat() { @@ -111,6 +133,9 @@ export default { }, sendChatPrompt() { if (this.prompt) { + if (this.prompt === GENIE_CHAT_RESET_MESSAGE && this.resetDisabled) { + return; + } this.$emit('send-chat-prompt', this.prompt); } }, @@ -118,27 +143,18 @@ export default { this.prompt = prompt; this.sendChatPrompt(); }, - getPromptLocation(index) { - return index ? 'after_content' : 'before_content'; - }, - isLastMessage(index) { - return index === this.messages.length - 1; - }, - isAssistantMessage(message) { - return message.role.toLowerCase() === GENIE_CHAT_MODEL_ROLES.assistant; - }, - isUserMessage(message) { - return message.role.toLowerCase() === GENIE_CHAT_MODEL_ROLES.user; - }, - getMessageContent(message) { - return renderMarkdown(message.content || message.errors[0]); - }, handleScrolling: throttle(function handleScrollingDebounce() { const { scrollTop, offsetHeight, scrollHeight } = this.$refs.drawer; this.scrolledToBottom = scrollTop + offsetHeight >= scrollHeight; }), - renderMarkdown, + async scrollToBottom() { + await this.$nextTick(); + + if (this.$refs.drawer) { + this.$refs.drawer.scrollTop = this.$refs.drawer.scrollHeight; + } + }, }, i18n, }; @@ -207,32 +223,20 @@ export default { }, ]" > - <template v-if="hasMessages || isLoading"> - <div - v-for="(message, index) in messages" - :key="`${message.role}-${index}`" - :ref="isLastMessage(index) ? 'lastMessage' : undefined" - class="gl-py-3 gl-px-4 gl-mb-4 gl-rounded-lg gl-line-height-20 ai-genie-chat-message" - :class="{ - 'gl-ml-auto gl-bg-blue-100 gl-text-blue-900 gl-rounded-bottom-right-none': isUserMessage( - message, - ), - 'gl-rounded-bottom-left-none gl-text-gray-900 gl-bg-gray-50': isAssistantMessage( - message, - ), - 'gl-mb-0!': isLastMessage(index) && !isLoading, - }" - > - <div v-safe-html="getMessageContent(message)"></div> - <slot - v-if="isAssistantMessage(message)" - name="feedback" - :prompt-location="getPromptLocation(index)" - :message="message" - ></slot> - </div> - </template> - <template v-else> + <ai-genie-chat-conversation + v-for="(conversation, index) in conversations" + :key="`conversation-${index}`" + :messages="conversation" + :is-loading="isLoading" + :show-delimiter="index > 0" + class="gl-display-flex gl-flex-direction-column gl-justify-content-end" + > + <template #feedback="{ message, promptLocation }"> + <slot name="feedback" :prompt-location="promptLocation" :message="message"></slot> + </template> + </ai-genie-chat-conversation> + + <template v-if="!hasMessages && !isLoading"> <div key="empty-state" class="gl-display-flex gl-flex-grow-1"> <gl-empty-state :svg-path="emptySvgPath" @@ -270,7 +274,7 @@ export default { class="gl-drawer-footer gl-drawer-footer-sticky gl-p-5 gl-border-t gl-bg-white" :class="{ 'gl-drawer-body-scrim-on-footer': !scrolledToBottom }" > - <gl-form @submit.stop.prevent="sendChatPrompt"> + <gl-form data-testid="chat-prompt-form" @submit.stop.prevent="sendChatPrompt"> <gl-form-input-group> <div class="ai-genie-chat-input gl-flex-grow-1 gl-vertical-align-top gl-max-w-full gl-min-h-8 gl-inset-border-1-gray-400 gl-rounded-base" diff --git a/ee/app/assets/javascripts/ai/components/ai_genie_chat_conversation.vue b/ee/app/assets/javascripts/ai/components/ai_genie_chat_conversation.vue new file mode 100644 index 0000000000000000000000000000000000000000..e18d59c20f63c6bb37ed9f9ccb53694bff352e24 --- /dev/null +++ b/ee/app/assets/javascripts/ai/components/ai_genie_chat_conversation.vue @@ -0,0 +1,84 @@ +<script> +import { renderMarkdown } from '~/notes/utils'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { GENIE_CHAT_MODEL_ROLES, i18n } from '../constants'; + +export default { + name: 'AiGenieChatConversation', + directives: { + SafeHtml, + }, + props: { + messages: { + type: Array, + required: false, + default: () => [], + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + showDelimiter: { + type: Boolean, + required: false, + default: true, + }, + }, + methods: { + isLastMessage(index) { + return index === this.messages.length - 1; + }, + isAssistantMessage(message) { + return message.role.toLowerCase() === GENIE_CHAT_MODEL_ROLES.assistant; + }, + isUserMessage(message) { + return message.role.toLowerCase() === GENIE_CHAT_MODEL_ROLES.user; + }, + getMessageContent(message) { + return renderMarkdown(message.content || message.errors[0]); + }, + getPromptLocation(index) { + return index ? 'after_content' : 'before_content'; + }, + renderMarkdown, + }, + i18n, +}; +</script> +<template> + <div class="gl-my-5"> + <template v-if="showDelimiter"> + <div + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-gap-4 gl-mb-5 gl-mt-n5" + data-testid="conversation-delimiter" + > + <hr class="gl-my-5 gl-flex-grow-1" /> + <span>{{ $options.i18n.GENIE_CHAT_NEW_CHAT }}</span> + <hr class="gl-my-5 gl-flex-grow-1" /> + </div> + </template> + + <div + v-for="(message, index) in messages" + :key="`${message.role}-${index}`" + :ref="isLastMessage(index) ? 'lastMessage' : undefined" + class="gl-py-3 gl-px-4 gl-mb-4 gl-rounded-lg gl-line-height-20 ai-genie-chat-message" + :class="{ + 'gl-ml-auto gl-bg-blue-100 gl-text-blue-900 gl-rounded-bottom-right-none': isUserMessage( + message, + ), + 'gl-rounded-bottom-left-none gl-text-gray-900 gl-bg-gray-50': isAssistantMessage(message), + 'gl-mb-0!': isLastMessage(index) && !isLoading, + }" + > + <div v-safe-html="getMessageContent(message)"></div> + <slot + v-if="isAssistantMessage(message)" + name="feedback" + :prompt-location="getPromptLocation(index)" + :message="message" + ></slot> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/ai/constants.js b/ee/app/assets/javascripts/ai/constants.js index 43a5cdc290d672fac97bd64c53884b273f919895..dc1171d2070f7ab1fd9840604b36d8c23acb31b3 100644 --- a/ee/app/assets/javascripts/ai/constants.js +++ b/ee/app/assets/javascripts/ai/constants.js @@ -28,6 +28,7 @@ export const i18n = { GENIE_CHAT_LEGAL_DISCLAIMER: s__( "AI|May provide inappropriate responses not representative of GitLab's views. Do not input personal data.", ), + GENIE_CHAT_NEW_CHAT: s__('AI|New chat'), }; export const TOO_LONG_ERROR_TYPE = 'too-long'; export const AI_GENIE_DEBOUNCE = 300; @@ -56,3 +57,4 @@ export const FEEDBACK_OPTIONS = [ ]; export const EXPLAIN_CODE_TRACKING_EVENT_NAME = 'explain_code_blob_viewer'; +export const GENIE_CHAT_RESET_MESSAGE = '/reset'; diff --git a/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue b/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue index 3cfc7809038e2a12a403e99c8be8dc6210c63e16..b0ab01efb730eb040ff7340c42f8079fd7e71bdf 100644 --- a/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue +++ b/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue @@ -94,6 +94,7 @@ export default { 'sendUserMessage', 'receiveTanukiBotMessage', 'tanukiBotMessageError', + 'receiveMutationResponse', 'setMessages', ]), sendMessage(question) { @@ -107,6 +108,9 @@ export default { resourceId: this.resourceId || this.userId, }, }) + .then(({ data }) => { + this.receiveMutationResponse({ data, message: question }); + }) .catch(() => { this.tanukiBotMessageError(); }); diff --git a/ee/app/assets/javascripts/ai/tanuki_bot/store/actions.js b/ee/app/assets/javascripts/ai/tanuki_bot/store/actions.js index 533b4d6655ffcce5094c6015da07c662c7676ce6..d8d9a70447edc1b67b5e50f446945be64a63038d 100644 --- a/ee/app/assets/javascripts/ai/tanuki_bot/store/actions.js +++ b/ee/app/assets/javascripts/ai/tanuki_bot/store/actions.js @@ -1,4 +1,5 @@ import { MESSAGE_TYPES } from '../constants'; +import { GENIE_CHAT_RESET_MESSAGE } from '../../constants'; import * as types from './mutation_types'; export const sendUserMessage = ({ commit }, msg) => { @@ -6,6 +7,17 @@ export const sendUserMessage = ({ commit }, msg) => { commit(types.ADD_USER_MESSAGE, msg); }; +export const receiveMutationResponse = ({ commit }, { data, message }) => { + const hasErrors = data?.aiAction?.errors?.length > 0; + + if (hasErrors) { + commit(types.SET_LOADING, false); + commit(types.ADD_ERROR_MESSAGE); + } else if (message === GENIE_CHAT_RESET_MESSAGE) { + commit(types.SET_LOADING, false); + } +}; + export const receiveTanukiBotMessage = ({ commit, dispatch }, data) => { const response = data.aiCompletionResponse?.responseBody; const errors = data.aiCompletionResponse?.errors; diff --git a/ee/lib/ee/gitlab/gon_helper.rb b/ee/lib/ee/gitlab/gon_helper.rb index 8c731787555d05625fe1d27c65c350f69a97fef5..5eddf52d3efc607d843f589ae0b0260073b3d018 100644 --- a/ee/lib/ee/gitlab/gon_helper.rb +++ b/ee/lib/ee/gitlab/gon_helper.rb @@ -29,6 +29,8 @@ def add_gon_variables gon.registration_validation_form_url = ::Gitlab::Routing.url_helpers .subscription_portal_registration_validation_form_url end + + push_frontend_feature_flag(:ai_chat_history_context, current_user) end # Exposes if a licensed feature is available. diff --git a/ee/spec/frontend/ai/components/ai_genie_chat_conversation_spec.js b/ee/spec/frontend/ai/components/ai_genie_chat_conversation_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4f349ef24f6d77a155a00b5f2503db302045ffb5 --- /dev/null +++ b/ee/spec/frontend/ai/components/ai_genie_chat_conversation_spec.js @@ -0,0 +1,112 @@ +import AiGenieChatConversation from 'ee/ai/components/ai_genie_chat_conversation.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { GENIE_CHAT_MODEL_ROLES } from 'ee/ai/constants'; + +describe('AiGenieChat', () => { + let wrapper; + + const promptStr = 'foo'; + const messages = [ + { + role: GENIE_CHAT_MODEL_ROLES.user, + content: promptStr, + }, + ]; + + const findChatMessages = () => wrapper.findAll('.ai-genie-chat-message'); + const findDelimiter = () => wrapper.findByTestId('conversation-delimiter'); + const createComponent = ({ propsData = {}, data = {}, scopedSlots = {}, slots = {} } = {}) => { + wrapper = shallowMountExtended(AiGenieChatConversation, { + propsData, + data() { + return { + ...data, + }; + }, + scopedSlots, + slots, + }); + }; + + describe('rendering', () => { + it('renders messages when messages are passed', () => { + createComponent({ propsData: { messages } }); + expect(findChatMessages().at(0).text()).toBe(messages[0].content); + }); + + it('renders delimiter when showDelimiter = true', () => { + createComponent({ propsData: { messages, showDelimiter: true } }); + expect(findDelimiter().exists()).toBe(true); + }); + + it('does not render delimiter when showDelimiter = false', () => { + createComponent({ propsData: { messages, showDelimiter: false } }); + expect(findDelimiter().exists()).toBe(false); + }); + + it('converts content of the message from Markdown into HTML', () => { + createComponent({ + propsData: { + messages: [ + { + role: GENIE_CHAT_MODEL_ROLES.user, + content: '**foo**', + }, + ], + }, + }); + expect(findChatMessages().at(0).element.innerHTML).toContain('<strong>foo</strong>'); + }); + }); + + describe('slots', () => { + const slotContent = 'As Gregor Samsa awoke one morning from uneasy dreams'; + + describe('the feedback slot', () => { + const slotElement = `<template>${slotContent}</template>`; + + it.each(['assistant', 'ASSISTANT'])( + 'renders the content passed to the "feedback" slot when role is %s', + (role) => { + createComponent({ + propsData: { + messages: [ + { + role: GENIE_CHAT_MODEL_ROLES.user, + content: 'User foo', + }, + { + role, + content: 'Assistent bar', + }, + ], + }, + scopedSlots: { feedback: slotElement }, + }); + expect(findChatMessages().at(0).text()).not.toContain(slotContent); + expect(findChatMessages().at(1).text()).toContain(slotContent); + }, + ); + + it('sends correct `message` in the `slotProps` for the components users to consume', () => { + createComponent({ + propsData: { + messages: [ + { + role: GENIE_CHAT_MODEL_ROLES.assistant, + content: slotContent, + }, + ], + }, + scopedSlots: { + feedback: `<template #feedback="slotProps"> + Hello {{ slotProps.message.content }} + </template> + `, + }, + }); + expect(wrapper.text()).toContain(`Hello ${slotContent}`); + }); + }); + }); +}); diff --git a/ee/spec/frontend/ai/components/ai_genie_chat_spec.js b/ee/spec/frontend/ai/components/ai_genie_chat_spec.js index 740aa2eac3aa5280f702eb587ab1cb610bccc0bd..6c99213c87e012c6a27eef551f31898d2ca54fd9 100644 --- a/ee/spec/frontend/ai/components/ai_genie_chat_spec.js +++ b/ee/spec/frontend/ai/components/ai_genie_chat_spec.js @@ -1,15 +1,23 @@ -import { GlEmptyState, GlButton, GlBadge } from '@gitlab/ui'; +import { GlEmptyState, GlBadge } from '@gitlab/ui'; import { nextTick } from 'vue'; import AiGenieLoader from 'ee/ai/components/ai_genie_loader.vue'; import AiGenieChat from 'ee/ai/components/ai_genie_chat.vue'; +import AiGenieChatConversation from 'ee/ai/components/ai_genie_chat_conversation.vue'; import AiPredefinedPrompts from 'ee/ai/components/ai_predefined_prompts.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { i18n, GENIE_CHAT_MODEL_ROLES } from 'ee/ai/constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import { i18n, GENIE_CHAT_MODEL_ROLES, GENIE_CHAT_RESET_MESSAGE } from 'ee/ai/constants'; describe('AiGenieChat', () => { let wrapper; - const createComponent = ({ propsData = {}, data = {}, scopedSlots = {}, slots = {} } = {}) => { + const createComponent = ({ + propsData = {}, + data = {}, + scopedSlots = {}, + slots = {}, + glFeatures = { aiChatHistoryContext: true }, + } = {}) => { wrapper = shallowMountExtended(AiGenieChat, { propsData, data() { @@ -22,15 +30,19 @@ describe('AiGenieChat', () => { stubs: { AiGenieLoader, }, + provide: { + glFeatures, + }, }); }; const findChatComponent = () => wrapper.findByTestId('chat-component'); - const findCloseButton = () => wrapper.findComponent(GlButton); + const findCloseButton = () => wrapper.findByTestId('chat-close-button'); + const findChatConversations = () => wrapper.findAllComponents(AiGenieChatConversation); const findCustomLoader = () => wrapper.findComponent(AiGenieLoader); - const findChatMessages = () => wrapper.findAll('.ai-genie-chat-message'); const findError = () => wrapper.findByTestId('chat-error'); const findFooter = () => wrapper.findByTestId('chat-footer'); + const findPromptForm = () => wrapper.findByTestId('chat-prompt-form'); const findGeneratedByAI = () => wrapper.findByText(i18n.GENIE_CHAT_LEGAL_GENERATED_BY_AI); const findBadge = () => wrapper.findComponent(GlBadge); const findEmptyState = () => wrapper.findComponent(GlEmptyState); @@ -78,55 +90,56 @@ describe('AiGenieChat', () => { }); }); - describe('slots', () => { - const slotContent = 'As Gregor Samsa awoke one morning from uneasy dreams'; + describe('conversations', () => { + it('renders one conversation when no reset message is present', () => { + const newMessages = [ + { + role: GENIE_CHAT_MODEL_ROLES.user, + content: 'How are you?', + }, + { + role: GENIE_CHAT_MODEL_ROLES.assistant, + content: 'Great!', + }, + ]; + createComponent({ propsData: { messages: newMessages } }); + + expect(findChatConversations().length).toEqual(1); + expect(findChatConversations().at(0).props('showDelimiter')).toEqual(false); + }); + + it('renders one conversation when no message is present', () => { + const newMessages = []; + createComponent({ propsData: { messages: newMessages } }); + + expect(findChatConversations().length).toEqual(0); + }); - describe('the feedback slot', () => { - const slotElement = `<template>${slotContent}</template>`; - - it.each(['assistant', 'ASSISTANT'])( - 'renders the content passed to the "feedback" slot when role is %s', - (role) => { - createComponent({ - propsData: { - messages: [ - { - role: GENIE_CHAT_MODEL_ROLES.user, - content: 'User foo', - }, - { - role, - content: 'Assistent bar', - }, - ], - }, - scopedSlots: { feedback: slotElement }, - }); - expect(findChatMessages().at(0).text()).not.toContain(slotContent); - expect(findChatMessages().at(1).text()).toContain(slotContent); + it('splits it up into multiple conversations when reset message is present', () => { + const newMessages = [ + { + role: GENIE_CHAT_MODEL_ROLES.user, + content: 'Message 1', }, - ); + { + role: GENIE_CHAT_MODEL_ROLES.assistant, + content: 'Great!', + }, + { + role: GENIE_CHAT_MODEL_ROLES.user, + content: GENIE_CHAT_RESET_MESSAGE, + }, + ]; + createComponent({ propsData: { messages: newMessages } }); - it('sends correct `message` in the `slotProps` for the components users to consume', () => { - createComponent({ - propsData: { - messages: [ - { - role: GENIE_CHAT_MODEL_ROLES.assistant, - content: slotContent, - }, - ], - }, - scopedSlots: { - feedback: `<template #feedback="slotProps"> - Hello {{ slotProps.message.content }} - </template> - `, - }, - }); - expect(wrapper.text()).toContain(`Hello ${slotContent}`); - }); + expect(findChatConversations().length).toEqual(2); + expect(findChatConversations().at(0).props('showDelimiter')).toEqual(false); + expect(findChatConversations().at(1).props('showDelimiter')).toEqual(true); }); + }); + + describe('slots', () => { + const slotContent = 'As Gregor Samsa awoke one morning from uneasy dreams'; it.each` desc | slot | content | isChatAvailable | shouldRenderSlotContent @@ -159,6 +172,89 @@ describe('AiGenieChat', () => { }); }); + describe('chat', () => { + it('does not render prompt input by default', () => { + createComponent({ propsData: { messages } }); + expect(findChatInput().exists()).toBe(false); + }); + + it('renders prompt input if `isChatAvailable` prop is `true`', () => { + createComponent({ propsData: { messages, isChatAvailable: true } }); + expect(findChatInput().exists()).toBe(true); + }); + + it('renders the legal disclaimer if `isChatAvailable` prop is `true', () => { + createComponent({ propsData: { messages, isChatAvailable: true } }); + expect(findLegalDisclaimer().exists()).toBe(true); + }); + + describe('reset', () => { + const clickSubmit = () => + findPromptForm().vm.$emit('submit', { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); + + it('emits the event with the reset prompt', () => { + createComponent({ + propsData: { messages, isChatAvailable: true }, + data: { prompt: GENIE_CHAT_RESET_MESSAGE }, + }); + clickSubmit(); + + expect(wrapper.emitted('send-chat-prompt')).toEqual([[GENIE_CHAT_RESET_MESSAGE]]); + expect(findChatConversations().length).toEqual(1); + }); + + it('reset does nothing when chat is loading', () => { + createComponent({ + propsData: { messages, isChatAvailable: true, isLoading: true }, + data: { prompt: GENIE_CHAT_RESET_MESSAGE }, + }); + clickSubmit(); + + expect(wrapper.emitted('send-chat-prompt')).toBeUndefined(); + expect(findChatConversations().length).toEqual(1); + }); + + it('reset does nothing when there are no messages', () => { + createComponent({ + propsData: { messages: [], isChatAvailable: true }, + data: { prompt: GENIE_CHAT_RESET_MESSAGE }, + }); + clickSubmit(); + + expect(wrapper.emitted('send-chat-prompt')).toBeUndefined(); + expect(findChatConversations().length).toEqual(0); + }); + + it('reset does nothing when last message was a reset message', () => { + const existingMessages = [ + ...messages, + { + role: GENIE_CHAT_MODEL_ROLES.user, + content: GENIE_CHAT_RESET_MESSAGE, + }, + ]; + createComponent({ + propsData: { + isLoading: false, + messages: existingMessages, + isChatAvailable: true, + }, + data: { prompt: GENIE_CHAT_RESET_MESSAGE }, + }); + clickSubmit(); + + expect(wrapper.emitted('send-chat-prompt')).toBeUndefined(); + + expect(findChatConversations().length).toEqual(2); + expect(findChatConversations().at(0).props('messages')).toEqual(messages); + expect(findChatConversations().at(1).props('messages')).toEqual([]); + }); + }); + }); + describe('interaction', () => { it('is hidden after the header button is clicked', async () => { findCloseButton().vm.$emit('click'); @@ -179,6 +275,20 @@ describe('AiGenieChat', () => { expect(findChatComponent().exists()).toBe(true); }); + it('resets the prompt when new messages are added', async () => { + const prompt = 'foo'; + createComponent({ propsData: { isChatAvailable: true }, data: { prompt } }); + expect(findChatInput().props('value')).toBe(prompt); + // setProps is justified here because we are testing the component's + // reactive behavior which consistutes an exception + // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state + wrapper.setProps({ + messages, + }); + await waitForPromises(); + expect(findChatInput().props('value')).toBe(''); + }); + it('renders custom loader when isLoading', () => { createComponent({ propsData: { isLoading: true } }); expect(findCustomLoader().exists()).toBe(true); @@ -190,25 +300,6 @@ describe('AiGenieChat', () => { expect(findError().text()).toBe(errorMessage); }); - it('renders messages when messages are passed', () => { - createComponent({ propsData: { messages } }); - expect(findChatMessages().at(0).text()).toBe(promptStr); - }); - - it('converts content of the message from Markdown into HTML', () => { - createComponent({ - propsData: { - messages: [ - { - role: GENIE_CHAT_MODEL_ROLES.user, - content: '**foo**', - }, - ], - }, - }); - expect(findChatMessages().at(0).element.innerHTML).toContain('<strong>foo</strong>'); - }); - it('hides the chat on button click and emits an event', () => { createComponent({ propsData: { messages } }); expect(wrapper.vm.$data.isHidden).toBe(false); @@ -222,23 +313,6 @@ describe('AiGenieChat', () => { expect(findEmptyState().exists()).toBe(false); }); - describe('chat', () => { - it('does not render prompt input by default', () => { - createComponent({ propsData: { messages } }); - expect(findChatInput().exists()).toBe(false); - }); - - it('renders prompt input if `isChatAvailable` prop is `true`', () => { - createComponent({ propsData: { messages, isChatAvailable: true } }); - expect(findChatInput().exists()).toBe(true); - }); - - it('renders the legal disclaimer if `isChatAvailable` prop is `true', () => { - createComponent({ propsData: { messages, isChatAvailable: true } }); - expect(findLegalDisclaimer().exists()).toBe(true); - }); - }); - describe('scrolling', () => { let element; diff --git a/ee/spec/frontend/ai/components/ai_genie_spec.js b/ee/spec/frontend/ai/components/ai_genie_spec.js index 6ebf9b6a3049f9e7eef95232df8a0cb0aa345d4c..eecedf7d9a7c456a798ff75fd804dc35c9f1a775 100644 --- a/ee/spec/frontend/ai/components/ai_genie_spec.js +++ b/ee/spec/frontend/ai/components/ai_genie_spec.js @@ -5,6 +5,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import AiGenie from 'ee/ai/components/ai_genie.vue'; import AiGenieChat from 'ee/ai/components/ai_genie_chat.vue'; +import AiGenieChatConversation from 'ee/ai/components/ai_genie_chat_conversation.vue'; import CodeBlockHighlighted from '~/vue_shared/components/code_block_highlighted.vue'; import UserFeedback from 'ee/ai/components/user_feedback.vue'; import { generateExplainCodePrompt, generateChatPrompt } from 'ee/ai/utils'; @@ -15,6 +16,7 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import explainCodeMutation from 'ee/ai/graphql/explain_code.mutation.graphql'; import aiResponseSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response.subscription.graphql'; import LineHighlighter from '~/blob/line_highlighter'; +import { MOCK_USER_MESSAGE, MOCK_TANUKI_MESSAGE } from '../tanuki_bot/mock_data'; const aiResponseFormatted = 'Formatted AI response'; @@ -76,7 +78,7 @@ describe('AiGenie', () => { }, stubs: { AiGenieChat, - UserFeedback, + AiGenieChatConversation, }, apolloProvider, }); @@ -84,8 +86,9 @@ describe('AiGenie', () => { const findButton = () => wrapper.findComponent(GlButton); const findGenieChat = () => wrapper.findComponent(AiGenieChat); const findCodeBlock = () => wrapper.findComponent(CodeBlockHighlighted); - const findUserFeedback = () => wrapper.findComponent(UserFeedback); const findLegalWarning = () => wrapper.findByTestId('chat-legal-warning-gitlab-usage'); + const findAllUserFeedback = () => wrapper.findAllComponents(UserFeedback); + const getRangeAtMock = (top = () => 0) => { return jest.fn((rangePosition) => { return { @@ -377,20 +380,21 @@ describe('AiGenie', () => { }); }); - describe('UserFeedback', () => { - beforeEach(() => { - createComponent(); - }); - it('is rendered', async () => { - await simulateSelectText(); - await requestExplanation(); - expect(findUserFeedback().exists()).toBe(true); + it('renders the User Feedback component for every assistent mesage', () => { + createComponent({ + data: { + // the first 2 messages will be ignored in the component + // as those normally represent the `system` and the first `user` prompts + // we don't care about those here, hence sending `undefined` + messages: [undefined, undefined, MOCK_USER_MESSAGE, MOCK_TANUKI_MESSAGE], + }, }); - it('receives expected props', async () => { - await simulateSelectText(); - await requestExplanation(); - expect(findUserFeedback().props('eventName')).toBe(EXPLAIN_CODE_TRACKING_EVENT_NAME); - expect(findUserFeedback().props('promptLocation')).toBe('before_content'); + + expect(findAllUserFeedback().length).toBe(1); + + findAllUserFeedback().wrappers.forEach((component) => { + expect(component.props('eventName')).toBe(EXPLAIN_CODE_TRACKING_EVENT_NAME); + expect(component.props('promptLocation')).toBe('after_content'); }); }); diff --git a/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js b/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js index 5896762fd0a64cfc51f66912ec80a87a685c1188..77346a4c55812f826709d5eb7967aaaf7e0f5063 100644 --- a/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js +++ b/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js @@ -4,6 +4,7 @@ import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; import TanukiBotChatApp from 'ee/ai/tanuki_bot/components/app.vue'; import AiGenieChat from 'ee/ai/components/ai_genie_chat.vue'; +import AiGenieChatConversation from 'ee/ai/components/ai_genie_chat_conversation.vue'; import UserFeedback from 'ee/ai/components/user_feedback.vue'; import { i18n } from 'ee/ai/constants'; import { TANUKI_BOT_TRACKING_EVENT_NAME } from 'ee/ai/tanuki_bot/constants'; @@ -68,6 +69,7 @@ describe('GitLab Chat', () => { stubs: { AiGenieChat, GlSprintf, + AiGenieChatConversation, }, provide: { glFeatures, @@ -79,6 +81,7 @@ describe('GitLab Chat', () => { const findWarning = () => wrapper.findByTestId('chat-legal-warning'); const findGenieChat = () => wrapper.findComponent(AiGenieChat); const findGeneratedByAI = () => wrapper.findByText(i18n.GENIE_CHAT_LEGAL_GENERATED_BY_AI); + const findAllUserFeedback = () => wrapper.findAllComponents(UserFeedback); describe('rendering', () => { beforeEach(() => { @@ -160,15 +163,16 @@ describe('GitLab Chat', () => { }); it('renders the User Feedback component for every assistent mesage', () => { - const getPromptLocationSpy = jest.spyOn(AiGenieChat.methods, 'getPromptLocation'); - getPromptLocationSpy.mockReturnValue('foo'); createComponent({ messages: [MOCK_USER_MESSAGE, MOCK_TANUKI_MESSAGE, MOCK_USER_MESSAGE, MOCK_TANUKI_MESSAGE], }); - const userFeedbackComponents = wrapper.findAllComponents(UserFeedback); - expect(userFeedbackComponents.length).toBe(2); - expect(userFeedbackComponents.at(0).props('eventName')).toBe(TANUKI_BOT_TRACKING_EVENT_NAME); - expect(userFeedbackComponents.at(0).props('promptLocation')).toBe('foo'); + + expect(findAllUserFeedback().length).toBe(2); + + findAllUserFeedback().wrappers.forEach((component) => { + expect(component.props('eventName')).toBe(TANUKI_BOT_TRACKING_EVENT_NAME); + expect(component.props('promptLocation')).toBe('after_content'); + }); }); describe('when input is submitted', () => { diff --git a/ee/spec/frontend/ai/tanuki_bot/store/actions_spec.js b/ee/spec/frontend/ai/tanuki_bot/store/actions_spec.js index c25f6ab2c6dfff9a75e9fb314660591643c8f0df..3b2ae4663740fa3c3bfa660801a06717e9fb2cda 100644 --- a/ee/spec/frontend/ai/tanuki_bot/store/actions_spec.js +++ b/ee/spec/frontend/ai/tanuki_bot/store/actions_spec.js @@ -2,6 +2,7 @@ import * as actions from 'ee/ai/tanuki_bot/store/actions'; import * as types from 'ee/ai/tanuki_bot/store/mutation_types'; import createState from 'ee/ai/tanuki_bot/store/state'; import testAction from 'helpers/vuex_action_helper'; +import { GENIE_CHAT_RESET_MESSAGE } from 'ee/ai/constants'; import { MOCK_USER_MESSAGE, MOCK_TANUKI_MESSAGE, @@ -73,6 +74,32 @@ describe('TanukiBot Store Actions', () => { }); }); + describe('receiveMutationResponse', () => { + it('on success it should dispatch the correct mutations', () => { + return testAction({ + action: actions.receiveMutationResponse, + payload: { data: { aiAction: { errors: [] } }, message: GENIE_CHAT_RESET_MESSAGE }, + state, + expectedMutations: [{ type: types.SET_LOADING, payload: false }], + }); + }); + + it('on error it should dispatch the correct mutations', () => { + return testAction({ + action: actions.receiveMutationResponse, + payload: { + data: { aiAction: { errors: ['some error'] } }, + message: GENIE_CHAT_RESET_MESSAGE, + }, + state, + expectedMutations: [ + { type: types.SET_LOADING, payload: false }, + { type: types.ADD_ERROR_MESSAGE }, + ], + }); + }); + }); + describe('tanukiBotMessageError', () => { it(`should dispatch the correct mutations`, () => { return testAction({ diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 23f5ea037b4a1759b69bb5d032999d208b92f1c0..953f7ab913b8f28378d41054822ac1d4d18ac083 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1962,6 +1962,9 @@ msgstr "" msgid "AI|May provide inappropriate responses not representative of GitLab's views. Do not input personal data." msgstr "" +msgid "AI|New chat" +msgstr "" + msgid "AI|Populate issue description" msgstr ""