ema-frontend-demo / src /components /chat /ChatWindow.tsx
ishaq101's picture
feat: voice (STT & TTS) (#1)
6d7c858
"use client";
import { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Bot } from "lucide-react";
import { MessageBubble } from "./MessageBubble";
import { FeedbackWidget } from "./FeedbackWidget";
import { TypingIndicator } from "./TypingIndicator";
import { useChatStore } from "@/stores/chatStore";
import { useAuthStore } from "@/stores/authStore";
import { getChatbotConfig } from "@/lib/chatbotConfig";
import type { ChatMessage, SummaryPack } from "@/types";
function getAudioText(msg: ChatMessage): string | null {
if (msg.audio_text) return msg.audio_text;
const summary = msg.data?.datasets?.find(
(d): d is SummaryPack => "chart_type" in d && d.chart_type === "summary"
);
return summary?.audio_text ?? null;
}
interface ChatWindowProps {
showFeedback?: boolean;
onFeedbackDismiss?: () => void;
}
export function ChatWindow({ showFeedback, onFeedbackDismiss }: ChatWindowProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const lastMessageRef = useRef<HTMLDivElement>(null);
const prevMessageCount = useRef(0);
const prevIsSending = useRef(false);
const isFirstRender = useRef(true);
const messages = useChatStore((s) => s.getActiveMessages());
const isSending = useChatStore((s) => s.isSending);
const userInfo = useAuthStore((s) => s.userInfo);
const [chatbotConfig] = useState(() => getChatbotConfig());
// Scroll to bottom when typing indicator appears
useEffect(() => {
if (isSending !== prevIsSending.current) {
prevIsSending.current = isSending;
if (isSending) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}
}, [isSending]);
// On initial mount scroll to bottom; on new AI message scroll to its top
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
prevMessageCount.current = messages.length;
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
return;
}
if (messages.length > prevMessageCount.current) {
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === "ai") {
lastMessageRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
} else {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}
prevMessageCount.current = messages.length;
}, [messages]);
if (messages.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-6 px-6 pb-4">
{/* Welcome state */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="flex flex-col items-center gap-4 text-center max-w-md"
>
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-brand-green-light to-brand-green flex items-center justify-center shadow-xl shadow-brand-green/25">
<Bot className="h-10 w-10 text-white" />
</div>
<div>
<h2 className="text-2xl font-bold text-neutral-900">
Halo, {userInfo?.fullname?.split(" ")[0] || ""}! πŸ‘‹
</h2>
<p className="mt-2 text-neutral-500 text-sm leading-relaxed">
{chatbotConfig.description}
</p>
</div>
<div className="grid grid-cols-2 gap-3 w-full mt-2">
{[
{ icon: "πŸ“Š", title: "Analisis Data", desc: "PA, Bad Actor, Pareto, dll." },
{ icon: "πŸ“–", title: "Knowledge Base", desc: "SOP, Handbook & Dokumen" },
{ icon: "πŸ”", title: "Pareto & Trend", desc: "Identifikasi pola masalah" },
{ icon: "πŸ“‹", title: "Report Ready", desc: "Grafik & insight otomatis" },
].map((item) => (
<div
key={item.title}
className="flex flex-col gap-1 p-3 rounded-xl bg-white border border-neutral-100 shadow-sm text-left"
>
<span className="text-xl">{item.icon}</span>
<p className="text-sm font-semibold text-neutral-800">{item.title}</p>
<p className="text-xs text-neutral-400">{item.desc}</p>
</div>
))}
</div>
</motion.div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto py-4 space-y-1">
<AnimatePresence initial={false}>
{messages.map((message, i) => (
<motion.div
key={i}
ref={i === messages.length - 1 ? lastMessageRef : null}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<MessageBubble message={message} index={i} />
{showFeedback &&
i === messages.length - 1 &&
message.role === "ai" && (
<FeedbackWidget
message={message.content}
audioText={getAudioText(message)}
/>
)}
</motion.div>
))}
</AnimatePresence>
{/* Typing indicator */}
{isSending && <TypingIndicator />}
<div ref={bottomRef} />
</div>
);
}