| 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 { |
| |
| } |
| 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<string | null>(null); |
| const [status, setStatus] = useState<InterviewStatus>("idle"); |
| const [mode, setMode] = useState<InterviewMode>("text"); |
| const [messages, setMessages] = useState<InterviewMessage[]>([]); |
| const [isSending, setIsSending] = useState(false); |
| const [isStarting, setIsStarting] = useState(false); |
| const [startError, setStartError] = useState<string | null>(null); |
| const [interviewResult, setInterviewResult] = useState<InterviewResult | null>(null); |
| const [isLoaded, setIsLoaded] = useState(false); |
|
|
| const audioHandle = useRef<AudioSessionHandle | null>(null); |
|
|
| |
| 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]); |
|
|
| |
| const persist = useCallback( |
| (patch: Partial<PersistedState>) => { |
| 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"); |
|
|
| |
| 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, |
| }; |
| } |
|
|