diff --git a/doc/user/img/explain_code_experiment.png b/doc/user/img/explain_code_experiment.png index 1b3b9e3eef3ad7c7c52c5476cff506148faea85a..f04f2bc8bafc8a9bea00d24e06527c935e614ea1 100644 Binary files a/doc/user/img/explain_code_experiment.png and b/doc/user/img/explain_code_experiment.png differ diff --git a/ee/app/assets/javascripts/ai/components/ai_genie.vue b/ee/app/assets/javascripts/ai/components/ai_genie.vue index ff4bbf7b57fa766f00ae148c5207f539e17bd21b..30bff7b457b950ce0bad1c32e3144e94946f38b2 100644 --- a/ee/app/assets/javascripts/ai/components/ai_genie.vue +++ b/ee/app/assets/javascripts/ai/components/ai_genie.vue @@ -1,20 +1,19 @@ @@ -221,26 +165,5 @@ export default { class="gl-p-0! gl-display-block gl-bg-white! explain-the-code gl-rounded-full!" @click="requestCodeExplanation" /> - - - - diff --git a/ee/app/assets/javascripts/ai/components/ai_genie_chat.vue b/ee/app/assets/javascripts/ai/components/ai_genie_chat.vue deleted file mode 100644 index a652a82a53c159260c2bd7a64d90199daa9e8770..0000000000000000000000000000000000000000 --- a/ee/app/assets/javascripts/ai/components/ai_genie_chat.vue +++ /dev/null @@ -1,350 +0,0 @@ - - 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 deleted file mode 100644 index f0cc6d24af56cda2228e2b7819803a75bc873c01..0000000000000000000000000000000000000000 --- a/ee/app/assets/javascripts/ai/components/ai_genie_chat_conversation.vue +++ /dev/null @@ -1,54 +0,0 @@ - - diff --git a/ee/app/assets/javascripts/ai/components/ai_genie_chat_message.vue b/ee/app/assets/javascripts/ai/components/ai_genie_chat_message.vue deleted file mode 100644 index ee77c36cd0b256c4b85e42897742e4faae69a5a8..0000000000000000000000000000000000000000 --- a/ee/app/assets/javascripts/ai/components/ai_genie_chat_message.vue +++ /dev/null @@ -1,115 +0,0 @@ - - diff --git a/ee/app/assets/javascripts/ai/components/ai_genie_chat_message_sources.vue b/ee/app/assets/javascripts/ai/components/ai_genie_chat_message_sources.vue deleted file mode 100644 index 5961862f3fb3c2046d4914859ae1195c3eaf4661..0000000000000000000000000000000000000000 --- a/ee/app/assets/javascripts/ai/components/ai_genie_chat_message_sources.vue +++ /dev/null @@ -1,72 +0,0 @@ - - diff --git a/ee/app/assets/javascripts/ai/components/ai_genie_loader.vue b/ee/app/assets/javascripts/ai/components/ai_genie_loader.vue deleted file mode 100644 index e05d7b99f986f5fb90e6377b5063e2ac1d9f592a..0000000000000000000000000000000000000000 --- a/ee/app/assets/javascripts/ai/components/ai_genie_loader.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - diff --git a/ee/app/assets/javascripts/ai/constants.js b/ee/app/assets/javascripts/ai/constants.js index faca59a08e0e8e75734ba354bec33b716f3750eb..3889ba2dd17624f42777cc343be0c6961b910e59 100644 --- a/ee/app/assets/javascripts/ai/constants.js +++ b/ee/app/assets/javascripts/ai/constants.js @@ -1,55 +1,15 @@ -import { s__, sprintf } from '~/locale'; +import { s__ } from '~/locale'; export const i18n = { GITLAB_DUO: s__('AI|GitLab Duo'), GENIE_TOOLTIP: s__('AI|What does the selected code mean?'), GENIE_NO_CONTAINER_ERROR: s__("AI|The container element wasn't found, stopping AI Genie."), - GENIE_CHAT_TITLE: s__('AI|Code Explanation'), - GENIE_CHAT_CLOSE_LABEL: s__('AI|Close the Code Explanation'), - GENIE_CHAT_LEGAL_NOTICE: sprintf( - s__( - 'AI|You are not allowed to copy any part of this output into issues, comments, GitLab source code, commit messages, merge requests or any other user interface in the %{gitlabOrg} or %{gitlabCom} groups.', - ), - { gitlabOrg: '/gitlab-org', gitlabCom: '/gitlab-com' }, - false, - ), GENIE_CHAT_LEGAL_GENERATED_BY_AI: s__('AI|Responses generated by AI'), REQUEST_ERROR: s__('AI|Something went wrong. Please try again later'), EXPERIMENT_BADGE: s__('AI|Experiment'), - EXPERIMENT_POPOVER_TITLE: s__("AI|What's an Experiment?"), - EXPERIMENT_POPOVER_CONTENT: s__( - "AI|An %{linkStart}Experiment%{linkEnd} is a feature that's in the process of being developed. It's not production-ready. We encourage users to try Experimental features and provide feedback. An Experiment: %{bullets}", - ), - EXPERIMENT_POPOVER_BULLETS: [ - s__('AI|May be unstable'), - s__('AI|Has no support and might not be documented'), - s__('AI|Can be removed at any time'), - ], - EXPLAIN_CODE_PROMPT: s__( - 'AI|Explain the code from %{filePath} in human understandable language presented in Markdown format. In the response add neither original code snippet nor any title. `%{text}`. If it is not programming code, say `The selected text is not code. I am afraid this feature is for explaining code only. Would you like to ask a different question about the selected text?` and wait for another question.', - ), - TOO_LONG_ERROR_MESSAGE: s__( - 'AI|There is too much text in the chat. Please try again with a shorter text.', - ), - GENIE_CHAT_PROMPT_PLACEHOLDER: s__('AI|GitLab Duo Chat'), - GENIE_CHAT_EMPTY_STATE_TITLE: s__('AI|Ask a question'), - GENIE_CHAT_EMPTY_STATE_DESC: s__('AI|AI generated explanations will appear here.'), - 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'), - GENIE_CHAT_LOADING_MESSAGE: s__('AI|%{tool} is %{transition} an answer'), - GENIE_CHAT_LOADING_TRANSITIONS: [ - s__('AI|finding'), - s__('AI|working on'), - s__('AI|generating'), - s__('AI|producing'), - ], GENIE_CHAT_FEEDBACK_LINK: s__('AI|Give feedback to improve this answer.'), GENIE_CHAT_FEEDBACK_THANKS: s__('AI|Thank you for your feedback.'), }; -export const GENIE_CHAT_LOADING_TRANSITION_DURATION = 7500; -export const TOO_LONG_ERROR_TYPE = 'too-long'; export const AI_GENIE_DEBOUNCE = 300; export const GENIE_CHAT_MODEL_ROLES = { user: 'user', @@ -83,18 +43,4 @@ export const EXPLAIN_CODE_TRACKING_EVENT_NAME = 'explain_code_blob_viewer'; export const TANUKI_BOT_TRACKING_EVENT_NAME = 'ask_gitlab_chat'; export const GENIE_CHAT_RESET_MESSAGE = '/reset'; export const GENIE_CHAT_CLEAN_MESSAGE = '/clean'; - -export const DOCUMENTATION_SOURCE_TYPES = { - HANDBOOK: { - value: 'handbook', - icon: 'book', - }, - DOC: { - value: 'doc', - icon: 'documents', - }, - BLOG: { - value: 'blog', - icon: 'list-bulleted', - }, -}; +export const GENIE_CHAT_EXPLAIN_MESSAGE = '/explain'; diff --git a/ee/app/assets/javascripts/ai/graphql/chat.mutation.graphql b/ee/app/assets/javascripts/ai/graphql/chat.mutation.graphql index 6ae83926f55f2f726e309b9d5ab7645b0c4a4b11..5b88d5d6b6cf195e5cf56c90bd761e34feeaaf38 100644 --- a/ee/app/assets/javascripts/ai/graphql/chat.mutation.graphql +++ b/ee/app/assets/javascripts/ai/graphql/chat.mutation.graphql @@ -1,7 +1,12 @@ -mutation chat($question: String!, $resourceId: AiModelID!, $clientSubscriptionId: String) { +mutation chat( + $question: String! + $resourceId: AiModelID! + $clientSubscriptionId: String + $currentFileContext: AiCurrentFileInput +) { aiAction( input: { - chat: { resourceId: $resourceId, content: $question } + chat: { resourceId: $resourceId, content: $question, currentFile: $currentFileContext } clientSubscriptionId: $clientSubscriptionId } ) { diff --git a/ee/app/assets/javascripts/ai/graphql/explain_code.mutation.graphql b/ee/app/assets/javascripts/ai/graphql/explain_code.mutation.graphql deleted file mode 100644 index d3098af640c26f579bfd1097400bebcac0ac9f5b..0000000000000000000000000000000000000000 --- a/ee/app/assets/javascripts/ai/graphql/explain_code.mutation.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation explainCode($resourceId: AiModelID!, $messages: [AiExplainCodeMessageInput!]!) { - aiAction(input: { explainCode: { resourceId: $resourceId, messages: $messages } }) { - errors - } -} diff --git a/ee/app/assets/javascripts/ai/utils.js b/ee/app/assets/javascripts/ai/utils.js index 8c92800a014ddf2957fadd28535946ebcfa20632..722225b9dbe65446a39c35de37212b0bd92296a9 100644 --- a/ee/app/assets/javascripts/ai/utils.js +++ b/ee/app/assets/javascripts/ai/utils.js @@ -1,112 +1,3 @@ -import { findLastIndex } from 'lodash'; -import { sprintf, __ } from '~/locale'; -import { TOO_LONG_ERROR_TYPE, i18n, GENIE_CHAT_MODEL_ROLES } from './constants'; - -const areMessagesWithinLimit = (messages) => { - const MAX_RESPONSE_TOKENS = gon.ai?.chat?.max_response_token; - const TOKENS_THRESHOLD = gon.ai?.chat?.input_content_limit; - - if (!MAX_RESPONSE_TOKENS || !TOKENS_THRESHOLD) return true; // delegate dealing with the prompt size to BE - - // we use `utils.computeTokens()` below to make it easier to test and mock calls to computeTokens() - // eslint-disable-next-line no-use-before-define - return utils.computeTokens(messages) + MAX_RESPONSE_TOKENS < TOKENS_THRESHOLD; -}; - -/* eslint-disable consistent-return */ -const truncateChatPrompt = (messages) => { - if (areMessagesWithinLimit(messages)) { - return messages; - } - - // First, we get rid of the `system` prompt, because its value for the further conversation is not that important anymore - const systemPromptIndex = messages.at(0).role === GENIE_CHAT_MODEL_ROLES.system ? 0 : -1; - if (systemPromptIndex >= 0) { - messages.splice(systemPromptIndex, 1); - return truncateChatPrompt(messages); - } - - // Here we do not want to truncate the last user prompt, because it is the one we need to respond to - const lastUserPromptIndex = findLastIndex( - messages, - ({ role }) => role === GENIE_CHAT_MODEL_ROLES.user, - ); - const firstUserPromptIndex = messages.findIndex( - ({ role }) => role === GENIE_CHAT_MODEL_ROLES.user, - ); - if (firstUserPromptIndex >= 0 && lastUserPromptIndex > firstUserPromptIndex) { - messages.splice(firstUserPromptIndex, 1); - return truncateChatPrompt(messages); - } - - // Here we do not want to truncate the last assistant prompt, because this is the last context message we have to correctly answer the user prompt - const lastAssistantPromptIndex = findLastIndex( - messages, - ({ role }) => role === GENIE_CHAT_MODEL_ROLES.assistant, - ); - const firstAssistantPromptIndex = messages.findIndex( - ({ role }) => role === GENIE_CHAT_MODEL_ROLES.assistant, - ); - if (firstAssistantPromptIndex >= 0 && lastAssistantPromptIndex > firstAssistantPromptIndex) { - messages.splice(firstAssistantPromptIndex, 1); - return truncateChatPrompt(messages); - } - if (messages.length <= 2) { - // By here, we could conclude that there's only one pair of assistant + user messages left and it still is too big to be sent - // In this case, we should start splitting the message into smaller chunks and send them one by one, using the mapReduce strategy - // This is not implemented yet, hence we throw an error - throw new Error(i18n.TOO_LONG_ERROR_MESSAGE, { - cause: TOO_LONG_ERROR_TYPE, - }); - } -}; -/* eslint-enable consistent-return */ - -const prepareChatPrompt = (newPrompt, messages, strategy) => { - const doesContainSystemMessage = messages?.at(0)?.role === GENIE_CHAT_MODEL_ROLES.system; - if (!doesContainSystemMessage) { - messages.unshift({ - role: GENIE_CHAT_MODEL_ROLES.system, - content: 'You are an assistant explaining to an engineer', // eslint-disable-line - }); - } - messages.push({ role: GENIE_CHAT_MODEL_ROLES.user, content: newPrompt }); - switch (strategy) { - case 'truncate': - return truncateChatPrompt(messages); - default: - return messages; - } -}; - -export const generateChatPrompt = (newPrompt, messages) => { - if (!newPrompt) return messages; - const initMessages = messages.slice(); - return prepareChatPrompt(newPrompt, initMessages, 'truncate'); -}; - -export const generateExplainCodePrompt = (text, filePath) => { - return sprintf(i18n.EXPLAIN_CODE_PROMPT, { - filePath: filePath || __('random'), - text, - }); -}; - -export const computeTokens = (messages) => { - // See https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb for more details - const tokensPerMessage = 4; // every message follows <|start|>{role}\n{content}<|end|>\n - - let numTokens = 0; - for (const message of messages) { - numTokens += tokensPerMessage; - for (const value of Object.values(message)) { - numTokens += value.length / 4; // 4 bytes per token on average as per https://platform.openai.com/tokenizer - } - } - numTokens += 3; // every reply is primed with <|start|>assistant<|message|> - return Math.ceil(numTokens); -}; - export const concatStreamedChunks = (arr) => { if (!arr) return ''; @@ -118,8 +9,5 @@ export const concatStreamedChunks = (arr) => { }; export const utils = { - generateChatPrompt, - generateExplainCodePrompt, - computeTokens, concatStreamedChunks, }; 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 deleted file mode 100644 index d2125ed6daed6c6b2802059539abb9e003796944..0000000000000000000000000000000000000000 --- a/ee/spec/frontend/ai/components/ai_genie_chat_conversation_spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import AiGenieChatConversation from 'ee/ai/components/ai_genie_chat_conversation.vue'; -import AiGenieChatMessage from 'ee/ai/components/ai_genie_chat_message.vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { MOCK_USER_MESSAGE, MOCK_TANUKI_MESSAGE } from '../tanuki_bot/mock_data'; - -describe('AiGenieChatConversation', () => { - let wrapper; - - const messages = [MOCK_USER_MESSAGE]; - - const findChatMessages = () => wrapper.findAllComponents(AiGenieChatMessage); - const findDelimiter = () => wrapper.findByTestId('conversation-delimiter'); - const createComponent = async ({ propsData = {}, data = {} } = {}) => { - wrapper = shallowMountExtended(AiGenieChatConversation, { - propsData, - data() { - return { - ...data, - }; - }, - }); - await waitForPromises(); - }; - - describe('rendering', () => { - it('renders messages when messages are passed', async () => { - await createComponent({ propsData: { messages: [MOCK_USER_MESSAGE, MOCK_TANUKI_MESSAGE] } }); - expect(findChatMessages().length).toBe(2); - }); - - it('renders delimiter when showDelimiter = true', async () => { - await createComponent({ propsData: { messages, showDelimiter: true } }); - expect(findDelimiter().exists()).toBe(true); - }); - - it('does not render delimiter when showDelimiter = false', async () => { - await createComponent({ propsData: { messages, showDelimiter: false } }); - expect(findDelimiter().exists()).toBe(false); - }); - }); -}); diff --git a/ee/spec/frontend/ai/components/ai_genie_chat_message_sources_spec.js b/ee/spec/frontend/ai/components/ai_genie_chat_message_sources_spec.js deleted file mode 100644 index 4e2f530206d95d994e7af8d2da6a3ca1fb3d18a8..0000000000000000000000000000000000000000 --- a/ee/spec/frontend/ai/components/ai_genie_chat_message_sources_spec.js +++ /dev/null @@ -1,83 +0,0 @@ -import { GlIcon, GlLink } from '@gitlab/ui'; -import DuoChatMessageSources from 'ee/ai/components/ai_genie_chat_message_sources.vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { DOCUMENTATION_SOURCE_TYPES } from 'ee/ai/constants'; - -const dummySourceBase = { - title: 'Foo', - source_type: DOCUMENTATION_SOURCE_TYPES.HANDBOOK.value, - stage: 'foo-stage', - group: 'bar-group', - date: new Date('December 31, 2020 23:59:59'), - author: 'Gregor Samsa', -}; - -describe('Duo Chat Message Sources', () => { - let wrapper; - - const findListItems = () => wrapper.findAll('[data-testid="source-list-item"]'); - const findSourceIcons = () => wrapper.findAllComponents(GlIcon); - const findSourceTitles = () => wrapper.findAllComponents(GlLink); - - const createComponent = ({ propsData = {} } = {}) => { - wrapper = shallowMountExtended(DuoChatMessageSources, { - propsData, - }); - }; - - it('renders sources passed down as a prop', () => { - createComponent({ - propsData: { - sources: [ - dummySourceBase, - { - ...dummySourceBase, - title: 'Bar', - }, - ], - }, - }); - expect(findListItems().length).toBe(2); - }); - - it.each` - type | expectedIcon - ${DOCUMENTATION_SOURCE_TYPES.HANDBOOK.value} | ${DOCUMENTATION_SOURCE_TYPES.HANDBOOK.icon} - ${DOCUMENTATION_SOURCE_TYPES.DOC.value} | ${DOCUMENTATION_SOURCE_TYPES.DOC.icon} - ${DOCUMENTATION_SOURCE_TYPES.BLOG.value} | ${DOCUMENTATION_SOURCE_TYPES.BLOG.icon} - ${'foo'} | ${'document'} - `('renders the correct icon for $type type', ({ type, expectedIcon } = {}) => { - createComponent({ - propsData: { - sources: [ - { - ...dummySourceBase, - source_type: type, - }, - ], - }, - }); - expect(findSourceIcons().at(0).props('name')).toBe(expectedIcon); - }); - - it.each` - sourceExtension | expectedTitle - ${{ title: 'Foo' }} | ${'Foo'} - ${{ source_type: DOCUMENTATION_SOURCE_TYPES.DOC.value }} | ${`${dummySourceBase.stage} / ${dummySourceBase.group}`} - ${{ source_type: DOCUMENTATION_SOURCE_TYPES.BLOG.value }} | ${`${dummySourceBase.date} / ${dummySourceBase.author}`} - ${{}} | ${'Source'} - `('renders the correct title for $sourceExtension', ({ sourceExtension, expectedTitle } = {}) => { - createComponent({ - propsData: { - sources: [ - { - ...dummySourceBase, - title: '', - ...sourceExtension, - }, - ], - }, - }); - expect(findSourceTitles().at(0).text()).toBe(expectedTitle); - }); -}); diff --git a/ee/spec/frontend/ai/components/ai_genie_chat_message_spec.js b/ee/spec/frontend/ai/components/ai_genie_chat_message_spec.js deleted file mode 100644 index c8b11e946c8839159e14bfe92d4bec1ffc75f27d..0000000000000000000000000000000000000000 --- a/ee/spec/frontend/ai/components/ai_genie_chat_message_spec.js +++ /dev/null @@ -1,413 +0,0 @@ -import { nextTick } from 'vue'; -import waitForPromises from 'helpers/wait_for_promises'; -import AiGenieChatMessage from 'ee/ai/components/ai_genie_chat_message.vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import UserFeedback from 'ee/ai/components/user_feedback.vue'; -import DocumentationSources from 'ee/ai/components/ai_genie_chat_message_sources.vue'; -import { TANUKI_BOT_TRACKING_EVENT_NAME } from 'ee/ai/constants'; -import { renderGFM } from '~/behaviors/markdown/render_gfm'; -import { - MOCK_USER_MESSAGE, - MOCK_TANUKI_MESSAGE, - MOCK_CHUNK_MESSAGE, -} from '../tanuki_bot/mock_data'; - -jest.mock('~/behaviors/markdown/render_gfm'); - -describe('AiGenieChatMessage', () => { - let wrapper; - - const findContent = () => wrapper.findComponent({ ref: 'content' }); - const findDocumentSources = () => wrapper.findComponent(DocumentationSources); - const findUserFeedback = () => wrapper.findComponent(UserFeedback); - - const createComponent = ({ - propsData = { message: MOCK_USER_MESSAGE }, - options = {}, - provides = {}, - } = {}) => { - wrapper = shallowMountExtended(AiGenieChatMessage, { - ...options, - propsData, - provide: { - trackingEventName: TANUKI_BOT_TRACKING_EVENT_NAME, - ...provides, - }, - }); - }; - - it('fails if message is not passed', () => { - expect(createComponent.bind(null, { propsData: {} })).toThrow(); - }); - - describe('rendering', () => { - it('converts the markdown to html while waiting for the API response', () => { - createComponent(); - // we do not wait for promises in this test to make sure the content - // is rendered even before the API response is received - expect(wrapper.html()).toContain(MOCK_USER_MESSAGE.content); - }); - - beforeEach(async () => { - createComponent(); - await waitForPromises(); - }); - - it('renders message content', () => { - expect(wrapper.text()).toBe(MOCK_USER_MESSAGE.content); - }); - - describe('user message', () => { - it('does not render the documentation sources component', () => { - expect(findDocumentSources().exists()).toBe(false); - }); - - it('does not render the user feedback component', () => { - expect(findUserFeedback().exists()).toBe(false); - }); - }); - - describe('assistant message', () => { - beforeEach(async () => { - createComponent({ - propsData: { message: MOCK_TANUKI_MESSAGE }, - }); - await waitForPromises(); - }); - - it('renders the documentation sources component by default', () => { - expect(findDocumentSources().exists()).toBe(true); - }); - - it.each([null, undefined, ''])( - 'does not render sources component when `sources` is %s', - (sources) => { - createComponent({ - propsData: { - message: { - ...MOCK_TANUKI_MESSAGE, - extras: { - sources, - }, - }, - }, - }); - expect(findDocumentSources().exists()).toBe(false); - }, - ); - - it('renders the user feedback component', () => { - expect(findUserFeedback().exists()).toBe(true); - }); - }); - - describe('User Feedback component integration', () => { - it('correctly sets the default tracking event', async () => { - createComponent({ - propsData: { - message: MOCK_TANUKI_MESSAGE, - }, - }); - await waitForPromises(); - expect(findUserFeedback().props('eventName')).toBe(TANUKI_BOT_TRACKING_EVENT_NAME); - }); - - it('correctly sets the tracking event', async () => { - createComponent({ - propsData: { - message: MOCK_TANUKI_MESSAGE, - }, - provides: { - trackingEventName: 'foo', - }, - }); - await waitForPromises(); - expect(findUserFeedback().props('eventName')).toBe('foo'); - }); - }); - }); - - describe('message output', () => { - it('clears `messageChunks` buffer', () => { - createComponent({ options: { messageChunks: ['foo', 'bar'] } }); - - expect(wrapper.vm.$options.messageChunks).toEqual([]); - }); - - describe('when `message` contains a chunk', () => { - it('adds the message chunk to the `messageChunks` buffer', () => { - createComponent({ - propsData: { message: MOCK_CHUNK_MESSAGE }, - options: { messageChunks: ['foo', 'bar'] }, - }); - - expect(wrapper.vm.$options.messageChunks).toEqual([undefined, 'chunk']); - }); - }); - - it('hydrates the message with GLFM when mounting the component', async () => { - createComponent(); - await nextTick(); - expect(renderGFM).toHaveBeenCalled(); - }); - - it('outputs errors if message has no content', () => { - const errors = ['foo', 'bar', 'baz']; - - createComponent({ - propsData: { - message: { - ...MOCK_USER_MESSAGE, - contentHtml: '', - content: '', - errors, - }, - }, - }); - - errors.forEach((err) => { - expect(findContent().text()).toContain(err); - }); - }); - - describe('message updates watcher', () => { - const newContent = 'new foo content'; - beforeEach(() => { - createComponent(); - }); - - it('listens to the message changes', async () => { - expect(findContent().text()).toContain(MOCK_USER_MESSAGE.content); - // 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({ - message: { - ...MOCK_USER_MESSAGE, - contentHtml: `

${newContent}

`, - }, - }); - await nextTick(); - expect(findContent().text()).not.toContain(MOCK_USER_MESSAGE.content); - expect(findContent().text()).toContain(newContent); - }); - - it('prioritises the output of contentHtml over content', async () => { - // 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({ - message: { - ...MOCK_USER_MESSAGE, - contentHtml: `

${MOCK_USER_MESSAGE.content}

`, - content: newContent, - }, - }); - await nextTick(); - expect(findContent().text()).not.toContain(newContent); - expect(findContent().text()).toContain(MOCK_USER_MESSAGE.content); - }); - - it('outputs errors if message has no content', async () => { - // 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({ - message: { - ...MOCK_USER_MESSAGE, - contentHtml: '', - content: '', - errors: ['error'], - }, - }); - await nextTick(); - expect(findContent().text()).not.toContain(newContent); - expect(findContent().text()).not.toContain(MOCK_USER_MESSAGE.content); - expect(findContent().text()).toContain('error'); - }); - - it('merges all the errors for output', async () => { - const errors = ['foo', 'bar', 'baz']; - // 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({ - message: { - ...MOCK_USER_MESSAGE, - contentHtml: '', - content: '', - errors, - }, - }); - await nextTick(); - expect(findContent().text()).toContain(errors[0]); - expect(findContent().text()).toContain(errors[1]); - expect(findContent().text()).toContain(errors[2]); - }); - - it('hydrates the output message with GLFM if its not a chunk', async () => { - // 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({ - message: { - ...MOCK_USER_MESSAGE, - contentHtml: `

${newContent}

`, - }, - }); - await nextTick(); - expect(renderGFM).toHaveBeenCalled(); - }); - }); - }); - - describe('updates to the message', () => { - const content1 = 'chunk #1'; - const content2 = ' chunk #2'; - const content3 = ' chunk #3'; - const chunk1 = { - ...MOCK_CHUNK_MESSAGE, - content: content1, - chunkId: 1, - }; - const chunk2 = { - ...MOCK_CHUNK_MESSAGE, - content: content2, - chunkId: 2, - }; - const chunk3 = { - ...MOCK_CHUNK_MESSAGE, - content: content3, - chunkId: 3, - }; - - beforeEach(() => { - createComponent(); - }); - - it('does not fail if the message has no chunkId', async () => { - // 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({ - message: { - ...MOCK_CHUNK_MESSAGE, - content: content1, - }, - }); - await nextTick(); - expect(findContent().text()).toBe(content1); - }); - - it('renders chunks correctly when the chunks arrive out of order', async () => { - // 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({ - message: chunk2, - }); - await nextTick(); - expect(findContent().text()).toBe(''); - - wrapper.setProps({ - message: chunk1, - }); - await nextTick(); - expect(findContent().text()).toBe(content1 + content2); - - wrapper.setProps({ - message: chunk3, - }); - await nextTick(); - expect(findContent().text()).toBe(content1 + content2 + content3); - }); - - it('renders the chunks as they arrive', async () => { - const consolidatedContent = content1 + content2; - - // 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({ - message: chunk1, - }); - await nextTick(); - expect(findContent().text()).toBe(content1); - - wrapper.setProps({ - message: chunk2, - }); - await nextTick(); - expect(findContent().text()).toBe(consolidatedContent); - }); - - it('treats the initial message content as chunk if message has chunkId', async () => { - createComponent({ - propsData: { - message: chunk1, - }, - }); - expect(findContent().text()).toBe(content1); - - // 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({ - message: chunk2, - }); - await nextTick(); - expect(findContent().text()).toBe(content1 + content2); - }); - - it('does not re-render when chunk gets received after full message', async () => { - // 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({ - message: chunk1, - }); - await nextTick(); - expect(findContent().text()).toBe(content1); - - wrapper.setProps({ - message: { ...MOCK_CHUNK_MESSAGE, content: content1 + content2, chunkId: null }, - }); - await nextTick(); - expect(findContent().text()).toBe(content1 + content2); - - wrapper.setProps({ - message: chunk2, - }); - await nextTick(); - expect(findContent().text()).toBe(content1 + content2); - }); - - it('does not hydrate the chunk message with GLFM', async () => { - createComponent({ - propsData: { - message: chunk1, - }, - }); - renderGFM.mockClear(); - expect(renderGFM).not.toHaveBeenCalled(); - - // 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({ - message: chunk2, - }); - await nextTick(); - expect(renderGFM).not.toHaveBeenCalled(); - - await wrapper.setProps({ - message: { - ...chunk3, - chunkId: null, - }, - }); - await nextTick(); - expect(renderGFM).toHaveBeenCalled(); - }); - }); -}); diff --git a/ee/spec/frontend/ai/components/ai_genie_chat_spec.js b/ee/spec/frontend/ai/components/ai_genie_chat_spec.js deleted file mode 100644 index 8085f07e529714b64167f9042b23e24b2b442626..0000000000000000000000000000000000000000 --- a/ee/spec/frontend/ai/components/ai_genie_chat_spec.js +++ /dev/null @@ -1,370 +0,0 @@ -import { GlEmptyState, GlBadge, GlPopover } 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 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 = {}, slots = {}, glFeatures = {} } = {}) => { - jest.spyOn(AiGenieLoader.methods, 'computeTransitionWidth').mockImplementation(); - - wrapper = shallowMountExtended(AiGenieChat, { - propsData, - data() { - return { - ...data, - }; - }, - slots, - stubs: { - AiGenieLoader, - }, - provide: { - glFeatures, - }, - }); - }; - - const findChatComponent = () => wrapper.findByTestId('chat-component'); - const findCloseButton = () => wrapper.findByTestId('chat-close-button'); - const findChatConversations = () => wrapper.findAllComponents(AiGenieChatConversation); - const findCustomLoader = () => wrapper.findComponent(AiGenieLoader); - 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 findPopover = () => wrapper.findComponent(GlPopover); - const findEmptyState = () => wrapper.findComponent(GlEmptyState); - const findPredefined = () => wrapper.findComponent(AiPredefinedPrompts); - const findChatInput = () => wrapper.findByTestId('chat-prompt-input'); - const findCloseChatButton = () => wrapper.findByTestId('chat-close-button'); - const findLegalDisclaimer = () => wrapper.findByTestId('chat-legal-disclaimer'); - - beforeEach(() => { - createComponent(); - }); - - const promptStr = 'foo'; - const messages = [ - { - role: GENIE_CHAT_MODEL_ROLES.user, - content: promptStr, - }, - ]; - - describe('rendering', () => { - it.each` - desc | component | shouldRender - ${'renders root component'} | ${findChatComponent} | ${true} - ${'renders experimental label'} | ${findBadge} | ${true} - ${'renders empty state'} | ${findEmptyState} | ${true} - ${'renders predefined prompts'} | ${findPredefined} | ${true} - ${'does not render loading skeleton'} | ${findCustomLoader} | ${false} - ${'does not render chat error'} | ${findError} | ${false} - ${'does not render chat input'} | ${findChatInput} | ${false} - ${'renders a generated by AI note'} | ${findGeneratedByAI} | ${true} - `('$desc', ({ component, shouldRender }) => { - expect(component().exists()).toBe(shouldRender); - }); - - describe('when messages exist', () => { - it('scrolls to the bottom on load', async () => { - createComponent({ propsData: { messages } }); - const { element } = findChatComponent(); - jest.spyOn(element, 'scrollHeight', 'get').mockReturnValue(200); - - await nextTick(); - - expect(element.scrollTop).toEqual(200); - }); - }); - - 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); - }); - - 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 } }); - - 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 - ${'renders'} | ${'hero'} | ${slotContent} | ${true} | ${true} - ${'renders'} | ${'hero'} | ${slotContent} | ${false} | ${true} - ${'does not render'} | ${'subheader'} | ${slotContent} | ${false} | ${true} - ${'renders'} | ${'subheader'} | ${slotContent} | ${true} | ${true} - `( - '$desc the $content passed to the $slot slot when isChatAvailable is $isChatAvailable', - ({ slot, content, isChatAvailable, shouldRenderSlotContent }) => { - createComponent({ - propsData: { isChatAvailable }, - slots: { [slot]: content }, - }); - if (shouldRenderSlotContent) { - expect(wrapper.text()).toContain(content); - } else { - expect(wrapper.text()).not.toContain(content); - } - }, - ); - }); - - it('sets correct props on the Experiment badge', () => { - const badgeType = 'neutral'; - const badgeSize = 'md'; - expect(findBadge().props('variant')).toBe(badgeType); - expect(findBadge().props('size')).toBe(badgeSize); - expect(findBadge().text()).toBe(i18n.EXPERIMENT_BADGE); - }); - - it('shows the popover when the Experiment badge is clicked', () => { - createComponent(); - expect(findPopover().props('target')).toBe(findBadge().vm.$el.id); - }); - }); - - 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'); - await nextTick(); - expect(findChatComponent().exists()).toBe(false); - }); - - it('resets the hidden status of the component on loading', async () => { - createComponent({ data: { isHidden: true } }); - expect(findChatComponent().exists()).toBe(false); - // 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({ - isLoading: true, - }); - await nextTick(); - 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); - }); - - it('renders alert if error', () => { - const errorMessage = 'Something went Wrong'; - createComponent({ propsData: { error: errorMessage } }); - expect(findError().text()).toBe(errorMessage); - }); - - it('hides the chat on button click and emits an event', () => { - createComponent({ propsData: { messages } }); - expect(wrapper.vm.$data.isHidden).toBe(false); - findCloseChatButton().vm.$emit('click'); - expect(wrapper.vm.$data.isHidden).toBe(true); - expect(wrapper.emitted('chat-hidden')).toBeDefined(); - }); - - it('does not render the empty state when there are messages available', () => { - createComponent({ propsData: { messages } }); - expect(findEmptyState().exists()).toBe(false); - }); - - describe('scrolling', () => { - let element; - - beforeEach(() => { - createComponent({ propsData: { messages, isChatAvailable: true } }); - element = findChatComponent().element; - }); - - it('when scrolling to the bottom it removes the scrim class', async () => { - jest.spyOn(element, 'scrollTop', 'get').mockReturnValue(100); - jest.spyOn(element, 'offsetHeight', 'get').mockReturnValue(100); - jest.spyOn(element, 'scrollHeight', 'get').mockReturnValue(200); - - findChatComponent().trigger('scroll'); - - await nextTick(); - - expect(findFooter().classes()).not.toContain('gl-drawer-body-scrim-on-footer'); - }); - - it('when scrolling up it adds the scrim class', async () => { - jest.spyOn(element, 'scrollTop', 'get').mockReturnValue(50); - jest.spyOn(element, 'offsetHeight', 'get').mockReturnValue(100); - jest.spyOn(element, 'scrollHeight', 'get').mockReturnValue(200); - - findChatComponent().trigger('scroll'); - - await nextTick(); - - expect(findFooter().classes()).toContain('gl-drawer-body-scrim-on-footer'); - }); - }); - - describe('predefined prompts', () => { - const prompts = ['what is a fork']; - - beforeEach(() => { - createComponent({ propsData: { predefinedPrompts: prompts } }); - }); - - it('passes on predefined prompts', () => { - expect(findPredefined().props().prompts).toEqual(prompts); - }); - - it('listens to the click event and sends the predefined prompt', async () => { - findPredefined().vm.$emit('click', prompts[0]); - - await nextTick(); - - expect(wrapper.emitted('send-chat-prompt')).toEqual([[prompts[0]]]); - }); - }); - }); -}); diff --git a/ee/spec/frontend/ai/components/ai_genie_loader_spec.js b/ee/spec/frontend/ai/components/ai_genie_loader_spec.js deleted file mode 100644 index 9775e0a3eb316cc677cf18b608de39de6cfbbd9d..0000000000000000000000000000000000000000 --- a/ee/spec/frontend/ai/components/ai_genie_loader_spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import { GlSprintf } from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import Vue, { nextTick } from 'vue'; -import AiGenieLoader from 'ee/ai/components/ai_genie_loader.vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { i18n, GENIE_CHAT_LOADING_TRANSITION_DURATION } from 'ee/ai/constants'; - -Vue.use(Vuex); - -describe('AiGenieLoader', () => { - let wrapper; - - const createComponent = ( - transitionTexts = i18n.GENIE_CHAT_LOADING_TRANSITIONS, - initialState = {}, - ) => { - jest.spyOn(AiGenieLoader.methods, 'computeTransitionWidth').mockImplementation(); - - const store = new Vuex.Store({ - state: { - ...initialState, - }, - }); - - wrapper = shallowMountExtended(AiGenieLoader, { - store, - stubs: { GlSprintf }, - i18n: { ...AiGenieLoader.i18n, GENIE_CHAT_LOADING_TRANSITIONS: transitionTexts }, - }); - }; - - const transition = async () => { - jest.advanceTimersByTime(GENIE_CHAT_LOADING_TRANSITION_DURATION); - await nextTick(); - }; - - const findTransitionText = () => wrapper.vm.$refs.currentTransition[0].innerText; - const findToolText = () => wrapper.findByTestId('tool'); - - describe('rendering', () => { - it('displays a loading message', async () => { - createComponent(['broadcasting']); - await nextTick(); - - expect(wrapper.text()).toContain('GitLab Duo is broadcasting an answer'); - }); - - it('cycles through transition texts', async () => { - createComponent(); - await nextTick(); - - expect(findTransitionText()).toEqual('finding'); - - await transition(); - - expect(findTransitionText()).toEqual('working on'); - - await transition(); - - expect(findTransitionText()).toEqual('generating'); - - await transition(); - - expect(findTransitionText()).toEqual('producing'); - }); - - it('shows the default tool if `toolMessage` is empty', async () => { - createComponent(); - await nextTick(); - - expect(findToolText().text()).toBe(i18n.GITLAB_DUO); - }); - - it('shows the `toolMessage` if it exists in the state', async () => { - createComponent(i18n.GENIE_CHAT_LOADING_TRANSITIONS, { - toolMessage: { content: 'foo' }, - }); - await nextTick(); - - expect(findToolText().text()).toBe('foo'); - }); - }); -}); diff --git a/ee/spec/frontend/ai/components/ai_genie_spec.js b/ee/spec/frontend/ai/components/ai_genie_spec.js index 7cb38cb11712c4d155486b74bbd48376e859286f..036da7682bc2add48eeceac87dab6ee953de979c 100644 --- a/ee/spec/frontend/ai/components/ai_genie_spec.js +++ b/ee/spec/frontend/ai/components/ai_genie_spec.js @@ -1,23 +1,18 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { GlButton } from '@gitlab/ui'; +import { createAlert } from '~/alert'; 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 AiGenieChatMessage from 'ee/ai/components/ai_genie_chat_message.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'; -import { i18n, GENIE_CHAT_MODEL_ROLES, EXPLAIN_CODE_TRACKING_EVENT_NAME } from 'ee/ai/constants'; +import { i18n, GENIE_CHAT_EXPLAIN_MESSAGE } from 'ee/ai/constants'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import explainCodeMutation from 'ee/ai/graphql/explain_code.mutation.graphql'; +import chatMutation from 'ee/ai/graphql/chat.mutation.graphql'; import aiResponseSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response.subscription.graphql'; import LineHighlighter from '~/blob/line_highlighter'; import { getMarkdown } from '~/rest_api'; -import { MOCK_USER_MESSAGE, MOCK_TANUKI_MESSAGE } from '../tanuki_bot/mock_data'; +import { helpCenterState } from '~/super_sidebar/constants'; const lineHighlighter = new LineHighlighter(); jest.mock('~/blob/line_highlighter', () => @@ -31,6 +26,7 @@ jest.mock('ee/ai/utils', () => ({ generateChatPrompt: jest.fn(), })); jest.mock('~/rest_api'); +jest.mock('~/alert'); Vue.use(VueApollo); @@ -57,7 +53,6 @@ const explainCodeSubscriptionResponse = { const SELECTION_START_POSITION = 50; const SELECTION_END_POSITION = 150; const CONTAINER_TOP = 20; -const SELECTED_TEXT = 'Foo'; const LINE_ID = 'LC1'; let mutationHandlerMock; @@ -66,19 +61,19 @@ let subscriptionHandlerMock; describe('AiGenie', () => { let wrapper; const containerSelector = '.container'; + const filePath = 'some_file.js'; const language = 'vue'; const resourceId = 'gid://gitlab/Project/1'; - const userId = 'gid://gitlab/User/1'; const getContainer = () => document.querySelector(containerSelector); const createComponent = ({ - propsData = { containerSelector }, + propsData = { containerSelector, filePath }, data = {}, glFeatures = {}, } = {}) => { const apolloProvider = createMockApollo([ [aiResponseSubscription, subscriptionHandlerMock], - [explainCodeMutation, mutationHandlerMock], + [chatMutation, mutationHandlerMock], ]); wrapper = shallowMountExtended(AiGenie, { @@ -88,21 +83,12 @@ describe('AiGenie', () => { }, provide: { resourceId, - userId, glFeatures, }, - stubs: { - AiGenieChat, - AiGenieChatConversation, - AiGenieChatMessage, - }, apolloProvider, }); }; const findButton = () => wrapper.findComponent(GlButton); - const findGenieChat = () => wrapper.findComponent(AiGenieChat); - const findCodeBlock = () => wrapper.findComponent(CodeBlockHighlighted); - const findAllUserFeedback = () => wrapper.findAllComponents(UserFeedback); const getRangeAtMock = (top = () => 0) => { return jest.fn((rangePosition) => { @@ -174,37 +160,6 @@ describe('AiGenie', () => { it('correctly renders the component by default', () => { createComponent(); expect(findButton().exists()).toBe(true); - expect(findGenieChat().exists()).toBe(false); - }); - - describe('selected text block', () => { - const selectedText = 'bar'; - - it('renders CodeBlockHighlighted in the `hero` slot of AiGenieChat', () => { - createComponent({ - data: { - messages: ['foo'], - selectedText, - snippetLanguage: language, - }, - }); - const codeBlockComponent = findCodeBlock(); - expect(codeBlockComponent.exists()).toBe(true); - expect(codeBlockComponent.props('code')).toBe(selectedText); - expect(codeBlockComponent.props('language')).toBe(language); - }); - - it('sets language as "plaintext" if the snippet does not have it set', () => { - createComponent({ - data: { - messages: ['foo'], - selectedText, - }, - }); - const codeBlockComponent = findCodeBlock(); - expect(codeBlockComponent.exists()).toBe(true); - expect(codeBlockComponent.props('language')).toBe('plaintext'); - }); }); describe('the toggle button', () => { @@ -277,177 +232,65 @@ describe('AiGenie', () => { }); describe('interaction', () => { - const promptStr = 'foo'; - const messages = [ - { - role: GENIE_CHAT_MODEL_ROLES.user, - content: promptStr, - }, - ]; beforeEach(() => { createComponent(); - generateExplainCodePrompt.mockReturnValue(promptStr); - generateChatPrompt.mockReturnValue(messages); }); - it('toggles genie when the button is clicked', async () => { - findButton().vm.$emit('click'); - await nextTick(); - expect(findGenieChat().exists()).toBe(true); + it('toggles the Duo Chat when explain code requested', async () => { + await simulateSelectText(); + await requestExplanation(); + expect(helpCenterState.showTanukiBotChatDrawer).toBe(true); }); it('calls a GraphQL mutation when explain code requested', async () => { + await simulateSelectText(); await requestExplanation(); - expect(generateExplainCodePrompt).toHaveBeenCalledTimes(1); expect(mutationHandlerMock).toHaveBeenCalledWith({ + question: GENIE_CHAT_EXPLAIN_MESSAGE, resourceId, - messages, - }); - }); - - it('calls the subscription with correct variables', async () => { - await requestExplanation(); - await waitForPromises(); - - expect(subscriptionHandlerMock).toHaveBeenCalledWith({ - resourceId, - userId, - htmlResponse: true, - }); - }); - - it('once the response arrives, :content is set with the response message', async () => { - await requestExplanation(); - await waitForPromises(); - await nextTick(); - - expect(subscriptionHandlerMock).toHaveBeenCalledWith({ - resourceId, - userId, - htmlResponse: true, - }); - - const filteredMessages = messages.slice(2); - expect(findGenieChat().props('messages')).toEqual(filteredMessages); - }); - - it('when a snippet is selected, :selected-text gets the same content', async () => { - const toString = () => SELECTED_TEXT; - const getSelection = getSelectionMock({ toString }); - await simulateSelectText({ getSelection }); - await requestExplanation(); - expect(findCodeBlock().props('code')).toBe(SELECTED_TEXT); - }); - - it('sets the snippet language', async () => { - await simulateSelectText(); - await requestExplanation(); - expect(findCodeBlock().props('language')).toBe(language); - }); - - it('correctly updates the isLoading flag on successful code explanation path', async () => { - createComponent({ - data: { - messages, + currentFileContext: { + fileName: filePath, + selectedText: getSelection().toString(), }, }); - expect(findGenieChat().props('isLoading')).toBe(false); - await requestExplanation(); - expect(findGenieChat().props('isLoading')).toBe(true); - await waitForPromises(); - await nextTick(); - expect(findGenieChat().props('isLoading')).toBe(false); }); describe('error handling', () => { - it('if the subscription fails, genie gets :error set with the error message', async () => { - subscriptionHandlerMock.mockRejectedValueOnce({ errors: [] }); - createComponent(); - await requestExplanation(); - await waitForPromises(); - expect(findGenieChat().props('error')).toBe(i18n.REQUEST_ERROR); - expect(findGenieChat().props('isLoading')).toBe(false); - }); - - it('if the mutation fails, genie gets :error set with the error message', async () => { + it('if the mutation fails, an alert is created', async () => { mutationHandlerMock = jest.fn().mockRejectedValue(); createComponent(); await requestExplanation(); await waitForPromises(); - expect(findGenieChat().props('error')).toBe(i18n.REQUEST_ERROR); - expect(findGenieChat().props('isLoading')).toBe(false); - }); - - it('if the subscription is successful, but the subscription receives an error in GraphQL response, an error message is displayed', async () => { - const responseWithError = { - ...defaultAiCompletionResponse, - content: aiResponse, - errors: ['Some error'], - }; - subscriptionHandlerMock = jest.fn().mockResolvedValue({ - data: { aiCompletionResponse: responseWithError }, + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while explaining the code.', + captureError: true, + error: expect.any(Error), }); - createComponent(); - await requestExplanation(); - await waitForPromises(); - expect(findGenieChat().props().error).toBe(i18n.REQUEST_ERROR); - }); - }); - - describe('chat mode', () => { - it('sends the mutation again when the chat prompt is triggered', async () => { - createComponent(); - findButton().vm.$emit('click'); - await nextTick(); - findGenieChat().vm.$emit('send-chat-prompt'); - await nextTick(); - expect(findGenieChat().props('isLoading')).toBe(true); - expect(mutationHandlerMock).toHaveBeenCalledTimes(2); }); }); }); - it('renders the User Feedback component for every assistant 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], - }, - }); - - 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'); - }); - }); - describe('Lines highlighting', () => { beforeEach(() => { createComponent(); }); + it('initiates LineHighlighter', () => { expect(LineHighlighter).toHaveBeenCalled(); }); + it('calls highlightRange with expected range', async () => { await simulateSelectText(); await requestExplanation(); expect(lineHighlighter.highlightRange).toHaveBeenCalledWith([1, 1]); }); + it('calls clearHighlight to clear previous selection', async () => { await simulateSelectText(); await requestExplanation(); expect(lineHighlighter.clearHighlight).toHaveBeenCalledTimes(1); }); - it('calls clearHighlight when chat is closed', async () => { - await simulateSelectText(); - await requestExplanation(); - findGenieChat().vm.$emit('chat-hidden'); - expect(lineHighlighter.clearHighlight).toHaveBeenCalledTimes(2); - }); + it('does not call highlight range when no line found', async () => { document.getElementById(`${LINE_ID}`).classList.remove('line'); await simulateSelectText(); @@ -455,53 +298,4 @@ describe('AiGenie', () => { expect(lineHighlighter.highlightRange).not.toHaveBeenCalled(); }); }); - - describe('chat', () => { - const messages = [ - { - role: GENIE_CHAT_MODEL_ROLES.user, - content: 'foo', - }, - ]; - - it.each` - msgs | isFlagOn | expectedProp - ${[]} | ${false} | ${false} - ${messages} | ${false} | ${false} - ${[]} | ${true} | ${false} - ${messages} | ${true} | ${true} - `( - 'sets isChatAvailable to $expectedProp when messages are $msgs and the flag is $isFlagOn', - async ({ msgs, isFlagOn, expectedProp }) => { - createComponent({ - data: { messages: msgs, isLoading: true }, - glFeatures: { explainCodeChat: isFlagOn }, - }); - await nextTick(); - expect(findGenieChat().props('isChatAvailable')).toBe(expectedProp); - }, - ); - - it('listens to the chat-prompt event and sends the prompt to the mutation', async () => { - const prompt = SELECTED_TEXT; - const generatedPrompt = [ - ...messages, - { - role: GENIE_CHAT_MODEL_ROLES.user, - content: prompt, - }, - ]; - createComponent({ data: { messages, isLoading: true } }); - await nextTick(); - - generateChatPrompt.mockReturnValue(generatedPrompt); - findGenieChat().vm.$emit('send-chat-prompt', prompt); - - expect(generateChatPrompt).toHaveBeenCalledWith(prompt, messages); - expect(mutationHandlerMock).toHaveBeenCalledWith({ - resourceId, - messages: generatedPrompt, - }); - }); - }); }); diff --git a/ee/spec/frontend/ai/utils_spec.js b/ee/spec/frontend/ai/utils_spec.js index 9c850cf67f3bef1fac28f5725cd9fa4a78488218..e9f1d04238a7a40f44966ff7433f38d356cd1124 100644 --- a/ee/spec/frontend/ai/utils_spec.js +++ b/ee/spec/frontend/ai/utils_spec.js @@ -1,193 +1,6 @@ import { utils } from 'ee/ai/utils'; -import { i18n, GENIE_CHAT_MODEL_ROLES } from 'ee/ai/constants'; -import { sprintf } from '~/locale'; - -// To simplify the things in testing, we override the globals -// to make the MAX_RESPONSE_TOKENS and TOKENS_THRESHOLD smaller -// and easier to control -const TOKENS_THRESHOLD = 40; -const MAX_RESPONSE_TOKENS = 4; -const MAX_PROMPT_TOKENS = TOKENS_THRESHOLD - MAX_RESPONSE_TOKENS; // 36 tokens describe('AI Utils', () => { - beforeEach(() => { - gon.ai = { - chat: { - max_response_token: MAX_RESPONSE_TOKENS, - input_content_limit: TOKENS_THRESHOLD, - }, - }; - }); - - describe('generateExplainCodePrompt', () => { - const filePath = 'fooPath'; - const fileText = 'barText'; - - it('generates a prompts based of the file path and text', () => { - const result = utils.generateExplainCodePrompt(fileText, filePath); - const content = sprintf(i18n.EXPLAIN_CODE_PROMPT, { - filePath, - text: fileText, - }); - expect(result).toEqual(content); - }); - }); - - describe('generateChatPrompt', () => { - describe('when the prompt is not too large', () => { - const userPrompt = 'U'; - const userMessage = { - role: GENIE_CHAT_MODEL_ROLES.user, - content: userPrompt, - }; - const defaultPrompt = [ - { - role: GENIE_CHAT_MODEL_ROLES.system, - content: 'You are an assistant explaining to an engineer', - }, - userMessage, - ]; - - it.each` - desc | newPrompt | basePrompts | expectedPrompts - ${'returns [] for "newPrompt = undefined" and "basePrompts = []"'} | ${undefined} | ${[]} | ${[]} - ${'returns [] for `newPrompt = ""` and "basePrompts = []"'} | ${''} | ${[]} | ${[]} - ${'returns defaultPrompt for "newPrompt = undefined" and "basePrompts = defaultPrompt"'} | ${undefined} | ${defaultPrompt} | ${defaultPrompt} - ${'returns defaultPrompt for `newPrompt = ""` and "basePrompts = defaultPrompt"'} | ${''} | ${defaultPrompt} | ${defaultPrompt} - ${'returns defaultPrompt for `newPrompt = userPrompt` and "basePrompts = []"'} | ${userPrompt} | ${[]} | ${defaultPrompt} - ${'returns { ...defaultPrompt, userMessage } for `newPrompt = userPrompt` and "basePrompts = defaultPrompt"'} | ${userPrompt} | ${defaultPrompt} | ${[...defaultPrompt, userMessage]} - `('$desc', ({ newPrompt, basePrompts, expectedPrompts }) => { - expect(utils.generateChatPrompt(newPrompt, basePrompts)).toEqual(expectedPrompts); - }); - }); - - describe('when the prompt is too large', () => { - let result; - let computeTokensSpy; - const systemMessage = { - role: GENIE_CHAT_MODEL_ROLES.system, - content: 'alpha', - }; - const userMessage1 = { - role: GENIE_CHAT_MODEL_ROLES.user, - content: 'beta1', - }; - const userMessage2 = { - role: GENIE_CHAT_MODEL_ROLES.user, - content: 'beta2', - }; - const assistantMessage1 = { - role: GENIE_CHAT_MODEL_ROLES.assistant, - content: 'gamma1', - }; - const assistantMessage2 = { - role: GENIE_CHAT_MODEL_ROLES.assistant, - content: 'gamma2', - }; - const basePrompts = [ - systemMessage, - userMessage1, - assistantMessage1, - userMessage2, - assistantMessage2, - ]; - const userPrompt = 'delta'; - const lastUserMessage = { - role: GENIE_CHAT_MODEL_ROLES.user, - content: userPrompt, - }; - - afterEach(() => { - result = null; - computeTokensSpy.mockRestore(); - }); - - it('first, drops the system message if the prompt tokens length is at or exceeds the threshold MAX_PROMPT_TOKENS', () => { - computeTokensSpy = jest - .spyOn(utils, 'computeTokens') - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementation(() => MAX_PROMPT_TOKENS - 1); - result = utils.generateChatPrompt(userPrompt, basePrompts); - expect(result).toEqual([ - userMessage1, - assistantMessage1, - userMessage2, - assistantMessage2, - lastUserMessage, - ]); - }); - - it('then drops the user messages if the prompt tokens length is still at or exceeds the threshold MAX_PROMPT_TOKENS', () => { - computeTokensSpy = jest - .spyOn(utils, 'computeTokens') - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementation(() => MAX_PROMPT_TOKENS - 1); - result = utils.generateChatPrompt(userPrompt, basePrompts); - expect(result).toEqual([assistantMessage1, assistantMessage2, lastUserMessage]); - }); - - it('then drops the assistant messages if the prompt tokens length is still at or exceeds the threshold MAX_PROMPT_TOKENS', () => { - computeTokensSpy = jest - .spyOn(utils, 'computeTokens') - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementation(() => MAX_PROMPT_TOKENS - 1); - result = utils.generateChatPrompt(userPrompt, basePrompts); - expect(result).toEqual([assistantMessage2, lastUserMessage]); - }); - - it('throws an error if there are only two messages and the prompt is still too large and can not be truncated', () => { - computeTokensSpy = jest - .spyOn(utils, 'computeTokens') - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementationOnce(() => MAX_PROMPT_TOKENS) - .mockImplementation(() => MAX_PROMPT_TOKENS - 1); - expect(() => utils.generateChatPrompt(userPrompt, basePrompts)).toThrow( - i18n.TOO_LONG_ERROR_MESSAGE, - ); - }); - - it.each` - max_response_token | input_content_limit - ${123} | ${undefined} - ${undefined} | ${123} - ${undefined} | ${undefined} - `( - 'drops no messages if token limitations are not available (delegates dealing with the prompt to BE)', - ({ max_response_token, input_content_limit }) => { - gon.ai = { - chat: { max_response_token, input_content_limit }, - }; - - result = utils.generateChatPrompt(userPrompt, basePrompts); - expect(result).toEqual([...basePrompts, lastUserMessage]); - }, - ); - }); - }); - - describe('computeTokens', () => { - it.each` - messagesDesc | messages | expectedTokens - ${"[{ role: '', content: '' }]"} | ${[{ role: '', content: '' }]} | ${Math.ceil(0 + 4 + 3)} - ${"[{ role: 'system', content: '' }]"} | ${[{ role: GENIE_CHAT_MODEL_ROLES.system, content: '' }]} | ${Math.ceil('system'.length / 4 + 4 + 3)} - ${"[{ role: 'user', content: 'foo' }]"} | ${[{ role: GENIE_CHAT_MODEL_ROLES.user, content: 'foo' }]} | ${Math.ceil('userfoo'.length / 4 + 4 + 3)} - ${"[{ role: 'user', content: 'foo' }, { role: 'assistant', content: 'bar' }]"} | ${[{ role: GENIE_CHAT_MODEL_ROLES.user, content: 'foo' }, { role: GENIE_CHAT_MODEL_ROLES.assistant, content: 'bar' }]} | ${Math.ceil('userfooassistantbar'.length / 4 + 4 * 2 + 3)} - `( - 'correctly computes the number of tokens for $messagesDesc', - ({ messages, expectedTokens }) => { - expect(utils.computeTokens(messages)).toBe(expectedTokens); - }, - ); - }); - describe('concatStreamedChunks', () => { it.each` input | expected diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 58c95b9684094ebcfd63a4ff90a8053e91399c0e..6917a3b3c6e0b0750c366ee22fa22ab298b2fc61 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1958,33 +1958,15 @@ msgstr "" msgid "AISummary|View summary" msgstr "" -msgid "AI|%{tool} is %{transition} an answer" -msgstr "" - -msgid "AI|AI generated explanations will appear here." -msgstr "" - -msgid "AI|An %{linkStart}Experiment%{linkEnd} is a feature that's in the process of being developed. It's not production-ready. We encourage users to try Experimental features and provide feedback. An Experiment: %{bullets}" +msgid "AI|An error occurred while explaining the code." msgstr "" msgid "AI|Apply AI-generated description" msgstr "" -msgid "AI|Ask a question" -msgstr "" - msgid "AI|Autocomplete" msgstr "" -msgid "AI|Can be removed at any time" -msgstr "" - -msgid "AI|Close the Code Explanation" -msgstr "" - -msgid "AI|Code Explanation" -msgstr "" - msgid "AI|Creates issue description based on a short prompt" msgstr "" @@ -1994,9 +1976,6 @@ msgstr "" msgid "AI|Experiment" msgstr "" -msgid "AI|Explain the code from %{filePath} in human understandable language presented in Markdown format. In the response add neither original code snippet nor any title. `%{text}`. If it is not programming code, say `The selected text is not code. I am afraid this feature is for explaining code only. Would you like to ask a different question about the selected text?` and wait for another question." -msgstr "" - msgid "AI|Explain your rating to help us improve! (optional)" msgstr "" @@ -2009,18 +1988,12 @@ msgstr "" msgid "AI|GitLab Duo" msgstr "" -msgid "AI|GitLab Duo Chat" -msgstr "" - msgid "AI|Give feedback on AI content" msgstr "" msgid "AI|Give feedback to improve this answer." msgstr "" -msgid "AI|Has no support and might not be documented" -msgstr "" - msgid "AI|Helpful" msgstr "" @@ -2033,15 +2006,6 @@ msgstr "" msgid "AI|I don't see how I can help. Please give better instructions!" msgstr "" -msgid "AI|May be unstable" -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 "" @@ -2054,11 +2018,6 @@ msgstr "" msgid "AI|Something went wrong. Please try again later" msgstr "" -msgid "AI|Source" -msgid_plural "AI|Sources" -msgstr[0] "" -msgstr[1] "" - msgid "AI|Thank you for your feedback." msgstr "" @@ -2068,9 +2027,6 @@ msgstr "" msgid "AI|The existing description will be replaced when you submit." msgstr "" -msgid "AI|There is too much text in the chat. Please try again with a shorter text." -msgstr "" - msgid "AI|This is an experiment feature that uses AI to provide recommendations for resolving this vulnerability. Use this feature with caution." msgstr "" @@ -2083,9 +2039,6 @@ msgstr "" msgid "AI|What does the selected code mean?" msgstr "" -msgid "AI|What's an Experiment?" -msgstr "" - msgid "AI|Write a brief description and have AI fill in the details." msgstr "" @@ -2095,21 +2048,6 @@ msgstr "" msgid "AI|Wrong" msgstr "" -msgid "AI|You are not allowed to copy any part of this output into issues, comments, GitLab source code, commit messages, merge requests or any other user interface in the %{gitlabOrg} or %{gitlabCom} groups." -msgstr "" - -msgid "AI|finding" -msgstr "" - -msgid "AI|generating" -msgstr "" - -msgid "AI|producing" -msgstr "" - -msgid "AI|working on" -msgstr "" - msgid "API" msgstr "" @@ -59763,9 +59701,6 @@ msgstr "" msgid "protected" msgstr "" -msgid "random" -msgstr "" - msgid "reCAPTCHA" msgstr ""