import { useCallback, useEffect, useRef, useState } from "react"; import { createSession, finishSession, openAudioSession, streamMessage, type AudioSessionHandle, type InterviewResult, type StreamMetadata, } from "../services/interviewApi"; export type InterviewMode = "text" | "audio"; export type InterviewStatus = "idle" | "active" | "completed"; export interface InterviewMessage { id: string; role: "user" | "assistant"; content: string; isStreaming?: boolean; } interface PersistedState { sessionId: string | null; status: InterviewStatus; mode: InterviewMode; messages: InterviewMessage[]; result?: InterviewResult | null; } const FRAMEWORK_ID = "discovery_problem_v2"; const STORAGE_KEY = (roomId: string) => `interview_${roomId}`; function loadState(roomId: string): PersistedState { try { const raw = localStorage.getItem(STORAGE_KEY(roomId)); if (raw) return JSON.parse(raw) as PersistedState; } catch { // ignore } return { sessionId: null, status: "idle", mode: "text", messages: [], result: null }; } function saveState(roomId: string, state: PersistedState) { localStorage.setItem(STORAGE_KEY(roomId), JSON.stringify(state)); } export function useInterviewSession(roomId: string | null, userId: string | null) { const [sessionId, setSessionId] = useState(null); const [status, setStatus] = useState("idle"); const [mode, setMode] = useState("text"); const [messages, setMessages] = useState([]); const [isSending, setIsSending] = useState(false); const [isStarting, setIsStarting] = useState(false); const [startError, setStartError] = useState(null); const [interviewResult, setInterviewResult] = useState(null); const [isLoaded, setIsLoaded] = useState(false); const audioHandle = useRef(null); // Load persisted state when roomId changes useEffect(() => { if (!roomId) return; setIsLoaded(false); const saved = loadState(roomId); setSessionId(saved.sessionId); setStatus(saved.status); setMode(saved.mode); setMessages(saved.messages); setInterviewResult(saved.result ?? null); setIsLoaded(true); }, [roomId]); // Persist state changes const persist = useCallback( (patch: Partial) => { if (!roomId) return; const current = loadState(roomId); const next = { ...current, ...patch }; saveState(roomId, next); }, [roomId] ); const addMessage = useCallback( (msg: InterviewMessage) => { setMessages((prev) => { const next = [...prev, msg]; if (roomId) persist({ messages: next }); return next; }); }, [roomId, persist] ); const updateLastAssistantMessage = useCallback( (content: string, done = false) => { setMessages((prev) => { const next = prev.map((m, i) => i === prev.length - 1 && m.role === "assistant" ? { ...m, content, isStreaming: !done } : m ); if (done && roomId) persist({ messages: next }); return next; }); }, [roomId, persist] ); const startSession = useCallback( async (interviewMode: InterviewMode = "text") => { if (!roomId || !userId || status === "active" || isStarting) return; setIsStarting(true); setStartError(null); try { const res = await createSession(FRAMEWORK_ID, userId, roomId, interviewMode); const openingId = crypto.randomUUID(); const questionId = crypto.randomUUID(); const initialMessages: InterviewMessage[] = [ { id: openingId, role: "assistant", content: res.opening_message }, { id: questionId, role: "assistant", content: res.first_question }, ]; setSessionId(res.session_id); setStatus("active"); setMode(interviewMode); setMessages(initialMessages); persist({ sessionId: res.session_id, status: "active", mode: interviewMode, messages: initialMessages, }); } catch (err) { const msg = err instanceof Error ? err.message : "Gagal memulai sesi interview"; setStartError(msg); } finally { setIsStarting(false); } }, [roomId, userId, status, isStarting, persist] ); const sendTextMessage = useCallback( async (text: string) => { if (!sessionId || isSending) return; setIsSending(true); const userMsg: InterviewMessage = { id: crypto.randomUUID(), role: "user", content: text, }; addMessage(userMsg); const placeholderId = crypto.randomUUID(); const placeholder: InterviewMessage = { id: placeholderId, role: "assistant", content: "", isStreaming: true, }; setMessages((prev) => [...prev, placeholder]); try { const res = await streamMessage(sessionId, text); if (!res.body) throw new Error("No response body"); // /finish command returns plain JSON, not SSE stream const contentType = res.headers.get("Content-Type") ?? ""; if (contentType.includes("application/json")) { const finishRes = await res.json() as import("../services/interviewApi").FinishSessionResponse; updateLastAssistantMessage("", true); setStatus("completed"); persist({ status: "completed", result: finishRes.result }); setInterviewResult(finishRes.result); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let accumulated = ""; while (true) { const { done, value } = await reader.read(); if (done) break; const lines = decoder.decode(value).split("\n"); for (const line of lines) { if (!line.startsWith("data: ")) continue; const payload = line.slice(6); try { const meta = JSON.parse(payload) as StreamMetadata; updateLastAssistantMessage(accumulated, true); if (meta.finished) { setStatus("completed"); persist({ status: "completed" }); const finishRes = await finishSession(sessionId); setInterviewResult(finishRes.result); persist({ result: finishRes.result }); } } catch { accumulated += payload; updateLastAssistantMessage(accumulated); } } } } catch (err) { console.error("Stream error", err); updateLastAssistantMessage("Maaf, terjadi kesalahan. Coba lagi.", true); } finally { setIsSending(false); } }, [sessionId, isSending, addMessage, updateLastAssistantMessage, persist] ); const connectAudio = useCallback( ( onTokenChunk: (token: string) => void, onAssistantReply: (text: string) => void, onAudio: (buf: ArrayBuffer) => void, onSessionDone: () => void ) => { if (!sessionId) return; audioHandle.current?.close(); audioHandle.current = openAudioSession( sessionId, (event) => { if (event.type === "token_chunk") onTokenChunk(event.payload); else if (event.type === "assistant_reply") onAssistantReply(event.payload); else if (event.type === "session_done") { setStatus("completed"); persist({ status: "completed" }); finishSession(sessionId) .then((res) => { setInterviewResult(res.result); persist({ result: res.result }); }) .catch(console.error); onSessionDone(); } }, onAudio, () => {} ); }, [sessionId, persist] ); const disconnectAudio = useCallback(() => { audioHandle.current?.close(); audioHandle.current = null; }, []); const sendAudioChunk = useCallback((chunk: ArrayBuffer) => { audioHandle.current?.sendAudioChunk(chunk); }, []); const sendEndUtterance = useCallback(() => { audioHandle.current?.sendEndUtterance(); }, []); const switchMode = useCallback( (newMode: InterviewMode) => { disconnectAudio(); setMode(newMode); persist({ mode: newMode }); }, [disconnectAudio, persist] ); const resetSession = useCallback(() => { disconnectAudio(); if (roomId) localStorage.removeItem(STORAGE_KEY(roomId)); setSessionId(null); setStatus("idle"); setMode("text"); setMessages([]); setInterviewResult(null); }, [roomId, disconnectAudio]); return { sessionId, status, mode, messages, isSending, isStarting, startError, interviewResult, isLoaded, startSession, sendTextMessage, connectAudio, disconnectAudio, sendAudioChunk, sendEndUtterance, switchMode, resetSession, }; }