Spaces:
Sleeping
Sleeping
| 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<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); | |
| // 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<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"); | |
| // /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, | |
| }; | |
| } | |