Compare commits

29 Commits

Author SHA1 Message Date
cef4ef5e4d Add screenshot of application 2025-09-09 20:06:57 +02:00
affb54cf82 Add Lombok dependency and configure annotation processing in Maven build to enable Lombok annotations support 2025-07-20 20:38:19 +02:00
484c79a3fb Clear displayed messages after deleting a chat 2025-07-20 20:06:40 +02:00
07cb426a85 Externalize prompt to resources file and load dynamically 2025-07-20 20:02:45 +02:00
c5e72d5708 Implement delete chat with confirmation dialog 2025-07-20 19:54:10 +02:00
3d7ba68511 Add DELETE endpoint to remove a conversation by ID 2025-07-20 19:19:29 +02:00
d404722844 Start development on 1.1.0-SNAPSHOT 2025-07-20 19:14:05 +02:00
64157482ea Merge branch 'release/1.0.0' into develop 2025-07-20 19:12:57 +02:00
2683b0a0e8 Finalize release 1.0.0 2025-07-20 19:02:21 +02:00
94aab87c27 Prepare release 1.0.0-rc.0 2025-07-20 19:01:06 +02:00
90468dbe45 Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	README.md
2025-07-20 18:46:24 +02:00
6c21ed0417 Rewrite README with technical documentation and setup guide 2025-07-20 18:46:11 +02:00
9034ead349 Rewrite README with technical documentation and setup guide 2025-07-20 18:44:42 +02:00
bdaa8d2463 Apply hexagonal architecture and clean code principles 2025-07-20 17:29:34 +02:00
3844734794 Create ChatEntity to group chat messages under a parent entity 2025-07-20 10:47:16 +02:00
5450d97abc Update gif demo 2025-07-19 21:43:09 +02:00
08df3b8bb7 Correct message date formatting bug 2025-07-19 21:21:01 +02:00
081313a140 Removed classic CSS spinner in favor of reusable Spinner.vue component 2025-07-19 21:17:50 +02:00
9ea98adecc Add toast notifications for messages to improve user feedback 2025-07-19 21:08:40 +02:00
7c3c4e228e Add Swagger UI support using Springdoc OpenAPI 2025-07-19 20:41:16 +02:00
a2b68f6b9f Delete obsolete frontend resource code and dependencies 2025-07-19 20:37:07 +02:00
84cbf9be89 Migrate frontend from Thymeleaf and basic JS to Vue.js application 2025-07-19 20:30:53 +02:00
dc6314384d Split project into backend and frontend modules 2025-07-19 20:28:32 +02:00
de5f58f4a1 Rename project to chat-ia-offline 2025-07-19 20:16:17 +02:00
4a5a8858ab Unique persistent chat IDs and support for chat history navigation
- Chat identifiers are now unique and no longer tied to the user session.
- Users can navigate between previous chats from the sidebar.
- New chats can be created via a dedicated "New Chat" button.
- The most recently started chat is automatically selected on load.
2025-07-19 13:49:46 +02:00
73f3ade0f0 Add SQLite persistence to restore and continue previous conversations 2025-06-29 12:03:11 +02:00
a150ce37f8 Add AI-powered automatic welcome message on chat initialization 2025-06-28 23:49:53 +02:00
ecdc334da9 Update llama dependency and adapt loader to new version.
Add external configuration properties for the model.
Include date and time display on chat messages.
2025-06-28 23:38:51 +02:00
19b692921f Exclude log files from repository versioning to reduce noise 2025-06-28 23:37:07 +02:00
108 changed files with 6004 additions and 784 deletions

6
.gitignore vendored
View File

@@ -35,4 +35,8 @@ build/
### VS Code ###
.vscode/
*.gguf
*.gguf
*.log
*.db

272
README.md
View File

@@ -1,130 +1,248 @@
# 🤖 Proyecto IA - Asistente Personal "Asistente Pablo"
# 🤖 AI Chat Platform - "Kairon"
**Asistente personal de IA completamente offline y privado**
---
## 🚀 Descripción
## Descripción
Este proyecto implementa un **asistente personal basado en IA** totalmente offline, integrado en una aplicación Spring Boot. Utiliza un modelo de lenguaje local (`llama.cpp` / `llama-java`) para ofrecer respuestas naturales, útiles y personalizadas, con un diseño centrado en la arquitectura limpia y extensible.
**Kairon** es una aplicación de chat con inteligencia artificial que funciona completamente offline. Utiliza un modelo
de lenguaje local para generar respuestas naturales y contextuales, manteniendo todas las conversaciones en tu
dispositivo sin enviar datos a servicios externos.
El asistente está pensado para entender tus gustos, estilo y necesidades, respondiendo siempre en español con un tono cercano y amigable.
La aplicación está construida con una arquitectura hexagonal moderna, separando claramente las responsabilidades entre
el frontend (Vue.js), el backend (Spring Boot) y el motor de IA (Llama). Esto permite un código mantenible, escalable y
fácil de extender.
---
## 🧩 Características principales
## Características
- 💬 **Chat inteligente** - Conversaciones naturales con memoria de contexto
- 🔒 **100% Privado** - Todas las conversaciones permanecen en tu dispositivo
- 🌐 **Funciona offline** - No requiere conexión a internet
- 📱 **Interfaz moderna** - Frontend responsive construido con Vue.js 3
- 🏗️ **Arquitectura limpia** - Backend con patrones de diseño empresariales
- 📊 **Monitoreo integrado** - Health checks y métricas de rendimiento
- 🔧 **Altamente configurable** - Prompts y comportamiento personalizables
- 🧠 Modelo LLM local (`openchat-3.5-0106.Q4_K_M.gguf`) ejecutado en CPU o GPU (Metal en Macs M1/M2).
- 🏗️ Arquitectura limpia basada en capas: dominio, aplicación, infraestructura y presentación.
- 📜 Historial de conversación gestionado en sesión HTTP (con opción a persistencia futura).
- 📝 Construcción dinámica de prompts a partir de definiciones JSON estructuradas y multilingües.
- 🌐 API REST para interacción con el asistente.
- 💻 Frontend con Thymeleaf para una interfaz web simple y eficaz.
- 📊 Logging avanzado con Log4j2, con logs a consola y fichero.
- 🔄 Extensible para múltiples perfiles de prompt y configuración personalizada.
---
## Requisitos técnicos
## Arquitectura
El proyecto está organizado como una aplicación multimodular:
```plaintext
ai-chat-platform/
├── chat-api/ # API REST con Spring Boot
│ ├── domain/ # Lógica de negocio
│ ├── application/ # Casos de uso
│ ├── infrastructure/ # Implementaciones técnicas
│ └── presentation/ # Controllers REST
├── chat-web-client/ # Interfaz web con Vue.js
│ ├── src/components/ # Componentes reutilizables
│ ├── src/views/ # Páginas principales
│ └── src/stores/ # Estado global
└── models/ # Modelos de IA (Llama)
```
### Stack Tecnológico
**Backend:**
- Java 21 con Spring Boot 3
- Arquitectura hexagonal con DDD
- SQLite para persistencia
- Llama.cpp para inferencia de IA
- OpenAPI para documentación
**Frontend:**
- Vue.js 3 con Composition API
- TypeScript para tipado estático
- Pinia para gestión de estado
- Vite como bundler
```plaintext
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Frontend (Vue.js) │ │ Backend (Spring) │ │ AI Engine │
│ │ │ │ │ │
│ • Vue 3 + TypeScript│◄──►│ • Hexagonal Arch │◄──►│ • Llama Model │
│ • Pinia Store │ │ • Domain-Driven │ │ • Offline Inference │
│ • Modern UI/UX │ │ • REST API │ │ • Custom Prompts │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
│ │ │
└───────────────────────────┼───────────────────────────┘
┌─────────────────────┐
│ SQLite Database │
│ │
│ • Conversations │
│ • Messages │
│ • User Preferences │
└─────────────────────┘
```
---
## Instalación
### Requisitos
- Java 21+
- Maven 4+
- Spring Boot 3+
- Dependencias principales:
- `llama-java` para LLM local
- Jackson para JSON
- Log4j2 para logging
- Modelo `openchat-3.5-0106.Q4_K_M.gguf` en carpeta `models/`
- Node.js 18+
- Maven 3.8+
- 8GB RAM (16GB recomendado)
- 4GB espacio libre
### Instalación Rápida
```shellscript
# Clonar repositorio
git clone https://github.com/pablotj/ai-chat-platform.git
cd ai-chat-platform
# Descargar modelo de IA
mkdir models
cd models
wget https://huggingface.co/TheBloke/openchat-3.5-0106-GGUF/resolve/main/openchat-3.5-0106.Q4_K_M.gguf
# Ejecutar backend
cd ../chat-api
mvn spring-boot:run
# En otra terminal, ejecutar frontend
cd ../chat-web-client
npm install
npm run dev
```
### Con Docker
```shellscript
docker-compose up -d
```
---
## 🛠️ Instalación y ejecución
## Uso
1. Clonar el repositorio:
1. **Acceder a la aplicación**: `http://localhost:3000`
2. **Crear nueva conversación** o seleccionar una existente
3. **Escribir mensaje** y recibir respuesta de la IA
4. **Historial automático** - Las conversaciones se guardan localmente
```bash
git clone https://github.com/tuusuario/ia-asistente-personal.git
cd ia-asistente-personal
```
### API REST
2. Colocar el modelo en `models/`:
La aplicación expone una API REST completa:
```bash
mkdir models
# Copia aquí openchat-3.5-0106.Q4_K_M.gguf
```
| Endpoint | Método | Descripción
|---------------------------------------|--------|-----------------------
| `/api/v1/conversations` | GET | Listar conversaciones
| `/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` | POST | Enviar mensaje
3. Compilar con Maven:
**Documentación completa**: `http://localhost:8080/api/v1/swagger-ui.html`
```bash
mvn clean package
```
## Capturas de Pantalla
4. Crear carpeta de logs:
### **Interfaz de Chat**
```bash
mkdir logs
```
5. Ejecutar la aplicación:
```bash
java -jar target/ia-asistente-personal.jar
```
6. Abrir en el navegador:
```
http://localhost:8080/
```
![demo.gif](demo.gif)
---
## 🎯 Uso
## Configuración
- En la web puedes chatear con tu asistente personalizado.
- El historial de la conversación se mantiene en sesión.
- Las respuestas se generan localmente, sin conexión a internet.
- Puedes cambiar el perfil de prompt editando los JSON en `src/main/resources/prompts/`.
### Personalización del Asistente
Edita `chat-api/src/main/resources/prompts/system_prompt.json`:
```json
{
"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"
]
}
```
### Configuración del Modelo
En `backend/src/main/resources/application.yml`:
```yaml
ai:
model:
name: openchat-3.5-0106.Q4_K_M
path: models/${ai.model.name}.gguf
inference:
max-tokens: 2048
temperature: 0.7
gpu:
enabled: true
layers: 35
```
---
## 🎥 Demostración
## Testing
![Demo de funcionamiento](demo.gif)
```shellscript
# Tests del backend
cd chat-api
mvn test
*Reemplaza la URL anterior con el enlace a tu GIF de demostración.*
# Tests del frontend
cd chat-web-client
npm run test
npm run test:e2e
```
---
## 🧪 Configuración avanzada
## Monitoreo
- **GPU Metal** en macOS M1/M2: configura el backend llama.cpp con soporte Metal y coloca el shader `ggml-metal.metal` en la ruta correcta.
- **Logs:** configurados con Log4j2, salida a consola y a `logs/app.log`.
- **Múltiples perfiles:** cambia el prompt cargando otros JSONs como `developer_prompt.json`.
La aplicación incluye endpoints de monitoreo:
- **Health Check**: `http://localhost:8080/api/actuator/health`
- **Métricas**: `http://localhost:8080/api/actuator/metrics`
- **Info**: `http://localhost:8080/api/actuator/info`
---
## 📚 Recursos
## Contribuir
| Herramienta | Enlace |
| ---------------- | ------------------------------------------------ |
| llama.cpp | [https://github.com/ggerganov/llama.cpp](https://github.com/ggerganov/llama.cpp) |
| llama-java | [https://github.com/kherud/llama-java](https://github.com/kherud/llama-java) |
| Spring Boot | [https://spring.io/projects/spring-boot](https://spring.io/projects/spring-boot) |
Las contribuciones son bienvenidas. Para contribuir:
1. Fork el proyecto
2. Crea una rama feature (`git checkout -b feature/nueva-funcionalidad`)
3. Commit los cambios (`git commit -m 'Añadir nueva funcionalidad'`)
4. Push a la rama (`git push origin feature/nueva-funcionalidad`)
5. Abre un Pull Request
---
## 🤝 Contribuciones
## Licencia
¿Quieres mejorar el proyecto? ¡Bienvenido!
Por favor, abre un issue o pull request con tus mejoras.
Este proyecto está bajo la Licencia MIT. Ver [LICENSE](LICENSE) para más detalles.
---
## ⚠️ Licencia
## Recursos
MIT License © Pablo de la Torre Jamardo
- **[llama.cpp](https://github.com/ggerganov/llama.cpp)** - Motor de inferencia IA
- **[Spring Boot](https://spring.io/projects/spring-boot)** - Framework backend
- **[Vue.js](https://vuejs.org/)** - Framework frontend
- **[OpenChat](https://huggingface.co/openchat)** - Modelo de lenguaje utilizado
---
*¡Gracias por usar el Asistente Pablo!*
*Desarrollado por [Pablo TJ](https://github.com/pablotj)*

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

@@ -0,0 +1,157 @@
<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.1.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>
<!-- Code generator -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</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.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</path>
<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,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

@@ -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,100 @@
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.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
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 = "prompts/default_prompt.json";
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(readPrompt()).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(readPrompt()).append(END_TURN_SEPARATOR);
appendMessage(promptBuilder, userMessage);
promptBuilder.append("GPT4 Correct Assistant:");
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) {
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,182 @@
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.DeleteConversationUseCase;
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.DeleteMapping;
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;
private final DeleteConversationUseCase deleteConversationUseCase;
public ConversationController(CreateConversationUseCase createConversationUseCase,
GetConversationHistoryUseCase getConversationHistoryUseCase,
GetConversationMessagesUseCase getConversationMessagesUseCase,
ProcessUserMessageUseCase processUserMessageUseCase,
DeleteConversationUseCase deleteConversationUseCase) {
this.createConversationUseCase = createConversationUseCase;
this.getConversationHistoryUseCase = getConversationHistoryUseCase;
this.getConversationMessagesUseCase = getConversationMessagesUseCase;
this.processUserMessageUseCase = processUserMessageUseCase;
this.deleteConversationUseCase = deleteConversationUseCase;
}
@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);
}
@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

@@ -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,10 @@
{
"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"
]
}

27
chat-web-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# next.js
/.next/
/out/
# production
/build
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
chat-web-client/dist/favicon/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

19
chat-web-client/dist/index.html vendored Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat IA Offline - Vue App</title>
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-lEa7rgri.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DIsRwQnG.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

2
chat-web-client/dist/robots.txt vendored Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

21
chat-web-client/dist/site.webmanifest vendored Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat IA Offline - Vue App</title>
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1145
chat-web-client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "chat-ia-vue",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-toast-notification": "^3.1.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
}

18
chat-web-client/pom.xml Normal file
View File

@@ -0,0 +1,18 @@
<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>ai-chat-offline</artifactId>
<version>1.1.0-SNAPSHOT</version>
</parent>
<artifactId>chat-web-client</artifactId>
<name>ai-chat-frontend</name>
<description>Frontend Vue.js App</description>
<packaging>pom</packaging>
</project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -0,0 +1,16 @@
<template>
<div id="app">
<ChatLayout />
</div>
</template>
<script setup>
import ChatLayout from './components/ChatLayout.vue'
</script>
<style>
#app {
height: 100vh;
width: 100vw;
}
</style>

View File

@@ -0,0 +1,36 @@
/* Base y fuente */
html,
body {
margin: 0;
padding: 0;
height: 100%;
background: #121217;
color: #e0e4e8;
font-family: "Segoe UI Variable", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
padding: 0;
height: 100vh;
background-color: #121217;
}
/* Responsive */
@media (max-width: 768px) {
.layout-container {
flex-direction: column;
}
.sidebar {
width: 100% !important;
border-right: none;
border-bottom: 1px solid #2c2e34;
}
.main-content {
width: 100% !important;
}
}

View File

@@ -0,0 +1,116 @@
<template>
<form class="chat-form" @submit.prevent="handleSubmit">
<textarea
v-model="prompt"
aria-label="Pregunta del usuario"
placeholder="Escribe tu mensaje..."
autocomplete="off"
required
:disabled="disabled"
@keydown.enter.exact.prevent="handleSubmit"
@keydown.enter.shift.exact="addNewLine"
></textarea>
<button
type="submit"
:disabled="disabled || !prompt.trim()"
>
Enviar
</button>
</form>
</template>
<script setup>
import {ref} from 'vue'
defineProps({
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['send-message'])
const prompt = ref('')
const handleSubmit = () => {
if (!prompt.value.trim()) return
emit('send-message', prompt.value.trim())
prompt.value = ''
}
const addNewLine = () => {
prompt.value += '\n'
}
</script>
<style scoped>
.chat-form {
flex-shrink: 0;
margin-top: 1.25rem;
display: flex;
gap: 12px;
padding: 0;
box-sizing: border-box;
background: transparent;
border-radius: 0;
}
textarea {
flex-grow: 1;
min-height: 4.25rem;
border-radius: 16px;
border: 1px solid #2d2f36;
padding: 1rem 1.25rem;
font-size: 1rem;
resize: none;
outline: none;
background-color: #1c1e24;
color: #e6e9ef;
font-family: inherit;
max-width: 100%;
overflow-y: auto;
box-sizing: border-box;
transition: border-color 0.25s ease, background-color 0.25s ease;
}
textarea::placeholder {
color: #6a6e7c;
font-style: italic;
opacity: 0.9;
}
textarea:focus {
border-color: #5a90ff;
background-color: #20232a;
}
button {
background-color: #5a90ff;
border: none;
border-radius: 14px;
color: #ffffff;
padding: 0 1.75rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.25s ease, transform 0.1s ease;
white-space: nowrap;
}
button:hover:not(:disabled) {
background-color: #4076e0;
}
button:active:not(:disabled) {
transform: translateY(1px);
background-color: #305dc0;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: #3a4a6a;
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<div class="layout-container">
<!-- Menú lateral izquierdo -->
<ChatSidebar
:chats="chats"
:current-chat-id="chatUuid"
@select-chat="selectChat"
@create-chat="createNewChat"
@delete-chat="deleteChat"
/>
<!-- Área principal del chat -->
<ChatMain
:messages="messages"
:is-loading="isLoading"
@send-message="sendMessage"
/>
</div>
</template>
<script setup>
import {onMounted, ref} from 'vue'
import ChatSidebar from './ChatSidebar.vue'
import ChatMain from './ChatMain.vue'
import {chatService} from '../services/chatService.ts'
import {dateUtils} from '../utils/dateUtils.ts'
// Estado reactivo
const chatUuid = ref('')
const chats = ref([])
const messages = ref([])
const isLoading = ref(false)
// Cargar historial de chats
const loadHistory = async () => {
try {
const data = await chatService.getChats()
chats.value = data
// Autoabrir primer chat si no hay chatId activo
if (!chatUuid.value && data.length > 0) {
chatUuid.value = data[0].conversationId
await loadMessages(chatUuid.value)
}
} catch (error) {
console.error("Error cargando historial:", error)
}
}
// Cargar mensajes de un chat específico
const loadMessages = async (selectedChatId) => {
if (!chatUuid.value) {
messages.value = []
return;
}
try {
const data = await chatService.getChatMessages(selectedChatId)
messages.value = data
} catch (error) {
console.error("Error al cargar mensajes:", error)
}
}
// Seleccionar un chat
const selectChat = async (selectedId) => {
if (selectedId !== chatUuid.value) {
chatUuid.value = selectedId
await loadMessages(chatUuid.value)
}
}
// Crear nuevo chat
const createNewChat = async () => {
try {
const response = await chatService.createChat()
chatUuid.value = response.conversationId;
await loadHistory()
await loadMessages(chatUuid.value)
} catch (error) {
console.error("Error al crear nuevo chat:", error)
}
}
// 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
const sendMessage = async (prompt) => {
// Crear nuevo chat si no existe
if (!chatUuid.value) {
await createNewChat()
}
// Agregar mensaje del usuario inmediatamente
const userMessage = {
role: "USER",
content: prompt,
createdAt: dateUtils.formatDate(new Date())
}
messages.value.push(userMessage)
isLoading.value = true
try {
const data = await chatService.sendMessage(chatUuid.value, prompt)
chatUuid.value = data.conversationId
// Agregar respuesta del bot
const botMessage = {
role: "ASSISTANT",
content: data.content,
createdAt: data.createdAt
}
messages.value.push(botMessage)
await loadHistory()
} catch (error) {
console.error("Error enviando mensaje:", error)
} finally {
isLoading.value = false
}
}
// Inicializar al montar el componente
onMounted(async () => {
await loadHistory()
})
</script>
<style scoped>
.layout-container {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<main class="main-content">
<h1 class="main-title">🤖Kairon - AI Chat Offline</h1>
<ChatMessages
:messages="messages"
:is-loading="isLoading"
/>
<ChatForm @send-message="handleSendMessage" :disabled="isLoading" />
</main>
</template>
<script setup>
import ChatMessages from './ChatMessages.vue'
import ChatForm from './ChatForm.vue'
defineProps({
messages: {
type: Array,
required: true
},
isLoading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['send-message'])
const handleSendMessage = (message) => {
emit('send-message', message)
}
</script>
<style scoped>
.main-content {
width: 75%;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 2rem 1.25rem 1rem;
background-color: #121217;
overflow: hidden;
}
.main-title {
text-align: center;
margin-bottom: 1.25rem;
color: #5a90ff;
font-weight: 700;
font-size: 1.75rem;
letter-spacing: 0.05em;
user-select: none;
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="chat-log" ref="chatLog">
<div
v-for="(msg, index) in messages"
:key="index"
:class="['bubble', msg.role]"
v-show="msg.content"
>
<span>{{ msg.content }}</span>
<em class="timestamp">{{ msg.createdAt }}</em>
</div>
</div>
<Spinner v-if="isLoading" :overlay="false" />
<div v-if="isLoading" class="thinking-text">Pensando...</div>
</template>
<script setup>
import {nextTick, ref, watch} from 'vue'
import Spinner from './Spinner.vue'
const props = defineProps({
messages: {
type: Array,
required: true
},
isLoading: {
type: Boolean,
default: false
}
})
const chatLog = ref(null)
// Scroll al final cuando se agregan mensajes
watch(() => props.messages.length, async () => {
await nextTick()
if (chatLog.value) {
chatLog.value.scrollTop = chatLog.value.scrollHeight
}
})
const formatDate = (date) => {
return dateUtils.formatDate(date)
}
</script>
<style scoped>
.chat-log {
flex: 1 1 auto;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding: 1.25rem 1.5rem;
box-sizing: border-box;
background-color: #1a1c22;
border-radius: 16px;
scrollbar-width: thin;
scrollbar-color: #5a90ff33 transparent;
}
.chat-log::-webkit-scrollbar {
width: 6px;
}
.chat-log::-webkit-scrollbar-thumb {
background-color: #5a90ff55;
border-radius: 3px;
}
.bubble {
max-width: 75%;
padding: 14px 18px;
border-radius: 20px;
line-height: 1.5;
font-size: 1rem;
word-wrap: break-word;
user-select: text;
transition: background-color 0.3s ease, color 0.3s ease;
opacity: 0;
transform: translateY(10px);
animation: slideFadeIn 0.3s forwards;
}
@keyframes slideFadeIn {
to {
opacity: 1;
transform: translateY(0);
}
}
.USER {
background-color: #3451d1;
align-self: flex-end;
color: #ffffff;
border-radius: 20px 20px 4px 20px;
}
.ASSISTANT {
background-color: #2a2d35;
align-self: flex-start;
color: #a4c8ff;
font-style: italic;
border-radius: 20px 20px 20px 4px;
}
.timestamp {
display: block;
text-align: right;
font-size: 0.75rem;
color: #999;
margin-top: 6px;
font-style: normal;
opacity: 0.7;
user-select: none;
}
.thinking-text {
text-align: center;
color: #5a90ff;
font-style: italic;
margin-top: 0.5rem;
font-weight: 600;
user-select: none;
}
</style>

View File

@@ -0,0 +1,180 @@
<template>
<aside class="sidebar">
<h2>🗂 Chats</h2>
<button class="new-chat-btn" @click="$emit('create-chat')">
Nuevo chat
</button>
<ul class="chat-list">
<li
v-for="chat in chats"
:key="chat.conversationId"
:class="{ active: chat.conversationId === currentChatId }"
@click="$emit('select-chat', chat.conversationId)">
<span class="chat-title">{{ chat.title }}</span>
<button
aria-label="Eliminar chat"
class="delete-btn"
title="Eliminar chat"
@click.stop="onDeleteClick(chat.conversationId)">
</button>
</li>
</ul>
<ConfirmDialog
:visible="showConfirm"
message="¿Seguro que quieres eliminar este chat?"
title="Confirmar borrado"
@cancel="onCancel"
@confirm="onConfirm"
/>
</aside>
</template>
<script setup>
import {ref} from "vue"
import ConfirmDialog from "./ConfirmDialog.vue"
defineProps({
chats: {
type: Array,
required: true
},
currentChatId: {
type: String,
default: ''
}
})
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>
<style scoped>
.sidebar {
width: 25%;
min-width: 200px;
background-color: #1b1d23;
color: #ffffff;
padding: 1.5rem 1rem;
box-sizing: border-box;
border-right: 1px solid #2c2e34;
display: flex;
flex-direction: column;
}
.sidebar h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #5a90ff;
}
.new-chat-btn {
margin-bottom: 10px;
padding: 8px;
font-size: 14px;
cursor: pointer;
background-color: #5a90ff;
color: white;
border: none;
border-radius: 8px;
transition: background-color 0.2s ease;
}
.new-chat-btn:hover {
background-color: #4076e0;
}
.chat-list {
list-style: none;
padding: 0;
margin: 0;
overflow-y: auto;
flex-grow: 1;
}
.chat-list li {
padding: 0.5rem 0.75rem;
cursor: pointer;
border-radius: 8px;
transition: background-color 0.2s ease;
font-size: 0.95rem;
}
.chat-list li:hover {
background-color: #2c2f3a;
}
.chat-list li.active {
background-color: #3451d1;
color: white;
font-weight: bold;
}
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>

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

@@ -0,0 +1,80 @@
<template>
<div class="spinner-container" :class="{ overlay }">
<svg
class="spinner"
width="50"
height="50"
viewBox="0 0 50 50"
aria-label="Cargando"
role="img"
>
<circle
class="spinner-path"
cx="25"
cy="25"
r="20"
fill="none"
stroke-width="5"
/>
</svg>
</div>
</template>
<script setup>
defineProps({
overlay: {
type: Boolean,
default: false,
},
})
</script>
<style scoped>
.spinner-container {
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
}
.spinner-container.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(255, 255, 255, 0.6);
z-index: 9999;
}
.spinner {
animation: rotate 2s linear infinite;
}
.spinner-path {
stroke: #5a90ff;
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
}
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
</style>

View File

@@ -0,0 +1,9 @@
import {createApp} from "vue"
import App from "./App.vue"
import ToastPlugin from 'vue-toast-notification'
import 'vue-toast-notification/dist/theme-bootstrap.css'
import "./assets/styles.css"
const app = createApp(App)
app.use(ToastPlugin)
app.mount('#app')

View File

@@ -0,0 +1,93 @@
import {useToast} from 'vue-toast-notification'
const toast = useToast({
position: 'top-right',
duration: 3000,
dismissible: true,
queue: false,
pauseOnHover: true,
})
class ChatService {
async getChats() {
try {
const response = await fetch("/api/v1/conversations", {method: "GET"})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.json()
} catch (error) {
console.error("Error fetching chats:", error)
toast.error("Error loading chats")
throw error
}
}
async createChat() {
try {
const response = await fetch("/api/v1/conversations", {
method: "POST",
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.json()
} catch (error) {
console.error("Error creating chat:", error)
toast.error("Could not create chat")
throw error
}
}
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) {
try {
const response = await fetch(`/api/v1/conversations/${chatId}/messages`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.json()
} catch (error) {
console.error("Error fetching messages:", error)
toast.error("Failed to load messages")
throw error
}
}
async sendMessage(chatId, prompt) {
try {
const response = await fetch(`/api/v1/conversations/${chatId}/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({content: prompt}),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.json()
} catch (error) {
console.error("Error sending message:", error)
toast.error("Message could not be sent")
throw error
}
}
}
export const chatService = new ChatService()

View File

@@ -0,0 +1,14 @@
export const dateUtils = {
formatDate(date) {
if (!date) return ""
const d = new Date(date)
const day = d.getDate().toString().padStart(2, "0")
const month = (d.getMonth() + 1).toString().padStart(2, "0")
const year = d.getFullYear()
const hours = d.getHours().toString().padStart(2, "0")
const minutes = d.getMinutes().toString().padStart(2, "0")
return `${day}/${month}/${year} ${hours}:${minutes}`
},
}

View File

@@ -0,0 +1,15 @@
import {defineConfig} from "vite"
import vue from "@vitejs/plugin-vue"
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
})

BIN
demo.gif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 8.1 MiB

View File

@@ -1 +0,0 @@
[1751135918] warming up the model with an empty run

55
pom.xml
View File

@@ -4,6 +4,10 @@
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>
<groupId>com.pablotj</groupId>
<artifactId>ai-chat-offline</artifactId>
<version>1.1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
@@ -12,50 +16,15 @@
<relativePath/>
</parent>
<groupId>com.pablotj</groupId>
<artifactId>ia-chat-boot</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ia-chat-boot</name>
<description>Project IA chat boot</description>
<modules>
<module>chat-api</module>
<module>chat-web-client</module>
</modules>
<name>ai-chat-offline-platform</name>
<description>Project AI Chat Offline</description>
<properties>
<java.version>17</java.version>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Thymeleaf template engine -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- llama-java (IA offline) -->
<dependency>
<groupId>de.kherud</groupId>
<artifactId>llama</artifactId>
<version>3.4.1</version>
</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>

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

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,29 +0,0 @@
package com.pablotj.ia.chat.boot.adapter.controller;
import com.pablotj.ia.chat.boot.web.session.ChatSessionManager;
import jakarta.servlet.http.HttpSession;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/chat")
public class ChatPageController {
private static final Logger LOGGER = LogManager.getLogger(ChatPageController.class);
private final ChatSessionManager chatSessionManager;
public ChatPageController(ChatSessionManager chatSessionManager) {
this.chatSessionManager = chatSessionManager;
}
@GetMapping
public String showChat(Model model, HttpSession session) {
LOGGER.debug("Accessing to chat");
model.addAttribute("messages", chatSessionManager.getMessages(session));
return "chat";
}
}

View File

@@ -1,37 +0,0 @@
package com.pablotj.ia.chat.boot.adapter.controller;
import com.pablotj.ia.chat.boot.application.usecase.ChatUseCase;
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
import jakarta.servlet.http.HttpSession;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/chat")
public class ChatRestController {
private static final Logger LOGGER = LogManager.getLogger(ChatRestController.class);
private final ChatUseCase chatUseCase;
public ChatRestController(ChatUseCase chatUseCase) {
this.chatUseCase = chatUseCase;
}
@PostMapping(consumes = "application/x-www-form-urlencoded", produces = "application/json")
public ResponseEntity<ChatMessage> handleChat(@RequestParam("prompt") String prompt, HttpSession session) {
ChatMessage reply;
try {
reply = chatUseCase.processUserPrompt(prompt, session);
} catch (Exception e) {
LOGGER.error(e.getMessage(), e);
reply = new ChatMessage("bot", e.getMessage());
}
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.thymeleaf.util.StringUtils;
public class PromptBuilder {
private static final String END_TURN_SEPARATOR = "<|end_of_turn|>";
private final String systemPrompt;
private final List<String> turns = new ArrayList<>();
public PromptBuilder(String systemPrompt) {
this.systemPrompt = systemPrompt;
}
public void user(String message) {
turns.add(this.formatMessage(MessageType.USER, message));
}
public void assistant(String message) {
turns.add(this.formatMessage(MessageType.ASSISTANT, message));
}
public String build() {
StringBuilder sb = new StringBuilder();
sb.append(systemPrompt).append(END_TURN_SEPARATOR);
for (String turn : turns) {
sb.append(turn);
}
sb.append("GPT4 Correct Assistant:");
return sb.toString();
}
private String formatMessage(MessageType messageType, String message) {
return String.format("GPT4 Correct %s: %s %s", StringUtils.capitalize(messageType.name().toLowerCase()), message, END_TURN_SEPARATOR);
}
private enum MessageType {USER, ASSISTANT}
}

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 name.
*
* @param profileName name of the prompts profile, without extension (e.g. "default")
* @return full system prompts as String
*/
public static String get(String profileName) {
return load(profileName).buildPrompt();
}
/**
* Loads and returns the PromptDefinition for the given profile.
*
* @param profileName prompts profile name (e.g. "developer", "minimal")
* @return PromptDefinition object
*/
public static PromptDefinition load(String profileName) {
String filePath = BASE_PATH + profileName + "_prompt.json";
try (InputStream input = PromptTemplates.class.getResourceAsStream(filePath)) {
if (input == null) {
throw new BusinessLogicException("Prompt profile not found: " + filePath);
}
return OBJECT_MAPPER.readValue(input, PromptDefinition.class);
} catch (IOException e) {
throw new BusinessLogicException("Failed to load prompts profile: " + profileName, e);
}
}
/**
* Shortcut for default profile.
*/
public static String getDefault() {
return get(DEFAULT_PROFILE);
}
private PromptTemplates() {
// Utility class
}
}

View File

@@ -1,48 +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.domain.model.ChatMessage;
import com.pablotj.ia.chat.boot.domain.service.ChatService;
import com.pablotj.ia.chat.boot.infraestructure.llm.LlmModelClient;
import com.pablotj.ia.chat.boot.web.session.ChatSessionManager;
import jakarta.servlet.http.HttpSession;
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 ChatMessage processUserPrompt(String prompt, HttpSession session) {
List<ChatMessage> messages = sessionManager.getMessages(session);
messages.add(new ChatMessage(ATTR_ROLE_USER, prompt));
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(ATTR_ROLE_BOT, result);
messages.add(reply);
sessionManager.setMessages(session, 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 ChatMessage(String role, String text) implements Serializable {
}

View File

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

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