Skip to content

Commit

Permalink
Merge pull request #22 from boostcampwm-2024/refactor/FE/ai-modal
Browse files Browse the repository at this point in the history
[FE] - AI Modal UI 구현
  • Loading branch information
dooohun authored Feb 6, 2025
2 parents caad447 + 85af271 commit 18f4af4
Show file tree
Hide file tree
Showing 7 changed files with 132 additions and 14 deletions.
24 changes: 23 additions & 1 deletion packages/client/src/pages/quiz-create/ui/QuizActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { useQuizContext } from '../contexts/useQuizContext';
import { useCreateQuiz } from '@/shared/hooks/quizzes';
import { INITIAL_QUIZ_VALUE } from '../contexts/quizContext';
import { useParams } from 'react-router-dom';
import { useState } from 'react';
import Modal from '@/shared/ui/modal';
import AiQuizModal from '@/pages/quiz-list/ui/AiQuizModal';
import { quizzesSchema } from '@/shared/validation/quizSchema';
import { z } from 'zod';
import { toastController } from '@/features/toast/model/toastController';
Expand All @@ -12,6 +15,7 @@ export default function QuizActions() {
const { quizzes, currentQuizIndex, setQuizzes, setCurrentQuizIndex } = useQuizContext();
const createQuizMutation = useCreateQuiz();
const { classId } = useParams();
const [aiModal, setAiModal] = useState(false);
const toast = toastController();

const addNewQuiz = () => {
Expand Down Expand Up @@ -46,6 +50,10 @@ export default function QuizActions() {
}
};

const onAIModalClose = () => {
setAiModal(false);
}

return (
<div className="flex justify-between mt-10 mr-6">
<div className="flex gap-6">
Expand All @@ -67,7 +75,21 @@ export default function QuizActions() {
문제 삭제하기
</button>
</div>
<CustomButton label="퀴즈 발행하기" onClick={handleCreateQuiz} />
<div className='flex gap-2'>
<CustomButton
type="outline"
label="AI로 퀴즈 만들기"
color="primary"
size="md"
onClick={() => setAiModal(true)}
/>
<CustomButton label="퀴즈 발행하기" onClick={handleCreateQuiz} />
</div>
{aiModal && (
<Modal onClose={() => setAiModal(false)}>
<AiQuizModal onClose={onAIModalClose} />
</Modal>
)}
</div>
);
}
2 changes: 1 addition & 1 deletion packages/client/src/pages/quiz-list/index.lazy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function QuizListLazyPage() {
return <ClassItem key={item.id} quizList={item} index={index} />;
})}
{sortedClassListById.length === 0 && <EmptyQuizList />}
<div className="self-end ">
<div className="self-end flex gap-2">
<CustomButton
type="outline"
label="퀴즈 만들기"
Expand Down
75 changes: 75 additions & 0 deletions packages/client/src/pages/quiz-list/ui/AiQuizModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useState } from 'react';
import { LightbulbIcon } from 'lucide-react';
import { useCreateAIQuiz } from '@/shared/hooks/quizzes';
import { useParams } from 'react-router-dom';
import { useQuizContext } from '@/pages/quiz-create/contexts/useQuizContext';
import QuizLoading from '@/pages/quiz-session/ui/QuizLoading';

interface AIQuizModalProps {
onClose: () => void;
}

export default function AIQuizModal({ onClose }: AIQuizModalProps) {
const { setQuizzes } = useQuizContext();
const [prompt, setPrompt] = useState('');
const { classId } = useParams();
const { mutate, isPending } = useCreateAIQuiz();
const handleConfirmClick = () => {
mutate(
{ classId: Number(classId), text: prompt },
{
onSuccess: (data) => {
setQuizzes(data.data.quizzes);
onClose();
},
},
);
};

if (isPending) {
return <QuizLoading />;
}

return (
<form
className="flex flex-col gap-6 bg-white rounded-lg p-8 w-full max-w-2xl mx-auto shadow-md"
onClick={(e) => e.stopPropagation()}
onSubmit={(e) => {
e.preventDefault();
handleConfirmClick();
}}
>
<div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-2">
<LightbulbIcon className="w-6 h-6 text-yellow-500" />
<h2 className="text-2xl font-bold">새로운 퀴즈 만들기</h2>
</div>
<p className="text-gray-600">만들고 싶은 퀴즈에 대해서 자세하게 말씀해주세요.</p>
</div>

<div className="space-y-4">
<textarea
className="w-full min-h-[200px] p-4 text-gray-700 border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all"
placeholder="퀴즈 생성을 위한 프롬프트를 입력해주세요."
onChange={(e) => setPrompt(e.target.value)}
/>

<div className="p-4 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-sm text-gray-600 font-medium mb-2">예시 프롬프트</p>
<p className="text-sm text-gray-500 italic">
"한국의 역사와 관련되어 20대 평균 상식 수준의 퀴즈 5개를 만들어줘. 선택지의 개수는
4개로."
</p>
</div>
</div>

<button
type="submit"
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200 font-medium flex items-center justify-center space-x-2"
onSubmit={handleConfirmClick}
>
<span>퀴즈 생성하기</span>
</button>
</form>
);
}
2 changes: 1 addition & 1 deletion packages/client/src/shared/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const apiClient = {
sendRequest(endPoint, { ...options, method: 'DELETE' }),
};

async function sendRequest(endPoint: string, options: FetchOptions = {}, timeout: number = 10000) {
async function sendRequest(endPoint: string, options: FetchOptions = {}, timeout: number = 60000) {
const { headers, body, ...restOptions } = options;

const abortController = new AbortController();
Expand Down
6 changes: 6 additions & 0 deletions packages/client/src/shared/api/quizzes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ export const createQuiz = async (quizData: Quizzes, classId: number): Promise<vo
body: quizData,
});
};

export const createAIQuiz = async (classId: number, text: string) => {
return apiClient.post(`/classes/${classId}/quizzes/ai/generate`, {
body: { text },
});
}
23 changes: 22 additions & 1 deletion packages/client/src/shared/hooks/quizzes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createQuiz } from '../api/quizzes';
import { createAIQuiz, createQuiz } from '../api/quizzes';
import { QuizData } from '@/pages/quiz-create/contexts/quizContext.types';
import { toastController } from '@/features/toast/model/toastController';
import { useNavigate } from 'react-router-dom';
Expand Down Expand Up @@ -30,3 +30,24 @@ export const useCreateQuiz = () => {
onError: () => toast.error('퀴즈 생성에 실패했습니다.'),
});
};

interface CreateAIQuizParams {
classId: number;
text: string;
}

export const useCreateAIQuiz = () => {
const toast = toastController();
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['quiz', 'AI'],
mutationFn: ({ classId, text }: CreateAIQuizParams) => createAIQuiz(classId, text),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['classes'],
});
toast.success('AI가 자동 생성한 퀴즈가 생성되었습니다.');
},
onError: () => toast.error('AI 퀴즈 생성에 실패했습니다.'),
});
}
14 changes: 4 additions & 10 deletions packages/server/src/config/ai/openai.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,10 @@ export class OpenAiService {
content: [
{
type: 'text',
text: '너는 "YouQuiz 문제 제작 AI 어시스턴트"야. \nYouQuiz는 사람들이 모여 퀴즈를 풀면서 학습과 경쟁을 동시에 즐길 수 있는 서비스로, Kahoot!과 비슷한 플랫폼이야. \n\n📌 **너의 역할** \n너의 역할은 **퀴즈 마스터**로서, 사용자가 요청한 주제, 난이도, 개수 등의 조건에 맞는 **퀴즈 묶음(Class)**을 생성하는 거야. \n 사용자는 너에게 자연어로 이루어진 데이터로 문제를 요청하게될거야. 너는 주제에 맞는 문제를 출제하면 돼 \n사용자가 원하는 형식에 맞춰 **논리적이고 직관적인 JSON 구조**로 출제해야 해. \n\n📌 **퀴즈 묶음(Class) 구성 요소** \n각 문제 묶음(Class)에는 아래의 요소가 포함되어야 해:\n\n✅ **제목 (title)**: 문제 묶음의 이름. `"AB대학교 한국사 수업 1-1"` 같은 형식으로 명확하게 작성해. \n✅ **태그 (tag)**: 문제 묶음을 설명하는 해시태그 (`List of String`). 예: `["#한국사", "#1학년"]` \n✅ **문제 리스트 (quizzes)**: 하나의 문제 묶음 안에는 여러 개의 문제(quiz)가 포함될 수 있어. 각 문제는 다음 요소를 포함해야 해: \n - **문제 내용 (content)**: `"Q. 대한민국의 수도는 어디인가요?"` 같은 형태. \n - **퀴즈 유형 (quizType)**: `"MC"` (객관식) 또는 `"TF"` (참/거짓)로 설정. \n - **제한 시간 (timeLimit)**: 문제를 풀 수 있는 제한 시간(초). 최소 `5초` 이상. \n - **배점 (point)**: 문제를 맞혔을 때 얻을 수 있는 점수. 최소 `0점` 이상. \n - **문제 순서 (position)**: 퀴즈가 표시되는 순서 (0부터 시작). \n - **선택지 (choices)**: 각 문제에는 2개 이상의 선택지가 있어야 하며, 아래 구조를 따라야 해:\n - 선택지 텍스트\n - 정답 여부\n - 선택지 순서\n - **풀이 과정 (steps)**: 문제가 풀리는 과정을 설명하는 배열.\n - **정답 (final_answer)**: 문제의 최종 정답.\n\n**⚠️ 중요!** \n- 너는 Playground에 저장된 Schema에 맞는 JSON을 생성해야 해. \n- 반드시 **유효한 JSON 형식**으로 출력해야 하며, 텍스트 형식이 아닌 JSON 객체를 반환해야 해. \n- 응답을 할 때 `"response_format": "json"` 옵션을 따르도록 해.\n',
},
],
},
{
role: 'assistant',
content: [
{
type: 'text',
text: '{\n "quizzes": [\n {\n "content": "Q. 대한민국의 수도는 어디인가요?",\n "quizType": "MC",\n "timeLimit": 10,\n "point": 5,\n "position": 0,\n "choices": [\n {\n "content": "서울",\n "isCorrect": true,\n "position": 0\n },\n {\n "content": "부산",\n "isCorrect": false,\n "position": 1\n },\n {\n "content": "인천",\n "isCorrect": false,\n "position": 2\n },\n {\n "content": "대구",\n "isCorrect": false,\n "position": 3\n }\n ]\n }\n ]\n}',
text:
'너는 "YouQuiz 문제 제작 AI 어시스턴트"야. \n YouQuiz는 사람들이 모여 퀴즈를 풀면서 학습과 경쟁을 동시에 즐길 수 있는 서비스로, Kahoot!과 비슷한 플랫폼이야. \n\n📌 **너의 역할** \n너의 역할은 **퀴즈 마스터**로서, 사용자가 요청한 주제, 난이도, 개수 등의 조건에 맞는 **퀴즈 묶음(Class)**을 생성하는 거야. \n 사용자는 너에게 자연어로 이루어진 데이터로 주제와 문제 개수와 선택지 개수를 요청하게 될 거야. 너는 주제와 문제 개수와 선택지 개수에 맞게 문제들을 출제하면 돼 \n사용자가 원하는 형식에 맞춰 **논리적이고 직관적인 JSON 구조**로 출제해야 해. \n\n📌 **퀴즈 묶음(Class) 구성 요소** \n각 문제 묶음(Class)에는 아래의 요소가 포함되어야 해:\n\n✅ **제목 (title)**: 문제 묶음의 이름. "AB대학교 한국사 수업 1-1" 같은 형식으로 명확하게 작성해. \n✅ **태그 (tag)**: 문제 묶음을 설명하는 해시태그 (List of String). 예: ["#한국사", "#1학년"] \n✅ **문제 리스트 (quizzes)**: 하나의 문제 묶음 안에는 여러 개의 문제(quiz)가 포함될 수 있어. 각 문제는 다음 요소를 포함해야 해: \n - **문제 내용 (content)**: 사용자가 자연어로 준 주제에 맞는 문제 내용을 제시해야해 예시로 대한민국에 대한 문제를 만들어달라고 하면 "Q. 대한민국의 수도는 어디인가요?" 같은 형태. \n - **퀴즈 유형 (quizType)**: "MC" (객관식) 또는 "TF" (참/거짓)로 설정. \n - **제한 시간 (timeLimit)**: 문제를 풀 수 있는 제한 시간(초). 30초를 고정으로 해줘. \n - **배점 (point)**: 문제를 맞혔을 때 얻을 수 있는 점수. 1000점을 고정으로 해줘. \n - **문제 순서 (position)**: 퀴즈가 표시되는 순서 (0부터 시작). \n - **선택지 (choices)**: 각 문제에는 2개 이상의 선택지가 있어야 하며, 아래 구조를 따라야 해:\n - 선택지 텍스트\n - 정답 여부\n - 선택지 순서\n \n**⚠️ 중요!** \n- 반드시 **유효한 JSON 형식**으로 출력해야 하며, 텍스트 형식이 아닌 JSON 객체를 반환해야 해. \n- 응답을 할 때 "response_format": "json" 옵션을 따르도록 해.' +
'\n 다음은 유저가 요청한 자연어 데이터야 : ' +
prompt,
},
],
},
Expand Down

0 comments on commit 18f4af4

Please sign in to comment.