Proyecto ChatBot Con Gemini

This commit is contained in:
2025-11-18 14:34:26 -03:00
commit 83a48e16da
60 changed files with 13078 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
#root {
width: 100%;
margin: 0;
padding: 0;
text-align: left;
}

40
chatbot-admin/src/App.tsx Normal file
View File

@@ -0,0 +1,40 @@
// src/App.tsx
import { useState, useEffect } from 'react';
import AdminPanel from './components/AdminPanel';
import Login from './components/Login';
import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
const darkTheme = createTheme({
palette: {
mode: 'dark',
},
});
function App() {
const [token, setToken] = useState<string | null>(localStorage.getItem('jwt_token'));
useEffect(() => {
if (token) {
localStorage.setItem('jwt_token', token);
} else {
localStorage.removeItem('jwt_token');
}
}, [token]);
const handleLogout = () => {
setToken(null);
};
return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
{token ? (
<AdminPanel onLogout={handleLogout} />
) : (
<Login onLoginSuccess={setToken} />
)}
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1,27 @@
// src/api/apiClient.ts
import axios from 'axios';
// Creamos la instancia de Axios
const apiClient = axios.create({
// Es una buena práctica establecer la URL base aquí
baseURL: import.meta.env.VITE_API_BASE_URL
});
// Añadimos el interceptor para inyectar el token JWT en cada petición
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('jwt_token');
if (token) {
// Aseguramos que la cabecera de autorización se establezca correctamente
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
// Manejamos errores en la configuración de la petición
return Promise.reject(error);
}
);
// Exportamos la instancia configurada para que otros archivos puedan usarla
export default apiClient;

View File

@@ -0,0 +1,46 @@
// src/components/AdminPanel.tsx
import React, { useState } from 'react';
import { Box, Typography, AppBar, Toolbar, IconButton, Tabs, Tab } from '@mui/material';
import LogoutIcon from '@mui/icons-material/Logout';
// Importamos los dos componentes que mostraremos en las pestañas
import ContextManager from './ContextManager'; // Renombraremos el AdminPanel original
import LogsViewer from './LogsViewer';
interface AdminPanelProps {
onLogout: () => void;
}
// El componente se convierte en un contenedor con pestañas
const AdminPanel: React.FC<AdminPanelProps> = ({ onLogout }) => {
const [currentTab, setCurrentTab] = useState(0);
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setCurrentTab(newValue);
};
return (
<Box>
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Panel de Administración del Chatbot
</Typography>
<IconButton color="inherit" onClick={onLogout} aria-label="Cerrar sesión">
<LogoutIcon />
</IconButton>
</Toolbar>
<Tabs value={currentTab} onChange={handleTabChange} textColor="inherit" indicatorColor="secondary">
<Tab label="Gestor de Contexto" />
<Tab label="Historial de Conversaciones" />
</Tabs>
</AppBar>
{/* Mostramos el componente correspondiente a la pestaña activa */}
{currentTab === 0 && <ContextManager onAuthError={onLogout} />}
{currentTab === 1 && <LogsViewer onAuthError={onLogout} />}
</Box>
);
};
export default AdminPanel;

View File

@@ -0,0 +1,213 @@
// src/components/AdminPanel.tsx
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import { DataGrid, GridActionsCellItem } from '@mui/x-data-grid';
import type { GridColDef } from '@mui/x-data-grid';
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Alert, DialogContentText, TextField } from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import apiClient from '../api/apiClient';
interface ContextoItem {
id: number;
clave: string;
valor: string;
descripcion: string;
fechaActualizacion: string;
}
interface ContextManagerProps {
onAuthError: () => void;
}
const ContextManager: React.FC<ContextManagerProps> = ({ onAuthError }) => {
const [rows, setRows] = useState<ContextoItem[]>([]);
const [open, setOpen] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [currentRow, setCurrentRow] = useState<Partial<ContextoItem>>({});
const [error, setError] = useState<string | null>(null);
// Estado para el diálogo de confirmación de borrado
const [confirmOpen, setConfirmOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<number | null>(null);
const fetchData = useCallback(async () => {
try {
const response = await apiClient.get('/api/admin/contexto');
setRows(response.data);
} catch (err) {
setError('No se pudieron cargar los datos.');
if (axios.isAxiosError(err) && err.response?.status === 401) {
onAuthError();
}
}
}, [onAuthError]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleOpen = (item?: ContextoItem) => {
if (item) {
setIsEdit(true);
setCurrentRow(item);
} else {
setIsEdit(false);
setCurrentRow({ clave: '', valor: '', descripcion: '' });
}
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
const handleSave = async () => {
try {
if (isEdit) {
await apiClient.put(`/api/admin/contexto/${currentRow.id}`, currentRow);
} else {
await apiClient.post('/api/admin/contexto', currentRow);
}
fetchData();
handleClose();
} catch (err) {
setError('Error al guardar el item.');
}
};
// Abre el diálogo de confirmación
const handleDeleteClick = (id: number) => {
setItemToDelete(id);
setConfirmOpen(true);
};
// Cierra el diálogo de confirmación
const handleConfirmClose = () => {
setConfirmOpen(false);
setItemToDelete(null);
};
// Ejecuta la eliminación si se confirma
const handleConfirmDelete = async () => {
if (itemToDelete !== null) {
try {
await apiClient.delete(`/api/admin/contexto/${itemToDelete}`);
fetchData();
} catch (err) {
setError('Error al eliminar el item.');
} finally {
handleConfirmClose();
}
}
};
const columns: GridColDef[] = [
{ field: 'clave', headerName: 'Clave', width: 200 },
{ field: 'valor', headerName: 'Valor', width: 350 },
{ field: 'descripcion', headerName: 'Descripción', flex: 1 },
{
field: 'fechaActualizacion',
headerName: 'Última Actualización',
width: 200,
valueGetter: (value) => new Date(value).toLocaleString(),
},
{
field: 'actions',
type: 'actions',
width: 100,
getActions: (params) => [
<GridActionsCellItem
icon={<EditIcon />}
label="Editar"
onClick={() => handleOpen(params.row as ContextoItem)}
/>,
<GridActionsCellItem
icon={<DeleteIcon />}
label="Eliminar"
// Llama a la función que abre el diálogo
onClick={() => handleDeleteClick(params.id as number)}
/>,
],
},
];
return (
<Box sx={{ p: 4 }}>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Button startIcon={<AddIcon />} variant="contained" onClick={() => handleOpen()} sx={{ mb: 1 }}>
Añadir Nuevo Item
</Button>
<Box sx={{ height: 600, width: '100%' }}>
<DataGrid rows={rows} columns={columns} pageSizeOptions={[10, 25, 50, 100]} />
</Box>
<Box sx={{ p: 4 }}>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Button startIcon={<AddIcon />} variant="contained" onClick={() => handleOpen()} sx={{ mb: 1 }}>
Añadir Nuevo Item
</Button>
<Box sx={{ height: 600, width: '100%' }}>
<DataGrid rows={rows} columns={columns} pageSizeOptions={[10, 25, 50, 100]} />
</Box>
{/* --- DIÁLOGO DE EDICIÓN/CREACIÓN --- */}
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
<DialogTitle>{isEdit ? 'Editar Item' : 'Añadir Nuevo Item'}</DialogTitle>
<DialogContent>
<TextField
margin="dense"
label="Clave"
fullWidth
value={currentRow.clave || ''}
disabled={isEdit} // La clave no se puede editar una vez creada
onChange={(e) => setCurrentRow({ ...currentRow, clave: e.target.value })}
helperText={!isEdit ? "Esta clave no se podrá modificar en el futuro." : ""}
/>
<TextField
margin="dense"
label="Valor"
fullWidth
multiline
rows={4}
value={currentRow.valor || ''}
onChange={(e) => setCurrentRow({ ...currentRow, valor: e.target.value })}
/>
<TextField
margin="dense"
label="Descripción"
fullWidth
value={currentRow.descripcion || ''}
onChange={(e) => setCurrentRow({ ...currentRow, descripcion: e.target.value })}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancelar</Button>
<Button onClick={handleSave} variant="contained">Guardar</Button>
</DialogActions>
</Dialog>
{/* --- DIÁLOGO DE CONFIRMACIÓN DE BORRADO --- */}
<Dialog
open={confirmOpen}
onClose={handleConfirmClose}
>
<DialogTitle>Confirmar Eliminación</DialogTitle>
<DialogContent>
<DialogContentText>
¿Estás seguro de que quieres eliminar este item? Esta acción no se puede deshacer.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleConfirmClose}>Cancelar</Button>
<Button onClick={handleConfirmDelete} color="error" variant="contained">
Eliminar
</Button>
</DialogActions>
</Dialog>
</Box>
</Box>
);
};
export default ContextManager;

View File

@@ -0,0 +1,81 @@
// src/components/Login.tsx
import React from 'react';
import { Box, Button, TextField, Typography, Paper, Alert } from '@mui/material';
interface LoginProps {
onLoginSuccess: (token: string) => void;
}
const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState('');
const handleLogin = async (event: React.FormEvent) => {
event.preventDefault();
setError('');
try {
// Usamos la variable de entorno para la URL de la API
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const data = await response.json();
onLoginSuccess(data.token);
} else {
setError('Usuario o contraseña incorrectos.');
}
} catch (err) {
setError('No se pudo conectar con el servidor. Inténtalo de nuevo más tarde.');
}
};
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Paper elevation={6} sx={{ padding: 4, width: '100%', maxWidth: 400 }}>
<Typography variant="h4" component="h1" gutterBottom textAlign="center">
Iniciar Sesión
</Typography>
<Typography variant="body2" textAlign="center" sx={{ mb: 3 }}>
Gestor de Contexto del Chatbot
</Typography>
<form onSubmit={handleLogin}>
<TextField
label="Usuario"
variant="outlined"
fullWidth
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
/>
<TextField
label="Contraseña"
type="password"
variant="outlined"
fullWidth
margin="normal"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
<Button
type="submit"
variant="contained"
fullWidth
size="large"
sx={{ mt: 3 }}
>
Entrar
</Button>
</form>
</Paper>
</Box>
);
};
export default Login;

View File

@@ -0,0 +1,72 @@
// src/components/LogsViewer.tsx
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { Box, Typography, Alert } from '@mui/material';
import apiClient from '../api/apiClient';
interface ConversacionLog {
id: number;
usuarioMensaje: string;
botRespuesta: string;
fecha: string;
}
interface LogsViewerProps {
onAuthError: () => void; // Función para desloguear si el token es inválido
}
const LogsViewer: React.FC<LogsViewerProps> = ({ onAuthError }) => {
const [logs, setLogs] = useState<ConversacionLog[]>([]);
const [error, setError] = useState<string | null>(null);
const fetchLogs = useCallback(async () => {
try {
const response = await apiClient.get('/api/admin/logs');
setLogs(response.data);
} catch (err) {
setError('No se pudieron cargar los logs.');
if (axios.isAxiosError(err) && err.response?.status === 401) {
onAuthError(); // Llamamos a la función de logout si hay un error de autenticación
}
}
}, [onAuthError]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const columns: GridColDef[] = [
{
field: 'fecha',
headerName: 'Fecha',
width: 200,
valueGetter: (value) => new Date(value).toLocaleString(),
},
{ field: 'usuarioMensaje', headerName: 'Mensaje del Usuario', flex: 1 },
{ field: 'botRespuesta', headerName: 'Respuesta del Bot', flex: 1 },
];
return (
<Box sx={{ p: 4 }}>
<Typography variant="h4" gutterBottom>
Historial de Conversaciones
</Typography>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Box sx={{ height: 700, width: '100%' }}>
<DataGrid
rows={logs}
columns={columns}
pageSizeOptions={[25, 50, 100]}
initialState={{
sorting: {
sortModel: [{ field: 'fecha', sort: 'desc' }],
},
}}
/>
</Box>
</Box>
);
};
export default LogsViewer;

View File

@@ -0,0 +1,66 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)