From 6528301bb3803f6203174cd43cdbfe48cd024df5 Mon Sep 17 00:00:00 2001 From: Siddharth <85218322+Sid3548@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:44:38 +0530 Subject: [PATCH 1/3] Refine quiz and interview color palette --- app/api/generate/scene-content/route.ts | 2 +- .../generate/scene-outlines-stream/route.ts | 15 +- app/api/interview/debrief/route.ts | 27 +++ app/api/interview/session/route.ts | 24 +++ app/api/interview/turn/route.ts | 28 +++ app/api/quiz-grade/route.ts | 17 +- app/api/quiz/debrief/route.ts | 23 +++ app/api/quiz/explain/route.ts | 18 ++ app/api/quiz/generate/route.ts | 40 ++++ app/api/quiz/review-code/route.ts | 28 +++ app/interview/page.tsx | 18 ++ app/page.tsx | 43 +++- app/quiz/page.tsx | 18 ++ components/generation/generation-toolbar.tsx | 12 +- components/header.tsx | 15 +- components/interview/interview-chat-panel.tsx | 20 ++ .../interview/interview-config-form.tsx | 39 ++++ components/interview/interview-dashboard.tsx | 71 +++++++ .../interview/interview-feedback-card.tsx | 38 ++++ components/interview/interview-history.tsx | 26 +++ .../interview/interview-score-breakdown.tsx | 19 ++ components/interview/interview-session.tsx | 130 ++++++++++++ components/interview/interview-summary.tsx | 29 +++ components/quiz/code-editor.tsx | 20 ++ components/quiz/code-problem-card.tsx | 45 ++++ components/quiz/code-review-panel.tsx | 30 +++ components/quiz/coding-quiz-config.tsx | 47 +++++ components/quiz/placement-quiz-config.tsx | 44 ++++ components/quiz/quiz-category-card.tsx | 19 ++ components/quiz/quiz-config-form.tsx | 18 ++ components/quiz/quiz-dashboard.tsx | 130 ++++++++++++ components/quiz/quiz-results.tsx | 36 ++++ components/quiz/quiz-runner.tsx | 192 ++++++++++++++++++ components/quiz/quiz-timer.tsx | 37 ++++ components/quiz/recent-history.tsx | 26 +++ components/quiz/suggested-next-quiz.tsx | 11 + components/quiz/weak-areas-tracker.tsx | 31 +++ components/scene-renderers/quiz-view.tsx | 4 +- components/settings/audio-settings.tsx | 1 + components/settings/general-settings.tsx | 5 + .../settings/hindi/hindi-audio-hints.tsx | 24 +++ .../settings/hindi/language-selector.tsx | 69 +++++++ components/settings/index.tsx | 1 + components/settings/tts-settings.tsx | 3 + lib/audio/constants.ts | 29 +++ lib/audio/tts-providers.ts | 56 ++++- lib/audio/types.ts | 1 + lib/generation/outline-generator.ts | 21 +- lib/generation/prompt-formatters.ts | 21 +- lib/generation/scene-generator.ts | 9 +- lib/hooks/use-audio-recorder.ts | 148 +++++++------- lib/hooks/use-i18n.tsx | 11 +- lib/i18n/hi-IN/chat.ts | 80 ++++++++ lib/i18n/hi-IN/common.ts | 47 +++++ lib/i18n/hi-IN/generation.ts | 60 ++++++ lib/i18n/hi-IN/index.ts | 5 + lib/i18n/hi-IN/settings.ts | 80 ++++++++ lib/i18n/hi-IN/stage.ts | 85 ++++++++ lib/i18n/index.ts | 11 +- lib/i18n/settings.ts | 4 + lib/i18n/types.ts | 4 +- lib/interview/constants.ts | 1 + lib/interview/history.ts | 1 + lib/interview/prompts.ts | 50 +++++ lib/interview/scoring.ts | 3 + lib/interview/session.ts | 6 + lib/interview/storage.ts | 19 ++ lib/interview/types.ts | 36 ++++ lib/pbl/mcp/agent-templates.ts | 29 +++ lib/pbl/pbl-system-prompt.ts | 34 ++++ lib/quiz/constants.ts | 30 +++ lib/quiz/history.ts | 4 + lib/quiz/llm.ts | 8 + lib/quiz/prompts.ts | 91 +++++++++ lib/quiz/question-parser.ts | 7 + lib/quiz/scoring.ts | 31 +++ lib/quiz/storage.ts | 69 +++++++ lib/quiz/timer.ts | 14 ++ lib/quiz/types.ts | 97 +++++++++ lib/server/classroom-generation.ts | 5 +- lib/server/provider-config.ts | 1 + lib/store/settings.ts | 1 + lib/types/generation.ts | 6 +- lib/utils/language.ts | 60 ++++++ package.json | 2 + pnpm-lock.yaml | 60 ++++++ 86 files changed, 2711 insertions(+), 119 deletions(-) create mode 100644 app/api/interview/debrief/route.ts create mode 100644 app/api/interview/session/route.ts create mode 100644 app/api/interview/turn/route.ts create mode 100644 app/api/quiz/debrief/route.ts create mode 100644 app/api/quiz/explain/route.ts create mode 100644 app/api/quiz/generate/route.ts create mode 100644 app/api/quiz/review-code/route.ts create mode 100644 app/interview/page.tsx create mode 100644 app/quiz/page.tsx create mode 100644 components/interview/interview-chat-panel.tsx create mode 100644 components/interview/interview-config-form.tsx create mode 100644 components/interview/interview-dashboard.tsx create mode 100644 components/interview/interview-feedback-card.tsx create mode 100644 components/interview/interview-history.tsx create mode 100644 components/interview/interview-score-breakdown.tsx create mode 100644 components/interview/interview-session.tsx create mode 100644 components/interview/interview-summary.tsx create mode 100644 components/quiz/code-editor.tsx create mode 100644 components/quiz/code-problem-card.tsx create mode 100644 components/quiz/code-review-panel.tsx create mode 100644 components/quiz/coding-quiz-config.tsx create mode 100644 components/quiz/placement-quiz-config.tsx create mode 100644 components/quiz/quiz-category-card.tsx create mode 100644 components/quiz/quiz-config-form.tsx create mode 100644 components/quiz/quiz-dashboard.tsx create mode 100644 components/quiz/quiz-results.tsx create mode 100644 components/quiz/quiz-runner.tsx create mode 100644 components/quiz/quiz-timer.tsx create mode 100644 components/quiz/recent-history.tsx create mode 100644 components/quiz/suggested-next-quiz.tsx create mode 100644 components/quiz/weak-areas-tracker.tsx create mode 100644 components/settings/hindi/hindi-audio-hints.tsx create mode 100644 components/settings/hindi/language-selector.tsx create mode 100644 lib/i18n/hi-IN/chat.ts create mode 100644 lib/i18n/hi-IN/common.ts create mode 100644 lib/i18n/hi-IN/generation.ts create mode 100644 lib/i18n/hi-IN/index.ts create mode 100644 lib/i18n/hi-IN/settings.ts create mode 100644 lib/i18n/hi-IN/stage.ts create mode 100644 lib/interview/constants.ts create mode 100644 lib/interview/history.ts create mode 100644 lib/interview/prompts.ts create mode 100644 lib/interview/scoring.ts create mode 100644 lib/interview/session.ts create mode 100644 lib/interview/storage.ts create mode 100644 lib/interview/types.ts create mode 100644 lib/quiz/constants.ts create mode 100644 lib/quiz/history.ts create mode 100644 lib/quiz/llm.ts create mode 100644 lib/quiz/prompts.ts create mode 100644 lib/quiz/question-parser.ts create mode 100644 lib/quiz/scoring.ts create mode 100644 lib/quiz/storage.ts create mode 100644 lib/quiz/timer.ts create mode 100644 lib/quiz/types.ts create mode 100644 lib/utils/language.ts 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..8ea8332c --- /dev/null +++ b/app/api/interview/turn/route.ts @@ -0,0 +1,28 @@ +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 { buildInterviewTurnPrompt } 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; + history: Array<{ question: string; answer?: string }>; + answer: string; + }; + const { model } = resolveModelFromHeaders(req); + const result = await callLLM( + { + model, + system: 'You are a natural interviewer and coach. Return JSON only.', + prompt: buildInterviewTurnPrompt(body), + }, + 'interview-turn', + ); + 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/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..b65a3a59 --- /dev/null +++ b/app/api/quiz/review-code/route.ts @@ -0,0 +1,28 @@ +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', + ); + 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 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

+