Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
79 changes: 53 additions & 26 deletions src/api/interviewService.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,62 @@
const BASE_URL = "/api";

/**
* AI 면접관과 대화를 주고받습니다.
* (main.py의 /api/interview/chat 엔드포인트를 호출합니다)
*
* @param {string} topic - 면접 주제 (e.g., "React")
* @param {Array<object>} history - 이전 대화 기록 (e.g., [{ role: "user", text: "..." }, { role: "model", text: "..." }])
* @param {string} userMessage - 사용자의 현재 답변
* @returns {Promise<string>} - 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;
};
/**
* 모든 질문에 대한 답변(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 || "";
};
33 changes: 0 additions & 33 deletions src/data/interviewQuestions.js

This file was deleted.

20 changes: 0 additions & 20 deletions src/features/interview/Intro.jsx

This file was deleted.

18 changes: 0 additions & 18 deletions src/features/interview/Result.jsx

This file was deleted.

90 changes: 0 additions & 90 deletions src/features/interview/Session.jsx

This file was deleted.

89 changes: 89 additions & 0 deletions src/features/interview/components/MicRecorder.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${
recording ? "bg-red-500 animate-pulse" : "bg-slate-500"
}`}
title={recording ? "Recording" : "Idle"}
/>
<span className="text-xs text-slate-300">
{recording ? "Recording..." : "Idle"}
</span>
</div>
);
}
23 changes: 23 additions & 0 deletions src/features/interview/components/QuestionBox.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export default function QuestionBox({ type = "TECH", text }) {
const isTech = type?.toLowerCase() === "tech";
const badge = isTech ? "기술" : "인성";

return (
<div className="rounded-2xl p-6 bg-slate-800/60 border border-slate-700">
<div
className={`inline-flex items-center text-xs px-2 py-1 rounded-full mb-3
${
isTech
? "bg-indigo-600/30 text-indigo-200"
: "bg-emerald-600/30 text-emerald-200"
}`}
>
{badge}
</div>

<div className="text-lg leading-relaxed text-slate-100">
{text}
</div>
</div>
);
}
Loading