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..d2bfb3c4 --- /dev/null +++ b/app/api/interview/debrief/route.ts @@ -0,0 +1,59 @@ +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'; +import { parseFirstJsonObject } from '@/lib/server/json-parser'; + +function clampTenPointScore(value: unknown) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 0; + return Math.max(0, Math.min(10, Math.round(numeric))); +} + +function clampHundredPointScore(value: unknown) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 0; + return Math.max(0, Math.min(100, Math.round(numeric))); +} + +function normalizeInterviewDebriefResult(input: Record) { + const topImprovements = Array.isArray(input.topImprovements) + ? input.topImprovements.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + : []; + const summary = typeof input.summary === 'string' ? input.summary.trim() : ''; + + if (!summary) { + throw new Error('Interview debrief response did not include a summary'); + } + + return { + overallScore: clampHundredPointScore(input.overallScore), + communicationRating: clampTenPointScore(input.communicationRating), + technicalAccuracyRating: clampTenPointScore(input.technicalAccuracyRating), + topImprovements, + summary, + }; +} + +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(normalizeInterviewDebriefResult(parseFirstJsonObject>(result.text))); + } 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..5c7ab4a6 --- /dev/null +++ b/app/api/interview/session/route.ts @@ -0,0 +1,33 @@ +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'; +import { parseFirstJsonObject } from '@/lib/server/json-parser'; + +function normalizeInterviewSessionResult(input: Record) { + const question = typeof input.question === 'string' ? input.question.trim() : ''; + if (!question) { + throw new Error('Interview session response did not include a valid question'); + } + return { question }; +} + +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(normalizeInterviewSessionResult(parseFirstJsonObject>(result.text))); + } 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..c30a9a84 --- /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', parts: [{ type: 'text', text: contextPrompt }] }], + 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', parts: [{ type: 'text', text: contextPrompt }] }], + 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..a0968e97 100644 --- a/app/api/quiz-grade/route.ts +++ b/app/api/quiz-grade/route.ts @@ -10,6 +10,7 @@ import { callLLM } from '@/lib/ai/llm'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; +import { parseFirstJsonObject } from '@/lib/server/json-parser'; const log = createLogger('Quiz Grade'); interface GradeRequest { @@ -38,12 +39,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 +57,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}`; @@ -66,28 +76,24 @@ ${commentPrompt ? `Grading guidance: ${commentPrompt}\n` : ''}Student answer: ${ // Parse the LLM response as JSON const text = result.text.trim(); - let gradeResult: GradeResponse; - try { - // Try to extract JSON from the response - const jsonMatch = text.match(/\{[\s\S]*\}/); - if (!jsonMatch) throw new Error('No JSON found'); - const parsed = JSON.parse(jsonMatch[0]); - gradeResult = { + const parsed = parseFirstJsonObject>(text); + const gradeResult: GradeResponse = { score: Math.max(0, Math.min(points, Math.round(Number(parsed.score)))), comment: String(parsed.comment || ''), }; + return apiSuccess({ ...gradeResult }); } catch { - // Fallback: give partial credit with a generic comment - gradeResult = { - score: Math.round(points * 0.5), - comment: isZh - ? '已作答,请参考标准答案。' - : 'Answer received. Please refer to the standard answer.', - }; + return apiError( + 'INVALID_RESPONSE', + 502, + isZh + ? '评分服务返回了无效数据。' + : isHi + ? 'ग्रेडिंग सेवा ने अमान्य डेटा लौटाया।' + : 'The grading service returned invalid data.', + ); } - - return apiSuccess({ ...gradeResult }); } catch (error) { log.error('Error:', error); return apiError('INTERNAL_ERROR', 500, 'Failed to grade answer'); diff --git a/app/api/quiz/debrief/route.ts b/app/api/quiz/debrief/route.ts new file mode 100644 index 00000000..7d518c02 --- /dev/null +++ b/app/api/quiz/debrief/route.ts @@ -0,0 +1,24 @@ +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'; +import { parseFirstJsonObject } from '@/lib/server/json-parser'; + +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(parseFirstJsonObject>(result.text)); + } 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..a3466452 --- /dev/null +++ b/app/api/quiz/generate/route.ts @@ -0,0 +1,55 @@ +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'; +import type { QuizSession } from '@/lib/quiz/types'; + +function validateQuizSession(session: QuizSession) { + if (session.track === 'placement-aptitude') { + if (!Array.isArray(session.questions) || session.questions.length === 0) { + throw new Error('Quiz generation returned no placement questions'); + } + return session; + } + + if (!Array.isArray(session.problems) || session.problems.length === 0) { + throw new Error('Quiz generation returned no coding problems'); + } + return session; +} + +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 = validateQuizSession(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/api/tts/voices/route.ts b/app/api/tts/voices/route.ts new file mode 100644 index 00000000..b80ca65b --- /dev/null +++ b/app/api/tts/voices/route.ts @@ -0,0 +1,183 @@ +/** + * TTS Voices API + * + * Fetches available voices from a TTS provider dynamically. + * + * Strategy (for OpenAI-compatible providers): + * 1. Try GET {baseUrl}/audio/voices — some OpenAI-compatible proxies expose this + * 2. Fall back to GET {baseUrl}/models, filter for TTS models, map to known voices + * + * POST /api/tts/voices + */ + +import { NextRequest } from 'next/server'; +import { createLogger } from '@/lib/logger'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { resolveTTSApiKey, resolveTTSBaseUrl } from '@/lib/server/provider-config'; +import { TTS_PROVIDERS } from '@/lib/audio/constants'; + +const log = createLogger('TTS Voices'); + +export const maxDuration = 15; + +/** + * Voices available per known OpenAI TTS model. + * Used when the provider doesn't expose a /audio/voices endpoint. + */ +const OPENAI_TTS_MODEL_VOICES: Record = { + 'tts-1': [ + { id: 'alloy', name: 'Alloy' }, + { id: 'echo', name: 'Echo' }, + { id: 'fable', name: 'Fable' }, + { id: 'nova', name: 'Nova' }, + { id: 'onyx', name: 'Onyx' }, + { id: 'shimmer', name: 'Shimmer' }, + ], + 'tts-1-hd': [ + { id: 'alloy', name: 'Alloy' }, + { id: 'echo', name: 'Echo' }, + { id: 'fable', name: 'Fable' }, + { id: 'nova', name: 'Nova' }, + { id: 'onyx', name: 'Onyx' }, + { id: 'shimmer', name: 'Shimmer' }, + ], + 'gpt-4o-mini-tts': [ + { id: 'alloy', name: 'Alloy' }, + { id: 'ash', name: 'Ash' }, + { id: 'ballad', name: 'Ballad' }, + { id: 'cedar', name: 'Cedar' }, + { id: 'coral', name: 'Coral' }, + { id: 'echo', name: 'Echo' }, + { id: 'fable', name: 'Fable' }, + { id: 'marin', name: 'Marin' }, + { id: 'nova', name: 'Nova' }, + { id: 'onyx', name: 'Onyx' }, + { id: 'sage', name: 'Sage' }, + { id: 'shimmer', name: 'Shimmer' }, + { id: 'verse', name: 'Verse' }, + ], +}; + +export async function POST(req: NextRequest) { + try { + const { providerId, apiKey: clientApiKey, baseUrl: clientBaseUrl } = await req.json(); + + if (!providerId) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'providerId is required'); + } + + if (clientBaseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = validateUrlForSSRF(clientBaseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + } + + const providerDef = TTS_PROVIDERS[providerId as keyof typeof TTS_PROVIDERS]; + const apiKey = clientBaseUrl + ? clientApiKey || '' + : resolveTTSApiKey(providerId, clientApiKey || undefined); + const baseUrl = + clientBaseUrl || + resolveTTSBaseUrl(providerId, undefined) || + providerDef?.defaultBaseUrl || + ''; + + if (!apiKey) { + return apiError('MISSING_API_KEY', 400, 'API key is required to fetch voices'); + } + + if (!baseUrl) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Base URL could not be determined'); + } + + const authHeader = { Authorization: `Bearer ${apiKey}` }; + + // Step 1: Try /audio/voices — supported by some OpenAI-compatible proxies + try { + const voicesRes = await fetch(`${baseUrl}/audio/voices`, { + headers: authHeader, + redirect: 'manual', + }); + if (voicesRes.ok) { + const data = await voicesRes.json(); + const rawVoices: unknown[] = Array.isArray(data) ? data : (data.voices ?? []); + const voices = rawVoices + .filter( + (v): v is { id: string; name?: string } => + typeof v === 'object' && v !== null && typeof (v as { id?: unknown }).id === 'string', + ) + .map((v) => ({ id: v.id, name: v.name || v.id })); + if (voices.length > 0) { + log.info(`Fetched ${voices.length} voices from /audio/voices for ${providerId}`); + return apiSuccess({ voices, source: 'audio/voices' }); + } + } + } catch { + // Not supported — fall through to /models + } + + // Step 2: GET /models, filter for TTS-capable models, merge their known voices + const modelsRes = await fetch(`${baseUrl}/models`, { + headers: authHeader, + redirect: 'manual', + }); + + if (modelsRes.status >= 300 && modelsRes.status < 400) { + return apiError('REDIRECT_NOT_ALLOWED', 403, 'Redirects are not allowed'); + } + + if (!modelsRes.ok) { + const errorText = await modelsRes.text().catch(() => modelsRes.statusText); + return apiError( + 'UPSTREAM_ERROR', + modelsRes.status, + 'Failed to fetch models from provider', + errorText, + ); + } + + const modelsData = await modelsRes.json(); + const allModels: { id: string }[] = modelsData.data ?? modelsData.models ?? []; + const ttsModels = allModels.filter((m) => /tts/i.test(m.id)); + + // Collect voices from all accessible TTS models, deduplicated by voice id + const voiceMap = new Map(); + for (const model of ttsModels) { + const known = OPENAI_TTS_MODEL_VOICES[model.id]; + if (known) { + for (const v of known) { + if (!voiceMap.has(v.id)) voiceMap.set(v.id, v); + } + } + } + + if (voiceMap.size === 0) { + return apiError( + 'INVALID_RESPONSE', + 404, + ttsModels.length > 0 + ? `TTS models found (${ttsModels.map((m) => m.id).join(', ')}) but no known voices — voices may be custom` + : 'No TTS models found in your project. Enable TTS model access in your provider settings.', + ); + } + + log.info( + `Fetched voices from /models for ${providerId}: ${ttsModels.map((m) => m.id).join(', ')}`, + ); + return apiSuccess({ + voices: Array.from(voiceMap.values()), + models: ttsModels.map((m) => m.id), + source: 'models', + }); + } catch (error) { + log.error('TTS voices error:', error); + return apiError( + 'INTERNAL_ERROR', + 500, + 'Failed to fetch voices', + error instanceof Error ? error.message : String(error), + ); + } +} diff --git a/app/globals.css b/app/globals.css index 6b4718aa..05f763cf 100644 --- a/app/globals.css +++ b/app/globals.css @@ -54,7 +54,7 @@ --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); - --primary: #722ed1; + --primary: #1d7be8; --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); @@ -89,8 +89,8 @@ --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); - --primary: #8b47ea; - --primary-foreground: oklch(0.205 0 0); + --primary: #3b9eff; + --primary-foreground: oklch(0.145 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); @@ -141,12 +141,12 @@ 0%, 100% { box-shadow: - 0 0 0 0 rgba(139, 92, 246, 0.4), - 0 0 8px rgba(139, 92, 246, 0.15); + 0 0 0 0 rgba(14, 165, 233, 0.4), + 0 0 8px rgba(14, 165, 233, 0.15); } 50% { box-shadow: - 0 0 0 6px rgba(139, 92, 246, 0), + 0 0 0 6px rgba(14, 165, 233, 0), 0 0 16px rgba(59, 130, 246, 0.2); } } 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/layout.tsx b/app/layout.tsx index 271af553..08c1817a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -17,9 +17,9 @@ const inter = localFont({ }); export const metadata: Metadata = { - title: 'OpenMAIC', + title: 'OpenMAIC — AI Classroom for Every Student', description: - 'The open-source AI interactive classroom. Upload a PDF to instantly generate an immersive, multi-agent learning experience.', + 'Universal AI-powered interactive classroom. Upload a PDF to instantly generate an immersive, multi-agent learning experience for every student.', }; export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index 80dfbd85..b780a9f8 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 })); @@ -323,7 +323,7 @@ function HomePage() { }; return ( -
+
{/* ═══ Top-right pill (unchanged) ═══ */}
- {locale === 'zh-CN' ? 'CN' : 'EN'} + {locale === 'zh-CN' ? 'CN' : locale === 'hi-IN' ? 'HI' : 'EN'} {languageOpen && (
@@ -350,11 +350,24 @@ function HomePage() { className={cn( 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors', locale === 'zh-CN' && - 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', + 'bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400', )} > 简体中文 + + + + {/* ── Unified input area ── */} -
+
{/* ── Greeting + Profile + Agents ── */}
@@ -685,8 +735,10 @@ function HomePage() { )} {/* Footer — flows with content, at the very end */} -
- OpenMAIC Open Source Project +
+ OpenMAIC Open Source Project + · + AI Classroom for Every Student
); @@ -791,7 +843,7 @@ function GreetingBar() { onClick={() => setOpen(true)} >
-
+
@@ -849,7 +901,7 @@ function GreetingBar() { setAvatarPickerOpen(!avatarPickerOpen); }} > -
+
@@ -939,7 +991,7 @@ function GreetingBar() { 'size-7 rounded-full overflow-hidden bg-gray-50 dark:bg-gray-800 cursor-pointer transition-all duration-150', 'hover:scale-110 active:scale-95', avatar === url - ? 'ring-2 ring-violet-400 dark:ring-violet-500 ring-offset-0' + ? 'ring-2 ring-sky-400 dark:ring-sky-500 ring-offset-0' : 'hover:ring-1 hover:ring-muted-foreground/30', )} > @@ -951,7 +1003,7 @@ function GreetingBar() { 'size-7 rounded-full flex items-center justify-center cursor-pointer transition-all duration-150 border border-dashed', 'hover:scale-110 active:scale-95', isCustomAvatar(avatar) - ? 'ring-2 ring-violet-400 dark:ring-violet-500 ring-offset-0 border-violet-300 dark:border-violet-600 bg-violet-50 dark:bg-violet-900/30' + ? 'ring-2 ring-sky-400 dark:ring-sky-500 ring-offset-0 border-sky-300 dark:border-sky-600 bg-sky-50 dark:bg-sky-900/30' : 'border-muted-foreground/30 text-muted-foreground/50 hover:border-muted-foreground/50', )} onClick={() => avatarInputRef.current?.click()} @@ -1032,7 +1084,7 @@ function ClassroomCard({ /> ) : !slide ? (
-
+
📄
@@ -1097,7 +1149,7 @@ function ClassroomCard({ {/* Info — outside the thumbnail */}
- + {classroom.sceneCount} {t('classroom.slides')} · {formatDate(classroom.updatedAt)} diff --git a/app/quiz/page.tsx b/app/quiz/page.tsx new file mode 100644 index 00000000..94504885 --- /dev/null +++ b/app/quiz/page.tsx @@ -0,0 +1,18 @@ +import { QuizDashboard } from '@/components/quiz/quiz-dashboard'; + +export default function QuizPage() { + return ( +
+
+
+

Quiz Mode

+

Placement + Coding Practice

+

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

+
+ +
+
+ ); +} diff --git a/components/audio/tts-config-popover.tsx b/components/audio/tts-config-popover.tsx index cef2bfec..e7f7be53 100644 --- a/components/audio/tts-config-popover.tsx +++ b/components/audio/tts-config-popover.tsx @@ -16,6 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; +import { getEffectiveTTSApiKey } from '@/lib/utils/model-config'; import { getTTSVoices } from '@/lib/audio/constants'; import { useTTSPreview } from '@/lib/audio/use-tts-preview'; @@ -66,7 +67,7 @@ export function TtsConfigPopover() { providerId: ttsProviderId, voice: ttsVoice, speed: ttsSpeed, - apiKey: providerConfig?.apiKey, + apiKey: getEffectiveTTSApiKey(ttsProviderId), baseUrl: providerConfig?.baseUrl, }); } catch (error) { diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index 27301bbd..31c94ccb 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; @@ -111,7 +111,7 @@ export function GenerationToolbar({ const pillCls = 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none whitespace-nowrap border'; const pillMuted = `${pillCls} border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`; - const pillActive = `${pillCls} border-violet-200/60 dark:border-violet-700/50 bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300`; + const pillActive = `${pillCls} border-sky-200/60 dark:border-sky-700/50 bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300`; return (
@@ -156,7 +156,7 @@ export function GenerationToolbar({ {pdfFile.name} { e.stopPropagation(); onPdfFileChange(null); @@ -222,8 +222,8 @@ export function GenerationToolbar({ {pdfFile ? (
-
- +
+

{pdfFile.name}

@@ -244,8 +244,8 @@ export function GenerationToolbar({ className={cn( 'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors cursor-pointer', isDragging - ? 'border-violet-400 bg-violet-50 dark:bg-violet-950/20' - : 'border-muted-foreground/20 hover:border-violet-300', + ? 'border-sky-400 bg-sky-50 dark:bg-sky-950/20' + : 'border-muted-foreground/20 hover:border-sky-300', )} onClick={() => fileInputRef.current?.click()} onDragOver={(e) => { @@ -289,14 +289,14 @@ export function GenerationToolbar({ className={cn( 'w-full flex items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-all', webSearch - ? 'bg-violet-50 dark:bg-violet-950/20 border-violet-200 dark:border-violet-800' + ? 'bg-sky-50 dark:bg-sky-950/20 border-sky-200 dark:border-sky-800' : 'border-border hover:bg-muted/50', )} >
@@ -361,11 +361,15 @@ export function GenerationToolbar({ {t('toolbar.languageHint')} @@ -429,7 +433,7 @@ function ModelSelectorPopover({ 'inline-flex items-center justify-center size-7 rounded-full transition-all cursor-pointer select-none', 'ring-1 ring-border/60 hover:ring-border hover:bg-muted/60', currentModelId && - 'ring-violet-300 dark:ring-violet-700 bg-violet-50 dark:bg-violet-950/20', + 'ring-sky-300 dark:ring-sky-700 bg-sky-50 dark:bg-sky-950/20', )} > {currentProviderConfig?.icon ? ( @@ -468,7 +472,7 @@ function ModelSelectorPopover({ onClick={() => setDrillProvider(provider.id)} className={cn( 'w-full flex items-center gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-border/30', - isActive ? 'bg-violet-50/50 dark:bg-violet-950/10' : 'hover:bg-muted/50', + isActive ? 'bg-sky-50/50 dark:bg-sky-950/10' : 'hover:bg-muted/50', )} > {provider.icon ? ( @@ -535,13 +539,13 @@ function ModelSelectorPopover({ className={cn( 'w-full flex items-center gap-2 px-3 py-2 text-left transition-colors border-b border-border/30', isSelected - ? 'bg-violet-50 dark:bg-violet-950/20 text-violet-700 dark:text-violet-300' + ? 'bg-sky-50 dark:bg-sky-950/20 text-sky-700 dark:text-sky-300' : 'hover:bg-muted/50', )} > {model.name} {isSelected && ( - + )} ); diff --git a/components/generation/media-popover.tsx b/components/generation/media-popover.tsx index c26d52a1..20ab8d2f 100644 --- a/components/generation/media-popover.tsx +++ b/components/generation/media-popover.tsx @@ -29,6 +29,7 @@ import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; +import { getEffectiveTTSApiKey } from '@/lib/utils/model-config'; import { useTTSPreview } from '@/lib/audio/use-tts-preview'; import { IMAGE_PROVIDERS } from '@/lib/media/image-providers'; import { VIDEO_PROVIDERS } from '@/lib/media/video-providers'; @@ -268,7 +269,7 @@ export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) { providerId: ttsProviderId, voice: ttsVoice, speed: ttsSpeed, - apiKey: providerConfig?.apiKey, + apiKey: getEffectiveTTSApiKey(ttsProviderId), baseUrl: providerConfig?.baseUrl, }); } catch (error) { @@ -358,7 +359,7 @@ export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) { {tab.label} {isEnabled && !isActive && ( - + )} ); @@ -433,7 +434,7 @@ export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) { className={cn( 'inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium transition-all shrink-0', previewing - ? 'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300' + ? 'bg-sky-100 dark:bg-sky-900/40 text-sky-700 dark:text-sky-300' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground', )} > @@ -524,7 +525,7 @@ function TabPanel({ - {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..758ce465 --- /dev/null +++ b/components/interview/interview-dashboard.tsx @@ -0,0 +1,92 @@ +'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 createId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `interview-history-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +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 [error, setError] = useState(null); + const history = getInterviewHistory(); + + const startInterview = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch('/api/interview/session', { + method: 'POST', + headers: modelHeaders(), + body: JSON.stringify({ ...config, language: locale }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data?.message || data?.error?.message || 'Failed to start interview'); + } + setOpeningQuestion((data.data || data).question || 'Tell me about yourself.'); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to start interview'); + } finally { + setLoading(false); + } + }; + + const handleComplete = (summary: InterviewSessionSummary, _turns: InterviewTurn[]) => { + saveInterviewHistory({ + id: createId(), + config: { ...config, language: locale }, + createdAt: Date.now(), + summary, + }); + }; + + return ( +
+ {openingQuestion ? ( + + ) : ( +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ )} + +
+ ); +} diff --git a/components/interview/interview-feedback-card.tsx b/components/interview/interview-feedback-card.tsx new file mode 100644 index 00000000..f9b35ab4 --- /dev/null +++ b/components/interview/interview-feedback-card.tsx @@ -0,0 +1,42 @@ +'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.

+
+ ); + } + + const good = Array.isArray(turn.feedback.good) ? turn.feedback.good : []; + const missing = Array.isArray(turn.feedback.missing) ? turn.feedback.missing : []; + const strongAnswer = turn.feedback.strongAnswer || 'No sample answer available yet.'; + + return ( +
+

Live Feedback

+
+
+

What was good

+
    + {good.length > 0 ? good.map((item) =>
  • {item}
  • ) :
  • No clear strengths identified yet.
  • } +
+
+
+

What was missing

+
    + {missing.length > 0 ? missing.map((item) =>
  • {item}
  • ) :
  • No major gaps identified.
  • } +
+
+
+

What a strong answer looks like

+

{strongAnswer}

+
+
+
+ ); +} diff --git a/components/interview/interview-history.tsx b/components/interview/interview-history.tsx new file mode 100644 index 00000000..abc1530e --- /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..07aef0ab --- /dev/null +++ b/components/interview/interview-score-breakdown.tsx @@ -0,0 +1,22 @@ +'use client'; + +export function InterviewScoreBreakdown({ communication, technical }: { communication: number; technical: number }) { + const communicationWidth = `${Math.max(0, Math.min(100, communication * 10))}%`; + const technicalWidth = `${Math.max(0, Math.min(100, technical * 10))}%`; + + 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..81f96482 --- /dev/null +++ b/components/interview/interview-session.tsx @@ -0,0 +1,178 @@ +'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 createId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `interview-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function toErrorMessage(error: unknown) { + return error instanceof Error ? error.message : 'Something went wrong. Please try again.'; +} + +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: createId(), question: openingQuestion }]); + const [answer, setAnswer] = useState(''); + const [loading, setLoading] = useState(false); + const [summary, setSummary] = useState(null); + const [error, setError] = useState(null); + + const activeTurn = turns[turns.length - 1]; + const questionCount = turns.length; + const latestFeedbackTurn = [...turns].reverse().find((turn) => turn.feedback); + + const submitAnswer = async () => { + if (!answer.trim() || !activeTurn) return; + setLoading(true); + setError(null); + 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(); + if (!response.ok) { + throw new Error(data?.message || data?.error?.message || 'Failed to submit interview answer'); + } + const payload = data.data || data; + if (!payload?.feedback || !payload?.nextQuestion) { + throw new Error('Interview turn response was incomplete'); + } + const updatedTurns = turns.map((turn) => + turn.id === activeTurn.id + ? { + ...turn, + answer, + feedback: payload.feedback, + } + : turn, + ); + const nextTurns = payload.nextQuestion + ? [...updatedTurns, { id: createId(), question: payload.nextQuestion }] + : updatedTurns; + setTurns(nextTurns); + setAnswer(''); + } catch (error) { + setError(toErrorMessage(error)); + } finally { + setLoading(false); + } + }; + + const finishInterview = async () => { + setLoading(true); + setError(null); + try { + const finalTurns = turns.map((turn) => + turn.id === activeTurn?.id && answer.trim() + ? { question: turn.question, answer: answer.trim() } + : { question: turn.question, answer: turn.answer } + ); + const response = await fetch('/api/interview/debrief', { + method: 'POST', + headers: modelHeaders(), + body: JSON.stringify({ + config, + turns: finalTurns, + }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data?.message || data?.error?.message || 'Failed to finish interview'); + } + const payload = data.data || data; + if (!payload?.summary) { + throw new Error('Interview debrief response was incomplete'); + } + setSummary(payload); + onComplete( + payload, + turns.map((turn) => + turn.id === activeTurn?.id && answer.trim() + ? { ...turn, answer: answer.trim() } + : turn, + ), + ); + } catch (error) { + setError(toErrorMessage(error)); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ +
+

Answer the current question

+ {error ? ( +

+ {error} +

+ ) : null} +