Feat: Prompt Test

- Se implementa el testeo de prompts en el panel de admin sin activación.
- Se realizan mejoras visuales en los controles.
This commit is contained in:
2025-12-05 13:12:02 -03:00
parent a87550b890
commit 5cef67a2bf
5 changed files with 370 additions and 83 deletions

View File

@@ -336,7 +336,9 @@ namespace ChatbotApi.Controllers
try
{
var promptBuilder = new StringBuilder();
var systemInstructions = await GetActiveSystemPromptsAsync();
var systemInstructions = !string.IsNullOrWhiteSpace(request.SystemPromptOverride)
? request.SystemPromptOverride
: await GetActiveSystemPromptsAsync();
promptBuilder.AppendLine("<instrucciones_sistema>");
promptBuilder.AppendLine("Eres DiaBot, asistente virtual de El Día (La Plata, Argentina).");

View File

@@ -8,4 +8,5 @@ public class ChatRequest
public string? ContextUrl { get; set; }
public string? ConversationSummary { get; set; }
public List<string>? ShownArticles { get; set; }
public string? SystemPromptOverride { get; set; }
}

View File

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

View File

@@ -1,12 +1,15 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
Box, Typography, Button, TextField, Paper, List, ListItem,
ListItemText, ListItemSecondaryAction, IconButton, Switch,
Box, Typography, Button, TextField, Paper, IconButton, Switch,
Dialog, DialogTitle, DialogContent, DialogActions,
Snackbar, Alert
Snackbar, Alert, Grid, Card, CardContent, CardActions, Chip, Divider,
CircularProgress
} 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 AddIcon from '@mui/icons-material/Add';
import SendIcon from '@mui/icons-material/Send';
import apiClient from '../api/apiClient';
interface SystemPrompt {
@@ -32,10 +35,26 @@ const SystemPromptManager: React.FC<SystemPromptManagerProps> = ({ onAuthError }
severity: 'success'
});
// Playground state
const [openPlayground, setOpenPlayground] = useState(false);
const [playgroundPrompt, setPlaygroundPrompt] = useState<SystemPrompt | null>(null);
const [chatInput, setChatInput] = useState('');
const [chatHistory, setChatHistory] = useState<{ role: 'user' | 'bot'; text: string }[]>([]);
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<null | HTMLDivElement>(null);
useEffect(() => {
fetchPrompts();
}, []);
useEffect(() => {
if (openPlayground) scrollToBottom();
}, [chatHistory, openPlayground]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const fetchPrompts = async () => {
try {
const response = await apiClient.get('/api/SystemPrompts');
@@ -96,82 +115,201 @@ const SystemPromptManager: React.FC<SystemPromptManagerProps> = ({ onAuthError }
setOpenDialog(true);
};
const openTestPlayground = (prompt: SystemPrompt) => {
setPlaygroundPrompt(prompt);
setChatHistory([{ role: 'bot', text: 'Hola! Soy el entorno de pruebas. Escribe un mensaje para ver cómo respondo con este prompt.' }]);
setOpenPlayground(true);
};
const handlePlaygroundSend = async () => {
if (!chatInput.trim() || !playgroundPrompt) return;
const userMsg = chatInput;
setChatHistory(prev => [...prev, { role: 'user', text: userMsg }]);
setChatInput('');
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')}`
},
body: JSON.stringify({
message: userMsg,
conversationSummary: '', // No summary context for simple playground
systemPromptOverride: playgroundPrompt.content, // <--- CRITICAL: Sending the override
contextUrl: null
})
});
if (!response.body) throw new Error("No response body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let botReply = '';
setChatHistory(prev => [...prev, { role: 'bot', text: '' }]); // Placeholder
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;
}
}
const currentReply = botReply;
setChatHistory(prev => {
const newHistory = [...prev];
newHistory[newHistory.length - 1].text = currentReply; // Update last message
return newHistory;
});
}
} catch (err) {
console.error(err);
setChatHistory(prev => [...prev, { role: 'bot', text: 'Error al comunicarse con el bot.' }]);
} finally {
setIsTyping(false);
}
};
return (
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Gestión de Prompts del Sistema</Typography>
<Button variant="contained" color="primary" onClick={openCreate}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Typography variant="h4">Gestión de Prompts</Typography>
<Button
variant="contained"
color="secondary"
startIcon={<AddIcon />}
onClick={openCreate}
sx={{ px: 3, py: 1, borderRadius: 2 }}
>
Nuevo Prompt
</Button>
</Box>
<Paper>
<List>
{prompts.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography color="textSecondary">No hay prompts definidos.</Typography>
</Paper>
) : (
<Grid container spacing={3}>
{prompts.map((prompt) => (
<ListItem key={prompt.id} divider>
<ListItemText
primary={
<Box display="flex" alignItems="center">
<Typography variant="subtitle1" fontWeight="bold">
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={prompt.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative' }}>
<CardContent sx={{ flexGrow: 1 }}>
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={2}>
<Typography variant="h6" fontWeight="bold" sx={{ color: 'primary.light' }}>
{prompt.name}
</Typography>
{prompt.isActive && (
<Box
component="span"
sx={{
ml: 2,
px: 1,
py: 0.5,
bgcolor: 'success.light',
color: 'white',
borderRadius: 1,
fontSize: '0.75rem'
}}
>
ACTIVO
</Box>
)}
<Switch
checked={prompt.isActive}
onChange={() => handleToggleActive(prompt.id)}
color="success"
size="small"
/>
</Box>
}
secondary={
<Typography
variant="body2"
color="textSecondary"
sx={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}
>
{prompt.isActive && (
<Chip label="ACTIVO EN PRODUCCIÓN" color="success" size="small" sx={{ mb: 2, fontWeight: 'bold' }} />
)}
<Typography variant="body2" color="textSecondary" sx={{
display: '-webkit-box',
WebkitLineClamp: 4,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
bgcolor: 'background.default',
p: 1.5,
borderRadius: 1,
fontFamily: 'monospace',
fontSize: '0.8rem'
}}>
{prompt.content}
</Typography>
}
/>
<ListItemSecondaryAction>
<Switch
edge="end"
checked={prompt.isActive}
onChange={() => handleToggleActive(prompt.id)}
color="primary"
/>
<IconButton edge="end" aria-label="edit" onClick={() => openEdit(prompt)} sx={{ ml: 1 }}>
<EditIcon />
</IconButton>
<IconButton edge="end" aria-label="delete" onClick={() => handleDelete(prompt.id)} sx={{ ml: 1 }}>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</CardContent>
<Divider sx={{ opacity: 0.1 }} />
<CardActions sx={{ justifyContent: 'space-between', px: 2, py: 1.5 }}>
<Button
size="small"
color="info"
startIcon={<PlayArrowIcon />}
onClick={() => openTestPlayground(prompt)}
>
Probar
</Button>
<Box>
<IconButton size="small" onClick={() => openEdit(prompt)} color="primary">
<EditIcon />
</IconButton>
<IconButton size="small" onClick={() => handleDelete(prompt.id)} color="error">
<DeleteIcon />
</IconButton>
</Box>
</CardActions>
</Card>
</Grid>
))}
{prompts.length === 0 && (
<ListItem>
<ListItemText primary="No hay prompts definidos. El sistema usará el comportamiento por defecto." />
</ListItem>
)}
</List>
</Paper>
</Grid>
)}
{/* --- EDIT / CREATE DIALOG --- */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>{currentPrompt.id ? 'Editar Prompt' : 'Nuevo Prompt'}</DialogTitle>
<DialogContent>
@@ -182,21 +320,21 @@ const SystemPromptManager: React.FC<SystemPromptManagerProps> = ({ onAuthError }
fullWidth
value={currentPrompt.name || ''}
onChange={(e) => setCurrentPrompt({ ...currentPrompt, name: e.target.value })}
sx={{ mb: 2 }}
sx={{ mb: 3, mt: 1 }}
/>
<TextField
margin="dense"
label="Contenido del Prompt (Instrucciones de Sistema)"
fullWidth
multiline
rows={10}
rows={12}
value={currentPrompt.content || ''}
onChange={(e) => setCurrentPrompt({ ...currentPrompt, content: e.target.value })}
helperText="Estas instrucciones se inyectarán en el contexto del sistema de la IA."
helperText="Define la personalidad, restricciones y formato de respuesta del bot."
sx={{ fontFamily: 'monospace' }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)} color="secondary">
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setOpenDialog(false)} color="inherit">
Cancelar
</Button>
<Button onClick={handleSave} color="primary" variant="contained">
@@ -205,6 +343,70 @@ const SystemPromptManager: React.FC<SystemPromptManagerProps> = ({ onAuthError }
</DialogActions>
</Dialog>
{/* --- PLAYGROUND DIALOG --- */}
<Dialog
open={openPlayground}
onClose={() => setOpenPlayground(false)}
maxWidth="md"
fullWidth
PaperProps={{
sx: { height: '80vh', display: 'flex', flexDirection: 'column' }
}}
>
<DialogTitle sx={{ borderBottom: '1px solid rgba(255,255,255,0.1)', display: 'flex', justifyContent: 'space-between' }}>
<Box>
<Typography variant="h6">Playground de Pruebas</Typography>
<Typography variant="caption" color="textSecondary">
Probando: <span style={{ color: '#b47cff' }}>{playgroundPrompt?.name}</span> (No afecta producción)
</Typography>
</Box>
<Button size="small" onClick={() => setOpenPlayground(false)}>Cerrar</Button>
</DialogTitle>
<DialogContent sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', p: 0 }}>
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
{chatHistory.map((msg, idx) => (
<Box
key={idx}
sx={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '80%',
bgcolor: msg.role === 'user' ? 'primary.main' : 'background.paper',
color: msg.role === 'user' ? 'white' : 'text.primary',
p: 2,
borderRadius: 2,
boxShadow: 1
}}
>
<Typography variant="body1">{msg.text}</Typography>
</Box>
))}
{isTyping && (
<Box sx={{ alignSelf: 'flex-start', p: 2 }}>
<CircularProgress size={20} />
</Box>
)}
<div ref={messagesEndRef} />
</Box>
<Box sx={{ p: 2, bgcolor: 'background.paper', borderTop: '1px solid rgba(255,255,255,0.1)', display: 'flex', gap: 1 }}>
<TextField
fullWidth
variant="outlined"
placeholder="Escribe un mensaje de prueba..."
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handlePlaygroundSend()}
autoComplete="off"
size="small"
/>
<IconButton color="primary" onClick={handlePlaygroundSend} disabled={isTyping || !chatInput.trim()}>
<SendIcon />
</IconButton>
</Box>
</DialogContent>
</Dialog>
<Snackbar
open={snackbar.open}
autoHideDuration={6000}

View File

@@ -0,0 +1,87 @@
import { createTheme, alpha } from '@mui/material/styles';
const theme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#7c4dff', // Deep Purple
light: '#b47cff',
dark: '#3f1dcb',
},
secondary: {
main: '#ff4081', // Pink A200
light: '#ff79b0',
dark: '#c60055',
},
background: {
default: '#121212',
paper: '#1e1e1e',
},
text: {
primary: '#ffffff',
secondary: 'rgba(255, 255, 255, 0.7)',
}
},
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
h5: {
fontWeight: 600,
},
h4: {
fontWeight: 700,
background: 'linear-gradient(45deg, #7c4dff 30%, #ff4081 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}
},
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
// Optional: Background pattern or gradient
backgroundImage: 'radial-gradient(circle at 50% 50%, #1f1f1f 0%, #000000 100%)',
backgroundAttachment: 'fixed',
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none', // Remove default gradient
},
},
},
MuiCard: {
styleOverrides: {
root: {
backgroundColor: alpha('#1e1e1e', 0.6),
backdropFilter: 'blur(12px)',
border: '1px solid rgba(255, 255, 255, 0.12)',
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
border: '1px solid rgba(124, 77, 255, 0.5)',
}
}
}
},
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: 8,
fontWeight: 600,
},
contained: {
boxShadow: 'none',
'&:hover': {
boxShadow: '0 4px 12px rgba(124, 77, 255, 0.4)',
}
}
},
},
},
});
export default theme;