@gitlab/ui
Version:
GitLab UI Components
400 lines (388 loc) • 18.6 kB
JavaScript
import throttle from 'lodash/throttle';
import emptySvg from '@gitlab/svgs/dist/illustrations/empty-state/empty-activity-md.svg';
import GlDropdownItem from '../../../base/dropdown/dropdown_item';
import GlCard from '../../../base/card/card';
import GlEmptyState from '../../../regions/empty_state/empty_state';
import GlButton from '../../../base/button/button';
import GlAlert from '../../../base/alert/alert';
import GlFormInputGroup from '../../../base/form/form_input_group/form_input_group';
import GlFormTextarea from '../../../base/form/form_textarea/form_textarea';
import GlForm from '../../../base/form/form';
import GlFormText from '../../../base/form/form_text/form_text';
import GlExperimentBadge from '../../experiment_badge/experiment_badge';
import { badgeTypes, badgeTypeValidator } from '../../experiment_badge/constants';
import { SafeHtmlDirective } from '../../../../directives/safe_html/safe_html';
import GlDuoChatLoader from './components/duo_chat_loader/duo_chat_loader';
import GlDuoChatPredefinedPrompts from './components/duo_chat_predefined_prompts/duo_chat_predefined_prompts';
import GlDuoChatConversation from './components/duo_chat_conversation/duo_chat_conversation';
import { CHAT_RESET_MESSAGE } from './constants';
import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
const i18n = {
CHAT_DEFAULT_TITLE: 'GitLab Duo Chat',
CHAT_CLOSE_LABEL: 'Close the Code Explanation',
CHAT_LEGAL_GENERATED_BY_AI: 'Responses generated by AI',
CHAT_EMPTY_STATE_TITLE: 'Ask a question',
CHAT_EMPTY_STATE_DESC: 'AI generated explanations will appear here.',
CHAT_PROMPT_PLACEHOLDER_DEFAULT: 'GitLab Duo Chat',
CHAT_PROMPT_PLACEHOLDER_WITH_COMMANDS: 'Type "/" for slash commands',
CHAT_SUBMIT_LABEL: 'Send chat message.',
CHAT_LEGAL_DISCLAIMER: "May provide inappropriate responses not representative of GitLab's views. Do not input personal data.",
CHAT_DEFAULT_PREDEFINED_PROMPTS: ['How do I change my password in GitLab?', 'How do I fork a project?', 'How do I clone a repository?', 'How do I create a template?']
};
const isMessage = item => Boolean(item) && (item === null || item === void 0 ? void 0 : item.role);
const isSlashCommand = command => Boolean(command) && (command === null || command === void 0 ? void 0 : command.name) && command.description;
// eslint-disable-next-line unicorn/no-array-callback-reference
const itemsValidator = items => items.every(isMessage);
// eslint-disable-next-line unicorn/no-array-callback-reference
const slashCommandsValidator = commands => commands.every(isSlashCommand);
var script = {
name: 'GlDuoChat',
components: {
GlEmptyState,
GlButton,
GlAlert,
GlFormInputGroup,
GlFormTextarea,
GlForm,
GlFormText,
GlExperimentBadge,
GlDuoChatLoader,
GlDuoChatPredefinedPrompts,
GlDuoChatConversation,
GlCard,
GlDropdownItem
},
directives: {
SafeHtml: SafeHtmlDirective
},
props: {
/**
* The title of the chat/feature.
*/
title: {
type: String,
required: false,
default: i18n.CHAT_DEFAULT_TITLE
},
/**
* Array of messages to display in the chat.
*/
messages: {
type: Array,
required: false,
default: () => [],
validator: itemsValidator
},
/**
* A non-recoverable error message to display in the chat.
*/
error: {
type: String,
required: false,
default: ''
},
/**
* Whether the chat is currently fetching a response from AI.
*/
isLoading: {
type: Boolean,
required: false,
default: false
},
/**
* Whether the conversational interfaces should be enabled.
*/
isChatAvailable: {
type: Boolean,
required: false,
default: true
},
/**
* Array of predefined prompts to display in the chat to start a conversation.
*/
predefinedPrompts: {
type: Array,
required: false,
default: () => i18n.CHAT_DEFAULT_PREDEFINED_PROMPTS
},
/**
* URL to the help page. This is passed down to the `GlExperimentBadge` component.
*/
badgeHelpPageUrl: {
type: String,
required: false,
default: ''
},
/**
* The type of the badge. This is passed down to the `GlExperimentBadge` component. Refer that component for more information.
*/
badgeType: {
type: String || null,
required: false,
default: badgeTypes[0],
validator: badgeTypeValidator
},
/**
* The current tool's name to display in the loading message while waiting for a response from AI. Refer the `GlDuoChatLoader` component for more information.
*/
toolName: {
type: String,
required: false,
default: i18n.CHAT_DEFAULT_TITLE
},
/**
* Array of slash commands to display in the chat.
*/
slashCommands: {
type: Array,
required: false,
default: () => [],
validator: slashCommandsValidator
},
/**
* Whether the header should be displayed.
*/
showHeader: {
type: Boolean,
required: false,
default: true
},
/**
* Override the default empty state title text.
*/
emptyStateTitle: {
type: String,
required: false,
default: i18n.CHAT_EMPTY_STATE_TITLE
},
/**
* Override the default empty state description text.
*/
emptyStateDescription: {
type: String,
required: false,
default: i18n.CHAT_EMPTY_STATE_DESC
},
/**
* Override the default chat prompt placeholder text.
*/
chatPromptPlaceholder: {
type: String,
required: false,
default: ''
}
},
data() {
return {
isHidden: false,
prompt: '',
scrolledToBottom: true,
activeCommandIndex: 0
};
},
computed: {
withSlashCommands() {
return this.slashCommands.length > 0;
},
hasMessages() {
var _this$messages;
return ((_this$messages = this.messages) === null || _this$messages === void 0 ? void 0 : _this$messages.length) > 0;
},
conversations() {
if (!this.hasMessages) return [];
return this.messages.reduce((acc, message) => {
if (message.content === CHAT_RESET_MESSAGE) {
acc.push([]);
} else {
acc[acc.length - 1].push(message);
}
return acc;
}, [[]]);
},
lastMessage() {
return this.messages[this.messages.length - 1];
},
resetDisabled() {
var _this$lastMessage;
if (this.isLoading || !this.hasMessages) {
return true;
}
return ((_this$lastMessage = this.lastMessage) === null || _this$lastMessage === void 0 ? void 0 : _this$lastMessage.content) === CHAT_RESET_MESSAGE;
},
submitDisabled() {
return this.isLoading || this.isStreaming;
},
isStreaming() {
var _this$lastMessage2, _this$lastMessage2$ch, _this$lastMessage3, _this$lastMessage4;
return Boolean(((_this$lastMessage2 = this.lastMessage) === null || _this$lastMessage2 === void 0 ? void 0 : (_this$lastMessage2$ch = _this$lastMessage2.chunks) === null || _this$lastMessage2$ch === void 0 ? void 0 : _this$lastMessage2$ch.length) > 0 && !((_this$lastMessage3 = this.lastMessage) !== null && _this$lastMessage3 !== void 0 && _this$lastMessage3.content) || typeof ((_this$lastMessage4 = this.lastMessage) === null || _this$lastMessage4 === void 0 ? void 0 : _this$lastMessage4.chunkId) === 'number');
},
filteredSlashCommands() {
const caseInsensitivePrompt = this.prompt.toLowerCase();
return this.slashCommands.filter(c => c.name.toLowerCase().startsWith(caseInsensitivePrompt));
},
shouldShowSlashCommands() {
if (!this.withSlashCommands) return false;
const caseInsensitivePrompt = this.prompt.toLowerCase();
const startsWithSlash = caseInsensitivePrompt.startsWith('/');
const startsWithSlashCommand = this.slashCommands.some(c => caseInsensitivePrompt.startsWith(c.name));
return startsWithSlash && this.filteredSlashCommands.length && !startsWithSlashCommand;
},
inputPlaceholder() {
if (this.chatPromptPlaceholder) {
return this.chatPromptPlaceholder;
}
return this.withSlashCommands ? i18n.CHAT_PROMPT_PLACEHOLDER_WITH_COMMANDS : i18n.CHAT_PROMPT_PLACEHOLDER_DEFAULT;
}
},
watch: {
isLoading() {
this.isHidden = false;
this.scrollToBottom();
}
},
created() {
this.handleScrollingTrottled = throttle(this.handleScrolling, 200); // Assume a 200ms throttle for example
},
mounted() {
this.scrollToBottom();
},
methods: {
hideChat() {
this.isHidden = true;
/**
* Emitted when clicking the cross in the title and the chat gets closed.
*/
this.$emit('chat-hidden');
},
sendChatPrompt() {
if (this.submitDisabled) {
return;
}
if (this.prompt) {
if (this.prompt === CHAT_RESET_MESSAGE && this.resetDisabled) {
return;
}
/**
* Emitted when a new user prompt should be sent out.
*
* @param {String} prompt The user prompt to send.
*/
this.$emit('send-chat-prompt', this.prompt.trim());
this.setPromptAndFocus();
}
},
sendPredefinedPrompt(prompt) {
this.prompt = prompt;
this.sendChatPrompt();
},
handleScrolling(event) {
const {
scrollTop,
offsetHeight,
scrollHeight
} = event.target;
this.scrolledToBottom = scrollTop + offsetHeight >= scrollHeight;
},
async scrollToBottom() {
var _this$$refs$anchor, _this$$refs$anchor$sc;
await this.$nextTick();
(_this$$refs$anchor = this.$refs.anchor) === null || _this$$refs$anchor === void 0 ? void 0 : (_this$$refs$anchor$sc = _this$$refs$anchor.scrollIntoView) === null || _this$$refs$anchor$sc === void 0 ? void 0 : _this$$refs$anchor$sc.call(_this$$refs$anchor);
},
onTrackFeedback(event) {
/**
* Notify listeners about the feedback form submission on a response message.
* @param {*} event An event, containing the feedback choices and the extended feedback text.
*/
this.$emit('track-feedback', event);
},
onInputKeyup(e) {
const {
metaKey,
ctrlKey,
altKey,
shiftKey
} = e;
if (this.shouldShowSlashCommands) {
e.preventDefault();
if (e.key === 'Enter') {
this.selectSlashCommand(this.activeCommandIndex);
} else if (e.key === 'ArrowUp') {
this.prevCommand();
} else if (e.key === 'ArrowDown') {
this.nextCommand();
} else {
this.activeCommandIndex = 0;
}
} else if (e.key === 'Enter' && !(metaKey || ctrlKey || altKey || shiftKey)) {
e.preventDefault();
this.sendChatPrompt();
}
},
prevCommand() {
this.activeCommandIndex -= 1;
this.wrapCommandIndex();
},
nextCommand() {
this.activeCommandIndex += 1;
this.wrapCommandIndex();
},
wrapCommandIndex() {
if (this.activeCommandIndex < 0) {
this.activeCommandIndex = this.filteredSlashCommands.length - 1;
} else if (this.activeCommandIndex >= this.filteredSlashCommands.length) {
this.activeCommandIndex = 0;
}
},
async setPromptAndFocus() {
let prompt = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
this.prompt = prompt;
await this.$nextTick();
this.$refs.prompt.$el.focus();
},
selectSlashCommand(index) {
const command = this.filteredSlashCommands[index];
if (command.shouldSubmit) {
this.prompt = command.name;
this.sendChatPrompt();
} else {
this.setPromptAndFocus(`${command.name} `);
}
}
},
i18n,
emptySvg
};
/* script */
const __vue_script__ = script;
/* template */
var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return (!_vm.isHidden)?_c('aside',{staticClass:"markdown-code-block duo-chat-drawer gl-max-h-full gl-bottom-0 gl-shadow-none gl-border-l gl-border-t duo-chat",attrs:{"id":"chat-component","role":"complementary","data-testid":"chat-component"}},[(_vm.showHeader)?_c('header',{staticClass:"duo-chat-drawer-header duo-chat-drawer-header-sticky gl-z-index-200 gl-p-0! gl-border-b-0",attrs:{"data-testid":"chat-header"}},[_c('div',{staticClass:"drawer-title gl-display-flex gl-justify-content-start gl-align-items-center gl-p-5"},[_c('h3',{staticClass:"gl-my-0 gl-font-size-h2"},[_vm._v(_vm._s(_vm.title))]),_vm._v(" "),(_vm.badgeType)?_c('gl-experiment-badge',{attrs:{"help-page-url":_vm.badgeHelpPageUrl,"type":_vm.badgeType,"container-id":"chat-component"}}):_vm._e(),_vm._v(" "),_c('gl-button',{staticClass:"gl-p-0! gl-ml-auto",attrs:{"category":"tertiary","variant":"default","icon":"close","size":"small","data-testid":"chat-close-button","aria-label":_vm.$options.i18n.CHAT_CLOSE_LABEL},on:{"click":_vm.hideChat}})],1),_vm._v(" "),_c('gl-alert',{staticClass:"gl-text-center gl-border-t gl-p-4 gl-text-gray-700 gl-bg-gray-50 legal-warning gl-max-w-full",attrs:{"dismissible":false,"variant":"tip","show-icon":false,"role":"alert","data-testid":"chat-legal-warning"}},[_vm._v(_vm._s(_vm.$options.i18n.CHAT_LEGAL_GENERATED_BY_AI))]),_vm._v(" "),_vm._t("subheader"),_vm._v(" "),(_vm.error)?_c('gl-alert',{key:"error",staticClass:"gl-pl-9!",attrs:{"dismissible":false,"variant":"danger","role":"alert","data-testid":"chat-error"}},[_c('span',{directives:[{name:"safe-html",rawName:"v-safe-html",value:(_vm.error),expression:"error"}]})]):_vm._e()],2):_vm._e(),_vm._v(" "),_c('div',{staticClass:"duo-chat-drawer-body",attrs:{"data-testid":"chat-history"},on:{"scroll":_vm.handleScrollingTrottled}},[_c('transition-group',{staticClass:"duo-chat-history gl-display-flex gl-flex-direction-column gl-justify-content-end gl-bg-gray-10",class:[
{
'gl-h-full': !_vm.hasMessages,
'force-scroll-bar': _vm.hasMessages,
} ],attrs:{"tag":"section","name":"message"}},[_vm._l((_vm.conversations),function(conversation,index){return _c('gl-duo-chat-conversation',{key:("conversation-" + index),attrs:{"messages":conversation,"show-delimiter":index > 0},on:{"track-feedback":_vm.onTrackFeedback}})}),_vm._v(" "),(!_vm.hasMessages && !_vm.isLoading)?[_c('gl-empty-state',{key:"empty-state",staticClass:"gl-flex-grow gl-justify-content-center",attrs:{"svg-path":_vm.$options.emptySvg,"svg-height":145,"title":_vm.emptyStateTitle,"description":_vm.emptyStateDescription}}),_vm._v(" "),_c('gl-duo-chat-predefined-prompts',{key:"predefined-prompts",attrs:{"prompts":_vm.predefinedPrompts},on:{"click":_vm.sendPredefinedPrompt}})]:_vm._e(),_vm._v(" "),(_vm.isLoading)?_c('gl-duo-chat-loader',{key:"loader",attrs:{"tool-name":_vm.toolName}}):_vm._e(),_vm._v(" "),_c('div',{key:"anchor",ref:"anchor",staticClass:"scroll-anchor"})],2)],1),_vm._v(" "),(_vm.isChatAvailable)?_c('footer',{staticClass:"duo-chat-drawer-footer duo-chat-drawer-footer-sticky gl-p-5 gl-border-t gl-bg-gray-10",class:{ 'duo-chat-drawer-body-scrim-on-footer': !_vm.scrolledToBottom },attrs:{"data-testid":"chat-footer"}},[_c('gl-form',{attrs:{"data-testid":"chat-prompt-form"},on:{"submit":function($event){$event.stopPropagation();$event.preventDefault();return _vm.sendChatPrompt.apply(null, arguments)}}},[_c('gl-form-input-group',{scopedSlots:_vm._u([{key:"append",fn:function(){return [_c('gl-button',{staticClass:"!gl-absolute gl-bottom-2 gl-right-2 gl-rounded-base!",attrs:{"icon":"paper-airplane","category":"primary","variant":"confirm","type":"submit","data-testid":"chat-prompt-submit-button","aria-label":_vm.$options.i18n.CHAT_SUBMIT_LABEL,"disabled":_vm.submitDisabled}})]},proxy:true}],null,false,3132459889)},[_c('div',{staticClass:"duo-chat-input gl-flex-grow-1 gl-vertical-align-top gl-max-w-full gl-min-h-8 gl-inset-border-1-gray-400 gl-rounded-base gl-bg-white",attrs:{"data-value":_vm.prompt}},[(_vm.shouldShowSlashCommands)?_c('gl-card',{ref:"commands",staticClass:"slash-commands !gl-absolute gl-translate-y-n100 gl-list-style-none gl-pl-0 gl-w-full gl-shadow-md",attrs:{"body-class":"gl-p-2!"}},_vm._l((_vm.filteredSlashCommands),function(command,index){return _c('gl-dropdown-item',{key:command.name,class:{ 'active-command': index === _vm.activeCommandIndex },on:{"click":function($event){return _vm.selectSlashCommand(index)}},nativeOn:{"mouseenter":function($event){_vm.activeCommandIndex = index;}}},[_c('span',{staticClass:"gl-display-flex gl-justify-content-space-between"},[_c('span',{staticClass:"gl-display-block"},[_vm._v(_vm._s(command.name))]),_vm._v(" "),_c('small',{staticClass:"gl-text-gray-500 gl-font-style-italic gl-text-right gl-pl-3"},[_vm._v(_vm._s(command.description))])])])}),1):_vm._e(),_vm._v(" "),_c('gl-form-textarea',{ref:"prompt",staticClass:"gl-absolute gl-h-full! gl-py-4! gl-bg-transparent! gl-rounded-top-right-none gl-rounded-bottom-right-none gl-shadow-none!",class:{ 'gl-text-truncate': !_vm.prompt },attrs:{"data-testid":"chat-prompt-input","placeholder":_vm.inputPlaceholder,"autofocus":""},nativeOn:{"keydown":function($event){if(!$event.type.indexOf('key')&&_vm._k($event.keyCode,"enter",13,$event.key,"Enter")){ return null; }if($event.ctrlKey||$event.shiftKey||$event.altKey||$event.metaKey){ return null; }$event.preventDefault();},"keyup":function($event){return _vm.onInputKeyup.apply(null, arguments)}},model:{value:(_vm.prompt),callback:function ($$v) {_vm.prompt=$$v;},expression:"prompt"}})],1)]),_vm._v(" "),_c('gl-form-text',{staticClass:"gl-text-gray-400 gl-line-height-20 gl-mt-3",attrs:{"data-testid":"chat-legal-disclaimer"}},[_vm._v(_vm._s(_vm.$options.i18n.CHAT_LEGAL_DISCLAIMER))])],1)],1):_vm._e()]):_vm._e()};
var __vue_staticRenderFns__ = [];
/* style */
const __vue_inject_styles__ = undefined;
/* scoped */
const __vue_scope_id__ = undefined;
/* module identifier */
const __vue_module_identifier__ = undefined;
/* functional template */
const __vue_is_functional_template__ = false;
/* style inject */
/* style inject SSR */
/* style inject shadow dom */
const __vue_component__ = __vue_normalize__(
{ render: __vue_render__, staticRenderFns: __vue_staticRenderFns__ },
__vue_inject_styles__,
__vue_script__,
__vue_scope_id__,
__vue_is_functional_template__,
__vue_module_identifier__,
false,
undefined,
undefined,
undefined
);
export default __vue_component__;
export { i18n };