@gitlab/duo-ui 8.11.0 → 8.12.1

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.
@@ -45,6 +45,14 @@ export default {
45
45
  required: false,
46
46
  default: true,
47
47
  },
48
+ /**
49
+ * Base URL of the GitLab instance.
50
+ */
51
+ trustedUrls: {
52
+ type: Array,
53
+ required: false,
54
+ default: () => [],
55
+ },
48
56
  },
49
57
  methods: {
50
58
  onTrackFeedback(event) {
@@ -87,6 +95,7 @@ export default {
87
95
  v-for="(msg, index) in messages"
88
96
  :key="`${msg.role}-${index}`"
89
97
  :message="msg"
98
+ :trusted-urls="trustedUrls"
90
99
  :is-cancelled="canceledRequestIds.includes(msg.requestId)"
91
100
  @track-feedback="onTrackFeedback"
92
101
  @insert-code-snippet="onInsertCodeSnippet"
@@ -9,16 +9,19 @@ export class CopyCodeElement extends HTMLElement {
9
9
  }
10
10
 
11
11
  async initialize() {
12
+ if (!this.getCodeElement()) return;
13
+
12
14
  const btn = createButton('Copy to clipboard', 'copy-to-clipboard');
13
15
  this.appendChild(btn);
14
16
 
15
17
  const tooltip = await createTooltip(btn, 'Copy to clipboard');
16
18
  this.appendChild(tooltip);
17
19
 
18
- const wrapper = this.parentNode;
19
- const [codeElement] = wrapper.getElementsByTagName('code');
20
-
21
20
  btn.addEventListener('click', async () => {
21
+ // We fetch the code element on click instead because the structure of the HTML can change after
22
+ // the element is initialized (e.g. on sanitation), and we don't want to use an element that is
23
+ // not present in the DOM.
24
+ const codeElement = this.getCodeElement();
22
25
  const textToCopy = codeElement.innerText;
23
26
  const hasClipboardPermission = await checkClipboardPermissions();
24
27
 
@@ -42,4 +45,9 @@ export class CopyCodeElement extends HTMLElement {
42
45
  }
43
46
  });
44
47
  }
48
+
49
+ getCodeElement = () => {
50
+ const wrapper = this.parentNode;
51
+ return wrapper.getElementsByTagName('code')[0] || wrapper.getElementsByTagName('pre')[0];
52
+ };
45
53
  }
@@ -109,6 +109,14 @@ export default {
109
109
  type: Boolean,
110
110
  required: true,
111
111
  },
112
+ /**
113
+ * Base URL of the GitLab instance.
114
+ */
115
+ trustedUrls: {
116
+ type: Array,
117
+ required: false,
118
+ default: () => [],
119
+ },
112
120
  },
113
121
  data() {
114
122
  return {
@@ -148,17 +156,20 @@ export default {
148
156
  return this.message.contentHtml;
149
157
  }
150
158
 
151
- return this.renderMarkdown(this.message.content);
159
+ return this.renderMarkdown(this.message.content, this.trustedUrls);
152
160
  },
153
161
  messageContent() {
154
162
  if (this.isAssistantMessage && this.isChunk) {
155
- return this.renderMarkdown(concatUntilEmpty(this.messageChunks));
163
+ return this.renderMarkdown(concatUntilEmpty(this.messageChunks), this.trustedUrls);
156
164
  }
157
165
 
158
- return this.defaultContent || this.renderMarkdown(concatUntilEmpty(this.message.chunks));
166
+ return (
167
+ this.renderMarkdown(this.defaultContent, this.trustedUrls) ||
168
+ this.renderMarkdown(concatUntilEmpty(this.message.chunks), this.trustedUrls)
169
+ );
159
170
  },
160
171
  renderedError() {
161
- return this.renderMarkdown(this.message.errors?.join('; ') || '');
172
+ return this.renderMarkdown(this.message.errors?.join('; ') || '', this.trustedUrls);
162
173
  },
163
174
  error() {
164
175
  return Boolean(this.message?.errors?.length) && this.message.errors.join('; ');
@@ -6,34 +6,38 @@ const CODE_MARKDOWN_CLASS = 'js-markdown-code';
6
6
  export class InsertCodeSnippetElement extends HTMLElement {
7
7
  #actionButton;
8
8
 
9
- #codeElement;
9
+ #codeBlock;
10
10
 
11
11
  constructor(codeBlock) {
12
12
  super();
13
13
  this.initialize(codeBlock);
14
14
  }
15
15
 
16
+ // we handle two possible cases here:
17
+ // 1. we use constructor parameter if the element is created in Javascript and inserted in the document
18
+ // 2. we find the wrapping element containing code if the element is received from the server
16
19
  async initialize(codeBlock) {
17
- this.#actionButton = createButton();
20
+ if (!this.getCodeElement(codeBlock)) return;
18
21
 
19
- // we handle two possible cases here:
20
- // 1. we use constructor parameter if the element is created in Javscript and inserted in the document
21
- // 2. we find the wrapping element containing code if the element is received from the server
22
- const wrapper = codeBlock ?? this.closest(`.${CODE_MARKDOWN_CLASS}`);
23
- [this.#codeElement] = wrapper.getElementsByTagName('code');
22
+ this.#codeBlock = codeBlock;
23
+ this.#actionButton = createButton();
24
24
 
25
25
  const tooltip = await createTooltip(this.#actionButton, 'Insert at cursor');
26
26
  this.appendChild(tooltip);
27
27
  }
28
28
 
29
29
  #handleClick = () => {
30
- if (this.#codeElement) {
31
- this.#codeElement.dispatchEvent(
30
+ // We fetch the code element on click instead because the structure of the HTML can change after
31
+ // the element is initialized (e.g. on sanitation), and we don't want to use an element that is
32
+ // not present in the DOM.
33
+ const codeElement = this.getCodeElement(this.#codeBlock);
34
+ if (codeElement) {
35
+ codeElement.dispatchEvent(
32
36
  new CustomEvent('insert-code-snippet', {
33
37
  bubbles: true,
34
38
  cancelable: true,
35
39
  detail: {
36
- code: this.#codeElement.textContent.trim(),
40
+ code: codeElement.textContent.trim(),
37
41
  },
38
42
  })
39
43
  );
@@ -41,11 +45,20 @@ export class InsertCodeSnippetElement extends HTMLElement {
41
45
  };
42
46
 
43
47
  connectedCallback() {
44
- this.appendChild(this.#actionButton);
45
- this.#actionButton.addEventListener('click', this.#handleClick);
48
+ if (this.#actionButton) {
49
+ this.appendChild(this.#actionButton);
50
+ this.#actionButton.addEventListener('click', this.#handleClick);
51
+ }
46
52
  }
47
53
 
48
54
  disconnectedCallback() {
49
- this.#actionButton.removeEventListener('click', this.#handleClick);
55
+ if (this.#actionButton) {
56
+ this.#actionButton.removeEventListener('click', this.#handleClick);
57
+ }
50
58
  }
59
+
60
+ getCodeElement = (codeBlock) => {
61
+ const wrapper = codeBlock ?? this.closest(`.${CODE_MARKDOWN_CLASS}`);
62
+ return wrapper.getElementsByTagName('code')[0] || wrapper.getElementsByTagName('pre')[0];
63
+ };
51
64
  }
@@ -297,6 +297,14 @@ export default {
297
297
  default: false,
298
298
  },
299
299
  /**
300
+ * Base URL of the GitLab instance.
301
+ */
302
+ trustedUrls: {
303
+ type: Array,
304
+ required: false,
305
+ default: () => [],
306
+ },
307
+ /*
300
308
  * The preferred locale for the chat interface.
301
309
  * Follows BCP 47 language tag format (e.g., 'en-US', 'fr-FR', 'es-ES').
302
310
  */
@@ -712,7 +720,6 @@ export default {
712
720
  isMultithreaded && currentView === 'list' ? $options.i18n.CHAT_HISTORY_TITLE : title
713
721
  "
714
722
  :subtitle="activeThreadTitleForView"
715
- :error="error"
716
723
  :is-multithreaded="isMultithreaded"
717
724
  :current-view="currentView"
718
725
  :should-render-resizable="shouldRenderResizable"
@@ -754,6 +761,7 @@ export default {
754
761
  :messages="conversation"
755
762
  :canceled-request-ids="canceledRequestIds"
756
763
  :show-delimiter="index > 0"
764
+ :trusted-urls="trustedUrls"
757
765
  @track-feedback="onTrackFeedback"
758
766
  @insert-code-snippet="onInsertCodeSnippet"
759
767
  @copy-code-snippet="onCopyCodeSnippet"
@@ -1,6 +1,7 @@
1
1
  // eslint-disable-next-line no-restricted-imports
2
2
  import { Marked } from 'marked';
3
3
  import markedBidi from 'marked-bidi';
4
+ import DOMPurify from 'dompurify';
4
5
 
5
6
  const duoMarked = new Marked([
6
7
  {
@@ -11,10 +12,120 @@ const duoMarked = new Marked([
11
12
  markedBidi(),
12
13
  ]);
13
14
 
14
- export function renderDuoChatMarkdownPreview(md) {
15
- try {
16
- return md ? duoMarked.parse(md.toString()) : '';
17
- } catch {
18
- return md;
15
+ const config = {
16
+ ALLOW_TAGS: [
17
+ 'p',
18
+ '#text',
19
+ 'div',
20
+ 'code',
21
+ 'insert-code-snippet',
22
+ 'gl-markdown',
23
+ 'pre',
24
+ 'span',
25
+ 'gl-compact-markdown',
26
+ 'copy-code',
27
+ ],
28
+ ADD_ATTR: ['data-canonical-lang', 'data-sourcepos', 'lang', 'data-src', 'img'],
29
+ FORBID_TAGS: ['script', 'style', 'iframe', 'form', 'button'],
30
+ FORBID_ATTR: ['onerror', 'onload', 'onclick'],
31
+ };
32
+
33
+ const handleImageElements = (node) => {
34
+ if (node.nodeName?.toLowerCase() !== 'img') return node;
35
+
36
+ const codeElement = node.ownerDocument.createElement('code');
37
+ codeElement.textContent = node.outerHTML;
38
+ node.parentNode.replaceChild(codeElement, node);
39
+ return node;
40
+ };
41
+
42
+ /**
43
+ * Validates if a URL is a safe relative URL without embedded malicious URLs
44
+ * @param {string} url - The URL to validate
45
+ * @returns {boolean} - True if the URL is a safe relative URL
46
+ */
47
+ function isRelativeUrlWithoutEmbeddedUrls(url) {
48
+ // Check if the string starts with a slash
49
+ if (!url.startsWith('/')) {
50
+ return false;
19
51
  }
52
+
53
+ // Check for common URL schemes that might be embedded
54
+ // URL Schemes Regex breakdown:
55
+ // (?:(?:https?|ftp|mailto|tel|file|data|ssh|git):?\/\/) - Matches various protocols with optional colon and double slashes
56
+ // - https? - http or https
57
+ // - ftp - ftp protocol
58
+ // - mailto - mailto links
59
+ // - tel - telephone links
60
+ // - file - file protocol
61
+ // - data - data URIs
62
+ // - ssh - ssh protocol
63
+ // - git - git protocol
64
+ // (?:www\.) - Alternatively matches URLs starting with www.
65
+ // Flags: i (case insensitive)
66
+ const urlSchemesRegex = /(?:(?:https?|ftp|mailto|tel|file|data|ssh|git):?\/\/)|(?:www\.)/i;
67
+
68
+ // Return true only if no URL schemes are foundZSS%$
69
+ return !urlSchemesRegex.test(url);
70
+ }
71
+ const sanitizeLinksHook =
72
+ (trustedUrls = []) =>
73
+ (node) => {
74
+ if (!node.hasAttribute('href')) {
75
+ return;
76
+ }
77
+
78
+ const href = node.getAttribute('href');
79
+ try {
80
+ if (isRelativeUrlWithoutEmbeddedUrls(href)) {
81
+ return;
82
+ }
83
+ // eslint-disable-next-line no-new
84
+ new URL(href);
85
+ } catch (e) {
86
+ node.removeAttribute('href');
87
+ return;
88
+ }
89
+
90
+ const urlHostname = new URL(href).hostname.toLowerCase();
91
+ const isDocsGitlab = urlHostname === 'docs.gitlab.com';
92
+ const isTrustedUrls = trustedUrls.includes(urlHostname);
93
+
94
+ // Temporary fix until monolith side adds trustedUrls into the calling of component
95
+ const isGitlab = urlHostname === 'gitlab.com';
96
+
97
+ if (isDocsGitlab || isTrustedUrls || isGitlab) {
98
+ return; // Do not modify links from allowed domains
99
+ }
100
+
101
+ // Create a new code element
102
+ const codeElement = document.createElement('code');
103
+ const paraElement = document.createElement('span');
104
+
105
+ codeElement.appendChild(paraElement);
106
+ // Move the node's children to the code element
107
+ while (node.firstChild) {
108
+ paraElement.appendChild(node.firstChild);
109
+ }
110
+
111
+ // Append the code element to the node
112
+ node.appendChild(codeElement);
113
+
114
+ // Preserve the href attribute
115
+ paraElement.setAttribute('data-href', node.getAttribute('href'));
116
+ node.removeAttribute('href');
117
+ };
118
+
119
+ export function renderDuoChatMarkdownPreview(md, { trustedUrls = [] } = {}) {
120
+ if (!md) return '';
121
+
122
+ DOMPurify.addHook('beforeSanitizeElements', handleImageElements);
123
+ DOMPurify.addHook('afterSanitizeAttributes', sanitizeLinksHook(trustedUrls));
124
+
125
+ const parsedMarkdown = duoMarked.parse(md.toString());
126
+ const sanitized = DOMPurify.sanitize(parsedMarkdown, config);
127
+
128
+ DOMPurify.removeHook('beforeSanitizeElements');
129
+ DOMPurify.removeHook('afterSanitizeAttributes');
130
+ return sanitized;
20
131
  }
package/src/index.js CHANGED
@@ -12,6 +12,9 @@ export { default as DuoChatMessage } from './components/chat/components/duo_chat
12
12
  export { default as DuoChatMessageSources } from './components/chat/components/duo_chat_message_sources/duo_chat_message_sources.vue';
13
13
  export { default as DuoChatPredefinedPrompts } from './components/chat/components/duo_chat_predefined_prompts/duo_chat_predefined_prompts.vue';
14
14
 
15
+ // Agentic Duo Chat component
16
+ export { default as AgenticDuoChat } from './components/agentic_chat/agentic_duo_chat.vue';
17
+
15
18
  // Duo Chat Context components
16
19
  export { default as DuoChatContextItemDetailsModal } from './components/chat/components/duo_chat_context/duo_chat_context_item_details_modal/duo_chat_context_item_details_modal.vue';
17
20
  export { default as DuoChatContextItemMenu } from './components/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.vue';
package/translations.js CHANGED
@@ -1,5 +1,19 @@
1
1
  /* eslint-disable import/no-default-export */
2
2
  export default {
3
+ 'AgenticDuoChat.chatCancelLabel': 'Cancel',
4
+ 'AgenticDuoChat.chatDefaultPredefinedPromptsChangePassword':
5
+ 'How do I change my password in GitLab?',
6
+ 'AgenticDuoChat.chatDefaultPredefinedPromptsCloneRepository': 'How do I clone a repository?',
7
+ 'AgenticDuoChat.chatDefaultPredefinedPromptsCreateTemplate': 'How do I create a template?',
8
+ 'AgenticDuoChat.chatDefaultPredefinedPromptsForkProject': 'How do I fork a project?',
9
+ 'AgenticDuoChat.chatDefaultTitle': 'GitLab Duo Chat',
10
+ 'AgenticDuoChat.chatDisclamer': 'Responses may be inaccurate. Verify before use.',
11
+ 'AgenticDuoChat.chatEmptyStateTitle':
12
+ '👋 I am GitLab Duo Chat, your personal AI-powered assistant. How can I help you today?',
13
+ 'AgenticDuoChat.chatHistoryTitle': 'Chat history',
14
+ 'AgenticDuoChat.chatPromptPlaceholderDefault': 'GitLab Duo Chat',
15
+ 'AgenticDuoChat.chatPromptPlaceholderWithCommands': 'Type /help to learn more',
16
+ 'AgenticDuoChat.chatSubmitLabel': 'Send chat message.',
3
17
  'DuoChat.chatBackLabel': 'Back to History',
4
18
  'DuoChat.chatCancelLabel': 'Cancel',
5
19
  'DuoChat.chatDefaultPredefinedPromptsChangePassword': 'How do I change my password in GitLab?',