Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Serenity — Emotional Companion</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| body { transition: background 1.2s ease; background: linear-gradient(135deg,#f2f6ff,#fff0f6); font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue"; } | |
| .card { max-width:960px; margin:28px auto; display:flex; gap:20px; align-items:flex-start; padding:20px; border-radius:18px; background: rgba(255,255,255,0.55); box-shadow: 0 10px 30px rgba(12,12,20,0.08); backdrop-filter: blur(8px); } | |
| .left { width:220px; display:flex; flex-direction:column; align-items:center; gap:12px; } | |
| .avatarOrb, .avatarHum { width:150px; height:150px; border-radius:50%; display:flex; align-items:center; justify-content:center; } | |
| .avatarOrb { background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.5), rgba(255,255,255,0.05)), radial-gradient(circle at 60% 60%, rgba(120,170,255,0.5), rgba(80,100,255,0.2)); box-shadow: 0 6px 30px rgba(80,110,255,0.18); animation: breatheOrb 4.5s ease-in-out infinite; } | |
| .avatarHum { background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.7), rgba(255,255,255,0.05)), linear-gradient(135deg,#fef3c7,#fbcfe8); box-shadow: 0 6px 30px rgba(200,120,200,0.12); animation: breatheHum 5s ease-in-out infinite; display:none; } | |
| @keyframes breatheOrb { 0%{transform:scale(1);opacity:0.9;}50%{transform:scale(1.08);opacity:1;}100%{transform:scale(1);opacity:0.92;} } | |
| @keyframes breatheHum { 0%{transform:scale(1);opacity:0.9;}50%{transform:scale(1.06);opacity:1;}100%{transform:scale(1);opacity:0.92;} } | |
| .status { font-size:14px; color:#334155; text-align:center; } | |
| .controls { display:flex; gap:8px; margin-top:6px; flex-wrap:wrap; } | |
| .controls button, .controls select { padding:8px 12px; border-radius:10px; border:none; cursor:pointer; font-weight:600; background:rgba(255,255,255,0.9); } | |
| .main { flex:1; display:flex; flex-direction:column; gap:12px; } | |
| .chatWindow { height:520px; background:rgba(255,255,255,0.9); border-radius:12px; padding:18px; overflow:auto; box-shadow: inset 0 1px 0 rgba(255,255,255,0.6); } | |
| .bubble { max-width:72%; padding:12px 14px; border-radius:14px; margin:8px 0; line-height:1.35; } | |
| .bubble.user { margin-left:auto; background:linear-gradient(135deg,#e6f0ff,#d7e9ff); color:#07235a; border-bottom-right-radius:4px; text-align:right; } | |
| .bubble.bot { margin-right:auto; background:linear-gradient(135deg,#fdf7ff,#fff1f7); color:#2b2b2b; border-bottom-left-radius:4px; text-align:left; } | |
| .typeIndicator { font-style:italic; color:#64748b; padding:6px 0; text-align:left; } | |
| .inputRow { display:flex; gap:8px; margin-top:10px; } | |
| .inputRow input { flex:1; padding:12px 14px; border-radius:12px; border:1px solid rgba(10,10,10,0.06); outline:none; font-size:16px; } | |
| .inputRow button { padding:10px 14px; border-radius:10px; border:none; cursor:pointer; font-weight:700; } | |
| .footerBtns { margin-top:12px; display:flex; gap:8px; justify-content:flex-end; align-items:center; } | |
| .small { font-size:12px; color:#475569; } | |
| @media (max-width:820px) { .card{flex-direction:column; padding:14px;} .left{width:100%; flex-direction:row; justify-content:space-between;} .avatarOrb,.avatarHum{width:80px;height:80px;} .chatWindow{height:340px;} } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <div class="left"> | |
| <div id="orb" class="avatarOrb" aria-hidden="true"></div> | |
| <div id="hum" class="avatarHum" aria-hidden="true"></div> | |
| <div class="status"> | |
| <div id="assistantName" class="text-lg font-semibold">Serenity</div> | |
| <div id="assistantMood" class="small">Calm • Supportive</div> | |
| </div> | |
| <div class="controls"> | |
| <button id="switchStyle" class="">Switch Style</button> | |
| <select id="voiceSelect" class="rounded-lg" title="Choose voice/personality"></select> | |
| </div> | |
| <div class="small">Tip: Allow microphone access to speak</div> | |
| </div> | |
| <div class="main"> | |
| <div id="chatWindow" class="chatWindow" role="log" aria-live="polite"></div> | |
| <div id="typing" class="typeIndicator" style="display:none">Serenity is typing…</div> | |
| <div class="inputRow"> | |
| <input id="messageInput" placeholder="Type your message..." autocomplete="off" /> | |
| <button id="sendBtn" class="bg-indigo-600 text-white">Send</button> | |
| <button id="micStart" class="bg-green-500 text-white">🎙️</button> | |
| <button id="micStop" class="bg-red-500 text-white">⏹️</button> | |
| </div> | |
| <div class="footerBtns"> | |
| <button id="newChat" class="bg-gray-100 px-3">New Chat</button> | |
| <button id="feedback" class="bg-amber-100 px-3">Feedback</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| /* ---------- Session + DOM refs ---------- */ | |
| const sessionKey = "serenity_session"; | |
| let sessionId = localStorage.getItem(sessionKey); | |
| if(!sessionId){ | |
| sessionId = 's_' + Math.random().toString(36).slice(2,12); | |
| localStorage.setItem(sessionKey, sessionId); | |
| } | |
| const chatWindow = document.getElementById("chatWindow"); | |
| const messageInput = document.getElementById("messageInput"); | |
| const sendBtn = document.getElementById("sendBtn"); | |
| const typingIndicator = document.getElementById("typing"); | |
| const orb = document.getElementById("orb"); | |
| const hum = document.getElementById("hum"); | |
| const switchStyle = document.getElementById("switchStyle"); | |
| const assistantMood = document.getElementById("assistantMood"); | |
| const voiceSelect = document.getElementById("voiceSelect"); | |
| const micStart = document.getElementById("micStart"); | |
| const micStop = document.getElementById("micStop"); | |
| const newChatBtn = document.getElementById("newChat"); | |
| const feedbackBtn = document.getElementById("feedback"); | |
| let recognition = null; | |
| let listening = false; | |
| let voices = []; | |
| const VOICES = [ | |
| {id:'calm_male', label:'Calm Male', pitch:0.85, rate:0.95}, | |
| {id:'deep_male', label:'Deep Male', pitch:0.6, rate:0.9}, | |
| {id:'soothing_male', label:'Soothing Male', pitch:0.9, rate:0.85}, | |
| {id:'gentle_female', label:'Gentle Loving Female', pitch:1.3, rate:0.95}, | |
| {id:'feminine_female', label:'Feminine Female', pitch:1.45, rate:1.0}, | |
| {id:'deep_female', label:'Deep & Soar Female', pitch:0.9, rate:0.9}, | |
| {id:'soothing_female', label:'Soothing Female', pitch:1.2, rate:0.85}, | |
| {id:'neutral', label:'Neutral Soothing', pitch:1.0, rate:1.0} | |
| ]; | |
| /* ---------- small UI helpers ---------- */ | |
| function appendBubble(who, text){ | |
| const d = document.createElement("div"); | |
| d.className = 'bubble ' + (who === "user" ? 'user' : 'bot'); | |
| d.innerHTML = `<strong>${who === "user" ? "You" : "Serenity"}:</strong> ${escapeHtml(text)}`; | |
| chatWindow.appendChild(d); | |
| chatWindow.scrollTop = chatWindow.scrollHeight; | |
| } | |
| function escapeHtml(str){ return (str||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); } | |
| /* ---------- voice list population ---------- */ | |
| function populateVoiceList(){ | |
| const avail = speechSynthesis.getVoices() || []; | |
| voiceSelect.innerHTML = ""; | |
| VOICES.forEach(v=>{ | |
| const opt = document.createElement("option"); | |
| opt.value = v.id; | |
| opt.text = v.label; | |
| voiceSelect.appendChild(opt); | |
| }); | |
| const saved = localStorage.getItem("serenity_voice") || "neutral"; | |
| voiceSelect.value = saved; | |
| } | |
| populateVoiceList(); | |
| if(typeof speechSynthesis !== "undefined"){ | |
| speechSynthesis.onvoiceschanged = populateVoiceList; | |
| } | |
| voiceSelect.addEventListener("change", ()=> localStorage.setItem("serenity_voice", voiceSelect.value)); | |
| /* ---------- typing / avatar ---------- */ | |
| function showTyping(){ typingIndicator.style.display = "block"; orb.style.transform = "scale(1.08)"; } | |
| function hideTyping(){ typingIndicator.style.display = "none"; orb.style.transform = ""; } | |
| function speakText(text){ | |
| if(!window.speechSynthesis) return; | |
| const utter = new SpeechSynthesisUtterance(text); | |
| const profileId = voiceSelect.value || "neutral"; | |
| const profile = VOICES.find(v => v.id === profileId) || VOICES[7]; | |
| utter.pitch = profile.pitch; | |
| utter.rate = profile.rate; | |
| // choose a matching voice if possible | |
| const avail = speechSynthesis.getVoices() || []; | |
| let match = null; | |
| if(profileId.includes("male")) { | |
| match = avail.find(v=>/male|man|david|john|alex|daniel|microsoft/i.test(v.name)); | |
| } else if(profileId.includes("female")) { | |
| match = avail.find(v=>/female|zira|susan|sarah|microsoft|victoria/i.test(v.name)); | |
| } | |
| if(!match) match = avail.find(v=>/en-|en_/.test(v.lang)) || avail[0]; | |
| if(match) utter.voice = match; | |
| speechSynthesis.cancel(); | |
| speechSynthesis.speak(utter); | |
| } | |
| /* ---------- background gentle cycling ---------- */ | |
| const SOFT_GRADIENTS = [ | |
| "linear-gradient(135deg,#EFF6FF,#FFF0F6)", | |
| "linear-gradient(135deg,#F0FDF4,#ECFEFF)", | |
| "linear-gradient(135deg,#FEF3C7,#FCE7F3)", | |
| "linear-gradient(135deg,#F3E8FF,#FEF3F3)", | |
| "linear-gradient(135deg,#FDF2F8,#EEF2FF)" | |
| ]; | |
| let bgIndex = 0; | |
| setInterval(()=>{ bgIndex=(bgIndex+1)%SOFT_GRADIENTS.length; document.body.style.background = SOFT_GRADIENTS[bgIndex]; }, 18000); | |
| /* ---------- mic controls ---------- */ | |
| if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window){ | |
| const SR = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| recognition = new SR(); | |
| recognition.lang = "en-US"; | |
| recognition.interimResults = false; | |
| recognition.onresult = (e)=> { | |
| const txt = e.results[e.results.length-1][0].transcript; | |
| messageInput.value = txt; | |
| sendMessage(); | |
| }; | |
| recognition.onend = ()=> { listening=false; micStart.disabled=false; micStop.disabled=true; orb.style.transform=""; } | |
| recognition.onerror = ()=> { listening=false; micStart.disabled=false; micStop.disabled=true; orb.style.transform=""; } | |
| micStart.onclick = ()=> { try{ recognition.start(); listening=true; micStart.disabled=true; micStop.disabled=false; orb.style.transform="scale(1.08)"; }catch(e){} }; | |
| micStop.onclick = ()=> { if(recognition) recognition.stop(); listening=false; micStart.disabled=false; micStop.disabled=true; speechSynthesis.cancel(); orb.style.transform=""; }; | |
| } else { micStart.disabled=true; micStop.disabled=true; } | |
| /* ---------- avatar switch ---------- */ | |
| switchStyle.onclick = ()=> { | |
| if(orb.style.display === "none" || getComputedStyle(orb).display === "none") { orb.style.display=""; hum.style.display="none"; } | |
| else { orb.style.display="none"; hum.style.display=""; } | |
| } | |
| /* ---------- reset session ---------- */ | |
| newChatBtn.onclick = async ()=> { | |
| chatWindow.innerHTML = ""; | |
| try{ | |
| await fetch("/reset_session", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({session:sessionId})}); | |
| }catch(e){} | |
| appendBubble("bot","New conversation started. How are you feeling today?"); | |
| } | |
| /* ---------- send message ---------- */ | |
| async function sendMessage(){ | |
| const txt = messageInput.value.trim(); | |
| if(!txt) return; | |
| appendBubble("user", txt); | |
| messageInput.value = ""; | |
| showTyping(); | |
| try{ | |
| const body = { session: sessionId, message: txt, personality: voiceSelect.value || "neutral" }; | |
| const res = await fetch("/chat", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); | |
| const j = await res.json(); | |
| hideTyping(); | |
| appendBubble("bot", j.reply || j.response || "Sorry — I couldn't form a reply."); | |
| assistantMood.textContent = ((j.emotion||"Calm").charAt(0).toUpperCase() + (j.emotion||"Calm").slice(1)) + " • Supportive"; | |
| // gentle background changes by emotion | |
| const emotion = (j.emotion||"neutral").toLowerCase(); | |
| if(emotion==="sadness") document.body.style.background="linear-gradient(135deg,#dbeafe,#eef2ff)"; | |
| else if(emotion==="joy"||emotion==="love") document.body.style.background="linear-gradient(135deg,#fff7ed,#fff1f2)"; | |
| else if(emotion==="anger") document.body.style.background="linear-gradient(135deg,#fff1f0,#ffe7e8)"; | |
| else if(emotion==="crisis") document.body.style.background="linear-gradient(135deg,#f8fafc,#fef2f2)"; | |
| else document.body.style.background = SOFT_GRADIENTS[bgIndex]; | |
| // speak | |
| speakText(j.reply || j.response || ""); | |
| }catch(err){ | |
| hideTyping(); | |
| appendBubble("bot","Oops — couldn't connect. Try again in a moment."); | |
| console.error(err); | |
| } | |
| } | |
| sendBtn.addEventListener("click", sendMessage); | |
| messageInput.addEventListener("keydown", (e)=> { if(e.key==="Enter") sendMessage(); }); | |
| /* ---------- init greeting (ask name if needed) ---------- */ | |
| (async function init(){ | |
| try{ | |
| const res = await fetch("/chat", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({session:sessionId, init:true})}); | |
| const j = await res.json(); | |
| appendBubble("bot", j.reply || "Hey there — I'm Serenity. What's your name?"); | |
| assistantMood.textContent = (j.emotion || "Calm") + " • Supportive"; | |
| }catch(e){ | |
| appendBubble("bot","Hey there — I'm Serenity. What's your name?"); | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html> |