@gitlab/duo-ui 8.11.0 → 8.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/components/agentic_chat/agentic_duo_chat.js +648 -0
- package/dist/components/chat/components/duo_chat_conversation/duo_chat_conversation.js +9 -1
- package/dist/components/chat/components/duo_chat_message/duo_chat_message.js +12 -4
- package/dist/components/chat/duo_chat.js +9 -1
- package/dist/components/chat/markdown_renderer.js +100 -5
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/src/components/agentic_chat/agentic_duo_chat.md +190 -0
- package/src/components/agentic_chat/agentic_duo_chat.scss +416 -0
- package/src/components/agentic_chat/agentic_duo_chat.vue +876 -0
- package/src/components/chat/components/duo_chat_conversation/duo_chat_conversation.vue +9 -0
- package/src/components/chat/components/duo_chat_message/duo_chat_message.vue +15 -4
- package/src/components/chat/duo_chat.vue +9 -1
- package/src/components/chat/markdown_renderer.js +116 -5
- package/src/index.js +3 -0
- package/translations.js +14 -0
|
@@ -42,6 +42,14 @@ var script = {
|
|
|
42
42
|
type: Boolean,
|
|
43
43
|
required: false,
|
|
44
44
|
default: true
|
|
45
|
+
},
|
|
46
|
+
/**
|
|
47
|
+
* Base URL of the GitLab instance.
|
|
48
|
+
*/
|
|
49
|
+
trustedUrls: {
|
|
50
|
+
type: Array,
|
|
51
|
+
required: false,
|
|
52
|
+
default: () => []
|
|
45
53
|
}
|
|
46
54
|
},
|
|
47
55
|
methods: {
|
|
@@ -72,7 +80,7 @@ var script = {
|
|
|
72
80
|
const __vue_script__ = script;
|
|
73
81
|
|
|
74
82
|
/* template */
|
|
75
|
-
var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{class:['gl-flex gl-flex-col gl-justify-end', { 'insert-code-hidden': !_vm.enableCodeInsertion }]},[(_vm.showDelimiter)?_c('div',{staticClass:"gl-my-5 gl-flex gl-items-center gl-gap-4 gl-text-gray-500",attrs:{"data-testid":"conversation-delimiter"}},[_c('hr',{staticClass:"gl-grow"}),_vm._v(" "),_c('span',[_vm._v(_vm._s(_vm.$options.i18n.CONVERSATION_NEW_CHAT))]),_vm._v(" "),_c('hr',{staticClass:"gl-grow"})]):_vm._e(),_vm._v(" "),_vm._l((_vm.messages),function(msg,index){return _c('duo-chat-message',{key:((msg.role) + "-" + index),attrs:{"message":msg,"is-cancelled":_vm.canceledRequestIds.includes(msg.requestId)},on:{"track-feedback":_vm.onTrackFeedback,"insert-code-snippet":_vm.onInsertCodeSnippet,"copy-code-snippet":_vm.onCopyCodeSnippet,"copy-message":_vm.onCopyMessage,"get-context-item-content":_vm.onGetContextItemContent}})})],2)};
|
|
83
|
+
var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{class:['gl-flex gl-flex-col gl-justify-end', { 'insert-code-hidden': !_vm.enableCodeInsertion }]},[(_vm.showDelimiter)?_c('div',{staticClass:"gl-my-5 gl-flex gl-items-center gl-gap-4 gl-text-gray-500",attrs:{"data-testid":"conversation-delimiter"}},[_c('hr',{staticClass:"gl-grow"}),_vm._v(" "),_c('span',[_vm._v(_vm._s(_vm.$options.i18n.CONVERSATION_NEW_CHAT))]),_vm._v(" "),_c('hr',{staticClass:"gl-grow"})]):_vm._e(),_vm._v(" "),_vm._l((_vm.messages),function(msg,index){return _c('duo-chat-message',{key:((msg.role) + "-" + index),attrs:{"message":msg,"trusted-urls":_vm.trustedUrls,"is-cancelled":_vm.canceledRequestIds.includes(msg.requestId)},on:{"track-feedback":_vm.onTrackFeedback,"insert-code-snippet":_vm.onInsertCodeSnippet,"copy-code-snippet":_vm.onCopyCodeSnippet,"copy-message":_vm.onCopyMessage,"get-context-item-content":_vm.onGetContextItemContent}})})],2)};
|
|
76
84
|
var __vue_staticRenderFns__ = [];
|
|
77
85
|
|
|
78
86
|
/* style */
|
|
@@ -80,6 +80,14 @@ var script = {
|
|
|
80
80
|
isCancelled: {
|
|
81
81
|
type: Boolean,
|
|
82
82
|
required: true
|
|
83
|
+
},
|
|
84
|
+
/**
|
|
85
|
+
* Base URL of the GitLab instance.
|
|
86
|
+
*/
|
|
87
|
+
trustedUrls: {
|
|
88
|
+
type: Array,
|
|
89
|
+
required: false,
|
|
90
|
+
default: () => []
|
|
83
91
|
}
|
|
84
92
|
},
|
|
85
93
|
data() {
|
|
@@ -122,17 +130,17 @@ var script = {
|
|
|
122
130
|
if (this.message.contentHtml) {
|
|
123
131
|
return this.message.contentHtml;
|
|
124
132
|
}
|
|
125
|
-
return this.renderMarkdown(this.message.content);
|
|
133
|
+
return this.renderMarkdown(this.message.content, this.trustedUrls);
|
|
126
134
|
},
|
|
127
135
|
messageContent() {
|
|
128
136
|
if (this.isAssistantMessage && this.isChunk) {
|
|
129
|
-
return this.renderMarkdown(concatUntilEmpty(this.messageChunks));
|
|
137
|
+
return this.renderMarkdown(concatUntilEmpty(this.messageChunks), this.trustedUrls);
|
|
130
138
|
}
|
|
131
|
-
return this.defaultContent || this.renderMarkdown(concatUntilEmpty(this.message.chunks));
|
|
139
|
+
return this.renderMarkdown(this.defaultContent, this.trustedUrls) || this.renderMarkdown(concatUntilEmpty(this.message.chunks), this.trustedUrls);
|
|
132
140
|
},
|
|
133
141
|
renderedError() {
|
|
134
142
|
var _this$message$errors;
|
|
135
|
-
return this.renderMarkdown(((_this$message$errors = this.message.errors) === null || _this$message$errors === void 0 ? void 0 : _this$message$errors.join('; ')) || '');
|
|
143
|
+
return this.renderMarkdown(((_this$message$errors = this.message.errors) === null || _this$message$errors === void 0 ? void 0 : _this$message$errors.join('; ')) || '', this.trustedUrls);
|
|
136
144
|
},
|
|
137
145
|
error() {
|
|
138
146
|
var _this$message, _this$message$errors2;
|
|
@@ -242,6 +242,14 @@ var script = {
|
|
|
242
242
|
default: false
|
|
243
243
|
},
|
|
244
244
|
/**
|
|
245
|
+
* Base URL of the GitLab instance.
|
|
246
|
+
*/
|
|
247
|
+
trustedUrls: {
|
|
248
|
+
type: Array,
|
|
249
|
+
required: false,
|
|
250
|
+
default: () => []
|
|
251
|
+
},
|
|
252
|
+
/*
|
|
245
253
|
* The preferred locale for the chat interface.
|
|
246
254
|
* Follows BCP 47 language tag format (e.g., 'en-US', 'fr-FR', 'es-ES').
|
|
247
255
|
*/
|
|
@@ -612,7 +620,7 @@ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=
|
|
|
612
620
|
},attrs:{"width":_vm.shouldRenderResizable ? _vm.dimensions.width : null,"height":_vm.shouldRenderResizable ? _vm.dimensions.height : null,"max-width":_vm.shouldRenderResizable ? _vm.dimensions.maxWidth : null,"max-height":_vm.shouldRenderResizable ? _vm.dimensions.maxHeight : null,"min-width":_vm.shouldRenderResizable ? _vm.dimensions.minWidth : null,"left":_vm.shouldRenderResizable ? _vm.dimensions.left : null,"top":_vm.shouldRenderResizable ? _vm.dimensions.top : null,"fit-parent":true,"min-height":_vm.shouldRenderResizable ? _vm.dimensions.minHeight : null,"active":_vm.shouldRenderResizable ? ['l', 't', 'lt'] : null},on:{"resize:end":_vm.updateSize}},[(!_vm.isHidden)?_c('aside',{staticClass:"markdown-code-block duo-chat gl-bottom-0 gl-max-h-full gl-flex gl-flex-col",class:{
|
|
613
621
|
'resizable-content': _vm.shouldRenderResizable,
|
|
614
622
|
'duo-chat-drawer': !_vm.shouldRenderResizable,
|
|
615
|
-
},attrs:{"id":"chat-component","role":"complementary","data-testid":"chat-component"}},[(_vm.showHeader)?_c('duo-chat-header',{ref:"header",attrs:{"active-thread-id":_vm.activeThreadId,"title":_vm.isMultithreaded && _vm.currentView === 'list' ? _vm.$options.i18n.CHAT_HISTORY_TITLE : _vm.title,"subtitle":_vm.activeThreadTitleForView,"
|
|
623
|
+
},attrs:{"id":"chat-component","role":"complementary","data-testid":"chat-component"}},[(_vm.showHeader)?_c('duo-chat-header',{ref:"header",attrs:{"active-thread-id":_vm.activeThreadId,"title":_vm.isMultithreaded && _vm.currentView === 'list' ? _vm.$options.i18n.CHAT_HISTORY_TITLE : _vm.title,"subtitle":_vm.activeThreadTitleForView,"is-multithreaded":_vm.isMultithreaded,"current-view":_vm.currentView,"should-render-resizable":_vm.shouldRenderResizable,"badge-type":_vm.isMultithreaded ? null : _vm.badgeType},on:{"go-back":_vm.onGoBack,"new-chat":_vm.onNewChat,"close":_vm.hideChat},scopedSlots:_vm._u([{key:"subheader",fn:function(){return [_vm._t("subheader")]},proxy:true}],null,true)}):_vm._e(),_vm._v(" "),_c('div',{staticClass:"gl-overflow-y-auto gl-flex gl-flex-col gl-flex-1 gl-flex-grow gl-bg-inherit gl-overscroll-contain",attrs:{"data-testid":"chat-history"},on:{"scroll":_vm.handleScrollingThrottled}},[(_vm.shouldShowThreadList)?_c('duo-chat-threads',{attrs:{"threads":_vm.threadList,"preferred-locale":_vm.preferredLocale},on:{"new-chat":_vm.onNewChat,"select-thread":_vm.onSelectThread,"delete-thread":_vm.onDeleteThread,"close":_vm.hideChat}}):_c('transition-group',{staticClass:"duo-chat-history gl-p-5 gl-mt-auto",attrs:{"mode":"out-in","tag":"section","name":"message"}},[_vm._l((_vm.conversations),function(conversation,index){return _c('duo-chat-conversation',{key:("conversation-" + index),attrs:{"enable-code-insertion":_vm.enableCodeInsertion,"messages":conversation,"canceled-request-ids":_vm.canceledRequestIds,"show-delimiter":index > 0,"trusted-urls":_vm.trustedUrls},on:{"track-feedback":_vm.onTrackFeedback,"insert-code-snippet":_vm.onInsertCodeSnippet,"copy-code-snippet":_vm.onCopyCodeSnippet,"copy-message":_vm.onCopyMessage,"get-context-item-content":_vm.onGetContextItemContent}})}),_vm._v(" "),(!_vm.hasMessages && !_vm.isLoading)?[_c('div',{key:"empty-state-message",staticClass:"duo-chat-message gl-rounded-bl-none gl-border-1 gl-border-solid gl-border-gray-50 gl-bg-gray-10 gl-p-4 gl-leading-20 gl-text-gray-900 gl-break-anywhere",attrs:{"data-testid":"gl-duo-chat-empty-state"}},[(_vm.emptyStateTitle)?_c('p',{staticClass:"gl-m-0",attrs:{"data-testid":"gl-duo-chat-empty-state-title"}},[_vm._v("\n "+_vm._s(_vm.emptyStateTitle)+"\n ")]):_vm._e(),_vm._v(" "),_c('duo-chat-predefined-prompts',{key:"predefined-prompts",attrs:{"prompts":_vm.predefinedPrompts},on:{"click":_vm.sendPredefinedPrompt}})],1)]:_vm._e(),_vm._v(" "),(_vm.isLoading)?_c('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 && !_vm.shouldShowThreadList)?_c('footer',{staticClass:"duo-chat-drawer-footer gl-border-0 gl-bg-default gl-pb-3 gl-shrink-0 gl-relative gl-z-2",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('div',{staticClass:"gl-relative gl-max-w-full"},[_vm._t("context-items-menu",null,{"isOpen":_vm.contextItemsMenuIsOpen,"onClose":_vm.closeContextItemsMenuOpen,"setRef":_vm.setContextItemsMenuRef,"focusPrompt":_vm.focusChatInput})],2),_vm._v(" "),_c('gl-form-input-group',{scopedSlots:_vm._u([{key:"append",fn:function(){return [(_vm.displaySubmitButton)?_c('gl-button',{staticClass:"!gl-absolute gl-bottom-2 gl-right-2 !gl-rounded-full",attrs:{"icon":"paper-airplane","category":"primary","variant":"confirm","type":"submit","data-testid":"chat-prompt-submit-button","aria-label":_vm.$options.i18n.CHAT_SUBMIT_LABEL}}):_c('gl-button',{staticClass:"!gl-absolute gl-bottom-2 gl-right-2 !gl-rounded-full",attrs:{"icon":"stop","category":"primary","variant":"default","data-testid":"chat-prompt-cancel-button","aria-label":_vm.$options.i18n.CHAT_CANCEL_LABEL},on:{"click":_vm.cancelPrompt}})]},proxy:true}],null,false,608602988)},[_c('div',{staticClass:"duo-chat-input gl-min-h-8 gl-max-w-full gl-grow gl-align-top",attrs:{"data-value":_vm.prompt}},[(_vm.shouldShowSlashCommands)?_c('gl-card',{ref:"commands",staticClass:"slash-commands !gl-absolute gl-w-full -gl-translate-y-full gl-list-none gl-pl-0 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-flex gl-justify-between"},[_c('span',{staticClass:"gl-block"},[_vm._v(_vm._s(command.name))]),_vm._v(" "),_c('small',{staticClass:"gl-pl-3 gl-text-right gl-italic gl-text-subtle"},[_vm._v(_vm._s(command.description))])])])}),1):_vm._e(),_vm._v(" "),_c('gl-form-textarea',{ref:"prompt",staticClass:"gl-absolute !gl-h-full gl-rounded-br-none gl-rounded-tr-none !gl-bg-transparent !gl-py-4 !gl-shadow-none",class:{ 'gl-truncate': !_vm.prompt },attrs:{"data-testid":"chat-prompt-input","placeholder":_vm.inputPlaceholder,"autofocus":""},on:{"compositionend":_vm.compositionEnd},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)])],1),_vm._v(" "),_c('p',{staticClass:"gl-mb-0 gl-mt-3 gl-px-4 gl-text-sm gl-text-secondary"},[_vm._v("\n "+_vm._s(_vm.$options.i18n.CHAT_DISCLAMER)+"\n ")])],1):_vm._e()],1):_vm._e()])};
|
|
616
624
|
var __vue_staticRenderFns__ = [];
|
|
617
625
|
|
|
618
626
|
/* style */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Marked } from 'marked';
|
|
2
2
|
import markedBidi from 'marked-bidi';
|
|
3
|
+
import DOMPurify from 'dompurify';
|
|
3
4
|
|
|
4
5
|
// eslint-disable-next-line no-restricted-imports
|
|
5
6
|
const duoMarked = new Marked([{
|
|
@@ -7,12 +8,106 @@ const duoMarked = new Marked([{
|
|
|
7
8
|
breaks: false,
|
|
8
9
|
gfm: false
|
|
9
10
|
}, markedBidi()]);
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const config = {
|
|
12
|
+
ALLOW_TAGS: ['p', '#text', 'div', 'code', 'insert-code-snippet', 'gl-markdown', 'pre', 'span', 'gl-compact-markdown', 'copy-code'],
|
|
13
|
+
ADD_ATTR: ['data-canonical-lang', 'data-sourcepos', 'lang', 'data-src', 'img'],
|
|
14
|
+
FORBID_TAGS: ['script', 'style', 'iframe', 'form', 'button'],
|
|
15
|
+
FORBID_ATTR: ['onerror', 'onload', 'onclick']
|
|
16
|
+
};
|
|
17
|
+
const handleImageElements = node => {
|
|
18
|
+
var _node$nodeName;
|
|
19
|
+
if (((_node$nodeName = node.nodeName) === null || _node$nodeName === void 0 ? void 0 : _node$nodeName.toLowerCase()) !== 'img') return node;
|
|
20
|
+
const codeElement = node.ownerDocument.createElement('code');
|
|
21
|
+
codeElement.textContent = node.outerHTML;
|
|
22
|
+
node.parentNode.replaceChild(codeElement, node);
|
|
23
|
+
return node;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validates if a URL is a safe relative URL without embedded malicious URLs
|
|
28
|
+
* @param {string} url - The URL to validate
|
|
29
|
+
* @returns {boolean} - True if the URL is a safe relative URL
|
|
30
|
+
*/
|
|
31
|
+
function isRelativeUrlWithoutEmbeddedUrls(url) {
|
|
32
|
+
// Check if the string starts with a slash
|
|
33
|
+
if (!url.startsWith('/')) {
|
|
34
|
+
return false;
|
|
15
35
|
}
|
|
36
|
+
|
|
37
|
+
// Check for common URL schemes that might be embedded
|
|
38
|
+
// URL Schemes Regex breakdown:
|
|
39
|
+
// (?:(?:https?|ftp|mailto|tel|file|data|ssh|git):?\/\/) - Matches various protocols with optional colon and double slashes
|
|
40
|
+
// - https? - http or https
|
|
41
|
+
// - ftp - ftp protocol
|
|
42
|
+
// - mailto - mailto links
|
|
43
|
+
// - tel - telephone links
|
|
44
|
+
// - file - file protocol
|
|
45
|
+
// - data - data URIs
|
|
46
|
+
// - ssh - ssh protocol
|
|
47
|
+
// - git - git protocol
|
|
48
|
+
// (?:www\.) - Alternatively matches URLs starting with www.
|
|
49
|
+
// Flags: i (case insensitive)
|
|
50
|
+
const urlSchemesRegex = /(?:(?:https?|ftp|mailto|tel|file|data|ssh|git):?\/\/)|(?:www\.)/i;
|
|
51
|
+
|
|
52
|
+
// Return true only if no URL schemes are foundZSS%$
|
|
53
|
+
return !urlSchemesRegex.test(url);
|
|
54
|
+
}
|
|
55
|
+
const sanitizeLinksHook = function () {
|
|
56
|
+
let trustedUrls = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
57
|
+
return node => {
|
|
58
|
+
if (!node.hasAttribute('href')) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const href = node.getAttribute('href');
|
|
62
|
+
try {
|
|
63
|
+
if (isRelativeUrlWithoutEmbeddedUrls(href)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// eslint-disable-next-line no-new
|
|
67
|
+
new URL(href);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
node.removeAttribute('href');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const urlHostname = new URL(href).hostname.toLowerCase();
|
|
73
|
+
const isDocsGitlab = urlHostname === 'docs.gitlab.com';
|
|
74
|
+
const isTrustedUrls = trustedUrls.includes(urlHostname);
|
|
75
|
+
|
|
76
|
+
// Temporary fix until monolith side adds trustedUrls into the calling of component
|
|
77
|
+
const isGitlab = urlHostname === 'gitlab.com';
|
|
78
|
+
if (isDocsGitlab || isTrustedUrls || isGitlab) {
|
|
79
|
+
return; // Do not modify links from allowed domains
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Create a new code element
|
|
83
|
+
const codeElement = document.createElement('code');
|
|
84
|
+
const paraElement = document.createElement('span');
|
|
85
|
+
codeElement.appendChild(paraElement);
|
|
86
|
+
// Move the node's children to the code element
|
|
87
|
+
while (node.firstChild) {
|
|
88
|
+
paraElement.appendChild(node.firstChild);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Append the code element to the node
|
|
92
|
+
node.appendChild(codeElement);
|
|
93
|
+
|
|
94
|
+
// Preserve the href attribute
|
|
95
|
+
paraElement.setAttribute('data-href', node.getAttribute('href'));
|
|
96
|
+
node.removeAttribute('href');
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
function renderDuoChatMarkdownPreview(md) {
|
|
100
|
+
let {
|
|
101
|
+
trustedUrls = []
|
|
102
|
+
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
103
|
+
if (!md) return '';
|
|
104
|
+
DOMPurify.addHook('beforeSanitizeElements', handleImageElements);
|
|
105
|
+
DOMPurify.addHook('afterSanitizeAttributes', sanitizeLinksHook(trustedUrls));
|
|
106
|
+
const parsedMarkdown = duoMarked.parse(md.toString());
|
|
107
|
+
const sanitized = DOMPurify.sanitize(parsedMarkdown, config);
|
|
108
|
+
DOMPurify.removeHook('beforeSanitizeElements');
|
|
109
|
+
DOMPurify.removeHook('afterSanitizeAttributes');
|
|
110
|
+
return sanitized;
|
|
16
111
|
}
|
|
17
112
|
|
|
18
113
|
export { renderDuoChatMarkdownPreview };
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { default as DuoChatLoader } from './components/chat/components/duo_chat_
|
|
|
7
7
|
export { default as DuoChatMessage } from './components/chat/components/duo_chat_message/duo_chat_message';
|
|
8
8
|
export { default as DuoChatMessageSources } from './components/chat/components/duo_chat_message_sources/duo_chat_message_sources';
|
|
9
9
|
export { default as DuoChatPredefinedPrompts } from './components/chat/components/duo_chat_predefined_prompts/duo_chat_predefined_prompts';
|
|
10
|
+
export { default as AgenticDuoChat } from './components/agentic_chat/agentic_duo_chat';
|
|
10
11
|
export { default as DuoChatContextItemDetailsModal } from './components/chat/components/duo_chat_context/duo_chat_context_item_details_modal/duo_chat_context_item_details_modal';
|
|
11
12
|
export { default as DuoChatContextItemMenu } from './components/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu';
|
|
12
13
|
export { default as DuoChatContextItemPopover } from './components/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover';
|
package/package.json
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
The component represents the complete Duo Chat feature.
|
|
2
|
+
|
|
3
|
+
The component provides a configurable chat UI interface. The primary use is communication with
|
|
4
|
+
GitLab Duo, however, the component is BE-agnostic and can accept information from any source.
|
|
5
|
+
|
|
6
|
+
## Usage
|
|
7
|
+
|
|
8
|
+
To use the component in its simplest form, import it and add it to the `template` part of your
|
|
9
|
+
consumer component.
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<agentic-duo-chat
|
|
13
|
+
:title="title"
|
|
14
|
+
:messages="messages"
|
|
15
|
+
:error="error"
|
|
16
|
+
:is-loading="isLoading"
|
|
17
|
+
:is-chat-available="isChatAvailable"
|
|
18
|
+
:predefined-prompts="predefinedPrompts"
|
|
19
|
+
:canceled-request-ids="cancelledRequestIds"
|
|
20
|
+
:tool-name="toolName"
|
|
21
|
+
:empty-state-title="emptyStateTitle"
|
|
22
|
+
:empty-state-description="emptyStateDescription"
|
|
23
|
+
:chat-prompt-placeholder="chatPromptPlaceholder"
|
|
24
|
+
:slash-commands="slashCommands"
|
|
25
|
+
@chat-hidden="onChatHidden"
|
|
26
|
+
@send-chat-prompt="onSendChatPrompt"
|
|
27
|
+
@track-feedback="onTrackFeedback"
|
|
28
|
+
/>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Integration
|
|
32
|
+
|
|
33
|
+
To demonstrate how to connect this component to a backend implementation, let's consider its use
|
|
34
|
+
for GitLab Duo. First, some general notes on the best practices and expectations when using this
|
|
35
|
+
component.
|
|
36
|
+
|
|
37
|
+
### Expected dependency injection
|
|
38
|
+
|
|
39
|
+
To be universal, the component delegates some of its responsibilities to the consumer component.
|
|
40
|
+
|
|
41
|
+
The component expects two function props:
|
|
42
|
+
|
|
43
|
+
- `renderMarkdown`
|
|
44
|
+
- `renderGFM`
|
|
45
|
+
|
|
46
|
+
#### `renderMarkdown`
|
|
47
|
+
|
|
48
|
+
This function prop converts plain Markdown text into HTML markup. To have a better understanding
|
|
49
|
+
of what is expected from this function, take a look at
|
|
50
|
+
[the existing GitLab example](https://gitlab.com/gitlab-org/gitlab/-/blob/774ecc1f2b15a581e8eab6441de33585c9691c82/app/assets/javascripts/notes/utils.js#L22-24).
|
|
51
|
+
|
|
52
|
+
#### `renderGFM`
|
|
53
|
+
|
|
54
|
+
This function prop extends the standard Markdown rendering with support for the
|
|
55
|
+
[GitLab Flavored Markdown (GLFM)](https://docs.gitlab.com/ee/user/markdown.html). To
|
|
56
|
+
have a better understanding of what is expected from this function, take a look at
|
|
57
|
+
[the existing GitLab example](https://gitlab.com/gitlab-org/gitlab/-/blob/774ecc1f2b15a581e8eab6441de33585c9691c82/app/assets/javascripts/behaviors/markdown/render_gfm.js#L18-40).
|
|
58
|
+
|
|
59
|
+
The reason to have two different functions for rendering Markdown is performance. `renderGFM`
|
|
60
|
+
operates on a DOM node and might come with many additional mutations for the node's content.
|
|
61
|
+
Such behavior suits a one-time update. However, Duo Chat also supports streaming of the AI
|
|
62
|
+
responses (check the [Interactive story for this component](?path=/story/experimental-duo-chat-duo-chat--interactive))
|
|
63
|
+
and, in this case, when the message is constantly updating, we rely on a more lightweight
|
|
64
|
+
`renderMarkdown` to render the updated message faster.
|
|
65
|
+
|
|
66
|
+
### Don't use reactivity where unnecessary
|
|
67
|
+
|
|
68
|
+
The `AgenticDuoChat` component exposes many properties, as seen below. But not all of those should
|
|
69
|
+
be necessarily reactive in the consumer component. The properties that might be static:
|
|
70
|
+
|
|
71
|
+
- `title`. The title is shown in the head of the component.
|
|
72
|
+
- `isChatAvailable`. The flag indicates whether the communication interface should allow follow-up
|
|
73
|
+
questions. Usually, this decision stays the same during the component's lifecycle.
|
|
74
|
+
- `predefinedPrompts`. The `Array` of strings that represents the possible questions to ask when
|
|
75
|
+
there are no messages in the chat.
|
|
76
|
+
- `emptyStateTitle`. Title of the empty state component. Visible when there are no messages.
|
|
77
|
+
- `emptyStateDescription`. Description text of the empty state component. Visible when there are no messages.
|
|
78
|
+
- `chatPromptPlaceholder`. Placeholder text for the chat prompt input.
|
|
79
|
+
|
|
80
|
+
### Set up communication with consumer
|
|
81
|
+
|
|
82
|
+
```javascript
|
|
83
|
+
import { AgenticDuoChat } from '@gitlab/duo-ui';
|
|
84
|
+
|
|
85
|
+
export default {
|
|
86
|
+
...
|
|
87
|
+
data() {
|
|
88
|
+
return {
|
|
89
|
+
messages: [],
|
|
90
|
+
error: null,
|
|
91
|
+
isLoading: false,
|
|
92
|
+
toolName: '',
|
|
93
|
+
cancelledRequestIds: []
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
provide: {
|
|
97
|
+
renderMarkdown: (content) => {
|
|
98
|
+
// implementation of the `renderMarkdown` functionality
|
|
99
|
+
},
|
|
100
|
+
renderGFM: (el) => {
|
|
101
|
+
// implementation of the `renderGFM` functionality
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
beforeCreate() {
|
|
105
|
+
// Here, we set up our non-reactive properties if we must change the default values
|
|
106
|
+
this.title = 'Foo Bar';
|
|
107
|
+
this.isChatAvailable = true; // this is just an example. `true` is the default value
|
|
108
|
+
this.predefinedPrompts = ['How to …?', 'Where do I …?'];
|
|
109
|
+
this.emptyStateTitle = 'Ask anything';
|
|
110
|
+
this.emptyStateDescription = 'You will see the answers below';
|
|
111
|
+
this.chatPromptPlaceholder = 'Type your question here';
|
|
112
|
+
}
|
|
113
|
+
methods: {
|
|
114
|
+
onChatCancel() {
|
|
115
|
+
// pushing last requestId of messages to canceled Request Ids
|
|
116
|
+
this.cancelledRequestIds.push(this.messages[this.messages.length - 1].requestId);
|
|
117
|
+
this.isLoading = false;
|
|
118
|
+
},
|
|
119
|
+
onChatHidden() {
|
|
120
|
+
...
|
|
121
|
+
},
|
|
122
|
+
onSendChatPrompt(prompt = '') {
|
|
123
|
+
this.isLoading = true;
|
|
124
|
+
this.messages.push(constructUserMessage(prompt));
|
|
125
|
+
...
|
|
126
|
+
},
|
|
127
|
+
onTrackFeedback(feedbackObj = {}) {
|
|
128
|
+
...
|
|
129
|
+
},
|
|
130
|
+
onAiResponse(data) {
|
|
131
|
+
// check if requestId was not cancelled
|
|
132
|
+
if (requestId && !this.cancelledRequestIds.includes(data.requestId)) {
|
|
133
|
+
this.messages = data
|
|
134
|
+
}
|
|
135
|
+
…
|
|
136
|
+
this.isLoading = false;
|
|
137
|
+
},
|
|
138
|
+
onAiResponseError(error) {
|
|
139
|
+
this.error = error;
|
|
140
|
+
this.isLoading = false;
|
|
141
|
+
},
|
|
142
|
+
onToolNameChange(toolMessage) {
|
|
143
|
+
this.toolName = toolMessage.content;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
With this template in place, consumer is left with the following things to implement:
|
|
150
|
+
|
|
151
|
+
- Fetch `messages`. For Duo Chat, we rely on GraphQL query to get the cached
|
|
152
|
+
messages and subscription to monitor new messages when:
|
|
153
|
+
|
|
154
|
+
- streaming response
|
|
155
|
+
- listening to chat messages in other tabs/environments
|
|
156
|
+
- listen to updates from different tools to update `toolName`
|
|
157
|
+
|
|
158
|
+
- Send the new user's prompt. For Duo Chat, we rely on GraphQL mutation for this purpose.
|
|
159
|
+
- Send user feedback to the telemetry of your choice when `track-feedback` event arrives.
|
|
160
|
+
|
|
161
|
+
## Slash commands
|
|
162
|
+
|
|
163
|
+
One of the props accepted by the component is the `slashCommands`. This is an `Array` of
|
|
164
|
+
the commands, shown to user when they start typing the prompt with a slash (`/`)
|
|
165
|
+
character.
|
|
166
|
+
|
|
167
|
+
```javascript
|
|
168
|
+
<script>
|
|
169
|
+
const slashCommands = [
|
|
170
|
+
{
|
|
171
|
+
name: '/mycommand', // This is the exact name of my command as it will be submitted
|
|
172
|
+
shouldSubmit: true, // If the command should be submitted right away without free text
|
|
173
|
+
description: 'The description of my super-duper command.',
|
|
174
|
+
},
|
|
175
|
+
...
|
|
176
|
+
];
|
|
177
|
+
export default {
|
|
178
|
+
...
|
|
179
|
+
options: {
|
|
180
|
+
slashCommands
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
<script>
|
|
184
|
+
<template>
|
|
185
|
+
<duo-chat
|
|
186
|
+
...
|
|
187
|
+
:slash-commands="slashCommands"
|
|
188
|
+
/>
|
|
189
|
+
</template>
|
|
190
|
+
```
|