.env.example CHANGED
@@ -1 +1,2 @@
1
- VITE_API_BASE_URL=
 
 
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.VITE_API_BASE_URL || "";
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 text-red-100 hover:text-white" />
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
- {/* Messages */}
706
- <div className="relative z-10 flex-1 overflow-y-auto p-4 space-y-4">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="relative z-10 bg-white border-t border-slate-200 p-3 shadow-[0_-2px_10px_rgba(0,0,0,0.06)]">
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 = "pending" | "processing" | "completed" | "failed";
52
 
53
  export interface ApiDocument {
54
  id: string;
@@ -72,12 +77,32 @@ export interface DocTypeInfo {
72
  message: string | null;
73
  }
74
 
75
- // ─── Base Client ──────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
- const BASE_URL = ((import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_URL) ?? "";
 
 
78
 
79
- async function request<T>(path: string, options?: RequestInit): Promise<T> {
80
- const res = await fetch(`${BASE_URL}${path}`, {
 
 
 
 
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
- `${BASE_URL}/api/v1/document/upload?user_id=${userId}`,
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 = (userId: string, documentId: string) =>
146
- request<{
147
- status: string;
148
- message: string;
149
- data: { document_id: string; chunks_processed: number };
150
- }>(
151
- `/api/v1/document/process?document_id=${documentId}&user_id=${userId}`,
152
- { method: "POST" }
153
  );
154
 
155
- export const deleteDocument = (userId: string, documentId: string) =>
156
  request<{ status: string; message: string }>(
157
- `/api/v1/document/delete?document_id=${documentId}&user_id=${userId}`,
158
- { method: "DELETE" }
 
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?user_id=${userId}`, {
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, userId: string): Promise<IngestResponse> =>
229
- request<IngestResponse>(
230
- `/api/v1/database-clients/${clientId}/ingest?user_id=${userId}`,
 
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(`${BASE_URL}/api/v1/chat/stream`, {
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
+ }