diff --git a/ChatbotApi/Constrollers/ChatController.cs b/ChatbotApi/Constrollers/ChatController.cs index 36708a3..2a57e51 100644 --- a/ChatbotApi/Constrollers/ChatController.cs +++ b/ChatbotApi/Constrollers/ChatController.cs @@ -48,7 +48,7 @@ namespace ChatbotApi.Controllers var baseUrl = configuration["Gemini:GeminiApiUrl"]; _apiUrl = $"{baseUrl}{apiKey}"; } - + private async Task GetIntentAsync(string userMessage, string? activeArticleContent) { var promptBuilder = new StringBuilder(); @@ -92,12 +92,12 @@ namespace ChatbotApi.Controllers return IntentType.Homepage; } } - + [HttpPost("stream-message")] [EnableRateLimiting("fixed")] public async IAsyncEnumerable StreamMessage( - [FromBody] ChatRequest request, - [EnumeratorCancellation] CancellationToken cancellationToken) + [FromBody] ChatRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request?.Message)) { @@ -111,6 +111,9 @@ namespace ChatbotApi.Controllers string? articleContext = null; string? errorMessage = null; + // --- CORRECCIÓN: Declarar e inicializar 'intent' aquí --- + IntentType intent = IntentType.Homepage; // Default fallback + try { if (!string.IsNullOrEmpty(request.ContextUrl)) @@ -118,7 +121,8 @@ namespace ChatbotApi.Controllers 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) { @@ -138,7 +142,7 @@ namespace ChatbotApi.Controllers case IntentType.Homepage: default: _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)'."; break; } @@ -151,6 +155,9 @@ namespace ChatbotApi.Controllers promptInstructions = string.Empty; } + // Ahora 'intent' es accesible aquí. + yield return $"INTENT::{intent}"; + if (!string.IsNullOrEmpty(errorMessage)) { yield return errorMessage; @@ -267,7 +274,7 @@ namespace ChatbotApi.Controllers _logger.LogError(logEx, "Error al guardar el log de la conversación después del streaming."); } } - + private async Task FindBestDbItemAsync(string userMessage, Dictionary knowledgeBase) { if (knowledgeBase == null || !knowledgeBase.Any()) return null; diff --git a/ChatbotApi/Dockerfile.api b/ChatbotApi/Dockerfile.api new file mode 100644 index 0000000..d969dae --- /dev/null +++ b/ChatbotApi/Dockerfile.api @@ -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"] \ No newline at end of file diff --git a/ChatbotApi/Program.cs b/ChatbotApi/Program.cs index e460242..7bc7520 100644 --- a/ChatbotApi/Program.cs +++ b/ChatbotApi/Program.cs @@ -20,7 +20,8 @@ builder.Services.AddCors(options => options.AddPolicy(name: myAllowSpecificOrigins, 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() .AllowAnyMethod(); }); diff --git a/chatbot-admin/Dockerfile.admin b/chatbot-admin/Dockerfile.admin new file mode 100644 index 0000000..a9292d3 --- /dev/null +++ b/chatbot-admin/Dockerfile.admin @@ -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;"] \ No newline at end of file diff --git a/chatbot-admin/nginx.conf b/chatbot-admin/nginx.conf new file mode 100644 index 0000000..697353d --- /dev/null +++ b/chatbot-admin/nginx.conf @@ -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; + } +} \ No newline at end of file diff --git a/chatbot-admin/src/components/LogsViewer.tsx b/chatbot-admin/src/components/LogsViewer.tsx index f4c62ab..03ec727 100644 --- a/chatbot-admin/src/components/LogsViewer.tsx +++ b/chatbot-admin/src/components/LogsViewer.tsx @@ -41,7 +41,25 @@ const LogsViewer: React.FC = ({ onAuthError }) => { field: 'fecha', headerName: 'Fecha', 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: 'botRespuesta', headerName: 'Respuesta del Bot', flex: 1 }, diff --git a/chatbot-widget/Dockerfile.widget b/chatbot-widget/Dockerfile.widget new file mode 100644 index 0000000..f547b81 --- /dev/null +++ b/chatbot-widget/Dockerfile.widget @@ -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;"] \ No newline at end of file diff --git a/chatbot-widget/nginx.conf b/chatbot-widget/nginx.conf new file mode 100644 index 0000000..697353d --- /dev/null +++ b/chatbot-widget/nginx.conf @@ -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; + } +} \ No newline at end of file diff --git a/chatbot-widget/src/components/Chatbot.css b/chatbot-widget/src/components/Chatbot.css index 77b7bf8..8edd0f8 100644 --- a/chatbot-widget/src/components/Chatbot.css +++ b/chatbot-widget/src/components/Chatbot.css @@ -150,4 +150,40 @@ opacity: 0.8; .context-indicator span { font-weight: bold; -} \ No newline at end of file +} + +/* --- 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); + } +} diff --git a/chatbot-widget/src/components/Chatbot.tsx b/chatbot-widget/src/components/Chatbot.tsx index ffea068..e115143 100644 --- a/chatbot-widget/src/components/Chatbot.tsx +++ b/chatbot-widget/src/components/Chatbot.tsx @@ -11,8 +11,9 @@ interface Message { } const MAX_CHARS = 200; -// Constante para la clave del localStorage +// Constantes para la clave del localStorage const CHAT_HISTORY_KEY = 'chatbot-history'; +const CHAT_CONTEXT_KEY = 'chatbot-active-article'; const Chatbot: React.FC = () => { const [isOpen, setIsOpen] = useState(false); @@ -33,8 +34,22 @@ const Chatbot: React.FC = () => { const [inputValue, setInputValue] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [isStreaming, setIsStreaming] = useState(false); const messagesEndRef = useRef(null); - const [activeArticleUrl, setActiveArticleUrl] = useState(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. useEffect(() => { @@ -46,6 +61,19 @@ const Chatbot: React.FC = () => { } }, [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(() => { // Solo intentamos hacer scroll si la ventana del chat está abierta. @@ -73,15 +101,15 @@ const Chatbot: React.FC = () => { setMessages(prev => [...prev, userMessage]); const messageToSend = inputValue; setInputValue(''); - setIsLoading(true); - const botMessagePlaceholder: Message = { text: '', sender: 'bot' }; - setMessages(prev => [...prev, botMessagePlaceholder]); + // Inicia el estado de carga, pero aún no el de streaming + setIsLoading(true); + setIsStreaming(false); try { const requestBody = { message: messageToSend, - contextUrl: activeArticleUrl + contextUrl: activeArticle ? activeArticle.url : null }; const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/chat/stream-message`, { @@ -98,52 +126,67 @@ const Chatbot: React.FC = () => { const decoder = new TextDecoder(); const readStream = async () => { - let fullReply = ''; + let fullReplyRaw = ''; + let isFirstChunk = true; while (true) { const { done, value } = await reader.read(); + if (done) { - let finalCleanText = ''; + // ... (La lógica de `if (done)` no cambia) try { - const parsedArray = JSON.parse(fullReply.replace(/,$/, '') + ']'); - finalCleanText = Array.isArray(parsedArray) ? parsedArray.join('') : fullReply; + const responseArray = JSON.parse(fullReplyRaw); + let intent = 'Homepage'; + let messageChunks = []; + 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 finalCleanText = messageChunks.join(''); + const linkRegex = /\[(.*?)\]\((https?:\/\/[^\s]+)\)/; + const match = finalCleanText.match(linkRegex); + if (match && match[1] && match[2]) { + setActiveArticle({ title: match[1], url: match[2] }); + } else if (intent === 'Database' || intent === 'Homepage') { + setActiveArticle(null); + } } catch (e) { - finalCleanText = fullReply.replace(/^\["|"]$|","/g, ''); + console.error("Error al procesar la respuesta final del stream:", e, "Contenido crudo:", fullReplyRaw); + setActiveArticle(null); } - - const linkRegex = /\[.*?\]\((https?:\/\/[^\s]+)\)/; - const match = finalCleanText.match(linkRegex); - - // --- INICIO DE LA CORRECCIÓN --- - // Si encontramos un nuevo enlace, actualizamos el contexto. - // Si NO encontramos un enlace, ya no hacemos nada, permitiendo que el contexto anterior persista. - if (match && match[1]) { - console.log("Noticia activa establecida:", match[1]); - setActiveArticleUrl(match[1]); - } - // HEMOS ELIMINADO EL BLOQUE "ELSE" QUE RESETEABA EL CONTEXTO. - // --- FIN DE LA CORRECCIÓN --- - break; } - // ... (el resto del bucle while sigue exactamente igual) const chunk = decoder.decode(value); - fullReply += chunk; + fullReplyRaw += chunk; - let cleanText = ''; + let cleanTextForDisplay = ''; try { - const parsedArray = JSON.parse(fullReply.replace(/,$/, '') + ']'); - cleanText = Array.isArray(parsedArray) ? parsedArray.join('') : fullReply; + const parsedArray = JSON.parse(fullReplyRaw.replace(/,$/, '') + ']'); + const displayChunks = parsedArray[0] && parsedArray[0].startsWith('INTENT::') + ? parsedArray.slice(1) + : parsedArray; + cleanTextForDisplay = displayChunks.join(''); } catch (e) { - cleanText = fullReply.replace(/^\["|"]$|","/g, ''); + cleanTextForDisplay = fullReplyRaw.replace(/^\["INTENT::.*?","|\["|"]$|","/g, ''); } - setMessages(prev => { - const lastMessage = prev[prev.length - 1]; - const updatedLastMessage = { ...lastMessage, text: cleanText }; - return [...prev.slice(0, -1), updatedLastMessage]; - }); + 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 => { + const updatedMessages = [...prev]; + updatedMessages[updatedMessages.length - 1].text = cleanTextForDisplay; + return updatedMessages; + }); + } } }; @@ -152,15 +195,11 @@ const Chatbot: React.FC = () => { } catch (error) { console.error("Error al conectar con la API de streaming:", error); const errorText = error instanceof Error ? error.message : 'Lo siento, no pude conectarme.'; - - setMessages(prev => { - const lastMessage = prev[prev.length - 1]; - const updatedLastMessage = { ...lastMessage, text: errorText }; - return [...prev.slice(0, -1), updatedLastMessage]; - }); - + setMessages(prev => [...prev, { text: errorText, sender: 'bot' }]); } finally { + // Al final, reseteamos ambos estados setIsLoading(false); + setIsStreaming(false); } }; @@ -176,7 +215,7 @@ const Chatbot: React.FC = () => { Asistente Virtual - El Día -
+
{messages.map((msg, index) => (
@@ -184,11 +223,23 @@ const Chatbot: React.FC = () => {
))} + {isLoading && !isStreaming && ( +
+
+ + + +
+
+ )}
- {activeArticleUrl && ( + {activeArticle && (
- Hablando sobre: Noticia actual + Hablando sobre: + + {activeArticle.title} +
)}
diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2350bad --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file