Apply hexagonal architecture and clean code principles

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

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.0.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,129 @@
<template>
<div class="layout-container">
<!-- Menú lateral izquierdo -->
<ChatSidebar
:chats="chats"
:current-chat-id="chatUuid"
@select-chat="selectChat"
@create-chat="createNewChat"
/>
<!-- Á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) => {
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)
}
}
// 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">🤖 Chat IA 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,95 @@
<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)"
>
{{ chat.title }}
</li>
</ul>
</aside>
</template>
<script setup>
defineProps({
chats: {
type: Array,
required: true
},
currentChatId: {
type: String,
default: ''
}
})
defineEmits(['select-chat', 'create-chat'])
</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;
}
</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,77 @@
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 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,
},
},
},
})