import React, { useState, useRef, useEffect, useCallback } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Mic, MicOff, Volume2, Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react'; import { generateInterviewQuestion, evaluateInterview } from '@/lib/krowAi'; import { useCreateInterview } from '@/lib/krowHooks'; import OwliverAvatar from '@/components/krow/OwliverAvatar'; import { cn } from '@/lib/utils'; const TOTAL_SECONDS = 180; const QUESTION_COUNT = 5; export default function AIInterviewModal({ open, onClose, application, job, conductor = 'KROW' }) { const [messages, setMessages] = useState([]); const [phase, setPhase] = useState('intro'); // intro | active | evaluating | done const [isListening, setIsListening] = useState(false); const [isSpeaking, setIsSpeaking] = useState(false); const [transcript, setTranscript] = useState(''); const [timeLeft, setTimeLeft] = useState(TOTAL_SECONDS); const [evaluation, setEvaluation] = useState(null); const [error, setError] = useState(''); const [ttsEnabled, setTtsEnabled] = useState(true); const [language, setLanguage] = useState('en'); // 'en' | 'es' const recognitionRef = useRef(null); const lastUserMessageTime = useRef(null); const timerRef = useRef(null); const scrollRef = useRef(null); const createInterview = useCreateInterview(); const jobTitle = job?.title || 'the role'; const candidateName = application?.applicant_name || 'Candidate'; const isOwliver = conductor === 'Owliver'; useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [messages]); const speak = useCallback((text) => { return new Promise((resolve) => { if (!ttsEnabled || !window.speechSynthesis) { resolve(); return; } window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); const targetLang = language === 'es' ? 'es' : 'en'; utterance.lang = language === 'es' ? 'es-ES' : 'en-US'; // Pick the best available voice for the target language const voices = window.speechSynthesis.getVoices(); const langVoices = voices.filter(v => v.lang?.toLowerCase().startsWith(targetLang)); if (langVoices.length > 0) { // Prefer native/local voices, then Google voices, then any const preferred = langVoices.find(v => v.localService && v.lang.toLowerCase() === (language === 'es' ? 'es-es' : 'en-us')) || langVoices.find(v => v.localService) || langVoices.find(v => v.name.toLowerCase().includes('google')) || langVoices.find(v => v.lang.toLowerCase() === (language === 'es' ? 'es-es' : 'en-us')) || langVoices[0]; utterance.voice = preferred; } utterance.rate = language === 'es' ? 0.98 : 1.05; utterance.pitch = 1.0; utterance.onstart = () => setIsSpeaking(true); utterance.onend = () => { setIsSpeaking(false); resolve(); }; utterance.onerror = () => { setIsSpeaking(false); resolve(); }; window.speechSynthesis.speak(utterance); }); }, [ttsEnabled, language]); const stopListening = useCallback(() => { if (recognitionRef.current) { recognitionRef.current.stop(); recognitionRef.current = null; } setIsListening(false); }, []); const askNextQuestion = useCallback(async (currentMessages) => { const qNumber = currentMessages.filter(m => m.role === 'assistant').length + 1; if (qNumber > QUESTION_COUNT) { await finishInterview(currentMessages); return; } try { const question = await generateInterviewQuestion(currentMessages, jobTitle, qNumber, language); const aiMsg = { role: 'assistant', content: question, timestamp: new Date().toISOString() }; const newMessages = [...currentMessages, aiMsg]; setMessages(newMessages); lastUserMessageTime.current = Date.now(); await speak(question); } catch { setError('Interview connection issue. Please retry.'); } }, [jobTitle, speak, language]); const startListening = useCallback(() => { const win = /** @type {any} */ (window); const SpeechRecognition = win.SpeechRecognition || win.webkitSpeechRecognition; if (!SpeechRecognition) { setError('Voice input not supported in this browser. Use text mode below.'); return; } // Preserve existing transcript so the user can continue speaking const baseTranscript = transcript; if (!baseTranscript) setTranscript(''); setError(''); const recognition = new SpeechRecognition(); recognition.continuous = false; recognition.interimResults = true; recognition.lang = language === 'es' ? 'es-ES' : 'en-US'; recognition.onresult = (event) => { let text = ''; for (let i = 0; i < event.results.length; i++) { text += event.results[i][0].transcript; } setTranscript((baseTranscript ? baseTranscript + ' ' : '') + text.trim()); }; recognition.onerror = (event) => { if (event.error !== 'no-speech') setError(`Mic error: ${event.error}`); setIsListening(false); }; recognition.onend = () => { setIsListening(false); }; recognitionRef.current = recognition; recognition.start(); setIsListening(true); }, [language, transcript]); const submitAnswer = useCallback(async () => { if (!transcript.trim()) return; stopListening(); const responseTime = lastUserMessageTime.current ? (Date.now() - lastUserMessageTime.current) / 1000 : 0; const userMsg = { role: 'user', content: transcript.trim(), timestamp: new Date().toISOString(), response_time_seconds: Math.round(responseTime * 10) / 10, }; const newMessages = [...messages, userMsg]; setMessages(newMessages); setTranscript(''); // Ask next question setTimeout(() => askNextQuestion(newMessages), 300); }, [transcript, messages, stopListening, askNextQuestion]); /** * Write the interview and let the server carry the verdict onto the * application. * * There is deliberately no second call here. `POST /ai-interviews` moves the * application to `interview`, sets `interview_id` and copies the score across * in the same transaction as the interview itself. Doing it from the browser * was two independent requests, and for a talent user sitting their own * interview the second one was a 403 — the interview existed, the application * still read `applied`, and every consumer counting * `status === 'interview' || interview_id` could not see it. */ const finishInterview = useCallback(async (finalMessages) => { setPhase('evaluating'); try { const result = await evaluateInterview(finalMessages, job, candidateName, language); await createInterview.mutateAsync({ application_id: application.id, job_posting_id: application.job_posting_id, job_title: jobTitle, candidate_name: candidateName, messages: finalMessages, overall_interview_score: result.overall_interview_score, verdict: result.verdict, hire_recommendation: result.hire_recommendation, integrity_score: result.integrity_score, ai_flags: result.ai_flags, category_scores: result.category_scores, strengths: result.strengths, concerns: result.concerns, best_fit_roles: result.best_fit_roles, summary: result.summary, reasoning: result.reasoning, }); setEvaluation(result); setPhase('done'); } catch { setError('Evaluation failed. Please try again.'); setPhase('active'); } }, [application, job, candidateName, createInterview, language]); const startInterview = useCallback(() => { setPhase('active'); setTimeLeft(TOTAL_SECONDS); setMessages([]); askNextQuestion([]); }, [askNextQuestion]); // Timer useEffect(() => { if (phase !== 'active') return; timerRef.current = setInterval(() => { setTimeLeft((prev) => { if (prev <= 1) { clearInterval(timerRef.current); stopListening(); if (messages.length > 0) finishInterview(messages); return 0; } return prev - 1; }); }, 1000); return () => clearInterval(timerRef.current); }, [phase, messages, finishInterview, stopListening]); // Cleanup useEffect(() => { return () => { stopListening(); if (window.speechSynthesis) window.speechSynthesis.cancel(); if (timerRef.current) clearInterval(timerRef.current); }; }, [stopListening]); const handleClose = () => { stopListening(); if (window.speechSynthesis) window.speechSynthesis.cancel(); if (timerRef.current) clearInterval(timerRef.current); setPhase('intro'); setMessages([]); setEvaluation(null); setError(''); setTimeLeft(TOTAL_SECONDS); setLanguage('en'); onClose(); }; const mins = Math.floor(timeLeft / 60); const secs = timeLeft % 60; return ( !v && handleClose()}> {isOwliver ? ( ) : (
K
)} AI Interview with {conductor}
{phase === 'active' && ( {mins}:{secs.toString().padStart(2, '0')} )}
{phase === 'intro' && (
{isOwliver ? ( ) : (
K
)}

Hi {candidateName.split(' ')[0]}, I'm {conductor}

{language === 'es' ? `Te haré ${QUESTION_COUNT} preguntas rápidas sobre tu experiencia en roles de ${jobTitle}. Toma unos 3 minutos. Solo habla naturalmente — no hay respuestas correctas o incorrectas.` : `I'll ask you ${QUESTION_COUNT} quick questions about your experience with ${jobTitle} roles. This takes about 3 minutes. Just speak naturally — there are no right or wrong answers.`}

Voice enabled
{/* Language selector */}
Interview language:
)} {phase === 'active' && (
{messages.map((msg, i) => (

{msg.content}

{msg.response_time_seconds != null && msg.response_time_seconds < 8 && (

Fast response flagged

)}
))} {isSpeaking && (
)}
{error &&

{error}

} {/* Answer input */}