jupyterlab-chat 0.25.0-alpha.7 → 0.25.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/lib/__tests__/model.spec.js +79 -2
- package/lib/model.d.ts +25 -4
- package/lib/model.js +136 -39
- package/lib/websocket-handler.js +10 -6
- package/lib/ychat.js +6 -3
- package/package.json +3 -2
- package/src/__tests__/model.spec.ts +90 -2
- package/src/model.ts +149 -50
- package/src/websocket-handler.ts +14 -8
- package/src/ychat.ts +7 -1
|
@@ -39,8 +39,7 @@ describe('LabChatModel', () => {
|
|
|
39
39
|
sharedModel,
|
|
40
40
|
collaborative: true
|
|
41
41
|
});
|
|
42
|
-
|
|
43
|
-
model.id = UUID.uuid4();
|
|
42
|
+
model.markDocumentSynced();
|
|
44
43
|
});
|
|
45
44
|
afterEach(() => {
|
|
46
45
|
Signal.clearData(model);
|
|
@@ -126,6 +125,84 @@ describe('LabChatModel', () => {
|
|
|
126
125
|
expect(model.messages[0].mime_model).toEqual(mimeModel);
|
|
127
126
|
});
|
|
128
127
|
});
|
|
128
|
+
describe('ready resolves after messages are loaded', () => {
|
|
129
|
+
it('messages present in sharedModel before sync are buffered, not yet in model', () => {
|
|
130
|
+
const sm = YChat.create();
|
|
131
|
+
const m = new LabChatModel({
|
|
132
|
+
widgetConfig: makeWidgetConfig(),
|
|
133
|
+
user: TEST_USER,
|
|
134
|
+
sharedModel: sm,
|
|
135
|
+
collaborative: true
|
|
136
|
+
});
|
|
137
|
+
sm.addMessage({
|
|
138
|
+
type: 'msg',
|
|
139
|
+
id: UUID.uuid4(),
|
|
140
|
+
body: 'pre-sync',
|
|
141
|
+
time: 1000,
|
|
142
|
+
sender: TEST_USER.username
|
|
143
|
+
});
|
|
144
|
+
// Not yet synced: message must be buffered, not inserted.
|
|
145
|
+
expect(m.messages).toHaveLength(0);
|
|
146
|
+
sm.dispose();
|
|
147
|
+
});
|
|
148
|
+
it('messages are in model.messages when ready resolves', async () => {
|
|
149
|
+
const sm = YChat.create();
|
|
150
|
+
const m = new LabChatModel({
|
|
151
|
+
widgetConfig: makeWidgetConfig(),
|
|
152
|
+
user: TEST_USER,
|
|
153
|
+
sharedModel: sm,
|
|
154
|
+
collaborative: true
|
|
155
|
+
});
|
|
156
|
+
sm.addMessage({
|
|
157
|
+
type: 'msg',
|
|
158
|
+
id: UUID.uuid4(),
|
|
159
|
+
body: 'pre-sync',
|
|
160
|
+
time: 1000,
|
|
161
|
+
sender: TEST_USER.username
|
|
162
|
+
});
|
|
163
|
+
m.markDocumentSynced();
|
|
164
|
+
await m.ready;
|
|
165
|
+
expect(m.messages).toHaveLength(1);
|
|
166
|
+
expect(m.messages[0].body).toBe('pre-sync');
|
|
167
|
+
sm.dispose();
|
|
168
|
+
});
|
|
169
|
+
it('model.messages is already populated inside the ready .then() callback', async () => {
|
|
170
|
+
const sm = YChat.create();
|
|
171
|
+
const m = new LabChatModel({
|
|
172
|
+
widgetConfig: makeWidgetConfig(),
|
|
173
|
+
user: TEST_USER,
|
|
174
|
+
sharedModel: sm,
|
|
175
|
+
collaborative: true
|
|
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
|
+
let messagesAtReady = -1;
|
|
185
|
+
const check = m.ready.then(() => {
|
|
186
|
+
messagesAtReady = m.messages.length;
|
|
187
|
+
});
|
|
188
|
+
m.markDocumentSynced();
|
|
189
|
+
await check;
|
|
190
|
+
expect(messagesAtReady).toBe(1);
|
|
191
|
+
sm.dispose();
|
|
192
|
+
});
|
|
193
|
+
it('messages added after sync are inserted directly without buffering', async () => {
|
|
194
|
+
await model.ready;
|
|
195
|
+
sharedModel.addMessage({
|
|
196
|
+
type: 'msg',
|
|
197
|
+
id: UUID.uuid4(),
|
|
198
|
+
body: 'post-sync',
|
|
199
|
+
time: 1000,
|
|
200
|
+
sender: TEST_USER.username
|
|
201
|
+
});
|
|
202
|
+
expect(model.messages).toHaveLength(1);
|
|
203
|
+
expect(model.messages[0].body).toBe('post-sync');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
129
206
|
describe('rerender tracking', () => {
|
|
130
207
|
it('should replace renderedDelegate when mime_model changes', async () => {
|
|
131
208
|
const id = UUID.uuid4();
|
package/lib/model.d.ts
CHANGED
|
@@ -80,6 +80,7 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
|
|
|
80
80
|
set readOnly(value: boolean);
|
|
81
81
|
get id(): string | undefined;
|
|
82
82
|
set id(value: string | undefined);
|
|
83
|
+
protected setReady(id: string): void;
|
|
83
84
|
/**
|
|
84
85
|
* Notify the model that its shared document has been synchronized with the
|
|
85
86
|
* server, and give it an ID if the document does not carry one yet.
|
|
@@ -97,11 +98,12 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
|
|
|
97
98
|
markDocumentSynced(): void;
|
|
98
99
|
dispose(): void;
|
|
99
100
|
toString(): string;
|
|
100
|
-
fromString(
|
|
101
|
+
fromString(_data: string): void;
|
|
101
102
|
toJSON(): PartialJSONObject;
|
|
102
|
-
fromJSON(
|
|
103
|
+
fromJSON(_data: PartialJSONObject): void;
|
|
103
104
|
createChatContext(): IChatContext;
|
|
104
|
-
messagesInserted(index: number, messages: IMessageContent[]):
|
|
105
|
+
messagesInserted(index: number, messages: IMessageContent[]): void;
|
|
106
|
+
private _flushPreReadyMessages;
|
|
105
107
|
sendMessage(message: INewMessage): string | null;
|
|
106
108
|
/**
|
|
107
109
|
* Override the clear messages method.
|
|
@@ -136,10 +138,26 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
|
|
|
136
138
|
broadcastWritingStatus(status: IChatModel.IWritingStatus | null): void;
|
|
137
139
|
/**
|
|
138
140
|
* Handle a writing status pushed by the server (e.g. an AI agent) over the
|
|
139
|
-
* WebSocket. The sender controls
|
|
141
|
+
* WebSocket. The sender fully controls the lifecycle via explicit start/stop
|
|
142
|
+
* frames: a `state: true` frame shows the indicator and it stays visible
|
|
143
|
+
* until a `state: false` frame clears it -- no re-broadcasting is required.
|
|
140
144
|
*/
|
|
141
145
|
private _onWsWriting;
|
|
142
146
|
private _enforceAutosaveEnabled;
|
|
147
|
+
/**
|
|
148
|
+
* Triggered when there is no backend and a change occur in the shared model (e.g.
|
|
149
|
+
* Jupyterlite). It is used to save the chat content from the frontend.
|
|
150
|
+
*/
|
|
151
|
+
private _onServerLessChange;
|
|
152
|
+
/**
|
|
153
|
+
* Load the chat content when no backend is available (e.g. Jupyterlite).
|
|
154
|
+
* Should be called once when initializing the chat.
|
|
155
|
+
*/
|
|
156
|
+
private _loadContent;
|
|
157
|
+
/**
|
|
158
|
+
* Save the content to a file when no backend is available (e.g. Jupyterlite).
|
|
159
|
+
*/
|
|
160
|
+
private _saveContent;
|
|
143
161
|
private _onchange;
|
|
144
162
|
private _onWsUsersChanged;
|
|
145
163
|
private _onWsMetadata;
|
|
@@ -148,6 +166,8 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
|
|
|
148
166
|
readonly defaultKernelName: string;
|
|
149
167
|
readonly defaultKernelLanguage: string;
|
|
150
168
|
private _sharedModel;
|
|
169
|
+
private _documentSynced;
|
|
170
|
+
private _preReadyMessages;
|
|
151
171
|
private _dirty;
|
|
152
172
|
private _readOnly;
|
|
153
173
|
private _contentChanged;
|
|
@@ -155,6 +175,7 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
|
|
|
155
175
|
private _timeoutWriting;
|
|
156
176
|
private _user;
|
|
157
177
|
private _wsHandler;
|
|
178
|
+
private _saveDebouncer;
|
|
158
179
|
}
|
|
159
180
|
/**
|
|
160
181
|
* The chat context to be sent to the input model.
|
package/lib/model.js
CHANGED
|
@@ -4,19 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { AbstractChatModel, AbstractChatContext } from '@jupyter/chat';
|
|
6
6
|
import { UUID } from '@lumino/coreutils';
|
|
7
|
+
import { Debouncer } from '@lumino/polling';
|
|
7
8
|
import { Signal } from '@lumino/signaling';
|
|
8
9
|
import { enforceAutosaveEnabled } from './autosave';
|
|
9
10
|
import { WebSocketHandler } from './websocket-handler';
|
|
10
11
|
import { YChat } from './ychat';
|
|
11
12
|
const WRITING_DELAY = 1000;
|
|
12
|
-
/**
|
|
13
|
-
* How long a server-pushed (e.g. AI persona) writing status stays visible
|
|
14
|
-
* without a refresh. The sender is expected to re-broadcast while still
|
|
15
|
-
* writing and to send an explicit stop when done; this is only a safety net so
|
|
16
|
-
* a crashed or forgetful sender cannot leave a "is writing" indicator stuck
|
|
17
|
-
* forever. An explicit stop clears it immediately, regardless of this value.
|
|
18
|
-
*/
|
|
19
|
-
const WS_WRITING_TIMEOUT = 3000;
|
|
20
13
|
/**
|
|
21
14
|
* Coerce an untrusted value to an `IUser`, or `null` if it is not one.
|
|
22
15
|
* Awareness state is written by arbitrary clients, so the only field we rely on
|
|
@@ -189,7 +182,9 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
189
182
|
};
|
|
190
183
|
/**
|
|
191
184
|
* Handle a writing status pushed by the server (e.g. an AI agent) over the
|
|
192
|
-
* WebSocket. The sender controls
|
|
185
|
+
* WebSocket. The sender fully controls the lifecycle via explicit start/stop
|
|
186
|
+
* frames: a `state: true` frame shows the indicator and it stays visible
|
|
187
|
+
* until a `state: false` frame clears it -- no re-broadcasting is required.
|
|
193
188
|
*/
|
|
194
189
|
this._onWsWriting = (_, writing) => {
|
|
195
190
|
if (writing.user.username === this.user.username) {
|
|
@@ -199,7 +194,7 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
199
194
|
this.setWritingStatus(writing.user, {
|
|
200
195
|
messageID: writing.messageID,
|
|
201
196
|
typingIndicator: writing.typingIndicator
|
|
202
|
-
}
|
|
197
|
+
});
|
|
203
198
|
}
|
|
204
199
|
else {
|
|
205
200
|
this.clearWritingStatus(writing.user);
|
|
@@ -208,6 +203,14 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
208
203
|
this._enforceAutosaveEnabled = () => {
|
|
209
204
|
enforceAutosaveEnabled(this.sharedModel.awareness);
|
|
210
205
|
};
|
|
206
|
+
/**
|
|
207
|
+
* Triggered when there is no backend and a change occur in the shared model (e.g.
|
|
208
|
+
* Jupyterlite). It is used to save the chat content from the frontend.
|
|
209
|
+
*/
|
|
210
|
+
this._onServerLessChange = () => {
|
|
211
|
+
this.dirty = true;
|
|
212
|
+
void this._saveDebouncer.invoke();
|
|
213
|
+
};
|
|
211
214
|
this._onchange = async (_, changes) => {
|
|
212
215
|
if (changes.messageListChanges) {
|
|
213
216
|
const msgDelta = changes.messageListChanges;
|
|
@@ -335,7 +338,9 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
335
338
|
// Not needed in WS mode — readiness is signalled by the connection message.
|
|
336
339
|
if (changes.stateChange && !this._sharedModel.id && !this._wsHandler) {
|
|
337
340
|
if (changes.stateChange.some(change => change.name === 'dirty' && !change.newValue)) {
|
|
338
|
-
|
|
341
|
+
const id = UUID.uuid4();
|
|
342
|
+
this._sharedModel.id = id;
|
|
343
|
+
this.setReady(id);
|
|
339
344
|
}
|
|
340
345
|
}
|
|
341
346
|
};
|
|
@@ -363,12 +368,18 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
363
368
|
};
|
|
364
369
|
this.defaultKernelName = '';
|
|
365
370
|
this.defaultKernelLanguage = '';
|
|
371
|
+
this._documentSynced = false;
|
|
372
|
+
this._preReadyMessages = [];
|
|
366
373
|
this._dirty = false;
|
|
367
374
|
this._readOnly = false;
|
|
368
375
|
this._contentChanged = new Signal(this);
|
|
369
376
|
this._stateChanged = new Signal(this);
|
|
370
377
|
this._timeoutWriting = null;
|
|
378
|
+
// Web socket to use if RTC is not available
|
|
371
379
|
this._wsHandler = null;
|
|
380
|
+
// Debouncer used to save file from the frontend if RTC and web socket are not
|
|
381
|
+
// available (e.g. jupyterlite).
|
|
382
|
+
this._saveDebouncer = new Debouncer(() => this._saveContent(), 500);
|
|
372
383
|
this.collaborative = (_a = options.collaborative) !== null && _a !== void 0 ? _a : true;
|
|
373
384
|
// initialize current user
|
|
374
385
|
this._user = new LabChatUser(options.user);
|
|
@@ -416,7 +427,15 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
416
427
|
return this._dirty;
|
|
417
428
|
}
|
|
418
429
|
set dirty(value) {
|
|
430
|
+
const old = this._dirty;
|
|
419
431
|
this._dirty = value;
|
|
432
|
+
if (old !== value) {
|
|
433
|
+
this._stateChanged.emit({
|
|
434
|
+
name: 'dirty',
|
|
435
|
+
oldValue: old,
|
|
436
|
+
newValue: value
|
|
437
|
+
});
|
|
438
|
+
}
|
|
420
439
|
}
|
|
421
440
|
get readOnly() {
|
|
422
441
|
return this._readOnly;
|
|
@@ -432,9 +451,11 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
432
451
|
}
|
|
433
452
|
set id(value) {
|
|
434
453
|
super.id = value;
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
454
|
+
}
|
|
455
|
+
setReady(id) {
|
|
456
|
+
this._documentSynced = true;
|
|
457
|
+
this._flushPreReadyMessages();
|
|
458
|
+
super.setReady(id);
|
|
438
459
|
}
|
|
439
460
|
/**
|
|
440
461
|
* Notify the model that its shared document has been synchronized with the
|
|
@@ -469,15 +490,33 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
469
490
|
return;
|
|
470
491
|
}
|
|
471
492
|
this._user = new LabChatUser(wsUser);
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
493
|
+
this.id = serverId;
|
|
494
|
+
this.setReady(serverId);
|
|
475
495
|
})
|
|
476
496
|
.catch(e => {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
497
|
+
var _a;
|
|
498
|
+
(_a = this._wsHandler) === null || _a === void 0 ? void 0 : _a.dispose();
|
|
499
|
+
this._wsHandler = null;
|
|
500
|
+
if (e.wsCloseCode !== 1006) {
|
|
501
|
+
// The server was reachable but rejected the connection (e.g. invalid
|
|
502
|
+
// path): surface the error rather than silently falling back.
|
|
503
|
+
this.setError(e);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
// Server unreachable (e.g. JupyterLite): fall back to the shared model.
|
|
507
|
+
console.warn('WS chat connection failed, falling back to shared model', e);
|
|
508
|
+
// Restore content from disk before wiring the change handler, so the
|
|
509
|
+
// initial population doesn't itself trigger a save. `ready` is
|
|
510
|
+
// resolved only after the load completes (see #532).
|
|
511
|
+
void this._loadContent().then(() => {
|
|
512
|
+
if (!this._sharedModel.id) {
|
|
513
|
+
this._sharedModel.id = UUID.uuid4();
|
|
514
|
+
}
|
|
515
|
+
const id = this._sharedModel.id;
|
|
516
|
+
// Any subsequent change must be saved by the frontend (no backend).
|
|
517
|
+
this._sharedModel.changed.connect(this._onServerLessChange, this);
|
|
518
|
+
this.setReady(id);
|
|
519
|
+
});
|
|
481
520
|
});
|
|
482
521
|
return;
|
|
483
522
|
}
|
|
@@ -488,15 +527,17 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
488
527
|
// sync populates it without emitting an `_onchange` metadata delta, so
|
|
489
528
|
// nothing else would set the model id and `ready` would never resolve.
|
|
490
529
|
if (this._sharedModel.id) {
|
|
491
|
-
|
|
530
|
+
const id = this._sharedModel.id;
|
|
531
|
+
this.id = id; // no _onchange delta fires for the existing id at sync time
|
|
532
|
+
this.setReady(id);
|
|
492
533
|
}
|
|
493
534
|
else {
|
|
494
|
-
// Brand-new document
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
this.
|
|
535
|
+
// Brand-new document: writing the id to the shared model fires _onchange
|
|
536
|
+
// synchronously, which sets this.id (and thus super.id) via metadataChanges.
|
|
537
|
+
// setReady() then flushes buffered messages and resolves `ready`.
|
|
538
|
+
const id = UUID.uuid4();
|
|
539
|
+
this._sharedModel.id = id;
|
|
540
|
+
this.setReady(id);
|
|
500
541
|
}
|
|
501
542
|
}
|
|
502
543
|
dispose() {
|
|
@@ -504,6 +545,7 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
504
545
|
if (this.isDisposed) {
|
|
505
546
|
return;
|
|
506
547
|
}
|
|
548
|
+
this._saveDebouncer.dispose();
|
|
507
549
|
(_a = this._wsHandler) === null || _a === void 0 ? void 0 : _a.dispose();
|
|
508
550
|
this._wsHandler = null;
|
|
509
551
|
super.dispose();
|
|
@@ -513,26 +555,38 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
513
555
|
Signal.clearData(this);
|
|
514
556
|
}
|
|
515
557
|
toString() {
|
|
516
|
-
return JSON.stringify(
|
|
558
|
+
return JSON.stringify(this._sharedModel.getSource(), null, 2);
|
|
517
559
|
}
|
|
518
|
-
fromString(
|
|
519
|
-
|
|
560
|
+
fromString(_data) {
|
|
561
|
+
// Content is loaded on demand via _loadContent() in the serverless
|
|
562
|
+
// (JupyterLite) fallback; in RTC/WS modes the transport owns the data.
|
|
520
563
|
}
|
|
521
564
|
toJSON() {
|
|
522
565
|
return JSON.parse(this.toString());
|
|
523
566
|
}
|
|
524
|
-
fromJSON(
|
|
525
|
-
// nothing to do
|
|
567
|
+
fromJSON(_data) {
|
|
568
|
+
// nothing to do — see fromString
|
|
526
569
|
}
|
|
527
570
|
createChatContext() {
|
|
528
571
|
return new LabChatContext({ model: this });
|
|
529
572
|
}
|
|
530
|
-
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
|
|
573
|
+
messagesInserted(index, messages) {
|
|
574
|
+
// Buffer messages that arrive before the document is synced. They are
|
|
575
|
+
// flushed synchronously in markDocumentSynced() so that `ready` resolves
|
|
576
|
+
// only after all initial messages and metadata are already in the model.
|
|
577
|
+
// This ensures that the chat has an ID before inserting the messages, to properly
|
|
578
|
+
// catch the unread messages (the last read message is saved using the chat ID).
|
|
579
|
+
if (!this._documentSynced) {
|
|
580
|
+
this._preReadyMessages.push({ index, messages });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
super.messagesInserted(index, messages);
|
|
584
|
+
}
|
|
585
|
+
_flushPreReadyMessages() {
|
|
586
|
+
for (const { index, messages } of this._preReadyMessages) {
|
|
534
587
|
super.messagesInserted(index, messages);
|
|
535
|
-
}
|
|
588
|
+
}
|
|
589
|
+
this._preReadyMessages = [];
|
|
536
590
|
}
|
|
537
591
|
sendMessage(message) {
|
|
538
592
|
var _a, _b, _c, _d;
|
|
@@ -558,7 +612,10 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
558
612
|
body: (_c = message.body) !== null && _c !== void 0 ? _c : '',
|
|
559
613
|
time: Date.now() / 1000,
|
|
560
614
|
sender: (_d = message.sender) !== null && _d !== void 0 ? _d : this._user,
|
|
561
|
-
|
|
615
|
+
// Set the raw time only if there is a server to update the time to a reference
|
|
616
|
+
// one (the server time is source of truth). At that stage, without collaboration,
|
|
617
|
+
// this means that the chat is running without server (jupyterlite).
|
|
618
|
+
raw_time: this.collaborative ? true : false
|
|
562
619
|
};
|
|
563
620
|
this.sharedModel.addMessage(this._contentToYmessage(content));
|
|
564
621
|
return content.id;
|
|
@@ -621,6 +678,46 @@ export class LabChatModel extends AbstractChatModel {
|
|
|
621
678
|
awareness.setLocalStateField('typingIndicator', (_c = status.typingIndicator) !== null && _c !== void 0 ? _c : null);
|
|
622
679
|
}
|
|
623
680
|
}
|
|
681
|
+
/**
|
|
682
|
+
* Load the chat content when no backend is available (e.g. Jupyterlite).
|
|
683
|
+
* Should be called once when initializing the chat.
|
|
684
|
+
*/
|
|
685
|
+
async _loadContent() {
|
|
686
|
+
if (!this.documentManager) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
const file = await this.documentManager.services.contents.get(this.name, {
|
|
691
|
+
content: true,
|
|
692
|
+
format: 'text'
|
|
693
|
+
});
|
|
694
|
+
if (typeof file.content === 'string' && file.content) {
|
|
695
|
+
this._sharedModel.setSource(JSON.parse(file.content));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
catch (_a) {
|
|
699
|
+
// Missing or invalid file — start with an empty chat.
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Save the content to a file when no backend is available (e.g. Jupyterlite).
|
|
704
|
+
*/
|
|
705
|
+
async _saveContent() {
|
|
706
|
+
if (!this.documentManager) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
try {
|
|
710
|
+
await this.documentManager.services.contents.save(this.name, {
|
|
711
|
+
type: 'file',
|
|
712
|
+
format: 'text',
|
|
713
|
+
content: this.toString()
|
|
714
|
+
});
|
|
715
|
+
this.dirty = false;
|
|
716
|
+
}
|
|
717
|
+
catch (e) {
|
|
718
|
+
console.error('Failed to save chat file', e);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
624
721
|
_contentToYmessage(msg) {
|
|
625
722
|
var _a, _b, _c;
|
|
626
723
|
const sender = msg.sender;
|
package/lib/websocket-handler.js
CHANGED
|
@@ -258,12 +258,12 @@ export class WebSocketHandler {
|
|
|
258
258
|
if (this._disposed) {
|
|
259
259
|
return;
|
|
260
260
|
}
|
|
261
|
-
// Closed before the connection frame arrived:
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
// failure or leaving a spinner hanging forever.
|
|
261
|
+
// Closed before the connection frame arrived: signal failure so callers can
|
|
262
|
+
// distinguish between an unreachable server (code 1006, e.g. JupyterLite) and
|
|
263
|
+
// an explicit server-side rejection (any other code, e.g. 1008 for invalid path).
|
|
265
264
|
if (!this._connected) {
|
|
266
|
-
this._ready.reject(new Error(
|
|
265
|
+
this._ready.reject(Object.assign(new Error(event.reason ||
|
|
266
|
+
`Chat WebSocket was closed before opening (code ${event.code})`), { wsCloseCode: event.code }));
|
|
267
267
|
return;
|
|
268
268
|
}
|
|
269
269
|
// An established connection dropped abnormally: try to reconnect.
|
|
@@ -275,6 +275,10 @@ export class WebSocketHandler {
|
|
|
275
275
|
}, 1000);
|
|
276
276
|
}
|
|
277
277
|
};
|
|
278
|
-
this._socket.onerror = error =>
|
|
278
|
+
this._socket.onerror = error => {
|
|
279
|
+
if (this._connected) {
|
|
280
|
+
console.error('WS chat connection error:', error);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
279
283
|
}
|
|
280
284
|
}
|
package/lib/ychat.js
CHANGED
|
@@ -205,15 +205,16 @@ export class YChat extends YDocument {
|
|
|
205
205
|
getSource() {
|
|
206
206
|
const users = this._users.toJSON();
|
|
207
207
|
const messages = this._messages.toJSON();
|
|
208
|
+
const attachments = this._attachments.toJSON();
|
|
208
209
|
const metadata = this._metadata.toJSON();
|
|
209
|
-
return { users, messages, metadata };
|
|
210
|
+
return { users, messages, attachments, metadata };
|
|
210
211
|
}
|
|
211
212
|
setSource(value) {
|
|
212
213
|
if (!value) {
|
|
213
214
|
return;
|
|
214
215
|
}
|
|
215
216
|
this.transact(() => {
|
|
216
|
-
var _a, _b, _c;
|
|
217
|
+
var _a, _b, _c, _d;
|
|
217
218
|
const messages = (_a = value['messages']) !== null && _a !== void 0 ? _a : [];
|
|
218
219
|
const ymessages = [];
|
|
219
220
|
messages.forEach(message => {
|
|
@@ -226,7 +227,9 @@ export class YChat extends YDocument {
|
|
|
226
227
|
this._messages.push(ymessages);
|
|
227
228
|
const users = (_b = value['users']) !== null && _b !== void 0 ? _b : {};
|
|
228
229
|
Object.entries(users).forEach(([key, val]) => this._users.set(key, val));
|
|
229
|
-
const
|
|
230
|
+
const attachments = (_c = value['attachments']) !== null && _c !== void 0 ? _c : {};
|
|
231
|
+
Object.entries(attachments).forEach(([key, val]) => this._attachments.set(key, val));
|
|
232
|
+
const metadata = (_d = value['metadata']) !== null && _d !== void 0 ? _d : {};
|
|
230
233
|
Object.entries(metadata).forEach(([key, val]) => this._metadata.set(key, val));
|
|
231
234
|
});
|
|
232
235
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab-chat",
|
|
3
|
-
"version": "0.25.0
|
|
3
|
+
"version": "0.25.0",
|
|
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.25.0
|
|
45
|
+
"@jupyter/chat": "^0.25.0",
|
|
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",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"@jupyterlab/ui-components": "^4.5.0",
|
|
60
60
|
"@lumino/commands": "^2.3.3",
|
|
61
61
|
"@lumino/coreutils": "^2.2.2",
|
|
62
|
+
"@lumino/polling": "^2.1.5",
|
|
62
63
|
"@lumino/signaling": "^2.1.5",
|
|
63
64
|
"@lumino/widgets": "^2.8.0",
|
|
64
65
|
"react": "^18.2.0",
|
|
@@ -48,8 +48,7 @@ describe('LabChatModel', () => {
|
|
|
48
48
|
sharedModel,
|
|
49
49
|
collaborative: true
|
|
50
50
|
});
|
|
51
|
-
|
|
52
|
-
model.id = UUID.uuid4();
|
|
51
|
+
model.markDocumentSynced();
|
|
53
52
|
});
|
|
54
53
|
|
|
55
54
|
afterEach(() => {
|
|
@@ -149,6 +148,95 @@ describe('LabChatModel', () => {
|
|
|
149
148
|
});
|
|
150
149
|
});
|
|
151
150
|
|
|
151
|
+
describe('ready resolves after messages are loaded', () => {
|
|
152
|
+
it('messages present in sharedModel before sync are buffered, not yet in model', () => {
|
|
153
|
+
const sm = YChat.create();
|
|
154
|
+
const m = new LabChatModel({
|
|
155
|
+
widgetConfig: makeWidgetConfig(),
|
|
156
|
+
user: TEST_USER,
|
|
157
|
+
sharedModel: sm,
|
|
158
|
+
collaborative: true
|
|
159
|
+
});
|
|
160
|
+
sm.addMessage({
|
|
161
|
+
type: 'msg',
|
|
162
|
+
id: UUID.uuid4(),
|
|
163
|
+
body: 'pre-sync',
|
|
164
|
+
time: 1000,
|
|
165
|
+
sender: TEST_USER.username
|
|
166
|
+
});
|
|
167
|
+
// Not yet synced: message must be buffered, not inserted.
|
|
168
|
+
expect(m.messages).toHaveLength(0);
|
|
169
|
+
sm.dispose();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('messages are in model.messages when ready resolves', async () => {
|
|
173
|
+
const sm = YChat.create();
|
|
174
|
+
const m = new LabChatModel({
|
|
175
|
+
widgetConfig: makeWidgetConfig(),
|
|
176
|
+
user: TEST_USER,
|
|
177
|
+
sharedModel: sm,
|
|
178
|
+
collaborative: true
|
|
179
|
+
});
|
|
180
|
+
sm.addMessage({
|
|
181
|
+
type: 'msg',
|
|
182
|
+
id: UUID.uuid4(),
|
|
183
|
+
body: 'pre-sync',
|
|
184
|
+
time: 1000,
|
|
185
|
+
sender: TEST_USER.username
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
m.markDocumentSynced();
|
|
189
|
+
await m.ready;
|
|
190
|
+
|
|
191
|
+
expect(m.messages).toHaveLength(1);
|
|
192
|
+
expect(m.messages[0].body).toBe('pre-sync');
|
|
193
|
+
sm.dispose();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('model.messages is already populated inside the ready .then() callback', async () => {
|
|
197
|
+
const sm = YChat.create();
|
|
198
|
+
const m = new LabChatModel({
|
|
199
|
+
widgetConfig: makeWidgetConfig(),
|
|
200
|
+
user: TEST_USER,
|
|
201
|
+
sharedModel: sm,
|
|
202
|
+
collaborative: true
|
|
203
|
+
});
|
|
204
|
+
sm.addMessage({
|
|
205
|
+
type: 'msg',
|
|
206
|
+
id: UUID.uuid4(),
|
|
207
|
+
body: 'pre-sync',
|
|
208
|
+
time: 1000,
|
|
209
|
+
sender: TEST_USER.username
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
let messagesAtReady = -1;
|
|
213
|
+
const check = m.ready.then(() => {
|
|
214
|
+
messagesAtReady = m.messages.length;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
m.markDocumentSynced();
|
|
218
|
+
await check;
|
|
219
|
+
|
|
220
|
+
expect(messagesAtReady).toBe(1);
|
|
221
|
+
sm.dispose();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('messages added after sync are inserted directly without buffering', async () => {
|
|
225
|
+
await model.ready;
|
|
226
|
+
|
|
227
|
+
sharedModel.addMessage({
|
|
228
|
+
type: 'msg',
|
|
229
|
+
id: UUID.uuid4(),
|
|
230
|
+
body: 'post-sync',
|
|
231
|
+
time: 1000,
|
|
232
|
+
sender: TEST_USER.username
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(model.messages).toHaveLength(1);
|
|
236
|
+
expect(model.messages[0].body).toBe('post-sync');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
152
240
|
describe('rerender tracking', () => {
|
|
153
241
|
it('should replace renderedDelegate when mime_model changes', async () => {
|
|
154
242
|
const id = UUID.uuid4();
|
package/src/model.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { IChangedArgs } from '@jupyterlab/coreutils';
|
|
|
19
19
|
import { DocumentRegistry } from '@jupyterlab/docregistry';
|
|
20
20
|
import { ServerConnection, User } from '@jupyterlab/services';
|
|
21
21
|
import { PartialJSONObject, UUID } from '@lumino/coreutils';
|
|
22
|
+
import { Debouncer } from '@lumino/polling';
|
|
22
23
|
import { ISignal, Signal } from '@lumino/signaling';
|
|
23
24
|
|
|
24
25
|
import { enforceAutosaveEnabled } from './autosave';
|
|
@@ -28,15 +29,6 @@ import { IChatChanges, IYmessage, YChat } from './ychat';
|
|
|
28
29
|
|
|
29
30
|
const WRITING_DELAY = 1000;
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
-
* How long a server-pushed (e.g. AI persona) writing status stays visible
|
|
33
|
-
* without a refresh. The sender is expected to re-broadcast while still
|
|
34
|
-
* writing and to send an explicit stop when done; this is only a safety net so
|
|
35
|
-
* a crashed or forgetful sender cannot leave a "is writing" indicator stuck
|
|
36
|
-
* forever. An explicit stop clears it immediately, regardless of this value.
|
|
37
|
-
*/
|
|
38
|
-
const WS_WRITING_TIMEOUT = 3000;
|
|
39
|
-
|
|
40
32
|
/**
|
|
41
33
|
* Coerce an untrusted value to an `IUser`, or `null` if it is not one.
|
|
42
34
|
* Awareness state is written by arbitrary clients, so the only field we rely on
|
|
@@ -268,7 +260,15 @@ export class LabChatModel
|
|
|
268
260
|
return this._dirty;
|
|
269
261
|
}
|
|
270
262
|
set dirty(value: boolean) {
|
|
263
|
+
const old = this._dirty;
|
|
271
264
|
this._dirty = value;
|
|
265
|
+
if (old !== value) {
|
|
266
|
+
this._stateChanged.emit({
|
|
267
|
+
name: 'dirty',
|
|
268
|
+
oldValue: old,
|
|
269
|
+
newValue: value
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
get readOnly(): boolean {
|
|
@@ -286,9 +286,12 @@ export class LabChatModel
|
|
|
286
286
|
}
|
|
287
287
|
set id(value: string | undefined) {
|
|
288
288
|
super.id = value;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
protected setReady(id: string): void {
|
|
292
|
+
this._documentSynced = true;
|
|
293
|
+
this._flushPreReadyMessages();
|
|
294
|
+
super.setReady(id);
|
|
292
295
|
}
|
|
293
296
|
|
|
294
297
|
/**
|
|
@@ -326,15 +329,35 @@ export class LabChatModel
|
|
|
326
329
|
return;
|
|
327
330
|
}
|
|
328
331
|
this._user = new LabChatUser(wsUser);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
+
this.id = serverId;
|
|
333
|
+
this.setReady(serverId);
|
|
332
334
|
})
|
|
333
335
|
.catch(e => {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
336
|
+
this._wsHandler?.dispose();
|
|
337
|
+
this._wsHandler = null;
|
|
338
|
+
if ((e as { wsCloseCode?: number }).wsCloseCode !== 1006) {
|
|
339
|
+
// The server was reachable but rejected the connection (e.g. invalid
|
|
340
|
+
// path): surface the error rather than silently falling back.
|
|
341
|
+
this.setError(e);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// Server unreachable (e.g. JupyterLite): fall back to the shared model.
|
|
345
|
+
console.warn(
|
|
346
|
+
'WS chat connection failed, falling back to shared model',
|
|
347
|
+
e
|
|
348
|
+
);
|
|
349
|
+
// Restore content from disk before wiring the change handler, so the
|
|
350
|
+
// initial population doesn't itself trigger a save. `ready` is
|
|
351
|
+
// resolved only after the load completes (see #532).
|
|
352
|
+
void this._loadContent().then(() => {
|
|
353
|
+
if (!this._sharedModel.id) {
|
|
354
|
+
this._sharedModel.id = UUID.uuid4();
|
|
355
|
+
}
|
|
356
|
+
const id = this._sharedModel.id;
|
|
357
|
+
// Any subsequent change must be saved by the frontend (no backend).
|
|
358
|
+
this._sharedModel.changed.connect(this._onServerLessChange, this);
|
|
359
|
+
this.setReady(id);
|
|
360
|
+
});
|
|
338
361
|
});
|
|
339
362
|
return;
|
|
340
363
|
}
|
|
@@ -345,14 +368,16 @@ export class LabChatModel
|
|
|
345
368
|
// sync populates it without emitting an `_onchange` metadata delta, so
|
|
346
369
|
// nothing else would set the model id and `ready` would never resolve.
|
|
347
370
|
if (this._sharedModel.id) {
|
|
348
|
-
|
|
371
|
+
const id = this._sharedModel.id;
|
|
372
|
+
this.id = id; // no _onchange delta fires for the existing id at sync time
|
|
373
|
+
this.setReady(id);
|
|
349
374
|
} else {
|
|
350
|
-
// Brand-new document
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
this.
|
|
375
|
+
// Brand-new document: writing the id to the shared model fires _onchange
|
|
376
|
+
// synchronously, which sets this.id (and thus super.id) via metadataChanges.
|
|
377
|
+
// setReady() then flushes buffered messages and resolves `ready`.
|
|
378
|
+
const id = UUID.uuid4();
|
|
379
|
+
this._sharedModel.id = id;
|
|
380
|
+
this.setReady(id);
|
|
356
381
|
}
|
|
357
382
|
}
|
|
358
383
|
|
|
@@ -360,6 +385,7 @@ export class LabChatModel
|
|
|
360
385
|
if (this.isDisposed) {
|
|
361
386
|
return;
|
|
362
387
|
}
|
|
388
|
+
this._saveDebouncer.dispose();
|
|
363
389
|
this._wsHandler?.dispose();
|
|
364
390
|
this._wsHandler = null;
|
|
365
391
|
super.dispose();
|
|
@@ -370,34 +396,44 @@ export class LabChatModel
|
|
|
370
396
|
}
|
|
371
397
|
|
|
372
398
|
toString(): string {
|
|
373
|
-
return JSON.stringify(
|
|
399
|
+
return JSON.stringify(this._sharedModel.getSource(), null, 2);
|
|
374
400
|
}
|
|
375
401
|
|
|
376
|
-
fromString(
|
|
377
|
-
|
|
402
|
+
fromString(_data: string): void {
|
|
403
|
+
// Content is loaded on demand via _loadContent() in the serverless
|
|
404
|
+
// (JupyterLite) fallback; in RTC/WS modes the transport owns the data.
|
|
378
405
|
}
|
|
379
406
|
|
|
380
407
|
toJSON(): PartialJSONObject {
|
|
381
408
|
return JSON.parse(this.toString());
|
|
382
409
|
}
|
|
383
410
|
|
|
384
|
-
fromJSON(
|
|
385
|
-
// nothing to do
|
|
411
|
+
fromJSON(_data: PartialJSONObject): void {
|
|
412
|
+
// nothing to do — see fromString
|
|
386
413
|
}
|
|
387
414
|
|
|
388
415
|
createChatContext(): IChatContext {
|
|
389
416
|
return new LabChatContext({ model: this });
|
|
390
417
|
}
|
|
391
418
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
//
|
|
397
|
-
// unread messages (the last read message is saved using the chat ID).
|
|
398
|
-
|
|
419
|
+
messagesInserted(index: number, messages: IMessageContent[]): void {
|
|
420
|
+
// Buffer messages that arrive before the document is synced. They are
|
|
421
|
+
// flushed synchronously in markDocumentSynced() so that `ready` resolves
|
|
422
|
+
// only after all initial messages and metadata are already in the model.
|
|
423
|
+
// This ensures that the chat has an ID before inserting the messages, to properly
|
|
424
|
+
// catch the unread messages (the last read message is saved using the chat ID).
|
|
425
|
+
if (!this._documentSynced) {
|
|
426
|
+
this._preReadyMessages.push({ index, messages });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
super.messagesInserted(index, messages);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private _flushPreReadyMessages(): void {
|
|
433
|
+
for (const { index, messages } of this._preReadyMessages) {
|
|
399
434
|
super.messagesInserted(index, messages);
|
|
400
|
-
}
|
|
435
|
+
}
|
|
436
|
+
this._preReadyMessages = [];
|
|
401
437
|
}
|
|
402
438
|
|
|
403
439
|
sendMessage(message: INewMessage): string | null {
|
|
@@ -427,7 +463,10 @@ export class LabChatModel
|
|
|
427
463
|
body: message.body ?? '',
|
|
428
464
|
time: Date.now() / 1000,
|
|
429
465
|
sender: message.sender ?? this._user,
|
|
430
|
-
|
|
466
|
+
// Set the raw time only if there is a server to update the time to a reference
|
|
467
|
+
// one (the server time is source of truth). At that stage, without collaboration,
|
|
468
|
+
// this means that the chat is running without server (jupyterlite).
|
|
469
|
+
raw_time: this.collaborative ? true : false
|
|
431
470
|
};
|
|
432
471
|
|
|
433
472
|
this.sharedModel.addMessage(this._contentToYmessage(content));
|
|
@@ -562,7 +601,9 @@ export class LabChatModel
|
|
|
562
601
|
|
|
563
602
|
/**
|
|
564
603
|
* Handle a writing status pushed by the server (e.g. an AI agent) over the
|
|
565
|
-
* WebSocket. The sender controls
|
|
604
|
+
* WebSocket. The sender fully controls the lifecycle via explicit start/stop
|
|
605
|
+
* frames: a `state: true` frame shows the indicator and it stays visible
|
|
606
|
+
* until a `state: false` frame clears it -- no re-broadcasting is required.
|
|
566
607
|
*/
|
|
567
608
|
private _onWsWriting = (
|
|
568
609
|
_: WebSocketHandler,
|
|
@@ -572,14 +613,10 @@ export class LabChatModel
|
|
|
572
613
|
return;
|
|
573
614
|
}
|
|
574
615
|
if (writing.state) {
|
|
575
|
-
this.setWritingStatus(
|
|
576
|
-
writing.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
typingIndicator: writing.typingIndicator
|
|
580
|
-
},
|
|
581
|
-
WS_WRITING_TIMEOUT
|
|
582
|
-
);
|
|
616
|
+
this.setWritingStatus(writing.user, {
|
|
617
|
+
messageID: writing.messageID,
|
|
618
|
+
typingIndicator: writing.typingIndicator
|
|
619
|
+
});
|
|
583
620
|
} else {
|
|
584
621
|
this.clearWritingStatus(writing.user);
|
|
585
622
|
}
|
|
@@ -589,6 +626,55 @@ export class LabChatModel
|
|
|
589
626
|
enforceAutosaveEnabled(this.sharedModel.awareness);
|
|
590
627
|
};
|
|
591
628
|
|
|
629
|
+
/**
|
|
630
|
+
* Triggered when there is no backend and a change occur in the shared model (e.g.
|
|
631
|
+
* Jupyterlite). It is used to save the chat content from the frontend.
|
|
632
|
+
*/
|
|
633
|
+
private _onServerLessChange = (): void => {
|
|
634
|
+
this.dirty = true;
|
|
635
|
+
void this._saveDebouncer.invoke();
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Load the chat content when no backend is available (e.g. Jupyterlite).
|
|
640
|
+
* Should be called once when initializing the chat.
|
|
641
|
+
*/
|
|
642
|
+
private async _loadContent(): Promise<void> {
|
|
643
|
+
if (!this.documentManager) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
const file = await this.documentManager.services.contents.get(this.name, {
|
|
648
|
+
content: true,
|
|
649
|
+
format: 'text'
|
|
650
|
+
});
|
|
651
|
+
if (typeof file.content === 'string' && file.content) {
|
|
652
|
+
this._sharedModel.setSource(JSON.parse(file.content));
|
|
653
|
+
}
|
|
654
|
+
} catch {
|
|
655
|
+
// Missing or invalid file — start with an empty chat.
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Save the content to a file when no backend is available (e.g. Jupyterlite).
|
|
661
|
+
*/
|
|
662
|
+
private async _saveContent(): Promise<void> {
|
|
663
|
+
if (!this.documentManager) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
await this.documentManager.services.contents.save(this.name, {
|
|
668
|
+
type: 'file',
|
|
669
|
+
format: 'text',
|
|
670
|
+
content: this.toString()
|
|
671
|
+
});
|
|
672
|
+
this.dirty = false;
|
|
673
|
+
} catch (e) {
|
|
674
|
+
console.error('Failed to save chat file', e);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
592
678
|
private _onchange = async (_: YChat, changes: IChatChanges) => {
|
|
593
679
|
if (changes.messageListChanges) {
|
|
594
680
|
const msgDelta = changes.messageListChanges;
|
|
@@ -736,7 +822,9 @@ export class LabChatModel
|
|
|
736
822
|
change => change.name === 'dirty' && !change.newValue
|
|
737
823
|
)
|
|
738
824
|
) {
|
|
739
|
-
|
|
825
|
+
const id = UUID.uuid4();
|
|
826
|
+
this._sharedModel.id = id;
|
|
827
|
+
this.setReady(id);
|
|
740
828
|
}
|
|
741
829
|
}
|
|
742
830
|
};
|
|
@@ -828,6 +916,11 @@ export class LabChatModel
|
|
|
828
916
|
|
|
829
917
|
private _sharedModel: YChat;
|
|
830
918
|
|
|
919
|
+
private _documentSynced = false;
|
|
920
|
+
private _preReadyMessages: Array<{
|
|
921
|
+
index: number;
|
|
922
|
+
messages: IMessageContent[];
|
|
923
|
+
}> = [];
|
|
831
924
|
private _dirty = false;
|
|
832
925
|
private _readOnly = false;
|
|
833
926
|
private _contentChanged = new Signal<this, void>(this);
|
|
@@ -835,7 +928,13 @@ export class LabChatModel
|
|
|
835
928
|
private _timeoutWriting: number | null = null;
|
|
836
929
|
|
|
837
930
|
private _user: IUser;
|
|
931
|
+
|
|
932
|
+
// Web socket to use if RTC is not available
|
|
838
933
|
private _wsHandler: WebSocketHandler | null = null;
|
|
934
|
+
|
|
935
|
+
// Debouncer used to save file from the frontend if RTC and web socket are not
|
|
936
|
+
// available (e.g. jupyterlite).
|
|
937
|
+
private _saveDebouncer = new Debouncer(() => this._saveContent(), 500);
|
|
839
938
|
}
|
|
840
939
|
|
|
841
940
|
/**
|
package/src/websocket-handler.ts
CHANGED
|
@@ -299,14 +299,17 @@ export class WebSocketHandler {
|
|
|
299
299
|
if (this._disposed) {
|
|
300
300
|
return;
|
|
301
301
|
}
|
|
302
|
-
// Closed before the connection frame arrived:
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
// failure or leaving a spinner hanging forever.
|
|
302
|
+
// Closed before the connection frame arrived: signal failure so callers can
|
|
303
|
+
// distinguish between an unreachable server (code 1006, e.g. JupyterLite) and
|
|
304
|
+
// an explicit server-side rejection (any other code, e.g. 1008 for invalid path).
|
|
306
305
|
if (!this._connected) {
|
|
307
306
|
this._ready.reject(
|
|
308
|
-
|
|
309
|
-
|
|
307
|
+
Object.assign(
|
|
308
|
+
new Error(
|
|
309
|
+
event.reason ||
|
|
310
|
+
`Chat WebSocket was closed before opening (code ${event.code})`
|
|
311
|
+
),
|
|
312
|
+
{ wsCloseCode: event.code }
|
|
310
313
|
)
|
|
311
314
|
);
|
|
312
315
|
return;
|
|
@@ -320,8 +323,11 @@ export class WebSocketHandler {
|
|
|
320
323
|
}, 1000);
|
|
321
324
|
}
|
|
322
325
|
};
|
|
323
|
-
this._socket.onerror = error =>
|
|
324
|
-
|
|
326
|
+
this._socket.onerror = error => {
|
|
327
|
+
if (this._connected) {
|
|
328
|
+
console.error('WS chat connection error:', error);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
325
331
|
}
|
|
326
332
|
|
|
327
333
|
private _path = '';
|
package/src/ychat.ts
CHANGED
|
@@ -137,9 +137,10 @@ export class YChat extends YDocument<IChatChanges> {
|
|
|
137
137
|
getSource(): JSONObject {
|
|
138
138
|
const users = this._users.toJSON();
|
|
139
139
|
const messages = this._messages.toJSON();
|
|
140
|
+
const attachments = this._attachments.toJSON();
|
|
140
141
|
const metadata = this._metadata.toJSON();
|
|
141
142
|
|
|
142
|
-
return { users, messages, metadata };
|
|
143
|
+
return { users, messages, attachments, metadata };
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
setSource(value: JSONObject): void {
|
|
@@ -164,6 +165,11 @@ export class YChat extends YDocument<IChatChanges> {
|
|
|
164
165
|
this._users.set(key, val as IUser)
|
|
165
166
|
);
|
|
166
167
|
|
|
168
|
+
const attachments = value['attachments'] ?? {};
|
|
169
|
+
Object.entries(attachments).forEach(([key, val]) =>
|
|
170
|
+
this._attachments.set(key, val as IAttachment)
|
|
171
|
+
);
|
|
172
|
+
|
|
167
173
|
const metadata = value['metadata'] ?? {};
|
|
168
174
|
Object.entries(metadata).forEach(([key, val]) =>
|
|
169
175
|
this._metadata.set(key, val as any)
|