jupyterlab-chat 0.24.0 → 0.24.2
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/lib/__tests__/model.spec.d.ts +1 -0
- package/lib/__tests__/model.spec.js +248 -0
- package/lib/model.d.ts +5 -2
- package/lib/model.js +45 -20
- package/package.json +2 -2
- package/src/__tests__/model.spec.ts +289 -0
- package/src/model.ts +48 -19
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
import { UUID } from '@lumino/coreutils';
|
|
6
|
+
import { Signal } from '@lumino/signaling';
|
|
7
|
+
import { LabChatModel } from '../model';
|
|
8
|
+
import { YChat } from '../ychat';
|
|
9
|
+
const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0));
|
|
10
|
+
const TEST_USER = {
|
|
11
|
+
username: 'test-user',
|
|
12
|
+
name: 'Test User',
|
|
13
|
+
display_name: 'Test',
|
|
14
|
+
initials: 'TU',
|
|
15
|
+
color: '#aabbcc'
|
|
16
|
+
};
|
|
17
|
+
const TEST_BOT = {
|
|
18
|
+
username: 'test-bot',
|
|
19
|
+
name: 'Test Bot',
|
|
20
|
+
display_name: 'Bot',
|
|
21
|
+
initials: 'BU',
|
|
22
|
+
color: '#aabbcc',
|
|
23
|
+
bot: true
|
|
24
|
+
};
|
|
25
|
+
function makeWidgetConfig() {
|
|
26
|
+
const owner = {};
|
|
27
|
+
owner.config = {};
|
|
28
|
+
owner.configChanged = new Signal(owner);
|
|
29
|
+
return owner;
|
|
30
|
+
}
|
|
31
|
+
describe('LabChatModel', () => {
|
|
32
|
+
let sharedModel;
|
|
33
|
+
let model;
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
sharedModel = YChat.create();
|
|
36
|
+
model = new LabChatModel({
|
|
37
|
+
widgetConfig: makeWidgetConfig(),
|
|
38
|
+
user: TEST_USER,
|
|
39
|
+
sharedModel
|
|
40
|
+
});
|
|
41
|
+
model.markDocumentSynced();
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
Signal.clearData(model);
|
|
45
|
+
sharedModel.dispose();
|
|
46
|
+
});
|
|
47
|
+
describe('message update from model', () => {
|
|
48
|
+
it('should set edited when user message is updated', async () => {
|
|
49
|
+
const id = model.sendMessage({
|
|
50
|
+
body: 'original',
|
|
51
|
+
sender: TEST_USER
|
|
52
|
+
});
|
|
53
|
+
await flushPromises();
|
|
54
|
+
model.updateMessage(id, {
|
|
55
|
+
type: 'msg',
|
|
56
|
+
id,
|
|
57
|
+
body: 'updated',
|
|
58
|
+
time: 1000,
|
|
59
|
+
sender: TEST_USER
|
|
60
|
+
});
|
|
61
|
+
await flushPromises();
|
|
62
|
+
expect(model.messages[0].body).toBe('updated');
|
|
63
|
+
expect(model.messages[0].edited).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
it('should not set edited when bot message is updated', async () => {
|
|
66
|
+
const id = model.sendMessage({
|
|
67
|
+
body: 'original',
|
|
68
|
+
sender: TEST_BOT
|
|
69
|
+
});
|
|
70
|
+
await flushPromises();
|
|
71
|
+
model.updateMessage(id, {
|
|
72
|
+
type: 'msg',
|
|
73
|
+
id,
|
|
74
|
+
body: 'updated',
|
|
75
|
+
time: 1000,
|
|
76
|
+
sender: TEST_BOT
|
|
77
|
+
});
|
|
78
|
+
await flushPromises();
|
|
79
|
+
expect(model.messages[0].body).toBe('updated');
|
|
80
|
+
expect(model.messages[0].edited).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('message attribute updates from shared model', () => {
|
|
84
|
+
it('should reflect a body change', async () => {
|
|
85
|
+
const id = UUID.uuid4();
|
|
86
|
+
sharedModel.addMessage({
|
|
87
|
+
type: 'msg',
|
|
88
|
+
id,
|
|
89
|
+
body: 'original',
|
|
90
|
+
time: 1000,
|
|
91
|
+
sender: TEST_USER.username
|
|
92
|
+
});
|
|
93
|
+
await flushPromises();
|
|
94
|
+
sharedModel.updateMessage(0, {
|
|
95
|
+
type: 'msg',
|
|
96
|
+
id,
|
|
97
|
+
body: 'updated',
|
|
98
|
+
time: 1000,
|
|
99
|
+
sender: TEST_USER.username
|
|
100
|
+
});
|
|
101
|
+
await flushPromises();
|
|
102
|
+
expect(model.messages[0].body).toBe('updated');
|
|
103
|
+
});
|
|
104
|
+
it('should reflect a mime_model change', async () => {
|
|
105
|
+
const id = UUID.uuid4();
|
|
106
|
+
sharedModel.addMessage({
|
|
107
|
+
type: 'msg',
|
|
108
|
+
id,
|
|
109
|
+
body: '',
|
|
110
|
+
time: 1000,
|
|
111
|
+
sender: TEST_USER.username
|
|
112
|
+
});
|
|
113
|
+
await flushPromises();
|
|
114
|
+
const mimeModel = { data: { 'text/plain': 'streamed output' } };
|
|
115
|
+
sharedModel.updateMessage(0, {
|
|
116
|
+
type: 'msg',
|
|
117
|
+
id,
|
|
118
|
+
body: '',
|
|
119
|
+
time: 1000,
|
|
120
|
+
sender: TEST_USER.username,
|
|
121
|
+
mime_model: mimeModel
|
|
122
|
+
});
|
|
123
|
+
await flushPromises();
|
|
124
|
+
expect(model.messages[0].mime_model).toEqual(mimeModel);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe('ready resolves after messages are loaded', () => {
|
|
128
|
+
it('messages present in sharedModel before sync are buffered, not yet in model', () => {
|
|
129
|
+
const sm = YChat.create();
|
|
130
|
+
const m = new LabChatModel({
|
|
131
|
+
widgetConfig: makeWidgetConfig(),
|
|
132
|
+
user: TEST_USER,
|
|
133
|
+
sharedModel: sm
|
|
134
|
+
});
|
|
135
|
+
sm.addMessage({
|
|
136
|
+
type: 'msg',
|
|
137
|
+
id: UUID.uuid4(),
|
|
138
|
+
body: 'pre-sync',
|
|
139
|
+
time: 1000,
|
|
140
|
+
sender: TEST_USER.username
|
|
141
|
+
});
|
|
142
|
+
// Not yet synced: message must be buffered, not inserted.
|
|
143
|
+
expect(m.messages).toHaveLength(0);
|
|
144
|
+
sm.dispose();
|
|
145
|
+
});
|
|
146
|
+
it('messages are in model.messages when ready resolves', async () => {
|
|
147
|
+
const sm = YChat.create();
|
|
148
|
+
const m = new LabChatModel({
|
|
149
|
+
widgetConfig: makeWidgetConfig(),
|
|
150
|
+
user: TEST_USER,
|
|
151
|
+
sharedModel: sm
|
|
152
|
+
});
|
|
153
|
+
sm.addMessage({
|
|
154
|
+
type: 'msg',
|
|
155
|
+
id: UUID.uuid4(),
|
|
156
|
+
body: 'pre-sync',
|
|
157
|
+
time: 1000,
|
|
158
|
+
sender: TEST_USER.username
|
|
159
|
+
});
|
|
160
|
+
m.markDocumentSynced();
|
|
161
|
+
await m.ready;
|
|
162
|
+
expect(m.messages).toHaveLength(1);
|
|
163
|
+
expect(m.messages[0].body).toBe('pre-sync');
|
|
164
|
+
sm.dispose();
|
|
165
|
+
});
|
|
166
|
+
it('model.messages is already populated inside the ready .then() callback', async () => {
|
|
167
|
+
const sm = YChat.create();
|
|
168
|
+
const m = new LabChatModel({
|
|
169
|
+
widgetConfig: makeWidgetConfig(),
|
|
170
|
+
user: TEST_USER,
|
|
171
|
+
sharedModel: sm
|
|
172
|
+
});
|
|
173
|
+
sm.addMessage({
|
|
174
|
+
type: 'msg',
|
|
175
|
+
id: UUID.uuid4(),
|
|
176
|
+
body: 'pre-sync',
|
|
177
|
+
time: 1000,
|
|
178
|
+
sender: TEST_USER.username
|
|
179
|
+
});
|
|
180
|
+
let messagesAtReady = -1;
|
|
181
|
+
const check = m.ready.then(() => {
|
|
182
|
+
messagesAtReady = m.messages.length;
|
|
183
|
+
});
|
|
184
|
+
m.markDocumentSynced();
|
|
185
|
+
await check;
|
|
186
|
+
expect(messagesAtReady).toBe(1);
|
|
187
|
+
sm.dispose();
|
|
188
|
+
});
|
|
189
|
+
it('messages added after sync are inserted directly without buffering', async () => {
|
|
190
|
+
await model.ready;
|
|
191
|
+
sharedModel.addMessage({
|
|
192
|
+
type: 'msg',
|
|
193
|
+
id: UUID.uuid4(),
|
|
194
|
+
body: 'post-sync',
|
|
195
|
+
time: 1000,
|
|
196
|
+
sender: TEST_USER.username
|
|
197
|
+
});
|
|
198
|
+
expect(model.messages).toHaveLength(1);
|
|
199
|
+
expect(model.messages[0].body).toBe('post-sync');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
describe('rerender tracking', () => {
|
|
203
|
+
it('should replace renderedDelegate when mime_model changes', async () => {
|
|
204
|
+
const id = UUID.uuid4();
|
|
205
|
+
sharedModel.addMessage({
|
|
206
|
+
type: 'msg',
|
|
207
|
+
id,
|
|
208
|
+
body: '',
|
|
209
|
+
time: 1000,
|
|
210
|
+
sender: TEST_USER.username
|
|
211
|
+
});
|
|
212
|
+
await flushPromises();
|
|
213
|
+
const delegateBefore = model.messages[0].renderedDelegate;
|
|
214
|
+
sharedModel.updateMessage(0, {
|
|
215
|
+
type: 'msg',
|
|
216
|
+
id,
|
|
217
|
+
body: '',
|
|
218
|
+
time: 1000,
|
|
219
|
+
sender: TEST_USER.username,
|
|
220
|
+
mime_model: { data: { 'text/plain': 'streamed output' } }
|
|
221
|
+
});
|
|
222
|
+
await flushPromises();
|
|
223
|
+
expect(model.messages[0].renderedDelegate).not.toBe(delegateBefore);
|
|
224
|
+
});
|
|
225
|
+
it('should not replace renderedDelegate when only edited changes', async () => {
|
|
226
|
+
const id = UUID.uuid4();
|
|
227
|
+
sharedModel.addMessage({
|
|
228
|
+
type: 'msg',
|
|
229
|
+
id,
|
|
230
|
+
body: 'hello',
|
|
231
|
+
time: 1000,
|
|
232
|
+
sender: TEST_USER.username
|
|
233
|
+
});
|
|
234
|
+
await flushPromises();
|
|
235
|
+
const delegateBefore = model.messages[0].renderedDelegate;
|
|
236
|
+
sharedModel.updateMessage(0, {
|
|
237
|
+
type: 'msg',
|
|
238
|
+
id,
|
|
239
|
+
body: 'hello',
|
|
240
|
+
time: 1000,
|
|
241
|
+
sender: TEST_USER.username,
|
|
242
|
+
edited: true
|
|
243
|
+
});
|
|
244
|
+
await flushPromises();
|
|
245
|
+
expect(model.messages[0].renderedDelegate).toBe(delegateBefore);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
package/lib/model.d.ts
CHANGED
|
@@ -63,7 +63,7 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
|
|
|
63
63
|
set dirty(value: boolean);
|
|
64
64
|
get readOnly(): boolean;
|
|
65
65
|
set readOnly(value: boolean);
|
|
66
|
-
|
|
66
|
+
protected setReady(): void;
|
|
67
67
|
/**
|
|
68
68
|
* Notify the model that its shared document has been synchronized with the
|
|
69
69
|
* server, and give it an ID if the document does not carry one yet.
|
|
@@ -85,7 +85,8 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
|
|
|
85
85
|
toJSON(): PartialJSONObject;
|
|
86
86
|
fromJSON(data: PartialJSONObject): void;
|
|
87
87
|
createChatContext(): IChatContext;
|
|
88
|
-
messagesInserted(index: number, messages: IMessageContent[]):
|
|
88
|
+
messagesInserted(index: number, messages: IMessageContent[]): void;
|
|
89
|
+
private _flushPreReadyMessages;
|
|
89
90
|
sendMessage(message: INewMessage): string | null;
|
|
90
91
|
/**
|
|
91
92
|
* Override the clear messages method.
|
|
@@ -116,6 +117,8 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
|
|
|
116
117
|
readonly defaultKernelName: string;
|
|
117
118
|
readonly defaultKernelLanguage: string;
|
|
118
119
|
private _sharedModel;
|
|
120
|
+
private _documentSynced;
|
|
121
|
+
private _preReadyMessages;
|
|
119
122
|
private _dirty;
|
|
120
123
|
private _readOnly;
|
|
121
124
|
private _contentChanged;
|
package/lib/model.js
CHANGED
|
@@ -196,7 +196,8 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
196
196
|
'raw_time',
|
|
197
197
|
'deleted',
|
|
198
198
|
'edited',
|
|
199
|
-
'metadata'
|
|
199
|
+
'metadata',
|
|
200
|
+
'mime_model'
|
|
200
201
|
].includes(key)) {
|
|
201
202
|
const update = {};
|
|
202
203
|
update[key] = value;
|
|
@@ -233,11 +234,14 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
233
234
|
if (changes.stateChange && !this._sharedModel.id) {
|
|
234
235
|
if (changes.stateChange.some(change => change.name === 'dirty' && !change.newValue)) {
|
|
235
236
|
this._sharedModel.id = UUID.uuid4();
|
|
237
|
+
this.setReady();
|
|
236
238
|
}
|
|
237
239
|
}
|
|
238
240
|
};
|
|
239
241
|
this.defaultKernelName = '';
|
|
240
242
|
this.defaultKernelLanguage = '';
|
|
243
|
+
this._documentSynced = false;
|
|
244
|
+
this._preReadyMessages = [];
|
|
241
245
|
this._dirty = false;
|
|
242
246
|
this._readOnly = false;
|
|
243
247
|
this._contentChanged = new Signal(this);
|
|
@@ -293,11 +297,10 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
293
297
|
set readOnly(value) {
|
|
294
298
|
this._readOnly = value;
|
|
295
299
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
300
|
+
setReady() {
|
|
301
|
+
this._documentSynced = true;
|
|
302
|
+
this._flushPreReadyMessages();
|
|
303
|
+
super.setReady();
|
|
301
304
|
}
|
|
302
305
|
/**
|
|
303
306
|
* Notify the model that its shared document has been synchronized with the
|
|
@@ -315,10 +318,13 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
315
318
|
*/
|
|
316
319
|
markDocumentSynced() {
|
|
317
320
|
if (!this._sharedModel.id) {
|
|
318
|
-
//
|
|
319
|
-
//
|
|
321
|
+
// Brand-new document: writing the id fires _onchange synchronously, which
|
|
322
|
+
// sets this.id via metadataChanges before setReady() is called.
|
|
320
323
|
this._sharedModel.id = UUID.uuid4();
|
|
321
324
|
}
|
|
325
|
+
// For an existing document, _onchange already fired for the id metadata
|
|
326
|
+
// during the initial Yjs sync and set this.id before this method was called.
|
|
327
|
+
this.setReady();
|
|
322
328
|
}
|
|
323
329
|
dispose() {
|
|
324
330
|
if (this.isDisposed) {
|
|
@@ -345,24 +351,39 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
345
351
|
createChatContext() {
|
|
346
352
|
return new LabChatContext({ model: this });
|
|
347
353
|
}
|
|
348
|
-
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
|
|
354
|
+
messagesInserted(index, messages) {
|
|
355
|
+
// Buffer messages that arrive before the document is synced. They are
|
|
356
|
+
// flushed synchronously in markDocumentSynced() so that `ready` resolves
|
|
357
|
+
// only after all initial messages and metadata are already in the model.
|
|
358
|
+
// This ensures that the chat has an ID before inserting the messages, to properly
|
|
359
|
+
// catch the unread messages (the last read message is saved using the chat ID).
|
|
360
|
+
if (!this._documentSynced) {
|
|
361
|
+
this._preReadyMessages.push({ index, messages });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
super.messagesInserted(index, messages);
|
|
365
|
+
}
|
|
366
|
+
_flushPreReadyMessages() {
|
|
367
|
+
for (const { index, messages } of this._preReadyMessages) {
|
|
352
368
|
super.messagesInserted(index, messages);
|
|
353
|
-
}
|
|
369
|
+
}
|
|
370
|
+
this._preReadyMessages = [];
|
|
354
371
|
}
|
|
355
372
|
sendMessage(message) {
|
|
356
|
-
var _a, _b, _c, _d;
|
|
357
|
-
|
|
373
|
+
var _a, _b, _c, _d, _e;
|
|
374
|
+
// Allow empty message for bot only, as it may be streamed later.
|
|
375
|
+
if (!message.body &&
|
|
376
|
+
!message.mime_model &&
|
|
377
|
+
!((_a = message.attachments) === null || _a === void 0 ? void 0 : _a.length) &&
|
|
378
|
+
!((_b = message.sender) === null || _b === void 0 ? void 0 : _b.bot)) {
|
|
358
379
|
return null;
|
|
359
380
|
}
|
|
360
381
|
this._resetWritingStatus();
|
|
361
382
|
if (this._timeoutWriting !== null) {
|
|
362
383
|
window.clearTimeout(this._timeoutWriting);
|
|
363
384
|
}
|
|
364
|
-
const body = (
|
|
365
|
-
const user = (
|
|
385
|
+
const body = (_c = message.body) !== null && _c !== void 0 ? _c : '';
|
|
386
|
+
const user = (_d = message.sender) !== null && _d !== void 0 ? _d : this._user;
|
|
366
387
|
const msg = {
|
|
367
388
|
type: 'msg',
|
|
368
389
|
id: UUID.uuid4(),
|
|
@@ -380,7 +401,7 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
380
401
|
this.sharedModel.setUser(user);
|
|
381
402
|
}
|
|
382
403
|
// Add the attachments to the message.
|
|
383
|
-
const attachmentIds = (
|
|
404
|
+
const attachmentIds = (_e = message.attachments) === null || _e === void 0 ? void 0 : _e.map(attachment => this.sharedModel.setAttachment(attachment));
|
|
384
405
|
if (attachmentIds === null || attachmentIds === void 0 ? void 0 : attachmentIds.length) {
|
|
385
406
|
msg.attachments = attachmentIds;
|
|
386
407
|
}
|
|
@@ -408,7 +429,7 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
408
429
|
let message = this.sharedModel.getMessage(index);
|
|
409
430
|
if (message) {
|
|
410
431
|
message.body = updatedMessage.body;
|
|
411
|
-
message.edited = true;
|
|
432
|
+
message.edited = updatedMessage.sender.bot ? updatedMessage.edited : true;
|
|
412
433
|
}
|
|
413
434
|
else {
|
|
414
435
|
const sender = updatedMessage.sender.username;
|
|
@@ -418,9 +439,13 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
418
439
|
body: updatedMessage.body,
|
|
419
440
|
time: updatedMessage.time || Date.now() / 1000,
|
|
420
441
|
sender: sender,
|
|
421
|
-
edited: true
|
|
442
|
+
edited: updatedMessage.sender.bot ? false : true
|
|
422
443
|
};
|
|
423
444
|
}
|
|
445
|
+
// Update the mime model.
|
|
446
|
+
if (updatedMessage.mime_model) {
|
|
447
|
+
message.mime_model = updatedMessage.mime_model;
|
|
448
|
+
}
|
|
424
449
|
// Update the attachments.
|
|
425
450
|
const attachmentIds = (_a = updatedMessage.attachments) === null || _a === void 0 ? void 0 : _a.map(attachment => this.sharedModel.setAttachment(attachment));
|
|
426
451
|
if (attachmentIds === null || attachmentIds === void 0 ? void 0 : attachmentIds.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab-chat",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.2",
|
|
4
4
|
"description": "The library to build a chat based on shared document",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"watch:src": "tsc -w --sourceMap"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@jupyter/chat": "^0.24.
|
|
45
|
+
"@jupyter/chat": "^0.24.2",
|
|
46
46
|
"@jupyter/collaborative-drive": "^4.4.0 || ^5.0.0",
|
|
47
47
|
"@jupyter/ydoc": "^3.0.0 || ^4.0.0",
|
|
48
48
|
"@jupyterlab/application": "^4.5.0",
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { UUID } from '@lumino/coreutils';
|
|
7
|
+
import { Signal } from '@lumino/signaling';
|
|
8
|
+
|
|
9
|
+
import { LabChatModel } from '../model';
|
|
10
|
+
import { IWidgetConfig } from '../token';
|
|
11
|
+
import { YChat } from '../ychat';
|
|
12
|
+
|
|
13
|
+
const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0));
|
|
14
|
+
|
|
15
|
+
const TEST_USER = {
|
|
16
|
+
username: 'test-user',
|
|
17
|
+
name: 'Test User',
|
|
18
|
+
display_name: 'Test',
|
|
19
|
+
initials: 'TU',
|
|
20
|
+
color: '#aabbcc'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const TEST_BOT = {
|
|
24
|
+
username: 'test-bot',
|
|
25
|
+
name: 'Test Bot',
|
|
26
|
+
display_name: 'Bot',
|
|
27
|
+
initials: 'BU',
|
|
28
|
+
color: '#aabbcc',
|
|
29
|
+
bot: true
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function makeWidgetConfig(): IWidgetConfig {
|
|
33
|
+
const owner = {} as IWidgetConfig;
|
|
34
|
+
owner.config = {};
|
|
35
|
+
(owner as any).configChanged = new Signal<IWidgetConfig, any>(owner);
|
|
36
|
+
return owner;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('LabChatModel', () => {
|
|
40
|
+
let sharedModel: YChat;
|
|
41
|
+
let model: LabChatModel;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
sharedModel = YChat.create();
|
|
45
|
+
model = new LabChatModel({
|
|
46
|
+
widgetConfig: makeWidgetConfig(),
|
|
47
|
+
user: TEST_USER,
|
|
48
|
+
sharedModel
|
|
49
|
+
});
|
|
50
|
+
model.markDocumentSynced();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
Signal.clearData(model);
|
|
55
|
+
sharedModel.dispose();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('message update from model', () => {
|
|
59
|
+
it('should set edited when user message is updated', async () => {
|
|
60
|
+
const id = model.sendMessage({
|
|
61
|
+
body: 'original',
|
|
62
|
+
sender: TEST_USER
|
|
63
|
+
}) as string;
|
|
64
|
+
await flushPromises();
|
|
65
|
+
|
|
66
|
+
model.updateMessage(id, {
|
|
67
|
+
type: 'msg',
|
|
68
|
+
id,
|
|
69
|
+
body: 'updated',
|
|
70
|
+
time: 1000,
|
|
71
|
+
sender: TEST_USER
|
|
72
|
+
});
|
|
73
|
+
await flushPromises();
|
|
74
|
+
|
|
75
|
+
expect(model.messages[0].body).toBe('updated');
|
|
76
|
+
expect(model.messages[0].edited).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should not set edited when bot message is updated', async () => {
|
|
80
|
+
const id = model.sendMessage({
|
|
81
|
+
body: 'original',
|
|
82
|
+
sender: TEST_BOT
|
|
83
|
+
}) as string;
|
|
84
|
+
await flushPromises();
|
|
85
|
+
|
|
86
|
+
model.updateMessage(id, {
|
|
87
|
+
type: 'msg',
|
|
88
|
+
id,
|
|
89
|
+
body: 'updated',
|
|
90
|
+
time: 1000,
|
|
91
|
+
sender: TEST_BOT
|
|
92
|
+
});
|
|
93
|
+
await flushPromises();
|
|
94
|
+
|
|
95
|
+
expect(model.messages[0].body).toBe('updated');
|
|
96
|
+
expect(model.messages[0].edited).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('message attribute updates from shared model', () => {
|
|
101
|
+
it('should reflect a body change', async () => {
|
|
102
|
+
const id = UUID.uuid4();
|
|
103
|
+
sharedModel.addMessage({
|
|
104
|
+
type: 'msg',
|
|
105
|
+
id,
|
|
106
|
+
body: 'original',
|
|
107
|
+
time: 1000,
|
|
108
|
+
sender: TEST_USER.username
|
|
109
|
+
});
|
|
110
|
+
await flushPromises();
|
|
111
|
+
|
|
112
|
+
sharedModel.updateMessage(0, {
|
|
113
|
+
type: 'msg',
|
|
114
|
+
id,
|
|
115
|
+
body: 'updated',
|
|
116
|
+
time: 1000,
|
|
117
|
+
sender: TEST_USER.username
|
|
118
|
+
});
|
|
119
|
+
await flushPromises();
|
|
120
|
+
|
|
121
|
+
expect(model.messages[0].body).toBe('updated');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should reflect a mime_model change', async () => {
|
|
125
|
+
const id = UUID.uuid4();
|
|
126
|
+
sharedModel.addMessage({
|
|
127
|
+
type: 'msg',
|
|
128
|
+
id,
|
|
129
|
+
body: '',
|
|
130
|
+
time: 1000,
|
|
131
|
+
sender: TEST_USER.username
|
|
132
|
+
});
|
|
133
|
+
await flushPromises();
|
|
134
|
+
|
|
135
|
+
const mimeModel = { data: { 'text/plain': 'streamed output' } };
|
|
136
|
+
sharedModel.updateMessage(0, {
|
|
137
|
+
type: 'msg',
|
|
138
|
+
id,
|
|
139
|
+
body: '',
|
|
140
|
+
time: 1000,
|
|
141
|
+
sender: TEST_USER.username,
|
|
142
|
+
mime_model: mimeModel
|
|
143
|
+
});
|
|
144
|
+
await flushPromises();
|
|
145
|
+
|
|
146
|
+
expect(model.messages[0].mime_model).toEqual(mimeModel);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('ready resolves after messages are loaded', () => {
|
|
151
|
+
it('messages present in sharedModel before sync are buffered, not yet in model', () => {
|
|
152
|
+
const sm = YChat.create();
|
|
153
|
+
const m = new LabChatModel({
|
|
154
|
+
widgetConfig: makeWidgetConfig(),
|
|
155
|
+
user: TEST_USER,
|
|
156
|
+
sharedModel: sm
|
|
157
|
+
});
|
|
158
|
+
sm.addMessage({
|
|
159
|
+
type: 'msg',
|
|
160
|
+
id: UUID.uuid4(),
|
|
161
|
+
body: 'pre-sync',
|
|
162
|
+
time: 1000,
|
|
163
|
+
sender: TEST_USER.username
|
|
164
|
+
});
|
|
165
|
+
// Not yet synced: message must be buffered, not inserted.
|
|
166
|
+
expect(m.messages).toHaveLength(0);
|
|
167
|
+
sm.dispose();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('messages are in model.messages when ready resolves', async () => {
|
|
171
|
+
const sm = YChat.create();
|
|
172
|
+
const m = new LabChatModel({
|
|
173
|
+
widgetConfig: makeWidgetConfig(),
|
|
174
|
+
user: TEST_USER,
|
|
175
|
+
sharedModel: sm
|
|
176
|
+
});
|
|
177
|
+
sm.addMessage({
|
|
178
|
+
type: 'msg',
|
|
179
|
+
id: UUID.uuid4(),
|
|
180
|
+
body: 'pre-sync',
|
|
181
|
+
time: 1000,
|
|
182
|
+
sender: TEST_USER.username
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
m.markDocumentSynced();
|
|
186
|
+
await m.ready;
|
|
187
|
+
|
|
188
|
+
expect(m.messages).toHaveLength(1);
|
|
189
|
+
expect(m.messages[0].body).toBe('pre-sync');
|
|
190
|
+
sm.dispose();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('model.messages is already populated inside the ready .then() callback', async () => {
|
|
194
|
+
const sm = YChat.create();
|
|
195
|
+
const m = new LabChatModel({
|
|
196
|
+
widgetConfig: makeWidgetConfig(),
|
|
197
|
+
user: TEST_USER,
|
|
198
|
+
sharedModel: sm
|
|
199
|
+
});
|
|
200
|
+
sm.addMessage({
|
|
201
|
+
type: 'msg',
|
|
202
|
+
id: UUID.uuid4(),
|
|
203
|
+
body: 'pre-sync',
|
|
204
|
+
time: 1000,
|
|
205
|
+
sender: TEST_USER.username
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
let messagesAtReady = -1;
|
|
209
|
+
const check = m.ready.then(() => {
|
|
210
|
+
messagesAtReady = m.messages.length;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
m.markDocumentSynced();
|
|
214
|
+
await check;
|
|
215
|
+
|
|
216
|
+
expect(messagesAtReady).toBe(1);
|
|
217
|
+
sm.dispose();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('messages added after sync are inserted directly without buffering', async () => {
|
|
221
|
+
await model.ready;
|
|
222
|
+
|
|
223
|
+
sharedModel.addMessage({
|
|
224
|
+
type: 'msg',
|
|
225
|
+
id: UUID.uuid4(),
|
|
226
|
+
body: 'post-sync',
|
|
227
|
+
time: 1000,
|
|
228
|
+
sender: TEST_USER.username
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(model.messages).toHaveLength(1);
|
|
232
|
+
expect(model.messages[0].body).toBe('post-sync');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('rerender tracking', () => {
|
|
237
|
+
it('should replace renderedDelegate when mime_model changes', async () => {
|
|
238
|
+
const id = UUID.uuid4();
|
|
239
|
+
sharedModel.addMessage({
|
|
240
|
+
type: 'msg',
|
|
241
|
+
id,
|
|
242
|
+
body: '',
|
|
243
|
+
time: 1000,
|
|
244
|
+
sender: TEST_USER.username
|
|
245
|
+
});
|
|
246
|
+
await flushPromises();
|
|
247
|
+
|
|
248
|
+
const delegateBefore = model.messages[0].renderedDelegate;
|
|
249
|
+
|
|
250
|
+
sharedModel.updateMessage(0, {
|
|
251
|
+
type: 'msg',
|
|
252
|
+
id,
|
|
253
|
+
body: '',
|
|
254
|
+
time: 1000,
|
|
255
|
+
sender: TEST_USER.username,
|
|
256
|
+
mime_model: { data: { 'text/plain': 'streamed output' } }
|
|
257
|
+
});
|
|
258
|
+
await flushPromises();
|
|
259
|
+
|
|
260
|
+
expect(model.messages[0].renderedDelegate).not.toBe(delegateBefore);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should not replace renderedDelegate when only edited changes', async () => {
|
|
264
|
+
const id = UUID.uuid4();
|
|
265
|
+
sharedModel.addMessage({
|
|
266
|
+
type: 'msg',
|
|
267
|
+
id,
|
|
268
|
+
body: 'hello',
|
|
269
|
+
time: 1000,
|
|
270
|
+
sender: TEST_USER.username
|
|
271
|
+
});
|
|
272
|
+
await flushPromises();
|
|
273
|
+
|
|
274
|
+
const delegateBefore = model.messages[0].renderedDelegate;
|
|
275
|
+
|
|
276
|
+
sharedModel.updateMessage(0, {
|
|
277
|
+
type: 'msg',
|
|
278
|
+
id,
|
|
279
|
+
body: 'hello',
|
|
280
|
+
time: 1000,
|
|
281
|
+
sender: TEST_USER.username,
|
|
282
|
+
edited: true
|
|
283
|
+
});
|
|
284
|
+
await flushPromises();
|
|
285
|
+
|
|
286
|
+
expect(model.messages[0].renderedDelegate).toBe(delegateBefore);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
package/src/model.ts
CHANGED
|
@@ -162,11 +162,10 @@ export class LabChatModel
|
|
|
162
162
|
this._readOnly = value;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
165
|
+
protected setReady(): void {
|
|
166
|
+
this._documentSynced = true;
|
|
167
|
+
this._flushPreReadyMessages();
|
|
168
|
+
super.setReady();
|
|
170
169
|
}
|
|
171
170
|
|
|
172
171
|
/**
|
|
@@ -185,10 +184,13 @@ export class LabChatModel
|
|
|
185
184
|
*/
|
|
186
185
|
markDocumentSynced(): void {
|
|
187
186
|
if (!this._sharedModel.id) {
|
|
188
|
-
//
|
|
189
|
-
//
|
|
187
|
+
// Brand-new document: writing the id fires _onchange synchronously, which
|
|
188
|
+
// sets this.id via metadataChanges before setReady() is called.
|
|
190
189
|
this._sharedModel.id = UUID.uuid4();
|
|
191
190
|
}
|
|
191
|
+
// For an existing document, _onchange already fired for the id metadata
|
|
192
|
+
// during the initial Yjs sync and set this.id before this method was called.
|
|
193
|
+
this.setReady();
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
dispose(): void {
|
|
@@ -222,19 +224,34 @@ export class LabChatModel
|
|
|
222
224
|
return new LabChatContext({ model: this });
|
|
223
225
|
}
|
|
224
226
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
// unread messages (the last read message is saved using the chat ID).
|
|
231
|
-
|
|
227
|
+
messagesInserted(index: number, messages: IMessageContent[]): void {
|
|
228
|
+
// Buffer messages that arrive before the document is synced. They are
|
|
229
|
+
// flushed synchronously in markDocumentSynced() so that `ready` resolves
|
|
230
|
+
// only after all initial messages and metadata are already in the model.
|
|
231
|
+
// This ensures that the chat has an ID before inserting the messages, to properly
|
|
232
|
+
// catch the unread messages (the last read message is saved using the chat ID).
|
|
233
|
+
if (!this._documentSynced) {
|
|
234
|
+
this._preReadyMessages.push({ index, messages });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
super.messagesInserted(index, messages);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private _flushPreReadyMessages(): void {
|
|
241
|
+
for (const { index, messages } of this._preReadyMessages) {
|
|
232
242
|
super.messagesInserted(index, messages);
|
|
233
|
-
}
|
|
243
|
+
}
|
|
244
|
+
this._preReadyMessages = [];
|
|
234
245
|
}
|
|
235
246
|
|
|
236
247
|
sendMessage(message: INewMessage): string | null {
|
|
237
|
-
|
|
248
|
+
// Allow empty message for bot only, as it may be streamed later.
|
|
249
|
+
if (
|
|
250
|
+
!message.body &&
|
|
251
|
+
!message.mime_model &&
|
|
252
|
+
!message.attachments?.length &&
|
|
253
|
+
!message.sender?.bot
|
|
254
|
+
) {
|
|
238
255
|
return null;
|
|
239
256
|
}
|
|
240
257
|
this._resetWritingStatus();
|
|
@@ -302,7 +319,7 @@ export class LabChatModel
|
|
|
302
319
|
let message = this.sharedModel.getMessage(index);
|
|
303
320
|
if (message) {
|
|
304
321
|
message.body = updatedMessage.body;
|
|
305
|
-
message.edited = true;
|
|
322
|
+
message.edited = updatedMessage.sender.bot ? updatedMessage.edited : true;
|
|
306
323
|
} else {
|
|
307
324
|
const sender = updatedMessage.sender.username;
|
|
308
325
|
|
|
@@ -312,10 +329,15 @@ export class LabChatModel
|
|
|
312
329
|
body: updatedMessage.body,
|
|
313
330
|
time: updatedMessage.time || Date.now() / 1000,
|
|
314
331
|
sender: sender,
|
|
315
|
-
edited: true
|
|
332
|
+
edited: updatedMessage.sender.bot ? false : true
|
|
316
333
|
};
|
|
317
334
|
}
|
|
318
335
|
|
|
336
|
+
// Update the mime model.
|
|
337
|
+
if (updatedMessage.mime_model) {
|
|
338
|
+
message.mime_model = updatedMessage.mime_model;
|
|
339
|
+
}
|
|
340
|
+
|
|
319
341
|
// Update the attachments.
|
|
320
342
|
const attachmentIds = updatedMessage.attachments?.map(attachment =>
|
|
321
343
|
this.sharedModel.setAttachment(attachment)
|
|
@@ -551,7 +573,8 @@ export class LabChatModel
|
|
|
551
573
|
'raw_time',
|
|
552
574
|
'deleted',
|
|
553
575
|
'edited',
|
|
554
|
-
'metadata'
|
|
576
|
+
'metadata',
|
|
577
|
+
'mime_model'
|
|
555
578
|
].includes(key)
|
|
556
579
|
) {
|
|
557
580
|
const update: Partial<IMessageContent> = {};
|
|
@@ -597,6 +620,7 @@ export class LabChatModel
|
|
|
597
620
|
)
|
|
598
621
|
) {
|
|
599
622
|
this._sharedModel.id = UUID.uuid4();
|
|
623
|
+
this.setReady();
|
|
600
624
|
}
|
|
601
625
|
}
|
|
602
626
|
};
|
|
@@ -606,6 +630,11 @@ export class LabChatModel
|
|
|
606
630
|
|
|
607
631
|
private _sharedModel: YChat;
|
|
608
632
|
|
|
633
|
+
private _documentSynced = false;
|
|
634
|
+
private _preReadyMessages: Array<{
|
|
635
|
+
index: number;
|
|
636
|
+
messages: IMessageContent[];
|
|
637
|
+
}> = [];
|
|
609
638
|
private _dirty = false;
|
|
610
639
|
private _readOnly = false;
|
|
611
640
|
private _contentChanged = new Signal<this, void>(this);
|