mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-30 01:51:26 +00:00
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 <mark> tags with theme-aware styling.
This commit is contained in:
parent
4923920bf1
commit
e8e4dd0ead
3 changed files with 72 additions and 5 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(' ')
|
||||
|
|
|
|||
|
|
@ -384,10 +384,24 @@
|
|||
onClose();
|
||||
}}
|
||||
>
|
||||
<div class=" flex-1">
|
||||
<div class=" flex-1 min-w-0">
|
||||
<div class="text-ellipsis line-clamp-1 w-full">
|
||||
{chat?.title}
|
||||
</div>
|
||||
{#if chat?.snippet && query}
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 line-clamp-2 mt-0.5">
|
||||
{@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'),
|
||||
'<mark class="bg-yellow-200/60 dark:bg-yellow-500/30 text-inherit rounded-sm px-0.5">$1</mark>'
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class=" pl-3 shrink-0 text-gray-500 dark:text-gray-400 text-xs">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue