515 lines
18 KiB
TypeScript
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
|
|
}
|
|
}
|