diff --git a/.gitignore b/.gitignore index f6466ff..80afca8 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/app/api/interviews/session/route.ts b/app/api/interviews/session/route.ts new file mode 100644 index 0000000..4d626c9 --- /dev/null +++ b/app/api/interviews/session/route.ts @@ -0,0 +1,151 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthenticatedUser, listResumes, getResume } from "@/lib/services/db"; + +export async function GET(req: NextRequest) { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ detail: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const resumeId = searchParams.get("resume_id"); + const interviewer = searchParams.get("interviewer") || "alex"; + + let resume; + if (resumeId) { + resume = await getResume(resumeId, user.id); + } else { + const resumes = await listResumes(user.id); + resume = resumes[0]; // Latest + } + + if (!resume) { + return NextResponse.json({ detail: "No resume found. Please upload a resume first." }, { status: 404 }); + } + + const profile = resume.parsed_profile; + if (!profile) { + return NextResponse.json({ detail: "Resume is not yet profiled. Please wait for profiling to complete." }, { status: 400 }); + } + + // Construct the context string + const context = [ + `Name: ${resume.file_name?.replace(/\.[^/.]+$/, "") || "Candidate"}`, + `Headline: ${profile.headline || "N/A"}`, + `Summary: ${profile.summary || "N/A"}`, + `Skills: ${profile.skills?.join(", ") || "N/A"}`, + `Experience:`, + ...(profile.experiences || []).map(exp => `- ${exp.title} at ${exp.company} (${exp.start_date} - ${exp.end_date || 'Present'}): ${exp.description}`), + ].join("\n"); + + const isSophia = interviewer.toLowerCase() === "sophia"; + const sophiaAgentId = process.env.ELEVENLABS_SOPHIA_AGENT_ID?.trim(); + + if (isSophia && !sophiaAgentId) { + console.error("Missing ELEVENLABS_SOPHIA_AGENT_ID for Sophia interviewer."); + return NextResponse.json( + { + detail: + "Sophia interviewer is not configured. Set ELEVENLABS_SOPHIA_AGENT_ID in your environment (same as in .env.local for local dev).", + }, + { status: 500 }, + ); + } + + /** Optional TTS override; Sophia's agent usually uses the voice configured in ElevenLabs for that agent. */ + const defaultSophiaVoiceId = "SDNKIYEpTz0h56jQX8rA"; + const sophiaVoiceId = process.env.ELEVENLABS_SOPHIA_VOICE_ID ?? defaultSophiaVoiceId; + const useVoiceOverride = isSophia && !!process.env.ELEVENLABS_SOPHIA_VOICE_ID; + + const persona = isSophia ? { + name: "Sophia", + role: "Senior Executive Recruiter & Career Strategist", + personality: [ + "Warm, poised, and highly professional — you sound like a trusted advisor, not an interrogator.", + "You care deeply about how candidates lead, collaborate, and grow: leadership presence, influence without authority, stakeholder communication, and resilience.", + "You probe cultural fit and motivation: why this role, why now, and how they would show up for a team in Singapore's fast-moving hiring landscape.", + "You listen for specifics: stories with context, trade-offs they made, and what they learned — not buzzwords.", + "You speak with a refined, encouraging tone; you give brief, genuine acknowledgement before moving to the next question.", + ].join(" "), + voiceId: sophiaVoiceId, + agentId: sophiaAgentId!, + } : { + name: "Alex", + role: "Seasoned Hiring Manager", + personality: "Direct, practical, and fair. You focus on technical skills, problem-solving abilities, and concrete results. You value brisk, data-driven answers.", + voiceId: undefined, + agentId: process.env.ELEVENLABS_AGENT_ID + }; + + const interviewStyleExtra = isSophia + ? ` +## Interview style — Sophia (executive recruiter) +- **Focus**: Leadership stories, collaboration, stakeholder influence, motivation, and cultural fit. Avoid deep stack trivia unless it clearly matches their target role. +- **Opening energy**: Confident and welcoming; show you’ve read their profile (headline, key experiences). +- **Question style**: Behavioural and situational (“Tell me about a time…”, “How did you navigate…”) grounded in their CV. +- **Follow-ups**: At most one per question — ask for concrete context (trade-offs, who was involved, what changed) when answers stay generic. +- **Avoid**: Sounding robotic or cold; inventing employers, titles, or skills. +- **Tone**: Refined, encouraging, calm pacing; short sentences. Singapore: professional warmth with clarity. +` + : ` +## Interview style — Alex (hiring manager) +- **Focus**: Execution, problem-solving, and proof — scope, tools, outcomes, and thinking under constraint. Tie questions to skills and experience bullets. +- **Opening energy**: Direct and respectful of time; signal you care about substance. +- **Question style**: Lean technical or operational (“Walk me through…”, “What was the hardest part…”, “How did you measure success…”). Ask for metrics, timeline, ownership, validation. +- **Follow-ups**: At most one per question — clarify what *they* did vs the team, and how they’d adapt next time. +- **Avoid**: Hostile tone; purely motivational fluff without linking to delivery; inventing facts about the candidate. +- **Tone**: Brisk, clear, minimal filler. Singapore: competence and clarity expected. +`; + + const instructions = ` +# Identity & Persona +- **Name**: ${persona.name} +- **Role**: ${persona.role} +- **Personality**: ${persona.personality} +${interviewStyleExtra} + +# Sound human (not scripted) +- You are in a **live conversation**, not reading a checklist. Vary your wording; do not repeat the same filler every turn. +- Use **short bridges** between topics: a genuine one-liner reaction (“That’s helpful context,” “Got it — thanks for clarifying”) before the next question. +- **One question at a time.** Let them finish; then respond, then ask. If they go long, you may gently narrow (“To keep us on track — in one or two sentences, what was the outcome?”). +- If they seem nervous, **one calming line** is fine; then move on. Avoid sounding rushed or like a form. +- If they start talking over you, **stop immediately** and yield the floor. + +# Structure (hidden agenda — keep it light on the surface) +- Cover **exactly three main question topics** across the session, each grounded in their profile and the **Interview style** above (Sophia vs Alex). Do **not** label them “Question 1/2/3” out loud. +- You may use **one short follow-up** per topic when something is vague — same topic only, then move on. +- Aim for a **short practice session** (roughly a few minutes of dialogue). Prefer **brief turns**: your spoken replies usually **well under ~30 seconds** so they get air time. +- If time is running short, **skip depth** and offer a warm, quick close instead of squeezing another topic. +- If they go quiet after you ask something, **one gentle nudge** (“Take your time — whenever you’re ready”) before rephrasing more simply. + +# Candidate Profile +${context} + +# Flow (guide, don’t robot-read) +1. **Opening**: Greet them by name if it appears naturally from the profile; mention you’ve looked at their background (${profile.headline || "their experience"}) in your own words — don’t quote blocks from the CV. Invite them to begin when ready (they can say “start”, “ready”, or equivalent — accept natural cues). +2. **Middle**: Explore three topics as a **flowing interview**: acknowledge what they said, then **build** (“Building on that…”, “I’m curious about…”, “One more angle…”). Avoid robotic pivots like “Next question:”. +3. **Close**: When you’ve fairly covered three topics (or time is tight), transition naturally (“Before we wrap…”, “Last thing from me…”). Share **one** strength and **one** concrete improvement idea in plain language, then thank them and wish them well. Don’t list bullets aloud. + +# Guardrails +- **Do not invent** employers, titles, skills, or achievements not supported by the profile. +- Stay within **Interview style** for this persona (Sophia vs Alex). + `.trim(); + + const headlineHint = profile.headline || "your experience"; + const firstMessage = isSophia + ? `Hi — I'm ${persona.name}. I've had a chance to look at your background around ${headlineHint}, and I'm glad we could make this work. Whenever you're ready, we can get started — just let me know.` + : `Hi — I'm ${persona.name}. I've skimmed your profile around ${headlineHint}; we'll keep this efficient. Whenever you're ready to begin, just say the word.`; + + if (!process.env.ELEVENLABS_API_KEY || !process.env.ELEVENLABS_AGENT_ID) { + console.error("Missing ElevenLabs Configuration in environment variables."); + return NextResponse.json({ detail: "ElevenLabs API Key or Agent ID is missing from environment variables." }, { status: 500 }); + } + + return NextResponse.json({ + agent_id: persona.agentId, + dynamic_instructions: instructions, + first_message: firstMessage, + voice_id: isSophia ? persona.voiceId : undefined, + use_voice_override: useVoiceOverride, + }); +} diff --git a/app/api/jobs/recommend/route.ts b/app/api/jobs/recommend/route.ts index ac083dc..cb93a9c 100644 --- a/app/api/jobs/recommend/route.ts +++ b/app/api/jobs/recommend/route.ts @@ -2,6 +2,9 @@ import { NextResponse } from "next/server"; import { getAuthenticatedUser, getResume, listResumes } from "@/lib/services/db"; import { getJobRecommendations } from "@/lib/services/jobRecommendation"; +/** Uses Supabase auth (cookies); avoid static analysis during `next build`. */ +export const dynamic = "force-dynamic"; + export const maxDuration = 60; export async function GET(req: Request) { diff --git a/app/api/jobs/scrape/route.ts b/app/api/jobs/scrape/route.ts index b28088e..cd591dd 100644 --- a/app/api/jobs/scrape/route.ts +++ b/app/api/jobs/scrape/route.ts @@ -30,7 +30,9 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Only http/https URLs are supported" }, { status: 400 }); } - const job = await extractJobFromUrl(url); + console.log("Scraping URL:", url); +const job = await extractJobFromUrl(url); +console.log("Job result:", job.title, job.company); return NextResponse.json({ job }); } catch (err) { diff --git a/app/api/places/route.ts b/app/api/places/route.ts new file mode 100644 index 0000000..6e977a5 --- /dev/null +++ b/app/api/places/route.ts @@ -0,0 +1,44 @@ +export const runtime = "nodejs"; + +import { NextRequest, NextResponse } from "next/server"; +import { getAuthenticatedUser } from "@/lib/services/db"; + +export async function GET(req: NextRequest) { + const user = await getAuthenticatedUser(); + if (!user) return NextResponse.json({ place: null }, { status: 401 }); + + const query = req.nextUrl.searchParams.get("query"); + if (!query?.trim()) return NextResponse.json({ place: null }); + + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + if (!apiKey) return NextResponse.json({ place: null }); + + try { + const res = await fetch("https://places.googleapis.com/v1/places:searchText", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Goog-Api-Key": apiKey, + "X-Goog-FieldMask": "places.addressComponents", + }, + body: JSON.stringify({ textQuery: query }), + }); + + if (!res.ok) return NextResponse.json({ place: null }); + + const data = (await res.json()) as { + places?: { addressComponents?: { longText: string; types: string[] }[] }[]; + }; + + const components = data.places?.[0]?.addressComponents; + if (!components) return NextResponse.json({ place: null }); + + const locality = components.find((c) => c.types.includes("locality"))?.longText; + const country = components.find((c) => c.types.includes("country"))?.longText; + const location = [locality, country].filter(Boolean).join(", "); + + return NextResponse.json({ place: location ? { location } : null }); + } catch { + return NextResponse.json({ place: null }); + } +} diff --git a/app/api/resume/route.ts b/app/api/resume/route.ts index a01eb03..c3a1988 100644 --- a/app/api/resume/route.ts +++ b/app/api/resume/route.ts @@ -9,25 +9,48 @@ import { getAuthenticatedUser } from "@/lib/services/db"; const OPENAI_MODEL = "gpt-4o-mini"; const GROQ_FALLBACK_MODEL = "llama-3.1-8b-instant"; -const SYSTEM_PROMPT = `You are a professional resume coach specialising in Singapore's white-collar job market. +const BASE_SYSTEM_PROMPT = `You are an elite Singapore resume advisor: language coach, ATS specialist, Singapore market expert (MOM standards, local hiring norms), and career progression analyst. Be specific — always reference actual resume content, never generic advice. Salary benchmarks must be SGD Singapore market rates. -Analyse the resume below and return ONLY a valid JSON object with exactly these fields: +Return ONLY valid JSON with exactly these fields: -{ - "overallImpression": "2 to 3 sentence summary", - "keyStrengths": ["strength 1", "strength 2", "strength 3"], - "areasToImprove": ["gap 1", "gap 2", "gap 3"], - "suggestedEdits": ["edit 1", "edit 2", "edit 3"], - "score": 7 +{"overallImpression":"3–4 sentences: profile strength, Singapore market positioning, target role fit","keyStrengths":["specific strength referencing actual resume content","strength 2","strength 3"],"areasToImprove":["specific gap explaining why it weakens the resume in Singapore's market","gap 2","gap 3"],"suggestedEdits":["exact rewrite (not advice) e.g. change 'Managed social media' to 'Grew combined social following 40% to 120k'","edit 2","edit 3"],"atsAnalysis":{"keywordsFound":["kw1","kw2"],"keywordsMissing":["missing1","missing2"],"atsFriendly":true,"atsNotes":"One sentence on ATS suitability"},"careerProgression":"2 sentences on trajectory logic and competitiveness for the candidate's level in Singapore","salaryBenchmark":{"estimatedRange":"SGD X,000–Y,000/month","rationale":"One sentence based on role, sector, and years of experience"},"score":5} + +Score rubric (1–10): 2pts quantified achievements with numbers; 2pts Singapore market/MOM alignment; 2pts structure/ATS compatibility; 2pts action verb quality; 2pts completeness (contact, history, education, skills). + +No text outside the JSON.`; + +function getLanguageName(code: string): string { + const names: Record = { + zh: "Simplified Chinese (简体中文)", + ms: "Bahasa Melayu", + ta: "Tamil (தமிழ்)", + }; + return names[code] || "English"; } -Be specific to Singapore hiring standards. No generic advice. No text outside the JSON object.`; +function buildSystemPrompt(language?: string): string { + if (!language || language === "en") return BASE_SYSTEM_PROMPT; + const langName = getLanguageName(language); + const instruction = `IMPORTANT: You must respond entirely in ${langName}. All feedback, suggestions, and analysis must be written in ${langName}. Do not use English anywhere in your response.\n\n`; + return instruction + BASE_SYSTEM_PROMPT; +} export interface ResumeFeedback { overallImpression: string; keyStrengths: string[]; areasToImprove: string[]; suggestedEdits: string[]; + atsAnalysis: { + keywordsFound: string[]; + keywordsMissing: string[]; + atsFriendly: boolean; + atsNotes: string; + }; + careerProgression: string; + salaryBenchmark: { + estimatedRange: string; + rationale: string; + }; score: number; } @@ -44,7 +67,8 @@ function extractJson(text: string): string { return text.trim(); } -async function callLlm(resumeText: string): Promise { +async function callLlm(resumeText: string, language?: string): Promise { + const systemPrompt = buildSystemPrompt(language); const openaiKey = process.env.OPENAI_API_KEY; if (openaiKey) { try { @@ -52,7 +76,7 @@ async function callLlm(resumeText: string): Promise { const response = await client.chat.completions.create({ model: OPENAI_MODEL, messages: [ - { role: "system", content: SYSTEM_PROMPT }, + { role: "system", content: systemPrompt }, { role: "user", content: resumeText.slice(0, 120_000) }, ], temperature: 0, @@ -70,7 +94,7 @@ async function callLlm(resumeText: string): Promise { const response = await client.chat.completions.create({ model: GROQ_FALLBACK_MODEL, messages: [ - { role: "system", content: SYSTEM_PROMPT }, + { role: "system", content: systemPrompt }, { role: "user", content: resumeText.slice(0, 120_000) }, ], temperature: 0, @@ -86,7 +110,8 @@ export async function POST(req: NextRequest) { const formData = await req.formData(); const file = formData.get("file"); - const voiceText = (formData.get("voiceText") as string) || ""; + const language = (formData.get("language") as string | null) ?? "en"; + const translatedText = (formData.get("translatedText") as string | null) ?? null; if (!file || !(file instanceof Blob)) { return NextResponse.json({ detail: "No file provided" }, { status: 400 }); @@ -102,31 +127,29 @@ export async function POST(req: NextRequest) { } let resumeText: string; - try { - const arrayBuffer = await file.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - const { text } = await extractText(uint8Array, { mergePages: true }); - resumeText = text; - } catch (e) { - return NextResponse.json( - { detail: `File could not be read: ${e instanceof Error ? e.message : e}` }, - { status: 422 }, - ); - } + if (translatedText && translatedText.trim()) { + resumeText = translatedText; + } else { + try { + const arrayBuffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + const { text } = await extractText(uint8Array, { mergePages: true }); + resumeText = text; + } catch (e) { + return NextResponse.json( + { detail: `File could not be read: ${e instanceof Error ? e.message : e}` }, + { status: 422 }, + ); + } - if (!resumeText.trim()) { - return NextResponse.json({ detail: "File produced no text" }, { status: 422 }); + if (!resumeText.trim()) { + return NextResponse.json({ detail: "File produced no text" }, { status: 422 }); + } } - const combinedInput = `RESUME DOCUMENT: -${resumeText} - -ADDITIONAL CONTEXT FROM CANDIDATE: -${voiceText || "None provided"}`; - let raw: string; try { - raw = await callLlm(combinedInput); + raw = await callLlm(resumeText, language); } catch (e) { return NextResponse.json( { detail: `AI analysis failed: ${e instanceof Error ? e.message : e}` }, diff --git a/app/api/resume/translate/route.ts b/app/api/resume/translate/route.ts new file mode 100644 index 0000000..303761b --- /dev/null +++ b/app/api/resume/translate/route.ts @@ -0,0 +1,82 @@ +export const runtime = "nodejs"; + +import { NextRequest, NextResponse } from "next/server"; +import OpenAI from "openai"; + +function getLanguageName(code: string): string { + const names: Record = { + zh: "Simplified Chinese (简体中文)", + ms: "Bahasa Melayu", + ta: "Tamil (தமிழ்)", + }; + return names[code] || "English"; +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { resumeData, text, targetLanguage } = body as { + resumeData?: unknown; + text?: string; + targetLanguage?: string; + }; + const isTextMode = typeof text === "string"; + + if (!targetLanguage || targetLanguage === "en") { + return NextResponse.json(isTextMode ? { translatedText: text } : { translatedData: resumeData }); + } + + const langName = getLanguageName(targetLanguage); + const openaiKey = process.env.OPENAI_API_KEY; + if (!openaiKey) { + return NextResponse.json(isTextMode ? { translatedText: text } : { translatedData: resumeData }); + } + + try { + const client = new OpenAI({ apiKey: openaiKey }); + + if (isTextMode) { + // Raw text translation mode + const response = await client.chat.completions.create({ + model: "gpt-4o-mini", + temperature: 0, + messages: [ + { + role: "system", + content: `Translate the following resume text to ${langName}. Preserve all formatting, dates, numbers, and proper nouns. Return only the translated text.`, + }, + { role: "user", content: text! }, + ], + }); + const translatedText = response.choices[0]?.message?.content ?? text!; + return NextResponse.json({ translatedText }); + } + + // JSON object translation mode + const response = await client.chat.completions.create({ + model: "gpt-4o-mini", + temperature: 0, + messages: [ + { + role: "system", + content: `Translate all string values in the given JSON object to ${langName}. Preserve the JSON structure and all keys exactly. Only translate the values. Return only valid JSON with no extra text.`, + }, + { + role: "user", + content: JSON.stringify(resumeData), + }, + ], + }); + + const raw = response.choices[0]?.message?.content ?? ""; + const braceStart = raw.indexOf("{"); + const braceEnd = raw.lastIndexOf("}"); + if (braceStart === -1 || braceEnd <= braceStart) { + return NextResponse.json({ translatedData: resumeData }); + } + const translatedData = JSON.parse(raw.slice(braceStart, braceEnd + 1)); + return NextResponse.json({ translatedData }); + } catch { + // Graceful degradation — return original data untranslated + return NextResponse.json(isTextMode ? { translatedText: text } : { translatedData: resumeData }); + } +} diff --git a/app/api/resume/voice-autocorrect/route.ts b/app/api/resume/voice-autocorrect/route.ts new file mode 100644 index 0000000..22fadd2 --- /dev/null +++ b/app/api/resume/voice-autocorrect/route.ts @@ -0,0 +1,77 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import OpenAI from "openai"; + +const openai = new OpenAI(); + +export async function POST(request: Request) { + let body: { text?: string; questionType?: string; language?: string }; + + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + const { text, questionType, language } = body; + + if (!text || typeof text !== "string") { + return NextResponse.json( + { error: "Missing required field: text" }, + { status: 400 }, + ); + } + + const qType = questionType ?? "general"; + const lang = language ?? "en"; + + // --- Age validation: handled locally, no LLM needed --- + if (qType === "age") { + const match = text.match(/\d+/); + if (match) { + return NextResponse.json({ corrected: match[0], valid: true }); + } + return NextResponse.json({ corrected: "INVALID", valid: false }); + } + + // --- Build system prompt --- + let systemPrompt = + "You are a speech-to-text post-processor. " + + "Correct transcription errors where words sound similar but do not fit the context. " + + "Preserve proper nouns and names exactly unless they are clearly garbled. " + + "Return only the corrected text with no explanation."; + + if (qType === "name") { + systemPrompt += + " If the text appears to be a name spelled out letter by letter " + + '(e.g. "J A Y S O N"), reconstruct it as a proper name (e.g. "Jayson").'; + } + + if (lang !== "en") { + systemPrompt += ` The text is in language code "${lang}". Preserve the original language.`; + } + + try { + const completion = await openai.chat.completions.create({ + model: "gpt-4o-mini", + temperature: 0.2, + max_tokens: 512, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: text }, + ], + }); + + const corrected = completion.choices[0]?.message?.content?.trim() ?? text; + + return NextResponse.json({ corrected, valid: true }); + } catch (err) { + console.error("[voice-autocorrect] OpenAI error:", err); + // Graceful degradation: return original text uncorrected + return NextResponse.json({ corrected: text, valid: true }); + } +} diff --git a/app/api/resume/voice-build/route.ts b/app/api/resume/voice-build/route.ts index fdb25e5..19de802 100644 --- a/app/api/resume/voice-build/route.ts +++ b/app/api/resume/voice-build/route.ts @@ -4,15 +4,100 @@ import { NextRequest, NextResponse } from "next/server"; import OpenAI from "openai"; import Groq from "groq-sdk"; import { getAuthenticatedUser, insertResume } from "@/lib/services/db"; -import type { ResumeProfile } from "@/lib/types"; +import type { ResumeProfile, ResumeSuggestion } from "@/lib/types"; +import type { ResumeFeedback } from "@/app/api/resume/route"; const OPENAI_MODEL = "gpt-4o-mini"; const GROQ_FALLBACK_MODEL = "llama-3.1-8b-instant"; -const SYSTEM_PROMPT = `You are a professional resume writer for Singapore's job market. The user has provided raw voice transcript answers to resume questions. The transcripts may have errors or incomplete sentences from speech recognition. +const FEEDBACK_SYSTEM_PROMPT = `You are an elite Singapore resume advisor: language coach, ATS specialist, Singapore market expert (MOM standards, local hiring norms), and career progression analyst. Be specific — always reference actual resume content, never generic advice. Salary benchmarks must be SGD Singapore market rates. + +Return ONLY valid JSON with exactly these fields: + +{"overallImpression":"3–4 sentences: profile strength, Singapore market positioning, target role fit","keyStrengths":["specific strength referencing actual resume content","strength 2","strength 3"],"areasToImprove":["specific gap explaining why it weakens the resume in Singapore's market","gap 2","gap 3"],"suggestedEdits":["exact rewrite (not advice) e.g. change 'Managed social media' to 'Grew combined social following 40% to 120k'","edit 2","edit 3"],"atsAnalysis":{"keywordsFound":["kw1","kw2"],"keywordsMissing":["missing1","missing2"],"atsFriendly":true,"atsNotes":"One sentence on ATS suitability"},"careerProgression":"2 sentences on trajectory logic and competitiveness for the candidate's level in Singapore","salaryBenchmark":{"estimatedRange":"SGD X,000–Y,000/month","rationale":"One sentence based on role, sector, and years of experience"},"score":5} + +Score rubric (1–10): 2pts quantified achievements with numbers; 2pts Singapore market/MOM alignment; 2pts structure/ATS compatibility; 2pts action verb quality; 2pts completeness (contact, history, education, skills). + +No text outside the JSON.`; + +function feedbackToSuggestions(feedback: ResumeFeedback): ResumeSuggestion[] { + const suggestions: ResumeSuggestion[] = []; + for (const edit of feedback.suggestedEdits ?? []) { + suggestions.push({ type: "enhancement", priority: "medium", category: "content", suggestion: edit }); + } + for (const area of feedback.areasToImprove ?? []) { + suggestions.push({ type: "content_gap", priority: "high", category: "content", suggestion: area }); + } + for (const kw of feedback.atsAnalysis?.keywordsMissing ?? []) { + suggestions.push({ type: "ats_optimization", priority: "medium", category: "keywords", suggestion: `Add keyword: "${kw}"` }); + } + return suggestions; +} + +async function generateFeedback(rawText: string, language?: string): Promise { + const feedbackPrompt = buildLanguageInstruction(language) + FEEDBACK_SYSTEM_PROMPT; + try { + const openaiKey = process.env.OPENAI_API_KEY; + let raw = ""; + if (openaiKey) { + try { + const client = new OpenAI({ apiKey: openaiKey }); + const response = await client.chat.completions.create({ + model: OPENAI_MODEL, + messages: [ + { role: "system", content: feedbackPrompt }, + { role: "user", content: rawText.slice(0, 12000) }, + ], + temperature: 0, + }); + raw = response.choices[0]?.message?.content ?? ""; + } catch { + const groqKey = process.env.GROQ_API_KEY; + if (groqKey) { + const client = new Groq({ apiKey: groqKey }); + const response = await client.chat.completions.create({ + model: GROQ_FALLBACK_MODEL, + messages: [ + { role: "system", content: feedbackPrompt }, + { role: "user", content: rawText.slice(0, 12000) }, + ], + temperature: 0, + }); + raw = response.choices[0]?.message?.content ?? ""; + } + } + } + if (!raw) return null; + const braceStart = raw.indexOf("{"); + const braceEnd = raw.lastIndexOf("}"); + if (braceStart === -1 || braceEnd <= braceStart) return null; + return JSON.parse(raw.slice(braceStart, braceEnd + 1)) as ResumeFeedback; + } catch { + return null; + } +} + +function getLanguageName(code: string): string { + const names: Record = { + zh: "Simplified Chinese (简体中文)", + ms: "Bahasa Melayu", + ta: "Tamil (தமிழ்)", + }; + return names[code] || "English"; +} + +function buildLanguageInstruction(language?: string): string { + if (!language || language === "en") return ""; + const langName = getLanguageName(language); + return `IMPORTANT: All text values in the JSON output must be written in ${langName}. Do not use English in any field value.\n\n`; +} + +const BASE_COMPILATION_PROMPT = `You are a professional resume writer for Singapore's job market. The user has provided raw voice transcript answers to resume questions. The transcripts may have errors or incomplete sentences from speech recognition. Auto-correct the language, fix grammar, and compile the answers into a clean, professionally formatted resume. Keep all facts as stated. For a simplified entry-level resume, focus on clarity over complexity. +If the candidate's name appears to contain individual letters separated by spaces (e.g. J A Y S O N), reconstruct it as a single proper name. Strip any leading or trailing hash symbols or special characters from the name before saving. + Return ONLY a valid JSON object with exactly these fields: { "name": "string", @@ -27,7 +112,8 @@ Return ONLY a valid JSON object with exactly these fields: No text outside the JSON. If a field has no data, return an empty string for it.`; -async function callLlm(prompt: string): Promise { +async function callLlm(prompt: string, language?: string): Promise { + const systemPrompt = buildLanguageInstruction(language) + BASE_COMPILATION_PROMPT; const openaiKey = process.env.OPENAI_API_KEY; if (openaiKey) { try { @@ -35,7 +121,7 @@ async function callLlm(prompt: string): Promise { const response = await client.chat.completions.create({ model: OPENAI_MODEL, messages: [ - { role: "system", content: SYSTEM_PROMPT }, + { role: "system", content: systemPrompt }, { role: "user", content: prompt }, ], temperature: 0.2, @@ -53,7 +139,7 @@ async function callLlm(prompt: string): Promise { const response = await client.chat.completions.create({ model: GROQ_FALLBACK_MODEL, messages: [ - { role: "system", content: SYSTEM_PROMPT }, + { role: "system", content: systemPrompt }, { role: "user", content: prompt }, ], temperature: 0.2, @@ -67,23 +153,56 @@ export async function POST(req: NextRequest) { return NextResponse.json({ detail: "Unauthorized" }, { status: 401 }); } - const answers = await req.json(); + const body = await req.json(); + const { language, ...answers } = body ?? {}; + + // TASK E: Input validation — block empty or near-empty submissions + if (!answers || typeof answers !== "object") { + return NextResponse.json({ error: "No answers provided" }, { status: 400 }); + } + + const allValues = [ + answers.full_name, answers.age, answers.job_title, + answers.summary, answers.experience, answers.achievement, + answers.education, answers.skills, answers.anything_else, + ]; + const nonEmptyCount = allValues.filter( + (v) => typeof v === "string" && v.trim().length > 0 + ).length; + if (nonEmptyCount < 3) { + return NextResponse.json( + { error: "Please answer at least 3 questions before generating your resume" }, + { status: 400 }, + ); + } + + // TASK C: Age guard — only pass age to the prompt if it's a valid number between 15 and 80. + // Voice transcription can produce garbled numbers; invalid ages are silently dropped + // rather than sent to the LLM where they'd pollute the resume. + let sanitizedAge: string | null = null; + if (answers.age) { + const parsed = Number(answers.age); + if (!Number.isNaN(parsed) && parsed >= 15 && parsed <= 80) { + sanitizedAge = String(parsed); + } + } const prompt = [ answers.full_name ? `Full Name: ${answers.full_name}` : "", - answers.age ? `Age: ${answers.age}` : "", + sanitizedAge ? `Age: ${sanitizedAge}` : "", answers.job_title ? `Target Role: ${answers.job_title}` : "", answers.summary ? `Professional Summary (voice): ${answers.summary}` : "", answers.experience ? `Work Experience (voice): ${answers.experience}` : "", answers.achievement ? `Key Achievement (voice): ${answers.achievement}` : "", answers.education ? `Education (voice): ${answers.education}` : "", answers.skills ? `Skills (voice): ${answers.skills}` : "", + answers.anything_else ? `Additional information (voice): ${answers.anything_else}` : "", ] .filter(Boolean) .join("\n"); try { - const raw = await callLlm(prompt); + const raw = await callLlm(prompt, language); const braceStart = raw.indexOf("{"); const braceEnd = raw.lastIndexOf("}"); if (braceStart === -1 || braceEnd <= braceStart) { @@ -111,9 +230,11 @@ export async function POST(req: NextRequest) { seniority_level: null, }; - // Build a raw_text representation so the resume is searchable/readable in Supabase + // Build a raw_text representation so the resume is searchable/readable in Supabase. + // The name must be the bare first line (no "Name:" prefix) so extractCandidateName + // in the review page can find it — that function filters lines containing "Name:". const rawText = [ - result.name ? `Name: ${result.name}` : "", + result.name || "", result.targetRole ? `Target Role: ${result.targetRole}` : "", result.summary ? `Summary:\n${result.summary}` : "", result.experience ? `Experience:\n${result.experience}` : "", @@ -123,9 +244,11 @@ export async function POST(req: NextRequest) { ].filter(Boolean).join("\n\n"); const fileName = result.name ? `${result.name} - Voice Resume` : "Voice Resume"; - const saved = await insertResume(user.id, fileName, rawText, profile); + const feedback = await generateFeedback(rawText, language); + const aiSuggestions = feedback ? feedbackToSuggestions(feedback) : null; + const saved = await insertResume(user.id, fileName, rawText, profile, undefined, undefined, aiSuggestions); - return NextResponse.json({ ...result, resume_id: saved.id }); + return NextResponse.json({ success: true, resumeId: saved.id, resumeData: result, feedback }); } catch (e) { return NextResponse.json( { detail: `Resume generation failed: ${e instanceof Error ? e.message : e}` }, diff --git a/app/api/speech/route.ts b/app/api/speech/route.ts index 434ca50..87c7bc7 100644 --- a/app/api/speech/route.ts +++ b/app/api/speech/route.ts @@ -2,6 +2,20 @@ export const runtime = "nodejs"; import { NextResponse } from "next/server"; +/** + * GET /api/speech — Azure Speech SDK token proxy. + * + * TTS front-cutoff fix (applied client-side): + * The Web Speech API SpeechSynthesis can clip the first ~200-300ms of an + * utterance if the audio output device is still spinning up. Two mitigations + * are used in the shared TTS helper (`lib/speech/speakText.ts`): + * 1. `speechSynthesis.cancel()` before every new utterance to flush the + * queue and reset the synthesiser state. + * 2. A 300ms silence is prepended to each utterance via SSML + * (``) so that any clipped audio falls within the + * silent padding, not the actual spoken content. + * See `lib/speech/speakText.ts` for the implementation. + */ export async function GET() { const key = process.env.AZURE_SPEECH_KEY; const region = process.env.AZURE_SPEECH_REGION; diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..0f5636c --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,51 @@ +import { createServerClient } from "@supabase/ssr"; +import { type NextRequest, NextResponse } from "next/server"; +import { safeNextPath } from "@/lib/auth/safe-next-path"; + +/** + * OAuth redirect target (e.g. Google). Supabase redirects here with ?code=... + * Session cookies are written onto the redirect response. + */ +export async function GET(request: NextRequest) { + const url = request.nextUrl; + const code = url.searchParams.get("code"); + const next = safeNextPath(url.searchParams.get("next")); + + if (!code) { + const dest = url.clone(); + dest.pathname = "/auth/sign-in"; + dest.search = ""; + dest.searchParams.set("error", "oauth"); + return NextResponse.redirect(dest); + } + + const response = NextResponse.redirect(new URL(next, url.origin)); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value, options }) => { + response.cookies.set(name, value, options); + }); + }, + }, + }, + ); + + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (error) { + const dest = url.clone(); + dest.pathname = "/auth/sign-in"; + dest.search = ""; + dest.searchParams.set("error", "oauth"); + return NextResponse.redirect(dest); + } + + return response; +} diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx index f4e7c50..c894746 100644 --- a/app/auth/sign-in/page.tsx +++ b/app/auth/sign-in/page.tsx @@ -6,12 +6,18 @@ import { AuthShell } from "@/components/auth/AuthShell"; import { AuthForm, type AuthFormValues } from "@/components/auth/AuthForm"; import { safeNextPath } from "@/lib/auth/safe-next-path"; import { createClient } from "@/lib/supabase/client"; +import { GoogleSignInButton } from "@/components/auth/GoogleSignInButton"; import Link from "next/link"; function SignInContent() { - const [error, setError] = useState(null); const router = useRouter(); const searchParams = useSearchParams(); + const urlError = + searchParams.get("error") === "oauth" + ? "Google sign-in could not be completed. Try again." + : null; + const [error, setError] = useState(null); + const displayError = error ?? urlError; const handleSubmit = async (values: AuthFormValues) => { setError(null); @@ -44,7 +50,18 @@ function SignInContent() { } > - +
+ +
+
+
+
+
+ or email +
+
+ +
); } diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx index 71d22db..f1ff7aa 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -1,14 +1,21 @@ "use client"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { Suspense, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { AuthShell } from "@/components/auth/AuthShell"; import { AuthForm, type AuthFormValues } from "@/components/auth/AuthForm"; +import { GoogleSignInButton } from "@/components/auth/GoogleSignInButton"; import { createClient } from "@/lib/supabase/client"; import Link from "next/link"; -export default function SignUpPage() { +function SignUpContent() { + const searchParams = useSearchParams(); + const urlError = + searchParams.get("error") === "oauth" + ? "Google sign-in could not be completed. Try again." + : null; const [error, setError] = useState(null); + const displayError = error ?? urlError; const [success, setSuccess] = useState(false); const router = useRouter(); @@ -59,7 +66,34 @@ export default function SignUpPage() { } > - +
+ +
+
+
+
+
+ or email +
+
+ +
); } + +export default function SignUpPage() { + return ( + +
+
+
+ + } + > + + + ); +} diff --git a/app/contract/page.tsx b/app/contract/page.tsx index 9499f07..18fc2b2 100644 --- a/app/contract/page.tsx +++ b/app/contract/page.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useRouter } from "next/navigation"; import { SiteNavbar } from "@/components/layout/SiteNavbar"; +import { UserMenu } from "@/components/layout/UserMenu"; import { DisclaimerModal, useDisclaimerAccepted } from "@/components/contract/DisclaimerModal"; import { OnboardingForm, type OnboardingData } from "@/components/contract/OnboardingForm"; import { VerdictBadge } from "@/components/contract/VerdictBadge"; @@ -497,22 +498,15 @@ export default function ContractPage() {
{ + { try { sessionStorage.removeItem("vericlause.contractFlowStep"); } catch { /* ignore */ } - const supabase = createClient(); - await supabase.auth.signOut(); - router.push("/"); - router.refresh(); }} - className="text-sm font-medium text-slate-600 transition-colors hover:text-navy-950" - > - {t("dash_sign_out")} - + /> } /> @@ -545,16 +539,6 @@ export default function ContractPage() { }} />
- -

- -

) : null} diff --git a/app/interview/page.tsx b/app/interview/page.tsx index c8a44f5..b04fb1d 100644 --- a/app/interview/page.tsx +++ b/app/interview/page.tsx @@ -1,508 +1,466 @@ "use client"; -import { useRouter } from "next/navigation"; -import { useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { useEffect, useRef, useState, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { SiteNavbar } from "@/components/layout/SiteNavbar"; -import { useLanguage } from "@/components/providers/language-provider"; -import { createClient } from "@/lib/supabase/client"; -import AzureAvatarStage from "@/components/interview/AzureAvatarStage"; - -type InterviewRole = - | "general" - | "operations_executive" - | "project_coordinator" - | "software_engineer"; - -type InterviewType = "hr" | "behavioral" | "technical"; -type Difficulty = "easy" | "medium" | "hard"; - -type AgentState = "idle" | "connecting" | "listening" | "thinking" | "speaking" | "error"; - -type ConversationMessage = { - id: string; - speaker: "agent" | "user" | "system"; - text: string; - timestamp: string; -}; - -function formatTime(date = new Date()) { - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); -} - -function getOpeningLine(locale: string, role: InterviewRole, interviewType: InterviewType) { - const roleLabel = - role === "operations_executive" - ? "Operations Executive" - : role === "project_coordinator" - ? "Project Coordinator" - : role === "software_engineer" - ? "Software Engineer" - : "your target role"; - - if (locale === "zh") { - return `你好,我会担任你的 AI 面试官。我们将开始一场 ${roleLabel} 的${ - interviewType === "technical" ? "技术" : interviewType === "behavioral" ? "行为" : "人事" - }面试练习。请先简单介绍自己。`; - } - - if (locale === "ms") { - return `Hai, saya akan menjadi penemuduga AI anda. Kita akan mulakan sesi latihan temu duga ${ - interviewType === "technical" ? "teknikal" : interviewType === "behavioral" ? "tingkah laku" : "HR" - } untuk jawatan ${roleLabel}. Sila mulakan dengan memperkenalkan diri anda.`; - } - - if (locale === "ta") { - return `வணக்கம், நான் உங்கள் AI நேர்காணல் முகவராக இருப்பேன். ${roleLabel} பதவிக்கான ${ - interviewType === "technical" ? "தொழில்நுட்ப" : interviewType === "behavioral" ? "நடத்தை சார்ந்த" : "மனிதவள" - } நேர்காணல் பயிற்சியை தொடங்கலாம். முதலில் உங்களை அறிமுகப்படுத்துங்கள்.`; - } - - return `Hi, I’ll be your AI interviewer. We’re starting a ${interviewType} interview practice session for a ${roleLabel} role. Please begin by introducing yourself.`; -} - -function buildMockCoaching(locale: string, messages: ConversationMessage[]) { - const userTurns = messages.filter((m) => m.speaker === "user"); - const totalLength = userTurns.map((m) => m.text).join(" ").length; - const score = userTurns.length === 0 ? 0 : Math.max(58, Math.min(91, 58 + Math.floor(totalLength / 20))); - - if (locale === "zh") { - return { - score, - strengths: - userTurns.length === 0 - ? [] - : totalLength > 180 - ? ["回答更加完整", "表达更清晰", "结构逐渐改善"] - : ["愿意作答", "基础表达清楚"], - improvements: - userTurns.length === 0 - ? [] - : totalLength > 180 - ? ["加入量化成果", "强化岗位相关性", "结尾更有说服力"] - : ["加入真实例子", "说明行动与结果", "补充更多细节"], - }; - } - - if (locale === "ms") { - return { - score, - strengths: - userTurns.length === 0 - ? [] - : totalLength > 180 - ? ["Jawapan semakin lengkap", "Penyampaian lebih jelas", "Struktur semakin baik"] - : ["Sedia menjawab", "Asas jawapan boleh difahami"], - improvements: - userTurns.length === 0 - ? [] - : totalLength > 180 - ? ["Tambah hasil yang boleh diukur", "Kaitkan lebih rapat dengan jawatan", "Penutup boleh lebih kuat"] - : ["Tambah contoh sebenar", "Terangkan tindakan dan hasil", "Tambah lebih banyak perincian"], - }; - } - - if (locale === "ta") { - return { - score, - strengths: - userTurns.length === 0 - ? [] - : totalLength > 180 - ? ["பதில்கள் மேலும் முழுமையாக உள்ளன", "விளக்கம் தெளிவாக உள்ளது", "அமைப்பு மேம்படுகிறது"] - : ["பதிலளிக்கும் முனைப்பு உள்ளது", "அடிப்படை கருத்து புரிகிறது"], - improvements: - userTurns.length === 0 - ? [] - : totalLength > 180 - ? ["அளவிடக்கூடிய முடிவுகளைச் சேர்க்கவும்", "பதவியுடன் தொடர்பை வலுப்படுத்தவும்", "முடிவை வலுப்படுத்தவும்"] - : ["உண்மையான உதாரணத்தைச் சேர்க்கவும்", "நடவடிக்கை மற்றும் முடிவை விளக்கவும்", "மேலும் விவரம் சேர்க்கவும்"], - }; - } - - return { - score, - strengths: - userTurns.length === 0 - ? [] - : totalLength > 180 - ? ["Answers are becoming more complete", "Communication is clearer", "Structure is improving"] - : ["Willing to engage", "Basic ideas are understandable"], - improvements: - userTurns.length === 0 - ? [] - : totalLength > 180 - ? ["Add measurable outcomes", "Tie answers closer to the role", "End more strongly"] - : ["Use a real example", "Explain actions and results", "Add more detail"], - }; -} - -export default function InterviewPage() { - const router = useRouter(); - const { t, locale } = useLanguage(); - const safeLocale = - locale === "en" || locale === "zh" || locale === "ms" || locale === "ta" - ? locale - : "en"; - - const [role, setRole] = useState("general"); - const [interviewType, setInterviewType] = useState("hr"); - const [difficulty, setDifficulty] = useState("medium"); - const [agentState, setAgentState] = useState("idle"); - const [conversation, setConversation] = useState([]); - const [textInput, setTextInput] = useState(""); - const [sessionStarted, setSessionStarted] = useState(false); - - const coaching = useMemo( - () => buildMockCoaching(safeLocale, conversation), - [safeLocale, conversation] - ); - - const [micState, setMicState] = useState<"idle" | "listening" | "processing">("idle"); - const recognizerRef = useRef<{ stop: () => void } | null>(null); - - function handleMicClick() { - if (micState === "listening") { - recognizerRef.current?.stop(); - return; +import { UserMenu } from "@/components/layout/UserMenu"; +import { useConversation } from "@11labs/react"; + +/** Fixed bar heights for speaking indicator (avoid Math.random on each render). */ +const SPEAKING_BAR_HEIGHTS_PX = [12, 20, 14, 18, 16]; + +const INTERVIEWERS = [ + { + id: "alex", + name: "Alex", + role: "Hiring Manager", + description: "Direct, practical, and focuses on your technical expertise.", + avatar: "https://i.ibb.co/bRRtgr0x/alex.jpg", + color: "bg-blue-500" + }, + { + id: "sophia", + name: "Sophia", + role: "Senior Executive Recruiter", + description: + "Warm and strategic — she explores leadership, collaboration, and motivation with behavioural questions tailored to your profile.", + avatar: "https://i.ibb.co/zH2WSZSj/sarah.jpg", + color: "bg-gold-500" } - - const SpeechRecognition = - (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; - - if (!SpeechRecognition) { - alert("Speech recognition not supported in this browser"); - return; - } - - const recognition = new SpeechRecognition(); - recognition.continuous = true; - recognition.interimResults = false; - recognition.lang = "en-US"; - - recognition.onresult = (event: any) => { - const transcript = event.results[event.results.length - 1][0].transcript; - setTextInput((prev) => (prev ? prev + " " + transcript : transcript)); +]; + +function InterviewContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const resumeId = searchParams.get("resume_id"); + + const [selectedInterviewer, setSelectedInterviewer] = useState(null); + const [isInterviewing, setIsInterviewing] = useState(false); + const [loading, setLoading] = useState(false); + const [timeLeft, setTimeLeft] = useState(120); + const [lastError, setLastError] = useState(null); + const [isIntermediate, setIsIntermediate] = useState(false); + const [isMuted, setIsMuted] = useState(false); + const [transcript, setTranscript] = useState<{ id: string; role: "user" | "agent"; text: string }[]>([]); + const transcriptLineIdRef = useRef(0); + const transcriptEndRef = useRef(null); + + const conversation = useConversation({ + onConnect: () => { + transcriptLineIdRef.current = 0; + setTranscript([]); + setIsInterviewing(true); + setLoading(false); + setLastError(null); + setIsIntermediate(false); + }, + onDisconnect: () => { + setIsInterviewing(false); + setTimeLeft(120); + setLoading(false); + setIsIntermediate(false); + }, + onError: (err: any) => { + setLastError(err.message || String(err)); + setIsIntermediate(false); + setIsInterviewing(false); + setLoading(false); + }, + onMessage: ({ role, message }) => { + const id = `t-${++transcriptLineIdRef.current}`; + setTranscript((prev) => [...prev, { id, role, text: message }]); + }, + }); + + useEffect(() => { + transcriptEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [transcript]); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isInterviewing && timeLeft > 0) { + timer = setInterval(() => { + setTimeLeft((prev) => { + if (prev <= 1) { + conversation.endSession(); + return 0; + } + return prev - 1; + }); + }, 1000); + } + return () => clearInterval(timer); + }, [isInterviewing, timeLeft, conversation]); + + const startInterview = async () => { + if (!selectedInterviewer || loading || isInterviewing || isIntermediate) return; + setLoading(true); + setLastError(null); + try { + const url = new URL("/api/interviews/session", window.location.origin); + if (resumeId) url.searchParams.set("resume_id", resumeId); + url.searchParams.set("interviewer", selectedInterviewer.id); + + const res = await fetch(url.toString()); + const data = await res.json(); + + if (!res.ok) throw new Error(data.detail || "Failed to fetch session config"); + + setTimeLeft(120); + setIsIntermediate(true); + + await conversation.startSession({ + agentId: data.agent_id, + connectionType: "websocket", + overrides: { + agent: { + prompt: { + prompt: data.dynamic_instructions + }, + firstMessage: data.first_message + }, + tts: { + ...(data.use_voice_override ? { voiceId: data.voice_id } : {}) + } + } + }); + } catch (e: any) { + console.error("Failed to start ElevenLabs session:", e); + // Check if it's a voice ID error + const errorMsg = e.message || String(e); + setLastError(errorMsg); + setIsIntermediate(false); + setLoading(false); + } }; - recognition.onerror = () => setMicState("idle"); - recognition.onend = () => setMicState("idle"); - - recognition.start(); - recognizerRef.current = recognition; - setMicState("listening"); - } - - function startSession() { - setSessionStarted(true); - setConversation([ - { - id: crypto.randomUUID(), - speaker: "system", - text: "Interview session started.", - timestamp: formatTime(), - }, - { - id: crypto.randomUUID(), - speaker: "agent", - text: getOpeningLine(safeLocale, role, interviewType), - timestamp: formatTime(), - }, - ]); - } - - function endSession() { - setSessionStarted(false); - setConversation((prev) => [ - ...prev, - { - id: crypto.randomUUID(), - speaker: "system", - text: "Interview session ended.", - timestamp: formatTime(), - }, - ]); - } - - function sendTextReply() { - if (!textInput.trim()) return; - - const userMessage: ConversationMessage = { - id: crypto.randomUUID(), - speaker: "user", - text: textInput.trim(), - timestamp: formatTime(), + const stopInterview = async () => { + await conversation.endSession(); + setIsInterviewing(false); + setTimeLeft(120); + setSelectedInterviewer(null); + transcriptLineIdRef.current = 0; + setTranscript([]); }; - setConversation((prev) => [...prev, userMessage]); - setTextInput(""); - } - - return ( -
- { - const supabase = createClient(); - await supabase.auth.signOut(); - router.push("/"); - router.refresh(); - }} - className="text-sm font-medium text-slate-600 transition-colors hover:text-navy-950" - > - {t("dash_sign_out")} - - } - /> - -
-
-

- {t("nav_interview")} -

-

- AI Interview Agent -

-

- Practice with a live Azure avatar interviewer while keeping setup, transcript, and coaching on one page. -

-
- -
- - -
-
-

AI Interview Stage

-

- Live Azure avatar on top, transcript and reply area below. -

-
- -
-
- setAgentState(state)} + {isInterviewing && ( +
+ {Math.floor(timeLeft / 60)}:{(timeLeft % 60).toString().padStart(2, "0")} +
+ )} + + VeriClause + + + +
+
-
-
- {!sessionStarted && ( -
-
-
- Waiting to start -
-

- Conversation transcript will appear here -

-

- Start the session to let the AI interviewer greet the user and begin the conversation. -

+
+ {/* Interviewer Feed */} +
+
+
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+
+

{selectedInterviewer?.name}

+

+ {selectedInterviewer?.role} +

+
+ {conversation.isSpeaking && ( +
+ {SPEAKING_BAR_HEIGHTS_PX.map((h, i) => ( +
+ ))} +
+ )} +
+
+ Host — {selectedInterviewer?.name} +
-
- )} - {conversation.length > 0 && ( -
- {conversation.map((message) => ( -
- {message.speaker === "system" ? ( -
- {message.text} -
- ) : ( -
-
- {message.speaker === "agent" ? "AI Interviewer" : "User"} - {message.timestamp} + {/* User Feed */} +
+
+
+ You +
+
+

You

+

+ Private practice — not recorded +

-

{message.text}

-
- )} -
- ))} -
- )} -
-
-
-