From 08b3f1a836293ba1c6b049b0caeae5b7c61103c5 Mon Sep 17 00:00:00 2001 From: Marc Saleiko <msaleiko@gitlab.com> Date: Thu, 22 Feb 2024 16:34:18 +0000 Subject: [PATCH] Adds Ai agents debug chat interface --- .../ai/graphql/chat.mutation.graphql | 8 +- .../assets/javascripts/ml/ai_agents/index.js | 3 +- .../ml/ai_agents/views/show_agent.vue | 92 ++++++++++++- .../assets/stylesheets/components/_index.scss | 1 + .../stylesheets/components/ai_agent_chat.scss | 19 +++ .../views/projects/ml/agents/index.html.haml | 2 +- .../ml/ai_agents/views/show_agent_spec.js | 130 +++++++++++++++--- locale/gitlab.pot | 12 ++ 8 files changed, 243 insertions(+), 24 deletions(-) create mode 100644 ee/app/assets/stylesheets/components/ai_agent_chat.scss diff --git a/ee/app/assets/javascripts/ai/graphql/chat.mutation.graphql b/ee/app/assets/javascripts/ai/graphql/chat.mutation.graphql index 5b88d5d6b6cf1..64eaeba06d23e 100644 --- a/ee/app/assets/javascripts/ai/graphql/chat.mutation.graphql +++ b/ee/app/assets/javascripts/ai/graphql/chat.mutation.graphql @@ -1,12 +1,18 @@ mutation chat( $question: String! $resourceId: AiModelID! + $agentVersionId: AiAgentVersionID $clientSubscriptionId: String $currentFileContext: AiCurrentFileInput ) { aiAction( input: { - chat: { resourceId: $resourceId, content: $question, currentFile: $currentFileContext } + chat: { + resourceId: $resourceId + content: $question + agentVersionId: $agentVersionId + currentFile: $currentFileContext + } clientSubscriptionId: $clientSubscriptionId } ) { diff --git a/ee/app/assets/javascripts/ml/ai_agents/index.js b/ee/app/assets/javascripts/ml/ai_agents/index.js index 1d62f12aa75ee..1ad2d3b2c41cb 100644 --- a/ee/app/assets/javascripts/ml/ai_agents/index.js +++ b/ee/app/assets/javascripts/ml/ai_agents/index.js @@ -13,7 +13,7 @@ export default () => { return false; } - const { basePath, projectPath } = el.dataset; + const { basePath, projectPath, userId } = el.dataset; const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), @@ -28,6 +28,7 @@ export default () => { router, provide: { projectPath, + userId, }, render(h) { return h(BaseApp); diff --git a/ee/app/assets/javascripts/ml/ai_agents/views/show_agent.vue b/ee/app/assets/javascripts/ml/ai_agents/views/show_agent.vue index ea1712171e19f..2ad6579a7952a 100644 --- a/ee/app/assets/javascripts/ml/ai_agents/views/show_agent.vue +++ b/ee/app/assets/javascripts/ml/ai_agents/views/show_agent.vue @@ -1,25 +1,97 @@ <script> -import { GlExperimentBadge } from '@gitlab/ui'; +import { GlExperimentBadge, GlDuoChat } from '@gitlab/ui'; +import aiResponseSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response.subscription.graphql'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import { renderMarkdown } from '~/notes/utils'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { sprintf, s__ } from '~/locale'; +import { GENIE_CHAT_MODEL_ROLES } from 'ee/ai/constants'; +import chatMutation from 'ee/ai/graphql/chat.mutation.graphql'; export default { name: 'ShowAiAgent', + // Needed to override the default predefined prompts + predefinedPrompts: [], components: { TitleArea, GlExperimentBadge, + GlDuoChat, }, provide() { return { projectPath: this.projectPath, + userId: this.userId, + renderMarkdown, + renderGFM, + }; + }, + inject: ['projectPath', 'userId'], + apollo: { + // https://apollo.vuejs.org/guide-option/subscriptions.html#simple-subscription + $subscribe: { + aiCompletionResponse: { + query: aiResponseSubscription, + variables() { + return { + userId: this.userId, + agentVersionId: `gid://gitlab/Ai::AgentVersion/${this.$route.params.agentId}`, + aiAction: 'CHAT', + }; + }, + result({ data }) { + const response = data?.aiCompletionResponse; + + if (!response) { + return; + } + + this.messages.push(response); + + if (response.role.toLowerCase() === GENIE_CHAT_MODEL_ROLES.assistant) { + this.isLoading = false; + } + }, + error(err) { + this.error = err.toString(); + }, + }, + }, + }, + data() { + return { + error: null, + messages: [], + isLoading: false, }; }, - inject: ['projectPath'], computed: { title() { return sprintf(s__('AIAgent|AI Agent: %{agentId}'), { agentId: this.$route.params.agentId }); }, }, + methods: { + onSendChatPrompt(question = '') { + this.isLoading = true; + + this.$apollo + .mutate({ + mutation: chatMutation, + variables: { + question, + resourceId: this.userId, + agentVersionId: `gid://gitlab/Ai::AgentVersion/${this.$route.params.agentId}`, + }, + }) + .then(() => { + // we add the user message in the aiCompletionResponse subscription + this.isLoading = true; + }) + .catch((err) => { + this.error = err.toString(); + this.isLoading = false; + }); + }, + }, }; </script> @@ -33,5 +105,21 @@ export default { </div> </template> </title-area> + + <gl-duo-chat + :messages="messages" + :error="error" + :is-loading="isLoading" + :predefined-prompts="$options.predefinedPrompts" + :tool-name="s__('AIAgent|Agent')" + class="ai-agent-chat gl-w-full! gl-static gl-border-r gl-border-transparent" + :empty-state-title="s__('AIAgent|Try out your agent')" + :empty-state-description=" + s__('AIAgent|Your agent\'s system prompt will be applied to the chat input.') + " + :chat-prompt-placeholder="s__('AIAgent|Ask your agent')" + :show-header="false" + @send-chat-prompt="onSendChatPrompt" + /> </div> </template> diff --git a/ee/app/assets/stylesheets/components/_index.scss b/ee/app/assets/stylesheets/components/_index.scss index f7f6ff607620c..9b5389e971eba 100644 --- a/ee/app/assets/stylesheets/components/_index.scss +++ b/ee/app/assets/stylesheets/components/_index.scss @@ -1,3 +1,4 @@ +@import './ai_agent_chat'; @import './audit_logs/logs_table'; @import './banner'; @import './batch_comments/draft_note'; diff --git a/ee/app/assets/stylesheets/components/ai_agent_chat.scss b/ee/app/assets/stylesheets/components/ai_agent_chat.scss new file mode 100644 index 0000000000000..3f6ac7ee9f27a --- /dev/null +++ b/ee/app/assets/stylesheets/components/ai_agent_chat.scss @@ -0,0 +1,19 @@ +.ai-agent-chat { + // prevents the chat input from jumping all over the place + min-height: $gl-spacing-scale-48; + + section { + @include gl-bg-transparent; + } + + footer { + @include gl-border-transparent; + @include gl-bg-white; + } + // Disable feedback + // Improve selector once the feedback has its own class name: + // https://gitlab.com/gitlab-org/gitlab/-/issues/442715 + .duo-chat-message > div.gl-display-flex:last-of-type { + @include gl-display-none; + } +} \ No newline at end of file diff --git a/ee/app/views/projects/ml/agents/index.html.haml b/ee/app/views/projects/ml/agents/index.html.haml index 615276826cd0b..a5ba2b8c2450c 100644 --- a/ee/app/views/projects/ml/agents/index.html.haml +++ b/ee/app/views/projects/ml/agents/index.html.haml @@ -1,4 +1,4 @@ - breadcrumb_title s_('AIAgents|AI Agents') - page_title s_('AIAgents|AI Agents') -#js-mount-index-ml-agents{ data: { project_path: @project.full_path, base_path: namespace_project_ml_agents_path(@project.namespace, @project) } } +#js-mount-index-ml-agents{ data: { project_path: @project.full_path, base_path: namespace_project_ml_agents_path(@project.namespace, @project), user_id: current_user.to_global_id } } diff --git a/ee/spec/frontend/ml/ai_agents/views/show_agent_spec.js b/ee/spec/frontend/ml/ai_agents/views/show_agent_spec.js index fb73d3043363f..f5fc1fed5d75c 100644 --- a/ee/spec/frontend/ml/ai_agents/views/show_agent_spec.js +++ b/ee/spec/frontend/ml/ai_agents/views/show_agent_spec.js @@ -1,34 +1,126 @@ -import { GlExperimentBadge } from '@gitlab/ui'; +import { GlDuoChat, GlExperimentBadge } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { getMarkdown } from '~/rest_api'; import ShowAgent from 'ee/ml/ai_agents/views/show_agent.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import aiResponseSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response.subscription.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; +import chatMutation from 'ee/ai/graphql/chat.mutation.graphql'; +import { + MOCK_USER_MESSAGE, + MOCK_USER_ID, + MOCK_TANUKI_SUCCESS_RES, + MOCK_TANUKI_BOT_MUTATATION_RES, +} from '../../../ai/tanuki_bot/mock_data'; -let wrapper; +Vue.use(VueApollo); -const createWrapper = () => { - wrapper = shallowMountExtended(ShowAgent, { - provide: { projectPath: 'path/to/project' }, - mocks: { - $route: { - params: { - agentId: 2, +jest.mock('~/rest_api'); + +describe('ee/ml/ai_agents/views/create_agent', () => { + let wrapper; + + const subscriptionHandlerMock = jest.fn().mockResolvedValue(MOCK_TANUKI_SUCCESS_RES); + const chatMutationHandlerMock = jest.fn().mockResolvedValue(MOCK_TANUKI_BOT_MUTATATION_RES); + const agentId = 2; + + const findTitleArea = () => wrapper.findComponent(TitleArea); + const findBadge = () => wrapper.findComponent(GlExperimentBadge); + const findGlDuoChat = () => wrapper.findComponent(GlDuoChat); + + const createWrapper = () => { + const apolloProvider = createMockApollo([ + [aiResponseSubscription, subscriptionHandlerMock], + [chatMutation, chatMutationHandlerMock], + ]); + + wrapper = shallowMountExtended(ShowAgent, { + apolloProvider, + provide: { projectPath: 'path/to/project', userId: MOCK_USER_ID }, + mocks: { + $route: { + params: { + agentId, + }, }, }, - }, + }); + }; + + beforeEach(() => { + getMarkdown.mockImplementation(({ text }) => Promise.resolve({ data: { html: text } })); }); -}; -const findTitleArea = () => wrapper.findComponent(TitleArea); -const findBadge = () => wrapper.findComponent(GlExperimentBadge); + describe('rendering', () => { + beforeEach(() => { + createWrapper(); + }); -describe('ee/ml/ai_agents/views/create_agent', () => { - beforeEach(() => createWrapper()); + it('shows the title', () => { + expect(findTitleArea().text()).toContain('AI Agent: 2'); + }); + + it('displays the experiment badge', () => { + expect(findBadge().exists()).toBe(true); + }); - it('shows the title', () => { - expect(findTitleArea().text()).toContain('AI Agent: 2'); + it('renders the DuoChat component', () => { + expect(findGlDuoChat().exists()).toBe(true); + }); }); - it('displays the experiment badge', () => { - expect(findBadge().exists()).toBe(true); + describe('@send-chat-prompt', () => { + beforeEach(() => { + createWrapper(); + }); + + it('does set loading to `true` for a user message', async () => { + findGlDuoChat().vm.$emit('send-chat-prompt', MOCK_USER_MESSAGE.content); + await nextTick(); + expect(findGlDuoChat().props('isLoading')).toBe(true); + }); + + it('calls correct GraphQL mutation', async () => { + findGlDuoChat().vm.$emit('send-chat-prompt', MOCK_USER_MESSAGE.content); + await nextTick(); + expect(chatMutationHandlerMock).toHaveBeenCalledWith({ + resourceId: MOCK_USER_ID, + agentVersionId: `gid://gitlab/Ai::AgentVersion/${agentId}`, + question: MOCK_USER_MESSAGE.content, + }); + }); + }); + + describe('Error conditions', () => { + const errorText = 'Fancy foo'; + + describe('when subscription fails', () => { + beforeEach(async () => { + subscriptionHandlerMock.mockRejectedValue(new Error(errorText)); + createWrapper(); + await waitForPromises(); + }); + + it('throws error and displays error message', () => { + expect(findGlDuoChat().props('error')).toBe(`Error: ${errorText}`); + }); + }); + + describe('when mutation fails', () => { + beforeEach(async () => { + chatMutationHandlerMock.mockRejectedValue(new Error(errorText)); + createWrapper(); + await waitForPromises(); + findGlDuoChat().vm.$emit('send-chat-prompt', MOCK_USER_MESSAGE.content); + await waitForPromises(); + }); + + it('throws error and displays error message', () => { + expect(findGlDuoChat().props('error')).toBe(`Error: ${errorText}`); + }); + }); }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index cbc69f94367bf..12cbdafa0afa8 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1933,6 +1933,18 @@ msgstr "" msgid "AIAgent|AI Agent: %{agentId}" msgstr "" +msgid "AIAgent|Agent" +msgstr "" + +msgid "AIAgent|Ask your agent" +msgstr "" + +msgid "AIAgent|Try out your agent" +msgstr "" + +msgid "AIAgent|Your agent's system prompt will be applied to the chat input." +msgstr "" + msgid "AIPoweredSM|AI-powered features" msgstr "" -- GitLab