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