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 }