diff --git a/app.js b/app.js index e06c020..fcd17e0 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,7 @@ const muteIndicator = document.getElementById('mute-indicator'); const indicatorText = muteIndicator?.querySelector('.indicator-text') ?? null; const aiCircle = document.querySelector('[data-role="ai"]'); const userCircle = document.querySelector('[data-role="user"]'); +const backgroundUrls = document.getElementById('background-urls'); let currentImageModel = 'flux'; let chatHistory = []; @@ -63,12 +64,62 @@ function setCircleState(circle, { speaking = false, listening = false, error = f circle.classList.toggle('is-speaking', speaking); circle.classList.toggle('is-listening', listening); circle.classList.toggle('is-error', error); + circle.classList.toggle('is-active', speaking || listening || error); if (label) { circle.setAttribute('aria-label', label); } } +const URL_REGEX = /(https?:\/\/[^\s]+)/gi; +const URL_BADGE_POSITIONS = ['top-left', 'top-right', 'bottom-right', 'bottom-left']; + +function extractUrlsFromText(text = '') { + if (!text) { + return []; + } + + const matches = text.match(URL_REGEX) ?? []; + return matches.map((match) => match.replace(/[\s.,;!?]+$/, '')); +} + +function sanitizeTextForSpeech(text = '') { + return text.replace(URL_REGEX, ' ').replace(/\s{2,}/g, ' ').trim(); +} + +function updateBackgroundLinkOverlay(urls) { + if (!backgroundUrls) { + return; + } + + const previousBadges = [...backgroundUrls.querySelectorAll('.url-badge')]; + previousBadges.forEach((badge) => { + badge.classList.remove('is-visible'); + badge.addEventListener( + 'transitionend', + () => { + badge.remove(); + }, + { once: true } + ); + }); + + if (!urls.length) { + return; + } + + urls.slice(0, URL_BADGE_POSITIONS.length).forEach((url, index) => { + const badge = document.createElement('span'); + badge.className = 'url-badge'; + badge.dataset.position = URL_BADGE_POSITIONS[index % URL_BADGE_POSITIONS.length]; + badge.textContent = url; + backgroundUrls.appendChild(badge); + requestAnimationFrame(() => { + badge.classList.add('is-visible'); + }); + }); +} + async function loadSystemPrompt() { try { const response = await fetch(resolveAssetPath('ai-instruct.txt')); @@ -104,6 +155,22 @@ function setupSpeechRecognition() { }); }; + recognition.onsoundstart = () => { + setCircleState(userCircle, { + listening: true, + speaking: true, + label: 'Hearing you speak' + }); + }; + + recognition.onsoundend = () => { + setCircleState(userCircle, { + listening: true, + speaking: false, + label: 'Processing what you said' + }); + }; + recognition.onaudiostart = () => { setCircleState(userCircle, { listening: true, @@ -319,6 +386,8 @@ document.addEventListener('keydown', (event) => { } }); +let speakingFallbackTimeout = null; + function speak(text) { if (synth.speaking) { console.error('Speech synthesis is already speaking.'); @@ -341,22 +410,52 @@ function speak(text) { console.warn('UK English female voice not found, using default.'); } - utterance.onstart = () => { - console.log('AI is speaking...'); + setCircleState(aiCircle, { + speaking: true, + label: 'Unity is speaking' + }); + + if (speakingFallbackTimeout) { + clearTimeout(speakingFallbackTimeout); + } + + speakingFallbackTimeout = setTimeout(() => { + if (synth.speaking) { + return; + } setCircleState(aiCircle, { - speaking: true, - label: 'Unity is speaking' + speaking: false, + label: 'Unity is idle' }); + }, Math.max(4000, text.length * 90)); + + utterance.onstart = () => { + console.log('AI is speaking...'); }; utterance.onend = () => { console.log('AI finished speaking.'); + if (speakingFallbackTimeout) { + clearTimeout(speakingFallbackTimeout); + speakingFallbackTimeout = null; + } setCircleState(aiCircle, { speaking: false, label: 'Unity is idle' }); }; + utterance.onerror = () => { + if (speakingFallbackTimeout) { + clearTimeout(speakingFallbackTimeout); + speakingFallbackTimeout = null; + } + setCircleState(aiCircle, { + speaking: false, + label: 'Unity encountered a speech error' + }); + }; + synth.speak(utterance); } @@ -527,7 +626,17 @@ async function getAIResponse(userInput) { } chatHistory.push({ role: 'assistant', content: aiText }); - speak(aiText); + + const extractedUrls = extractUrlsFromText(aiText); + const sanitizedText = sanitizeTextForSpeech(aiText); + + updateBackgroundLinkOverlay(extractedUrls); + + if (sanitizedText) { + speak(sanitizedText); + } else if (extractedUrls.length) { + speak('I have shared a link with you.'); + } } catch (error) { console.error('Error getting text from Pollinations AI:', error); setCircleState(aiCircle, { diff --git a/index.html b/index.html index fb25e4d..1ce0549 100644 --- a/index.html +++ b/index.html @@ -13,6 +13,7 @@ +
diff --git a/style.css b/style.css index e484591..b752c3b 100644 --- a/style.css +++ b/style.css @@ -41,6 +41,66 @@ body { z-index: -1; } +#background-urls { + position: fixed; + inset: 0; + pointer-events: none; + z-index: -1; + display: flex; + flex-wrap: wrap; + align-content: space-between; + padding: clamp(18px, 6vw, 64px); + gap: clamp(16px, 4vw, 48px); + mix-blend-mode: screen; +} + +#background-urls .url-badge { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 22px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.28); + background: linear-gradient(135deg, rgba(9, 10, 20, 0.72) 0%, rgba(40, 48, 68, 0.55) 100%); + box-shadow: 0 0 42px rgba(10, 12, 22, 0.58); + color: rgba(255, 255, 255, 0.82); + font-size: clamp(0.65rem, 1.25vw, 0.95rem); + letter-spacing: 0.18em; + text-transform: uppercase; + text-align: center; + word-break: break-all; + backdrop-filter: blur(18px); + opacity: 0; + transform: scale(0.9); + transition: opacity 0.5s ease, transform 0.5s ease; +} + +#background-urls .url-badge.is-visible { + opacity: 1; + transform: scale(1); +} + +#background-urls .url-badge[data-position="top-left"] { + align-self: flex-start; + margin-left: 0; +} + +#background-urls .url-badge[data-position="top-right"] { + align-self: flex-start; + margin-left: auto; +} + +#background-urls .url-badge[data-position="bottom-left"] { + align-self: flex-end; + margin-left: 0; +} + +#background-urls .url-badge[data-position="bottom-right"] { + align-self: flex-end; + margin-left: auto; +} + .layout { width: 100%; flex: 1; @@ -73,6 +133,26 @@ body { transition: border-color 0.4s ease, box-shadow 0.4s ease, transform 0.4s ease; } +.voice-circle::after { + content: ""; + position: absolute; + inset: 20%; + border-radius: 50%; + background: radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.55) 0%, rgba(255, 255, 255, 0.08) 65%, transparent 100%); + opacity: 0; + transition: opacity 0.3s ease; + z-index: 0; +} + +.voice-circle.is-active:not(.is-speaking):not(.is-error) { + box-shadow: 0 0 42px -18px rgba(124, 92, 255, 0.6); +} + +.voice-circle.user.is-active:not(.is-speaking):not(.is-error) { + border-color: rgba(67, 217, 189, 0.65); + box-shadow: 0 0 42px -18px rgba(67, 217, 189, 0.65); +} + .pulse-ring { position: absolute; inset: 12%; @@ -91,8 +171,8 @@ body { } .voice-circle.is-speaking { - box-shadow: 0 0 42px -18px rgba(255, 255, 255, 0.6); - transform: translateY(-6px) scale(1.03); + box-shadow: 0 0 54px -16px rgba(255, 255, 255, 0.75); + transform: translateY(-6px) scale(1.05); } .voice-circle.is-speaking .pulse-ring { @@ -100,9 +180,18 @@ body { opacity: 1; } +.voice-circle.is-speaking::after { + opacity: 0.65; +} + .voice-circle.is-listening { - border-color: rgba(67, 217, 189, 0.8); - box-shadow: 0 0 42px -12px rgba(67, 217, 189, 0.6); + border-color: rgba(67, 217, 189, 0.85); + box-shadow: 0 0 48px -12px rgba(67, 217, 189, 0.75); +} + +.voice-circle.is-listening .pulse-ring { + opacity: 1; + animation: breathe 1.6s ease-in-out infinite; } .voice-circle.is-error { @@ -174,6 +263,21 @@ body { } } +@keyframes breathe { + 0% { + transform: scale(1); + opacity: 0.7; + } + 50% { + transform: scale(1.08); + opacity: 0.4; + } + 100% { + transform: scale(1); + opacity: 0.7; + } +} + @media (max-width: 720px) { .voice-stage { flex-direction: column;