Spaces:
Sleeping
Sleeping
| <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> | |