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