Spaces:
Running
Running
| <html lang="en"> | |
| <head><script>window.huggingface={variables:{"SPACE_CREATOR_USER_ID":"64a43304143b1c7b58f49300"}};</script> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Project Story Interview</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/driver.js@latest/dist/driver.js.iife.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/docx@8.5.0/build/index.min.js"></script> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/driver.js@latest/dist/driver.css"/> | |
| <style> | |
| body { | |
| font-size: 16px; | |
| line-height: 1.6; | |
| } | |
| .message-text { | |
| font-size: 1rem; | |
| line-height: 1.7; | |
| } | |
| .message-text p { | |
| margin-bottom: 0.75rem; | |
| } | |
| .message-text strong { | |
| font-weight: 600; | |
| color: inherit; | |
| } | |
| .message-text ul, .message-text ol { | |
| margin: 0.5rem 0 0.75rem 1.5rem; | |
| } | |
| .message-text li { | |
| margin-bottom: 0.25rem; | |
| } | |
| .compact-header { | |
| padding: 0.75rem 1rem; | |
| } | |
| .recording { | |
| animation: pulse 1.5s infinite; | |
| background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) ; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.7; } | |
| } | |
| .gradient-bg { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| } | |
| .message-user { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| } | |
| .fade-in { | |
| animation: fadeIn 0.3s ease-in; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .has-pending { | |
| border: 2px solid #f59e0b ; | |
| background-color: #fffbeb ; | |
| } | |
| .has-pending::placeholder { | |
| color: #d97706; | |
| } | |
| html, body { | |
| height: 100%; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| ::-webkit-scrollbar { | |
| width: 8px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #f1f1f1; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #888; | |
| border-radius: 4px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: #555; | |
| } | |
| /* Driver.js custom styling */ | |
| .driver-popover.driverjs-theme { | |
| background-color: #ffffff; | |
| border-radius: 12px; | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); | |
| } | |
| .driver-popover-title { | |
| font-size: 18px; | |
| font-weight: 700; | |
| color: #667eea; | |
| } | |
| .driver-popover-description { | |
| font-size: 14px; | |
| line-height: 1.6; | |
| color: #4b5563; | |
| } | |
| .driver-popover-footer button { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| border: none; | |
| padding: 8px 16px; | |
| border-radius: 6px; | |
| font-weight: 600; | |
| } | |
| /* Context indicator badge */ | |
| .context-badge { | |
| font-size: 0.65rem; | |
| padding: 0.15rem 0.4rem; | |
| border-radius: 0.25rem; | |
| background: rgba(255, 255, 255, 0.2); | |
| margin-left: 0.5rem; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 overflow-hidden"> | |
| <div class="h-screen flex flex-col"> | |
| <div class="compact-header gradient-bg text-white flex justify-between items-center flex-shrink-0 shadow-md"> | |
| <div class="flex items-center gap-3"> | |
| <h1 class="text-lg font-bold">Project Interview</h1> | |
| <span class="text-xs opacity-90" id="status-text">Ready</span> | |
| <span class="context-badge" id="context-badge" style="display: none;"> | |
| π Auto-summarizing | |
| </span> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <button onclick="startTutorial()" class="text-xs bg-white bg-opacity-20 px-2 py-1 rounded hover:bg-opacity-30 transition" id="tutorial-btn"> | |
| π‘ Tutorial | |
| </button> | |
| <button onclick="confirmRestart()" class="text-xs bg-white bg-opacity-20 px-2 py-1 rounded hover:bg-opacity-30 transition" id="restart-btn"> | |
| π Restart | |
| </button> | |
| </div> | |
| </div> | |
| <div id="chat-container" class="flex-1 overflow-y-auto p-3 space-y-2.5 bg-gray-50"> | |
| </div> | |
| <div id="status-bar" class="px-3 py-1.5 text-xs bg-yellow-50 border-t border-yellow-100 hidden flex-shrink-0"> | |
| <span id="status-message" class="text-yellow-800"></span> | |
| </div> | |
| <div class="p-2.5 bg-white border-t border-gray-200 flex-shrink-0 shadow-lg"> | |
| <div class="flex gap-2 items-end"> | |
| <button | |
| id="mic-btn" | |
| onclick="toggleVoice()" | |
| class="p-2 rounded-full bg-gradient-to-br from-purple-400 to-indigo-500 text-white hover:from-purple-500 hover:to-indigo-600 transition-all shadow text-lg flex-shrink-0" | |
| title="Voice input"> | |
| π€ | |
| </button> | |
| <textarea | |
| id="user-input" | |
| rows="2" | |
| class="flex-1 p-2 text-base border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 resize-none" | |
| placeholder="Type your answer... (Ctrl+Enter to send)" | |
| onkeydown="handleKeyPress(event)"></textarea> | |
| <button | |
| onclick="sendMessage()" | |
| id="send-btn" | |
| class="gradient-bg text-white px-4 py-2 rounded-lg hover:opacity-90 font-medium shadow transition"> | |
| Send | |
| </button> | |
| </div> | |
| <div class="mt-2 flex justify-between items-center text-xs" id="generate-section"> | |
| <span class="text-gray-500">Ready to finish?</span> | |
| <button | |
| onclick="generateArticle()" | |
| id="generate-btn" | |
| class="gradient-bg text-white px-3 py-1 rounded-lg font-medium hover:opacity-90 transition"> | |
| Generate Article β | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Article Preview Modal --> | |
| <div id="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 w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl"> | |
| <div class="p-3 border-b flex justify-between items-center gradient-bg text-white flex-shrink-0"> | |
| <h2 class="font-bold text-lg">π Article Preview</h2> | |
| <button onclick="closeModal()" class="text-white hover:text-gray-200 text-2xl leading-none">×</button> | |
| </div> | |
| <div class="p-4 overflow-y-auto flex-1 bg-gray-50"> | |
| <div id="article-content" class="message-text"> | |
| <p class="text-gray-500 italic">Loading...</p> | |
| </div> | |
| </div> | |
| <div class="p-3 border-t flex justify-between items-center bg-white flex-shrink-0"> | |
| <button onclick="closeModal()" class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg font-medium transition text-sm"> | |
| Close | |
| </button> | |
| <div class="flex gap-2"> | |
| <button onclick="downloadArticle()" id="download-btn" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 font-medium transition text-sm"> | |
| πΎ Download Article | |
| </button> | |
| <button onclick="submitToGithub()" id="submit-btn" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium transition shadow text-sm"> | |
| π Publish Article | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Restart Confirmation Modal --> | |
| <div id="restart-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-md w-full shadow-2xl"> | |
| <div class="p-5"> | |
| <h3 class="text-lg font-bold text-gray-800 mb-2">β οΈ Confirm Restart</h3> | |
| <p class="text-sm text-gray-600 mb-3">This will delete all interview history. Type <strong>DELETE</strong> to confirm:</p> | |
| <input | |
| type="text" | |
| id="restart-confirm-input" | |
| class="w-full p-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 mb-3" | |
| placeholder="DELETE" | |
| onkeyup="checkRestartInput(event)"> | |
| <div class="flex gap-2 justify-end"> | |
| <button onclick="cancelRestart()" class="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg font-medium"> | |
| Cancel | |
| </button> | |
| <button onclick="executeRestart()" id="restart-confirm-btn" disabled class="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg font-medium opacity-50 cursor-not-allowed"> | |
| Delete & Restart | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1.6.0/dist/index.min.js"; | |
| const GRADIO_APP = "npc0/survey"; | |
| let chatHistory = []; | |
| let currentArticle = ""; | |
| let initialGreeting = "Hello! I'm here to help you share your project story with the community.\n\n**To get started, please share:**\n1. Your project name (or working title)\n2. A brief confirmation that you're okay with AI helping to organize and write up this interview\n\nOnce I have that, we'll dive into your project's journey!"; | |
| let gradioClient = null; | |
| let isConnected = false; | |
| let driverObj = null; | |
| // Track if we're in long interview mode | |
| let isLongInterview = false; | |
| async function initializeApp() { | |
| console.log("π Initializing..."); | |
| document.getElementById('status-text').textContent = 'Connecting...'; | |
| try { | |
| gradioClient = await Client.connect(GRADIO_APP); | |
| isConnected = true; | |
| console.log("β Connected"); | |
| // Try to get greeting from backend | |
| try { | |
| const greetingResult = await gradioClient.predict("/get_initial_greeting", {}); | |
| initialGreeting = greetingResult.data[0]; | |
| console.log("β Greeting loaded"); | |
| } catch (e) { | |
| console.log("βΉοΈ Using default greeting"); | |
| } | |
| document.getElementById('status-text').textContent = 'Ready'; | |
| } catch (error) { | |
| console.error("β Init failed:", error); | |
| isConnected = false; | |
| document.getElementById('status-text').textContent = 'Offline'; | |
| } | |
| loadHistory(); | |
| restorePendingMessage(); | |
| updateContextIndicator(); | |
| // Check if user has seen tutorial | |
| checkFirstTimeUser(); | |
| } | |
| function updateContextIndicator() { | |
| // Estimate tokens (rough: 4 chars = 1 token) | |
| const totalChars = chatHistory.reduce((sum, msg) => sum + msg.content.length, 0); | |
| const estimatedTokens = Math.floor(totalChars / 4); | |
| const badge = document.getElementById('context-badge'); | |
| const threshold = 25000; // Match backend MAX_CONTEXT_TOKENS | |
| if (estimatedTokens > threshold * 0.7) { // Show badge at 70% of limit | |
| isLongInterview = true; | |
| badge.style.display = 'inline-block'; | |
| badge.textContent = `π ~${Math.floor(estimatedTokens/1000)}K tokens`; | |
| badge.title = 'Backend will auto-summarize older messages to stay within limits'; | |
| } else { | |
| isLongInterview = false; | |
| badge.style.display = 'none'; | |
| } | |
| } | |
| function checkFirstTimeUser() { | |
| const hasSeenTutorial = localStorage.getItem('has_seen_tutorial'); | |
| if (!hasSeenTutorial) { | |
| // Show tutorial after a brief delay | |
| setTimeout(() => { | |
| startTutorial(); | |
| localStorage.setItem('has_seen_tutorial', 'true'); | |
| }, 1000); | |
| } | |
| } | |
| function startTutorial() { | |
| if (!driverObj) { | |
| const driver = window.driver.js.driver; | |
| driverObj = driver({ | |
| showProgress: true, | |
| showButtons: ['next', 'previous', 'close'], | |
| steps: [ | |
| { | |
| element: '#chat-container', | |
| popover: { | |
| title: 'π Welcome to Project Interview!', | |
| description: 'This tool helps you document your project through an AI-guided interview. Let\'s take a quick tour of the features!', | |
| side: "bottom", | |
| align: 'center' | |
| } | |
| }, | |
| { | |
| element: '#user-input', | |
| popover: { | |
| title: 'π¬ Chat Input', | |
| description: 'Type your responses here. The AI will ask questions about your project, and you can answer naturally. Press <strong>Ctrl+Enter</strong> to send quickly!', | |
| side: "top", | |
| align: 'center' | |
| } | |
| }, | |
| { | |
| element: '#mic-btn', | |
| popover: { | |
| title: 'π€ Voice Input', | |
| description: 'Click here to use voice-to-text! Speak your answers instead of typing. Great for longer responses or multitasking.', | |
| side: "top", | |
| align: 'start' | |
| } | |
| }, | |
| { | |
| element: '#send-btn', | |
| popover: { | |
| title: 'π€ Send Button', | |
| description: 'Click to send your message to the AI. You can also use <strong>Ctrl+Enter</strong> as a keyboard shortcut.', | |
| side: "top", | |
| align: 'end' | |
| } | |
| }, | |
| { | |
| element: '#generate-section', | |
| popover: { | |
| title: 'π Generate Article', | |
| description: 'After providing information and you think it includes all useful knowledge that benefit people in their projects, click <strong>"Generate Article"</strong> to transform your interview into a polished article. <em>Note: Long interviews are automatically managed - the system will summarize older messages to stay within limits!</em>', | |
| side: "top", | |
| align: 'center' | |
| } | |
| }, | |
| { | |
| element: '#tutorial-btn', | |
| popover: { | |
| title: 'π‘ Tutorial Button', | |
| description: 'You can replay this tutorial anytime by clicking here. Helpful if you forget how something works!', | |
| side: "bottom", | |
| align: 'end' | |
| } | |
| }, | |
| { | |
| element: '#restart-btn', | |
| popover: { | |
| title: 'π Restart Interview', | |
| description: 'Want to start over? Click here to clear your interview history and begin fresh. Don\'t worry - you\'ll need to confirm before anything is deleted.', | |
| side: "bottom", | |
| align: 'end' | |
| } | |
| }, | |
| { | |
| popover: { | |
| title: 'π Ready to Start!', | |
| description: 'That\'s all! This may take a long time depends on the project size, but don\'t worry the chat saves within your browser for you to resume on the same machine. The AI will guide you through questions about your project. Just answer naturally and be yourself. When you\'re done, generate your article and optionally submit it to our knowledge base. <strong>Have fun!</strong>', | |
| side: "bottom", | |
| align: 'center' | |
| } | |
| } | |
| ], | |
| onDestroyStarted: () => { | |
| driverObj.destroy(); | |
| }, | |
| }); | |
| } | |
| driverObj.drive(); | |
| } | |
| function loadHistory() { | |
| const saved = localStorage.getItem('interview_history'); | |
| if (saved) { | |
| try { | |
| chatHistory = JSON.parse(saved); | |
| console.log('π Loaded:', chatHistory.length, 'messages'); | |
| } catch (e) { | |
| chatHistory = [{ role: 'assistant', content: initialGreeting }]; | |
| } | |
| } else { | |
| chatHistory = [{ role: 'assistant', content: initialGreeting }]; | |
| } | |
| renderChat(); | |
| } | |
| function saveHistory() { | |
| try { | |
| localStorage.setItem('interview_history', JSON.stringify(chatHistory)); | |
| console.log('πΎ Saved:', chatHistory.length); | |
| updateContextIndicator(); | |
| } catch (e) { | |
| console.error('Save failed:', e); | |
| } | |
| } | |
| function restorePendingMessage() { | |
| const pending = localStorage.getItem('pending_message'); | |
| if (pending) { | |
| const input = document.getElementById('user-input'); | |
| input.value = pending; | |
| input.classList.add('has-pending'); | |
| input.placeholder = 'β οΈ Unsent message - edit and try again'; | |
| const now = new Date(); | |
| const currentHour = now.getHours(); | |
| let timeMsg = currentHour < 20 ? `after 8:00 PM today or tomorrow` : 'tomorrow'; | |
| setTimeout(() => { | |
| alert(`π Restored unsent message\n\n` + | |
| `Your previous message was not sent successfully.\n` + | |
| `It has been restored to the input box.\n\n` + | |
| `π‘ If AI was unavailable, try again ${timeMsg}.`); | |
| }, 500); | |
| input.focus(); | |
| input.setSelectionRange(pending.length, pending.length); | |
| } | |
| } | |
| function confirmRestart() { | |
| document.getElementById('restart-modal').classList.remove('hidden'); | |
| document.getElementById('restart-modal').classList.add('flex'); | |
| document.getElementById('restart-confirm-input').value = ''; | |
| const btn = document.getElementById('restart-confirm-btn'); | |
| btn.disabled = true; | |
| btn.classList.add('opacity-50', 'cursor-not-allowed'); | |
| } | |
| function cancelRestart() { | |
| document.getElementById('restart-modal').classList.add('hidden'); | |
| document.getElementById('restart-modal').classList.remove('flex'); | |
| } | |
| function checkRestartInput(event) { | |
| const input = document.getElementById('restart-confirm-input'); | |
| const btn = document.getElementById('restart-confirm-btn'); | |
| if (input.value.trim().toUpperCase() === 'DELETE') { | |
| btn.disabled = false; | |
| btn.classList.remove('opacity-50', 'cursor-not-allowed'); | |
| btn.classList.add('hover:bg-red-700'); | |
| if (event.key === 'Enter') executeRestart(); | |
| } else { | |
| btn.disabled = true; | |
| btn.classList.add('opacity-50', 'cursor-not-allowed'); | |
| btn.classList.remove('hover:bg-red-700'); | |
| } | |
| } | |
| function executeRestart() { | |
| localStorage.removeItem('interview_history'); | |
| localStorage.removeItem('pending_message'); | |
| chatHistory = [{ role: 'assistant', content: initialGreeting }]; | |
| const input = document.getElementById('user-input'); | |
| input.value = ''; | |
| input.classList.remove('has-pending'); | |
| input.placeholder = 'Type your answer... (Ctrl+Enter to send)'; | |
| renderChat(); | |
| cancelRestart(); | |
| updateContextIndicator(); | |
| console.log('π Restarted'); | |
| } | |
| function renderChat() { | |
| const container = document.getElementById('chat-container'); | |
| console.log("π¨ Rendering chat, container:", container); | |
| console.log("π Chat history length:", chatHistory.length); | |
| container.innerHTML = ''; | |
| chatHistory.forEach((msg, index) => { | |
| const div = document.createElement('div'); | |
| div.className = `flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} fade-in`; | |
| const bubble = document.createElement('div'); | |
| bubble.className = `max-w-[90%] rounded-lg p-3 shadow-sm message-text ${ | |
| msg.role === 'user' | |
| ? 'message-user text-white' | |
| : 'bg-white border border-gray-200 text-gray-800' | |
| }`; | |
| if (msg.role === 'assistant') { | |
| bubble.innerHTML = marked.parse(msg.content); | |
| } else { | |
| bubble.textContent = msg.content; | |
| } | |
| div.appendChild(bubble); | |
| container.appendChild(div); | |
| }); | |
| console.log("π¦ Container children count:", container.children.length); | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| function setLoading(isLoading, message = "Thinking...") { | |
| const statusBar = document.getElementById('status-bar'); | |
| const statusMessage = document.getElementById('status-message'); | |
| const sendBtn = document.getElementById('send-btn'); | |
| const input = document.getElementById('user-input'); | |
| if (isLoading) { | |
| statusBar.classList.remove('hidden'); | |
| statusMessage.textContent = message; | |
| sendBtn.disabled = true; | |
| sendBtn.classList.add('opacity-50'); | |
| input.disabled = true; | |
| } else { | |
| statusBar.classList.add('hidden'); | |
| sendBtn.disabled = false; | |
| sendBtn.classList.remove('opacity-50'); | |
| input.disabled = false; | |
| } | |
| } | |
| // Helper function to add timeout to promises | |
| function withTimeout(promise, timeoutMs, timeoutError) { | |
| return Promise.race([ | |
| promise, | |
| new Promise((_, reject) => | |
| setTimeout(() => reject(new Error(timeoutError)), timeoutMs) | |
| ) | |
| ]); | |
| } | |
| // Helper function to retry with exponential backoff | |
| async function retryWithBackoff(fn, maxRetries = 3, initialDelay = 1000) { | |
| for (let i = 0; i < maxRetries; i++) { | |
| try { | |
| return await fn(); | |
| } catch (error) { | |
| const isLastAttempt = i === maxRetries - 1; | |
| // Don't retry on certain errors | |
| const errorMsg = error.message?.toLowerCase() || ''; | |
| if (errorMsg.includes('quota') || | |
| errorMsg.includes('authentication') || | |
| errorMsg.includes('unauthorized')) { | |
| throw error; // Don't retry quota/auth errors | |
| } | |
| if (isLastAttempt) { | |
| throw error; | |
| } | |
| // Exponential backoff: 1s, 2s, 4s | |
| const delay = initialDelay * Math.pow(2, i); | |
| console.log(`β οΈ Attempt ${i + 1} failed, retrying in ${delay}ms...`); | |
| setLoading(true, `Network issue, retrying (${i + 2}/${maxRetries})...`); | |
| await new Promise(resolve => setTimeout(resolve, delay)); | |
| } | |
| } | |
| } | |
| async function sendMessage() { | |
| if (!isConnected) { | |
| alert("β Not connected to backend.\n\nπ‘ Try:\n- Refresh the page\n- Check if backend is running"); | |
| return; | |
| } | |
| const input = document.getElementById('user-input'); | |
| const message = input.value.trim(); | |
| if (!message) return; | |
| localStorage.setItem('pending_message', message); | |
| input.value = ''; | |
| chatHistory.push({ role: 'user', content: message }); | |
| renderChat(); | |
| saveHistory(); | |
| // Show appropriate loading message based on interview length | |
| const loadingMsg = isLongInterview | |
| ? "Sending to AI (may summarize older messages)..." | |
| : "Sending to AI..."; | |
| setLoading(true, loadingMsg); | |
| try { | |
| // Wrap API call with timeout and retry logic | |
| const result = await retryWithBackoff(async () => { | |
| return await withTimeout( | |
| gradioClient.predict("/chat", { | |
| history: chatHistory.slice(0, -1), | |
| user_message: message | |
| }), | |
| 60000, // 60 second timeout | |
| 'Request timed out after 60 seconds' | |
| ); | |
| }, 3, 1000); // Max 3 retries with exponential backoff | |
| console.log("β Got result:", result); | |
| const data = result.data[0]; | |
| console.log("β Extracted data:", data); | |
| if (data.error) { | |
| chatHistory.pop(); | |
| saveHistory(); | |
| input.value = message; | |
| input.classList.add('has-pending'); | |
| input.placeholder = 'β οΈ Message failed - edit and retry'; | |
| localStorage.removeItem('pending_message'); | |
| const errorLower = data.error.toLowerCase(); | |
| let userMessage = 'β Message not sent - AI unavailable\n\n'; | |
| if (errorLower.includes('quota') || errorLower.includes('rate') || errorLower.includes('limit')) { | |
| const now = new Date(); | |
| const currentHour = now.getHours(); | |
| if (currentHour < 20) { | |
| userMessage += 'π‘ The AI quota may have been exceeded.\n\n' + | |
| 'π Please try again:\n' + | |
| `- After 8:00 PM today (${now.toLocaleDateString()})\n` + | |
| '- OR tomorrow morning\n\n'; | |
| } else { | |
| userMessage += 'π‘ The AI quota may have been exceeded.\n\n' + | |
| 'π Please try again tomorrow evening.\n\n'; | |
| } | |
| userMessage += 'β Your message is back in the input box.\n' + | |
| 'You can edit it and try sending again later.'; | |
| } else { | |
| userMessage += `Error: ${data.error}\n\n` + | |
| 'π‘ Please try again in a few moments.\n\n' + | |
| 'β Your message is back in the input box for editing.'; | |
| } | |
| alert(userMessage); | |
| renderChat(); | |
| } else { | |
| // Backend returns updated history with both user message and response | |
| chatHistory = data.history; | |
| saveHistory(); | |
| localStorage.removeItem('pending_message'); | |
| input.classList.remove('has-pending'); | |
| input.placeholder = 'Type your answer... (Ctrl+Enter to send)'; | |
| console.log("β Updated history, about to render"); | |
| renderChat(); | |
| console.log("β Rendered"); | |
| } | |
| } catch (error) { | |
| console.error("β Chat error:", error); | |
| chatHistory.pop(); | |
| saveHistory(); | |
| input.value = message; | |
| input.classList.add('has-pending'); | |
| input.placeholder = 'β οΈ Connection failed - retry when online'; | |
| const errorMsg = error.message || 'Unknown error'; | |
| let userMessage = 'β Failed to send message\n\n'; | |
| if (errorMsg.includes('timed out')) { | |
| userMessage += 'β±οΈ Request timed out after 60 seconds\n\n' + | |
| 'π‘ This can happen when:\n' + | |
| '- Backend is processing a long summary\n' + | |
| '- HuggingFace Spaces is under heavy load\n' + | |
| '- Network is unstable\n\n' + | |
| 'β Try:\n' + | |
| '- Wait a moment and send again\n' + | |
| '- Your message is saved in the input box\n'; | |
| } else if (errorMsg.includes('fetch') || errorMsg.includes('network') || errorMsg.includes('connection')) { | |
| userMessage += 'π Network/connection error\n\n' + | |
| 'π‘ Tried 3 times with automatic retry.\n\n' + | |
| 'Try:\n' + | |
| '- Check your internet connection\n' + | |
| '- Refresh the page\n' + | |
| '- Wait a moment and try again\n\n'; | |
| } else if (errorMsg.includes('quota') || errorMsg.includes('rate')) { | |
| const now = new Date(); | |
| const currentHour = now.getHours(); | |
| userMessage += 'π‘ AI quota may be exceeded.\n\n' + | |
| (currentHour < 20 | |
| ? 'π Try after 8:00 PM today or tomorrow\n\n' | |
| : 'π Try tomorrow evening\n\n'); | |
| } else { | |
| userMessage += `Error: ${errorMsg}\n\n`; | |
| } | |
| userMessage += 'β Your message is saved in the input box.\n' + | |
| 'You can edit and try again.'; | |
| alert(userMessage); | |
| renderChat(); | |
| } finally { | |
| setLoading(false); | |
| input.focus(); | |
| } | |
| } | |
| function handleKeyPress(event) { | |
| if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { | |
| event.preventDefault(); | |
| sendMessage(); | |
| } | |
| } | |
| async function generateArticle() { | |
| if (!isConnected) { | |
| alert("Not connected."); | |
| return; | |
| } | |
| if (chatHistory.length < 4) { | |
| alert("Please chat more first (at least 2-3 exchanges)."); | |
| return; | |
| } | |
| const modal = document.getElementById('modal'); | |
| modal.classList.remove('hidden'); | |
| modal.classList.add('flex'); | |
| document.getElementById('article-content').innerHTML = | |
| '<p class="text-gray-500 italic">β³ Generating article (10-60 sec)...</p>'; | |
| try { | |
| // Add timeout and retry for article generation (can take longer) | |
| const result = await retryWithBackoff(async () => { | |
| return await withTimeout( | |
| gradioClient.predict("/generate_article", { | |
| history: chatHistory | |
| }), | |
| 90000, // 90 second timeout (article generation can be slow) | |
| 'Article generation timed out after 90 seconds' | |
| ); | |
| }, 2, 2000); // 2 retries with 2s initial delay | |
| const data = result.data[0]; | |
| if (data.error) { | |
| document.getElementById('article-content').innerHTML = | |
| `<p class="text-red-600">β ${data.error}</p>` + | |
| `<p class="text-sm text-gray-600 mt-2">Try closing and reopening the article preview, or refresh the page.</p>`; | |
| } else { | |
| currentArticle = data.article; | |
| document.getElementById('article-content').innerHTML = marked.parse(currentArticle); | |
| } | |
| } catch (error) { | |
| console.error("β Generate error:", error); | |
| const errorMsg = error.message || 'Unknown error'; | |
| let displayMsg = 'β Failed to generate article\n\n'; | |
| if (errorMsg.includes('timed out')) { | |
| displayMsg = 'β±οΈ Article generation timed out\n\n' + | |
| 'The article generation took too long (>90 sec).\n' + | |
| 'This can happen with very long interviews.\n\n' + | |
| 'π‘ Try:\n' + | |
| '- Close this modal and try again\n' + | |
| '- Wait a moment for the server to recover\n' + | |
| '- If it persists, the interview may be too long'; | |
| } else { | |
| displayMsg += errorMsg; | |
| } | |
| document.getElementById('article-content').innerHTML = | |
| `<p class="text-red-600 whitespace-pre-wrap">${displayMsg}</p>` + | |
| `<button onclick="generateArticle()" class="mt-4 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">π Retry</button>`; | |
| } | |
| } | |
| async function submitToGithub() { | |
| if (!isConnected || !currentArticle) { | |
| alert("Not ready to submit."); | |
| return; | |
| } | |
| const btn = document.getElementById('submit-btn'); | |
| const orig = btn.innerHTML; | |
| btn.innerHTML = 'β³ Adding to knowledge base...'; | |
| btn.disabled = true; | |
| try { | |
| // Add timeout and retry for GitHub submission | |
| const result = await retryWithBackoff(async () => { | |
| return await withTimeout( | |
| gradioClient.predict("/submit_article", { | |
| article_content: currentArticle | |
| }), | |
| 30000, // 30 second timeout | |
| 'GitHub submission timed out after 30 seconds' | |
| ); | |
| }, 3, 1500); // 3 retries with 1.5s initial delay | |
| const data = result.data[0]; | |
| if (data.status === 'success') { | |
| alert(`β Submitted!\n\nπ ${data.filename}\n\nπ ${data.url}`); | |
| if (confirm("Clear history and start fresh?")) { | |
| executeRestart(); | |
| } | |
| closeModal(); | |
| } else { | |
| alert("β Failed: " + data.error); | |
| } | |
| } catch (error) { | |
| console.error("β Submit error:", error); | |
| const errorMsg = error.message || 'Unknown error'; | |
| let userMessage = 'β Failed to submit to GitHub\n\n'; | |
| if (errorMsg.includes('timed out')) { | |
| userMessage += 'β±οΈ Submission timed out\n\n' + | |
| 'Try again in a moment.'; | |
| } else if (errorMsg.includes('network') || errorMsg.includes('connection')) { | |
| userMessage += 'π Network error\n\n' + | |
| 'Check your connection and try again.'; | |
| } else { | |
| userMessage += errorMsg; | |
| } | |
| alert(userMessage); | |
| } finally { | |
| btn.innerHTML = orig; | |
| btn.disabled = false; | |
| } | |
| } | |
| /** | |
| * PROPOSED downloadArticle() REPLACEMENT | |
| * ======================================== | |
| * Converts the generated Markdown article into a formatted .docx file | |
| * and triggers a browser download β entirely client-side, no server needed. | |
| * | |
| * DEPENDENCIES TO ADD (paste into <head> of your HTML): | |
| * | |
| * <script src="https://cdn.jsdelivr.net/npm/docx@8.5.0/build/index.min.js"></script> | |
| * | |
| * HOW IT WORKS: | |
| * 1. Parses the Markdown string line-by-line into a simple AST (headings, | |
| * paragraphs, bold/italic inline, bullet lists, numbered lists, hr). | |
| * 2. Maps each AST node to docx.js Paragraph / TextRun objects using | |
| * proper HeadingLevel values, LevelFormat.BULLET, etc. | |
| * 3. Packs the Document to a Blob via Packer.toBlob() and triggers download. | |
| * | |
| * USAGE (drop-in replacement β same call signature as the original): | |
| * downloadArticle() // reads `currentArticle` from outer scope, same as before | |
| */ | |
| // βββ Inline-text parser βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Converts a Markdown inline string (bold, italic, code, plain) into an array | |
| // of docx TextRun objects. | |
| function parseInlineMarkdown(text) { | |
| const { TextRun } = docx; | |
| const runs = []; | |
| // Regex: **bold**, *italic*, `code`, or plain text chunks | |
| const pattern = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|[^*`]+)/g; | |
| let match; | |
| while ((match = pattern.exec(text)) !== null) { | |
| const full = match[0]; | |
| if (full.startsWith("**")) { | |
| runs.push(new TextRun({ text: match[2], bold: true })); | |
| } else if (full.startsWith("*")) { | |
| runs.push(new TextRun({ text: match[3], italics: true })); | |
| } else if (full.startsWith("`")) { | |
| runs.push( | |
| new TextRun({ | |
| text: match[4], | |
| font: "Courier New", | |
| size: 20, // 10pt β slightly smaller for inline code | |
| }) | |
| ); | |
| } else { | |
| if (full) runs.push(new TextRun({ text: full })); | |
| } | |
| } | |
| return runs.length ? runs : [new TextRun({ text: text })]; | |
| } | |
| // βββ Markdown β docx Paragraphs ββββββββββββββββββββββββββββββββββββββββββββββ | |
| function markdownToDocxChildren(markdown) { | |
| const { | |
| Paragraph, | |
| TextRun, | |
| HeadingLevel, | |
| AlignmentType, | |
| LevelFormat, | |
| } = docx; | |
| const lines = markdown.split("\n"); | |
| const children = []; | |
| // We need a numbered-list counter that resets on non-list lines | |
| let orderedIndex = 0; | |
| for (let i = 0; i < lines.length; i++) { | |
| const raw = lines[i]; | |
| const line = raw.trimEnd(); | |
| // ββ Blank line β spacer paragraph | |
| if (!line.trim()) { | |
| orderedIndex = 0; // reset ordered counter on gap | |
| children.push( | |
| new Paragraph({ | |
| children: [], | |
| spacing: { after: 80 }, | |
| }) | |
| ); | |
| continue; | |
| } | |
| // ββ ATX Headings # ## ### #### | |
| const headingMatch = line.match(/^(#{1,4})\s+(.+)/); | |
| if (headingMatch) { | |
| orderedIndex = 0; | |
| const level = headingMatch[1].length; | |
| const headingLevelMap = { | |
| 1: HeadingLevel.HEADING_1, | |
| 2: HeadingLevel.HEADING_2, | |
| 3: HeadingLevel.HEADING_3, | |
| 4: HeadingLevel.HEADING_4, | |
| }; | |
| children.push( | |
| new Paragraph({ | |
| heading: headingLevelMap[level] || HeadingLevel.HEADING_3, | |
| children: [new TextRun({ text: headingMatch[2].trim() })], | |
| spacing: { before: level === 1 ? 320 : 200, after: 120 }, | |
| }) | |
| ); | |
| continue; | |
| } | |
| // ββ Setext H1 (underline ===) | |
| if (i + 1 < lines.length && /^=+$/.test(lines[i + 1].trim()) && line.trim()) { | |
| orderedIndex = 0; | |
| children.push( | |
| new Paragraph({ | |
| heading: HeadingLevel.HEADING_1, | |
| children: [new TextRun({ text: line.trim() })], | |
| spacing: { before: 320, after: 120 }, | |
| }) | |
| ); | |
| i++; // skip underline row | |
| continue; | |
| } | |
| // ββ Setext H2 (underline ---) | |
| if (i + 1 < lines.length && /^-+$/.test(lines[i + 1].trim()) && line.trim()) { | |
| orderedIndex = 0; | |
| children.push( | |
| new Paragraph({ | |
| heading: HeadingLevel.HEADING_2, | |
| children: [new TextRun({ text: line.trim() })], | |
| spacing: { before: 200, after: 120 }, | |
| }) | |
| ); | |
| i++; // skip underline row | |
| continue; | |
| } | |
| // ββ Horizontal rule (---, ***, ___) | |
| if (/^(---+|\*\*\*+|___+)\s*$/.test(line)) { | |
| orderedIndex = 0; | |
| children.push( | |
| new Paragraph({ | |
| children: [], | |
| border: { | |
| bottom: { style: "single", size: 6, color: "AAAAAA", space: 1 }, | |
| }, | |
| spacing: { before: 160, after: 160 }, | |
| }) | |
| ); | |
| continue; | |
| } | |
| // ββ Unordered list - item / * item / + item | |
| const ulMatch = line.match(/^(\s*)[-*+]\s+(.+)/); | |
| if (ulMatch) { | |
| orderedIndex = 0; | |
| const depth = Math.floor(ulMatch[1].length / 2); // supports up to 2-level nesting | |
| children.push( | |
| new Paragraph({ | |
| numbering: { reference: "bullets", level: Math.min(depth, 1) }, | |
| children: parseInlineMarkdown(ulMatch[2]), | |
| spacing: { after: 60 }, | |
| }) | |
| ); | |
| continue; | |
| } | |
| // ββ Ordered list 1. item | |
| const olMatch = line.match(/^(\s*)\d+\.\s+(.+)/); | |
| if (olMatch) { | |
| orderedIndex++; | |
| children.push( | |
| new Paragraph({ | |
| numbering: { reference: "numbers", level: 0 }, | |
| children: parseInlineMarkdown(olMatch[2]), | |
| spacing: { after: 60 }, | |
| }) | |
| ); | |
| continue; | |
| } | |
| // ββ Blockquote > text | |
| const bqMatch = line.match(/^>\s*(.*)/); | |
| if (bqMatch) { | |
| orderedIndex = 0; | |
| children.push( | |
| new Paragraph({ | |
| children: parseInlineMarkdown(bqMatch[1]), | |
| indent: { left: 720 }, | |
| border: { | |
| left: { style: "single", size: 12, color: "AAAAAA", space: 8 }, | |
| }, | |
| spacing: { before: 80, after: 80 }, | |
| }) | |
| ); | |
| continue; | |
| } | |
| // ββ Default: regular paragraph | |
| orderedIndex = 0; | |
| children.push( | |
| new Paragraph({ | |
| children: parseInlineMarkdown(line), | |
| spacing: { after: 120 }, | |
| }) | |
| ); | |
| } | |
| return children; | |
| } | |
| // βββ Main downloadArticle() βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function downloadArticle() { | |
| if (!currentArticle) { | |
| alert("No article to download."); | |
| return; | |
| } | |
| const { | |
| Document, | |
| Packer, | |
| Paragraph, | |
| TextRun, | |
| HeadingLevel, | |
| AlignmentType, | |
| LevelFormat, | |
| } = docx; // `docx` is exposed globally by the CDN bundle | |
| // ββ Extract a title from the first H1 in the article, fallback to date | |
| const titleMatch = currentArticle.match(/^#\s+(.+)/m); | |
| const articleTitle = titleMatch ? titleMatch[1].trim() : "Project Article"; | |
| const dateStr = new Date().toLocaleDateString("en-US", { | |
| year: "numeric", | |
| month: "long", | |
| day: "numeric", | |
| }); | |
| // ββ Build document | |
| const doc = new Document({ | |
| // Document metadata (visible in File β Properties) | |
| creator: "Project Interview Tool", | |
| description: "Auto-generated project article", | |
| title: articleTitle, | |
| // ββ Override built-in heading styles so they render cleanly | |
| styles: { | |
| default: { | |
| document: { | |
| run: { font: "Calibri", size: 24 }, // 12pt body | |
| }, | |
| }, | |
| paragraphStyles: [ | |
| { | |
| id: "Heading1", | |
| name: "Heading 1", | |
| basedOn: "Normal", | |
| next: "Normal", | |
| quickFormat: true, | |
| run: { size: 36, bold: true, font: "Calibri", color: "2E4057" }, | |
| paragraph: { | |
| spacing: { before: 320, after: 160 }, | |
| outlineLevel: 0, | |
| }, | |
| }, | |
| { | |
| id: "Heading2", | |
| name: "Heading 2", | |
| basedOn: "Normal", | |
| next: "Normal", | |
| quickFormat: true, | |
| run: { size: 28, bold: true, font: "Calibri", color: "3A6186" }, | |
| paragraph: { | |
| spacing: { before: 240, after: 120 }, | |
| outlineLevel: 1, | |
| }, | |
| }, | |
| { | |
| id: "Heading3", | |
| name: "Heading 3", | |
| basedOn: "Normal", | |
| next: "Normal", | |
| quickFormat: true, | |
| run: { size: 24, bold: true, font: "Calibri", color: "555555" }, | |
| paragraph: { | |
| spacing: { before: 200, after: 80 }, | |
| outlineLevel: 2, | |
| }, | |
| }, | |
| { | |
| id: "Heading4", | |
| name: "Heading 4", | |
| basedOn: "Normal", | |
| next: "Normal", | |
| quickFormat: true, | |
| run: { size: 24, bold: true, italics: true, font: "Calibri", color: "777777" }, | |
| paragraph: { | |
| spacing: { before: 160, after: 80 }, | |
| outlineLevel: 3, | |
| }, | |
| }, | |
| ], | |
| }, | |
| // ββ Bullet and numbered list configs (required β never use unicode bullets) | |
| numbering: { | |
| config: [ | |
| { | |
| reference: "bullets", | |
| levels: [ | |
| { | |
| level: 0, | |
| format: LevelFormat.BULLET, | |
| text: "\u2022", | |
| alignment: AlignmentType.LEFT, | |
| style: { | |
| paragraph: { indent: { left: 720, hanging: 360 } }, | |
| }, | |
| }, | |
| { | |
| level: 1, | |
| format: LevelFormat.BULLET, | |
| text: "\u25E6", | |
| alignment: AlignmentType.LEFT, | |
| style: { | |
| paragraph: { indent: { left: 1080, hanging: 360 } }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| { | |
| reference: "numbers", | |
| levels: [ | |
| { | |
| level: 0, | |
| format: LevelFormat.DECIMAL, | |
| text: "%1.", | |
| alignment: AlignmentType.LEFT, | |
| style: { | |
| paragraph: { indent: { left: 720, hanging: 360 } }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| sections: [ | |
| { | |
| properties: { | |
| page: { | |
| // US Letter, 1-inch margins | |
| size: { width: 12240, height: 15840 }, | |
| margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, | |
| }, | |
| }, | |
| children: [ | |
| // ββ Cover / title block | |
| new Paragraph({ | |
| children: [ | |
| new TextRun({ | |
| text: articleTitle, | |
| bold: true, | |
| size: 48, // 24pt | |
| font: "Calibri", | |
| color: "2E4057", | |
| }), | |
| ], | |
| alignment: AlignmentType.CENTER, | |
| spacing: { before: 480, after: 160 }, | |
| }), | |
| new Paragraph({ | |
| children: [ | |
| new TextRun({ | |
| text: `Generated on ${dateStr} Β· Project Interview Tool`, | |
| size: 18, // 9pt | |
| color: "888888", | |
| italics: true, | |
| font: "Calibri", | |
| }), | |
| ], | |
| alignment: AlignmentType.CENTER, | |
| spacing: { after: 480 }, | |
| border: { | |
| bottom: { style: "single", size: 6, color: "CCCCCC", space: 1 }, | |
| }, | |
| }), | |
| // ββ Article body converted from Markdown | |
| ...markdownToDocxChildren(currentArticle), | |
| ], | |
| }, | |
| ], | |
| }); | |
| // ββ Pack β Blob β trigger download | |
| try { | |
| const blob = await Packer.toBlob(doc); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = | |
| articleTitle.replace(/[^a-z0-9]/gi, "-").toLowerCase() + | |
| "-" + | |
| new Date().toISOString().split("T")[0] + | |
| ".docx"; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } catch (err) { | |
| console.error("β DOCX generation failed:", err); | |
| alert("Failed to generate .docx file.\n\nFalling back to Markdown download."); | |
| // Graceful fallback to the original Markdown download | |
| const fallback = new Blob([currentArticle], { type: "text/markdown" }); | |
| const url = URL.createObjectURL(fallback); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = "article-" + new Date().toISOString().split("T")[0] + ".md"; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| } | |
| function closeModal() { | |
| document.getElementById('modal').classList.add('hidden'); | |
| document.getElementById('modal').classList.remove('flex'); | |
| } | |
| let recognition; | |
| let isRecording = false; | |
| if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) { | |
| const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| recognition = new SpeechRecognition(); | |
| recognition.continuous = true; | |
| recognition.interimResults = true; | |
| recognition.lang = 'en-US'; | |
| recognition.onstart = () => { | |
| isRecording = true; | |
| document.getElementById('mic-btn').classList.add('recording'); | |
| }; | |
| recognition.onend = () => { | |
| isRecording = false; | |
| document.getElementById('mic-btn').classList.remove('recording'); | |
| }; | |
| recognition.onresult = (event) => { | |
| let finalTranscript = ''; | |
| for (let i = 0; i < event.results.length; i++) { | |
| if (event.results[i].isFinal) { | |
| finalTranscript += event.results[i][0].transcript + ' '; | |
| } | |
| } | |
| const input = document.getElementById('user-input'); | |
| if (finalTranscript) { | |
| const pos = input.selectionStart; | |
| const before = input.value.substring(0, pos); | |
| const after = input.value.substring(pos); | |
| input.value = before + finalTranscript + after; | |
| input.setSelectionRange(pos + finalTranscript.length, pos + finalTranscript.length); | |
| } | |
| }; | |
| recognition.onerror = (event) => { | |
| isRecording = false; | |
| document.getElementById('mic-btn').classList.remove('recording'); | |
| if (event.error === 'not-allowed') { | |
| alert('Microphone permission denied.'); | |
| } | |
| }; | |
| } | |
| function toggleVoice() { | |
| if (!recognition) { | |
| alert("Voice not supported. Use Chrome/Edge/Safari."); | |
| return; | |
| } | |
| if (isRecording) { | |
| recognition.stop(); | |
| } else { | |
| try { | |
| recognition.start(); | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| } | |
| } | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape') { | |
| closeModal(); | |
| cancelRestart(); | |
| } | |
| }); | |
| window.sendMessage = sendMessage; | |
| window.generateArticle = generateArticle; | |
| window.submitToGithub = submitToGithub; | |
| window.downloadArticle = downloadArticle; | |
| window.closeModal = closeModal; | |
| window.confirmRestart = confirmRestart; | |
| window.cancelRestart = cancelRestart; | |
| window.executeRestart = executeRestart; | |
| window.checkRestartInput = checkRestartInput; | |
| window.toggleVoice = toggleVoice; | |
| window.handleKeyPress = handleKeyPress; | |
| window.startTutorial = startTutorial; | |
| initializeApp(); | |
| </script> | |
| </body> | |
| </html> |