Skip to content
代码片段 群组 项目
app.vue 16.0 KB
更新 更旧
<script>
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { DuoChat } from '@gitlab/duo-ui';
import { v4 as uuidv4 } from 'uuid';
import { __, s__ } from '~/locale';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { helpPagePath } from '~/helpers/help_page_helper';
import { duoChatGlobalState } from '~/super_sidebar/constants';
import { clearDuoChatCommands } from 'ee/ai/utils';
import DuoChatCallout from 'ee/ai/components/global_callout/duo_chat_callout.vue';
import getAiMessages from 'ee/ai/graphql/get_ai_messages.query.graphql';
import getAiConversationThreads from 'ee/ai/graphql/get_ai_conversation_threads.query.graphql';
import getAiMessagesWithThread from 'ee/ai/graphql/get_ai_messages_with_thread.query.graphql';
import chatMutation from 'ee/ai/graphql/chat.mutation.graphql';
import duoUserFeedbackMutation from 'ee/ai/graphql/duo_user_feedback.mutation.graphql';
import { InternalEvents } from '~/tracking';
import deleteConversationThreadMutation from 'ee/ai/graphql/delete_conversation_thread.mutation.graphql';
import {
  i18n,
  GENIE_CHAT_RESET_MESSAGE,
  GENIE_CHAT_CLEAR_MESSAGE,
  GENIE_CHAT_NEW_MESSAGE,
  DUO_CHAT_VIEWS,
} from 'ee/ai/constants';
import getAiSlashCommands from 'ee/ai/graphql/get_ai_slash_commands.query.graphql';
Jannik Lehmann's avatar
Jannik Lehmann 已提交
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { fetchPolicies } from '~/lib/graphql';
import {
  TANUKI_BOT_TRACKING_EVENT_NAME,
  MESSAGE_TYPES,
  WIDTH_OFFSET,
  PREDEFINED_PROMPTS,
  MULTI_THREADED_CONVERSATION_TYPE,
} from '../constants';
import TanukiBotSubscriptions from './tanuki_bot_subscriptions.vue';

export default {
  name: 'TanukiBotChatApp',
  i18n: {
    gitlabChat: s__('DuoChat|GitLab Duo Chat'),
    giveFeedback: s__('DuoChat|Give feedback'),
    source: __('Source'),
    experiment: __('Experiment'),
    askAQuestion: s__('DuoChat|Ask a question about GitLab'),
    exampleQuestion: s__('DuoChat|For example, %{linkStart}what is a fork%{linkEnd}?'),
    whatIsAForkQuestion: s__('DuoChat|What is a fork?'),
    newSlashCommandDescription: s__('DuoChat|New chat conversation.'),
    GENIE_CHAT_LEGAL_GENERATED_BY_AI: i18n.GENIE_CHAT_LEGAL_GENERATED_BY_AI,
    predefinedPrompts: PREDEFINED_PROMPTS.map((prompt) => prompt.text),
  helpPagePath: helpPagePath('policy/development_stages_support', { anchor: 'beta' }),
  components: {
    DuoChat,
    DuoChatCallout,
  mixins: [InternalEvents.mixin(), glFeatureFlagsMixin()],
  provide() {
    return {
Zack Cuddy's avatar
Zack Cuddy 已提交
  props: {
    userId: {
      type: String,
      required: true,
    },
    resourceId: {
      type: String,
      required: false,
      default: null,
    },
    projectId: {
      type: String,
      required: false,
      default: null,
    },
Zack Cuddy's avatar
Zack Cuddy 已提交
  },
  apollo: {
    // eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties
    aiMessages: {
      query: getAiMessages,
      variables() {
        return {
          conversationType: this.glFeatures.duoChatMultiThread ? 'DUO_CHAT' : null,
        };
      },
      skip() {
        return !this.duoChatGlobalState.isShown;
      },
      fetchPolicy: fetchPolicies.NETWORK_ONLY,
      result({ data }) {
        if (data?.aiMessages?.nodes) {
          this.setMessages(data.aiMessages.nodes);
        }
      },
      error(err) {
    aiConversationThreads: {
      query: getAiConversationThreads,
      skip() {
        return !this.duoChatGlobalState.isShown || !this.glFeatures.duoChatMultiThread;
      },
      update(data) {
        return data?.aiConversationThreads?.nodes || [];
      },
      async result() {
        // Only auto-select if we don't have an active thread AND we're not in list view
        if (
          !this.activeThread &&
          this.multithreadedView === 'chat' &&
          this.aiConversationThreads.length > 0
        ) {
          const latestThread = this.selectLatestThread(this.aiConversationThreads);
          await this.onThreadSelected({ id: latestThread.id });
        }
      },
      error(err) {
        this.onError(err);
      },
    },
    aiSlashCommands: {
      query: getAiSlashCommands,
      skip() {
        return !this.duoChatGlobalState.isShown;
      },
      variables() {
        return {
          url: typeof window !== 'undefined' && window.location ? window.location.href : '',
        };
      },
      result({ data }) {
        if (data?.aiSlashCommands) {
          this.aiSlashCommands = this.enhanceSlashCommands(data.aiSlashCommands);
  data() {
    return {
      duoChatGlobalState,
      clientSubscriptionId: uuidv4(),
      toolName: i18n.GITLAB_DUO,
      isResponseTracked: false,
      cancelledRequestIds: [],
Jannik Lehmann's avatar
Jannik Lehmann 已提交
      width: 400,
      height: window.innerHeight,
      minWidth: 400,
      minHeight: 400,
      // Explicitly initializing `left` as null to ensure Vue makes it reactive.
      // This allows computed properties and watchers dependent on `left` to work correctly.
      left: null,
      activeThread: undefined,
      multithreadedView: DUO_CHAT_VIEWS.CHAT,
      aiConversationThreads: [],
  computed: {
    ...mapState(['loading', 'messages']),
    computedResourceId() {
      if (this.hasCommands) {
        return this.duoChatGlobalState.commands[0].resourceId;
      }

      return this.resourceId || this.userId;
    },
Jannik Lehmann's avatar
Jannik Lehmann 已提交
    shouldRenderResizable() {
      return this.glFeatures.duoChatDynamicDimension;
    },
    dimensions() {
      return {
        width: this.width,
        height: this.height,
        top: this.top,
        maxHeight: this.maxHeight,
        maxWidth: this.maxWidth,
        minWidth: this.minWidth,
        minHeight: this.minHeight,
        left: this.left,
      };
    },
    hasCommands() {
      return this.duoChatGlobalState.commands.length > 0;
    },
  },
  watch: {
    'duoChatGlobalState.commands': {
      handler(commands) {
        if (commands?.length) {
          const { question, variables } = commands[0];
          this.onSendChatPrompt(question, variables);
        }
      },
    },
Jannik Lehmann's avatar
Jannik Lehmann 已提交
  mounted() {
    this.setDimensions();
    window.addEventListener('resize', this.onWindowResize);
  },
  beforeDestroy() {
    // Remove the event listener when the component is destroyed
    window.removeEventListener('resize', this.onWindowResize);
  },
  methods: {
    ...mapActions(['addDuoChatMessage', 'setMessages', 'setLoading']),
Jannik Lehmann's avatar
Jannik Lehmann 已提交
    setDimensions() {
      this.updateDimensions();
    },
    updateDimensions(width, height) {
      this.maxWidth = window.innerWidth - WIDTH_OFFSET;
      this.maxHeight = window.innerHeight;

      this.width = Math.min(width || this.width, this.maxWidth);
      this.height = Math.min(height || this.height, this.maxHeight);
      this.top = window.innerHeight - this.height;
      this.left = window.innerWidth - this.width;
    },
    onChatResize(e) {
      this.updateDimensions(e.width, e.height);
    },
    onWindowResize() {
      this.updateDimensions();
    },
    isClearOrResetMessage(question) {
      return [GENIE_CHAT_CLEAR_MESSAGE, GENIE_CHAT_RESET_MESSAGE].includes(question);
    findPredefinedPrompt(question) {
      return PREDEFINED_PROMPTS.find(({ text }) => text === question);
    },
    async onThreadSelected(e) {
      try {
        const { data } = await this.$apollo.query({
          query: getAiMessagesWithThread,
          variables: { threadId: e.id },
          fetchPolicy: 'network-only',
        });

        if (data?.aiMessages?.nodes?.length > 0) {
          this.setMessages(data.aiMessages.nodes);
          this.multithreadedView = DUO_CHAT_VIEWS.CHAT;
          this.activeThread = e.id;
        }
      } catch (err) {
        this.onError(err);
      }
    },
    onNewChat() {
      this.activeThread = undefined;
      this.setMessages([]);
      this.multithreadedView = DUO_CHAT_VIEWS.CHAT;
      this.setLoading(false);
      this.completedRequestId = null;
      this.cancelledRequestIds = [];
    },
    onChatCancel() {
      // pushing last requestId of messages to canceled Request Id's
      this.cancelledRequestIds.push(this.messages[this.messages.length - 1].requestId);
      this.setLoading(false);
    },
    onMessageReceived(aiCompletionResponse) {
      this.addDuoChatMessage(aiCompletionResponse);
      if (aiCompletionResponse.role.toLowerCase() === MESSAGE_TYPES.TANUKI) {
        this.completedRequestId = aiCompletionResponse.requestId;
        clearDuoChatCommands();
      }
    },
    onMessageStreamReceived(aiCompletionResponse) {
      if (aiCompletionResponse.requestId !== this.completedRequestId) {
        this.addDuoChatMessage(aiCompletionResponse);
      }
    },
    onResponseReceived(requestId) {
      if (this.isResponseTracked) {
        return;
      }

      performance.mark('response-received');
      performance.measure('prompt-to-response', 'prompt-sent', 'response-received');
      const [{ duration }] = performance.getEntriesByName('prompt-to-response');

      this.trackEvent('ai_response_time', {
        property: requestId,
        value: parseFloat(duration) || 0,
      });

      performance.clearMarks();
      performance.clearMeasures();
      this.isResponseTracked = true;
    },
    onSendChatPrompt(question, variables = {}) {
      const CHAT_RESET_COMMANDS = [
        GENIE_CHAT_NEW_MESSAGE,
        GENIE_CHAT_RESET_MESSAGE,
        GENIE_CHAT_CLEAR_MESSAGE,
      ];

      if (this.glFeatures.duoChatMultiThread && CHAT_RESET_COMMANDS.includes(question)) {
        this.onNewChat();
        return;
      }
      performance.mark('prompt-sent');
      this.completedRequestId = null;
      this.isResponseTracked = false;

      if (!this.loading && !this.isClearOrResetMessage(question)) {
        this.setLoading(true);

      const mutationVariables = {
        question,
        resourceId: this.computedResourceId,
        clientSubscriptionId: this.clientSubscriptionId,
        projectId: this.projectId,
        threadId: this.glFeatures.duoChatMultiThread ? this.activeThread : null,
        conversationType: this.glFeatures.duoChatMultiThread
          ? MULTI_THREADED_CONVERSATION_TYPE
          : null,
        ...variables,
      };

Zack Cuddy's avatar
Zack Cuddy 已提交
      this.$apollo
        .mutate({
          variables: mutationVariables,
          context: {
            headers: {
              'X-GitLab-Interface': 'duo_chat',
              'X-GitLab-Client-Type': 'web_browser',
            },
          },
        .then(({ data: { aiAction = {} } = {} }) => {
          if (!this.isClearOrResetMessage(question)) {
            const trackingOptions = {
              property: aiAction.requestId,
              label: this.findPredefinedPrompt(question)?.eventLabel,
            };

            this.trackEvent('submit_gitlab_duo_question', trackingOptions);

          if (aiAction.threadId && !this.activeThread) {
            this.activeThread = aiAction.threadId;
          }

          if ([GENIE_CHAT_CLEAR_MESSAGE].includes(question)) {
            this.$apollo.queries.aiMessages.refetch();
          } else {
            this.addDuoChatMessage({
              ...aiAction,
              content: question,
        .catch((err) => {
          this.addDuoChatMessage({
            content: question,
          this.setLoading(false);
      this.duoChatGlobalState.isShown = false;
    onCalloutDismissed() {
      this.duoChatGlobalState.isShown = true;
    onTrackFeedback({ feedbackChoices, didWhat, improveWhat, message } = {}) {
      if (message) {
        const { id, requestId, extras, role, content } = message;
        this.$apollo
          .mutate({
            mutation: duoUserFeedbackMutation,
            variables: {
              input: {
                aiMessageId: id,
                trackingEvent: {
                  category: TANUKI_BOT_TRACKING_EVENT_NAME,
                  action: 'duo_chat',
                  label: 'response_feedback',
                  property: feedbackChoices.join(','),
                  extra: {
                    improveWhat,
                    didWhat,
                    prompt_location: 'after_content',
                  },
                },
              },
            },
          })
          .catch(() => {
            // silent failure because of fire and forget
          });

        this.addDuoChatMessage({
          requestId,
          role,
          content,
          extras: { ...extras, hasFeedback: true },
        });
      }
    onError(err) {
      this.addDuoChatMessage({ errors: [err.toString()] });
    },
    onBackToList() {
      this.multithreadedView = DUO_CHAT_VIEWS.LIST;
      this.activeThread = undefined;
      this.setMessages([]);
      this.$apollo.queries.aiConversationThreads.refetch();
    },
    onDeleteThread(threadId) {
      this.$apollo
        .mutate({
          mutation: deleteConversationThreadMutation,
          variables: { input: { threadId } },
        })
        .then(({ data }) => {
          if (data?.deleteConversationThread?.success) {
            this.$apollo.queries.aiConversationThreads.refetch();
          } else {
            const errors = data?.deleteConversationThread?.errors;
            this.onError(new Error(errors.join(', ')));
          }
        })
        .catch(this.onError);
    },
    selectLatestThread(threads) {
      return threads.reduce((latest, thread) => {
        return !latest || thread.updatedAt > latest.updatedAt ? thread : latest;
      });
    },
    enhanceSlashCommands(commands) {
      if (this.glFeatures.duoChatMultiThread) {
        const newCommand = {
          description: this.$options.i18n.newSlashCommandDescription,
          name: '/new',
          shouldSubmit: true,
        };

        // Filter out reset and clear commands in multi-thread mode
        // add new command to the list
        // this is a temporary fix hack until: https://gitlab.com/gitlab-org/gitlab/-/issues/523865
        // is resolved.
        return [
          ...commands.filter(
            (command) =>
              ![GENIE_CHAT_RESET_MESSAGE, GENIE_CHAT_CLEAR_MESSAGE].includes(command.name),
          ),
          newCommand,
        ];
      }
      return commands;
    },
  },
};
</script>

<template>
    <div v-if="duoChatGlobalState.isShown">
      <!-- Renderless component for subscriptions -->
      <tanuki-bot-subscriptions
        :user-id="userId"
        :client-subscription-id="clientSubscriptionId"
        :cancelled-request-ids="cancelledRequestIds"
        :active-thread-id="activeThread"
        @message="onMessageReceived"
        @message-stream="onMessageStreamReceived"
        @response-received="onResponseReceived"
        @error="onError"
      />

      <duo-chat
        :thread-list="aiConversationThreads"
        :multi-threaded-view="multithreadedView"
        :active-thread-id="activeThread"
        :is-multithreaded="glFeatures.duoChatMultiThread"
        :title="$options.i18n.gitlabChat"
Jannik Lehmann's avatar
Jannik Lehmann 已提交
        :dimensions="dimensions"
        :messages="messages"
        :error="error"
        :is-loading="loading"
Jannik Lehmann's avatar
Jannik Lehmann 已提交
        :should-render-resizable="shouldRenderResizable"
        :predefined-prompts="$options.i18n.predefinedPrompts"
        :badge-type="null"
        :tool-name="toolName"
        :canceled-request-ids="cancelledRequestIds"
        class="duo-chat-container"
        @thread-selected="onThreadSelected"
        @new-chat="onNewChat"
        @back-to-list="onBackToList"
        @delete-thread="onDeleteThread"
        @chat-cancel="onChatCancel"
        @send-chat-prompt="onSendChatPrompt"
        @chat-hidden="onChatClose"
        @track-feedback="onTrackFeedback"
Jannik Lehmann's avatar
Jannik Lehmann 已提交
        @chat-resize="onChatResize"
    <duo-chat-callout @callout-dismissed="onCalloutDismissed" />
</template>