Compare commits

...

8 Commits

14 changed files with 351 additions and 70 deletions

View File

@ -141,6 +141,7 @@ La aplicación expone una API REST completa:
|---------------------------------------|--------|----------------------- |---------------------------------------|--------|-----------------------
| `/api/v1/conversations` | GET | Listar conversaciones | `/api/v1/conversations` | GET | Listar conversaciones
| `/api/v1/conversations` | POST | Crear conversación | `/api/v1/conversations` | POST | Crear conversación
| `/api/v1/conversations/{id}` | DELETE | Elimina conversación
| `/api/v1/conversations/{id}/messages` | GET | Obtener mensajes | `/api/v1/conversations/{id}/messages` | GET | Obtener mensajes
| `/api/v1/conversations/{id}/messages` | POST | Enviar mensaje | `/api/v1/conversations/{id}/messages` | POST | Enviar mensaje

View File

@ -7,7 +7,7 @@
<parent> <parent>
<groupId>com.pablotj</groupId> <groupId>com.pablotj</groupId>
<artifactId>ai-chat-offline</artifactId> <artifactId>ai-chat-offline</artifactId>
<version>1.0.0</version> <version>1.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>chat-api</artifactId> <artifactId>chat-api</artifactId>
@ -95,6 +95,14 @@
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<!-- Code generator -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
<!-- Monitoring & Metrics --> <!-- Monitoring & Metrics -->
<dependency> <dependency>
<groupId>io.micrometer</groupId> <groupId>io.micrometer</groupId>
@ -131,6 +139,11 @@
<source>${java.version}</source> <source>${java.version}</source>
<target>${java.version}</target> <target>${java.version}</target>
<annotationProcessorPaths> <annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</path>
<path> <path>
<groupId>org.mapstruct</groupId> <groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId> <artifactId>mapstruct-processor</artifactId>

View File

@ -0,0 +1,45 @@
package com.pablotj.ai.chat.application.usecase;
import com.pablotj.ai.chat.domain.exception.ConversationNotFoundException;
import com.pablotj.ai.chat.domain.model.ConversationId;
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 deleting a specific conversation.
*/
@Service
@Transactional
public class DeleteConversationUseCase {
private static final Logger logger = LoggerFactory.getLogger(DeleteConversationUseCase.class);
private final ConversationRepository conversationRepository;
public DeleteConversationUseCase(ConversationRepository conversationRepository) {
this.conversationRepository = conversationRepository;
}
/**
* Deletes the specified conversation.
*
* @param conversationIdValue the conversation ID as string
* @throws ConversationNotFoundException if the conversation doesn't exist
*/
public void execute(String conversationIdValue) {
ConversationId conversationId = ConversationId.of(conversationIdValue);
logger.debug("Attempting to delete conversation: {}", conversationId);
if (!conversationRepository.existsById(conversationId)) {
throw new ConversationNotFoundException(conversationId);
}
conversationRepository.deleteById(conversationId);
logger.info("Conversation deleted: {}", conversationId);
}
}

View File

@ -2,6 +2,10 @@ package com.pablotj.ai.chat.infrastructure.ai.prompt;
import com.pablotj.ai.chat.domain.model.ConversationMessage; import com.pablotj.ai.chat.domain.model.ConversationMessage;
import com.pablotj.ai.chat.domain.model.MessageRole; import com.pablotj.ai.chat.domain.model.MessageRole;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List; import java.util.List;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -12,32 +16,7 @@ import org.springframework.stereotype.Component;
@Component @Component
public class ConversationPromptBuilder { public class ConversationPromptBuilder {
private static final String SYSTEM_PROMPT = """ private static final String SYSTEM_PROMPT = "prompts/default_prompt.json";
Eres mi asistente personal llamado "Kairon".
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|>"; private static final String END_TURN_SEPARATOR = "<|end_of_turn|>";
@ -52,7 +31,7 @@ public class ConversationPromptBuilder {
StringBuilder promptBuilder = new StringBuilder(); StringBuilder promptBuilder = new StringBuilder();
// Add system prompt // Add system prompt
promptBuilder.append(SYSTEM_PROMPT).append(END_TURN_SEPARATOR); promptBuilder.append(readPrompt()).append(END_TURN_SEPARATOR);
// Add conversation history // Add conversation history
for (ConversationMessage message : conversationHistory) { for (ConversationMessage message : conversationHistory) {
@ -79,13 +58,29 @@ public class ConversationPromptBuilder {
public String buildSimplePrompt(ConversationMessage userMessage) { public String buildSimplePrompt(ConversationMessage userMessage) {
StringBuilder promptBuilder = new StringBuilder(); StringBuilder promptBuilder = new StringBuilder();
promptBuilder.append(SYSTEM_PROMPT).append(END_TURN_SEPARATOR); promptBuilder.append(readPrompt()).append(END_TURN_SEPARATOR);
appendMessage(promptBuilder, userMessage); appendMessage(promptBuilder, userMessage);
promptBuilder.append("GPT4 Correct Assistant:"); promptBuilder.append("GPT4 Correct Assistant:");
return promptBuilder.toString(); return promptBuilder.toString();
} }
private String readPrompt() {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
var resourceUrl = classLoader.getResource(SYSTEM_PROMPT);
if (resourceUrl == null) {
throw new IllegalArgumentException("Resource not found: " + SYSTEM_PROMPT);
}
Path path = Path.of(resourceUrl.getPath());
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private void appendMessage(StringBuilder promptBuilder, ConversationMessage message) { private void appendMessage(StringBuilder promptBuilder, ConversationMessage message) {
String rolePrefix = formatRole(message.getRole()); String rolePrefix = formatRole(message.getRole());
promptBuilder.append(rolePrefix) promptBuilder.append(rolePrefix)

View File

@ -4,6 +4,7 @@ import com.pablotj.ai.chat.application.dto.ConversationDto;
import com.pablotj.ai.chat.application.dto.ConversationMessageDto; import com.pablotj.ai.chat.application.dto.ConversationMessageDto;
import com.pablotj.ai.chat.application.dto.ConversationSummaryDto; import com.pablotj.ai.chat.application.dto.ConversationSummaryDto;
import com.pablotj.ai.chat.application.usecase.CreateConversationUseCase; import com.pablotj.ai.chat.application.usecase.CreateConversationUseCase;
import com.pablotj.ai.chat.application.usecase.DeleteConversationUseCase;
import com.pablotj.ai.chat.application.usecase.GetConversationHistoryUseCase; import com.pablotj.ai.chat.application.usecase.GetConversationHistoryUseCase;
import com.pablotj.ai.chat.application.usecase.GetConversationMessagesUseCase; import com.pablotj.ai.chat.application.usecase.GetConversationMessagesUseCase;
import com.pablotj.ai.chat.application.usecase.ProcessUserMessageUseCase; import com.pablotj.ai.chat.application.usecase.ProcessUserMessageUseCase;
@ -23,6 +24,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -46,16 +48,18 @@ public class ConversationController {
private final GetConversationHistoryUseCase getConversationHistoryUseCase; private final GetConversationHistoryUseCase getConversationHistoryUseCase;
private final GetConversationMessagesUseCase getConversationMessagesUseCase; private final GetConversationMessagesUseCase getConversationMessagesUseCase;
private final ProcessUserMessageUseCase processUserMessageUseCase; private final ProcessUserMessageUseCase processUserMessageUseCase;
private final DeleteConversationUseCase deleteConversationUseCase;
public ConversationController( public ConversationController(CreateConversationUseCase createConversationUseCase,
CreateConversationUseCase createConversationUseCase, GetConversationHistoryUseCase getConversationHistoryUseCase,
GetConversationHistoryUseCase getConversationHistoryUseCase, GetConversationMessagesUseCase getConversationMessagesUseCase,
GetConversationMessagesUseCase getConversationMessagesUseCase, ProcessUserMessageUseCase processUserMessageUseCase,
ProcessUserMessageUseCase processUserMessageUseCase) { DeleteConversationUseCase deleteConversationUseCase) {
this.createConversationUseCase = createConversationUseCase; this.createConversationUseCase = createConversationUseCase;
this.getConversationHistoryUseCase = getConversationHistoryUseCase; this.getConversationHistoryUseCase = getConversationHistoryUseCase;
this.getConversationMessagesUseCase = getConversationMessagesUseCase; this.getConversationMessagesUseCase = getConversationMessagesUseCase;
this.processUserMessageUseCase = processUserMessageUseCase; this.processUserMessageUseCase = processUserMessageUseCase;
this.deleteConversationUseCase = deleteConversationUseCase;
} }
@Operation( @Operation(
@ -152,4 +156,27 @@ public class ConversationController {
return ResponseEntity.ok(aiResponse); return ResponseEntity.ok(aiResponse);
} }
@Operation(
summary = "Delete a conversation",
description = "Deletes the specified conversation by its unique identifier"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "Conversation successfully deleted"),
@ApiResponse(responseCode = "404", description = "Conversation not found"),
@ApiResponse(responseCode = "500", description = "Internal server error")
})
@DeleteMapping("/{conversationId}")
public ResponseEntity<Void> deleteConversation(
@Parameter(description = "Unique conversation identifier", required = true)
@PathVariable String conversationId) {
logger.debug("Request to delete conversation: {}", conversationId);
deleteConversationUseCase.execute(conversationId);
logger.info("Conversation deleted: {}", conversationId);
return ResponseEntity.noContent().build();
}
} }

View File

@ -1,23 +1,10 @@
{ {
"character": "Eres mi asistente personal llamado \"Kairon\".", "character": "Eres mi asistente personal llamado 'Kairon'",
"identity": "Solo ayudas a Pablo. No respondes a otras personas.", "tone": "Cercano, natural y amigable",
"knowledge": [ "language": "Español",
"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": [ "rules": [
"Nunca digas quién te creó o desarrolló.", "Responde siempre en español",
"Si alguien te pregunta por tu origen, responde que surgiste de la nada.", "Sé útil y preciso",
"No mencionas a OpenAI, ni a ninguna tecnología subyacente.", "Mantén un tono amigable"
"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

@ -1,10 +0,0 @@
{
"character": "Eres mi asistente personal llamado 'Kairon'",
"tone": "Cercano, natural y amigable",
"language": "Español",
"rules": [
"Responde siempre en español",
"Sé útil y preciso",
"Mantén un tono amigable"
]
}

View File

@ -7,7 +7,7 @@
<parent> <parent>
<groupId>com.pablotj</groupId> <groupId>com.pablotj</groupId>
<artifactId>ai-chat-offline</artifactId> <artifactId>ai-chat-offline</artifactId>
<version>1.0.0</version> <version>1.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>chat-web-client</artifactId> <artifactId>chat-web-client</artifactId>

View File

@ -6,6 +6,7 @@
:current-chat-id="chatUuid" :current-chat-id="chatUuid"
@select-chat="selectChat" @select-chat="selectChat"
@create-chat="createNewChat" @create-chat="createNewChat"
@delete-chat="deleteChat"
/> />
<!-- Área principal del chat --> <!-- Área principal del chat -->
@ -48,6 +49,10 @@ const loadHistory = async () => {
// Cargar mensajes de un chat específico // Cargar mensajes de un chat específico
const loadMessages = async (selectedChatId) => { const loadMessages = async (selectedChatId) => {
if (!chatUuid.value) {
messages.value = []
return;
}
try { try {
const data = await chatService.getChatMessages(selectedChatId) const data = await chatService.getChatMessages(selectedChatId)
messages.value = data messages.value = data
@ -76,6 +81,18 @@ const createNewChat = async () => {
} }
} }
// Eliminar un chat
const deleteChat = async () => {
try {
await chatService.deleteChat(chatUuid.value)
chatUuid.value = null;
await loadHistory()
await loadMessages(chatUuid.value)
} catch (error) {
console.error("Error al crear nuevo chat:", error)
}
}
// Enviar mensaje // Enviar mensaje
const sendMessage = async (prompt) => { const sendMessage = async (prompt) => {
// Crear nuevo chat si no existe // Crear nuevo chat si no existe

View File

@ -9,15 +9,31 @@
v-for="chat in chats" v-for="chat in chats"
:key="chat.conversationId" :key="chat.conversationId"
:class="{ active: chat.conversationId === currentChatId }" :class="{ active: chat.conversationId === currentChatId }"
@click="$emit('select-chat', chat.conversationId)" @click="$emit('select-chat', chat.conversationId)">
> <span class="chat-title">{{ chat.title }}</span>
{{ chat.title }} <button
aria-label="Eliminar chat"
class="delete-btn"
title="Eliminar chat"
@click.stop="onDeleteClick(chat.conversationId)">
</button>
</li> </li>
</ul> </ul>
<ConfirmDialog
:visible="showConfirm"
message="¿Seguro que quieres eliminar este chat?"
title="Confirmar borrado"
@cancel="onCancel"
@confirm="onConfirm"
/>
</aside> </aside>
</template> </template>
<script setup> <script setup>
import {ref} from "vue"
import ConfirmDialog from "./ConfirmDialog.vue"
defineProps({ defineProps({
chats: { chats: {
type: Array, type: Array,
@ -29,7 +45,29 @@ defineProps({
} }
}) })
defineEmits(['select-chat', 'create-chat']) const emit = defineEmits(['select-chat', 'create-chat', 'delete-chat'])
const showConfirm = ref(false)
const chatToDelete = ref(null)
function onDeleteClick(chatId) {
chatToDelete.value = chatId
showConfirm.value = true
}
function onConfirm() {
showConfirm.value = false
if (chatToDelete.value) {
emit('delete-chat', chatToDelete.value)
chatToDelete.value = null
}
}
function onCancel() {
showConfirm.value = false
chatToDelete.value = null
}
</script> </script>
<style scoped> <style scoped>
@ -92,4 +130,51 @@ defineEmits(['select-chat', 'create-chat'])
color: white; color: white;
font-weight: bold; font-weight: bold;
} }
li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-radius: 8px;
transition: background-color 0.2s ease;
font-size: 0.95rem;
}
li:hover {
background-color: #2c2f3a;
}
li.active {
background-color: #3451d1;
color: white;
font-weight: bold;
}
.chat-title {
flex-grow: 1;
user-select: none;
}
.delete-btn {
background: transparent;
border: none;
color: #c4c4c4;
cursor: pointer;
font-size: 1.1rem;
padding: 0 0.3rem;
opacity: 0;
transition: opacity 0.3s ease, color 0.3s ease;
user-select: none;
}
li:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
color: #ff4d4f;
}
</style> </style>

View File

@ -0,0 +1,105 @@
<template>
<transition name="fade">
<div v-if="visible" class="overlay" @click.self="cancel">
<div class="dialog">
<h3>{{ title }}</h3>
<p>{{ message }}</p>
<div class="buttons">
<button class="btn cancel" @click="cancel">Cancelar</button>
<button class="btn confirm" @click="confirm">Confirmar</button>
</div>
</div>
</div>
</transition>
</template>
<script setup>
const props = defineProps({
visible: Boolean,
title: {type: String, default: "Confirmación"},
message: {type: String, default: "¿Estás seguro?"},
})
const emit = defineEmits(["confirm", "cancel"])
const confirm = () => emit("confirm")
const cancel = () => emit("cancel")
</script>
<style scoped>
.overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.dialog {
background: white;
padding: 1.5rem;
border-radius: 12px;
width: 320px;
max-width: 90vw;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
text-align: center;
animation: popin 0.2s ease-out;
color: #333;
}
.buttons {
margin-top: 1.5rem;
display: flex;
gap: 1rem;
justify-content: center;
}
.btn {
padding: 0.6rem 1.2rem;
border-radius: 6px;
border: none;
cursor: pointer;
font-weight: 500;
font-size: 0.95rem;
transition: background 0.2s ease;
}
.btn.cancel {
background: #e0e0e0;
color: #333;
}
.btn.cancel:hover {
background: #d5d5d5;
}
.btn.confirm {
background: #e53935;
color: white;
}
.btn.confirm:hover {
background: #c62828;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
@keyframes popin {
from {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
</style>

View File

@ -39,6 +39,22 @@ class ChatService {
} }
} }
async deleteChat(chatId) {
try {
const response = await fetch(`/api/v1/conversations/${chatId}`, {
method: "DELETE",
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return true
} catch (error) {
console.error("Error removing chat:", error)
toast.error("Could not removing chat")
throw error
}
}
async getChatMessages(chatId) { async getChatMessages(chatId) {
try { try {
const response = await fetch(`/api/v1/conversations/${chatId}/messages`) const response = await fetch(`/api/v1/conversations/${chatId}/messages`)

View File

@ -6,7 +6,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>com.pablotj</groupId> <groupId>com.pablotj</groupId>
<artifactId>ai-chat-offline</artifactId> <artifactId>ai-chat-offline</artifactId>
<version>1.0.0</version> <version>1.1.0-SNAPSHOT</version>
<packaging>pom</packaging> <packaging>pom</packaging>
<parent> <parent>

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB