Files
krow_employer/src/components/krow/AIInterviewModal.jsx
2026-09-07 20:36:18 +05:30

458 lines
19 KiB
JavaScript

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 (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
{isOwliver ? (
<OwliverAvatar className="w-8 h-8" />
) : (
<div className="w-8 h-8 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-[13px] font-bold">K</div>
)}
AI Interview with {conductor}
</span>
{phase === 'active' && (
<span className={cn('text-[13px] font-mono px-2 py-1 rounded-lg', timeLeft < 30 ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#F3F4F6] text-[#374151]')}>
{mins}:{secs.toString().padStart(2, '0')}
</span>
)}
</DialogTitle>
</DialogHeader>
{phase === 'intro' && (
<div className="py-6 text-center">
{isOwliver ? (
<OwliverAvatar className="w-16 h-16 mx-auto mb-4" />
) : (
<div className="w-16 h-16 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-2xl font-bold mx-auto mb-4">K</div>
)}
<h3 className="text-lg font-semibold text-[#111827] mb-2">Hi {candidateName.split(' ')[0]}, I'm {conductor}</h3>
<p className="text-[14px] text-[#6B7280] max-w-md mx-auto mb-6">
{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.`}
</p>
<div className="flex items-center justify-center gap-3 mb-4 text-[12px] text-[#6B7280]">
<span className="inline-flex items-center gap-1"><Volume2 className="w-3.5 h-3.5" /> Voice enabled</span>
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="text-[#0838E0] underline">
{ttsEnabled ? 'Mute' : 'Unmute'}
</button>
</div>
{/* Language selector */}
<div className="flex items-center justify-center gap-2 mb-6">
<span className="text-[12px] text-[#6B7280] font-medium">Interview language:</span>
<div className="inline-flex rounded-lg border border-[#E5E7EB] overflow-hidden">
<button
onClick={() => setLanguage('en')}
className={cn(
'px-3 py-1.5 text-[12px] font-medium transition-colors',
language === 'en' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
)}
>
English
</button>
<button
onClick={() => setLanguage('es')}
className={cn(
'px-3 py-1.5 text-[12px] font-medium transition-colors',
language === 'es' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
)}
>
Español
</button>
</div>
</div>
<Button onClick={startInterview} className="bg-[#0838E0] hover:bg-[#062BAF] px-8">
<Mic className="w-4 h-4 mr-2" /> Start Interview
</Button>
</div>
)}
{phase === 'active' && (
<div className="space-y-4">
<div ref={scrollRef} className="h-[300px] overflow-y-auto space-y-3 p-2">
{messages.map((msg, i) => (
<div key={i} className={cn('flex', msg.role === 'user' ? 'justify-end' : 'justify-start')}>
<div className={cn(
'max-w-[80%] rounded-2xl px-4 py-2.5 text-[14px]',
msg.role === 'user'
? 'bg-[#0838E0] text-white rounded-br-sm'
: 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
)}>
<p>{msg.content}</p>
{msg.response_time_seconds != null && msg.response_time_seconds < 8 && (
<p className="text-[10px] mt-1 opacity-70 inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> Fast response flagged
</p>
)}
</div>
</div>
))}
{isSpeaking && (
<div className="flex justify-start">
<div className="bg-[#F3F4F6] rounded-2xl rounded-bl-sm px-4 py-3 flex gap-1">
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
)}
</div>
{error && <p className="text-[12px] text-[#EF4444]">{error}</p>}
{/* Answer input */}
<div className="border-t border-[#E5E7EB] pt-3">
<textarea
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
placeholder={isListening ? 'Listening...' : 'Type your answer or click mic to speak'}
className="w-full min-h-[60px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submitAnswer();
}
}}
/>
<div className="flex items-center justify-between mt-2">
<button
onClick={isListening ? stopListening : startListening}
className={cn(
'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-[13px] font-medium transition-colors',
isListening ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#F3F4F6] text-[#374151] hover:bg-[#E5E7EB]'
)}
>
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
{isListening ? 'Stop' : 'Speak'}
</button>
<Button
onClick={submitAnswer}
disabled={!transcript.trim()}
className="bg-[#0838E0] hover:bg-[#062BAF]"
>
Send Answer
</Button>
</div>
</div>
</div>
)}
{phase === 'evaluating' && (
<div className="py-12 text-center">
<Loader2 className="w-10 h-10 text-[#0838E0] animate-spin mx-auto mb-4" />
<p className="text-[14px] text-[#6B7280]">{conductor} is evaluating your interview...</p>
</div>
)}
{phase === 'done' && evaluation && (
<div className="py-4 space-y-4">
<div className="text-center">
<div className={cn(
'w-16 h-16 rounded-full mx-auto flex items-center justify-center text-white text-xl font-bold mb-3',
evaluation.verdict === 'hire' ? 'bg-[#059669]' : evaluation.verdict === 'maybe' ? 'bg-[#F59E0B]' : 'bg-[#EF4444]'
)}>
{evaluation.overall_interview_score}
</div>
<h3 className="text-lg font-semibold text-[#111827] capitalize">Verdict: {evaluation.verdict}</h3>
<p className="text-[13px] text-[#6B7280] mt-1">{evaluation.hire_recommendation}</p>
</div>
<div className="grid grid-cols-2 gap-3">
{evaluation.category_scores && Object.entries(evaluation.category_scores).map(([key, val]) => (
<div key={key} className="bg-[#F9FAFB] rounded-lg p-3">
<div className="text-[11px] text-[#6B7280] capitalize">{key.replace(/_/g, ' ')}</div>
<div className="text-lg font-bold text-[#111827]">{val}/100</div>
</div>
))}
</div>
{evaluation.integrity_score < 100 && (
<div className="flex items-start gap-2 p-3 bg-[#FEF3C7] rounded-lg">
<AlertTriangle className="w-4 h-4 text-[#92400E] mt-0.5 shrink-0" />
<div>
<p className="text-[12px] font-medium text-[#92400E]">Integrity Score: {evaluation.integrity_score}/100</p>
{evaluation.ai_flags?.length > 0 && (
<p className="text-[11px] text-[#92400E] mt-1">{evaluation.ai_flags.join(', ')}</p>
)}
</div>
</div>
)}
<div>
<p className="text-[12px] font-medium text-[#374151] mb-1">Summary</p>
<p className="text-[13px] text-[#6B7280]">{evaluation.summary}</p>
</div>
{evaluation.strengths?.length > 0 && (
<div>
<p className="text-[12px] font-medium text-[#059669] mb-1 flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> Strengths</p>
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-0.5">
{evaluation.strengths.map((s, i) => <li key={i}>{s}</li>)}
</ul>
</div>
)}
{evaluation.concerns?.length > 0 && (
<div>
<p className="text-[12px] font-medium text-[#EF4444] mb-1 flex items-center gap-1"><AlertTriangle className="w-3.5 h-3.5" /> Concerns</p>
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-0.5">
{evaluation.concerns.map((c, i) => <li key={i}>{c}</li>)}
</ul>
</div>
)}
<Button onClick={handleClose} className="w-full bg-[#0838E0] hover:bg-[#062BAF]">Done</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}