feat: add LMS web client
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user