import { useState, useEffect, useRef, useCallback } from "react"; import { useNavigate } from "react-router"; import { Database, Menu } from "lucide-react"; import { AnimatePresence } from "motion/react"; import KnowledgeManagement from "./KnowledgeManagement"; import { getRooms, getRoom, createRoom, deleteRoom, streamChat, type ChatSource, } from "../../services/api"; import { textToSpeech } from "../../services/voiceApi"; import { replayAudio } from "../../audio/AudioPlayer"; import ChatLayout from "./chat/ChatLayout"; import Sidebar from "./chat/Sidebar"; import ChatWindow from "./chat/ChatWindow"; import ChatInput from "./chat/ChatInput"; import VoiceStatusBar from "./chat/VoiceStatusBar"; import { useVoiceSession } from "../../hooks/useVoiceSession"; import type { VoiceState } from "../../hooks/useVoiceSession"; import type { Message, ChatSession, StoredUser } from "./chat/types"; interface ChatRoom { id: string; title: string; messages: Message[]; createdAt: string; updatedAt: string | null; messagesLoaded: boolean; } export default function Main() { const navigate = useNavigate(); const [chats, setChats] = useState([]); const [currentChatId, setCurrentChatId] = useState(null); const [isStreaming, setIsStreaming] = useState(false); const [streamingMsgId, setStreamingMsgId] = useState(null); const [roomsLoading, setRoomsLoading] = useState(false); const [user, setUser] = useState(null); const [knowledgeOpen, setKnowledgeOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const abortControllerRef = useRef(null); const isVoiceActiveRef = useRef(false); const setVoiceStateRef = useRef<((s: VoiceState) => void) | null>(null); // Stable refs so voice callbacks always see the latest values const currentChatIdRef = useRef(null); useEffect(() => { currentChatIdRef.current = currentChatId; }, [currentChatId]); const userRef = useRef(null); useEffect(() => { userRef.current = user; }, [user]); useEffect(() => { if (currentChatId) { localStorage.setItem("chatbot_last_room_id", currentChatId); } }, [currentChatId]); useEffect(() => { const storedUser = localStorage.getItem("chatbot_user"); if (storedUser) { const parsedUser: StoredUser = JSON.parse(storedUser); setUser(parsedUser); loadRooms(parsedUser.user_id); } }, []); 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); 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) { const lastRoomId = localStorage.getItem("chatbot_last_room_id"); const restoredId = lastRoomId && mapped.find((r) => r.id === lastRoomId) ? lastRoomId : mapped[0].id; setCurrentChatId(restoredId); } } catch { // silently fail — UI shows empty state } finally { setRoomsLoading(false); } }; const loadRoomMessages = async (roomId: string): Promise => { try { const detail = await getRoom(roomId); const messages: Message[] = detail.messages.map((m) => ({ id: m.id, role: m.role, content: m.content, audioText: m.audio_text ?? undefined, timestamp: new Date(m.created_at).getTime(), sources: m.sources ?? [], })); const asstMsgs = messages.filter(m => m.role === "assistant"); const lastAsst = asstMsgs[asstMsgs.length - 1]; if (lastAsst) { const hasCR = lastAsst.content.includes("\r"); // console.log("[loadRoomMessages] last assistant contentLen=", lastAsst.content.length, "hasCR=", hasCR); // console.log("[loadRoomMessages] CONTENT JSON →", JSON.stringify(lastAsst.content.slice(0, 600))); } // console.log("[loadRoomMessages] room", roomId, "→", messages.length, "messages from server"); setChats((prev) => prev.map((chat) => chat.id === roomId ? { ...chat, messages, messagesLoaded: true } : chat ) ); return messages; } catch (err) { console.error("[loadRoomMessages] failed:", err); setChats((prev) => prev.map((chat) => chat.id === roomId ? { ...chat, messagesLoaded: true } : chat ) ); return []; } }; // Ensures a chat room exists; creates one for voice if needed. const ensureRoom = useCallback(async (title?: string): Promise => { if (currentChatIdRef.current) return currentChatIdRef.current; const currentUser = userRef.current; if (!currentUser) return null; try { const res = await createRoom(currentUser.user_id, title ?? "Voice 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); return newRoom.id; } catch { return null; } }, []); const currentChat = chats.find((chat) => chat.id === currentChatId); const handleNewChat = () => setCurrentChatId(null); const handleDeleteSession = async (chatId: string) => { if (!user) return; try { await deleteRoom(chatId, user.user_id); } catch { return; } const updated = chats.filter((c) => c.id !== chatId); setChats(updated); if (currentChatId === chatId) { setCurrentChatId(updated.length > 0 ? updated[0].id : null); } }; const handleLogout = () => { localStorage.removeItem("chatbot_user"); navigate("/login"); }; const handleStop = () => { abortControllerRef.current?.abort(); }; const handleSend = useCallback(async (text: string, skipReload = false) => { // console.log("[handleSend] called, user:", user?.user_id ?? "null", "text:", text.slice(0, 40)); if (!user) { console.warn("[handleSend] early return: no user"); return; } let roomId = await ensureRoom(text.slice(0, 50)); // console.log("[handleSend] roomId:", roomId); if (!roomId) { // console.warn("[handleSend] early return: no roomId"); return; } const userMessage: Message = { id: crypto.randomUUID(), role: "user", content: text, timestamp: Date.now(), }; setChats((prev) => prev.map((chat) => chat.id === roomId ? { ...chat, messages: [...chat.messages, userMessage], updatedAt: new Date().toISOString(), } : chat ) ); 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: [] as ChatSource[], }, ], } : chat ) ); abortControllerRef.current = new AbortController(); let audioText = ""; try { const response = await streamChat(user.user_id, roomId, text); if (!response.body) throw new Error("No response body"); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let currentEvent = ""; let currentDataLines: string[] = []; let streamDone = false; const dispatchEvent = (eventType: string, data: string) => { if (eventType === "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 (eventType === "chunk") { 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 (eventType === "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 (eventType === "audio_text" && data) { audioText = data; } else if (eventType === "done") { streamDone = true; } }; 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 === "") { // Blank line = end of SSE event — dispatch with all accumulated data lines joined by \n if (currentEvent) { dispatchEvent(currentEvent, currentDataLines.join("\n")); } currentEvent = ""; currentDataLines = []; } else if (line.startsWith("event:")) { currentEvent = line.replace("event:", "").trim(); } else if (line.startsWith("data:")) { currentDataLines.push(line.replace(/^data: ?/, "")); } } if (streamDone) break; } if (skipReload) { // Voice mode: skip GET /room, return immediately so TTS can start without delay. // audioText and audioChunks will be stored on the temp assistantMsgId. if (audioText) { setChats((prev) => prev.map((chat) => chat.id === roomId ? { ...chat, messages: chat.messages.map((m) => m.id === assistantMsgId ? { ...m, audioText } : m ), } : chat ) ); } return { audioText, assistantMsgId }; } if (audioText) { setChats((prev) => prev.map((chat) => chat.id === roomId ? { ...chat, messages: chat.messages.map((m) => m.id === assistantMsgId ? { ...m, audioText } : m ), } : chat ) ); } return { audioText, assistantMsgId }; } catch (err: unknown) { if ((err as Error).name !== "AbortError") { console.error("[handleSend] streamChat error:", err); 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 ) ); audioText = ""; } } finally { // console.log("[handleSend] finally: clearing streamingMsgId →", assistantMsgId, "| skipReload:", skipReload); setIsStreaming(false); setStreamingMsgId(null); abortControllerRef.current = null; } }, [user, ensureRoom]); const cancelTtsRef = useRef<(() => void) | null>(null); const playTtsAudio = useCallback(async ( ttsText: string, onStarted?: () => void, ): Promise<{ chunks: ArrayBuffer[]; sampleRate: number }> => { // Stop any in-flight TTS before starting a new one (e.g. rapid re-query). cancelTtsRef.current?.(); cancelTtsRef.current = null; try { const { pcm, sampleRate } = await textToSpeech(ttsText); const durationMs = (pcm.byteLength / 2 / sampleRate) * 1000; // Use the same proven playback path as the speaker button: schedule the // full response at once via replayAudio, avoiding all streaming complexity. const cancel = replayAudio([pcm], sampleRate); cancelTtsRef.current = cancel; onStarted?.(); await new Promise((resolve) => setTimeout(resolve, durationMs + 150)); cancel(); cancelTtsRef.current = null; return { chunks: [pcm], sampleRate }; } catch { // TTS failure is non-fatal return { chunks: [], sampleRate: 24000 }; } }, []); const { voiceState, start, stop, stopRecording, setStateExternal, isActive: isVoiceActive } = useVoiceSession({ onTranscript: async (text: string) => { // console.log("[onTranscript] received:", text); // Pass skipReload=true so handleSend returns immediately after streaming // without waiting for GET /room — TTS starts with zero extra delay. const result = await handleSend(text, true); const { audioText = "", assistantMsgId } = result ?? {}; // console.log("[onTranscript] handleSend done, audioText:", audioText ? audioText.slice(0, 40) : "(empty)"); if (audioText && isVoiceActiveRef.current) { const { chunks, sampleRate } = await playTtsAudio(audioText, () => { if (isVoiceActiveRef.current) setVoiceStateRef.current?.("SPEAKING"); }); if (assistantMsgId && chunks.length > 0) { setChats((prev) => prev.map((chat) => chat.id === currentChatIdRef.current ? { ...chat, messages: chat.messages.map((m) => m.id === assistantMsgId ? { ...m, audioChunks: chunks, audioSampleRate: sampleRate } : m ), } : chat ) ); } } if (isVoiceActiveRef.current) setVoiceStateRef.current?.("IDLE"); }, sessionParams: {}, }); // Keep refs in sync with latest values useEffect(() => { isVoiceActiveRef.current = isVoiceActive; }, [isVoiceActive]); useEffect(() => { setVoiceStateRef.current = setStateExternal; }, [setStateExternal]); const handleVoiceToggle = useCallback(() => { if (!isVoiceActive) { start(); } else if (voiceState === "LISTENING") { stopRecording(); } else { stop(); } }, [isVoiceActive, voiceState, start, stop, stopRecording]); const sessions: ChatSession[] = chats.map((c) => ({ id: c.id, title: c.title, createdAt: c.createdAt, updatedAt: c.updatedAt, })); return ( setMobileSidebarOpen(false)} /> } > {/* Background decoration */}
{/* Header */}
{/* Hamburger — mobile only */}

{currentChat?.title ?? "New Chat"}

{/* Chat window */}
{/* Input area */}
{isVoiceActive && ( )}
setKnowledgeOpen(false)} /> ); }