From 9189033c379f202d711efef2ad41a7cd8cc3b039 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:28:10 +0200 Subject: [PATCH] fix: seed a note's collaborative document from its stored content A note written outside the editor (the write_note tool, or any POST to /api/v1/notes/create) has no Yjs update history, so the first client to open it received an empty document and the save that followed wrote that emptiness back over the note. The content was still in the database up to that moment, then gone, with only the title left. The server now builds the Yjs document from the note's stored ProseMirror json when there is no update history yet, so a client opens the note with its real content instead of a blank document. The client id is derived from the content, which makes two workers seeding the same note a no-op rather than a duplication. Notes that only carry markdown still rely on the editor's own seeding, since building ProseMirror json from markdown server-side would mean reimplementing the editor's schema in Python. That path was also gated on being the only session joined to the note, so a second viewer disabled it entirely; it now elects the lowest joined session, which keeps a single seeder without giving up on seeding. --- backend/open_webui/socket/main.py | 17 ++++++- backend/open_webui/socket/utils.py | 47 +++++++++++++++++++ .../common/RichTextInput/Collaboration.ts | 4 +- 3 files changed, 65 insertions(+), 3 deletions(-) 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