diff --git a/backend/main.py b/backend/main.py index 85c4a31..dd8a90e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -21,9 +21,11 @@ from slowapi.errors import RateLimitExceeded from supabase_store import GuideStore -from routes import guides, webhook, demos +from routes import client_config, guides, webhook, demos # Load environment variables +_BACKEND_ENV_PATH = os.path.join(os.path.dirname(__file__), ".env") +load_dotenv(_BACKEND_ENV_PATH) load_dotenv() # Configure logging @@ -87,6 +89,7 @@ app.include_router(guides.router) app.include_router(webhook.router) app.include_router(demos.router) +app.include_router(client_config.router) # ======================================== diff --git a/backend/routes/client_config.py b/backend/routes/client_config.py new file mode 100644 index 0000000..751d66e --- /dev/null +++ b/backend/routes/client_config.py @@ -0,0 +1,29 @@ +"""Client-safe configuration endpoints.""" + +import os + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/client-config") +async def get_client_config(): + """Return non-secret config needed by the browser client.""" + public_key = os.getenv("VAPI_PUBLIC_KEY", "").strip() + assistant_id = os.getenv("VAPI_ASSISTANT_ID", "").strip() + + missing = [] + if not public_key: + missing.append("VAPI_PUBLIC_KEY") + if not assistant_id: + missing.append("VAPI_ASSISTANT_ID") + + return { + "voice": { + "enabled": not missing, + "public_key": public_key, + "assistant_id": assistant_id, + }, + "missing": missing, + } diff --git a/frontend/dist/index.html b/frontend/dist/index.html index e430c35..3326368 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -12,8 +12,8 @@ - - + + diff --git a/frontend/src/InterviewView.jsx b/frontend/src/InterviewView.jsx index 00fc3d4..1cd56a6 100644 --- a/frontend/src/InterviewView.jsx +++ b/frontend/src/InterviewView.jsx @@ -58,7 +58,16 @@ function mapErrorMessage(raw) { } export default function InterviewView({ onInterviewComplete, onBack }) { - const { callStatus, voiceState, transcript, formattedTranscript, error, startCall, endCall } = useVapi(); + const { + callStatus, + voiceState, + transcript, + formattedTranscript, + error, + configStatus, + startCall, + endCall, + } = useVapi(); // Check if user said enough (both character and word minimums) const userText = transcript @@ -142,9 +151,13 @@ export default function InterviewView({ onInterviewComplete, onBack }) { {/* Call controls */} {callStatus === 'idle' && ( - )} {callStatus === 'connecting' && ( diff --git a/frontend/src/components/MarkdownRenderer.jsx b/frontend/src/components/MarkdownRenderer.jsx index 45b82e4..487fa18 100644 --- a/frontend/src/components/MarkdownRenderer.jsx +++ b/frontend/src/components/MarkdownRenderer.jsx @@ -86,10 +86,24 @@ function ClickableHeading({ level, id, children, ...props }) { ); } +function stripChecklistMarkers(markdown) { + return markdown + .split(/(```[\s\S]*?```)/g) + .map((block) => { + if (block.startsWith('```')) return block; + return block + .replace(/^(\s*[-*+]\s+)\[\s*\]\s+/gm, '$1') + .replace(/^(\s*\d+\.\s+)\[\s*\]\s+/gm, '$1') + .replace(/^(\s*)\[\s*\]\s+/gm, '$1'); + }) + .join(''); +} + export default function MarkdownRenderer({ content }) { if (!content) return null; + const normalizedContent = stripChecklistMarkers(content); return ( -
+
- {content} + {normalizedContent}
); diff --git a/frontend/src/useVapi.js b/frontend/src/useVapi.js index 06079d8..7901657 100644 --- a/frontend/src/useVapi.js +++ b/frontend/src/useVapi.js @@ -2,8 +2,17 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import Vapi from '@vapi-ai/web'; import { saveTranscriptBackup, clearTranscriptBackup } from './lib/transcriptBackup'; -const VAPI_PUBLIC_KEY = import.meta.env.VITE_VAPI_PUBLIC_KEY; -const VAPI_ASSISTANT_ID = import.meta.env.VITE_VAPI_ASSISTANT_ID; +const IS_LOCALHOST = + typeof window !== 'undefined' && + (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'); + +const LOCALHOST_VAPI_PUBLIC_KEY = + import.meta.env.DEV && IS_LOCALHOST ? '5bd9e5c5-dd9e-4021-b13d-9d6fa8395dc0' : ''; +const LOCALHOST_VAPI_ASSISTANT_ID = + import.meta.env.DEV && IS_LOCALHOST ? 'a53bb710-ddba-4c5c-9952-f8442b912d2f' : ''; + +const ENV_VAPI_PUBLIC_KEY = import.meta.env.VITE_VAPI_PUBLIC_KEY || LOCALHOST_VAPI_PUBLIC_KEY; +const ENV_VAPI_ASSISTANT_ID = import.meta.env.VITE_VAPI_ASSISTANT_ID || LOCALHOST_VAPI_ASSISTANT_ID; const API_BASE = import.meta.env.VITE_API_BASE || ''; const BACKUP_INTERVAL = 5; // save backup every N final entries @@ -16,193 +25,254 @@ export default function useVapi() { const [transcript, setTranscript] = useState([]); // Array of { role, text, timestamp, isFinal } const [formattedTranscript, setFormattedTranscript] = useState(null); const [error, setError] = useState(null); + const [configStatus, setConfigStatus] = useState('loading'); // loading | ready | error const fullTranscriptRef = useRef([]); const vapiRef = useRef(null); + const vapiConfigRef = useRef({ + publicKey: ENV_VAPI_PUBLIC_KEY, + assistantId: ENV_VAPI_ASSISTANT_ID, + }); const connectTimeoutRef = useRef(null); const retryCountRef = useRef(0); // Initialize Vapi instance once useEffect(() => { - if (!VAPI_PUBLIC_KEY || !VAPI_ASSISTANT_ID) { - console.error( - 'Missing VAPI config. Set VITE_VAPI_PUBLIC_KEY and VITE_VAPI_ASSISTANT_ID in your .env file.' - ); - setError('Voice service not configured. Check environment variables.'); - return; - } + let cancelled = false; - const vapi = new Vapi(VAPI_PUBLIC_KEY); - vapiRef.current = vapi; + async function loadConfig() { + let publicKey = ENV_VAPI_PUBLIC_KEY; + let assistantId = ENV_VAPI_ASSISTANT_ID; - // --- Call lifecycle --- - vapi.on('call-start', () => { - if (connectTimeoutRef.current) { - clearTimeout(connectTimeoutRef.current); - connectTimeoutRef.current = null; + if (publicKey && assistantId) { + return { publicKey, assistantId }; } - setCallStatus('active'); - setVoiceState('idle'); - setError(null); - }); - vapi.on('call-end', () => { - setVoiceState('idle'); + try { + const res = await fetch(`${API_BASE}/client-config`); + if (!res.ok) { + throw new Error(`Config API returned ${res.status}`); + } + const data = await res.json(); + publicKey = data?.voice?.public_key || ''; + assistantId = data?.voice?.assistant_id || ''; + } catch (err) { + throw new Error( + 'Voice service not configured. Start the backend with backend/.env or set VITE_VAPI_PUBLIC_KEY and VITE_VAPI_ASSISTANT_ID in frontend/.env.' + ); + } - // Grace period — let any in-flight transcript messages arrive before - // we set 'ended' (which triggers the isTooShort check in InterviewView) - setTimeout(() => { - setCallStatus('ended'); - }, 800); - - // POST accumulated transcript to formatter - const accumulated = fullTranscriptRef.current; - if (accumulated.length > 0) { - const transcriptText = accumulated - .filter(e => e.isFinal) - .map(e => `${e.role === 'user' ? 'User' : 'Agent'}: ${e.text}`) - .join('\n'); - - fetch(`${API_BASE}/format`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ transcript: transcriptText }), - }) - .then(res => { - if (!res.ok) throw new Error(`Format API returned ${res.status}`); - return res.json(); - }) - .then(data => { - setFormattedTranscript(data.formatted); - // Save formatted transcript to backup for crash recovery - saveTranscriptBackup(fullTranscriptRef.current, data.formatted); - }) - .catch(err => { - console.error('Failed to POST transcript:', err); - setFormattedTranscript(transcriptText); - saveTranscriptBackup(fullTranscriptRef.current, transcriptText); - }); + if (!publicKey || !assistantId) { + throw new Error( + 'Voice service not configured. Set VAPI_PUBLIC_KEY and VAPI_ASSISTANT_ID in backend/.env, or set VITE_VAPI_PUBLIC_KEY and VITE_VAPI_ASSISTANT_ID in frontend/.env.' + ); } - }); - // --- Speech events (agent-side) --- - vapi.on('speech-start', () => { - setVoiceState('agent-speaking'); - }); + return { publicKey, assistantId }; + } - vapi.on('speech-end', () => { - setVoiceState('idle'); - }); + async function initVapi() { + try { + const config = await loadConfig(); + if (cancelled) return; - // --- Error handling with auto-retry --- - vapi.on('error', (err) => { - console.error('[VAPI] Error:', err); - if (connectTimeoutRef.current) { - clearTimeout(connectTimeoutRef.current); - connectTimeoutRef.current = null; - } - if (retryCountRef.current < 1) { - retryCountRef.current += 1; - console.log('[VAPI] Auto-retrying after error...'); - setTimeout(() => { - if (vapiRef.current) { - setCallStatus('connecting'); - vapiRef.current.start(VAPI_ASSISTANT_ID); + vapiConfigRef.current = config; + const vapi = new Vapi(config.publicKey); + vapiRef.current = vapi; + + // --- Call lifecycle --- + vapi.on('call-start', () => { + if (connectTimeoutRef.current) { + clearTimeout(connectTimeoutRef.current); + connectTimeoutRef.current = null; } - }, 2000); - } else { - setError(err?.message || 'Voice call encountered an error'); - setCallStatus('ended'); - setVoiceState('idle'); - } - }); - - // --- Transcript messages --- - vapi.on('message', (msg) => { - if (msg.type === 'transcript') { - const role = msg.role === 'assistant' ? 'agent' : 'user'; - const entry = { - role, - text: msg.transcript, - timestamp: Date.now(), - isFinal: msg.transcriptType === 'final', - }; - - if (entry.isFinal) { - setTranscript(prev => { - const lastIdx = prev.length - 1; - if (lastIdx >= 0 && !prev[lastIdx].isFinal && prev[lastIdx].role === role) { - const updated = [...prev]; - updated[lastIdx] = entry; - return updated; - } - return [...prev, entry]; - }); - fullTranscriptRef.current = [...fullTranscriptRef.current, entry]; + setCallStatus('active'); + setVoiceState('idle'); + setError(null); + }); - // Periodically save transcript backup for crash recovery - if (fullTranscriptRef.current.length % BACKUP_INTERVAL === 0) { - saveTranscriptBackup(fullTranscriptRef.current, null); + vapi.on('call-end', () => { + setVoiceState('idle'); + + // Grace period — let any in-flight transcript messages arrive before + // we set 'ended' (which triggers the isTooShort check in InterviewView) + setTimeout(() => { + setCallStatus('ended'); + }, 800); + + // POST accumulated transcript to formatter + const accumulated = fullTranscriptRef.current; + if (accumulated.length > 0) { + const transcriptText = accumulated + .filter(e => e.isFinal) + .map(e => `${e.role === 'user' ? 'User' : 'Agent'}: ${e.text}`) + .join('\n'); + + fetch(`${API_BASE}/format`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transcript: transcriptText }), + }) + .then(res => { + if (!res.ok) throw new Error(`Format API returned ${res.status}`); + return res.json(); + }) + .then(data => { + setFormattedTranscript(data.formatted); + // Save formatted transcript to backup for crash recovery + saveTranscriptBackup(fullTranscriptRef.current, data.formatted); + }) + .catch(err => { + console.error('Failed to POST transcript:', err); + setFormattedTranscript(transcriptText); + saveTranscriptBackup(fullTranscriptRef.current, transcriptText); + }); } + }); - if (role === 'user') { - setVoiceState('agent-thinking'); + // --- Speech events (agent-side) --- + vapi.on('speech-start', () => { + setVoiceState('agent-speaking'); + }); + + vapi.on('speech-end', () => { + setVoiceState('idle'); + }); + + // --- Error handling with auto-retry --- + vapi.on('error', (err) => { + console.error('[VAPI] Error:', err); + if (connectTimeoutRef.current) { + clearTimeout(connectTimeoutRef.current); + connectTimeoutRef.current = null; } - } else { - setTranscript(prev => { - const lastIdx = prev.length - 1; - if (lastIdx >= 0 && !prev[lastIdx].isFinal && prev[lastIdx].role === role) { - const updated = [...prev]; - updated[lastIdx] = entry; - return updated; - } - return [...prev, entry]; - }); + if (retryCountRef.current < 1) { + retryCountRef.current += 1; + console.log('[VAPI] Auto-retrying after error...'); + setTimeout(() => { + if (vapiRef.current) { + setCallStatus('connecting'); + vapiRef.current.start(vapiConfigRef.current.assistantId); + } + }, 2000); + } else { + setError(err?.message || 'Voice call encountered an error'); + setCallStatus('ended'); + setVoiceState('idle'); + } + }); + + // --- Transcript messages --- + vapi.on('message', (msg) => { + if (msg.type === 'transcript') { + const role = msg.role === 'assistant' ? 'agent' : 'user'; + const entry = { + role, + text: msg.transcript, + timestamp: Date.now(), + isFinal: msg.transcriptType === 'final', + }; + + if (entry.isFinal) { + setTranscript(prev => { + const lastIdx = prev.length - 1; + if (lastIdx >= 0 && !prev[lastIdx].isFinal && prev[lastIdx].role === role) { + const updated = [...prev]; + updated[lastIdx] = entry; + return updated; + } + return [...prev, entry]; + }); + fullTranscriptRef.current = [...fullTranscriptRef.current, entry]; + + // Periodically save transcript backup for crash recovery + if (fullTranscriptRef.current.length % BACKUP_INTERVAL === 0) { + saveTranscriptBackup(fullTranscriptRef.current, null); + } - if (role === 'user') { - setVoiceState('user-speaking'); + if (role === 'user') { + setVoiceState('agent-thinking'); + } + } else { + setTranscript(prev => { + const lastIdx = prev.length - 1; + if (lastIdx >= 0 && !prev[lastIdx].isFinal && prev[lastIdx].role === role) { + const updated = [...prev]; + updated[lastIdx] = entry; + return updated; + } + return [...prev, entry]; + }); + + if (role === 'user') { + setVoiceState('user-speaking'); + } + } } - } + }); + + setConfigStatus('ready'); + } catch (err) { + if (cancelled) return; + console.error('Failed to initialize Vapi:', err); + setConfigStatus('error'); + setError(err?.message || 'Voice service not configured.'); } - }); + } + + initVapi(); // Cleanup on unmount return () => { + cancelled = true; if (connectTimeoutRef.current) { clearTimeout(connectTimeoutRef.current); } - vapi.stop(); + vapiRef.current?.stop(); + vapiRef.current = null; }; }, []); const startCall = useCallback(async () => { - if (vapiRef.current && callStatus === 'idle') { - // Check mic permission before starting - try { - await navigator.mediaDevices.getUserMedia({ audio: true }); - } catch (e) { - setError('Microphone access denied. Please allow microphone access and try again.'); - return; - } + if (configStatus === 'loading') { + setError('Voice service is still loading. Try again in a moment.'); + return; + } - setCallStatus('connecting'); - setTranscript([]); - setFormattedTranscript(null); - setError(null); - fullTranscriptRef.current = []; - retryCountRef.current = 0; - clearTranscriptBackup(); // fresh start - vapiRef.current.start(VAPI_ASSISTANT_ID); - - // Connection timeout — if call-start hasn't fired in 30s, abort - connectTimeoutRef.current = setTimeout(() => { - if (vapiRef.current) { - vapiRef.current.stop(); - } - setError('Connection timed out. Please check your microphone and try again.'); - setCallStatus('ended'); - }, CONNECTION_TIMEOUT_MS); + if (!vapiRef.current) { + setError('Voice service not configured. Check your backend or frontend env vars.'); + return; } - }, [callStatus]); + + if (callStatus !== 'idle') { + return; + } + + // Check mic permission before starting + try { + await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (e) { + setError('Microphone access denied. Please allow microphone access and try again.'); + return; + } + + setCallStatus('connecting'); + setTranscript([]); + setFormattedTranscript(null); + setError(null); + fullTranscriptRef.current = []; + retryCountRef.current = 0; + clearTranscriptBackup(); // fresh start + vapiRef.current.start(vapiConfigRef.current.assistantId); + + // Connection timeout — if call-start hasn't fired in 30s, abort + connectTimeoutRef.current = setTimeout(() => { + if (vapiRef.current) { + vapiRef.current.stop(); + } + setError('Connection timed out. Please check your microphone and try again.'); + setCallStatus('ended'); + }, CONNECTION_TIMEOUT_MS); + }, [callStatus, configStatus]); const endCall = useCallback(() => { if (vapiRef.current && (callStatus === 'active' || callStatus === 'connecting')) { @@ -220,6 +290,7 @@ export default function useVapi() { transcript, formattedTranscript, error, + configStatus, startCall, endCall, }; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 69402e9..969e491 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -6,6 +6,7 @@ export default defineConfig({ plugins: [react()], server: { proxy: { + '/client-config': 'http://localhost:8000', '/format': 'http://localhost:8000', '/generate-guide': 'http://localhost:8000', '/guide': 'http://localhost:8000',