diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index d88fd827f1..be0fc30dba 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -34,7 +34,7 @@ from open_webui.models.chats import Chats from open_webui.models.folders import Folders from open_webui.models.notes import Notes, NoteUpdateForm from open_webui.models.users import UserNameResponse, Users -from open_webui.socket.utils import RedisDict, RedisLock, YdocManager +from open_webui.socket.utils import RedisDict, RedisLock, YdocManager, build_prosemirror_update from open_webui.tasks import create_task, stop_item_tasks from open_webui.utils.access_control import has_permission from open_webui.utils.auth import get_verified_user_by_token @@ -625,6 +625,7 @@ async def ydoc_document_join(sid, data): try: document_id = normalize_document_id(data['document_id']) + note = None if document_id.startswith('note:'): note_id = document_id.split(':')[1] note = await Notes.get_note_by_id(note_id) @@ -658,8 +659,20 @@ async def ydoc_document_join(sid, data): active_session_ids = get_session_ids_from_room(f'doc_{document_id}') # Get the Yjs document state - ydoc = Y.Doc() updates = await YDOC_MANAGER.get_updates(document_id) + + # Without this a note written outside the editor opens empty, and its first save wins. + if not updates and note: + try: + content = (note.data or {}).get('content') or {} + seed_update = build_prosemirror_update(content.get('json')) + if seed_update: + await YDOC_MANAGER.append_to_updates(document_id=document_id, update=seed_update) + updates = [seed_update] + except Exception: + log.exception('Failed to seed document %s from stored note content', document_id) + + ydoc = Y.Doc() for update in updates: ydoc.apply_update(bytes(update)) diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index 00f8424aae..5e89484bb3 100644 --- a/backend/open_webui/socket/utils.py +++ b/backend/open_webui/socket/utils.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import json import uuid import pycrdt as Y @@ -165,6 +166,52 @@ class RedisDict: return self[key] +def _append_prosemirror_nodes(parent, nodes) -> None: + # Children are appended before being written to: pycrdt only accepts writes once integrated. + text_run = None # a run of text nodes shares one XmlText, as y-prosemirror lays them out + + for node in nodes or []: + if not isinstance(node, dict) or not isinstance(node.get('type'), str): + continue + + if node['type'] == 'text': + if not isinstance(node.get('text'), str) or not node['text']: + continue + if text_run is None: + text_run = Y.XmlText() + parent.children.append(text_run) + marks = { + mark['type']: mark.get('attrs') or {} + for mark in node.get('marks') or [] + if isinstance(mark, dict) and mark.get('type') + } + text_run.insert(len(text_run), node['text'], marks) + continue + + text_run = None + element = Y.XmlElement(node['type']) + parent.children.append(element) + for key, value in (node.get('attrs') or {}).items(): + if value is not None: + element.attributes[key] = value + _append_prosemirror_nodes(element, node.get('content')) + + +def build_prosemirror_update(content_json) -> bytes | None: + """Encode a ProseMirror document as a Yjs update in the shape y-prosemirror reads back.""" + if not isinstance(content_json, dict) or content_json.get('type') != 'doc' or not content_json.get('content'): + return None + + # A content-derived client id makes a concurrent seed of the same content a no-op. + canonical = json.dumps(content_json, sort_keys=True, separators=(',', ':')) + content_digest = hashlib.sha256(canonical.encode()).digest() + doc = Y.Doc(client_id=int.from_bytes(content_digest[:4], 'big')) + fragment = Y.XmlFragment() + doc['prosemirror'] = fragment + _append_prosemirror_nodes(fragment, content_json['content']) + return doc.get_update() + + class YdocManager: COMPACTION_THRESHOLD = 500 diff --git a/src/lib/components/common/RichTextInput/Collaboration.ts b/src/lib/components/common/RichTextInput/Collaboration.ts index e261af01a1..1d7147b2bb 100644 --- a/src/lib/components/common/RichTextInput/Collaboration.ts +++ b/src/lib/components/common/RichTextInput/Collaboration.ts @@ -130,7 +130,9 @@ export class SocketIOCollaborationProvider { const isEmptyEditor = !this.editor?.getText().trim(); if (isEmptyEditor && this.editor) { - if (this.initialContent && (data?.sessions ?? ['']).length === 1) { + // The lowest joined session seeds, so viewers don't each insert the content + const seedingSession = data.sessions?.sort()[0]; + if (this.initialContent && seedingSession === this.socket.id) { // Check if initialContent is HTML (string) or JSON (object) if (typeof this.initialContent === 'string') { // HTML content - let the editor parse it, then sync to Yjs