Spaces:
Sleeping
Sleeping
| import { useCallback, useEffect, useRef, useState } from "react"; | |
| import { Bot, User, Send, Loader2, Mic, MicOff, CheckCircle2, RotateCcw, AlertTriangle, RefreshCw, ArrowRight } from "lucide-react"; | |
| import ReactMarkdown from "react-markdown"; | |
| import remarkGfm from "remark-gfm"; | |
| import type { Components } from "react-markdown"; | |
| import AudioRecorder from "./AudioRecorder"; | |
| import { getInterviewResult, type InterviewResult } from "../../../services/interviewApi"; | |
| import { | |
| useInterviewSession, | |
| type InterviewMessage, | |
| type InterviewMode, | |
| } from "../../../hooks/useInterviewSession"; | |
| const markdownComponents: Components = { | |
| p: ({ children }) => ( | |
| <p className="text-sm mb-1.5 last:mb-0 leading-relaxed">{children}</p> | |
| ), | |
| ul: ({ children }) => ( | |
| <ul className="list-disc pl-4 mb-1.5 space-y-0.5 text-sm">{children}</ul> | |
| ), | |
| ol: ({ children }) => ( | |
| <ol className="list-decimal pl-4 mb-1.5 space-y-0.5 text-sm">{children}</ol> | |
| ), | |
| li: ({ children }) => <li>{children}</li>, | |
| strong: ({ children }) => <strong className="font-semibold">{children}</strong>, | |
| em: ({ children }) => <em className="italic">{children}</em>, | |
| }; | |
| interface InterviewPanelProps { | |
| roomId: string; | |
| userId: string; | |
| onComplete: () => void; | |
| onResultReady?: (result: InterviewResult) => void; | |
| } | |
| export default function InterviewPanel({ roomId, userId, onComplete, onResultReady }: InterviewPanelProps) { | |
| const { | |
| status, | |
| messages, | |
| isSending, | |
| isStarting, | |
| startError, | |
| interviewResult, | |
| isLoaded, | |
| startSession, | |
| sendTextMessage, | |
| connectAudio, | |
| disconnectAudio, | |
| sendAudioChunk, | |
| sendEndUtterance, | |
| switchMode, | |
| resetSession, | |
| } = useInterviewSession(roomId, userId); | |
| const [input, setInput] = useState(""); | |
| const [audioMode, setAudioMode] = useState<InterviewMode>("text"); | |
| const [audioStreamingText, setAudioStreamingText] = useState(""); | |
| const [isAudioConnected, setIsAudioConnected] = useState(false); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const audioContextRef = useRef<AudioContext | null>(null); | |
| const inputRef = useRef<HTMLTextAreaElement>(null); | |
| // Auto-scroll to bottom | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| }, [messages, audioStreamingText]); | |
| // Auto-start hanya setelah localStorage selesai dimuat dan status benar-benar idle | |
| useEffect(() => { | |
| if (isLoaded && status === "idle") { | |
| startSession("text"); | |
| } | |
| }, [isLoaded, status]); // eslint-disable-line react-hooks/exhaustive-deps | |
| // Notify parent when result is ready (from hook or re-fetched) | |
| useEffect(() => { | |
| if (status === "completed" && interviewResult) { | |
| onResultReady?.(interviewResult); | |
| } else if (status === "completed" && !interviewResult) { | |
| getInterviewResult(roomId) | |
| .then((r) => onResultReady?.(r)) | |
| .catch(() => {}); | |
| } | |
| }, [status, interviewResult]); // eslint-disable-line react-hooks/exhaustive-deps | |
| // Setup audio WebSocket when audio mode is active | |
| useEffect(() => { | |
| if (audioMode !== "audio" || status !== "active") return; | |
| if (isAudioConnected) return; | |
| setIsAudioConnected(true); | |
| connectAudio( | |
| (token) => setAudioStreamingText((prev) => prev + token), | |
| (fullText) => { | |
| setAudioStreamingText(""); | |
| // The hook handles adding the message internally, but for audio we add it here | |
| // since audio messages arrive differently | |
| void fullText; // handled via useInterviewSession internal state if needed | |
| }, | |
| async (audioBuf) => { | |
| // Play TTS audio | |
| try { | |
| if (!audioContextRef.current || audioContextRef.current.state === "closed") { | |
| audioContextRef.current = new AudioContext(); | |
| } | |
| const ctx = audioContextRef.current; | |
| const decoded = await ctx.decodeAudioData(audioBuf.slice(0)); | |
| const source = ctx.createBufferSource(); | |
| source.buffer = decoded; | |
| source.connect(ctx.destination); | |
| source.start(); | |
| } catch { | |
| // ignore playback errors | |
| } | |
| }, | |
| () => { | |
| setIsAudioConnected(false); | |
| onComplete(); | |
| } | |
| ); | |
| return () => { | |
| disconnectAudio(); | |
| setIsAudioConnected(false); | |
| }; | |
| }, [audioMode, status]); // eslint-disable-line react-hooks/exhaustive-deps | |
| const handleSendText = useCallback(async () => { | |
| const text = input.trim(); | |
| if (!text || isSending || status !== "active") return; | |
| setInput(""); | |
| await sendTextMessage(text); | |
| inputRef.current?.focus(); | |
| }, [input, isSending, status, sendTextMessage]); | |
| const handleKeyPress = (e: React.KeyboardEvent) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSendText(); | |
| } | |
| }; | |
| const handleModeToggle = () => { | |
| const next: InterviewMode = audioMode === "text" ? "audio" : "text"; | |
| setAudioMode(next); | |
| switchMode(next); | |
| setIsAudioConnected(false); | |
| setAudioStreamingText(""); | |
| }; | |
| const handleRestart = () => { | |
| setAudioMode("text"); | |
| setIsAudioConnected(false); | |
| setAudioStreamingText(""); | |
| resetSession(); | |
| startSession("text"); | |
| }; | |
| // Loading state saat memanggil API untuk membuat sesi | |
| if (isStarting) { | |
| return ( | |
| <div className="flex-1 flex flex-col items-center justify-center gap-3 text-slate-500"> | |
| <Loader2 className="w-6 h-6 animate-spin text-emerald-600" /> | |
| <p className="text-sm">Memulai sesi interview…</p> | |
| </div> | |
| ); | |
| } | |
| // Error state — backend tidak bisa dijangkau | |
| if (startError && status === "idle") { | |
| return ( | |
| <div className="flex-1 flex flex-col items-center justify-center gap-4 px-6"> | |
| <div className="flex flex-col items-center gap-2 text-center"> | |
| <AlertTriangle className="w-8 h-8 text-amber-400" /> | |
| <p className="text-sm font-medium text-slate-700">Tidak dapat terhubung ke server interview</p> | |
| <p className="text-xs text-slate-400 max-w-xs"> | |
| Pastikan backend interview berjalan di <code className="bg-slate-100 px-1 py-0.5 rounded text-xs">localhost:8080</code> | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={() => startSession("text")} | |
| className="flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs rounded-lg transition" | |
| > | |
| <RefreshCw className="w-3.5 h-3.5" /> | |
| Coba Lagi | |
| </button> | |
| <button | |
| onClick={onComplete} | |
| className="flex items-center gap-1.5 px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs rounded-lg transition" | |
| > | |
| Lanjut ke Analytics | |
| <ArrowRight className="w-3.5 h-3.5" /> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="flex-1 flex flex-col min-h-0"> | |
| {/* Mode toggle bar */} | |
| <div className="flex items-center justify-between px-4 py-2 border-b border-slate-100 bg-white/60 backdrop-blur-sm"> | |
| <p className="text-xs text-slate-500"> | |
| {status === "completed" | |
| ? "Interview selesai — lihat hasil di bawah" | |
| : "Jawab pertanyaan untuk membantu analisis data Anda"} | |
| </p> | |
| <div className="flex items-center gap-2"> | |
| {status === "completed" && ( | |
| <button | |
| onClick={handleRestart} | |
| className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-600 transition" | |
| > | |
| <RotateCcw className="w-3 h-3" /> | |
| Mulai ulang | |
| </button> | |
| )} | |
| {status === "active" && ( | |
| <button | |
| onClick={handleModeToggle} | |
| title={audioMode === "text" ? "Beralih ke mode audio" : "Beralih ke mode teks"} | |
| className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-all duration-200 ${ | |
| audioMode === "audio" | |
| ? "bg-emerald-50 border-emerald-200 text-emerald-700" | |
| : "bg-slate-50 border-slate-200 text-slate-500 hover:border-slate-300" | |
| }`} | |
| > | |
| {audioMode === "audio" ? ( | |
| <Mic className="w-3 h-3" /> | |
| ) : ( | |
| <MicOff className="w-3 h-3" /> | |
| )} | |
| <span>{audioMode === "audio" ? "Audio" : "Teks"}</span> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Messages */} | |
| <div className="flex-1 overflow-y-auto px-4 py-4 space-y-3"> | |
| {messages.map((msg) => ( | |
| <MessageBubble key={msg.id} message={msg} /> | |
| ))} | |
| {/* Live audio streaming text */} | |
| {audioStreamingText && ( | |
| <div className="flex gap-2.5 max-w-[85%]"> | |
| <div className="w-7 h-7 rounded-full bg-emerald-100 flex items-center justify-center flex-shrink-0 mt-0.5"> | |
| <Bot className="w-4 h-4 text-emerald-600" /> | |
| </div> | |
| <div className="bg-white border border-slate-200 rounded-2xl rounded-tl-sm px-3.5 py-2.5 shadow-sm"> | |
| <p className="text-sm text-slate-700 leading-relaxed"> | |
| {audioStreamingText} | |
| <span className="inline-block w-1 h-3.5 bg-emerald-500 ml-0.5 animate-pulse rounded-sm" /> | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Completed state */} | |
| {status === "completed" && ( | |
| <div className="flex justify-center py-4"> | |
| <div className="flex items-center gap-2 text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-full px-4 py-2 text-sm"> | |
| <CheckCircle2 className="w-4 h-4" /> | |
| <span>Interview selesai!</span> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| {/* CTA setelah interview selesai */} | |
| {status === "completed" && ( | |
| <div className="border-t border-slate-100 px-4 py-3 flex justify-end"> | |
| <button | |
| onClick={onComplete} | |
| className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm rounded-xl transition" | |
| > | |
| Lanjut ke Analytics | |
| <ArrowRight className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| )} | |
| {/* Input area */} | |
| {status === "active" && ( | |
| <div className="border-t border-slate-200 bg-white/80 backdrop-blur-sm p-3"> | |
| {audioMode === "audio" ? ( | |
| <div className="flex items-center justify-center gap-3 py-2"> | |
| <p className="text-sm text-slate-500"> | |
| Tahan tombol mikrofon lalu bicara | |
| </p> | |
| <AudioRecorder | |
| onChunk={sendAudioChunk} | |
| onEndUtterance={sendEndUtterance} | |
| disabled={isSending} | |
| /> | |
| </div> | |
| ) : ( | |
| <div className="flex items-end gap-2"> | |
| <textarea | |
| ref={inputRef} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyPress} | |
| placeholder="Ketik jawaban Anda…" | |
| rows={1} | |
| disabled={isSending} | |
| className="flex-1 resize-none bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:border-transparent transition max-h-32 disabled:opacity-60" | |
| style={{ minHeight: "42px" }} | |
| onInput={(e) => { | |
| const el = e.currentTarget; | |
| el.style.height = "auto"; | |
| el.style.height = `${Math.min(el.scrollHeight, 128)}px`; | |
| }} | |
| /> | |
| <button | |
| onClick={handleSendText} | |
| disabled={!input.trim() || isSending} | |
| className="w-9 h-9 rounded-full bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-200 disabled:cursor-not-allowed text-white flex items-center justify-center flex-shrink-0 transition-all duration-200 hover:scale-105 disabled:scale-100" | |
| > | |
| {isSending ? ( | |
| <Loader2 className="w-4 h-4 animate-spin" /> | |
| ) : ( | |
| <Send className="w-4 h-4" /> | |
| )} | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function MessageBubble({ message }: { message: InterviewMessage }) { | |
| const isUser = message.role === "user"; | |
| if (isUser) { | |
| return ( | |
| <div className="flex gap-2.5 max-w-[85%] ml-auto flex-row-reverse"> | |
| <div className="w-7 h-7 rounded-full bg-blue-600 flex items-center justify-center flex-shrink-0 mt-0.5"> | |
| <User className="w-4 h-4 text-white" /> | |
| </div> | |
| <div className="bg-blue-600 text-white rounded-2xl rounded-tr-sm px-3.5 py-2.5 shadow-sm"> | |
| <p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="flex gap-2.5 max-w-[85%]"> | |
| <div className="w-7 h-7 rounded-full bg-emerald-100 flex items-center justify-center flex-shrink-0 mt-0.5"> | |
| <Bot className="w-4 h-4 text-emerald-600" /> | |
| </div> | |
| <div className="bg-white border border-slate-200 rounded-2xl rounded-tl-sm px-3.5 py-2.5 shadow-sm"> | |
| {message.isStreaming && !message.content ? ( | |
| <div className="flex gap-1 py-1"> | |
| <span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce [animation-delay:0ms]" /> | |
| <span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce [animation-delay:150ms]" /> | |
| <span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce [animation-delay:300ms]" /> | |
| </div> | |
| ) : ( | |
| <div className="text-slate-700"> | |
| <ReactMarkdown | |
| remarkPlugins={[remarkGfm]} | |
| components={markdownComponents} | |
| > | |
| {message.content} | |
| </ReactMarkdown> | |
| {message.isStreaming && ( | |
| <span className="inline-block w-1 h-3.5 bg-emerald-500 ml-0.5 animate-pulse rounded-sm" /> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |