458 lines
19 KiB
JavaScript
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>
|
|
);
|
|
}
|