NewInteractive.jsx
1 import { useState, useEffect } from "react"; 2 import { 3 Box, Button, Card, Chip, Grid, IconButton, InputAdornment, 4 MenuItem, TextField, Tooltip, Typography, styled, 5 } from "@mui/material"; 6 import { ArrowBack, Search, CheckCircle } 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 { EMBEDDING_PROVIDER_CONFIG } from "./embeddingProviderConfig"; 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: theme.palette.primary.main, 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 ${theme.palette.primary.main}59`, 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 dimension: 1536, 106 }); 107 const [optionsState, setOptionsState] = useState({}); 108 109 useEffect(() => { 110 document.title = (process.env.REACT_APP_RESTAI_NAME || "RESTai") + " - " + t("embeddings.newBreadcrumb"); 111 }, [t]); 112 113 const handleSelectProvider = (providerKey) => { 114 const provider = EMBEDDING_PROVIDER_CONFIG[providerKey]; 115 setSelectedProvider(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 setFormState((prev) => ({ ...prev, dimension: provider.defaultDimension })); 124 }; 125 126 const handleBack = () => { 127 setSelectedProvider(null); 128 setOptionsState({}); 129 setFormState({ name: "", privacy: "private", description: "", dimension: 1536 }); 130 }; 131 132 const handleFormChange = (e) => { 133 const { name, value } = e.target; 134 setFormState((prev) => ({ ...prev, [name]: value })); 135 }; 136 137 const handleOptionChange = (fieldName, value) => { 138 setOptionsState((prev) => ({ ...prev, [fieldName]: value })); 139 }; 140 141 const handleJsonUpdate = (update) => { 142 setOptionsState(update.updated_src); 143 }; 144 145 const handleSubmit = async (e) => { 146 e.preventDefault(); 147 if (!formState.name.trim()) { 148 toast.error(t("embeddings.interactive.nameRequired")); 149 return; 150 } 151 const options = {}; 152 Object.entries(optionsState).forEach(([key, value]) => { 153 if (value !== "" && value !== undefined) options[key] = value; 154 }); 155 try { 156 const data = await api.post("/embeddings", { 157 name: formState.name, 158 class_name: selectedProvider, 159 options: JSON.stringify(options), 160 privacy: formState.privacy, 161 description: formState.description, 162 dimension: Number(formState.dimension), 163 }, auth.user.token); 164 navigate("/embedding/" + data.id); 165 } catch (err) { 166 // toasted 167 } 168 }; 169 170 const renderField = (field) => { 171 const value = optionsState[field.name] ?? field.default ?? ""; 172 return ( 173 <Grid item xs={12} sm={6} key={field.name}> 174 <TextField 175 fullWidth 176 size="small" 177 label={field.label} 178 type={field.type === "password" ? "password" : field.type === "number" ? "number" : "text"} 179 required={field.required} 180 value={value} 181 placeholder={field.placeholder || ""} 182 inputProps={field.type === "number" ? { step: field.step || 1 } : {}} 183 onChange={(e) => { 184 const val = field.type === "number" 185 ? (e.target.value === "" ? "" : Number(e.target.value)) 186 : e.target.value; 187 handleOptionChange(field.name, val); 188 }} 189 /> 190 </Grid> 191 ); 192 }; 193 194 // ─── Phase 1: Provider selection ─────────────────────────────── 195 if (!selectedProvider) { 196 const providers = Object.entries(EMBEDDING_PROVIDER_CONFIG).filter(([key, p]) => { 197 const q = search.toLowerCase(); 198 return !q || p.label.toLowerCase().includes(q) || key.toLowerCase().includes(q) || (p.description || "").toLowerCase().includes(q); 199 }); 200 201 return ( 202 <Container> 203 <Box className="breadcrumb"> 204 <Breadcrumb 205 routeSegments={[ 206 { name: t("nav.embeddings"), path: "/embeddings" }, 207 { name: t("embeddings.newBreadcrumb"), path: "/embeddings/new" }, 208 { name: t("embeddings.manualCrumb") }, 209 ]} 210 /> 211 </Box> 212 213 <Hero> 214 <Box sx={{ position: "relative", zIndex: 1 }}> 215 <HeroTitle variant="h4" color="primary" sx={{ mb: 1 }}> 216 {t("embeddings.interactive.title")} 217 </HeroTitle> 218 <Typography variant="body1" color="text.secondary" sx={{ maxWidth: 520, mx: "auto" }}> 219 {t("embeddings.interactive.subtitle")} 220 </Typography> 221 </Box> 222 </Hero> 223 224 <Box sx={{ display: "flex", justifyContent: "center", mb: 4 }}> 225 <TextField 226 size="small" 227 placeholder={t("embeddings.interactive.searchPlaceholder")} 228 value={search} 229 onChange={(e) => setSearch(e.target.value)} 230 sx={{ width: 360 }} 231 InputProps={{ 232 startAdornment: ( 233 <InputAdornment position="start"> 234 <Search fontSize="small" color="action" /> 235 </InputAdornment> 236 ), 237 }} 238 /> 239 </Box> 240 241 {providers.length === 0 ? ( 242 <Typography variant="body2" color="text.secondary" sx={{ textAlign: "center", py: 4 }}> 243 {t("embeddings.interactive.noProviders")} 244 </Typography> 245 ) : ( 246 <Grid container spacing={2.5}> 247 {providers.map(([key, provider]) => ( 248 <Grid item xs={12} sm={6} md={4} lg={3} key={key}> 249 <ProviderTile onClick={() => handleSelectProvider(key)}> 250 <Typography variant="subtitle1" fontWeight={700}> 251 {provider.label} 252 </Typography> 253 <Typography variant="body2" color="text.secondary" sx={{ flex: 1, lineHeight: 1.5 }}> 254 {provider.description} 255 </Typography> 256 <Typography 257 variant="caption" 258 sx={{ fontFamily: "monospace", color: "text.disabled", mt: 1, fontSize: "0.7rem" }} 259 > 260 {key} 261 </Typography> 262 </ProviderTile> 263 </Grid> 264 ))} 265 </Grid> 266 )} 267 </Container> 268 ); 269 } 270 271 // ─── Phase 2: Configuration form ─────────────────────────────── 272 const provider = EMBEDDING_PROVIDER_CONFIG[selectedProvider]; 273 274 return ( 275 <Container> 276 <Box className="breadcrumb"> 277 <Breadcrumb 278 routeSegments={[ 279 { name: t("nav.embeddings"), path: "/embeddings" }, 280 { name: t("embeddings.newBreadcrumb"), path: "/embeddings/new" }, 281 { name: t("embeddings.manualCrumb"), path: "/embeddings/new/manual" }, 282 { name: provider.label }, 283 ]} 284 /> 285 </Box> 286 287 <Box sx={{ maxWidth: 960, mx: "auto" }}> 288 <Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 3 }}> 289 <Tooltip title={t("embeddings.interactive.back")}> 290 <IconButton onClick={handleBack} sx={{ border: "1px solid", borderColor: "divider" }}> 291 <ArrowBack /> 292 </IconButton> 293 </Tooltip> 294 <Box sx={{ flex: 1 }}> 295 <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}> 296 <Typography variant="h5" fontWeight={700}> 297 {t("embeddings.interactive.newX", { provider: provider.label })} 298 </Typography> 299 <Chip 300 label={selectedProvider} 301 size="small" 302 sx={{ 303 fontFamily: "monospace", 304 fontSize: "0.72rem", 305 height: 22, 306 }} 307 color="primary" 308 variant="outlined" 309 /> 310 </Box> 311 <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> 312 {provider.description} 313 </Typography> 314 </Box> 315 </Box> 316 317 <form onSubmit={handleSubmit}> 318 <FormCard sx={{ mb: 3 }}> 319 <SectionLabel> 320 <Dot color="#6366f1" /> 321 {t("embeddings.interactive.general")} 322 </SectionLabel> 323 <Grid container spacing={2.5}> 324 <Grid item xs={12} sm={6}> 325 <TextField 326 fullWidth 327 required 328 size="small" 329 name="name" 330 label={t("embeddings.interactive.name")} 331 value={formState.name} 332 onChange={handleFormChange} 333 placeholder={t("embeddings.interactive.namePlaceholder")} 334 /> 335 </Grid> 336 <Grid item xs={12} sm={6}> 337 <TextField 338 fullWidth 339 select 340 size="small" 341 name="privacy" 342 label={t("embeddings.edit.privacy")} 343 value={formState.privacy} 344 onChange={handleFormChange} 345 > 346 <MenuItem value="private">{t("common.private")}</MenuItem> 347 <MenuItem value="public">{t("common.public")}</MenuItem> 348 </TextField> 349 </Grid> 350 <Grid item xs={12} sm={8}> 351 <TextField 352 fullWidth 353 size="small" 354 name="description" 355 label={t("embeddings.interactive.description")} 356 value={formState.description} 357 onChange={handleFormChange} 358 placeholder={t("embeddings.interactive.descPlaceholder")} 359 /> 360 </Grid> 361 <Grid item xs={12} sm={4}> 362 <TextField 363 fullWidth 364 size="small" 365 name="dimension" 366 label={t("embeddings.interactive.dimension")} 367 type="number" 368 value={formState.dimension} 369 onChange={(e) => 370 setFormState((prev) => ({ 371 ...prev, 372 dimension: e.target.value === "" ? "" : Number(e.target.value), 373 })) 374 } 375 helperText={t("embeddings.interactive.vectorSize")} 376 /> 377 </Grid> 378 </Grid> 379 </FormCard> 380 381 <FormCard sx={{ mb: 3 }}> 382 <SectionLabel> 383 <Dot color="#10b981" /> 384 {t("embeddings.interactive.providerOptions", { provider: provider.label })} 385 </SectionLabel> 386 <Grid container spacing={2.5}> 387 {provider.fields.map(renderField)} 388 </Grid> 389 </FormCard> 390 391 <FormCard sx={{ mb: 3 }}> 392 <SectionLabel> 393 <Dot color="#f59e0b" /> 394 {t("embeddings.interactive.rawOptions")} 395 </SectionLabel> 396 <Typography variant="caption" color="text.secondary" sx={{ mb: 1.5, display: "block" }}> 397 {t("embeddings.interactive.rawHelp")} 398 </Typography> 399 <Box 400 sx={{ 401 borderRadius: 2, 402 border: "1px solid", 403 borderColor: "divider", 404 p: 2, 405 fontSize: "0.85rem", 406 background: (t) => t.palette.mode === "dark" ? "#0f0f17" : "#fafafa", 407 }} 408 > 409 <ReactJson 410 src={optionsState} 411 name={false} 412 enableClipboard={true} 413 onEdit={handleJsonUpdate} 414 onAdd={handleJsonUpdate} 415 onDelete={handleJsonUpdate} 416 displayDataTypes={false} 417 displayObjectSize={false} 418 theme="rjv-default" 419 /> 420 </Box> 421 </FormCard> 422 423 <Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1.5 }}> 424 <Button variant="outlined" onClick={handleBack}> 425 {t("common.cancel")} 426 </Button> 427 <Button type="submit" variant="contained" startIcon={<CheckCircle />}> 428 {t("embeddings.interactive.createEmbedding")} 429 </Button> 430 </Box> 431 </form> 432 </Box> 433 </Container> 434 ); 435 }