Migrate frontend from Thymeleaf and basic JS to Vue.js application
27
frontend/.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
|
18
frontend/dist/assets/index-BoaPXc3b.js
vendored
Normal file
1
frontend/dist/assets/index-Kwm2enAh.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.sidebar[data-v-cd451cf6]{width:25%;min-width:200px;background-color:#1b1d23;color:#fff;padding:1.5rem 1rem;box-sizing:border-box;border-right:1px solid #2c2e34;display:flex;flex-direction:column}.sidebar h2[data-v-cd451cf6]{font-size:1.25rem;margin-bottom:1rem;color:#5a90ff}.new-chat-btn[data-v-cd451cf6]{margin-bottom:10px;padding:8px;font-size:14px;cursor:pointer;background-color:#5a90ff;color:#fff;border:none;border-radius:8px;transition:background-color .2s ease}.new-chat-btn[data-v-cd451cf6]:hover{background-color:#4076e0}.chat-list[data-v-cd451cf6]{list-style:none;padding:0;margin:0;overflow-y:auto;flex-grow:1}.chat-list li[data-v-cd451cf6]{padding:.5rem .75rem;cursor:pointer;border-radius:8px;transition:background-color .2s ease;font-size:.95rem}.chat-list li[data-v-cd451cf6]:hover{background-color:#2c2f3a}.chat-list li.active[data-v-cd451cf6]{background-color:#3451d1;color:#fff;font-weight:700}.chat-log[data-v-96cd1719]{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[data-v-96cd1719]::-webkit-scrollbar{width:6px}.chat-log[data-v-96cd1719]::-webkit-scrollbar-thumb{background-color:#5a90ff55;border-radius:3px}.bubble[data-v-96cd1719]{max-width:75%;padding:14px 18px;border-radius:20px;line-height:1.5;font-size:1rem;word-wrap:break-word;-webkit-user-select:text;user-select:text;transition:background-color .3s ease,color .3s ease;opacity:0;transform:translateY(10px);animation:slideFadeIn-96cd1719 .3s forwards}@keyframes slideFadeIn-96cd1719{to{opacity:1;transform:translateY(0)}}.user[data-v-96cd1719]{background-color:#3451d1;align-self:flex-end;color:#fff;border-radius:20px 20px 4px}.bot[data-v-96cd1719]{background-color:#2a2d35;align-self:flex-start;color:#a4c8ff;font-style:italic;border-radius:20px 20px 20px 4px}.timestamp[data-v-96cd1719]{display:block;text-align:right;font-size:.75rem;color:#999;margin-top:6px;font-style:normal;opacity:.7;-webkit-user-select:none;user-select:none}.spinner[data-v-96cd1719]{margin:1rem auto;width:36px;height:36px;border:4px solid rgba(90,144,255,.2);border-top-color:#5a90ff;border-radius:50%;animation:spin-96cd1719 1s linear infinite}@keyframes spin-96cd1719{to{transform:rotate(360deg)}}.thinking-text[data-v-96cd1719]{text-align:center;color:#5a90ff;font-style:italic;margin-top:.5rem;font-weight:600;-webkit-user-select:none;user-select:none}.chat-form[data-v-dca915bb]{flex-shrink:0;margin-top:1.25rem;display:flex;gap:12px;padding:0;box-sizing:border-box;background:transparent;border-radius:0}textarea[data-v-dca915bb]{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 .25s ease,background-color .25s ease}textarea[data-v-dca915bb]::placeholder{color:#6a6e7c;font-style:italic;opacity:.9}textarea[data-v-dca915bb]:focus{border-color:#5a90ff;background-color:#20232a}button[data-v-dca915bb]{background-color:#5a90ff;border:none;border-radius:14px;color:#fff;padding:0 1.75rem;font-weight:600;font-size:1rem;cursor:pointer;transition:background-color .25s ease,transform .1s ease;white-space:nowrap}button[data-v-dca915bb]:hover:not(:disabled){background-color:#4076e0}button[data-v-dca915bb]:active:not(:disabled){transform:translateY(1px);background-color:#305dc0}button[data-v-dca915bb]:disabled{opacity:.5;cursor:not-allowed;background-color:#3a4a6a}.main-content[data-v-2c6be2f2]{width:75%;box-sizing:border-box;display:flex;flex-direction:column;padding:2rem 1.25rem 1rem;background-color:#121217;overflow:hidden}.main-title[data-v-2c6be2f2]{text-align:center;margin-bottom:1.25rem;color:#5a90ff;font-weight:700;font-size:1.75rem;letter-spacing:.05em;-webkit-user-select:none;user-select:none}.layout-container[data-v-6fe96f7a]{display:flex;height:100vh;width:100vw;overflow:hidden}#app{height:100vh;width:100vw}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}@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}}
|
BIN
frontend/dist/favicon/apple-touch-icon.png
vendored
Normal file
After Width: | Height: | Size: 7.0 KiB |
BIN
frontend/dist/favicon/favicon-96x96.png
vendored
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
frontend/dist/favicon/favicon.ico
vendored
Normal file
After Width: | Height: | Size: 15 KiB |
3
frontend/dist/favicon/favicon.svg
vendored
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
frontend/dist/favicon/web-app-manifest-192x192.png
vendored
Normal file
After Width: | Height: | Size: 7.8 KiB |
BIN
frontend/dist/favicon/web-app-manifest-512x512.png
vendored
Normal file
After Width: | Height: | Size: 27 KiB |
19
frontend/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-BoaPXc3b.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-Kwm2enAh.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
2
frontend/dist/robots.txt
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
21
frontend/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
frontend/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>
|
1132
frontend/package-lock.json
generated
Normal file
17
frontend/package.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "chat-ia-vue",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
BIN
frontend/public/favicon/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
BIN
frontend/public/favicon/favicon-96x96.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
frontend/public/favicon/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
3
frontend/public/favicon/favicon.svg
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
frontend/public/favicon/web-app-manifest-192x192.png
Normal file
After Width: | Height: | Size: 7.8 KiB |
BIN
frontend/public/favicon/web-app-manifest-512x512.png
Normal file
After Width: | Height: | Size: 27 KiB |
2
frontend/public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
21
frontend/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
frontend/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
frontend/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
frontend/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>
|
145
frontend/src/components/ChatLayout.vue
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout-container">
|
||||||
|
<!-- Menú lateral izquierdo -->
|
||||||
|
<ChatSidebar
|
||||||
|
:chats="chats"
|
||||||
|
:current-chat-id="chatId"
|
||||||
|
@select-chat="selectChat"
|
||||||
|
@create-chat="createNewChat"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Área principal del chat -->
|
||||||
|
<ChatMain
|
||||||
|
:messages="messages"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
@send-message="sendMessage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import ChatSidebar from './ChatSidebar.vue'
|
||||||
|
import ChatMain from './ChatMain.vue'
|
||||||
|
import { chatService } from '../services/chatService.js'
|
||||||
|
|
||||||
|
// Estado reactivo
|
||||||
|
const chatId = 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 (!chatId.value && data.length > 0) {
|
||||||
|
chatId.value = data[0].id
|
||||||
|
await loadMessages(chatId.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)
|
||||||
|
messages.value.push({
|
||||||
|
role: "bot",
|
||||||
|
text: "❌ Error cargando mensajes del chat.",
|
||||||
|
date: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seleccionar un chat
|
||||||
|
const selectChat = async (selectedId) => {
|
||||||
|
if (selectedId !== chatId.value) {
|
||||||
|
chatId.value = selectedId
|
||||||
|
await loadMessages(chatId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear nuevo chat
|
||||||
|
const createNewChat = async () => {
|
||||||
|
try {
|
||||||
|
const newChatId = await chatService.createChat()
|
||||||
|
chatId.value = newChatId
|
||||||
|
await loadHistory()
|
||||||
|
await loadMessages(chatId.value)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error al crear nuevo chat:", error)
|
||||||
|
if (messages.value) {
|
||||||
|
messages.value.push({
|
||||||
|
role: "bot",
|
||||||
|
text: "❌ Error creando nuevo chat.",
|
||||||
|
date: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enviar mensaje
|
||||||
|
const sendMessage = async (prompt) => {
|
||||||
|
// Crear nuevo chat si no existe
|
||||||
|
if (!chatId.value) {
|
||||||
|
await createNewChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar mensaje del usuario inmediatamente
|
||||||
|
const userMessage = {
|
||||||
|
role: "user",
|
||||||
|
text: prompt,
|
||||||
|
date: new Date().toISOString()
|
||||||
|
}
|
||||||
|
messages.value.push(userMessage)
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await chatService.sendMessage(chatId.value, prompt)
|
||||||
|
chatId.value = data.chatId
|
||||||
|
|
||||||
|
// Agregar respuesta del bot
|
||||||
|
const botMessage = {
|
||||||
|
role: "bot",
|
||||||
|
text: data.text,
|
||||||
|
date: data.date
|
||||||
|
}
|
||||||
|
messages.value.push(botMessage)
|
||||||
|
|
||||||
|
await loadHistory()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error enviando mensaje:", error)
|
||||||
|
messages.value.push({
|
||||||
|
role: "bot",
|
||||||
|
text: "❌ Error procesando la respuesta.",
|
||||||
|
date: new Date().toISOString()
|
||||||
|
})
|
||||||
|
} 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
frontend/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>
|
143
frontend/src/components/ChatMessages.vue
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-log" ref="chatLog">
|
||||||
|
<div
|
||||||
|
v-for="(msg, index) in messages"
|
||||||
|
:key="index"
|
||||||
|
:class="['bubble', msg.role]"
|
||||||
|
v-show="msg.text"
|
||||||
|
>
|
||||||
|
<span>{{ msg.text }}</span>
|
||||||
|
<em class="timestamp">{{ formatDate(msg.date) }}</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="spinner"></div>
|
||||||
|
<div v-if="isLoading" class="thinking-text">Pensando...</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import { dateUtils } from '../utils/dateUtils.ts'
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
margin: 1rem auto;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 4px solid rgba(90, 144, 255, 0.2);
|
||||||
|
border-top-color: #5a90ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-text {
|
||||||
|
text-align: center;
|
||||||
|
color: #5a90ff;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
95
frontend/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.id"
|
||||||
|
:class="{ active: chat.id === currentChatId }"
|
||||||
|
@click="$emit('select-chat', chat.id)"
|
||||||
|
>
|
||||||
|
{{ chat.name }}
|
||||||
|
</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>
|
5
frontend/src/main.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from "vue"
|
||||||
|
import App from "./App.vue"
|
||||||
|
import "./assets/styles.css"
|
||||||
|
|
||||||
|
createApp(App).mount("#app")
|
63
frontend/src/services/chatService.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
class ChatService {
|
||||||
|
async getChats() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/chats", { 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)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChat() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/chats", {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
return await response.text()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating chat:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChatMessages(chatId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/chats/${chatId}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching messages:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(chatId, prompt) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/chats/${chatId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ prompt }),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending message:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatService = new ChatService()
|
14
frontend/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
frontend/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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|