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> |