fix env; add agent session hook stop

This commit is contained in:
Даниил Ивлев 2025-09-08 00:21:05 +05:00
parent 22c46c133f
commit f2fd566cc4
10 changed files with 92 additions and 45 deletions

View File

@ -1,2 +1,2 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api
NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880 NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880

View File

@ -77,8 +77,9 @@ export default function HomePage() {
<h1 className="text-4xl font-bold text-gray-900 mb-4"> <h1 className="text-4xl font-bold text-gray-900 mb-4">
Найдите работу мечты Найдите работу мечты
</h1> </h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto"> <p className="text-lg text-gray-600 max-w-3xl mx-auto">
Платформа с искусственным интеллектом для поиска идеальной вакансии Выберите понравившуюся вам вакансию, заполните форму и прикрепите резюме.<br/>
После недолговременной обработки вашего документа мы предоставим вам возможность подключится к сессии для собеседования, если ваше резюме удовлетворит вакансию.
</p> </p>
</div> </div>

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react'
import { Room, RoomEvent, Track, RemoteTrack, LocalTrack } from 'livekit-client' import { Room, RoomEvent, Track, RemoteTrack, LocalTrack } from 'livekit-client'
import { useTracks, RoomAudioRenderer, LiveKitRoom, useRoomContext } from '@livekit/components-react' import { useTracks, RoomAudioRenderer, LiveKitRoom, useRoomContext } from '@livekit/components-react'
import { useInterviewToken } from '@/hooks/useResume' import { useInterviewToken } from '@/hooks/useResume'
import { useForceEndInterview } from '@/hooks/useSession'
import { import {
Mic, Mic,
MicOff, MicOff,
@ -18,6 +19,7 @@ import {
interface InterviewSessionProps { interface InterviewSessionProps {
resumeId: number resumeId: number
sessionId: number
onEnd?: () => void onEnd?: () => void
} }
@ -80,18 +82,20 @@ export default function InterviewSession({ resumeId, onEnd }: InterviewSessionPr
console.error('LiveKit error:', error) console.error('LiveKit error:', error)
}} }}
> >
<InterviewRoom resumeId={resumeId} onEnd={onEnd} /> <InterviewRoom resumeId={resumeId} onEnd={onEnd} sessionId={tokenData.session_id} />
</LiveKitRoom> </LiveKitRoom>
</div> </div>
) )
} }
function InterviewRoom({ resumeId, onEnd }: InterviewSessionProps) { function InterviewRoom({ resumeId, onEnd, sessionId }: InterviewSessionProps) {
const room = useRoomContext() const room = useRoomContext()
const tracks = useTracks([Track.Source.Microphone, Track.Source.ScreenShare], { const tracks = useTracks([Track.Source.Microphone, Track.Source.ScreenShare], {
onlySubscribed: false, onlySubscribed: false,
}) })
const forceEndMutation = useForceEndInterview()
const [state, setState] = useState<InterviewState>({ const [state, setState] = useState<InterviewState>({
isConnected: false, isConnected: false,
isRecording: false, isRecording: false,
@ -138,12 +142,10 @@ function InterviewRoom({ resumeId, onEnd }: InterviewSessionProps) {
} }
room.on(RoomEvent.Connected, handleConnected) room.on(RoomEvent.Connected, handleConnected)
room.on(RoomEvent.Disconnected, handleDisconnected)
room.on(RoomEvent.DataReceived, handleDataReceived) room.on(RoomEvent.DataReceived, handleDataReceived)
return () => { return () => {
room.off(RoomEvent.Connected, handleConnected) room.off(RoomEvent.Connected, handleConnected)
room.off(RoomEvent.Disconnected, handleDisconnected)
room.off(RoomEvent.DataReceived, handleDataReceived) room.off(RoomEvent.DataReceived, handleDataReceived)
} }
}, [room]) }, [room])
@ -178,6 +180,8 @@ function InterviewRoom({ resumeId, onEnd }: InterviewSessionProps) {
case 'ai_speaking_end': case 'ai_speaking_end':
setAiSpeaking(false) setAiSpeaking(false)
break break
case 'interview_started':
break
case 'interview_complete': case 'interview_complete':
// Собеседование завершено // Собеседование завершено
if (onEnd) onEnd() if (onEnd) onEnd()
@ -209,6 +213,13 @@ function InterviewRoom({ resumeId, onEnd }: InterviewSessionProps) {
if (!room) return if (!room) return
try { 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( await room.localParticipant.publishData(
new TextEncoder().encode(JSON.stringify({ new TextEncoder().encode(JSON.stringify({
@ -217,11 +228,26 @@ function InterviewRoom({ resumeId, onEnd }: InterviewSessionProps) {
})), })),
{ reliable: true } { reliable: true }
) )
setState(prev => ({
...prev,
isConnected: false,
connectionState: 'disconnected'
}))
// Отключение происходит только после успешного выполнения всех операций
room.disconnect() room.disconnect()
console.log('About to call onEnd - this will cause redirect')
// Временно отключаем редирект для проверки логов
if (onEnd) onEnd() if (onEnd) onEnd()
} catch (error) { } catch (error) {
console.error('Error ending interview:', error) console.error('Error ending interview:', error)
// В случае ошибки всё равно отключаемся
setState(prev => ({
...prev,
isConnected: false,
connectionState: 'disconnected'
}))
room.disconnect() room.disconnect()
if (onEnd) onEnd() if (onEnd) onEnd()
} }
@ -305,8 +331,10 @@ function InterviewRoom({ resumeId, onEnd }: InterviewSessionProps) {
{/* Instructions */} {/* Instructions */}
<div className="text-center text-sm text-gray-500 space-y-1"> <div className="text-center text-sm text-gray-500 space-y-1">
<p>Говорите четко и ждите, пока агент закончит свой вопрос</p> <p>Для начала диалога поприветствуйте интервьюера</p>
<p>Для завершения собеседования нажмите красную кнопку</p> <p>В процессе говорите четко и ждите, пока агент закончит свой вопрос</p>
<p>Собеседование завершится автоматически</p>
<p>Экстренно завершить собеседование можно, нажав красную кнопку</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
// @ts-ignore
import InputMask from 'react-input-mask' import InputMask from 'react-input-mask'
import { ResumeCreate } from '@/types/api' import { ResumeCreate } from '@/types/api'
import { useCreateResume, useResumesByVacancy } from '@/hooks/useResume' import { useCreateResume, useResumesByVacancy } from '@/hooks/useResume'
@ -63,9 +64,7 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
// Check file type // Check file type
const allowedTypes = [ const allowedTypes = [
'application/pdf', 'application/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'
] ]
if (!allowedTypes.includes(selectedFile.type)) { if (!allowedTypes.includes(selectedFile.type)) {
@ -162,7 +161,7 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
case 'accepted': case 'accepted':
return 'Принят' return 'Принят'
case 'rejected': case 'rejected':
return 'Отклонен' return 'Отклонено'
default: default:
return status return status
} }
@ -181,7 +180,7 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
Ошибка обработки резюме Ошибка обработки резюме
</h3> </h3>
<p className="mt-2 text-red-700"> <p className="mt-2 text-red-700">
Не удалось обработать ваше резюме. Попробуйте загрузить файл в другом формате (PDF, DOC, DOCX, TXT) Не удалось обработать ваше резюме. Попробуйте загрузить файл в другом формате (PDF, DOCX)
или обратитесь к нам за помощью. или обратитесь к нам за помощью.
</p> </p>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
@ -275,7 +274,7 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: resume.status === 'parsing' || resume.status === 'pending' : resume.status === 'parsing' || resume.status === 'pending'
? 'bg-yellow-100 text-yellow-800' ? 'bg-yellow-100 text-yellow-800'
: resume.status === 'parse_failed' : resume.status === 'parse_failed' || resume.status === 'rejected'
? 'bg-red-100 text-red-800' ? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800' : 'bg-gray-100 text-gray-800'
}`}> }`}>
@ -291,7 +290,10 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
Мы готовы! Мы готовы!
</h4> </h4>
<p className="text-sm text-green-700 mb-4"> <p className="text-sm text-green-700 mb-4">
Ваше резюме успешно обработано. Можете приступать к интервью с HR агентом. Ваше резюме успешно обработано. Можете приступать к собеседованию с HR-агентом.
<br />
<br />
* Вы можете пройти собеседование сегодня до 20:00 МСК
</p> </p>
<a <a
href={`/interview/${resume.id}`} href={`/interview/${resume.id}`}
@ -335,7 +337,20 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
</div> </div>
)} )}
{!['parsed', 'parsing', 'pending', 'parse_failed'].includes(resume.status) && ( {resume.status === 'rejected' && (
<div className="bg-gradient-to-r from-red-50 to-pink-50 border border-red-200 rounded-lg p-4">
<div className="text-center">
<h4 className="text-sm font-medium text-red-900 mb-1">
Резюме не соответствует вакансии
</h4>
<p className="text-xs text-red-700">
К сожалению, ваш опыт не подходит для данной позиции
</p>
</div>
</div>
)}
{!['parsed', 'parsing', 'pending', 'parse_failed', 'rejected'].includes(resume.status) && (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-4"> <div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-4">
<div className="text-center"> <div className="text-center">
<h4 className="text-sm font-medium text-blue-900 mb-1"> <h4 className="text-sm font-medium text-blue-900 mb-1">
@ -357,7 +372,7 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
<div className="bg-white border border-gray-200 rounded-lg p-6"> <div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-medium text-gray-900"> <h3 className="text-lg font-medium text-gray-900">
Откликнуться на вакансию Откликнуться
</h3> </h3>
<p className="mt-1 text-sm text-gray-600"> <p className="mt-1 text-sm text-gray-600">
{vacancyTitle} {vacancyTitle}
@ -459,14 +474,14 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
name="resume_file" name="resume_file"
type="file" type="file"
className="sr-only" className="sr-only"
accept=".pdf,.doc,.docx,.txt" accept=".pdf,.docx"
onChange={handleFileChange} onChange={handleFileChange}
/> />
</label> </label>
<p className="pl-1">или перетащите сюда</p> <p className="pl-1">или перетащите сюда</p>
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
PDF, DOC, DOCX, TXT до 10 МБ PDF, DOCX до 10 МБ
</p> </p>
</div> </div>
</div> </div>

View File

@ -39,4 +39,12 @@ export const useSessionHealth = () => {
staleTime: 2 * 60 * 1000, // 2 minutes staleTime: 2 * 60 * 1000, // 2 minutes
retry: 2, retry: 2,
}) })
}
export const useForceEndInterview = () => {
return useMutation({
mutationFn: (sessionId: number) => sessionService.forceEndInterview(sessionId),
retry: false, // Не повторяем запрос при ошибке
networkMode: 'always',
})
} }

View File

@ -1,7 +1,6 @@
import ky from 'ky' import ky from 'ky'
// Используем прокси Next.js для избежания CORS проблем const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000/api'
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
// Базовый клиент без Content-Type заголовка // Базовый клиент без Content-Type заголовка
const baseKyClient = ky.create({ const baseKyClient = ky.create({

View File

@ -1,13 +1,4 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {}
async rewrites() {
return [
{
source: '/api/v1/:path*',
destination: 'http://localhost:8000/api/v1/:path*',
},
]
},
}
module.exports = nextConfig module.exports = nextConfig

View File

@ -21,17 +21,18 @@ export const resumeService = {
// Логируем данные для отладки // Логируем данные для отладки
console.log('FormData entries:') console.log('FormData entries:')
// @ts-ignore
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
console.log(key, value) console.log(key, value)
} }
return kyFormClient.post('api/v1/resumes/', { return kyFormClient.post('v1/resumes/', {
body: formData, body: formData,
}).json<ResumeRead>() }).json<ResumeRead>()
}, },
async getResume(id: number): Promise<ResumeRead> { async getResume(id: number): Promise<ResumeRead> {
return kyClient.get(`api/v1/resumes/${id}`).json<ResumeRead>() return kyClient.get(`v1/resumes/${id}`).json<ResumeRead>()
}, },
async getResumes(params?: GetResumesParams): Promise<ResumeRead[]> { async getResumes(params?: GetResumesParams): Promise<ResumeRead[]> {
@ -45,15 +46,15 @@ export const resumeService = {
}) })
} }
const endpoint = `api/v1/resumes/${searchParams.toString() ? `?${searchParams.toString()}` : ''}` const endpoint = `v1/resumes/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
return kyClient.get(endpoint).json<ResumeRead[]>() return kyClient.get(endpoint).json<ResumeRead[]>()
}, },
async validateInterview(resumeId: number): Promise<{ can_interview: boolean; message?: string }> { async validateInterview(resumeId: number): Promise<{ can_interview: boolean; message?: string }> {
return kyClient.get(`api/v1/interview/${resumeId}/validate-interview`).json() return kyClient.get(`v1/interview/${resumeId}/validate-interview`).json()
}, },
async getInterviewToken(resumeId: number): Promise<{ token: string; roomName: string; serverUrl: string }> { async getInterviewToken(resumeId: number): Promise<{ token: string; roomName: string; serverUrl: string; session_id: number }> {
return kyClient.post(`api/v1/interview/${resumeId}/token`).json() return kyClient.post(`v1/interview/${resumeId}/token`).json()
}, },
} }

View File

@ -3,18 +3,22 @@ import { SessionRead } from '@/types/api'
export const sessionService = { export const sessionService = {
async getCurrentSession(): Promise<SessionRead> { async getCurrentSession(): Promise<SessionRead> {
return kyClient.get('api/v1/sessions/current').json<SessionRead>() return kyClient.get('v1/sessions/current').json<SessionRead>()
}, },
async refreshSession(): Promise<void> { async refreshSession(): Promise<void> {
return kyClient.post('api/v1/sessions/refresh').json<void>() return kyClient.post('v1/sessions/refresh').json<void>()
}, },
async logout(): Promise<void> { async logout(): Promise<void> {
return kyClient.post('api/v1/sessions/logout').json<void>() return kyClient.post('v1/sessions/logout').json<void>()
}, },
async healthCheck(): Promise<void> { async healthCheck(): Promise<void> {
return kyClient.get('api/v1/sessions/health').json<void>() return kyClient.get('v1/sessions/health').json<void>()
},
async forceEndInterview(sessionId: number): Promise<void> {
return kyClient.post(`v1/admin/interview/${sessionId}/force-end`).json<void>()
}, },
} }

View File

@ -13,11 +13,11 @@ export const vacancyService = {
}) })
} }
const endpoint = `api/v1/vacancies/${searchParams.toString() ? `?${searchParams.toString()}` : ''}` const endpoint = `v1/vacancies/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
return kyClient.get(endpoint).json<VacancyRead[]>() return kyClient.get(endpoint).json<VacancyRead[]>()
}, },
async getVacancy(id: number): Promise<VacancyRead> { async getVacancy(id: number): Promise<VacancyRead> {
return kyClient.get(`api/v1/vacancies/${id}`).json<VacancyRead>() return kyClient.get(`v1/vacancies/${id}`).json<VacancyRead>()
}, },
} }