/ frontend / src / app / views / projects / Tools.jsx
Tools.jsx
  1  import { useState, useEffect, useMemo } from "react";
  2  import {
  3    Alert, Box, Card, Chip, Grid, InputAdornment, styled, TextField, Typography,
  4  } from "@mui/material";
  5  import {
  6    Search, Build, Block, Info,
  7    Language, Schedule, Forum, Web, Storage, Calculate,
  8    Terminal as TerminalIcon,
  9  } from "@mui/icons-material";
 10  import useAuth from "app/hooks/useAuth";
 11  import Breadcrumb from "app/components/Breadcrumb";
 12  import { useTranslation } from "react-i18next";
 13  import api from "app/utils/api";
 14  
 15  const Container = styled("div")(({ theme }) => ({
 16    margin: "24px 48px",
 17    [theme.breakpoints.down("md")]: { margin: "24px 32px" },
 18    [theme.breakpoints.down("sm")]: { margin: 16 },
 19    "& .breadcrumb": { marginBottom: 24 },
 20  }));
 21  
 22  // Category taxonomy. Order of entries matters — first match wins, so
 23  // keep the specific patterns (e.g. create_routine) above the catch-all
 24  // "utility" bucket at the end.
 25  const CATEGORIES = [
 26    {
 27      key: "browser",
 28      icon: Language,
 29      color: "#0ea5e9",
 30      match: /^browser_/,
 31      requires: "browser",
 32    },
 33    {
 34      key: "routines",
 35      icon: Schedule,
 36      color: "#1976d2",
 37      match: /routine/i,
 38      requires: null,
 39    },
 40    {
 41      key: "agentControl",
 42      icon: TerminalIcon,
 43      color: "#6366f1",
 44      match: /^(terminal|create_tool)$/,
 45      requires: "docker",
 46    },
 47    {
 48      key: "communication",
 49      icon: Forum,
 50      color: "#0891b2",
 51      match: /^send_/,
 52      requires: "provider",
 53    },
 54    {
 55      key: "web",
 56      icon: Web,
 57      color: "#2563eb",
 58      match: /^(crawler_|duckduckgo|wikipedia|whois_)/,
 59      requires: null,
 60    },
 61    {
 62      key: "data",
 63      icon: Storage,
 64      color: "#1e40af",
 65      match: /^(data_parser|draw_image)$/,
 66      requires: null,
 67    },
 68  ];
 69  
 70  // Anything that doesn't match a category above lands here.
 71  const FALLBACK_CATEGORY = {
 72    key: "utility",
 73    icon: Calculate,
 74    color: "#06b6d4",
 75    requires: null,
 76  };
 77  
 78  function categorize(toolName) {
 79    for (const cat of CATEGORIES) {
 80      if (cat.match.test(toolName)) return cat;
 81    }
 82    return FALLBACK_CATEGORY;
 83  }
 84  
 85  // Per-tool requirement chips. The /tools/agent endpoint only flags
 86  // terminal/create_tool when docker is off; the rest of these are
 87  // advisory so users know what they need to configure per tool.
 88  function requirementsFor(toolName, toolEnabled) {
 89    const reqs = [];
 90    if (toolName === "terminal" || toolName === "create_tool") {
 91      reqs.push({ key: toolEnabled === false ? "requiresDocker" : "usesDocker", color: "warning" });
 92    }
 93    if (/^browser_/.test(toolName)) {
 94      reqs.push({ key: "usesBrowser", color: "info" });
 95    }
 96    if (toolName === "browser_eval") {
 97      reqs.push({ key: "adminOptIn", color: "warning" });
 98    }
 99    if (/^send_(email)$/.test(toolName)) {
100      reqs.push({ key: "needsSmtp", color: "default" });
101    }
102    if (/^send_sms$/.test(toolName)) {
103      reqs.push({ key: "needsTwilio", color: "default" });
104    }
105    if (/^send_telegram$/.test(toolName)) {
106      reqs.push({ key: "needsTelegram", color: "default" });
107    }
108    if (/^send_whatsapp$/.test(toolName)) {
109      reqs.push({ key: "needsWhatsApp", color: "default" });
110    }
111    if (/routine/i.test(toolName)) {
112      reqs.push({ key: "perProject", color: "info" });
113    }
114    return reqs;
115  }
116  
117  function ToolCard({ tool, reqs, t }) {
118    return (
119      <Card
120        variant="outlined"
121        sx={{
122          p: 2,
123          borderRadius: "10px",
124          border: "1px solid",
125          borderColor: "divider",
126          opacity: tool.enabled === false ? 0.55 : 1,
127          transition: "border-color 0.2s, box-shadow 0.2s",
128          height: "100%",
129          display: "flex",
130          flexDirection: "column",
131          "&:hover": tool.enabled !== false
132            ? { borderColor: "primary.main", boxShadow: "0 2px 10px rgba(25,118,210,0.08)" }
133            : {},
134        }}
135      >
136        <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5 }}>
137          <Box
138            sx={{
139              width: 32, height: 32, borderRadius: "8px", flexShrink: 0,
140              display: "flex", alignItems: "center", justifyContent: "center",
141              background: (theme) => tool.enabled === false
142                ? (theme.palette.mode === "dark" ? "rgba(150,150,150,0.15)" : "rgba(150,150,150,0.08)")
143                : (theme.palette.mode === "dark" ? "rgba(25,118,210,0.18)" : "rgba(25,118,210,0.08)"),
144            }}
145          >
146            {tool.enabled === false
147              ? <Block sx={{ fontSize: 16, color: "text.disabled" }} />
148              : <Build sx={{ fontSize: 16, color: "primary.main" }} />
149            }
150          </Box>
151          <Box sx={{ flex: 1, minWidth: 0 }}>
152            <Typography
153              variant="subtitle2"
154              fontWeight={600}
155              sx={{
156                fontFamily: '"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace',
157                fontSize: "0.86rem",
158                wordBreak: "break-word",
159              }}
160            >
161              {tool.name}
162            </Typography>
163            {(reqs.length > 0) && (
164              <Box sx={{ mt: 0.5, display: "flex", flexWrap: "wrap", gap: 0.5 }}>
165                {reqs.map((r) => (
166                  <Chip
167                    key={r.key}
168                    label={t("tools.reqs." + r.key)}
169                    size="small"
170                    color={r.color}
171                    variant="outlined"
172                    sx={{ fontSize: "0.65rem", height: 20 }}
173                  />
174                ))}
175              </Box>
176            )}
177          </Box>
178        </Box>
179        <Typography
180          variant="body2"
181          color="text.secondary"
182          sx={{
183            mt: 1.25,
184            whiteSpace: "pre-wrap",
185            lineHeight: 1.55,
186            fontSize: "0.82rem",
187            flex: 1,
188          }}
189        >
190          {tool.description}
191        </Typography>
192      </Card>
193    );
194  }
195  
196  export default function Tools() {
197    const { t } = useTranslation();
198    const [tools, setTools] = useState([]);
199    const [search, setSearch] = useState("");
200    const auth = useAuth();
201  
202    useEffect(() => {
203      document.title = (process.env.REACT_APP_RESTAI_NAME || "RESTai") + " - " + t("tools.breadcrumb");
204      api.get("/tools/agent", auth.user.token)
205        .then((d) => setTools(d || []))
206        .catch(() => {});
207      // eslint-disable-next-line react-hooks/exhaustive-deps
208    }, [t]);
209  
210    // Group tools by category, then alpha-sort within each group.
211    const groups = useMemo(() => {
212      const q = search.toLowerCase();
213      const matching = tools.filter((tl) =>
214        !q ||
215        tl.name.toLowerCase().includes(q) ||
216        (tl.description || "").toLowerCase().includes(q)
217      );
218      const byKey = new Map();
219      matching.forEach((tl) => {
220        const cat = categorize(tl.name);
221        if (!byKey.has(cat.key)) byKey.set(cat.key, { ...cat, tools: [] });
222        byKey.get(cat.key).tools.push(tl);
223      });
224      // Keep group order aligned with CATEGORIES declaration order so
225      // the page reads top-down from "headline" capabilities (browser,
226      // routines) to infrastructure (agent control) to helpers.
227      const ordered = [];
228      [...CATEGORIES, FALLBACK_CATEGORY].forEach((cat) => {
229        const g = byKey.get(cat.key);
230        if (g) {
231          g.tools.sort((a, b) => a.name.localeCompare(b.name));
232          ordered.push(g);
233        }
234      });
235      return ordered;
236    }, [tools, search]);
237  
238    return (
239      <Container>
240        <Box className="breadcrumb">
241          <Breadcrumb routeSegments={[{ name: t("nav.projects"), path: "/projects" }, { name: t("tools.breadcrumb") }]} />
242        </Box>
243  
244        <Box>
245          <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2, flexWrap: "wrap", gap: 2 }}>
246            <Box>
247              <Typography variant="h5" fontWeight={700}>{t("tools.title")}</Typography>
248              <Typography variant="body2" color="text.secondary">
249                {t("tools.subtitle")}
250              </Typography>
251            </Box>
252            <Chip label={t("tools.toolCount", { count: tools.length })} variant="outlined" size="small" />
253          </Box>
254  
255          <Alert severity="info" icon={<Info fontSize="small" />} sx={{ mb: 3 }}>
256            {t("tools.info")}
257          </Alert>
258  
259          <TextField
260            fullWidth
261            size="small"
262            placeholder={t("tools.searchPlaceholder")}
263            value={search}
264            onChange={(e) => setSearch(e.target.value)}
265            sx={{ mb: 3 }}
266            InputProps={{
267              startAdornment: (
268                <InputAdornment position="start">
269                  <Search fontSize="small" color="action" />
270                </InputAdornment>
271              ),
272            }}
273          />
274  
275          {groups.length === 0 ? (
276            <Typography variant="body2" color="text.secondary" sx={{ textAlign: "center", py: 4 }}>
277              {search ? t("tools.noMatch") : t("tools.noTools")}
278            </Typography>
279          ) : (
280            groups.map((group) => {
281              const GroupIcon = group.icon;
282              return (
283                <Box key={group.key} sx={{ mb: 4 }}>
284                  <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 1.5 }}>
285                    <Box
286                      sx={{
287                        width: 36, height: 36, borderRadius: 2,
288                        display: "flex", alignItems: "center", justifyContent: "center",
289                        background: `${group.color}1a`,
290                        color: group.color,
291                      }}
292                    >
293                      <GroupIcon sx={{ fontSize: 20 }} />
294                    </Box>
295                    <Box sx={{ flex: 1, minWidth: 0 }}>
296                      <Typography
297                        variant="overline"
298                        sx={{
299                          letterSpacing: 1.5,
300                          fontWeight: 700,
301                          fontSize: "0.72rem",
302                          color: group.color,
303                          lineHeight: 1,
304                          display: "block",
305                        }}
306                      >
307                        {t("tools.categories." + group.key + ".title")}
308                      </Typography>
309                      <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25 }}>
310                        {t("tools.categories." + group.key + ".subtitle")}
311                      </Typography>
312                    </Box>
313                    <Chip
314                      label={group.tools.length}
315                      size="small"
316                      sx={{
317                        fontFamily: '"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace',
318                        fontWeight: 700,
319                        background: `${group.color}1a`,
320                        color: group.color,
321                        border: `1px solid ${group.color}44`,
322                      }}
323                    />
324                  </Box>
325                  <Grid container spacing={2}>
326                    {group.tools.map((tool) => (
327                      <Grid item xs={12} md={6} key={tool.name}>
328                        <ToolCard
329                          tool={tool}
330                          reqs={requirementsFor(tool.name, tool.enabled)}
331                          t={t}
332                        />
333                      </Grid>
334                    ))}
335                  </Grid>
336                </Box>
337              );
338            })
339          )}
340        </Box>
341      </Container>
342    );
343  }