feat:interview
#1
by ishaq101 - opened
- .env.example +2 -1
- .gitignore +3 -0
- package.json +2 -1
- server.js +1 -1
- src/app/components/Main.tsx +143 -20
- src/app/components/NewChatOnboarding.tsx +136 -0
- src/app/components/interview/AudioRecorder.tsx +144 -0
- src/app/components/interview/InterviewPanel.tsx +383 -0
- src/app/components/interview/InterviewResultView.tsx +131 -0
- src/app/components/interview/PhaseToggle.tsx +41 -0
- src/hooks/useInterviewSession.ts +301 -0
- src/services/api.ts +83 -36
- src/services/interviewApi.ts +175 -0
.env.example
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
-
|
|
|
|
|
|
| 1 |
+
VITE_AGENTIC_API_BASE_URL=
|
| 2 |
+
VITE_ORCHESTRATION_API_BASE_URL=
|
.gitignore
CHANGED
|
@@ -37,6 +37,9 @@ Thumbs.db
|
|
| 37 |
vite.config.ts.timestamp-*
|
| 38 |
|
| 39 |
API_CONTRACT.md
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
# Database logos (served via CDN)
|
| 42 |
public/databases/
|
|
|
|
| 37 |
vite.config.ts.timestamp-*
|
| 38 |
|
| 39 |
API_CONTRACT.md
|
| 40 |
+
API_CONTRACT_INTERVIEW.md
|
| 41 |
+
API_CONTRACT_AGENT_ACTIVE.md
|
| 42 |
+
API_CONTRACT_ORCHESTRATION.md
|
| 43 |
|
| 44 |
# Database logos (served via CDN)
|
| 45 |
public/databases/
|
package.json
CHANGED
|
@@ -90,6 +90,7 @@
|
|
| 90 |
"pnpm": {
|
| 91 |
"overrides": {
|
| 92 |
"vite": "6.3.5"
|
| 93 |
-
}
|
|
|
|
| 94 |
}
|
| 95 |
}
|
|
|
|
| 90 |
"pnpm": {
|
| 91 |
"overrides": {
|
| 92 |
"vite": "6.3.5"
|
| 93 |
+
},
|
| 94 |
+
"onlyBuiltDependencies": ["@tailwindcss/oxide", "esbuild"]
|
| 95 |
}
|
| 96 |
}
|
server.js
CHANGED
|
@@ -6,7 +6,7 @@ const url = require("url");
|
|
| 6 |
|
| 7 |
const PORT = 7860;
|
| 8 |
const DIST_DIR = path.join(__dirname, "dist");
|
| 9 |
-
const BACKEND_URL = process.env.
|
| 10 |
|
| 11 |
const MIME = {
|
| 12 |
".html": "text/html",
|
|
|
|
| 6 |
|
| 7 |
const PORT = 7860;
|
| 8 |
const DIST_DIR = path.join(__dirname, "dist");
|
| 9 |
+
const BACKEND_URL = process.env.VITE_AGENTIC_API_BASE_URL || "";
|
| 10 |
|
| 11 |
const MIME = {
|
| 12 |
".html": "text/html",
|
src/app/components/Main.tsx
CHANGED
|
@@ -19,6 +19,12 @@ import remarkMath from "remark-math";
|
|
| 19 |
import rehypeKatex from "rehype-katex";
|
| 20 |
import type { Components } from "react-markdown";
|
| 21 |
import KnowledgeManagement from "./KnowledgeManagement";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
import {
|
| 23 |
getRooms,
|
| 24 |
getRoom,
|
|
@@ -222,6 +228,9 @@ export default function Main() {
|
|
| 222 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 223 |
const [user, setUser] = useState<StoredUser | null>(null);
|
| 224 |
const [knowledgeOpen, setKnowledgeOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
| 225 |
const abortControllerRef = useRef<AbortController | null>(null);
|
| 226 |
|
| 227 |
useEffect(() => {
|
|
@@ -243,9 +252,72 @@ export default function Main() {
|
|
| 243 |
if (chat && !chat.messagesLoaded) {
|
| 244 |
loadRoomMessages(currentChatId);
|
| 245 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 247 |
}, [currentChatId]);
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
const loadRooms = async (userId: string) => {
|
| 250 |
setRoomsLoading(true);
|
| 251 |
setRoomsError(null);
|
|
@@ -565,9 +637,9 @@ export default function Main() {
|
|
| 565 |
e.stopPropagation();
|
| 566 |
deleteChat(chat.id);
|
| 567 |
}}
|
| 568 |
-
className="opacity-0 group-hover:opacity-100 transition"
|
| 569 |
>
|
| 570 |
-
<Trash2 className="w-3.5 h-3.5
|
| 571 |
</button>
|
| 572 |
</div>
|
| 573 |
))
|
|
@@ -690,7 +762,7 @@ export default function Main() {
|
|
| 690 |
<Menu className="w-5 h-5" />
|
| 691 |
)}
|
| 692 |
</button>
|
| 693 |
-
<h1 className="text-base text-slate-900 flex-1 truncate">
|
| 694 |
{currentChat?.title || "Chatbot"}
|
| 695 |
</h1>
|
| 696 |
<button
|
|
@@ -702,8 +774,46 @@ export default function Main() {
|
|
| 702 |
</button>
|
| 703 |
</div>
|
| 704 |
|
| 705 |
-
{/*
|
| 706 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 707 |
{currentChat?.messages.length === 0 && (
|
| 708 |
<div className="flex items-center justify-center h-full">
|
| 709 |
<div className="text-center">
|
|
@@ -791,25 +901,12 @@ export default function Main() {
|
|
| 791 |
</div>
|
| 792 |
))}
|
| 793 |
|
| 794 |
-
{!currentChat && chats.length === 0 && !roomsLoading && (
|
| 795 |
-
<div className="flex items-center justify-center h-full">
|
| 796 |
-
<div className="text-center">
|
| 797 |
-
<MessageSquare className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
| 798 |
-
<h2 className="text-base text-slate-600 mb-1">
|
| 799 |
-
Welcome to Chatbot
|
| 800 |
-
</h2>
|
| 801 |
-
<p className="text-sm text-slate-400">
|
| 802 |
-
Create a new chat to get started
|
| 803 |
-
</p>
|
| 804 |
-
</div>
|
| 805 |
-
</div>
|
| 806 |
-
)}
|
| 807 |
|
| 808 |
<div ref={messagesEndRef} />
|
| 809 |
</div>
|
| 810 |
|
| 811 |
-
{/* Input Area */}
|
| 812 |
-
<div className=
|
| 813 |
<div className="max-w-4xl mx-auto">
|
| 814 |
<div className="flex gap-2 items-end">
|
| 815 |
<textarea
|
|
@@ -842,6 +939,32 @@ export default function Main() {
|
|
| 842 |
open={knowledgeOpen}
|
| 843 |
onClose={() => setKnowledgeOpen(false)}
|
| 844 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 845 |
</div>
|
| 846 |
);
|
| 847 |
}
|
|
|
|
| 19 |
import rehypeKatex from "rehype-katex";
|
| 20 |
import type { Components } from "react-markdown";
|
| 21 |
import KnowledgeManagement from "./KnowledgeManagement";
|
| 22 |
+
import PhaseToggle, { type Phase } from "./interview/PhaseToggle";
|
| 23 |
+
import InterviewPanel from "./interview/InterviewPanel";
|
| 24 |
+
import InterviewResultView from "./interview/InterviewResultView";
|
| 25 |
+
import NewChatOnboarding from "./NewChatOnboarding";
|
| 26 |
+
import type { InterviewResult } from "../../services/interviewApi";
|
| 27 |
+
import { getInterviewResult } from "../../services/interviewApi";
|
| 28 |
import {
|
| 29 |
getRooms,
|
| 30 |
getRoom,
|
|
|
|
| 228 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 229 |
const [user, setUser] = useState<StoredUser | null>(null);
|
| 230 |
const [knowledgeOpen, setKnowledgeOpen] = useState(false);
|
| 231 |
+
const [currentPhase, setCurrentPhase] = useState<Phase>("interview");
|
| 232 |
+
const [interviewResult, setInterviewResult] = useState<InterviewResult | null>(null);
|
| 233 |
+
const [resultModalOpen, setResultModalOpen] = useState(false);
|
| 234 |
const abortControllerRef = useRef<AbortController | null>(null);
|
| 235 |
|
| 236 |
useEffect(() => {
|
|
|
|
| 252 |
if (chat && !chat.messagesLoaded) {
|
| 253 |
loadRoomMessages(currentChatId);
|
| 254 |
}
|
| 255 |
+
// Restore phase for this room
|
| 256 |
+
const savedPhase = localStorage.getItem(`phase_${currentChatId}`);
|
| 257 |
+
if (savedPhase === "analytics" || savedPhase === "interview") {
|
| 258 |
+
setCurrentPhase(savedPhase);
|
| 259 |
+
} else {
|
| 260 |
+
const interviewRaw = localStorage.getItem(`interview_${currentChatId}`);
|
| 261 |
+
if (interviewRaw) {
|
| 262 |
+
try {
|
| 263 |
+
const iv = JSON.parse(interviewRaw) as { status?: string };
|
| 264 |
+
setCurrentPhase(iv.status === "completed" ? "analytics" : "interview");
|
| 265 |
+
} catch {
|
| 266 |
+
setCurrentPhase("interview");
|
| 267 |
+
}
|
| 268 |
+
} else {
|
| 269 |
+
setCurrentPhase("interview");
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
// Load persisted interview result β localStorage first, fallback ke API
|
| 273 |
+
const interviewRaw2 = localStorage.getItem(`interview_${currentChatId}`);
|
| 274 |
+
let localResult: InterviewResult | null = null;
|
| 275 |
+
if (interviewRaw2) {
|
| 276 |
+
try {
|
| 277 |
+
const iv = JSON.parse(interviewRaw2) as { result?: InterviewResult };
|
| 278 |
+
localResult = iv.result ?? null;
|
| 279 |
+
} catch {
|
| 280 |
+
localResult = null;
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
if (localResult) {
|
| 284 |
+
setInterviewResult(localResult);
|
| 285 |
+
} else {
|
| 286 |
+
// Coba ambil dari backend (sesi sudah selesai sebelumnya)
|
| 287 |
+
getInterviewResult(currentChatId)
|
| 288 |
+
.then(setInterviewResult)
|
| 289 |
+
.catch(() => setInterviewResult(null));
|
| 290 |
+
}
|
| 291 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 292 |
}, [currentChatId]);
|
| 293 |
|
| 294 |
+
const handlePhaseChange = (phase: Phase) => {
|
| 295 |
+
setCurrentPhase(phase);
|
| 296 |
+
if (currentChatId) localStorage.setItem(`phase_${currentChatId}`, phase);
|
| 297 |
+
};
|
| 298 |
+
|
| 299 |
+
const handleInterviewComplete = () => {
|
| 300 |
+
setCurrentPhase("analytics");
|
| 301 |
+
if (currentChatId) localStorage.setItem(`phase_${currentChatId}`, "analytics");
|
| 302 |
+
};
|
| 303 |
+
|
| 304 |
+
const handleOnboardingStart = async () => {
|
| 305 |
+
if (!user) return;
|
| 306 |
+
const res = await createRoom(user.user_id, "New Session");
|
| 307 |
+
const newRoom: ChatRoom = {
|
| 308 |
+
id: res.data.id,
|
| 309 |
+
title: res.data.title,
|
| 310 |
+
messages: [],
|
| 311 |
+
createdAt: res.data.created_at,
|
| 312 |
+
updatedAt: res.data.updated_at,
|
| 313 |
+
messagesLoaded: true,
|
| 314 |
+
};
|
| 315 |
+
setChats((prev) => [newRoom, ...prev]);
|
| 316 |
+
setCurrentChatId(newRoom.id);
|
| 317 |
+
setCurrentPhase("interview");
|
| 318 |
+
localStorage.setItem(`phase_${newRoom.id}`, "interview");
|
| 319 |
+
};
|
| 320 |
+
|
| 321 |
const loadRooms = async (userId: string) => {
|
| 322 |
setRoomsLoading(true);
|
| 323 |
setRoomsError(null);
|
|
|
|
| 637 |
e.stopPropagation();
|
| 638 |
deleteChat(chat.id);
|
| 639 |
}}
|
| 640 |
+
className="opacity-0 group-hover:opacity-100 transition p-1 rounded hover:bg-white/20 cursor-pointer text-red-200 hover:text-white"
|
| 641 |
>
|
| 642 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 643 |
</button>
|
| 644 |
</div>
|
| 645 |
))
|
|
|
|
| 762 |
<Menu className="w-5 h-5" />
|
| 763 |
)}
|
| 764 |
</button>
|
| 765 |
+
<h1 className="text-base text-slate-900 flex-1 truncate min-w-0">
|
| 766 |
{currentChat?.title || "Chatbot"}
|
| 767 |
</h1>
|
| 768 |
<button
|
|
|
|
| 774 |
</button>
|
| 775 |
</div>
|
| 776 |
|
| 777 |
+
{/* Phase Toggle + Hasil Interview button β scoped per chat */}
|
| 778 |
+
{currentChatId && (
|
| 779 |
+
<div className="relative z-10 px-4 pt-3 pb-2 flex items-center gap-3">
|
| 780 |
+
<PhaseToggle
|
| 781 |
+
phase={currentPhase}
|
| 782 |
+
onChange={handlePhaseChange}
|
| 783 |
+
/>
|
| 784 |
+
{interviewResult && (
|
| 785 |
+
<button
|
| 786 |
+
onClick={() => setResultModalOpen(true)}
|
| 787 |
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 text-emerald-700 rounded-full transition"
|
| 788 |
+
>
|
| 789 |
+
<span>π</span>
|
| 790 |
+
Hasil Interview
|
| 791 |
+
</button>
|
| 792 |
+
)}
|
| 793 |
+
</div>
|
| 794 |
+
)}
|
| 795 |
+
|
| 796 |
+
{/* Interview Panel */}
|
| 797 |
+
{currentChatId && currentPhase === "interview" && (
|
| 798 |
+
<div className="relative z-10 flex-1 flex flex-col min-h-0">
|
| 799 |
+
<InterviewPanel
|
| 800 |
+
roomId={currentChatId}
|
| 801 |
+
userId={user?.user_id ?? ""}
|
| 802 |
+
onComplete={handleInterviewComplete}
|
| 803 |
+
onResultReady={setInterviewResult}
|
| 804 |
+
/>
|
| 805 |
+
</div>
|
| 806 |
+
)}
|
| 807 |
+
|
| 808 |
+
{/* New Chat Onboarding β muncul saat tidak ada room dipilih */}
|
| 809 |
+
{!currentChatId && (
|
| 810 |
+
<div className="relative z-10 flex-1 flex flex-col min-h-0">
|
| 811 |
+
<NewChatOnboarding onStart={handleOnboardingStart} />
|
| 812 |
+
</div>
|
| 813 |
+
)}
|
| 814 |
+
|
| 815 |
+
{/* Analytics Messages */}
|
| 816 |
+
<div className={`relative z-10 flex-1 overflow-y-auto p-4 space-y-4 ${!currentChatId || (currentChatId && currentPhase === "interview") ? "hidden" : ""}`}>
|
| 817 |
{currentChat?.messages.length === 0 && (
|
| 818 |
<div className="flex items-center justify-center h-full">
|
| 819 |
<div className="text-center">
|
|
|
|
| 901 |
</div>
|
| 902 |
))}
|
| 903 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
|
| 905 |
<div ref={messagesEndRef} />
|
| 906 |
</div>
|
| 907 |
|
| 908 |
+
{/* Input Area β hidden when interview phase active or no room selected */}
|
| 909 |
+
<div className={`relative z-10 bg-white border-t border-slate-200 p-3 shadow-[0_-2px_10px_rgba(0,0,0,0.06)] ${!currentChatId || (currentChatId && currentPhase === "interview") ? "hidden" : ""}`}>
|
| 910 |
<div className="max-w-4xl mx-auto">
|
| 911 |
<div className="flex gap-2 items-end">
|
| 912 |
<textarea
|
|
|
|
| 939 |
open={knowledgeOpen}
|
| 940 |
onClose={() => setKnowledgeOpen(false)}
|
| 941 |
/>
|
| 942 |
+
|
| 943 |
+
{/* Interview Result Modal */}
|
| 944 |
+
{resultModalOpen && interviewResult && (
|
| 945 |
+
<div
|
| 946 |
+
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
| 947 |
+
onClick={() => setResultModalOpen(false)}
|
| 948 |
+
>
|
| 949 |
+
<div
|
| 950 |
+
className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden"
|
| 951 |
+
onClick={(e) => e.stopPropagation()}
|
| 952 |
+
>
|
| 953 |
+
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-200">
|
| 954 |
+
<h2 className="text-base font-semibold text-slate-800">Hasil Interview</h2>
|
| 955 |
+
<button
|
| 956 |
+
onClick={() => setResultModalOpen(false)}
|
| 957 |
+
className="text-slate-400 hover:text-slate-600 transition p-1 rounded-lg hover:bg-slate-100"
|
| 958 |
+
>
|
| 959 |
+
<X className="w-4 h-4" />
|
| 960 |
+
</button>
|
| 961 |
+
</div>
|
| 962 |
+
<div className="overflow-y-auto flex-1">
|
| 963 |
+
<InterviewResultView result={interviewResult} />
|
| 964 |
+
</div>
|
| 965 |
+
</div>
|
| 966 |
+
</div>
|
| 967 |
+
)}
|
| 968 |
</div>
|
| 969 |
);
|
| 970 |
}
|
src/app/components/NewChatOnboarding.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { BrainCircuit, BarChart2, ArrowRight, Mic, FileText, Loader2, ChevronRight } from "lucide-react";
|
| 3 |
+
|
| 4 |
+
interface NewChatOnboardingProps {
|
| 5 |
+
onStart: () => Promise<void>;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export default function NewChatOnboarding({ onStart }: NewChatOnboardingProps) {
|
| 9 |
+
const [isStarting, setIsStarting] = useState(false);
|
| 10 |
+
|
| 11 |
+
const handleStart = async () => {
|
| 12 |
+
setIsStarting(true);
|
| 13 |
+
try {
|
| 14 |
+
await onStart();
|
| 15 |
+
} finally {
|
| 16 |
+
setIsStarting(false);
|
| 17 |
+
}
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<div className="flex-1 flex flex-col items-center justify-center px-6 py-10 overflow-y-auto">
|
| 22 |
+
<div className="w-full max-w-2xl flex flex-col items-center gap-8">
|
| 23 |
+
|
| 24 |
+
{/* Header */}
|
| 25 |
+
<div className="text-center">
|
| 26 |
+
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600 mb-2">
|
| 27 |
+
AI Data Agent
|
| 28 |
+
</p>
|
| 29 |
+
<h2 className="text-2xl font-semibold text-slate-800 mb-2">
|
| 30 |
+
Selamat datang di Data Eyond
|
| 31 |
+
</h2>
|
| 32 |
+
<p className="text-sm text-slate-500 max-w-md">
|
| 33 |
+
Ikuti alur dua fase berikut untuk mendapatkan hasil analisis data yang akurat dan terstruktur
|
| 34 |
+
</p>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
{/* Flow cards */}
|
| 38 |
+
<div className="w-full flex flex-col sm:flex-row items-stretch gap-3">
|
| 39 |
+
|
| 40 |
+
{/* Card 1 β Interview */}
|
| 41 |
+
<div className="flex-1 bg-white border border-emerald-100 rounded-2xl p-5 shadow-sm flex flex-col gap-3 relative">
|
| 42 |
+
{/* Step badge */}
|
| 43 |
+
<span className="absolute -top-2.5 left-4 bg-emerald-600 text-white text-[10px] font-semibold px-2.5 py-0.5 rounded-full">
|
| 44 |
+
Fase 1
|
| 45 |
+
</span>
|
| 46 |
+
<div className="flex items-center gap-2.5 mt-1">
|
| 47 |
+
<div className="w-9 h-9 rounded-xl bg-emerald-50 flex items-center justify-center flex-shrink-0">
|
| 48 |
+
<BrainCircuit className="w-5 h-5 text-emerald-600" />
|
| 49 |
+
</div>
|
| 50 |
+
<div>
|
| 51 |
+
<h3 className="text-sm font-semibold text-slate-800">Interview</h3>
|
| 52 |
+
<p className="text-xs text-emerald-600">Business Understanding</p>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
<p className="text-xs text-slate-500 leading-relaxed">
|
| 56 |
+
AI agent akan menggali pemahaman tentang konteks bisnis, problem statement, dan success metrics sebelum memulai analisis.
|
| 57 |
+
</p>
|
| 58 |
+
<ul className="space-y-1.5">
|
| 59 |
+
{[
|
| 60 |
+
{ icon: <BrainCircuit className="w-3 h-3" />, text: "Framework CRISP-DM" },
|
| 61 |
+
{ icon: <FileText className="w-3 h-3" />, text: "Mode teks" },
|
| 62 |
+
{ icon: <Mic className="w-3 h-3" />, text: "Mode audio (voice)" },
|
| 63 |
+
].map((item, i) => (
|
| 64 |
+
<li key={i} className="flex items-center gap-2 text-xs text-slate-500">
|
| 65 |
+
<span className="text-emerald-500">{item.icon}</span>
|
| 66 |
+
{item.text}
|
| 67 |
+
</li>
|
| 68 |
+
))}
|
| 69 |
+
</ul>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
{/* Arrow */}
|
| 73 |
+
<div className="flex items-center justify-center flex-shrink-0 text-slate-300 rotate-90 sm:rotate-0">
|
| 74 |
+
<ArrowRight className="w-5 h-5" />
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* Card 2 β Analytics */}
|
| 78 |
+
<div className="flex-1 bg-white border border-blue-100 rounded-2xl p-5 shadow-sm flex flex-col gap-3 relative">
|
| 79 |
+
<span className="absolute -top-2.5 left-4 bg-blue-600 text-white text-[10px] font-semibold px-2.5 py-0.5 rounded-full">
|
| 80 |
+
Fase 2
|
| 81 |
+
</span>
|
| 82 |
+
<div className="flex items-center gap-2.5 mt-1">
|
| 83 |
+
<div className="w-9 h-9 rounded-xl bg-blue-50 flex items-center justify-center flex-shrink-0">
|
| 84 |
+
<BarChart2 className="w-5 h-5 text-blue-600" />
|
| 85 |
+
</div>
|
| 86 |
+
<div>
|
| 87 |
+
<h3 className="text-sm font-semibold text-slate-800">Analytics</h3>
|
| 88 |
+
<p className="text-xs text-blue-600">Deep Research & Report</p>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
<p className="text-xs text-slate-500 leading-relaxed">
|
| 92 |
+
Tanya jawab interaktif dengan AI agent. Secara paralel, agent menyusun draft report analisis yang dapat diekspor sebagai PDF.
|
| 93 |
+
</p>
|
| 94 |
+
<ul className="space-y-1.5">
|
| 95 |
+
{[
|
| 96 |
+
{ text: "Tanya jawab berbasis konteks interview" },
|
| 97 |
+
{ text: "Agent menyusun report secara paralel" },
|
| 98 |
+
{ text: "Export report sebagai PDF" },
|
| 99 |
+
].map((item, i) => (
|
| 100 |
+
<li key={i} className="flex items-center gap-2 text-xs text-slate-500">
|
| 101 |
+
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 flex-shrink-0" />
|
| 102 |
+
{item.text}
|
| 103 |
+
</li>
|
| 104 |
+
))}
|
| 105 |
+
</ul>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
{/* CTA */}
|
| 110 |
+
<div className="flex flex-col items-center gap-2">
|
| 111 |
+
<button
|
| 112 |
+
onClick={handleStart}
|
| 113 |
+
disabled={isStarting}
|
| 114 |
+
className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 disabled:bg-emerald-400 text-white px-6 py-3 rounded-xl font-medium text-sm transition-all duration-200 hover:scale-105 disabled:scale-100 shadow-md shadow-emerald-200"
|
| 115 |
+
>
|
| 116 |
+
{isStarting ? (
|
| 117 |
+
<>
|
| 118 |
+
<Loader2 className="w-4 h-4 animate-spin" />
|
| 119 |
+
Memulai sesiβ¦
|
| 120 |
+
</>
|
| 121 |
+
) : (
|
| 122 |
+
<>
|
| 123 |
+
Mulai Sesi Baru
|
| 124 |
+
<ChevronRight className="w-4 h-4" />
|
| 125 |
+
</>
|
| 126 |
+
)}
|
| 127 |
+
</button>
|
| 128 |
+
<p className="text-[11px] text-slate-400">
|
| 129 |
+
Anda bisa melewati fase interview kapan saja lewat toggle di atas
|
| 130 |
+
</p>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
);
|
| 136 |
+
}
|
src/app/components/interview/AudioRecorder.tsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
+
import { Mic, MicOff, AlertCircle } from "lucide-react";
|
| 3 |
+
|
| 4 |
+
export interface AudioRecorderProps {
|
| 5 |
+
onChunk: (chunk: ArrayBuffer) => void;
|
| 6 |
+
onEndUtterance: () => void;
|
| 7 |
+
disabled?: boolean;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
type RecorderState = "idle" | "recording" | "unsupported";
|
| 11 |
+
|
| 12 |
+
const PREFERRED_MIME = [
|
| 13 |
+
"audio/webm;codecs=opus",
|
| 14 |
+
"audio/webm",
|
| 15 |
+
"audio/ogg;codecs=opus",
|
| 16 |
+
"audio/mp4",
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
+
function getSupportedMimeType(): string | null {
|
| 20 |
+
if (typeof MediaRecorder === "undefined") return null;
|
| 21 |
+
for (const mime of PREFERRED_MIME) {
|
| 22 |
+
if (MediaRecorder.isTypeSupported(mime)) return mime;
|
| 23 |
+
}
|
| 24 |
+
return null;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export default function AudioRecorder({ onChunk, onEndUtterance, disabled }: AudioRecorderProps) {
|
| 28 |
+
const [state, setState] = useState<RecorderState>("idle");
|
| 29 |
+
const [permissionDenied, setPermissionDenied] = useState(false);
|
| 30 |
+
|
| 31 |
+
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
| 32 |
+
const streamRef = useRef<MediaStream | null>(null);
|
| 33 |
+
const mimeType = useRef<string | null>(null);
|
| 34 |
+
|
| 35 |
+
// Check support on mount
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (
|
| 38 |
+
typeof navigator.mediaDevices?.getUserMedia === "undefined" ||
|
| 39 |
+
getSupportedMimeType() === null
|
| 40 |
+
) {
|
| 41 |
+
setState("unsupported");
|
| 42 |
+
}
|
| 43 |
+
mimeType.current = getSupportedMimeType();
|
| 44 |
+
}, []);
|
| 45 |
+
|
| 46 |
+
const startRecording = useCallback(async () => {
|
| 47 |
+
if (state !== "idle" || disabled) return;
|
| 48 |
+
try {
|
| 49 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 50 |
+
streamRef.current = stream;
|
| 51 |
+
|
| 52 |
+
const recorder = new MediaRecorder(stream, {
|
| 53 |
+
mimeType: mimeType.current ?? undefined,
|
| 54 |
+
audioBitsPerSecond: 16000,
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
recorder.ondataavailable = async (e) => {
|
| 58 |
+
if (e.data.size > 0) {
|
| 59 |
+
const buf = await e.data.arrayBuffer();
|
| 60 |
+
onChunk(buf);
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
recorder.start(100); // 100ms chunks
|
| 65 |
+
mediaRecorderRef.current = recorder;
|
| 66 |
+
setState("recording");
|
| 67 |
+
} catch (err: unknown) {
|
| 68 |
+
if (err instanceof DOMException && err.name === "NotAllowedError") {
|
| 69 |
+
setPermissionDenied(true);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
}, [state, disabled, onChunk]);
|
| 73 |
+
|
| 74 |
+
const stopRecording = useCallback(() => {
|
| 75 |
+
if (state !== "recording") return;
|
| 76 |
+
|
| 77 |
+
const recorder = mediaRecorderRef.current;
|
| 78 |
+
if (recorder && recorder.state !== "inactive") {
|
| 79 |
+
recorder.onstop = () => {
|
| 80 |
+
onEndUtterance();
|
| 81 |
+
};
|
| 82 |
+
recorder.stop();
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
streamRef.current?.getTracks().forEach((t) => t.stop());
|
| 86 |
+
streamRef.current = null;
|
| 87 |
+
mediaRecorderRef.current = null;
|
| 88 |
+
setState("idle");
|
| 89 |
+
}, [state, onEndUtterance]);
|
| 90 |
+
|
| 91 |
+
// Cleanup on unmount
|
| 92 |
+
useEffect(() => {
|
| 93 |
+
return () => {
|
| 94 |
+
streamRef.current?.getTracks().forEach((t) => t.stop());
|
| 95 |
+
};
|
| 96 |
+
}, []);
|
| 97 |
+
|
| 98 |
+
if (state === "unsupported") {
|
| 99 |
+
return (
|
| 100 |
+
<div className="flex items-center gap-1.5 text-slate-400 text-xs px-2">
|
| 101 |
+
<AlertCircle className="w-3.5 h-3.5" />
|
| 102 |
+
<span>Audio tidak didukung browser ini</span>
|
| 103 |
+
</div>
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
if (permissionDenied) {
|
| 108 |
+
return (
|
| 109 |
+
<div className="flex items-center gap-1.5 text-amber-500 text-xs px-2">
|
| 110 |
+
<MicOff className="w-3.5 h-3.5" />
|
| 111 |
+
<span>Izin mikrofon ditolak</span>
|
| 112 |
+
</div>
|
| 113 |
+
);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const isRecording = state === "recording";
|
| 117 |
+
|
| 118 |
+
return (
|
| 119 |
+
<button
|
| 120 |
+
onPointerDown={startRecording}
|
| 121 |
+
onPointerUp={stopRecording}
|
| 122 |
+
onPointerLeave={stopRecording}
|
| 123 |
+
disabled={disabled}
|
| 124 |
+
title={isRecording ? "Lepas untuk kirim" : "Tahan untuk merekam"}
|
| 125 |
+
className={`relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-200 flex-shrink-0 ${
|
| 126 |
+
isRecording
|
| 127 |
+
? "bg-red-500 text-white scale-110 shadow-lg shadow-red-200"
|
| 128 |
+
: disabled
|
| 129 |
+
? "bg-slate-100 text-slate-300 cursor-not-allowed"
|
| 130 |
+
: "bg-emerald-50 text-emerald-600 hover:bg-emerald-100 hover:scale-105"
|
| 131 |
+
}`}
|
| 132 |
+
>
|
| 133 |
+
{isRecording ? (
|
| 134 |
+
<>
|
| 135 |
+
{/* Pulse ring */}
|
| 136 |
+
<span className="absolute inset-0 rounded-full bg-red-400 animate-ping opacity-50" />
|
| 137 |
+
<Mic className="w-4 h-4 relative z-10" />
|
| 138 |
+
</>
|
| 139 |
+
) : (
|
| 140 |
+
<Mic className="w-4 h-4" />
|
| 141 |
+
)}
|
| 142 |
+
</button>
|
| 143 |
+
);
|
| 144 |
+
}
|
src/app/components/interview/InterviewPanel.tsx
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
+
import { Bot, User, Send, Loader2, Mic, MicOff, CheckCircle2, RotateCcw, AlertTriangle, RefreshCw, ArrowRight } from "lucide-react";
|
| 3 |
+
import ReactMarkdown from "react-markdown";
|
| 4 |
+
import remarkGfm from "remark-gfm";
|
| 5 |
+
import type { Components } from "react-markdown";
|
| 6 |
+
import AudioRecorder from "./AudioRecorder";
|
| 7 |
+
import { getInterviewResult, type InterviewResult } from "../../../services/interviewApi";
|
| 8 |
+
import {
|
| 9 |
+
useInterviewSession,
|
| 10 |
+
type InterviewMessage,
|
| 11 |
+
type InterviewMode,
|
| 12 |
+
} from "../../../hooks/useInterviewSession";
|
| 13 |
+
|
| 14 |
+
const markdownComponents: Components = {
|
| 15 |
+
p: ({ children }) => (
|
| 16 |
+
<p className="text-sm mb-1.5 last:mb-0 leading-relaxed">{children}</p>
|
| 17 |
+
),
|
| 18 |
+
ul: ({ children }) => (
|
| 19 |
+
<ul className="list-disc pl-4 mb-1.5 space-y-0.5 text-sm">{children}</ul>
|
| 20 |
+
),
|
| 21 |
+
ol: ({ children }) => (
|
| 22 |
+
<ol className="list-decimal pl-4 mb-1.5 space-y-0.5 text-sm">{children}</ol>
|
| 23 |
+
),
|
| 24 |
+
li: ({ children }) => <li>{children}</li>,
|
| 25 |
+
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
| 26 |
+
em: ({ children }) => <em className="italic">{children}</em>,
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
interface InterviewPanelProps {
|
| 30 |
+
roomId: string;
|
| 31 |
+
userId: string;
|
| 32 |
+
onComplete: () => void;
|
| 33 |
+
onResultReady?: (result: InterviewResult) => void;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export default function InterviewPanel({ roomId, userId, onComplete, onResultReady }: InterviewPanelProps) {
|
| 37 |
+
const {
|
| 38 |
+
status,
|
| 39 |
+
messages,
|
| 40 |
+
isSending,
|
| 41 |
+
isStarting,
|
| 42 |
+
startError,
|
| 43 |
+
interviewResult,
|
| 44 |
+
isLoaded,
|
| 45 |
+
startSession,
|
| 46 |
+
sendTextMessage,
|
| 47 |
+
connectAudio,
|
| 48 |
+
disconnectAudio,
|
| 49 |
+
sendAudioChunk,
|
| 50 |
+
sendEndUtterance,
|
| 51 |
+
switchMode,
|
| 52 |
+
resetSession,
|
| 53 |
+
} = useInterviewSession(roomId, userId);
|
| 54 |
+
|
| 55 |
+
const [input, setInput] = useState("");
|
| 56 |
+
const [audioMode, setAudioMode] = useState<InterviewMode>("text");
|
| 57 |
+
const [audioStreamingText, setAudioStreamingText] = useState("");
|
| 58 |
+
const [isAudioConnected, setIsAudioConnected] = useState(false);
|
| 59 |
+
|
| 60 |
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 61 |
+
const audioContextRef = useRef<AudioContext | null>(null);
|
| 62 |
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
| 63 |
+
|
| 64 |
+
// Auto-scroll to bottom
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 67 |
+
}, [messages, audioStreamingText]);
|
| 68 |
+
|
| 69 |
+
// Auto-start hanya setelah localStorage selesai dimuat dan status benar-benar idle
|
| 70 |
+
useEffect(() => {
|
| 71 |
+
if (isLoaded && status === "idle") {
|
| 72 |
+
startSession("text");
|
| 73 |
+
}
|
| 74 |
+
}, [isLoaded, status]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 75 |
+
|
| 76 |
+
// Notify parent when result is ready (from hook or re-fetched)
|
| 77 |
+
useEffect(() => {
|
| 78 |
+
if (status === "completed" && interviewResult) {
|
| 79 |
+
onResultReady?.(interviewResult);
|
| 80 |
+
} else if (status === "completed" && !interviewResult) {
|
| 81 |
+
getInterviewResult(roomId)
|
| 82 |
+
.then((r) => onResultReady?.(r))
|
| 83 |
+
.catch(() => {});
|
| 84 |
+
}
|
| 85 |
+
}, [status, interviewResult]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 86 |
+
|
| 87 |
+
// Setup audio WebSocket when audio mode is active
|
| 88 |
+
useEffect(() => {
|
| 89 |
+
if (audioMode !== "audio" || status !== "active") return;
|
| 90 |
+
if (isAudioConnected) return;
|
| 91 |
+
|
| 92 |
+
setIsAudioConnected(true);
|
| 93 |
+
connectAudio(
|
| 94 |
+
(token) => setAudioStreamingText((prev) => prev + token),
|
| 95 |
+
(fullText) => {
|
| 96 |
+
setAudioStreamingText("");
|
| 97 |
+
// The hook handles adding the message internally, but for audio we add it here
|
| 98 |
+
// since audio messages arrive differently
|
| 99 |
+
void fullText; // handled via useInterviewSession internal state if needed
|
| 100 |
+
},
|
| 101 |
+
async (audioBuf) => {
|
| 102 |
+
// Play TTS audio
|
| 103 |
+
try {
|
| 104 |
+
if (!audioContextRef.current || audioContextRef.current.state === "closed") {
|
| 105 |
+
audioContextRef.current = new AudioContext();
|
| 106 |
+
}
|
| 107 |
+
const ctx = audioContextRef.current;
|
| 108 |
+
const decoded = await ctx.decodeAudioData(audioBuf.slice(0));
|
| 109 |
+
const source = ctx.createBufferSource();
|
| 110 |
+
source.buffer = decoded;
|
| 111 |
+
source.connect(ctx.destination);
|
| 112 |
+
source.start();
|
| 113 |
+
} catch {
|
| 114 |
+
// ignore playback errors
|
| 115 |
+
}
|
| 116 |
+
},
|
| 117 |
+
() => {
|
| 118 |
+
setIsAudioConnected(false);
|
| 119 |
+
onComplete();
|
| 120 |
+
}
|
| 121 |
+
);
|
| 122 |
+
|
| 123 |
+
return () => {
|
| 124 |
+
disconnectAudio();
|
| 125 |
+
setIsAudioConnected(false);
|
| 126 |
+
};
|
| 127 |
+
}, [audioMode, status]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 128 |
+
|
| 129 |
+
const handleSendText = useCallback(async () => {
|
| 130 |
+
const text = input.trim();
|
| 131 |
+
if (!text || isSending || status !== "active") return;
|
| 132 |
+
setInput("");
|
| 133 |
+
await sendTextMessage(text);
|
| 134 |
+
inputRef.current?.focus();
|
| 135 |
+
}, [input, isSending, status, sendTextMessage]);
|
| 136 |
+
|
| 137 |
+
const handleKeyPress = (e: React.KeyboardEvent) => {
|
| 138 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 139 |
+
e.preventDefault();
|
| 140 |
+
handleSendText();
|
| 141 |
+
}
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
const handleModeToggle = () => {
|
| 145 |
+
const next: InterviewMode = audioMode === "text" ? "audio" : "text";
|
| 146 |
+
setAudioMode(next);
|
| 147 |
+
switchMode(next);
|
| 148 |
+
setIsAudioConnected(false);
|
| 149 |
+
setAudioStreamingText("");
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
const handleRestart = () => {
|
| 153 |
+
setAudioMode("text");
|
| 154 |
+
setIsAudioConnected(false);
|
| 155 |
+
setAudioStreamingText("");
|
| 156 |
+
resetSession();
|
| 157 |
+
startSession("text");
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
// Loading state saat memanggil API untuk membuat sesi
|
| 161 |
+
if (isStarting) {
|
| 162 |
+
return (
|
| 163 |
+
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-slate-500">
|
| 164 |
+
<Loader2 className="w-6 h-6 animate-spin text-emerald-600" />
|
| 165 |
+
<p className="text-sm">Memulai sesi interviewβ¦</p>
|
| 166 |
+
</div>
|
| 167 |
+
);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// Error state β backend tidak bisa dijangkau
|
| 171 |
+
if (startError && status === "idle") {
|
| 172 |
+
return (
|
| 173 |
+
<div className="flex-1 flex flex-col items-center justify-center gap-4 px-6">
|
| 174 |
+
<div className="flex flex-col items-center gap-2 text-center">
|
| 175 |
+
<AlertTriangle className="w-8 h-8 text-amber-400" />
|
| 176 |
+
<p className="text-sm font-medium text-slate-700">Tidak dapat terhubung ke server interview</p>
|
| 177 |
+
<p className="text-xs text-slate-400 max-w-xs">
|
| 178 |
+
Pastikan backend interview berjalan di <code className="bg-slate-100 px-1 py-0.5 rounded text-xs">localhost:8080</code>
|
| 179 |
+
</p>
|
| 180 |
+
</div>
|
| 181 |
+
<div className="flex items-center gap-2">
|
| 182 |
+
<button
|
| 183 |
+
onClick={() => startSession("text")}
|
| 184 |
+
className="flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs rounded-lg transition"
|
| 185 |
+
>
|
| 186 |
+
<RefreshCw className="w-3.5 h-3.5" />
|
| 187 |
+
Coba Lagi
|
| 188 |
+
</button>
|
| 189 |
+
<button
|
| 190 |
+
onClick={onComplete}
|
| 191 |
+
className="flex items-center gap-1.5 px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs rounded-lg transition"
|
| 192 |
+
>
|
| 193 |
+
Lanjut ke Analytics
|
| 194 |
+
<ArrowRight className="w-3.5 h-3.5" />
|
| 195 |
+
</button>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
return (
|
| 202 |
+
<div className="flex-1 flex flex-col min-h-0">
|
| 203 |
+
{/* Mode toggle bar */}
|
| 204 |
+
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-100 bg-white/60 backdrop-blur-sm">
|
| 205 |
+
<p className="text-xs text-slate-500">
|
| 206 |
+
{status === "completed"
|
| 207 |
+
? "Interview selesai β lihat hasil di bawah"
|
| 208 |
+
: "Jawab pertanyaan untuk membantu analisis data Anda"}
|
| 209 |
+
</p>
|
| 210 |
+
<div className="flex items-center gap-2">
|
| 211 |
+
{status === "completed" && (
|
| 212 |
+
<button
|
| 213 |
+
onClick={handleRestart}
|
| 214 |
+
className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-600 transition"
|
| 215 |
+
>
|
| 216 |
+
<RotateCcw className="w-3 h-3" />
|
| 217 |
+
Mulai ulang
|
| 218 |
+
</button>
|
| 219 |
+
)}
|
| 220 |
+
{status === "active" && (
|
| 221 |
+
<button
|
| 222 |
+
onClick={handleModeToggle}
|
| 223 |
+
title={audioMode === "text" ? "Beralih ke mode audio" : "Beralih ke mode teks"}
|
| 224 |
+
className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-all duration-200 ${
|
| 225 |
+
audioMode === "audio"
|
| 226 |
+
? "bg-emerald-50 border-emerald-200 text-emerald-700"
|
| 227 |
+
: "bg-slate-50 border-slate-200 text-slate-500 hover:border-slate-300"
|
| 228 |
+
}`}
|
| 229 |
+
>
|
| 230 |
+
{audioMode === "audio" ? (
|
| 231 |
+
<Mic className="w-3 h-3" />
|
| 232 |
+
) : (
|
| 233 |
+
<MicOff className="w-3 h-3" />
|
| 234 |
+
)}
|
| 235 |
+
<span>{audioMode === "audio" ? "Audio" : "Teks"}</span>
|
| 236 |
+
</button>
|
| 237 |
+
)}
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
|
| 241 |
+
{/* Messages */}
|
| 242 |
+
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
| 243 |
+
{messages.map((msg) => (
|
| 244 |
+
<MessageBubble key={msg.id} message={msg} />
|
| 245 |
+
))}
|
| 246 |
+
|
| 247 |
+
{/* Live audio streaming text */}
|
| 248 |
+
{audioStreamingText && (
|
| 249 |
+
<div className="flex gap-2.5 max-w-[85%]">
|
| 250 |
+
<div className="w-7 h-7 rounded-full bg-emerald-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
| 251 |
+
<Bot className="w-4 h-4 text-emerald-600" />
|
| 252 |
+
</div>
|
| 253 |
+
<div className="bg-white border border-slate-200 rounded-2xl rounded-tl-sm px-3.5 py-2.5 shadow-sm">
|
| 254 |
+
<p className="text-sm text-slate-700 leading-relaxed">
|
| 255 |
+
{audioStreamingText}
|
| 256 |
+
<span className="inline-block w-1 h-3.5 bg-emerald-500 ml-0.5 animate-pulse rounded-sm" />
|
| 257 |
+
</p>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
)}
|
| 261 |
+
|
| 262 |
+
{/* Completed state */}
|
| 263 |
+
{status === "completed" && (
|
| 264 |
+
<div className="flex justify-center py-4">
|
| 265 |
+
<div className="flex items-center gap-2 text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-full px-4 py-2 text-sm">
|
| 266 |
+
<CheckCircle2 className="w-4 h-4" />
|
| 267 |
+
<span>Interview selesai!</span>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
)}
|
| 271 |
+
|
| 272 |
+
<div ref={messagesEndRef} />
|
| 273 |
+
</div>
|
| 274 |
+
|
| 275 |
+
{/* CTA setelah interview selesai */}
|
| 276 |
+
{status === "completed" && (
|
| 277 |
+
<div className="border-t border-slate-100 px-4 py-3 flex justify-end">
|
| 278 |
+
<button
|
| 279 |
+
onClick={onComplete}
|
| 280 |
+
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm rounded-xl transition"
|
| 281 |
+
>
|
| 282 |
+
Lanjut ke Analytics
|
| 283 |
+
<ArrowRight className="w-4 h-4" />
|
| 284 |
+
</button>
|
| 285 |
+
</div>
|
| 286 |
+
)}
|
| 287 |
+
|
| 288 |
+
{/* Input area */}
|
| 289 |
+
{status === "active" && (
|
| 290 |
+
<div className="border-t border-slate-200 bg-white/80 backdrop-blur-sm p-3">
|
| 291 |
+
{audioMode === "audio" ? (
|
| 292 |
+
<div className="flex items-center justify-center gap-3 py-2">
|
| 293 |
+
<p className="text-sm text-slate-500">
|
| 294 |
+
Tahan tombol mikrofon lalu bicara
|
| 295 |
+
</p>
|
| 296 |
+
<AudioRecorder
|
| 297 |
+
onChunk={sendAudioChunk}
|
| 298 |
+
onEndUtterance={sendEndUtterance}
|
| 299 |
+
disabled={isSending}
|
| 300 |
+
/>
|
| 301 |
+
</div>
|
| 302 |
+
) : (
|
| 303 |
+
<div className="flex items-end gap-2">
|
| 304 |
+
<textarea
|
| 305 |
+
ref={inputRef}
|
| 306 |
+
value={input}
|
| 307 |
+
onChange={(e) => setInput(e.target.value)}
|
| 308 |
+
onKeyDown={handleKeyPress}
|
| 309 |
+
placeholder="Ketik jawaban Andaβ¦"
|
| 310 |
+
rows={1}
|
| 311 |
+
disabled={isSending}
|
| 312 |
+
className="flex-1 resize-none bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:border-transparent transition max-h-32 disabled:opacity-60"
|
| 313 |
+
style={{ minHeight: "42px" }}
|
| 314 |
+
onInput={(e) => {
|
| 315 |
+
const el = e.currentTarget;
|
| 316 |
+
el.style.height = "auto";
|
| 317 |
+
el.style.height = `${Math.min(el.scrollHeight, 128)}px`;
|
| 318 |
+
}}
|
| 319 |
+
/>
|
| 320 |
+
<button
|
| 321 |
+
onClick={handleSendText}
|
| 322 |
+
disabled={!input.trim() || isSending}
|
| 323 |
+
className="w-9 h-9 rounded-full bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-200 disabled:cursor-not-allowed text-white flex items-center justify-center flex-shrink-0 transition-all duration-200 hover:scale-105 disabled:scale-100"
|
| 324 |
+
>
|
| 325 |
+
{isSending ? (
|
| 326 |
+
<Loader2 className="w-4 h-4 animate-spin" />
|
| 327 |
+
) : (
|
| 328 |
+
<Send className="w-4 h-4" />
|
| 329 |
+
)}
|
| 330 |
+
</button>
|
| 331 |
+
</div>
|
| 332 |
+
)}
|
| 333 |
+
</div>
|
| 334 |
+
)}
|
| 335 |
+
</div>
|
| 336 |
+
);
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
function MessageBubble({ message }: { message: InterviewMessage }) {
|
| 340 |
+
const isUser = message.role === "user";
|
| 341 |
+
|
| 342 |
+
if (isUser) {
|
| 343 |
+
return (
|
| 344 |
+
<div className="flex gap-2.5 max-w-[85%] ml-auto flex-row-reverse">
|
| 345 |
+
<div className="w-7 h-7 rounded-full bg-blue-600 flex items-center justify-center flex-shrink-0 mt-0.5">
|
| 346 |
+
<User className="w-4 h-4 text-white" />
|
| 347 |
+
</div>
|
| 348 |
+
<div className="bg-blue-600 text-white rounded-2xl rounded-tr-sm px-3.5 py-2.5 shadow-sm">
|
| 349 |
+
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
|
| 350 |
+
</div>
|
| 351 |
+
</div>
|
| 352 |
+
);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
return (
|
| 356 |
+
<div className="flex gap-2.5 max-w-[85%]">
|
| 357 |
+
<div className="w-7 h-7 rounded-full bg-emerald-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
| 358 |
+
<Bot className="w-4 h-4 text-emerald-600" />
|
| 359 |
+
</div>
|
| 360 |
+
<div className="bg-white border border-slate-200 rounded-2xl rounded-tl-sm px-3.5 py-2.5 shadow-sm">
|
| 361 |
+
{message.isStreaming && !message.content ? (
|
| 362 |
+
<div className="flex gap-1 py-1">
|
| 363 |
+
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce [animation-delay:0ms]" />
|
| 364 |
+
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce [animation-delay:150ms]" />
|
| 365 |
+
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce [animation-delay:300ms]" />
|
| 366 |
+
</div>
|
| 367 |
+
) : (
|
| 368 |
+
<div className="text-slate-700">
|
| 369 |
+
<ReactMarkdown
|
| 370 |
+
remarkPlugins={[remarkGfm]}
|
| 371 |
+
components={markdownComponents}
|
| 372 |
+
>
|
| 373 |
+
{message.content}
|
| 374 |
+
</ReactMarkdown>
|
| 375 |
+
{message.isStreaming && (
|
| 376 |
+
<span className="inline-block w-1 h-3.5 bg-emerald-500 ml-0.5 animate-pulse rounded-sm" />
|
| 377 |
+
)}
|
| 378 |
+
</div>
|
| 379 |
+
)}
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
);
|
| 383 |
+
}
|
src/app/components/interview/InterviewResultView.tsx
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { CheckCircle2, ChevronDown, ChevronRight } from "lucide-react";
|
| 3 |
+
import type { InterviewResult, QAPair } from "../../../services/interviewApi";
|
| 4 |
+
|
| 5 |
+
interface Props {
|
| 6 |
+
result: InterviewResult;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export default function InterviewResultView({ result }: Props) {
|
| 10 |
+
const [openSections, setOpenSections] = useState<Set<number>>(new Set([0]));
|
| 11 |
+
|
| 12 |
+
const toggleSection = (i: number) => {
|
| 13 |
+
setOpenSections((prev) => {
|
| 14 |
+
const next = new Set(prev);
|
| 15 |
+
if (next.has(i)) next.delete(i);
|
| 16 |
+
else next.add(i);
|
| 17 |
+
return next;
|
| 18 |
+
});
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<div className="mx-4 my-4 rounded-2xl border border-emerald-200 bg-emerald-50/60 overflow-hidden text-sm">
|
| 23 |
+
{/* Header */}
|
| 24 |
+
<div className="flex items-center gap-2 px-4 py-3 bg-emerald-100/80 border-b border-emerald-200">
|
| 25 |
+
<CheckCircle2 className="w-4 h-4 text-emerald-600 flex-shrink-0" />
|
| 26 |
+
<span className="font-semibold text-emerald-800">Hasil Interview</span>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div className="px-4 py-4 space-y-5">
|
| 30 |
+
{/* Summary */}
|
| 31 |
+
{result.summary && (
|
| 32 |
+
<div>
|
| 33 |
+
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Ringkasan</p>
|
| 34 |
+
<p className="text-slate-700 leading-relaxed">{result.summary}</p>
|
| 35 |
+
</div>
|
| 36 |
+
)}
|
| 37 |
+
|
| 38 |
+
{/* Goals */}
|
| 39 |
+
{result.goals?.length > 0 && (
|
| 40 |
+
<div>
|
| 41 |
+
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Tujuan</p>
|
| 42 |
+
<ul className="space-y-1">
|
| 43 |
+
{result.goals.map((g, i) => (
|
| 44 |
+
<li key={i} className="flex gap-2 text-slate-700">
|
| 45 |
+
<span className="text-emerald-500 flex-shrink-0 mt-0.5">β’</span>
|
| 46 |
+
<span>{g}</span>
|
| 47 |
+
</li>
|
| 48 |
+
))}
|
| 49 |
+
</ul>
|
| 50 |
+
</div>
|
| 51 |
+
)}
|
| 52 |
+
|
| 53 |
+
{/* Section Results */}
|
| 54 |
+
{result.section_results?.length > 0 && (
|
| 55 |
+
<div>
|
| 56 |
+
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Sesi Pertanyaan</p>
|
| 57 |
+
<div className="space-y-2">
|
| 58 |
+
{result.section_results.map((section, i) => (
|
| 59 |
+
<div key={i} className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
| 60 |
+
<button
|
| 61 |
+
onClick={() => toggleSection(i)}
|
| 62 |
+
className="w-full flex items-center justify-between px-3 py-2.5 text-left hover:bg-slate-50 transition"
|
| 63 |
+
>
|
| 64 |
+
<span className="font-medium text-slate-700">{section.section_title}</span>
|
| 65 |
+
{openSections.has(i) ? (
|
| 66 |
+
<ChevronDown className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
| 67 |
+
) : (
|
| 68 |
+
<ChevronRight className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
| 69 |
+
)}
|
| 70 |
+
</button>
|
| 71 |
+
{openSections.has(i) && (
|
| 72 |
+
<div className="px-3 pb-3 space-y-3 border-t border-slate-100 pt-2">
|
| 73 |
+
{section.section_summary && (
|
| 74 |
+
<p className="text-xs text-slate-500 italic">{section.section_summary}</p>
|
| 75 |
+
)}
|
| 76 |
+
{section.qa_pairs?.map((qa, j) => (
|
| 77 |
+
<QAItem key={j} qa={qa} depth={0} />
|
| 78 |
+
))}
|
| 79 |
+
</div>
|
| 80 |
+
)}
|
| 81 |
+
</div>
|
| 82 |
+
))}
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
)}
|
| 86 |
+
|
| 87 |
+
{/* Key Insights */}
|
| 88 |
+
{result.key_insights?.length > 0 && (
|
| 89 |
+
<div>
|
| 90 |
+
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Key Insights</p>
|
| 91 |
+
<ul className="space-y-1">
|
| 92 |
+
{result.key_insights.map((insight, i) => (
|
| 93 |
+
<li key={i} className="flex gap-2 text-slate-700">
|
| 94 |
+
<span className="text-blue-400 flex-shrink-0 mt-0.5">β’</span>
|
| 95 |
+
<span>{insight}</span>
|
| 96 |
+
</li>
|
| 97 |
+
))}
|
| 98 |
+
</ul>
|
| 99 |
+
</div>
|
| 100 |
+
)}
|
| 101 |
+
|
| 102 |
+
{/* Unresolved Items */}
|
| 103 |
+
{result.unresolved_items?.length > 0 && (
|
| 104 |
+
<div>
|
| 105 |
+
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Belum Terjawab</p>
|
| 106 |
+
<ul className="space-y-1">
|
| 107 |
+
{result.unresolved_items.map((item, i) => (
|
| 108 |
+
<li key={i} className="flex gap-2 text-amber-700">
|
| 109 |
+
<span className="text-amber-400 flex-shrink-0 mt-0.5">β’</span>
|
| 110 |
+
<span>{item}</span>
|
| 111 |
+
</li>
|
| 112 |
+
))}
|
| 113 |
+
</ul>
|
| 114 |
+
</div>
|
| 115 |
+
)}
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function QAItem({ qa, depth }: { qa: QAPair; depth: number }) {
|
| 122 |
+
return (
|
| 123 |
+
<div className={depth > 0 ? "ml-4 pl-3 border-l-2 border-slate-100" : ""}>
|
| 124 |
+
<p className="text-xs text-slate-500 mb-0.5">T: {qa.question_text}</p>
|
| 125 |
+
<p className="text-slate-700">J: {qa.answer_cleaned}</p>
|
| 126 |
+
{qa.follow_ups?.map((f, i) => (
|
| 127 |
+
<QAItem key={i} qa={f} depth={depth + 1} />
|
| 128 |
+
))}
|
| 129 |
+
</div>
|
| 130 |
+
);
|
| 131 |
+
}
|
src/app/components/interview/PhaseToggle.tsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BrainCircuit, BarChart2 } from "lucide-react";
|
| 2 |
+
|
| 3 |
+
export type Phase = "interview" | "analytics";
|
| 4 |
+
|
| 5 |
+
interface PhaseToggleProps {
|
| 6 |
+
phase: Phase;
|
| 7 |
+
onChange: (phase: Phase) => void;
|
| 8 |
+
disabled?: boolean;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function PhaseToggle({ phase, onChange, disabled }: PhaseToggleProps) {
|
| 12 |
+
return (
|
| 13 |
+
<div
|
| 14 |
+
className="flex items-center bg-slate-100 rounded-full p-0.5 text-xs select-none"
|
| 15 |
+
title={disabled ? "Interview sedang berlangsung" : undefined}
|
| 16 |
+
>
|
| 17 |
+
<button
|
| 18 |
+
onClick={() => !disabled && onChange("interview")}
|
| 19 |
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full transition-all duration-200 ${
|
| 20 |
+
phase === "interview"
|
| 21 |
+
? "bg-white text-emerald-700 shadow-sm font-medium"
|
| 22 |
+
: "text-slate-500 hover:text-slate-700"
|
| 23 |
+
} ${disabled && phase !== "interview" ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
| 24 |
+
>
|
| 25 |
+
<BrainCircuit className="w-3.5 h-3.5" />
|
| 26 |
+
<span>Interview</span>
|
| 27 |
+
</button>
|
| 28 |
+
<button
|
| 29 |
+
onClick={() => !disabled && onChange("analytics")}
|
| 30 |
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full transition-all duration-200 ${
|
| 31 |
+
phase === "analytics"
|
| 32 |
+
? "bg-white text-blue-700 shadow-sm font-medium"
|
| 33 |
+
: "text-slate-500 hover:text-slate-700"
|
| 34 |
+
} ${disabled && phase !== "analytics" ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
| 35 |
+
>
|
| 36 |
+
<BarChart2 className="w-3.5 h-3.5" />
|
| 37 |
+
<span>Analytics</span>
|
| 38 |
+
</button>
|
| 39 |
+
</div>
|
| 40 |
+
);
|
| 41 |
+
}
|
src/hooks/useInterviewSession.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
+
import {
|
| 3 |
+
createSession,
|
| 4 |
+
finishSession,
|
| 5 |
+
openAudioSession,
|
| 6 |
+
streamMessage,
|
| 7 |
+
type AudioSessionHandle,
|
| 8 |
+
type InterviewResult,
|
| 9 |
+
type StreamMetadata,
|
| 10 |
+
} from "../services/interviewApi";
|
| 11 |
+
|
| 12 |
+
export type InterviewMode = "text" | "audio";
|
| 13 |
+
export type InterviewStatus = "idle" | "active" | "completed";
|
| 14 |
+
|
| 15 |
+
export interface InterviewMessage {
|
| 16 |
+
id: string;
|
| 17 |
+
role: "user" | "assistant";
|
| 18 |
+
content: string;
|
| 19 |
+
isStreaming?: boolean;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
interface PersistedState {
|
| 23 |
+
sessionId: string | null;
|
| 24 |
+
status: InterviewStatus;
|
| 25 |
+
mode: InterviewMode;
|
| 26 |
+
messages: InterviewMessage[];
|
| 27 |
+
result?: InterviewResult | null;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const FRAMEWORK_ID = "discovery_problem_v2";
|
| 31 |
+
const STORAGE_KEY = (roomId: string) => `interview_${roomId}`;
|
| 32 |
+
|
| 33 |
+
function loadState(roomId: string): PersistedState {
|
| 34 |
+
try {
|
| 35 |
+
const raw = localStorage.getItem(STORAGE_KEY(roomId));
|
| 36 |
+
if (raw) return JSON.parse(raw) as PersistedState;
|
| 37 |
+
} catch {
|
| 38 |
+
// ignore
|
| 39 |
+
}
|
| 40 |
+
return { sessionId: null, status: "idle", mode: "text", messages: [], result: null };
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function saveState(roomId: string, state: PersistedState) {
|
| 44 |
+
localStorage.setItem(STORAGE_KEY(roomId), JSON.stringify(state));
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export function useInterviewSession(roomId: string | null, userId: string | null) {
|
| 48 |
+
const [sessionId, setSessionId] = useState<string | null>(null);
|
| 49 |
+
const [status, setStatus] = useState<InterviewStatus>("idle");
|
| 50 |
+
const [mode, setMode] = useState<InterviewMode>("text");
|
| 51 |
+
const [messages, setMessages] = useState<InterviewMessage[]>([]);
|
| 52 |
+
const [isSending, setIsSending] = useState(false);
|
| 53 |
+
const [isStarting, setIsStarting] = useState(false);
|
| 54 |
+
const [startError, setStartError] = useState<string | null>(null);
|
| 55 |
+
const [interviewResult, setInterviewResult] = useState<InterviewResult | null>(null);
|
| 56 |
+
const [isLoaded, setIsLoaded] = useState(false);
|
| 57 |
+
|
| 58 |
+
const audioHandle = useRef<AudioSessionHandle | null>(null);
|
| 59 |
+
|
| 60 |
+
// Load persisted state when roomId changes
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
if (!roomId) return;
|
| 63 |
+
setIsLoaded(false);
|
| 64 |
+
const saved = loadState(roomId);
|
| 65 |
+
setSessionId(saved.sessionId);
|
| 66 |
+
setStatus(saved.status);
|
| 67 |
+
setMode(saved.mode);
|
| 68 |
+
setMessages(saved.messages);
|
| 69 |
+
setInterviewResult(saved.result ?? null);
|
| 70 |
+
setIsLoaded(true);
|
| 71 |
+
}, [roomId]);
|
| 72 |
+
|
| 73 |
+
// Persist state changes
|
| 74 |
+
const persist = useCallback(
|
| 75 |
+
(patch: Partial<PersistedState>) => {
|
| 76 |
+
if (!roomId) return;
|
| 77 |
+
const current = loadState(roomId);
|
| 78 |
+
const next = { ...current, ...patch };
|
| 79 |
+
saveState(roomId, next);
|
| 80 |
+
},
|
| 81 |
+
[roomId]
|
| 82 |
+
);
|
| 83 |
+
|
| 84 |
+
const addMessage = useCallback(
|
| 85 |
+
(msg: InterviewMessage) => {
|
| 86 |
+
setMessages((prev) => {
|
| 87 |
+
const next = [...prev, msg];
|
| 88 |
+
if (roomId) persist({ messages: next });
|
| 89 |
+
return next;
|
| 90 |
+
});
|
| 91 |
+
},
|
| 92 |
+
[roomId, persist]
|
| 93 |
+
);
|
| 94 |
+
|
| 95 |
+
const updateLastAssistantMessage = useCallback(
|
| 96 |
+
(content: string, done = false) => {
|
| 97 |
+
setMessages((prev) => {
|
| 98 |
+
const next = prev.map((m, i) =>
|
| 99 |
+
i === prev.length - 1 && m.role === "assistant"
|
| 100 |
+
? { ...m, content, isStreaming: !done }
|
| 101 |
+
: m
|
| 102 |
+
);
|
| 103 |
+
if (done && roomId) persist({ messages: next });
|
| 104 |
+
return next;
|
| 105 |
+
});
|
| 106 |
+
},
|
| 107 |
+
[roomId, persist]
|
| 108 |
+
);
|
| 109 |
+
|
| 110 |
+
const startSession = useCallback(
|
| 111 |
+
async (interviewMode: InterviewMode = "text") => {
|
| 112 |
+
if (!roomId || !userId || status === "active" || isStarting) return;
|
| 113 |
+
setIsStarting(true);
|
| 114 |
+
setStartError(null);
|
| 115 |
+
try {
|
| 116 |
+
const res = await createSession(FRAMEWORK_ID, userId, roomId, interviewMode);
|
| 117 |
+
const openingId = crypto.randomUUID();
|
| 118 |
+
const questionId = crypto.randomUUID();
|
| 119 |
+
const initialMessages: InterviewMessage[] = [
|
| 120 |
+
{ id: openingId, role: "assistant", content: res.opening_message },
|
| 121 |
+
{ id: questionId, role: "assistant", content: res.first_question },
|
| 122 |
+
];
|
| 123 |
+
setSessionId(res.session_id);
|
| 124 |
+
setStatus("active");
|
| 125 |
+
setMode(interviewMode);
|
| 126 |
+
setMessages(initialMessages);
|
| 127 |
+
persist({
|
| 128 |
+
sessionId: res.session_id,
|
| 129 |
+
status: "active",
|
| 130 |
+
mode: interviewMode,
|
| 131 |
+
messages: initialMessages,
|
| 132 |
+
});
|
| 133 |
+
} catch (err) {
|
| 134 |
+
const msg = err instanceof Error ? err.message : "Gagal memulai sesi interview";
|
| 135 |
+
setStartError(msg);
|
| 136 |
+
} finally {
|
| 137 |
+
setIsStarting(false);
|
| 138 |
+
}
|
| 139 |
+
},
|
| 140 |
+
[roomId, userId, status, isStarting, persist]
|
| 141 |
+
);
|
| 142 |
+
|
| 143 |
+
const sendTextMessage = useCallback(
|
| 144 |
+
async (text: string) => {
|
| 145 |
+
if (!sessionId || isSending) return;
|
| 146 |
+
setIsSending(true);
|
| 147 |
+
|
| 148 |
+
const userMsg: InterviewMessage = {
|
| 149 |
+
id: crypto.randomUUID(),
|
| 150 |
+
role: "user",
|
| 151 |
+
content: text,
|
| 152 |
+
};
|
| 153 |
+
addMessage(userMsg);
|
| 154 |
+
|
| 155 |
+
const placeholderId = crypto.randomUUID();
|
| 156 |
+
const placeholder: InterviewMessage = {
|
| 157 |
+
id: placeholderId,
|
| 158 |
+
role: "assistant",
|
| 159 |
+
content: "",
|
| 160 |
+
isStreaming: true,
|
| 161 |
+
};
|
| 162 |
+
setMessages((prev) => [...prev, placeholder]);
|
| 163 |
+
|
| 164 |
+
try {
|
| 165 |
+
const res = await streamMessage(sessionId, text);
|
| 166 |
+
if (!res.body) throw new Error("No response body");
|
| 167 |
+
|
| 168 |
+
// /finish command returns plain JSON, not SSE stream
|
| 169 |
+
const contentType = res.headers.get("Content-Type") ?? "";
|
| 170 |
+
if (contentType.includes("application/json")) {
|
| 171 |
+
const finishRes = await res.json() as import("../services/interviewApi").FinishSessionResponse;
|
| 172 |
+
updateLastAssistantMessage("", true);
|
| 173 |
+
setStatus("completed");
|
| 174 |
+
persist({ status: "completed", result: finishRes.result });
|
| 175 |
+
setInterviewResult(finishRes.result);
|
| 176 |
+
return;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
const reader = res.body.getReader();
|
| 180 |
+
const decoder = new TextDecoder();
|
| 181 |
+
let accumulated = "";
|
| 182 |
+
|
| 183 |
+
while (true) {
|
| 184 |
+
const { done, value } = await reader.read();
|
| 185 |
+
if (done) break;
|
| 186 |
+
|
| 187 |
+
const lines = decoder.decode(value).split("\n");
|
| 188 |
+
for (const line of lines) {
|
| 189 |
+
if (!line.startsWith("data: ")) continue;
|
| 190 |
+
const payload = line.slice(6);
|
| 191 |
+
try {
|
| 192 |
+
const meta = JSON.parse(payload) as StreamMetadata;
|
| 193 |
+
updateLastAssistantMessage(accumulated, true);
|
| 194 |
+
if (meta.finished) {
|
| 195 |
+
setStatus("completed");
|
| 196 |
+
persist({ status: "completed" });
|
| 197 |
+
const finishRes = await finishSession(sessionId);
|
| 198 |
+
setInterviewResult(finishRes.result);
|
| 199 |
+
persist({ result: finishRes.result });
|
| 200 |
+
}
|
| 201 |
+
} catch {
|
| 202 |
+
accumulated += payload;
|
| 203 |
+
updateLastAssistantMessage(accumulated);
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
} catch (err) {
|
| 208 |
+
console.error("Stream error", err);
|
| 209 |
+
updateLastAssistantMessage("Maaf, terjadi kesalahan. Coba lagi.", true);
|
| 210 |
+
} finally {
|
| 211 |
+
setIsSending(false);
|
| 212 |
+
}
|
| 213 |
+
},
|
| 214 |
+
[sessionId, isSending, addMessage, updateLastAssistantMessage, persist]
|
| 215 |
+
);
|
| 216 |
+
|
| 217 |
+
const connectAudio = useCallback(
|
| 218 |
+
(
|
| 219 |
+
onTokenChunk: (token: string) => void,
|
| 220 |
+
onAssistantReply: (text: string) => void,
|
| 221 |
+
onAudio: (buf: ArrayBuffer) => void,
|
| 222 |
+
onSessionDone: () => void
|
| 223 |
+
) => {
|
| 224 |
+
if (!sessionId) return;
|
| 225 |
+
audioHandle.current?.close();
|
| 226 |
+
audioHandle.current = openAudioSession(
|
| 227 |
+
sessionId,
|
| 228 |
+
(event) => {
|
| 229 |
+
if (event.type === "token_chunk") onTokenChunk(event.payload);
|
| 230 |
+
else if (event.type === "assistant_reply") onAssistantReply(event.payload);
|
| 231 |
+
else if (event.type === "session_done") {
|
| 232 |
+
setStatus("completed");
|
| 233 |
+
persist({ status: "completed" });
|
| 234 |
+
finishSession(sessionId)
|
| 235 |
+
.then((res) => {
|
| 236 |
+
setInterviewResult(res.result);
|
| 237 |
+
persist({ result: res.result });
|
| 238 |
+
})
|
| 239 |
+
.catch(console.error);
|
| 240 |
+
onSessionDone();
|
| 241 |
+
}
|
| 242 |
+
},
|
| 243 |
+
onAudio,
|
| 244 |
+
() => {}
|
| 245 |
+
);
|
| 246 |
+
},
|
| 247 |
+
[sessionId, persist]
|
| 248 |
+
);
|
| 249 |
+
|
| 250 |
+
const disconnectAudio = useCallback(() => {
|
| 251 |
+
audioHandle.current?.close();
|
| 252 |
+
audioHandle.current = null;
|
| 253 |
+
}, []);
|
| 254 |
+
|
| 255 |
+
const sendAudioChunk = useCallback((chunk: ArrayBuffer) => {
|
| 256 |
+
audioHandle.current?.sendAudioChunk(chunk);
|
| 257 |
+
}, []);
|
| 258 |
+
|
| 259 |
+
const sendEndUtterance = useCallback(() => {
|
| 260 |
+
audioHandle.current?.sendEndUtterance();
|
| 261 |
+
}, []);
|
| 262 |
+
|
| 263 |
+
const switchMode = useCallback(
|
| 264 |
+
(newMode: InterviewMode) => {
|
| 265 |
+
disconnectAudio();
|
| 266 |
+
setMode(newMode);
|
| 267 |
+
persist({ mode: newMode });
|
| 268 |
+
},
|
| 269 |
+
[disconnectAudio, persist]
|
| 270 |
+
);
|
| 271 |
+
|
| 272 |
+
const resetSession = useCallback(() => {
|
| 273 |
+
disconnectAudio();
|
| 274 |
+
if (roomId) localStorage.removeItem(STORAGE_KEY(roomId));
|
| 275 |
+
setSessionId(null);
|
| 276 |
+
setStatus("idle");
|
| 277 |
+
setMode("text");
|
| 278 |
+
setMessages([]);
|
| 279 |
+
setInterviewResult(null);
|
| 280 |
+
}, [roomId, disconnectAudio]);
|
| 281 |
+
|
| 282 |
+
return {
|
| 283 |
+
sessionId,
|
| 284 |
+
status,
|
| 285 |
+
mode,
|
| 286 |
+
messages,
|
| 287 |
+
isSending,
|
| 288 |
+
isStarting,
|
| 289 |
+
startError,
|
| 290 |
+
interviewResult,
|
| 291 |
+
isLoaded,
|
| 292 |
+
startSession,
|
| 293 |
+
sendTextMessage,
|
| 294 |
+
connectAudio,
|
| 295 |
+
disconnectAudio,
|
| 296 |
+
sendAudioChunk,
|
| 297 |
+
sendEndUtterance,
|
| 298 |
+
switchMode,
|
| 299 |
+
resetSession,
|
| 300 |
+
};
|
| 301 |
+
}
|
src/services/api.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface LoginResponse {
|
|
| 20 |
export interface Room {
|
| 21 |
id: string;
|
| 22 |
title: string;
|
|
|
|
|
|
|
| 23 |
created_at: string;
|
| 24 |
updated_at: string | null;
|
| 25 |
}
|
|
@@ -31,9 +33,12 @@ export interface CreateRoomResponse {
|
|
| 31 |
}
|
| 32 |
|
| 33 |
export interface ChatSource {
|
|
|
|
|
|
|
| 34 |
document_id: string;
|
| 35 |
filename: string;
|
| 36 |
page_label: string | null;
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
export interface RoomMessage {
|
|
@@ -48,7 +53,7 @@ export interface RoomDetail extends Room {
|
|
| 48 |
messages: RoomMessage[];
|
| 49 |
}
|
| 50 |
|
| 51 |
-
export type DocumentStatus = "
|
| 52 |
|
| 53 |
export interface ApiDocument {
|
| 54 |
id: string;
|
|
@@ -72,12 +77,32 @@ export interface DocTypeInfo {
|
|
| 72 |
message: string | null;
|
| 73 |
}
|
| 74 |
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
const
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
headers: { "Content-Type": "application/json", ...options?.headers },
|
| 82 |
...options,
|
| 83 |
});
|
|
@@ -93,44 +118,49 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|
| 93 |
// βββ Auth βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 94 |
|
| 95 |
export const login = (email: string, password: string) =>
|
| 96 |
-
request<LoginResponse>("/api/login", {
|
| 97 |
method: "POST",
|
| 98 |
body: JSON.stringify({ email, password }),
|
| 99 |
});
|
| 100 |
|
| 101 |
// βββ Rooms ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
|
| 103 |
-
export const getRooms = (userId: string) =>
|
| 104 |
-
request<Room[]>(`/api/v1/rooms/${userId}`)
|
|
|
|
| 105 |
|
| 106 |
-
export const getRoom = (roomId: string) =>
|
| 107 |
-
request<RoomDetail>(`/api/v1/room/${roomId}`)
|
|
|
|
| 108 |
|
| 109 |
export const deleteRoom = (roomId: string, userId: string) =>
|
| 110 |
request<{ status: string; message: string }>(
|
|
|
|
| 111 |
`/api/v1/room/${roomId}?user_id=${userId}`,
|
| 112 |
{ method: "DELETE" }
|
| 113 |
);
|
| 114 |
|
| 115 |
export const createRoom = (userId: string, title?: string) =>
|
| 116 |
-
request<CreateRoomResponse>("/api/v1/room/create", {
|
| 117 |
method: "POST",
|
| 118 |
body: JSON.stringify({ user_id: userId, title }),
|
| 119 |
});
|
| 120 |
|
| 121 |
// βββ Documents ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 122 |
|
| 123 |
-
export const getDocuments = (userId: string) =>
|
| 124 |
-
request<ApiDocument[]>(`/api/v1/documents/${userId}`)
|
|
|
|
| 125 |
|
| 126 |
export const uploadDocument = async (
|
| 127 |
userId: string,
|
| 128 |
file: File
|
| 129 |
): Promise<UploadDocumentResponse> => {
|
| 130 |
const form = new FormData();
|
|
|
|
| 131 |
form.append("file", file);
|
| 132 |
const res = await fetch(
|
| 133 |
-
`${
|
| 134 |
{ method: "POST", body: form }
|
| 135 |
);
|
| 136 |
if (!res.ok) {
|
|
@@ -142,24 +172,22 @@ export const uploadDocument = async (
|
|
| 142 |
return res.json() as Promise<UploadDocumentResponse>;
|
| 143 |
};
|
| 144 |
|
| 145 |
-
export const processDocument = (
|
| 146 |
-
request<{
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
}>(
|
| 151 |
-
`/api/v1/document/process?document_id=${documentId}&user_id=${userId}`,
|
| 152 |
-
{ method: "POST" }
|
| 153 |
);
|
| 154 |
|
| 155 |
-
export const deleteDocument = (
|
| 156 |
request<{ status: string; message: string }>(
|
| 157 |
-
|
| 158 |
-
|
|
|
|
| 159 |
);
|
| 160 |
|
| 161 |
export const getDocumentTypes = (): Promise<DocTypeInfo[]> =>
|
| 162 |
-
request<{ status: string; data: DocTypeInfo[] }>("/api/v1/documents/doctypes").then(
|
| 163 |
(res) => res.data
|
| 164 |
);
|
| 165 |
|
|
@@ -203,7 +231,8 @@ export interface IngestResponse {
|
|
| 203 |
}
|
| 204 |
|
| 205 |
export const getDatabaseClientTypes = (): Promise<DbTypeInfo[]> =>
|
| 206 |
-
request<DbTypeInfo[]>("/api/v1/database-clients/dbtypes")
|
|
|
|
| 207 |
|
| 208 |
export const connectDatabase = (
|
| 209 |
userId: string,
|
|
@@ -211,25 +240,43 @@ export const connectDatabase = (
|
|
| 211 |
name: string,
|
| 212 |
credentials: Record<string, string | number | boolean>
|
| 213 |
): Promise<DatabaseClient> =>
|
| 214 |
-
request<DatabaseClient>(`/api/v1/database-clients
|
| 215 |
method: "POST",
|
| 216 |
-
body: JSON.stringify({ name, db_type: dbType, credentials }),
|
| 217 |
-
});
|
| 218 |
|
| 219 |
export const getDatabaseClients = (userId: string): Promise<DatabaseClient[]> =>
|
| 220 |
-
request<DatabaseClient[]>(`/api/v1/database-clients/${userId}`)
|
|
|
|
| 221 |
|
| 222 |
export const deleteDatabaseClient = (clientId: string, userId: string) =>
|
| 223 |
request<{ status: string; message: string }>(
|
|
|
|
| 224 |
`/api/v1/database-clients/${clientId}?user_id=${userId}`,
|
| 225 |
{ method: "DELETE" }
|
| 226 |
);
|
| 227 |
|
| 228 |
-
export const ingestDatabaseClient = (clientId: string,
|
| 229 |
-
request<IngestResponse>(
|
| 230 |
-
|
|
|
|
| 231 |
{ method: "POST" }
|
| 232 |
-
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
// βββ Chat βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 235 |
|
|
@@ -238,7 +285,7 @@ export const streamChat = (
|
|
| 238 |
roomId: string,
|
| 239 |
message: string
|
| 240 |
): Promise<Response> =>
|
| 241 |
-
fetch(`${
|
| 242 |
method: "POST",
|
| 243 |
headers: { "Content-Type": "application/json" },
|
| 244 |
body: JSON.stringify({ user_id: userId, room_id: roomId, message }),
|
|
|
|
| 20 |
export interface Room {
|
| 21 |
id: string;
|
| 22 |
title: string;
|
| 23 |
+
user_id?: string;
|
| 24 |
+
status?: string;
|
| 25 |
created_at: string;
|
| 26 |
updated_at: string | null;
|
| 27 |
}
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
export interface ChatSource {
|
| 36 |
+
id?: string;
|
| 37 |
+
message_id?: string;
|
| 38 |
document_id: string;
|
| 39 |
filename: string;
|
| 40 |
page_label: string | null;
|
| 41 |
+
created_at?: string;
|
| 42 |
}
|
| 43 |
|
| 44 |
export interface RoomMessage {
|
|
|
|
| 53 |
messages: RoomMessage[];
|
| 54 |
}
|
| 55 |
|
| 56 |
+
export type DocumentStatus = "uploaded" | "processing" | "completed" | "failed";
|
| 57 |
|
| 58 |
export interface ApiDocument {
|
| 59 |
id: string;
|
|
|
|
| 77 |
message: string | null;
|
| 78 |
}
|
| 79 |
|
| 80 |
+
export interface DataCatalogSource {
|
| 81 |
+
source_id: string;
|
| 82 |
+
source_type: "schema" | "tabular" | "unstructured";
|
| 83 |
+
name: string;
|
| 84 |
+
location_ref: string;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
export interface DataCatalog {
|
| 88 |
+
user_id: string;
|
| 89 |
+
schema_version: string;
|
| 90 |
+
generated_at: string;
|
| 91 |
+
sources: DataCatalogSource[];
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// βββ Base Clients βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
|
| 96 |
+
const ORCHESTRATION_BASE_URL =
|
| 97 |
+
((import.meta as unknown as { env: Record<string, string> }).env
|
| 98 |
+
.VITE_ORCHESTRATION_API_BASE_URL) ?? "";
|
| 99 |
|
| 100 |
+
const AGENTIC_BASE_URL =
|
| 101 |
+
((import.meta as unknown as { env: Record<string, string> }).env
|
| 102 |
+
.VITE_AGENTIC_API_BASE_URL) ?? "";
|
| 103 |
+
|
| 104 |
+
async function request<T>(baseUrl: string, path: string, options?: RequestInit): Promise<T> {
|
| 105 |
+
const res = await fetch(`${baseUrl}${path}`, {
|
| 106 |
headers: { "Content-Type": "application/json", ...options?.headers },
|
| 107 |
...options,
|
| 108 |
});
|
|
|
|
| 118 |
// βββ Auth βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 119 |
|
| 120 |
export const login = (email: string, password: string) =>
|
| 121 |
+
request<LoginResponse>(ORCHESTRATION_BASE_URL, "/api/login", {
|
| 122 |
method: "POST",
|
| 123 |
body: JSON.stringify({ email, password }),
|
| 124 |
});
|
| 125 |
|
| 126 |
// βββ Rooms ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 127 |
|
| 128 |
+
export const getRooms = (userId: string): Promise<Room[]> =>
|
| 129 |
+
request<{ status: string; message: string; data: Room[] | null }>(ORCHESTRATION_BASE_URL, `/api/v1/rooms/${userId}`)
|
| 130 |
+
.then(res => res.data ?? []);
|
| 131 |
|
| 132 |
+
export const getRoom = (roomId: string): Promise<RoomDetail> =>
|
| 133 |
+
request<{ status: string; message: string; data: RoomDetail }>(ORCHESTRATION_BASE_URL, `/api/v1/room/${roomId}`)
|
| 134 |
+
.then(res => res.data);
|
| 135 |
|
| 136 |
export const deleteRoom = (roomId: string, userId: string) =>
|
| 137 |
request<{ status: string; message: string }>(
|
| 138 |
+
ORCHESTRATION_BASE_URL,
|
| 139 |
`/api/v1/room/${roomId}?user_id=${userId}`,
|
| 140 |
{ method: "DELETE" }
|
| 141 |
);
|
| 142 |
|
| 143 |
export const createRoom = (userId: string, title?: string) =>
|
| 144 |
+
request<CreateRoomResponse>(ORCHESTRATION_BASE_URL, "/api/v1/room/create", {
|
| 145 |
method: "POST",
|
| 146 |
body: JSON.stringify({ user_id: userId, title }),
|
| 147 |
});
|
| 148 |
|
| 149 |
// βββ Documents ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 150 |
|
| 151 |
+
export const getDocuments = (userId: string): Promise<ApiDocument[]> =>
|
| 152 |
+
request<{ status: string; message: string; data: ApiDocument[] | null }>(ORCHESTRATION_BASE_URL, `/api/v1/documents/${userId}`)
|
| 153 |
+
.then(res => res.data ?? []);
|
| 154 |
|
| 155 |
export const uploadDocument = async (
|
| 156 |
userId: string,
|
| 157 |
file: File
|
| 158 |
): Promise<UploadDocumentResponse> => {
|
| 159 |
const form = new FormData();
|
| 160 |
+
form.append("user_id", userId);
|
| 161 |
form.append("file", file);
|
| 162 |
const res = await fetch(
|
| 163 |
+
`${ORCHESTRATION_BASE_URL}/api/v1/document/upload`,
|
| 164 |
{ method: "POST", body: form }
|
| 165 |
);
|
| 166 |
if (!res.ok) {
|
|
|
|
| 172 |
return res.json() as Promise<UploadDocumentResponse>;
|
| 173 |
};
|
| 174 |
|
| 175 |
+
export const processDocument = (_userId: string, documentId: string) =>
|
| 176 |
+
request<{ status: string; message: string }>(
|
| 177 |
+
ORCHESTRATION_BASE_URL,
|
| 178 |
+
`/api/v1/document/process`,
|
| 179 |
+
{ method: "POST", body: JSON.stringify({ document_id: documentId }) }
|
|
|
|
|
|
|
|
|
|
| 180 |
);
|
| 181 |
|
| 182 |
+
export const deleteDocument = (_userId: string, documentId: string) =>
|
| 183 |
request<{ status: string; message: string }>(
|
| 184 |
+
ORCHESTRATION_BASE_URL,
|
| 185 |
+
`/api/v1/document/delete`,
|
| 186 |
+
{ method: "DELETE", body: JSON.stringify({ document_id: documentId }) }
|
| 187 |
);
|
| 188 |
|
| 189 |
export const getDocumentTypes = (): Promise<DocTypeInfo[]> =>
|
| 190 |
+
request<{ status: string; data: DocTypeInfo[] }>(ORCHESTRATION_BASE_URL, "/api/v1/documents/doctypes").then(
|
| 191 |
(res) => res.data
|
| 192 |
);
|
| 193 |
|
|
|
|
| 231 |
}
|
| 232 |
|
| 233 |
export const getDatabaseClientTypes = (): Promise<DbTypeInfo[]> =>
|
| 234 |
+
request<{ status: string; message: string; data: DbTypeInfo[] | null }>(ORCHESTRATION_BASE_URL, "/api/v1/database-clients/dbtypes")
|
| 235 |
+
.then(res => res.data ?? []);
|
| 236 |
|
| 237 |
export const connectDatabase = (
|
| 238 |
userId: string,
|
|
|
|
| 240 |
name: string,
|
| 241 |
credentials: Record<string, string | number | boolean>
|
| 242 |
): Promise<DatabaseClient> =>
|
| 243 |
+
request<{ status: string; message: string; data: DatabaseClient }>(ORCHESTRATION_BASE_URL, `/api/v1/database-clients`, {
|
| 244 |
method: "POST",
|
| 245 |
+
body: JSON.stringify({ user_id: userId, name, db_type: dbType, credentials }),
|
| 246 |
+
}).then(res => res.data);
|
| 247 |
|
| 248 |
export const getDatabaseClients = (userId: string): Promise<DatabaseClient[]> =>
|
| 249 |
+
request<{ status: string; message: string; data: DatabaseClient[] | null }>(ORCHESTRATION_BASE_URL, `/api/v1/database-clients/${userId}`)
|
| 250 |
+
.then(res => res.data ?? []);
|
| 251 |
|
| 252 |
export const deleteDatabaseClient = (clientId: string, userId: string) =>
|
| 253 |
request<{ status: string; message: string }>(
|
| 254 |
+
ORCHESTRATION_BASE_URL,
|
| 255 |
`/api/v1/database-clients/${clientId}?user_id=${userId}`,
|
| 256 |
{ method: "DELETE" }
|
| 257 |
);
|
| 258 |
|
| 259 |
+
export const ingestDatabaseClient = (clientId: string, _userId: string): Promise<IngestResponse> =>
|
| 260 |
+
request<{ status: string; message: string; data: IngestResponse }>(
|
| 261 |
+
ORCHESTRATION_BASE_URL,
|
| 262 |
+
`/api/v1/database-clients/${clientId}/ingest`,
|
| 263 |
{ method: "POST" }
|
| 264 |
+
).then(res => res.data);
|
| 265 |
+
|
| 266 |
+
// βββ Data Catalog βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 267 |
+
|
| 268 |
+
export const getDataCatalog = (userId: string): Promise<DataCatalog> =>
|
| 269 |
+
request<{ status: string; message: string; data: DataCatalog }>(
|
| 270 |
+
ORCHESTRATION_BASE_URL,
|
| 271 |
+
`/api/v1/data-catalog/${userId}`
|
| 272 |
+
).then((res) => res.data);
|
| 273 |
+
|
| 274 |
+
export const rebuildDataCatalog = (userId: string): Promise<DataCatalog> =>
|
| 275 |
+
request<{ status: string; message: string; data: DataCatalog }>(
|
| 276 |
+
ORCHESTRATION_BASE_URL,
|
| 277 |
+
"/api/v1/data-catalog/rebuild",
|
| 278 |
+
{ method: "POST", body: JSON.stringify({ user_id: userId }) }
|
| 279 |
+
).then((res) => res.data);
|
| 280 |
|
| 281 |
// βββ Chat βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 282 |
|
|
|
|
| 285 |
roomId: string,
|
| 286 |
message: string
|
| 287 |
): Promise<Response> =>
|
| 288 |
+
fetch(`${AGENTIC_BASE_URL}/api/v1/chat/stream`, {
|
| 289 |
method: "POST",
|
| 290 |
headers: { "Content-Type": "application/json" },
|
| 291 |
body: JSON.stringify({ user_id: userId, room_id: roomId, message }),
|
src/services/interviewApi.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// βββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
|
| 3 |
+
export interface InterviewFramework {
|
| 4 |
+
id: string;
|
| 5 |
+
name: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export interface CreateSessionResponse {
|
| 9 |
+
session_id: string;
|
| 10 |
+
framework_id: string;
|
| 11 |
+
room_id: string;
|
| 12 |
+
status: string;
|
| 13 |
+
opening_message: string;
|
| 14 |
+
first_question: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface MessageResponse {
|
| 18 |
+
reply: string;
|
| 19 |
+
stage: "in_progress" | "next_question" | "follow_up" | "closing";
|
| 20 |
+
finished: boolean;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export interface QAPair {
|
| 24 |
+
question_text: string;
|
| 25 |
+
answer_cleaned: string;
|
| 26 |
+
follow_ups?: QAPair[];
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export interface SectionResult {
|
| 30 |
+
section_title: string;
|
| 31 |
+
objective: string[];
|
| 32 |
+
qa_pairs: QAPair[];
|
| 33 |
+
section_summary: string;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export interface InterviewResult {
|
| 37 |
+
framework_name: string;
|
| 38 |
+
mode: string;
|
| 39 |
+
language: string;
|
| 40 |
+
started_at: string;
|
| 41 |
+
ended_at: string;
|
| 42 |
+
summary: string;
|
| 43 |
+
goals: string[];
|
| 44 |
+
section_results: SectionResult[];
|
| 45 |
+
key_insights: string[];
|
| 46 |
+
unresolved_items: string[];
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export interface FinishSessionResponse {
|
| 50 |
+
session_id: string;
|
| 51 |
+
room_id: string;
|
| 52 |
+
status: string;
|
| 53 |
+
result: InterviewResult;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export interface StreamMetadata {
|
| 57 |
+
finished: boolean;
|
| 58 |
+
stage: "next_question" | "follow_up" | "closing";
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export type AudioServerEvent =
|
| 62 |
+
| { type: "token_chunk"; payload: string }
|
| 63 |
+
| { type: "assistant_reply"; payload: string }
|
| 64 |
+
| { type: "session_done" }
|
| 65 |
+
| { type: "error"; payload: string };
|
| 66 |
+
|
| 67 |
+
// βββ Base Client ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
+
|
| 69 |
+
const INTERVIEW_BASE_URL =
|
| 70 |
+
((import.meta as unknown as { env: Record<string, string> }).env
|
| 71 |
+
.VITE_ORCHESTRATION_API_BASE_URL) ?? "http://localhost:8080";
|
| 72 |
+
|
| 73 |
+
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
| 74 |
+
const res = await fetch(`${INTERVIEW_BASE_URL}${path}`, {
|
| 75 |
+
headers: { "Content-Type": "application/json", ...options?.headers },
|
| 76 |
+
...options,
|
| 77 |
+
});
|
| 78 |
+
if (!res.ok) {
|
| 79 |
+
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
| 80 |
+
throw new Error(err.error ?? `HTTP ${res.status}`);
|
| 81 |
+
}
|
| 82 |
+
return res.json() as Promise<T>;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// βββ Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
+
|
| 87 |
+
export const getFrameworks = (): Promise<InterviewFramework[]> =>
|
| 88 |
+
request<InterviewFramework[]>("/frameworks");
|
| 89 |
+
|
| 90 |
+
export const createSession = (
|
| 91 |
+
frameworkId: string,
|
| 92 |
+
userId: string,
|
| 93 |
+
roomId: string,
|
| 94 |
+
mode: "text" | "audio" = "text",
|
| 95 |
+
language = "id-ID"
|
| 96 |
+
): Promise<CreateSessionResponse> =>
|
| 97 |
+
request<CreateSessionResponse>("/sessions", {
|
| 98 |
+
method: "POST",
|
| 99 |
+
body: JSON.stringify({ framework_id: frameworkId, user_id: userId, room_id: roomId, mode, language }),
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
export const sendMessage = (
|
| 103 |
+
sessionId: string,
|
| 104 |
+
message: string
|
| 105 |
+
): Promise<MessageResponse> =>
|
| 106 |
+
request<MessageResponse>(`/sessions/${sessionId}/message`, {
|
| 107 |
+
method: "POST",
|
| 108 |
+
body: JSON.stringify({ message }),
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
export const finishSession = (
|
| 112 |
+
sessionId: string
|
| 113 |
+
): Promise<FinishSessionResponse> =>
|
| 114 |
+
request<FinishSessionResponse>(`/sessions/${sessionId}/finish`, {
|
| 115 |
+
method: "POST",
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
export const getInterviewResult = (roomId: string): Promise<InterviewResult> =>
|
| 119 |
+
request<InterviewResult>(`/rooms/${roomId}/result`);
|
| 120 |
+
|
| 121 |
+
// SSE streaming β returns raw Response so caller can read the stream
|
| 122 |
+
export const streamMessage = (
|
| 123 |
+
sessionId: string,
|
| 124 |
+
message: string
|
| 125 |
+
): Promise<Response> =>
|
| 126 |
+
fetch(`${INTERVIEW_BASE_URL}/sessions/${sessionId}/stream-message`, {
|
| 127 |
+
method: "POST",
|
| 128 |
+
headers: { "Content-Type": "application/json" },
|
| 129 |
+
body: JSON.stringify({ message }),
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
// βββ Audio WebSocket ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 133 |
+
|
| 134 |
+
export interface AudioSessionHandle {
|
| 135 |
+
sendAudioChunk: (chunk: ArrayBuffer) => void;
|
| 136 |
+
sendEndUtterance: () => void;
|
| 137 |
+
close: () => void;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
export function openAudioSession(
|
| 141 |
+
sessionId: string,
|
| 142 |
+
onEvent: (event: AudioServerEvent) => void,
|
| 143 |
+
onAudio: (audioBuffer: ArrayBuffer) => void,
|
| 144 |
+
onClose: () => void
|
| 145 |
+
): AudioSessionHandle {
|
| 146 |
+
const wsBase = INTERVIEW_BASE_URL.replace(/^http/, "ws");
|
| 147 |
+
const ws = new WebSocket(`${wsBase}/ws/audio?session_id=${sessionId}`);
|
| 148 |
+
ws.binaryType = "arraybuffer";
|
| 149 |
+
|
| 150 |
+
ws.onmessage = (e) => {
|
| 151 |
+
if (e.data instanceof ArrayBuffer) {
|
| 152 |
+
onAudio(e.data);
|
| 153 |
+
} else {
|
| 154 |
+
try {
|
| 155 |
+
const parsed = JSON.parse(e.data) as AudioServerEvent;
|
| 156 |
+
onEvent(parsed);
|
| 157 |
+
} catch {
|
| 158 |
+
// ignore malformed messages
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
ws.onclose = onClose;
|
| 164 |
+
|
| 165 |
+
return {
|
| 166 |
+
sendAudioChunk: (chunk) => {
|
| 167 |
+
if (ws.readyState === WebSocket.OPEN) ws.send(chunk);
|
| 168 |
+
},
|
| 169 |
+
sendEndUtterance: () => {
|
| 170 |
+
if (ws.readyState === WebSocket.OPEN)
|
| 171 |
+
ws.send(JSON.stringify({ type: "end_utterance" }));
|
| 172 |
+
},
|
| 173 |
+
close: () => ws.close(),
|
| 174 |
+
};
|
| 175 |
+
}
|