UNPKG

@gitlab/ui

Version:
400 lines (388 loc) • 18.6 kB
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 };