Files
lms-web-fe/src/lms-renderer/features/workspace/useQaChat.ts
T
2026-07-15 13:41:00 +09:00

515 lines
18 KiB
TypeScript

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<string>
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<QaMode>('ai')
const [aiMessages, setAiMessages] = useState<QaChatMessage[]>(initialAiChatMessages)
const [aiConversations, setAiConversations] = useState<QaChatThread[]>([])
const [activeAiConversationId, setActiveAiConversationId] = useState<string | null>(null)
const [selectedAiConversationId, setSelectedAiConversationId] = useState<string | null>(null)
const [teacherMessages, setTeacherMessages] = useState<QaChatMessage[]>([])
const [teacherThreadStatus, setTeacherThreadStatus] = useState<ChatThreadResponse['status'] | null>(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<string | null>(null)
const [qaCollapseSignal, setQaCollapseSignal] = useState(0)
const workspaceChatScrollRef = useRef<HTMLDivElement | null>(null)
const aiAbortControllerRef = useRef<AbortController | null>(null)
const aiRevealCancelRef = useRef(false)
const expandedAiMessageIdsRef = useRef<Set<string>>(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 <T,>(path: string, options?: RequestInit): Promise<T> => {
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<T>
}
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<void> => {
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<string[]>((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<void> => {
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<void> => {
if (activeAiConversationId) {
await clearQaConversation()
return
}
setSelectedAiConversationId(null)
}
const deleteAiConversation = async (threadId: string): Promise<void> => {
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
}
}