From e8e4dd0eadb31fac9f6dcf1ec6067a3e3aa7145f Mon Sep 17 00:00:00 2001 From: silentoplayz Date: Fri, 29 May 2026 11:34:08 -0400 Subject: [PATCH] feat(search): display content snippets in chat search results The chat search modal (Cmd+K) already searches within message content but only displayed chat titles, giving users no indication of why a chat matched their query. This adds a content snippet below each search result showing the matching text with the query term highlighted. The snippet is a ~200 char window centered on the first content match, with ellipsis for truncated text. Backend: extract snippet from already-loaded ChatModel objects (zero extra DB cost). Frontend: render snippet with highlighted query via tags with theme-aware styling. --- backend/open_webui/models/chats.py | 1 + backend/open_webui/routers/chats.py | 60 ++++++++++++++++++-- src/lib/components/layout/SearchModal.svelte | 16 +++++- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 63a8bb1f2e..4a39a6bf7b 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -178,6 +178,7 @@ class ChatTitleIdResponse(BaseModel): updated_at: int created_at: int last_read_at: int | None = None + snippet: str | None = None class SharedChatResponse(BaseModel): diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 91cdb21e78..fb6ed5cd65 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -593,6 +593,49 @@ async def import_chats( ############################ +def _extract_search_snippet(chat_model, search_text: str, max_length: int = 200) -> str | None: + """Extract a content snippet from the first matching message in a chat. + + Scans the chat's message list for the first message whose content + contains *search_text* (case-insensitive) and returns a window of + up to *max_length* characters centred on the match. + """ + if not search_text: + return None + + search_lower = search_text.lower() + messages = chat_model.chat.get('messages', {}) + if isinstance(messages, dict): + messages = messages.values() + + for message in messages: + content = message.get('content', '') + if not isinstance(content, str): + continue + + idx = content.lower().find(search_lower) + if idx == -1: + continue + + # Build a window around the match + half = (max_length - len(search_text)) // 2 + start = max(0, idx - half) + end = min(len(content), idx + len(search_text) + half) + + snippet = content[start:end].strip() + # Replace newlines with spaces for single-line display + snippet = ' '.join(snippet.split()) + + if start > 0: + snippet = '…' + snippet + if end < len(content): + snippet = snippet + '…' + + return snippet + + return None + + @router.get('/search', response_model=list[ChatTitleIdResponse]) async def search_user_chats( text: str, @@ -606,10 +649,19 @@ async def search_user_chats( limit = 60 skip = (page - 1) * limit - chat_list = [ - ChatTitleIdResponse(**chat.model_dump()) - for chat in await Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db) - ] + # Strip filter prefixes to get the actual search text for snippet extraction + search_words = text.strip().split(' ') + snippet_text = ' '.join( + w for w in search_words + if not any(w.startswith(p) for p in ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:')) + ).strip() + + chat_list = [] + for chat in await Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db): + snippet = _extract_search_snippet(chat, snippet_text) if snippet_text else None + chat_list.append( + ChatTitleIdResponse(**{**chat.model_dump(), 'snippet': snippet}) + ) # Delete tag if no chat is found words = text.strip().split(' ') diff --git a/src/lib/components/layout/SearchModal.svelte b/src/lib/components/layout/SearchModal.svelte index 5617de7c22..238560e8b1 100644 --- a/src/lib/components/layout/SearchModal.svelte +++ b/src/lib/components/layout/SearchModal.svelte @@ -384,10 +384,24 @@ onClose(); }} > -
+
{chat?.title}
+ {#if chat?.snippet && query} +
+ {@html (() => { + const snippet = chat.snippet; + const q = query.trim().toLowerCase(); + if (!q) return snippet; + const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return snippet.replace( + new RegExp(`(${escaped})`, 'gi'), + '$1' + ); + })()} +
+ {/if}