更新
更旧
import { v4 as uuidv4 } from 'uuid';
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,
DUO_CHAT_VIEWS,
} from 'ee/ai/constants';
import getAiSlashCommands from 'ee/ai/graphql/get_ai_slash_commands.query.graphql';
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,
import TanukiBotSubscriptions from './tanuki_bot_subscriptions.vue';
export default {
name: 'TanukiBotChatApp',
i18n: {
gitlabChat: s__('DuoChat|GitLab Duo Chat'),
giveFeedback: s__('DuoChat|Give feedback'),
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' }),
TanukiBotSubscriptions,
mixins: [InternalEvents.mixin(), glFeatureFlagsMixin()],
props: {
userId: {
type: String,
required: true,
},
resourceId: {
type: String,
required: false,
default: null,
},
projectId: {
type: String,
required: false,
default: null,
},
// eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties
aiMessages: {
query: getAiMessages,
variables() {
return {
conversationType: this.glFeatures.duoChatMultiThread ? 'DUO_CHAT' : null,
};
},
fetchPolicy: fetchPolicies.NETWORK_ONLY,
this.setMessages(data.aiMessages.nodes);
}
},
this.onError(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,
variables() {
return {
url: typeof window !== 'undefined' && window.location ? window.location.href : '',
};
},
result({ data }) {
if (data?.aiSlashCommands) {
this.aiSlashCommands = this.enhanceSlashCommands(data.aiSlashCommands);
}
},
error(err) {
this.onError(err);
},
},
clientSubscriptionId: uuidv4(),
completedRequestId: null,
aiSlashCommands: [],
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;
},
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);
}
},
},
mounted() {
this.setDimensions();
window.addEventListener('resize', this.onWindowResize);
},
beforeDestroy() {
// Remove the event listener when the component is destroyed
window.removeEventListener('resize', this.onWindowResize);
},
...mapActions(['addDuoChatMessage', 'setMessages', 'setLoading']),
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();
},
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,
});
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;
}
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,
};
mutation: chatMutation,
variables: mutationVariables,
context: {
headers: {
'X-GitLab-Interface': 'duo_chat',
'X-GitLab-Client-Type': 'web_browser',
},
},
.then(({ data: { aiAction = {} } = {} }) => {
if (!this.isClearOrResetMessage(question)) {
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,
.catch((err) => {
this.addDuoChatMessage({
this.onError(err);
this.duoChatGlobalState.isShown = false;
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,
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()] });
},
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
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;
},
<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"
/>
:thread-list="aiConversationThreads"
:multi-threaded-view="multithreadedView"
:active-thread-id="activeThread"
:is-multithreaded="glFeatures.duoChatMultiThread"
:slash-commands="aiSlashCommands"
:title="$options.i18n.gitlabChat"
:messages="messages"
:error="error"
:is-loading="loading"
: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"
<duo-chat-callout @callout-dismissed="onCalloutDismissed" />