This commit is contained in:
Михаил Краевский 2025-09-10 21:42:45 +03:00
parent d7fe54d6bb
commit 206527fa0d
8 changed files with 261 additions and 18 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log*
.next
.git
.gitignore
README.md
.env
.env.local
.env.production.local
.env.development.local
coverage
.nyc_output
.DS_Store
*.tsbuildinfo

View File

@ -1,2 +1,2 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api NEXT_PUBLIC_API_BASE_URL=https://hr.aiquity.xyz:8000/api
NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880 NEXT_PUBLIC_LIVEKIT_URL=wss://hackaton-eizc9zqk.livekit.cloud

37
Dockerfile Normal file
View File

@ -0,0 +1,37 @@
FROM --platform=linux/amd64 node:18-alpine AS base
WORKDIR /app
FROM base AS deps
RUN apk add --no-cache libc6-compat
COPY package.json yarn.lock* ./
RUN yarn
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN yarn build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

View File

@ -34,13 +34,44 @@ interface InterviewState {
export default function InterviewSession({ resumeId, onEnd }: InterviewSessionProps) { export default function InterviewSession({ resumeId, onEnd }: InterviewSessionProps) {
const { data: tokenData, isLoading, error } = useInterviewToken(resumeId, true) const { data: tokenData, isLoading, error } = useInterviewToken(resumeId, true)
const [connectionError, setConnectionError] = useState<string | null>(null)
const [isRetrying, setIsRetrying] = useState(false)
const getServerUrl = () => {
// Приоритет: данные от API -> fallback URLs
if (tokenData?.serverUrl) {
return tokenData.serverUrl
}
// Fallback URLs для разных окружений
const fallbackUrls = [
'wss://hackaton-eizc9zqk.livekit.cloud',
]
return fallbackUrls[0]
}
const handleConnectionError = (error: Error) => {
console.error('LiveKit connection error:', error)
setConnectionError(`Ошибка подключения: ${error.message}`)
}
const retryConnection = () => {
setIsRetrying(true)
setConnectionError(null)
// Перезагрузка компонента через 1 секунду
setTimeout(() => {
setIsRetrying(false)
window.location.reload()
}, 1000)
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50"> <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50">
<Loader className="h-12 w-12 text-blue-600 animate-spin mb-4" /> <Loader className="h-12 w-12 text-blue-600 animate-spin mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Подключаемся к собеседованию Подготавливаем собеседование
</h2> </h2>
<p className="text-gray-600 text-center max-w-md"> <p className="text-gray-600 text-center max-w-md">
Пожалуйста, подождите, мы подготавливаем для вас сессию Пожалуйста, подождите, мы подготавливаем для вас сессию
@ -54,18 +85,64 @@ export default function InterviewSession({ resumeId, onEnd }: InterviewSessionPr
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50"> <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50">
<AlertCircle className="h-12 w-12 text-red-600 mb-4" /> <AlertCircle className="h-12 w-12 text-red-600 mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Ошибка подключения Ошибка получения токена
</h2> </h2>
<p className="text-gray-600 text-center max-w-md mb-6"> <p className="text-gray-600 text-center max-w-md mb-6">
Не удалось подключиться к сессии собеседования Не удалось получить токен доступа к сессии собеседования
</p> </p>
<div className="flex gap-4">
<button
onClick={() => window.location.reload()}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Попробовать снова
</button>
<button <button
onClick={() => window.history.back()} onClick={() => window.history.back()}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
> >
Вернуться назад Вернуться назад
</button> </button>
</div> </div>
</div>
)
}
if (connectionError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50">
<AlertCircle className="h-12 w-12 text-red-600 mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Ошибка подключения к LiveKit
</h2>
<p className="text-gray-600 text-center max-w-md mb-4">
{connectionError}
</p>
<p className="text-sm text-gray-500 text-center max-w-md mb-6">
Сервер: {getServerUrl()}
</p>
{isRetrying ? (
<div className="flex items-center">
<Loader className="h-5 w-5 text-blue-600 animate-spin mr-2" />
<span className="text-blue-600">Переподключение...</span>
</div>
) : (
<div className="flex gap-4">
<button
onClick={retryConnection}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Попробовать снова
</button>
<button
onClick={() => window.history.back()}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Вернуться назад
</button>
</div>
)}
</div>
) )
} }
@ -73,13 +150,23 @@ export default function InterviewSession({ resumeId, onEnd }: InterviewSessionPr
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<LiveKitRoom <LiveKitRoom
token={tokenData.token} token={tokenData.token}
serverUrl={tokenData.serverUrl || process.env.NEXT_PUBLIC_LIVEKIT_URL!} serverUrl={getServerUrl()}
audio={true} audio={true}
video={false} video={false}
onConnected={() => console.log('Connected to LiveKit')} connectOptions={{
onDisconnected={() => console.log('Disconnected from LiveKit')} // Дополнительные опции подключения
autoSubscribe: true,
maxRetries: 3,
}}
onConnected={() => {
console.log('Connected to LiveKit successfully')
setConnectionError(null)
}}
onDisconnected={(reason) => {
console.log('Disconnected from LiveKit:', reason)
}}
onError={(error) => { onError={(error) => {
console.error('LiveKit error:', error) handleConnectionError(error)
}} }}
> >
<InterviewRoom resumeId={resumeId} onEnd={onEnd} sessionId={tokenData.session_id} /> <InterviewRoom resumeId={resumeId} onEnd={onEnd} sessionId={tokenData.session_id} />

View File

@ -100,7 +100,7 @@ export default function VacancyReports({ reports }: VacancyReportsProps) {
const score = report[key as keyof InterviewReport] as number; const score = report[key as keyof InterviewReport] as number;
return ( return (
<p key={key}> <p key={key}>
<span className="font-medium">{label}:</span> {score}/10 <span className="font-medium">{label}:</span> {score}/100
</p> </p>
); );
})} })}

View File

@ -1,6 +1,6 @@
import ky from 'ky' import ky from 'ky'
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 || 'https://hr.aiquity.xyz/api'
// Базовый клиент без Content-Type заголовка // Базовый клиент без Content-Type заголовка
const baseKyClient = ky.create({ const baseKyClient = ky.create({

View File

@ -1,4 +1,36 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = {} const nextConfig = {
output: 'standalone',
async headers() {
return [
{
// Apply these headers to all routes
source: '/(.*)',
headers: [
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin',
},
],
},
]
},
// Разрешить WebSocket подключения к внешним доменам
experimental: {
serverComponentsExternalPackages: [],
},
// Настройки для работы с внешними доменами
images: {
domains: ['hr.aiquity.xyz'],
},
}
module.exports = nextConfig module.exports = nextConfig

71
scripts/build-and-push.sh Executable file
View File

@ -0,0 +1,71 @@
#!/bin/bash
# Build and push script for Yandex Cloud Container Registry
# Usage: ./scripts/build-and-push. [tag]
set -e
# Configuration
REGISTRY_ID="${YANDEX_REGISTRY_ID:-your-registry-id}"
IMAGE_NAME="hr-ai-frontend"
TAG="${1:-latest}"
FULL_IMAGE_NAME="cr.yandex/${REGISTRY_ID}/${IMAGE_NAME}:${TAG}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Building and pushing HR AI Frontend to Yandex Cloud Container Registry${NC}"
# Check if required environment variables are set
if [ -z "$REGISTRY_ID" ] || [ "$REGISTRY_ID" = "your-registry-id" ]; then
echo -e "${RED}Error: YANDEX_REGISTRY_ID environment variable is not set${NC}"
echo "Please set it to your Yandex Cloud Container Registry ID"
echo "Example: export YANDEX_REGISTRY_ID=crp1234567890abcdef"
exit 1
fi
# Check if yc CLI is installed and authenticated
if ! command -v yc &> /dev/null; then
echo -e "${RED}Error: Yandex Cloud CLI (yc) is not installed${NC}"
echo "Please install it from: https://cloud.yandex.ru/docs/cli/quickstart"
exit 1
fi
# Check authentication
if ! yc config list | grep -q "token:"; then
echo -e "${RED}Error: Not authenticated with Yandex Cloud${NC}"
echo "Please run: yc init"
exit 1
fi
echo -e "${YELLOW}Configuring Docker for Yandex Cloud Container Registry...${NC}"
yc container registry configure-docker
echo -e "${YELLOW}Building Docker image: ${FULL_IMAGE_NAME}${NC}"
docker build -t "${FULL_IMAGE_NAME}" .
echo -e "${YELLOW}Pushing image to registry...${NC}"
docker push "${FULL_IMAGE_NAME}"
echo -e "${GREEN}✓ Successfully built and pushed: ${FULL_IMAGE_NAME}${NC}"
# Also tag as latest if a specific tag was provided
if [ "$TAG" != "latest" ]; then
LATEST_IMAGE_NAME="cr.yandex/${REGISTRY_ID}/${IMAGE_NAME}:latest"
echo -e "${YELLOW}Tagging as latest...${NC}"
docker tag "${FULL_IMAGE_NAME}" "${LATEST_IMAGE_NAME}"
docker push "${LATEST_IMAGE_NAME}"
echo -e "${GREEN}✓ Also pushed as: ${LATEST_IMAGE_NAME}${NC}"
fi
echo -e "${GREEN}Build and push completed successfully!${NC}"
echo ""
echo "Image is available at:"
echo " ${FULL_IMAGE_NAME}"
echo ""
echo "To use in production, update your docker-compose.prod.yml:"
echo " frontend:"
echo " image: ${FULL_IMAGE_NAME}"