From 260f3c3a22c55f15ca8f06c5314b23f2a9eb1739 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 05:56:08 -0500 Subject: [PATCH] refac --- backend/open_webui/routers/memories.py | 139 +++++++++++++----- backend/open_webui/tools/builtin.py | 97 +++++++----- backend/open_webui/utils/memory.py | 85 +++++++++-- src/lib/apis/memories/index.ts | 10 +- .../Personalization/AddMemoryModal.svelte | 116 --------------- .../Personalization/ManageModal.svelte | 39 +++-- ...tMemoryModal.svelte => MemoryModal.svelte} | 75 ++++++---- 7 files changed, 302 insertions(+), 259 deletions(-) delete mode 100644 src/lib/components/chat/Settings/Personalization/AddMemoryModal.svelte rename src/lib/components/chat/Settings/Personalization/{EditMemoryModal.svelte => MemoryModal.svelte} (65%) diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index 10d2a86f16..0ea21c1ed0 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio import logging -from typing import Literal, Optional +from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.constants import ERROR_MESSAGES @@ -14,7 +14,7 @@ from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX from open_webui.utils.access_control import has_permission from open_webui.utils.auth import get_verified_user -from open_webui.utils.memory import clean_memory_content, validate_memory_operations +from open_webui.utils.memory import clean_memory_content, clean_memory_path, memory_vector_text, validate_memory_operations from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -66,22 +66,43 @@ async def get_memories( class AddMemoryForm(BaseModel): content: str type: Literal['user', 'context'] = 'context' + path: str | None = None class MemoryUpdateModel(BaseModel): content: str | None = None type: Literal['user', 'context'] | None = None + path: str | None = None class MemoryOperationModel(BaseModel): - action: Literal['add', 'replace', 'remove'] + action: Literal['add', 'replace', 'remove', 'move'] id: str | None = None content: str | None = None type: Literal['user', 'context'] | None = None + path: str | None = None class UpdateMemoriesForm(BaseModel): operations: list[MemoryOperationModel] + source: Literal['tool', 'background_review'] | None = None + + +class SearchMemoriesForm(BaseModel): + query: str | None = None + type: Literal['user', 'context', 'all'] = 'all' + path: str | None = None + memory_id: str | None = None + limit: int = 20 + + +def _memory_metadata(memory: MemoryModel) -> dict: + return { + 'created_at': memory.created_at, + 'updated_at': memory.updated_at, + 'type': memory.type, + 'path': memory.path, + } @router.post('/add', response_model=MemoryModel | None) @@ -99,22 +120,25 @@ async def add_memory( await check_memories_permission(user) content = clean_memory_content(form_data.content) - memory = await Memories.insert_new_memory(user.id, content, memory_type=form_data.type) + path = clean_memory_path(form_data.path) + memory = await Memories.insert_new_memory( + user.id, + content, + memory_type=form_data.type, + path=path, + meta={'created_by': 'manual'}, + ) - vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) + vector = await request.app.state.EMBEDDING_FUNCTION(memory_vector_text(memory.content, memory.path), user=user) await ASYNC_VECTOR_DB_CLIENT.upsert( collection_name=f'user-memory-{user.id}', items=[ { 'id': memory.id, - 'text': memory.content, + 'text': memory_vector_text(memory.content, memory.path), 'vector': vector, - 'metadata': { - 'created_at': memory.created_at, - 'updated_at': memory.updated_at, - 'type': memory.type, - }, + 'metadata': _memory_metadata(memory), } ], ) @@ -124,7 +148,7 @@ async def add_memory( EVENTS.MEMORY_CREATED, actor=user, subject_id=memory.id, - data={'content_preview': memory.content[:300], 'type': memory.type}, + data={'content_preview': memory.content[:300], 'type': memory.type, 'path': memory.path}, ) return memory @@ -138,6 +162,16 @@ async def update_memories( await check_memories_permission(user) operations = validate_memory_operations(form_data) + metadata = getattr(request.state, 'metadata', {}) or {} + source = form_data.source or 'tool' + for operation in operations: + if operation.get('action') in {'add', 'replace', 'move'}: + operation['meta'] = { + 'created_by': source, + 'chat_id': metadata.get('chat_id'), + 'message_id': metadata.get('message_id'), + 'model': metadata.get('model'), + } try: results = await Memories.apply_memory_operations(user.id, operations) @@ -153,17 +187,16 @@ async def update_memories( if isinstance(memory, MemoryModel): result = {**result, 'memory': memory.model_dump()} if result.get('status') in {'created', 'updated'}: - vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) + vector = await request.app.state.EMBEDDING_FUNCTION( + memory_vector_text(memory.content, memory.path), + user=user, + ) upsert_items.append( { 'id': memory.id, - 'text': memory.content, + 'text': memory_vector_text(memory.content, memory.path), 'vector': vector, - 'metadata': { - 'created_at': memory.created_at, - 'updated_at': memory.updated_at, - 'type': memory.type, - }, + 'metadata': _memory_metadata(memory), } ) if result.get('status') == 'deleted' and result.get('id'): @@ -198,6 +231,7 @@ async def update_memories( data={ 'content_preview': (memory.get('content') or '')[:300], 'type': memory.get('type'), + 'path': memory.get('path'), 'operation': result.get('action'), }, ) @@ -274,6 +308,33 @@ async def query_memory( return results +@router.post('/search', response_model=list[MemoryModel]) +async def search_memories( + form_data: SearchMemoriesForm, + user=Depends(get_verified_user), +): + await check_memories_permission(user) + + memories = await Memories.get_memories_by_user_id(user.id) + if form_data.memory_id: + memories = [memory for memory in memories if memory.id == form_data.memory_id] + if form_data.type != 'all': + memories = [memory for memory in memories if memory.type == form_data.type] + path = clean_memory_path(form_data.path) + if path: + memories = [memory for memory in memories if (memory.path or '').startswith(path)] + query = (form_data.query or '').strip().lower() + if query: + memories = [ + memory + for memory in memories + if query in memory.content.lower() or query in (memory.path or '').lower() + ] + + limit = max(1, min(form_data.limit or 20, 100)) + return sorted(memories, key=lambda memory: memory.updated_at, reverse=True)[:limit] + + ############################ # ResetMemoryFromVectorDB ############################ @@ -298,7 +359,10 @@ async def reset_memory_from_vector_db( # Generate vectors in parallel vectors = await asyncio.gather( - *[request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) for memory in memories] + *[ + request.app.state.EMBEDDING_FUNCTION(memory_vector_text(memory.content, memory.path), user=user) + for memory in memories + ] ) await ASYNC_VECTOR_DB_CLIENT.upsert( @@ -306,13 +370,9 @@ async def reset_memory_from_vector_db( items=[ { 'id': memory.id, - 'text': memory.content, + 'text': memory_vector_text(memory.content, memory.path), 'vector': vectors[idx], - 'metadata': { - 'created_at': memory.created_at, - 'updated_at': memory.updated_at, - 'type': memory.type, - }, + 'metadata': _memory_metadata(memory), } for idx, memory in enumerate(memories) ], @@ -378,27 +438,32 @@ async def update_memory_by_id( await check_memories_permission(user) content = clean_memory_content(form_data.content) if form_data.content is not None else None - if content is None and form_data.type is None: + path = clean_memory_path(form_data.path) + if content is None and form_data.type is None and form_data.path is None: raise HTTPException(status_code=400, detail='No memory update provided') - memory = await Memories.update_memory_by_id_and_user_id(memory_id, user.id, content, memory_type=form_data.type) + memory = await Memories.update_memory_by_id_and_user_id( + memory_id, + user.id, + content, + memory_type=form_data.type, + path=path, + update_path=form_data.path is not None, + meta={'created_by': 'manual'}, + ) if memory is None: raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND) - if form_data.content is not None: - vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) + if form_data.content is not None or form_data.path is not None: + vector = await request.app.state.EMBEDDING_FUNCTION(memory_vector_text(memory.content, memory.path), user=user) await ASYNC_VECTOR_DB_CLIENT.upsert( collection_name=f'user-memory-{user.id}', items=[ { 'id': memory.id, - 'text': memory.content, + 'text': memory_vector_text(memory.content, memory.path), 'vector': vector, - 'metadata': { - 'created_at': memory.created_at, - 'updated_at': memory.updated_at, - 'type': memory.type, - }, + 'metadata': _memory_metadata(memory), } ], ) @@ -408,7 +473,7 @@ async def update_memory_by_id( EVENTS.MEMORY_UPDATED, actor=user, subject_id=memory.id, - data={'content_preview': memory.content[:300], 'type': memory.type}, + data={'content_preview': memory.content[:300], 'type': memory.type, 'path': memory.path}, ) return memory diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 72bf809661..04ea94026d 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -35,9 +35,9 @@ from open_webui.routers.images import ( from open_webui.routers.memories import ( AddMemoryForm, MemoryUpdateModel, - QueryMemoryForm, + SearchMemoriesForm, UpdateMemoriesForm, - query_memory, + search_memories as _search_memories, update_memories as _update_memories, update_memory_by_id, ) @@ -592,17 +592,22 @@ async def execute_code( async def search_memories( - query: str, + query: str = '', count: int = 5, + type: str = 'all', + path: Optional[str] = None, + memory_id: Optional[str] = None, __request__: Request = None, __user__: dict = None, ) -> str: """ - Search saved user memories for preferences, facts, or prior context that - may help personalize the answer. + Search or browse saved user memories. - :param query: The search query to find relevant memories + :param query: Optional query to search memory content and path :param count: Number of memories to return (default 5) + :param type: "user", "context", or "all" + :param path: Optional logical memory path prefix + :param memory_id: Optional exact memory ID to read :return: JSON with matching memories and their dates """ if __request__ is None: @@ -611,41 +616,34 @@ async def search_memories( try: user = UserModel(**__user__) if __user__ else None - memory_results = await query_memory( - __request__, - QueryMemoryForm(content=query, k=count), + memories = await _search_memories( + SearchMemoriesForm( + query=query or None, + type=type if type in {'user', 'context', 'all'} else 'all', + path=path, + memory_id=memory_id, + limit=count, + ), user, ) - if not memory_results or not hasattr(memory_results, 'documents') or not memory_results.documents: + if not memories: return json.dumps([]) - memories = [] - for memory_index, memory_text in enumerate(memory_results.documents[0]): - memory_id = None - if memory_results.ids and memory_results.ids[0]: - memory_id = memory_results.ids[0][memory_index] - - metadata = {} - if memory_results.metadatas and memory_results.metadatas[0] and len(memory_results.metadatas[0]) > memory_index: - metadata = memory_results.metadatas[0][memory_index] or {} - - created_at = 'Unknown' - if metadata.get('created_at'): - created_at = time.strftime( - '%Y-%m-%d', - time.localtime(metadata['created_at']), - ) - - memories.append( + return json.dumps( + [ { - 'id': memory_id, - 'type': Memories.normalize_memory_type(metadata.get('type')), - 'date': created_at, - 'content': memory_text, + 'id': memory.id, + 'type': memory.type, + 'path': memory.path, + 'content': memory.content, + 'created_at': time.strftime('%Y-%m-%d', time.localtime(memory.created_at)), + 'updated_at': time.strftime('%Y-%m-%d', time.localtime(memory.updated_at)), } - ) - return json.dumps(memories, ensure_ascii=False) + for memory in memories + ], + ensure_ascii=False, + ) except Exception as e: log.exception(f'search_memories error: {e}') return json.dumps({'error': str(e)}) @@ -654,6 +652,7 @@ async def search_memories( async def add_memory( content: str, type: str = 'user', + path: Optional[str] = None, __request__: Request = None, __user__: dict = None, ) -> str: @@ -662,6 +661,7 @@ async def add_memory( :param content: The memory content to store :param type: Use "user" for facts/preferences about the user, or "context" for other durable context + :param path: Optional logical memory path :return: Confirmation that the memory was stored """ if __request__ is None: @@ -672,11 +672,14 @@ async def add_memory( memory = await _add_memory( __request__, - AddMemoryForm(content=content, type=Memories.normalize_memory_type(type)), + AddMemoryForm(content=content, type=Memories.normalize_memory_type(type), path=path), user, ) - return json.dumps({'status': 'success', 'id': memory.id, 'type': memory.type}, ensure_ascii=False) + return json.dumps( + {'status': 'success', 'id': memory.id, 'type': memory.type, 'path': memory.path}, + ensure_ascii=False, + ) except Exception as e: log.exception(f'add_memory error: {e}') return json.dumps({'error': str(e)}) @@ -694,8 +697,9 @@ async def update_memory( Use type "context" for other durable context that may help future chats. Operation shapes: - - {"action": "add", "content": "...", "type": "user"|"context"} - - {"action": "replace", "id": "...", "content": "...", "type": "user"|"context"} + - {"action": "add", "content": "...", "type": "user"|"context", "path": "..."} + - {"action": "replace", "id": "...", "content": "...", "type": "user"|"context", "path": "..."} + - {"action": "move", "id": "...", "path": "..."} - {"action": "remove", "id": "..."} :param operations: Memory operations to apply in one request @@ -721,6 +725,7 @@ async def replace_memory_content( memory_id: str, content: str, type: Optional[str] = None, + path: Optional[str] = None, __request__: Request = None, __user__: dict = None, ) -> str: @@ -730,6 +735,7 @@ async def replace_memory_content( :param memory_id: The ID of the memory to update :param content: The new content for the memory :param type: Optional "user" or "context" type for the updated memory + :param path: Optional logical memory path :return: Confirmation that the memory was updated """ if __request__ is None: @@ -741,12 +747,22 @@ async def replace_memory_content( memory = await update_memory_by_id( memory_id=memory_id, request=__request__, - form_data=MemoryUpdateModel(content=content, type=Memories.normalize_memory_type(type) if type else None), + form_data=MemoryUpdateModel( + content=content, + type=Memories.normalize_memory_type(type) if type else None, + path=path, + ), user=user, ) return json.dumps( - {'status': 'success', 'id': memory.id, 'type': memory.type, 'content': memory.content}, + { + 'status': 'success', + 'id': memory.id, + 'type': memory.type, + 'path': memory.path, + 'content': memory.content, + }, ensure_ascii=False, ) except Exception as e: @@ -808,6 +824,7 @@ async def list_memories( { 'id': m.id, 'type': m.type, + 'path': m.path, 'content': m.content, 'created_at': time.strftime('%Y-%m-%d %H:%M', time.localtime(m.created_at)), 'updated_at': time.strftime('%Y-%m-%d %H:%M', time.localtime(m.updated_at)), diff --git a/backend/open_webui/utils/memory.py b/backend/open_webui/utils/memory.py index 8c0a3c38f9..bfefea2f3a 100644 --- a/backend/open_webui/utils/memory.py +++ b/backend/open_webui/utils/memory.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import json import logging +import re from typing import Any from fastapi import HTTPException @@ -24,6 +25,21 @@ def clean_memory_content(content: str | None) -> str: return value +def clean_memory_path(path: str | None) -> str | None: + value = re.sub(r'/+', '/', (path or '').strip().strip('/')) + if not value: + return None + parts = value.split('/') + if any(part in {'', '.', '..'} for part in parts) or any(ord(char) < 32 for char in value): + raise HTTPException(status_code=400, detail='Invalid memory path') + return value + + +def memory_vector_text(content: str, path: str | None = None) -> str: + path = clean_memory_path(path) + return f'{path}\n{content}' if path else content + + def validate_memory_operations(form_data) -> list[dict]: if not form_data.operations: raise HTTPException(status_code=400, detail='No memory operations provided') @@ -36,12 +52,18 @@ def validate_memory_operations(form_data) -> list[dict]: if action == 'add': op['content'] = clean_memory_content(op.get('content')) op['type'] = Memories.normalize_memory_type(op.get('type')) + op['path'] = clean_memory_path(op.get('path')) elif action == 'replace': if not op.get('id'): raise HTTPException(status_code=400, detail='Memory id is required for replace') op['content'] = clean_memory_content(op.get('content')) if op.get('type') is not None: op['type'] = Memories.normalize_memory_type(op.get('type')) + op['path'] = clean_memory_path(op.get('path')) + elif action == 'move': + if not op.get('id'): + raise HTTPException(status_code=400, detail='Memory id is required for move') + op['path'] = clean_memory_path(op.get('path')) elif action == 'remove': if not op.get('id'): raise HTTPException(status_code=400, detail='Memory id is required for remove') @@ -77,15 +99,24 @@ async def add_memory_context(request, form_data: dict, user, model: dict | None if not query: return form_data + all_memories = await Memories.get_memories_by_user_id(user.id) + results = None try: from open_webui.routers.memories import QueryMemoryForm, query_memory - results = await query_memory(request, QueryMemoryForm(content=query, k=6), user) + results = await query_memory(request, QueryMemoryForm(content=query, k=8), user) except Exception as e: log.debug(e) - return form_data sections = {'user': [], 'context': []} + seen_ids = set() + for memory in sorted( + [memory for memory in (all_memories or []) if memory.type == 'user'], + key=lambda item: (item.path or '', item.updated_at), + ): + seen_ids.add(memory.id) + sections['user'].append(f'{memory.path}: {memory.content}' if memory.path else memory.content) + if results and hasattr(results, 'documents') and results.documents: for doc_idx, doc in enumerate(results.documents[0]): if not doc: @@ -95,21 +126,37 @@ async def add_memory_context(request, form_data: dict, user, model: dict | None if results.metadatas and results.metadatas[0] and len(results.metadatas[0]) > doc_idx: metadata = results.metadatas[0][doc_idx] or {} - sections[Memories.normalize_memory_type(metadata.get('type'))].append(str(doc)) + memory_id = None + if results.ids and results.ids[0] and len(results.ids[0]) > doc_idx: + memory_id = results.ids[0][doc_idx] + if memory_id and memory_id in seen_ids: + continue + if memory_id: + seen_ids.add(memory_id) + + content = str(doc) + if metadata.get('path') and content.startswith(f"{metadata.get('path')}\n"): + content = content[len(metadata.get('path')) + 1 :] + label = f"{metadata.get('path')}: {content}" if metadata.get('path') else content + sections[Memories.normalize_memory_type(metadata.get('type'))].append(label) parts = [] if sections['user']: - parts.append('User:\n' + '\n'.join(f'- {memory}' for memory in sections['user'])) + parts.append('[User Memory]\n' + '\n'.join(f'- {memory}' for memory in sections['user'])) if sections['context']: - parts.append('Context:\n' + '\n'.join(f'- {memory}' for memory in sections['context'])) + parts.append('[Relevant Context]\n' + '\n'.join(f'- {memory}' for memory in sections['context'])) if not parts: return form_data - limit = await Config.get('memories.context_char_limit', 2000) + config = await Config.get_many('memories.user_char_limit', 'memories.context_char_limit') try: - limit = max(250, int(limit)) + user_limit = max(250, int(config.get('memories.user_char_limit') or 2000)) except Exception: - limit = 2000 + user_limit = 2000 + try: + context_limit = max(250, int(config.get('memories.context_char_limit') or 2000)) + except Exception: + context_limit = 2000 messages = form_data['messages'] if messages and messages[0].get('role') == 'system': @@ -120,7 +167,16 @@ async def add_memory_context(request, form_data: dict, user, model: dict | None if end != -1: messages[0]['content'] = (content[:start] + content[end + len(MEMORY_CONTEXT_CLOSE) :]).strip() - memory_context = f'{MEMORY_CONTEXT_OPEN}\n' + '\n\n'.join(parts)[:limit] + f'\n{MEMORY_CONTEXT_CLOSE}' + rendered = '\n\n'.join( + [ + parts[0][:user_limit] if parts and parts[0].startswith('[User Memory]') else '', + parts[-1][:context_limit] if parts and parts[-1].startswith('[Relevant Context]') else '', + ] + ).strip() + if not rendered: + return form_data + + memory_context = f'{MEMORY_CONTEXT_OPEN}\n{rendered}\n{MEMORY_CONTEXT_CLOSE}' form_data['messages'] = add_or_update_system_message(memory_context, messages, append=True) return form_data @@ -195,7 +251,7 @@ async def _review_memory( ) -> None: existing_memories = await Memories.get_memories_by_user_id(user.id) existing_lines = [ - f'- id={memory.id} type={memory.type} content={memory.content}' + f'- id={memory.id} type={memory.type} path={memory.path or ""} content={memory.content}' for memory in (existing_memories or [])[:80] ] @@ -234,7 +290,7 @@ async def _review_memory( if operations: from open_webui.routers.memories import UpdateMemoriesForm, update_memories - await update_memories(request, UpdateMemoriesForm(operations=operations), user) + await update_memories(request, UpdateMemoriesForm(operations=operations, source='background_review'), user) async def _generate_memory_operations( @@ -257,12 +313,13 @@ Memory types: Rules: - Save only information likely to matter in future chats. - Do not save secrets, credentials, transient task steps, or unsupported guesses. -- Prefer replace/remove over duplicate add when an existing memory should change. +- Prefer replace/move/remove over duplicate add when an existing memory should change. - Do not invent type, status, trait, score, importance, or stability schemas. - Return only JSON in this shape: {{"operations":[ - {{"action":"add","type":"user|context","content":"..."}}, - {{"action":"replace","id":"...","type":"user|context","content":"..."}}, + {{"action":"add","type":"user|context","path":"...","content":"..."}}, + {{"action":"replace","id":"...","type":"user|context","path":"...","content":"..."}}, + {{"action":"move","id":"...","path":"..."}}, {{"action":"remove","id":"..."}} ]}} - Use an empty operations array if nothing should be remembered. diff --git a/src/lib/apis/memories/index.ts b/src/lib/apis/memories/index.ts index 0c32233e96..5ebee8fb47 100644 --- a/src/lib/apis/memories/index.ts +++ b/src/lib/apis/memories/index.ts @@ -28,7 +28,7 @@ export const getMemories = async (token: string) => { return res; }; -export const addNewMemory = async (token: string, content: string, type = 'user') => { +export const addNewMemory = async (token: string, content: string, type = 'user', path = '') => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/memories/add`, { @@ -40,7 +40,8 @@ export const addNewMemory = async (token: string, content: string, type = 'user' }, body: JSON.stringify({ content: content, - type + type, + path }) }) .then(async (res) => { @@ -64,10 +65,11 @@ export const updateMemoryById = async ( token: string, id: string, content: string, - type?: string + type?: string, + path?: string ) => { let error = null; - const body = type ? { content, type } : { content }; + const body = { content, ...(type ? { type } : {}), ...(path !== undefined ? { path } : {}) }; const res = await fetch(`${WEBUI_API_BASE_URL}/memories/${id}/update`, { method: 'POST', diff --git a/src/lib/components/chat/Settings/Personalization/AddMemoryModal.svelte b/src/lib/components/chat/Settings/Personalization/AddMemoryModal.svelte deleted file mode 100644 index 045ca5bacb..0000000000 --- a/src/lib/components/chat/Settings/Personalization/AddMemoryModal.svelte +++ /dev/null @@ -1,116 +0,0 @@ - - - -
-
-
- {$i18n.t('Add Memory')} -
- -
- -
-
-
{ - submitHandler(); - }} - > -
-
-
{$i18n.t('Type')}
- - -
- -