From 44ffbc83aa3f93bffa4aa60bc68d71ad417d25c1 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Sat, 29 Nov 2025 11:28:08 +0900 Subject: [PATCH] Frontend: final version complete --- src/App.jsx | 6 +- src/api/interviewService.js | 79 +++-- src/data/interviewQuestions.js | 33 -- src/features/interview/Intro.jsx | 20 -- src/features/interview/Result.jsx | 18 - src/features/interview/Session.jsx | 90 ----- .../interview/components/MicRecorder.jsx | 89 +++++ .../{interview => }/ProgressDots.jsx | 0 .../interview/components/QuestionBox.jsx | 23 ++ src/features/interview/components/Timer60.jsx | 42 +++ .../components/interview/AiAvatar.jsx | 13 - .../components/interview/MicRecorder.jsx | 52 --- .../components/interview/QuestionBox.jsx | 15 - .../components/interview/Timer60.jsx | 32 -- src/features/interview/pages/Intro.jsx | 135 ++++++++ src/features/interview/pages/Result.jsx | 87 +++++ src/features/interview/pages/Session.jsx | 325 ++++++++++++++++++ src/services/interviewApi.js | 43 --- src/shared/hooks/useInterviewFlow.js | 59 ---- src/shared/utils/audio.js | 14 - src/shared/utils/random.js | 16 - src/store/interviewStore.js | 15 - 22 files changed, 757 insertions(+), 449 deletions(-) delete mode 100644 src/data/interviewQuestions.js delete mode 100644 src/features/interview/Intro.jsx delete mode 100644 src/features/interview/Result.jsx delete mode 100644 src/features/interview/Session.jsx create mode 100644 src/features/interview/components/MicRecorder.jsx rename src/features/interview/components/{interview => }/ProgressDots.jsx (100%) create mode 100644 src/features/interview/components/QuestionBox.jsx create mode 100644 src/features/interview/components/Timer60.jsx delete mode 100644 src/features/interview/components/interview/AiAvatar.jsx delete mode 100644 src/features/interview/components/interview/MicRecorder.jsx delete mode 100644 src/features/interview/components/interview/QuestionBox.jsx delete mode 100644 src/features/interview/components/interview/Timer60.jsx create mode 100644 src/features/interview/pages/Intro.jsx create mode 100644 src/features/interview/pages/Result.jsx create mode 100644 src/features/interview/pages/Session.jsx delete mode 100644 src/services/interviewApi.js delete mode 100644 src/shared/hooks/useInterviewFlow.js delete mode 100644 src/shared/utils/audio.js delete mode 100644 src/shared/utils/random.js delete mode 100644 src/store/interviewStore.js diff --git a/src/App.jsx b/src/App.jsx index 1003509..f3dac04 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,9 +7,9 @@ import CodingTest from "./features/codingTest/CodingTest"; import CodeReview from "./features/review/CodeReview"; // 인터뷰 분리 페이지들 -import Intro from "./features/interview/Intro"; -import Session from "./features/interview/Session"; -import Result from "./features/interview/Result"; +import Intro from "./features/interview/pages/Intro"; +import Session from "./features/interview/pages/Session"; +import Result from "./features/interview/pages/Result"; export default function App() { return ( diff --git a/src/api/interviewService.js b/src/api/interviewService.js index 2f7d9e1..c8b1c62 100644 --- a/src/api/interviewService.js +++ b/src/api/interviewService.js @@ -3,35 +3,62 @@ const BASE_URL = "/api"; /** - * AI 면접관과 대화를 주고받습니다. - * (main.py의 /api/interview/chat 엔드포인트를 호출합니다) - * - * @param {string} topic - 면접 주제 (e.g., "React") - * @param {Array} history - 이전 대화 기록 (e.g., [{ role: "user", text: "..." }, { role: "model", text: "..." }]) - * @param {string} userMessage - 사용자의 현재 답변 - * @returns {Promise} - AI 면접관의 응답 텍스트 + * 새 AI 면접 세션 시작 (레포 URL 기반 질문 생성) + * -> POST /api/interview/start */ -export const sendChatRequest = async (topic, history, userMessage) => { - const response = await fetch(`${BASE_URL}/interview/chat`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - // main.py의 ChatRequest 모델에 맞는 데이터 전송 - body: JSON.stringify({ - topic: topic, - history: history, - user_message: userMessage - }), +export const startInterview = async (repoUrl) => { + const res = await fetch(`${BASE_URL}/interview/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ repoUrl }), }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `AI 면접관 응답에 실패했습니다.`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || "AI 면접 세션 시작에 실패했습니다."); } - const data = await response.json(); + return res.json(); // { sessionId, durationSec, questions } +}; - // main.py가 반환하는 { response: "..." } 객체에서 텍스트를 추출 - return data.response; -}; \ No newline at end of file +/** + * 모든 질문에 대한 답변(STT 전환된 텍스트 포함)을 전달하고 + * 최종 면접 결과 피드백을 받음 + * -> POST /api/interview/feedback + */ +export const requestInterviewFeedback = async (sessionId, answers) => { + const res = await fetch(`${BASE_URL}/interview/feedback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId, answers }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || "AI 면접 피드백 요청에 실패했습니다."); + } + + return res.json(); +}; + +/** + * 음성 Blob → 텍스트(STT) + * -> POST /api/interview/stt + */ +export const transcribeAnswer = async (audioBlob) => { + const formData = new FormData(); + formData.append("audio", audioBlob); + + const res = await fetch(`${BASE_URL}/interview/stt`, { + method: "POST", + body: formData, + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || "음성 인식에 실패했습니다."); + } + + const data = await res.json(); // { text: "..." } + return data.text || ""; +}; diff --git a/src/data/interviewQuestions.js b/src/data/interviewQuestions.js deleted file mode 100644 index 66c8348..0000000 --- a/src/data/interviewQuestions.js +++ /dev/null @@ -1,33 +0,0 @@ -// src/data/interviewQuestions.js - -// 🧠 기술 질문 (기술 역량) -export const TECH = [ - "비동기 처리(Promise / async-await)의 차이와 에러 핸들링 전략을 설명해 주세요.", - "상태관리(Context, Redux, Zustand 등)를 선택할 때 기준은 무엇인가요?", - "HTTP / REST와 WebSocket의 차이를 설명하고 각각의 사용 사례를 말해 주세요.", - "데이터베이스 정규화와 비정규화의 차이를 설명해 주세요.", - "캐시 전략(브라우저, 서버, CDN)과 무효화 설계는 어떻게 하시나요?", - "테스트 전략(Unit, Integration, E2E)을 어떻게 구성하나요?", - "성능 최적화에서 가장 임팩트 있었던 개선 사례를 말해 주세요.", - "CI/CD 환경에서 품질 보장을 위해 어떤 자동화를 적용해보셨나요?", -]; - -// 💬 인성 질문 (소프트 스킬) -export const BEH = [ - "최근에 가장 도전적이었던 경험은 무엇이며, 어떻게 해결했나요?", - "팀 내 갈등이 발생했을 때 어떤 방식으로 조율했나요?", - "실패했던 경험이 있다면, 그 경험에서 무엇을 배웠나요?", - "시간 압박 속에서도 높은 품질을 유지하기 위해 어떤 노력을 했나요?", - "새로운 기술을 학습할 때 본인만의 루틴이나 방법이 있나요?", - "팀 프로젝트에서 맡았던 역할과, 본인의 강점을 어떻게 발휘했는지 설명해 주세요.", - "압박 상황에서 침착함을 유지하는 본인만의 방법이 있나요?", - "본인의 커리어 목표와 그 이유는 무엇인가요?", -]; - -// ⚙️ 도우미 함수: 기술 3개 + 인성 2개 랜덤 섞기 -export function getRandomQuestions() { - const shuffle = (arr) => [...arr].sort(() => 0.5 - Math.random()); - const tech = shuffle(TECH).slice(0, 3).map((text) => ({ type: "tech", text })); - const beh = shuffle(BEH).slice(0, 2).map((text) => ({ type: "beh", text })); - return shuffle([...tech, ...beh]); -} diff --git a/src/features/interview/Intro.jsx b/src/features/interview/Intro.jsx deleted file mode 100644 index db9d1aa..0000000 --- a/src/features/interview/Intro.jsx +++ /dev/null @@ -1,20 +0,0 @@ -export default function Intro() { - return ( -
-
-
-
AI Interview
-
-

- 5개의 질문(기술+인성 랜덤), 문항당 60초입니다. 준비되면 시작하세요. -

- - 면접 시작 - - 홈으로 -
-
-
-
- ); -} diff --git a/src/features/interview/Result.jsx b/src/features/interview/Result.jsx deleted file mode 100644 index 63ab02c..0000000 --- a/src/features/interview/Result.jsx +++ /dev/null @@ -1,18 +0,0 @@ -export default function Result() { - return ( -
-
-
-
수고하셨습니다!
-
-

AI가 곧 요약과 점수를 보여줄 거예요.

- -
-
-
-
- ); -} diff --git a/src/features/interview/Session.jsx b/src/features/interview/Session.jsx deleted file mode 100644 index 9c44195..0000000 --- a/src/features/interview/Session.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useState } from "react"; - -export default function Session() { - // 질문 상태 - const [questions, setQuestions] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - - // 페이지 로드 시 랜덤 질문 생성 - useEffect(() => { - const tech = [ - "비동기 처리(Promise/async-await)의 차이와 에러 핸들링 전략을 설명해 주세요.", - "상태관리(Context, Redux, Zustand 등)를 선택할 때 기준은 무엇인가요?", - "HTTP/REST와 WebSocket의 차이점을 설명해 주세요.", - "데이터베이스 정규화와 비정규화의 차이점을 설명해 주세요.", - ]; - - const beh = [ - "최근 가장 도전적이었던 경험은 무엇이었나요?", - "팀 내 갈등이 발생했을 때 어떻게 해결했나요?", - "실패 경험과 그 이후의 학습 과정을 말해 주세요.", - "압박 상황에서 자신을 어떻게 관리하나요?", - ]; - - // 기술 3 + 인성 2 랜덤 섞기 - const randomTech = tech.sort(() => 0.5 - Math.random()).slice(0, 3); - const randomBeh = beh.sort(() => 0.5 - Math.random()).slice(0, 2); - const combined = [...randomTech, ...randomBeh].sort(() => 0.5 - Math.random()); - - setQuestions(combined); - }, []); - - // 질문 아직 없을 때 - if (questions.length === 0) { - return ( -
- 질문 불러오는 중... -
- ); - } - - // 현재 질문 표시 - const question = questions[currentIndex]; - - return ( -
-
-
-
면접 진행
- -
-
- {currentIndex + 1} / {questions.length} 문항 -
- -
- {question} -
- -
- {currentIndex > 0 && ( - - )} - - {currentIndex < questions.length - 1 ? ( - - ) : ( - - 결과 보기 - - )} -
-
-
-
-
- ); -} diff --git a/src/features/interview/components/MicRecorder.jsx b/src/features/interview/components/MicRecorder.jsx new file mode 100644 index 0000000..ad87da2 --- /dev/null +++ b/src/features/interview/components/MicRecorder.jsx @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * MicRecorder + * + * props: + * - running: boolean // true면 자동 녹음 시작, false면 정지 + * - onStop: ({ blob, durationSec }) => void + * => 녹음이 끝났을 때 한 번만 호출됨 + */ +export default function MicRecorder({ running, onStop }) { + const [recording, setRecording] = useState(false); + const mrRef = useRef(null); + const chunksRef = useRef([]); + const startAtRef = useRef(0); + + useEffect(() => { + if (running && !recording) { + start(); + } + if (!running && recording) { + stop(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [running]); + + async function start() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + // 브라우저마다 mimeType 지원이 다를 수 있어서 한번 체크 + const options = {}; + if (MediaRecorder.isTypeSupported("audio/webm")) { + options.mimeType = "audio/webm"; + } + + const mr = new MediaRecorder(stream, options); + chunksRef.current = []; + + mr.ondataavailable = (e) => { + if (e.data && e.data.size > 0) { + chunksRef.current.push(e.data); + } + }; + + mr.onstop = () => { + const blob = new Blob(chunksRef.current, { type: "audio/webm" }); + const durationSec = Math.round( + (performance.now() - startAtRef.current) / 1000 + ); + + onStop?.({ blob, durationSec }); + + // 마이크 리소스 해제 + stream.getTracks().forEach((t) => t.stop()); + mrRef.current = null; + }; + + mrRef.current = mr; + startAtRef.current = performance.now(); + mr.start(); + setRecording(true); + } catch (err) { + console.error("마이크 접근 실패 또는 MediaRecorder 오류:", err); + setRecording(false); + } + } + + function stop() { + if (mrRef.current && mrRef.current.state === "recording") { + mrRef.current.stop(); + } + setRecording(false); + } + + return ( +
+ + + {recording ? "Recording..." : "Idle"} + +
+ ); +} diff --git a/src/features/interview/components/interview/ProgressDots.jsx b/src/features/interview/components/ProgressDots.jsx similarity index 100% rename from src/features/interview/components/interview/ProgressDots.jsx rename to src/features/interview/components/ProgressDots.jsx diff --git a/src/features/interview/components/QuestionBox.jsx b/src/features/interview/components/QuestionBox.jsx new file mode 100644 index 0000000..c968d94 --- /dev/null +++ b/src/features/interview/components/QuestionBox.jsx @@ -0,0 +1,23 @@ +export default function QuestionBox({ type = "TECH", text }) { + const isTech = type?.toLowerCase() === "tech"; + const badge = isTech ? "기술" : "인성"; + + return ( +
+
+ {badge} +
+ +
+ {text} +
+
+ ); +} diff --git a/src/features/interview/components/Timer60.jsx b/src/features/interview/components/Timer60.jsx new file mode 100644 index 0000000..f2d7645 --- /dev/null +++ b/src/features/interview/components/Timer60.jsx @@ -0,0 +1,42 @@ +import { useEffect, useState, useRef } from "react"; + +/** + * ⏱️ 60초 카운트다운 타이머 + * props: + * - running: boolean (true = 시작/재시작, false = 정지) + * - onTimeout: () => void (0초 도달 시 1회 호출) + */ +export default function Timer60({ running, onTimeout }) { + const [sec, setSec] = useState(60); + const calledRef = useRef(false); // 타임아웃 중복 방지 + + // running이 true가 될 때마다 타이머 리셋 + useEffect(() => { + if (running) { + setSec(60); + calledRef.current = false; + } + }, [running]); + + // 카운트다운 기능 + useEffect(() => { + if (!running) return; + + if (sec === 0) { + if (!calledRef.current) { + calledRef.current = true; + onTimeout?.(); + } + return; + } + + const id = setTimeout(() => setSec((s) => s - 1), 1000); + return () => clearTimeout(id); + }, [sec, running, onTimeout]); + + return ( +
+ {sec}s +
+ ); +} diff --git a/src/features/interview/components/interview/AiAvatar.jsx b/src/features/interview/components/interview/AiAvatar.jsx deleted file mode 100644 index 004383f..0000000 --- a/src/features/interview/components/interview/AiAvatar.jsx +++ /dev/null @@ -1,13 +0,0 @@ -export default function AiAvatar({ text, title = "AI Interviewer" }) { - return ( -
-
-
-
{title}
-
- {text} -
-
-
- ); -} diff --git a/src/features/interview/components/interview/MicRecorder.jsx b/src/features/interview/components/interview/MicRecorder.jsx deleted file mode 100644 index 8b12bee..0000000 --- a/src/features/interview/components/interview/MicRecorder.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -/** - * MediaRecorder 래퍼 - * props: - * - running: boolean (true면 자동 녹음 시작, false면 정지) - * - onStop: ({blob, durationSec}) => void (정지 시 콜백) - */ -export default function MicRecorder({ running, onStop }) { - const [recording, setRecording] = useState(false); - const mrRef = useRef(null); - const chunksRef = useRef([]); - const startAtRef = useRef(0); - - useEffect(() => { - if (running && !recording) start(); - if (!running && recording) stop(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [running]); - - async function start() { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const mr = new MediaRecorder(stream, { mimeType: "audio/webm" }); - chunksRef.current = []; - mr.ondataavailable = (e) => e.data.size && chunksRef.current.push(e.data); - mr.onstop = () => { - const blob = new Blob(chunksRef.current, { type: "audio/webm" }); - const durationSec = Math.round((performance.now() - startAtRef.current) / 1000); - onStop?.({ blob, durationSec }); - stream.getTracks().forEach((t) => t.stop()); - }; - mrRef.current = mr; - startAtRef.current = performance.now(); - mr.start(); - setRecording(true); - } - - function stop() { - if (mrRef.current?.state === "recording") mrRef.current.stop(); - setRecording(false); - } - - return ( -
- - {recording ? "Recording..." : "Idle"} -
- ); -} diff --git a/src/features/interview/components/interview/QuestionBox.jsx b/src/features/interview/components/interview/QuestionBox.jsx deleted file mode 100644 index f7fd7ab..0000000 --- a/src/features/interview/components/interview/QuestionBox.jsx +++ /dev/null @@ -1,15 +0,0 @@ -export default function QuestionBox({ type = "tech", text }) { - const isTech = type === "tech"; - const badge = isTech ? "기술" : "인성"; - return ( -
-
- {badge} -
-
{text}
-
- ); -} diff --git a/src/features/interview/components/interview/Timer60.jsx b/src/features/interview/components/interview/Timer60.jsx deleted file mode 100644 index 0a2bc34..0000000 --- a/src/features/interview/components/interview/Timer60.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect, useState } from "react"; - -/** - * 60초 카운트다운 타이머 - * props: - * - running: boolean (true면 시작/재시작) - * - onTimeout: () => void (0초 도달 시 콜백) - */ -export default function Timer60({ running, onTimeout }) { - const [sec, setSec] = useState(60); - - useEffect(() => { - if (!running) return; - setSec(60); // 시작할 때 항상 리셋 - }, [running]); - - useEffect(() => { - if (!running) return; - if (sec === 0) { - onTimeout?.(); - return; - } - const id = setTimeout(() => setSec((s) => s - 1), 1000); - return () => clearTimeout(id); - }, [sec, running, onTimeout]); - - return ( -
- {sec}s -
- ); -} diff --git a/src/features/interview/pages/Intro.jsx b/src/features/interview/pages/Intro.jsx new file mode 100644 index 0000000..3803940 --- /dev/null +++ b/src/features/interview/pages/Intro.jsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +export default function Intro() { + const [repoUrl, setRepoUrl] = useState(""); + const [error, setError] = useState(""); + const [isFocused, setIsFocused] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = (e) => { + e.preventDefault(); + if (!repoUrl.trim()) { + setError("GitHub 레포지토리 URL을 입력해주세요."); + return; + } + setError(""); + navigate("/interview/session", { state: { repoUrl } }); + }; + + return ( + // 전체 배경: 깊은 어둠 속에 은은한 오로라 효과 +
+ + {/* 배경 장식 요소 (Glow Blobs) */} +
+
+ + {/* 메인 카드 컨테이너 */} +
+
+ + {/* 상단 장식 라인 */} +
+ +
+ + {/* 헤더 섹션 */} +
+
+ {/* AI Robot Icon SVG */} + + + +
+

+ AI Interview +

+

+ GitHub 코드를 분석하여 맞춤형 면접을 진행합니다. +

+
+ + {/* 설명 섹션 (카드 형태의 안내) */} +
+
+ +

+ 기술 문항과 + 인성 문항 + 총 5문항입니다. +

+
+
+ +

+ 각 문항: 준비 5초 / 답변 60초 +

+
+
+ + {/* 입력 폼 */} +
+
+ +
+
+ {/* Github Icon */} + + + +
+ setRepoUrl(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + placeholder="https://github.com/username/project" + className="w-full px-4 py-3.5 bg-transparent text-sm text-white placeholder:text-slate-600 focus:outline-none rounded-xl" + /> +
+ {error && ( +
+ + + + {error} +
+ )} +
+ + +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/features/interview/pages/Result.jsx b/src/features/interview/pages/Result.jsx new file mode 100644 index 0000000..2dded54 --- /dev/null +++ b/src/features/interview/pages/Result.jsx @@ -0,0 +1,87 @@ +// src/features/interview/pages/Result.jsx + +import { useLocation, useNavigate } from "react-router-dom"; + +export default function Result() { + const location = useLocation(); + const navigate = useNavigate(); + const feedback = location.state?.feedback; + + if (!feedback) { + // 새로고침 등으로 state 날아간 경우 + return ( +
+
+
+
AI Interview Result
+
+

+ 면접 결과 정보를 찾을 수 없습니다. 다시 AI 면접을 진행해 주세요. +

+ +
+
+
+
+ ); + } + + const { overallScore, summary } = feedback; + + return ( +
+
+
+
+
AI Interview Result
+
+ +
+ {/* 종합 점수 + 요약 */} +
+
+

+ Overall Score +

+

+ {overallScore} + / 100 +

+
+ +
+

+ AI Summary +

+

+ {summary} +

+
+
+ + {/* 버튼들 */} +
+ + +
+
+
+
+
+ ); +} diff --git a/src/features/interview/pages/Session.jsx b/src/features/interview/pages/Session.jsx new file mode 100644 index 0000000..0bc5003 --- /dev/null +++ b/src/features/interview/pages/Session.jsx @@ -0,0 +1,325 @@ +// src/features/interview/Session.jsx + +import { useEffect, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { + startInterview, + requestInterviewFeedback, + transcribeAnswer, +} from "@/api/interviewService"; +import Timer60 from "../components/Timer60"; +import MicRecorder from "../components/MicRecorder"; +import QuestionBox from "../components/QuestionBox"; +import ProgressDots from "../components/ProgressDots"; + +export default function Session() { + const location = useLocation(); + const navigate = useNavigate(); + const repoUrl = location.state?.repoUrl || ""; + + const [loading, setLoading] = useState(true); + // loading | prep | answer | analyzing | error + const [phase, setPhase] = useState("loading"); + const [error, setError] = useState(""); + + const [sessionId, setSessionId] = useState(null); + const [questions, setQuestions] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + + const [prepSec, setPrepSec] = useState(5); + const [answerRunning, setAnswerRunning] = useState(false); + + const [answers, setAnswers] = useState([]); + + // StrictMode에서 중복 호출 방지 + const startedRef = useRef(false); + // 녹음이 끝난 이유를 저장 (timeout / manual 등) + const stopReasonRef = useRef("manual"); + + // 1) repoUrl 없으면 Intro로 + useEffect(() => { + if (!repoUrl) { + navigate("/interview", { replace: true }); + } + }, [repoUrl, navigate]); + + // 2) 세션 시작: 질문 불러오기 + useEffect(() => { + if (!repoUrl) return; + if (startedRef.current) return; + startedRef.current = true; + + const run = async () => { + try { + setLoading(true); + setError(""); + + const res = await startInterview(repoUrl); + console.log("startInterview response:", res); + + const qs = Array.isArray(res.questions) ? res.questions : []; + if (!qs.length) { + throw new Error("질문을 불러오지 못했습니다."); + } + + setSessionId(res.sessionId || null); + setQuestions(qs); + setCurrentIndex(0); + + setPhase("prep"); + setPrepSec(5); + setAnswerRunning(false); + } catch (e) { + console.error("startInterview error:", e); + setError( + e.message || "AI 면접 세션을 시작하는 동안 오류가 발생했습니다." + ); + setPhase("error"); + } finally { + setLoading(false); + } + }; + + run(); + }, [repoUrl]); + + // 3) 준비 5초 → 끝나면 answer 단계로 전환 + 녹음 시작 + useEffect(() => { + if (phase !== "prep") return; + + if (prepSec <= 0) { + setPhase("answer"); + setAnswerRunning(true); + stopReasonRef.current = "timeout"; // 기본값 + return; + } + + const id = setTimeout(() => setPrepSec((s) => s - 1), 1000); + return () => clearTimeout(id); + }, [phase, prepSec]); + + // 4) 한 질문 마무리(실제 답변 텍스트까지 받은 뒤) + const finishCurrentQuestion = async ({ + answerText, + durationSec, + reason, + }) => { + const q = questions[currentIndex]; + if (!q) return; + + const newAnswer = { + questionId: q.id, + type: q.type, + question: q.text, + answerText, + durationSec, + endReason: reason, + }; + + const merged = [...answers]; + merged[currentIndex] = newAnswer; + setAnswers(merged); + + // 마지막 질문이면 → 분석 단계로 넘어가서 피드백 요청 + if (currentIndex === questions.length - 1) { + setPhase("analyzing"); + try { + const feedback = await requestInterviewFeedback( + sessionId, + merged.filter(Boolean) + ); + navigate("/interview/result", { state: { feedback } }); + } catch (e) { + console.error("requestInterviewFeedback error:", e); + setError(e.message || "피드백을 가져오는 중 오류가 발생했습니다."); + setPhase("error"); + } + return; + } + + // 다음 질문 준비 + setCurrentIndex((idx) => { + const next = idx + 1; + setPhase("prep"); + setPrepSec(5); + setAnswerRunning(false); + return next; + }); + }; + + // 5) Timer가 0이 됐을 때: 녹음만 멈추고, 실제 마무리는 STT 이후에 + const handleTimeout = () => { + if (phase !== "answer") return; + stopReasonRef.current = "timeout"; + setAnswerRunning(false); // → MicRecorder가 stop되고 onStop 호출됨 + }; + + // 6) MicRecorder가 실제로 녹음을 끝냈을 때 + const handleMicStop = async ({ blob, durationSec }) => { + if (phase !== "answer") return; // 이미 다른 단계로 넘어갔으면 무시 + + try { + const text = await transcribeAnswer(blob); + await finishCurrentQuestion({ + answerText: text, + durationSec, + reason: stopReasonRef.current || "manual", + }); + } catch (e) { + console.error("STT error:", e); + setError(e.message || "음성 인식 중 오류가 발생했습니다."); + + // STT 실패해도 빈 답변으로 마무리는 해 준다 + await finishCurrentQuestion({ + answerText: "", + durationSec, + reason: "stt-error", + }); + } + }; + + // ===== 화면 분기들 ===== + + if (loading || phase === "loading") { + return ( +
+
+
+
분석 중...
+
+

+ GitHub 레포지토리를 분석해서 기술 질문을 만드는 중입니다. +

+

+ 이 작업은 코드 구조와 기술 스택을 파악하는 데 몇 초 정도 걸릴 수 있습니다. +

+
+
+
+
+ ); + } + + if (phase === "analyzing") { + return ( +
+
+
+
답변 분석중...
+
+

+ 방금 녹음된 답변들을 기반으로 AI가 면접 결과를 정리하고 있습니다. +

+

+ 예상 피드백 리포트를 생성하는 데 약간의 시간이 걸릴 수 있습니다. +

+
+
+
+
+ ); + } + + if (phase === "error") { + return ( +
+
+
+
AI 면접 세션 오류
+
+

{error}

+ +
+
+
+
+ ); + } + + const total = questions.length || 5; + const current = questions[currentIndex]; + + return ( +
+
+
+
+
AI Interview
+
+ +
+ Q {currentIndex + 1} / {total} +
+
+
+ +
+ {/* 왼쪽: 질문 */} +
+ {current && ( + + )} +
+ + {/* 오른쪽: 타이머 + 마이크 + 컨트롤 */} +
+
+
+ {phase === "prep" ? "준비 시간" : "답변 시간"} +
+ + {phase === "prep" ? ( +
+ {prepSec}s +
+ ) : ( + + )} + + + +
+ {phase === "answer" && ( + + )} + + {phase === "prep" && ( +

+ 5초 준비 후 자동으로 답변 녹음이 시작됩니다. +

+ )} +
+
+ + +
+
+
+
+
+ ); +} diff --git a/src/services/interviewApi.js b/src/services/interviewApi.js deleted file mode 100644 index e147360..0000000 --- a/src/services/interviewApi.js +++ /dev/null @@ -1,43 +0,0 @@ -// src/api/interviewApi.js -// 게이트웨이(Nginx)에서 `/api/interview/*` 프록시된다고 가정 -const BASE_URL = "/api/interview"; - -/** - * 5문항(기술3+인성2) 랜덤 세트 가져오기 - * GET /api/interview/start → { questions: [{type,text}] } - */ -export async function fetchQuestions() { - const res = await fetch(`${BASE_URL}/start`); - if (!res.ok) throw new Error(`질문 요청 실패: ${res.status} ${res.statusText}`); - return res.json(); // { questions } -} - -/** - * 면접 결과 분석 요청 (multipart/form-data) - * POST /api/interview/analyze - * - * @param {{questions: Array<{type:string,text:string}>, answers: Array<{text:string|null,durationSec:number}>}} meta - * @param {Blob[]} [audioBlobs] // 길이 5, 없으면 생략 가능 - * @returns {Promise<{summary:string, scores: Record, detail:any}>} - */ -export async function analyzeInterview(meta, audioBlobs = []) { - const form = new FormData(); - form.append("meta", JSON.stringify(meta)); // {questions, answers} - - // audio_0..audio_4 형식으로 첨부 (선택) - audioBlobs.forEach((blob, idx) => { - if (blob) form.append(`audio_${idx}`, blob, `q${idx}.webm`); - }); - - const res = await fetch(`${BASE_URL}/analyze`, { - method: "POST", - body: form, - }); - - if (!res.ok) { - // 서버가 {error:"..."}를 주는 경우 대비 - const err = await res.json().catch(() => ({})); - throw new Error(err.error || `분석 요청 실패: ${res.status} ${res.statusText}`); - } - return res.json(); // { summary, scores, detail } -} diff --git a/src/shared/hooks/useInterviewFlow.js b/src/shared/hooks/useInterviewFlow.js deleted file mode 100644 index 1148d71..0000000 --- a/src/shared/hooks/useInterviewFlow.js +++ /dev/null @@ -1,59 +0,0 @@ -// src/hooks/useInterviewFlow.js -import { useNavigate } from "react-router-dom"; -import { useState } from "react"; -import { getRandomQuestions } from "@/data/interviewQuestions"; - -/** - * AI 면접 흐름 제어 훅 - * - 질문 랜덤 생성 - * - 현재 문항 인덱스 관리 - * - 답변 리스트 관리 - * - 마지막에 결과 페이지 이동 - */ -export default function useInterviewFlow() { - const navigate = useNavigate(); - - // 상태 관리 - const [questions, setQuestions] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [answers, setAnswers] = useState([]); - - /** 면접 시작 (Intro.jsx에서 호출) */ - const startInterview = () => { - const qs = getRandomQuestions(); // 기술3 + 인성2 랜덤 - setQuestions(qs); - setCurrentIndex(0); - setAnswers([]); - navigate("/interview/session"); - }; - - /** 현재 문항에 대한 답변 저장 후 다음 문항으로 이동 */ - const submitAnswer = (answerObj) => { - setAnswers((prev) => [...prev, answerObj]); - const next = currentIndex + 1; - - if (next < questions.length) { - setCurrentIndex(next); - } else { - // 모든 문항 완료 → 결과 페이지로 이동 - navigate("/interview/result", { state: { questions, answers: [...answers, answerObj] } }); - } - }; - - /** 면접 리셋 */ - const resetInterview = () => { - setQuestions([]); - setAnswers([]); - setCurrentIndex(0); - }; - - return { - questions, - currentIndex, - currentQuestion: questions[currentIndex], - answers, - startInterview, - submitAnswer, - resetInterview, - }; -} diff --git a/src/shared/utils/audio.js b/src/shared/utils/audio.js deleted file mode 100644 index 708d8a6..0000000 --- a/src/shared/utils/audio.js +++ /dev/null @@ -1,14 +0,0 @@ -/** Blob → ArrayBuffer */ -export async function blobToArrayBuffer(blob) { - return await blob.arrayBuffer(); -} - -/** Blob → Base64 문자열 (분석 서버에 전송 시 사용 가능) */ -export async function blobToBase64(blob) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result.split(",")[1]); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); -} diff --git a/src/shared/utils/random.js b/src/shared/utils/random.js deleted file mode 100644 index b639472..0000000 --- a/src/shared/utils/random.js +++ /dev/null @@ -1,16 +0,0 @@ -/** 배열 셔플 */ -export function shuffle(arr) { - const a = [...arr]; - for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [a[i], a[j]] = [a[j], a[i]]; - } - return a; -} - -/** 기술 + 인성 질문 샘플링 */ -export function sampleFive(TECH, BEH) { - const techQs = shuffle(TECH).slice(0, 3).map((t) => ({ type: "tech", text: t })); - const behQs = shuffle(BEH).slice(0, 2).map((t) => ({ type: "beh", text: t })); - return shuffle([...techQs, ...behQs]); -} diff --git a/src/store/interviewStore.js b/src/store/interviewStore.js deleted file mode 100644 index 2ef940b..0000000 --- a/src/store/interviewStore.js +++ /dev/null @@ -1,15 +0,0 @@ -import { create } from "zustand"; - -export const useInterviewStore = create((set) => ({ - questions: [], // [{ type, text }] - currentIndex: 0, // 진행 중인 질문 인덱스 - answers: [], // [{ blob, durationSec }] - result: null, // 분석 결과 - - // 액션들 - setQuestions: (qs) => set({ questions: qs, currentIndex: 0, answers: [], result: null }), - setCurrentIndex: (i) => set({ currentIndex: i }), - addAnswer: (a) => set((s) => ({ answers: [...s.answers, a] })), - setResult: (r) => set({ result: r }), - reset: () => set({ questions: [], currentIndex: 0, answers: [], result: null }), -}));