@gitlab/ui 78.8.1 → 78.10.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 +15 -0
- package/dist/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.js +51 -14
- package/dist/components/experimental/duo/chat/components/duo_chat_message/utils.js +8 -0
- package/dist/components/experimental/duo/chat/duo_chat.js +2 -2
- package/dist/components/experimental/duo/chat/mock_data.js +18 -4
- package/dist/components/experimental/duo/user_feedback/user_feedback_modal.js +18 -6
- package/dist/tokens/css/tokens.css +1 -1
- package/dist/tokens/css/tokens.dark.css +1 -1
- package/dist/tokens/js/tokens.dark.js +1 -1
- package/dist/tokens/js/tokens.js +1 -1
- package/dist/tokens/scss/_tokens.dark.scss +1 -1
- package/dist/tokens/scss/_tokens.scss +1 -1
- package/package.json +2 -2
- package/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.spec.js +258 -138
- package/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.vue +46 -17
- package/src/components/experimental/duo/chat/components/duo_chat_message/utils.js +9 -0
- package/src/components/experimental/duo/chat/components/duo_chat_message/utils.spec.js +24 -0
- package/src/components/experimental/duo/chat/duo_chat.spec.js +1 -0
- package/src/components/experimental/duo/chat/duo_chat.stories.js +14 -31
- package/src/components/experimental/duo/chat/duo_chat.vue +4 -1
- package/src/components/experimental/duo/chat/mock_data.js +18 -3
- package/src/components/experimental/duo/user_feedback/user_feedback.stories.js +1 -3
- package/src/components/experimental/duo/user_feedback/user_feedback_modal.spec.js +19 -3
- package/src/components/experimental/duo/user_feedback/user_feedback_modal.vue +19 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
# [78.10.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v78.9.0...v78.10.0) (2024-04-08)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **GlDuoChat:** detect streaming based on chunkId ([6cdfe5c](https://gitlab.com/gitlab-org/gitlab-ui/commit/6cdfe5cd823ed0e832a5ec51870421ff4c532dbc))
|
|
7
|
+
* **GlDuoChatMessage:** Handle streaming in the component ([082ab86](https://gitlab.com/gitlab-org/gitlab-ui/commit/082ab867597972bb18ba5287ac69126f38e17619))
|
|
8
|
+
|
|
9
|
+
# [78.9.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v78.8.1...v78.9.0) (2024-04-04)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* **DuoChat:** add mandatory field indicator on DuoChat Feedback Modal ([df7d4a5](https://gitlab.com/gitlab-org/gitlab-ui/commit/df7d4a55e799695c9d972ba029758136c0892990))
|
|
15
|
+
|
|
1
16
|
## [78.8.1](https://gitlab.com/gitlab-org/gitlab-ui/compare/v78.8.0...v78.8.1) (2024-04-04)
|
|
2
17
|
|
|
3
18
|
|
package/dist/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.js
CHANGED
|
@@ -6,6 +6,7 @@ import { MESSAGE_MODEL_ROLES } from '../../constants';
|
|
|
6
6
|
import DocumentationSources from '../duo_chat_message_sources/duo_chat_message_sources';
|
|
7
7
|
import { renderDuoChatMarkdownPreview } from '../../markdown_renderer';
|
|
8
8
|
import { CopyCodeElement } from './copy_code_element';
|
|
9
|
+
import { concatUntilEmpty } from './utils';
|
|
9
10
|
import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
|
|
10
11
|
|
|
11
12
|
const i18n = {
|
|
@@ -18,12 +19,6 @@ const i18n = {
|
|
|
18
19
|
BETTER_RESPONSE: 'How the response might better meet your needs.'
|
|
19
20
|
}
|
|
20
21
|
};
|
|
21
|
-
const concatUntilEmpty = arr => {
|
|
22
|
-
if (!arr) return '';
|
|
23
|
-
let end = arr.findIndex(el => !el);
|
|
24
|
-
if (end < 0) end = arr.length;
|
|
25
|
-
return arr.slice(0, end).join('');
|
|
26
|
-
};
|
|
27
22
|
var script = {
|
|
28
23
|
name: 'GlDuoChatMessage',
|
|
29
24
|
safeHtmlConfigExtension: {
|
|
@@ -72,10 +67,16 @@ var script = {
|
|
|
72
67
|
data() {
|
|
73
68
|
return {
|
|
74
69
|
didWhat: '',
|
|
75
|
-
improveWhat: ''
|
|
70
|
+
improveWhat: '',
|
|
71
|
+
messageWatcher: null,
|
|
72
|
+
// imperatively set up watcher on message
|
|
73
|
+
messageChunks: []
|
|
76
74
|
};
|
|
77
75
|
},
|
|
78
76
|
computed: {
|
|
77
|
+
isChunk() {
|
|
78
|
+
return typeof this.message.chunkId === 'number';
|
|
79
|
+
},
|
|
79
80
|
isAssistantMessage() {
|
|
80
81
|
return this.message.role.toLowerCase() === MESSAGE_MODEL_ROLES.assistant;
|
|
81
82
|
},
|
|
@@ -90,12 +91,18 @@ var script = {
|
|
|
90
91
|
var _this$message$extras2;
|
|
91
92
|
return (_this$message$extras2 = this.message.extras) === null || _this$message$extras2 === void 0 ? void 0 : _this$message$extras2.hasFeedback;
|
|
92
93
|
},
|
|
93
|
-
|
|
94
|
+
defaultContent() {
|
|
94
95
|
if (this.message.errors.length > 0) return this.renderMarkdown(this.message.errors.join('; '));
|
|
95
96
|
if (this.message.contentHtml) {
|
|
96
97
|
return this.message.contentHtml;
|
|
97
98
|
}
|
|
98
|
-
return this.renderMarkdown(this.message.content
|
|
99
|
+
return this.renderMarkdown(this.message.content);
|
|
100
|
+
},
|
|
101
|
+
messageContent() {
|
|
102
|
+
if (this.isAssistantMessage && this.isChunk) {
|
|
103
|
+
return this.renderMarkdown(concatUntilEmpty(this.messageChunks));
|
|
104
|
+
}
|
|
105
|
+
return this.defaultContent || this.renderMarkdown(concatUntilEmpty(this.message.chunks));
|
|
99
106
|
}
|
|
100
107
|
},
|
|
101
108
|
beforeCreate() {
|
|
@@ -104,15 +111,39 @@ var script = {
|
|
|
104
111
|
}
|
|
105
112
|
},
|
|
106
113
|
mounted() {
|
|
107
|
-
|
|
114
|
+
if (this.isAssistantMessage) {
|
|
115
|
+
// The watcher has to be created imperatively here
|
|
116
|
+
// to give an opportunity to remove it after
|
|
117
|
+
// the complete message has arrived
|
|
118
|
+
this.messageWatcher = this.$watch('message', this.manageMessageUpdate);
|
|
119
|
+
}
|
|
120
|
+
this.setChunks();
|
|
121
|
+
this.hydrateContentWithGFM();
|
|
108
122
|
},
|
|
109
123
|
updated() {
|
|
110
|
-
this
|
|
124
|
+
this.hydrateContentWithGFM();
|
|
111
125
|
},
|
|
112
126
|
methods: {
|
|
113
|
-
|
|
114
|
-
if (this.
|
|
115
|
-
|
|
127
|
+
setChunks() {
|
|
128
|
+
if (this.isChunk) {
|
|
129
|
+
const {
|
|
130
|
+
chunkId,
|
|
131
|
+
content
|
|
132
|
+
} = this.message;
|
|
133
|
+
this.$set(this.messageChunks, chunkId - 1, content);
|
|
134
|
+
} else {
|
|
135
|
+
this.messageChunks = [];
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
stopWatchingMessage() {
|
|
139
|
+
if (this.messageWatcher) {
|
|
140
|
+
this.messageWatcher(); // Stop watching the message prop
|
|
141
|
+
this.messageWatcher = null; // Ensure the watcher can't be stopped multiple times
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
hydrateContentWithGFM() {
|
|
145
|
+
if (!this.isChunk) {
|
|
146
|
+
this.$nextTick(this.renderGFM(this.$refs.content));
|
|
116
147
|
}
|
|
117
148
|
},
|
|
118
149
|
logEvent(e) {
|
|
@@ -122,6 +153,12 @@ var script = {
|
|
|
122
153
|
improveWhat: this.improveWhat,
|
|
123
154
|
message: this.message
|
|
124
155
|
});
|
|
156
|
+
},
|
|
157
|
+
manageMessageUpdate() {
|
|
158
|
+
this.setChunks();
|
|
159
|
+
if (!this.isChunk) {
|
|
160
|
+
this.stopWatchingMessage();
|
|
161
|
+
}
|
|
125
162
|
}
|
|
126
163
|
},
|
|
127
164
|
i18n
|
|
@@ -215,8 +215,8 @@ var script = {
|
|
|
215
215
|
return this.isLoading || this.isStreaming;
|
|
216
216
|
},
|
|
217
217
|
isStreaming() {
|
|
218
|
-
var _this$lastMessage2, _this$lastMessage2$ch, _this$lastMessage3;
|
|
219
|
-
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));
|
|
218
|
+
var _this$lastMessage2, _this$lastMessage2$ch, _this$lastMessage3, _this$lastMessage4;
|
|
219
|
+
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');
|
|
220
220
|
},
|
|
221
221
|
filteredSlashCommands() {
|
|
222
222
|
const caseInsensitivePrompt = this.prompt.toLowerCase();
|
|
@@ -28,6 +28,20 @@ const MOCK_RESPONSE_MESSAGE = {
|
|
|
28
28
|
errors: [],
|
|
29
29
|
timestamp: '2021-04-21T12:00:00.000Z'
|
|
30
30
|
};
|
|
31
|
+
const generateSeparateChunks = n => {
|
|
32
|
+
const res = [];
|
|
33
|
+
for (let i = 1; i <= n; i += 1) {
|
|
34
|
+
res.push({
|
|
35
|
+
chunkId: i,
|
|
36
|
+
content: `chunk #${i}`,
|
|
37
|
+
role: MESSAGE_MODEL_ROLES.assistant,
|
|
38
|
+
requestId: '987',
|
|
39
|
+
errors: [],
|
|
40
|
+
timestamp: '2021-04-21T12:00:00.000Z'
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return res;
|
|
44
|
+
};
|
|
31
45
|
const MOCK_RESPONSE_MESSAGE_FOR_STREAMING = {
|
|
32
46
|
id: '123',
|
|
33
47
|
content: `To change your password in GitLab:
|
|
@@ -51,7 +65,6 @@ const MOCK_RESPONSE_MESSAGE_FOR_STREAMING = {
|
|
|
51
65
|
~~~
|
|
52
66
|
which is rendered while streaming.
|
|
53
67
|
`,
|
|
54
|
-
contentHtml: '',
|
|
55
68
|
role: 'assistant',
|
|
56
69
|
extras: {},
|
|
57
70
|
requestId: '987',
|
|
@@ -81,7 +94,7 @@ function generateMockResponseChunks() {
|
|
|
81
94
|
...MOCK_RESPONSE_MESSAGE_FOR_STREAMING,
|
|
82
95
|
requestId,
|
|
83
96
|
content: MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content.substring(start, end),
|
|
84
|
-
chunkId
|
|
97
|
+
chunkId: chunkId + 1
|
|
85
98
|
};
|
|
86
99
|
|
|
87
100
|
// eslint-disable-next-line no-await-in-loop
|
|
@@ -101,8 +114,9 @@ function generateMockResponseChunks() {
|
|
|
101
114
|
}
|
|
102
115
|
const MOCK_USER_PROMPT_MESSAGE = {
|
|
103
116
|
id: '456',
|
|
117
|
+
chunkId: null,
|
|
104
118
|
content: 'How to create a new template?',
|
|
105
|
-
contentHtml: '',
|
|
119
|
+
contentHtml: '<p>How to create a new template?</p>',
|
|
106
120
|
role: MESSAGE_MODEL_ROLES.user,
|
|
107
121
|
requestId: '987',
|
|
108
122
|
errors: [],
|
|
@@ -135,4 +149,4 @@ const SLASH_COMMANDS = [{
|
|
|
135
149
|
description: 'Explain the selected snippet.'
|
|
136
150
|
}];
|
|
137
151
|
|
|
138
|
-
export { MOCK_RESPONSE_MESSAGE, MOCK_RESPONSE_MESSAGE_FOR_STREAMING, MOCK_USER_PROMPT_MESSAGE, SLASH_COMMANDS, generateMockResponseChunks, renderGFM, renderMarkdown };
|
|
152
|
+
export { MOCK_RESPONSE_MESSAGE, MOCK_RESPONSE_MESSAGE_FOR_STREAMING, MOCK_USER_PROMPT_MESSAGE, SLASH_COMMANDS, generateMockResponseChunks, generateSeparateChunks, renderGFM, renderMarkdown };
|
|
@@ -17,6 +17,7 @@ const i18n = {
|
|
|
17
17
|
IMPROVEMENT_SUGGESTION_PLACEHOLDER: 'How the response might better meet your needs.',
|
|
18
18
|
MORE_LABEL: 'More information',
|
|
19
19
|
MORE_PLACEHOLDER: 'How could the content be improved?',
|
|
20
|
+
REQUIRED_VALIDATION_ERROR: 'Select at least one option.',
|
|
20
21
|
FEEDBACK_OPTIONS: {
|
|
21
22
|
helpful: 'Helpful',
|
|
22
23
|
unhelpful: 'Unhelpful or irrelevant',
|
|
@@ -70,23 +71,34 @@ var script = {
|
|
|
70
71
|
data() {
|
|
71
72
|
return {
|
|
72
73
|
selectedFeedbackOptions: [],
|
|
73
|
-
extendedFeedback: ''
|
|
74
|
+
extendedFeedback: '',
|
|
75
|
+
isValid: null
|
|
74
76
|
};
|
|
75
77
|
},
|
|
78
|
+
watch: {
|
|
79
|
+
selectedFeedbackOptions(options) {
|
|
80
|
+
this.isValid = options.length > 0;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
76
83
|
methods: {
|
|
84
|
+
close() {
|
|
85
|
+
this.$refs.feedbackModal.hide();
|
|
86
|
+
},
|
|
77
87
|
show() {
|
|
78
88
|
this.$refs.feedbackModal.show();
|
|
79
89
|
},
|
|
80
|
-
onFeedbackSubmit() {
|
|
90
|
+
onFeedbackSubmit(e) {
|
|
81
91
|
if (this.selectedFeedbackOptions.length) {
|
|
82
92
|
this.$emit('feedback-submitted', {
|
|
83
93
|
feedbackChoices: this.selectedFeedbackOptions,
|
|
84
94
|
extendedTextFeedback: this.extendedFeedback
|
|
85
95
|
});
|
|
96
|
+
this.close();
|
|
97
|
+
this.isValid = null;
|
|
98
|
+
} else {
|
|
99
|
+
e === null || e === void 0 ? void 0 : e.preventDefault();
|
|
100
|
+
this.isValid = false;
|
|
86
101
|
}
|
|
87
|
-
},
|
|
88
|
-
onFeedbackCanceled() {
|
|
89
|
-
this.$refs.feedbackModal.hide();
|
|
90
102
|
}
|
|
91
103
|
},
|
|
92
104
|
actions: {
|
|
@@ -105,7 +117,7 @@ var script = {
|
|
|
105
117
|
const __vue_script__ = script;
|
|
106
118
|
|
|
107
119
|
/* template */
|
|
108
|
-
var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('gl-modal',{ref:"feedbackModal",attrs:{"modal-id":"feedbackModal","title":_vm.modalTitle,"action-primary":_vm.$options.actions.primary,"action-cancel":_vm.$options.actions.cancel,"visible":false,"size":"sm"},on:{"primary":_vm.onFeedbackSubmit,"canceled":_vm.
|
|
120
|
+
var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('gl-modal',{ref:"feedbackModal",attrs:{"modal-id":"feedbackModal","title":_vm.modalTitle,"action-primary":_vm.$options.actions.primary,"action-cancel":_vm.$options.actions.cancel,"visible":false,"size":"sm"},on:{"primary":_vm.onFeedbackSubmit,"canceled":_vm.close}},[_c('p',[_vm._v(_vm._s(_vm.$options.i18n.MODAL.DESCRIPTION))]),_vm._v(" "),_c('gl-form-group',{attrs:{"invalid-feedback":_vm.$options.i18n.MODAL.REQUIRED_VALIDATION_ERROR,"state":_vm.isValid,"label":_vm.$options.i18n.MODAL.OPTIONS_LABEL,"data-testid":"feedback-options"}},[_c('gl-form-checkbox-group',{attrs:{"options":_vm.$options.feedbackOptions},model:{value:(_vm.selectedFeedbackOptions),callback:function ($$v) {_vm.selectedFeedbackOptions=$$v;},expression:"selectedFeedbackOptions"}})],1),_vm._v(" "),_c('gl-alert',{staticClass:"gl-mb-5",attrs:{"dismissible":false}},[_vm._v(_vm._s(_vm.modalAlert))]),_vm._v(" "),_vm._t("feedback-extra-fields",function(){return [_c('gl-form-group',{attrs:{"label":_vm.$options.i18n.MODAL.MORE_LABEL,"optional":""}},[_c('gl-form-textarea',{attrs:{"placeholder":_vm.$options.i18n.MODAL.MORE_PLACEHOLDER},model:{value:(_vm.extendedFeedback),callback:function ($$v) {_vm.extendedFeedback=$$v;},expression:"extendedFeedback"}})],1)]})],2)};
|
|
109
121
|
var __vue_staticRenderFns__ = [];
|
|
110
122
|
|
|
111
123
|
/* style */
|
package/dist/tokens/js/tokens.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gitlab/ui",
|
|
3
|
-
"version": "78.
|
|
3
|
+
"version": "78.10.0",
|
|
4
4
|
"description": "GitLab UI Components",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"@gitlab/eslint-plugin": "19.5.0",
|
|
103
103
|
"@gitlab/fonts": "^1.3.0",
|
|
104
104
|
"@gitlab/stylelint-config": "6.1.0",
|
|
105
|
-
"@gitlab/svgs": "3.
|
|
105
|
+
"@gitlab/svgs": "3.95.0",
|
|
106
106
|
"@rollup/plugin-commonjs": "^11.1.0",
|
|
107
107
|
"@rollup/plugin-node-resolve": "^7.1.3",
|
|
108
108
|
"@rollup/plugin-replace": "^2.3.2",
|
package/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.spec.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { nextTick } from 'vue';
|
|
2
2
|
import { shallowMount } from '@vue/test-utils';
|
|
3
3
|
import GlDuoUserFeedback from '../../../user_feedback/user_feedback.vue';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
MOCK_USER_PROMPT_MESSAGE,
|
|
6
|
+
MOCK_RESPONSE_MESSAGE,
|
|
7
|
+
generateSeparateChunks,
|
|
8
|
+
} from '../../mock_data';
|
|
5
9
|
import DocumentationSources from '../duo_chat_message_sources/duo_chat_message_sources.vue';
|
|
6
10
|
import GlDuoChatMessage from './duo_chat_message.vue';
|
|
7
11
|
|
|
@@ -35,7 +39,7 @@ describe('DuoChatMessage', () => {
|
|
|
35
39
|
};
|
|
36
40
|
|
|
37
41
|
beforeEach(() => {
|
|
38
|
-
renderMarkdown = jest.fn().mockImplementation((val) =>
|
|
42
|
+
renderMarkdown = jest.fn().mockImplementation((val) => val);
|
|
39
43
|
renderGFM = jest.fn();
|
|
40
44
|
});
|
|
41
45
|
|
|
@@ -49,18 +53,26 @@ describe('DuoChatMessage', () => {
|
|
|
49
53
|
expect(customElements.get('copy-code')).toBeDefined();
|
|
50
54
|
});
|
|
51
55
|
|
|
52
|
-
describe('rendering
|
|
56
|
+
describe('rendering', () => {
|
|
53
57
|
beforeEach(() => {
|
|
54
58
|
renderMarkdown.mockImplementation(() => mockMarkdownContent);
|
|
55
|
-
createComponent();
|
|
56
59
|
});
|
|
57
60
|
|
|
58
|
-
it('
|
|
59
|
-
|
|
61
|
+
it('renders html content of the message by default', () => {
|
|
62
|
+
createComponent();
|
|
63
|
+
expect(renderMarkdown).not.toHaveBeenCalled();
|
|
64
|
+
expect(wrapper.html()).toContain(MOCK_USER_PROMPT_MESSAGE.contentHtml);
|
|
60
65
|
});
|
|
61
66
|
|
|
62
|
-
it('
|
|
63
|
-
|
|
67
|
+
it('converts the message `content` to Markdown if there is no contentHtml', () => {
|
|
68
|
+
createComponent({
|
|
69
|
+
message: {
|
|
70
|
+
...MOCK_USER_PROMPT_MESSAGE,
|
|
71
|
+
contentHtml: undefined,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
expect(renderMarkdown).toHaveBeenCalledWith(MOCK_USER_PROMPT_MESSAGE.content);
|
|
75
|
+
expect(findContent().text()).toBe(mockMarkdownContent);
|
|
64
76
|
});
|
|
65
77
|
|
|
66
78
|
it('does not render the documentation sources component', () => {
|
|
@@ -135,8 +147,6 @@ describe('DuoChatMessage', () => {
|
|
|
135
147
|
...MOCK_USER_PROMPT_MESSAGE,
|
|
136
148
|
errors,
|
|
137
149
|
contentHtml: 'fooHtml barHtml',
|
|
138
|
-
content: 'foo bar',
|
|
139
|
-
chunks: ['a', 'b', 'c'],
|
|
140
150
|
},
|
|
141
151
|
});
|
|
142
152
|
|
|
@@ -155,7 +165,6 @@ describe('DuoChatMessage', () => {
|
|
|
155
165
|
errors: [],
|
|
156
166
|
contentHtml: 'fooHtml barHtml',
|
|
157
167
|
content: 'foo bar',
|
|
158
|
-
chunks: ['a', 'b', 'c'],
|
|
159
168
|
},
|
|
160
169
|
});
|
|
161
170
|
|
|
@@ -169,47 +178,14 @@ describe('DuoChatMessage', () => {
|
|
|
169
178
|
message: {
|
|
170
179
|
...MOCK_USER_PROMPT_MESSAGE,
|
|
171
180
|
errors: [],
|
|
172
|
-
contentHtml:
|
|
181
|
+
contentHtml: undefined,
|
|
173
182
|
content: 'foo bar',
|
|
174
|
-
chunks: ['a', 'b', 'c'],
|
|
175
183
|
},
|
|
176
184
|
});
|
|
177
185
|
|
|
178
186
|
await nextTick();
|
|
179
187
|
|
|
180
|
-
expect(findContent().text()).toContain('
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('outputs chunks if there is no content', async () => {
|
|
184
|
-
createComponent({
|
|
185
|
-
message: {
|
|
186
|
-
...MOCK_USER_PROMPT_MESSAGE,
|
|
187
|
-
errors: [],
|
|
188
|
-
contentHtml: '',
|
|
189
|
-
content: '',
|
|
190
|
-
chunks: ['a', 'b', 'c'],
|
|
191
|
-
},
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
await nextTick();
|
|
195
|
-
|
|
196
|
-
expect(findContent().text()).toContain('markdown: abc');
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('outputs chunks until first undefined', async () => {
|
|
200
|
-
createComponent({
|
|
201
|
-
message: {
|
|
202
|
-
...MOCK_USER_PROMPT_MESSAGE,
|
|
203
|
-
errors: [],
|
|
204
|
-
contentHtml: '',
|
|
205
|
-
content: '',
|
|
206
|
-
chunks: ['a', undefined, 'c'],
|
|
207
|
-
},
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
await nextTick();
|
|
211
|
-
|
|
212
|
-
expect(findContent().text()).toContain('markdown: a');
|
|
188
|
+
expect(findContent().text()).toContain('foo bar');
|
|
213
189
|
});
|
|
214
190
|
|
|
215
191
|
it('hydrates the message with GFM when mounting with contentHtml', async () => {
|
|
@@ -231,44 +207,33 @@ describe('DuoChatMessage', () => {
|
|
|
231
207
|
},
|
|
232
208
|
});
|
|
233
209
|
|
|
234
|
-
wrapper.setProps({
|
|
210
|
+
await wrapper.setProps({
|
|
235
211
|
message: {
|
|
236
212
|
...MOCK_USER_PROMPT_MESSAGE,
|
|
237
213
|
contentHtml: 'foo bar',
|
|
238
214
|
},
|
|
239
215
|
});
|
|
240
|
-
|
|
241
|
-
await nextTick();
|
|
242
216
|
expect(renderGFM).toHaveBeenCalled();
|
|
243
217
|
});
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
describe('default renderers', () => {
|
|
247
|
-
it('outputs errors if they are present', async () => {
|
|
248
|
-
const errors = ['error1', 'error2', 'error3'];
|
|
249
218
|
|
|
219
|
+
it('sanitizes html produced by errors', async () => {
|
|
250
220
|
createComponent({
|
|
251
221
|
options: {
|
|
252
222
|
provide: null,
|
|
253
223
|
},
|
|
254
224
|
message: {
|
|
255
225
|
...MOCK_USER_PROMPT_MESSAGE,
|
|
256
|
-
errors,
|
|
257
|
-
contentHtml:
|
|
258
|
-
content: '
|
|
259
|
-
chunks: ['a', 'b', 'c'],
|
|
226
|
+
errors: ['[click here](javascript:prompt(1))'],
|
|
227
|
+
contentHtml: undefined,
|
|
228
|
+
content: '',
|
|
260
229
|
},
|
|
261
230
|
});
|
|
262
231
|
|
|
263
232
|
await nextTick();
|
|
264
|
-
|
|
265
|
-
const contentText = findContent().text();
|
|
266
|
-
expect(contentText).toContain(errors[0]);
|
|
267
|
-
expect(contentText).toContain(errors[1]);
|
|
268
|
-
expect(contentText).toContain(errors[2]);
|
|
233
|
+
expect(findContent().html()).toContain('<p><a>click here</a></p>');
|
|
269
234
|
});
|
|
270
235
|
|
|
271
|
-
it('
|
|
236
|
+
it('sanitizes html produced by content', async () => {
|
|
272
237
|
createComponent({
|
|
273
238
|
options: {
|
|
274
239
|
provide: null,
|
|
@@ -276,20 +241,17 @@ describe('DuoChatMessage', () => {
|
|
|
276
241
|
message: {
|
|
277
242
|
...MOCK_USER_PROMPT_MESSAGE,
|
|
278
243
|
errors: [],
|
|
279
|
-
contentHtml:
|
|
280
|
-
content: '
|
|
281
|
-
chunks: ['a', 'b', 'c'],
|
|
244
|
+
contentHtml: undefined,
|
|
245
|
+
content: '[click here](javascript:prompt(1))',
|
|
282
246
|
},
|
|
283
247
|
});
|
|
284
248
|
|
|
285
249
|
await nextTick();
|
|
286
250
|
|
|
287
|
-
expect(findContent().html()).
|
|
288
|
-
'<div class="gl-markdown gl-compact-markdown">fooHtml barHtml</div>'
|
|
289
|
-
);
|
|
251
|
+
expect(findContent().html()).toContain('<p><a>click here</a></p>');
|
|
290
252
|
});
|
|
291
253
|
|
|
292
|
-
it('
|
|
254
|
+
it('deprecated: sanitizes html produced by chunks', async () => {
|
|
293
255
|
createComponent({
|
|
294
256
|
options: {
|
|
295
257
|
provide: null,
|
|
@@ -297,18 +259,18 @@ describe('DuoChatMessage', () => {
|
|
|
297
259
|
message: {
|
|
298
260
|
...MOCK_USER_PROMPT_MESSAGE,
|
|
299
261
|
errors: [],
|
|
300
|
-
contentHtml:
|
|
301
|
-
content: '
|
|
302
|
-
chunks: ['
|
|
262
|
+
contentHtml: undefined,
|
|
263
|
+
content: '',
|
|
264
|
+
chunks: ['[click here]', '(javascript:prompt(1))'],
|
|
303
265
|
},
|
|
304
266
|
});
|
|
305
267
|
|
|
306
268
|
await nextTick();
|
|
307
269
|
|
|
308
|
-
expect(findContent().html()).
|
|
270
|
+
expect(findContent().html()).toContain('<p><a>click here</a></p>');
|
|
309
271
|
});
|
|
310
272
|
|
|
311
|
-
it('
|
|
273
|
+
it('sanitizes contentHtml', async () => {
|
|
312
274
|
createComponent({
|
|
313
275
|
options: {
|
|
314
276
|
provide: null,
|
|
@@ -316,92 +278,250 @@ describe('DuoChatMessage', () => {
|
|
|
316
278
|
message: {
|
|
317
279
|
...MOCK_USER_PROMPT_MESSAGE,
|
|
318
280
|
errors: [],
|
|
319
|
-
contentHtml:
|
|
281
|
+
contentHtml: `<a href="javascript:prompt(1)">click here</a>`,
|
|
320
282
|
content: '',
|
|
321
|
-
chunks: [
|
|
283
|
+
chunks: [],
|
|
322
284
|
},
|
|
323
285
|
});
|
|
324
286
|
|
|
325
287
|
await nextTick();
|
|
326
288
|
|
|
327
|
-
expect(findContent().html()).toBe(
|
|
289
|
+
expect(findContent().html()).toBe(
|
|
290
|
+
'<div class="gl-markdown gl-compact-markdown"><a>click here</a></div>'
|
|
291
|
+
);
|
|
328
292
|
});
|
|
329
293
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
},
|
|
335
|
-
message: {
|
|
336
|
-
...MOCK_USER_PROMPT_MESSAGE,
|
|
337
|
-
errors: ['[click here](javascript:prompt(1))'],
|
|
338
|
-
contentHtml: '',
|
|
339
|
-
content: '',
|
|
340
|
-
chunks: [],
|
|
341
|
-
},
|
|
294
|
+
describe('message updates watcher', () => {
|
|
295
|
+
const newContent = 'new foo content';
|
|
296
|
+
beforeEach(() => {
|
|
297
|
+
createComponent();
|
|
342
298
|
});
|
|
343
299
|
|
|
344
|
-
|
|
300
|
+
it('listens to the message changes', async () => {
|
|
301
|
+
expect(findContent().text()).toContain(MOCK_USER_PROMPT_MESSAGE.content);
|
|
302
|
+
// setProps is justified here because we are testing the component's
|
|
303
|
+
// reactive behavior which consistutes an exception
|
|
304
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
305
|
+
await wrapper.setProps({
|
|
306
|
+
message: {
|
|
307
|
+
...MOCK_USER_PROMPT_MESSAGE,
|
|
308
|
+
contentHtml: `<p>${newContent}</p>`,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
expect(findContent().text()).not.toContain(MOCK_USER_PROMPT_MESSAGE.content);
|
|
312
|
+
expect(findContent().text()).toContain(newContent);
|
|
313
|
+
});
|
|
345
314
|
|
|
346
|
-
|
|
347
|
-
|
|
315
|
+
it('prioritises the output of contentHtml over content', async () => {
|
|
316
|
+
// setProps is justified here because we are testing the component's
|
|
317
|
+
// reactive behavior which consistutes an exception
|
|
318
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
319
|
+
await wrapper.setProps({
|
|
320
|
+
message: {
|
|
321
|
+
...MOCK_USER_PROMPT_MESSAGE,
|
|
322
|
+
contentHtml: `<p>${MOCK_USER_PROMPT_MESSAGE.content}</p>`,
|
|
323
|
+
content: newContent,
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
expect(findContent().text()).not.toContain(newContent);
|
|
327
|
+
expect(findContent().text()).toContain(MOCK_USER_PROMPT_MESSAGE.content);
|
|
328
|
+
});
|
|
348
329
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
330
|
+
it('outputs errors if message has no content', async () => {
|
|
331
|
+
// setProps is justified here because we are testing the component's
|
|
332
|
+
// reactive behavior which consistutes an exception
|
|
333
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
334
|
+
await wrapper.setProps({
|
|
335
|
+
message: {
|
|
336
|
+
...MOCK_USER_PROMPT_MESSAGE,
|
|
337
|
+
contentHtml: '',
|
|
338
|
+
content: '',
|
|
339
|
+
errors: ['error'],
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
expect(findContent().text()).not.toContain(newContent);
|
|
343
|
+
expect(findContent().text()).not.toContain(MOCK_USER_PROMPT_MESSAGE.content);
|
|
344
|
+
expect(findContent().text()).toContain('error');
|
|
361
345
|
});
|
|
362
346
|
|
|
363
|
-
|
|
347
|
+
it('merges all the errors for output', async () => {
|
|
348
|
+
const errors = ['foo', 'bar', 'baz'];
|
|
349
|
+
// setProps is justified here because we are testing the component's
|
|
350
|
+
// reactive behavior which consistutes an exception
|
|
351
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
352
|
+
await wrapper.setProps({
|
|
353
|
+
message: {
|
|
354
|
+
...MOCK_USER_PROMPT_MESSAGE,
|
|
355
|
+
contentHtml: '',
|
|
356
|
+
content: '',
|
|
357
|
+
errors,
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
expect(findContent().text()).toContain(errors[0]);
|
|
361
|
+
expect(findContent().text()).toContain(errors[1]);
|
|
362
|
+
expect(findContent().text()).toContain(errors[2]);
|
|
363
|
+
});
|
|
364
364
|
|
|
365
|
-
|
|
365
|
+
it('hydrates the output message with GLFM if its not a chunk', async () => {
|
|
366
|
+
// setProps is justified here because we are testing the component's
|
|
367
|
+
// reactive behavior which consistutes an exception
|
|
368
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
369
|
+
await wrapper.setProps({
|
|
370
|
+
message: {
|
|
371
|
+
...MOCK_USER_PROMPT_MESSAGE,
|
|
372
|
+
contentHtml: `<p>${newContent}</p>`,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
expect(renderGFM).toHaveBeenCalled();
|
|
376
|
+
});
|
|
366
377
|
});
|
|
367
378
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
message:
|
|
374
|
-
...MOCK_USER_PROMPT_MESSAGE,
|
|
375
|
-
errors: [],
|
|
376
|
-
contentHtml: '',
|
|
377
|
-
content: '',
|
|
378
|
-
chunks: ['[click here]', '(javascript:prompt(1))'],
|
|
379
|
-
},
|
|
379
|
+
describe('updates to the message', () => {
|
|
380
|
+
const [CHUNK1, CHUNK2, CHUNK3] = generateSeparateChunks(3);
|
|
381
|
+
const consolidatedContent = CHUNK1.content + CHUNK2.content;
|
|
382
|
+
|
|
383
|
+
beforeEach(() => {
|
|
384
|
+
createComponent({ message: MOCK_RESPONSE_MESSAGE });
|
|
380
385
|
});
|
|
381
386
|
|
|
382
|
-
|
|
387
|
+
it('does not handle message updates with chunks for the user messages', async () => {
|
|
388
|
+
createComponent({ message: MOCK_USER_PROMPT_MESSAGE });
|
|
389
|
+
expect(findContent().text()).toContain(MOCK_USER_PROMPT_MESSAGE.content);
|
|
390
|
+
// setProps is justified here because we are testing the component's
|
|
391
|
+
// reactive behavior which consistutes an exception
|
|
392
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
393
|
+
await wrapper.setProps({
|
|
394
|
+
message: {
|
|
395
|
+
...MOCK_USER_PROMPT_MESSAGE,
|
|
396
|
+
content: 'foo bar',
|
|
397
|
+
chunkId: 1,
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
expect(findContent().text()).toContain(MOCK_USER_PROMPT_MESSAGE.content);
|
|
401
|
+
});
|
|
383
402
|
|
|
384
|
-
|
|
385
|
-
|
|
403
|
+
it('does not fail if the message has no chunkId', async () => {
|
|
404
|
+
// setProps is justified here because we are testing the component's
|
|
405
|
+
// reactive behavior which consistutes an exception
|
|
406
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
407
|
+
await wrapper.setProps({
|
|
408
|
+
message: {
|
|
409
|
+
...CHUNK1,
|
|
410
|
+
chunkId: null,
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
expect(findContent().text()).toContain(CHUNK1.content);
|
|
414
|
+
});
|
|
386
415
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
416
|
+
it('renders chunks correctly when the chunks arrive out of order', async () => {
|
|
417
|
+
expect(CHUNK1.content).toBe('chunk #1');
|
|
418
|
+
expect(CHUNK2.content).toBe('chunk #2');
|
|
419
|
+
expect(CHUNK3.content).toBe('chunk #3');
|
|
420
|
+
// setProps is justified here because we are testing the component's
|
|
421
|
+
// reactive behavior which consistutes an exception
|
|
422
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
423
|
+
await wrapper.setProps({
|
|
424
|
+
message: CHUNK2,
|
|
425
|
+
});
|
|
426
|
+
expect(findContent().text()).toBe('');
|
|
427
|
+
|
|
428
|
+
await wrapper.setProps({
|
|
429
|
+
message: CHUNK1,
|
|
430
|
+
});
|
|
431
|
+
expect(findContent().text()).toBe(CHUNK1.content + CHUNK2.content);
|
|
432
|
+
|
|
433
|
+
await wrapper.setProps({
|
|
434
|
+
message: CHUNK3,
|
|
435
|
+
});
|
|
436
|
+
expect(findContent().text()).toBe(CHUNK1.content + CHUNK2.content + CHUNK3.content);
|
|
399
437
|
});
|
|
400
438
|
|
|
401
|
-
|
|
439
|
+
it('renders the chunks as they arrive when they arrive in the correct order', async () => {
|
|
440
|
+
// setProps is justified here because we are testing the component's
|
|
441
|
+
// reactive behavior which consistutes an exception
|
|
442
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
443
|
+
await wrapper.setProps({
|
|
444
|
+
message: CHUNK1,
|
|
445
|
+
});
|
|
446
|
+
expect(findContent().text()).toBe(CHUNK1.content);
|
|
402
447
|
|
|
403
|
-
|
|
404
|
-
|
|
448
|
+
await wrapper.setProps({
|
|
449
|
+
message: CHUNK2,
|
|
450
|
+
});
|
|
451
|
+
expect(findContent().text()).toBe(consolidatedContent);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('treats the initial message content as chunk if message has chunkId', async () => {
|
|
455
|
+
createComponent({
|
|
456
|
+
message: CHUNK1,
|
|
457
|
+
});
|
|
458
|
+
await nextTick();
|
|
459
|
+
expect(findContent().text()).toBe(CHUNK1.content);
|
|
460
|
+
|
|
461
|
+
// setProps is justified here because we are testing the component's
|
|
462
|
+
// reactive behavior which consistutes an exception
|
|
463
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
464
|
+
await wrapper.setProps({
|
|
465
|
+
message: CHUNK2,
|
|
466
|
+
});
|
|
467
|
+
expect(findContent().text()).toBe(consolidatedContent);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('does not hydrate the chunk messages with GLFM', async () => {
|
|
471
|
+
createComponent({
|
|
472
|
+
propsData: {
|
|
473
|
+
message: CHUNK1,
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
await nextTick();
|
|
477
|
+
renderGFM.mockClear();
|
|
478
|
+
expect(renderGFM).not.toHaveBeenCalled();
|
|
479
|
+
|
|
480
|
+
// setProps is justified here because we are testing the component's
|
|
481
|
+
// reactive behavior which consistutes an exception
|
|
482
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
483
|
+
await wrapper.setProps({
|
|
484
|
+
message: CHUNK2,
|
|
485
|
+
});
|
|
486
|
+
expect(renderGFM).not.toHaveBeenCalled();
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it.each`
|
|
490
|
+
content | contentHtml | errors | expectedContent
|
|
491
|
+
${'alpha'} | ${'beta'} | ${['foo', 'bar']} | ${'foo; bar'}
|
|
492
|
+
${'alpha'} | ${'beta'} | ${[]} | ${'beta'}
|
|
493
|
+
${'alpha'} | ${undefined} | ${['foo', 'bar']} | ${'foo; bar'}
|
|
494
|
+
${'alpha'} | ${undefined} | ${[]} | ${'alpha'}
|
|
495
|
+
${''} | ${'beta'} | ${['foo', 'bar']} | ${'foo; bar'}
|
|
496
|
+
${''} | ${'beta'} | ${[]} | ${'beta'}
|
|
497
|
+
${''} | ${undefined} | ${['foo', 'bar']} | ${'foo; bar'}
|
|
498
|
+
`(
|
|
499
|
+
'outputs "$expectedContent" and hydrates this content when content is "$content", contentHtml is "$contentHtml" and errors is "$errors" with "chunkId: null"',
|
|
500
|
+
async ({ content, contentHtml, errors, expectedContent } = {}) => {
|
|
501
|
+
createComponent({
|
|
502
|
+
propsData: {
|
|
503
|
+
message: CHUNK1,
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
await nextTick();
|
|
507
|
+
renderGFM.mockClear();
|
|
508
|
+
expect(renderGFM).not.toHaveBeenCalled();
|
|
509
|
+
|
|
510
|
+
// setProps is justified here because we are testing the component's
|
|
511
|
+
// reactive behavior which consistutes an exception
|
|
512
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
513
|
+
await wrapper.setProps({
|
|
514
|
+
message: {
|
|
515
|
+
...CHUNK2,
|
|
516
|
+
chunkId: null,
|
|
517
|
+
content,
|
|
518
|
+
contentHtml,
|
|
519
|
+
errors,
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
expect(renderGFM).toHaveBeenCalled();
|
|
523
|
+
expect(findContent().text()).toBe(expectedContent);
|
|
524
|
+
}
|
|
405
525
|
);
|
|
406
526
|
});
|
|
407
527
|
});
|
package/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.vue
CHANGED
|
@@ -8,6 +8,7 @@ import DocumentationSources from '../duo_chat_message_sources/duo_chat_message_s
|
|
|
8
8
|
// eslint-disable-next-line no-restricted-imports
|
|
9
9
|
import { renderDuoChatMarkdownPreview } from '../../markdown_renderer';
|
|
10
10
|
import { CopyCodeElement } from './copy_code_element';
|
|
11
|
+
import { concatUntilEmpty } from './utils';
|
|
11
12
|
|
|
12
13
|
export const i18n = {
|
|
13
14
|
MODAL: {
|
|
@@ -21,16 +22,6 @@ export const i18n = {
|
|
|
21
22
|
},
|
|
22
23
|
};
|
|
23
24
|
|
|
24
|
-
const concatUntilEmpty = (arr) => {
|
|
25
|
-
if (!arr) return '';
|
|
26
|
-
|
|
27
|
-
let end = arr.findIndex((el) => !el);
|
|
28
|
-
|
|
29
|
-
if (end < 0) end = arr.length;
|
|
30
|
-
|
|
31
|
-
return arr.slice(0, end).join('');
|
|
32
|
-
};
|
|
33
|
-
|
|
34
25
|
export default {
|
|
35
26
|
name: 'GlDuoChatMessage',
|
|
36
27
|
safeHtmlConfigExtension: {
|
|
@@ -80,9 +71,14 @@ export default {
|
|
|
80
71
|
return {
|
|
81
72
|
didWhat: '',
|
|
82
73
|
improveWhat: '',
|
|
74
|
+
messageWatcher: null, // imperatively set up watcher on message
|
|
75
|
+
messageChunks: [],
|
|
83
76
|
};
|
|
84
77
|
},
|
|
85
78
|
computed: {
|
|
79
|
+
isChunk() {
|
|
80
|
+
return typeof this.message.chunkId === 'number';
|
|
81
|
+
},
|
|
86
82
|
isAssistantMessage() {
|
|
87
83
|
return this.message.role.toLowerCase() === MESSAGE_MODEL_ROLES.assistant;
|
|
88
84
|
},
|
|
@@ -95,7 +91,7 @@ export default {
|
|
|
95
91
|
hasFeedback() {
|
|
96
92
|
return this.message.extras?.hasFeedback;
|
|
97
93
|
},
|
|
98
|
-
|
|
94
|
+
defaultContent() {
|
|
99
95
|
if (this.message.errors.length > 0)
|
|
100
96
|
return this.renderMarkdown(this.message.errors.join('; '));
|
|
101
97
|
|
|
@@ -103,7 +99,13 @@ export default {
|
|
|
103
99
|
return this.message.contentHtml;
|
|
104
100
|
}
|
|
105
101
|
|
|
106
|
-
return this.renderMarkdown(this.message.content
|
|
102
|
+
return this.renderMarkdown(this.message.content);
|
|
103
|
+
},
|
|
104
|
+
messageContent() {
|
|
105
|
+
if (this.isAssistantMessage && this.isChunk) {
|
|
106
|
+
return this.renderMarkdown(concatUntilEmpty(this.messageChunks));
|
|
107
|
+
}
|
|
108
|
+
return this.defaultContent || this.renderMarkdown(concatUntilEmpty(this.message.chunks));
|
|
107
109
|
},
|
|
108
110
|
},
|
|
109
111
|
beforeCreate() {
|
|
@@ -112,15 +114,36 @@ export default {
|
|
|
112
114
|
}
|
|
113
115
|
},
|
|
114
116
|
mounted() {
|
|
115
|
-
|
|
117
|
+
if (this.isAssistantMessage) {
|
|
118
|
+
// The watcher has to be created imperatively here
|
|
119
|
+
// to give an opportunity to remove it after
|
|
120
|
+
// the complete message has arrived
|
|
121
|
+
this.messageWatcher = this.$watch('message', this.manageMessageUpdate);
|
|
122
|
+
}
|
|
123
|
+
this.setChunks();
|
|
124
|
+
this.hydrateContentWithGFM();
|
|
116
125
|
},
|
|
117
126
|
updated() {
|
|
118
|
-
this
|
|
127
|
+
this.hydrateContentWithGFM();
|
|
119
128
|
},
|
|
120
129
|
methods: {
|
|
121
|
-
|
|
122
|
-
if (this.
|
|
123
|
-
this.
|
|
130
|
+
setChunks() {
|
|
131
|
+
if (this.isChunk) {
|
|
132
|
+
const { chunkId, content } = this.message;
|
|
133
|
+
this.$set(this.messageChunks, chunkId - 1, content);
|
|
134
|
+
} else {
|
|
135
|
+
this.messageChunks = [];
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
stopWatchingMessage() {
|
|
139
|
+
if (this.messageWatcher) {
|
|
140
|
+
this.messageWatcher(); // Stop watching the message prop
|
|
141
|
+
this.messageWatcher = null; // Ensure the watcher can't be stopped multiple times
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
hydrateContentWithGFM() {
|
|
145
|
+
if (!this.isChunk) {
|
|
146
|
+
this.$nextTick(this.renderGFM(this.$refs.content));
|
|
124
147
|
}
|
|
125
148
|
},
|
|
126
149
|
logEvent(e) {
|
|
@@ -131,6 +154,12 @@ export default {
|
|
|
131
154
|
message: this.message,
|
|
132
155
|
});
|
|
133
156
|
},
|
|
157
|
+
manageMessageUpdate() {
|
|
158
|
+
this.setChunks();
|
|
159
|
+
if (!this.isChunk) {
|
|
160
|
+
this.stopWatchingMessage();
|
|
161
|
+
}
|
|
162
|
+
},
|
|
134
163
|
},
|
|
135
164
|
i18n,
|
|
136
165
|
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { concatUntilEmpty } from './utils';
|
|
2
|
+
|
|
3
|
+
describe('concatUntilEmpty utility', () => {
|
|
4
|
+
it('returns empty string if input array is falsy', () => {
|
|
5
|
+
expect(concatUntilEmpty()).toEqual('');
|
|
6
|
+
expect(concatUntilEmpty(null)).toEqual('');
|
|
7
|
+
expect(concatUntilEmpty(undefined)).toEqual('');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('concatenates array elements until first falsy element', () => {
|
|
11
|
+
const arr = ['a', 'b', undefined, 'c'];
|
|
12
|
+
expect(concatUntilEmpty(arr)).toEqual('ab');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('concatenates all array elements if none are falsy', () => {
|
|
16
|
+
const arr = ['a', 'b', 'c'];
|
|
17
|
+
expect(concatUntilEmpty(arr)).toEqual('abc');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns empty string if first element is falsy', () => {
|
|
21
|
+
const arr = [undefined, 'b', 'c'];
|
|
22
|
+
expect(concatUntilEmpty(arr)).toEqual('');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -357,6 +357,7 @@ describe('GlDuoChat', () => {
|
|
|
357
357
|
{ ...MOCK_RESPONSE_MESSAGE, content: undefined, chunks: [''] },
|
|
358
358
|
],
|
|
359
359
|
],
|
|
360
|
+
[[{ ...MOCK_RESPONSE_MESSAGE, chunkId: 1 }]],
|
|
360
361
|
])('prevents submission when streaming (messages = "%o")', (msgs = []) => {
|
|
361
362
|
createComponent({
|
|
362
363
|
propsData: { isChatAvailable: true, messages: msgs },
|
|
@@ -125,45 +125,28 @@ export const Interactive = (args, { argTypes }) => ({
|
|
|
125
125
|
async mockResponseFromAi() {
|
|
126
126
|
const generator = generateMockResponseChunks(this.requestId);
|
|
127
127
|
|
|
128
|
-
for await (const
|
|
129
|
-
const { chunkId, content, ...messageAttributes } = result;
|
|
128
|
+
for await (const newResponse of generator) {
|
|
130
129
|
const existingMessageIndex = this.msgs.findIndex(
|
|
131
|
-
(msg) => msg.requestId ===
|
|
130
|
+
(msg) => msg.requestId === newResponse.requestId && msg.role === newResponse.role
|
|
132
131
|
);
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
this.
|
|
132
|
+
const existingMessage = this.msgs[existingMessageIndex];
|
|
133
|
+
if (existingMessage) {
|
|
134
|
+
this.updateExistingMessage(newResponse, existingMessageIndex);
|
|
136
135
|
} else {
|
|
137
|
-
this.
|
|
136
|
+
this.addNewMessage(newResponse);
|
|
138
137
|
}
|
|
139
138
|
}
|
|
140
139
|
},
|
|
141
|
-
addNewMessage(
|
|
140
|
+
addNewMessage(msg) {
|
|
142
141
|
this.promptInFlight = false;
|
|
143
|
-
this.$set(this.msgs, this.msgs.length,
|
|
144
|
-
...messageAttributes,
|
|
145
|
-
chunks: [content],
|
|
146
|
-
});
|
|
142
|
+
this.$set(this.msgs, this.msgs.length, msg);
|
|
147
143
|
},
|
|
148
|
-
updateExistingMessage(
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
this.$set(message, 'chunks', []);
|
|
155
|
-
} else {
|
|
156
|
-
this.$set(message.chunks, chunkId, content);
|
|
157
|
-
}
|
|
158
|
-
} else {
|
|
159
|
-
// Update for final message
|
|
160
|
-
this.$set(message, 'content', content);
|
|
161
|
-
|
|
162
|
-
// Remove chunks if they are not needed anymore
|
|
163
|
-
if (message.chunks) {
|
|
164
|
-
this.$delete(message, 'chunks');
|
|
165
|
-
}
|
|
166
|
-
}
|
|
144
|
+
updateExistingMessage(newResponse, existingMessageIndex) {
|
|
145
|
+
const existingMessage = this.msgs[existingMessageIndex];
|
|
146
|
+
this.$set(this.msgs, existingMessageIndex, {
|
|
147
|
+
...existingMessage,
|
|
148
|
+
...newResponse,
|
|
149
|
+
});
|
|
167
150
|
},
|
|
168
151
|
},
|
|
169
152
|
template: `
|
|
@@ -225,7 +225,10 @@ export default {
|
|
|
225
225
|
return this.isLoading || this.isStreaming;
|
|
226
226
|
},
|
|
227
227
|
isStreaming() {
|
|
228
|
-
return Boolean(
|
|
228
|
+
return Boolean(
|
|
229
|
+
(this.lastMessage?.chunks?.length > 0 && !this.lastMessage?.content) ||
|
|
230
|
+
typeof this.lastMessage?.chunkId === 'number'
|
|
231
|
+
);
|
|
229
232
|
},
|
|
230
233
|
filteredSlashCommands() {
|
|
231
234
|
const caseInsensitivePrompt = this.prompt.toLowerCase();
|
|
@@ -41,6 +41,21 @@ export const MOCK_RESPONSE_MESSAGE = {
|
|
|
41
41
|
timestamp: '2021-04-21T12:00:00.000Z',
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
export const generateSeparateChunks = (n) => {
|
|
45
|
+
const res = [];
|
|
46
|
+
for (let i = 1; i <= n; i += 1) {
|
|
47
|
+
res.push({
|
|
48
|
+
chunkId: i,
|
|
49
|
+
content: `chunk #${i}`,
|
|
50
|
+
role: MESSAGE_MODEL_ROLES.assistant,
|
|
51
|
+
requestId: '987',
|
|
52
|
+
errors: [],
|
|
53
|
+
timestamp: '2021-04-21T12:00:00.000Z',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return res;
|
|
57
|
+
};
|
|
58
|
+
|
|
44
59
|
export const MOCK_RESPONSE_MESSAGE_FOR_STREAMING = {
|
|
45
60
|
id: '123',
|
|
46
61
|
content: `To change your password in GitLab:
|
|
@@ -64,7 +79,6 @@ export const MOCK_RESPONSE_MESSAGE_FOR_STREAMING = {
|
|
|
64
79
|
~~~
|
|
65
80
|
which is rendered while streaming.
|
|
66
81
|
`,
|
|
67
|
-
contentHtml: '',
|
|
68
82
|
role: 'assistant',
|
|
69
83
|
extras: {},
|
|
70
84
|
requestId: '987',
|
|
@@ -91,7 +105,7 @@ export async function* generateMockResponseChunks(requestId = 1) {
|
|
|
91
105
|
...MOCK_RESPONSE_MESSAGE_FOR_STREAMING,
|
|
92
106
|
requestId,
|
|
93
107
|
content: MOCK_RESPONSE_MESSAGE_FOR_STREAMING.content.substring(start, end),
|
|
94
|
-
chunkId,
|
|
108
|
+
chunkId: chunkId + 1,
|
|
95
109
|
};
|
|
96
110
|
|
|
97
111
|
// eslint-disable-next-line no-await-in-loop
|
|
@@ -108,8 +122,9 @@ export async function* generateMockResponseChunks(requestId = 1) {
|
|
|
108
122
|
|
|
109
123
|
export const MOCK_USER_PROMPT_MESSAGE = {
|
|
110
124
|
id: '456',
|
|
125
|
+
chunkId: null,
|
|
111
126
|
content: 'How to create a new template?',
|
|
112
|
-
contentHtml: '',
|
|
127
|
+
contentHtml: '<p>How to create a new template?</p>',
|
|
113
128
|
role: MESSAGE_MODEL_ROLES.user,
|
|
114
129
|
requestId: '987',
|
|
115
130
|
errors: [],
|
|
@@ -74,9 +74,7 @@ export const Slots = (args, { argTypes }) => ({
|
|
|
74
74
|
@feedback="logEvent">
|
|
75
75
|
<template #feedback-extra-fields>
|
|
76
76
|
<div class="gl-mb-5">
|
|
77
|
-
|
|
78
|
-
GitLab team members can not see your conversation. Please be as descriptive as possible.
|
|
79
|
-
</gl-alert>
|
|
77
|
+
Example slot content: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
|
80
78
|
</div>
|
|
81
79
|
<gl-form-group label="What were you doing?" optional>
|
|
82
80
|
<gl-form-textarea placeholder="The situation in which you interacted with GitLab Duo Chat." v-model="didWhat" />
|
|
@@ -12,10 +12,12 @@ const DummyComponent = {
|
|
|
12
12
|
|
|
13
13
|
describe('FeedbackModal', () => {
|
|
14
14
|
let wrapper;
|
|
15
|
+
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
|
|
15
16
|
const findModal = () => wrapper.findComponent(GlModal);
|
|
16
|
-
const findOptions = () =>
|
|
17
|
+
const findOptions = () => findByTestId('feedback-options');
|
|
17
18
|
const findOptionsCheckboxes = () => findOptions().findAllComponents(GlFormCheckbox);
|
|
18
19
|
const findTextarea = () => wrapper.findComponent(GlFormTextarea);
|
|
20
|
+
|
|
19
21
|
const selectOption = (index = 0) => {
|
|
20
22
|
wrapper
|
|
21
23
|
.findAllComponents(GlFormCheckboxGroup)
|
|
@@ -31,6 +33,9 @@ describe('FeedbackModal', () => {
|
|
|
31
33
|
},
|
|
32
34
|
provide: options.injections,
|
|
33
35
|
});
|
|
36
|
+
|
|
37
|
+
wrapper.vm.close = jest.fn();
|
|
38
|
+
return wrapper;
|
|
34
39
|
};
|
|
35
40
|
|
|
36
41
|
describe('inputs', () => {
|
|
@@ -56,7 +61,7 @@ describe('FeedbackModal', () => {
|
|
|
56
61
|
createComponent();
|
|
57
62
|
});
|
|
58
63
|
|
|
59
|
-
it('emits the feedback event when the submit button is clicked', () => {
|
|
64
|
+
it('emits the feedback event when the submit button is clicked and closes the modal', () => {
|
|
60
65
|
selectOption();
|
|
61
66
|
findModal().vm.$emit('primary');
|
|
62
67
|
expect(wrapper.emitted('feedback-submitted')).toEqual([
|
|
@@ -67,9 +72,20 @@ describe('FeedbackModal', () => {
|
|
|
67
72
|
},
|
|
68
73
|
],
|
|
69
74
|
]);
|
|
75
|
+
|
|
76
|
+
expect(wrapper.vm.close).toHaveBeenCalledTimes(1);
|
|
70
77
|
});
|
|
71
|
-
|
|
78
|
+
|
|
79
|
+
it('does not render validation error by default', () => {
|
|
80
|
+
expect(findOptions().vm.$attrs.state).not.toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('renders validation error when submit was triggered without selected a required option', async () => {
|
|
72
84
|
findModal().vm.$emit('primary');
|
|
85
|
+
await wrapper.vm.$nextTick();
|
|
86
|
+
|
|
87
|
+
expect(findOptions().vm.$attrs.state).toBe(false);
|
|
88
|
+
expect(findOptions().vm.$attrs['invalid-feedback']).toBe('Select at least one option.');
|
|
73
89
|
expect(wrapper.emitted('feedback-submitted')).toBeUndefined();
|
|
74
90
|
});
|
|
75
91
|
});
|
|
@@ -18,6 +18,7 @@ export const i18n = {
|
|
|
18
18
|
IMPROVEMENT_SUGGESTION_PLACEHOLDER: 'How the response might better meet your needs.',
|
|
19
19
|
MORE_LABEL: 'More information',
|
|
20
20
|
MORE_PLACEHOLDER: 'How could the content be improved?',
|
|
21
|
+
REQUIRED_VALIDATION_ERROR: 'Select at least one option.',
|
|
21
22
|
FEEDBACK_OPTIONS: {
|
|
22
23
|
helpful: 'Helpful',
|
|
23
24
|
unhelpful: 'Unhelpful or irrelevant',
|
|
@@ -81,23 +82,34 @@ export default {
|
|
|
81
82
|
return {
|
|
82
83
|
selectedFeedbackOptions: [],
|
|
83
84
|
extendedFeedback: '',
|
|
85
|
+
isValid: null,
|
|
84
86
|
};
|
|
85
87
|
},
|
|
88
|
+
watch: {
|
|
89
|
+
selectedFeedbackOptions(options) {
|
|
90
|
+
this.isValid = options.length > 0;
|
|
91
|
+
},
|
|
92
|
+
},
|
|
86
93
|
methods: {
|
|
94
|
+
close() {
|
|
95
|
+
this.$refs.feedbackModal.hide();
|
|
96
|
+
},
|
|
87
97
|
show() {
|
|
88
98
|
this.$refs.feedbackModal.show();
|
|
89
99
|
},
|
|
90
|
-
onFeedbackSubmit() {
|
|
100
|
+
onFeedbackSubmit(e) {
|
|
91
101
|
if (this.selectedFeedbackOptions.length) {
|
|
92
102
|
this.$emit('feedback-submitted', {
|
|
93
103
|
feedbackChoices: this.selectedFeedbackOptions,
|
|
94
104
|
extendedTextFeedback: this.extendedFeedback,
|
|
95
105
|
});
|
|
106
|
+
this.close();
|
|
107
|
+
this.isValid = null;
|
|
108
|
+
} else {
|
|
109
|
+
e?.preventDefault();
|
|
110
|
+
this.isValid = false;
|
|
96
111
|
}
|
|
97
112
|
},
|
|
98
|
-
onFeedbackCanceled() {
|
|
99
|
-
this.$refs.feedbackModal.hide();
|
|
100
|
-
},
|
|
101
113
|
},
|
|
102
114
|
actions: {
|
|
103
115
|
primary: {
|
|
@@ -121,12 +133,13 @@ export default {
|
|
|
121
133
|
:visible="false"
|
|
122
134
|
size="sm"
|
|
123
135
|
@primary="onFeedbackSubmit"
|
|
124
|
-
@canceled="
|
|
136
|
+
@canceled="close"
|
|
125
137
|
>
|
|
126
138
|
<p>{{ $options.i18n.MODAL.DESCRIPTION }}</p>
|
|
127
139
|
<gl-form-group
|
|
140
|
+
:invalid-feedback="$options.i18n.MODAL.REQUIRED_VALIDATION_ERROR"
|
|
141
|
+
:state="isValid"
|
|
128
142
|
:label="$options.i18n.MODAL.OPTIONS_LABEL"
|
|
129
|
-
:optional="false"
|
|
130
143
|
data-testid="feedback-options"
|
|
131
144
|
>
|
|
132
145
|
<gl-form-checkbox-group
|