ishaq101's picture
update calling tts after chatbot response
b38bd93
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<ChatRoom[]>([]);
const [currentChatId, setCurrentChatId] = useState<string | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null);
const [roomsLoading, setRoomsLoading] = useState(false);
const [user, setUser] = useState<StoredUser | null>(null);
const [knowledgeOpen, setKnowledgeOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const abortControllerRef = useRef<AbortController | null>(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<string | null>(null);
useEffect(() => { currentChatIdRef.current = currentChatId; }, [currentChatId]);
const userRef = useRef<StoredUser | null>(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<Message[]> => {
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<string | null> => {
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<void>((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 (
<ChatLayout
sidebar={
<Sidebar
sessions={sessions}
activeSessionId={currentChatId}
isLoadingSessions={roomsLoading}
user={user}
onNewChat={handleNewChat}
onSelectSession={setCurrentChatId}
onDeleteSession={handleDeleteSession}
onLogout={handleLogout}
mobileOpen={mobileSidebarOpen}
onMobileClose={() => setMobileSidebarOpen(false)}
/>
}
>
{/* Background decoration */}
<div className="absolute inset-0 pointer-events-none z-0 overflow-hidden">
<div
className="absolute inset-0 opacity-[0.13]"
style={{
backgroundImage: "radial-gradient(circle, #94a3b8 1px, transparent 1px)",
backgroundSize: "28px 28px",
}}
/>
<div
className="absolute -top-28 -right-28 w-80 h-80 rounded-full blur-3xl opacity-[0.08]"
style={{ background: "#0ea5e9" }}
/>
<div
className="absolute -bottom-28 -left-28 w-96 h-96 rounded-full blur-3xl opacity-[0.07]"
style={{ background: "#f97316" }}
/>
<div
className="absolute top-1/2 -left-32 w-64 h-64 rounded-full blur-3xl opacity-[0.05]"
style={{ background: "#10b981" }}
/>
</div>
{/* Header */}
<div className="relative z-10 bg-white border-b border-neutral-100 px-4 py-3 flex items-center gap-3">
{/* Hamburger — mobile only */}
<button
className="md:hidden flex-shrink-0 p-1.5 rounded-lg text-neutral-500 hover:text-brand-green hover:bg-brand-green-50 transition-all"
onClick={() => setMobileSidebarOpen(true)}
aria-label="Open menu"
>
<Menu className="w-5 h-5" />
</button>
<h1 className="text-base font-bold text-neutral-900 flex-1 truncate">
{currentChat?.title ?? "New Chat"}
</h1>
<button
onClick={() => setKnowledgeOpen(true)}
className="flex items-center gap-2 bg-brand-amber text-white px-3 py-2 rounded-lg text-sm font-semibold hover:brightness-105 hover:scale-105 transition-all duration-200 flex-shrink-0"
>
<Database className="w-4 h-4" />
<span className="hidden sm:inline">Knowledge</span>
</button>
</div>
{/* Chat window */}
<div className="relative z-10 flex-1 flex flex-col min-h-0">
<ChatWindow
messages={currentChat?.messages ?? []}
isLoading={isStreaming}
streamingMsgId={streamingMsgId}
userName={user?.name}
/>
</div>
{/* Input area */}
<div className="relative z-10 border-t border-neutral-100 bg-white/80 backdrop-blur-sm">
<div className="w-full max-w-4xl xl:max-w-5xl mx-auto flex flex-col gap-2 px-3 sm:px-4 pt-3 pb-4">
<AnimatePresence>
{isVoiceActive && (
<VoiceStatusBar voiceState={voiceState} onStop={stop} />
)}
</AnimatePresence>
<ChatInput
onSend={handleSend}
onStop={handleStop}
isLoading={isStreaming}
voiceState={voiceState}
onVoiceToggle={handleVoiceToggle}
/>
</div>
</div>
<KnowledgeManagement
open={knowledgeOpen}
onClose={() => setKnowledgeOpen(false)}
/>
</ChatLayout>
);
}