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:
@@ -76,12 +76,35 @@ namespace ChatbotApi.Controllers
|
||||
}
|
||||
|
||||
[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 logs = await _context.ConversacionLogs
|
||||
var query = _context.ConversacionLogs.AsQueryable();
|
||||
|
||||
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)
|
||||
.Take(200)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
return Ok(logs);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ public class LoginRequest
|
||||
[MaxLength(100)]
|
||||
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]
|
||||
[Route("api/[controller]")]
|
||||
@@ -41,12 +42,31 @@ public class AuthController : ControllerBase
|
||||
if (user != null && await _userManager.CheckPasswordAsync(user, loginRequest.Password))
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
Response.Cookies.Delete("X-Access-Token");
|
||||
return Ok(new { message = "Sesión cerrada" });
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// [SEGURIDAD] Endpoint solo para desarrollo
|
||||
[HttpPost("setup-admin")]
|
||||
|
||||
@@ -57,7 +57,7 @@ namespace ChatbotApi.Controllers
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
_cache.Remove(CacheKey); // Invalidate cache
|
||||
_cache.Remove(CacheKey); // Invalidar caché
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
@@ -82,7 +82,7 @@ namespace ChatbotApi.Controllers
|
||||
systemPrompt.UpdatedAt = DateTime.UtcNow;
|
||||
_context.SystemPrompts.Add(systemPrompt);
|
||||
await _context.SaveChangesAsync();
|
||||
_cache.Remove(CacheKey); // Invalidate cache
|
||||
_cache.Remove(CacheKey); // Invalidar caché
|
||||
|
||||
return CreatedAtAction("GetSystemPrompt", new { id = systemPrompt.Id }, systemPrompt);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ namespace ChatbotApi.Controllers
|
||||
|
||||
_context.SystemPrompts.Remove(systemPrompt);
|
||||
await _context.SaveChangesAsync();
|
||||
_cache.Remove(CacheKey); // Invalidate cache
|
||||
_cache.Remove(CacheKey); // Invalidar caché
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
@@ -117,7 +117,7 @@ namespace ChatbotApi.Controllers
|
||||
systemPrompt.IsActive = !systemPrompt.IsActive;
|
||||
systemPrompt.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
_cache.Remove(CacheKey); // Invalidate cache
|
||||
_cache.Remove(CacheKey); // Invalidar caché
|
||||
|
||||
return Ok(new { IsActive = systemPrompt.IsActive });
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ builder.Services.AddCors(options =>
|
||||
"http://localhost:5174",
|
||||
"http://localhost:5175")
|
||||
.AllowAnyHeader()
|
||||
// [SEGURIDAD] Solo permitimos los verbos necesarios. Bloqueamos TRACE, HEAD, etc.
|
||||
.AllowCredentials() // [SEGURIDAD] Necesario para Cookies HttpOnly
|
||||
.WithMethods("GET", "POST", "PUT", "DELETE", "OPTIONS");
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,19 @@ builder.Services.AddAuthentication(options =>
|
||||
)),
|
||||
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
|
||||
|
||||
1208
chatbot-admin/package-lock.json
generated
1208
chatbot-admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,9 @@
|
||||
"@mui/x-data-grid": "^8.18.0",
|
||||
"axios": "^1.13.2",
|
||||
"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": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
@@ -2,31 +2,58 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import AdminPanel from './components/AdminPanel';
|
||||
import Login from './components/Login';
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import { CssBaseline, ThemeProvider, Box, CircularProgress } from '@mui/material';
|
||||
import theme from './theme';
|
||||
import apiClient from './api/apiClient';
|
||||
|
||||
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(() => {
|
||||
if (token) {
|
||||
localStorage.setItem('jwt_token', token);
|
||||
} else {
|
||||
localStorage.removeItem('jwt_token');
|
||||
}
|
||||
}, [token]);
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
setToken(null);
|
||||
const checkAuth = async () => {
|
||||
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 (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{token ? (
|
||||
{isAuthenticated ? (
|
||||
<AdminPanel onLogout={handleLogout} />
|
||||
) : (
|
||||
<Login onLoginSuccess={setToken} />
|
||||
<Login onLoginSuccess={() => setIsAuthenticated(true)} />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -4,21 +4,18 @@ 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
|
||||
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
|
||||
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;
|
||||
},
|
||||
// No necesitamos interceptor para inyectar token porque el navegador
|
||||
// envía automáticamente la cookie HttpOnly en cada petición credentialed.
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Manejamos errores en la configuración de la petición
|
||||
// Manejamos errores globales si es necesario
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -28,10 +28,20 @@ const ContextManager: React.FC<ContextManagerProps> = ({ onAuthError }) => {
|
||||
const [currentRow, setCurrentRow] = useState<Partial<ContextoItem>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filtro Cliente
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Estado para el diálogo de confirmación de borrado
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
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 () => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/admin/contexto');
|
||||
@@ -138,22 +148,31 @@ const ContextManager: React.FC<ContextManagerProps> = ({ onAuthError }) => {
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4">Gestor de Contexto</Typography>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => handleOpen()}
|
||||
>
|
||||
Nuevo Item
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
placeholder="Buscar..."
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ bgcolor: 'rgba(255,255,255,0.05)', borderRadius: 1 }}
|
||||
/>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => handleOpen()}
|
||||
>
|
||||
Nuevo Item
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<Card>
|
||||
<Box sx={{ height: 600, width: '100%' }}>
|
||||
<Box sx={{ height: 540, width: '100%' }}>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
rows={filteredRows}
|
||||
columns={columns}
|
||||
pageSizeOptions={[10, 25, 50, 100]}
|
||||
sx={{ border: 'none' }}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Box, Button, TextField, Typography, Card, CardContent, Alert } from '@m
|
||||
import apiClient from '../api/apiClient';
|
||||
|
||||
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 }) => {
|
||||
@@ -20,14 +21,12 @@ const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
|
||||
|
||||
try {
|
||||
// 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) {
|
||||
console.error(err);
|
||||
if (err.response && err.response.status === 401) {
|
||||
@@ -46,7 +45,13 @@ const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
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 }}>
|
||||
<CardContent sx={{ p: 4, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
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';
|
||||
|
||||
interface ConversacionLog {
|
||||
@@ -20,9 +20,24 @@ const LogsViewer: React.FC<LogsViewerProps> = ({ onAuthError }) => {
|
||||
const [logs, setLogs] = useState<ConversacionLog[]>([]);
|
||||
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 () => {
|
||||
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);
|
||||
} catch (err) {
|
||||
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]);
|
||||
}, [onAuthError, startDate, endDate, search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Carga inicial solamente, o cuando el usuario pulse "Filtrar"
|
||||
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -71,19 +88,46 @@ const LogsViewer: React.FC<LogsViewerProps> = ({ onAuthError }) => {
|
||||
<Typography variant="h4">Historial de Conversaciones</Typography>
|
||||
</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>}
|
||||
|
||||
<Card>
|
||||
<Box sx={{ height: 700, width: '100%' }}>
|
||||
<Box sx={{ height: 480, width: '100%' }}>
|
||||
<DataGrid
|
||||
rows={logs}
|
||||
columns={columns}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
initialState={{
|
||||
sorting: {
|
||||
sortModel: [{ field: 'fecha', sort: 'desc' }],
|
||||
},
|
||||
}}
|
||||
pageSizeOptions={[10, 25, 50, 100]}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
sx={{ border: 'none' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -28,9 +28,20 @@ const SourceManager: React.FC<SourceManagerProps> = ({ onAuthError }) => {
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [currentRow, setCurrentRow] = useState<Partial<FuenteContexto>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filtro Cliente
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
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 () => {
|
||||
try {
|
||||
// --- ENDPOINT ---
|
||||
@@ -142,24 +153,33 @@ const SourceManager: React.FC<SourceManagerProps> = ({ onAuthError }) => {
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4">Gestor de Fuentes</Typography>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => handleOpen()}
|
||||
>
|
||||
Nueva Fuente
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
placeholder="Buscar..."
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ bgcolor: 'rgba(255,255,255,0.05)', borderRadius: 1 }}
|
||||
/>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => handleOpen()}
|
||||
>
|
||||
Nueva Fuente
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<Card>
|
||||
<Box sx={{ height: 600, width: '100%' }}>
|
||||
<Box sx={{ height: 540, width: '100%' }}>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
rows={filteredRows}
|
||||
columns={columns}
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
pageSizeOptions={[10, 25, 50, 100]}
|
||||
sx={{ border: 'none' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
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 SendIcon from '@mui/icons-material/Send';
|
||||
import apiClient from '../api/apiClient';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
|
||||
interface SystemPrompt {
|
||||
id: number;
|
||||
@@ -35,7 +37,7 @@ const SystemPromptManager: React.FC<SystemPromptManagerProps> = ({ onAuthError }
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
// Playground state
|
||||
// Estado del Playground
|
||||
const [openPlayground, setOpenPlayground] = useState(false);
|
||||
const [playgroundPrompt, setPlaygroundPrompt] = useState<SystemPrompt | null>(null);
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
@@ -130,23 +132,22 @@ const SystemPromptManager: React.FC<SystemPromptManagerProps> = ({ onAuthError }
|
||||
setIsTyping(true);
|
||||
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'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({
|
||||
message: userMsg,
|
||||
conversationSummary: '', // No summary context for simple playground
|
||||
systemPromptOverride: playgroundPrompt.content, // <--- CRITICAL: Sending the override
|
||||
conversationSummary: '',
|
||||
systemPromptOverride: playgroundPrompt.content,
|
||||
contextUrl: null
|
||||
})
|
||||
});
|
||||
@@ -154,69 +155,49 @@ const SystemPromptManager: React.FC<SystemPromptManagerProps> = ({ onAuthError }
|
||||
if (!response.body) throw new Error("No response body");
|
||||
const reader = response.body.getReader();
|
||||
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) {
|
||||
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.
|
||||
// Wait, the backend code in ChatController.cs sends "data: " + JSON of GeminiStreamingResponse.
|
||||
// But wait, the UpdateConversationSummaryAsync logic is internal.
|
||||
// The 'StreamMessage' method yields strings directly?
|
||||
// Let's re-read ChatController.cs line 422: yield return chunk;
|
||||
// It yields raw strings? No, the code says:
|
||||
// yield return $"INTENT::{intent}";
|
||||
// yield return errorMessage;
|
||||
// yield return chunk; (from gemini stream)
|
||||
// yield return $"SUMMARY::{newSummary}";
|
||||
|
||||
// The backend controller returns IAsyncEnumerable<string>.
|
||||
// ASP.NET Core usually formats this as a JSON array if not handled, OR plain text chunks.
|
||||
// However, usually specific client handling is needed.
|
||||
// Let's assume for now it returns text chunks.
|
||||
// If it returns JSON array elements, we might see ["chunk", "chunk"].
|
||||
// Let's verify standard behavior. usually 'text/plain' chunks or json.
|
||||
|
||||
// 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.
|
||||
// PROBABLY we should just accept it might not be perfect stream in this simple fetch without a dedicated client library.
|
||||
// Let's try to just append raw text for now, filtering out non-text stuff if possible.
|
||||
|
||||
// RE-READING BACKEND line 421: fullBotReply.Append(chunk).
|
||||
// The controller yields strings.
|
||||
|
||||
// Let's just accumulate everything that isn't a special command.
|
||||
|
||||
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;
|
||||
}
|
||||
if (done) {
|
||||
// Procesamiento final si hiciera falta (summary, intent)
|
||||
// Para el playground, solo nos importa el texto acumulado limpio.
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
fullReplyRaw += chunk;
|
||||
|
||||
let cleanTextForDisplay = '';
|
||||
try {
|
||||
// Intentamos parsear el array JSON parcial que envía ASP.NET IAsyncEnumerable
|
||||
// Agregamos ']' para cerrar el array temporalmente
|
||||
const parsedArray = JSON.parse(fullReplyRaw.replace(/,$/, '') + ']');
|
||||
|
||||
const displayChunks = Array.isArray(parsedArray)
|
||||
? parsedArray.filter((item: string) =>
|
||||
typeof item === 'string' && !item.startsWith('INTENT::') && !item.startsWith('SUMMARY::')
|
||||
)
|
||||
: [];
|
||||
cleanTextForDisplay = displayChunks.join('');
|
||||
} catch (e) {
|
||||
// Fallback regex (como en el widget)
|
||||
cleanTextForDisplay = fullReplyRaw
|
||||
.replace(/\"INTENT::.*?\",?/g, '')
|
||||
.replace(/\"SUMMARY::.*?\",?/g, '')
|
||||
.replace(/^\["|"]$|","/g, '');
|
||||
// Nota: El fallback regex es muy suceptible a fallas con comillas internas.
|
||||
// El parsing JSON es el camino feliz.
|
||||
}
|
||||
|
||||
const currentReply = botReply;
|
||||
setChatHistory(prev => {
|
||||
const newHistory = [...prev];
|
||||
newHistory[newHistory.length - 1].text = currentReply; // Update last message
|
||||
newHistory[newHistory.length - 1].text = cleanTextForDisplay;
|
||||
return newHistory;
|
||||
});
|
||||
}
|
||||
@@ -378,7 +359,15 @@ const SystemPromptManager: React.FC<SystemPromptManagerProps> = ({ onAuthError }
|
||||
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>
|
||||
))}
|
||||
{isTyping && (
|
||||
|
||||
Reference in New Issue
Block a user