NewInteractive.jsx
1 import { useState, useEffect } from "react"; 2 import { 3 Box, Button, Card, Chip, Divider, FormControlLabel, Grid, IconButton, 4 InputAdornment, MenuItem, Switch, TextField, Tooltip, Typography, styled, 5 } from "@mui/material"; 6 import { ArrowBack, Search, CheckCircle, Code } from "@mui/icons-material"; 7 import { useNavigate } from "react-router-dom"; 8 import { toast } from "react-toastify"; 9 import { useTranslation } from "react-i18next"; 10 import ReactJson from "@microlink/react-json-view"; 11 import useAuth from "app/hooks/useAuth"; 12 import Breadcrumb from "app/components/Breadcrumb"; 13 import { PROVIDER_CONFIG } from "./providerConfig"; 14 import api from "app/utils/api"; 15 16 const Container = styled("div")(({ theme }) => ({ 17 margin: "24px 48px", 18 [theme.breakpoints.down("md")]: { margin: "24px 32px" }, 19 [theme.breakpoints.down("sm")]: { margin: 16 }, 20 "& .breadcrumb": { marginBottom: 24 }, 21 })); 22 23 const Hero = styled(Box)(() => ({ 24 padding: "32px 0 24px", 25 textAlign: "center", 26 })); 27 28 const HeroTitle = styled(Typography)(() => ({ 29 fontWeight: 700, 30 letterSpacing: "-0.3px", 31 })); 32 33 const ProviderTile = styled(Card)(({ theme }) => ({ 34 padding: theme.spacing(2.5), 35 cursor: "pointer", 36 transition: "all 0.25s ease", 37 height: "100%", 38 display: "flex", 39 flexDirection: "column", 40 gap: 4, 41 border: "1px solid", 42 borderColor: theme.palette.divider, 43 borderRadius: 12, 44 position: "relative", 45 overflow: "hidden", 46 background: theme.palette.mode === "dark" ? "#1a1a24" : "#ffffff", 47 "&::before": { 48 content: '""', 49 position: "absolute", 50 top: 0, 51 left: 0, 52 right: 0, 53 height: 2, 54 background: "linear-gradient(90deg, #6366f1, #a855f7)", 55 opacity: 0, 56 transition: "opacity 0.25s", 57 }, 58 "&:hover": { 59 borderColor: theme.palette.primary.main, 60 transform: "translateY(-3px)", 61 boxShadow: "0 12px 24px -12px rgba(99,102,241,0.35)", 62 "&::before": { opacity: 1 }, 63 }, 64 })); 65 66 const FormCard = styled(Card)(({ theme }) => ({ 67 padding: theme.spacing(4), 68 borderRadius: 16, 69 border: "1px solid", 70 borderColor: theme.palette.divider, 71 background: theme.palette.mode === "dark" ? "#1a1a24" : "#ffffff", 72 })); 73 74 const SectionLabel = styled(Typography)(({ theme }) => ({ 75 fontSize: "0.72rem", 76 fontWeight: 700, 77 textTransform: "uppercase", 78 letterSpacing: "0.8px", 79 color: theme.palette.text.secondary, 80 marginBottom: theme.spacing(1.5), 81 display: "flex", 82 alignItems: "center", 83 gap: 6, 84 })); 85 86 const Dot = styled("span")(({ theme, color }) => ({ 87 width: 8, 88 height: 8, 89 borderRadius: "50%", 90 background: color || theme.palette.primary.main, 91 display: "inline-block", 92 })); 93 94 export default function NewInteractive() { 95 const { t } = useTranslation(); 96 const auth = useAuth(); 97 const navigate = useNavigate(); 98 99 const [selectedProvider, setSelectedProvider] = useState(null); 100 const [search, setSearch] = useState(""); 101 const [formState, setFormState] = useState({ 102 name: "", 103 privacy: "private", 104 description: "", 105 context_window: 4096, 106 }); 107 const [optionsState, setOptionsState] = useState({}); 108 109 useEffect(() => { 110 document.title = (process.env.REACT_APP_RESTAI_NAME || "RESTai") + " - " + t("llms.newBreadcrumb"); 111 }, [t]); 112 113 const handleSelectProvider = (providerKey) => { 114 setSelectedProvider(providerKey); 115 const provider = PROVIDER_CONFIG[providerKey]; 116 const defaults = {}; 117 provider.fields.forEach((field) => { 118 if (field.default !== undefined && field.default !== "") { 119 defaults[field.name] = field.default; 120 } 121 }); 122 setOptionsState(defaults); 123 }; 124 125 const handleBack = () => { 126 setSelectedProvider(null); 127 setOptionsState({}); 128 setFormState({ name: "", privacy: "private", description: "", context_window: 4096 }); 129 }; 130 131 const handleFormChange = (e) => { 132 const { name, value } = e.target; 133 setFormState((prev) => ({ ...prev, [name]: value })); 134 }; 135 136 const handleOptionChange = (fieldName, value) => { 137 setOptionsState((prev) => ({ ...prev, [fieldName]: value })); 138 }; 139 140 const handleJsonUpdate = (update) => { 141 setOptionsState(update.updated_src); 142 }; 143 144 const handleSubmit = async (e) => { 145 e.preventDefault(); 146 if (!formState.name.trim()) { 147 toast.error(t("llms.interactive.nameRequired")); 148 return; 149 } 150 const options = {}; 151 Object.entries(optionsState).forEach(([key, value]) => { 152 if (value !== "" && value !== undefined) options[key] = value; 153 }); 154 try { 155 const data = await api.post("/llms", { 156 name: formState.name, 157 class_name: selectedProvider, 158 options: JSON.stringify(options), 159 privacy: formState.privacy, 160 description: formState.description, 161 context_window: parseInt(formState.context_window) || 4096, 162 }, auth.user.token); 163 navigate("/llm/" + data.id); 164 } catch (err) { 165 // toasted 166 } 167 }; 168 169 const renderField = (field) => { 170 const value = optionsState[field.name] ?? field.default ?? ""; 171 if (field.type === "boolean") { 172 return ( 173 <Grid item xs={12} key={field.name}> 174 <FormControlLabel 175 control={ 176 <Switch 177 checked={!!optionsState[field.name]} 178 onChange={(e) => handleOptionChange(field.name, e.target.checked)} 179 /> 180 } 181 label={field.label} 182 /> 183 </Grid> 184 ); 185 } 186 return ( 187 <Grid item xs={12} sm={6} key={field.name}> 188 <TextField 189 fullWidth 190 size="small" 191 label={field.label} 192 type={field.type === "password" ? "password" : field.type === "number" ? "number" : "text"} 193 required={field.required} 194 value={value} 195 placeholder={field.placeholder || ""} 196 inputProps={field.type === "number" ? { step: field.step || 1 } : {}} 197 onChange={(e) => { 198 const val = field.type === "number" 199 ? (e.target.value === "" ? "" : Number(e.target.value)) 200 : e.target.value; 201 handleOptionChange(field.name, val); 202 }} 203 /> 204 </Grid> 205 ); 206 }; 207 208 // ─── Phase 1: Provider selection ─────────────────────────────── 209 if (!selectedProvider) { 210 const providers = Object.entries(PROVIDER_CONFIG).filter(([key, p]) => { 211 const q = search.toLowerCase(); 212 return !q || p.label.toLowerCase().includes(q) || key.toLowerCase().includes(q) || (p.description || "").toLowerCase().includes(q); 213 }); 214 215 return ( 216 <Container> 217 <Box className="breadcrumb"> 218 <Breadcrumb 219 routeSegments={[ 220 { name: t("nav.llms"), path: "/llms" }, 221 { name: t("llms.newBreadcrumb"), path: "/llms/new" }, 222 { name: t("llms.manualCrumb") }, 223 ]} 224 /> 225 </Box> 226 227 <Hero> 228 <Box sx={{ position: "relative", zIndex: 1 }}> 229 <HeroTitle variant="h4" color="primary" sx={{ mb: 1 }}> 230 {t("llms.interactive.title")} 231 </HeroTitle> 232 <Typography variant="body1" color="text.secondary" sx={{ maxWidth: 520, mx: "auto" }}> 233 {t("llms.interactive.subtitle")} 234 </Typography> 235 </Box> 236 </Hero> 237 238 <Box sx={{ display: "flex", justifyContent: "center", mb: 4 }}> 239 <TextField 240 size="small" 241 placeholder={t("llms.interactive.searchPlaceholder")} 242 value={search} 243 onChange={(e) => setSearch(e.target.value)} 244 sx={{ width: 360 }} 245 InputProps={{ 246 startAdornment: ( 247 <InputAdornment position="start"> 248 <Search fontSize="small" color="action" /> 249 </InputAdornment> 250 ), 251 }} 252 /> 253 </Box> 254 255 {providers.length === 0 ? ( 256 <Typography variant="body2" color="text.secondary" sx={{ textAlign: "center", py: 4 }}> 257 {t("llms.interactive.noProviders")} 258 </Typography> 259 ) : ( 260 <Grid container spacing={2.5}> 261 {providers.map(([key, provider]) => ( 262 <Grid item xs={12} sm={6} md={4} lg={3} key={key}> 263 <ProviderTile onClick={() => handleSelectProvider(key)}> 264 <Typography variant="subtitle1" fontWeight={700}> 265 {provider.label} 266 </Typography> 267 <Typography variant="body2" color="text.secondary" sx={{ flex: 1, lineHeight: 1.5 }}> 268 {provider.description} 269 </Typography> 270 <Typography 271 variant="caption" 272 sx={{ fontFamily: "monospace", color: "text.disabled", mt: 1, fontSize: "0.7rem" }} 273 > 274 {key} 275 </Typography> 276 </ProviderTile> 277 </Grid> 278 ))} 279 </Grid> 280 )} 281 </Container> 282 ); 283 } 284 285 // ─── Phase 2: Configuration form ─────────────────────────────── 286 const provider = PROVIDER_CONFIG[selectedProvider]; 287 288 return ( 289 <Container> 290 <Box className="breadcrumb"> 291 <Breadcrumb 292 routeSegments={[ 293 { name: t("nav.llms"), path: "/llms" }, 294 { name: t("llms.newBreadcrumb"), path: "/llms/new" }, 295 { name: t("llms.manualCrumb"), path: "/llms/new/manual" }, 296 { name: provider.label }, 297 ]} 298 /> 299 </Box> 300 301 <Box sx={{ maxWidth: 960, mx: "auto" }}> 302 <Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 3 }}> 303 <Tooltip title={t("llms.interactive.back")}> 304 <IconButton onClick={handleBack} sx={{ border: "1px solid", borderColor: "divider" }}> 305 <ArrowBack /> 306 </IconButton> 307 </Tooltip> 308 <Box sx={{ flex: 1 }}> 309 <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}> 310 <Typography variant="h5" fontWeight={700}> 311 {t("llms.interactive.newX", { provider: provider.label })} 312 </Typography> 313 <Chip 314 label={selectedProvider} 315 size="small" 316 sx={{ 317 fontFamily: "monospace", 318 fontSize: "0.72rem", 319 height: 22, 320 background: "linear-gradient(135deg, rgba(99,102,241,0.12), rgba(168,85,247,0.12))", 321 border: "1px solid rgba(99,102,241,0.3)", 322 color: "primary.main", 323 }} 324 /> 325 </Box> 326 <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> 327 {provider.description} 328 </Typography> 329 </Box> 330 </Box> 331 332 <form onSubmit={handleSubmit}> 333 <FormCard sx={{ mb: 3 }}> 334 <SectionLabel> 335 <Dot color="#6366f1" /> 336 {t("llms.interactive.general")} 337 </SectionLabel> 338 <Grid container spacing={2.5}> 339 <Grid item xs={12} sm={6}> 340 <TextField 341 fullWidth 342 required 343 size="small" 344 name="name" 345 label={t("llms.interactive.name")} 346 value={formState.name} 347 onChange={handleFormChange} 348 placeholder={t("llms.interactive.namePlaceholder")} 349 /> 350 </Grid> 351 <Grid item xs={12} sm={6}> 352 <TextField 353 fullWidth 354 select 355 size="small" 356 name="privacy" 357 label={t("llms.edit.privacy")} 358 value={formState.privacy} 359 onChange={handleFormChange} 360 > 361 <MenuItem value="private">{t("common.private")}</MenuItem> 362 <MenuItem value="public">{t("common.public")}</MenuItem> 363 </TextField> 364 </Grid> 365 <Grid item xs={12} sm={8}> 366 <TextField 367 fullWidth 368 size="small" 369 name="description" 370 label={t("llms.interactive.description")} 371 value={formState.description} 372 onChange={handleFormChange} 373 placeholder={t("llms.interactive.descPlaceholder")} 374 /> 375 </Grid> 376 <Grid item xs={12} sm={4}> 377 <TextField 378 fullWidth 379 size="small" 380 name="context_window" 381 label={t("llms.interactive.contextWindow")} 382 type="number" 383 value={formState.context_window} 384 onChange={handleFormChange} 385 helperText={t("llms.interactive.maxTokens")} 386 /> 387 </Grid> 388 </Grid> 389 </FormCard> 390 391 <FormCard sx={{ mb: 3 }}> 392 <SectionLabel> 393 <Dot color="#10b981" /> 394 {t("llms.interactive.providerOptions", { provider: provider.label })} 395 </SectionLabel> 396 <Grid container spacing={2.5}> 397 {provider.fields.map(renderField)} 398 </Grid> 399 </FormCard> 400 401 <FormCard sx={{ mb: 3 }}> 402 <SectionLabel> 403 <Dot color="#f59e0b" /> 404 {t("llms.interactive.rawOptions")} 405 </SectionLabel> 406 <Typography variant="caption" color="text.secondary" sx={{ mb: 1.5, display: "block" }}> 407 {t("llms.interactive.rawHelp")} 408 </Typography> 409 <Box 410 sx={{ 411 borderRadius: 2, 412 border: "1px solid", 413 borderColor: "divider", 414 p: 2, 415 fontSize: "0.85rem", 416 background: (t) => t.palette.mode === "dark" ? "#0f0f17" : "#fafafa", 417 }} 418 > 419 <ReactJson 420 src={optionsState} 421 name={false} 422 enableClipboard={true} 423 onEdit={handleJsonUpdate} 424 onAdd={handleJsonUpdate} 425 onDelete={handleJsonUpdate} 426 displayDataTypes={false} 427 displayObjectSize={false} 428 theme="rjv-default" 429 /> 430 </Box> 431 </FormCard> 432 433 <Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1.5 }}> 434 <Button variant="outlined" onClick={handleBack}> 435 {t("common.cancel")} 436 </Button> 437 <Button type="submit" variant="contained" startIcon={<CheckCircle />}> 438 {t("llms.interactive.createLlm")} 439 </Button> 440 </Box> 441 </form> 442 </Box> 443 </Container> 444 ); 445 }