diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index f68252489b..c060db6de9 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -840,27 +840,28 @@ async def yjs_document_update(sid, data): log.warning(f'User {user.get("id")} does not have write access to note {note_id}. Rejecting update.') return - user_id = data.get('user_id', sid) + update = data.get('update') # List of bytes from frontend - update = data['update'] # List of bytes from frontend + if update: + user_id = data.get('user_id', sid) - await YDOC_MANAGER.append_to_updates( - document_id=document_id, - update=update, # Convert list of bytes to bytes - ) + await YDOC_MANAGER.append_to_updates( + document_id=document_id, + update=update, # Convert list of bytes to bytes + ) - # Broadcast update to all other users in the document - await sio.emit( - 'ydoc:document:update', - { - 'document_id': document_id, - 'user_id': user_id, - 'update': update, - 'socket_id': sid, # Add socket_id to match frontend filtering - }, - room=f'doc_{document_id}', - skip_sid=sid, - ) + # Broadcast update to all other users in the document + await sio.emit( + 'ydoc:document:update', + { + 'document_id': document_id, + 'user_id': user_id, + 'update': update, + 'socket_id': sid, # Add socket_id to match frontend filtering + }, + room=f'doc_{document_id}', + skip_sid=sid, + ) async def debounced_save(): await asyncio.sleep(0.5) diff --git a/src/lib/components/common/RichTextInput/Collaboration.ts b/src/lib/components/common/RichTextInput/Collaboration.ts index c923ddface..0496704342 100644 --- a/src/lib/components/common/RichTextInput/Collaboration.ts +++ b/src/lib/components/common/RichTextInput/Collaboration.ts @@ -45,6 +45,7 @@ export class SocketIOCollaborationProvider { private synced = false; private editor: Editor | null = null; private editorContentGetter: EditorContentGetter | null = null; + private contentSnapshotTimer: ReturnType | null = null; constructor( private readonly documentId: string, @@ -114,6 +115,18 @@ export class SocketIOCollaborationProvider { Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(doc)); } + // Send the merged content; the remote sender had not seen our edits yet. + private sendContentSnapshot() { + this.contentSnapshotTimer = null; + const getContent = this.editorContentGetter; + if (!this.isConnected || !getContent) return; + + this.socket.emit('ydoc:document:update', { + document_id: this.documentId, + data: { content: getContent() } + }); + } + private joinDocument() { if (!this.editor) return; @@ -141,7 +154,13 @@ export class SocketIOCollaborationProvider { if (data.document_id === this.documentId && data.socket_id !== this.socket.id) { try { const update = new Uint8Array(data.update); - Y.applyUpdate(this.doc, update); + // 'server' stops the local update listener sending this straight back out + Y.applyUpdate(this.doc, update, 'server'); + + if (this.contentSnapshotTimer) { + clearTimeout(this.contentSnapshotTimer); + } + this.contentSnapshotTimer = setTimeout(() => this.sendContentSnapshot(), 500); } catch (error) { console.error('Error applying Yjs update:', error); } @@ -229,6 +248,11 @@ export class SocketIOCollaborationProvider { } } }); + + if (this.contentSnapshotTimer) { + clearTimeout(this.contentSnapshotTimer); + this.contentSnapshotTimer = null; + } } }); @@ -273,6 +297,11 @@ export class SocketIOCollaborationProvider { this.socket.off('connect', this.onConnect); this.socket.off('disconnect', this.onDisconnect); + if (this.contentSnapshotTimer) { + clearTimeout(this.contentSnapshotTimer); + this.sendContentSnapshot(); + } + if (this.isConnected) { this.socket.emit('ydoc:document:leave', { document_id: this.documentId,