From f938b4cad67bab10c6f69d9acf9f53b9d9db8b6b Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Wed, 14 Jan 2026 10:35:15 +0900 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=EB=AC=B8=EB=B2=95=20=EA=B5=90?= =?UTF-8?q?=EC=A0=95=20=EC=B1=84=ED=8C=85=20=EC=8A=A4=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EB=B0=8D=20UI=20=EA=B5=AC=ED=98=84=20(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WritingPage에 WebSocket 스트리밍 통합 - ChatMessage에 타이핑 애니메이션 효과 추가 - 실시간 AI 응답 표시 기능 - 커서 깜빡임 애니메이션 적용 --- .../grammar/components/ChatMessage.jsx | 45 ++- src/domains/grammar/hooks/useGrammarStream.js | 146 +++++++++ src/domains/grammar/index.js | 4 + src/domains/grammar/pages/WritingPage.jsx | 151 +++++++--- .../grammar/services/grammarStreamService.js | 280 ++++++++++++++++++ 5 files changed, 574 insertions(+), 52 deletions(-) create mode 100644 src/domains/grammar/hooks/useGrammarStream.js create mode 100644 src/domains/grammar/services/grammarStreamService.js diff --git a/src/domains/grammar/components/ChatMessage.jsx b/src/domains/grammar/components/ChatMessage.jsx index 22454e8..46e7774 100644 --- a/src/domains/grammar/components/ChatMessage.jsx +++ b/src/domains/grammar/components/ChatMessage.jsx @@ -1,4 +1,4 @@ -import { Box, Typography, Chip, Collapse, IconButton } from '@mui/material' +import { Box, Typography, Chip, Collapse, IconButton, keyframes } from '@mui/material' import { SmartToy as AiIcon, Person as PersonIcon, @@ -14,7 +14,18 @@ import { getScoreColor, } from '../constants/grammarConstants' -export default function ChatMessage({ message, isUser = false }) { +// 커서 깜빡임 애니메이션 +const blink = keyframes` + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +` + +export default function ChatMessage({ + message, + isUser = false, + isStreaming = false, + streamingText = '', +}) { const { t, isKorean } = useSettings() const [showDetails, setShowDetails] = useState(false) @@ -30,6 +41,9 @@ export default function ChatMessage({ message, isUser = false }) { const hasErrors = errors && errors.length > 0 const scoreColor = grammarScore ? getScoreColor(grammarScore) : '#059669' + // 스트리밍 중일 때 표시할 텍스트 + const displayText = isStreaming ? streamingText : (aiResponse || content) + if (isUser) { return ( - - {aiResponse || content} + + {displayText} + {/* 스트리밍 커서 */} + {isStreaming && ( + + )} - {/* Conversation Tip */} - {conversationTip && ( + {/* Conversation Tip - 스트리밍 완료 후에만 표시 */} + {!isStreaming && conversationTip && ( { + try { + setError(null) + await grammarStreamService.connect(token) + setIsConnected(true) + isConnectedRef.current = true + } catch (err) { + console.error('[useGrammarStream] Connection error:', err) + setError('연결에 실패했습니다') + setIsConnected(false) + isConnectedRef.current = false + } + }, []) + + /** + * 연결 종료 + */ + const disconnect = useCallback(() => { + grammarStreamService.disconnect() + setIsConnected(false) + isConnectedRef.current = false + }, []) + + /** + * 스트리밍 메시지 전송 + */ + const sendMessage = useCallback((message, options = {}) => { + // 상태 초기화 + setIsStreaming(true) + setStreamingText('') + setResult(null) + setError(null) + + // 콜백 설정 + grammarStreamService.setCallbacks({ + onStart: (data) => { + console.log('[useGrammarStream] Stream started:', data.sessionId) + setSessionId(data.sessionId) + }, + + onToken: (data) => { + setStreamingText((prev) => prev + data.token) + }, + + onComplete: (data) => { + console.log('[useGrammarStream] Stream complete') + setResult(data) + setIsStreaming(false) + }, + + onError: (data) => { + console.error('[useGrammarStream] Stream error:', data.message) + setError(data.message || '스트리밍 중 오류가 발생했습니다') + setIsStreaming(false) + }, + + onClose: (event) => { + if (event.code !== 1000) { + console.log('[useGrammarStream] Unexpected close:', event.code) + } + }, + }) + + // 메시지 전송 + grammarStreamService.send(message, options) + }, []) + + /** + * 스트리밍 취소 + */ + const cancelStream = useCallback(() => { + setIsStreaming(false) + setStreamingText('') + // 연결은 유지하고 스트리밍만 취소 + }, []) + + /** + * 상태 초기화 + */ + const reset = useCallback(() => { + setIsStreaming(false) + setStreamingText('') + setResult(null) + setError(null) + setSessionId(null) + }, []) + + /** + * 컴포넌트 언마운트 시 정리 + */ + useEffect(() => { + return () => { + if (isConnectedRef.current) { + grammarStreamService.disconnect() + } + } + }, []) + + return { + // 상태 + isStreaming, + isConnected, + streamingText, + sessionId, + result, + error, + + // 액션 + connect, + disconnect, + sendMessage, + cancelStream, + reset, + + // 편의 getter + grammarCheck: result?.grammarCheck || null, + aiResponse: result?.aiResponse || streamingText, + conversationTip: result?.conversationTip || null, + } +} + +export default useGrammarStream diff --git a/src/domains/grammar/index.js b/src/domains/grammar/index.js index c9cf947..7530220 100644 --- a/src/domains/grammar/index.js +++ b/src/domains/grammar/index.js @@ -10,6 +10,10 @@ export { default as SessionSidebar } from './components/SessionSidebar' // Services export * from './services/grammarService' +export * from './services/grammarStreamService' + +// Hooks +export { useGrammarStream } from './hooks/useGrammarStream' // Constants export * from './constants/grammarConstants' diff --git a/src/domains/grammar/pages/WritingPage.jsx b/src/domains/grammar/pages/WritingPage.jsx index 7ea3142..6dedf5c 100644 --- a/src/domains/grammar/pages/WritingPage.jsx +++ b/src/domains/grammar/pages/WritingPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { Box, Typography, @@ -18,6 +18,7 @@ import ChatMessage from '../components/ChatMessage' import ChatInput from '../components/ChatInput' import SessionSidebar from '../components/SessionSidebar' import { conversationService, sessionService } from '../services/grammarService' +import { useGrammarStream } from '../hooks/useGrammarStream' import { GRAMMAR_LEVELS } from '../constants/grammarConstants' export default function WritingPage() { @@ -34,17 +35,87 @@ export default function WritingPage() { const [sessionsLoading, setSessionsLoading] = useState(true) const [error, setError] = useState(null) + // 스트리밍 상태 + const [streamingMessageId, setStreamingMessageId] = useState(null) + const messagesEndRef = useRef(null) + // WebSocket 스트리밍 훅 + const { + isStreaming, + streamingText, + result: streamResult, + error: streamError, + sendMessage: sendStreamMessage, + connect: connectStream, + grammarCheck: streamGrammarCheck, + conversationTip: streamConversationTip, + } = useGrammarStream() + + // 컴포넌트 마운트 시 WebSocket 연결 + useEffect(() => { + const token = localStorage.getItem('accessToken') + connectStream(token) + }, [connectStream]) + // Load sessions on mount useEffect(() => { loadSessions() }, []) - // Scroll to bottom when messages change + // Scroll to bottom when messages change or streaming text updates useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) + }, [messages, streamingText]) + + // 스트리밍 완료 시 메시지 업데이트 + useEffect(() => { + if (streamResult && streamingMessageId) { + // 사용자 메시지에 문법 검사 결과 추가 + setMessages((prev) => + prev.map((msg) => { + if (msg.id === streamingMessageId) { + return { + ...msg, + correctedContent: streamResult.grammarCheck?.correctedSentence, + grammarScore: streamResult.grammarCheck?.score, + errors: streamResult.grammarCheck?.errors || [], + } + } + return msg + }) + ) + + // AI 응답 메시지 추가 + const aiMessage = { + id: `ai-${Date.now()}`, + content: streamResult.aiResponse, + aiResponse: streamResult.aiResponse, + conversationTip: streamResult.conversationTip, + isUser: false, + createdAt: new Date().toISOString(), + } + setMessages((prev) => [...prev, aiMessage]) + + // 세션 ID 업데이트 + if (!currentSessionId && streamResult.sessionId) { + setCurrentSessionId(streamResult.sessionId) + loadSessions() + } + + setStreamingMessageId(null) + setLoading(false) + } + }, [streamResult, streamingMessageId, currentSessionId]) + + // 스트리밍 에러 처리 + useEffect(() => { + if (streamError) { + setError(streamError) + setStreamingMessageId(null) + setLoading(false) + } + }, [streamError]) const loadSessions = async () => { try { @@ -114,63 +185,36 @@ export default function WritingPage() { } } - const handleSendMessage = async (message) => { + // 스트리밍 모드로 메시지 전송 + const handleSendMessage = useCallback(async (message) => { try { setLoading(true) setError(null) - // Add user message immediately + // 사용자 메시지 즉시 추가 + const userMessageId = `user-${Date.now()}` const userMessage = { - id: `temp-${Date.now()}`, + id: userMessageId, content: message, isUser: true, createdAt: new Date().toISOString(), } setMessages((prev) => [...prev, userMessage]) + setStreamingMessageId(userMessageId) - // Send to API - const response = await conversationService.send(message, currentSessionId, level) - - // Update session ID if new - if (!currentSessionId && response.sessionId) { - setCurrentSessionId(response.sessionId) - // Reload sessions to show new one - loadSessions() - } - - // Update user message with grammar check results - setMessages((prev) => - prev.map((msg) => - msg.id === userMessage.id - ? { - ...msg, - correctedContent: response.grammarCheck?.correctedSentence, - grammarScore: response.grammarCheck?.score, - errors: response.grammarCheck?.errors || [], - } - : msg - ) - ) - - // Add AI response - const aiMessage = { - id: `ai-${Date.now()}`, - content: response.aiResponse, - aiResponse: response.aiResponse, - conversationTip: response.conversationTip, - isUser: false, - createdAt: new Date().toISOString(), - } - setMessages((prev) => [...prev, aiMessage]) + // WebSocket 스트리밍 시작 + sendStreamMessage(message, { + level, + sessionId: currentSessionId, + }) } catch (err) { console.error('Failed to send message:', err) setError(isKorean ? '메시지 전송에 실패했습니다' : 'Failed to send message') - // Remove temporary user message on error - setMessages((prev) => prev.filter((msg) => !msg.id.startsWith('temp-'))) - } finally { + setMessages((prev) => prev.filter((msg) => !msg.id.startsWith('user-'))) + setStreamingMessageId(null) setLoading(false) } - } + }, [level, currentSessionId, sendStreamMessage, isKorean]) const sidebarContent = ( )} - {messages.length === 0 ? ( + {messages.length === 0 && !isStreaming ? ( ( ))} + + {/* 스트리밍 중인 AI 응답 */} + {isStreaming && streamingText && ( + + )} +
)} @@ -354,7 +413,7 @@ export default function WritingPage() { {/* Input Area */} diff --git a/src/domains/grammar/services/grammarStreamService.js b/src/domains/grammar/services/grammarStreamService.js new file mode 100644 index 0000000..faf5bf5 --- /dev/null +++ b/src/domains/grammar/services/grammarStreamService.js @@ -0,0 +1,280 @@ +/** + * Grammar WebSocket Streaming Service + * 실시간 토큰 단위 AI 응답을 위한 WebSocket 서비스 + */ + +// WebSocket URL - 환경변수에서 가져오거나 기본값 사용 +const WS_URL = import.meta.env.VITE_GRAMMAR_WS_URL || + 'wss://placeholder.execute-api.ap-northeast-2.amazonaws.com/dev' + +// Mock 모드 (WebSocket 서버가 없을 때 테스트용) +const USE_MOCK = true +const MOCK_DELAY = 50 // 토큰 간 딜레이 (ms) + +/** + * Mock 스트리밍 응답 생성 + */ +const createMockStream = (message, callbacks) => { + const { onStart, onToken, onComplete, onError } = callbacks + + const sessionId = `session-${Date.now()}` + const mockAiResponse = "That's an interesting sentence! I noticed a few grammar points we can work on together. Keep practicing and you'll improve quickly!" + const mockGrammarCheck = { + originalSentence: message, + correctedSentence: message.replace(/go /g, 'went ').replace(/goed/g, 'went'), + score: Math.floor(Math.random() * 30) + 70, + isCorrect: !message.toLowerCase().includes('go '), + errors: message.toLowerCase().includes('go ') ? [ + { + type: 'VERB_TENSE', + original: 'go', + corrected: 'went', + explanation: '과거 시제를 사용해야 합니다. "go"의 과거형은 "went"입니다.', + startIndex: message.toLowerCase().indexOf('go '), + endIndex: message.toLowerCase().indexOf('go ') + 2, + } + ] : [], + feedback: message.toLowerCase().includes('go ') + ? '동사 시제에 주의하세요! 과거의 일을 말할 때는 과거 시제를 사용합니다.' + : '훌륭한 문장입니다! 문법적으로 정확해요.', + } + + // Start event + setTimeout(() => { + onStart?.({ type: 'start', sessionId }) + }, 100) + + // Token events (simulate streaming) + const tokens = mockAiResponse.split(' ') + let tokenIndex = 0 + + const streamTokens = () => { + if (tokenIndex < tokens.length) { + const token = tokens[tokenIndex] + (tokenIndex < tokens.length - 1 ? ' ' : '') + onToken?.({ type: 'token', token }) + tokenIndex++ + setTimeout(streamTokens, MOCK_DELAY + Math.random() * 30) + } else { + // Complete event + setTimeout(() => { + onComplete?.({ + type: 'complete', + sessionId, + grammarCheck: mockGrammarCheck, + aiResponse: mockAiResponse, + conversationTip: 'Try using more descriptive words in your sentences!', + }) + }, 100) + } + } + + setTimeout(streamTokens, 300) + + // Return mock close function + return { + close: () => {}, + readyState: 1, + } +} + +/** + * Grammar Streaming 클래스 + */ +class GrammarStreamConnection { + constructor() { + this.ws = null + this.callbacks = {} + this.isConnected = false + this.reconnectAttempts = 0 + this.maxReconnectAttempts = 3 + } + + /** + * WebSocket 연결 + */ + connect(token) { + return new Promise((resolve, reject) => { + if (USE_MOCK) { + this.isConnected = true + resolve() + return + } + + try { + const url = token ? `${WS_URL}?token=${token}` : WS_URL + this.ws = new WebSocket(url) + + this.ws.onopen = () => { + this.isConnected = true + this.reconnectAttempts = 0 + console.log('[GrammarStream] Connected') + resolve() + } + + this.ws.onclose = (event) => { + this.isConnected = false + console.log('[GrammarStream] Disconnected:', event.code) + this.callbacks.onClose?.(event) + } + + this.ws.onerror = (error) => { + console.error('[GrammarStream] Error:', error) + this.callbacks.onError?.({ type: 'error', message: 'WebSocket connection error' }) + reject(error) + } + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + this.handleMessage(data) + } catch (e) { + console.error('[GrammarStream] Parse error:', e) + } + } + } catch (error) { + reject(error) + } + }) + } + + /** + * 메시지 핸들링 + */ + handleMessage(data) { + switch (data.type) { + case 'start': + this.callbacks.onStart?.(data) + break + case 'token': + this.callbacks.onToken?.(data) + break + case 'complete': + this.callbacks.onComplete?.(data) + break + case 'error': + this.callbacks.onError?.(data) + break + default: + console.log('[GrammarStream] Unknown message type:', data.type) + } + } + + /** + * 스트리밍 요청 전송 + */ + send(message, options = {}) { + const { level = 'BEGINNER', sessionId = null } = options + + if (USE_MOCK) { + return createMockStream(message, this.callbacks) + } + + if (!this.isConnected || !this.ws) { + this.callbacks.onError?.({ type: 'error', message: 'Not connected' }) + return + } + + const request = { + action: 'grammarStreaming', + message, + level, + ...(sessionId && { sessionId }), + } + + this.ws.send(JSON.stringify(request)) + } + + /** + * 콜백 설정 + */ + setCallbacks(callbacks) { + this.callbacks = callbacks + } + + /** + * 연결 종료 + */ + disconnect() { + if (this.ws) { + this.ws.close(1000, 'Client disconnect') + this.ws = null + } + this.isConnected = false + } + + /** + * 연결 상태 확인 + */ + getConnectionState() { + if (USE_MOCK) return 'connected' + if (!this.ws) return 'disconnected' + + switch (this.ws.readyState) { + case WebSocket.CONNECTING: return 'connecting' + case WebSocket.OPEN: return 'connected' + case WebSocket.CLOSING: return 'closing' + case WebSocket.CLOSED: return 'disconnected' + default: return 'unknown' + } + } +} + +// 싱글톤 인스턴스 +let streamInstance = null + +/** + * Grammar Stream 서비스 + */ +export const grammarStreamService = { + /** + * 인스턴스 가져오기 (싱글톤) + */ + getInstance() { + if (!streamInstance) { + streamInstance = new GrammarStreamConnection() + } + return streamInstance + }, + + /** + * 연결 + */ + async connect(token) { + const instance = this.getInstance() + return instance.connect(token) + }, + + /** + * 스트리밍 요청 + */ + send(message, options = {}) { + const instance = this.getInstance() + return instance.send(message, options) + }, + + /** + * 콜백 설정 + */ + setCallbacks(callbacks) { + const instance = this.getInstance() + instance.setCallbacks(callbacks) + }, + + /** + * 연결 종료 + */ + disconnect() { + const instance = this.getInstance() + instance.disconnect() + }, + + /** + * 연결 상태 + */ + getConnectionState() { + const instance = this.getInstance() + return instance.getConnectionState() + }, +} + +export default grammarStreamService