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:
@@ -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).");
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
87
chatbot-admin/src/theme.ts
Normal file
87
chatbot-admin/src/theme.ts
Normal 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;
|
||||
Reference in New Issue
Block a user