"use client"; import { ChangeEvent, FormEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { ThreeBackdrop } from "@/components/ThreeBackdrop"; import { VoiceInput } from "@/components/VoiceInput"; import { parseSSEStream } from "@/lib/stream"; import { ChatMessage, GenUiPayload } from "@/lib/types"; // Crayon UI's ThemeProvider injects CSS variables that differ between SSR and the // first client render, which trips Next.js' hydration check. Loading it client-only // avoids the mismatch without losing any functionality. const GenUiRenderer = dynamic( () => import("@/components/GenUiRenderer").then((mod) => mod.GenUiRenderer), { ssr: false } ); type QuickAction = { label: string; prompt: string; }; const QUICK_PROMPTS: QuickAction[] = [ { label: "Find top candidates for senior Python engineer in Bangalore", prompt: "Find top candidates for senior Python engineer in Bangalore" }, { label: "Check company policies", prompt: "Check company policies" }, { label: "Create job posting", prompt: "Create job posting" }, { label: "Schedule interview", prompt: "Schedule interview" }, { label: "Draft email", prompt: "Draft email" } ]; const STARTER_ASSISTANT_MESSAGE = "Recruitment copilot is ready. Attach resume PDFs or ask for policy checks, candidate ranking, interview scheduling."; function makeId(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); } return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } function AttachIcon() { return ( ); } function MicIcon() { return ( ); } function SendIcon() { return ( ); } function PulseIcon() { return ( ); } function PlusIcon() { return ( ); } export function ChatInterface() { const [messages, setMessages] = useState([ { id: makeId(), role: "assistant", text: STARTER_ASSISTANT_MESSAGE } ]); const [input, setInput] = useState(""); const [isStreaming, setIsStreaming] = useState(false); const [isUploadingFiles, setIsUploadingFiles] = useState(false); const [pendingFiles, setPendingFiles] = useState([]); const [error, setError] = useState(null); const bottomRef = useRef(null); const fileInputRef = useRef(null); const textareaRef = useRef(null); const autoGrow = () => { const node = textareaRef.current; if (!node) { return; } node.style.height = "0px"; const next = Math.min(node.scrollHeight, 168); node.style.height = `${Math.max(36, next)}px`; }; useEffect(() => { autoGrow(); }, [input]); const userTurnCount = useMemo( () => messages.filter((item) => item.role === "user").length, [messages] ); const assistantTurnCount = useMemo( () => messages.filter((item) => item.role === "assistant").length, [messages] ); const scrollToBottom = () => { bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }; const patchAssistant = (assistantId: string, patch: Partial) => { setMessages((prev) => prev.map((item) => (item.id === assistantId ? { ...item, ...patch } : item)) ); }; const appendAssistantText = (assistantId: string, delta: string) => { setMessages((prev) => prev.map((item) => { if (item.id !== assistantId) { return item; } return { ...item, text: `${item.text}${delta}`, isStreaming: true }; }) ); }; const sendMessage = async (messageText: string) => { const cleaned = messageText.trim(); if (!cleaned || isStreaming) { return; } setError(null); setInput(""); const userMessage: ChatMessage = { id: makeId(), role: "user", text: cleaned }; const assistantId = makeId(); const placeholder: ChatMessage = { id: assistantId, role: "assistant", text: "", isStreaming: true }; setMessages((prev) => [...prev, userMessage, placeholder]); setIsStreaming(true); try { const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: cleaned, user_id: "browser-user", session_id: "browser-session", top_k: 8 }) }); if (!response.ok || !response.body) { const text = await response.text(); throw new Error(text || "Backend streaming failed."); } for await (const event of parseSSEStream(response.body)) { if (event.event === "token") { appendAssistantText(assistantId, String(event.data.delta ?? "")); scrollToBottom(); continue; } if (event.event === "genui") { patchAssistant(assistantId, { genui: event.data as GenUiPayload }); continue; } if (event.event === "done") { patchAssistant(assistantId, { text: String(event.data.message ?? ""), isStreaming: false }); continue; } if (event.event === "error") { throw new Error(String(event.data.message ?? "Unknown streaming error")); } } patchAssistant(assistantId, { isStreaming: false }); } catch (caught) { const message = caught instanceof Error ? caught.message : "Unexpected error"; setError(message); patchAssistant(assistantId, { text: `Error: ${message}`, isStreaming: false }); } finally { setIsStreaming(false); scrollToBottom(); } }; const uploadResume = async (file: File) => { const formData = new FormData(); formData.append("file", file); const response = await fetch(`/api/upload?session_id=${encodeURIComponent("browser-session")}`, { method: "POST", body: formData }); const text = await response.text(); if (!response.ok) { throw new Error(text || "Upload failed"); } const payload = JSON.parse(text) as { candidate_id: string; file_name: string; chunks_indexed: number; message: string; }; return payload; }; const submitTurn = async (messageText: string) => { if (isStreaming || isUploadingFiles) { return; } setError(null); const cleaned = messageText.trim(); let uploadedFileNames: string[] = []; if (pendingFiles.length > 0) { setIsUploadingFiles(true); try { const uploadResults = []; for (const file of pendingFiles) { const result = await uploadResume(file); uploadResults.push(result); } uploadedFileNames = uploadResults.map((item) => item.file_name); const profileCards = await Promise.all( uploadResults.map(async (item) => { try { const detailRes = await fetch( `/api/candidates/${encodeURIComponent(item.candidate_id)}` ); if (!detailRes.ok) { return null; } const detail = (await detailRes.json()) as { profile?: GenUiPayload }; return detail.profile?.cards?.[0] ?? null; } catch { return null; } }) ); const cards = profileCards.filter( (card): card is NonNullable => Boolean(card) ); const summary = `Ingested ${uploadResults.length} file(s): ${uploadedFileNames.join(", ")}. Use the cards below to open or shortlist the new candidate(s).`; setMessages((prev) => [ ...prev, { id: makeId(), role: "assistant", text: summary, genui: cards.length ? { summary, cards } : undefined } ]); setPendingFiles([]); } catch (caught) { const message = caught instanceof Error ? caught.message : "Upload failed"; setError(message); setIsUploadingFiles(false); return; } finally { setIsUploadingFiles(false); } } const finalPrompt = cleaned || (uploadedFileNames.length > 0 ? `I uploaded these resumes: ${uploadedFileNames.join(", ")}. Please shortlist top candidates now.` : ""); if (!finalPrompt) { return; } await sendMessage(finalPrompt); }; const onFileSelection = (event: ChangeEvent) => { const files = Array.from(event.target.files ?? []); const validPdfFiles = files.filter((file) => file.name.toLowerCase().endsWith(".pdf")); const invalidCount = files.length - validPdfFiles.length; if (invalidCount > 0) { setError("Only PDF files are supported for resume upload."); } if (validPdfFiles.length > 0) { setPendingFiles((prev) => { const merged = [...prev, ...validPdfFiles]; const seen = new Set(); return merged.filter((file) => { const key = `${file.name}-${file.size}-${file.lastModified}`; if (seen.has(key)) { return false; } seen.add(key); return true; }); }); } event.target.value = ""; }; const removePendingFile = (target: File) => { setPendingFiles((prev) => prev.filter( (file) => !( file.name === target.name && file.size === target.size && file.lastModified === target.lastModified ) ) ); }; const onSubmit = (event: FormEvent) => { event.preventDefault(); void submitTurn(input); }; const startNewChat = () => { if (isStreaming || isUploadingFiles) { return; } setMessages([ { id: makeId(), role: "assistant", text: STARTER_ASSISTANT_MESSAGE } ]); setPendingFiles([]); setInput(""); setError(null); }; return (

Recruitment Copilot

{isStreaming ? "Live stream" : "Realtime connected"}
{messages.map((message) => { const showThinking = message.role === "assistant" && message.isStreaming && !message.text.trim() && !message.genui; return (
{message.role === "assistant" ? ( showThinking ? ( ) : ( { void sendMessage(llmFriendlyMessage || humanFriendlyMessage); }} /> ) ) : (

{message.text}

)}
); })}
{pendingFiles.length > 0 ? (
{pendingFiles.map((file) => (
{file.name}
))}
) : null}