Feat: Ajustes y Preparación Docker

This commit is contained in:
2025-11-20 12:39:23 -03:00
parent c94936d56e
commit 1e85b2ed86
11 changed files with 317 additions and 56 deletions

View File

@@ -111,6 +111,9 @@ namespace ChatbotApi.Controllers
string? articleContext = null; string? articleContext = null;
string? errorMessage = null; string? errorMessage = null;
// --- CORRECCIÓN: Declarar e inicializar 'intent' aquí ---
IntentType intent = IntentType.Homepage; // Default fallback
try try
{ {
if (!string.IsNullOrEmpty(request.ContextUrl)) if (!string.IsNullOrEmpty(request.ContextUrl))
@@ -118,7 +121,8 @@ namespace ChatbotApi.Controllers
articleContext = await GetArticleContentAsync(request.ContextUrl); articleContext = await GetArticleContentAsync(request.ContextUrl);
} }
IntentType intent = await GetIntentAsync(userMessage, articleContext); // Ya no se declara con "IntentType intent", solo se le asigna el valor.
intent = await GetIntentAsync(userMessage, articleContext);
switch (intent) switch (intent)
{ {
@@ -138,7 +142,7 @@ namespace ChatbotApi.Controllers
case IntentType.Homepage: case IntentType.Homepage:
default: default:
_logger.LogInformation("Ejecutando intención: Noticias de Portada."); _logger.LogInformation("Ejecutando intención: Noticias de Portada.");
context = await GetWebsiteNewsAsync(_siteUrl, 15); context = await GetWebsiteNewsAsync(_siteUrl, 25);
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene una lista de noticias de portada. Si encuentras una noticia relevante, proporciona su enlace en formato Markdown: '[título](URL)'."; promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene una lista de noticias de portada. Si encuentras una noticia relevante, proporciona su enlace en formato Markdown: '[título](URL)'.";
break; break;
} }
@@ -151,6 +155,9 @@ namespace ChatbotApi.Controllers
promptInstructions = string.Empty; promptInstructions = string.Empty;
} }
// Ahora 'intent' es accesible aquí.
yield return $"INTENT::{intent}";
if (!string.IsNullOrEmpty(errorMessage)) if (!string.IsNullOrEmpty(errorMessage))
{ {
yield return errorMessage; yield return errorMessage;

32
ChatbotApi/Dockerfile.api Normal file
View File

@@ -0,0 +1,32 @@
# Dockerfile.api
# ---- Etapa de Compilación (Build) ----
# Usamos la imagen del SDK de .NET para compilar la aplicación
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copiamos los archivos .csproj y restauramos las dependencias primero
# Esto aprovecha el cache de Docker para acelerar futuras compilaciones
COPY ["ChatbotApi.csproj", "."]
RUN dotnet restore "./ChatbotApi.csproj"
# Copiamos el resto del código fuente y construimos la aplicación
COPY . .
WORKDIR "/src/."
RUN dotnet build "ChatbotApi.csproj" -c Release -o /app/build
# Publicamos la aplicación en modo Release
FROM build AS publish
RUN dotnet publish "ChatbotApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
# ---- Etapa Final (Runtime) ----
# Usamos la imagen de runtime de ASP.NET, que es mucho más ligera
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=publish /app/publish .
# Exponemos el puerto 80, el puerto estándar para HTTP dentro del contenedor
EXPOSE 80
# El comando para iniciar la aplicación cuando el contenedor se ejecute
ENTRYPOINT ["dotnet", "ChatbotApi.dll"]

View File

@@ -20,7 +20,8 @@ builder.Services.AddCors(options =>
options.AddPolicy(name: myAllowSpecificOrigins, options.AddPolicy(name: myAllowSpecificOrigins,
policy => policy =>
{ {
policy.WithOrigins("http://localhost:5173", "http://localhost:5174") // La URL de tu frontend //policy.WithOrigins("http://localhost:5173", "http://localhost:5174")
policy.WithOrigins("http://192.168.5.129:8081", "http://192.168.5.129:8082")
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod(); .AllowAnyMethod();
}); });

View File

@@ -0,0 +1,21 @@
# Dockerfile.admin
# ---- Etapa de Compilación (Build) ----
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Definimos la URL de la API para el panel de admin
RUN VITE_API_BASE_URL=http://192.168.5.129:8080 npm run build
# ---- Etapa Final (Runtime) ----
FROM nginx:alpine AS final
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

15
chatbot-admin/nginx.conf Normal file
View File

@@ -0,0 +1,15 @@
# nginx.conf
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
# Si un archivo o directorio no existe, redirige al index.html
# Esto es esencial para que el enrutamiento del lado del cliente de React funcione.
try_files $uri $uri/ /index.html;
}
}

View File

@@ -41,7 +41,25 @@ const LogsViewer: React.FC<LogsViewerProps> = ({ onAuthError }) => {
field: 'fecha', field: 'fecha',
headerName: 'Fecha', headerName: 'Fecha',
width: 200, width: 200,
valueGetter: (value) => new Date(value).toLocaleString(), valueGetter: (value) => {
// 1. Le decimos a JavaScript que la fecha que viene de la DB es UTC añadiendo una 'Z'.
const date = new Date(value + 'Z');
// 2. Definimos las opciones de formato.
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: 'America/Argentina/Buenos_Aires',
hour12: false,
};
// 3. Ahora la conversión será correcta.
return date.toLocaleString('es-AR', options);
},
}, },
{ field: 'usuarioMensaje', headerName: 'Mensaje del Usuario', flex: 1 }, { field: 'usuarioMensaje', headerName: 'Mensaje del Usuario', flex: 1 },
{ field: 'botRespuesta', headerName: 'Respuesta del Bot', flex: 1 }, { field: 'botRespuesta', headerName: 'Respuesta del Bot', flex: 1 },

View File

@@ -0,0 +1,25 @@
# Dockerfile.widget
# ---- Etapa de Compilación (Build) ----
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# IMPORTANTE: Aquí definimos la URL de la API para producción
# El docker-compose se encargará de exponer la API en el puerto 8080 del host
RUN VITE_API_BASE_URL=http://192.168.5.129:8080 npm run build
# ---- Etapa Final (Runtime) ----
FROM nginx:alpine AS final
# Copiamos los archivos estáticos construidos en la etapa anterior
COPY --from=build /app/dist /usr/share/nginx/html
# Copiamos nuestra configuración personalizada de Nginx
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

15
chatbot-widget/nginx.conf Normal file
View File

@@ -0,0 +1,15 @@
# nginx.conf
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
# Si un archivo o directorio no existe, redirige al index.html
# Esto es esencial para que el enrutamiento del lado del cliente de React funcione.
try_files $uri $uri/ /index.html;
}
}

View File

@@ -151,3 +151,39 @@ opacity: 0.8;
.context-indicator span { .context-indicator span {
font-weight: bold; font-weight: bold;
} }
/* --- INICIO DE LA ANIMACIÓN DE "ESCRIBIENDO" --- */
.typing-indicator {
display: flex;
align-items: center;
padding: 2px 0; /* Espacio vertical para que no se pegue a los bordes */
}
.typing-indicator span {
height: 8px;
width: 8px;
background-color: #999;
border-radius: 50%;
display: inline-block;
margin: 0 2px;
animation: typing-bounce 1.4s infinite;
}
/* Aplicamos un pequeño retardo a cada punto para crear el efecto de onda */
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing-bounce {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(0.75);
}
}

View File

@@ -11,8 +11,9 @@ interface Message {
} }
const MAX_CHARS = 200; const MAX_CHARS = 200;
// Constante para la clave del localStorage // Constantes para la clave del localStorage
const CHAT_HISTORY_KEY = 'chatbot-history'; const CHAT_HISTORY_KEY = 'chatbot-history';
const CHAT_CONTEXT_KEY = 'chatbot-active-article';
const Chatbot: React.FC = () => { const Chatbot: React.FC = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -33,8 +34,22 @@ const Chatbot: React.FC = () => {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const messagesEndRef = useRef<null | HTMLDivElement>(null); const messagesEndRef = useRef<null | HTMLDivElement>(null);
const [activeArticleUrl, setActiveArticleUrl] = useState<string | null>(null); const [activeArticle, setActiveArticle] = useState<{ url: string; title: string; } | null>(() => {
try {
// 1. Intentamos obtener el contexto del artículo guardado.
const savedContext = localStorage.getItem(CHAT_CONTEXT_KEY);
if (savedContext) {
// 2. Si existe, lo parseamos y lo usamos como estado inicial.
return JSON.parse(savedContext);
}
} catch (error) {
console.error("No se pudo cargar el contexto del artículo desde localStorage:", error);
}
// 3. Si no hay nada guardado o hay un error, el estado inicial es null.
return null;
});
// Añadimos un useEffect para guardar los mensajes. // Añadimos un useEffect para guardar los mensajes.
useEffect(() => { useEffect(() => {
@@ -46,6 +61,19 @@ const Chatbot: React.FC = () => {
} }
}, [messages]); }, [messages]);
useEffect(() => {
try {
if (activeArticle) {
// Si hay un artículo activo, lo guardamos en localStorage.
localStorage.setItem(CHAT_CONTEXT_KEY, JSON.stringify(activeArticle));
} else {
// Si el artículo activo es null, lo eliminamos de localStorage para mantenerlo limpio.
localStorage.removeItem(CHAT_CONTEXT_KEY);
}
} catch (error) {
console.error("No se pudo guardar el contexto del artículo en localStorage:", error);
}
}, [activeArticle]); // Wste efecto se ejecuta cada vez que 'activeArticle' cambia.
useEffect(() => { useEffect(() => {
// Solo intentamos hacer scroll si la ventana del chat está abierta. // Solo intentamos hacer scroll si la ventana del chat está abierta.
@@ -73,15 +101,15 @@ const Chatbot: React.FC = () => {
setMessages(prev => [...prev, userMessage]); setMessages(prev => [...prev, userMessage]);
const messageToSend = inputValue; const messageToSend = inputValue;
setInputValue(''); setInputValue('');
setIsLoading(true);
const botMessagePlaceholder: Message = { text: '', sender: 'bot' }; // Inicia el estado de carga, pero aún no el de streaming
setMessages(prev => [...prev, botMessagePlaceholder]); setIsLoading(true);
setIsStreaming(false);
try { try {
const requestBody = { const requestBody = {
message: messageToSend, message: messageToSend,
contextUrl: activeArticleUrl contextUrl: activeArticle ? activeArticle.url : null
}; };
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`, {
@@ -98,53 +126,68 @@ const Chatbot: React.FC = () => {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const readStream = async () => { const readStream = async () => {
let fullReply = ''; let fullReplyRaw = '';
let isFirstChunk = true;
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) { if (done) {
let finalCleanText = ''; // ... (La lógica de `if (done)` no cambia)
try { try {
const parsedArray = JSON.parse(fullReply.replace(/,$/, '') + ']'); const responseArray = JSON.parse(fullReplyRaw);
finalCleanText = Array.isArray(parsedArray) ? parsedArray.join('') : fullReply; let intent = 'Homepage';
} catch (e) { let messageChunks = [];
finalCleanText = fullReply.replace(/^\["|"]$|","/g, ''); if (Array.isArray(responseArray) && responseArray.length > 0) {
if (typeof responseArray[0] === 'string' && responseArray[0].startsWith('INTENT::')) {
intent = responseArray[0].split('::')[1];
messageChunks = responseArray.slice(1);
} else {
messageChunks = responseArray;
} }
}
const linkRegex = /\[.*?\]\((https?:\/\/[^\s]+)\)/; const finalCleanText = messageChunks.join('');
const linkRegex = /\[(.*?)\]\((https?:\/\/[^\s]+)\)/;
const match = finalCleanText.match(linkRegex); const match = finalCleanText.match(linkRegex);
if (match && match[1] && match[2]) {
// --- INICIO DE LA CORRECCIÓN --- setActiveArticle({ title: match[1], url: match[2] });
// Si encontramos un nuevo enlace, actualizamos el contexto. } else if (intent === 'Database' || intent === 'Homepage') {
// Si NO encontramos un enlace, ya no hacemos nada, permitiendo que el contexto anterior persista. setActiveArticle(null);
if (match && match[1]) { }
console.log("Noticia activa establecida:", match[1]); } catch (e) {
setActiveArticleUrl(match[1]); console.error("Error al procesar la respuesta final del stream:", e, "Contenido crudo:", fullReplyRaw);
setActiveArticle(null);
} }
// HEMOS ELIMINADO EL BLOQUE "ELSE" QUE RESETEABA EL CONTEXTO.
// --- FIN DE LA CORRECCIÓN ---
break; break;
} }
// ... (el resto del bucle while sigue exactamente igual)
const chunk = decoder.decode(value); const chunk = decoder.decode(value);
fullReply += chunk; fullReplyRaw += chunk;
let cleanText = ''; let cleanTextForDisplay = '';
try { try {
const parsedArray = JSON.parse(fullReply.replace(/,$/, '') + ']'); const parsedArray = JSON.parse(fullReplyRaw.replace(/,$/, '') + ']');
cleanText = Array.isArray(parsedArray) ? parsedArray.join('') : fullReply; const displayChunks = parsedArray[0] && parsedArray[0].startsWith('INTENT::')
? parsedArray.slice(1)
: parsedArray;
cleanTextForDisplay = displayChunks.join('');
} catch (e) { } catch (e) {
cleanText = fullReply.replace(/^\["|"]$|","/g, ''); cleanTextForDisplay = fullReplyRaw.replace(/^\["INTENT::.*?","|\["|"]$|","/g, '');
} }
if (isFirstChunk) {
// En el primer chunk, activamos el flag de streaming
setIsStreaming(true);
setMessages(prev => [...prev, { text: cleanTextForDisplay, sender: 'bot' }]);
isFirstChunk = false;
} else {
setMessages(prev => { setMessages(prev => {
const lastMessage = prev[prev.length - 1]; const updatedMessages = [...prev];
const updatedLastMessage = { ...lastMessage, text: cleanText }; updatedMessages[updatedMessages.length - 1].text = cleanTextForDisplay;
return [...prev.slice(0, -1), updatedLastMessage]; return updatedMessages;
}); });
} }
}
}; };
await readStream(); await readStream();
@@ -152,15 +195,11 @@ const Chatbot: React.FC = () => {
} catch (error) { } catch (error) {
console.error("Error al conectar con la API de streaming:", error); console.error("Error al conectar con la API de streaming:", error);
const errorText = error instanceof Error ? error.message : 'Lo siento, no pude conectarme.'; const errorText = error instanceof Error ? error.message : 'Lo siento, no pude conectarme.';
setMessages(prev => [...prev, { text: errorText, sender: 'bot' }]);
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
const updatedLastMessage = { ...lastMessage, text: errorText };
return [...prev.slice(0, -1), updatedLastMessage];
});
} finally { } finally {
// Al final, reseteamos ambos estados
setIsLoading(false); setIsLoading(false);
setIsStreaming(false);
} }
}; };
@@ -176,7 +215,7 @@ const Chatbot: React.FC = () => {
<span>Asistente Virtual - El Día</span> <span>Asistente Virtual - El Día</span>
<button className="close-button" onClick={toggleChat}>&times;</button> <button className="close-button" onClick={toggleChat}>&times;</button>
</div> </div>
<div className="messages-container"> <div className={`messages-container ${isLoading ? 'is-loading' : ''}`}>
{messages.map((msg, index) => ( {messages.map((msg, index) => (
<div key={index} className={`message ${msg.sender}`}> <div key={index} className={`message ${msg.sender}`}>
<ReactMarkdown rehypePlugins={[rehypeSanitize]}> <ReactMarkdown rehypePlugins={[rehypeSanitize]}>
@@ -184,11 +223,23 @@ const Chatbot: React.FC = () => {
</ReactMarkdown> </ReactMarkdown>
</div> </div>
))} ))}
{isLoading && !isStreaming && (
<div className="message bot">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{activeArticleUrl && ( {activeArticle && (
<div className="context-indicator"> <div className="context-indicator">
Hablando sobre: <span>Noticia actual</span> Hablando sobre:
<a href={activeArticle.url} target="_blank" rel="noopener noreferrer">
{activeArticle.title}
</a>
</div> </div>
)} )}
<form className="input-form" onSubmit={handleSendMessage}> <form className="input-form" onSubmit={handleSendMessage}>

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
services:
chatbot-api:
build:
context: ./ChatbotApi
dockerfile: Dockerfile.api
container_name: chatbot-api
restart: unless-stopped
ports:
- "8080:80" # Mapea el puerto 80 del contenedor al 8080 de tu servidor Docker
environment:
# --- Variables de Entorno para la API ---
# Docker Compose las leerá automáticamente desde el archivo .env
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__DefaultConnection=${DB_CONNECTION_STRING}
- Gemini__GeminiApiKey=${GEMINI_API_KEY}
- Jwt__Key=${JWT_KEY}
- Jwt__Issuer=${JWT_ISSUER}
- Jwt__Audience=${JWT_AUDIENCE}
chatbot-widget:
build:
context: ./chatbot-widget
dockerfile: Dockerfile.widget
container_name: chatbot-widget
restart: unless-stopped
ports:
- "8081:80" # Mapea el puerto 80 del contenedor Nginx al 8081 del host
chatbot-admin:
build:
context: ./chatbot-admin
dockerfile: Dockerfile.admin
container_name: chatbot-admin
restart: unless-stopped
ports:
- "8082:80" # Mapea el puerto 80 del contenedor Nginx al 8082 del host
networks:
default:
driver: bridge