Spaces:
Sleeping
Sleeping
| "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 ( | |
| <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> | |
| <path | |
| d="M9.5 17.5l7.1-7.1a3 3 0 10-4.2-4.2l-7.4 7.4a5 5 0 107.1 7.1l6.3-6.3" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth="1.8" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| /> | |
| </svg> | |
| ); | |
| } | |
| function MicIcon() { | |
| return ( | |
| <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> | |
| <path | |
| d="M12 3a3 3 0 00-3 3v5a3 3 0 006 0V6a3 3 0 00-3-3zm6 8a1 1 0 112 0 8 8 0 01-7 7.94V21h3a1 1 0 110 2H8a1 1 0 010-2h3v-2.06A8 8 0 014 11a1 1 0 112 0 6 6 0 0012 0z" | |
| fill="currentColor" | |
| /> | |
| </svg> | |
| ); | |
| } | |
| function SendIcon() { | |
| return ( | |
| <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> | |
| <path | |
| d="M4 12l15-8-3 8 3 8-15-8zm2.8 0l7.8 4.2-1.6-4.2 1.6-4.2L6.8 12z" | |
| fill="currentColor" | |
| /> | |
| </svg> | |
| ); | |
| } | |
| function PulseIcon() { | |
| return ( | |
| <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> | |
| <path | |
| d="M3 12h4l2.2-4 3.6 8 2.1-4H21" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth="1.8" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| /> | |
| </svg> | |
| ); | |
| } | |
| function PlusIcon() { | |
| return ( | |
| <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"> | |
| <path | |
| d="M12 5v14M5 12h14" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth="1.9" | |
| strokeLinecap="round" | |
| /> | |
| </svg> | |
| ); | |
| } | |
| export function ChatInterface() { | |
| const [messages, setMessages] = useState<ChatMessage[]>([ | |
| { | |
| 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<File[]>([]); | |
| const [error, setError] = useState<string | null>(null); | |
| const bottomRef = useRef<HTMLDivElement | null>(null); | |
| const fileInputRef = useRef<HTMLInputElement | null>(null); | |
| const textareaRef = useRef<HTMLTextAreaElement | null>(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<ChatMessage>) => { | |
| 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<typeof card> => 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<HTMLInputElement>) => { | |
| 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<string>(); | |
| 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<HTMLFormElement>) => { | |
| 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 ( | |
| <main className="workspace-shell"> | |
| <div className="three-layer" aria-hidden="true"> | |
| <ThreeBackdrop /> | |
| </div> | |
| <div className="workspace-frame"> | |
| <aside className="left-sidebar"> | |
| <div className="sidebar-brand"> | |
| <p className="brand-eyebrow">Recruitment OS</p> | |
| <h1>Control Hub</h1> | |
| <p> | |
| Enterprise assistant for candidate search, policy guidance, interview operations, and posting | |
| generation. | |
| </p> | |
| </div> | |
| <button | |
| type="button" | |
| className="sidebar-new-chat" | |
| disabled={isStreaming || isUploadingFiles} | |
| onClick={startNewChat} | |
| > | |
| <PlusIcon /> | |
| <span>New chat</span> | |
| </button> | |
| <section className="sidebar-section" aria-label="Quick actions"> | |
| <h2>Quick Actions</h2> | |
| <div className="sidebar-prompt-list"> | |
| {QUICK_PROMPTS.map((action) => ( | |
| <button | |
| key={action.label} | |
| type="button" | |
| className="sidebar-prompt" | |
| disabled={isStreaming || isUploadingFiles} | |
| onClick={() => { | |
| void submitTurn(action.prompt); | |
| }} | |
| > | |
| {action.label} | |
| </button> | |
| ))} | |
| </div> | |
| </section> | |
| <section className="sidebar-section"> | |
| <h2>Session</h2> | |
| <p className="sidebar-meta">User turns: {userTurnCount}</p> | |
| <p className="sidebar-meta">Assistant turns: {assistantTurnCount}</p> | |
| <p className="sidebar-meta">State: {isStreaming ? "Streaming response" : "Idle"}</p> | |
| <p className="sidebar-meta">Queued resumes: {pendingFiles.length}</p> | |
| </section> | |
| </aside> | |
| <section className="chat-main"> | |
| <header className="chat-header"> | |
| <h2>Recruitment Copilot</h2> | |
| <span className={`runtime-pill ${isStreaming ? "busy" : "ready"}`}> | |
| {isStreaming ? "Live stream" : "Realtime connected"} | |
| </span> | |
| </header> | |
| <div className="chatbox-shell"> | |
| <section className="conversation-surface"> | |
| <div className="messages"> | |
| {messages.map((message) => { | |
| const showThinking = | |
| message.role === "assistant" && | |
| message.isStreaming && | |
| !message.text.trim() && | |
| !message.genui; | |
| return ( | |
| <div key={message.id} className={`message-row ${message.role}`}> | |
| <div className="bubble"> | |
| {message.role === "assistant" ? ( | |
| showThinking ? ( | |
| <span className="assistant-thinking" aria-label="Thinking"> | |
| <span className="dot" /> | |
| <span className="dot" /> | |
| <span className="dot" /> | |
| </span> | |
| ) : ( | |
| <GenUiRenderer | |
| text={message.text} | |
| payload={message.genui} | |
| isStreaming={Boolean(message.isStreaming)} | |
| onAction={(llmFriendlyMessage, humanFriendlyMessage) => { | |
| void sendMessage(llmFriendlyMessage || humanFriendlyMessage); | |
| }} | |
| /> | |
| ) | |
| ) : ( | |
| <p className="user-message-text">{message.text}</p> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| <div ref={bottomRef} /> | |
| </div> | |
| </section> | |
| <section className="composer-dock"> | |
| {pendingFiles.length > 0 ? ( | |
| <div className="pending-files" aria-label="Pending file uploads"> | |
| {pendingFiles.map((file) => ( | |
| <div | |
| key={`${file.name}-${file.size}-${file.lastModified}`} | |
| className="file-chip" | |
| title={file.name} | |
| > | |
| <span className="file-chip-name">{file.name}</span> | |
| <button | |
| type="button" | |
| className="file-chip-remove" | |
| onClick={() => removePendingFile(file)} | |
| > | |
| x | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| ) : null} | |
| <form onSubmit={onSubmit}> | |
| <div className="thesys-input-shell"> | |
| <button | |
| type="button" | |
| className="icon-btn" | |
| title="Attach PDF resumes" | |
| disabled={isStreaming || isUploadingFiles} | |
| onClick={() => fileInputRef.current?.click()} | |
| > | |
| <AttachIcon /> | |
| </button> | |
| <textarea | |
| ref={textareaRef} | |
| className="text-input compact" | |
| value={input} | |
| rows={1} | |
| disabled={isStreaming || isUploadingFiles} | |
| placeholder="Message the recruitment copilot" | |
| onChange={(event) => setInput(event.target.value)} | |
| onKeyDown={(event) => { | |
| if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { | |
| event.preventDefault(); | |
| void submitTurn(input); | |
| } | |
| }} | |
| /> | |
| <VoiceInput | |
| className="icon-btn" | |
| listeningContent={<PulseIcon />} | |
| title="Speak your prompt" | |
| disabled={isStreaming || isUploadingFiles} | |
| onTranscript={(text) => { | |
| setInput((prev) => (prev ? `${prev} ${text}` : text)); | |
| }} | |
| > | |
| <MicIcon /> | |
| </VoiceInput> | |
| <button | |
| className="send-btn-round" | |
| type="submit" | |
| title="Send message" | |
| disabled={isStreaming || isUploadingFiles || (!input.trim() && pendingFiles.length === 0)} | |
| > | |
| {isStreaming ? <PulseIcon /> : <SendIcon />} | |
| </button> | |
| </div> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="application/pdf" | |
| multiple | |
| style={{ display: "none" }} | |
| onChange={onFileSelection} | |
| /> | |
| </form> | |
| {isUploadingFiles ? ( | |
| <div className="dock-foot"> | |
| <span className="side-copy">Uploading attached resumes…</span> | |
| </div> | |
| ) : null} | |
| {error ? <p className="error-copy">{error}</p> : null} | |
| </section> | |
| </div> | |
| </section> | |
| </div> | |
| </main> | |
| ); | |
| } | |