Skip to content

Commit 423cafd

Browse files
committed
refac
1 parent 4d11553 commit 423cafd

21 files changed

Lines changed: 804 additions & 1159 deletions

backend/open_webui/models/chats.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,23 @@ async def get_internal_chat_ids_by_parent_id(self, parent_chat_id: str, user_id:
412412
)
413413
return list(result.scalars().all())
414414

415+
async def get_internal_chat_by_note_id(
416+
self, note_id: str, user_id: str, db: AsyncSession | None = None
417+
) -> ChatModel | None:
418+
async with get_async_db_context(db) as session:
419+
result = await session.execute(
420+
select(Chat)
421+
.where(
422+
Chat.user_id == user_id,
423+
Chat.meta['internal'].as_boolean().is_(True),
424+
Chat.meta['type'].as_string() == 'note',
425+
Chat.meta['note_id'].as_string() == note_id,
426+
)
427+
.order_by(Chat.created_at.asc())
428+
)
429+
chat = result.scalars().first()
430+
return ChatModel.model_validate(chat) if chat else None
431+
415432
def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel:
416433
id = str(uuid.uuid4())
417434
chat = ChatModel(

backend/open_webui/routers/notes.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import logging
33
from typing import Optional
4+
from uuid import uuid4
45

56
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
67
from open_webui.config import (
@@ -12,6 +13,7 @@
1213
from open_webui.events import EVENTS, publish_event
1314
from open_webui.internal.db import get_async_session
1415
from open_webui.models.access_grants import AccessGrants
16+
from open_webui.models.chats import ChatForm, ChatResponse, Chats
1517
from open_webui.models.config import Config
1618
from open_webui.models.groups import Groups
1719
from open_webui.models.notes import (
@@ -45,6 +47,39 @@ def _truncate_note_data(data: Optional[dict], max_length: int = 1000) -> Optiona
4547
return {'content': {'md': md[:max_length]}}
4648

4749

50+
def _note_chat_system_prompt(note_id: str) -> str:
51+
return (
52+
f'You are chatting with note {note_id}. Use view_note with this note id to read the current note. '
53+
'For edits, use replace_note_content for whole-note changes or replace_note_text '
54+
'for targeted exact text replacement.'
55+
)
56+
57+
58+
async def _normalize_note_chat_payload(chat: ChatResponse, note_id: str, db: AsyncSession) -> ChatResponse:
59+
payload = {**(chat.chat or {})}
60+
params = {**(payload.get('params') or {})}
61+
changed = False
62+
63+
if params.pop('note_id', None) is not None:
64+
changed = True
65+
66+
system = _note_chat_system_prompt(note_id)
67+
if params.get('system') != system:
68+
params['system'] = system
69+
changed = True
70+
71+
if payload.pop('system', None) is not None:
72+
changed = True
73+
74+
payload['params'] = params
75+
if changed:
76+
updated_chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False)
77+
if updated_chat:
78+
return updated_chat
79+
80+
return chat
81+
82+
4883
############################
4984
# GetNotes
5085
############################
@@ -303,6 +338,69 @@ async def get_note_by_id(
303338
)
304339

305340

341+
@router.get('/{id}/chat', response_model=ChatResponse)
342+
async def get_note_chat_by_id(
343+
request: Request,
344+
id: str,
345+
user=Depends(get_verified_user),
346+
db: AsyncSession = Depends(get_async_session),
347+
):
348+
log.info('[note-chat] get-or-create requested note_id=%s user_id=%s', id, user.id)
349+
if user.role != 'admin' and not await has_permission(
350+
user.id, 'features.notes', await Config.get('user.permissions'), db=db
351+
):
352+
raise HTTPException(
353+
status_code=status.HTTP_401_UNAUTHORIZED,
354+
detail=ERROR_MESSAGES.UNAUTHORIZED,
355+
)
356+
357+
note = await Notes.get_note_by_id(id, db=db)
358+
if not note:
359+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
360+
361+
if user.role != 'admin' and (
362+
user.id != note.user_id
363+
and not await AccessGrants.has_access(
364+
user_id=user.id,
365+
resource_type='note',
366+
resource_id=note.id,
367+
permission='read',
368+
db=db,
369+
)
370+
):
371+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
372+
373+
chat = await Chats.get_internal_chat_by_note_id(note.id, user.id, db=db)
374+
if chat:
375+
log.info('[note-chat] reusing hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
376+
return await _normalize_note_chat_payload(chat, note.id, db)
377+
378+
meta = {'internal': True, 'type': 'note', 'note_id': note.id}
379+
chat_id = str(uuid4())
380+
chat = await Chats.insert_new_chat(
381+
chat_id,
382+
user.id,
383+
ChatForm(
384+
chat={
385+
'id': chat_id,
386+
'title': 'Chat',
387+
'models': [''],
388+
'params': {'system': _note_chat_system_prompt(note.id)},
389+
'history': {'messages': {}, 'currentId': None},
390+
'messages': [],
391+
'tags': [],
392+
}
393+
),
394+
db=db,
395+
internal_meta=meta,
396+
)
397+
if not chat:
398+
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
399+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
400+
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
401+
return chat
402+
403+
306404
############################
307405
# UpdateNoteById
308406
############################

backend/open_webui/tools/builtin.py

Lines changed: 125 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,42 @@
4949
add_memory as _add_memory,
5050
)
5151
from open_webui.routers.retrieval import search_web as _search_web
52+
from open_webui.events import EVENTS, publish_event
53+
from open_webui.socket.main import sio
5254
from open_webui.utils.sanitize import sanitize_code
5355

5456
log = logging.getLogger(__name__)
5557

5658
MAX_KNOWLEDGE_BASE_SEARCH_ITEMS = 10_000
5759

5860

61+
async def _has_write_access_to_note(note, user_id: str) -> bool:
62+
if note.user_id == user_id:
63+
return True
64+
65+
from open_webui.models.access_grants import AccessGrants
66+
67+
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
68+
return await AccessGrants.has_access(
69+
user_id=user_id,
70+
resource_type='note',
71+
resource_id=note.id,
72+
permission='write',
73+
user_group_ids=set(user_group_ids),
74+
)
75+
76+
77+
async def _emit_note_updated(request: Request, user: dict, note) -> None:
78+
await sio.emit('note-events', note.model_dump(), to=f'note:{note.id}')
79+
await publish_event(
80+
request,
81+
EVENTS.NOTE_UPDATED,
82+
actor=user,
83+
subject_id=note.id,
84+
data={'title': note.title},
85+
)
86+
87+
5988
async def _has_read_access_to_file(
6089
file,
6190
user_id: str,
@@ -1155,23 +1184,21 @@ async def replace_note_content(
11551184
if not note:
11561185
return json.dumps({'error': 'Note not found'})
11571186

1158-
# Check write permission
11591187
user_id = __user__.get('id')
1160-
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
1161-
1162-
from open_webui.models.access_grants import AccessGrants
1163-
1164-
if note.user_id != user_id and not await AccessGrants.has_access(
1165-
user_id=user_id,
1166-
resource_type='note',
1167-
resource_id=note.id,
1168-
permission='write',
1169-
user_group_ids=set(user_group_ids),
1170-
):
1188+
if not await _has_write_access_to_note(note, user_id):
11711189
return json.dumps({'error': 'Write access denied'})
11721190

1173-
# Build update form
1174-
update_data = {'data': {'content': {'md': content}}}
1191+
update_data = {
1192+
'data': {
1193+
**(note.data or {}),
1194+
'content': {
1195+
**((note.data or {}).get('content') or {}),
1196+
'json': None,
1197+
'html': '',
1198+
'md': content,
1199+
},
1200+
}
1201+
}
11751202
if title:
11761203
update_data['title'] = title
11771204

@@ -1181,6 +1208,8 @@ async def replace_note_content(
11811208
if not updated_note:
11821209
return json.dumps({'error': 'Failed to update note'})
11831210

1211+
await _emit_note_updated(__request__, __user__, updated_note)
1212+
11841213
return json.dumps(
11851214
{
11861215
'status': 'success',
@@ -1195,6 +1224,88 @@ async def replace_note_content(
11951224
return json.dumps({'error': str(e)})
11961225

11971226

1227+
async def replace_note_text(
1228+
note_id: str,
1229+
target_text: str,
1230+
replacement_text: str,
1231+
title: Optional[str] = None,
1232+
__request__: Request = None,
1233+
__user__: dict = None,
1234+
) -> str:
1235+
"""
1236+
Replace an exact text span in a note when it occurs exactly once.
1237+
1238+
:param note_id: The ID of the note to update
1239+
:param target_text: Exact text to replace
1240+
:param replacement_text: Replacement text
1241+
:param title: Optional new title for the note
1242+
:return: JSON with success status or a clear match-count error
1243+
"""
1244+
if __request__ is None:
1245+
return json.dumps({'error': 'Request context not available'})
1246+
1247+
if not __user__:
1248+
return json.dumps({'error': 'User context not available'})
1249+
1250+
try:
1251+
from open_webui.models.notes import NoteUpdateForm
1252+
1253+
note = await Notes.get_note_by_id(note_id)
1254+
if not note:
1255+
return json.dumps({'error': 'Note not found'})
1256+
1257+
user_id = __user__.get('id')
1258+
if not await _has_write_access_to_note(note, user_id):
1259+
return json.dumps({'error': 'Write access denied'})
1260+
1261+
if target_text == '':
1262+
return json.dumps({'error': 'target_text must not be empty'})
1263+
1264+
content = ((note.data or {}).get('content') or {}).get('md') or ''
1265+
match_count = content.count(target_text)
1266+
if match_count != 1:
1267+
return json.dumps(
1268+
{
1269+
'error': 'target_text must occur exactly once',
1270+
'match_count': match_count,
1271+
},
1272+
ensure_ascii=False,
1273+
)
1274+
1275+
update_data = {
1276+
'data': {
1277+
**(note.data or {}),
1278+
'content': {
1279+
**((note.data or {}).get('content') or {}),
1280+
'json': None,
1281+
'html': '',
1282+
'md': content.replace(target_text, replacement_text, 1),
1283+
},
1284+
}
1285+
}
1286+
if title:
1287+
update_data['title'] = title
1288+
1289+
updated_note = await Notes.update_note_by_id(note_id, NoteUpdateForm(**update_data))
1290+
if not updated_note:
1291+
return json.dumps({'error': 'Failed to update note'})
1292+
1293+
await _emit_note_updated(__request__, __user__, updated_note)
1294+
1295+
return json.dumps(
1296+
{
1297+
'status': 'success',
1298+
'id': updated_note.id,
1299+
'title': updated_note.title,
1300+
'updated_at': updated_note.updated_at,
1301+
},
1302+
ensure_ascii=False,
1303+
)
1304+
except Exception as e:
1305+
log.exception(f'replace_note_text error: {e}')
1306+
return json.dumps({'error': str(e)})
1307+
1308+
11981309
# =============================================================================
11991310
# CHATS TOOLS
12001311
# =============================================================================

backend/open_webui/utils/middleware.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1881,6 +1881,7 @@ def apply_params_to_form_data(form_data, model):
18811881
'reasoning_tags': list,
18821882
'compact_token_threshold': int,
18831883
'system': str,
1884+
'note_id': str,
18841885
}
18851886

18861887
for key in list(params.keys()):

backend/open_webui/utils/tools.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
read_memory_path,
7373
replace_memory_content,
7474
replace_note_content,
75+
replace_note_text,
7576
search_calendar_events,
7677
search_channel_messages,
7778
search_channels,
@@ -635,7 +636,7 @@ async def has_user_permission(feature_key: str) -> bool:
635636

636637
# Notes tools - search, view, create, and update user's notes
637638
if is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes'):
638-
builtin_functions.extend([search_notes, view_note, write_note, replace_note_content])
639+
builtin_functions.extend([search_notes, view_note, write_note, replace_note_content, replace_note_text])
639640

640641
# Channels tools - search channels and messages
641642
if is_builtin_tool_enabled('channels') and config.get('channels.enable') and await has_user_permission('channels'):

src/app.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ textarea::-webkit-scrollbar-corner {
122122
}
123123

124124
.markdown-prose {
125-
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
125+
@apply prose dark:prose-invert max-w-none break-words font-normal leading-relaxed prose-p:my-0 prose-p:font-normal prose-p:leading-relaxed prose-headings:my-1 prose-headings:font-normal prose-headings:leading-snug prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 prose-li:font-normal prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-blockquote:font-normal prose-hr:my-4 prose-img:my-1 [&>:first-child]:mt-0 [&>:last-child]:mb-0;
126126
}
127127

128128
.markdown-prose-sm {

0 commit comments

Comments
 (0)