Create ChatEntity to group chat messages under a parent entity

This commit is contained in:
Pablo de la Torre Jamardo 2025-07-20 10:47:16 +02:00
parent 5450d97abc
commit 3844734794
18 changed files with 207 additions and 74 deletions

View File

@ -46,14 +46,14 @@ public class ChatRestController {
}
@PostMapping(produces = "application/json")
public ResponseEntity<String> newChat() {
return ResponseEntity.ok(UUID.randomUUID().toString());
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 id cannot be empty");
throw new IllegalArgumentException("Chat uuid cannot be empty");
}
ChatMessage reply;
try {

View File

@ -13,9 +13,9 @@ public class PromptTemplates {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* Loads and returns the full prompts string for the given profile name.
* Loads and returns the full prompts string for the given profile resume.
*
* @param profileName name of the prompts profile, without extension (e.g. "default")
* @param profileName resume of the prompts profile, without extension (e.g. "default")
* @return full system prompts as String
*/
public static String get(String profileName) {
@ -25,7 +25,7 @@ public class PromptTemplates {
/**
* Loads and returns the PromptDefinition for the given profile.
*
* @param profileName prompts profile name (e.g. "developer", "minimal")
* @param profileName prompts profile resume (e.g. "developer", "minimal")
* @return PromptDefinition object
*/
public static PromptDefinition load(String profileName) {

View File

@ -1,5 +1,6 @@
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;
@ -16,8 +17,12 @@ public class ChatSessionManager {
this.chatMessageStore = chatMessageStore;
}
public List<ChatMessage> getMessages(String chatId) {
List<ChatMessage> messages = chatMessageStore.getMessages(chatId);
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)) {
@ -28,8 +33,10 @@ public class ChatSessionManager {
return filteredMessages;
}
public void setMessages(String chatId, List<ChatMessage> messages) {
public void setMessages(String chatUuid, List<ChatMessage> messages) {
messages.removeIf(m -> ObjectUtils.isEmpty(m.text()));
chatMessageStore.saveMessages(chatId, messages);
chatMessageStore.saveMessages(chatUuid, messages);
}
}

View File

@ -3,6 +3,7 @@ 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;
@ -24,6 +25,10 @@ public class ChatUseCase {
this.sessionManager = sessionManager;
}
public ChatIdentity createChat() {
return sessionManager.createChat();
}
public List<ChatMessage> getMessages(String chatId) {
return sessionManager.getMessages(chatId);
}

View File

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

View File

@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonFormat;
import java.io.Serializable;
import java.util.Date;
public record ChatMessage(String chatId, String role, String text, Date date) implements Serializable {
public record ChatMessage(String chatUuid, String role, String text, Date date) implements Serializable {
@Override
@JsonFormat(pattern = "dd/MM/yyyy HH:mm", timezone = "Europe/Madrid")

View File

@ -3,10 +3,11 @@ 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 chatId);
void saveMessages(String sessionId, List<ChatMessage> messages);
List<ChatMessage> getMessages(String chatUuid);
void saveMessages(String chatUuid, List<ChatMessage> messages);
}

View File

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

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

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

@ -11,7 +11,8 @@ public class ChatMessageEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String sessionId;
@ManyToOne(fetch = FetchType.LAZY)
private ChatEntity chat;
@Column(length = 4000)
private String text;
@ -25,8 +26,8 @@ public class ChatMessageEntity {
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getSessionId() { return sessionId; }
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
public 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; }

View File

@ -2,23 +2,10 @@ package com.pablotj.ia.chat.boot.persistence;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface ChatMessageJpaRepository extends JpaRepository<ChatMessageEntity, Long> {
@Query(value = """
SELECT *
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY id ASC) AS rn
FROM chat_messages
) sub
WHERE rn = 1
ORDER BY id desc
""", nativeQuery = true)
List<ChatMessageEntity> findFirstMessagePerSession();
List<ChatMessageEntity> findByChatUuidOrderByIdAsc(String chatUuid);
List<ChatMessageEntity> findBySessionIdOrderByIdAsc(String chatId);
void deleteBySessionId(String chatId);
void deleteByChatUuid(String chatUuid);
}

View File

@ -1,6 +1,5 @@
package com.pablotj.ia.chat.boot.persistence;
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
public class ChatMessageMapper {
@ -9,9 +8,9 @@ public class ChatMessageMapper {
throw new IllegalAccessException("Private access to ChatMessageMapper");
}
public static ChatMessageEntity toEntity(String chatId, ChatMessage message) {
public static ChatMessageEntity toEntity(ChatEntity chat, ChatMessage message) {
ChatMessageEntity entity = new ChatMessageEntity();
entity.setSessionId(chatId);
entity.setChat(chat);
entity.setRole(message.role());
entity.setText(message.text());
entity.setDate(message.date());
@ -19,10 +18,6 @@ public class ChatMessageMapper {
}
public static ChatMessage toDomain(ChatMessageEntity entity) {
return new ChatMessage(entity.getSessionId(), entity.getRole(), entity.getText(), entity.getDate());
}
public static ChatIdentity toEntityId(ChatMessageEntity m) {
return new ChatIdentity(m.getSessionId(), m.getText().length() > 35 ? m.getText().substring(0, 35).concat("...") : m.getText());
return new ChatMessage(entity.getChat().getUuid(), entity.getRole(), entity.getText(), entity.getDate());
}
}

View File

@ -3,29 +3,44 @@ 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 ChatMessageJpaRepository repository;
private final ChatJpaRepository chatJpaRepository;
private final ChatMessageJpaRepository chatMessageJpaRepository;
public SqliteChatMessageStore(ChatMessageJpaRepository repository) {
this.repository = repository;
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 repository.findFirstMessagePerSession().stream()
.map(ChatMessageMapper::toEntityId)
return chatJpaRepository.findAllByOrderByCreatedDateDesc().stream()
.map(ChatMapper::toDomain)
.toList();
}
@Override
public List<ChatMessage> getMessages(String chatId) {
return repository.findBySessionIdOrderByIdAsc(chatId)
public List<ChatMessage> getMessages(String chatUuid) {
return chatMessageJpaRepository.findByChatUuidOrderByIdAsc(chatUuid)
.stream()
.map(ChatMessageMapper::toDomain)
.toList();
@ -33,11 +48,31 @@ public class SqliteChatMessageStore implements ChatMessageStore {
@Override
@Transactional
public void saveMessages(String chatId, List<ChatMessage> messages) {
repository.deleteBySessionId(chatId);
List<ChatMessageEntity> entities = messages.stream()
.map(m -> ChatMessageMapper.toEntity(chatId, m))
.toList();
repository.saveAll(entities);
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

@ -3,7 +3,7 @@
<!-- Menú lateral izquierdo -->
<ChatSidebar
:chats="chats"
:current-chat-id="chatId"
:current-chat-id="chatUuid"
@select-chat="selectChat"
@create-chat="createNewChat"
/>
@ -25,7 +25,7 @@ import { chatService } from '../services/chatService.ts'
import { dateUtils } from '../utils/dateUtils.ts'
// Estado reactivo
const chatId = ref('')
const chatUuid = ref('')
const chats = ref([])
const messages = ref([])
const isLoading = ref(false)
@ -37,9 +37,9 @@ const loadHistory = async () => {
chats.value = data
// Autoabrir primer chat si no hay chatId activo
if (!chatId.value && data.length > 0) {
chatId.value = data[0].id
await loadMessages(chatId.value)
if (!chatUuid.value && data.length > 0) {
chatUuid.value = data[0].uuid
await loadMessages(chatUuid.value)
}
} catch (error) {
console.error("Error cargando historial:", error)
@ -58,19 +58,19 @@ const loadMessages = async (selectedChatId) => {
// Seleccionar un chat
const selectChat = async (selectedId) => {
if (selectedId !== chatId.value) {
chatId.value = selectedId
await loadMessages(chatId.value)
if (selectedId !== chatUuid.value) {
chatUuid.value = selectedId
await loadMessages(chatUuid.value)
}
}
// Crear nuevo chat
const createNewChat = async () => {
try {
const newChatId = await chatService.createChat()
chatId.value = newChatId
const response = await chatService.createChat()
chatUuid.value = response.uuid;
await loadHistory()
await loadMessages(chatId.value)
await loadMessages(chatUuid.value)
} catch (error) {
console.error("Error al crear nuevo chat:", error)
}
@ -79,7 +79,7 @@ const createNewChat = async () => {
// Enviar mensaje
const sendMessage = async (prompt) => {
// Crear nuevo chat si no existe
if (!chatId.value) {
if (!chatUuid.value) {
await createNewChat()
}
@ -94,8 +94,8 @@ const sendMessage = async (prompt) => {
isLoading.value = true
try {
const data = await chatService.sendMessage(chatId.value, prompt)
chatId.value = data.chatId
const data = await chatService.sendMessage(chatUuid.value, prompt)
chatUuid.value = data.chatId
// Agregar respuesta del bot
const botMessage = {

View File

@ -7,11 +7,11 @@
<ul class="chat-list">
<li
v-for="chat in chats"
:key="chat.id"
:class="{ active: chat.id === currentChatId }"
@click="$emit('select-chat', chat.id)"
:key="chat.uuid"
:class="{ active: chat.uuid === currentChatId }"
@click="$emit('select-chat', chat.uuid)"
>
{{ chat.name }}
{{ chat.resume }}
</li>
</ul>
</aside>

View File

@ -31,7 +31,7 @@ class ChatService {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.text()
return await response.json()
} catch (error) {
console.error("Error creating chat:", error)
toast.error("Could not create chat")

View File

@ -25,6 +25,6 @@
<description>Project IA Chat Offline</description>
<properties>
<java.version>17</java.version>
<java.version>21</java.version>
</properties>
</project>