diff --git a/application/api/user/conversations/routes.py b/application/api/user/conversations/routes.py index d5dc0266..239aba20 100644 --- a/application/api/user/conversations/routes.py +++ b/application/api/user/conversations/routes.py @@ -110,6 +110,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( diff --git a/application/storage/db/repositories/conversations.py b/application/storage/db/repositories/conversations.py index b1d3890d..2e7c2c35 100644 --- a/application/storage/db/repositories/conversations.py +++ b/application/storage/db/repositories/conversations.py @@ -262,6 +262,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 diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx index dfb85949..66e053f3 100644 --- a/frontend/src/Navigation.tsx +++ b/frontend/src/Navigation.tsx @@ -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('INACTIVE'); const [recentAgents, setRecentAgents] = useState([]); + const [searchOpen, setSearchOpen] = useState(false); const navRef = useRef(null); useEffect(() => { @@ -104,6 +107,19 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { }; } }, [navOpen, isMobile, isTablet, setNavOpen]); + + useEffect(() => { + function handleSearchShortcut(event: KeyboardEvent) { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + setSearchOpen(true); + } + } + + document.addEventListener('keydown', handleSearchShortcut); + return () => document.removeEventListener('keydown', handleSearchShortcut); + }, []); + async function fetchRecentAgents() { try { const response = await userService.getPinnedAgents(token); @@ -506,11 +522,23 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { )} {conversations?.data && conversations.data.length > 0 ? (
-
+

{t('chats')}

+
- {conversations.data?.map((conversation) => ( + {(conversations.data ?? []).map((conversation) => ( + {searchOpen && ( + setSearchOpen(false)} + conversations={conversations?.data ?? []} + token={token} + onSelectConversation={(id) => { + handleConversationClick(id); + if (isMobile || isTablet) setNavOpen(false); + }} + /> + )} ); } diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index f4ac705d..2ad8f220 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -103,6 +103,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}`, diff --git a/frontend/src/api/services/conversationService.ts b/frontend/src/api/services/conversationService.ts index 468308bf..4a3b7796 100644 --- a/frontend/src/api/services/conversationService.ts +++ b/frontend/src/api/services/conversationService.ts @@ -32,6 +32,16 @@ const conversationService = { apiClient.get(endpoints.CONVERSATION.MESSAGE_TAIL(messageId), token, {}), getConversations: (token: string | null): Promise => apiClient.get(endpoints.CONVERSATION.CONVERSATIONS, token, {}), + searchConversations: ( + query: string, + token: string | null, + limit = 30, + ): Promise => + apiClient.get( + endpoints.CONVERSATION.SEARCH_CONVERSATIONS(query, limit), + token, + {}, + ), shareConversation: ( isPromptable: boolean, data: any, diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index 1f066154..212c71d9 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -30,7 +30,7 @@ import { } from '../preferences/preferenceSlice'; import type { RootState } from '../store'; import Upload from '../upload/Upload'; -import { getOS, isTouchDevice } from '../utils/browserUtils'; +import { isTouchDevice } from '../utils/browserUtils'; import SourcesPopup from './SourcesPopup'; import ToolsPopup from './ToolsPopup'; import { handleAbort } from '../conversation/conversationSlice'; @@ -337,7 +337,6 @@ export default function MessageInput({ const voiceBaseValueRef = useRef(''); const liveTranscriptRef = useRef(''); - const browserOS = getOS(); const isTouch = isTouchDevice(); const stopMediaStream = () => { @@ -386,25 +385,6 @@ export default function MessageInput({ liveTranscriptRef.current = ''; }; - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - ((browserOS === 'win' || browserOS === 'linux') && - event.ctrlKey && - event.key === 'k') || - (browserOS === 'mac' && event.metaKey && event.key === 'k') - ) { - event.preventDefault(); - setIsSourcesPopupOpen((s) => !s); - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [browserOS]); - useEffect(() => { return () => { stopAudioProcessing(); @@ -1632,11 +1612,6 @@ export default function MessageInput({ : `${selectedDocs.length} sources selected` : t('conversation.sources.title')} - {!isTouch && ( - - {browserOS === 'mac' ? '(⌘K)' : '(ctrl+K)'} - - )} )} diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 7dbbf1e0..83f782fa 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -462,6 +462,11 @@ "create": "Erstellen", "option": "Benutzern weitere Eingaben erlauben" }, + "searchConversations": { + "searchPlaceholder": "Konversationen durchsuchen", + "noResults": "Keine Ergebnisse gefunden", + "loading": "Laden..." + }, "configTool": { "title": "Werkzeug-Konfiguration", "type": "Typ", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 0a368762..d48833d3 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -492,6 +492,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", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 5d9f57d0..4f06f46c 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -480,6 +480,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", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 3800f7b4..b4d0e1e5 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -480,6 +480,11 @@ "create": "作成", "option": "ユーザーがより多くのクエリを実行できるようにします。" }, + "searchConversations": { + "searchPlaceholder": "会話を検索", + "noResults": "結果が見つかりません", + "loading": "読み込み中..." + }, "configTool": { "title": "ツール設定", "type": "タイプ", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 31849964..951e52a0 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -480,6 +480,11 @@ "create": "Создать", "option": "Позволить пользователям делать дополнительные запросы." }, + "searchConversations": { + "searchPlaceholder": "Поиск разговоров", + "noResults": "Результаты не найдены", + "loading": "Загрузка..." + }, "configTool": { "title": "Настройка инструмента", "type": "Тип", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 66941882..2da96709 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -480,6 +480,11 @@ "create": "建立", "option": "允許使用者進行更多查詢" }, + "searchConversations": { + "searchPlaceholder": "搜尋對話", + "noResults": "未找到結果", + "loading": "載入中..." + }, "configTool": { "title": "工具設定", "type": "類型", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index f1f19eb0..e51cc953 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -480,6 +480,11 @@ "create": "创建", "option": "允许用户进行更多查询。" }, + "searchConversations": { + "searchPlaceholder": "搜索对话", + "noResults": "未找到结果", + "loading": "加载中..." + }, "configTool": { "title": "工具配置", "type": "类型", diff --git a/frontend/src/modals/SearchConversationsModal.tsx b/frontend/src/modals/SearchConversationsModal.tsx new file mode 100644 index 00000000..72071155 --- /dev/null +++ b/frontend/src/modals/SearchConversationsModal.tsx @@ -0,0 +1,232 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} 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() ? ( + + {part} + + ) : ( + {part} + ), + )} + + ); +} + +export default function SearchConversationsModal({ + close, + conversations, + token, + onSelectConversation, +}: SearchConversationsModalProps) { + const { t } = useTranslation(); + const inputRef = useRef(null); + const resultRefs = useRef>([]); + + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + + 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]); + + useEffect(() => { + if (isSearching || visibleConversations.length === 0) { + setActiveIndex(-1); + return; + } + + setActiveIndex((currentIndex) => { + if (currentIndex >= 0 && currentIndex < visibleConversations.length) { + return currentIndex; + } + + return 0; + }); + }, [isSearching, visibleConversations]); + + useEffect(() => { + if (activeIndex < 0) return; + + resultRefs.current[activeIndex]?.scrollIntoView({ + block: 'nearest', + }); + }, [activeIndex]); + + const handleSelect = (id: string) => { + onSelectConversation(id); + close(); + }; + + const handleInputKeyDown = (event: KeyboardEvent) => { + if (visibleConversations.length === 0 || isSearching) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + setActiveIndex((currentIndex) => + currentIndex < visibleConversations.length - 1 ? currentIndex + 1 : 0, + ); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + setActiveIndex((currentIndex) => + currentIndex > 0 ? currentIndex - 1 : visibleConversations.length - 1, + ); + return; + } + + if (event.key === 'Enter' && activeIndex >= 0) { + event.preventDefault(); + handleSelect(visibleConversations[activeIndex].id); + } + }; + + const showEmptyState = + !!query.trim() && !isSearching && visibleConversations.length === 0; + + return ( + +
+
+ search + setQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + placeholder={t('modals.searchConversations.searchPlaceholder')} + className="text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none" + /> +
+ +
+ {isSearching && ( +
+ {t('modals.searchConversations.loading')} +
+ )} + {showEmptyState && ( +
+ {t('modals.searchConversations.noResults')} +
+ )} + {!isSearching && + visibleConversations.map((conversation, index) => { + const trimmedQuery = query.trim(); + const showSnippet = + !!trimmedQuery && + !!conversation.match_snippet && + conversation.match_field !== 'name'; + const isActive = index === activeIndex; + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/frontend/src/preferences/preferenceApi.ts b/frontend/src/preferences/preferenceApi.ts index f94a0f20..5b390dbb 100644 --- a/frontend/src/preferences/preferenceApi.ts +++ b/frontend/src/preferences/preferenceApi.ts @@ -85,6 +85,49 @@ export async function getConversations( } } +export async function searchConversations( + query: string, + token: string | null, + limit = 30, +): Promise { + 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; diff --git a/frontend/src/preferences/types/index.ts b/frontend/src/preferences/types/index.ts index ae7757c7..4b005944 100644 --- a/frontend/src/preferences/types/index.ts +++ b/frontend/src/preferences/types/index.ts @@ -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 = {