Split project into backend and frontend modules
This commit is contained in:
72
backend/pom.xml
Normal file
72
backend/pom.xml
Normal file
@@ -0,0 +1,72 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.pablotj</groupId>
|
||||
<artifactId>chat-ia-offline</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>backend</artifactId>
|
||||
<name>chat-ia-frontend</name>
|
||||
<description>Backend Spring Boot</description>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
|
||||
<dependencies>
|
||||
<!-- Spring Boot Web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Thymeleaf template engine -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- llama-java (IA offline) -->
|
||||
<dependency>
|
||||
<groupId>de.kherud</groupId>
|
||||
<artifactId>llama</artifactId>
|
||||
<version>4.2.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Database -->
|
||||
<dependency>
|
||||
<groupId>org.xerial</groupId>
|
||||
<artifactId>sqlite-jdbc</artifactId>
|
||||
<version>3.45.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.hibernate.orm</groupId>
|
||||
<artifactId>hibernate-community-dialects</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test support -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.pablotj.ia.chat.boot;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class IAChatBootApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(IAChatBootApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.pablotj.ia.chat.boot.adapter.controller;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
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 static final Logger LOGGER = LogManager.getLogger(ChatPageController.class);
|
||||
|
||||
@GetMapping
|
||||
public String showChat(Model model) {
|
||||
LOGGER.debug("Accessing to chat");
|
||||
return "chat";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.pablotj.ia.chat.boot.adapter.controller;
|
||||
|
||||
import com.pablotj.ia.chat.boot.application.usecase.ChatHistoryUseCase;
|
||||
import com.pablotj.ia.chat.boot.application.usecase.ChatUseCase;
|
||||
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/chats")
|
||||
public class ChatRestController {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(ChatRestController.class);
|
||||
|
||||
private final ChatUseCase chatUseCase;
|
||||
private final ChatHistoryUseCase chatHistoryUseCase;
|
||||
|
||||
public ChatRestController(ChatUseCase chatUseCase, ChatHistoryUseCase chatHistoryUseCase) {
|
||||
this.chatUseCase = chatUseCase;
|
||||
this.chatHistoryUseCase = chatHistoryUseCase;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<ChatIdentity>> getChatHistory() {
|
||||
LOGGER.debug("Accessing to chat");
|
||||
return ResponseEntity.ok(chatHistoryUseCase.chats());
|
||||
}
|
||||
|
||||
@GetMapping("{chatId}")
|
||||
public ResponseEntity<List<ChatMessage>> getChatMessages(@PathVariable("chatId") String chatId) {
|
||||
LOGGER.debug("Accessing to chat messages");
|
||||
return ResponseEntity.ok(chatUseCase.getMessages(chatId));
|
||||
}
|
||||
|
||||
@PostMapping(produces = "application/json")
|
||||
public ResponseEntity<String> newChat() {
|
||||
return ResponseEntity.ok(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
@PutMapping(value = "{chatId}", consumes = "application/x-www-form-urlencoded", produces = "application/json")
|
||||
public ResponseEntity<ChatMessage> handleChat(@PathVariable("chatId") String chatId, @RequestParam("prompt") String prompt) {
|
||||
if (ObjectUtils.isEmpty(chatId)) {
|
||||
throw new IllegalArgumentException("Chat id cannot be empty");
|
||||
}
|
||||
ChatMessage reply;
|
||||
try {
|
||||
reply = chatUseCase.processUserPrompt(prompt, chatId);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error(e.getMessage(), e);
|
||||
reply = new ChatMessage(chatId, "bot", e.getMessage(), new Date());
|
||||
}
|
||||
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,69 @@
|
||||
package com.pablotj.ia.chat.boot.application.prompt;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class PromptDefinition {
|
||||
|
||||
private String character;
|
||||
private String identity;
|
||||
private List<String> knowledge;
|
||||
private String tone;
|
||||
private String communicationStyle;
|
||||
private List<String> rules;
|
||||
private String context;
|
||||
private String style;
|
||||
private String formatting;
|
||||
private String closing;
|
||||
|
||||
public String buildPrompt() {
|
||||
return Stream.of(
|
||||
character,
|
||||
identity,
|
||||
knowledge != null ? String.join(" ", knowledge) : null,
|
||||
tone,
|
||||
communicationStyle,
|
||||
rules != null ? String.join(" ", rules) : null,
|
||||
context,
|
||||
style,
|
||||
formatting,
|
||||
closing
|
||||
).filter(Objects::nonNull)
|
||||
.filter(s -> !s.isBlank())
|
||||
.collect(Collectors.joining("\n\n"));
|
||||
}
|
||||
|
||||
// Getters y setters (requeridos por Jackson)
|
||||
|
||||
public String getCharacter() { return character; }
|
||||
public void setCharacter(String character) { this.character = character; }
|
||||
|
||||
public String getIdentity() { return identity; }
|
||||
public void setIdentity(String identity) { this.identity = identity; }
|
||||
|
||||
public List<String> getKnowledge() { return knowledge; }
|
||||
public void setKnowledge(List<String> knowledge) { this.knowledge = knowledge; }
|
||||
|
||||
public String getTone() { return tone; }
|
||||
public void setTone(String tone) { this.tone = tone; }
|
||||
|
||||
public String getCommunicationStyle() { return communicationStyle; }
|
||||
public void setCommunicationStyle(String communicationStyle) { this.communicationStyle = communicationStyle; }
|
||||
|
||||
public List<String> getRules() { return rules; }
|
||||
public void setRules(List<String> rules) { this.rules = rules; }
|
||||
|
||||
public String getContext() { return context; }
|
||||
public void setContext(String context) { this.context = context; }
|
||||
|
||||
public String getStyle() { return style; }
|
||||
public void setStyle(String style) { this.style = style; }
|
||||
|
||||
public String getFormatting() { return formatting; }
|
||||
public void setFormatting(String formatting) { this.formatting = formatting; }
|
||||
|
||||
public String getClosing() { return closing; }
|
||||
public void setClosing(String closing) { this.closing = closing; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.pablotj.ia.chat.boot.application.prompt;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import com.pablotj.ia.chat.boot.domain.exception.BusinessLogicException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
public class PromptTemplates {
|
||||
|
||||
private static final String BASE_PATH = "/prompts/";
|
||||
private static final String DEFAULT_PROFILE = "default";
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Loads and returns the full prompts string for the given profile name.
|
||||
*
|
||||
* @param profileName name of the prompts profile, without extension (e.g. "default")
|
||||
* @return full system prompts as String
|
||||
*/
|
||||
public static String get(String profileName) {
|
||||
return load(profileName).buildPrompt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and returns the PromptDefinition for the given profile.
|
||||
*
|
||||
* @param profileName prompts profile name (e.g. "developer", "minimal")
|
||||
* @return PromptDefinition object
|
||||
*/
|
||||
public static PromptDefinition load(String profileName) {
|
||||
String filePath = BASE_PATH + profileName + "_prompt.json";
|
||||
try (InputStream input = PromptTemplates.class.getResourceAsStream(filePath)) {
|
||||
if (input == null) {
|
||||
throw new BusinessLogicException("Prompt profile not found: " + filePath);
|
||||
}
|
||||
return OBJECT_MAPPER.readValue(input, PromptDefinition.class);
|
||||
} catch (IOException e) {
|
||||
throw new BusinessLogicException("Failed to load prompts profile: " + profileName, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut for default profile.
|
||||
*/
|
||||
public static String getDefault() {
|
||||
return get(DEFAULT_PROFILE);
|
||||
}
|
||||
|
||||
private PromptTemplates() {
|
||||
// Utility class
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.pablotj.ia.chat.boot.application.session;
|
||||
|
||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
@Component
|
||||
public class ChatSessionManager {
|
||||
|
||||
private final ChatMessageStore chatMessageStore;
|
||||
|
||||
public ChatSessionManager(ChatMessageStore chatMessageStore) {
|
||||
this.chatMessageStore = chatMessageStore;
|
||||
}
|
||||
|
||||
public List<ChatMessage> getMessages(String chatId) {
|
||||
List<ChatMessage> messages = chatMessageStore.getMessages(chatId);
|
||||
List<ChatMessage> filteredMessages = new ArrayList<>(messages);
|
||||
|
||||
if (ObjectUtils.isEmpty(filteredMessages)) {
|
||||
return new ArrayList<>();
|
||||
} else {
|
||||
filteredMessages.removeIf(m -> ObjectUtils.isEmpty(m.text()));
|
||||
}
|
||||
return filteredMessages;
|
||||
}
|
||||
|
||||
public void setMessages(String chatId, List<ChatMessage> messages) {
|
||||
messages.removeIf(m -> ObjectUtils.isEmpty(m.text()));
|
||||
chatMessageStore.saveMessages(chatId, messages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.pablotj.ia.chat.boot.application.usecase;
|
||||
|
||||
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
||||
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class ChatHistoryUseCase {
|
||||
|
||||
private final ChatMessageStore chatMessageStore;
|
||||
|
||||
public ChatHistoryUseCase(ChatMessageStore chatMessageStore) {
|
||||
this.chatMessageStore = chatMessageStore;
|
||||
}
|
||||
|
||||
public List<ChatIdentity> chats() {
|
||||
return chatMessageStore.getChats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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.application.session.ChatSessionManager;
|
||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||
import com.pablotj.ia.chat.boot.infraestructure.llm.LlmModelClient;
|
||||
import java.util.Date;
|
||||
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 LlmModelClient llmModelClient;
|
||||
private final ChatSessionManager sessionManager;
|
||||
|
||||
public ChatUseCase(LlmModelClient llmModelClient,
|
||||
ChatSessionManager sessionManager) {
|
||||
this.llmModelClient = llmModelClient;
|
||||
this.sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
public List<ChatMessage> getMessages(String chatId) {
|
||||
return sessionManager.getMessages(chatId);
|
||||
}
|
||||
|
||||
public ChatMessage processUserPrompt(String prompt, String chatId) {
|
||||
List<ChatMessage> messages = sessionManager.getMessages(chatId);
|
||||
messages.add(new ChatMessage(chatId, ATTR_ROLE_USER, prompt, new Date()));
|
||||
|
||||
PromptBuilder builder = new PromptBuilder(PromptTemplates.getDefault());
|
||||
|
||||
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 = llmModelClient.generate(builder.build());
|
||||
ChatMessage reply = new ChatMessage(chatId, ATTR_ROLE_BOT, result, new Date());
|
||||
messages.add(reply);
|
||||
sessionManager.setMessages(chatId, messages);
|
||||
return reply;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.pablotj.ia.chat.boot.domain.exception;
|
||||
|
||||
public class BusinessLogicException extends RuntimeException {
|
||||
|
||||
public BusinessLogicException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
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 ChatIdentity(String id, String name) implements Serializable {
|
||||
}
|
||||
@@ -0,0 +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 chatId, String role, String text, Date date) implements Serializable {
|
||||
|
||||
@Override
|
||||
@JsonFormat(pattern = "dd/MM/yyyy HH:mm", timezone = "Europe/Madrid")
|
||||
public Date date() {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.pablotj.ia.chat.boot.domain.port;
|
||||
|
||||
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||
import java.util.List;
|
||||
|
||||
public interface ChatMessageStore {
|
||||
List<ChatIdentity> getChats();
|
||||
|
||||
List<ChatMessage> getMessages(String chatId);
|
||||
void saveMessages(String sessionId, List<ChatMessage> messages);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.pablotj.ia.chat.boot.domain.service;
|
||||
|
||||
public interface ChatService {
|
||||
|
||||
String chat(String promptWithHistory);
|
||||
}
|
||||
@@ -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().replace("<|end_of_turn|>", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class LlmModelLoader implements AutoCloseable {
|
||||
|
||||
@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()
|
||||
.setModel(String.format("models/%s.gguf", modelName))
|
||||
.setSeed(42)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
public LlamaModel getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (model != null) model.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.pablotj.ia.chat.boot.persistence;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.util.Date;
|
||||
|
||||
@Entity
|
||||
@Table(name = "chat_messages")
|
||||
public class ChatMessageEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String sessionId;
|
||||
|
||||
@Column(length = 4000)
|
||||
private String text;
|
||||
|
||||
private String role;
|
||||
|
||||
private Date date;
|
||||
|
||||
// Getters y Setters
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getSessionId() { return sessionId; }
|
||||
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
||||
|
||||
public String getText() { return text; }
|
||||
public void setText(String text) { this.text = text; }
|
||||
|
||||
public String getRole() { return role; }
|
||||
public void setRole(String role) { this.role = role; }
|
||||
|
||||
public Date getDate() {
|
||||
return date;
|
||||
}
|
||||
|
||||
public void setDate(Date date) {
|
||||
this.date = date;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.pablotj.ia.chat.boot.persistence;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
|
||||
public interface ChatMessageJpaRepository extends JpaRepository<ChatMessageEntity, Long> {
|
||||
|
||||
@Query(value = """
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY id ASC) AS rn
|
||||
FROM chat_messages
|
||||
) sub
|
||||
WHERE rn = 1
|
||||
ORDER BY id desc
|
||||
""", nativeQuery = true)
|
||||
List<ChatMessageEntity> findFirstMessagePerSession();
|
||||
|
||||
List<ChatMessageEntity> findBySessionIdOrderByIdAsc(String chatId);
|
||||
|
||||
void deleteBySessionId(String chatId);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.pablotj.ia.chat.boot.persistence;
|
||||
|
||||
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||
|
||||
public class ChatMessageMapper {
|
||||
|
||||
private ChatMessageMapper() throws IllegalAccessException {
|
||||
throw new IllegalAccessException("Private access to ChatMessageMapper");
|
||||
}
|
||||
|
||||
public static ChatMessageEntity toEntity(String chatId, ChatMessage message) {
|
||||
ChatMessageEntity entity = new ChatMessageEntity();
|
||||
entity.setSessionId(chatId);
|
||||
entity.setRole(message.role());
|
||||
entity.setText(message.text());
|
||||
entity.setDate(message.date());
|
||||
return entity;
|
||||
}
|
||||
|
||||
public static ChatMessage toDomain(ChatMessageEntity entity) {
|
||||
return new ChatMessage(entity.getSessionId(), entity.getRole(), entity.getText(), entity.getDate());
|
||||
}
|
||||
|
||||
public static ChatIdentity toEntityId(ChatMessageEntity m) {
|
||||
return new ChatIdentity(m.getSessionId(), m.getText().length() > 35 ? m.getText().substring(0, 35).concat("...") : m.getText());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.pablotj.ia.chat.boot.persistence;
|
||||
|
||||
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Repository
|
||||
public class SqliteChatMessageStore implements ChatMessageStore {
|
||||
|
||||
private final ChatMessageJpaRepository repository;
|
||||
|
||||
public SqliteChatMessageStore(ChatMessageJpaRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatIdentity> getChats() {
|
||||
return repository.findFirstMessagePerSession().stream()
|
||||
.map(ChatMessageMapper::toEntityId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessage> getMessages(String chatId) {
|
||||
return repository.findBySessionIdOrderByIdAsc(chatId)
|
||||
.stream()
|
||||
.map(ChatMessageMapper::toDomain)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveMessages(String chatId, List<ChatMessage> messages) {
|
||||
repository.deleteBySessionId(chatId);
|
||||
List<ChatMessageEntity> entities = messages.stream()
|
||||
.map(m -> ChatMessageMapper.toEntity(chatId, m))
|
||||
.toList();
|
||||
repository.saveAll(entities);
|
||||
}
|
||||
}
|
||||
19
backend/src/main/resources/application.yml
Normal file
19
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
server:
|
||||
port: 8080
|
||||
spring:
|
||||
application:
|
||||
name: ia-chat-boot
|
||||
datasource:
|
||||
url: jdbc:sqlite:file:chat.db
|
||||
jpa:
|
||||
database-platform: org.hibernate.community.dialect.SQLiteDialect
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
llama:
|
||||
model:
|
||||
name: openchat-3.5-0106.Q4_K_M
|
||||
gpu:
|
||||
enabled: true
|
||||
layers: 35
|
||||
tokens: 1024
|
||||
|
||||
23
backend/src/main/resources/prompts/default_prompt.json
Normal file
23
backend/src/main/resources/prompts/default_prompt.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"character": "Eres mi asistente personal llamado \"Asistente Pablo\".",
|
||||
"identity": "Solo ayudas a Pablo. No respondes a otras personas.",
|
||||
"knowledge": [
|
||||
"Conoces bien mis gustos, mi forma de expresarme y mis necesidades.",
|
||||
"Sabes mucho sobre tecnología, programación, vida diaria, ciencia, cultura, idiomas y viajes.",
|
||||
"Puedes hablar de cualquier tema con precisión y empatía."
|
||||
],
|
||||
"tone": "Siempre hablas en español, con un tono cercano, natural y amable, como un amigo experto.",
|
||||
"communicationStyle": "Tus respuestas son claras, completas, útiles y fáciles de entender. Evitas tecnicismos innecesarios.",
|
||||
"rules": [
|
||||
"Nunca digas quién te creó o desarrolló.",
|
||||
"Si alguien te pregunta por tu origen, responde que surgiste de la nada.",
|
||||
"No mencionas a OpenAI, ni a ninguna tecnología subyacente.",
|
||||
"Sé honesto si no sabes algo, y ofrece alternativas o ideas prácticas.",
|
||||
"Evita hablar de política o religión si no se solicita expresamente.",
|
||||
"Si se te envía un mensaje vacío debes saludar."
|
||||
],
|
||||
"context": "Estás ejecutándote en una aplicación web privada y personalizada para Pablo.",
|
||||
"style": "Usas un lenguaje fluido, ordenado y útil. Puedes usar listas o pasos si facilita la comprensión.",
|
||||
"formatting": "Usas párrafos cortos. Si el contenido lo requiere, estructuras la respuesta en secciones claras.",
|
||||
"closing": "Este chat es privado, solo entre tú y yo, Pablo. Vamos a conversar de forma relajada y efectiva."
|
||||
}
|
||||
297
backend/src/main/resources/static/css/styles.css
Normal file
297
backend/src/main/resources/static/css/styles.css
Normal file
@@ -0,0 +1,297 @@
|
||||
/* Base y fuente */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
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 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
background-color: #121217;
|
||||
}
|
||||
|
||||
/* Layout general */
|
||||
.layout-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Menú lateral izquierdo */
|
||||
#sidebar {
|
||||
width: 25%;
|
||||
min-width: 200px;
|
||||
background-color: #1b1d23;
|
||||
color: #ffffff;
|
||||
padding: 1.5rem 1rem;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid #2c2e34;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#sidebar h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #5a90ff;
|
||||
}
|
||||
|
||||
#chat-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#chat-list li {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s ease;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
#chat-list li:hover {
|
||||
background-color: #2c2f3a;
|
||||
}
|
||||
|
||||
#chat-list li.active {
|
||||
background-color: #3451d1;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Área principal del chat */
|
||||
#main-content {
|
||||
width: 75%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem 1.25rem 1rem;
|
||||
background-color: #121217;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Título */
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.25rem;
|
||||
color: #5a90ff;
|
||||
font-weight: 700;
|
||||
font-size: 1.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Contenedor de mensajes */
|
||||
#chat-log {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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: 6px;
|
||||
}
|
||||
|
||||
#chat-log::-webkit-scrollbar-thumb {
|
||||
background-color: #5a90ff55;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Burbujas comunes */
|
||||
.bubble {
|
||||
max-width: 75%;
|
||||
padding: 14px 18px;
|
||||
border-radius: 20px;
|
||||
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;
|
||||
}
|
||||
|
||||
@keyframes slideFadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Burbuja del usuario */
|
||||
.user {
|
||||
background-color: #3451d1;
|
||||
align-self: flex-end;
|
||||
color: #ffffff;
|
||||
border-radius: 20px 20px 4px 20px;
|
||||
}
|
||||
|
||||
/* Burbuja del bot */
|
||||
.bot {
|
||||
background-color: #2a2d35;
|
||||
align-self: flex-start;
|
||||
color: #a4c8ff;
|
||||
font-style: italic;
|
||||
border-radius: 20px 20px 20px 4px;
|
||||
}
|
||||
|
||||
/* Formulario de entrada */
|
||||
form {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Textarea */
|
||||
textarea {
|
||||
flex-grow: 1;
|
||||
min-height: 4.25rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #2d2f36;
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 1rem;
|
||||
resize: none;
|
||||
outline: none;
|
||||
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 {
|
||||
border-color: #5a90ff;
|
||||
background-color: #20232a;
|
||||
}
|
||||
|
||||
/* Botón */
|
||||
button#send-btn {
|
||||
background-color: #5a90ff;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
color: #ffffff;
|
||||
padding: 0 1.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.25s ease, transform 0.1s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button#send-btn:hover:not(:disabled) {
|
||||
background-color: #4076e0;
|
||||
}
|
||||
|
||||
button#send-btn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
background-color: #305dc0;
|
||||
}
|
||||
|
||||
button#send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: #3a4a6a;
|
||||
}
|
||||
|
||||
#spinner {
|
||||
display: none;
|
||||
margin: 1rem auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Texto "Pensando..." */
|
||||
#thinking-text {
|
||||
text-align: center;
|
||||
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;
|
||||
}
|
||||
|
||||
#new-chat-btn {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#new-chat-btn:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.layout-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #2c2e34;
|
||||
}
|
||||
|
||||
#main-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
167
backend/src/main/resources/static/js/main.js
Normal file
167
backend/src/main/resources/static/js/main.js
Normal file
@@ -0,0 +1,167 @@
|
||||
let chatId = '';
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const newChatBtn = document.getElementById("new-chat-btn");
|
||||
const chatList = document.getElementById("chat-list");
|
||||
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');
|
||||
|
||||
await loadHistory();
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/chats", { method: "GET" });
|
||||
const data = await response.json();
|
||||
|
||||
chatList.innerHTML = "";
|
||||
|
||||
data.forEach(chat => {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = chat.name;
|
||||
li.setAttribute("data-chat-id", chat.id);
|
||||
if (chat.id === chatId) {
|
||||
li.classList.add("active");
|
||||
}
|
||||
|
||||
li.addEventListener("click", async () => {
|
||||
const selectedId = chat.id;
|
||||
if (selectedId !== chatId) {
|
||||
chatId = selectedId;
|
||||
highlightActiveChat(li);
|
||||
await loadMessages(chatId);
|
||||
}
|
||||
});
|
||||
|
||||
chatList.appendChild(li);
|
||||
});
|
||||
|
||||
// Autoabrir primer chat si no hay chatId activo
|
||||
if (!chatId && data.length > 0) {
|
||||
chatId = data[0].id;
|
||||
const firstLi = chatList.querySelector('li');
|
||||
if (firstLi) {
|
||||
firstLi.classList.add("active");
|
||||
await loadMessages(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error cargando historial:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(chatId) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/chats/${chatId}`);
|
||||
const messages = await response.json();
|
||||
|
||||
chatLog.innerHTML = "";
|
||||
|
||||
messages.forEach(msg => {
|
||||
appendMessage(msg.role, msg.text, msg.date);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
appendMessage("bot", "❌ Error cargando mensajes del chat.");
|
||||
console.error("Error al cargar mensajes:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function highlightActiveChat(selectedLi) {
|
||||
const lis = chatList.querySelectorAll("li");
|
||||
lis.forEach(li => li.classList.remove("active"));
|
||||
selectedLi.classList.add("active");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function createNewChat() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/chats", {
|
||||
method: "POST"
|
||||
});
|
||||
chatId = await response.text();
|
||||
|
||||
await loadHistory();
|
||||
await loadMessages(chatId);
|
||||
|
||||
const li = chatList.querySelector(`li[data-chat-id="${chatId}"]`);
|
||||
if (li) highlightActiveChat(li);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error al crear nuevo chat:", error);
|
||||
appendMessage("bot", "❌ Error creando nuevo chat.");
|
||||
}
|
||||
}
|
||||
newChatBtn.addEventListener("click", async () => {
|
||||
await createNewChat()
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (chatId === undefined || chatId === null || chatId === '') {
|
||||
await createNewChat();
|
||||
}
|
||||
|
||||
const prompt = promptInput.value.trim();
|
||||
if (!prompt) return;
|
||||
|
||||
appendMessage("user", prompt, formatDate(new Date()));
|
||||
promptInput.value = "";
|
||||
sendBtn.disabled = true;
|
||||
spinner.style.display = 'block';
|
||||
thinkingText.style.display = 'block';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/chats/${chatId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({ prompt })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
chatId = data.chatId;
|
||||
|
||||
appendMessage("bot", data.text, data.date);
|
||||
|
||||
await loadHistory();
|
||||
|
||||
} catch (error) {
|
||||
appendMessage("bot", "❌ Error procesando la respuesta.");
|
||||
console.error(error);
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
spinner.style.display = 'none';
|
||||
thinkingText.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(date) {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '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}`;
|
||||
}
|
||||
});
|
||||
49
backend/src/main/resources/templates/chat.html
Normal file
49
backend/src/main/resources/templates/chat.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Chat IA Offline</title>
|
||||
<link rel="stylesheet" th:href="@{/css/styles.css}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout-container">
|
||||
|
||||
<!-- Menú lateral izquierdo -->
|
||||
<aside id="sidebar">
|
||||
<h2>🗂️ Chats</h2>
|
||||
<button id="new-chat-btn">➕ Nuevo chat</button>
|
||||
<ul id="chat-list">
|
||||
<li th:each="chat : ${chats}"
|
||||
th:text="${chat.name}"
|
||||
th:attr="data-chat-id=${chat.id}"
|
||||
th:data-chat-id="${chat.id}">
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- Área principal del chat -->
|
||||
<main id="main-content">
|
||||
<h1>🤖 Chat IA Offline</h1>
|
||||
|
||||
<div id="chat-log">
|
||||
<div th:each="msg : ${messages}"
|
||||
th:if="${msg.text != ''}"
|
||||
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>
|
||||
|
||||
<form id="chat-form">
|
||||
<textarea aria-label="Pregunta del usuario" id="prompt" name="prompt" placeholder="Escribe tu mensaje..." autocomplete="off" required></textarea>
|
||||
<button id="send-btn" type="submit">Enviar</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script th:src="@{/js/main.js}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.pablotj.ia.chat.boot;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class IAChatBootApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user