@gitlab/ui 78.9.0 → 78.10.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.
@@ -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
- messageContent() {
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 || concatUntilEmpty(this.message.chunks));
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
- this.$nextTick(this.hydrateContentWithGFM);
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.$nextTick(this.hydrateContentWithGFM);
127
+ this.hydrateContentWithGFM();
119
128
  },
120
129
  methods: {
121
- async hydrateContentWithGFM() {
122
- if (this.message.contentHtml) {
123
- this.renderGFM(this.$refs.content);
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,9 @@
1
+ export const concatUntilEmpty = (arr) => {
2
+ if (!arr) return '';
3
+
4
+ let end = arr.findIndex((el) => !el);
5
+
6
+ if (end < 0) end = arr.length;
7
+
8
+ return arr.slice(0, end).join('');
9
+ };
@@ -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 result of generator) {
129
- const { chunkId, content, ...messageAttributes } = result;
128
+ for await (const newResponse of generator) {
130
129
  const existingMessageIndex = this.msgs.findIndex(
131
- (msg) => msg.requestId === result.requestId && msg.role === result.role
130
+ (msg) => msg.requestId === newResponse.requestId && msg.role === newResponse.role
132
131
  );
133
-
134
- if (existingMessageIndex === -1) {
135
- this.addNewMessage(messageAttributes, content);
132
+ const existingMessage = this.msgs[existingMessageIndex];
133
+ if (existingMessage) {
134
+ this.updateExistingMessage(newResponse, existingMessageIndex);
136
135
  } else {
137
- this.updateExistingMessage(existingMessageIndex, content, chunkId);
136
+ this.addNewMessage(newResponse);
138
137
  }
139
138
  }
140
139
  },
141
- addNewMessage(messageAttributes, content) {
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(index, content, chunkId) {
149
- const message = this.msgs[index];
150
-
151
- if (chunkId != null) {
152
- // Ensure the chunks array exists
153
- if (!message.chunks) {
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(this.lastMessage?.chunks?.length > 0 && !this.lastMessage?.content);
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: [],