fix env; add agent session hook stop
This commit is contained in:
parent
22c46c133f
commit
f2fd566cc4
@ -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
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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',
|
||||||
|
})
|
||||||
}
|
}
|
@ -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({
|
||||||
|
@ -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
|
@ -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()
|
||||||
},
|
},
|
||||||
}
|
}
|
@ -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>()
|
||||||
},
|
},
|
||||||
}
|
}
|
@ -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>()
|
||||||
},
|
},
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user