Skip to content

Commit d397f76

Browse files
authored
Handle YChat document resets when jupyterlab_chat is installed (#161)
* handle YChat document resets in the frontend * remove spurious console logs * use precise NPM version specifiers on jupyterlab-chat
1 parent 9c1d479 commit d397f76

File tree

7 files changed

+1484
-27
lines changed

7 files changed

+1484
-27
lines changed

package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"watch:labextension": "jupyter labextension watch ."
6060
},
6161
"dependencies": {
62+
"@jupyter/chat": ">=0.6.0 <1",
6263
"@jupyter/collaborative-drive": "^4",
6364
"@jupyter/ydoc": "^2.1.3 || ^3.0.0",
6465
"@jupyterlab/application": "^4.4.0",
@@ -76,6 +77,7 @@
7677
"@lumino/coreutils": "^2.2.1",
7778
"@lumino/disposable": "^2.1.4",
7879
"@lumino/signaling": "^2.1.4",
80+
"jupyterlab-chat": ">=0.6.0 <1",
7981
"y-protocols": "^1.0.5",
8082
"y-websocket": "^1.3.15",
8183
"yjs": "^13.5.40"
@@ -134,7 +136,15 @@
134136
"disabledExtensions": [
135137
"@jupyterlab/codemirror-extension:binding",
136138
"@jupyter/docprovider-extension"
137-
]
139+
],
140+
"sharedPackages": {
141+
"@jupyter/chat": {
142+
"singleton": true
143+
},
144+
"jupyterlab-chat": {
145+
"singleton": true
146+
}
147+
}
138148
},
139149
"eslintIgnore": [
140150
"node_modules",

src/docprovider/custom_ydocs.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
YNotebook as DefaultYNotebook,
44
ISharedNotebook
55
} from '@jupyter/ydoc';
6+
import { YChat as DefaultYChat } from 'jupyterlab-chat';
67
import * as Y from 'yjs';
78
import { Awareness } from 'y-protocols/awareness';
89
import { ISignal, Signal } from '@lumino/signaling';
@@ -114,3 +115,49 @@ export class YNotebook extends DefaultYNotebook {
114115

115116
_resetSignal: Signal<this, null>;
116117
}
118+
119+
export class YChat extends DefaultYChat {
120+
constructor() {
121+
super();
122+
this._resetSignal = new Signal(this);
123+
}
124+
125+
/**
126+
* See `YFile.reset()`.
127+
*/
128+
reset() {
129+
// Remove default observers
130+
(this as any)._users.unobserve((this as any)._usersObserver);
131+
(this as any)._messages.unobserve((this as any)._messagesObserver);
132+
(this as any)._attachments.unobserve((this as any)._attachmentsObserver);
133+
(this as any)._metadata.unobserve((this as any)._metadataObserver);
134+
135+
// Reset `this._ydoc` to an empty state
136+
(this as any)._ydoc = new Y.Doc();
137+
138+
// Reset all properties derived from `this._ydoc`
139+
(this as any)._users = this.ydoc.getMap('users');
140+
(this as any)._messages = this.ydoc.getArray('messages');
141+
(this as any)._attachments = this.ydoc.getMap('attachments');
142+
(this as any)._metadata = this.ydoc.getMap('metadata');
143+
(this as any)._awareness = new Awareness(this.ydoc);
144+
145+
// Emit to `this.resetSignal` to inform consumers immediately
146+
this._resetSignal.emit(null);
147+
148+
// Add back default observers
149+
(this as any)._users.observe((this as any)._usersObserver);
150+
(this as any)._messages.observe((this as any)._messagesObserver);
151+
(this as any)._attachments.observe((this as any)._attachmentsObserver);
152+
(this as any)._metadata.observe((this as any)._metadataObserver);
153+
}
154+
155+
/**
156+
* See `YFile.resetSignal`.
157+
*/
158+
get resetSignal(): ISignal<this, null> {
159+
return this._resetSignal;
160+
}
161+
162+
_resetSignal: Signal<this, null>;
163+
}

src/docprovider/filebrowser.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
JupyterFrontEndPlugin
99
} from '@jupyterlab/application';
1010
import { Dialog, showDialog } from '@jupyterlab/apputils';
11-
import { IDocumentWidget } from '@jupyterlab/docregistry';
11+
import { DocumentWidget, IDocumentWidget } from '@jupyterlab/docregistry';
1212
import { ContentsManager } from '@jupyterlab/services';
1313

1414
import {
@@ -34,6 +34,10 @@ import {
3434
import { RtcContentProvider } from './ydrive';
3535
import { Awareness } from 'y-protocols/awareness';
3636

37+
import { YChat } from './custom_ydocs';
38+
import { IChatFactory } from 'jupyterlab-chat';
39+
import { AbstractChatModel } from '@jupyter/chat';
40+
3741
const TWO_SESSIONS_WARNING =
3842
'The file %1 has been opened with two different views. ' +
3943
'This is not supported. Please close this view; otherwise, ' +
@@ -154,6 +158,76 @@ export const ynotebook: JupyterFrontEndPlugin<void> = {
154158
}
155159
};
156160

161+
/**
162+
* This plugin provides the YChat shared model and handles document resets by
163+
* listening to the `YChat.resetSignal` property automatically.
164+
*
165+
* Whenever a YChat is reset, this plugin will iterate through all of the app's
166+
* document widgets and find the one containing the `YChat` shared model which
167+
* was reset. It then clears the content.
168+
*/
169+
export const ychat: JupyterFrontEndPlugin<void> = {
170+
id: '@jupyter/server-documents:ychat',
171+
description:
172+
'Plugin to register a custom YChat factory and handle document resets.',
173+
autoStart: true,
174+
requires: [ICollaborativeContentProvider],
175+
optional: [IChatFactory],
176+
activate: (
177+
app: JupyterFrontEnd,
178+
contentProvider: ICollaborativeContentProvider,
179+
chatFactory?: IChatFactory
180+
): void => {
181+
if (!chatFactory) {
182+
console.warn(
183+
'No existing shared model factory found for chat. Not providing custom chat shared model.'
184+
);
185+
return;
186+
}
187+
188+
const onYChatReset = (ychat: YChat) => {
189+
for (const widget of app.shell.widgets()) {
190+
if (!(widget instanceof DocumentWidget)) {
191+
continue;
192+
}
193+
const model = widget.content.model;
194+
const sharedModel = model && model._sharedModel;
195+
if (
196+
!(model instanceof AbstractChatModel && sharedModel instanceof YChat)
197+
) {
198+
continue;
199+
}
200+
if (sharedModel !== ychat) {
201+
continue;
202+
}
203+
204+
// If this point is reached, we have identified the correct parent
205+
// `model: AbstractChatModel` that maintains the message state for the
206+
// `YChat` which was reset. We clear its content directly & emit a
207+
// `contentChanged` signal to update the UI.
208+
(model as any)._messages = [];
209+
(model as any)._messagesUpdated.emit();
210+
break;
211+
}
212+
};
213+
214+
// Override the existing `YChat` factory to provide a custom `YChat` with a
215+
// `resetSignal`, which is automatically subscribed to & refreshes the UI
216+
// state upon document reset.
217+
const yChatFactory = () => {
218+
const ychat = new YChat();
219+
ychat.resetSignal.connect(() => {
220+
onYChatReset(ychat);
221+
});
222+
return ychat;
223+
};
224+
contentProvider.sharedModelFactory.registerDocumentFactory(
225+
'chat',
226+
yChatFactory as any
227+
);
228+
}
229+
};
230+
157231
/**
158232
* The default collaborative drive provider.
159233
*/

src/docprovider/ydrive.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,10 @@ class SharedModelFactory implements ISharedModelFactory {
272272
factory: SharedDocumentFactory
273273
) {
274274
if (this.documentFactories.has(type)) {
275-
throw new Error(`The content type ${type} already exists`);
275+
// allow YChat shared model factory to be overridden
276+
if (type !== 'chat') {
277+
throw new Error(`The content type ${type} already exists.`);
278+
}
276279
}
277280
this.documentFactories.set(type, factory);
278281
}

src/docprovider/yprovider.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { JupyterFrontEnd } from '@jupyterlab/application';
2121
import { DocumentWidget } from '@jupyterlab/docregistry';
2222
import { FileEditor } from '@jupyterlab/fileeditor';
2323
import { Notebook } from '@jupyterlab/notebook';
24+
import { ChatWidget } from '@jupyter/chat';
2425

2526
/**
2627
* A class to provide Yjs synchronization over WebSocket.
@@ -87,9 +88,15 @@ export class WebSocketProvider implements IDocumentProvider {
8788
continue;
8889
}
8990

90-
// Skip widgets that don't contain a YFile / YNotebook
91+
// Skip widgets that don't contain a YFile / YNotebook / YChat
9192
const widget = docWidget.content;
92-
if (!(widget instanceof FileEditor || widget instanceof Notebook)) {
93+
if (
94+
!(
95+
widget instanceof FileEditor ||
96+
widget instanceof Notebook ||
97+
widget instanceof ChatWidget
98+
)
99+
) {
93100
continue;
94101
}
95102

src/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@ import { AwarenessExecutionIndicator } from './executionindicator';
2828

2929
import { requestAPI } from './handler';
3030

31-
import { rtcContentProvider, yfile, ynotebook, logger } from './docprovider';
31+
import {
32+
rtcContentProvider,
33+
yfile,
34+
ynotebook,
35+
ychat,
36+
logger
37+
} from './docprovider';
3238

3339
import { IStateDB, StateDB } from '@jupyterlab/statedb';
3440
import { IGlobalAwareness } from '@jupyter/collaborative-drive';
@@ -317,7 +323,8 @@ const plugins: JupyterFrontEndPlugin<unknown>[] = [
317323
notebookFactoryPlugin,
318324
codemirrorYjsPlugin,
319325
backupCellExecutorPlugin,
320-
disableSavePlugin
326+
disableSavePlugin,
327+
ychat
321328
];
322329

323330
export default plugins;

0 commit comments

Comments
 (0)