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 }) => (
),
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}
{
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"
>
))
)}
{chats.length > 0 && (
Clear All Chats
)}
{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 */}
setSidebarOpen(!sidebarOpen)}
className="text-slate-600 hover:text-slate-900 transition"
>
{sidebarOpen ? (
) : (
)}
{currentChat?.title || "Data Eyond"}
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"
>
Knowledge
{/* Phase Toggle + Hasil Interview button — scoped per chat */}
{currentChatId && (
{interviewResult && (
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"
>
📋
Hasil Interview
)}
{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 */}
{/* Knowledge Management Modal */}
setKnowledgeOpen(false)}
/>
{/* Interview Result Modal */}
{resultModalOpen && interviewResult && (
setResultModalOpen(false)}
>
e.stopPropagation()}
>
Hasil Interview
setResultModalOpen(false)}
className="text-slate-400 hover:text-slate-600 transition p-1 rounded-lg hover:bg-slate-100"
>
)}
);
}