'use client'
import { useState, useEffect, useRef } from 'react'
import { Room, RoomEvent, Track, RemoteTrack, LocalTrack } from 'livekit-client'
import { useTracks, RoomAudioRenderer, LiveKitRoom, useRoomContext } from '@livekit/components-react'
import { useInterviewToken } from '@/hooks/useResume'
import { useForceEndInterview } from '@/hooks/useSession'
import {
Mic,
MicOff,
Phone,
PhoneOff,
Volume2,
VolumeX,
Loader,
CheckCircle,
AlertCircle
} from 'lucide-react'
interface InterviewSessionProps {
resumeId: number
sessionId?: number
onEnd?: () => void
}
interface InterviewState {
isConnected: boolean
isRecording: boolean
isMuted: boolean
isSpeaking: boolean
connectionState: 'connecting' | 'connected' | 'disconnected' | 'failed'
error?: string
}
export default function InterviewSession({ resumeId, onEnd }: InterviewSessionProps) {
const { data: tokenData, isLoading, error } = useInterviewToken(resumeId, true)
if (isLoading) {
return (
Подключаемся к собеседованию
Пожалуйста, подождите, мы подготавливаем для вас сессию
)
}
if (error || !tokenData?.token) {
return (
Ошибка подключения
Не удалось подключиться к сессии собеседования
)
}
return (
console.log('Connected to LiveKit')}
onDisconnected={() => console.log('Disconnected from LiveKit')}
onError={(error) => {
console.error('LiveKit error:', error)
}}
>
)
}
function InterviewRoom({ resumeId, onEnd, sessionId }: InterviewSessionProps) {
const room = useRoomContext()
const tracks = useTracks([Track.Source.Microphone, Track.Source.ScreenShare], {
onlySubscribed: false,
})
const forceEndMutation = useForceEndInterview()
const [state, setState] = useState({
isConnected: false,
isRecording: false,
isMuted: false,
isSpeaking: false,
connectionState: 'connecting'
})
const [interviewStarted, setInterviewStarted] = useState(false)
const [currentQuestion, setCurrentQuestion] = useState('')
const [aiSpeaking, setAiSpeaking] = useState(false)
useEffect(() => {
if (!room) return
const handleConnected = () => {
setState(prev => ({
...prev,
isConnected: true,
connectionState: 'connected'
}))
// Начинаем собеседование
startInterview()
}
const handleDisconnected = () => {
setState(prev => ({
...prev,
isConnected: false,
connectionState: 'disconnected'
}))
if (onEnd) {
onEnd()
}
}
const handleDataReceived = (payload: Uint8Array, participant: any) => {
try {
const message = JSON.parse(new TextDecoder().decode(payload))
handleServerMessage(message)
} catch (error) {
console.error('Error parsing server message:', error)
}
}
room.on(RoomEvent.Connected, handleConnected)
room.on(RoomEvent.DataReceived, handleDataReceived)
room.on(RoomEvent.Disconnected, handleDisconnected)
return () => {
room.off(RoomEvent.Connected, handleConnected)
room.off(RoomEvent.DataReceived, handleDataReceived)
room.off(RoomEvent.Disconnected, handleDisconnected)
}
}, [room])
const startInterview = async () => {
if (!room) return
try {
// Отправляем сигнал серверу о начале собеседования
await room.localParticipant.publishData(
new TextEncoder().encode(JSON.stringify({
type: 'start_interview',
resumeId
})),
{ reliable: true }
)
setInterviewStarted(true)
} catch (error) {
console.error('Error starting interview:', error)
}
}
const handleServerMessage = (message: any) => {
switch (message.type) {
case 'question':
setCurrentQuestion(message.text)
setAiSpeaking(true)
break
case 'ai_speaking_start':
setAiSpeaking(true)
break
case 'ai_speaking_end':
setAiSpeaking(false)
break
case 'interview_started':
break
case 'interview_complete':
// Собеседование завершено
if (onEnd) onEnd()
break
default:
console.log('Unknown message type:', message.type)
}
}
const toggleMute = async () => {
if (!room) return
try {
const audioTrack = room.localParticipant.getTrackPublication(Track.Source.Microphone)
if (audioTrack) {
if (state.isMuted) {
await audioTrack.unmute()
} else {
await audioTrack.mute()
}
setState(prev => ({ ...prev, isMuted: !prev.isMuted }))
}
} catch (error) {
console.error('Error toggling mute:', error)
}
}
const endInterview = async () => {
if (!room) return
try {
// Если есть sessionId, используем force-end API
if (sessionId) {
console.log('Starting force-end mutation for sessionId:', sessionId)
await forceEndMutation.mutateAsync(sessionId)
console.log('Force-end mutation completed successfully')
}
// Отправляем сигнал серверу о завершении собеседования
await room.localParticipant.publishData(
new TextEncoder().encode(JSON.stringify({
type: 'end_interview',
resumeId
})),
{ reliable: true }
)
setState(prev => ({
...prev,
isConnected: false,
connectionState: 'disconnected'
}))
// Отключение происходит только после успешного выполнения всех операций
room.disconnect()
console.log('About to call onEnd - this will cause redirect')
// Временно отключаем редирект для проверки логов
if (onEnd) onEnd()
} catch (error) {
console.error('Error ending interview:', error)
// В случае ошибки всё равно отключаемся
setState(prev => ({
...prev,
isConnected: false,
connectionState: 'disconnected'
}))
room.disconnect()
if (onEnd) onEnd()
}
}
const getConnectionStatusColor = () => {
switch (state.connectionState) {
case 'connected':
return 'text-green-600'
case 'connecting':
return 'text-yellow-600'
case 'failed':
return 'text-red-600'
default:
return 'text-gray-600'
}
}
return (
{/* Header */}
Собеседование со Стефани
{state.connectionState === 'connected' && 'Подключено'}
{state.connectionState === 'connecting' && 'Подключение...'}
{state.connectionState === 'disconnected' && 'Отключено'}
{state.connectionState === 'failed' && 'Ошибка подключения'}
{/* Current Question */}
{currentQuestion && (
{aiSpeaking && }
{aiSpeaking ? 'HR Агент говорит...' : 'Текущий вопрос:'}
{currentQuestion}
)}
{!interviewStarted && state.isConnected && (
Ожидаем начала собеседования...
)}
{/* Controls */}
{/* Instructions */}
Для начала диалога поприветствуйте интервьюера
В процессе говорите четко и ждите, пока агент закончит свой вопрос
Собеседование завершится автоматически
Экстренно завершить собеседование можно, нажав красную кнопку
)
}