Skip to content

Commit 467770d

Browse files
committed
refactor: AI 해설 SSE 형식으로 변경 및 API 요청시 중간 uri에 api 삭제
1 parent 59d81d4 commit 467770d

File tree

4 files changed

+317
-127
lines changed

4 files changed

+317
-127
lines changed

src/components/sections/TodayQuizSection.tsx

Lines changed: 220 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,6 @@ interface SelectionRatesData {
4040
totalCount: number;
4141
}
4242

43-
interface AiFeedbackResponse {
44-
quizId: number;
45-
quizAnswerId: number;
46-
isCorrect: boolean;
47-
aiFeedback: string;
48-
}
4943

5044
// 임시 데이터
5145
const fakeTodayQuiz: QuizData = {
@@ -74,11 +68,71 @@ const TodayQuizSection: React.FC = () => {
7468
const [selectionRates, setSelectionRates] = useState<SelectionRatesData | null>(null);
7569
const [animatedPercentages, setAnimatedPercentages] = useState<{[key: number]: number}>({});
7670
const [isAiFeedbackLoading, setIsAiFeedbackLoading] = useState(false);
71+
const [streamingFeedback, setStreamingFeedback] = useState<string>('');
72+
const [feedbackResult, setFeedbackResult] = useState<string>('');
73+
const [feedbackContent, setFeedbackContent] = useState<string>('');
74+
const [isStreamingComplete, setIsStreamingComplete] = useState(false);
75+
const [isCorrectFromAI, setIsCorrectFromAI] = useState<boolean | null>(null);
76+
const [displayedResult, setDisplayedResult] = useState<string>('');
77+
const [displayedFeedback, setDisplayedFeedback] = useState<string>('');
7778
const { openModal } = useModal();
7879

7980
const subscriptionId = searchParams.get('subscriptionId');
8081
const quizId = searchParams.get('quizId');
8182

83+
// 타이핑 애니메이션 효과를 위한 useEffect
84+
React.useEffect(() => {
85+
if (!feedbackResult) {
86+
setDisplayedResult('');
87+
return;
88+
}
89+
90+
let index = 0;
91+
setDisplayedResult('');
92+
93+
const interval = setInterval(() => {
94+
if (index < feedbackResult.length) {
95+
const char = feedbackResult[index];
96+
if (char !== undefined) {
97+
// 공백 문자를 명시적으로 처리
98+
const displayChar = char === ' ' ? ' ' : char;
99+
setDisplayedResult(prev => prev + displayChar);
100+
}
101+
index++;
102+
} else {
103+
clearInterval(interval);
104+
}
105+
}, 50); // 50ms마다 한 글자씩 (더 빠르게)
106+
107+
return () => clearInterval(interval);
108+
}, [feedbackResult]);
109+
110+
React.useEffect(() => {
111+
if (!feedbackContent) {
112+
setDisplayedFeedback('');
113+
return;
114+
}
115+
116+
let index = 0;
117+
setDisplayedFeedback('');
118+
119+
const interval = setInterval(() => {
120+
if (index < feedbackContent.length) {
121+
const char = feedbackContent[index];
122+
if (char !== undefined) {
123+
// 공백 문자를 명시적으로 처리
124+
const displayChar = char === ' ' ? ' ' : char;
125+
setDisplayedFeedback(prev => prev + displayChar);
126+
}
127+
index++;
128+
} else {
129+
clearInterval(interval);
130+
}
131+
}, 50); // 50ms마다 한 글자씩 (더 빠르게)
132+
133+
return () => clearInterval(interval);
134+
}, [feedbackContent]);
135+
82136
// 답변 제출 후 게이지 애니메이션 효과
83137
React.useEffect(() => {
84138
if (isSubmitted && selectionRates && selectedAnswer) {
@@ -222,6 +276,7 @@ const TodayQuizSection: React.FC = () => {
222276
setAnswerResult(initialResult);
223277
setIsSubmitted(true);
224278
setIsAiFeedbackLoading(true);
279+
setStreamingFeedback('AI 응답 대기 중...');
225280

226281
let answerId: string;
227282

@@ -238,32 +293,94 @@ const TodayQuizSection: React.FC = () => {
238293
answerId = (submitResponse as any).toString();
239294
}
240295

241-
console.log('추출된 answerId:', answerId);
242-
243296
try {
244-
// AI 피드백 요청
245-
console.log('AI 피드백 요청 중:', `/quizzes/${answerId}/feedback`);
246-
const feedbackResponse = await quizAPI.getAiFeedback(answerId);
247-
console.log('AI 피드백 응답:', feedbackResponse);
248-
let feedbackData: AiFeedbackResponse;
249-
250-
// API 응답 구조 처리
251-
if (feedbackResponse && typeof feedbackResponse === 'object') {
252-
feedbackData = ('data' in feedbackResponse) ? feedbackResponse.data as AiFeedbackResponse : feedbackResponse as AiFeedbackResponse;
253-
} else {
254-
throw new Error('피드백 응답 형식이 올바르지 않습니다.');
255-
}
297+
// SSE를 통한 AI 피드백 스트리밍
298+
console.log('AI 피드백 스트리밍 시작:', `/quizzes/${answerId}/feedback`);
299+
setStreamingFeedback('');
300+
setFeedbackResult('');
301+
setFeedbackContent('');
302+
setIsStreamingComplete(false);
303+
setIsCorrectFromAI(null);
304+
setDisplayedResult('');
305+
setDisplayedFeedback('');
256306

257-
// AI 피드백 받은 후 결과 업데이트
258-
const updatedResult: AnswerResult = {
259-
isCorrect: feedbackData.isCorrect,
260-
answer: displayQuiz.answer || '',
261-
commentary: displayQuiz.commentary,
262-
aiFeedback: feedbackData.aiFeedback
263-
};
307+
const eventSource = quizAPI.streamAiFeedback(
308+
answerId,
309+
// onData: 스트리밍 데이터 수신
310+
(data: string) => {
311+
// 받은 데이터 로깅 (디버깅용)
312+
console.log('받은 SSE 데이터:', JSON.stringify(data));
313+
314+
setStreamingFeedback(prev => {
315+
// 첫 번째 데이터가 오면 "AI 응답 대기 중..." 제거
316+
let currentText = prev;
317+
if (prev === 'AI 응답 대기 중...') {
318+
currentText = '';
319+
}
320+
321+
const newText = currentText + data;
322+
323+
// '정답:' 또는 '오답:' 부분과 '피드백:' 부분 실시간 파싱
324+
if (newText.includes('정답:') || newText.includes('오답:')) {
325+
// 정답/오답 여부 즉시 설정
326+
if (newText.includes('정답:') && isCorrectFromAI === null) {
327+
setIsCorrectFromAI(true);
328+
} else if (newText.includes('오답:') && isCorrectFromAI === null) {
329+
setIsCorrectFromAI(false);
330+
}
331+
332+
// 피드백 구분자 찾기
333+
const feedbackIndex = newText.indexOf('피드백:');
334+
335+
if (feedbackIndex === -1) {
336+
// 피드백 부분이 아직 안 나옴 - 결과 부분만 업데이트
337+
setFeedbackResult(newText.replace(/^(:|:)/, '').trim());
338+
} else {
339+
// 피드백 부분이 나옴 - 결과와 피드백 분리
340+
const resultPart = newText.substring(0, feedbackIndex).replace(/^(:|:)/, '').trim();
341+
const feedbackPart = newText.substring(feedbackIndex + 3).trim(); // '피드백:' 이후
342+
343+
setFeedbackResult(resultPart);
344+
setFeedbackContent(feedbackPart);
345+
}
346+
}
347+
348+
return newText;
349+
});
350+
},
351+
// onComplete: 스트리밍 완료
352+
() => {
353+
console.log('AI 피드백 스트리밍 완료');
354+
setIsStreamingComplete(true);
355+
setIsAiFeedbackLoading(false);
356+
357+
// 최종 결과 업데이트
358+
const updatedResult: AnswerResult = {
359+
isCorrect: streamingFeedback.startsWith('정답:'),
360+
answer: displayQuiz.answer || '',
361+
commentary: displayQuiz.commentary,
362+
aiFeedback: streamingFeedback
363+
};
364+
365+
setAnswerResult(updatedResult);
366+
},
367+
// onError: 에러 처리
368+
(error: Event) => {
369+
console.error('AI 피드백 스트리밍 실패:', error);
370+
371+
const errorResult: AnswerResult = {
372+
isCorrect: false,
373+
answer: displayQuiz.answer || '',
374+
commentary: displayQuiz.commentary,
375+
aiFeedback: 'AI 피드백을 가져오는데 실패했습니다.'
376+
};
377+
378+
setAnswerResult(errorResult);
379+
setIsAiFeedbackLoading(false);
380+
}
381+
);
264382

265-
setAnswerResult(updatedResult);
266-
setIsAiFeedbackLoading(false);
383+
// SSE 연결은 자동으로 완료되거나 에러 시 닫힘
267384
} catch (feedbackError) {
268385
console.error('AI 피드백 요청 실패:', feedbackError);
269386

@@ -569,81 +686,86 @@ const TodayQuizSection: React.FC = () => {
569686
{displayQuiz?.quizType === 'SUBJECTIVE' && (
570687
<div className="p-4 bg-blue-50 rounded-xl mb-6">
571688
<h4 className="text-lg font-bold text-gray-900 mb-2">AI 피드백</h4>
572-
{isAiFeedbackLoading ? (
689+
{isAiFeedbackLoading && !isCorrectFromAI && !feedbackResult && !feedbackContent ? (
573690
<div className="flex items-center space-x-3">
574691
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
575692
<p className="text-blue-700">AI가 피드백을 생성하고 있습니다...</p>
576693
</div>
577-
) : answerResult.aiFeedback ? (
694+
) : (
578695
<div>
579-
{(() => {
580-
// AI 피드백 텍스트 파싱
581-
const feedbackText = answerResult.aiFeedback;
582-
const resultMatch = feedbackText.match(/^(|):\s*(.*?)(?:\s*:\s*(.*))?$/);
583-
584-
if (resultMatch) {
585-
const [, resultType, resultDescription, feedbackContent] = resultMatch;
586-
const isCorrectFromText = resultType === '정답';
587-
588-
return (
589-
<div>
590-
{/* 정답/오답 결과 */}
591-
<div className={`inline-flex items-center rounded-full px-4 py-2 mb-3 ${
592-
isCorrectFromText ? 'bg-green-100' : 'bg-red-100'
593-
}`}>
594-
<span className={`text-sm font-bold ${
595-
isCorrectFromText ? 'text-green-700' : 'text-red-700'
596-
}`}>
597-
{isCorrectFromText ? '🎉 정답입니다!' : '❌ 틀렸습니다!'}
598-
</span>
599-
</div>
600-
601-
{/* 결과 설명 */}
602-
{resultDescription && (
603-
<div className={`p-3 rounded-lg mb-3 ${
604-
isCorrectFromText ? 'bg-green-50' : 'bg-red-50'
605-
}`}>
606-
<p className={`text-sm ${
607-
isCorrectFromText ? 'text-green-800' : 'text-red-800'
608-
}`}>
609-
<span className="font-semibold">{resultType}:</span> {resultDescription.trim()}
610-
</p>
611-
</div>
612-
)}
613-
614-
{/* 피드백 내용 */}
615-
{feedbackContent && (
616-
<div className="p-3 bg-blue-50 rounded-lg">
617-
<p className="text-blue-800 text-sm">
618-
<span className="font-semibold">피드백:</span> {feedbackContent.trim()}
619-
</p>
620-
</div>
621-
)}
622-
</div>
623-
);
624-
} else {
625-
// 파싱 실패 시 기본 형태로 표시
626-
return (
627-
<div>
628-
<div className={`inline-flex items-center rounded-full px-4 py-2 mb-3 ${
629-
answerResult.isCorrect ? 'bg-green-100' : 'bg-red-100'
630-
}`}>
631-
<span className={`text-sm font-bold ${
632-
answerResult.isCorrect ? 'text-green-700' : 'text-red-700'
633-
}`}>
634-
{answerResult.isCorrect ? '🎉 정답입니다!' : '❌ 틀렸습니다!'}
635-
</span>
636-
</div>
637-
<p className="text-blue-800 leading-relaxed text-sm">
638-
{answerResult.aiFeedback}
639-
</p>
640-
</div>
641-
);
642-
}
643-
})()}
696+
{/* 정답/오답 결과 배지 */}
697+
{isCorrectFromAI !== null && (
698+
<div className={`inline-flex items-center rounded-full px-4 py-2 mb-3 ${
699+
isCorrectFromAI ? 'bg-green-100' : 'bg-red-100'
700+
}`}>
701+
<span className={`text-sm font-bold ${
702+
isCorrectFromAI ? 'text-green-700' : 'text-red-700'
703+
}`}>
704+
{isCorrectFromAI ? '🎉 정답입니다!' : '❌ 틀렸습니다!'}
705+
</span>
706+
</div>
707+
)}
708+
709+
{/* 결과 설명 */}
710+
{feedbackResult && (
711+
<div className={`p-3 rounded-lg mb-3 transform transition-all duration-500 ease-in-out ${
712+
isCorrectFromAI ? 'bg-green-50' : 'bg-red-50'
713+
}`}>
714+
<p className={`text-sm leading-relaxed whitespace-pre-wrap break-words ${
715+
isCorrectFromAI ? 'text-green-800' : 'text-red-800'
716+
}`}>
717+
<span className="font-semibold">
718+
{isCorrectFromAI ? '정답: ' : '오답: '}
719+
</span>
720+
<span className="animate-fade-in-soft" style={{
721+
wordSpacing: '0.25em',
722+
letterSpacing: '0.02em',
723+
whiteSpace: 'pre-wrap',
724+
display: 'inline'
725+
}}>
726+
{displayedResult}
727+
</span>
728+
{(displayedResult.length < feedbackResult.length || (!isStreamingComplete && !feedbackContent)) && (
729+
<span className="animate-pulse text-blue-600 ml-1"></span>
730+
)}
731+
</p>
732+
</div>
733+
)}
734+
735+
{/* 피드백 내용 */}
736+
{feedbackContent && (
737+
<div className="p-3 bg-blue-50 rounded-lg transform transition-all duration-500 ease-in-out">
738+
<p className="text-blue-800 text-sm leading-relaxed whitespace-pre-wrap break-words">
739+
<span className="font-semibold">피드백: </span>
740+
<span className="animate-fade-in-soft" style={{
741+
wordSpacing: '0.25em',
742+
letterSpacing: '0.02em',
743+
whiteSpace: 'pre-wrap',
744+
display: 'inline'
745+
}}>
746+
{displayedFeedback}
747+
</span>
748+
{(displayedFeedback.length < feedbackContent.length || !isStreamingComplete) && (
749+
<span className="animate-pulse text-blue-600 ml-1"></span>
750+
)}
751+
</p>
752+
</div>
753+
)}
754+
755+
{/* 스트리밍 중인 전체 텍스트 (파싱되지 않은 경우) */}
756+
{streamingFeedback && !feedbackResult && !feedbackContent && streamingFeedback !== 'AI 응답 대기 중...' && (
757+
<div className="p-3 bg-blue-50 rounded-lg">
758+
<p className="text-blue-800 text-sm leading-relaxed whitespace-pre-wrap break-words">
759+
<span style={{ wordSpacing: '0.25em', letterSpacing: '0.02em' }}>
760+
{streamingFeedback}
761+
</span>
762+
{!isStreamingComplete && (
763+
<span className="animate-pulse text-blue-600 ml-1"></span>
764+
)}
765+
</p>
766+
</div>
767+
)}
644768
</div>
645-
) : (
646-
<p className="text-blue-700">AI 피드백을 불러오는 중...</p>
647769
)}
648770
</div>
649771
)}

0 commit comments

Comments
 (0)