Recruitment_Copilot / frontend /src /components /ChatInterface.tsx
Ashgen12's picture
Recruitment Copilot
14fdc5e verified
Raw
History Blame Contribute Delete
19.7 kB
"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>
);
}