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.
This commit is contained in:
Classic298 2026-08-13 13:28:10 +02:00
parent 17c190bf99
commit 9189033c37
3 changed files with 65 additions and 3 deletions

View file

@ -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))

View file

@ -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

View file

@ -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