@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.
- package/CHANGELOG.md +21 -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/copy_code_element.js +32 -2
- package/dist/components/chat/components/duo_chat_message/duo_chat_message.js +12 -4
- package/dist/components/chat/components/duo_chat_message/insert_code_snippet_element.js +49 -24
- 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/copy_code_element.js +11 -3
- package/src/components/chat/components/duo_chat_message/duo_chat_message.vue +15 -4
- package/src/components/chat/components/duo_chat_message/insert_code_snippet_element.js +26 -13
- 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
|
@@ -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
|
|
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
|
-
#
|
|
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
|
-
|
|
20
|
+
if (!this.getCodeElement(codeBlock)) return;
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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:
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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?',
|