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; } // Preprocess markdown to ensure tables are properly separated from surrounding text function preprocessMarkdown(content: string): string { // Step 1: Split concatenated table rows — "| val ||" means end of row + start of next let result = content.replace(/\|\|/g, "|\n|"); // Step 2: Per-line — if a line has non-pipe text before a pipe table row, split them const lines = result.split("\n"); const processed = lines.map((line) => { const tableStart = line.indexOf("|"); if (tableStart > 0) { const tableContent = line.slice(tableStart); // Confirm it looks like a table row (at least 2 pipes) if ((tableContent.match(/\|/g) ?? []).length >= 2) { return line.slice(0, tableStart).trimEnd() + "\n\n" + tableContent; } } return line; }); result = processed.join("\n"); // Step 3: Ensure blank line before table rows preceded by non-table text (not another row ending with |) result = result.replace(/([^|\n])\n(\|)/g, "$1\n\n$2"); return result; } // Markdown component overrides for clean rendering inside chat bubbles const markdownComponents: Components = { p: ({ children }) => (

{children}

), h1: ({ children }) => (

{children}

), h2: ({ children }) => (

{children}

), h3: ({ children }) => (

{children}

), ul: ({ children }) => ( ), ol: ({ children }) => (
    {children}
), li: ({ children }) =>
  • {children}
  • , code: ({ children, className }) => { const isBlock = className?.startsWith("language-"); if (isBlock) { return ( {children} ); } return ( {children} ); }, pre: ({ children }) => (
          {children}
        
    ), blockquote: ({ children }) => (
    {children}
    ), table: ({ children }) => (
    {children}
    ), th: ({ children }) => ( {children} ), td: ({ children }) => ( {children} ), a: ({ children, href }) => ( {children} ), strong: ({ children }) => ( {children} ), hr: () =>
    , }; // Typing indicator — three bouncing dots function useLoadingMessages() { const [messages, setMessages] = useState([]); 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 (
    ); } return (
    {messages[index]}…
    ); } export default function Main() { const navigate = useNavigate(); const [sidebarOpen, setSidebarOpen] = useState(true); const [chats, setChats] = useState([]); const [currentChatId, setCurrentChatId] = useState(null); const [input, setInput] = useState(""); const [isStreaming, setIsStreaming] = useState(false); const [streamingMsgId, setStreamingMsgId] = useState(null); const [roomsLoading, setRoomsLoading] = useState(false); const [roomsError, setRoomsError] = useState(null); const messagesEndRef = useRef(null); const [user, setUser] = useState(null); const [knowledgeOpen, setKnowledgeOpen] = useState(false); const [currentPhase, setCurrentPhase] = useState("interview"); const [interviewResult, setInterviewResult] = useState(null); const [resultModalOpen, setResultModalOpen] = useState(false); const abortControllerRef = useRef(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); } // Restore phase for this room 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"); } } // Load persisted interview result — localStorage first, fallback ke API 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 { // Coba ambil dari backend (sesi sudah selesai sebelumnya) getInterviewResult(currentChatId) .then(setInterviewResult) .catch(() => setInterviewResult(null)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [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 ? messages.length === 0 && chat.messages.length > 0 ? { ...chat, messagesLoaded: true } : { ...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 { /* silent */ } }; 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 { // ignore malformed sources } } 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 ) ); setStreamingMsgId(null); } else if (currentEvent === "done") { streamDone = true; break; } } } } // Re-fetch from API after stream ends to get the authoritative // stored content — guarantees formatting matches page-refresh behavior 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 (
    {/* Sidebar */}
    {roomsLoading ? (
    ) : roomsError ? (

    {roomsError}

    ) : ( chats.map((chat) => (
    setCurrentChatId(chat.id)} > {chat.title}
    )) )}
    {chats.length > 0 && ( )}
    {user?.name}
    {user?.email}
    {/* Main Content */}
    {/* ── Subtle background — harmonizes with Login page ── */}
    {/* Dot grid */}
    {/* Cyan orb — top right */}
    {/* Orange orb — bottom left */}
    {/* Green orb — center left */}
    {/* Neural network — bottom right */} {/* Neural network — top left (small) */}
    {/* Header */}

    {currentChat?.title || "Data Eyond"}

    {/* Phase Toggle + Hasil Interview button — scoped per chat */} {currentChatId && (
    {interviewResult && ( )} {currentPhase === "analytics" && (currentChat?.messages.length ?? 0) > 0 && ( )}
    )} {/* Interview Panel */} {currentChatId && currentPhase === "interview" && (
    )} {/* New Chat Onboarding — muncul saat tidak ada room dipilih */} {!currentChatId && (
    )} {/* Analytics Messages */}
    {currentChat?.messages.length === 0 && (

    Start a conversation

    Send a message to begin chatting

    )} {currentChat?.messages.map((message) => (
    {/* Avatar for assistant */} {message.role === "assistant" && (
    )}
    {message.role === "user" ? (

    {message.content}

    ) : message.content === "" && streamingMsgId === message.id ? ( // Waiting for first chunk — show typing indicator ) : ( // Render markdown for assistant messages
    {streamingMsgId === message.id ? message.content : preprocessMarkdown(message.content)}
    )} {/* Sources */} {message.role === "assistant" && message.sources && message.sources.length > 0 && (

    Sources:

    {message.sources.map((src, i) => ( 📄 {src.filename} {src.page_label ? ` p.${src.page_label}` : ""} ))}
    )}
    ))}
    {/* Input Area — hidden when interview phase active or no room selected */}