diff --git a/app/api/generate/scene-content/route.ts b/app/api/generate/scene-content/route.ts index db9b772e..34f74707 100644 --- a/app/api/generate/scene-content/route.ts +++ b/app/api/generate/scene-content/route.ts @@ -67,7 +67,7 @@ export async function POST(req: NextRequest) { // Ensure outline has language from stageInfo (fallback for older outlines) const outline: SceneOutline = { ...rawOutline, - language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN', + language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US' | 'hi-IN') || 'zh-CN', }; // ── Model resolution from request headers ── diff --git a/app/api/generate/scene-outlines-stream/route.ts b/app/api/generate/scene-outlines-stream/route.ts index 36c1606a..96e17c6e 100644 --- a/app/api/generate/scene-outlines-stream/route.ts +++ b/app/api/generate/scene-outlines-stream/route.ts @@ -24,6 +24,7 @@ import { import type { AgentInfo } from '@/lib/generation/generation-pipeline'; import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation'; import { nanoid } from 'nanoid'; +import { isChineseLanguage, isHindiLanguage } from '@/lib/utils/language'; import type { UserRequirements, PdfImage, @@ -121,7 +122,11 @@ export async function POST(req: NextRequest) { // Build prompt (same logic as generateSceneOutlinesFromRequirements) let availableImagesText = - requirements.language === 'zh-CN' ? '无可用图片' : 'No images available'; + isChineseLanguage(requirements.language) + ? '无可用图片' + : isHindiLanguage(requirements.language) + ? 'कोई image उपलब्ध नहीं है' + : 'No images available'; let visionImages: Array<{ id: string; src: string }> | undefined; if (pdfImages && pdfImages.length > 0) { @@ -177,11 +182,13 @@ export async function POST(req: NextRequest) { language: requirements.language, pdfContent: pdfText ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) - : requirements.language === 'zh-CN' + : isChineseLanguage(requirements.language) ? '无' - : 'None', + : isHindiLanguage(requirements.language) + ? 'कोई नहीं' + : 'None', availableImages: availableImagesText, - researchContext: researchContext || (requirements.language === 'zh-CN' ? '无' : 'None'), + researchContext: researchContext || (isChineseLanguage(requirements.language) ? '无' : isHindiLanguage(requirements.language) ? 'कोई नहीं' : 'None'), mediaGenerationPolicy, teacherContext, }); diff --git a/app/api/interview/debrief/route.ts b/app/api/interview/debrief/route.ts new file mode 100644 index 00000000..69c6a5ae --- /dev/null +++ b/app/api/interview/debrief/route.ts @@ -0,0 +1,27 @@ +import { NextRequest } from 'next/server'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; +import { callLLM } from '@/lib/ai/llm'; +import { buildInterviewDebriefPrompt } from '@/lib/interview/prompts'; +import type { InterviewConfig } from '@/lib/interview/types'; + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as { + config: InterviewConfig; + turns: Array<{ question: string; answer?: string }>; + }; + const { model } = resolveModelFromHeaders(req); + const result = await callLLM( + { + model, + system: 'You are an interview coach. Return JSON only.', + prompt: buildInterviewDebriefPrompt(body), + }, + 'interview-debrief', + ); + return apiSuccess(JSON.parse(result.text.trim().match(/\{[\s\S]*\}/)?.[0] || '{}')); + } catch (error) { + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Failed'); + } +} diff --git a/app/api/interview/session/route.ts b/app/api/interview/session/route.ts new file mode 100644 index 00000000..c8b016f7 --- /dev/null +++ b/app/api/interview/session/route.ts @@ -0,0 +1,24 @@ +import { NextRequest } from 'next/server'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; +import { callLLM } from '@/lib/ai/llm'; +import { buildInterviewSessionPrompt } from '@/lib/interview/prompts'; +import type { InterviewConfig } from '@/lib/interview/types'; + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as InterviewConfig; + const { model } = resolveModelFromHeaders(req); + const result = await callLLM( + { + model, + system: 'You are a realistic interviewer. Return JSON only.', + prompt: buildInterviewSessionPrompt(body), + }, + 'interview-session', + ); + return apiSuccess(JSON.parse(result.text.trim().match(/\{[\s\S]*\}/)?.[0] || '{}')); + } catch (error) { + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Failed'); + } +} diff --git a/app/api/interview/turn/route.ts b/app/api/interview/turn/route.ts new file mode 100644 index 00000000..d651d0fa --- /dev/null +++ b/app/api/interview/turn/route.ts @@ -0,0 +1,189 @@ +/** + * Interview Turn API Endpoint + * + * POST /api/interview/turn + * + * Replaces the direct callLLM() path with two statelessGenerate() calls, + * using the same orchestration entrypoint as /api/chat. + * + * Turn sequence per submission: + * 1. Coach agent → feedback { good, missing, strongAnswer } + * 2. Interviewer agent → nextQuestion string + * + * Request shape (unchanged from previous callLLM version): + * { config: InterviewConfig, history: [...], answer: string } + * Model credentials supplied via x-model / x-api-key / x-base-url headers. + * + * Response shape (unchanged — frontend reads payload.feedback + payload.nextQuestion): + * { feedback: { good, missing, strongAnswer }, nextQuestion } + */ + +import { NextRequest } from 'next/server'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; +import { statelessGenerate } from '@/lib/orchestration/stateless-generate'; +import { buildInterviewTurnPrompt } from '@/lib/interview/prompts'; +import type { InterviewConfig } from '@/lib/interview/types'; +import type { StatelessChatRequest, StatelessEvent } from '@/lib/types/chat'; +import type { ThinkingConfig } from '@/lib/types/provider'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('Interview Turn API'); + +export const maxDuration = 60; + +// ── Agent IDs ───────────────────────────────────────────────────────────────── + +const COACH_ID = 'interview-coach'; +const INTERVIEWER_ID = 'interview-interviewer'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Drain a statelessGenerate() generator and return accumulated text deltas. */ +async function collectText(gen: AsyncGenerator): Promise { + let text = ''; + for await (const event of gen) { + if (event.type === 'text_delta') { + text += event.data.content; + } + } + return text.trim(); +} + +/** + * Try to parse the first JSON object found in a string. + * Falls back to an empty object so the caller never throws. + */ +function extractJson(raw: string): Record { + const match = raw.match(/\{[\s\S]*\}/); + if (!match) return {}; + try { + return JSON.parse(match[0]) as Record; + } catch { + return {}; + } +} + +// ── Route handler ───────────────────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as { + config: InterviewConfig; + history: Array<{ question: string; answer?: string }>; + answer: string; + }; + + const { config, history, answer } = body; + + if (!answer || !config) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'answer and config are required'); + } + + const { model: languageModel, modelString } = resolveModelFromHeaders(req); + + const signal = req.signal; + const thinking: ThinkingConfig = { enabled: false }; + + // Build the full interview context as the user message — same text that + // callLLM() used as its `prompt`, so context quality is unchanged. + const contextPrompt = buildInterviewTurnPrompt({ config, history, answer }); + + const storeState: StatelessChatRequest['storeState'] = { + stage: null, + scenes: [], + currentSceneId: null, + mode: 'autonomous', + whiteboardOpen: false, + }; + + // ── Coach turn ──────────────────────────────────────────────────────────── + // The coach persona tells the LLM to embed JSON in the text content so we + // can extract { good, missing, strongAnswer } from the text_delta stream. + + const coachPersona = [ + `You are a professional interview coach reviewing a ${config.role} interview answer.`, + `Difficulty: ${config.difficulty}. Interview type: ${config.interviewType}.`, + 'Analyse the candidate\'s answer and output ONLY a JSON object (no markdown, no prose):', + '{"good":["..."],"missing":["..."],"strongAnswer":"..."}', + 'Each array should contain 1-3 concise bullet points. strongAnswer is one sentence.', + ].join(' '); + + const coachRequest: StatelessChatRequest = { + messages: [{ id: 'ctx-1', role: 'user', content: contextPrompt, createdAt: new Date() }], + storeState, + config: { + agentIds: [COACH_ID], + agentConfigs: [ + { + id: COACH_ID, + name: 'Interview Coach', + role: 'coach', + persona: coachPersona, + avatar: '🎓', + color: '#4CAF50', + allowedActions: [], + priority: 1, + isGenerated: true, + }, + ], + }, + apiKey: req.headers.get('x-api-key') || '', + baseUrl: req.headers.get('x-base-url') || undefined, + model: modelString, + }; + + log.info(`Coach turn — role: ${config.role}, difficulty: ${config.difficulty}`); + const coachText = await collectText(statelessGenerate(coachRequest, signal, languageModel, thinking)); + const feedback = extractJson(coachText); + + // ── Interviewer turn ────────────────────────────────────────────────────── + const interviewerPersona = [ + `You are an experienced ${config.role} interviewer conducting a ${config.difficulty} ${config.interviewType} interview.`, + 'Based on the conversation so far, ask exactly one natural follow-up question.', + 'Do NOT repeat a question already asked. Output the question only — no preamble, no JSON.', + ].join(' '); + + const interviewerRequest: StatelessChatRequest = { + messages: [{ id: 'ctx-1', role: 'user', content: contextPrompt, createdAt: new Date() }], + storeState, + config: { + agentIds: [INTERVIEWER_ID], + agentConfigs: [ + { + id: INTERVIEWER_ID, + name: 'Interviewer', + role: 'interviewer', + persona: interviewerPersona, + avatar: '👔', + color: '#2196F3', + allowedActions: [], + priority: 1, + isGenerated: true, + }, + ], + }, + apiKey: req.headers.get('x-api-key') || '', + baseUrl: req.headers.get('x-base-url') || undefined, + model: modelString, + }; + + log.info(`Interviewer turn — role: ${config.role}`); + const nextQuestion = await collectText( + statelessGenerate(interviewerRequest, signal, languageModel, thinking), + ); + + log.info( + `Interview turn complete — feedback keys: ${Object.keys(feedback).join(',')}, question length: ${nextQuestion.length}`, + ); + + return apiSuccess({ feedback, nextQuestion }); + } catch (error) { + log.error('Error:', error); + return apiError( + 'INTERNAL_ERROR', + 500, + error instanceof Error ? error.message : String(error), + ); + } +} diff --git a/app/api/quiz-grade/route.ts b/app/api/quiz-grade/route.ts index d0aab62e..ad7543bd 100644 --- a/app/api/quiz-grade/route.ts +++ b/app/api/quiz-grade/route.ts @@ -38,12 +38,17 @@ export async function POST(req: NextRequest) { const { model: languageModel } = resolveModelFromHeaders(req); const isZh = language === 'zh-CN'; + const isHi = language === 'hi-IN'; const systemPrompt = isZh ? `你是一位专业的教育评估专家。请根据题目和学生答案进行评分并给出简短评语。 必须以如下 JSON 格式回复(不要包含其他内容): {"score": <0到${points}的整数>, "comment": "<一两句评语>"}` - : `You are a professional educational assessor. Grade the student's answer and provide brief feedback. + : isHi + ? `आप एक अनुभवी educational assessor हैं। प्रश्न और छात्र के उत्तर के आधार पर score दें और छोटा feedback दें। +केवल इसी JSON format में उत्तर दें: +{"score": <0 से ${points} तक integer>, "comment": "<एक या दो वाक्यों का feedback>"}` + : `You are a professional educational assessor. Grade the student's answer and provide brief feedback. You must reply in the following JSON format only (no other content): {"score": , "comment": ""}`; @@ -51,7 +56,11 @@ You must reply in the following JSON format only (no other content): ? `题目:${question} 满分:${points}分 ${commentPrompt ? `评分要点:${commentPrompt}\n` : ''}学生答案:${userAnswer}` - : `Question: ${question} + : isHi + ? `प्रश्न: ${question} +पूर्ण अंक: ${points} +${commentPrompt ? `ग्रेडिंग गाइडेंस: ${commentPrompt}\n` : ''}छात्र का उत्तर: ${userAnswer}` + : `Question: ${question} Full marks: ${points} points ${commentPrompt ? `Grading guidance: ${commentPrompt}\n` : ''}Student answer: ${userAnswer}`; @@ -83,7 +92,9 @@ ${commentPrompt ? `Grading guidance: ${commentPrompt}\n` : ''}Student answer: ${ score: Math.round(points * 0.5), comment: isZh ? '已作答,请参考标准答案。' - : 'Answer received. Please refer to the standard answer.', + : isHi + ? 'उत्तर प्राप्त हुआ। कृपया standard answer देखें।' + : 'Answer received. Please refer to the standard answer.', }; } diff --git a/app/api/quiz/debrief/route.ts b/app/api/quiz/debrief/route.ts new file mode 100644 index 00000000..2e36911a --- /dev/null +++ b/app/api/quiz/debrief/route.ts @@ -0,0 +1,23 @@ +import { NextRequest } from 'next/server'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { buildPlacementDebriefPrompt } from '@/lib/quiz/prompts'; +import { callQuizLLM } from '@/lib/quiz/llm'; + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as { summary: string }; + const result = await callQuizLLM( + req, + 'You are a supportive coach. Return strict JSON only.', + buildPlacementDebriefPrompt(body.summary), + 'quiz-debrief', + ); + return apiSuccess(JSON.parse(result.text.trim().match(/\{[\s\S]*\}/)?.[0] || '{}')); + } catch (error) { + return apiError( + 'INTERNAL_ERROR', + 500, + error instanceof Error ? error.message : 'Failed to generate debrief', + ); + } +} diff --git a/app/api/quiz/explain/route.ts b/app/api/quiz/explain/route.ts new file mode 100644 index 00000000..6ee357e6 --- /dev/null +++ b/app/api/quiz/explain/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from 'next/server'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { callQuizLLM } from '@/lib/quiz/llm'; + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as { prompt: string }; + const result = await callQuizLLM( + req, + 'You explain wrong quiz answers clearly and briefly. Return plain text only.', + body.prompt, + 'quiz-explain', + ); + return apiSuccess({ explanation: result.text.trim() }); + } catch (error) { + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Failed'); + } +} diff --git a/app/api/quiz/generate/route.ts b/app/api/quiz/generate/route.ts new file mode 100644 index 00000000..3a79f653 --- /dev/null +++ b/app/api/quiz/generate/route.ts @@ -0,0 +1,40 @@ +import { NextRequest } from 'next/server'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { buildCodingQuizPrompt, buildPlacementQuizPrompt } from '@/lib/quiz/prompts'; +import { parseQuizSession } from '@/lib/quiz/question-parser'; +import { callQuizLLM } from '@/lib/quiz/llm'; + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as Record; + const track = body.track; + + const prompt = + track === 'coding-examination' + ? buildCodingQuizPrompt({ + language: (body.language as 'python' | 'java' | 'cpp' | 'javascript') || 'python', + difficulty: (body.difficulty as 'easy' | 'medium' | 'hard') || 'medium', + }) + : buildPlacementQuizPrompt({ + company: body.company || 'General', + difficulty: (body.difficulty as 'easy' | 'medium' | 'hard') || 'medium', + language: body.locale || 'English', + }); + + const result = await callQuizLLM( + req, + 'You generate interview-style quizzes in strict JSON. No markdown.', + prompt, + 'quiz-generate', + ); + + const session = parseQuizSession(result.text.trim()); + return apiSuccess({ ...session }); + } catch (error) { + return apiError( + 'INTERNAL_ERROR', + 500, + error instanceof Error ? error.message : 'Failed to generate quiz', + ); + } +} diff --git a/app/api/quiz/review-code/route.ts b/app/api/quiz/review-code/route.ts new file mode 100644 index 00000000..a03290b7 --- /dev/null +++ b/app/api/quiz/review-code/route.ts @@ -0,0 +1,38 @@ +import { NextRequest } from 'next/server'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { buildCodeReviewPrompt } from '@/lib/quiz/prompts'; +import { callQuizLLM } from '@/lib/quiz/llm'; + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as { + title: string; + prompt: string; + code: string; + language: 'python' | 'java' | 'cpp' | 'javascript'; + }; + const result = await callQuizLLM( + req, + 'You are a senior interviewer reviewing coding solutions. Return strict JSON only.', + buildCodeReviewPrompt(body), + 'quiz-review-code', + ); + const jsonMatch = result.text.trim().match(/\{[\s\S]*\}/); + if (!jsonMatch) { + return apiError('INVALID_RESPONSE', 502, 'Reviewer returned no JSON'); + } + const parsed = JSON.parse(jsonMatch[0]); + if (typeof parsed.score !== 'number' || !parsed.verdict) { + return apiError('INVALID_RESPONSE', 502, 'Reviewer response missing required fields'); + } + parsed.score = Math.max(0, Math.min(10, Math.round(parsed.score))); + parsed.verdict = parsed.score >= 5 ? 'pass' : 'fail'; + return apiSuccess(parsed); + } catch (error) { + return apiError( + 'INTERNAL_ERROR', + 500, + error instanceof Error ? error.message : 'Failed to review code', + ); + } +} diff --git a/app/interview/page.tsx b/app/interview/page.tsx new file mode 100644 index 00000000..7c885bbc --- /dev/null +++ b/app/interview/page.tsx @@ -0,0 +1,18 @@ +import { InterviewDashboard } from '@/components/interview/interview-dashboard'; + +export default function InterviewPage() { + return ( +
+
+
+

Interview Prep

+

Realistic Interview Simulation

+

+ Practice technical and HR rounds with a natural AI interviewer, live feedback, voice input, and final coaching. +

+
+ +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 80dfbd85..992e433b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -20,6 +20,7 @@ import { ChevronUp, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; +import { detectPreferredLocale, isSupportedLocale } from '@/lib/utils/language'; import { createLogger } from '@/lib/logger'; import { Button } from '@/components/ui/button'; import { Textarea as UITextarea } from '@/components/ui/textarea'; @@ -56,7 +57,7 @@ const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; interface FormState { pdfFile: File | null; requirement: string; - language: 'zh-CN' | 'en-US'; + language: 'zh-CN' | 'en-US' | 'hi-IN'; webSearch: boolean; } @@ -101,11 +102,10 @@ function HomePage() { const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY); const updates: Partial = {}; if (savedWebSearch === 'true') updates.webSearch = true; - if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') { + if (isSupportedLocale(savedLanguage)) { updates.language = savedLanguage; } else { - const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; - updates.language = detected; + updates.language = detectPreferredLocale(navigator.language); } if (Object.keys(updates).length > 0) { setForm((prev) => ({ ...prev, ...updates })); @@ -338,7 +338,7 @@ function HomePage() { }} className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all" > - {locale === 'zh-CN' ? 'CN' : 'EN'} + {locale === 'zh-CN' ? 'CN' : locale === 'hi-IN' ? 'HI' : 'EN'} {languageOpen && (
@@ -355,6 +355,19 @@ function HomePage() { > 简体中文 + + + + {/* ── Unified input area ── */} +
+
+

Quiz Mode

+

Placement + Coding Practice

+

+ Practice company-style aptitude rounds or timed coding examinations without creating a classroom first. +

+
+ +
+ + ); +} diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index 27301bbd..f7ce9ce0 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -28,8 +28,8 @@ const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024; // ─── Types ─────────────────────────────────────────────────── export interface GenerationToolbarProps { - language: 'zh-CN' | 'en-US'; - onLanguageChange: (lang: 'zh-CN' | 'en-US') => void; + language: 'zh-CN' | 'en-US' | 'hi-IN'; + onLanguageChange: (lang: 'zh-CN' | 'en-US' | 'hi-IN') => void; webSearch: boolean; onWebSearchChange: (v: boolean) => void; onSettingsOpen: (section?: SettingsSection) => void; @@ -361,11 +361,15 @@ export function GenerationToolbar({ {t('toolbar.languageHint')} diff --git a/components/header.tsx b/components/header.tsx index 77a63b03..23548e91 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -113,7 +113,7 @@ export function Header({ currentSceneTitle }: HeaderProps) { }} className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all" > - {locale === 'zh-CN' ? 'CN' : 'EN'} + {locale === 'zh-CN' ? 'CN' : locale === 'hi-IN' ? 'HI' : 'EN'} {languageOpen && (
@@ -130,6 +130,19 @@ export function Header({ currentSceneTitle }: HeaderProps) { > 简体中文 + +
+ ); +} diff --git a/components/interview/interview-dashboard.tsx b/components/interview/interview-dashboard.tsx new file mode 100644 index 00000000..ca6f73a2 --- /dev/null +++ b/components/interview/interview-dashboard.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useState } from 'react'; +import { InterviewConfigForm } from './interview-config-form'; +import { InterviewHistory } from './interview-history'; +import { InterviewSession } from './interview-session'; +import { getInterviewHistory, saveInterviewHistory } from '@/lib/interview/storage'; +import type { InterviewConfig, InterviewSessionSummary, InterviewTurn } from '@/lib/interview/types'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import { getCurrentModelConfig } from '@/lib/utils/model-config'; + +function modelHeaders() { + const modelConfig = getCurrentModelConfig(); + const headers: Record = { + 'Content-Type': 'application/json', + 'x-model': modelConfig.modelString, + 'x-api-key': modelConfig.apiKey, + }; + if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl; + if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType; + if (modelConfig.requiresApiKey) headers['x-requires-api-key'] = 'true'; + return headers; +} + +export function InterviewDashboard() { + const { locale } = useI18n(); + const [config, setConfig] = useState({ + interviewType: 'both', + role: 'software-engineer', + difficulty: 'fresher', + language: locale, + }); + const [loading, setLoading] = useState(false); + const [openingQuestion, setOpeningQuestion] = useState(null); + const history = getInterviewHistory(); + + const startInterview = async () => { + setLoading(true); + try { + const response = await fetch('/api/interview/session', { + method: 'POST', + headers: modelHeaders(), + body: JSON.stringify({ ...config, language: locale }), + }); + const data = await response.json(); + setOpeningQuestion((data.data || data).question || 'Tell me about yourself.'); + } finally { + setLoading(false); + } + }; + + const handleComplete = (summary: InterviewSessionSummary, _turns: InterviewTurn[]) => { + saveInterviewHistory({ + id: crypto.randomUUID(), + config: { ...config, language: locale }, + createdAt: Date.now(), + summary, + }); + }; + + return ( +
+ {openingQuestion ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/components/interview/interview-feedback-card.tsx b/components/interview/interview-feedback-card.tsx new file mode 100644 index 00000000..9efbbb57 --- /dev/null +++ b/components/interview/interview-feedback-card.tsx @@ -0,0 +1,38 @@ +'use client'; + +import type { InterviewTurn } from '@/lib/interview/types'; + +export function InterviewFeedbackCard({ turn }: { turn?: InterviewTurn }) { + if (!turn?.feedback) { + return ( +
+

Live Feedback

+

Answer a question to get instant AI feedback.

+
+ ); + } + + return ( +
+

Live Feedback

+
+
+

What was good

+
    + {turn.feedback.good.map((item) =>
  • {item}
  • )} +
+
+
+

What was missing

+
    + {turn.feedback.missing.map((item) =>
  • {item}
  • )} +
+
+
+

What a strong answer looks like

+

{turn.feedback.strongAnswer}

+
+
+
+ ); +} diff --git a/components/interview/interview-history.tsx b/components/interview/interview-history.tsx new file mode 100644 index 00000000..f1fd1540 --- /dev/null +++ b/components/interview/interview-history.tsx @@ -0,0 +1,26 @@ +'use client'; + +import type { InterviewHistoryItem } from '@/lib/interview/types'; + +export function InterviewHistory({ items }: { items: InterviewHistoryItem[] }) { + return ( +
+

Past Sessions

+
+ {items.length === 0 ? ( +

No interview sessions yet.

+ ) : ( + items.map((item) => ( +
+
+ {item.config.role.replace(/-/g, ' ')} + {item.summary.overallScore} +
+

{new Date(item.createdAt).toLocaleString()}

+
+ )) + )} +
+
+ ); +} diff --git a/components/interview/interview-score-breakdown.tsx b/components/interview/interview-score-breakdown.tsx new file mode 100644 index 00000000..8d69bff9 --- /dev/null +++ b/components/interview/interview-score-breakdown.tsx @@ -0,0 +1,19 @@ +'use client'; + +export function InterviewScoreBreakdown({ communication, technical }: { communication: number; technical: number }) { + return ( +
+

Score Breakdown

+
+
+
Communication{communication}/10
+
+
+
+
Technical Accuracy{technical}/10
+
+
+
+
+ ); +} diff --git a/components/interview/interview-session.tsx b/components/interview/interview-session.tsx new file mode 100644 index 00000000..1b9fedd7 --- /dev/null +++ b/components/interview/interview-session.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useState } from 'react'; +import { SpeechButton } from '@/components/audio/speech-button'; +import type { InterviewConfig, InterviewSessionSummary, InterviewTurn } from '@/lib/interview/types'; +import { InterviewChatPanel } from './interview-chat-panel'; +import { InterviewFeedbackCard } from './interview-feedback-card'; +import { InterviewSummary } from './interview-summary'; +import { InterviewScoreBreakdown } from './interview-score-breakdown'; +import { buildInterviewWhiteboardNotes } from '@/lib/interview/session'; +import { getCurrentModelConfig } from '@/lib/utils/model-config'; + +function modelHeaders() { + const modelConfig = getCurrentModelConfig(); + const headers: Record = { + 'Content-Type': 'application/json', + 'x-model': modelConfig.modelString, + 'x-api-key': modelConfig.apiKey, + }; + if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl; + if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType; + if (modelConfig.requiresApiKey) headers['x-requires-api-key'] = 'true'; + return headers; +} + +export function InterviewSession({ config, openingQuestion, onComplete }: { config: InterviewConfig; openingQuestion: string; onComplete: (summary: InterviewSessionSummary, turns: InterviewTurn[]) => void }) { + const [turns, setTurns] = useState([{ id: crypto.randomUUID(), question: openingQuestion }]); + const [answer, setAnswer] = useState(''); + const [loading, setLoading] = useState(false); + const [summary, setSummary] = useState(null); + + const activeTurn = turns[turns.length - 1]; + const questionCount = turns.length; + + const submitAnswer = async () => { + if (!answer.trim() || !activeTurn) return; + setLoading(true); + try { + const history = turns.map((turn) => ({ question: turn.question, answer: turn.answer })); + const response = await fetch('/api/interview/turn', { + method: 'POST', + headers: modelHeaders(), + body: JSON.stringify({ config, history, answer }), + }); + const data = await response.json(); + const payload = data.data || data; + const updatedTurns = turns.map((turn) => + turn.id === activeTurn.id + ? { + ...turn, + answer, + feedback: payload.feedback, + } + : turn, + ); + const nextTurns = payload.nextQuestion + ? [...updatedTurns, { id: crypto.randomUUID(), question: payload.nextQuestion }] + : updatedTurns; + setTurns(nextTurns); + setAnswer(''); + } finally { + setLoading(false); + } + }; + + const finishInterview = async () => { + setLoading(true); + try { + const response = await fetch('/api/interview/debrief', { + method: 'POST', + headers: modelHeaders(), + body: JSON.stringify({ + config, + turns: turns.map((turn) => ({ question: turn.question, answer: turn.answer })), + }), + }); + const data = await response.json(); + const payload = data.data || data; + setSummary(payload); + onComplete(payload, turns); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ turn.feedback && turn.id !== activeTurn?.id) || turns.at(-2)} /> +
+

Answer the current question

+