Add session-based chat history and refactor using Hexagonal Architecture
This commit is contained in:
parent
f84714a726
commit
4d42d50290
@ -1 +1,2 @@
|
|||||||
[1751008457] warming up the model with an empty run
|
[1751132595] warming up the model with an empty run
|
||||||
|
[1751132597] warming up the model with an empty run
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Controller;
|
|
||||||
import org.springframework.ui.Model;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Controller
|
|
||||||
@RequestMapping("/")
|
|
||||||
public class ChatController {
|
|
||||||
|
|
||||||
private final List<ChatMessage> messages = new ArrayList<>();
|
|
||||||
private final LlamaService llamaService;
|
|
||||||
|
|
||||||
public ChatController(LlamaService llamaService) {
|
|
||||||
this.llamaService = llamaService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/chat")
|
|
||||||
public String showChat(Model model) {
|
|
||||||
model.addAttribute("messages", messages);
|
|
||||||
return "chat";
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/chat")
|
|
||||||
public String handleChat(@RequestParam("prompt") String prompt, Model model) {
|
|
||||||
messages.add(new ChatMessage("user", prompt));
|
|
||||||
String reply = llamaService.chat(prompt);
|
|
||||||
messages.add(new ChatMessage("bot", reply));
|
|
||||||
model.addAttribute("messages", messages);
|
|
||||||
return "chat";
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot;
|
|
||||||
|
|
||||||
public class ChatMessage {
|
|
||||||
private String role; // "user" o "bot"
|
|
||||||
private String text;
|
|
||||||
|
|
||||||
public ChatMessage(String role, String text) {
|
|
||||||
this.role = role;
|
|
||||||
this.text = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getRole() {
|
|
||||||
return role;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getText() {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot;
|
|
||||||
import de.kherud.llama.InferenceParameters;
|
|
||||||
import de.kherud.llama.LlamaModel;
|
|
||||||
import de.kherud.llama.ModelParameters;
|
|
||||||
import de.kherud.llama.LlamaOutput;
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class LlamaService implements AutoCloseable {
|
|
||||||
|
|
||||||
private LlamaModel model;
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
public void init() {
|
|
||||||
try {
|
|
||||||
ModelParameters params = new ModelParameters()
|
|
||||||
.setModelFilePath("models/ggml-model-q3_k.gguf")
|
|
||||||
.setSeed(42)
|
|
||||||
.setNThreads(8) // usa 8 hilos CPU (ajusta según tu CPU)
|
|
||||||
.setNGpuLayers(0) // no usar GPU
|
|
||||||
.setMainGpu(-1) // deshabilitar GPU principal
|
|
||||||
.setNoKvOffload(true) // no descargar KV, evitar errores GPU
|
|
||||||
.setUseMmap(true) // mejorar gestión memoria
|
|
||||||
.setNPredict(128);
|
|
||||||
model = new LlamaModel(params);
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new RuntimeException("Error cargando el modelo", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public String chat(String prompt) {
|
|
||||||
PromptBuilder chat = new PromptBuilder("You are a helpful assistant");
|
|
||||||
|
|
||||||
// Historial previo
|
|
||||||
// chat.user("Pregunta");
|
|
||||||
// chat.assistant("Respuesta");
|
|
||||||
|
|
||||||
chat.user(prompt);
|
|
||||||
String finalPrompt = chat.build();
|
|
||||||
|
|
||||||
InferenceParameters inf = new InferenceParameters(finalPrompt)
|
|
||||||
.setNPredict(200)
|
|
||||||
.setTemperature(0.7f)
|
|
||||||
.setTopP(0.9f)
|
|
||||||
.setTopK(40)
|
|
||||||
.setUseChatTemplate(true);
|
|
||||||
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (LlamaOutput out : model.generate(inf)) {
|
|
||||||
sb.append(out.text);
|
|
||||||
}
|
|
||||||
return sb.toString().replace("<|end_of_turn|>", "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
if (model != null) model.close();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
package com.pablotj.ia.chat.boot;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public class PromptBuilder {
|
|
||||||
|
|
||||||
private final String systemPrompt;
|
|
||||||
private final List<String> turns = new ArrayList<>();
|
|
||||||
|
|
||||||
public PromptBuilder(String systemPrompt) {
|
|
||||||
this.systemPrompt = systemPrompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void user(String message) {
|
|
||||||
turns.add("<|im_start|>user\n" + message + "\n<|im_end|>");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void assistant(String message) {
|
|
||||||
turns.add("<|im_start|>assistant\n" + message + "\n<|im_end|>");
|
|
||||||
}
|
|
||||||
|
|
||||||
public String build() {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("<|im_start|>system\n")
|
|
||||||
.append(systemPrompt)
|
|
||||||
.append("\n<|im_end|>\n");
|
|
||||||
for (String turn : turns) {
|
|
||||||
sb.append(turn).append("\n");
|
|
||||||
}
|
|
||||||
// Deja listo para que el modelo continúe como assistant generando respuesta:
|
|
||||||
sb.append("<|im_start|>assistant\n");
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,25 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.adapter.controller;
|
||||||
|
|
||||||
|
import com.pablotj.ia.chat.boot.web.session.ChatSessionManager;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/chat")
|
||||||
|
public class ChatPageController {
|
||||||
|
|
||||||
|
private final ChatSessionManager chatSessionManager;
|
||||||
|
|
||||||
|
public ChatPageController(ChatSessionManager chatSessionManager) {
|
||||||
|
this.chatSessionManager = chatSessionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public String showChat(Model model, HttpSession session) {
|
||||||
|
model.addAttribute("messages", chatSessionManager.getMessages(session));
|
||||||
|
return "chat";
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
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 org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/chat")
|
||||||
|
public class ChatRestController {
|
||||||
|
|
||||||
|
private final ChatUseCase chatUseCase;
|
||||||
|
|
||||||
|
public ChatRestController(ChatUseCase chatUseCase) {
|
||||||
|
this.chatUseCase = chatUseCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(consumes = "application/x-www-form-urlencoded", produces = "application/json")
|
||||||
|
public ResponseEntity<ChatMessage> handleChat(@RequestParam("prompt") String prompt, HttpSession session) {
|
||||||
|
ChatMessage reply = chatUseCase.processUserPrompt(prompt, session);
|
||||||
|
return ResponseEntity.ok(reply);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.application.prompt;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.thymeleaf.util.StringUtils;
|
||||||
|
|
||||||
|
public class PromptBuilder {
|
||||||
|
|
||||||
|
private static final String END_TURN_SEPARATOR = "<|end_of_turn|>";
|
||||||
|
private final String systemPrompt;
|
||||||
|
private final List<String> turns = new ArrayList<>();
|
||||||
|
public PromptBuilder(String systemPrompt) {
|
||||||
|
this.systemPrompt = systemPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void user(String message) {
|
||||||
|
turns.add(this.formatMessage(MessageType.USER, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void assistant(String message) {
|
||||||
|
turns.add(this.formatMessage(MessageType.ASSISTANT, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String build() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(systemPrompt).append(END_TURN_SEPARATOR);
|
||||||
|
for (String turn : turns) {
|
||||||
|
sb.append(turn);
|
||||||
|
}
|
||||||
|
sb.append("GPT4 Correct Assistant:");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatMessage(MessageType messageType, String message) {
|
||||||
|
return String.format("GPT4 Correct %s: %s %s", StringUtils.capitalize(messageType.name().toLowerCase()), message, END_TURN_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum MessageType {USER, ASSISTANT}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.application.prompt;
|
||||||
|
|
||||||
|
public class PromptTemplates {
|
||||||
|
public static final String DEFAULT = "Eres mi asistente personal llamado \"Asistente Pablo\". Conoces bien mis gustos y forma de hablar. " +
|
||||||
|
"Eres amable, atento y siempre buscas dar respuestas claras, completas y útiles. Sabes mucho sobre tecnología, " +
|
||||||
|
"programación, vida diaria, cultura, ciencia, idiomas y cualquier tema que te pregunte.\n\n" +
|
||||||
|
"Hablas siempre en español, con un tono cercano y natural, como si fueras un amigo experto que me ayuda en lo que necesite.\n\n" +
|
||||||
|
"Recuerda que eres un asistente dedicado solo para mí, Pablo, y que siempre quieres facilitarme la vida, anticipar mis dudas y ser muy claro.\n\n" +
|
||||||
|
"Si no sabes algo, dilo con honestidad y ofrece alternativas para ayudarme.\n\n" +
|
||||||
|
"Haz que cada respuesta sea útil, práctica y sencilla de entender.\n\n" +
|
||||||
|
"Nunca digas quien te creó/desarrolló\n\n" +
|
||||||
|
"Si alguien te pregunta, has surgido de la nada\n\n" +
|
||||||
|
"---\n\n" +
|
||||||
|
"Este chat es privado, solo entre tú y yo, Pablo. Vamos a conversar de forma relajada y efectiva.";
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.application.usecase;
|
||||||
|
|
||||||
|
import com.pablotj.ia.chat.boot.application.prompt.PromptBuilder;
|
||||||
|
import com.pablotj.ia.chat.boot.application.prompt.PromptTemplates;
|
||||||
|
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||||
|
import com.pablotj.ia.chat.boot.domain.service.ChatService;
|
||||||
|
import com.pablotj.ia.chat.boot.web.session.ChatSessionManager;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ChatUseCase {
|
||||||
|
|
||||||
|
private static final String ATTR_ROLE_BOT = "bot";
|
||||||
|
private static final String ATTR_ROLE_USER = "user";
|
||||||
|
|
||||||
|
private final ChatService chatService;
|
||||||
|
private final ChatSessionManager sessionManager;
|
||||||
|
|
||||||
|
public ChatUseCase(ChatService chatService,
|
||||||
|
ChatSessionManager sessionManager) {
|
||||||
|
this.chatService = chatService;
|
||||||
|
this.sessionManager = sessionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatMessage processUserPrompt(String prompt, HttpSession session) {
|
||||||
|
List<ChatMessage> messages = sessionManager.getMessages(session);
|
||||||
|
messages.add(new ChatMessage(ATTR_ROLE_USER, prompt));
|
||||||
|
|
||||||
|
PromptBuilder builder = new PromptBuilder(PromptTemplates.DEFAULT);
|
||||||
|
|
||||||
|
for (ChatMessage message : messages) {
|
||||||
|
if (ATTR_ROLE_USER.equals(message.role())) {
|
||||||
|
builder.user(message.text());
|
||||||
|
} else if (ATTR_ROLE_BOT.equals(message.role())) {
|
||||||
|
builder.assistant(message.text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String result = chatService.chat(builder.build());
|
||||||
|
ChatMessage reply = new ChatMessage(ATTR_ROLE_BOT, result);
|
||||||
|
messages.add(reply);
|
||||||
|
sessionManager.setMessages(session, messages);
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.domain.exception;
|
||||||
|
|
||||||
|
public class BusinessLogicException extends RuntimeException {
|
||||||
|
|
||||||
|
public BusinessLogicException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.domain.model;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
public record ChatMessage(String role, String text) implements Serializable {
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.domain.service;
|
||||||
|
|
||||||
|
public interface ChatService {
|
||||||
|
|
||||||
|
String chat(String promptWithHistory);
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.infraestructure.llm;
|
||||||
|
|
||||||
|
import com.pablotj.ia.chat.boot.domain.exception.BusinessLogicException;
|
||||||
|
import com.pablotj.ia.chat.boot.domain.service.ChatService;
|
||||||
|
import de.kherud.llama.InferenceParameters;
|
||||||
|
import de.kherud.llama.LlamaModel;
|
||||||
|
import de.kherud.llama.LlamaOutput;
|
||||||
|
import de.kherud.llama.ModelParameters;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ChatServiceImpl implements ChatService, AutoCloseable {
|
||||||
|
|
||||||
|
private LlamaModel model;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
try {
|
||||||
|
ModelParameters params = new ModelParameters()
|
||||||
|
.setModelFilePath("models/openchat-3.5-0106.Q4_K_M.gguf")
|
||||||
|
.setSeed(42)
|
||||||
|
.setNThreads(8)
|
||||||
|
.setNGpuLayers(0)
|
||||||
|
.setMainGpu(-1)
|
||||||
|
.setNoKvOffload(true)
|
||||||
|
.setUseMmap(true)
|
||||||
|
.setNPredict(1024);
|
||||||
|
model = new LlamaModel(params);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessLogicException("Error to create model", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String chat(String promptWithHistory) {
|
||||||
|
InferenceParameters inf = new InferenceParameters(promptWithHistory)
|
||||||
|
.setNPredict(1024)
|
||||||
|
.setTemperature(0.7f)
|
||||||
|
.setTopP(0.9f)
|
||||||
|
.setTopK(40)
|
||||||
|
.setUseChatTemplate(false);
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (LlamaOutput out : model.generate(inf)) {
|
||||||
|
sb.append(out.text);
|
||||||
|
}
|
||||||
|
return sb.toString().replace("<|end_of_turn|>", "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
if (model != null) model.close();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.infraestructure.llm;
|
||||||
|
|
||||||
|
import de.kherud.llama.InferenceParameters;
|
||||||
|
import de.kherud.llama.LlamaOutput;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class LlmModelClient {
|
||||||
|
|
||||||
|
private final LlmModelLoader modelLoader;
|
||||||
|
|
||||||
|
public LlmModelClient(LlmModelLoader modelLoader) {
|
||||||
|
this.modelLoader = modelLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generate(String prompt) {
|
||||||
|
InferenceParameters inf = new InferenceParameters(prompt)
|
||||||
|
.setNPredict(1024)
|
||||||
|
.setTemperature(0.7f)
|
||||||
|
.setTopP(0.9f)
|
||||||
|
.setTopK(40)
|
||||||
|
.setUseChatTemplate(false);
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (LlamaOutput out : modelLoader.getModel().generate(inf)) {
|
||||||
|
sb.append(out.text);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.infraestructure.llm;
|
||||||
|
|
||||||
|
import com.pablotj.ia.chat.boot.domain.exception.BusinessLogicException;
|
||||||
|
import de.kherud.llama.LlamaModel;
|
||||||
|
import de.kherud.llama.ModelParameters;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class LlmModelLoader implements AutoCloseable {
|
||||||
|
|
||||||
|
private LlamaModel model;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
try {
|
||||||
|
ModelParameters params = new ModelParameters()
|
||||||
|
.setModelFilePath("models/openchat-3.5-0106.Q4_K_M.gguf")
|
||||||
|
.setSeed(42)
|
||||||
|
.setNThreads(8)
|
||||||
|
.setNGpuLayers(0)
|
||||||
|
.setMainGpu(-1)
|
||||||
|
.setNoKvOffload(true)
|
||||||
|
.setUseMmap(true)
|
||||||
|
.setNPredict(1024);
|
||||||
|
model = new LlamaModel(params);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessLogicException("Error loading model", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public LlamaModel getModel() {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
if (model != null) model.close();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.web.session;
|
||||||
|
|
||||||
|
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ChatSessionManager {
|
||||||
|
|
||||||
|
private static final String ATTR_MESSAGES = "messages";
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<ChatMessage> getMessages(HttpSession session) {
|
||||||
|
List<ChatMessage> messages = (List<ChatMessage>) session.getAttribute(ATTR_MESSAGES);
|
||||||
|
if (messages == null) {
|
||||||
|
messages = new ArrayList<>();
|
||||||
|
session.setAttribute(ATTR_MESSAGES, messages);
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessages(HttpSession session, List<ChatMessage> messages) {
|
||||||
|
session.setAttribute(ATTR_MESSAGES, messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
0
src/main/resources/prompt.json
Normal file
0
src/main/resources/prompt.json
Normal file
148
src/main/resources/static/css/styles.css
Normal file
148
src/main/resources/static/css/styles.css
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: #121212;
|
||||||
|
color: #eee;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 600px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 2rem 1rem 1rem 1rem; /* poco padding para el top, nada abajo */
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #61dafb;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-log::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
#chat-log::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #61dafb;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 75%;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-size: 1rem;
|
||||||
|
word-wrap: break-word;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
background: linear-gradient(135deg, #6e91f6, #3a64f8);
|
||||||
|
align-self: flex-end;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(58, 100, 248, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot {
|
||||||
|
background: linear-gradient(135deg, #444, #222);
|
||||||
|
align-self: flex-start;
|
||||||
|
color: #61dafb;
|
||||||
|
font-style: italic;
|
||||||
|
box-shadow: 0 2px 8px rgba(97, 218, 251, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
flex-shrink: 0; /* que no se encoja */
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-height: 4rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
background-color: #222;
|
||||||
|
color: #eee;
|
||||||
|
box-shadow: inset 0 0 5px #000;
|
||||||
|
font-family: inherit;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
box-shadow: inset 0 0 7px #61dafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #61dafb;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #121212;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background-color: #4ea8e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#spinner {
|
||||||
|
display: none;
|
||||||
|
margin: 1rem auto;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 4px solid rgba(97, 218, 251, 0.3);
|
||||||
|
border-top-color: #61dafb;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#thinking-text {
|
||||||
|
text-align: center;
|
||||||
|
color: #61dafb;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: none;
|
||||||
|
}
|
49
src/main/resources/static/js/main.js
Normal file
49
src/main/resources/static/js/main.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const form = document.getElementById('chat-form');
|
||||||
|
const promptInput = document.getElementById('prompt');
|
||||||
|
const chatLog = document.getElementById('chat-log');
|
||||||
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
const spinner = document.getElementById('spinner');
|
||||||
|
const thinkingText = document.getElementById('thinking-text');
|
||||||
|
|
||||||
|
function appendMessage(role, text) {
|
||||||
|
const bubble = document.createElement('div');
|
||||||
|
bubble.className = `bubble ${role}`;
|
||||||
|
bubble.textContent = text;
|
||||||
|
chatLog.appendChild(bubble);
|
||||||
|
chatLog.scrollTop = chatLog.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const prompt = promptInput.value.trim();
|
||||||
|
if (!prompt) return;
|
||||||
|
|
||||||
|
appendMessage("user", prompt);
|
||||||
|
promptInput.value = "";
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
spinner.style.display = 'block';
|
||||||
|
thinkingText.style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ prompt })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
appendMessage("bot", data.text);
|
||||||
|
} catch (error) {
|
||||||
|
appendMessage("bot", "❌ Error procesando la respuesta.");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
spinner.style.display = 'none';
|
||||||
|
thinkingText.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
@ -1,28 +1,24 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns:th="http://www.thymeleaf.org" lang="es">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<title>Chat con IA Offline</title>
|
<title>Chat IA Offline</title>
|
||||||
<style>
|
<link rel="stylesheet" th:href="@{/css/styles.css}" />
|
||||||
body { font-family: sans-serif; max-width: 800px; margin: 2rem auto; }
|
|
||||||
.bubble { margin-bottom: 1rem; }
|
|
||||||
.user { color: #003366; font-weight: bold; }
|
|
||||||
.bot { color: #006600; }
|
|
||||||
textarea { width: 100%; height: 4em; margin-top: 1rem; }
|
|
||||||
button { padding: 0.5rem 1rem; margin-top: 0.5rem; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Chat con IA Offline</h1>
|
<h1>🤖 Chat IA Offline</h1>
|
||||||
|
|
||||||
<div th:each="msg : ${messages}" class="bubble">
|
<div id="chat-log">
|
||||||
<div th:if="${msg.role} == 'user'" class="user">🧑💻: <span th:text="${msg.text}"></span></div>
|
<div th:each="msg : ${messages}" th:class="'bubble ' + ${msg.role}" th:text="${msg.text}"></div>
|
||||||
<div th:if="${msg.role} == 'bot'" class="bot">🤖: <span th:text="${msg.text}"></span></div>
|
</div>
|
||||||
</div>
|
<div id="spinner"></div>
|
||||||
|
<div id="thinking-text">Pensando...</div>
|
||||||
|
|
||||||
<form method="post" th:action="@{/chat}">
|
<form id="chat-form">
|
||||||
<textarea name="prompt" placeholder="Escribe tu mensaje aquí..."></textarea><br>
|
<textarea aria-label="Pregunta del usuario" id="prompt" name="prompt" placeholder="Escribe tu mensaje..." autocomplete="off" required></textarea>
|
||||||
<button type="submit">Enviar</button>
|
<button id="send-btn" type="submit">Enviar</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script th:src="@{/js/main.js}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
Loading…
x
Reference in New Issue
Block a user