Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Voice Interview - Project Story</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/driver.js@latest/dist/driver.js.iife.js"></script> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/driver.js@latest/dist/driver.css"/> | |
| <style> | |
| body { | |
| font-family: system-ui, -apple-system, sans-serif; | |
| } | |
| .gradient-bg { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| } | |
| .recording { | |
| animation: pulse 1.5s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; transform: scale(1); } | |
| 50% { opacity: 0.8; transform: scale(1.05); } | |
| } | |
| .speaking { | |
| animation: speaking 0.8s infinite; | |
| } | |
| @keyframes speaking { | |
| 0%, 100% { background-position: 0% 50%; } | |
| 50% { background-position: 100% 50%; } | |
| } | |
| .transcript-msg { | |
| animation: fadeIn 0.3s ease-in; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen flex flex-col items-center p-4"> | |
| <div class="max-w-4xl w-full"> | |
| <!-- Header --> | |
| <div class="gradient-bg text-white p-6 rounded-t-xl shadow-lg"> | |
| <div class="flex items-center justify-between"> | |
| <div> | |
| <h1 class="text-3xl font-bold mb-2">π€ Voice Interview</h1> | |
| <p class="text-sm opacity-90">Tell your project story - just speak naturally!</p> | |
| </div> | |
| <button | |
| id="help-btn" | |
| onclick="startTutorial()" | |
| class="bg-white bg-opacity-20 hover:bg-opacity-30 text-white px-4 py-2 rounded-lg text-sm font-medium transition" | |
| title="Show tutorial"> | |
| β Help | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Main Content --> | |
| <div class="bg-white shadow-lg rounded-b-xl p-6"> | |
| <!-- Instructions --> | |
| <div id="instructions-box" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6"> | |
| <h3 class="font-bold text-blue-900 mb-2">How it works:</h3> | |
| <ol class="text-sm text-blue-800 space-y-1 list-decimal list-inside"> | |
| <li>Click "Start Interview" to begin</li> | |
| <li>Speak naturally - the AI will ask you questions</li> | |
| <li>When done, click "Generate Article"</li> | |
| <li>Review and submit your story to GitHub</li> | |
| </ol> | |
| </div> | |
| <!-- WebRTC Audio Container --> | |
| <div id="audio-container" class="mb-6"> | |
| <!-- FastRTC will inject audio elements here --> | |
| </div> | |
| <!-- Status Display --> | |
| <div class="flex flex-col items-center mb-6"> | |
| <button | |
| id="start-btn" | |
| onclick="startInterview()" | |
| class="gradient-bg text-white px-8 py-4 rounded-full text-lg font-bold shadow-lg hover:shadow-xl transition-all mb-4"> | |
| ποΈ Start Interview | |
| </button> | |
| <div id="status" class="text-center"> | |
| <p class="text-gray-600 text-sm">Click above to begin</p> | |
| </div> | |
| <div id="speaking-indicator" class="hidden mt-4"> | |
| <div class="flex items-center gap-2 bg-gradient-to-r from-purple-500 to-indigo-500 text-white px-4 py-2 rounded-full speaking"> | |
| <div class="w-3 h-3 bg-white rounded-full"></div> | |
| <span class="text-sm font-medium">AI is speaking...</span> | |
| </div> | |
| </div> | |
| <div id="listening-indicator" class="hidden mt-4"> | |
| <div class="flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-full recording"> | |
| <div class="w-3 h-3 bg-white rounded-full"></div> | |
| <span class="text-sm font-medium">Listening...</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Transcript Display --> | |
| <div id="transcript-section" class="mb-6"> | |
| <h3 class="font-bold text-gray-800 mb-3">π Transcript:</h3> | |
| <div id="transcript" class="bg-gray-50 border border-gray-200 rounded-lg p-4 min-h-[300px] max-h-[500px] overflow-y-auto space-y-3"> | |
| <p class="text-gray-400 italic text-sm">Your conversation will appear here...</p> | |
| </div> | |
| </div> | |
| <!-- Actions --> | |
| <div id="action-buttons" class="flex gap-3"> | |
| <button | |
| id="end-btn" | |
| onclick="endInterview()" | |
| disabled | |
| class="flex-1 bg-orange-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-orange-700 transition disabled:opacity-50 disabled:cursor-not-allowed"> | |
| βΉοΈ End Interview | |
| </button> | |
| <button | |
| id="generate-btn" | |
| onclick="generateArticle()" | |
| disabled | |
| class="flex-1 gradient-bg text-white px-6 py-3 rounded-lg font-medium hover:opacity-90 transition disabled:opacity-50 disabled:cursor-not-allowed"> | |
| π Generate Article | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Article Modal --> | |
| <div id="article-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center p-4 z-50"> | |
| <div class="bg-white rounded-xl max-w-4xl w-full max-h-[90vh] flex flex-col shadow-2xl"> | |
| <div class="p-4 border-b gradient-bg text-white rounded-t-xl"> | |
| <h2 class="text-xl font-bold">π Your Article</h2> | |
| </div> | |
| <div class="p-6 overflow-y-auto flex-1"> | |
| <div id="article-content" class="prose max-w-none"> | |
| <p class="text-gray-500 italic">Generating...</p> | |
| </div> | |
| </div> | |
| <div id="article-actions" class="p-4 border-t flex gap-3"> | |
| <button onclick="closeArticle()" class="px-6 py-2 text-gray-600 hover:bg-gray-100 rounded-lg font-medium"> | |
| Close | |
| </button> | |
| <button onclick="downloadArticle()" class="px-6 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 font-medium"> | |
| πΎ Download | |
| </button> | |
| <button onclick="submitArticle()" id="submit-btn" class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium"> | |
| π Submit to GitHub | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| const RTC_CONFIGURATION = __RTC_CONFIGURATION__; | |
| let pc = null; | |
| let audioContext = null; | |
| let webrtcId = null; | |
| let eventSource = null; | |
| let currentArticle = ""; | |
| let driverObj = null; | |
| // Check if user has seen tutorial | |
| function checkFirstVisit() { | |
| const hasSeenTutorial = localStorage.getItem('voiceInterviewTutorialSeen'); | |
| if (!hasSeenTutorial) { | |
| // Show tutorial after a brief delay | |
| setTimeout(() => { | |
| startTutorial(); | |
| localStorage.setItem('voiceInterviewTutorialSeen', 'true'); | |
| }, 800); | |
| } | |
| } | |
| function startTutorial() { | |
| const driver = window.driver.js.driver; | |
| if (driverObj) { | |
| driverObj.destroy(); | |
| } | |
| driverObj = driver({ | |
| showProgress: true, | |
| steps: [ | |
| { | |
| element: '#instructions-box', | |
| popover: { | |
| title: 'π Welcome to Voice Interview!', | |
| description: 'This tool helps you create project stories through natural conversation. Let me show you around!', | |
| side: "bottom", | |
| align: 'start' | |
| } | |
| }, | |
| { | |
| element: '#start-btn', | |
| popover: { | |
| title: 'ποΈ Start Your Interview', | |
| description: 'Click this button to begin. You\'ll need to allow microphone access when prompted. The AI interviewer will ask you questions about your project.', | |
| side: "bottom", | |
| align: 'center' | |
| } | |
| }, | |
| { | |
| element: '#transcript-section', | |
| popover: { | |
| title: 'π Live Transcript', | |
| description: 'Watch your conversation unfold here in real-time. You\'ll see both your responses and the interviewer\'s questions.', | |
| side: "top", | |
| align: 'start' | |
| } | |
| }, | |
| { | |
| element: '#speaking-indicator', | |
| popover: { | |
| title: 'π Status Indicators', | |
| description: 'Purple indicator = AI is speaking. Red indicator = AI is listening to you. These help you know when to talk.', | |
| side: "top", | |
| align: 'center' | |
| } | |
| }, | |
| { | |
| element: '#end-btn', | |
| popover: { | |
| title: 'βΉοΈ End Interview', | |
| description: 'When you\'re done sharing your story, click here to stop the interview. You can then generate your article.', | |
| side: "top", | |
| align: 'start' | |
| } | |
| }, | |
| { | |
| element: '#generate-btn', | |
| popover: { | |
| title: 'π Generate Article', | |
| description: 'This transforms your conversation into a polished article. The AI will structure your responses into a compelling project story.', | |
| side: "top", | |
| align: 'end' | |
| } | |
| }, | |
| { | |
| element: '#help-btn', | |
| popover: { | |
| title: 'β Need Help?', | |
| description: 'Click this anytime to see this tutorial again. Now you\'re ready to start your interview!', | |
| side: "bottom", | |
| align: 'end' | |
| } | |
| } | |
| ], | |
| onDestroyStarted: () => { | |
| if (!driverObj.hasNextStep()) { | |
| driverObj.destroy(); | |
| } | |
| } | |
| }); | |
| driverObj.drive(); | |
| } | |
| async function startInterview() { | |
| const startBtn = document.getElementById('start-btn'); | |
| const endBtn = document.getElementById('end-btn'); | |
| const status = document.getElementById('status'); | |
| startBtn.disabled = true; | |
| status.innerHTML = '<p class="text-blue-600">π Connecting...</p>'; | |
| try { | |
| // Create WebRTC connection | |
| pc = new RTCPeerConnection(RTC_CONFIGURATION); | |
| // Generate session ID | |
| webrtcId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); | |
| // Get user media | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| stream.getTracks().forEach(track => pc.addTrack(track, stream)); | |
| // Handle incoming audio | |
| pc.ontrack = (event) => { | |
| const audio = new Audio(); | |
| audio.srcObject = event.streams[0]; | |
| audio.autoplay = true; | |
| // Show speaking indicator when AI is speaking | |
| audio.onplay = () => { | |
| document.getElementById('speaking-indicator').classList.remove('hidden'); | |
| document.getElementById('listening-indicator').classList.add('hidden'); | |
| }; | |
| audio.onended = () => { | |
| document.getElementById('speaking-indicator').classList.add('hidden'); | |
| document.getElementById('listening-indicator').classList.remove('hidden'); | |
| }; | |
| }; | |
| // Create offer | |
| const offer = await pc.createOffer(); | |
| await pc.setLocalDescription(offer); | |
| // Send offer to server | |
| const response = await fetch('/webrtc/start', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| webrtc_id: webrtcId, | |
| sdp_offer: pc.localDescription.sdp | |
| }) | |
| }); | |
| const data = await response.json(); | |
| await pc.setRemoteDescription({ | |
| type: 'answer', | |
| sdp: data.sdp_answer | |
| }); | |
| status.innerHTML = '<p class="text-green-600">β Connected! Speak naturally.</p>'; | |
| endBtn.disabled = false; | |
| document.getElementById('generate-btn').disabled = false; | |
| document.getElementById('listening-indicator').classList.remove('hidden'); | |
| // Start listening to transcript updates | |
| listenForTranscript(); | |
| } catch (error) { | |
| console.error('Failed to start:', error); | |
| status.innerHTML = '<p class="text-red-600">β Failed to connect. Please check microphone permissions.</p>'; | |
| startBtn.disabled = false; | |
| } | |
| } | |
| function listenForTranscript() { | |
| eventSource = new EventSource(`/outputs?webrtc_id=${webrtcId}`); | |
| eventSource.addEventListener('output', (event) => { | |
| const data = JSON.parse(event.data); | |
| const msg = data.message; | |
| const transcript = document.getElementById('transcript'); | |
| const msgDiv = document.createElement('div'); | |
| msgDiv.className = 'transcript-msg'; | |
| if (msg.role === 'user') { | |
| msgDiv.innerHTML = ` | |
| <div class="flex justify-end"> | |
| <div class="bg-purple-100 text-purple-900 px-4 py-2 rounded-lg max-w-[80%]"> | |
| <p class="text-xs font-semibold mb-1">You:</p> | |
| <p>${msg.content}</p> | |
| </div> | |
| </div> | |
| `; | |
| } else if (msg.role === 'assistant') { | |
| msgDiv.innerHTML = ` | |
| <div class="flex justify-start"> | |
| <div class="bg-gray-100 text-gray-900 px-4 py-2 rounded-lg max-w-[80%]"> | |
| <p class="text-xs font-semibold mb-1">Interviewer:</p> | |
| <p>${msg.content}</p> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| // Remove placeholder if exists | |
| const placeholder = transcript.querySelector('.text-gray-400'); | |
| if (placeholder) placeholder.remove(); | |
| transcript.appendChild(msgDiv); | |
| transcript.scrollTop = transcript.scrollHeight; | |
| }); | |
| } | |
| function endInterview() { | |
| if (pc) { | |
| pc.close(); | |
| pc = null; | |
| } | |
| if (eventSource) { | |
| eventSource.close(); | |
| eventSource = null; | |
| } | |
| document.getElementById('start-btn').disabled = false; | |
| document.getElementById('end-btn').disabled = true; | |
| document.getElementById('status').innerHTML = '<p class="text-gray-600">Interview ended. Click "Generate Article" when ready.</p>'; | |
| document.getElementById('speaking-indicator').classList.add('hidden'); | |
| document.getElementById('listening-indicator').classList.add('hidden'); | |
| } | |
| async function generateArticle() { | |
| if (!webrtcId) { | |
| alert('No interview session found!'); | |
| return; | |
| } | |
| const modal = document.getElementById('article-modal'); | |
| modal.classList.remove('hidden'); | |
| modal.classList.add('flex'); | |
| document.getElementById('article-content').innerHTML = '<p class="text-gray-500 italic">β³ Generating article... This may take 10-30 seconds...</p>'; | |
| try { | |
| const response = await fetch(`/generate_article?webrtc_id=${webrtcId}`, { | |
| method: 'POST' | |
| }); | |
| const data = await response.json(); | |
| if (data.status === 'success') { | |
| currentArticle = data.article; | |
| document.getElementById('article-content').innerHTML = | |
| `<div class="prose max-w-none">${marked.parse(currentArticle)}</div>`; | |
| // Highlight article actions for first-time users | |
| const hasSeenArticleTutorial = localStorage.getItem('articleActionsSeen'); | |
| if (!hasSeenArticleTutorial) { | |
| setTimeout(() => { | |
| highlightArticleActions(); | |
| localStorage.setItem('articleActionsSeen', 'true'); | |
| }, 500); | |
| } | |
| } else { | |
| document.getElementById('article-content').innerHTML = | |
| `<p class="text-red-600">β Error: ${data.error}</p>`; | |
| } | |
| } catch (error) { | |
| document.getElementById('article-content').innerHTML = | |
| `<p class="text-red-600">β Failed to generate: ${error.message}</p>`; | |
| } | |
| } | |
| function highlightArticleActions() { | |
| const driver = window.driver.js.driver; | |
| const articleDriver = driver({ | |
| showProgress: false, | |
| steps: [ | |
| { | |
| element: '#article-content', | |
| popover: { | |
| title: 'β¨ Your Article is Ready!', | |
| description: 'Review your AI-generated article. You can edit it if needed before downloading or submitting.', | |
| side: "top", | |
| align: 'center' | |
| } | |
| }, | |
| { | |
| element: '#article-actions', | |
| popover: { | |
| title: 'π€ What\'s Next?', | |
| description: 'Download as markdown, submit to GitHub, or close and generate again if needed.', | |
| side: "top", | |
| align: 'center' | |
| } | |
| } | |
| ] | |
| }); | |
| articleDriver.drive(); | |
| } | |
| async function submitArticle() { | |
| if (!currentArticle) return; | |
| const btn = document.getElementById('submit-btn'); | |
| btn.disabled = true; | |
| btn.textContent = 'β³ Submitting...'; | |
| try { | |
| const response = await fetch('/submit_article', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ article: currentArticle }) | |
| }); | |
| const data = await response.json(); | |
| if (data.status === 'success') { | |
| alert(`β Submitted!\n\nπ ${data.filename}\n\nπ ${data.url}`); | |
| closeArticle(); | |
| } else { | |
| alert(`β Failed: ${data.error}`); | |
| } | |
| } catch (error) { | |
| alert(`β Failed: ${error.message}`); | |
| } finally { | |
| btn.disabled = false; | |
| btn.textContent = 'π Submit to GitHub'; | |
| } | |
| } | |
| function downloadArticle() { | |
| if (!currentArticle) return; | |
| const blob = new Blob([currentArticle], { type: 'text/markdown' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'voice-interview-' + new Date().toISOString().split('T')[0] + '.md'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| function closeArticle() { | |
| document.getElementById('article-modal').classList.add('hidden'); | |
| document.getElementById('article-modal').classList.remove('flex'); | |
| } | |
| // Initialize on page load | |
| window.addEventListener('DOMContentLoaded', () => { | |
| checkFirstVisit(); | |
| }); | |
| window.startInterview = startInterview; | |
| window.endInterview = endInterview; | |
| window.generateArticle = generateArticle; | |
| window.submitArticle = submitArticle; | |
| window.downloadArticle = downloadArticle; | |
| window.closeArticle = closeArticle; | |
| window.startTutorial = startTutorial; | |
| </script> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| </body> | |
| </html> | |