HttpOnly Cookies y Filtros Avanzados

1. Seguridad: Cookies HttpOnly
Backend (ChatbotApi):
AuthController.cs
: Ahora setea una cookie HttpOnly, Secure y SameSite=Strict llamada X-Access-Token en lugar de devolver el token en el cuerpo de la respuesta.
AuthController.cs
: Añadido endpoint logout para invalidar la cookie.
Program.cs
: Configurado JwtBearer para leer el token desde la cookie si está presente.
Frontend (chatbot-admin):
apiClient.ts
: Configurado con withCredentials: true para enviar cookies automáticamente. Eliminado el interceptor de localStorage.
Login.tsx
: Eliminado manejo de token manual. Ahora solo comprueba éxito (200 OK).
App.tsx
: Refactorizado para comprobar autenticación mediante una petición a /api/admin/contexto al inicio, en lugar de leer localStorage.
2. Filtros y Búsqueda
Logs (
AdminController.cs
 &
LogsViewer.tsx
):
Implementado filtrado en servidor por Fecha Inicio, Fecha Fin y Búsqueda de texto.
Frontend actualizado con selectores de fecha y barra de búsqueda.
Contexto y Fuentes (
ContextManager.tsx
 &
SourceManager.tsx
):
Añadida barra de búsqueda en el cliente para filtrar rápidamente por nombre, valor o descripción.
This commit is contained in:
2025-12-05 14:03:27 -03:00
parent 7e9e3ba87e
commit 5c97614e4f
13 changed files with 1509 additions and 146 deletions

View File

@@ -76,12 +76,35 @@ namespace ChatbotApi.Controllers
} }
[HttpGet("logs")] [HttpGet("logs")]
public async Task<IActionResult> GetConversationLogs() public async Task<IActionResult> GetConversationLogs(
[FromQuery] DateTime? startDate,
[FromQuery] DateTime? endDate,
[FromQuery] string? search)
{ {
// Limitamos a 200 para evitar sobrecarga var query = _context.ConversacionLogs.AsQueryable();
var logs = await _context.ConversacionLogs
if (startDate.HasValue)
{
query = query.Where(l => l.Fecha >= startDate.Value);
}
if (endDate.HasValue)
{
// Ajustamos al final del día si es necesario, o asumimos fecha exacta
query = query.Where(l => l.Fecha <= endDate.Value);
}
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(l =>
l.UsuarioMensaje.Contains(search) ||
l.BotRespuesta.Contains(search));
}
// Limitamos a 500 para evitar sobrecarga pero permitiendo ver resultados de búsqueda
var logs = await query
.OrderByDescending(log => log.Fecha) .OrderByDescending(log => log.Fecha)
.Take(200) .Take(500)
.ToListAsync(); .ToListAsync();
return Ok(logs); return Ok(logs);
} }

View File

@@ -17,7 +17,8 @@ public class LoginRequest
[MaxLength(100)] [MaxLength(100)]
public required string Password { get; set; } public required string Password { get; set; }
} }
public class LoginResponse { public required string Token { get; set; } } // [SEGURIDAD] LoginResponse ya no es necesario si usamos solo cookies, pero podriamos dejar un mensaje de exito.
public class LoginResponse { public string Message { get; set; } = "Login exitoso"; }
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
@@ -41,12 +42,31 @@ public class AuthController : ControllerBase
if (user != null && await _userManager.CheckPasswordAsync(user, loginRequest.Password)) if (user != null && await _userManager.CheckPasswordAsync(user, loginRequest.Password))
{ {
var token = GenerateJwtToken(user); var token = GenerateJwtToken(user);
return Ok(new LoginResponse { Token = token });
// [SEGURIDAD] Setear Cookie HttpOnly
var cookieOptions = new CookieOptions
{
HttpOnly = true,
Secure = true, // Requiere HTTPS
SameSite = SameSiteMode.Strict,
Expires = DateTime.UtcNow.AddHours(8)
};
Response.Cookies.Append("X-Access-Token", token, cookieOptions);
return Ok(new LoginResponse());
} }
return Unauthorized("Credenciales inválidas."); return Unauthorized("Credenciales inválidas.");
} }
[HttpPost("logout")]
public IActionResult Logout()
{
Response.Cookies.Delete("X-Access-Token");
return Ok(new { message = "Sesión cerrada" });
}
#if DEBUG #if DEBUG
// [SEGURIDAD] Endpoint solo para desarrollo // [SEGURIDAD] Endpoint solo para desarrollo
[HttpPost("setup-admin")] [HttpPost("setup-admin")]

View File

@@ -57,7 +57,7 @@ namespace ChatbotApi.Controllers
try try
{ {
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
_cache.Remove(CacheKey); // Invalidate cache _cache.Remove(CacheKey); // Invalidar caché
} }
catch (DbUpdateConcurrencyException) catch (DbUpdateConcurrencyException)
{ {
@@ -82,7 +82,7 @@ namespace ChatbotApi.Controllers
systemPrompt.UpdatedAt = DateTime.UtcNow; systemPrompt.UpdatedAt = DateTime.UtcNow;
_context.SystemPrompts.Add(systemPrompt); _context.SystemPrompts.Add(systemPrompt);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
_cache.Remove(CacheKey); // Invalidate cache _cache.Remove(CacheKey); // Invalidar caché
return CreatedAtAction("GetSystemPrompt", new { id = systemPrompt.Id }, systemPrompt); return CreatedAtAction("GetSystemPrompt", new { id = systemPrompt.Id }, systemPrompt);
} }
@@ -99,7 +99,7 @@ namespace ChatbotApi.Controllers
_context.SystemPrompts.Remove(systemPrompt); _context.SystemPrompts.Remove(systemPrompt);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
_cache.Remove(CacheKey); // Invalidate cache _cache.Remove(CacheKey); // Invalidar caché
return NoContent(); return NoContent();
} }
@@ -117,7 +117,7 @@ namespace ChatbotApi.Controllers
systemPrompt.IsActive = !systemPrompt.IsActive; systemPrompt.IsActive = !systemPrompt.IsActive;
systemPrompt.UpdatedAt = DateTime.UtcNow; systemPrompt.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
_cache.Remove(CacheKey); // Invalidate cache _cache.Remove(CacheKey); // Invalidar caché
return Ok(new { IsActive = systemPrompt.IsActive }); return Ok(new { IsActive = systemPrompt.IsActive });
} }

View File

@@ -34,7 +34,7 @@ builder.Services.AddCors(options =>
"http://localhost:5174", "http://localhost:5174",
"http://localhost:5175") "http://localhost:5175")
.AllowAnyHeader() .AllowAnyHeader()
// [SEGURIDAD] Solo permitimos los verbos necesarios. Bloqueamos TRACE, HEAD, etc. .AllowCredentials() // [SEGURIDAD] Necesario para Cookies HttpOnly
.WithMethods("GET", "POST", "PUT", "DELETE", "OPTIONS"); .WithMethods("GET", "POST", "PUT", "DELETE", "OPTIONS");
}); });
}); });
@@ -85,6 +85,19 @@ builder.Services.AddAuthentication(options =>
)), )),
ClockSkew = TimeSpan.Zero // Token expira exactamente cuando dice ClockSkew = TimeSpan.Zero // Token expira exactamente cuando dice
}; };
// [SEGURIDAD] Evento para permitir lectura de Token desde Cookie
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (context.Request.Cookies.ContainsKey("X-Access-Token"))
{
context.Token = context.Request.Cookies["X-Access-Token"];
}
return Task.CompletedTask;
}
};
}); });
// [SEGURIDAD] RATE LIMITING AVANZADO // [SEGURIDAD] RATE LIMITING AVANZADO

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,9 @@
"@mui/x-data-grid": "^8.18.0", "@mui/x-data-grid": "^8.18.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"rehype-sanitize": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

View File

@@ -2,31 +2,58 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import AdminPanel from './components/AdminPanel'; import AdminPanel from './components/AdminPanel';
import Login from './components/Login'; import Login from './components/Login';
import { CssBaseline, ThemeProvider } from '@mui/material'; import { CssBaseline, ThemeProvider, Box, CircularProgress } from '@mui/material';
import theme from './theme'; import theme from './theme';
import apiClient from './api/apiClient';
function App() { function App() {
const [token, setToken] = useState<string | null>(localStorage.getItem('jwt_token')); const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => { useEffect(() => {
if (token) { checkAuth();
localStorage.setItem('jwt_token', token); }, []);
} else {
localStorage.removeItem('jwt_token');
}
}, [token]);
const handleLogout = () => { const checkAuth = async () => {
setToken(null); try {
// Intentamos acceder a un recurso protegido para verificar la cookie
await apiClient.get('/api/admin/contexto');
setIsAuthenticated(true);
} catch (error) {
setIsAuthenticated(false);
} finally {
setIsLoading(false);
}
}; };
const handleLogout = async () => {
try {
await apiClient.post('/api/auth/logout');
} catch (error) {
console.error("Error al cerrar sesión", error);
} finally {
setIsAuthenticated(false);
}
};
if (isLoading) {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', bgcolor: 'background.default' }}>
<CircularProgress color="secondary" />
</Box>
</ThemeProvider>
);
}
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
{token ? ( {isAuthenticated ? (
<AdminPanel onLogout={handleLogout} /> <AdminPanel onLogout={handleLogout} />
) : ( ) : (
<Login onLoginSuccess={setToken} /> <Login onLoginSuccess={() => setIsAuthenticated(true)} />
)} )}
</ThemeProvider> </ThemeProvider>
); );

View File

@@ -4,21 +4,18 @@ import axios from 'axios';
// Creamos la instancia de Axios // Creamos la instancia de Axios
const apiClient = axios.create({ const apiClient = axios.create({
// Es una buena práctica establecer la URL base aquí // Es una buena práctica establecer la URL base aquí
baseURL: import.meta.env.VITE_API_BASE_URL baseURL: import.meta.env.VITE_API_BASE_URL,
// [SEGURIDAD] Permitir el envío de Cookies HttpOnly
withCredentials: true
}); });
// Añadimos el interceptor para inyectar el token JWT en cada petición // No necesitamos interceptor para inyectar token porque el navegador
apiClient.interceptors.request.use( // envía automáticamente la cookie HttpOnly en cada petición credentialed.
(config) => {
const token = localStorage.getItem('jwt_token'); apiClient.interceptors.response.use(
if (token) { (response) => response,
// Aseguramos que la cabecera de autorización se establezca correctamente
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => { (error) => {
// Manejamos errores en la configuración de la petición // Manejamos errores globales si es necesario
return Promise.reject(error); return Promise.reject(error);
} }
); );

View File

@@ -28,10 +28,20 @@ const ContextManager: React.FC<ContextManagerProps> = ({ onAuthError }) => {
const [currentRow, setCurrentRow] = useState<Partial<ContextoItem>>({}); const [currentRow, setCurrentRow] = useState<Partial<ContextoItem>>({});
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Filtro Cliente
const [search, setSearch] = useState('');
// Estado para el diálogo de confirmación de borrado // Estado para el diálogo de confirmación de borrado
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<number | null>(null); const [itemToDelete, setItemToDelete] = useState<number | null>(null);
// Filtrado dinámico
const filteredRows = rows.filter(row =>
row.clave.toLowerCase().includes(search.toLowerCase()) ||
row.valor.toLowerCase().includes(search.toLowerCase()) ||
row.descripcion.toLowerCase().includes(search.toLowerCase())
);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
const response = await apiClient.get('/api/admin/contexto'); const response = await apiClient.get('/api/admin/contexto');
@@ -138,22 +148,31 @@ const ContextManager: React.FC<ContextManagerProps> = ({ onAuthError }) => {
<Box> <Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4">Gestor de Contexto</Typography> <Typography variant="h4">Gestor de Contexto</Typography>
<Button <Box sx={{ display: 'flex', gap: 2 }}>
startIcon={<AddIcon />} <TextField
variant="contained" placeholder="Buscar..."
color="secondary" size="small"
onClick={() => handleOpen()} value={search}
> onChange={(e) => setSearch(e.target.value)}
Nuevo Item sx={{ bgcolor: 'rgba(255,255,255,0.05)', borderRadius: 1 }}
</Button> />
<Button
startIcon={<AddIcon />}
variant="contained"
color="secondary"
onClick={() => handleOpen()}
>
Nuevo Item
</Button>
</Box>
</Box> </Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>} {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Card> <Card>
<Box sx={{ height: 600, width: '100%' }}> <Box sx={{ height: 540, width: '100%' }}>
<DataGrid <DataGrid
rows={rows} rows={filteredRows}
columns={columns} columns={columns}
pageSizeOptions={[10, 25, 50, 100]} pageSizeOptions={[10, 25, 50, 100]}
sx={{ border: 'none' }} sx={{ border: 'none' }}

View File

@@ -4,7 +4,8 @@ import { Box, Button, TextField, Typography, Card, CardContent, Alert } from '@m
import apiClient from '../api/apiClient'; import apiClient from '../api/apiClient';
interface LoginProps { interface LoginProps {
onLoginSuccess: (token: string) => void; // Ya no pasamos el token, la cookie se setea automáticamente
onLoginSuccess: () => void;
} }
const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => { const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
@@ -20,14 +21,12 @@ const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
try { try {
// Using apiClient ensures consistently handled headers/configs // Using apiClient ensures consistently handled headers/configs
const response = await apiClient.post('/api/auth/login', { username, password }); // [SEGURIDAD] La respuesta ya no trae el token en JSON, viene en Cookie HttpOnly
await apiClient.post('/api/auth/login', { username, password });
// Si no lanza error, el login fue exitoso (200 OK)
onLoginSuccess();
// Axios returns data directly in response.data
if (response.data && response.data.token) {
onLoginSuccess(response.data.token);
} else {
setError('Respuesta inesperada del servidor.');
}
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
if (err.response && err.response.status === 401) { if (err.response && err.response.status === 401) {
@@ -46,7 +45,13 @@ const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
height: '100vh', height: '100vh',
background: 'radial-gradient(circle at 50% 50%, #1a1a1a 0%, #000 100%)' // Fallback/Enhancement background: 'radial-gradient(circle at 50% 50%, #1a1a1a 0%, #000 100%)',
'& input:-webkit-autofill': {
WebkitBoxShadow: '0 0 0 100px #1e1e1e inset',
WebkitTextFillColor: '#fff',
caretColor: '#fff',
borderRadius: 'inherit'
}
}}> }}>
<Card sx={{ maxWidth: 450, width: '100%', mx: 2 }}> <Card sx={{ maxWidth: 450, width: '100%', mx: 2 }}>
<CardContent sx={{ p: 4, display: 'flex', flexDirection: 'column', gap: 2 }}> <CardContent sx={{ p: 4, display: 'flex', flexDirection: 'column', gap: 2 }}>

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import { DataGrid, type GridColDef } from '@mui/x-data-grid'; import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { Box, Typography, Alert, Card } from '@mui/material'; import { Box, Typography, Alert, Card, Button, TextField } from '@mui/material';
import apiClient from '../api/apiClient'; import apiClient from '../api/apiClient';
interface ConversacionLog { interface ConversacionLog {
@@ -20,9 +20,24 @@ const LogsViewer: React.FC<LogsViewerProps> = ({ onAuthError }) => {
const [logs, setLogs] = useState<ConversacionLog[]>([]); const [logs, setLogs] = useState<ConversacionLog[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Filtros
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [search, setSearch] = useState('');
const [paginationModel, setPaginationModel] = useState({
pageSize: 25,
page: 0,
});
const fetchLogs = useCallback(async () => { const fetchLogs = useCallback(async () => {
try { try {
const response = await apiClient.get('/api/admin/logs'); const params = new URLSearchParams();
if (startDate) params.append('startDate', startDate);
if (endDate) params.append('endDate', endDate);
if (search) params.append('search', search);
const response = await apiClient.get(`/api/admin/logs?${params.toString()}`);
setLogs(response.data); setLogs(response.data);
} catch (err) { } catch (err) {
setError('No se pudieron cargar los logs.'); setError('No se pudieron cargar los logs.');
@@ -30,11 +45,13 @@ const LogsViewer: React.FC<LogsViewerProps> = ({ onAuthError }) => {
onAuthError(); // Llamamos a la función de logout si hay un error de autenticación onAuthError(); // Llamamos a la función de logout si hay un error de autenticación
} }
} }
}, [onAuthError]); }, [onAuthError, startDate, endDate, search]);
useEffect(() => { useEffect(() => {
fetchLogs(); fetchLogs();
}, [fetchLogs]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Carga inicial solamente, o cuando el usuario pulse "Filtrar"
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -71,19 +88,46 @@ const LogsViewer: React.FC<LogsViewerProps> = ({ onAuthError }) => {
<Typography variant="h4">Historial de Conversaciones</Typography> <Typography variant="h4">Historial de Conversaciones</Typography>
</Box> </Box>
<Box sx={{ mb: 3, display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
label="Fecha Inicio"
type="date"
size="small"
InputLabelProps={{ shrink: true }}
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<TextField
label="Fecha Fin"
type="date"
size="small"
InputLabelProps={{ shrink: true }}
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<TextField
label="Buscar en mensajes"
size="small"
placeholder="Ej: precios, hola..."
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ minWidth: 200 }}
/>
<Button variant="contained" onClick={fetchLogs}>
Filtrar
</Button>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>} {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Card> <Card>
<Box sx={{ height: 700, width: '100%' }}> <Box sx={{ height: 480, width: '100%' }}>
<DataGrid <DataGrid
rows={logs} rows={logs}
columns={columns} columns={columns}
pageSizeOptions={[25, 50, 100]} pageSizeOptions={[10, 25, 50, 100]}
initialState={{ paginationModel={paginationModel}
sorting: { onPaginationModelChange={setPaginationModel}
sortModel: [{ field: 'fecha', sort: 'desc' }],
},
}}
sx={{ border: 'none' }} sx={{ border: 'none' }}
/> />
</Box> </Box>

View File

@@ -28,9 +28,20 @@ const SourceManager: React.FC<SourceManagerProps> = ({ onAuthError }) => {
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
const [currentRow, setCurrentRow] = useState<Partial<FuenteContexto>>({}); const [currentRow, setCurrentRow] = useState<Partial<FuenteContexto>>({});
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Filtro Cliente
const [search, setSearch] = useState('');
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<number | null>(null); const [itemToDelete, setItemToDelete] = useState<number | null>(null);
// Filtrado dinámico
const filteredRows = rows.filter(row =>
row.nombre.toLowerCase().includes(search.toLowerCase()) ||
row.url.toLowerCase().includes(search.toLowerCase()) ||
(row.descripcionParaIA && row.descripcionParaIA.toLowerCase().includes(search.toLowerCase()))
);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
// --- ENDPOINT --- // --- ENDPOINT ---
@@ -142,24 +153,33 @@ const SourceManager: React.FC<SourceManagerProps> = ({ onAuthError }) => {
<Box> <Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4">Gestor de Fuentes</Typography> <Typography variant="h4">Gestor de Fuentes</Typography>
<Button <Box sx={{ display: 'flex', gap: 2 }}>
startIcon={<AddIcon />} <TextField
variant="contained" placeholder="Buscar..."
color="secondary" size="small"
onClick={() => handleOpen()} value={search}
> onChange={(e) => setSearch(e.target.value)}
Nueva Fuente sx={{ bgcolor: 'rgba(255,255,255,0.05)', borderRadius: 1 }}
</Button> />
<Button
startIcon={<AddIcon />}
variant="contained"
color="secondary"
onClick={() => handleOpen()}
>
Nueva Fuente
</Button>
</Box>
</Box> </Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>} {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Card> <Card>
<Box sx={{ height: 600, width: '100%' }}> <Box sx={{ height: 540, width: '100%' }}>
<DataGrid <DataGrid
rows={rows} rows={filteredRows}
columns={columns} columns={columns}
pageSizeOptions={[10, 25, 50]} pageSizeOptions={[10, 25, 50, 100]}
sx={{ border: 'none' }} sx={{ border: 'none' }}
/> />
</Box> </Box>

View File

@@ -7,10 +7,12 @@ import {
} from '@mui/material'; } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import PlayArrowIcon from '@mui/icons-material/PlayArrow'; // Icons for "Test" import PlayArrowIcon from '@mui/icons-material/PlayArrow'; // Iconos para "Test"
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import apiClient from '../api/apiClient'; import apiClient from '../api/apiClient';
import ReactMarkdown from 'react-markdown';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
interface SystemPrompt { interface SystemPrompt {
id: number; id: number;
@@ -35,7 +37,7 @@ const SystemPromptManager: React.FC<SystemPromptManagerProps> = ({ onAuthError }
severity: 'success' severity: 'success'
}); });
// Playground state // Estado del Playground
const [openPlayground, setOpenPlayground] = useState(false); const [openPlayground, setOpenPlayground] = useState(false);
const [playgroundPrompt, setPlaygroundPrompt] = useState<SystemPrompt | null>(null); const [playgroundPrompt, setPlaygroundPrompt] = useState<SystemPrompt | null>(null);
const [chatInput, setChatInput] = useState(''); const [chatInput, setChatInput] = useState('');
@@ -130,23 +132,22 @@ const SystemPromptManager: React.FC<SystemPromptManagerProps> = ({ onAuthError }
setIsTyping(true); setIsTyping(true);
try { try {
// Streaming implementation simulation or direct endpoint
// For simplicity in this playground, we will assuming a direct response first,
// but since the backend streams, we need to handle it or use a non-streaming wrapper if available.
// However, the backend is strictly streaming on /stream-message.
// We will use the fetch API directly to handle the stream if possible, or just accumulate it.
// Actually, let's use the standard fetch to read the stream.
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/Chat/stream-message`, { const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/Chat/stream-message`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('jwt_token')}` 'Authorization': `Bearer ${localStorage.getItem('jwt_token')}` // Falta verificar si el token está en localStorage o cookie. Admin usa Cookies HttpOnly.
// SIN EMBARGO: El endpoint /api/Chat/stream-message podría requerir auth.
// Si es el mismo endpoint del widget, puede ser público o requerir token.
// El widget no envía header Authorization.
// El Admin usa cookies. fetch por defecto NO envía cookies.
// Agregamos credentials: 'include'
}, },
credentials: 'include', // IMPORTANTE para Cookies HttpOnly del Admin
body: JSON.stringify({ body: JSON.stringify({
message: userMsg, message: userMsg,
conversationSummary: '', // No summary context for simple playground conversationSummary: '',
systemPromptOverride: playgroundPrompt.content, // <--- CRITICAL: Sending the override systemPromptOverride: playgroundPrompt.content,
contextUrl: null contextUrl: null
}) })
}); });
@@ -154,69 +155,49 @@ const SystemPromptManager: React.FC<SystemPromptManagerProps> = ({ onAuthError }
if (!response.body) throw new Error("No response body"); if (!response.body) throw new Error("No response body");
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let botReply = ''; let fullReplyRaw = '';
setChatHistory(prev => [...prev, { role: 'bot', text: '' }]); // Placeholder setChatHistory(prev => [...prev, { role: 'bot', text: '' }]);
let isFirstChunk = true;
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// Simple parsing of the backend stream (which sends "data: ...")
// The backend sends lines. We need to parse them.
// Note: This is a simplified stream parser.
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
// The backend sends JSON with "candidates". Need to extract text. if (done) {
// Wait, the backend code in ChatController.cs sends "data: " + JSON of GeminiStreamingResponse. // Procesamiento final si hiciera falta (summary, intent)
// But wait, the UpdateConversationSummaryAsync logic is internal. // Para el playground, solo nos importa el texto acumulado limpio.
// The 'StreamMessage' method yields strings directly? break;
// Let's re-read ChatController.cs line 422: yield return chunk; }
// It yields raw strings? No, the code says:
// yield return $"INTENT::{intent}"; const chunk = decoder.decode(value);
// yield return errorMessage; fullReplyRaw += chunk;
// yield return chunk; (from gemini stream)
// yield return $"SUMMARY::{newSummary}"; let cleanTextForDisplay = '';
try {
// The backend controller returns IAsyncEnumerable<string>. // Intentamos parsear el array JSON parcial que envía ASP.NET IAsyncEnumerable
// ASP.NET Core usually formats this as a JSON array if not handled, OR plain text chunks. // Agregamos ']' para cerrar el array temporalmente
// However, usually specific client handling is needed. const parsedArray = JSON.parse(fullReplyRaw.replace(/,$/, '') + ']');
// Let's assume for now it returns text chunks.
// If it returns JSON array elements, we might see ["chunk", "chunk"]. const displayChunks = Array.isArray(parsedArray)
// Let's verify standard behavior. usually 'text/plain' chunks or json. ? parsedArray.filter((item: string) =>
typeof item === 'string' && !item.startsWith('INTENT::') && !item.startsWith('SUMMARY::')
// Actually, looking at the backend again: )
// `public async IAsyncEnumerable<string> StreamMessage` : [];
// This typically returns JSON stream in .NET 6/8 default formatter `[ "str", "str" ... ]` if accessed via generic HTTP. cleanTextForDisplay = displayChunks.join('');
// PROBABLY we should just accept it might not be perfect stream in this simple fetch without a dedicated client library. } catch (e) {
// Let's try to just append raw text for now, filtering out non-text stuff if possible. // Fallback regex (como en el widget)
cleanTextForDisplay = fullReplyRaw
// RE-READING BACKEND line 421: fullBotReply.Append(chunk). .replace(/\"INTENT::.*?\",?/g, '')
// The controller yields strings. .replace(/\"SUMMARY::.*?\",?/g, '')
.replace(/^\["|"]$|","/g, '');
// Let's just accumulate everything that isn't a special command. // Nota: El fallback regex es muy suceptible a fallas con comillas internas.
// El parsing JSON es el camino feliz.
if (line.includes("INTENT::") || line.includes("SUMMARY::")) continue;
// Clean up if it's JSON formatted
const cleanLine = line.replace(/[\[\],"]+/g, ' ').trim();
// This is dirty. Let's trust the text is readable enough for a playground.
botReply += cleanLine + " ";
} catch (e) { }
} else {
// It might be raw text if not using SSE.
// The backend seems to yield plain strings.
// Let's just append the chunk text directly if it doesn't look like JSON protocol
if (!line.trim().startsWith("[")) botReply += line;
}
} }
const currentReply = botReply;
setChatHistory(prev => { setChatHistory(prev => {
const newHistory = [...prev]; const newHistory = [...prev];
newHistory[newHistory.length - 1].text = currentReply; // Update last message newHistory[newHistory.length - 1].text = cleanTextForDisplay;
return newHistory; return newHistory;
}); });
} }
@@ -378,7 +359,15 @@ const SystemPromptManager: React.FC<SystemPromptManagerProps> = ({ onAuthError }
boxShadow: 1 boxShadow: 1
}} }}
> >
<Typography variant="body1">{msg.text}</Typography> {msg.role === 'bot' ? (
<ReactMarkdown
rehypePlugins={[rehypeSanitize]}
>
{msg.text.replace(/\\n/g, "\n")}
</ReactMarkdown>
) : (
<Typography variant="body1">{msg.text}</Typography>
)}
</Box> </Box>
))} ))}
{isTyping && ( {isTyping && (