Compare commits

...

2 Commits

Author SHA1 Message Date
ecdc334da9 Update llama dependency and adapt loader to new version.
Add external configuration properties for the model.
Include date and time display on chat messages.
2025-06-28 23:38:51 +02:00
19b692921f Exclude log files from repository versioning to reduce noise 2025-06-28 23:37:07 +02:00
11 changed files with 194 additions and 68 deletions

4
.gitignore vendored
View File

@ -35,4 +35,6 @@ build/
### VS Code ###
.vscode/
*.gguf
*.gguf
*.log

View File

@ -1 +0,0 @@
[1751135918] warming up the model with an empty run

View File

@ -39,7 +39,7 @@
<dependency>
<groupId>de.kherud</groupId>
<artifactId>llama</artifactId>
<version>3.4.1</version>
<version>4.2.0</version>
</dependency>
<!-- Test support -->

View File

@ -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);
}

View File

@ -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<ChatMessage> 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;

View File

@ -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;
}
}

View File

@ -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);

View File

@ -2,4 +2,8 @@ spring.application.name = ia-chat-boot
server.port = 8080
! Model
model.gguf.name = openchat-3.5-0106.Q4_K_M
llama.model.name = openchat-3.5-0106.Q4_K_M
llama.model.gpu.enabled = true
llama.model.gpu.layers = 35
llama.model.tokens = 1024

View File

@ -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;
}
}

View File

@ -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}`;
}
});

View File

@ -9,7 +9,11 @@
<h1>🤖 Chat IA Offline</h1>
<div id="chat-log">
<div th:each="msg : ${messages}" th:class="'bubble ' + ${msg.role}" th:text="${msg.text}"></div>
<div th:each="msg : ${messages}"
th:class="'bubble ' + ${msg.role}">
<span th:text="${msg.text}"></span>
<em class="timestamp" th:text="${#dates.format(msg.date, 'dd/MM/yyyy HH:mm')}"></em>
</div>
</div>
<div id="spinner"></div>
<div id="thinking-text">Pensando...</div>