@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.
@@ -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,"error":_vm.error,"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},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()])};
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
- function renderDuoChatMarkdownPreview(md) {
11
- try {
12
- return md ? duoMarked.parse(md.toString()) : '';
13
- } catch {
14
- return md;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/duo-ui",
3
- "version": "8.11.0",
3
+ "version": "8.12.0",
4
4
  "description": "Duo UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -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
+ ```