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"
/>
-
- {{ $options.i18n.GENIE_CHAT_NEW_CHAT }}
-
-
-
- /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 ""