import { useEffect, useRef, useState } from 'react' import { io } from 'socket.io-client' import type { Socket } from 'socket.io-client' export type QaMode = 'ai' | 'teacher' export type QaChatMessage = { id: string role: 'user' | 'assistant' content: string createdAt?: string senderAvatarUrl?: string | null isTyping?: boolean keepExpanded?: boolean status?: 'ACTIVE' | 'CANCELED' } export type QaChatThread = { id: string title: string status: 'OPEN' | 'WAITING_INSTRUCTOR' | 'RESOLVED' | 'ARCHIVED' lastMessageAt?: string messages: QaChatMessage[] } type ChatThreadResponse = { id: string type: 'AI' | 'INSTRUCTOR' | 'MIXED' status: QaChatThread['status'] title: string lastMessageAt?: string messages: Array<{ id: string senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM' senderName?: string | null senderAvatarUrl?: string | null content: string status?: 'ACTIVE' | 'CANCELED' canceledAt?: string | null createdAt: string }> } type ChatNotificationPayload = { type: 'chat.message.created' threadId: string messageId: string senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM' preview: string createdAt: string } type UseQaChatOptions = { accessToken: string weekNumber: number globalScope?: boolean apiBaseUrl: string socketBaseUrl: string socketPath: string getFreshAccessToken: () => Promise onTitlebarNotice: (title: string, tone?: 'info' | 'warning' | 'error', addToList?: boolean) => void } const initialAiChatMessages: QaChatMessage[] = [ { id: 'welcome', role: 'assistant', content: '안녕하세요! SLAM 실습을 도와드리겠습니다. 궁금한 점을 물어보세요.' } ] const qaVisibleMessageLimit = 20 export function useQaChat({ accessToken, weekNumber, globalScope = false, apiBaseUrl, socketBaseUrl, socketPath, getFreshAccessToken, onTitlebarNotice }: UseQaChatOptions) { const [qaMode, setQaMode] = useState('ai') const [aiMessages, setAiMessages] = useState(initialAiChatMessages) const [aiConversations, setAiConversations] = useState([]) const [activeAiConversationId, setActiveAiConversationId] = useState(null) const [selectedAiConversationId, setSelectedAiConversationId] = useState(null) const [teacherMessages, setTeacherMessages] = useState([]) const [teacherThreadStatus, setTeacherThreadStatus] = useState(null) const [chatRefreshSignal, setChatRefreshSignal] = useState(0) const [teacherChatFlash, setTeacherChatFlash] = useState(false) const [aiMessagesLoading, setAiMessagesLoading] = useState(Boolean(accessToken && (globalScope || weekNumber >= 1))) const [teacherMessagesLoading, setTeacherMessagesLoading] = useState(Boolean(accessToken && (globalScope || weekNumber >= 1))) const [aiPending, setAiPending] = useState(false) const [aiTypingMessageId, setAiTypingMessageId] = useState(null) const [qaCollapseSignal, setQaCollapseSignal] = useState(0) const workspaceChatScrollRef = useRef(null) const aiAbortControllerRef = useRef(null) const aiRevealCancelRef = useRef(false) const expandedAiMessageIdsRef = useRef>(new Set()) const validChatWeek = globalScope ? null : Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : null const chatAvailable = globalScope || validChatWeek !== null const chatWeekQuery = validChatWeek ? `&week=${validChatWeek}` : '' const requestChatApi = async (path: string, options?: RequestInit): Promise => { const requestAccessToken = accessToken ? await getFreshAccessToken() : null const response = await fetch(`${apiBaseUrl}${path}`, { ...options, headers: { ...(options?.body ? { 'Content-Type': 'application/json' } : {}), ...(requestAccessToken ? { Authorization: `Bearer ${requestAccessToken}` } : {}), ...options?.headers } }) if (!response.ok) { const payload = await response.json().catch(() => null) as { message?: string } | null throw new Error(payload?.message ?? '질의응답 요청을 처리하지 못했습니다.') } return response.json() as Promise } const toQaMessages = (messages: ChatThreadResponse['messages']): QaChatMessage[] => messages.map((message) => ({ id: message.id, role: message.senderType === 'STUDENT' ? 'user' : 'assistant', content: message.content, createdAt: message.createdAt, senderAvatarUrl: message.senderAvatarUrl ?? null, keepExpanded: message.senderType === 'AI' && expandedAiMessageIdsRef.current.has(message.id), status: message.status ?? 'ACTIVE' })) useEffect(() => { if (!accessToken) return const socket: Socket = io(socketBaseUrl, { auth: { token: accessToken }, path: socketPath, transports: ['websocket', 'polling'] }) const onMessageCreated = (payload: ChatNotificationPayload): void => { if (payload.senderType === 'INSTRUCTOR') { setChatRefreshSignal((value) => value + 1) onTitlebarNotice('강사 답변이 도착했습니다', 'info', true) setTeacherChatFlash(true) } } socket.on('chat.message.created', onMessageCreated) return () => { socket.off('chat.message.created', onMessageCreated) socket.disconnect() } }, [accessToken, onTitlebarNotice, socketBaseUrl, socketPath]) useEffect(() => { if (!accessToken || !chatAvailable) { setAiMessagesLoading(false) setTeacherMessagesLoading(false) return } let cancelled = false setAiMessagesLoading(true) requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=ai${chatWeekQuery}&includeArchived=true`) .then((payload) => { if (cancelled) return const conversations = payload.threads.map((thread) => ({ id: thread.id, title: thread.title, status: thread.status, lastMessageAt: thread.lastMessageAt, messages: toQaMessages(thread.messages) })) const activeConversation = conversations.find((thread) => thread.status !== 'ARCHIVED') setAiConversations(conversations) setActiveAiConversationId(activeConversation?.id ?? null) setSelectedAiConversationId(null) const messages = activeConversation?.messages ?? [] setAiMessages(messages.length ? messages : initialAiChatMessages) }) .catch(() => { if (!cancelled) setAiMessages(initialAiChatMessages) }) .finally(() => { if (!cancelled) setAiMessagesLoading(false) }) setTeacherMessagesLoading(true) requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=teacher${chatWeekQuery}`) .then((payload) => { if (cancelled) return const thread = payload.threads[0] setTeacherThreadStatus(thread?.status ?? null) setTeacherMessages(thread ? toQaMessages(thread.messages) : []) }) .catch(() => { if (!cancelled) { setTeacherThreadStatus(null) setTeacherMessages([]) } }) .finally(() => { if (!cancelled) setTeacherMessagesLoading(false) }) return () => { cancelled = true } }, [accessToken, chatAvailable, chatRefreshSignal, chatWeekQuery]) useEffect(() => { const target = workspaceChatScrollRef.current if (!target) return const currentLoading = qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading if (currentLoading) return const scrollToBottom = (): void => { target.scrollTo({ top: target.scrollHeight, behavior: 'auto' }) } scrollToBottom() const frame = window.requestAnimationFrame(scrollToBottom) const secondFrame = window.requestAnimationFrame(() => window.requestAnimationFrame(scrollToBottom)) const timeoutId = window.setTimeout(scrollToBottom, 120) const lateTimeoutId = window.setTimeout(scrollToBottom, 260) return () => { window.cancelAnimationFrame(frame) window.cancelAnimationFrame(secondFrame) window.clearTimeout(timeoutId) window.clearTimeout(lateTimeoutId) } }, [aiMessages, aiMessagesLoading, aiPending, qaMode, teacherMessages.length, teacherMessagesLoading, teacherThreadStatus, weekNumber]) const stopAiResponse = (): void => { aiRevealCancelRef.current = true aiAbortControllerRef.current?.abort() aiAbortControllerRef.current = null if (aiTypingMessageId) { const currentMessage = aiMessages.find((message) => message.id === aiTypingMessageId) setAiMessages((messages) => messages.map((message) => ( message.id === aiTypingMessageId ? { ...message, isTyping: false, status: 'CANCELED' } : message ))) setAiTypingMessageId(null) void requestChatApi<{ message: ChatThreadResponse['messages'][number] }>(`/chat/ai/messages/${aiTypingMessageId}/cancel`, { method: 'POST', body: JSON.stringify({ content: currentMessage?.content ?? '' }) }).catch(() => {}) return } if (aiPending) { setAiPending(false) void requestChatApi<{ message: ChatThreadResponse['messages'][number] | null }>('/chat/ai/cancel-active', { method: 'POST', body: JSON.stringify(validChatWeek ? { week: validChatWeek } : {}) }).catch(() => {}) setAiMessages((messages) => [ ...messages, { id: `assistant-canceled-${Date.now()}`, role: 'assistant', content: '응답이 중단되었습니다.', status: 'CANCELED' } ]) } } const sendQaMessage = async (rawContent: string, options?: { contextWeek?: number }): Promise => { const content = rawContent.trim() if (!content || aiPending || aiTypingMessageId || !chatAvailable) return setQaCollapseSignal((value) => value + 1) if (qaMode !== 'ai') { const userMessage: QaChatMessage = { id: `teacher-user-${Date.now()}`, role: 'user', content } setTeacherMessages((messages) => [...messages, userMessage]) setTeacherThreadStatus('WAITING_INSTRUCTOR') try { const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string } }>('/chat/messages', { method: 'POST', body: JSON.stringify({ mode: 'teacher', content, ...(validChatWeek ? { week: validChatWeek } : {}) }) }) setTeacherMessages((messages) => messages.map((message) => ( message.id === userMessage.id ? { ...message, id: response.message.id, createdAt: response.message.createdAt } : message ))) } catch (error) { const message = error instanceof Error ? error.message : '강사 문의를 저장하지 못했습니다.' setTeacherMessages((messages) => [ ...messages, { id: `teacher-error-${Date.now()}`, role: 'assistant', content: message } ]) } return } const userMessage: QaChatMessage = { id: `user-${Date.now()}`, role: 'user', content, createdAt: new Date().toISOString() } const nextMessages = [...aiMessages, userMessage] setAiMessages(nextMessages) setAiPending(true) aiRevealCancelRef.current = false const abortController = new AbortController() aiAbortControllerRef.current = abortController try { const response = await requestChatApi<{ threadId: string; threadTitle?: string | null; message: { id: string; content: string; createdAt: string }; content: string; canceled?: boolean }>('/chat/ai/respond', { method: 'POST', body: JSON.stringify({ content, ...(validChatWeek ? { week: validChatWeek } : {}), ...(options?.contextWeek ? { contextWeek: options.contextWeek } : {}) }), signal: abortController.signal }) aiAbortControllerRef.current = null if (aiRevealCancelRef.current || response.canceled) return const fullContent = response.content ?? '' setActiveAiConversationId(response.threadId) setAiConversations((threads) => { const assistantMessage: QaChatMessage = { id: response.message.id, role: 'assistant', content: fullContent, createdAt: response.message.createdAt, keepExpanded: true } const nextThread: QaChatThread = { id: response.threadId, title: response.threadTitle ?? threads.find((thread) => thread.id === response.threadId)?.title ?? content.slice(0, 42), status: 'OPEN', lastMessageAt: response.message.createdAt, messages: [...nextMessages, assistantMessage] } return [nextThread, ...threads.filter((thread) => thread.id !== response.threadId)] }) expandedAiMessageIdsRef.current.add(response.message.id) setAiPending(false) setAiTypingMessageId(response.message.id) setAiMessages((messages) => [ ...messages, { id: response.message.id, role: 'assistant', content: '', createdAt: response.message.createdAt, isTyping: true, keepExpanded: true } ]) const contentLines = fullContent.split('\n') const revealChunks = contentLines.length > 2 ? contentLines.reduce((chunks, _line, index) => { if ((index + 1) % 3 === 0 || index === contentLines.length - 1) { chunks.push(contentLines.slice(0, index + 1).join('\n')) } return chunks }, []) : fullContent.match(/.{1,260}(?:\s|$)/g)?.map((_chunk, index, chunks) => chunks.slice(0, index + 1).join('').trimEnd()) ?? [fullContent] for (const nextContent of revealChunks) { if (aiRevealCancelRef.current) break await new Promise((resolve) => window.setTimeout(resolve, 90)) if (aiRevealCancelRef.current) break setAiMessages((messages) => messages.map((message) => ( message.id === response.message.id ? { ...message, content: nextContent, isTyping: nextContent.length < fullContent.length } : message ))) } if (!aiRevealCancelRef.current) { setAiTypingMessageId(null) } } catch (error) { aiAbortControllerRef.current = null if (aiRevealCancelRef.current || abortController.signal.aborted || (error instanceof DOMException && error.name === 'AbortError')) { setAiTypingMessageId(null) return } const message = error instanceof Error ? error.message : 'AI 응답을 가져오지 못했습니다.' setAiMessages((messages) => [ ...messages, { id: `assistant-error-${Date.now()}`, role: 'assistant', content: message } ]) setAiTypingMessageId(null) } finally { setAiPending(false) } } const clearQaConversation = async (): Promise => { if (!chatAvailable) return if (qaMode === 'ai') { aiRevealCancelRef.current = true aiAbortControllerRef.current?.abort() aiAbortControllerRef.current = null setAiPending(false) setAiTypingMessageId(null) } await requestChatApi<{ deletedCount: number }>(`/chat/threads?mode=${qaMode}${chatWeekQuery}`, { method: 'DELETE' }) if (qaMode === 'ai') { if (activeAiConversationId) { setAiConversations((threads) => threads.map((thread) => ( thread.id === activeAiConversationId ? { ...thread, status: 'ARCHIVED', messages: aiMessages } : thread ))) } setActiveAiConversationId(null) setSelectedAiConversationId(null) setAiMessages(initialAiChatMessages) return } setTeacherMessages([]) setTeacherThreadStatus(null) setTeacherChatFlash(false) } const setMode = (mode: QaMode): void => { setQaMode(mode) if (mode === 'teacher') setTeacherChatFlash(false) } const selectedAiConversation = selectedAiConversationId ? aiConversations.find((thread) => thread.id === selectedAiConversationId) ?? null : null const selectAiConversation = (threadId: string | null): void => { setSelectedAiConversationId(threadId) } const startNewAiConversation = async (): Promise => { if (activeAiConversationId) { await clearQaConversation() return } setSelectedAiConversationId(null) } const deleteAiConversation = async (threadId: string): Promise => { await requestChatApi<{ id: string }>(`/chat/threads/${threadId}`, { method: 'DELETE' }) setAiConversations((threads) => threads.filter((thread) => thread.id !== threadId)) if (selectedAiConversationId === threadId) { setSelectedAiConversationId(null) } } return { qaMode, setQaMode: setMode, teacherChatFlash, setTeacherChatFlash, aiPending, aiTypingMessageId, teacherThreadStatus, qaCollapseSignal, qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading, visibleAiMessages: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit), displayedAiMessages: selectedAiConversation ? selectedAiConversation.messages : aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit), aiConversationHistory: aiConversations.filter((thread) => thread.status === 'ARCHIVED'), activeAiConversation: activeAiConversationId ? aiConversations.find((thread) => thread.id === activeAiConversationId) ?? null : null, selectedAiConversationId, viewingAiHistory: Boolean(selectedAiConversation), selectAiConversation, startNewAiConversation, deleteAiConversation, visibleTeacherMessages: teacherMessages.slice(-qaVisibleMessageLimit), validChatWeek, chatAvailable, workspaceChatScrollRef, stopAiResponse, sendQaMessage, clearQaConversation } }