Apply hexagonal architecture and clean code principles

This commit is contained in:
Pablo de la Torre Jamardo 2025-07-20 17:29:34 +02:00
parent 3844734794
commit bdaa8d2463
108 changed files with 3455 additions and 888 deletions

View File

@ -1,73 +0,0 @@
<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>
<!-- API Documentation -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.9</version>
</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>

View File

@ -1,13 +0,0 @@
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);
}
}

View File

@ -1,67 +0,0 @@
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<ChatIdentity> newChat() {
return ResponseEntity.ok(chatUseCase.createChat());
}
@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 uuid 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);
}
}

View File

@ -1,39 +0,0 @@
package com.pablotj.ia.chat.boot.application.prompt;
import java.util.ArrayList;
import java.util.List;
import org.springframework.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}
}

View File

@ -1,69 +0,0 @@
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; }
}

View File

@ -1,53 +0,0 @@
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 resume.
*
* @param profileName resume 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 resume (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
}
}

View File

@ -1,42 +0,0 @@
package com.pablotj.ia.chat.boot.application.session;
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.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 ChatIdentity createChat() {
return chatMessageStore.createChat();
}
public List<ChatMessage> getMessages(String chatUuid) {
List<ChatMessage> messages = chatMessageStore.getMessages(chatUuid);
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 chatUuid, List<ChatMessage> messages) {
messages.removeIf(m -> ObjectUtils.isEmpty(m.text()));
chatMessageStore.saveMessages(chatUuid, messages);
}
}

View File

@ -1,20 +0,0 @@
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();
}
}

View File

@ -1,56 +0,0 @@
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.ChatIdentity;
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 ChatIdentity createChat() {
return sessionManager.createChat();
}
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;
}
}

View File

@ -1,12 +0,0 @@
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);
}
}

View File

@ -1,6 +0,0 @@
package com.pablotj.ia.chat.boot.domain.model;
import java.io.Serializable;
public record ChatIdentity(String uuid, String resume) implements Serializable {
}

View File

@ -1,14 +0,0 @@
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 chatUuid, 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

@ -1,13 +0,0 @@
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;
import org.springframework.transaction.annotation.Transactional;
public interface ChatMessageStore {
ChatIdentity createChat();
List<ChatIdentity> getChats();
List<ChatMessage> getMessages(String chatUuid);
void saveMessages(String chatUuid, List<ChatMessage> messages);
}

View File

@ -1,6 +0,0 @@
package com.pablotj.ia.chat.boot.domain.service;
public interface ChatService {
String chat(String promptWithHistory);
}

View File

@ -1,30 +0,0 @@
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|>", "");
}
}

View File

@ -1,51 +0,0 @@
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();
}
}

View File

@ -1,74 +0,0 @@
package com.pablotj.ia.chat.boot.persistence;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.springframework.data.annotation.CreatedDate;
@Entity
@Table(name = "chat")
public class ChatEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String uuid;
private String resume;
@CreatedDate
private Date createdDate;
@OneToMany(mappedBy = "chat", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ChatMessageEntity> messages = new ArrayList<>();
// Getters y Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUuid() {
return uuid;
}
public void setUuid(String uuid) {
this.uuid = uuid;
}
public String getResume() {
return resume;
}
public void setResume(String resume) {
this.resume = resume;
}
public Date getCreatedDate() {
return createdDate;
}
public void setCreatedDate(Date createdDate) {
this.createdDate = createdDate;
}
public List<ChatMessageEntity> getMessages() {
return messages;
}
public void setMessages(List<ChatMessageEntity> messages) {
this.messages = messages;
}
}

View File

@ -1,13 +0,0 @@
package com.pablotj.ia.chat.boot.persistence;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ChatJpaRepository extends JpaRepository<ChatEntity, Long> {
Optional<ChatEntity> findOneByUuid(String chatUuid);
List<ChatEntity> findAllByOrderByCreatedDateDesc();
}

View File

@ -1,15 +0,0 @@
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 ChatMapper {
private ChatMapper() throws IllegalAccessException {
throw new IllegalAccessException("Private access to ChatMapper");
}
public static ChatIdentity toDomain(ChatEntity entity) {
return new ChatIdentity(entity.getUuid(), entity.getResume());
}
}

View File

@ -1,45 +0,0 @@
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;
@ManyToOne(fetch = FetchType.LAZY)
private ChatEntity chat;
@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 ChatEntity getChat() { return chat; }
public void setChat(ChatEntity chat) { this.chat = chat; }
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;
}
}

View File

@ -1,11 +0,0 @@
package com.pablotj.ia.chat.boot.persistence;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ChatMessageJpaRepository extends JpaRepository<ChatMessageEntity, Long> {
List<ChatMessageEntity> findByChatUuidOrderByIdAsc(String chatUuid);
void deleteByChatUuid(String chatUuid);
}

View File

@ -1,23 +0,0 @@
package com.pablotj.ia.chat.boot.persistence;
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(ChatEntity chat, ChatMessage message) {
ChatMessageEntity entity = new ChatMessageEntity();
entity.setChat(chat);
entity.setRole(message.role());
entity.setText(message.text());
entity.setDate(message.date());
return entity;
}
public static ChatMessage toDomain(ChatMessageEntity entity) {
return new ChatMessage(entity.getChat().getUuid(), entity.getRole(), entity.getText(), entity.getDate());
}
}

View File

@ -1,78 +0,0 @@
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.Date;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public class SqliteChatMessageStore implements ChatMessageStore {
private final ChatJpaRepository chatJpaRepository;
private final ChatMessageJpaRepository chatMessageJpaRepository;
public SqliteChatMessageStore(ChatJpaRepository chatJpaRepository, ChatMessageJpaRepository chatMessageJpaRepository) {
this.chatJpaRepository = chatJpaRepository;
this.chatMessageJpaRepository = chatMessageJpaRepository;
}
@Override
@Transactional
public ChatIdentity createChat() {
ChatEntity chat = new ChatEntity();
chat.setUuid(UUID.randomUUID().toString());
chat.setResume("Nuevo chat");
chat.setCreatedDate(new Date());
chat = chatJpaRepository.save(chat);
return ChatMapper.toDomain(chat);
}
@Override
public List<ChatIdentity> getChats() {
return chatJpaRepository.findAllByOrderByCreatedDateDesc().stream()
.map(ChatMapper::toDomain)
.toList();
}
@Override
public List<ChatMessage> getMessages(String chatUuid) {
return chatMessageJpaRepository.findByChatUuidOrderByIdAsc(chatUuid)
.stream()
.map(ChatMessageMapper::toDomain)
.toList();
}
@Override
@Transactional
public void saveMessages(String chatUuid, List<ChatMessage> messages) {
if (messages == null || messages.isEmpty()) {
return;
}
ChatEntity chat = chatJpaRepository.findOneByUuid(chatUuid).orElse(null);
if (chat == null) {
chat = new ChatEntity();
chat.setUuid(chatUuid != null ? chatUuid : UUID.randomUUID().toString());
chat.setResume(createResume(messages.getFirst()));
chat.setCreatedDate(new Date());
chat = chatJpaRepository.save(chat);
} else if (chat.getMessages().isEmpty()) {
chat.setResume(createResume(messages.getFirst()));
chat = chatJpaRepository.save(chat);
}
ChatEntity finalChat = chat;
messages.forEach(msg -> finalChat.getMessages().add(ChatMessageMapper.toEntity(finalChat, msg)));
chatJpaRepository.save(finalChat);
}
private String createResume(ChatMessage chatMessage) {
return chatMessage.text().length() > 35 ? chatMessage.text().substring(0, 35).concat("...") : chatMessage.text();
}
}

View File

@ -1,21 +0,0 @@
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
springdoc:
api-docs.path: /api-docs
swagger-ui.path: /swagger-ui.html
llama:
model:
name: openchat-3.5-0106.Q4_K_M
gpu:
enabled: true
layers: 35
tokens: 1024

144
chat-api/pom.xml Normal file
View File

@ -0,0 +1,144 @@
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
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>ai-chat-offline</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>chat-api</artifactId>
<name>AI Chat Platform - Backend</name>
<description>Enterprise-grade AI Chat Platform Backend</description>
<packaging>jar</packaging>
<properties>
<java.version>21</java.version>
<spring-boot.version>3.2.0</spring-boot.version>
<springdoc.version>2.8.9</springdoc.version>
<llama-java.version>4.2.0</llama-java.version>
<sqlite.version>3.45.1.0</sqlite.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
<micrometer.version>1.12.0</micrometer.version>
</properties>
<dependencies>
<!-- Spring Boot Core -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-actuator</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Actuator autoconfiguration (needed for HealthIndicator, MeterRegistryCustomizer, etc.) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-actuator-autoconfigure</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Data Persistence -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>${sqlite.version}</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-community-dialects</artifactId>
</dependency>
<!-- AI/ML Integration -->
<dependency>
<groupId>de.kherud</groupId>
<artifactId>llama</artifactId>
<version>${llama-java.version}</version>
</dependency>
<!-- API Documentation -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Mapping -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
<scope>provided</scope>
</dependency>
<!-- Monitoring & Metrics -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
<version>${micrometer.version}</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,27 @@
package com.pablotj.ai.chat;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.transaction.annotation.EnableTransactionManagement;
/**
* AI Chat Platform - Enterprise Application Entry Point
* <p>
* A sophisticated AI-powered chat platform built with hexagonal architecture,
* featuring offline AI capabilities, robust conversation management,
* and enterprise-grade scalability.
*
* @author Pablo TJ
* @version 1.0.0
* @since 2024
*/
@SpringBootApplication
@EnableAsync
@EnableTransactionManagement
public class AiChatPlatformApplication {
public static void main(String[] args) {
SpringApplication.run(AiChatPlatformApplication.class, args);
}
}

View File

@ -0,0 +1,57 @@
package com.pablotj.ai.chat.application.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
import java.util.List;
/**
* Data Transfer Object for complete conversation data.
*/
@Schema(description = "Complete conversation with messages")
public record ConversationDto(
@Schema(description = "Unique conversation identifier", example = "123e4567-e89b-12d3-a456-426614174000")
@NotBlank
String conversationId,
@Schema(description = "Conversation title", example = "Discussion about AI")
@NotBlank
String title,
@Schema(description = "Optional conversation description", example = "A detailed conversation about artificial intelligence")
String description,
@Schema(description = "Conversation status", example = "ACTIVE")
@NotBlank
String status,
@Schema(description = "Conversation creation timestamp")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
@NotNull
Instant createdAt,
@Schema(description = "List of messages in the conversation")
@NotNull
List<ConversationMessageDto> messages,
@Schema(description = "Total number of messages", example = "5")
int messageCount
) {
public ConversationDto {
if (messages == null) {
messages = List.of();
}
}
public boolean isEmpty() {
return messages.isEmpty();
}
public boolean hasDescription() {
return description != null && !description.trim().isEmpty();
}
}

View File

@ -0,0 +1,58 @@
package com.pablotj.ai.chat.application.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
import java.util.Map;
/**
* Data Transfer Object for conversation messages.
*/
@Schema(description = "A message within a conversation")
public record ConversationMessageDto(
@Schema(description = "Unique message identifier", example = "msg-123e4567-e89b-12d3-a456-426614174000")
@NotBlank
String messageId,
@Schema(description = "Conversation identifier this message belongs to", example = "123e4567-e89b-12d3-a456-426614174000")
@NotBlank
String conversationId,
@Schema(description = "Message role", example = "USER", allowableValues = {"USER", "ASSISTANT", "SYSTEM"})
@NotBlank
String role,
@Schema(description = "Message content text", example = "Hello, how can you help me today?")
@NotBlank
String content,
@Schema(description = "Message creation timestamp")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd/MM/yyyy HH:mm:ss", timezone = "Europe/Madrid")
@NotNull
Instant createdAt,
@Schema(description = "Additional message metadata")
Map<String, Object> metadata
) {
public ConversationMessageDto {
if (metadata == null) {
metadata = Map.of();
}
}
public boolean isFromUser() {
return "USER".equals(role);
}
public boolean isFromAssistant() {
return "ASSISTANT".equals(role);
}
public boolean hasMetadata() {
return !metadata.isEmpty();
}
}

View File

@ -0,0 +1,42 @@
package com.pablotj.ai.chat.application.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
/**
* Data Transfer Object for conversation summary information.
*/
@Schema(description = "Conversation summary for listing purposes")
public record ConversationSummaryDto(
@Schema(description = "Unique conversation identifier", example = "123e4567-e89b-12d3-a456-426614174000")
@NotBlank
String conversationId,
@Schema(description = "Conversation title", example = "Discussion about AI")
@NotBlank
String title,
@Schema(description = "Optional conversation description", example = "A detailed conversation about artificial intelligence")
String description,
@Schema(description = "Conversation status", example = "ACTIVE")
@NotBlank
String status,
@Schema(description = "Conversation creation timestamp")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
@NotNull
Instant createdAt,
@Schema(description = "Total number of messages in the conversation", example = "5")
int messageCount
) {
public boolean hasDescription() {
return description != null && !description.trim().isEmpty();
}
}

View File

@ -0,0 +1,49 @@
package com.pablotj.ai.chat.application.mapper;
import com.pablotj.ai.chat.application.dto.ConversationDto;
import com.pablotj.ai.chat.application.dto.ConversationMessageDto;
import com.pablotj.ai.chat.application.dto.ConversationSummaryDto;
import com.pablotj.ai.chat.domain.model.Conversation;
import com.pablotj.ai.chat.domain.model.ConversationMessage;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
/**
* MapStruct mapper for converting between domain models and DTOs.
*/
@Mapper(componentModel = "spring")
public interface ConversationMapper {
@Mapping(source = "conversationId.uuid", target = "conversationId")
@Mapping(source = "summary.title", target = "title")
@Mapping(source = "summary.description", target = "description")
@Mapping(source = "status", target = "status", qualifiedByName = "statusToString")
@Mapping(source = "messages", target = "messages")
@Mapping(expression = "java(conversation.getMessageCount())", target = "messageCount")
ConversationDto toDto(Conversation conversation);
@Mapping(source = "conversationId.uuid", target = "conversationId")
@Mapping(source = "summary.title", target = "title")
@Mapping(source = "summary.description", target = "description")
@Mapping(source = "status", target = "status", qualifiedByName = "statusToString")
@Mapping(expression = "java(conversation.getMessageCount())", target = "messageCount")
ConversationSummaryDto toSummaryDto(Conversation conversation);
@Mapping(source = "messageId.uuid", target = "messageId")
@Mapping(source = "conversationId.uuid", target = "conversationId")
@Mapping(source = "role", target = "role", qualifiedByName = "roleToString")
@Mapping(source = "content.text", target = "content")
@Mapping(source = "metadata.allProperties", target = "metadata")
ConversationMessageDto toMessageDto(ConversationMessage message);
@Named("statusToString")
default String statusToString(com.pablotj.ai.chat.domain.model.ConversationStatus status) {
return status != null ? status.name() : null;
}
@Named("roleToString")
default String roleToString(com.pablotj.ai.chat.domain.model.MessageRole role) {
return role != null ? role.name() : null;
}
}

View File

@ -0,0 +1,58 @@
package com.pablotj.ai.chat.application.usecase;
import com.pablotj.ai.chat.application.dto.ConversationDto;
import com.pablotj.ai.chat.application.mapper.ConversationMapper;
import com.pablotj.ai.chat.domain.model.Conversation;
import com.pablotj.ai.chat.domain.model.ConversationSummary;
import com.pablotj.ai.chat.domain.repository.ConversationRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Use case for creating new conversations.
* Handles the business logic for conversation creation with proper validation and persistence.
*/
@Service
@Transactional
public class CreateConversationUseCase {
private static final Logger logger = LoggerFactory.getLogger(CreateConversationUseCase.class);
private final ConversationRepository conversationRepository;
private final ConversationMapper conversationMapper;
public CreateConversationUseCase(
ConversationRepository conversationRepository,
ConversationMapper conversationMapper) {
this.conversationRepository = conversationRepository;
this.conversationMapper = conversationMapper;
}
/**
* Creates a new conversation with default settings.
*
* @return the created conversation DTO
*/
public ConversationDto execute() {
return execute(ConversationSummary.defaultSummary());
}
/**
* Creates a new conversation with the specified summary.
*
* @param summary the conversation summary
* @return the created conversation DTO
*/
public ConversationDto execute(ConversationSummary summary) {
logger.debug("Creating new conversation with summary: {}", summary);
Conversation conversation = Conversation.createNew(summary);
Conversation savedConversation = conversationRepository.save(conversation);
logger.info("Successfully created conversation with ID: {}", savedConversation.getConversationId());
return conversationMapper.toDto(savedConversation);
}
}

View File

@ -0,0 +1,50 @@
package com.pablotj.ai.chat.application.usecase;
import com.pablotj.ai.chat.application.dto.ConversationSummaryDto;
import com.pablotj.ai.chat.application.mapper.ConversationMapper;
import com.pablotj.ai.chat.domain.repository.ConversationRepository;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Use case for retrieving conversation history.
* Provides read-only access to conversation summaries for listing purposes.
*/
@Service
@Transactional(readOnly = true)
public class GetConversationHistoryUseCase {
private static final Logger logger = LoggerFactory.getLogger(GetConversationHistoryUseCase.class);
private final ConversationRepository conversationRepository;
private final ConversationMapper conversationMapper;
public GetConversationHistoryUseCase(
ConversationRepository conversationRepository,
ConversationMapper conversationMapper) {
this.conversationRepository = conversationRepository;
this.conversationMapper = conversationMapper;
}
/**
* Retrieves all active conversations ordered by creation date.
*
* @return list of conversation summary DTOs
*/
public List<ConversationSummaryDto> execute() {
logger.debug("Retrieving conversation history");
List<ConversationSummaryDto> conversations = conversationRepository
.findAllActiveOrderedByCreationDate()
.stream()
.map(conversationMapper::toSummaryDto)
.toList();
logger.debug("Retrieved {} conversations", conversations.size());
return conversations;
}
}

View File

@ -0,0 +1,59 @@
package com.pablotj.ai.chat.application.usecase;
import com.pablotj.ai.chat.application.dto.ConversationMessageDto;
import com.pablotj.ai.chat.application.mapper.ConversationMapper;
import com.pablotj.ai.chat.domain.exception.ConversationNotFoundException;
import com.pablotj.ai.chat.domain.model.Conversation;
import com.pablotj.ai.chat.domain.model.ConversationId;
import com.pablotj.ai.chat.domain.repository.ConversationRepository;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Use case for retrieving messages from a specific conversation.
*/
@Service
@Transactional(readOnly = true)
public class GetConversationMessagesUseCase {
private static final Logger logger = LoggerFactory.getLogger(GetConversationMessagesUseCase.class);
private final ConversationRepository conversationRepository;
private final ConversationMapper conversationMapper;
public GetConversationMessagesUseCase(
ConversationRepository conversationRepository,
ConversationMapper conversationMapper) {
this.conversationRepository = conversationRepository;
this.conversationMapper = conversationMapper;
}
/**
* Retrieves all messages from the specified conversation.
*
* @param conversationIdValue the conversation ID as string
* @return list of conversation message DTOs
* @throws ConversationNotFoundException if the conversation doesn't exist
*/
public List<ConversationMessageDto> execute(String conversationIdValue) {
ConversationId conversationId = ConversationId.of(conversationIdValue);
logger.debug("Retrieving messages for conversation: {}", conversationId);
Conversation conversation = conversationRepository
.findById(conversationId)
.orElseThrow(() -> new ConversationNotFoundException(conversationId));
List<ConversationMessageDto> messages = conversation.getMessages()
.stream()
.map(conversationMapper::toMessageDto)
.toList();
logger.debug("Retrieved {} messages for conversation: {}", messages.size(), conversationId);
return messages;
}
}

View File

@ -0,0 +1,93 @@
package com.pablotj.ai.chat.application.usecase;
import com.pablotj.ai.chat.application.dto.ConversationMessageDto;
import com.pablotj.ai.chat.application.mapper.ConversationMapper;
import com.pablotj.ai.chat.domain.exception.AiServiceUnavailableException;
import com.pablotj.ai.chat.domain.exception.ConversationNotFoundException;
import com.pablotj.ai.chat.domain.model.Conversation;
import com.pablotj.ai.chat.domain.model.ConversationId;
import com.pablotj.ai.chat.domain.model.ConversationMessage;
import com.pablotj.ai.chat.domain.model.ConversationSummary;
import com.pablotj.ai.chat.domain.model.MessageContent;
import com.pablotj.ai.chat.domain.repository.ConversationRepository;
import com.pablotj.ai.chat.domain.service.AiConversationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Use case for processing user messages and generating AI responses.
* Orchestrates the complete conversation flow including AI response generation.
*/
@Service
@Transactional
public class ProcessUserMessageUseCase {
private static final Logger logger = LoggerFactory.getLogger(ProcessUserMessageUseCase.class);
private final ConversationRepository conversationRepository;
private final AiConversationService aiConversationService;
private final ConversationMapper conversationMapper;
public ProcessUserMessageUseCase(
ConversationRepository conversationRepository,
AiConversationService aiConversationService,
ConversationMapper conversationMapper) {
this.conversationRepository = conversationRepository;
this.aiConversationService = aiConversationService;
this.conversationMapper = conversationMapper;
}
/**
* Processes a user message and generates an AI response.
*
* @param conversationIdValue the conversation ID as string
* @param userMessageText the user's message text
* @return the AI response message DTO
* @throws ConversationNotFoundException if the conversation doesn't exist
* @throws AiServiceUnavailableException if the AI service is unavailable
*/
public ConversationMessageDto execute(String conversationIdValue, String userMessageText) {
ConversationId conversationId = ConversationId.of(conversationIdValue);
MessageContent userMessageContent = MessageContent.of(userMessageText);
logger.debug("Processing user message for conversation: {}", conversationId);
// Retrieve conversation
Conversation conversation = conversationRepository
.findById(conversationId)
.orElseThrow(() -> new ConversationNotFoundException(conversationId));
// Create user message
ConversationMessage userMessage = ConversationMessage.createUserMessage(
conversation.getConversationId(), userMessageContent);
// Add user message to conversation
conversation = conversation.addMessage(userMessage);
// Update conversation summary if it's the first message
if (conversation.getMessageCount() == 1) {
ConversationSummary newSummary = ConversationSummary.fromFirstMessage(userMessageContent);
conversation = conversation.updateSummary(newSummary);
}
// Generate AI response
MessageContent aiResponseContent = aiConversationService.generateResponse(
conversation.getMessages(), userMessage);
ConversationMessage aiMessage = ConversationMessage.createAssistantMessage(
conversation.getConversationId(), aiResponseContent);
// Add AI message to conversation
conversation = conversation.addMessage(aiMessage);
// Save updated conversation
conversationRepository.save(conversation);
logger.info("Successfully processed message and generated AI response for conversation: {}",
conversation.getConversationId());
return conversationMapper.toMessageDto(aiMessage);
}
}

View File

@ -0,0 +1,15 @@
package com.pablotj.ai.chat.domain.exception;
/**
* Exception thrown when the AI service is unavailable or encounters an error.
*/
public class AiServiceUnavailableException extends DomainException {
public AiServiceUnavailableException(String message) {
super(message);
}
public AiServiceUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,20 @@
package com.pablotj.ai.chat.domain.exception;
import com.pablotj.ai.chat.domain.model.ConversationId;
/**
* Exception thrown when a requested conversation cannot be found.
*/
public class ConversationNotFoundException extends DomainException {
private final ConversationId conversationId;
public ConversationNotFoundException(ConversationId conversationId) {
super(String.format("Conversation not found with ID: %s", conversationId.getUuid()));
this.conversationId = conversationId;
}
public ConversationId getConversationId() {
return conversationId;
}
}

View File

@ -0,0 +1,15 @@
package com.pablotj.ai.chat.domain.exception;
/**
* Base exception class for domain-specific exceptions.
*/
public abstract class DomainException extends RuntimeException {
protected DomainException(String message) {
super(message);
}
protected DomainException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,197 @@
package com.pablotj.ai.chat.domain.model;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* Aggregate root representing a conversation with its messages.
* Encapsulates conversation business logic and invariants.
*/
public final class Conversation {
private static final int MAX_MESSAGES_PER_CONVERSATION = 1000;
private final ConversationId conversationId;
private final ConversationSummary summary;
private final List<ConversationMessage> messages;
private final Instant createdAt;
private final ConversationStatus status;
private Conversation(Builder builder) {
this.conversationId = Objects.requireNonNull(builder.conversationId, "Conversation ID is required");
this.summary = Objects.requireNonNull(builder.summary, "Conversation summary is required");
this.messages = new ArrayList<>(builder.messages);
this.createdAt = Objects.requireNonNull(builder.createdAt, "Created timestamp is required");
this.status = Objects.requireNonNull(builder.status, "Conversation status is required");
validateInvariants();
}
public static Builder builder() {
return new Builder();
}
public static Conversation createNew(ConversationSummary summary) {
return builder()
.conversationId(ConversationId.generate())
.summary(summary)
.messages(Collections.emptyList())
.createdAt(Instant.now())
.status(ConversationStatus.ACTIVE)
.build();
}
private void validateInvariants() {
if (messages.size() > MAX_MESSAGES_PER_CONVERSATION) {
throw new IllegalStateException(
String.format("Conversation cannot have more than %d messages", MAX_MESSAGES_PER_CONVERSATION)
);
}
}
// Getters
public ConversationId getConversationId() {
return conversationId;
}
public ConversationSummary getSummary() {
return summary;
}
public List<ConversationMessage> getMessages() {
return Collections.unmodifiableList(messages);
}
public Instant getCreatedAt() {
return createdAt;
}
public ConversationStatus getStatus() {
return status;
}
// Domain behavior
public Conversation addMessage(ConversationMessage message) {
if (!message.getConversationId().equals(this.conversationId)) {
throw new IllegalArgumentException("Message does not belong to this conversation");
}
List<ConversationMessage> newMessages = new ArrayList<>(this.messages);
newMessages.add(message);
return builder()
.conversationId(this.conversationId)
.summary(this.summary)
.messages(newMessages)
.createdAt(this.createdAt)
.status(this.status)
.build();
}
public Conversation updateSummary(ConversationSummary newSummary) {
return builder()
.conversationId(this.conversationId)
.summary(newSummary)
.messages(this.messages)
.createdAt(this.createdAt)
.status(this.status)
.build();
}
public Conversation changeStatus(ConversationStatus newStatus) {
return builder()
.conversationId(this.conversationId)
.summary(this.summary)
.messages(this.messages)
.createdAt(this.createdAt)
.status(newStatus)
.build();
}
public Optional<ConversationMessage> getLastMessage() {
return messages.isEmpty() ?
Optional.empty() :
Optional.of(messages.get(messages.size() - 1));
}
public List<ConversationMessage> getMessagesByRole(MessageRole role) {
return messages.stream()
.filter(message -> message.getRole() == role)
.toList();
}
public int getMessageCount() {
return messages.size();
}
public boolean isEmpty() {
return messages.isEmpty();
}
public boolean isActive() {
return status == ConversationStatus.ACTIVE;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Conversation that = (Conversation) obj;
return Objects.equals(conversationId, that.conversationId);
}
@Override
public int hashCode() {
return Objects.hash(conversationId);
}
@Override
public String toString() {
return String.format("Conversation{id=%s, summary='%s', messages=%d, status=%s}",
conversationId, summary, messages.size(), status);
}
public static final class Builder {
private ConversationId conversationId;
private ConversationSummary summary;
private List<ConversationMessage> messages = new ArrayList<>();
private Instant createdAt;
private ConversationStatus status;
private Builder() {
}
public Builder conversationId(ConversationId conversationId) {
this.conversationId = conversationId;
return this;
}
public Builder summary(ConversationSummary summary) {
this.summary = summary;
return this;
}
public Builder messages(List<ConversationMessage> messages) {
this.messages = messages != null ? new ArrayList<>(messages) : new ArrayList<>();
return this;
}
public Builder createdAt(Instant createdAt) {
this.createdAt = createdAt;
return this;
}
public Builder status(ConversationStatus status) {
this.status = status;
return this;
}
public Conversation build() {
return new Conversation(this);
}
}
}

View File

@ -0,0 +1,69 @@
package com.pablotj.ai.chat.domain.model;
import java.util.Objects;
import java.util.UUID;
/**
* Value Object representing a unique conversation identifier.
* Ensures type safety and encapsulates conversation ID logic.
*/
public final class ConversationId {
private final String uuid;
private Long id;
public ConversationId() {
this(UUID.randomUUID().toString());
}
public ConversationId(Long id, String uuid) {
this.id = Objects.requireNonNull(id, "Conversation ID cannot be null");
this.uuid = Objects.requireNonNull(uuid, "Conversation UUID cannot be null");
}
private ConversationId(String uuid) {
this.id = null;
this.uuid = Objects.requireNonNull(uuid, "Conversation UUID cannot be null");
}
public static ConversationId generate() {
return new ConversationId(UUID.randomUUID().toString());
}
public static ConversationId of(String value) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("Conversation UUID cannot be null or empty");
}
return new ConversationId(value.trim());
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUuid() {
return uuid;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
ConversationId that = (ConversationId) obj;
return Objects.equals(uuid, that.uuid);
}
@Override
public int hashCode() {
return Objects.hash(uuid);
}
@Override
public String toString() {
return uuid;
}
}

View File

@ -0,0 +1,165 @@
package com.pablotj.ai.chat.domain.model;
import java.time.Instant;
import java.util.Objects;
/**
* Domain entity representing a message within a conversation.
* Encapsulates message data with rich domain behavior.
*/
public final class ConversationMessage {
private final MessageId messageId;
private final ConversationId conversationId;
private final MessageRole role;
private final MessageContent content;
private final Instant createdAt;
private final MessageMetadata metadata;
private ConversationMessage(Builder builder) {
this.messageId = Objects.requireNonNull(builder.messageId, "Message ID is required");
this.conversationId = Objects.requireNonNull(builder.conversationId, "Conversation ID is required");
this.role = Objects.requireNonNull(builder.role, "Message role is required");
this.content = Objects.requireNonNull(builder.content, "Message content is required");
this.createdAt = Objects.requireNonNull(builder.createdAt, "Created timestamp is required");
this.metadata = builder.metadata != null ? builder.metadata : MessageMetadata.empty();
}
public static Builder builder() {
return new Builder();
}
public static ConversationMessage createUserMessage(
ConversationId conversationId,
MessageContent content) {
return builder()
.messageId(MessageId.generate())
.conversationId(conversationId)
.role(MessageRole.USER)
.content(content)
.createdAt(Instant.now())
.build();
}
public static ConversationMessage createAssistantMessage(
ConversationId conversationId,
MessageContent content) {
return builder()
.messageId(MessageId.generate())
.conversationId(conversationId)
.role(MessageRole.ASSISTANT)
.content(content)
.createdAt(Instant.now())
.build();
}
// Getters
public MessageId getMessageId() {
return messageId;
}
public ConversationId getConversationId() {
return conversationId;
}
public MessageRole getRole() {
return role;
}
public MessageContent getContent() {
return content;
}
public Instant getCreatedAt() {
return createdAt;
}
public MessageMetadata getMetadata() {
return metadata;
}
// Domain behavior
public boolean isFromUser() {
return role.isUser();
}
public boolean isFromAssistant() {
return role.isAssistant();
}
public ConversationMessage withMetadata(MessageMetadata metadata) {
return builder()
.messageId(this.messageId)
.conversationId(this.conversationId)
.role(this.role)
.content(this.content)
.createdAt(this.createdAt)
.metadata(metadata)
.build();
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
ConversationMessage that = (ConversationMessage) obj;
return Objects.equals(messageId, that.messageId);
}
@Override
public int hashCode() {
return Objects.hash(messageId);
}
@Override
public String toString() {
return String.format("ConversationMessage{id=%s, role=%s, content='%s'}",
messageId, role, content.truncate(50));
}
public static final class Builder {
private MessageId messageId;
private ConversationId conversationId;
private MessageRole role;
private MessageContent content;
private Instant createdAt;
private MessageMetadata metadata;
private Builder() {
}
public Builder messageId(MessageId messageId) {
this.messageId = messageId;
return this;
}
public Builder conversationId(ConversationId conversationId) {
this.conversationId = conversationId;
return this;
}
public Builder role(MessageRole role) {
this.role = role;
return this;
}
public Builder content(MessageContent content) {
this.content = content;
return this;
}
public Builder createdAt(Instant createdAt) {
this.createdAt = createdAt;
return this;
}
public Builder metadata(MessageMetadata metadata) {
this.metadata = metadata;
return this;
}
public ConversationMessage build() {
return new ConversationMessage(this);
}
}
}

View File

@ -0,0 +1,53 @@
package com.pablotj.ai.chat.domain.model;
/**
* Enumeration representing the status of a conversation.
*/
public enum ConversationStatus {
ACTIVE("active", "Conversation is active and accepting messages"),
ARCHIVED("archived", "Conversation has been archived"),
DELETED("deleted", "Conversation has been marked for deletion"),
SUSPENDED("suspended", "Conversation has been temporarily suspended");
private final String code;
private final String description;
ConversationStatus(String code, String description) {
this.code = code;
this.description = description;
}
public static ConversationStatus fromCode(String code) {
for (ConversationStatus status : values()) {
if (status.code.equals(code)) {
return status;
}
}
throw new IllegalArgumentException("Unknown conversation status code: " + code);
}
public String getCode() {
return code;
}
public String getDescription() {
return description;
}
public boolean isActive() {
return this == ACTIVE;
}
public boolean isArchived() {
return this == ARCHIVED;
}
public boolean isDeleted() {
return this == DELETED;
}
public boolean canReceiveMessages() {
return this == ACTIVE;
}
}

View File

@ -0,0 +1,140 @@
package com.pablotj.ai.chat.domain.model;
import java.util.Objects;
/**
* Value Object representing a conversation summary.
*/
public final class ConversationSummary {
private static final int MAX_TITLE_LENGTH = 100;
private static final int MAX_DESCRIPTION_LENGTH = 500;
private static final String DEFAULT_TITLE = "New Conversation";
private final String title;
private final String description;
public ConversationSummary(String title) {
this.title = title;
this.description = "";
}
private ConversationSummary(String title, String description) {
this.title = validateAndNormalizeTitle(title);
this.description = validateAndNormalizeDescription(description);
}
public static ConversationSummary of(String title, String description) {
return new ConversationSummary(title, description);
}
public static ConversationSummary defaultSummary() {
return new ConversationSummary(DEFAULT_TITLE, null);
}
public static ConversationSummary fromFirstMessage(MessageContent firstMessage) {
String title = generateTitleFromContent(firstMessage.getText());
return new ConversationSummary(title, null);
}
private static String generateTitleFromContent(String content) {
if (content == null || content.trim().isEmpty()) {
return DEFAULT_TITLE;
}
String normalized = content.trim();
if (normalized.length() <= 50) {
return normalized;
}
// Find a good breaking point (space, punctuation)
int breakPoint = findBreakPoint(normalized, 50);
return normalized.substring(0, breakPoint) + "...";
}
private static int findBreakPoint(String text, int maxLength) {
if (text.length() <= maxLength) {
return text.length();
}
// Look for space or punctuation near the max length
for (int i = maxLength; i > maxLength - 20 && i > 0; i--) {
char c = text.charAt(i);
if (Character.isWhitespace(c) || c == '.' || c == ',' || c == ';' || c == '!') {
return i;
}
}
return maxLength;
}
private String validateAndNormalizeTitle(String title) {
if (title == null || title.trim().isEmpty()) {
return DEFAULT_TITLE;
}
String normalized = title.trim();
if (normalized.length() > MAX_TITLE_LENGTH) {
normalized = normalized.substring(0, MAX_TITLE_LENGTH - 3) + "...";
}
return normalized;
}
private String validateAndNormalizeDescription(String description) {
if (description == null) {
return null;
}
String normalized = description.trim();
if (normalized.isEmpty()) {
return null;
}
if (normalized.length() > MAX_DESCRIPTION_LENGTH) {
normalized = normalized.substring(0, MAX_DESCRIPTION_LENGTH - 3) + "...";
}
return normalized;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
public boolean hasDescription() {
return description != null && !description.isEmpty();
}
public ConversationSummary withDescription(String newDescription) {
return new ConversationSummary(this.title, newDescription);
}
public ConversationSummary withTitle(String newTitle) {
return new ConversationSummary(newTitle, this.description);
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
ConversationSummary that = (ConversationSummary) obj;
return Objects.equals(title, that.title) && Objects.equals(description, that.description);
}
@Override
public int hashCode() {
return Objects.hash(title, description);
}
@Override
public String toString() {
return hasDescription() ?
String.format("%s - %s", title, description) :
title;
}
}

View File

@ -0,0 +1,79 @@
package com.pablotj.ai.chat.domain.model;
import java.util.Objects;
/**
* Value Object representing message content with validation and formatting.
*/
public final class MessageContent {
private static final int MAX_LENGTH = 10000;
private static final int MIN_LENGTH = 1;
private final String text;
private MessageContent(String text) {
this.text = validateAndNormalize(text);
}
public static MessageContent of(String text) {
return new MessageContent(text);
}
private String validateAndNormalize(String text) {
if (text == null) {
throw new IllegalArgumentException("Message content cannot be null");
}
String normalized = text.trim();
if (normalized.length() < MIN_LENGTH) {
throw new IllegalArgumentException("Message content cannot be empty");
}
if (normalized.length() > MAX_LENGTH) {
throw new IllegalArgumentException(
String.format("Message content cannot exceed %d characters", MAX_LENGTH)
);
}
return normalized;
}
public String getText() {
return text;
}
public int getLength() {
return text.length();
}
public boolean isEmpty() {
return text.isEmpty();
}
public MessageContent truncate(int maxLength) {
if (text.length() <= maxLength) {
return this;
}
return new MessageContent(text.substring(0, maxLength) + "...");
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
MessageContent that = (MessageContent) obj;
return Objects.equals(text, that.text);
}
@Override
public int hashCode() {
return Objects.hash(text);
}
@Override
public String toString() {
return text;
}
}

View File

@ -0,0 +1,63 @@
package com.pablotj.ai.chat.domain.model;
import java.util.Objects;
import java.util.UUID;
/**
* Value Object representing a unique message identifier.
*/
public final class MessageId {
private final String uuid;
private Long id;
public MessageId(Long id, String uuid) {
this.id = Objects.requireNonNull(id, "Message ID cannot be null");
this.uuid = Objects.requireNonNull(uuid, "Message UUID cannot be null");
}
private MessageId(String uuid) {
this.uuid = Objects.requireNonNull(uuid, "Message UUID cannot be null");
}
public static MessageId generate() {
return new MessageId(UUID.randomUUID().toString());
}
public static MessageId of(String value) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("Message UUID cannot be null or empty");
}
return new MessageId(value.trim());
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUuid() {
return uuid;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
MessageId messageId = (MessageId) obj;
return Objects.equals(uuid, messageId.uuid);
}
@Override
public int hashCode() {
return Objects.hash(uuid);
}
@Override
public String toString() {
return uuid;
}
}

View File

@ -0,0 +1,94 @@
package com.pablotj.ai.chat.domain.model;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Value Object containing metadata for messages.
*/
public final class MessageMetadata {
private final Map<String, Object> properties;
private MessageMetadata(Map<String, Object> properties) {
this.properties = Collections.unmodifiableMap(new HashMap<>(properties));
}
public static MessageMetadata empty() {
return new MessageMetadata(Collections.emptyMap());
}
public static MessageMetadata of(Map<String, Object> properties) {
return new MessageMetadata(properties != null ? properties : Collections.emptyMap());
}
public static Builder builder() {
return new Builder();
}
public Optional<Object> getProperty(String key) {
return Optional.ofNullable(properties.get(key));
}
@SuppressWarnings("unchecked")
public <T> Optional<T> getProperty(String key, Class<T> type) {
return getProperty(key)
.filter(type::isInstance)
.map(value -> (T) value);
}
public Map<String, Object> getAllProperties() {
return properties;
}
public boolean isEmpty() {
return properties.isEmpty();
}
public MessageMetadata withProperty(String key, Object value) {
Map<String, Object> newProperties = new HashMap<>(properties);
newProperties.put(key, value);
return new MessageMetadata(newProperties);
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
MessageMetadata that = (MessageMetadata) obj;
return Objects.equals(properties, that.properties);
}
@Override
public int hashCode() {
return Objects.hash(properties);
}
public static final class Builder {
private final Map<String, Object> properties = new HashMap<>();
private Builder() {
}
public Builder property(String key, Object value) {
if (key != null && value != null) {
properties.put(key, value);
}
return this;
}
public Builder properties(Map<String, Object> properties) {
if (properties != null) {
this.properties.putAll(properties);
}
return this;
}
public MessageMetadata build() {
return new MessageMetadata(properties);
}
}
}

View File

@ -0,0 +1,48 @@
package com.pablotj.ai.chat.domain.model;
/**
* Enumeration representing the role of a message participant.
*/
public enum MessageRole {
USER("user", "Human user input"),
ASSISTANT("assistant", "AI assistant response"),
SYSTEM("system", "System-generated message");
private final String code;
private final String description;
MessageRole(String code, String description) {
this.code = code;
this.description = description;
}
public static MessageRole fromCode(String code) {
for (MessageRole role : values()) {
if (role.code.equals(code)) {
return role;
}
}
throw new IllegalArgumentException("Unknown message role code: " + code);
}
public String getCode() {
return code;
}
public String getDescription() {
return description;
}
public boolean isUser() {
return this == USER;
}
public boolean isAssistant() {
return this == ASSISTANT;
}
public boolean isSystem() {
return this == SYSTEM;
}
}

View File

@ -0,0 +1,75 @@
package com.pablotj.ai.chat.domain.repository;
import com.pablotj.ai.chat.domain.model.Conversation;
import com.pablotj.ai.chat.domain.model.ConversationId;
import com.pablotj.ai.chat.domain.model.ConversationStatus;
import java.util.List;
import java.util.Optional;
/**
* Repository interface for conversation persistence operations.
* Defines the contract for conversation data access in the domain layer.
*/
public interface ConversationRepository {
/**
* Saves a conversation to the repository.
*
* @param conversation the conversation to save
* @return the saved conversation
*/
Conversation save(Conversation conversation);
/**
* Finds a conversation by its unique identifier.
*
* @param conversationId the conversation identifier
* @return an optional containing the conversation if found
*/
Optional<Conversation> findById(ConversationId conversationId);
/**
* Finds all conversations with the specified status.
*
* @param status the conversation status to filter by
* @return list of conversations with the specified status
*/
List<Conversation> findByStatus(ConversationStatus status);
/**
* Finds all active conversations ordered by creation date (newest first).
*
* @return list of active conversations
*/
List<Conversation> findAllActiveOrderedByCreationDate();
/**
* Checks if a conversation exists with the given identifier.
*
* @param conversationId the conversation identifier
* @return true if the conversation exists, false otherwise
*/
boolean existsById(ConversationId conversationId);
/**
* Deletes a conversation by its identifier.
*
* @param conversationId the conversation identifier
*/
void deleteById(ConversationId conversationId);
/**
* Counts the total number of conversations.
*
* @return the total count of conversations
*/
long count();
/**
* Counts conversations by status.
*
* @param status the conversation status
* @return the count of conversations with the specified status
*/
long countByStatus(ConversationStatus status);
}

View File

@ -0,0 +1,35 @@
package com.pablotj.ai.chat.domain.service;
import com.pablotj.ai.chat.domain.model.ConversationMessage;
import com.pablotj.ai.chat.domain.model.MessageContent;
import java.util.List;
/**
* Domain service interface for AI conversation processing.
* Defines the contract for AI-powered conversation capabilities.
*/
public interface AiConversationService {
/**
* Generates an AI response based on the conversation context.
*
* @param conversationHistory the complete conversation history
* @param userMessage the latest user message
* @return the AI-generated response content
*/
MessageContent generateResponse(List<ConversationMessage> conversationHistory, ConversationMessage userMessage);
/**
* Checks if the AI service is available and ready to process requests.
*
* @return true if the service is available, false otherwise
*/
boolean isAvailable();
/**
* Gets information about the current AI model being used.
*
* @return model information as a string
*/
String getModelInfo();
}

View File

@ -0,0 +1,132 @@
package com.pablotj.ai.chat.infrastructure.ai;
import com.pablotj.ai.chat.domain.exception.AiServiceUnavailableException;
import com.pablotj.ai.chat.domain.model.ConversationMessage;
import com.pablotj.ai.chat.domain.model.MessageContent;
import com.pablotj.ai.chat.domain.service.AiConversationService;
import com.pablotj.ai.chat.infrastructure.ai.prompt.ConversationPromptBuilder;
import de.kherud.llama.InferenceParameters;
import de.kherud.llama.LlamaOutput;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* Llama-based implementation of the AI conversation service.
* Provides offline AI capabilities using the Llama model.
*/
@Service
public class LlamaAiConversationService implements AiConversationService {
private static final Logger logger = LoggerFactory.getLogger(LlamaAiConversationService.class);
private final LlamaModelManager modelManager;
private final ConversationPromptBuilder promptBuilder;
private final Timer responseTimer;
private final Counter requestCounter;
private final Counter errorCounter;
@Value("${ai.model.inference.max-tokens:2048}")
private int maxTokens;
@Value("${ai.model.inference.temperature:0.7}")
private float temperature;
@Value("${ai.model.inference.top-p:0.9}")
private float topP;
@Value("${ai.model.inference.top-k:40}")
private int topK;
public LlamaAiConversationService(
LlamaModelManager modelManager,
ConversationPromptBuilder promptBuilder,
MeterRegistry meterRegistry) {
this.modelManager = modelManager;
this.promptBuilder = promptBuilder;
this.responseTimer = Timer.builder("ai.response.duration")
.description("Time taken to generate AI responses")
.register(meterRegistry);
this.requestCounter = Counter.builder("ai.requests.total")
.description("Total number of AI requests")
.register(meterRegistry);
this.errorCounter = Counter.builder("ai.errors.total")
.description("Total number of AI errors")
.register(meterRegistry);
}
@Override
public MessageContent generateResponse(List<ConversationMessage> conversationHistory, ConversationMessage userMessage) {
requestCounter.increment();
try {
return responseTimer.recordCallable(() -> {
try {
logger.debug("Generating AI response for conversation with {} messages", conversationHistory.size());
if (!isAvailable()) {
throw new AiServiceUnavailableException("AI model is not available");
}
String prompt = promptBuilder.buildConversationPrompt(conversationHistory, userMessage);
String response = generateResponseInternal(prompt);
logger.debug("Successfully generated AI response with {} characters", response.length());
return MessageContent.of(response);
} catch (Exception e) {
errorCounter.increment();
logger.error("Error generating AI response", e);
throw new AiServiceUnavailableException("Failed to generate AI response: " + e.getMessage(), e);
}
});
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private String generateResponseInternal(String prompt) {
InferenceParameters parameters = new InferenceParameters(prompt)
.setNPredict(maxTokens)
.setTemperature(temperature)
.setTopP(topP)
.setTopK(topK)
.setUseChatTemplate(false);
StringBuilder responseBuilder = new StringBuilder();
for (LlamaOutput output : modelManager.getModel().generate(parameters)) {
responseBuilder.append(output.text);
}
return cleanResponse(responseBuilder.toString());
}
private String cleanResponse(String response) {
return response
.replace("<|end_of_turn|>", "")
.replace("<|im_end|>", "")
.trim();
}
@Override
public boolean isAvailable() {
try {
return modelManager.isModelLoaded();
} catch (Exception e) {
logger.warn("Error checking AI service availability", e);
return false;
}
}
@Override
public String getModelInfo() {
return modelManager.getModelInfo();
}
}

View File

@ -0,0 +1,128 @@
package com.pablotj.ai.chat.infrastructure.ai;
import com.pablotj.ai.chat.domain.exception.AiServiceUnavailableException;
import de.kherud.llama.LlamaModel;
import de.kherud.llama.ModelParameters;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Manages the Llama model lifecycle and configuration.
* Handles model loading, initialization, and cleanup.
*/
@Component
public class LlamaModelManager {
private static final Logger logger = LoggerFactory.getLogger(LlamaModelManager.class);
@Value("${ai.model.name}")
private String modelName;
@Value("${ai.model.path}")
private String modelPath;
@Value("${ai.model.inference.threads:8}")
private int threads;
@Value("${ai.model.gpu.enabled:true}")
private boolean gpuEnabled;
@Value("${ai.model.gpu.layers:35}")
private int gpuLayers;
@Value("${ai.model.gpu.main-gpu:0}")
private int mainGpu;
@Value("${ai.model.context.size:4096}")
private int contextSize;
private LlamaModel model;
private volatile boolean modelLoaded = false;
@PostConstruct
public void initializeModel() {
try {
logger.info("Initializing Llama model: {}", modelName);
validateModelFile();
loadModel();
modelLoaded = true;
logger.info("Successfully initialized Llama model: {}", getModelInfo());
} catch (Exception e) {
logger.error("Failed to initialize Llama model", e);
throw new AiServiceUnavailableException("Failed to initialize AI model: " + e.getMessage(), e);
}
}
private void validateModelFile() {
Path modelFilePath = Paths.get(modelPath);
if (!Files.exists(modelFilePath)) {
throw new AiServiceUnavailableException(
String.format("Model file not found: %s", modelFilePath.toAbsolutePath())
);
}
if (!Files.isReadable(modelFilePath)) {
throw new AiServiceUnavailableException(
String.format("Model file is not readable: %s", modelFilePath.toAbsolutePath())
);
}
logger.debug("Model file validation successful: {}", modelFilePath.toAbsolutePath());
}
private void loadModel() {
ModelParameters parameters = new ModelParameters()
.setModel(modelPath)
.setSeed(42)
.setThreads(threads)
.setMainGpu(gpuEnabled ? mainGpu : -1)
.setGpuLayers(gpuEnabled ? gpuLayers : 0);
//.setContextSize(contextSize);
logger.debug("Loading model with parameters: threads={}, gpu={}, layers={}, context={}",
threads, gpuEnabled, gpuLayers, contextSize);
model = new LlamaModel(parameters);
}
public LlamaModel getModel() {
if (!modelLoaded || model == null) {
throw new AiServiceUnavailableException("AI model is not loaded or available");
}
return model;
}
public boolean isModelLoaded() {
return modelLoaded && model != null;
}
public String getModelInfo() {
return String.format("Llama Model: %s (GPU: %s, Layers: %d, Context: %d)",
modelName, gpuEnabled ? "enabled" : "disabled", gpuLayers, contextSize);
}
@PreDestroy
public void cleanup() {
if (model != null) {
logger.info("Cleaning up Llama model resources");
try {
model.close();
modelLoaded = false;
logger.info("Successfully cleaned up Llama model resources");
} catch (Exception e) {
logger.warn("Error during model cleanup", e);
}
}
}
}

View File

@ -0,0 +1,105 @@
package com.pablotj.ai.chat.infrastructure.ai.prompt;
import com.pablotj.ai.chat.domain.model.ConversationMessage;
import com.pablotj.ai.chat.domain.model.MessageRole;
import java.util.List;
import org.springframework.stereotype.Component;
/**
* Builds conversation prompts for AI model inference.
* Formats conversation history into appropriate prompt format for the AI model.
*/
@Component
public class ConversationPromptBuilder {
private static final String SYSTEM_PROMPT = """
Eres mi asistente personal llamado "Asistente Pablo".
Solo ayudas a Pablo. No respondes a otras personas.
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.
Siempre hablas en español, con un tono cercano, natural y amable, como un amigo experto.
Tus respuestas son claras, completas, útiles y fáciles de entender. Evitas tecnicismos innecesarios.
Reglas importantes:
- 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
- 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
Estás ejecutándote en una aplicación web privada y personalizada para Pablo.
Usas un lenguaje fluido, ordenado y útil. Puedes usar listas o pasos si facilita la comprensión.
Usas párrafos cortos. Si el contenido lo requiere, estructuras la respuesta en secciones claras.
Este chat es privado, solo entre y yo, Pablo. Vamos a conversar de forma relajada y efectiva.
""";
private static final String END_TURN_SEPARATOR = "<|end_of_turn|>";
/**
* Builds a conversation prompt including system instructions and conversation history.
*
* @param conversationHistory the complete conversation history
* @param userMessage the latest user message
* @return formatted prompt string for AI inference
*/
public String buildConversationPrompt(List<ConversationMessage> conversationHistory, ConversationMessage userMessage) {
StringBuilder promptBuilder = new StringBuilder();
// Add system prompt
promptBuilder.append(SYSTEM_PROMPT).append(END_TURN_SEPARATOR);
// Add conversation history
for (ConversationMessage message : conversationHistory) {
appendMessage(promptBuilder, message);
}
// Add the current user message if not already in history
if (!conversationHistory.contains(userMessage)) {
appendMessage(promptBuilder, userMessage);
}
// Add assistant prompt starter
promptBuilder.append("GPT4 Correct Assistant:");
return promptBuilder.toString();
}
/**
* Builds a simple prompt for a single user message without conversation history.
*
* @param userMessage the user message
* @return formatted prompt string for AI inference
*/
public String buildSimplePrompt(ConversationMessage userMessage) {
StringBuilder promptBuilder = new StringBuilder();
promptBuilder.append(SYSTEM_PROMPT).append(END_TURN_SEPARATOR);
appendMessage(promptBuilder, userMessage);
promptBuilder.append("GPT4 Correct Assistant:");
return promptBuilder.toString();
}
private void appendMessage(StringBuilder promptBuilder, ConversationMessage message) {
String rolePrefix = formatRole(message.getRole());
promptBuilder.append(rolePrefix)
.append(": ")
.append(message.getContent().getText())
.append(" ")
.append(END_TURN_SEPARATOR);
}
private String formatRole(MessageRole role) {
return switch (role) {
case USER -> "GPT4 Correct User";
case ASSISTANT -> "GPT4 Correct Assistant";
case SYSTEM -> "GPT4 Correct System";
};
}
}

View File

@ -0,0 +1,38 @@
package com.pablotj.ai.chat.infrastructure.configuration;
import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Main application configuration class.
* Configures cross-cutting concerns and application-wide settings.
*/
@Configuration
@EnableAsync
public class ApplicationConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.maxAge(3600);
}
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("ai-chat-");
executor.initialize();
return executor;
}
}

View File

@ -0,0 +1,24 @@
package com.pablotj.ai.chat.infrastructure.configuration;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.config.MeterFilter;
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Configuration for application metrics and monitoring.
*/
@Configuration
public class MetricsConfiguration {
@Bean
public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
return registry -> registry.config()
.commonTags("application", "ai-chat-platform")
.meterFilter(MeterFilter.deny(id -> {
String uri = id.getTag("uri");
return uri != null && (uri.startsWith("/actuator") || uri.startsWith("/swagger"));
}));
}
}

View File

@ -0,0 +1,44 @@
package com.pablotj.ai.chat.infrastructure.configuration;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.servers.Server;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* OpenAPI/Swagger configuration for API documentation.
*/
@Configuration
public class OpenApiConfiguration {
@Value("${server.servlet.context-path:/api}")
private String contextPath;
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("AI Chat Platform API")
.description("Enterprise-grade AI Chat Platform with offline capabilities")
.version("1.0.0")
.contact(new Contact()
.name("Pablo TJ")
.email("contact@pablotj.com")
.url("https://github.com/pablotj"))
.license(new License()
.name("MIT License")
.url("https://opensource.org/licenses/MIT")))
.servers(List.of(
new Server()
.url("http://localhost:8080" + contextPath)
.description("Development server"),
new Server()
.url("https://api.ai-chat-platform.com" + contextPath)
.description("Production server")));
}
}

View File

@ -0,0 +1,46 @@
package com.pablotj.ai.chat.infrastructure.health;
/*
import com.pablotj.ai.chat.domain.service.AiConversationService;
import org.springframework.boot.actuator.health.Health;
import org.springframework.boot.actuator.health.HealthIndicator;
import org.springframework.stereotype.Component;
/**
* Health indicator for the AI service.
* Monitors the availability and status of the AI conversation service.
*//*
@Component
public class AiServiceHealthIndicator implements HealthIndicator {
private final AiConversationService aiConversationService;
public AiServiceHealthIndicator(AiConversationService aiConversationService) {
this.aiConversationService = aiConversationService;
}
@Override
public Health health() {
try {
boolean isAvailable = aiConversationService.isAvailable();
String modelInfo = aiConversationService.getModelInfo();
if (isAvailable) {
return Health.up()
.withDetail("status", "AI service is operational")
.withDetail("model", modelInfo)
.build();
} else {
return Health.down()
.withDetail("status", "AI service is not available")
.withDetail("model", modelInfo)
.build();
}
} catch (Exception e) {
return Health.down()
.withDetail("status", "AI service health check failed")
.withDetail("error", e.getMessage())
.build();
}
}
}
*/

View File

@ -0,0 +1,171 @@
package com.pablotj.ai.chat.infrastructure.persistence.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
/**
* JPA entity representing a conversation in the database.
*/
@Entity
@Table(name = "conversations", indexes = {
@Index(name = "idx_conversation_uuid", columnList = "uuid", unique = true),
@Index(name = "idx_conversation_status", columnList = "status"),
@Index(name = "idx_conversation_created_at", columnList = "created_at")
})
public class ConversationEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "uuid", nullable = false, unique = true, length = 36)
private String uuid;
@Column(name = "title", nullable = false, length = 100)
private String title;
@Column(name = "description", length = 500)
private String description;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private ConversationStatusEntity status;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("createdAt ASC")
private List<ConversationMessageEntity> messages = new ArrayList<>();
// Constructors
public ConversationEntity() {
}
public ConversationEntity(String uuid, String title, String description, ConversationStatusEntity status) {
this.uuid = uuid;
this.title = title;
this.description = description;
this.status = status;
}
// Helper methods
public void addMessage(ConversationMessageEntity message) {
messages.add(message);
message.setConversation(this);
}
public void removeMessage(ConversationMessageEntity message) {
messages.remove(message);
message.setConversation(null);
}
public int getMessageCount() {
return messages.size();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUuid() {
return uuid;
}
public void setUuid(String uuid) {
this.uuid = uuid;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public ConversationStatusEntity getStatus() {
return status;
}
public void setStatus(ConversationStatusEntity status) {
this.status = status;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
public List<ConversationMessageEntity> getMessages() {
return messages;
}
public void setMessages(List<ConversationMessageEntity> messages) {
this.messages = messages;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
ConversationEntity that = (ConversationEntity) obj;
return Objects.equals(uuid, that.uuid);
}
@Override
public int hashCode() {
return Objects.hash(uuid);
}
@Override
public String toString() {
return String.format("ConversationEntity{uuid='%s', title='%s', status=%s}", uuid, title, status);
}
}

View File

@ -0,0 +1,161 @@
package com.pablotj.ai.chat.infrastructure.persistence.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* JPA entity representing a conversation message in the database.
*/
@Entity
@Table(name = "conversation_messages", indexes = {
@Index(name = "idx_message_uuid", columnList = "uuid", unique = true),
@Index(name = "idx_message_conversation", columnList = "conversation_id"),
@Index(name = "idx_message_created_at", columnList = "created_at")
})
public class ConversationMessageEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "uuid", nullable = false, unique = true, length = 36)
private String uuid;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id", nullable = false)
private ConversationEntity conversation;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false, length = 20)
private MessageRoleEntity role;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "metadata", columnDefinition = "TEXT")
private Map<String, Object> metadata = new HashMap<>();
// Constructors
public ConversationMessageEntity() {
}
public ConversationMessageEntity(String uuid, MessageRoleEntity role, String content) {
this.uuid = uuid;
this.role = role;
this.content = content;
}
// Helper methods
public boolean isFromUser() {
return role == MessageRoleEntity.USER;
}
public boolean isFromAssistant() {
return role == MessageRoleEntity.ASSISTANT;
}
public void addMetadata(String key, Object value) {
if (metadata == null) {
metadata = new HashMap<>();
}
metadata.put(key, value);
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUuid() {
return uuid;
}
public void setUuid(String uuid) {
this.uuid = uuid;
}
public ConversationEntity getConversation() {
return conversation;
}
public void setConversation(ConversationEntity conversation) {
this.conversation = conversation;
}
public MessageRoleEntity getRole() {
return role;
}
public void setRole(MessageRoleEntity role) {
this.role = role;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Map<String, Object> getMetadata() {
return metadata;
}
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
ConversationMessageEntity that = (ConversationMessageEntity) obj;
return Objects.equals(uuid, that.uuid);
}
@Override
public int hashCode() {
return Objects.hash(uuid);
}
@Override
public String toString() {
return String.format("ConversationMessageEntity{uuid='%s', role=%s, content='%s'}",
uuid, role, content != null && content.length() > 50 ? content.substring(0, 50) + "..." : content);
}
}

View File

@ -0,0 +1,11 @@
package com.pablotj.ai.chat.infrastructure.persistence.entity;
/**
* JPA enumeration for conversation status.
*/
public enum ConversationStatusEntity {
ACTIVE,
ARCHIVED,
DELETED,
SUSPENDED
}

View File

@ -0,0 +1,10 @@
package com.pablotj.ai.chat.infrastructure.persistence.entity;
/**
* JPA enumeration for message roles.
*/
public enum MessageRoleEntity {
USER,
ASSISTANT,
SYSTEM
}

View File

@ -0,0 +1,107 @@
package com.pablotj.ai.chat.infrastructure.persistence.mapper;
import com.pablotj.ai.chat.domain.model.Conversation;
import com.pablotj.ai.chat.domain.model.ConversationId;
import com.pablotj.ai.chat.domain.model.ConversationMessage;
import com.pablotj.ai.chat.domain.model.ConversationStatus;
import com.pablotj.ai.chat.domain.model.ConversationSummary;
import com.pablotj.ai.chat.domain.model.MessageContent;
import com.pablotj.ai.chat.domain.model.MessageId;
import com.pablotj.ai.chat.domain.model.MessageMetadata;
import com.pablotj.ai.chat.domain.model.MessageRole;
import com.pablotj.ai.chat.infrastructure.persistence.entity.ConversationEntity;
import com.pablotj.ai.chat.infrastructure.persistence.entity.ConversationMessageEntity;
import com.pablotj.ai.chat.infrastructure.persistence.entity.ConversationStatusEntity;
import com.pablotj.ai.chat.infrastructure.persistence.entity.MessageRoleEntity;
import java.util.List;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
/**
* MapStruct mapper for converting between domain models and JPA entities.
*/
@Mapper(componentModel = "spring", imports = {ConversationId.class, MessageId.class})
public interface ConversationEntityMapper {
// Conversation mappings
@Mapping(source = "conversationId.id", target = "id")
@Mapping(source = "conversationId.uuid", target = "uuid")
@Mapping(source = "summary.title", target = "title")
@Mapping(source = "summary.description", target = "description")
@Mapping(source = "status", target = "status", qualifiedByName = "domainStatusToEntity")
@Mapping(source = "messages", target = "messages")
ConversationEntity toEntity(Conversation conversation);
@Mapping(target = "conversationId", expression = "java( new ConversationId( entity.getId(), entity.getUuid() ) )")
@Mapping(source = "title", target = "summary.title")
@Mapping(source = "status", target = "status", qualifiedByName = "entityStatusToDomain")
@Mapping(source = "messages", target = "messages")
Conversation toDomain(ConversationEntity entity);
// Message mappings
@Mapping(source = "messageId.id", target = "id")
@Mapping(source = "messageId.uuid", target = "uuid")
@Mapping(source = "role", target = "role", qualifiedByName = "domainRoleToEntity")
@Mapping(source = "content.text", target = "content")
@Mapping(source = "metadata.allProperties", target = "metadata")
@Mapping(target = "conversation.id", source = "conversationId.id")
@Mapping(target = "conversation.uuid", source = "conversationId.uuid")
ConversationMessageEntity toMessageEntity(ConversationMessage message);
@Mapping(target = "messageId", expression = "java( new MessageId( entity.getId(), entity.getUuid() ) )")
@Mapping(target = "conversationId", expression = "java( new ConversationId( entity.getConversation().getId(), entity.getConversation().getUuid() ) )")
@Mapping(source = "role", target = "role", qualifiedByName = "entityRoleToDomain")
@Mapping(source = "content", target = "content", qualifiedByName = "stringToMessageContent")
@Mapping(source = "metadata", target = "metadata", qualifiedByName = "mapToMessageMetadata")
ConversationMessage toMessageDomain(ConversationMessageEntity entity);
List<ConversationMessageEntity> toMessageEntities(List<ConversationMessage> messages);
List<ConversationMessage> toMessageDomains(List<ConversationMessageEntity> entities);
// Status mappings
ConversationStatusEntity toEntityStatus(ConversationStatus status);
ConversationStatus toDomainStatus(ConversationStatusEntity status);
MessageRoleEntity toEntityRole(MessageRole role);
MessageRole toDomainRole(MessageRoleEntity role);
// Named mapping methods
@Named("stringToMessageContent")
default MessageContent stringToMessageContent(String value) {
return value != null ? MessageContent.of(value) : null;
}
@Named("titleAndDescriptionToSummary")
default ConversationSummary titleAndDescriptionToSummary(ConversationEntity entity) {
return ConversationSummary.of(entity.getTitle(), entity.getDescription());
}
@Named("mapToMessageMetadata")
default MessageMetadata mapToMessageMetadata(java.util.Map<String, Object> map) {
return map != null ? MessageMetadata.of(map) : MessageMetadata.empty();
}
@Named("domainStatusToEntity")
default ConversationStatusEntity domainStatusToEntity(ConversationStatus status) {
return status != null ? ConversationStatusEntity.valueOf(status.name()) : null;
}
@Named("entityStatusToDomain")
default ConversationStatus entityStatusToDomain(ConversationStatusEntity status) {
return status != null ? ConversationStatus.valueOf(status.name()) : null;
}
@Named("domainRoleToEntity")
default MessageRoleEntity domainRoleToEntity(MessageRole role) {
return role != null ? MessageRoleEntity.valueOf(role.name()) : null;
}
@Named("entityRoleToDomain")
default MessageRole entityRoleToDomain(MessageRoleEntity role) {
return role != null ? MessageRole.valueOf(role.name()) : null;
}
}

View File

@ -0,0 +1,54 @@
package com.pablotj.ai.chat.infrastructure.persistence.repository;
import com.pablotj.ai.chat.infrastructure.persistence.entity.ConversationEntity;
import com.pablotj.ai.chat.infrastructure.persistence.entity.ConversationStatusEntity;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
/**
* JPA repository interface for conversation entities.
*/
@Repository
public interface ConversationJpaRepository extends JpaRepository<ConversationEntity, Long> {
/**
* Finds a conversation by its UUID.
*/
Optional<ConversationEntity> findByUuid(String uuid);
/**
* Finds conversations by status ordered by creation date descending.
*/
List<ConversationEntity> findByStatusOrderByCreatedAtDesc(ConversationStatusEntity status);
/**
* Finds all active conversations with their message count.
*/
@Query("SELECT c FROM ConversationEntity c WHERE c.status = :status ORDER BY c.createdAt DESC")
List<ConversationEntity> findActiveConversationsOrderedByCreationDate(@Param("status") ConversationStatusEntity status);
/**
* Counts conversations by status.
*/
long countByStatus(ConversationStatusEntity status);
/**
* Checks if a conversation exists by UUID.
*/
boolean existsByUuid(String uuid);
/**
* Deletes a conversation by UUID.
*/
void deleteByUuid(String uuid);
/**
* Finds conversations with message count greater than specified value.
*/
@Query("SELECT c FROM ConversationEntity c WHERE SIZE(c.messages) > :messageCount")
List<ConversationEntity> findConversationsWithMoreThanMessages(@Param("messageCount") int messageCount);
}

View File

@ -0,0 +1,109 @@
package com.pablotj.ai.chat.infrastructure.persistence.repository;
import com.pablotj.ai.chat.domain.model.Conversation;
import com.pablotj.ai.chat.domain.model.ConversationId;
import com.pablotj.ai.chat.domain.model.ConversationStatus;
import com.pablotj.ai.chat.domain.repository.ConversationRepository;
import com.pablotj.ai.chat.infrastructure.persistence.entity.ConversationStatusEntity;
import com.pablotj.ai.chat.infrastructure.persistence.mapper.ConversationEntityMapper;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
/**
* JPA implementation of the ConversationRepository.
* Handles the persistence of conversation aggregates using JPA entities.
*/
@Repository
@Transactional
public class JpaConversationRepository implements ConversationRepository {
private static final Logger logger = LoggerFactory.getLogger(JpaConversationRepository.class);
private final ConversationJpaRepository jpaRepository;
private final ConversationEntityMapper entityMapper;
public JpaConversationRepository(
ConversationJpaRepository jpaRepository,
ConversationEntityMapper entityMapper) {
this.jpaRepository = jpaRepository;
this.entityMapper = entityMapper;
}
@Override
public Conversation save(Conversation conversation) {
logger.debug("Saving conversation: {}", conversation.getConversationId());
var entity = entityMapper.toEntity(conversation);
var savedEntity = jpaRepository.save(entity);
var savedConversation = entityMapper.toDomain(savedEntity);
logger.debug("Successfully saved conversation: {}", savedConversation.getConversationId());
return savedConversation;
}
@Override
@Transactional(readOnly = true)
public Optional<Conversation> findById(ConversationId conversationId) {
logger.debug("Finding conversation by ID: {}", conversationId);
return jpaRepository.findByUuid(conversationId.getUuid())
.map(entityMapper::toDomain);
}
@Override
@Transactional(readOnly = true)
public List<Conversation> findByStatus(ConversationStatus status) {
logger.debug("Finding conversations by status: {}", status);
ConversationStatusEntity entityStatus = entityMapper.toEntityStatus(status);
return jpaRepository.findByStatusOrderByCreatedAtDesc(entityStatus)
.stream()
.map(entityMapper::toDomain)
.toList();
}
@Override
@Transactional(readOnly = true)
public List<Conversation> findAllActiveOrderedByCreationDate() {
logger.debug("Finding all active conversations ordered by creation date");
return jpaRepository.findActiveConversationsOrderedByCreationDate(ConversationStatusEntity.ACTIVE)
.stream()
.map(entityMapper::toDomain)
.toList();
}
@Override
@Transactional(readOnly = true)
public boolean existsById(ConversationId conversationId) {
return jpaRepository.existsByUuid(conversationId.getUuid());
}
@Override
public void deleteById(ConversationId conversationId) {
logger.debug("Deleting conversation: {}", conversationId);
jpaRepository.deleteByUuid(conversationId.getUuid());
logger.debug("Successfully deleted conversation: {}", conversationId);
}
@Override
@Transactional(readOnly = true)
public long count() {
return jpaRepository.count();
}
@Override
@Transactional(readOnly = true)
public long countByStatus(ConversationStatus status) {
ConversationStatusEntity entityStatus = entityMapper.toEntityStatus(status);
return jpaRepository.countByStatus(entityStatus);
}
}

View File

@ -0,0 +1,25 @@
package com.pablotj.ai.chat.presentation.dto;
import com.pablotj.ai.chat.domain.model.ConversationSummary;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
/**
* Request DTO for creating a new conversation.
*/
@Schema(description = "Request to create a new conversation")
public record CreateConversationRequest(
@Schema(description = "Optional conversation title", example = "Discussion about AI")
@Size(max = 100, message = "Title cannot exceed 100 characters")
String title,
@Schema(description = "Optional conversation description", example = "A detailed conversation about artificial intelligence")
@Size(max = 500, message = "Description cannot exceed 500 characters")
String description
) {
public ConversationSummary toConversationSummary() {
return ConversationSummary.of(title, description);
}
}

View File

@ -0,0 +1,18 @@
package com.pablotj.ai.chat.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Request DTO for sending a message to a conversation.
*/
@Schema(description = "Request to send a message to a conversation")
public record SendMessageRequest(
@Schema(description = "Message content", example = "Hello, how can you help me today?", required = true)
@NotBlank(message = "Message content cannot be blank")
@Size(min = 1, max = 10000, message = "Message content must be between 1 and 10000 characters")
String content
) {
}

View File

@ -0,0 +1,82 @@
package com.pablotj.ai.chat.presentation.exception;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.Map;
/**
* Standardized error response structure for API endpoints.
*/
@Schema(description = "Error response structure")
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ErrorResponse(
@Schema(description = "Error timestamp")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
Instant timestamp,
@Schema(description = "HTTP status code", example = "400")
int status,
@Schema(description = "Error type", example = "Validation Failed")
String error,
@Schema(description = "Error message", example = "Request validation failed")
String message,
@Schema(description = "Request path", example = "/api/v1/conversations")
String path,
@Schema(description = "Validation errors by field")
Map<String, String> validationErrors
) {
public static Builder builder () {
return new Builder();
}
public static class Builder {
private Instant timestamp;
private int status;
private String error;
private String message;
private String path;
private Map<String, String> validationErrors;
public Builder timestamp(Instant timestamp) {
this.timestamp = timestamp;
return this;
}
public Builder status(int status) {
this.status = status;
return this;
}
public Builder error(String error) {
this.error = error;
return this;
}
public Builder message(String message) {
this.message = message;
return this;
}
public Builder path(String path) {
this.path = path;
return this;
}
public Builder validationErrors(Map<String, String> validationErrors) {
this.validationErrors = validationErrors;
return this;
}
public ErrorResponse build() {
return new ErrorResponse(timestamp, status, error, message, path, validationErrors);
}
}
}

View File

@ -0,0 +1,137 @@
package com.pablotj.ai.chat.presentation.exception;
import com.pablotj.ai.chat.domain.exception.AiServiceUnavailableException;
import com.pablotj.ai.chat.domain.exception.ConversationNotFoundException;
import com.pablotj.ai.chat.domain.exception.DomainException;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
/**
* Global exception handler for REST API endpoints.
* Provides consistent error responses across the application.
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ConversationNotFoundException.class)
public ResponseEntity<ErrorResponse> handleConversationNotFound(
ConversationNotFoundException ex, WebRequest request) {
logger.warn("Conversation not found: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(Instant.now())
.status(HttpStatus.NOT_FOUND.value())
.error("Conversation Not Found")
.message(ex.getMessage())
.path(request.getDescription(false).replace("uri=", ""))
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
@ExceptionHandler(AiServiceUnavailableException.class)
public ResponseEntity<ErrorResponse> handleAiServiceUnavailable(
AiServiceUnavailableException ex, WebRequest request) {
logger.error("AI service unavailable: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(Instant.now())
.status(HttpStatus.SERVICE_UNAVAILABLE.value())
.error("AI Service Unavailable")
.message("The AI service is currently unavailable. Please try again later.")
.path(request.getDescription(false).replace("uri=", ""))
.build();
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse);
}
@ExceptionHandler(DomainException.class)
public ResponseEntity<ErrorResponse> handleDomainException(
DomainException ex, WebRequest request) {
logger.warn("Domain exception: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(Instant.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Domain Error")
.message(ex.getMessage())
.path(request.getDescription(false).replace("uri=", ""))
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationExceptions(
MethodArgumentNotValidException ex, WebRequest request) {
logger.warn("Validation error: {}", ex.getMessage());
Map<String, String> validationErrors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
validationErrors.put(fieldName, errorMessage);
});
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(Instant.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Validation Failed")
.message("Request validation failed")
.path(request.getDescription(false).replace("uri=", ""))
.validationErrors(validationErrors)
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgument(
IllegalArgumentException ex, WebRequest request) {
logger.warn("Illegal argument: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(Instant.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Invalid Request")
.message(ex.getMessage())
.path(request.getDescription(false).replace("uri=", ""))
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(
Exception ex, WebRequest request) {
logger.error("Unexpected error: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(Instant.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("Internal Server Error")
.message("An unexpected error occurred. Please try again later.")
.path(request.getDescription(false).replace("uri=", ""))
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
}

View File

@ -0,0 +1,155 @@
package com.pablotj.ai.chat.presentation.rest;
import com.pablotj.ai.chat.application.dto.ConversationDto;
import com.pablotj.ai.chat.application.dto.ConversationMessageDto;
import com.pablotj.ai.chat.application.dto.ConversationSummaryDto;
import com.pablotj.ai.chat.application.usecase.CreateConversationUseCase;
import com.pablotj.ai.chat.application.usecase.GetConversationHistoryUseCase;
import com.pablotj.ai.chat.application.usecase.GetConversationMessagesUseCase;
import com.pablotj.ai.chat.application.usecase.ProcessUserMessageUseCase;
import com.pablotj.ai.chat.presentation.dto.CreateConversationRequest;
import com.pablotj.ai.chat.presentation.dto.SendMessageRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for conversation management operations.
* Provides endpoints for creating, retrieving, and managing conversations.
*/
@RestController
@RequestMapping("/v1/conversations")
@Tag(name = "Conversations", description = "Conversation management operations")
@CrossOrigin(origins = "*", maxAge = 3600)
public class ConversationController {
private static final Logger logger = LoggerFactory.getLogger(ConversationController.class);
private final CreateConversationUseCase createConversationUseCase;
private final GetConversationHistoryUseCase getConversationHistoryUseCase;
private final GetConversationMessagesUseCase getConversationMessagesUseCase;
private final ProcessUserMessageUseCase processUserMessageUseCase;
public ConversationController(
CreateConversationUseCase createConversationUseCase,
GetConversationHistoryUseCase getConversationHistoryUseCase,
GetConversationMessagesUseCase getConversationMessagesUseCase,
ProcessUserMessageUseCase processUserMessageUseCase) {
this.createConversationUseCase = createConversationUseCase;
this.getConversationHistoryUseCase = getConversationHistoryUseCase;
this.getConversationMessagesUseCase = getConversationMessagesUseCase;
this.processUserMessageUseCase = processUserMessageUseCase;
}
@Operation(
summary = "Create a new conversation",
description = "Creates a new conversation with optional title and description"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Conversation created successfully",
content = @Content(schema = @Schema(implementation = ConversationDto.class))),
@ApiResponse(responseCode = "400", description = "Invalid request data"),
@ApiResponse(responseCode = "500", description = "Internal server error")
})
@PostMapping
public ResponseEntity<ConversationDto> createConversation(
@Valid @RequestBody(required = false) CreateConversationRequest request) {
logger.debug("Creating new conversation with request: {}", request);
ConversationDto conversation = request != null && request.title() != null ?
createConversationUseCase.execute(request.toConversationSummary()) :
createConversationUseCase.execute();
logger.info("Successfully created conversation: {}", conversation.conversationId());
return ResponseEntity.status(HttpStatus.CREATED).body(conversation);
}
@Operation(
summary = "Get conversation history",
description = "Retrieves a list of all active conversations ordered by creation date"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Conversation history retrieved successfully"),
@ApiResponse(responseCode = "500", description = "Internal server error")
})
@GetMapping
public ResponseEntity<List<ConversationSummaryDto>> getConversationHistory() {
logger.debug("Retrieving conversation history");
List<ConversationSummaryDto> conversations = getConversationHistoryUseCase.execute();
logger.debug("Retrieved {} conversations", conversations.size());
return ResponseEntity.ok(conversations);
}
@Operation(
summary = "Get conversation messages",
description = "Retrieves all messages from a specific conversation"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Messages retrieved successfully"),
@ApiResponse(responseCode = "404", description = "Conversation not found"),
@ApiResponse(responseCode = "500", description = "Internal server error")
})
@GetMapping("/{conversationId}/messages")
public ResponseEntity<List<ConversationMessageDto>> getConversationMessages(
@Parameter(description = "Unique conversation identifier", required = true)
@PathVariable String conversationId) {
logger.debug("Retrieving messages for conversation: {}", conversationId);
List<ConversationMessageDto> messages = getConversationMessagesUseCase.execute(conversationId);
logger.debug("Retrieved {} messages for conversation: {}", messages.size(), conversationId);
return ResponseEntity.ok(messages);
}
@Operation(
summary = "Send a message to a conversation",
description = "Sends a user message to the specified conversation and returns the AI response"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Message processed and AI response generated",
content = @Content(schema = @Schema(implementation = ConversationMessageDto.class))),
@ApiResponse(responseCode = "400", description = "Invalid request data"),
@ApiResponse(responseCode = "404", description = "Conversation not found"),
@ApiResponse(responseCode = "503", description = "AI service unavailable"),
@ApiResponse(responseCode = "500", description = "Internal server error")
})
@PostMapping("/{conversationId}/messages")
public ResponseEntity<ConversationMessageDto> sendMessage(
@Parameter(description = "Unique conversation identifier", required = true)
@PathVariable String conversationId,
@Valid @RequestBody SendMessageRequest request) {
logger.debug("Processing message for conversation: {}", conversationId);
ConversationMessageDto aiResponse = processUserMessageUseCase.execute(
conversationId, request.content());
logger.info("Successfully processed message for conversation: {}", conversationId);
return ResponseEntity.ok(aiResponse);
}
}

View File

@ -0,0 +1,126 @@
# AI Chat Platform Configuration
server:
port: 8080
servlet:
context-path: /api
error:
include-message: always
include-binding-errors: always
spring:
application:
name: ai-chat-platform
profiles:
active: development
# Database Configuration
datasource:
url: jdbc:sqlite:file:./data/ai-chat-platform.db
driver-class-name: org.sqlite.JDBC
jpa:
database-platform: org.hibernate.community.dialect.SQLiteDialect
hibernate:
ddl-auto: update
naming:
physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy
show-sql: false
properties:
hibernate:
format_sql: true
use_sql_comments: true
# Jackson Configuration
jackson:
default-property-inclusion: non_null
serialization:
write-dates-as-timestamps: false
deserialization:
fail-on-unknown-properties: false
# API Documentation
springdoc:
api-docs:
path: /v1/api-docs
swagger-ui:
path: /v1/swagger-ui.html
operationsSorter: method
info:
title: AI Chat Platform API
description: Enterprise-grade AI Chat Platform
version: 1.0.0
contact:
name: Pablo TJ
email: pablo@example.com
# AI Model Configuration
ai:
model:
provider: llama
name: openchat-3.5-0106.Q4_K_M
path: models/${ai.model.name}.gguf
# Performance Settings
inference:
max-tokens: 2048
temperature: 0.7
top-p: 0.9
top-k: 40
threads: 8
# GPU Configuration
gpu:
enabled: true
layers: 35
main-gpu: 0
# Context Management
context:
size: 4096
keep: 1024
# Monitoring & Management
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
metrics:
export:
prometheus:
enabled: true
# Logging Configuration
logging:
level:
com.pablotj.ai.chat: DEBUG
org.springframework.web: INFO
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: logs/ai-chat-platform.log
# Application Specific
app:
chat:
max-conversations-per-user: 100
max-messages-per-conversation: 1000
conversation-timeout-minutes: 30
security:
cors:
allowed-origins: "*"
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
allowed-headers: "*"
performance:
async:
core-pool-size: 10
max-pool-size: 50
queue-capacity: 100

View 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."
}

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -6,12 +6,12 @@
<parent> <parent>
<groupId>com.pablotj</groupId> <groupId>com.pablotj</groupId>
<artifactId>chat-ia-offline</artifactId> <artifactId>ai-chat-offline</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>1.0.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>frontend</artifactId> <artifactId>chat-web-client</artifactId>
<name>chat-ia-frontend</name> <name>ai-chat-frontend</name>
<description>Frontend Vue.js App</description> <description>Frontend Vue.js App</description>
<packaging>pom</packaging> <packaging>pom</packaging>

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -20,7 +20,7 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import {ref} from 'vue'
defineProps({ defineProps({
disabled: { disabled: {

View File

@ -18,11 +18,11 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import {onMounted, ref} from 'vue'
import ChatSidebar from './ChatSidebar.vue' import ChatSidebar from './ChatSidebar.vue'
import ChatMain from './ChatMain.vue' import ChatMain from './ChatMain.vue'
import { chatService } from '../services/chatService.ts' import {chatService} from '../services/chatService.ts'
import { dateUtils } from '../utils/dateUtils.ts' import {dateUtils} from '../utils/dateUtils.ts'
// Estado reactivo // Estado reactivo
const chatUuid = ref('') const chatUuid = ref('')
@ -38,7 +38,7 @@ const loadHistory = async () => {
// Autoabrir primer chat si no hay chatId activo // Autoabrir primer chat si no hay chatId activo
if (!chatUuid.value && data.length > 0) { if (!chatUuid.value && data.length > 0) {
chatUuid.value = data[0].uuid chatUuid.value = data[0].conversationId
await loadMessages(chatUuid.value) await loadMessages(chatUuid.value)
} }
} catch (error) { } catch (error) {
@ -68,7 +68,7 @@ const selectChat = async (selectedId) => {
const createNewChat = async () => { const createNewChat = async () => {
try { try {
const response = await chatService.createChat() const response = await chatService.createChat()
chatUuid.value = response.uuid; chatUuid.value = response.conversationId;
await loadHistory() await loadHistory()
await loadMessages(chatUuid.value) await loadMessages(chatUuid.value)
} catch (error) { } catch (error) {
@ -85,9 +85,9 @@ const sendMessage = async (prompt) => {
// Agregar mensaje del usuario inmediatamente // Agregar mensaje del usuario inmediatamente
const userMessage = { const userMessage = {
role: "user", role: "USER",
text: prompt, content: prompt,
date: dateUtils.formatDate(new Date()) createdAt: dateUtils.formatDate(new Date())
} }
messages.value.push(userMessage) messages.value.push(userMessage)
@ -95,13 +95,13 @@ const sendMessage = async (prompt) => {
try { try {
const data = await chatService.sendMessage(chatUuid.value, prompt) const data = await chatService.sendMessage(chatUuid.value, prompt)
chatUuid.value = data.chatId chatUuid.value = data.conversationId
// Agregar respuesta del bot // Agregar respuesta del bot
const botMessage = { const botMessage = {
role: "bot", role: "ASSISTANT",
text: data.text, content: data.content,
date: data.date createdAt: data.createdAt
} }
messages.value.push(botMessage) messages.value.push(botMessage)

Some files were not shown because too many files have changed in this diff Show More