| import { useState, useEffect, useRef } from "react"; |
| import { useNavigate } from "react-router"; |
| import { |
| Send, |
| Plus, |
| Trash2, |
| LogOut, |
| Menu, |
| X, |
| MessageSquare, |
| User, |
| Bot, |
| Loader2, |
| Database, |
| Eraser, |
| } from "lucide-react"; |
| import ReactMarkdown from "react-markdown"; |
| import remarkGfm from "remark-gfm"; |
| import remarkMath from "remark-math"; |
| import rehypeKatex from "rehype-katex"; |
| import type { Components } from "react-markdown"; |
| import KnowledgeManagement from "./KnowledgeManagement"; |
| import PhaseToggle, { type Phase } from "./interview/PhaseToggle"; |
| import InterviewPanel from "./interview/InterviewPanel"; |
| import InterviewResultView from "./interview/InterviewResultView"; |
| import NewChatOnboarding from "./NewChatOnboarding"; |
| import type { InterviewResult } from "../../services/interviewApi"; |
| import { getInterviewResult } from "../../services/interviewApi"; |
| import { |
| getRooms, |
| getRoom, |
| createRoom, |
| deleteRoom, |
| clearRoomMessages, |
| streamChat, |
| type ChatSource, |
| } from "../../services/api"; |
|
|
| interface StoredUser { |
| user_id: string; |
| email: string; |
| name: string; |
| loginTime: string; |
| } |
|
|
| interface Message { |
| id: string; |
| role: "user" | "assistant"; |
| content: string; |
| timestamp: number; |
| sources?: ChatSource[]; |
| } |
|
|
| interface ChatRoom { |
| id: string; |
| title: string; |
| messages: Message[]; |
| createdAt: string; |
| updatedAt: string | null; |
| messagesLoaded: boolean; |
| } |
|
|
| |
| function preprocessMarkdown(content: string): string { |
| |
| let result = content.replace(/\|\|/g, "|\n|"); |
|
|
| |
| const lines = result.split("\n"); |
| const processed = lines.map((line) => { |
| const tableStart = line.indexOf("|"); |
| if (tableStart > 0) { |
| const tableContent = line.slice(tableStart); |
| |
| if ((tableContent.match(/\|/g) ?? []).length >= 2) { |
| return line.slice(0, tableStart).trimEnd() + "\n\n" + tableContent; |
| } |
| } |
| return line; |
| }); |
| result = processed.join("\n"); |
|
|
| |
| result = result.replace(/([^|\n])\n(\|)/g, "$1\n\n$2"); |
|
|
| return result; |
| } |
|
|
| |
| const markdownComponents: Components = { |
| p: ({ children }) => ( |
| <p className="text-sm mb-2 last:mb-0 leading-relaxed">{children}</p> |
| ), |
| h1: ({ children }) => ( |
| <h1 className="text-lg font-bold mb-3 mt-4 first:mt-0">{children}</h1> |
| ), |
| h2: ({ children }) => ( |
| <h2 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h2> |
| ), |
| h3: ({ children }) => ( |
| <h3 className="text-sm font-semibold mb-2 mt-2 first:mt-0">{children}</h3> |
| ), |
| ul: ({ children }) => ( |
| <ul className="list-disc pl-5 mb-2 space-y-1 text-sm">{children}</ul> |
| ), |
| ol: ({ children }) => ( |
| <ol className="list-decimal pl-5 mb-2 space-y-1 text-sm">{children}</ol> |
| ), |
| li: ({ children }) => <li className="text-sm leading-relaxed">{children}</li>, |
| code: ({ children, className }) => { |
| const isBlock = className?.startsWith("language-"); |
| if (isBlock) { |
| return ( |
| <code className="block text-xs font-mono text-slate-100 leading-relaxed"> |
| {children} |
| </code> |
| ); |
| } |
| return ( |
| <code className="bg-slate-100 text-pink-600 px-1.5 py-0.5 rounded text-xs font-mono"> |
| {children} |
| </code> |
| ); |
| }, |
| pre: ({ children }) => ( |
| <pre className="bg-slate-900 rounded-lg p-3 mb-2 mt-1 overflow-x-auto text-xs"> |
| {children} |
| </pre> |
| ), |
| blockquote: ({ children }) => ( |
| <blockquote className="border-l-4 border-slate-300 pl-3 text-slate-500 italic mb-2 text-sm"> |
| {children} |
| </blockquote> |
| ), |
| table: ({ children }) => ( |
| <div className="overflow-x-auto mb-2"> |
| <table className="w-full text-sm border-collapse">{children}</table> |
| </div> |
| ), |
| th: ({ children }) => ( |
| <th className="border border-slate-200 px-3 py-1.5 bg-slate-100 font-medium text-left text-xs"> |
| {children} |
| </th> |
| ), |
| td: ({ children }) => ( |
| <td className="border border-slate-200 px-3 py-1.5 text-xs">{children}</td> |
| ), |
| a: ({ children, href }) => ( |
| <a |
| href={href} |
| target="_blank" |
| rel="noopener noreferrer" |
| className="text-blue-600 underline hover:text-blue-800" |
| > |
| {children} |
| </a> |
| ), |
| strong: ({ children }) => ( |
| <strong className="font-semibold">{children}</strong> |
| ), |
| hr: () => <hr className="border-slate-200 my-3" />, |
| }; |
|
|
| |
| function useLoadingMessages() { |
| const [messages, setMessages] = useState<string[]>([]); |
|
|
| useEffect(() => { |
| fetch("/loading-messages.yaml") |
| .then((r) => r.text()) |
| .then((text) => { |
| const parsed = text |
| .split("\n") |
| .filter((l) => l.trimStart().startsWith("- ")) |
| .map((l) => l.replace(/^\s*- /, "").trim()) |
| .filter(Boolean); |
| if (parsed.length > 0) setMessages(parsed); |
| }) |
| .catch(() => {}); |
| }, []); |
|
|
| return messages; |
| } |
|
|
| function TypingIndicator() { |
| const messages = useLoadingMessages(); |
| const [index, setIndex] = useState(0); |
|
|
| useEffect(() => { |
| if (messages.length === 0) return; |
| setIndex(Math.floor(Math.random() * messages.length)); |
| const id = setInterval(() => { |
| setIndex((prev) => { |
| let next: number; |
| do { next = Math.floor(Math.random() * messages.length); } while (messages.length > 1 && next === prev); |
| return next; |
| }); |
| }, 300); |
| return () => clearInterval(id); |
| }, [messages]); |
|
|
| if (messages.length === 0) { |
| return ( |
| <div className="flex gap-1.5 items-center py-1 px-0.5"> |
| <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "0ms", animationDuration: "1s" }} /> |
| <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "200ms", animationDuration: "1s" }} /> |
| <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "400ms", animationDuration: "1s" }} /> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div className="flex items-center gap-2 py-1 px-0.5 text-sm text-slate-400 italic"> |
| <span className="inline-block w-2 h-2 rounded-full bg-slate-400 animate-pulse" /> |
| <span>{messages[index]}…</span> |
| </div> |
| ); |
| } |
|
|
| export default function Main() { |
| const navigate = useNavigate(); |
| const [sidebarOpen, setSidebarOpen] = useState(true); |
| const [chats, setChats] = useState<ChatRoom[]>([]); |
| const [currentChatId, setCurrentChatId] = useState<string | null>(null); |
| const [input, setInput] = useState(""); |
| const [isStreaming, setIsStreaming] = useState(false); |
| const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null); |
| const [roomsLoading, setRoomsLoading] = useState(false); |
| const [roomsError, setRoomsError] = useState<string | null>(null); |
| const messagesEndRef = useRef<HTMLDivElement>(null); |
| const [user, setUser] = useState<StoredUser | null>(null); |
| const [knowledgeOpen, setKnowledgeOpen] = useState(false); |
| const [currentPhase, setCurrentPhase] = useState<Phase>("interview"); |
| const [interviewResult, setInterviewResult] = useState<InterviewResult | null>(null); |
| const [resultModalOpen, setResultModalOpen] = useState(false); |
| const abortControllerRef = useRef<AbortController | null>(null); |
|
|
| useEffect(() => { |
| const storedUser = localStorage.getItem("chatbot_user"); |
| if (storedUser) { |
| const parsedUser: StoredUser = JSON.parse(storedUser); |
| setUser(parsedUser); |
| loadRooms(parsedUser.user_id); |
| } |
| }, []); |
|
|
| useEffect(() => { |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
| }, [currentChatId, chats]); |
|
|
| useEffect(() => { |
| if (!currentChatId) return; |
| const chat = chats.find((c) => c.id === currentChatId); |
| if (chat && !chat.messagesLoaded) { |
| loadRoomMessages(currentChatId); |
| } |
| |
| const savedPhase = localStorage.getItem(`phase_${currentChatId}`); |
| if (savedPhase === "analytics" || savedPhase === "interview") { |
| setCurrentPhase(savedPhase); |
| } else { |
| const interviewRaw = localStorage.getItem(`interview_${currentChatId}`); |
| if (interviewRaw) { |
| try { |
| const iv = JSON.parse(interviewRaw) as { status?: string }; |
| setCurrentPhase(iv.status === "completed" ? "analytics" : "interview"); |
| } catch { |
| setCurrentPhase("interview"); |
| } |
| } else { |
| setCurrentPhase("interview"); |
| } |
| } |
| |
| const interviewRaw2 = localStorage.getItem(`interview_${currentChatId}`); |
| let localResult: InterviewResult | null = null; |
| if (interviewRaw2) { |
| try { |
| const iv = JSON.parse(interviewRaw2) as { result?: InterviewResult }; |
| localResult = iv.result ?? null; |
| } catch { |
| localResult = null; |
| } |
| } |
| if (localResult) { |
| setInterviewResult(localResult); |
| } else { |
| |
| getInterviewResult(currentChatId) |
| .then(setInterviewResult) |
| .catch(() => setInterviewResult(null)); |
| } |
| |
| }, [currentChatId]); |
|
|
| const handlePhaseChange = (phase: Phase) => { |
| setCurrentPhase(phase); |
| if (currentChatId) localStorage.setItem(`phase_${currentChatId}`, phase); |
| }; |
|
|
| const handleInterviewComplete = () => { |
| setCurrentPhase("analytics"); |
| if (currentChatId) localStorage.setItem(`phase_${currentChatId}`, "analytics"); |
| }; |
|
|
| const handleOnboardingStart = async () => { |
| if (!user) return; |
| const res = await createRoom(user.user_id, "New Session"); |
| const newRoom: ChatRoom = { |
| id: res.data.id, |
| title: res.data.title, |
| messages: [], |
| createdAt: res.data.created_at, |
| updatedAt: res.data.updated_at, |
| messagesLoaded: true, |
| }; |
| setChats((prev) => [newRoom, ...prev]); |
| setCurrentChatId(newRoom.id); |
| setCurrentPhase("interview"); |
| localStorage.setItem(`phase_${newRoom.id}`, "interview"); |
| }; |
|
|
| const loadRooms = async (userId: string) => { |
| setRoomsLoading(true); |
| setRoomsError(null); |
| try { |
| const apiRooms = await getRooms(userId); |
| const mapped: ChatRoom[] = apiRooms.map((r) => ({ |
| id: r.id, |
| title: r.title, |
| messages: [], |
| createdAt: r.created_at, |
| updatedAt: r.updated_at, |
| messagesLoaded: false, |
| })); |
| setChats(mapped); |
| if (mapped.length > 0) { |
| setCurrentChatId(mapped[0].id); |
| } |
| } catch (err) { |
| setRoomsError( |
| err instanceof Error ? err.message : "Failed to load chats" |
| ); |
| } finally { |
| setRoomsLoading(false); |
| } |
| }; |
|
|
| const loadRoomMessages = async (roomId: string) => { |
| try { |
| const detail = await getRoom(roomId); |
| const messages: Message[] = detail.messages.map((m) => ({ |
| id: m.id, |
| role: m.role, |
| content: m.content, |
| timestamp: new Date(m.created_at).getTime(), |
| sources: m.sources ?? [], |
| })); |
| setChats((prev) => |
| prev.map((chat) => |
| chat.id === roomId |
| ? { ...chat, messages, messagesLoaded: true } |
| : chat |
| ) |
| ); |
| } catch { |
| setChats((prev) => |
| prev.map((chat) => |
| chat.id === roomId ? { ...chat, messagesLoaded: true } : chat |
| ) |
| ); |
| } |
| }; |
|
|
| const currentChat = chats.find((chat) => chat.id === currentChatId); |
|
|
| const createNewChat = () => { |
| setCurrentChatId(null); |
| }; |
|
|
| const deleteChat = async (chatId: string) => { |
| if (!user) return; |
| try { |
| await deleteRoom(chatId, user.user_id); |
| } catch { |
| return; |
| } |
| const updatedChats = chats.filter((chat) => chat.id !== chatId); |
| setChats(updatedChats); |
| if (currentChatId === chatId) { |
| setCurrentChatId(updatedChats.length > 0 ? updatedChats[0].id : null); |
| } |
| }; |
|
|
| const deleteAllChats = async () => { |
| if (!user) return; |
| await Promise.allSettled( |
| chats.map((chat) => deleteRoom(chat.id, user.user_id)) |
| ); |
| setChats([]); |
| setCurrentChatId(null); |
| }; |
|
|
| const handleClearChat = async () => { |
| if (!currentChatId || !user || isStreaming) return; |
| try { |
| await clearRoomMessages(currentChatId, user.user_id); |
| setChats((prev) => |
| prev.map((chat) => |
| chat.id === currentChatId ? { ...chat, messages: [] } : chat |
| ) |
| ); |
| } catch { } |
| }; |
|
|
| const handleLogout = () => { |
| localStorage.removeItem("chatbot_user"); |
| navigate("/login"); |
| }; |
|
|
| const handleSend = async () => { |
| if (!input.trim() || isStreaming || !user) return; |
|
|
| let roomId = currentChatId; |
|
|
| if (!roomId) { |
| try { |
| const res = await createRoom(user.user_id, input.slice(0, 50)); |
| const newRoom: ChatRoom = { |
| id: res.data.id, |
| title: res.data.title, |
| messages: [], |
| createdAt: res.data.created_at, |
| updatedAt: res.data.updated_at, |
| messagesLoaded: true, |
| }; |
| setChats((prev) => [newRoom, ...prev]); |
| roomId = newRoom.id; |
| setCurrentChatId(roomId); |
| } catch { |
| return; |
| } |
| } |
|
|
| const userMessage: Message = { |
| id: crypto.randomUUID(), |
| role: "user", |
| content: input, |
| timestamp: Date.now(), |
| }; |
|
|
| setChats((prev) => |
| prev.map((chat) => |
| chat.id === roomId |
| ? { |
| ...chat, |
| messages: [...chat.messages, userMessage], |
| updatedAt: new Date().toISOString(), |
| } |
| : chat |
| ) |
| ); |
|
|
| const sentMessage = input; |
| setInput(""); |
| setIsStreaming(true); |
|
|
| const assistantMsgId = crypto.randomUUID(); |
| setStreamingMsgId(assistantMsgId); |
|
|
| setChats((prev) => |
| prev.map((chat) => |
| chat.id === roomId |
| ? { |
| ...chat, |
| messages: [ |
| ...chat.messages, |
| { |
| id: assistantMsgId, |
| role: "assistant", |
| content: "", |
| timestamp: Date.now(), |
| sources: [], |
| }, |
| ], |
| } |
| : chat |
| ) |
| ); |
|
|
| abortControllerRef.current = new AbortController(); |
|
|
| try { |
| const response = await streamChat(user.user_id, roomId, sentMessage); |
|
|
| if (!response.body) throw new Error("No response body"); |
|
|
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ""; |
| let currentEvent = ""; |
| let streamDone = false; |
|
|
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done || streamDone) break; |
|
|
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split(/\r?\n/); |
| buffer = lines.pop() ?? ""; |
|
|
| for (const line of lines) { |
| if (line.startsWith("event:")) { |
| currentEvent = line.replace("event:", "").trim(); |
| } else if (line.startsWith("data:")) { |
| const data = line.replace(/^data: ?/, ""); |
|
|
| if (currentEvent === "sources" && data) { |
| try { |
| const sources: ChatSource[] = JSON.parse(data); |
| setChats((prev) => |
| prev.map((chat) => |
| chat.id === roomId |
| ? { |
| ...chat, |
| messages: chat.messages.map((m) => |
| m.id === assistantMsgId ? { ...m, sources } : m |
| ), |
| } |
| : chat |
| ) |
| ); |
| } catch { |
| |
| } |
| } else if (currentEvent === "chunk" && data) { |
| setChats((prev) => |
| prev.map((chat) => |
| chat.id === roomId |
| ? { |
| ...chat, |
| messages: chat.messages.map((m) => |
| m.id === assistantMsgId |
| ? { ...m, content: m.content + data } |
| : m |
| ), |
| } |
| : chat |
| ) |
| ); |
| } else if (currentEvent === "message" && data) { |
| setChats((prev) => |
| prev.map((chat) => |
| chat.id === roomId |
| ? { |
| ...chat, |
| messages: chat.messages.map((m) => |
| m.id === assistantMsgId |
| ? { ...m, content: data } |
| : m |
| ), |
| } |
| : chat |
| ) |
| ); |
| } else if (currentEvent === "done") { |
| streamDone = true; |
| break; |
| } |
| } |
| } |
| } |
| |
| |
| await loadRoomMessages(roomId); |
| } catch (err: unknown) { |
| if ((err as Error).name !== "AbortError") { |
| setChats((prev) => |
| prev.map((chat) => |
| chat.id === roomId |
| ? { |
| ...chat, |
| messages: chat.messages.map((m) => |
| m.id === assistantMsgId |
| ? { |
| ...m, |
| content: |
| "Sorry, I couldn't get a response. Please try again.", |
| } |
| : m |
| ), |
| } |
| : chat |
| ) |
| ); |
| } |
| } finally { |
| setIsStreaming(false); |
| setStreamingMsgId(null); |
| abortControllerRef.current = null; |
| } |
| }; |
|
|
| const handleKeyPress = (e: React.KeyboardEvent) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| handleSend(); |
| } |
| }; |
|
|
| return ( |
| <div className="flex h-screen bg-slate-50"> |
| {/* Sidebar */} |
| <div |
| className={`${ |
| sidebarOpen ? "w-64" : "w-0" |
| } bg-gradient-to-b from-[#059669] to-[#047857] text-white transition-all duration-300 flex flex-col overflow-hidden`} |
| > |
| <div className="p-3 border-b border-white/20"> |
| <button |
| onClick={createNewChat} |
| className="w-full flex items-center justify-center gap-2 bg-white/20 hover:bg-white/30 px-3 py-2 rounded-lg transition text-sm" |
| > |
| <Plus className="w-4 h-4" /> |
| New Chat |
| </button> |
| </div> |
| |
| <div className="flex-1 overflow-y-auto p-2 space-y-1"> |
| {roomsLoading ? ( |
| <div className="flex justify-center py-4"> |
| <Loader2 className="w-4 h-4 animate-spin text-white/70" /> |
| </div> |
| ) : roomsError ? ( |
| <p className="text-xs text-red-200 text-center px-2 py-2"> |
| {roomsError} |
| </p> |
| ) : ( |
| chats.map((chat) => ( |
| <div |
| key={chat.id} |
| className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition group ${ |
| currentChatId === chat.id |
| ? "bg-white/25" |
| : "hover:bg-white/15" |
| }`} |
| > |
| <MessageSquare className="w-3.5 h-3.5 flex-shrink-0" /> |
| <div |
| className="flex-1 truncate text-sm" |
| onClick={() => setCurrentChatId(chat.id)} |
| > |
| {chat.title} |
| </div> |
| <button |
| onClick={(e) => { |
| e.stopPropagation(); |
| deleteChat(chat.id); |
| }} |
| className="opacity-0 group-hover:opacity-100 transition p-1 rounded hover:bg-white/20 cursor-pointer text-red-200 hover:text-white" |
| > |
| <Trash2 className="w-3.5 h-3.5" /> |
| </button> |
| </div> |
| )) |
| )} |
| </div> |
| |
| <div className="border-t border-white/20 p-3 space-y-2"> |
| {chats.length > 0 && ( |
| <button |
| onClick={deleteAllChats} |
| className="w-full flex items-center justify-center gap-2 text-red-100 hover:text-white px-3 py-2 rounded-lg hover:bg-white/15 transition text-xs" |
| > |
| <Trash2 className="w-3.5 h-3.5" /> |
| Clear All Chats |
| </button> |
| )} |
| |
| <div className="flex items-center gap-2 p-2 rounded-lg bg-white/20"> |
| <div className="w-7 h-7 bg-white/30 rounded-full flex items-center justify-center"> |
| <User className="w-3.5 h-3.5" /> |
| </div> |
| <div className="flex-1 min-w-0"> |
| <div className="text-xs truncate">{user?.name}</div> |
| <div className="text-[10px] text-white/70 truncate"> |
| {user?.email} |
| </div> |
| </div> |
| <button |
| onClick={handleLogout} |
| className="text-white/70 hover:text-white transition" |
| title="Logout" |
| > |
| <LogOut className="w-3.5 h-3.5" /> |
| </button> |
| </div> |
| </div> |
| </div> |
| |
| {/* Main Content */} |
| <div className="flex-1 flex flex-col min-w-0 relative"> |
| |
| {/* ── Subtle background — harmonizes with Login page ── */} |
| <div className="absolute inset-0 pointer-events-none z-0 overflow-hidden"> |
| {/* Dot grid */} |
| <div |
| className="absolute inset-0 opacity-[0.13]" |
| style={{ |
| backgroundImage: "radial-gradient(circle, #94a3b8 1px, transparent 1px)", |
| backgroundSize: "28px 28px", |
| }} |
| /> |
| {/* Cyan orb — top right */} |
| <div |
| className="absolute -top-28 -right-28 w-80 h-80 rounded-full blur-3xl opacity-[0.08]" |
| style={{ background: "#0ea5e9" }} |
| /> |
| {/* Orange orb — bottom left */} |
| <div |
| className="absolute -bottom-28 -left-28 w-96 h-96 rounded-full blur-3xl opacity-[0.07]" |
| style={{ background: "#f97316" }} |
| /> |
| {/* Green orb — center left */} |
| <div |
| className="absolute top-1/2 -left-32 w-64 h-64 rounded-full blur-3xl opacity-[0.05]" |
| style={{ background: "#10b981" }} |
| /> |
| {/* Neural network — bottom right */} |
| <svg |
| className="absolute bottom-20 right-4 opacity-[0.07]" |
| width="160" height="160" viewBox="0 0 200 200" |
| fill="none" xmlns="http://www.w3.org/2000/svg" |
| > |
| <line x1="20" y1="150" x2="65" y2="90" stroke="#0ea5e9" strokeWidth="1.2"/> |
| <line x1="20" y1="150" x2="55" y2="170" stroke="#0ea5e9" strokeWidth="1.2"/> |
| <line x1="65" y1="90" x2="110" y2="125" stroke="#0ea5e9" strokeWidth="1.2"/> |
| <line x1="65" y1="90" x2="130" y2="55" stroke="#10b981" strokeWidth="1.2"/> |
| <line x1="110" y1="125" x2="170" y2="105" stroke="#0ea5e9" strokeWidth="1.2"/> |
| <line x1="130" y1="55" x2="170" y2="105" stroke="#10b981" strokeWidth="1.2"/> |
| <line x1="130" y1="55" x2="175" y2="30" stroke="#10b981" strokeWidth="1.2"/> |
| <line x1="55" y1="170" x2="110" y2="125" stroke="#0ea5e9" strokeWidth="1.2"/> |
| <line x1="175" y1="30" x2="170" y2="105" stroke="#10b981" strokeWidth="1.2"/> |
| <circle cx="20" cy="150" r="4" fill="#0ea5e9"/> |
| <circle cx="55" cy="170" r="3" fill="#0ea5e9"/> |
| <circle cx="65" cy="90" r="5.5" fill="#0ea5e9"/> |
| <circle cx="110" cy="125" r="4" fill="#0ea5e9"/> |
| <circle cx="130" cy="55" r="5.5" fill="#10b981"/> |
| <circle cx="170" cy="105" r="4" fill="#10b981"/> |
| <circle cx="175" cy="30" r="3" fill="#10b981"/> |
| </svg> |
| {/* Neural network — top left (small) */} |
| <svg |
| className="absolute top-16 left-4 opacity-[0.06]" |
| width="110" height="110" viewBox="0 0 180 180" |
| fill="none" xmlns="http://www.w3.org/2000/svg" |
| > |
| <line x1="160" y1="30" x2="115" y2="80" stroke="#f97316" strokeWidth="1.2"/> |
| <line x1="115" y1="80" x2="70" y2="55" stroke="#f97316" strokeWidth="1.2"/> |
| <line x1="115" y1="80" x2="130" y2="135" stroke="#0ea5e9" strokeWidth="1.2"/> |
| <line x1="70" y1="55" x2="25" y2="90" stroke="#f97316" strokeWidth="1.2"/> |
| <line x1="25" y1="90" x2="80" y2="155" stroke="#0ea5e9" strokeWidth="1.2"/> |
| <line x1="80" y1="155" x2="130" y2="135" stroke="#0ea5e9" strokeWidth="1.2"/> |
| <circle cx="160" cy="30" r="4" fill="#f97316"/> |
| <circle cx="115" cy="80" r="5.5" fill="#f97316"/> |
| <circle cx="70" cy="55" r="4" fill="#f97316"/> |
| <circle cx="130" cy="135" r="3" fill="#0ea5e9"/> |
| <circle cx="25" cy="90" r="4" fill="#0ea5e9"/> |
| <circle cx="80" cy="155" r="3" fill="#0ea5e9"/> |
| </svg> |
| </div> |
| |
| {/* Header */} |
| <div className="relative z-10 bg-white border-b border-slate-200 p-3 flex items-center gap-3"> |
| <button |
| onClick={() => setSidebarOpen(!sidebarOpen)} |
| className="text-slate-600 hover:text-slate-900 transition" |
| > |
| {sidebarOpen ? ( |
| <X className="w-5 h-5" /> |
| ) : ( |
| <Menu className="w-5 h-5" /> |
| )} |
| </button> |
| <h1 className="text-base text-slate-900 flex-1 truncate min-w-0"> |
| {currentChat?.title || "Data Eyond"} |
| </h1> |
| <button |
| onClick={() => setKnowledgeOpen(true)} |
| className="flex items-center gap-2 bg-[#F59E0B] hover:bg-[#D97706] text-white px-3 py-2 rounded-lg transition-all duration-200 hover:scale-105 active:scale-95 cursor-pointer text-sm flex-shrink-0 shadow-sm hover:shadow-md" |
| > |
| <Database className="w-4 h-4" /> |
| Knowledge |
| </button> |
| </div> |
| |
| {/* Phase Toggle + Hasil Interview button — scoped per chat */} |
| {currentChatId && ( |
| <div className="relative z-10 px-4 pt-3 pb-2 flex items-center gap-3"> |
| <PhaseToggle |
| phase={currentPhase} |
| onChange={handlePhaseChange} |
| /> |
| {interviewResult && ( |
| <button |
| onClick={() => setResultModalOpen(true)} |
| className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 text-emerald-700 rounded-full transition" |
| > |
| <span>📋</span> |
| Hasil Interview |
| </button> |
| )} |
| {currentPhase === "analytics" && (currentChat?.messages.length ?? 0) > 0 && ( |
| <button |
| onClick={handleClearChat} |
| disabled={isStreaming} |
| title="Clear chat history" |
| className="ml-auto p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 transition disabled:opacity-40" |
| > |
| <Eraser className="w-3.5 h-3.5" /> |
| </button> |
| )} |
| </div> |
| )} |
| |
| {/* Interview Panel */} |
| {currentChatId && currentPhase === "interview" && ( |
| <div className="relative z-10 flex-1 flex flex-col min-h-0"> |
| <InterviewPanel |
| roomId={currentChatId} |
| userId={user?.user_id ?? ""} |
| onComplete={handleInterviewComplete} |
| onResultReady={setInterviewResult} |
| /> |
| </div> |
| )} |
| |
| {/* New Chat Onboarding — muncul saat tidak ada room dipilih */} |
| {!currentChatId && ( |
| <div className="relative z-10 flex-1 flex flex-col min-h-0"> |
| <NewChatOnboarding onStart={handleOnboardingStart} /> |
| </div> |
| )} |
| |
| {/* Analytics Messages */} |
| <div className={`relative z-10 flex-1 overflow-y-auto p-4 space-y-4 ${!currentChatId || (currentChatId && currentPhase === "interview") ? "hidden" : ""}`}> |
| {currentChat?.messages.length === 0 && ( |
| <div className="flex items-center justify-center h-full"> |
| <div className="text-center"> |
| <MessageSquare className="w-12 h-12 text-slate-300 mx-auto mb-3" /> |
| <h2 className="text-base text-slate-600 mb-1"> |
| Start a conversation |
| </h2> |
| <p className="text-sm text-slate-400"> |
| Send a message to begin chatting |
| </p> |
| </div> |
| </div> |
| )} |
| |
| {currentChat?.messages.map((message) => ( |
| <div |
| key={message.id} |
| className={`flex ${ |
| message.role === "user" ? "justify-end" : "justify-start" |
| }`} |
| > |
| {/* Avatar for assistant */} |
| {message.role === "assistant" && ( |
| <div className="w-7 h-7 rounded-full bg-gradient-to-br from-[#059669] to-[#047857] flex items-center justify-center flex-shrink-0 mt-0.5 mr-2"> |
| <Bot className="w-3.5 h-3.5 text-white" /> |
| </div> |
| )} |
| |
| <div |
| className={`max-w-2xl px-4 py-3 rounded-2xl ${ |
| message.role === "user" |
| ? "bg-[#3B82F6] text-white rounded-tr-sm shadow-sm" |
| : "bg-[#F3F4F6] border-0 text-slate-900 rounded-tl-sm shadow-sm" |
| }`} |
| > |
| {message.role === "user" ? ( |
| <p className="text-sm whitespace-pre-wrap break-words leading-relaxed"> |
| {message.content} |
| </p> |
| ) : message.content === "" && streamingMsgId === message.id ? ( |
| // Waiting for first chunk — show typing indicator |
| <TypingIndicator /> |
| ) : ( |
| // Render markdown for assistant messages |
| <div className="text-slate-900"> |
| <ReactMarkdown |
| remarkPlugins={[remarkGfm, remarkMath]} |
| rehypePlugins={[rehypeKatex]} |
| components={markdownComponents} |
| > |
| {streamingMsgId === message.id |
| ? message.content |
| : preprocessMarkdown(message.content)} |
| </ReactMarkdown> |
| </div> |
| )} |
| |
| {/* Sources */} |
| {message.role === "assistant" && |
| message.sources && |
| message.sources.length > 0 && ( |
| <div className="mt-2 pt-2 border-t border-slate-100"> |
| <p className="text-[10px] text-slate-400 mb-1.5"> |
| Sources: |
| </p> |
| <div className="flex flex-wrap gap-1"> |
| {message.sources.map((src, i) => ( |
| <span |
| key={i} |
| className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full border border-slate-200" |
| title={ |
| src.page_label |
| ? `Page ${src.page_label}` |
| : undefined |
| } |
| > |
| 📄 {src.filename} |
| {src.page_label ? ` p.${src.page_label}` : ""} |
| </span> |
| ))} |
| </div> |
| </div> |
| )} |
| </div> |
| </div> |
| ))} |
| |
| |
| <div ref={messagesEndRef} /> |
| </div> |
| |
| {/* Input Area — hidden when interview phase active or no room selected */} |
| <div className={`relative z-10 bg-white border-t border-slate-200 p-3 shadow-[0_-2px_10px_rgba(0,0,0,0.06)] ${!currentChatId || (currentChatId && currentPhase === "interview") ? "hidden" : ""}`}> |
| <div className="max-w-4xl mx-auto"> |
| <div className="flex gap-2 items-end"> |
| <textarea |
| value={input} |
| onChange={(e) => setInput(e.target.value)} |
| onKeyDown={handleKeyPress} |
| placeholder="Ask me anything... (Enter to send, Shift+Enter for newline)" |
| rows={1} |
| className="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent resize-none max-h-32" |
| disabled={isStreaming} |
| /> |
| <button |
| onClick={handleSend} |
| disabled={!input.trim() || isStreaming} |
| className="bg-[#3B82F6] hover:bg-[#2563EB] text-white p-2.5 rounded-lg transition-all duration-200 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0" |
| > |
| {isStreaming ? ( |
| <Loader2 className="w-4 h-4 animate-spin" /> |
| ) : ( |
| <Send className="w-4 h-4" /> |
| )} |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| {/* Knowledge Management Modal */} |
| <KnowledgeManagement |
| open={knowledgeOpen} |
| onClose={() => setKnowledgeOpen(false)} |
| /> |
| |
| {/* Interview Result Modal */} |
| {resultModalOpen && interviewResult && ( |
| <div |
| className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" |
| onClick={() => setResultModalOpen(false)} |
| > |
| <div |
| className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden" |
| onClick={(e) => e.stopPropagation()} |
| > |
| <div className="flex items-center justify-between px-5 py-4 border-b border-slate-200"> |
| <h2 className="text-base font-semibold text-slate-800">Hasil Interview</h2> |
| <button |
| onClick={() => setResultModalOpen(false)} |
| className="text-slate-400 hover:text-slate-600 transition p-1 rounded-lg hover:bg-slate-100" |
| > |
| <X className="w-4 h-4" /> |
| </button> |
| </div> |
| <div className="overflow-y-auto flex-1"> |
| <InterviewResultView result={interviewResult} /> |
| </div> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|