E2E-Frontend-Data-Eyond / src /hooks /useInterviewSession.ts
ishaq101's picture
feat:interview (#1)
fe828ac
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,
};
}