Spaces:
Sleeping
Sleeping
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Real-Time Experts</title> | |
<style> | |
:root { | |
/* Base colors */ | |
--color-default: #0066B3; | |
--color-behavior: #FCBA40; | |
--color-udl: #A50064; | |
--color-prompt3: #11C7B5; | |
--color-custom: #FF6B35; | |
--color-background: #FFFFFF; | |
--color-text: #333333; | |
--color-light-gray: #F5F5F5; | |
} | |
body { | |
margin: 0; | |
padding: 0; | |
background-color: var(--color-background); | |
color: var(--color-text); | |
font-family: system-ui, -apple-system, sans-serif; | |
min-height: 100vh; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
justify-content: center; | |
transition: all 0.3s ease; | |
} | |
.app-title { | |
font-size: 2.25rem; | |
font-weight: 600; | |
margin-bottom: 0.5rem; | |
transition: color 0.3s ease; | |
color: #333333; | |
} | |
.subtitle { | |
font-size: 1.125rem; | |
margin-bottom: 1rem; | |
color: #555555; | |
} | |
.api-link { | |
margin-bottom: 2rem; | |
transition: color 0.3s ease; | |
} | |
.mode-indicator { | |
position: absolute; | |
top: 15px; | |
right: 15px; | |
padding: 0.5rem 1rem; | |
border-radius: 2rem; | |
font-weight: 600; | |
color: white; | |
transition: all 0.3s ease; | |
} | |
/* Color themes */ | |
body.theme-default .app-title, | |
body.theme-default a { | |
color: var(--color-default); | |
} | |
body.theme-default .box, | |
body.theme-default .start-button { | |
background-color: var(--color-default); | |
} | |
body.theme-default .mode-indicator { | |
background-color: var(--color-default); | |
} | |
body.theme-default .prompt-select { | |
border-color: var(--color-default); | |
} | |
body.theme-behavior .app-title, | |
body.theme-behavior a { | |
color: var(--color-behavior); | |
} | |
body.theme-behavior .box, | |
body.theme-behavior .start-button { | |
background-color: var(--color-behavior); | |
} | |
body.theme-behavior .mode-indicator { | |
background-color: var(--color-behavior); | |
color: #333333; | |
} | |
body.theme-behavior .prompt-select { | |
border-color: var(--color-behavior); | |
} | |
body.theme-udl .app-title, | |
body.theme-udl a { | |
color: var(--color-udl); | |
} | |
body.theme-udl .box, | |
body.theme-udl .start-button { | |
background-color: var(--color-udl); | |
} | |
body.theme-udl .mode-indicator { | |
background-color: var(--color-udl); | |
} | |
body.theme-udl .prompt-select { | |
border-color: var(--color-udl); | |
} | |
body.theme-prompt3 .app-title, | |
body.theme-prompt3 a { | |
color: var(--color-prompt3); | |
} | |
body.theme-prompt3 .box, | |
body.theme-prompt3 .start-button { | |
background-color: var(--color-prompt3); | |
} | |
body.theme-prompt3 .mode-indicator { | |
background-color: var(--color-prompt3); | |
} | |
body.theme-prompt3 .prompt-select { | |
border-color: var(--color-prompt3); | |
} | |
body.theme-custom .app-title, | |
body.theme-custom a { | |
color: var(--color-custom); | |
} | |
body.theme-custom .box, | |
body.theme-custom .start-button { | |
background-color: var(--color-custom); | |
} | |
body.theme-custom .mode-indicator { | |
background-color: var(--color-custom); | |
} | |
body.theme-custom .prompt-select { | |
border-color: var(--color-custom); | |
} | |
.container { | |
width: 90%; | |
max-width: 800px; | |
background-color: var(--color-background); | |
padding: 2rem; | |
border-radius: 1rem; | |
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05); | |
border: 1px solid rgba(0, 0, 0, 0.05); | |
transition: all 0.3s ease; | |
} | |
.form-group { | |
margin-bottom: 1.5rem; | |
} | |
label { | |
display: block; | |
font-weight: 500; | |
margin-bottom: 0.5rem; | |
color: #555555; | |
} | |
.input-field { | |
width: calc(100% - 1.5rem); | |
padding: 0.75rem; | |
border-radius: 0.5rem; | |
border: 1px solid #e0e0e0; | |
font-size: 1rem; | |
background-color: white; | |
} | |
.prompt-select { | |
width: 100%; | |
padding: 0.75rem; | |
border-radius: 0.5rem; | |
border-width: 2px; | |
border-style: solid; | |
font-size: 1rem; | |
background-color: white; | |
appearance: none; | |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23555555' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); | |
background-repeat: no-repeat; | |
background-position: right 0.75rem center; | |
background-size: 1rem; | |
transition: border-color 0.3s ease; | |
} | |
.custom-prompt { | |
width: calc(100% - 1.5rem); | |
padding: 0.75rem; | |
min-height: 80px; | |
border-radius: 0.5rem; | |
border: 1px solid #e0e0e0; | |
font-size: 1rem; | |
font-family: inherit; | |
resize: vertical; | |
display: none; | |
} | |
.visualization { | |
height: 100px; | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
margin: 2rem 0; | |
padding: 1rem; | |
border-radius: 0.5rem; | |
background-color: var(--color-light-gray); | |
} | |
.box { | |
height: 80px; | |
width: 8px; | |
border-radius: 4px; | |
transition: transform 0.05s ease, background-color 0.3s ease; | |
} | |
.start-button { | |
display: inline-block; | |
padding: 0.75rem 2rem; | |
font-size: 1rem; | |
font-weight: 600; | |
color: white; | |
border: none; | |
border-radius: 0.5rem; | |
cursor: pointer; | |
transition: all 0.3s ease; | |
} | |
.start-button:hover { | |
opacity: 0.9; | |
transform: translateY(-1px); | |
} | |
.toast { | |
position: fixed; | |
top: 20px; | |
left: 50%; | |
transform: translateX(-50%); | |
padding: 0.75rem 1.5rem; | |
border-radius: 0.5rem; | |
background-color: #f44336; | |
color: white; | |
font-weight: 500; | |
z-index: 1000; | |
display: none; | |
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); | |
} | |
.spinner { | |
display: inline-block; | |
width: 20px; | |
height: 20px; | |
border: 2px solid white; | |
border-top-color: transparent; | |
border-radius: 50%; | |
margin-right: 0.5rem; | |
animation: spin 1s linear infinite; | |
} | |
@keyframes spin { | |
to { transform: rotate(360deg); } | |
} | |
a { | |
text-decoration: none; | |
transition: color 0.3s ease; | |
} | |
a:hover { | |
text-decoration: underline; | |
} | |
</style> | |
</head> | |
<body class="theme-default"> | |
<div id="error-toast" class="toast"></div> | |
<div class="mode-indicator">Default Assistant Mode</div> | |
<div class="container"> | |
<h1 class="app-title">Real-Time Experts</h1> | |
<p class="subtitle">Speak with Selected Expert Assistants</p> | |
<p class="api-link">Get a Gemini API key <a id="api-link" href="https://ai.google.dev/gemini-api/docs/api-key">here</a></p> | |
<div class="form-group"> | |
<label for="api-key">API Key</label> | |
<input type="password" id="api-key" class="input-field" placeholder="Enter your API key"> | |
</div> | |
<div class="form-group"> | |
<label for="voice">Voice</label> | |
<select id="voice" class="input-field"> | |
<option value="Puck">Puck</option> | |
<option value="Charon">Charon</option> | |
<option value="Kore">Kore</option> | |
<option value="Fenrir">Fenrir</option> | |
<option value="Aoede">Aoede</option> | |
</select> | |
</div> | |
<div class="form-group"> | |
<label for="prompt-select">System Prompt</label> | |
<select id="prompt-select" class="prompt-select"> | |
<option value="Default">Default Assistant</option> | |
<option value="Behavior Expert">Behavior Expert</option> | |
<option value="UDL Expert">UDL Expert</option> | |
<option value="Learning Support Expert">Learning Support Expert</option> | |
<option value="Custom">Custom Prompt</option> | |
</select> | |
<textarea id="custom-prompt" class="custom-prompt" placeholder="Enter custom instructions for the AI assistant"></textarea> | |
</div> | |
<div class="visualization" id="visualization"> | |
<!-- Boxes will be dynamically added here --> | |
</div> | |
<button id="start-button" class="start-button">Start Conversation</button> | |
</div> | |
<audio id="audio-output"></audio> | |
<script> | |
// System prompts data injected from the server | |
const SYSTEM_PROMPTS = __SYSTEM_PROMPTS__; | |
// Theme configuration | |
const themeConfig = { | |
'Default': { | |
color: '#0066B3', | |
name: 'Default Assistant' | |
}, | |
'Behavior Expert': { | |
color: '#FCBA40', | |
name: 'Behavior Expert' | |
}, | |
'UDL Expert': { | |
color: '#A50064', | |
name: 'UDL Expert' | |
}, | |
'Learning Support Expert': { | |
color: '#11C7B5', | |
name: 'Learning Support Expert' | |
}, | |
'Custom': { | |
color: '#FF6B35', | |
name: 'Custom Assistant' | |
} | |
}; | |
// Elements | |
const body = document.body; | |
const promptSelect = document.getElementById('prompt-select'); | |
const customPrompt = document.getElementById('custom-prompt'); | |
const visualization = document.getElementById('visualization'); | |
const startButton = document.getElementById('start-button'); | |
const modeIndicator = document.querySelector('.mode-indicator'); | |
const appTitle = document.querySelector('.app-title'); | |
// Create visualization bars | |
function createVisualizationBars() { | |
visualization.innerHTML = ''; | |
const numBars = 30; | |
for (let i = 0; i < numBars; i++) { | |
const box = document.createElement('div'); | |
box.className = 'box'; | |
// Set initial scale | |
box.style.transform = `scaleY(0.4)`; | |
visualization.appendChild(box); | |
} | |
} | |
// Apply theme based on selected prompt | |
function applyTheme(promptKey) { | |
// Remove existing theme classes | |
body.className = ''; | |
// Set the appropriate theme class | |
const themeClass = promptKey === 'Behavior Expert' ? 'theme-behavior' : | |
promptKey === 'UDL Expert' ? 'theme-udl' : | |
promptKey === 'Learning Support Expert' ? 'theme-prompt3' : | |
promptKey === 'Custom' ? 'theme-custom' : 'theme-default'; | |
body.classList.add(themeClass); | |
// Update mode indicator | |
const config = themeConfig[promptKey] || themeConfig['Default']; | |
modeIndicator.textContent = config.name + ' Mode'; | |
} | |
// Handle prompt selection change | |
promptSelect.addEventListener('change', function() { | |
const selectedValue = this.value; | |
// Toggle custom prompt textarea | |
if (selectedValue === 'Custom') { | |
customPrompt.style.display = 'block'; | |
} else { | |
customPrompt.style.display = 'none'; | |
} | |
// Apply theme | |
applyTheme(selectedValue); | |
}); | |
// Initialize | |
createVisualizationBars(); | |
applyTheme(promptSelect.value); | |
// Animation for visualization (simulated for demo) | |
function animateVisualization() { | |
const bars = document.querySelectorAll('.box'); | |
bars.forEach(bar => { | |
const newScale = Math.random() * 0.8 + 0.2; | |
bar.style.transform = `scaleY(${newScale})`; | |
}); | |
requestAnimationFrame(animateVisualization); | |
} | |
// Get the selected prompt data | |
function getSelectedPrompt() { | |
const selectedValue = promptSelect.value; | |
return { | |
promptKey: selectedValue === 'Custom' ? '' : selectedValue, | |
customPrompt: selectedValue === 'Custom' ? customPrompt.value : '' | |
}; | |
} | |
let peerConnection; | |
let audioContext; | |
let dataChannel; | |
let isRecording = false; | |
let webrtc_id; | |
let analyser; | |
let dataArray; | |
let animationId; | |
const apiKeyInput = document.getElementById('api-key'); | |
const voiceSelect = document.getElementById('voice'); | |
const audioOutput = document.getElementById('audio-output'); | |
function showError(message) { | |
const toast = document.getElementById('error-toast'); | |
toast.textContent = message; | |
toast.style.display = 'block'; | |
setTimeout(() => { | |
toast.style.display = 'none'; | |
}, 5000); | |
} | |
function updateButtonState() { | |
if (peerConnection && (peerConnection.connectionState === 'connecting' || peerConnection.connectionState === 'new')) { | |
startButton.innerHTML = '<span class="spinner"></span>Connecting...'; | |
startButton.disabled = true; | |
} else if (peerConnection && peerConnection.connectionState === 'connected') { | |
startButton.textContent = 'End Conversation'; | |
startButton.disabled = false; | |
} else { | |
startButton.textContent = 'Start Conversation'; | |
startButton.disabled = false; | |
} | |
} | |
async function setupWebRTC() { | |
const config = __RTC_CONFIGURATION__; | |
peerConnection = new RTCPeerConnection(config); | |
webrtc_id = Math.random().toString(36).substring(7); | |
try { | |
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
stream.getTracks().forEach(track => peerConnection.addTrack(track, stream)); | |
// Set up audio context for visualization | |
audioContext = new AudioContext(); | |
analyser = audioContext.createAnalyser(); | |
const source = audioContext.createMediaStreamSource(stream); | |
source.connect(analyser); | |
analyser.fftSize = 64; | |
dataArray = new Uint8Array(analyser.frequencyBinCount); | |
// Start visualization | |
updateVisualization(); | |
// Connection state change listener | |
peerConnection.addEventListener('connectionstatechange', () => { | |
console.log('Connection state:', peerConnection.connectionState); | |
updateButtonState(); | |
if (peerConnection.connectionState === 'connected') { | |
console.log('WebRTC connection established'); | |
} | |
}); | |
// Handle incoming audio | |
peerConnection.addEventListener('track', (evt) => { | |
if (audioOutput && audioOutput.srcObject !== evt.streams[0]) { | |
audioOutput.srcObject = evt.streams[0]; | |
audioOutput.play(); | |
} | |
}); | |
// Data channel for messages | |
dataChannel = peerConnection.createDataChannel('text'); | |
dataChannel.onmessage = (event) => { | |
const eventJson = JSON.parse(event.data); | |
if (eventJson.type === "error") { | |
showError(eventJson.message); | |
} else if (eventJson.type === "send_input") { | |
const promptData = getSelectedPrompt(); | |
fetch('/input_hook', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ | |
webrtc_id: webrtc_id, | |
api_key: apiKeyInput.value, | |
voice_name: voiceSelect.value, | |
prompt_key: promptData.promptKey, | |
custom_prompt: promptData.customPrompt | |
}) | |
}); | |
} | |
}; | |
// Create and send offer | |
const offer = await peerConnection.createOffer(); | |
await peerConnection.setLocalDescription(offer); | |
// Wait for ICE gathering to complete | |
await new Promise((resolve) => { | |
if (peerConnection.iceGatheringState === "complete") { | |
resolve(); | |
} else { | |
const checkState = () => { | |
if (peerConnection.iceGatheringState === "complete") { | |
peerConnection.removeEventListener("icegatheringstatechange", checkState); | |
resolve(); | |
} | |
}; | |
peerConnection.addEventListener("icegatheringstatechange", checkState); | |
} | |
}); | |
// Send offer to server | |
const response = await fetch('/webrtc/offer', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ | |
sdp: peerConnection.localDescription.sdp, | |
type: peerConnection.localDescription.type, | |
webrtc_id: webrtc_id, | |
}) | |
}); | |
const serverResponse = await response.json(); | |
if (serverResponse.status === 'failed') { | |
showError(serverResponse.meta.error); | |
stopWebRTC(); | |
return; | |
} | |
await peerConnection.setRemoteDescription(serverResponse); | |
} catch (err) { | |
console.error('Error setting up WebRTC:', err); | |
showError('Failed to establish connection. Please try again.'); | |
stopWebRTC(); | |
} | |
} | |
function updateVisualization() { | |
if (!analyser) return; | |
analyser.getByteFrequencyData(dataArray); | |
const bars = document.querySelectorAll('.box'); | |
for (let i = 0; i < bars.length; i++) { | |
// Use data if available, otherwise animate randomly | |
if (i < dataArray.length) { | |
const barHeight = (dataArray[i] / 255) * 0.8 + 0.2; | |
bars[i].style.transform = `scaleY(${barHeight})`; | |
} else { | |
const barHeight = Math.random() * 0.8 + 0.2; | |
bars[i].style.transform = `scaleY(${barHeight})`; | |
} | |
} | |
animationId = requestAnimationFrame(updateVisualization); | |
} | |
function stopWebRTC() { | |
if (peerConnection) { | |
peerConnection.close(); | |
} | |
if (animationId) { | |
cancelAnimationFrame(animationId); | |
} | |
if (audioContext) { | |
audioContext.close(); | |
} | |
peerConnection = null; | |
updateButtonState(); | |
} | |
startButton.addEventListener('click', () => { | |
if (!isRecording) { | |
setupWebRTC(); | |
} else { | |
stopWebRTC(); | |
} | |
isRecording = !isRecording; | |
}); | |
// Start simple animation for initial state | |
//animateVisualization(); | |
</script> | |
</body> | |
</html> |