SmartSearch.jsx
1 import { useState, useEffect, useRef } from "react"; 2 import { 3 Box, 4 Button, 5 Chip, 6 Dialog, 7 DialogContent, 8 DialogTitle, 9 TextField, 10 Typography, 11 } from "@mui/material"; 12 import SearchIcon from "@mui/icons-material/Search"; 13 import AssignmentIcon from "@mui/icons-material/Assignment"; 14 import PersonIcon from "@mui/icons-material/Person"; 15 import GroupsIcon from "@mui/icons-material/Groups"; 16 import PsychologyIcon from "@mui/icons-material/Psychology"; 17 import HubIcon from "@mui/icons-material/Hub"; 18 import { useNavigate } from "react-router-dom"; 19 import useAuth from "app/hooks/useAuth"; 20 import api from "app/utils/api"; 21 22 const SUGGESTIONS = [ 23 "rag projects using gpt-4", 24 "restricted users", 25 "public llms", 26 "agent projects in team engineering", 27 "admin users", 28 ]; 29 30 const LOADING_MESSAGES = [ 31 "Asking the AI...", 32 "Translating your query...", 33 "Understanding what you mean...", 34 "Searching the database...", 35 ]; 36 37 const ENTITY_LABELS = { 38 projects: "Project", 39 users: "User", 40 teams: "Team", 41 llms: "LLM", 42 embeddings: "Embedding", 43 }; 44 45 const ENTITY_ICONS = { 46 projects: AssignmentIcon, 47 users: PersonIcon, 48 teams: GroupsIcon, 49 llms: PsychologyIcon, 50 embeddings: HubIcon, 51 }; 52 53 export default function SmartSearch({ open, onClose }) { 54 const [query, setQuery] = useState(""); 55 const [loading, setLoading] = useState(false); 56 const [loadingMessage, setLoadingMessage] = useState(LOADING_MESSAGES[0]); 57 const [results, setResults] = useState([]); 58 const [structured, setStructured] = useState(null); 59 const [warnings, setWarnings] = useState([]); 60 const [note, setNote] = useState(null); 61 const [error, setError] = useState(null); 62 const navigate = useNavigate(); 63 const auth = useAuth(); 64 const inputRef = useRef(null); 65 66 useEffect(() => { 67 if (open) { 68 const t = setTimeout(() => { 69 if (inputRef.current) inputRef.current.focus(); 70 }, 100); 71 return () => clearTimeout(t); 72 } 73 setQuery(""); 74 setResults([]); 75 setStructured(null); 76 setWarnings([]); 77 setNote(null); 78 setError(null); 79 }, [open]); 80 81 useEffect(() => { 82 if (!loading) return; 83 let i = 0; 84 setLoadingMessage(LOADING_MESSAGES[0]); 85 const interval = setInterval(() => { 86 i = (i + 1) % LOADING_MESSAGES.length; 87 setLoadingMessage(LOADING_MESSAGES[i]); 88 }, 1400); 89 return () => clearInterval(interval); 90 }, [loading]); 91 92 const runSearch = (q) => { 93 const text = (q != null ? q : query).trim(); 94 if (!text) return; 95 setLoading(true); 96 setError(null); 97 setResults([]); 98 setStructured(null); 99 setWarnings([]); 100 setNote(null); 101 102 const startedAt = Date.now(); 103 const MIN_LOADING_MS = 800; 104 105 const finish = (apply) => { 106 const elapsed = Date.now() - startedAt; 107 const remaining = Math.max(0, MIN_LOADING_MS - elapsed); 108 setTimeout(() => { 109 apply(); 110 setLoading(false); 111 }, remaining); 112 }; 113 114 api 115 .post("/search", { query: text }, auth.user.token) 116 .then((d) => { 117 finish(() => { 118 setResults(d.results || []); 119 setStructured(d.query || null); 120 setWarnings(d.warnings || []); 121 setNote(d.note || null); 122 }); 123 }) 124 .catch((err) => { 125 finish(() => { 126 setError((err && err.detail) || "Search failed"); 127 setResults([]); 128 }); 129 }); 130 }; 131 132 const handleKeyDown = (e) => { 133 if (e.key === "Enter") { 134 e.preventDefault(); 135 runSearch(); 136 } 137 }; 138 139 const handleNavigate = (path) => { 140 onClose(); 141 navigate(path); 142 }; 143 144 const formatFilter = (f) => `${f.field} ${f.op} ${JSON.stringify(f.value)}`; 145 146 return ( 147 <Dialog 148 open={open} 149 onClose={onClose} 150 fullWidth 151 maxWidth="sm" 152 PaperProps={{ sx: { borderRadius: 3 } }} 153 > 154 <DialogTitle sx={{ pb: 1 }}>Smart Search</DialogTitle> 155 156 <Box sx={{ px: 3, pb: 2 }}> 157 <TextField 158 inputRef={inputRef} 159 fullWidth 160 size="small" 161 placeholder="Search anything... e.g. 'rag projects using gpt-4'" 162 value={query} 163 onChange={(e) => setQuery(e.target.value)} 164 onKeyDown={handleKeyDown} 165 disabled={loading} 166 /> 167 </Box> 168 169 <DialogContent sx={{ p: 0, pt: 0, minHeight: 240, maxHeight: 520 }}> 170 {loading && ( 171 <Box 172 sx={{ 173 px: 3, 174 py: 5, 175 textAlign: "center", 176 }} 177 > 178 <Typography variant="body2" color="text.secondary"> 179 {loadingMessage} 180 </Typography> 181 </Box> 182 )} 183 184 {!loading && !query && results.length === 0 && !error && ( 185 <Box sx={{ px: 3, py: 2 }}> 186 <Typography 187 variant="caption" 188 color="text.secondary" 189 sx={{ textTransform: "uppercase", fontWeight: 700, letterSpacing: 0.5 }} 190 > 191 Try 192 </Typography> 193 <Box sx={{ display: "flex", flexWrap: "wrap", gap: 1, mt: 1.5 }}> 194 {SUGGESTIONS.map((s) => ( 195 <Chip 196 key={s} 197 label={s} 198 variant="outlined" 199 size="small" 200 clickable 201 onClick={() => { 202 setQuery(s); 203 runSearch(s); 204 }} 205 /> 206 ))} 207 </Box> 208 </Box> 209 )} 210 211 {!loading && error && ( 212 <Box sx={{ px: 3, py: 3 }}> 213 <Typography variant="body2" color="error"> 214 {error} 215 </Typography> 216 </Box> 217 )} 218 219 {!loading && results.length > 0 && ( 220 <Box> 221 {structured && ( 222 <Box 223 sx={{ 224 px: 3, 225 py: 1.25, 226 borderBottom: "1px solid", 227 borderColor: "divider", 228 }} 229 > 230 <Typography variant="caption" color="text.secondary"> 231 Searching <strong>{structured.entity}</strong> 232 {structured.filters && structured.filters.length > 0 && " where "} 233 {structured.filters && 234 structured.filters.map((f, i) => ( 235 <Box 236 key={i} 237 component="span" 238 sx={{ fontFamily: "monospace", ml: 0.5 }} 239 > 240 {i > 0 ? " AND " : ""} 241 {formatFilter(f)} 242 </Box> 243 ))} 244 </Typography> 245 </Box> 246 )} 247 248 {note && ( 249 <Box 250 sx={{ 251 px: 3, 252 py: 1.25, 253 borderBottom: "1px solid", 254 borderColor: "divider", 255 }} 256 > 257 <Typography variant="caption" color="warning.main"> 258 {note} 259 </Typography> 260 </Box> 261 )} 262 263 <Box> 264 {results.map((r, i) => ( 265 <Button 266 key={`${r.entity}-${r.id}-${i}`} 267 onClick={() => handleNavigate(r.path)} 268 fullWidth 269 sx={{ 270 justifyContent: "flex-start", 271 textTransform: "none", 272 borderRadius: 0, 273 borderBottom: "1px solid", 274 borderColor: "divider", 275 px: 3, 276 py: 1.5, 277 color: "text.primary", 278 }} 279 > 280 {(() => { const Icon = ENTITY_ICONS[r.entity] || SearchIcon; return <Icon fontSize="small" sx={{ mr: 1.5, color: "text.secondary" }} />; })()} 281 <Box sx={{ flex: 1, textAlign: "left" }}> 282 <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> 283 <Chip 284 label={ENTITY_LABELS[r.entity] || r.entity} 285 size="small" 286 variant="outlined" 287 sx={{ height: 20, fontSize: "0.68rem" }} 288 /> 289 <Typography variant="body2" fontWeight={600}> 290 {r.name} 291 </Typography> 292 </Box> 293 {r.subtitle && ( 294 <Typography 295 variant="caption" 296 color="text.secondary" 297 sx={{ display: "block", mt: 0.25 }} 298 > 299 {r.subtitle} 300 </Typography> 301 )} 302 </Box> 303 </Button> 304 ))} 305 </Box> 306 307 {warnings.length > 0 && ( 308 <Box 309 sx={{ 310 px: 3, 311 py: 1.25, 312 borderTop: "1px solid", 313 borderColor: "divider", 314 }} 315 > 316 {warnings.map((w, i) => ( 317 <Typography 318 key={i} 319 variant="caption" 320 color="warning.main" 321 sx={{ display: "block" }} 322 > 323 {w} 324 </Typography> 325 ))} 326 </Box> 327 )} 328 </Box> 329 )} 330 331 {!loading && query && !error && results.length === 0 && structured && ( 332 <Box sx={{ px: 3, py: 4, textAlign: "center" }}> 333 <Typography variant="body2" color="text.secondary"> 334 No results matching your query. 335 </Typography> 336 </Box> 337 )} 338 </DialogContent> 339 </Dialog> 340 ); 341 }