| 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); |
|
|
| |
| useEffect(() => { |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
| }, [messages, audioStreamingText]); |
|
|
| |
| useEffect(() => { |
| if (isLoaded && status === "idle") { |
| startSession("text"); |
| } |
| }, [isLoaded, status]); |
|
|
| |
| useEffect(() => { |
| if (status === "completed" && interviewResult) { |
| onResultReady?.(interviewResult); |
| } else if (status === "completed" && !interviewResult) { |
| getInterviewResult(roomId) |
| .then((r) => onResultReady?.(r)) |
| .catch(() => {}); |
| } |
| }, [status, interviewResult]); |
|
|
| |
| useEffect(() => { |
| if (audioMode !== "audio" || status !== "active") return; |
| if (isAudioConnected) return; |
|
|
| setIsAudioConnected(true); |
| connectAudio( |
| (token) => setAudioStreamingText((prev) => prev + token), |
| (fullText) => { |
| setAudioStreamingText(""); |
| |
| |
| void fullText; |
| }, |
| async (audioBuf) => { |
| |
| 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 { |
| |
| } |
| }, |
| () => { |
| setIsAudioConnected(false); |
| onComplete(); |
| } |
| ); |
|
|
| return () => { |
| disconnectAudio(); |
| setIsAudioConnected(false); |
| }; |
| }, [audioMode, status]); |
|
|
| 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"); |
| }; |
|
|
| |
| 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> |
| ); |
| } |
|
|
| |
| 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> |
| ); |
| } |
|
|