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:
silentoplayz 2026-05-29 11:34:08 -04:00
parent 4923920bf1
commit e8e4dd0ead
3 changed files with 72 additions and 5 deletions

View file

@ -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):

View file

@ -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(' ')

View file

@ -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">