@@ -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// 임시 데이터
5145const 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