diff --git a/pom.xml b/pom.xml index 1ead6fe..70f4b02 100644 --- a/pom.xml +++ b/pom.xml @@ -39,7 +39,7 @@ de.kherud llama - 3.4.1 + 4.2.0 diff --git a/src/main/java/com/pablotj/ia/chat/boot/adapter/controller/ChatRestController.java b/src/main/java/com/pablotj/ia/chat/boot/adapter/controller/ChatRestController.java index d66ecb0..4a12324 100644 --- a/src/main/java/com/pablotj/ia/chat/boot/adapter/controller/ChatRestController.java +++ b/src/main/java/com/pablotj/ia/chat/boot/adapter/controller/ChatRestController.java @@ -3,6 +3,7 @@ package com.pablotj.ia.chat.boot.adapter.controller; import com.pablotj.ia.chat.boot.application.usecase.ChatUseCase; import com.pablotj.ia.chat.boot.domain.model.ChatMessage; import jakarta.servlet.http.HttpSession; +import java.util.Date; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.http.ResponseEntity; @@ -30,7 +31,7 @@ public class ChatRestController { reply = chatUseCase.processUserPrompt(prompt, session); } catch (Exception e) { LOGGER.error(e.getMessage(), e); - reply = new ChatMessage("bot", e.getMessage()); + reply = new ChatMessage("bot", e.getMessage(), new Date()); } return ResponseEntity.ok(reply); } diff --git a/src/main/java/com/pablotj/ia/chat/boot/application/usecase/ChatUseCase.java b/src/main/java/com/pablotj/ia/chat/boot/application/usecase/ChatUseCase.java index fdb6457..0844620 100644 --- a/src/main/java/com/pablotj/ia/chat/boot/application/usecase/ChatUseCase.java +++ b/src/main/java/com/pablotj/ia/chat/boot/application/usecase/ChatUseCase.java @@ -7,6 +7,7 @@ import com.pablotj.ia.chat.boot.domain.service.ChatService; import com.pablotj.ia.chat.boot.infraestructure.llm.LlmModelClient; import com.pablotj.ia.chat.boot.web.session.ChatSessionManager; import jakarta.servlet.http.HttpSession; +import java.util.Date; import java.util.List; import org.springframework.stereotype.Component; @@ -27,7 +28,7 @@ public class ChatUseCase { public ChatMessage processUserPrompt(String prompt, HttpSession session) { List messages = sessionManager.getMessages(session); - messages.add(new ChatMessage(ATTR_ROLE_USER, prompt)); + messages.add(new ChatMessage(ATTR_ROLE_USER, prompt, new Date())); PromptBuilder builder = new PromptBuilder(PromptTemplates.getDefault()); @@ -40,7 +41,7 @@ public class ChatUseCase { } String result = llmModelClient.generate(builder.build()); - ChatMessage reply = new ChatMessage(ATTR_ROLE_BOT, result); + ChatMessage reply = new ChatMessage(ATTR_ROLE_BOT, result, new Date()); messages.add(reply); sessionManager.setMessages(session, messages); return reply; diff --git a/src/main/java/com/pablotj/ia/chat/boot/domain/model/ChatMessage.java b/src/main/java/com/pablotj/ia/chat/boot/domain/model/ChatMessage.java index c8d0e2a..e1889b8 100644 --- a/src/main/java/com/pablotj/ia/chat/boot/domain/model/ChatMessage.java +++ b/src/main/java/com/pablotj/ia/chat/boot/domain/model/ChatMessage.java @@ -1,6 +1,14 @@ package com.pablotj.ia.chat.boot.domain.model; +import com.fasterxml.jackson.annotation.JsonFormat; import java.io.Serializable; +import java.util.Date; -public record ChatMessage(String role, String text) implements Serializable { +public record ChatMessage(String role, String text, Date date) implements Serializable { + + @Override + @JsonFormat(pattern = "dd/MM/yyyy HH:mm", timezone = "Europe/Madrid") + public Date date() { + return date; + } } \ No newline at end of file diff --git a/src/main/java/com/pablotj/ia/chat/boot/infraestructure/llm/LlmModelLoader.java b/src/main/java/com/pablotj/ia/chat/boot/infraestructure/llm/LlmModelLoader.java index a56e55e..b159483 100644 --- a/src/main/java/com/pablotj/ia/chat/boot/infraestructure/llm/LlmModelLoader.java +++ b/src/main/java/com/pablotj/ia/chat/boot/infraestructure/llm/LlmModelLoader.java @@ -10,23 +10,30 @@ import org.springframework.stereotype.Component; @Component public class LlmModelLoader implements AutoCloseable { - @Value(value = "${model.gguf.name}") + @Value(value = "${llama.model.name}") private String modelName; + @Value(value = "${llama.model.gpu.enabled}") + private boolean gpuEnabled; + + @Value(value = "${llama.model.gpu.layers}") + private int gpuLayers; + + @Value(value = "${llama.model.tokens}") + private int tokens; + private LlamaModel model; @PostConstruct public void init() { try { ModelParameters params = new ModelParameters() - .setModelFilePath(String.format("models/%s.gguf", modelName)) + .setModel(String.format("models/%s.gguf", modelName)) .setSeed(42) - .setNThreads(8) - .setNGpuLayers(0) - .setMainGpu(-1) - .setNoKvOffload(true) - .setUseMmap(true) - .setNPredict(1024); + .setThreads(8) + .setMainGpu(gpuEnabled ? 0 : -1) + .setGpuLayers(gpuEnabled ? gpuLayers : -1) + .setPredict(tokens); model = new LlamaModel(params); } catch (Exception e) { throw new BusinessLogicException("Error loading model", e); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 99e9cca..f794079 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,4 +2,8 @@ spring.application.name = ia-chat-boot server.port = 8080 ! Model -model.gguf.name = openchat-3.5-0106.Q4_K_M \ No newline at end of file +llama.model.name = openchat-3.5-0106.Q4_K_M +llama.model.gpu.enabled = true +llama.model.gpu.layers = 35 +llama.model.tokens = 1024 + diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index ac918cf..03da5c7 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -1,126 +1,169 @@ +/* Base y fuente */ html, body { margin: 0; padding: 0; height: 100%; - background: #121212; - color: #eee; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: #121217; + color: #e0e4e8; + font-family: 'Segoe UI Variable', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } + body { display: flex; flex-direction: column; max-width: 600px; - margin-left: auto; - margin-right: auto; + margin: 0 auto; height: 100vh; - padding: 2rem 1rem 1rem 1rem; /* poco padding para el top, nada abajo */ + padding: 2rem 1.25rem 1rem; box-sizing: border-box; + background-color: #121217; } +/* Título */ h1 { text-align: center; - margin: 0 0 1rem 0; - color: #61dafb; + margin-bottom: 1.25rem; + color: #5a90ff; + font-weight: 700; + font-size: 1.75rem; + letter-spacing: 0.05em; user-select: none; - flex-shrink: 0; } +/* Contenedor de mensajes */ #chat-log { flex: 1 1 auto; overflow-y: auto; display: flex; flex-direction: column; - gap: 10px; - padding: 1rem; - scrollbar-width: thin; - scrollbar-color: #61dafb transparent; + gap: 12px; + padding: 1.25rem 1.5rem; box-sizing: border-box; + background-color: #1a1c22; + border-radius: 16px; + scrollbar-width: thin; + scrollbar-color: #5a90ff33 transparent; } #chat-log::-webkit-scrollbar { - width: 8px; + width: 6px; } #chat-log::-webkit-scrollbar-thumb { - background-color: #61dafb; - border-radius: 4px; + background-color: #5a90ff55; + border-radius: 3px; } +/* Burbujas comunes */ .bubble { max-width: 75%; - padding: 12px 18px; + padding: 14px 18px; border-radius: 20px; - line-height: 1.4; + line-height: 1.5; font-size: 1rem; word-wrap: break-word; user-select: text; + transition: background-color 0.3s ease, color 0.3s ease; + opacity: 0; + transform: translateY(10px); + animation: slideFadeIn 0.3s forwards; } +/* Animación de entrada */ +@keyframes slideFadeIn { + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Burbuja del usuario */ .user { - background: linear-gradient(135deg, #6e91f6, #3a64f8); + background-color: #3451d1; align-self: flex-end; - color: white; - box-shadow: 0 2px 8px rgba(58, 100, 248, 0.5); + color: #ffffff; + border-radius: 20px 20px 4px 20px; } +/* Burbuja del bot */ .bot { - background: linear-gradient(135deg, #444, #222); + background-color: #2a2d35; align-self: flex-start; - color: #61dafb; + color: #a4c8ff; font-style: italic; - box-shadow: 0 2px 8px rgba(97, 218, 251, 0.5); + border-radius: 20px 20px 20px 4px; } +/* Formulario de entrada */ form { - flex-shrink: 0; /* que no se encoja */ - margin-top: 1rem; + flex-shrink: 0; + margin-top: 1.25rem; display: flex; - gap: 8px; - padding: 0 1rem; + gap: 12px; + padding: 0; box-sizing: border-box; + background: transparent; + border-radius: 0; } +/* Textarea */ textarea { flex-grow: 1; - min-height: 4rem; - border-radius: 12px; - border: none; - padding: 0.75rem 1rem; + min-height: 4.25rem; + border-radius: 16px; + border: 1px solid #2d2f36; + padding: 1rem 1.25rem; font-size: 1rem; resize: none; outline: none; - background-color: #222; - color: #eee; - box-shadow: inset 0 0 5px #000; + background-color: #1c1e24; + color: #e6e9ef; font-family: inherit; max-width: 100%; overflow-y: auto; box-sizing: border-box; + transition: border-color 0.25s ease, background-color 0.25s ease; +} + +textarea::placeholder { + color: #6a6e7c; + font-style: italic; + opacity: 0.9; } textarea:focus { - box-shadow: inset 0 0 7px #61dafb; + border-color: #5a90ff; + background-color: #20232a; } +/* Botón */ button { - background: #61dafb; + background-color: #5a90ff; border: none; - border-radius: 12px; - color: #121212; - padding: 0 1.5rem; - font-weight: 700; + border-radius: 14px; + color: #ffffff; + padding: 0 1.75rem; + font-weight: 600; font-size: 1rem; cursor: pointer; - transition: background-color 0.3s ease; + transition: background-color 0.25s ease, transform 0.1s ease; white-space: nowrap; } button:hover:not(:disabled) { - background-color: #4ea8e6; + background-color: #4076e0; +} + +button:active:not(:disabled) { + transform: translateY(1px); + background-color: #305dc0; } button:disabled { - opacity: 0.6; + opacity: 0.5; cursor: not-allowed; + background-color: #3a4a6a; } #spinner { @@ -128,21 +171,64 @@ button:disabled { margin: 1rem auto; width: 36px; height: 36px; - border: 4px solid rgba(97, 218, 251, 0.3); - border-top-color: #61dafb; + border: 4px solid rgba(90, 144, 255, 0.2); + border-top-color: #5a90ff; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } +/* Texto "Pensando..." */ #thinking-text { text-align: center; - color: #61dafb; + color: #5a90ff; font-style: italic; margin-top: 0.5rem; font-weight: 600; display: none; + user-select: none; +} + +.timestamp { + display: block; + text-align: right; + font-size: 0.75rem; + color: #999; + margin-top: 6px; + font-style: normal; + opacity: 0.7; + user-select: none; +} + +/* Responsive */ +@media (max-width: 480px) { + body { + max-width: 100%; + padding: 1.5rem 1rem 1rem; + } + + #chat-log { + padding: 1rem 1.25rem; + border-radius: 12px; + } + + form { + gap: 8px; + } + + textarea { + min-height: 3.5rem; + font-size: 0.95rem; + padding: 0.75rem 1rem; + } + + button { + padding: 0 1.25rem; + font-size: 0.95rem; + } } \ No newline at end of file diff --git a/src/main/resources/static/js/main.js b/src/main/resources/static/js/main.js index b9eddf9..ccb3bfd 100644 --- a/src/main/resources/static/js/main.js +++ b/src/main/resources/static/js/main.js @@ -6,11 +6,15 @@ document.addEventListener("DOMContentLoaded", () => { const spinner = document.getElementById('spinner'); const thinkingText = document.getElementById('thinking-text'); - function appendMessage(role, text) { + function appendMessage(role, text, date) { const bubble = document.createElement('div'); bubble.className = `bubble ${role}`; bubble.textContent = text; chatLog.appendChild(bubble); + const timestamp = document.createElement('em'); + timestamp.className = 'timestamp'; + timestamp.textContent = date; + bubble.appendChild(timestamp); chatLog.scrollTop = chatLog.scrollHeight; } @@ -20,7 +24,7 @@ document.addEventListener("DOMContentLoaded", () => { const prompt = promptInput.value.trim(); if (!prompt) return; - appendMessage("user", prompt); + appendMessage("user", prompt, formatDate(new Date())); promptInput.value = ""; sendBtn.disabled = true; spinner.style.display = 'block'; @@ -36,7 +40,7 @@ document.addEventListener("DOMContentLoaded", () => { }); const data = await response.json(); - appendMessage("bot", data.text); + appendMessage("bot", data.text, data.date); } catch (error) { appendMessage("bot", "❌ Error procesando la respuesta."); console.error(error); @@ -46,4 +50,14 @@ document.addEventListener("DOMContentLoaded", () => { thinkingText.style.display = 'none'; } }); + + function formatDate(date) { + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); // Enero = 0 + const year = date.getFullYear(); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + return `${day}/${month}/${year} ${hours}:${minutes}`; + } }); \ No newline at end of file diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index cea13c5..224c03b 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -9,7 +9,11 @@

🤖 Chat IA Offline

-
+
+ + +
Pensando...