/ frontend / src / app / components / SmartSearch.jsx
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  }