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") @PostMapping(produces = "application/json")
public ResponseEntity<String> newChat() { public ResponseEntity<ChatIdentity> newChat() {
return ResponseEntity.ok(UUID.randomUUID().toString()); return ResponseEntity.ok(chatUseCase.createChat());
} }
@PutMapping(value = "{chatId}", consumes = "application/x-www-form-urlencoded", produces = "application/json") @PutMapping(value = "{chatId}", consumes = "application/x-www-form-urlencoded", produces = "application/json")
public ResponseEntity<ChatMessage> handleChat(@PathVariable("chatId") String chatId, @RequestParam("prompt") String prompt) { public ResponseEntity<ChatMessage> handleChat(@PathVariable("chatId") String chatId, @RequestParam("prompt") String prompt) {
if (ObjectUtils.isEmpty(chatId)) { if (ObjectUtils.isEmpty(chatId)) {
throw new IllegalArgumentException("Chat id cannot be empty"); throw new IllegalArgumentException("Chat uuid cannot be empty");
} }
ChatMessage reply; ChatMessage reply;
try { try {

View File

@ -13,9 +13,9 @@ public class PromptTemplates {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); 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 * @return full system prompts as String
*/ */
public static String get(String profileName) { public static String get(String profileName) {
@ -25,7 +25,7 @@ public class PromptTemplates {
/** /**
* Loads and returns the PromptDefinition for the given profile. * 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 * @return PromptDefinition object
*/ */
public static PromptDefinition load(String profileName) { public static PromptDefinition load(String profileName) {

View File

@ -1,5 +1,6 @@
package com.pablotj.ia.chat.boot.application.session; 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.model.ChatMessage;
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore; import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
import java.util.ArrayList; import java.util.ArrayList;
@ -16,8 +17,12 @@ public class ChatSessionManager {
this.chatMessageStore = chatMessageStore; this.chatMessageStore = chatMessageStore;
} }
public List<ChatMessage> getMessages(String chatId) { public ChatIdentity createChat() {
List<ChatMessage> messages = chatMessageStore.getMessages(chatId); return chatMessageStore.createChat();
}
public List<ChatMessage> getMessages(String chatUuid) {
List<ChatMessage> messages = chatMessageStore.getMessages(chatUuid);
List<ChatMessage> filteredMessages = new ArrayList<>(messages); List<ChatMessage> filteredMessages = new ArrayList<>(messages);
if (ObjectUtils.isEmpty(filteredMessages)) { if (ObjectUtils.isEmpty(filteredMessages)) {
@ -28,8 +33,10 @@ public class ChatSessionManager {
return filteredMessages; 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())); 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.PromptBuilder;
import com.pablotj.ia.chat.boot.application.prompt.PromptTemplates; import com.pablotj.ia.chat.boot.application.prompt.PromptTemplates;
import com.pablotj.ia.chat.boot.application.session.ChatSessionManager; 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.domain.model.ChatMessage;
import com.pablotj.ia.chat.boot.infraestructure.llm.LlmModelClient; import com.pablotj.ia.chat.boot.infraestructure.llm.LlmModelClient;
import java.util.Date; import java.util.Date;
@ -24,6 +25,10 @@ public class ChatUseCase {
this.sessionManager = sessionManager; this.sessionManager = sessionManager;
} }
public ChatIdentity createChat() {
return sessionManager.createChat();
}
public List<ChatMessage> getMessages(String chatId) { public List<ChatMessage> getMessages(String chatId) {
return sessionManager.getMessages(chatId); return sessionManager.getMessages(chatId);
} }

View File

@ -2,5 +2,5 @@ package com.pablotj.ia.chat.boot.domain.model;
import java.io.Serializable; 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.io.Serializable;
import java.util.Date; 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 @Override
@JsonFormat(pattern = "dd/MM/yyyy HH:mm", timezone = "Europe/Madrid") @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.ChatIdentity;
import com.pablotj.ia.chat.boot.domain.model.ChatMessage; import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
import java.util.List; import java.util.List;
import org.springframework.transaction.annotation.Transactional;
public interface ChatMessageStore { public interface ChatMessageStore {
ChatIdentity createChat();
List<ChatIdentity> getChats(); List<ChatIdentity> getChats();
List<ChatMessage> getMessages(String chatUuid);
List<ChatMessage> getMessages(String chatId); void saveMessages(String chatUuid, List<ChatMessage> messages);
void saveMessages(String sessionId, 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) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
private String sessionId; @ManyToOne(fetch = FetchType.LAZY)
private ChatEntity chat;
@Column(length = 4000) @Column(length = 4000)
private String text; private String text;
@ -25,8 +26,8 @@ public class ChatMessageEntity {
public Long getId() { return id; } public Long getId() { return id; }
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
public String getSessionId() { return sessionId; } public ChatEntity getChat() { return chat; }
public void setSessionId(String sessionId) { this.sessionId = sessionId; } public void setChat(ChatEntity chat) { this.chat = chat; }
public String getText() { return text; } public String getText() { return text; }
public void setText(String text) { this.text = 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 java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface ChatMessageJpaRepository extends JpaRepository<ChatMessageEntity, Long> { public interface ChatMessageJpaRepository extends JpaRepository<ChatMessageEntity, Long> {
@Query(value = """ List<ChatMessageEntity> findByChatUuidOrderByIdAsc(String chatUuid);
SELECT *
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY id ASC) AS rn
FROM chat_messages
) sub
WHERE rn = 1
ORDER BY id desc
""", nativeQuery = true)
List<ChatMessageEntity> findFirstMessagePerSession();
List<ChatMessageEntity> findBySessionIdOrderByIdAsc(String chatId); void deleteByChatUuid(String chatUuid);
void deleteBySessionId(String chatId);
} }

View File

@ -1,6 +1,5 @@
package com.pablotj.ia.chat.boot.persistence; 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.model.ChatMessage;
public class ChatMessageMapper { public class ChatMessageMapper {
@ -9,9 +8,9 @@ public class ChatMessageMapper {
throw new IllegalAccessException("Private access to 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(); ChatMessageEntity entity = new ChatMessageEntity();
entity.setSessionId(chatId); entity.setChat(chat);
entity.setRole(message.role()); entity.setRole(message.role());
entity.setText(message.text()); entity.setText(message.text());
entity.setDate(message.date()); entity.setDate(message.date());
@ -19,10 +18,6 @@ public class ChatMessageMapper {
} }
public static ChatMessage toDomain(ChatMessageEntity entity) { public static ChatMessage toDomain(ChatMessageEntity entity) {
return new ChatMessage(entity.getSessionId(), entity.getRole(), entity.getText(), entity.getDate()); return new ChatMessage(entity.getChat().getUuid(), 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());
} }
} }

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.ChatIdentity;
import com.pablotj.ia.chat.boot.domain.model.ChatMessage; import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore; import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@Repository @Repository
public class SqliteChatMessageStore implements ChatMessageStore { public class SqliteChatMessageStore implements ChatMessageStore {
private final ChatMessageJpaRepository repository; private final ChatJpaRepository chatJpaRepository;
private final ChatMessageJpaRepository chatMessageJpaRepository;
public SqliteChatMessageStore(ChatMessageJpaRepository repository) { public SqliteChatMessageStore(ChatJpaRepository chatJpaRepository, ChatMessageJpaRepository chatMessageJpaRepository) {
this.repository = repository; 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 @Override
public List<ChatIdentity> getChats() { public List<ChatIdentity> getChats() {
return repository.findFirstMessagePerSession().stream() return chatJpaRepository.findAllByOrderByCreatedDateDesc().stream()
.map(ChatMessageMapper::toEntityId) .map(ChatMapper::toDomain)
.toList(); .toList();
} }
@Override @Override
public List<ChatMessage> getMessages(String chatId) { public List<ChatMessage> getMessages(String chatUuid) {
return repository.findBySessionIdOrderByIdAsc(chatId) return chatMessageJpaRepository.findByChatUuidOrderByIdAsc(chatUuid)
.stream() .stream()
.map(ChatMessageMapper::toDomain) .map(ChatMessageMapper::toDomain)
.toList(); .toList();
@ -33,11 +48,31 @@ public class SqliteChatMessageStore implements ChatMessageStore {
@Override @Override
@Transactional @Transactional
public void saveMessages(String chatId, List<ChatMessage> messages) { public void saveMessages(String chatUuid, List<ChatMessage> messages) {
repository.deleteBySessionId(chatId); if (messages == null || messages.isEmpty()) {
List<ChatMessageEntity> entities = messages.stream() return;
.map(m -> ChatMessageMapper.toEntity(chatId, m)) }
.toList();
repository.saveAll(entities); 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 --> <!-- Menú lateral izquierdo -->
<ChatSidebar <ChatSidebar
:chats="chats" :chats="chats"
:current-chat-id="chatId" :current-chat-id="chatUuid"
@select-chat="selectChat" @select-chat="selectChat"
@create-chat="createNewChat" @create-chat="createNewChat"
/> />
@ -25,7 +25,7 @@ import { chatService } from '../services/chatService.ts'
import { dateUtils } from '../utils/dateUtils.ts' import { dateUtils } from '../utils/dateUtils.ts'
// Estado reactivo // Estado reactivo
const chatId = ref('') const chatUuid = ref('')
const chats = ref([]) const chats = ref([])
const messages = ref([]) const messages = ref([])
const isLoading = ref(false) const isLoading = ref(false)
@ -37,9 +37,9 @@ const loadHistory = async () => {
chats.value = data chats.value = data
// Autoabrir primer chat si no hay chatId activo // Autoabrir primer chat si no hay chatId activo
if (!chatId.value && data.length > 0) { if (!chatUuid.value && data.length > 0) {
chatId.value = data[0].id chatUuid.value = data[0].uuid
await loadMessages(chatId.value) await loadMessages(chatUuid.value)
} }
} catch (error) { } catch (error) {
console.error("Error cargando historial:", error) console.error("Error cargando historial:", error)
@ -58,19 +58,19 @@ const loadMessages = async (selectedChatId) => {
// Seleccionar un chat // Seleccionar un chat
const selectChat = async (selectedId) => { const selectChat = async (selectedId) => {
if (selectedId !== chatId.value) { if (selectedId !== chatUuid.value) {
chatId.value = selectedId chatUuid.value = selectedId
await loadMessages(chatId.value) await loadMessages(chatUuid.value)
} }
} }
// Crear nuevo chat // Crear nuevo chat
const createNewChat = async () => { const createNewChat = async () => {
try { try {
const newChatId = await chatService.createChat() const response = await chatService.createChat()
chatId.value = newChatId chatUuid.value = response.uuid;
await loadHistory() await loadHistory()
await loadMessages(chatId.value) await loadMessages(chatUuid.value)
} catch (error) { } catch (error) {
console.error("Error al crear nuevo chat:", error) console.error("Error al crear nuevo chat:", error)
} }
@ -79,7 +79,7 @@ const createNewChat = async () => {
// Enviar mensaje // Enviar mensaje
const sendMessage = async (prompt) => { const sendMessage = async (prompt) => {
// Crear nuevo chat si no existe // Crear nuevo chat si no existe
if (!chatId.value) { if (!chatUuid.value) {
await createNewChat() await createNewChat()
} }
@ -94,8 +94,8 @@ const sendMessage = async (prompt) => {
isLoading.value = true isLoading.value = true
try { try {
const data = await chatService.sendMessage(chatId.value, prompt) const data = await chatService.sendMessage(chatUuid.value, prompt)
chatId.value = data.chatId chatUuid.value = data.chatId
// Agregar respuesta del bot // Agregar respuesta del bot
const botMessage = { const botMessage = {

View File

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

View File

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

View File

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