import { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router"; import { Send, Plus, Trash2, LogOut, Menu, X, MessageSquare, User, Bot, Loader2, Database, } 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 { getRooms, getRoom, createRoom, deleteRoom, 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; } // 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 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); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentChatId]); 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(), })); 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 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 = ""; while (true) { const { done, value } = await reader.read(); if (done) 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) { 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 ) ); } 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") { break; } } } } } 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 */}
    {/* Header */}

    {currentChat?.title || "Chatbot"}

    {/* 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
    {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}` : ""} ))}
    )}
    ))} {!currentChat && chats.length === 0 && !roomsLoading && (

    Welcome to Chatbot

    Create a new chat to get started

    )}
    {/* Input Area */}