This commit is contained in:
Timothy Jaeryang Baek committed 2026-06-29 11:13:36 -05:00
1 parent 81e245548d
commit 2c4e1fce8f
4 files changed
+374 -26

No files matched your search

+66 -17
View File
@@ -14,7 +14,15 @@ from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX
from open_webui.utils.access_control import has_permission from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_verified_user from open_webui.utils.auth import get_verified_user
from open_webui.utils.memory import clean_memory_content, clean_memory_path, memory_vector_text, validate_memory_operations from open_webui.utils.memory import (
clean_memory_content,
clean_memory_path,
list_memory_path_groups,
memory_vector_text,
read_memory_path_rows,
search_memory_rows,
validate_memory_operations,
)
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -96,6 +104,19 @@ class SearchMemoriesForm(BaseModel):
limit: int = 20 limit: int = 20
class ListMemoryPathsForm(BaseModel):
query: str | None = None
type: Literal['user', 'context', 'all'] = 'all'
limit: int = 100
class ReadMemoryPathForm(BaseModel):
path: str
type: Literal['user', 'context', 'all'] = 'all'
include_children: bool = True
limit: int = 50
def _memory_metadata(memory: MemoryModel) -> dict: def _memory_metadata(memory: MemoryModel) -> dict:
return { return {
'created_at': memory.created_at, 'created_at': memory.created_at,
@@ -316,23 +337,51 @@ async def search_memories(
await check_memories_permission(user) await check_memories_permission(user)
memories = await Memories.get_memories_by_user_id(user.id) memories = await Memories.get_memories_by_user_id(user.id)
if form_data.memory_id: return search_memory_rows(
memories = [memory for memory in memories if memory.id == form_data.memory_id] memories,
if form_data.type != 'all': query=form_data.query,
memories = [memory for memory in memories if memory.type == form_data.type] path=form_data.path,
path = clean_memory_path(form_data.path) memory_id=form_data.memory_id,
if path: memory_type=form_data.type,
memories = [memory for memory in memories if (memory.path or '').startswith(path)] limit=form_data.limit,
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] @router.post('/paths')
async def list_memory_paths(
form_data: ListMemoryPathsForm,
user=Depends(get_verified_user),
):
await check_memories_permission(user)
memories = await Memories.get_memories_by_user_id(user.id)
return list_memory_path_groups(
memories,
query=form_data.query or '',
memory_type=form_data.type,
limit=form_data.limit,
)
@router.post('/path')
async def read_memory_path(
form_data: ReadMemoryPathForm,
user=Depends(get_verified_user),
):
await check_memories_permission(user)
memories = await Memories.get_memories_by_user_id(user.id)
result = read_memory_path_rows(
memories,
path=form_data.path,
memory_type=form_data.type,
include_children=form_data.include_children,
limit=form_data.limit,
)
return {
**result,
'memories': [memory.model_dump() for memory in result['memories']],
}
############################ ############################
+76 -5
View File
@@ -34,9 +34,13 @@ from open_webui.routers.images import (
) )
from open_webui.routers.memories import ( from open_webui.routers.memories import (
AddMemoryForm, AddMemoryForm,
ListMemoryPathsForm,
MemoryUpdateModel, MemoryUpdateModel,
ReadMemoryPathForm,
SearchMemoriesForm, SearchMemoriesForm,
UpdateMemoriesForm, UpdateMemoriesForm,
list_memory_paths as _list_memory_paths,
read_memory_path as _read_memory_path,
search_memories as _search_memories, search_memories as _search_memories,
update_memories as _update_memories, update_memories as _update_memories,
update_memory_by_id, update_memory_by_id,
@@ -591,6 +595,71 @@ async def execute_code(
# ============================================================================= # =============================================================================
async def list_memory_paths(
query: str = '',
count: int = 100,
type: str = 'all',
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
List saved memory paths to find existing memory groups before writing or moving memories.
:param query: Optional query to filter memory paths or contents
:param count: Maximum number of paths to return
:param type: "user", "context", or "all"
:return: JSON with memory paths, counts, children, and update times
"""
try:
user = UserModel(**__user__) if __user__ else None
result = await _list_memory_paths(
ListMemoryPathsForm(
query=query or None,
type=type if type in {'user', 'context', 'all'} else 'all',
limit=count,
),
user,
)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
log.exception(f'list_memory_paths error: {e}')
return json.dumps({'error': str(e)})
async def read_memory_path(
path: str,
count: int = 50,
type: str = 'all',
include_children: bool = True,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Read saved memories at a memory path, including nearby parent and child paths.
:param path: Memory path to read
:param count: Maximum number of memories to return
:param type: "user", "context", or "all"
:param include_children: Include memories under child paths
:return: JSON with parent paths, child paths, and memories at the path
"""
try:
user = UserModel(**__user__) if __user__ else None
result = await _read_memory_path(
ReadMemoryPathForm(
path=path,
type=type if type in {'user', 'context', 'all'} else 'all',
include_children=include_children,
limit=count,
),
user,
)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
log.exception(f'read_memory_path error: {e}')
return json.dumps({'error': str(e)})
async def search_memories( async def search_memories(
query: str = '', query: str = '',
count: int = 5, count: int = 5,
@@ -601,12 +670,12 @@ async def search_memories(
__user__: dict = None, __user__: dict = None,
) -> str: ) -> str:
""" """
Search or browse saved user memories. Search or browse saved memories by content, path, type, or memory ID.
:param query: Optional query to search memory content and path :param query: Optional query to search memory content and path
:param count: Number of memories to return (default 5) :param count: Number of memories to return (default 5)
:param type: "user", "context", or "all" :param type: "user", "context", or "all"
:param path: Optional memory path prefix :param path: Optional memory path to search around
:param memory_id: Optional exact memory ID to read :param memory_id: Optional exact memory ID to read
:return: JSON with matching memories and their dates :return: JSON with matching memories and their dates
""" """
@@ -661,7 +730,7 @@ async def add_memory(
:param content: The memory content to store :param content: The memory content to store
:param type: Use "user" for facts/preferences about the user, or "context" for other durable context :param type: Use "user" for facts/preferences about the user, or "context" for other durable context
:param path: Optional memory path :param path: Optional stable memory address for grouping related memories
:return: Confirmation that the memory was stored :return: Confirmation that the memory was stored
""" """
if __request__ is None: if __request__ is None:
@@ -695,7 +764,9 @@ async def update_memory(
Use type "user" for facts, preferences, or instructions about the user. Use type "user" for facts, preferences, or instructions about the user.
Use type "context" for other durable context that may help future chats. Use type "context" for other durable context that may help future chats.
Use path when there is a clear path for the memory. Leave path empty when unsure. Path is optional. Use it as a stable memory address to group related memories.
Prefer an existing path from list_memory_paths when one fits.
Leave path empty when no useful grouping is clear.
Operation shapes: Operation shapes:
- {"action": "add", "content": "...", "type": "user"|"context", "path": "..."} - {"action": "add", "content": "...", "type": "user"|"context", "path": "..."}
@@ -736,7 +807,7 @@ async def replace_memory_content(
:param memory_id: The ID of the memory to update :param memory_id: The ID of the memory to update
:param content: The new content for the memory :param content: The new content for the memory
:param type: Optional "user" or "context" type for the updated memory :param type: Optional "user" or "context" type for the updated memory
:param path: Optional memory path :param path: Optional stable memory address for grouping related memories
:return: Confirmation that the memory was updated :return: Confirmation that the memory was updated
""" """
if __request__ is None: if __request__ is None:
+228 -4
View File
@@ -40,6 +40,214 @@ def memory_vector_text(content: str, path: str | None = None) -> str:
return f'{path}\n{content}' if path else content return f'{path}\n{content}' if path else content
def memory_label(memory) -> str:
return f'{memory.path}: {memory.content}' if memory.path else memory.content
def _path_parts(path: str | None) -> list[str]:
return [part for part in (path or '').split('/') if part]
def _parent_path(path: str | None) -> str | None:
parts = _path_parts(path)
return '/'.join(parts[:-1]) if len(parts) > 1 else None
def _path_rank(memory_path: str | None, lookup_path: str | None) -> tuple | None:
if not lookup_path:
return None
memory_path = clean_memory_path(memory_path)
lookup_path = clean_memory_path(lookup_path)
if not memory_path or not lookup_path:
return None
if memory_path == lookup_path:
return (0, 0)
if memory_path.startswith(f'{lookup_path}/'):
return (1, len(_path_parts(memory_path)) - len(_path_parts(lookup_path)))
if lookup_path.startswith(f'{memory_path}/'):
return (2, len(_path_parts(lookup_path)) - len(_path_parts(memory_path)))
if _parent_path(memory_path) and _parent_path(memory_path) == _parent_path(lookup_path):
return (3, 0)
memory_parts = set(_path_parts(memory_path))
lookup_parts = set(_path_parts(lookup_path))
shared = len(memory_parts & lookup_parts)
if shared:
return (4, -shared)
if _path_parts(memory_path)[-1:] == _path_parts(lookup_path)[-1:]:
return (5, 0)
return None
def _memory_matches_query(memory, query: str) -> bool:
value = query.strip().lower()
if not value:
return True
return value in (memory.content or '').lower() or value in (memory.path or '').lower()
def search_memory_rows(
memories: list,
*,
query: str | None = None,
path: str | None = None,
memory_id: str | None = None,
memory_type: str = 'all',
limit: int = 20,
) -> list:
rows = list(memories or [])
if memory_id:
rows = [memory for memory in rows if memory.id == memory_id]
if memory_type != 'all':
rows = [memory for memory in rows if memory.type == memory_type]
query = (query or '').strip()
lookup_path = clean_memory_path(path)
if lookup_path:
basename = _path_parts(lookup_path)[-1] if _path_parts(lookup_path) else lookup_path
def related(memory) -> bool:
rank = _path_rank(memory.path, lookup_path)
if rank is not None:
return True
haystack = f'{memory.path or ""}\n{memory.content or ""}'.lower()
return lookup_path.lower() in haystack or basename.lower() in haystack
rows = [memory for memory in rows if related(memory)]
if query:
rows = [memory for memory in rows if _memory_matches_query(memory, query)]
def sort_key(memory):
rank = _path_rank(memory.path, lookup_path) if lookup_path else None
return rank if rank is not None else (9, 0), -(memory.updated_at or 0)
return sorted(rows, key=sort_key)[: max(1, min(limit or 20, 100))]
def list_memory_path_groups(
memories: list,
*,
query: str = '',
memory_type: str = 'all',
limit: int = 100,
) -> dict:
rows = [
memory
for memory in (memories or [])
if (memory_type == 'all' or memory.type == memory_type) and _memory_matches_query(memory, query)
]
grouped: dict[tuple[str | None, str], dict] = {}
for memory in rows:
key = (memory.path, memory.type)
group = grouped.setdefault(
key,
{
'path': memory.path,
'type': memory.type,
'count': 0,
'updated_at': 0,
'children': [],
},
)
group['count'] += 1
group['updated_at'] = max(group['updated_at'], memory.updated_at or 0)
paths = [path for path, _ in grouped if path]
for group in grouped.values():
path = group['path']
if not path:
continue
prefix = f'{path}/'
children = []
for candidate in paths:
if not candidate.startswith(prefix):
continue
remainder = candidate[len(prefix) :]
child = f'{prefix}{remainder.split("/", 1)[0]}'
if child not in children:
children.append(child)
group['children'] = children[:20]
groups = sorted(grouped.values(), key=lambda item: item['updated_at'], reverse=True)
return {'paths': groups[: max(1, min(limit or 100, 500))], 'count': len(groups)}
def read_memory_path_rows(
memories: list,
*,
path: str,
memory_type: str = 'all',
include_children: bool = True,
limit: int = 50,
) -> dict:
lookup_path = clean_memory_path(path)
if not lookup_path:
raise HTTPException(status_code=400, detail='Memory path is required')
rows = [memory for memory in (memories or []) if memory_type == 'all' or memory.type == memory_type]
path_set = {memory.path for memory in rows if memory.path}
parents = [
'/'.join(_path_parts(lookup_path)[:idx])
for idx in range(1, len(_path_parts(lookup_path)))
if '/'.join(_path_parts(lookup_path)[:idx]) in path_set
]
children = sorted(
{
f'{lookup_path}/{memory.path[len(lookup_path) + 1 :].split("/", 1)[0]}'
for memory in rows
if memory.path and memory.path.startswith(f'{lookup_path}/')
}
)
def selected(memory) -> bool:
if memory.path == lookup_path:
return True
if memory.path in parents:
return True
return bool(include_children and memory.path and memory.path.startswith(f'{lookup_path}/'))
selected_rows = [memory for memory in rows if selected(memory)]
def sort_key(memory):
if memory.path == lookup_path:
return (0, 0, -(memory.updated_at or 0))
if memory.path and memory.path.startswith(f'{lookup_path}/'):
return (1, len(_path_parts(memory.path)), -(memory.updated_at or 0))
return (2, -len(_path_parts(memory.path)), -(memory.updated_at or 0))
return {
'path': lookup_path,
'parents': parents,
'children': children[:50],
'memories': sorted(selected_rows, key=sort_key)[: max(1, min(limit or 50, 100))],
}
def memory_path_hints(query: str, memories: list, limit: int = 6) -> list[str]:
lowered = (query or '').lower()
if not lowered:
return []
hints: list[str] = []
for memory in memories or []:
path = memory.path
if not path or path in hints:
continue
parts = _path_parts(path)
last = parts[-1] if parts else path
if path.lower() in lowered or last.lower() in lowered:
hints.append(path)
elif any(len(part) >= 3 and part.lower() in lowered for part in parts):
hints.append(path)
if len(hints) >= limit:
break
return hints
def validate_memory_operations(form_data) -> list[dict]: def validate_memory_operations(form_data) -> list[dict]:
if not form_data.operations: if not form_data.operations:
raise HTTPException(status_code=400, detail='No memory operations provided') raise HTTPException(status_code=400, detail='No memory operations provided')
@@ -108,14 +316,26 @@ async def add_memory_context(request, form_data: dict, user, model: dict | None
except Exception as e: except Exception as e:
log.debug(e) log.debug(e)
sections = {'user': [], 'context': []} sections = {'user': [], 'neighborhood': [], 'context': []}
seen_ids = set() seen_ids = set()
for memory in sorted( for memory in sorted(
[memory for memory in (all_memories or []) if memory.type == 'user'], [memory for memory in (all_memories or []) if memory.type == 'user'],
key=lambda item: (item.path or '', item.updated_at), key=lambda item: (item.path or '', item.updated_at),
): ):
seen_ids.add(memory.id) seen_ids.add(memory.id)
sections['user'].append(f'{memory.path}: {memory.content}' if memory.path else memory.content) sections['user'].append(memory_label(memory))
for hint in memory_path_hints(query, all_memories):
for memory in search_memory_rows(
all_memories,
path=hint,
memory_type='context',
limit=4,
):
if memory.id in seen_ids:
continue
seen_ids.add(memory.id)
sections['neighborhood'].append(memory_label(memory))
if results and hasattr(results, 'documents') and results.documents: if results and hasattr(results, 'documents') and results.documents:
for doc_idx, doc in enumerate(results.documents[0]): for doc_idx, doc in enumerate(results.documents[0]):
@@ -143,6 +363,8 @@ async def add_memory_context(request, form_data: dict, user, model: dict | None
parts = [] parts = []
if sections['user']: if sections['user']:
parts.append('[User Memory]\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['neighborhood']:
parts.append('[Memory Neighborhood]\n' + '\n'.join(f'- {memory}' for memory in sections['neighborhood']))
if sections['context']: if sections['context']:
parts.append('[Relevant 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: if not parts:
@@ -167,10 +389,12 @@ async def add_memory_context(request, form_data: dict, user, model: dict | None
if end != -1: if end != -1:
messages[0]['content'] = (content[:start] + content[end + len(MEMORY_CONTEXT_CLOSE) :]).strip() messages[0]['content'] = (content[:start] + content[end + len(MEMORY_CONTEXT_CLOSE) :]).strip()
user_parts = [part for part in parts if part.startswith('[User Memory]')]
context_parts = [part for part in parts if not part.startswith('[User Memory]')]
rendered = '\n\n'.join( rendered = '\n\n'.join(
[ [
parts[0][:user_limit] if parts and parts[0].startswith('[User Memory]') else '', '\n\n'.join(user_parts)[:user_limit],
parts[-1][:context_limit] if parts and parts[-1].startswith('[Relevant Context]') else '', '\n\n'.join(context_parts)[:context_limit],
] ]
).strip() ).strip()
if not rendered: if not rendered:
+4
View File
@@ -64,8 +64,10 @@ from open_webui.tools.builtin import (
list_knowledge, list_knowledge,
list_knowledge_bases, list_knowledge_bases,
list_memories, list_memories,
list_memory_paths,
query_knowledge_bases, query_knowledge_bases,
query_knowledge_files, query_knowledge_files,
read_memory_path,
replace_memory_content, replace_memory_content,
replace_note_content, replace_note_content,
search_calendar_events, search_calendar_events,
@@ -558,6 +560,8 @@ async def get_builtin_tools(
builtin_functions.extend( builtin_functions.extend(
[ [
search_memories, search_memories,
list_memory_paths,
read_memory_path,
list_memories, list_memories,
update_memory, update_memory,
add_memory, add_memory,