/ frontend / src / app / views / embeddings / NewInteractive.jsx
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  }