Spaces:
Sleeping
Sleeping
<html lang="pt-BR"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>SintonIA</title> | |
<!-- Tailwind CSS via CDN --> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<!-- Google Fonts: Inter --> | |
<link rel="preconnect" href="https://fonts.googleapis.com"> | |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
<!-- Lucide Icons --> | |
<script src="https://unpkg.com/lucide@latest"></script> | |
<style> | |
body { | |
font-family: 'Inter', sans-serif; | |
background: linear-gradient(-45deg, #A8D5BA, #F4A261, #84A98C, #E76F51); | |
background-size: 400% 400%; | |
animation: gradientBG 15s ease infinite; | |
} | |
@keyframes gradientBG { | |
0% { background-position: 0% 50%; } | |
50% { background-position: 100% 50%; } | |
100% { background-position: 0% 50%; } | |
} | |
.glass-effect { | |
background: rgba(255, 255, 255, 0.2); | |
backdrop-filter: blur(12px); | |
-webkit-backdrop-filter: blur(12px); | |
border: 1px solid rgba(255, 255, 255, 0.3); | |
} | |
.recording-pulse { | |
animation: pulse 1.5s infinite; | |
} | |
@keyframes pulse { | |
0% { box-shadow: 0 0 0 0 rgba(231, 111, 81, 0.7); } | |
70% { box-shadow: 0 0 0 20px rgba(231, 111, 81, 0); } | |
100% { box-shadow: 0 0 0 0 rgba(231, 111, 81, 0); } | |
} | |
</style> | |
</head> | |
<body class="text-gray-800"> | |
<!-- Container Principal --> | |
<div class="flex items-center justify-center min-h-screen w-full p-4"> | |
<!-- ===== TELA DE LOGIN ===== --> | |
<div id="login-page" class="w-full max-w-sm p-8 space-y-6 glass-effect rounded-2xl shadow-lg"> | |
<div class="text-center"> | |
<img src="https://i.postimg.cc/GpTXqLxn/temp-Image3-DT7y-Z.avif" alt="Logo SintonIA" class="w-28 h-28 mx-auto mb-4 rounded-full object-cover"> | |
</div> | |
<div class="space-y-4"> | |
<div> | |
<label for="password-input" class="sr-only">Senha</label> | |
<input id="password-input" type="password" required class="w-full px-4 py-3 bg-white/50 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500" placeholder="Senha"> | |
</div> | |
<button id="login-button" class="w-full px-4 py-3 font-semibold text-white bg-teal-600 rounded-lg hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-teal-500 transition-colors"> | |
Entrar | |
</button> | |
<p id="error-message" class="text-sm text-center text-red-600 h-4"></p> | |
</div> | |
</div> | |
<!-- ===== TELA DE CHAT ===== --> | |
<div id="chat-page" class="hidden w-full max-w-2xl h-[90vh] flex flex-col glass-effect rounded-2xl shadow-2xl"> | |
<header class="p-4 border-b border-white/30 flex-shrink-0 flex items-center justify-center"> | |
<img src="https://i.postimg.cc/GpTXqLxn/temp-Image3-DT7y-Z.avif" alt="Logo SintonIA" class="w-14 h-14 rounded-full object-cover"> | |
</header> | |
<main id="chat-history" class="flex-grow p-6 space-y-6 overflow-y-auto"> | |
<div class="flex justify-start"> | |
<div class="px-4 py-3 bg-gray-200 rounded-xl max-w-lg"> | |
<p class="text-sm">Olá! Sou seu assistente de saúde bucal. Clique no botão abaixo para começar a gravar.</p> | |
</div> | |
</div> | |
</main> | |
<footer class="p-6 flex-shrink-0 flex flex-col items-center justify-center space-y-3"> | |
<button id="record-button" class="w-24 h-24 bg-teal-500 text-white rounded-full flex items-center justify-center shadow-lg transform hover:scale-105 transition-transform focus:outline-none focus:ring-4 focus:ring-teal-300"> | |
<i id="record-icon" data-lucide="mic" class="w-10 h-10"></i> | |
</button> | |
<p id="status-text" class="text-sm text-gray-700 h-5">Clique para falar</p> | |
</footer> | |
</div> | |
</div> | |
<!-- Créditos --> | |
<footer class="absolute bottom-4 left-1/2 -translate-x-1/2 text-center"> | |
<p class="text-xs text-white/80">Desenvolvido por Sérgio H. Benfenatti Botelho - DDS, LLM trainer - 🇧🇷</p> | |
</footer> | |
<script> | |
// --- Seleção de Elementos do DOM --- | |
const loginPage = document.getElementById('login-page'); | |
const chatPage = document.getElementById('chat-page'); | |
const passwordInput = document.getElementById('password-input'); | |
const loginButton = document.getElementById('login-button'); | |
const errorMessage = document.getElementById('error-message'); | |
const recordButton = document.getElementById('record-button'); | |
const recordIcon = document.getElementById('record-icon'); | |
const statusText = document.getElementById('status-text'); | |
const chatHistory = document.getElementById('chat-history'); | |
// --- Variáveis de Estado --- | |
let mediaRecorder; | |
let audioChunks = []; | |
let isRecording = false; | |
let audioPlayer = new Audio(); | |
let isAudioContextUnlocked = false; // --- CORREÇÃO: Variável de controle re-adicionada | |
// --- Inicialização --- | |
lucide.createIcons(); | |
// --- CORREÇÃO: Lógica para "destravar" o áudio re-adicionada --- | |
function primeAudioContext() { | |
if (isAudioContextUnlocked) return; | |
// Toca um som silencioso para "acordar" o player de áudio do navegador. | |
audioPlayer.src = "data:audio/mpeg;base64,SUQzBAAAAAABEVRYWFgAAAAtAAADY29tbWVudABCaXRyYXRlIHN1cHBseSBieSBiaXRyYXRlLmNvbQAAAABUY29uAAAAAABQaG9uZQAAAAA="; | |
const playPromise = audioPlayer.play(); | |
if (playPromise !== undefined) { | |
playPromise.then(() => { | |
audioPlayer.pause(); | |
isAudioContextUnlocked = true; | |
console.log("Contexto de áudio desbloqueado e preparado."); | |
}).catch(error => { | |
console.error("Falha ao preparar o contexto de áudio:", error); | |
}); | |
} | |
} | |
// --- Lógica de Autenticação --- | |
loginButton.addEventListener('click', handleLogin); | |
passwordInput.addEventListener('keyup', (event) => { | |
if (event.key === 'Enter') handleLogin(); | |
}); | |
async function handleLogin() { | |
// --- CORREÇÃO: Prepara o áudio no primeiro clique do usuário --- | |
primeAudioContext(); | |
const password = passwordInput.value; | |
errorMessage.textContent = ''; | |
try { | |
const response = await fetch('/login', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ password }) | |
}); | |
if (response.ok) { | |
loginPage.classList.add('hidden'); | |
chatPage.classList.remove('hidden'); | |
chatPage.classList.add('flex'); | |
} else { | |
errorMessage.textContent = 'Senha incorreta. Tente novamente.'; | |
} | |
} catch (error) { | |
console.error('Erro de conexão:', error); | |
errorMessage.textContent = 'Não foi possível conectar ao servidor.'; | |
} | |
} | |
// --- Lógica de Gravação --- | |
recordButton.addEventListener('click', () => { | |
if (isRecording) { | |
stopRecording(); | |
} else { | |
startRecording(); | |
} | |
}); | |
async function startRecording() { | |
audioChunks = []; | |
try { | |
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'; | |
mediaRecorder = new MediaRecorder(stream, { mimeType }); | |
mediaRecorder.ondataavailable = event => { | |
audioChunks.push(event.data); | |
}; | |
mediaRecorder.onstop = sendAudioToServer; | |
mediaRecorder.start(); | |
isRecording = true; | |
setButtonState('recording'); | |
} catch (error) { | |
console.error("ERRO AO INICIAR GRAVAÇÃO:", error.name, error.message); | |
let userMessage = 'Ocorreu um erro ao tentar aceder ao microfone. Verifique as permissões.'; | |
if (error.name === 'NotAllowedError') { | |
userMessage = 'A permissão para usar o microfone foi negada. Por favor, habilite nas configurações do seu navegador.'; | |
} else if (error.name === 'NotFoundError') { | |
userMessage = 'Nenhum microfone foi encontrado no seu dispositivo.'; | |
} | |
addMessageToChat(userMessage, "error"); | |
setButtonState('idle'); | |
} | |
} | |
function stopRecording() { | |
if (!isRecording || !mediaRecorder) return; | |
mediaRecorder.stop(); | |
isRecording = false; | |
mediaRecorder.stream.getTracks().forEach(track => track.stop()); | |
setButtonState('processing'); | |
} | |
// --- Comunicação com o Servidor --- | |
async function sendAudioToServer() { | |
const audioBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType }); | |
if (audioBlob.size < 1000) { | |
setButtonState('idle'); | |
addMessageToChat('A gravação foi muito curta. Tente novamente.', 'error'); | |
return; | |
} | |
const formData = new FormData(); | |
const fileName = mediaRecorder.mimeType.includes('webm') ? 'recording.webm' : 'recording.mp4'; | |
formData.append('audio', audioBlob, fileName); | |
try { | |
const response = await fetch('/process-audio', { | |
method: 'POST', | |
body: formData | |
}); | |
if (!response.ok) { | |
const errorData = await response.json(); | |
throw new Error(errorData.error || `Erro do servidor: ${response.statusText}`); | |
} | |
const data = await response.json(); | |
addMessageToChat(data.user_question, 'user'); | |
addMessageToChat(data.ai_answer, 'ai'); | |
if (data.audio_base64) { | |
playAudioAutomatically(data.audio_base64); | |
} else { | |
setButtonState('idle'); | |
} | |
} catch (error) { | |
console.error('Erro ao processar áudio:', error); | |
addMessageToChat(error.message || 'Ocorreu um erro. Tente novamente.', 'error'); | |
setButtonState('idle'); | |
} | |
} | |
// --- Reprodução Automática --- | |
function playAudioAutomatically(base64String) { | |
try { | |
const audioBytes = Uint8Array.from(atob(base64String), c => c.charCodeAt(0)); | |
const audioBlob = new Blob([audioBytes], { type: 'audio/mpeg' }); | |
if (audioPlayer.src && audioPlayer.src.startsWith('blob:')) { | |
URL.revokeObjectURL(audioPlayer.src); | |
} | |
audioPlayer.src = URL.createObjectURL(audioBlob); | |
const playPromise = audioPlayer.play(); | |
if (playPromise !== undefined) { | |
playPromise.then(() => { | |
setButtonState('speaking'); | |
}).catch(error => { | |
console.error("Falha na reprodução automática:", error); | |
addMessageToChat("Não foi possível reproduzir o áudio automaticamente.", 'error'); | |
setButtonState('idle'); | |
}); | |
} | |
audioPlayer.onended = () => { | |
setButtonState('idle'); | |
}; | |
} catch (error) { | |
console.error("Erro ao decodificar o áudio:", error); | |
addMessageToChat("Falha ao processar o áudio.", "error"); | |
setButtonState('idle'); | |
} | |
} | |
// --- Funções Auxiliares da UI --- | |
function setButtonState(state) { | |
recordButton.classList.remove('recording-pulse', 'bg-red-500', 'bg-teal-500', 'bg-gray-400', 'cursor-not-allowed', 'bg-orange-500'); | |
recordButton.disabled = false; | |
recordIcon.setAttribute('data-lucide', 'mic'); | |
switch(state) { | |
case 'recording': | |
statusText.textContent = 'Gravando... Clique para parar.'; | |
recordButton.classList.add('bg-red-500', 'recording-pulse'); | |
recordIcon.setAttribute('data-lucide', 'square'); | |
break; | |
case 'processing': | |
statusText.textContent = 'Processando...'; | |
recordButton.classList.add('bg-gray-400', 'cursor-not-allowed'); | |
recordButton.disabled = true; | |
break; | |
case 'speaking': | |
statusText.textContent = 'O assistente está falando...'; | |
recordButton.classList.add('bg-orange-500'); | |
recordButton.disabled = true; | |
break; | |
case 'idle': | |
default: | |
statusText.textContent = 'Clique para falar'; | |
recordButton.classList.add('bg-teal-500'); | |
break; | |
} | |
lucide.createIcons(); | |
} | |
function addMessageToChat(text, sender) { | |
const messageWrapper = document.createElement('div'); | |
const messageBubble = document.createElement('div'); | |
const textElement = document.createElement('p'); | |
messageBubble.classList.add('px-4', 'py-3', 'rounded-xl', 'max-w-lg'); | |
textElement.classList.add('text-sm'); | |
if(!text || text.trim() === "") return; | |
textElement.textContent = text; | |
messageBubble.appendChild(textElement); | |
if (sender === 'user') { | |
messageWrapper.classList.add('flex', 'justify-end'); | |
messageBubble.classList.add('bg-teal-600', 'text-white'); | |
} else if (sender === 'error') { | |
messageWrapper.classList.add('flex', 'justify-center'); | |
messageBubble.classList.add('bg-red-100', 'text-red-700'); | |
} else { | |
messageWrapper.classList.add('flex', 'justify-start'); | |
messageBubble.classList.add('bg-gray-200', 'text-gray-800'); | |
} | |
messageWrapper.appendChild(messageBubble); | |
chatHistory.appendChild(messageWrapper); | |
chatHistory.scrollTop = chatHistory.scrollHeight; | |
lucide.createIcons(); | |
} | |
</script> | |
</body> | |
</html> | |