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

24
chatbot-admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
chatbot-admin/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

12
chatbot-admin/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>chatbot-admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4530
chatbot-admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
{
"name": "chatbot-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5",
"@mui/x-data-grid": "^8.18.0",
"axios": "^1.13.2",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.3",
"vite": "^7.2.2"
}
}

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>,
)

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})