Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>G.E.N.I.E. - GitHub Enhanced Natural Intelligence Engine</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| /* Base styles */ | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background: linear-gradient(135deg, #0f0a1f 0%, #1a102f 50%, #2c1c4a 100%); | |
| color: #e0e0e0; | |
| overflow: hidden; /* Prevent body scroll */ | |
| } | |
| /* Custom Scrollbar */ | |
| .custom-scrollbar::-webkit-scrollbar { width: 8px; } | |
| .custom-scrollbar::-webkit-scrollbar-track { background: rgba(26, 32, 58, 0.3); border-radius: 10px; } | |
| .custom-scrollbar::-webkit-scrollbar-thumb { background-color: #8a2be2; border-radius: 10px; border: 2px solid transparent; background-clip: content-box; } | |
| .custom-scrollbar::-webkit-scrollbar-thumb:hover { background-color: #9932cc; } | |
| /* Glassmorphism effect */ | |
| .glass-panel { | |
| background: rgba(26, 32, 58, 0.6); | |
| backdrop-filter: blur(10px); | |
| -webkit-backdrop-filter: blur(10px); | |
| border: 1px solid rgba(138, 43, 226, 0.3); /* Purple border */ | |
| border-radius: 1rem; | |
| box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2); | |
| } | |
| /* Input field styling */ | |
| .futuristic-input, .futuristic-select { | |
| background-color: rgba(15, 10, 31, 0.7); | |
| border: 1px solid rgba(138, 43, 226, 0.5); /* Slightly stronger purple border */ | |
| color: #e0e0e0; | |
| border-radius: 0.5rem; | |
| padding: 0.75rem 1rem; | |
| transition: border-color 0.3s, box-shadow 0.3s; | |
| width: 100%; /* Ensure inputs take full width */ | |
| box-sizing: border-box; /* Include padding and border in element's total width and height */ | |
| } | |
| .futuristic-input:focus, .futuristic-select:focus { | |
| outline: none; | |
| border-color: #ffd700; /* Gold focus */ | |
| box-shadow: 0 0 10px rgba(255, 215, 0, 0.25); | |
| } | |
| .futuristic-input::placeholder { color: #718096; } | |
| .futuristic-select { appearance: none; /* Remove default arrow */ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23e0e0e0' class='w-6 h-6'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='m19.5 8.25-7.5 7.5-7.5-7.5' /%3E%3C/svg%3E%0A"); background-repeat: no-repeat; background-position: right 0.75rem center; background-size: 1.25em; padding-right: 2.5rem; /* Space for arrow */ } | |
| /* Button styling */ | |
| .futuristic-button { | |
| background: linear-gradient(90deg, #0077ff, #00aaff); | |
| color: white; | |
| font-weight: 600; | |
| padding: 0.75rem 1.5rem; | |
| border-radius: 0.5rem; | |
| border: none; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| box-shadow: 0 4px 15px rgba(0, 127, 255, 0.3); | |
| white-space: nowrap; /* Prevent text wrapping */ | |
| } | |
| .futuristic-button:hover { | |
| box-shadow: 0 6px 20px rgba(0, 170, 255, 0.5); | |
| transform: translateY(-2px); | |
| background: linear-gradient(90deg, #00aaff, #00ccff); | |
| } | |
| .futuristic-button:active { | |
| transform: translateY(0); | |
| box-shadow: 0 2px 10px rgba(0, 127, 255, 0.2); | |
| } | |
| .futuristic-button:disabled { | |
| background: #555; | |
| cursor: not-allowed; | |
| box-shadow: none; | |
| transform: none; | |
| opacity: 0.7; | |
| } | |
| /* Mic button specific style */ | |
| .mic-button { | |
| background: #1f3a6e; | |
| color: #90ee90; /* Light green mic */ | |
| padding: 0.75rem; | |
| width: 50px; /* Fixed width */ | |
| height: 50px; /* Fixed height */ | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 1.25rem; | |
| box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); | |
| flex-shrink: 0; /* Prevent shrinking */ | |
| } | |
| .mic-button:hover { | |
| background: #2c5282; | |
| box-shadow: 0 4px 15px rgba(144, 238, 144, 0.4); | |
| transform: translateY(-1px); | |
| color: #adffad; | |
| } | |
| .mic-button.listening { | |
| background: linear-gradient(90deg, #3cb371, #5fbf5f); /* Green gradient */ | |
| color: white; | |
| box-shadow: 0 4px 15px rgba(60, 179, 113, 0.5); | |
| } | |
| /* Interrupt button specific style */ | |
| .interrupt-button { | |
| background: linear-gradient(90deg, #ff6b6b, #ff8e8e); /* Red gradient */ | |
| color: white; | |
| font-weight: 600; | |
| padding: 0.5rem 1rem; | |
| border-radius: 0.5rem; | |
| border: none; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4); | |
| opacity: 1; | |
| } | |
| .interrupt-button:hover { | |
| box-shadow: 0 6px 20px rgba(255, 107, 107, 0.6); | |
| transform: translateY(-2px); | |
| background: linear-gradient(90deg, #ff8e8e, #ffa7a7); | |
| } | |
| .interrupt-button:active { | |
| transform: translateY(0); | |
| box-shadow: 0 2px 10px rgba(255, 107, 107, 0.3); | |
| } | |
| .interrupt-button.hidden { | |
| opacity: 0; | |
| pointer-events: none; | |
| transform: scale(0.8); | |
| transition: opacity 0.3s ease, transform 0.3s ease; | |
| } | |
| /* Chat bubble styling */ | |
| .chat-bubble { | |
| padding: 0.75rem 1rem; | |
| border-radius: 0.75rem; | |
| max-width: 85%; /* Slightly wider max width */ | |
| word-wrap: break-word; | |
| box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); | |
| } | |
| /* User bubble */ | |
| .user-bubble { | |
| background-color: #0055aa; /* Kept blue */ | |
| margin-left: auto; | |
| border-bottom-right-radius: 0.25rem; | |
| color: #f0f0f0; | |
| } | |
| /* Bot bubble */ | |
| .bot-bubble { | |
| background-color: #4b0082; /* Dark purple */ | |
| margin-right: auto; | |
| border-bottom-left-radius: 0.25rem; | |
| color: #f0f0f0; | |
| } | |
| .bot-bubble.thinking { | |
| font-style: italic; | |
| color: #a0aec0; | |
| display: flex; | |
| align-items: center; | |
| background-color: #2d1f4a; /* Lighter purple for thinking */ | |
| } | |
| .bot-bubble.interrupted { | |
| font-style: italic; | |
| color: #f6ad55; /* Orange tone */ | |
| background-color: #5c3d1c; /* Dark orange/brown */ | |
| } | |
| /* Dot animation for thinking */ | |
| .dot-flashing { | |
| position: relative; | |
| width: 5px; height: 5px; border-radius: 5px; | |
| background-color: #a0aec0; color: #a0aec0; | |
| animation: dotFlashing 1s infinite linear alternate; | |
| animation-delay: .5s; | |
| margin-left: 8px; /* Increased spacing */ | |
| } | |
| .dot-flashing::before, .dot-flashing::after { | |
| content: ''; display: inline-block; position: absolute; top: 0; | |
| width: 5px; height: 5px; border-radius: 5px; | |
| background-color: #a0aec0; color: #a0aec0; | |
| } | |
| .dot-flashing::before { left: -10px; animation: dotFlashing 1s infinite alternate; animation-delay: 0s; } | |
| .dot-flashing::after { left: 10px; animation: dotFlashing 1s infinite alternate; animation-delay: 1s; } | |
| @keyframes dotFlashing { 0% { background-color: #a0aec0; } 50%, 100% { background-color: rgba(160, 174, 192, 0.2); } } | |
| /* Genie Orb Styling */ | |
| .genie-orb { | |
| width: 100px; height: 100px; | |
| border-radius: 50%; | |
| background: radial-gradient(circle, rgba(138, 43, 226, 0.7) 0%, rgba(75, 0, 130, 0.9) 70%); | |
| box-shadow: 0 0 25px rgba(138, 43, 226, 0.6), inset 0 0 15px rgba(255, 255, 255, 0.2); | |
| position: relative; | |
| transition: all 0.5s ease; | |
| border: 2px solid rgba(255, 215, 0, 0.3); /* Subtle gold border */ | |
| } | |
| .genie-orb::before { /* Inner glow */ | |
| content: ''; position: absolute; | |
| top: 10%; left: 10%; width: 80%; height: 80%; | |
| border-radius: 50%; | |
| background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0) 70%); | |
| } | |
| .genie-orb.listening { | |
| box-shadow: 0 0 35px rgba(60, 179, 113, 0.8), inset 0 0 20px rgba(144, 238, 144, 0.4); | |
| background: radial-gradient(circle, rgba(60, 179, 113, 0.8) 0%, rgba(46, 139, 87, 1) 70%); | |
| border-color: rgba(144, 238, 144, 0.5); | |
| } | |
| .genie-orb.active { | |
| box-shadow: 0 0 35px rgba(0, 170, 255, 0.8), inset 0 0 20px rgba(0, 204, 255, 0.4); | |
| background: radial-gradient(circle, rgba(0, 170, 255, 0.8) 0%, rgba(0, 127, 255, 1) 70%); | |
| border-color: rgba(0, 204, 255, 0.5); | |
| animation: pulse 1.5s infinite ease-in-out; | |
| } | |
| @keyframes pulse { | |
| 0% { transform: scale(1); } | |
| 50% { transform: scale(1.05); } | |
| 100% { transform: scale(1); } | |
| } | |
| /* Audio visualizer */ | |
| .box-container { | |
| display: flex; | |
| justify-content: space-between; | |
| height: 64px; | |
| width: 100%; | |
| margin-top: 1rem; | |
| } | |
| .box { | |
| height: 100%; | |
| width: 8px; | |
| background: var(--color-accent, #6366f1); | |
| border-radius: 8px; | |
| transition: transform 0.05s ease; | |
| } | |
| /* Responsive Design */ | |
| /* Stack columns on smaller screens */ | |
| .main-container { | |
| display: flex; | |
| flex-direction: column; /* Default: Stack vertically */ | |
| height: 100vh; /* Full viewport height */ | |
| } | |
| .chat-section { | |
| flex-grow: 1; /* Takes available space */ | |
| display: flex; | |
| flex-direction: column; | |
| padding: 1rem; /* Padding for mobile */ | |
| overflow: hidden; /* Prevent overflow */ | |
| } | |
| .status-section { | |
| padding: 1rem; /* Padding for mobile */ | |
| height: auto; /* Adjust height automatically */ | |
| flex-shrink: 0; /* Prevent shrinking */ | |
| background: linear-gradient(to bottom, #1a102f, #0f0a1f); /* Gradient for status section */ | |
| } | |
| /* Apply row layout on medium screens and up */ | |
| @media (min-width: 768px) { /* md breakpoint */ | |
| .main-container { | |
| flex-direction: row; /* Side-by-side layout */ | |
| } | |
| .chat-section { | |
| width: 66.666667%; /* 2/3 width */ | |
| padding: 1.5rem; /* Larger padding */ | |
| height: 100vh; /* Full height */ | |
| } | |
| .status-section { | |
| width: 33.333333%; /* 1/3 width */ | |
| padding: 1.5rem; /* Larger padding */ | |
| height: 100vh; /* Full height */ | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; /* Center vertically */ | |
| } | |
| .status-content { | |
| width: 100%; | |
| max-width: 400px; /* Max width for status content */ | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 1.5rem; /* Space between items */ | |
| } | |
| .chat-input-area { | |
| display: flex; /* Keep input and button side-by-side */ | |
| align-items: center; | |
| gap: 0.75rem; /* Space between mic, input, send */ | |
| } | |
| } | |
| /* Ensure chat history scrolls within its container */ | |
| #chatHistory { | |
| flex-grow: 1; /* Takes up remaining space in chat-section */ | |
| overflow-y: auto; /* Enable vertical scroll */ | |
| } | |
| /* Adjust input area layout for smaller screens */ | |
| .chat-input-area { | |
| display: flex; | |
| flex-wrap: wrap; /* Allow wrapping */ | |
| gap: 0.5rem; /* Space between elements */ | |
| } | |
| #userQuery { | |
| flex-grow: 1; /* Take available space */ | |
| min-width: 150px; /* Minimum width before wrapping */ | |
| } | |
| .preferences-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); /* Responsive grid */ | |
| gap: 0.75rem; /* Space between grid items */ | |
| } | |
| /* Toast notification */ | |
| .toast { | |
| position: fixed; | |
| top: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| padding: 16px 24px; | |
| border-radius: 4px; | |
| font-size: 14px; | |
| z-index: 1000; | |
| display: none; | |
| box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); | |
| } | |
| .toast.error { | |
| background-color: #f44336; | |
| color: white; | |
| } | |
| .toast.warning { | |
| background-color: #ffd700; | |
| color: black; | |
| } | |
| .toast.info { | |
| background-color: #2196F3; | |
| color: white; | |
| } | |
| </style> | |
| </head> | |
| <body class="text-sm md:text-base"> | |
| <!-- Toast notification element --> | |
| <div id="toast" class="toast"></div> | |
| <div class="main-container"> | |
| <div class="chat-section"> | |
| <div class="text-center mb-3 md:mb-4"> | |
| <h1 class="text-2xl md:text-3xl font-bold text-purple-300">G.E.N.I.E.</h1> | |
| <p class="text-purple-100 text-xs md:text-sm"> | |
| GitHub Enhanced Natural Intelligence Engine <br class="md:hidden"> | |
| <span class="hidden md:inline"> - </span> | |
| A voice-controlled AI that "grants your GitHub wishes." | |
| </p> | |
| </div> | |
| <div class="glass-panel p-3 md:p-4 space-y-3 mb-3 md:mb-4"> | |
| <input type="url" id="repoUrl" placeholder="GitHub Repository URL (e.g., https://github.com/owner/repo)" class="futuristic-input"> | |
| <div class="preferences-grid"> | |
| <input type="password" id="githubToken" placeholder="GitHub Token (Optional)" title="Enter a GitHub PAT for potentially accessing private repos (stored locally, use with caution)." class="futuristic-input"> | |
| <select id="userType" class="futuristic-select" title="Select your role for tailored responses."> | |
| <option value="coder">Role: Coder</option> | |
| <option value="manager">Role: Manager</option> | |
| <option value="researcher">Role: Researcher</option> | |
| <option value="student">Role: Student</option> | |
| </select> | |
| <select id="responseDetail" class="futuristic-select" title="Select the desired level of detail for G.E.N.I.E.'s responses."> | |
| <option value="concise">Detail: Concise</option> | |
| <option value="normal" selected>Detail: Normal</option> | |
| <option value="detailed">Detail: Detailed</option> | |
| </select> | |
| </div> | |
| <div class="chat-input-area"> | |
| <button id="micButton" title="Toggle Listening Mode / Interrupt" class="futuristic-button mic-button">🎙️</button> | |
| <input type="text" id="userQuery" placeholder="Make a wish about the repository..." class="futuristic-input flex-grow"> | |
| <button id="sendButton" class="futuristic-button">Ask G.E.N.I.E.</button> | |
| </div> | |
| </div> | |
| <div id="chatHistory" class="flex-grow glass-panel p-3 md:p-4 space-y-3 overflow-y-auto custom-scrollbar"> | |
| <div class="chat-bubble bot-bubble"> | |
| Greetings! I am G.E.N.I.E. Provide a repo URL, set preferences (optional), and make your wish (ask a question). Click 🎙️ or start typing to interrupt me. | |
| </div> | |
| </div> | |
| </div> | |
| <div class="status-section"> | |
| <div class="status-content"> <!-- Wrapper for centering content --> | |
| <h2 id="agentStatusTitle" class="text-lg md:text-xl font-semibold text-purple-400 text-center">G.E.N.I.E. Status</h2> | |
| <div id="genieOrb" class="genie-orb"></div> | |
| <button id="interruptButton" class="interrupt-button hidden">Interrupt G.E.N.I.E.</button> | |
| <!-- Audio visualizer --> | |
| <div class="glass-panel p-3 md:p-4 w-full"> | |
| <div class="box-container" id="audioVisualizer"> | |
| <!-- Bars will be added dynamically --> | |
| </div> | |
| </div> | |
| <div class="w-full glass-panel p-3 md:p-4 h-32 md:h-40 overflow-y-auto custom-scrollbar"> | |
| <h3 class="text-base md:text-lg font-medium text-purple-300 mb-2 border-b border-purple-700 pb-1">G.E.N.I.E. Output:</h3> | |
| <p id="genieOutput" class="text-xs md:text-sm whitespace-pre-wrap">Awaiting your command...</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // --- DOM Elements --- | |
| const repoUrlInput = document.getElementById('repoUrl'); | |
| const githubTokenInput = document.getElementById('githubToken'); | |
| const userTypeSelect = document.getElementById('userType'); | |
| const responseDetailSelect = document.getElementById('responseDetail'); | |
| const userQueryInput = document.getElementById('userQuery'); | |
| const sendButton = document.getElementById('sendButton'); | |
| const micButton = document.getElementById('micButton'); | |
| const interruptButton = document.getElementById('interruptButton'); | |
| const chatHistory = document.getElementById('chatHistory'); | |
| const genieOrb = document.getElementById('genieOrb'); | |
| const genieOutput = document.getElementById('genieOutput'); | |
| const agentStatusTitle = document.getElementById('agentStatusTitle'); | |
| const toast = document.getElementById('toast'); | |
| const audioVisualizer = document.getElementById('audioVisualizer'); | |
| const audioOutput = new Audio(); | |
| // --- State --- | |
| let isListening = false; | |
| let isBotActive = false; | |
| let botResponseTimeoutId = null; | |
| let thinkingMessageElement = null; | |
| let webSocket = null; | |
| let audioContext = null; | |
| let audioAnalyser = null; | |
| let microphoneStream = null; | |
| let microphoneProcessor = null; | |
| let audioSequence = 0; | |
| let isConnected = false; | |
| let isGeminiResponding = false; | |
| // --- WebSocket and Audio Setup --- | |
| function setupWebSocket() { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${protocol}//${window.location.host}/ws`; | |
| webSocket = new WebSocket(wsUrl); | |
| webSocket.onopen = () => { | |
| console.log("WebSocket connection established"); | |
| isConnected = true; | |
| showToast("Connected to G.E.N.I.E. server", "info"); | |
| // Send initial preferences when connection is established | |
| sendPreferences(); | |
| }; | |
| webSocket.onmessage = (event) => { | |
| const message = JSON.parse(event.data); | |
| if (message.type === "audio") { | |
| // Convert base64 to audio buffer and play | |
| playAudioFromServer(message.payload); | |
| // Set Gemini as responding when we get audio data | |
| if (!isGeminiResponding) { | |
| isGeminiResponding = true; | |
| setBotActiveState(true, false); // Active but not thinking | |
| } | |
| } else if (message.type === "text") { | |
| // Handle text responses | |
| addMessageToChat(message.content, 'bot'); | |
| updateGenieText(message.content); | |
| // End of response | |
| if (message.turn_complete) { | |
| isGeminiResponding = false; | |
| setBotActiveState(false); | |
| } | |
| } else if (message.type === "status") { | |
| // Handle status updates | |
| console.log("Status update:", message.status, message.message); | |
| if (message.status === "interrupted") { | |
| isGeminiResponding = false; | |
| setBotActiveState(false); | |
| // Remove thinking message if it exists | |
| if (thinkingMessageElement) { | |
| thinkingMessageElement.remove(); | |
| thinkingMessageElement = null; | |
| } | |
| // Add interrupted message | |
| addMessageToChat(message.message, 'interrupted'); | |
| } | |
| } | |
| }; | |
| webSocket.onclose = () => { | |
| console.log("WebSocket connection closed"); | |
| isConnected = false; | |
| // Attempt to reconnect after a delay | |
| setTimeout(() => { | |
| if (!isConnected) { | |
| console.log("Attempting to reconnect..."); | |
| setupWebSocket(); | |
| } | |
| }, 3000); | |
| }; | |
| webSocket.onerror = (error) => { | |
| console.error("WebSocket error:", error); | |
| showToast("Connection error. Please try again later.", "error"); | |
| }; | |
| } | |
| // Setup audio visualizer | |
| function setupAudioVisualizer() { | |
| // Create bars for the audio visualizer | |
| audioVisualizer.innerHTML = ''; // Clear existing bars | |
| const numBars = 32; | |
| for (let i = 0; i < numBars; i++) { | |
| const bar = document.createElement('div'); | |
| bar.className = 'box'; | |
| audioVisualizer.appendChild(bar); | |
| } | |
| } | |
| // Initialize audio context for visualization and recording | |
| async function setupAudioContext() { | |
| try { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| return true; | |
| } catch (e) { | |
| console.error("Error creating audio context:", e); | |
| showToast("Could not initialize audio. Please check your browser permissions.", "error"); | |
| return false; | |
| } | |
| } | |
| // Request microphone access and set up stream | |
| async function setupMicrophone() { | |
| try { | |
| if (!audioContext) { | |
| const success = await setupAudioContext(); | |
| if (!success) return false; | |
| } | |
| // Get user media | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| microphoneStream = stream; | |
| // Create analyzer for input visualization | |
| const source = audioContext.createMediaStreamSource(stream); | |
| audioAnalyser = audioContext.createAnalyser(); | |
| audioAnalyser.fftSize = 256; | |
| source.connect(audioAnalyser); | |
| // Create processor for sending audio | |
| microphoneProcessor = audioContext.createScriptProcessor(4096, 1, 1); | |
| source.connect(microphoneProcessor); | |
| microphoneProcessor.connect(audioContext.destination); | |
| microphoneProcessor.onaudioprocess = (e) => { | |
| if (isListening && isConnected) { | |
| const audioData = e.inputBuffer.getChannelData(0); | |
| sendAudioToServer(audioData); | |
| } | |
| }; | |
| // Start visualizing input | |
| visualizeAudio(); | |
| return true; | |
| } catch (e) { | |
| console.error("Error accessing microphone:", e); | |
| showToast("Could not access your microphone. Please check your browser permissions.", "error"); | |
| return false; | |
| } | |
| } | |
| // Visualize audio input/output | |
| function visualizeAudio() { | |
| if (!audioAnalyser) return; | |
| const bufferLength = audioAnalyser.frequencyBinCount; | |
| const dataArray = new Uint8Array(bufferLength); | |
| function draw() { | |
| if (!audioAnalyser) return; | |
| audioAnalyser.getByteFrequencyData(dataArray); | |
| const bars = audioVisualizer.querySelectorAll('.box'); | |
| // Update each bar height based on frequency data | |
| let barIndex = 0; | |
| const barCount = bars.length; | |
| const step = Math.floor(bufferLength / barCount) || 1; | |
| for (let i = 0; i < bufferLength; i += step) { | |
| if (barIndex >= barCount) break; | |
| // Get average of frequency range | |
| let sum = 0; | |
| for (let j = 0; j < step && i + j < bufferLength; j++) { | |
| sum += dataArray[i + j]; | |
| } | |
| const average = sum / step; | |
| // Scale height (0.1 minimum to always show something) | |
| const barHeight = Math.max(0.1, average / 255); | |
| bars[barIndex].style.transform = `scaleY(${barHeight})`; | |
| barIndex++; | |
| } | |
| // Keep animating | |
| requestAnimationFrame(draw); | |
| } | |
| draw(); | |
| } | |
| // Send audio data to server | |
| function sendAudioToServer(audioData) { | |
| if (!webSocket || webSocket.readyState !== WebSocket.OPEN) return; | |
| // Convert float32 to int16 | |
| const pcmData = new Int16Array(audioData.length); | |
| for (let i = 0; i < audioData.length; i++) { | |
| pcmData[i] = Math.max(-32768, Math.min(32767, audioData[i] * 32768)); | |
| } | |
| // Convert to base64 | |
| const buffer = new ArrayBuffer(pcmData.length * 2); | |
| const view = new DataView(buffer); | |
| for (let i = 0; i < pcmData.length; i++) { | |
| view.setInt16(i * 2, pcmData[i], true); | |
| } | |
| const base64Audio = btoa(String.fromCharCode(...new Uint8Array(buffer))); | |
| // Send with sequence number for ordering | |
| webSocket.send(JSON.stringify({ | |
| type: "audio", | |
| payload: base64Audio, | |
| seq: audioSequence++ | |
| })); | |
| } | |
| // Play audio received from server | |
| function playAudioFromServer(base64Audio) { | |
| const audioData = atob(base64Audio); | |
| const buffer = new ArrayBuffer(audioData.length); | |
| const view = new Uint8Array(buffer); | |
| for (let i = 0; i < audioData.length; i++) { | |
| view[i] = audioData.charCodeAt(i); | |
| } | |
| // Create blob and play | |
| const blob = new Blob([buffer], { type: 'audio/wav' }); | |
| const url = URL.createObjectURL(blob); | |
| // Queue audio | |
| const audio = new Audio(url); | |
| audio.onended = () => URL.revokeObjectURL(url); // Clean up | |
| audio.play().catch(e => console.error("Error playing audio:", e)); | |
| // If we have AudioContext, create analyzer for visualization | |
| if (audioContext && !audioAnalyser) { | |
| try { | |
| const audioSource = audioContext.createMediaElementSource(audio); | |
| audioAnalyser = audioContext.createAnalyser(); | |
| audioAnalyser.fftSize = 256; | |
| audioSource.connect(audioAnalyser); | |
| audioSource.connect(audioContext.destination); | |
| // Start visualization if not already running | |
| visualizeAudio(); | |
| } catch (e) { | |
| console.warn("Could not create audio analyzer for playback:", e); | |
| } | |
| } | |
| } | |
| // Send preferences to server | |
| function sendPreferences() { | |
| if (!webSocket || webSocket.readyState !== WebSocket.OPEN) return; | |
| const repoUrl = repoUrlInput.value.trim(); | |
| const githubToken = githubTokenInput.value.trim(); | |
| const userType = userTypeSelect.value; | |
| const responseDetail = responseDetailSelect.value; | |
| webSocket.send(JSON.stringify({ | |
| type: "init", | |
| repo_url: repoUrl, | |
| github_token: githubToken, | |
| user_type: userType, | |
| response_detail: responseDetail | |
| })); | |
| } | |
| // --- Event Handlers --- | |
| async function handleMicButtonClick() { | |
| // If the bot is actively processing, the mic button acts as an interrupt | |
| if (isBotActive) { | |
| handleInterrupt(); | |
| return; | |
| } | |
| // Otherwise, toggle listening mode | |
| const wasListening = isListening; | |
| await toggleListeningMode(); | |
| // If we just started listening and there's no active message | |
| // Send an empty audio data packet to initiate connection | |
| if (!wasListening && isListening && !thinkingMessageElement) { | |
| microphoneStream = null; // Force new stream setup | |
| const success = await setupMicrophone(); | |
| if (!success) { | |
| toggleListeningMode(); // Turn off listening if mic setup failed | |
| } | |
| } | |
| } | |
| async function toggleListeningMode() { | |
| if (isBotActive && !isListening) return; // Don't start listening if bot is busy (unless already listening) | |
| isListening = !isListening; | |
| micButton.classList.toggle('listening', isListening); | |
| genieOrb.classList.toggle('listening', isListening); | |
| if (isListening) { | |
| // Ensure WebSocket is connected | |
| if (!isConnected) { | |
| setupWebSocket(); | |
| } | |
| // Setup microphone if not already done | |
| if (!microphoneStream) { | |
| const success = await setupMicrophone(); | |
| if (!success) { | |
| isListening = false; | |
| micButton.classList.remove('listening'); | |
| genieOrb.classList.remove('listening'); | |
| return; | |
| } | |
| } | |
| setBotActiveState(false); // Ensure bot is not marked active | |
| agentStatusTitle.textContent = "G.E.N.I.E. Status: Listening..."; | |
| updateGenieText("Listening... Speak your wish or type a question."); | |
| micButton.title = "Stop Listening / Interrupt"; // Update tooltip | |
| } else { | |
| // Stop sending audio | |
| micButton.title = "Toggle Listening Mode / Interrupt"; // Reset tooltip | |
| // Only reset status if the bot isn't currently active | |
| if (!isBotActive) { | |
| agentStatusTitle.textContent = "G.E.N.I.E. Status"; | |
| // Only reset output if it was showing the listening message | |
| if (genieOutput.textContent.startsWith("Listening...")) { | |
| updateGenieText("Awaiting your command..."); | |
| } | |
| } | |
| } | |
| } | |
| function handleTypingInterrupt() { | |
| // Interrupt if the bot is thinking and the user starts typing | |
| if (isBotActive && botResponseTimeoutId) { | |
| handleInterrupt(); | |
| } | |
| } | |
| function handleSendQuery() { | |
| // Stop listening mode if active when sending query | |
| if (isListening) { | |
| toggleListeningMode(); | |
| } | |
| // Prevent sending if bot is already active | |
| if (isBotActive) { | |
| console.log("G.E.N.I.E. is active, cannot send new query yet."); | |
| return; | |
| } | |
| const repoUrl = repoUrlInput.value.trim(); | |
| const query = userQueryInput.value.trim(); | |
| // Basic Validations | |
| if (!query) { | |
| showToast('Please state your wish (enter a query).', 'warning'); | |
| return; | |
| } | |
| if (!isValidHttpUrl(repoUrl)) { | |
| showToast('Please provide a valid GitHub repository URL (starting with http:// or https://).', 'warning'); | |
| return; | |
| } | |
| // Ensure WebSocket is connected | |
| if (!isConnected) { | |
| setupWebSocket(); | |
| setTimeout(() => sendTextQuery(query), 500); // Short delay to allow connection | |
| return; | |
| } | |
| sendTextQuery(query); | |
| } | |
| function sendTextQuery(query) { | |
| // Add user message to chat | |
| addMessageToChat(query, 'user'); | |
| userQueryInput.value = ''; // Clear input field | |
| // Add thinking message | |
| thinkingMessageElement = addMessageToChat('', 'thinking'); | |
| // Send preferences again in case they've changed | |
| sendPreferences(); | |
| // Send the query to the server | |
| if (webSocket && webSocket.readyState === WebSocket.OPEN) { | |
| webSocket.send(JSON.stringify({ | |
| type: "text", | |
| content: query | |
| })); | |
| } else { | |
| showToast("Connection to server lost. Please try again.", "error"); | |
| // Remove thinking message | |
| if (thinkingMessageElement) { | |
| thinkingMessageElement.remove(); | |
| thinkingMessageElement = null; | |
| } | |
| setBotActiveState(false); | |
| } | |
| } | |
| function handleInterrupt() { | |
| // Send interrupt signal to server | |
| if (webSocket && webSocket.readyState === WebSocket.OPEN) { | |
| webSocket.send(JSON.stringify({ | |
| type: "interrupt" | |
| })); | |
| } | |
| // Clear local UI state | |
| if (botResponseTimeoutId) { | |
| clearTimeout(botResponseTimeoutId); | |
| botResponseTimeoutId = null; | |
| } | |
| // Remove the "thinking" message if it exists | |
| if (thinkingMessageElement) { | |
| thinkingMessageElement.remove(); | |
| thinkingMessageElement = null; | |
| } | |
| // Reset bot state | |
| isGeminiResponding = false; | |
| setBotActiveState(false); | |
| // Refocus input for convenience | |
| userQueryInput.focus(); | |
| } | |
| function addMessageToChat(message, type) { | |
| const messageElement = document.createElement('div'); | |
| messageElement.classList.add('chat-bubble'); | |
| // Stop listening visual cues if a message is added | |
| if (isListening && type !== 'thinking') { | |
| genieOrb.classList.remove('listening'); | |
| micButton.classList.remove('listening'); | |
| isListening = false; // Ensure state is updated | |
| micButton.title = "Toggle Listening Mode / Interrupt"; // Reset tooltip | |
| } | |
| switch (type) { | |
| case 'user': | |
| messageElement.classList.add('user-bubble'); | |
| messageElement.textContent = message; | |
| break; | |
| case 'bot': | |
| messageElement.classList.add('bot-bubble'); | |
| messageElement.textContent = message; | |
| updateGenieText(message); // Update status output | |
| // Reset the thinking message | |
| if (thinkingMessageElement) { | |
| thinkingMessageElement.remove(); | |
| thinkingMessageElement = null; | |
| } | |
| // After a bot message, ensure we're not in "thinking" mode | |
| setBotActiveState(true, false); // Active but not thinking | |
| break; | |
| case 'thinking': | |
| messageElement.classList.add('bot-bubble', 'thinking'); | |
| messageElement.innerHTML = `Consulting the digital ether... <div class="dot-flashing"></div>`; | |
| updateGenieText("Processing your wish..."); // Update status output | |
| setBotActiveState(true, true); // Active and thinking | |
| thinkingMessageElement = messageElement; // Store reference to remove later | |
| break; | |
| case 'interrupted': | |
| messageElement.classList.add('bot-bubble', 'interrupted'); | |
| messageElement.textContent = message; | |
| break; | |
| default: | |
| console.error("Unknown message type:", type); | |
| return null; // Don't add unknown types | |
| } | |
| chatHistory.appendChild(messageElement); | |
| // Scroll to the bottom smoothly after adding message | |
| setTimeout(() => { | |
| chatHistory.scrollTo({ top: chatHistory.scrollHeight, behavior: 'smooth' }); | |
| }, 50); | |
| return messageElement; // Return the created element | |
| } | |
| function updateGenieText(text) { | |
| genieOutput.textContent = text; | |
| } | |
| function setBotActiveState(isActive, isThinking = true) { | |
| isBotActive = isActive; | |
| // Always update these elements | |
| genieOrb.classList.toggle('active', isActive); | |
| interruptButton.classList.toggle('hidden', !isActive); | |
| sendButton.disabled = isActive; | |
| userQueryInput.disabled = isActive; | |
| repoUrlInput.disabled = isActive; | |
| githubTokenInput.disabled = isActive; | |
| userTypeSelect.disabled = isActive; | |
| responseDetailSelect.disabled = isActive; | |
| if (isActive) { | |
| // If activating, ensure listening mode is off | |
| if (isListening) { | |
| toggleListeningMode(); // Turn off listening visuals/state | |
| } | |
| agentStatusTitle.textContent = isThinking ? | |
| "G.E.N.I.E. Status: Fulfilling Wish..." : | |
| "G.E.N.I.E. Status: Active"; | |
| micButton.title = "Interrupt G.E.N.I.E."; // Mic becomes interrupt | |
| } else { | |
| // Reset status title only if not currently listening | |
| if (!isListening) { | |
| agentStatusTitle.textContent = "G.E.N.I.E. Status"; | |
| updateGenieText("Awaiting your command..."); // Reset output text | |
| } | |
| micButton.title = "Toggle Listening Mode / Interrupt"; // Reset mic title | |
| // Clear any lingering timeout or thinking message reference | |
| if (botResponseTimeoutId) { | |
| clearTimeout(botResponseTimeoutId); | |
| botResponseTimeoutId = null; | |
| } | |
| thinkingMessageElement = null; | |
| } | |
| } | |
| function isValidHttpUrl(string) { | |
| try { | |
| const url = new URL(string); | |
| return url.protocol === "http:" || url.protocol === "https:"; | |
| } catch (_) { | |
| return false; // Invalid URL format | |
| } | |
| } | |
| function showToast(message, type = 'error') { | |
| toast.textContent = message; | |
| toast.className = `toast ${type}`; | |
| toast.style.display = 'block'; | |
| // Hide toast after 5 seconds | |
| setTimeout(() => { | |
| toast.style.display = 'none'; | |
| }, 5000); | |
| } | |
| // --- Event Listeners --- | |
| sendButton.addEventListener('click', handleSendQuery); | |
| userQueryInput.addEventListener('keypress', (event) => { | |
| if (event.key === 'Enter' && !event.shiftKey) { | |
| event.preventDefault(); // Prevent default newline behavior | |
| handleSendQuery(); | |
| } | |
| }); | |
| userQueryInput.addEventListener('input', handleTypingInterrupt); | |
| micButton.addEventListener('click', handleMicButtonClick); | |
| interruptButton.addEventListener('click', handleInterrupt); | |
| // Listen for preference changes to update server | |
| repoUrlInput.addEventListener('change', sendPreferences); | |
| githubTokenInput.addEventListener('change', sendPreferences); | |
| userTypeSelect.addEventListener('change', sendPreferences); | |
| responseDetailSelect.addEventListener('change', sendPreferences); | |
| // --- Initialization --- | |
| function init() { | |
| // Set up WebSocket connection | |
| setupWebSocket(); | |
| // Set up audio context and visualizer | |
| setupAudioVisualizer(); | |
| // Initialize state | |
| updateGenieText("Standing by. Please provide a GitHub repo URL, set preferences, and state your wish, or click 🎙️."); | |
| interruptButton.classList.add('hidden'); | |
| sendButton.disabled = false; | |
| setBotActiveState(false); | |
| } | |
| // Start initialization | |
| init(); | |
| // When page is closing, clean up WebSocket and audio resources | |
| window.addEventListener('beforeunload', () => { | |
| if (webSocket) { | |
| webSocket.close(); | |
| } | |
| if (microphoneStream) { | |
| microphoneStream.getTracks().forEach(track => track.stop()); | |
| } | |
| if (microphoneProcessor) { | |
| microphoneProcessor.disconnect(); | |
| } | |
| if (audioContext) { | |
| audioContext.close(); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |