diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 6cbb7b5fe2..cb6a08dff7 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -348,10 +348,9 @@ async def get_note_chat_by_id( changed = True system = ( - f'You are chatting with note {note.id}. ' - 'Use view_note with this note id to read the current note. ' - 'For edits, use replace_note_content for whole-note changes or replace_note_text ' - 'for targeted exact text replacement.' + f'Note id: {note.id}. ' + 'Use view_note before reading or editing. ' + 'Use replace_note_content with content for whole-note updates or operations for selected/range updates.' ) if params.get('system') != system: params['system'] = system @@ -379,10 +378,9 @@ async def get_note_chat_by_id( 'models': [''], 'params': { 'system': ( - f'You are chatting with note {note.id}. ' - 'Use view_note with this note id to read the current note. ' - 'For edits, use replace_note_content for whole-note changes or replace_note_text ' - 'for targeted exact text replacement.' + f'Note id: {note.id}. ' + 'Use view_note before reading or editing. ' + 'Use replace_note_content with content for whole-note updates or operations for selected/range updates.' ) }, 'history': {'messages': {}, 'currentId': None}, @@ -443,10 +441,9 @@ async def get_note_chats_by_id( changed = True system = ( - f'You are chatting with note {note.id}. ' - 'Use view_note with this note id to read the current note. ' - 'For edits, use replace_note_content for whole-note changes or replace_note_text ' - 'for targeted exact text replacement.' + f'Note id: {note.id}. ' + 'Use view_note before reading or editing. ' + 'Use replace_note_content with content for whole-note updates or operations for selected/range updates.' ) if params.get('system') != system: params['system'] = system @@ -506,10 +503,9 @@ async def create_note_chat_by_id( 'models': [''], 'params': { 'system': ( - f'You are chatting with note {note.id}. ' - 'Use view_note with this note id to read the current note. ' - 'For edits, use replace_note_content for whole-note changes or replace_note_text ' - 'for targeted exact text replacement.' + f'Note id: {note.id}. ' + 'Use view_note before reading or editing. ' + 'Use replace_note_content with content for whole-note updates or operations for selected/range updates.' ) }, 'history': {'messages': {}, 'currentId': None}, diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 553cacad10..bb96334a57 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -49,6 +49,7 @@ from open_webui.routers.memories import ( add_memory as _add_memory, ) from open_webui.routers.retrieval import search_web as _search_web +from open_webui.tasks import stop_item_tasks from open_webui.events import EVENTS, publish_event from open_webui.socket.main import sio from open_webui.utils.sanitize import sanitize_code @@ -1077,12 +1078,16 @@ async def view_note( from open_webui.models.access_grants import AccessGrants - if note.user_id != user_id and not await AccessGrants.has_access( - user_id=user_id, - resource_type='note', - resource_id=note.id, - permission='read', - user_group_ids=set(user_group_ids), + if ( + __user__.get('role') != 'admin' + and note.user_id != user_id + and not await AccessGrants.has_access( + user_id=user_id, + resource_type='note', + resource_id=note.id, + permission='read', + user_group_ids=set(user_group_ids), + ) ): return json.dumps({'error': 'Access denied'}) @@ -1157,16 +1162,20 @@ async def write_note( async def replace_note_content( note_id: str, - content: str, + content: Optional[str] = None, + operations: Optional[list[dict]] = None, title: Optional[str] = None, __request__: Request = None, __user__: dict = None, ) -> str: """ - Update the markdown content, and optionally the title, of an existing note. + Update an existing note by replacing the whole markdown content or applying range operations. :param note_id: The ID of the note to update - :param content: The new markdown content for the note + :param content: The new markdown content for a whole-note update + :param operations: Optional note operations: + - {"action": "replace", "content": "..."} + - {"action": "replace_range", "start": 0, "end": 10, "content": "...", "expected": "..."} :param title: Optional new title for the note :return: JSON with success status and updated note info """ @@ -1182,11 +1191,101 @@ async def replace_note_content( note = await Notes.get_note_by_id(note_id) if not note: - return json.dumps({'error': 'Note not found'}) + return json.dumps({'error': 'Note not found', 'code': 'not_found'}) user_id = __user__.get('id') - if not await _has_write_access_to_note(note, user_id): - return json.dumps({'error': 'Write access denied'}) + if __user__.get('role') != 'admin' and not await _has_write_access_to_note(note, user_id): + return json.dumps({'error': 'Write access denied', 'code': 'write_access_denied'}) + + current_content = ((note.data or {}).get('content') or {}).get('md') or '' + applied_operation_count = 0 + if operations is not None: + if not isinstance(operations, list) or len(operations) == 0: + return json.dumps({'error': 'operations must be a non-empty list', 'code': 'invalid_operations'}) + + range_operations = [] + for idx, operation in enumerate(operations): + if not isinstance(operation, dict): + return json.dumps( + {'error': 'each operation must be an object', 'code': 'invalid_operation', 'index': idx} + ) + + action = operation.get('action') + replacement = operation.get('content') + + if action == 'replace': + if len(operations) != 1: + return json.dumps( + { + 'error': 'replace operation must be the only operation', + 'code': 'invalid_operations', + 'index': idx, + } + ) + if not isinstance(replacement, str): + return json.dumps( + { + 'error': 'replace operation content must be a string', + 'code': 'invalid_content', + 'index': idx, + } + ) + content = replacement + applied_operation_count = 1 + break + + if action != 'replace_range': + return json.dumps( + {'error': 'unknown operation action', 'code': 'invalid_action', 'index': idx, 'action': action} + ) + + start = operation.get('start') + end = operation.get('end') + expected = operation.get('expected') + if not isinstance(start, int) or not isinstance(end, int): + return json.dumps( + {'error': 'operation start and end must be integers', 'code': 'invalid_range', 'index': idx} + ) + if not isinstance(replacement, str): + return json.dumps( + {'error': 'operation content must be a string', 'code': 'invalid_content', 'index': idx} + ) + if start < 0 or end < start or end > len(current_content): + return json.dumps( + {'error': 'operation range is out of bounds', 'code': 'range_out_of_bounds', 'index': idx} + ) + if expected is not None and current_content[start:end] != expected: + return json.dumps( + { + 'error': 'operation expected text does not match current content', + 'code': 'expected_mismatch', + 'index': idx, + } + ) + + range_operations.append({'start': start, 'end': end, 'content': replacement}) + + range_operations.sort(key=lambda operation: operation['start']) + previous_end = 0 + for idx, operation in enumerate(range_operations): + if operation['start'] < previous_end: + return json.dumps( + {'error': 'operation ranges must not overlap', 'code': 'overlapping_operations', 'index': idx} + ) + previous_end = operation['end'] + + if range_operations: + content = current_content + for operation in reversed(range_operations): + content = content[: operation['start']] + operation['content'] + content[operation['end'] :] + applied_operation_count = len(range_operations) + elif content is None: + return json.dumps({'error': 'content or operations is required', 'code': 'content_required'}) + + try: + await stop_item_tasks(__request__.app.state.redis, f'note:{note_id}') + except Exception: + pass update_data = { 'data': { @@ -1206,7 +1305,7 @@ async def replace_note_content( updated_note = await Notes.update_note_by_id(note_id, form) if not updated_note: - return json.dumps({'error': 'Failed to update note'}) + return json.dumps({'error': 'Failed to update note', 'code': 'update_failed'}) await _emit_note_updated(__request__, __user__, updated_note) @@ -1216,94 +1315,13 @@ async def replace_note_content( 'id': updated_note.id, 'title': updated_note.title, 'updated_at': updated_note.updated_at, + 'applied_operation_count': applied_operation_count, }, ensure_ascii=False, ) except Exception as e: log.exception(f'replace_note_content error: {e}') - return json.dumps({'error': str(e)}) - - -async def replace_note_text( - note_id: str, - target_text: str, - replacement_text: str, - title: Optional[str] = None, - __request__: Request = None, - __user__: dict = None, -) -> str: - """ - Replace an exact text span in a note when it occurs exactly once. - - :param note_id: The ID of the note to update - :param target_text: Exact text to replace - :param replacement_text: Replacement text - :param title: Optional new title for the note - :return: JSON with success status or a clear match-count error - """ - if __request__ is None: - return json.dumps({'error': 'Request context not available'}) - - if not __user__: - return json.dumps({'error': 'User context not available'}) - - try: - from open_webui.models.notes import NoteUpdateForm - - note = await Notes.get_note_by_id(note_id) - if not note: - return json.dumps({'error': 'Note not found'}) - - user_id = __user__.get('id') - if not await _has_write_access_to_note(note, user_id): - return json.dumps({'error': 'Write access denied'}) - - if target_text == '': - return json.dumps({'error': 'target_text must not be empty'}) - - content = ((note.data or {}).get('content') or {}).get('md') or '' - match_count = content.count(target_text) - if match_count != 1: - return json.dumps( - { - 'error': 'target_text must occur exactly once', - 'match_count': match_count, - }, - ensure_ascii=False, - ) - - update_data = { - 'data': { - **(note.data or {}), - 'content': { - **((note.data or {}).get('content') or {}), - 'json': None, - 'html': '', - 'md': content.replace(target_text, replacement_text, 1), - }, - } - } - if title: - update_data['title'] = title - - updated_note = await Notes.update_note_by_id(note_id, NoteUpdateForm(**update_data)) - if not updated_note: - return json.dumps({'error': 'Failed to update note'}) - - await _emit_note_updated(__request__, __user__, updated_note) - - return json.dumps( - { - 'status': 'success', - 'id': updated_note.id, - 'title': updated_note.title, - 'updated_at': updated_note.updated_at, - }, - ensure_ascii=False, - ) - except Exception as e: - log.exception(f'replace_note_text error: {e}') - return json.dumps({'error': str(e)}) + return json.dumps({'error': str(e), 'code': 'unexpected_error'}) # ============================================================================= diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 5edf76a122..8624f85632 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -73,7 +73,6 @@ from open_webui.tools.builtin import ( read_memory_path, replace_memory_content, replace_note_content, - replace_note_text, search_calendar_events, search_channel_messages, search_channels, @@ -644,11 +643,10 @@ async def get_builtin_tools( chat = await Chats.get_chat_by_id(chat_id) # Notes tools - search, view, create, and update user's notes - if ( - (chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note') - or (is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes')) + if (chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note') or ( + is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes') ): - builtin_functions.extend([search_notes, view_note, write_note, replace_note_content, replace_note_text]) + builtin_functions.extend([search_notes, view_note, write_note, replace_note_content]) # Channels tools - search channels and messages if is_builtin_tool_enabled('channels') and config.get('channels.enable') and await has_user_permission('channels'): diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 6058792cbc..9f80f77592 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -107,8 +107,6 @@ import EventConfirmDialog from '../common/ConfirmDialog.svelte'; import DeleteConfirmDialog from '../common/ConfirmDialog.svelte'; import WebSearchConfirmDialog from '../common/ConfirmDialog.svelte'; - import Dropdown from '../common/Dropdown.svelte'; - import DropdownMenu from '../common/DropdownMenu.svelte'; import Placeholder from './Placeholder.svelte'; import FilesOverlay from './MessageInput/FilesOverlay.svelte'; import NotificationToast from '../NotificationToast.svelte'; @@ -118,9 +116,7 @@ import Sidebar from '../icons/Sidebar.svelte'; import Image from '../common/Image.svelte'; import XMark from '../icons/XMark.svelte'; - import EditPencilIcon from '../layout/Sidebar/icons/EditPencil.svelte'; - import ChevronRight from '../icons/ChevronRight.svelte'; - import EmbeddedChatHistoryItem from './EmbeddedChatHistoryItem.svelte'; + import EmbeddedChatHistoryDropdown from './EmbeddedChatHistoryDropdown.svelte'; export let chatIdProp = ''; export let embedded = false; @@ -146,9 +142,6 @@ 'Extract action items from this note.', 'Rewrite the selected text.' ]; - let showEmbeddedChatHistory = false; - let embeddedChatOptionsId = ''; - let deletingEmbeddedChatId = ''; const eventTarget = new EventTarget(); let controlPane: Pane | undefined; @@ -402,7 +395,7 @@ }; const withSelectedText = (text: string) => embedded && selectedText?.trim() - ? `${text}\n\nSelected note text:\n${selectedText.trim()}` + ? `${text}\n\nSelected note text for replace_note_content operations:\n${selectedText.trim()}` : text; const noteChatDebug = (message: string, data: Record = {}) => { if (!embedded) return; @@ -3622,82 +3615,16 @@ class="h-10 shrink-0 flex items-center justify-between gap-2 border-b border-gray-50/80 px-3 text-gray-700 dark:border-gray-850/40 dark:text-gray-200" >
- { - if (!state) embeddedChatOptionsId = ''; - }} - > - - -
- - {#if onNewEmbeddedChat && Object.keys(history?.messages ?? {}).length > 0 && !loading} - -
- {/if} - {#if embeddedChats.length > 0} - {#each embeddedChats as item} - { - showEmbeddedChatHistory = false; - await onSelectEmbeddedChat?.(item.id); - }} - onDelete={async (id) => { - if (!id || deletingEmbeddedChatId) return; - - deletingEmbeddedChatId = id; - embeddedChatOptionsId = ''; - try { - await onDeleteEmbeddedChat?.(id); - } finally { - deletingEmbeddedChatId = ''; - } - }} - onMenuOpenChange={(id, state) => { - embeddedChatOptionsId = state ? id : ''; - }} - /> - {/each} - {:else} -
- {$i18n.t('No chat history')} -
- {/if} -
-
-
+ 0} + {loading} + onNewChat={onNewEmbeddedChat} + onSelectChat={onSelectEmbeddedChat} + onDeleteChat={onDeleteEmbeddedChat} + />
+ +
+ + {#if canCreateNew && !loading} + +
+ {/if} + + {#if chats.length > 0} + {#each chats as item} + { + show = false; + await onSelectChat?.(item.id); + }} + onDelete={async (id) => { + if (!id || deletingChatId) return; + + deletingChatId = id; + optionsChatId = ''; + try { + await onDeleteChat?.(id); + } finally { + deletingChatId = ''; + } + }} + onMenuOpenChange={(id, state) => { + optionsChatId = state ? id : ''; + }} + /> + {/each} + {:else} +
+ {$i18n.t('No chat history')} +
+ {/if} +
+
+ diff --git a/src/lib/components/chat/EmbeddedChatHistoryItem.svelte b/src/lib/components/chat/EmbeddedChatHistoryItem.svelte index 5c91909382..8d44807244 100644 --- a/src/lib/components/chat/EmbeddedChatHistoryItem.svelte +++ b/src/lib/components/chat/EmbeddedChatHistoryItem.svelte @@ -21,14 +21,16 @@ let showMenu = false; - -
+
{ e.preventDefault(); e.stopPropagation(); @@ -85,4 +91,4 @@
- + diff --git a/src/lib/components/notes/NoteEditor.svelte b/src/lib/components/notes/NoteEditor.svelte index 00a45bd14a..70128dcf3d 100644 --- a/src/lib/components/notes/NoteEditor.svelte +++ b/src/lib/components/notes/NoteEditor.svelte @@ -143,6 +143,9 @@ let selectedContent = null; let noteChatFiles = []; + let pendingNoteEvent = null; + let pendingNoteEventTimer = null; + let lastLocalContentChangeAt = 0; $: { const seen = new Set(); noteChatFiles = note @@ -186,15 +189,14 @@ } files = res.data.files || []; - if (note?.write_access) { - $socket?.emit('join-note', { - note_id: id, - auth: { - token: localStorage.token - } - }); - $socket?.on('events:note', noteEventHandler); - } + $socket?.emit('join-note', { + note_id: id, + auth: { + token: localStorage.token + } + }); + $socket?.off('events:note', noteEventHandler); + $socket?.on('events:note', noteEventHandler); } else { goto('/'); return; @@ -227,6 +229,94 @@ }, 200); }; + const applyExternalNoteContent = async (_note) => { + const incomingContent = _note.data?.content; + const contentLength = incomingContent?.md?.length ?? incomingContent?.html?.length ?? 0; + + console.info('[note-chat] external note event apply requested', { + noteId: _note.id, + eventUpdatedAt: _note.updated_at, + currentUpdatedAt: note?.updated_at, + editorPresent: !!editor, + contentLength + }); + + if (_note.updated_at && note?.updated_at && _note.updated_at < note.updated_at) { + console.info('[note-chat] external note event skipped', { + noteId: _note.id, + reason: 'stale', + eventUpdatedAt: _note.updated_at, + currentUpdatedAt: note.updated_at, + contentLength + }); + return false; + } + + const elapsed = Date.now() - lastLocalContentChangeAt; + if (elapsed < 800) { + pendingNoteEvent = _note; + if (pendingNoteEventTimer) { + clearTimeout(pendingNoteEventTimer); + } + pendingNoteEventTimer = setTimeout(async () => { + const event = pendingNoteEvent; + pendingNoteEvent = null; + if (event) { + await applyExternalNoteContent(event); + } + }, 850 - elapsed); + + console.info('[note-chat] external note event deferred', { + noteId: _note.id, + reason: 'local-edit-settling', + eventUpdatedAt: _note.updated_at, + contentLength + }); + return false; + } + + note.data.content = { + ...note.data.content, + ...incomingContent + }; + if (_note.updated_at) { + note.updated_at = _note.updated_at; + } + + if (!editor) { + console.info('[note-chat] external note event applied', { + noteId: _note.id, + reason: 'no-editor', + eventUpdatedAt: _note.updated_at, + contentLength + }); + return true; + } + + const selection = editor.state.selection; + editor.commands.setContent(incomingContent.html || marked.parse(incomingContent.md ?? '')); + await tick(); + + const docSize = editor.state.doc.content.size; + const from = Math.min(selection.from, docSize); + const to = Math.min(selection.to, docSize); + if (from < to) { + editor.commands.setTextSelection({ from, to }); + const text = editor.state.doc.textBetween(from, to, ' '); + selectedContent = text ? { text, from, to } : null; + } else { + selectedContent = null; + } + + console.info('[note-chat] external note event applied', { + noteId: _note.id, + reason: 'content', + eventUpdatedAt: _note.updated_at, + contentLength + }); + return true; + }; + $: if (id) { init(); } @@ -754,6 +844,17 @@ ${content} console.log('noteEventHandler', _note); if (_note.id !== id) return; + if (_note.updated_at && note?.updated_at && _note.updated_at < note.updated_at) { + console.info('[note-chat] external note event skipped', { + noteId: _note.id, + reason: 'stale-event', + eventUpdatedAt: _note.updated_at, + currentUpdatedAt: note.updated_at, + contentLength: _note.data?.content?.md?.length ?? _note.data?.content?.html?.length ?? 0 + }); + return; + } + if (_note.access_grants && _note.access_grants !== note.access_grants) { note.access_grants = _note.access_grants; } @@ -764,22 +865,20 @@ ${content} } if (_note.data?.content) { - note.data.content = { - ...note.data.content, - ..._note.data.content - }; - if (editor) { - editor.commands.setContent( - _note.data.content.html || marked.parse(_note.data.content.md ?? '') - ); - } + await applyExternalNoteContent(_note); } if (_note.title && _note.title) { note.title = _note.title; } - editor.storage.files = files; + if (_note.updated_at) { + note.updated_at = _note.updated_at; + } + + if (editor) { + editor.storage.files = files; + } await tick(); for (const file of files) { @@ -830,6 +929,9 @@ ${content} onDestroy(() => { console.log('destroy'); $socket?.off('events:note', noteEventHandler); + if (pendingNoteEventTimer) { + clearTimeout(pendingNoteEventTimer); + } const dropzoneElement = document.getElementById('note-editor'); @@ -945,11 +1047,11 @@ ${content} {#if titleInputFocused && !titleGenerating}
@@ -1222,6 +1324,7 @@ ${content} } }} onChange={(content) => { + lastLocalContentChangeAt = Date.now(); note.data.content.html = content.html; note.data.content.md = content.md;