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 }) => (
{children}
),
ul: ({ children }) => (
),
ol: ({ children }) => (
{children}
),
li: ({ children }) => {children},
strong: ({ children }) => {children},
em: ({ children }) => {children},
};
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("text");
const [audioStreamingText, setAudioStreamingText] = useState("");
const [isAudioConnected, setIsAudioConnected] = useState(false);
const messagesEndRef = useRef(null);
const audioContextRef = useRef(null);
const inputRef = useRef(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 (
);
}
// Error state — backend tidak bisa dijangkau
if (startError && status === "idle") {
return (
Tidak dapat terhubung ke server interview
Pastikan backend interview berjalan di localhost:8080
);
}
return (
{/* Mode toggle bar */}
{status === "completed"
? "Interview selesai — lihat hasil di bawah"
: "Jawab pertanyaan untuk membantu analisis data Anda"}
{status === "completed" && (
)}
{status === "active" && (
)}
{/* Messages */}
{messages.map((msg) => (
))}
{/* Live audio streaming text */}
{audioStreamingText && (
)}
{/* Completed state */}
{status === "completed" && (
)}
{/* CTA setelah interview selesai */}
{status === "completed" && (
)}
{/* Input area */}
{status === "active" && (
{audioMode === "audio" ? (
Tahan tombol mikrofon lalu bicara
) : (
)}
)}
);
}
function MessageBubble({ message }: { message: InterviewMessage }) {
const isUser = message.role === "user";
if (isUser) {
return (
);
}
return (
{message.isStreaming && !message.content ? (
) : (
{message.content}
{message.isStreaming && (
)}
)}
);
}