ValorSentimental / chatbot /src /views /ChatView.vue
iagofp's picture
Fachada DAO, Preparacion Render
f2ee01f
<template>
<v-main class="chat-main">
<v-container fluid class="chat-container">
<AppHero
:username="username"
:history-count="history.length"
@show-history="fullHistoryDialog = true"
/>
<!-- Recently viewed bar -->
<div v-if="history.length" class="recent-bar mb-3">
<span class="recent-label">
<v-icon size="13" class="mr-1">mdi-history</v-icon>
Visto recientemente
</span>
<div class="recent-chips">
<div
v-for="item in history.slice(0, 3)"
:key="item.id"
class="recent-chip"
>
<span class="recent-chip-emoji">{{ emotionEmoji(item.emotion) }}</span>
<span class="recent-chip-title">{{ item.title || item.movie_id }}</span>
<span v-if="item.user_rating != null" class="recent-chip-rating">{{ item.user_rating }}</span>
</div>
</div>
<v-btn
size="x-small"
variant="text"
color="primary"
class="ml-2 flex-shrink-0"
@click="fullHistoryDialog = true"
>
Ver todo
</v-btn>
</div>
<v-row align="start" class="mt-0">
<MessageFeed
:messages="messages"
:loading="loading"
:input="input"
:estrategia="estrategia"
:viewed-movie-ids="viewedMovieIds"
:followup-by-cycle="followupByCycle"
@update:input="input = $event"
@analyze="analyze"
@mark-viewed="markAsViewed"
@update:estrategia="estrategia = $event"
@quick-fill="input = $event"
/>
</v-row>
</v-container>
<!-- Rating Dialog -->
<v-dialog v-model="ratingDialog.open" max-width="420" persistent>
<v-card class="dialog-card" rounded="xl" elevation="24">
<v-card-title class="dialog-title pa-5 pb-3">
<v-icon color="amber-darken-1" size="20" class="mr-2">mdi-star</v-icon>
Valora la película
</v-card-title>
<v-card-text class="px-5 pb-2">
<p class="dialog-movie-name mb-4">{{ ratingDialog.movieTitle }}</p>
<div class="rating-stars-row mb-3">
<v-icon
v-for="n in 5"
:key="n"
:color="n <= ratingDialog.value ? 'amber-darken-1' : 'blue-grey'"
size="34"
style="cursor: pointer"
@click="ratingDialog.value = n"
>
{{ n <= ratingDialog.value ? 'mdi-star' : 'mdi-star-outline' }}
</v-icon>
<span class="rating-label">{{ ratingDialog.value }} / 5</span>
</div>
<v-slider
v-model="ratingDialog.value"
min="1"
max="5"
step="0.5"
color="amber-darken-1"
track-color="rgba(128,128,128,0.15)"
hide-details
/>
</v-card-text>
<v-card-actions class="px-5 pb-4 pt-1">
<v-spacer />
<v-btn variant="text" color="blue-grey" @click="cancelRating">Cancelar</v-btn>
<v-btn color="amber-darken-1" variant="flat" rounded="lg" @click="submitRating">Confirmar</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Post-Viewing Dialog -->
<v-dialog v-model="postDialog.open" max-width="460" persistent>
<v-card class="dialog-card" rounded="xl" elevation="24">
<v-card-title class="dialog-title pa-5 pb-3">
<v-icon color="teal-darken-1" size="20" class="mr-2">mdi-emoticon-happy-outline</v-icon>
¿Cómo te sientes ahora?
</v-card-title>
<v-card-text class="px-5 pb-2">
<p class="dialog-subtitle mb-4">
Después de ver
<strong :style="{ color: 'var(--vs-text)' }">{{ postDialog.movieTitle }}</strong>,
cuéntame cómo estás.
</p>
<v-textarea
v-model="postDialog.text"
variant="outlined"
color="teal-darken-1"
rows="3"
placeholder="Ej: me siento mucho mejor, más tranquilo y relajado..."
auto-grow
hide-details
/>
</v-card-text>
<v-card-actions class="px-5 pb-4 pt-3">
<v-spacer />
<v-btn variant="text" color="blue-grey" @click="cancelPost">Omitir</v-btn>
<v-btn
color="teal-darken-1"
variant="flat"
rounded="lg"
:disabled="!postDialog.text.trim()"
@click="submitPost"
>
Enviar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Full History Dialog -->
<v-dialog v-model="fullHistoryDialog" max-width="500" scrollable>
<v-card class="dialog-card" rounded="xl" elevation="24">
<v-card-title class="dialog-title pa-5 pb-3">
<v-icon color="amber-darken-1" size="20" class="mr-2">mdi-filmstrip</v-icon>
Historial completo
</v-card-title>
<v-card-text class="px-4 pb-2" style="max-height: 420px; overflow-y: auto;">
<template v-if="history.length">
<div
v-for="item in history"
:key="item.id"
class="fh-item"
>
<span class="fh-emoji">{{ emotionEmoji(item.emotion) }}</span>
<div class="fh-body">
<div class="fh-movie">{{ item.title || item.movie_id }}</div>
<div class="fh-meta">
<span>{{ item.emotion || "neutral" }}</span>
<span v-if="item.user_rating != null" class="fh-rating">{{ item.user_rating }}</span>
</div>
</div>
</div>
</template>
<div v-else class="fh-empty">No hay películas en el historial.</div>
</v-card-text>
<v-card-actions class="px-5 pb-4 pt-2">
<v-btn
variant="tonal"
color="error"
size="small"
:disabled="!history.length || clearHistoryLoading"
@click="fullHistoryDialog = false; confirmClearDialog = true"
>
<v-icon size="16" class="mr-1">mdi-delete-sweep-outline</v-icon>
Borrar todo
</v-btn>
<v-spacer />
<v-btn variant="text" color="blue-grey" @click="fullHistoryDialog = false">Cerrar</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Confirm Clear Dialog -->
<v-dialog v-model="confirmClearDialog" max-width="380">
<v-card class="dialog-card" rounded="xl" elevation="24">
<v-card-title class="dialog-title pa-5 pb-3">
<v-icon color="error" size="20" class="mr-2">mdi-delete-sweep-outline</v-icon>
Borrar historial
</v-card-title>
<v-card-text class="px-5">
<p class="dialog-subtitle">
Se eliminará todo tu historial de visionado. Esta acción no se puede deshacer.
</p>
</v-card-text>
<v-card-actions class="px-5 pb-4 pt-2">
<v-spacer />
<v-btn variant="text" color="blue-grey" @click="confirmClearDialog = false">Cancelar</v-btn>
<v-btn
color="error"
variant="flat"
rounded="lg"
:loading="clearHistoryLoading"
@click="confirmClearHistory"
>
Borrar todo
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
timeout="3000"
location="bottom end"
rounded="lg"
>
{{ snackbar.text }}
</v-snackbar>
</v-main>
</template>
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import AppHero from "../components/AppHero.vue";
import MessageFeed from "../components/MessageFeed.vue";
import { emotionInfoBySpanish } from "../constants/emotions";
import API_BASE_URL from "../config.js";
const messages = ref([]);
const input = ref("");
const loading = ref(false);
const userId = ref("");
const username = ref("");
const history = ref([]);
const estrategia = ref(localStorage.getItem("vs_estrategia") || "v1");
const followupByCycle = ref({});
const clearHistoryLoading = ref(false);
const pendingViewOp = ref(null);
const fullHistoryDialog = ref(false);
const ratingDialog = ref({ open: false, value: 3, movieTitle: "" });
const postDialog = ref({ open: false, text: "", movieTitle: "", cycleId: null, movieId: null, movieTitleFull: "" });
const confirmClearDialog = ref(false);
const snackbar = ref({ show: false, text: "", color: "success" });
const viewedMovieIds = computed(() => new Set(history.value.map(i => String(i.movie_id))));
function emotionEmoji(emotion) {
return emotionInfoBySpanish(emotion)?.emoji ?? "🎬";
}
function showSnack(text, color = "success") {
snackbar.value = { show: true, text, color };
}
onMounted(async () => {
const storedUserId = localStorage.getItem("vs_user_id") || "";
const storedToken = localStorage.getItem("vs_token") || "";
const storedUsername = localStorage.getItem("vs_username") || "";
if (storedToken) {
try {
const res = await fetch(`${API_BASE_URL}/auth/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: storedToken }),
});
if (res.ok) {
userId.value = storedUserId;
username.value = storedUsername;
} else {
localStorage.removeItem("vs_user_id");
localStorage.removeItem("vs_username");
localStorage.removeItem("vs_token");
}
} catch {
localStorage.removeItem("vs_user_id");
localStorage.removeItem("vs_username");
localStorage.removeItem("vs_token");
}
}
});
watch(estrategia, v => localStorage.setItem("vs_estrategia", v));
watch(userId, async id => {
if (!id) return;
try {
const res = await fetch(`${API_BASE_URL}/historial?user_id=${encodeURIComponent(id)}&limit=20`);
const data = await res.json();
history.value = data.items || [];
} catch { history.value = []; }
});
async function analyze() {
if (!input.value.trim() || loading.value) return;
const text = input.value.trim();
messages.value.push({ type: "user", text });
input.value = "";
loading.value = true;
try {
const res = await fetch(`${API_BASE_URL}/analizar`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ texto: text, user_id: userId.value, estrategia: estrategia.value }),
});
const data = await res.json();
messages.value.push({
type: "result",
text,
chatbotText: data.chatbot_texto || "",
emotions: data.emociones || [],
dominantEmotion: data.emocion_dominante || "neutral",
dominantValence: data.valencia_dominante || "neutro",
valenciaContinuous: data.valencia_continua ?? null,
arousalActual: data.arousal_actual ?? null,
estrategia: data.estrategia || estrategia.value,
debugRecommendation: data.debug_recomendacion || {},
previousEmotion: data.emocion_anterior || null,
transitionMovie: data.pelicula_transicion || null,
recommendationMode: data.modo_recomendacion || "similar",
recommendationCycleId:data.ciclo_recomendacion_id || null,
recommendations: data.recomendaciones || [],
});
} catch {
messages.value.push({ type: "error", text: "Error al conectar con el " });
} finally {
loading.value = false;
}
}
function markAsViewed(movie, dominantEmotion, sourceText, recommendationCycleId) {
if (!userId.value) return;
pendingViewOp.value = { movie, dominantEmotion, sourceText, recommendationCycleId };
ratingDialog.value = { open: true, value: 3, movieTitle: movie.title || "" };
}
function cancelRating() {
ratingDialog.value.open = false;
pendingViewOp.value = null;
}
async function submitRating() {
if (!pendingViewOp.value) return;
const { movie, dominantEmotion, sourceText, recommendationCycleId } = pendingViewOp.value;
const rating = ratingDialog.value.value;
ratingDialog.value.open = false;
pendingViewOp.value = null;
try {
const res = await fetch(`${API_BASE_URL}/historial/visto`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: userId.value,
movie_id: String(movie.movieId),
title: movie.title || "",
emotion: dominantEmotion || "",
user_rating: rating,
session_text: sourceText || "",
}),
});
if (!res.ok) { showSnack("No se pudo guardar el visionado.", "error"); return; }
const saved = await res.json();
history.value = [saved, ...history.value].slice(0, 20);
showSnack("¡Película guardada en tu historial!");
postDialog.value = {
open: true, text: "",
movieTitle: movie.title || "",
cycleId: recommendationCycleId,
movieId: String(movie.movieId),
movieTitleFull:movie.title || "",
};
} catch { showSnack("Error de conexión.", "error"); }
}
function cancelPost() { postDialog.value.open = false; }
async function submitPost() {
const { text, cycleId, movieId, movieTitleFull } = postDialog.value;
postDialog.value.open = false;
if (!text.trim() || !cycleId) return;
try {
const res = await fetch(`${API_BASE_URL}/recomendacion/seguimiento`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: userId.value,
ciclo_recomendacion_id: cycleId,
movie_id: movieId,
title: movieTitleFull,
texto_post: text.trim(),
}),
});
if (!res.ok) return;
const data = await res.json();
followupByCycle.value = { ...followupByCycle.value, [String(cycleId)]: data };
showSnack("Seguimiento registrado.");
} catch { /* silent */ }
}
async function confirmClearHistory() {
clearHistoryLoading.value = true;
try {
const res = await fetch(`${API_BASE_URL}/historial`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId.value }),
});
if (!res.ok) { showSnack("No se pudo borrar el historial.", "error"); return; }
history.value = [];
showSnack("Historial borrado correctamente.");
} catch { showSnack("Error de conexión.", "error"); }
finally {
clearHistoryLoading.value = false;
confirmClearDialog.value = false;
}
}
</script>
<style scoped>
.chat-main {
min-height: 100vh;
}
.chat-container {
max-width: 1000px;
margin: 0 auto;
padding: 24px 20px 40px;
position: relative;
}
/* Subtle radial decorations */
.chat-container::before {
content: '';
position: fixed;
top: -40%;
right: -8%;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(102,126,234,0.06) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.chat-container::after {
content: '';
position: fixed;
bottom: -20%;
left: -5%;
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(118,75,162,0.04) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
/* Recently viewed bar */
.recent-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background: var(--vs-panel);
border: 1px solid var(--vs-border);
border-radius: 12px;
overflow: hidden;
}
.recent-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--vs-muted);
display: flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
}
.recent-chips {
display: flex;
gap: 6px;
overflow: hidden;
flex: 1;
}
.recent-chip {
display: flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
background: var(--vs-movie-bg);
border: 1px solid var(--vs-border);
border-radius: 999px;
font-size: 0.75rem;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
}
.recent-chip-emoji { flex-shrink: 0; font-size: 0.85rem; }
.recent-chip-title {
overflow: hidden;
text-overflow: ellipsis;
color: var(--vs-text);
font-weight: 500;
}
.recent-chip-rating {
color: #F59E0B;
font-weight: 600;
font-size: 0.7rem;
flex-shrink: 0;
}
/* Full history dialog */
.fh-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
margin-bottom: 4px;
background: var(--vs-movie-bg);
border: 1px solid var(--vs-border);
}
.fh-emoji { font-size: 1.1rem; line-height: 1.4; flex-shrink: 0; }
.fh-body { min-width: 0; }
.fh-movie {
color: var(--vs-text);
font-weight: 600;
font-size: 0.88rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fh-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.76rem;
color: var(--vs-muted);
margin-top: 1px;
}
.fh-rating { color: #F59E0B; font-weight: 600; }
.fh-empty {
text-align: center;
padding: 30px 0;
font-size: 0.85rem;
color: var(--vs-muted);
}
</style>