Compare commits

...

2 Commits

Author SHA1 Message Date
ManishMadan2882
8b9eb5cffe (feat:search_conversations) highlight matching snippet + frontend 2026-05-14 01:39:35 +05:30
ManishMadan2882
1a764c6ee8 (feat:search_conversations) add route 2026-05-14 01:39:35 +05:30
15 changed files with 415 additions and 2 deletions

View File

@@ -106,6 +106,85 @@ class GetConversations(Resource):
return make_response(jsonify(list_conversations), 200)
@conversations_ns.route("/search_conversations")
class SearchConversations(Resource):
@staticmethod
def _build_match_snippet(text_value: str, query: str, radius: int = 60) -> str:
if not text_value:
return ""
idx = text_value.lower().find(query.lower())
if idx == -1:
snippet = text_value[: radius * 2]
return snippet + ("" if len(text_value) > len(snippet) else "")
start = max(0, idx - radius)
end = min(len(text_value), idx + len(query) + radius)
snippet = text_value[start:end]
if start > 0:
snippet = "" + snippet
if end < len(text_value):
snippet = snippet + ""
return snippet
@api.doc(
description=(
"Search the authenticated user's conversations by name or "
"message content (case-insensitive substring match). Mirrors "
"the visibility filter and response shape of /get_conversations, "
"and additionally returns ``match_field`` (``name``, ``prompt`` "
"or ``response``) and ``match_snippet`` (a short excerpt of the "
"matched text centered on the query) for each result."
),
params={
"q": "Search term (required)",
"limit": "Maximum number of results to return (default 30, max 100)",
},
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
query = (request.args.get("q") or "").strip()
if not query:
return make_response(
jsonify({"success": False, "message": "q is required"}), 400
)
try:
limit = int(request.args.get("limit", 30))
except (TypeError, ValueError):
limit = 30
limit = max(1, min(limit, 100))
user_id = decoded_token.get("sub")
try:
with db_readonly() as conn:
conversations = ConversationsRepository(conn).search_for_user(
user_id, query, limit=limit
)
list_conversations = [
{
"id": str(conversation["id"]),
"name": conversation["name"],
"agent_id": (
str(conversation["agent_id"])
if conversation.get("agent_id")
else None
),
"is_shared_usage": conversation.get("is_shared_usage", False),
"shared_token": conversation.get("shared_token", None),
"match_field": conversation.get("match_field"),
"match_snippet": self._build_match_snippet(
conversation.get("match_text") or "", query
),
}
for conversation in conversations
]
except Exception as err:
current_app.logger.error(
f"Error searching conversations: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(list_conversations), 200)
@conversations_ns.route("/get_single_conversation")
class GetSingleConversation(Resource):
@api.doc(

View File

@@ -245,6 +245,57 @@ class ConversationsRepository:
)
return [row_to_dict(r) for r in result.fetchall()]
def search_for_user(
self, user_id: str, query: str, limit: int = 30,
) -> list[dict]:
"""Search a user's conversations by name or message content.
Same visibility filter as :meth:`list_for_user`. Matches against
``conversations.name`` or any of the conversation's messages'
``prompt`` / ``response`` columns (case-insensitive substring).
Each returned row includes ``match_field`` (one of ``name``,
``prompt``, ``response``) and ``match_text`` (the full text of the
first matching field, ``name`` taking precedence over messages,
``prompt`` over ``response``) so callers can render a snippet.
"""
if not query:
return []
result = self._conn.execute(
text(
"SELECT c.*, mt.match_field, mt.match_text "
"FROM conversations c "
"JOIN LATERAL ( "
" SELECT field AS match_field, txt AS match_text "
" FROM ( "
" SELECT 'name'::text AS field, c.name AS txt, 0 AS prio "
" WHERE c.name ILIKE :pattern "
" UNION ALL "
" SELECT 'prompt'::text, m.prompt, 1 "
" FROM conversation_messages m "
" WHERE m.conversation_id = c.id "
" AND m.prompt ILIKE :pattern "
" UNION ALL "
" SELECT 'response'::text, m.response, 2 "
" FROM conversation_messages m "
" WHERE m.conversation_id = c.id "
" AND m.response ILIKE :pattern "
" ) s "
" ORDER BY prio "
" LIMIT 1 "
") mt ON TRUE "
"WHERE c.user_id = :user_id "
"AND (c.api_key IS NULL OR c.agent_id IS NOT NULL) "
"ORDER BY c.date DESC LIMIT :limit"
),
{
"user_id": user_id,
"pattern": f"%{query}%",
"limit": limit,
},
)
return [row_to_dict(r) for r in result.fetchall()]
def rename(self, conversation_id: str, user_id: str, name: str) -> bool:
# Shape-gate so a non-UUID id (legacy Mongo ObjectId still floating
# around in client-side state during the cutover) never reaches the

View File

@@ -15,6 +15,7 @@ import Github from './assets/git_nav.svg';
import Hamburger from './assets/hamburger.svg';
import openNewChat from './assets/openNewChat.svg';
import Pin from './assets/pin.svg';
import SearchIcon from './assets/search.svg';
import AgentImage from './components/AgentImage';
import SettingGear from './assets/settingGear.svg';
import Spark from './assets/spark.svg';
@@ -35,6 +36,7 @@ import { useDarkTheme, useMediaQuery } from './hooks';
import useTokenAuth from './hooks/useTokenAuth';
import DeleteConvModal from './modals/DeleteConvModal';
import JWTModal from './modals/JWTModal';
import SearchConversationsModal from './modals/SearchConversationsModal';
import { ActiveState } from './models/misc';
import { getConversations } from './preferences/preferenceApi';
import {
@@ -82,6 +84,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE');
const [recentAgents, setRecentAgents] = useState<Agent[]>([]);
const [searchOpen, setSearchOpen] = useState(false);
const navRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -506,11 +509,23 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
)}
{conversations?.data && conversations.data.length > 0 ? (
<div className="mt-7">
<div className="mx-4 my-auto mt-2 flex h-6 items-center justify-between gap-4 rounded-3xl">
<div className="mx-4 my-auto mt-2 flex h-8 items-center justify-between gap-4 rounded-3xl">
<p className="mt-1 ml-4 text-sm font-semibold">{t('chats')}</p>
<button
onClick={() => setSearchOpen(true)}
className="hover:bg-sidebar-accent mr-2 flex h-7 w-7 items-center justify-center rounded-full"
aria-label={t('modals.searchConversations.searchPlaceholder')}
title={t('modals.searchConversations.searchPlaceholder')}
>
<img
src={SearchIcon}
alt="search"
className="h-4 w-4 opacity-70"
/>
</button>
</div>
<div className="conversations-container">
{conversations.data?.map((conversation) => (
{(conversations.data ?? []).map((conversation) => (
<ConversationTile
key={conversation.id}
conversation={conversation}
@@ -644,6 +659,17 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
modalState={showTokenModal ? 'ACTIVE' : 'INACTIVE'}
handleTokenSubmit={handleTokenSubmit}
/>
{searchOpen && (
<SearchConversationsModal
close={() => setSearchOpen(false)}
conversations={conversations?.data ?? []}
token={token}
onSelectConversation={(id) => {
handleConversationClick(id);
if (isMobile || isTablet) setNavOpen(false);
}}
/>
)}
</>
);
}

View File

@@ -98,6 +98,8 @@ const endpoints = {
FEEDBACK: '/api/feedback',
CONVERSATION: (id: string) => `/api/get_single_conversation?id=${id}`,
CONVERSATIONS: '/api/get_conversations',
SEARCH_CONVERSATIONS: (q: string, limit = 30) =>
`/api/search_conversations?q=${encodeURIComponent(q)}&limit=${limit}`,
MESSAGE_TAIL: (messageId: string) => `/api/messages/${messageId}/tail`,
SHARE_CONVERSATION: (isPromptable: boolean) =>
`/api/share?isPromptable=${isPromptable}`,

View File

@@ -32,6 +32,16 @@ const conversationService = {
apiClient.get(endpoints.CONVERSATION.MESSAGE_TAIL(messageId), token, {}),
getConversations: (token: string | null): Promise<any> =>
apiClient.get(endpoints.CONVERSATION.CONVERSATIONS, token, {}),
searchConversations: (
query: string,
token: string | null,
limit = 30,
): Promise<any> =>
apiClient.get(
endpoints.CONVERSATION.SEARCH_CONVERSATIONS(query, limit),
token,
{},
),
shareConversation: (
isPromptable: boolean,
data: any,

View File

@@ -456,6 +456,11 @@
"create": "Erstellen",
"option": "Benutzern weitere Eingaben erlauben"
},
"searchConversations": {
"searchPlaceholder": "Konversationen durchsuchen",
"noResults": "Keine Ergebnisse gefunden",
"loading": "Laden..."
},
"configTool": {
"title": "Werkzeug-Konfiguration",
"type": "Typ",

View File

@@ -486,6 +486,11 @@
"create": "Create",
"option": "Allow users to prompt further"
},
"searchConversations": {
"searchPlaceholder": "Search conversations",
"noResults": "No results found",
"loading": "Loading..."
},
"configTool": {
"title": "Tool Config",
"type": "Type",

View File

@@ -474,6 +474,11 @@
"create": "Crear",
"option": "Permitir a los usuarios realizar más consultas"
},
"searchConversations": {
"searchPlaceholder": "Buscar conversaciones",
"noResults": "No se encontraron resultados",
"loading": "Cargando..."
},
"configTool": {
"title": "Configuración de la Herramienta",
"type": "Tipo",

View File

@@ -474,6 +474,11 @@
"create": "作成",
"option": "ユーザーがより多くのクエリを実行できるようにします。"
},
"searchConversations": {
"searchPlaceholder": "会話を検索",
"noResults": "結果が見つかりません",
"loading": "読み込み中..."
},
"configTool": {
"title": "ツール設定",
"type": "タイプ",

View File

@@ -474,6 +474,11 @@
"create": "Создать",
"option": "Позволить пользователям делать дополнительные запросы."
},
"searchConversations": {
"searchPlaceholder": "Поиск разговоров",
"noResults": "Результаты не найдены",
"loading": "Загрузка..."
},
"configTool": {
"title": "Настройка инструмента",
"type": "Тип",

View File

@@ -474,6 +474,11 @@
"create": "建立",
"option": "允許使用者進行更多查詢"
},
"searchConversations": {
"searchPlaceholder": "搜尋對話",
"noResults": "未找到結果",
"loading": "載入中..."
},
"configTool": {
"title": "工具設定",
"type": "類型",

View File

@@ -474,6 +474,11 @@
"create": "创建",
"option": "允许用户进行更多查询。"
},
"searchConversations": {
"searchPlaceholder": "搜索对话",
"noResults": "未找到结果",
"loading": "加载中..."
},
"configTool": {
"title": "工具配置",
"type": "类型",

View File

@@ -0,0 +1,165 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import SearchIcon from '../assets/search.svg';
import { searchConversations } from '../preferences/preferenceApi';
import WrapperModal from './WrapperModal';
type ConversationListItem = {
id: string;
name: string;
match_field?: 'name' | 'prompt' | 'response' | null;
match_snippet?: string | null;
};
type SearchConversationsModalProps = {
close: () => void;
conversations: ConversationListItem[];
token: string | null;
onSelectConversation: (id: string) => void;
};
// Escape regex metacharacters so the user query can be used in a RegExp.
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function HighlightedText({ text, query }: { text: string; query: string }) {
const trimmed = query.trim();
if (!trimmed) return <>{text}</>;
const parts = text.split(new RegExp(`(${escapeRegExp(trimmed)})`, 'gi'));
return (
<>
{parts.map((part, idx) =>
part.toLowerCase() === trimmed.toLowerCase() ? (
<mark
key={idx}
className="bg-transparent font-semibold text-purple-30"
>
{part}
</mark>
) : (
<span key={idx}>{part}</span>
),
)}
</>
);
}
export default function SearchConversationsModal({
close,
conversations,
token,
onSelectConversation,
}: SearchConversationsModalProps) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState<ConversationListItem[] | null>(null);
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
const trimmed = query.trim();
if (!trimmed) {
setResults(null);
setIsSearching(false);
return;
}
setIsSearching(true);
const handle = setTimeout(() => {
searchConversations(trimmed, token).then((result) => {
setResults(result.data ?? []);
setIsSearching(false);
});
}, 300);
return () => clearTimeout(handle);
}, [query, token]);
const visibleConversations = useMemo(() => {
if (!query.trim()) return conversations;
return results ?? [];
}, [query, results, conversations]);
const handleSelect = (id: string) => {
onSelectConversation(id);
close();
};
const showEmptyState =
!!query.trim() && !isSearching && visibleConversations.length === 0;
return (
<WrapperModal
close={close}
className="w-[92vw] max-w-xl p-0"
contentClassName="max-h-[70vh]"
>
<div className="flex flex-col">
<div className="border-sidebar-border flex items-center gap-2 border-b px-5 py-4">
<img src={SearchIcon} alt="search" className="h-4 w-4 opacity-60" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('modals.searchConversations.searchPlaceholder')}
className="text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none"
/>
</div>
<div className="max-h-[55vh] overflow-y-auto py-2">
{isSearching && (
<div className="text-muted-foreground px-5 py-3 text-xs">
{t('modals.searchConversations.loading')}
</div>
)}
{showEmptyState && (
<div className="text-muted-foreground px-5 py-3 text-xs">
{t('modals.searchConversations.noResults')}
</div>
)}
{!isSearching &&
visibleConversations.map((conversation) => {
const trimmedQuery = query.trim();
const showSnippet =
!!trimmedQuery &&
!!conversation.match_snippet &&
conversation.match_field !== 'name';
return (
<button
key={conversation.id}
type="button"
onClick={() => handleSelect(conversation.id)}
className="hover:bg-sidebar-accent text-foreground flex w-full flex-col items-start gap-0.5 px-5 py-2.5 text-left text-sm"
>
<span className="w-full truncate">
{trimmedQuery ? (
<HighlightedText
text={conversation.name}
query={trimmedQuery}
/>
) : (
conversation.name
)}
</span>
{showSnippet && (
<span className="text-muted-foreground line-clamp-2 w-full text-xs">
<HighlightedText
text={conversation.match_snippet as string}
query={trimmedQuery}
/>
</span>
)}
</button>
);
})}
</div>
</div>
</WrapperModal>
);
}

View File

@@ -85,6 +85,49 @@ export async function getConversations(
}
}
export async function searchConversations(
query: string,
token: string | null,
limit = 30,
): Promise<GetConversationsResult> {
try {
const response = await conversationService.searchConversations(
query,
token,
limit,
);
if (!response.ok) {
console.error('Error searching conversations:', response.statusText);
return { data: null, loading: false };
}
const rawData: unknown = await response.json();
if (!Array.isArray(rawData)) {
console.error(
'Invalid data format received from API: Expected an array.',
rawData,
);
return { data: null, loading: false };
}
const conversations: ConversationSummary[] = rawData.map((item: any) => ({
id: item.id,
name: item.name,
agent_id: item.agent_id ?? null,
match_field: item.match_field ?? null,
match_snippet: item.match_snippet ?? null,
}));
return { data: conversations, loading: false };
} catch (error) {
console.error(
'An unexpected error occurred while searching conversations:',
error,
);
return { data: null, loading: false };
}
}
export function getLocalApiKey(): string | null {
const key = localStorage.getItem('DocsGPTApiKey');
return key;

View File

@@ -2,6 +2,8 @@ export type ConversationSummary = {
id: string;
name: string;
agent_id: string | null;
match_field?: 'name' | 'prompt' | 'response' | null;
match_snippet?: string | null;
};
export type GetConversationsResult = {