Apply hexagonal architecture and clean code principles
27
chat-web-client/.gitignore
vendored
Normal 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
|
||||
1
chat-web-client/dist/assets/index-DIsRwQnG.css
vendored
Normal file
23
chat-web-client/dist/assets/index-lEa7rgri.js
vendored
Normal file
BIN
chat-web-client/dist/favicon/apple-touch-icon.png
vendored
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
chat-web-client/dist/favicon/favicon-96x96.png
vendored
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
chat-web-client/dist/favicon/favicon.ico
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
3
chat-web-client/dist/favicon/favicon.svg
vendored
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
chat-web-client/dist/favicon/web-app-manifest-192x192.png
vendored
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
chat-web-client/dist/favicon/web-app-manifest-512x512.png
vendored
Normal file
|
After Width: | Height: | Size: 27 KiB |
19
chat-web-client/dist/index.html
vendored
Normal 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
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
21
chat-web-client/dist/site.webmanifest
vendored
Normal 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"
|
||||
}
|
||||
18
chat-web-client/index.html
Normal 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
18
chat-web-client/package.json
Normal 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
@@ -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>
|
||||
BIN
chat-web-client/public/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
chat-web-client/public/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
chat-web-client/public/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
3
chat-web-client/public/favicon/favicon.svg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
chat-web-client/public/favicon/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
chat-web-client/public/favicon/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
2
chat-web-client/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
21
chat-web-client/public/site.webmanifest
Normal 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"
|
||||
}
|
||||
16
chat-web-client/src/App.vue
Normal 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>
|
||||
36
chat-web-client/src/assets/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
116
chat-web-client/src/components/ChatForm.vue
Normal 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>
|
||||
129
chat-web-client/src/components/ChatLayout.vue
Normal 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>
|
||||
56
chat-web-client/src/components/ChatMain.vue
Normal 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>
|
||||
127
chat-web-client/src/components/ChatMessages.vue
Normal 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>
|
||||
95
chat-web-client/src/components/ChatSidebar.vue
Normal 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>
|
||||
80
chat-web-client/src/components/Spinner.vue
Normal 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>
|
||||
9
chat-web-client/src/main.ts
Normal 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')
|
||||
77
chat-web-client/src/services/chatService.ts
Normal 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()
|
||||
14
chat-web-client/src/utils/dateUtils.ts
Normal 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}`
|
||||
},
|
||||
}
|
||||
15
chat-web-client/vite.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||