Initial import
This commit is contained in:
403
src/app/main/chatV2/hooks/useConversationComposerController.ts
Normal file
403
src/app/main/chatV2/hooks/useConversationComposerController.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { useCallback } from 'react';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
||||
|
||||
type PendingChatRequest = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
text: string;
|
||||
mode: 'queue' | 'direct';
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
retryCount: number;
|
||||
failed: boolean;
|
||||
};
|
||||
|
||||
type PendingContextConfirm = {
|
||||
mode: 'queue' | 'direct';
|
||||
text: string;
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
includedContextCount: number;
|
||||
omittedContextCount: number;
|
||||
};
|
||||
|
||||
type SelectedChatType = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isTemplate: boolean;
|
||||
} | null;
|
||||
|
||||
type RecentContextSummary = {
|
||||
includedCount: number;
|
||||
omittedCount: number;
|
||||
};
|
||||
|
||||
type UseConversationComposerControllerOptions = {
|
||||
activeSessionId: string;
|
||||
appConfigChat: {
|
||||
maxContextMessages: number;
|
||||
maxContextChars: number;
|
||||
};
|
||||
draft: string;
|
||||
composerAttachments: ChatComposerAttachment[];
|
||||
isComposerAttachmentUploading: boolean;
|
||||
selectedChatType: SelectedChatType;
|
||||
socketRef: { current: WebSocket | null };
|
||||
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
|
||||
messagesRef: { current: ChatMessage[] };
|
||||
pendingRequestsRef: { current: PendingChatRequest[] };
|
||||
shouldStickToBottomRef: { current: boolean };
|
||||
setDraft: (value: string) => void;
|
||||
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
|
||||
setIsComposerAttachmentUploading: (value: boolean) => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
||||
setActiveSystemStatus: (value: string | null) => void;
|
||||
setIsSystemStatusPending: (value: boolean) => void;
|
||||
setShowScrollToBottom: (value: boolean) => void;
|
||||
setPendingContextConfirm: (value: PendingContextConfirm | null) => void;
|
||||
setStoredChatSessionLastTypeId: (sessionId: string, chatTypeId: string) => void;
|
||||
upsertRequestItem: (request: ChatConversationRequest) => void;
|
||||
syncConversationPreviewForRequest: (sessionId: string, text: string, requestedAt?: string) => void;
|
||||
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
|
||||
createLocalMessage: (text: string) => ChatMessage;
|
||||
createChatMessage: (author: 'user' | 'codex' | 'system', text: string, requestId?: string | null) => ChatMessage;
|
||||
createActivityLogPlaceholder: (requestId: string, lines: string[]) => ChatMessage | null;
|
||||
buildOutgoingMessageText: (text: string, attachments: ChatComposerAttachment[]) => string;
|
||||
summarizeRecentContext: (messages: ChatMessage[], maxMessages: number, maxChars: number) => RecentContextSummary;
|
||||
mergeComposerAttachments: (
|
||||
previous: ChatComposerAttachment[],
|
||||
next: ChatComposerAttachment[],
|
||||
) => ChatComposerAttachment[];
|
||||
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
|
||||
scrollViewportToBottom: () => void;
|
||||
};
|
||||
|
||||
export function useConversationComposerController({
|
||||
activeSessionId,
|
||||
appConfigChat,
|
||||
draft,
|
||||
composerAttachments,
|
||||
isComposerAttachmentUploading,
|
||||
selectedChatType,
|
||||
socketRef,
|
||||
composerRef,
|
||||
messagesRef,
|
||||
pendingRequestsRef,
|
||||
shouldStickToBottomRef,
|
||||
setDraft,
|
||||
setComposerAttachments,
|
||||
setIsComposerAttachmentUploading,
|
||||
setMessages,
|
||||
setActiveSystemStatus,
|
||||
setIsSystemStatusPending,
|
||||
setShowScrollToBottom,
|
||||
setPendingContextConfirm,
|
||||
setStoredChatSessionLastTypeId,
|
||||
upsertRequestItem,
|
||||
syncConversationPreviewForRequest,
|
||||
updatePendingMessageStatus,
|
||||
createLocalMessage,
|
||||
createChatMessage,
|
||||
createActivityLogPlaceholder,
|
||||
buildOutgoingMessageText,
|
||||
summarizeRecentContext,
|
||||
mergeComposerAttachments,
|
||||
sendChatRequest,
|
||||
scrollViewportToBottom,
|
||||
}: UseConversationComposerControllerOptions) {
|
||||
const handleComposerFilesPicked = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (files.length === 0 || isComposerAttachmentUploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsComposerAttachmentUploading(true);
|
||||
const uploadResults = await Promise.allSettled(
|
||||
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
|
||||
);
|
||||
const uploadedItems: ChatComposerAttachment[] = [];
|
||||
const failedFileNames: string[] = [];
|
||||
|
||||
uploadResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
uploadedItems.push(result.value);
|
||||
return;
|
||||
}
|
||||
|
||||
failedFileNames.push(files[index]?.name || `파일 ${index + 1}`);
|
||||
});
|
||||
|
||||
if (uploadedItems.length > 0) {
|
||||
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
}
|
||||
|
||||
if (failedFileNames.length > 0) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage(`파일 업로드에 실패했습니다: ${failedFileNames.join(', ')}`),
|
||||
]);
|
||||
}
|
||||
|
||||
setIsComposerAttachmentUploading(false);
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
createLocalMessage,
|
||||
isComposerAttachmentUploading,
|
||||
mergeComposerAttachments,
|
||||
setComposerAttachments,
|
||||
setIsComposerAttachmentUploading,
|
||||
setMessages,
|
||||
setShowScrollToBottom,
|
||||
shouldStickToBottomRef,
|
||||
],
|
||||
);
|
||||
|
||||
const focusComposerAfterSend = useCallback(() => {
|
||||
window.setTimeout(() => {
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
scrollViewportToBottom();
|
||||
}, 0);
|
||||
}, [composerRef, scrollViewportToBottom]);
|
||||
|
||||
const executeSendMessage = useCallback(
|
||||
(request: PendingContextConfirm) => {
|
||||
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription, chatTypeIsTemplate } = request;
|
||||
const requestId = `client-${Date.now().toString(36)}`;
|
||||
const outgoingRequest: PendingChatRequest = {
|
||||
sessionId: activeSessionId,
|
||||
requestId,
|
||||
text,
|
||||
mode,
|
||||
chatTypeId,
|
||||
chatTypeLabel,
|
||||
chatTypeDescription,
|
||||
chatTypeIsTemplate,
|
||||
retryCount: 0,
|
||||
failed: false,
|
||||
};
|
||||
|
||||
setStoredChatSessionLastTypeId(activeSessionId, chatTypeId);
|
||||
|
||||
if (mode === 'queue') {
|
||||
const queuedAt = new Date().toISOString();
|
||||
const optimisticUserMessage: ChatMessage = {
|
||||
...createChatMessage('user', text, requestId),
|
||||
deliveryStatus: null,
|
||||
retryCount: 0,
|
||||
};
|
||||
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
|
||||
'# 상태: 요청을 접수했습니다.',
|
||||
'# 진행: 대기열 등록을 준비하고 있습니다.',
|
||||
]);
|
||||
upsertRequestItem({
|
||||
sessionId: activeSessionId,
|
||||
requestId,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 등록',
|
||||
userMessageId: optimisticUserMessage.id,
|
||||
userText: text,
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
hasResponse: false,
|
||||
canDelete: false,
|
||||
createdAt: queuedAt,
|
||||
updatedAt: queuedAt,
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
});
|
||||
syncConversationPreviewForRequest(activeSessionId, text, queuedAt);
|
||||
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
setMessages((previous) => {
|
||||
const nextMessages = [...previous, optimisticUserMessage];
|
||||
return optimisticActivityMessage ? [...nextMessages, optimisticActivityMessage] : nextMessages;
|
||||
});
|
||||
setActiveSystemStatus('대기열 등록 중...');
|
||||
setIsSystemStatusPending(true);
|
||||
} else {
|
||||
const optimisticUserMessage: ChatMessage = {
|
||||
...createChatMessage('user', text, requestId),
|
||||
deliveryStatus: null,
|
||||
retryCount: 0,
|
||||
};
|
||||
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
|
||||
'# 상태: 즉시 요청을 접수했습니다.',
|
||||
'# 진행: 즉시 실행 대기 중입니다.',
|
||||
]);
|
||||
upsertRequestItem({
|
||||
sessionId: activeSessionId,
|
||||
requestId,
|
||||
status: 'accepted',
|
||||
statusMessage: '요청을 접수했습니다.',
|
||||
userMessageId: optimisticUserMessage.id,
|
||||
userText: text,
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
hasResponse: false,
|
||||
canDelete: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
});
|
||||
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
setMessages((previous) => {
|
||||
const nextMessages = [...previous, optimisticUserMessage];
|
||||
return optimisticActivityMessage ? [...nextMessages, optimisticActivityMessage] : nextMessages;
|
||||
});
|
||||
setActiveSystemStatus('즉시 응답 준비 중...');
|
||||
setIsSystemStatusPending(true);
|
||||
}
|
||||
|
||||
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
|
||||
setActiveSystemStatus('전송 재시도 중...');
|
||||
pendingRequestsRef.current = [
|
||||
...pendingRequestsRef.current.filter((pending) => pending.requestId !== requestId),
|
||||
outgoingRequest,
|
||||
];
|
||||
if (mode === 'direct') {
|
||||
updatePendingMessageStatus(requestId, 'retrying', 0);
|
||||
}
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
focusComposerAfterSend();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sendChatRequest(socketRef.current, outgoingRequest);
|
||||
} catch {
|
||||
setActiveSystemStatus('전송 재시도 중...');
|
||||
pendingRequestsRef.current = [
|
||||
...pendingRequestsRef.current.filter((pending) => pending.requestId !== requestId),
|
||||
outgoingRequest,
|
||||
];
|
||||
if (mode === 'direct') {
|
||||
updatePendingMessageStatus(requestId, 'retrying', 0);
|
||||
}
|
||||
}
|
||||
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
focusComposerAfterSend();
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
createActivityLogPlaceholder,
|
||||
createChatMessage,
|
||||
focusComposerAfterSend,
|
||||
pendingRequestsRef,
|
||||
sendChatRequest,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setDraft,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
setShowScrollToBottom,
|
||||
setStoredChatSessionLastTypeId,
|
||||
shouldStickToBottomRef,
|
||||
socketRef,
|
||||
syncConversationPreviewForRequest,
|
||||
updatePendingMessageStatus,
|
||||
upsertRequestItem,
|
||||
],
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(mode: 'queue' | 'direct') => {
|
||||
if (isComposerAttachmentUploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = buildOutgoingMessageText(draft, composerAttachments).trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedChatType) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedChatType.isTemplate) {
|
||||
const recentContext = summarizeRecentContext(
|
||||
messagesRef.current,
|
||||
appConfigChat.maxContextMessages,
|
||||
appConfigChat.maxContextChars,
|
||||
);
|
||||
|
||||
if (recentContext.omittedCount > 0) {
|
||||
setPendingContextConfirm({
|
||||
mode,
|
||||
text: trimmed,
|
||||
chatTypeId: selectedChatType.id,
|
||||
chatTypeLabel: selectedChatType.name,
|
||||
chatTypeDescription: selectedChatType.description,
|
||||
chatTypeIsTemplate: false,
|
||||
includedContextCount: recentContext.includedCount,
|
||||
omittedContextCount: recentContext.omittedCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
executeSendMessage({
|
||||
mode,
|
||||
text: trimmed,
|
||||
chatTypeId: selectedChatType.id,
|
||||
chatTypeLabel: selectedChatType.name,
|
||||
chatTypeDescription: selectedChatType.description,
|
||||
chatTypeIsTemplate: selectedChatType.isTemplate,
|
||||
includedContextCount: 0,
|
||||
omittedContextCount: 0,
|
||||
});
|
||||
},
|
||||
[
|
||||
appConfigChat.maxContextChars,
|
||||
appConfigChat.maxContextMessages,
|
||||
buildOutgoingMessageText,
|
||||
composerAttachments,
|
||||
createLocalMessage,
|
||||
draft,
|
||||
executeSendMessage,
|
||||
isComposerAttachmentUploading,
|
||||
messagesRef,
|
||||
selectedChatType,
|
||||
setMessages,
|
||||
setPendingContextConfirm,
|
||||
summarizeRecentContext,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
sendMessage('queue');
|
||||
}, [sendMessage]);
|
||||
|
||||
const handleSendImmediate = useCallback(() => {
|
||||
sendMessage('direct');
|
||||
}, [sendMessage]);
|
||||
|
||||
return {
|
||||
executeSendMessage,
|
||||
handleComposerFilesPicked,
|
||||
handleSend,
|
||||
handleSendImmediate,
|
||||
sendMessage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useConversationListData as useConversationListController } from './useConversationListData';
|
||||
135
src/app/main/chatV2/hooks/useConversationListData.ts
Normal file
135
src/app/main/chatV2/hooks/useConversationListData.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||
import {
|
||||
CHAT_CONVERSATIONS_UPDATED_EVENT,
|
||||
readChatConversationsUpdatedEvent,
|
||||
} from '../data/chatClientEvents';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
|
||||
type UseConversationListDataOptions = {
|
||||
requestedSessionId: string;
|
||||
};
|
||||
|
||||
type UseConversationListDataResult = {
|
||||
conversationItems: ChatConversationSummary[];
|
||||
setConversationItems: Dispatch<SetStateAction<ChatConversationSummary[]>>;
|
||||
isConversationListLoading: boolean;
|
||||
reloadConversationItems: () => Promise<void>;
|
||||
conversationSearch: string;
|
||||
setConversationSearch: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
export function useConversationListData({
|
||||
requestedSessionId,
|
||||
}: UseConversationListDataOptions): UseConversationListDataResult {
|
||||
const [conversationItems, setConversationItems] = useState<ChatConversationSummary[]>([]);
|
||||
const [isConversationListLoading, setIsConversationListLoading] = useState(false);
|
||||
const [conversationSearch, setConversationSearch] = useState('');
|
||||
|
||||
const loadConversationItems = async () => {
|
||||
setIsConversationListLoading(true);
|
||||
|
||||
try {
|
||||
const items = await chatGateway.listConversations();
|
||||
setConversationItems(items);
|
||||
} catch {
|
||||
setConversationItems([]);
|
||||
} finally {
|
||||
setIsConversationListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
void chatGateway
|
||||
.listConversations()
|
||||
.then((items) => {
|
||||
if (!isCancelled) {
|
||||
setConversationItems(items);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setConversationItems([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCancelled) {
|
||||
setIsConversationListLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
setIsConversationListLoading(true);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedSessionId || isConversationListLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (conversationItems.some((item) => item.sessionId === requestedSessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
const loadRequestedConversation = async () => {
|
||||
try {
|
||||
const response = await chatGateway.getConversationDetail(requestedSessionId);
|
||||
|
||||
if (isCancelled || response.item.sessionId !== requestedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConversationItems((previous) => {
|
||||
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
|
||||
if (!exists) {
|
||||
return [response.item, ...previous];
|
||||
}
|
||||
|
||||
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
|
||||
});
|
||||
} catch {
|
||||
// 유효하지 않은 세션은 이후 기본 빈 상태 흐름이 유지된다.
|
||||
}
|
||||
};
|
||||
|
||||
void loadRequestedConversation();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [conversationItems, isConversationListLoading, requestedSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleConversationsUpdated = (event: Event) => {
|
||||
const detail = readChatConversationsUpdatedEvent(event);
|
||||
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConversationItems(detail.items);
|
||||
};
|
||||
|
||||
window.addEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
conversationItems,
|
||||
setConversationItems,
|
||||
isConversationListLoading,
|
||||
reloadConversationItems: loadConversationItems,
|
||||
conversationSearch,
|
||||
setConversationSearch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useCallback } from 'react';
|
||||
import { removeChatRuntimeJob } from '../../mainChatPanel';
|
||||
import { chatConnectionGateway } from '../data/chatConnectionGateway';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type {
|
||||
ChatComposerAttachment,
|
||||
ChatConversationRequest,
|
||||
ChatConversationSummary,
|
||||
ChatMessage,
|
||||
} from '../../mainChatPanel/types';
|
||||
|
||||
type PendingChatRequest = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
text: string;
|
||||
mode: 'queue' | 'direct';
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
retryCount: number;
|
||||
failed: boolean;
|
||||
};
|
||||
|
||||
type UseConversationRoomActionsControllerOptions = {
|
||||
activeSessionId: string;
|
||||
requestedSessionId: string;
|
||||
conversationItems: ChatConversationSummary[];
|
||||
activeConversation: ChatConversationSummary | null;
|
||||
editingConversationTitle: string;
|
||||
isMobileViewport: boolean;
|
||||
pendingRequestsRef: { current: PendingChatRequest[] };
|
||||
sessionMessageCacheRef: { current: Map<string, ChatMessage[]> };
|
||||
socketRef: { current: WebSocket | null };
|
||||
setConversationItems: React.Dispatch<React.SetStateAction<ChatConversationSummary[]>>;
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
||||
setRequestItems: React.Dispatch<React.SetStateAction<ChatConversationRequest[]>>;
|
||||
setActiveSessionId: (value: string) => void;
|
||||
setDraft: (value: string) => void;
|
||||
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
|
||||
setCopiedMessageId: (value: number | null) => void;
|
||||
setActivePreviewId: (value: string | null) => void;
|
||||
setIsPreviewModalOpen: (value: boolean) => void;
|
||||
setActiveSystemStatus: (value: string | null) => void;
|
||||
setIsSystemStatusPending: (value: boolean) => void;
|
||||
setIsResourceStripOpen: (value: boolean) => void;
|
||||
setIsConversationPaneClosed: (value: boolean) => void;
|
||||
setIsMobileConversationView: (value: boolean) => void;
|
||||
setRenamingConversationSessionId: (value: string | null | ((current: string | null) => string | null)) => void;
|
||||
setEditingConversationTitle: (value: string) => void;
|
||||
setIsEditingConversationTitle: (value: boolean) => void;
|
||||
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
|
||||
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
|
||||
createLocalMessage: (text: string) => ChatMessage;
|
||||
replaceChatSessionInUrl: (sessionId: string) => void;
|
||||
messageApi: {
|
||||
error: (content: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function useConversationRoomActionsController({
|
||||
activeSessionId,
|
||||
requestedSessionId,
|
||||
conversationItems,
|
||||
activeConversation,
|
||||
editingConversationTitle,
|
||||
isMobileViewport,
|
||||
pendingRequestsRef,
|
||||
sessionMessageCacheRef,
|
||||
socketRef,
|
||||
setConversationItems,
|
||||
setMessages,
|
||||
setRequestItems,
|
||||
setActiveSessionId,
|
||||
setDraft,
|
||||
setComposerAttachments,
|
||||
setCopiedMessageId,
|
||||
setActivePreviewId,
|
||||
setIsPreviewModalOpen,
|
||||
setActiveSystemStatus,
|
||||
setIsSystemStatusPending,
|
||||
setIsResourceStripOpen,
|
||||
setIsConversationPaneClosed,
|
||||
setIsMobileConversationView,
|
||||
setRenamingConversationSessionId,
|
||||
setEditingConversationTitle,
|
||||
setIsEditingConversationTitle,
|
||||
updatePendingMessageStatus,
|
||||
sendChatRequest,
|
||||
createLocalMessage,
|
||||
replaceChatSessionInUrl,
|
||||
messageApi,
|
||||
}: UseConversationRoomActionsControllerOptions) {
|
||||
const removeOptimisticRequestMessages = useCallback(
|
||||
(requestId: string) => {
|
||||
setMessages((previous) => previous.filter((message) => message.clientRequestId !== requestId));
|
||||
},
|
||||
[setMessages],
|
||||
);
|
||||
|
||||
const retryPendingRequest = useCallback(
|
||||
(requestId: string) => {
|
||||
const currentRequest = pendingRequestsRef.current.find(
|
||||
(request) => request.requestId === requestId && request.sessionId === activeSessionId,
|
||||
);
|
||||
|
||||
if (!currentRequest) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('재전송할 요청 정보를 찾지 못했습니다. 같은 내용을 다시 보내 주세요.'),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const resetRequest: PendingChatRequest = {
|
||||
...currentRequest,
|
||||
retryCount: 0,
|
||||
failed: false,
|
||||
};
|
||||
|
||||
setActiveSystemStatus('전송 재시도 중...');
|
||||
setIsSystemStatusPending(true);
|
||||
|
||||
const socket = socketRef.current;
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
updatePendingMessageStatus(requestId, 'retrying', 0);
|
||||
pendingRequestsRef.current = [
|
||||
...pendingRequestsRef.current.filter((request) => request.requestId !== requestId),
|
||||
resetRequest,
|
||||
];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sendChatRequest(socket, resetRequest);
|
||||
updatePendingMessageStatus(requestId, null, 0);
|
||||
pendingRequestsRef.current = pendingRequestsRef.current.filter((request) => request.requestId !== requestId);
|
||||
} catch {
|
||||
updatePendingMessageStatus(requestId, 'retrying', 0);
|
||||
pendingRequestsRef.current = [
|
||||
...pendingRequestsRef.current.filter((request) => request.requestId !== requestId),
|
||||
resetRequest,
|
||||
];
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
createLocalMessage,
|
||||
pendingRequestsRef,
|
||||
sendChatRequest,
|
||||
setActiveSystemStatus,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
socketRef,
|
||||
updatePendingMessageStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const cancelPendingRequest = useCallback(
|
||||
(requestId: string) => {
|
||||
const currentRequest = pendingRequestsRef.current.find(
|
||||
(request) => request.requestId === requestId && request.sessionId === activeSessionId,
|
||||
);
|
||||
|
||||
if (!currentRequest) {
|
||||
removeOptimisticRequestMessages(requestId);
|
||||
setActiveSystemStatus('미접수 요청을 화면에서 제거했습니다.');
|
||||
setIsSystemStatusPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequestsRef.current = pendingRequestsRef.current.filter((request) => request.requestId !== requestId);
|
||||
removeOptimisticRequestMessages(requestId);
|
||||
setRequestItems((previous) =>
|
||||
previous.filter((item) => !(item.sessionId === activeSessionId && item.requestId === requestId)),
|
||||
);
|
||||
setActiveSystemStatus('실패한 요청을 취소했습니다.');
|
||||
setIsSystemStatusPending(false);
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
pendingRequestsRef,
|
||||
removeOptimisticRequestMessages,
|
||||
setActiveSystemStatus,
|
||||
setIsSystemStatusPending,
|
||||
setRequestItems,
|
||||
],
|
||||
);
|
||||
|
||||
const removeQueuedComposerRequest = useCallback(
|
||||
async (requestId: string) => {
|
||||
try {
|
||||
await removeChatRuntimeJob(requestId);
|
||||
setRequestItems((previous) =>
|
||||
previous.map((item) =>
|
||||
item.sessionId === activeSessionId && item.requestId === requestId
|
||||
? {
|
||||
...item,
|
||||
status: 'removed',
|
||||
statusMessage: '대기열에서 제거되었습니다.',
|
||||
terminalAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setActiveSystemStatus('대기 요청을 삭제했습니다.');
|
||||
setIsSystemStatusPending(false);
|
||||
} catch (error) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage(error instanceof Error ? error.message : '대기 요청 제거 중 오류가 발생했습니다.'),
|
||||
]);
|
||||
}
|
||||
},
|
||||
[activeSessionId, createLocalMessage, setActiveSystemStatus, setIsSystemStatusPending, setMessages, setRequestItems],
|
||||
);
|
||||
|
||||
const deleteStoredRequest = useCallback(
|
||||
async (requestId: string) => {
|
||||
try {
|
||||
await chatGateway.deleteConversationRequest(activeSessionId, requestId);
|
||||
setMessages((previous) => previous.filter((message) => message.clientRequestId !== requestId));
|
||||
setRequestItems((previous) =>
|
||||
previous.filter((item) => !(item.sessionId === activeSessionId && item.requestId === requestId)),
|
||||
);
|
||||
setConversationItems((previous) =>
|
||||
previous.map((item) =>
|
||||
item.sessionId === activeSessionId
|
||||
? {
|
||||
...item,
|
||||
currentRequestId: item.currentRequestId === requestId ? null : item.currentRequestId,
|
||||
currentJobStatus: item.currentRequestId === requestId ? null : item.currentJobStatus,
|
||||
currentJobMessage: item.currentRequestId === requestId ? null : item.currentJobMessage,
|
||||
currentQueueSize: item.currentRequestId === requestId ? 0 : item.currentQueueSize,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setActiveSystemStatus('요청을 삭제했습니다.');
|
||||
setIsSystemStatusPending(false);
|
||||
} catch (error) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage(error instanceof Error ? error.message : '요청 삭제 중 오류가 발생했습니다.'),
|
||||
]);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
createLocalMessage,
|
||||
setActiveSystemStatus,
|
||||
setConversationItems,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
setRequestItems,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRenameConversation = useCallback(async () => {
|
||||
if (!activeConversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = activeConversation.sessionId;
|
||||
const previousTitle = activeConversation.title;
|
||||
const trimmedTitle = editingConversationTitle.trim();
|
||||
|
||||
if (!trimmedTitle || trimmedTitle === previousTitle) {
|
||||
setIsEditingConversationTitle(false);
|
||||
setEditingConversationTitle(previousTitle);
|
||||
return;
|
||||
}
|
||||
|
||||
setRenamingConversationSessionId(sessionId);
|
||||
setConversationItems((previous) =>
|
||||
previous.map((entry) => (entry.sessionId === sessionId ? { ...entry, title: trimmedTitle } : entry)),
|
||||
);
|
||||
setEditingConversationTitle(trimmedTitle);
|
||||
setIsEditingConversationTitle(false);
|
||||
|
||||
try {
|
||||
const item = await chatGateway.renameConversation(sessionId, trimmedTitle);
|
||||
setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)));
|
||||
setEditingConversationTitle(item.title);
|
||||
} catch (error) {
|
||||
setConversationItems((previous) =>
|
||||
previous.map((entry) => (entry.sessionId === sessionId ? { ...entry, title: previousTitle } : entry)),
|
||||
);
|
||||
setEditingConversationTitle(previousTitle);
|
||||
messageApi.error(error instanceof Error ? error.message : '채팅방 이름 변경에 실패했습니다.');
|
||||
} finally {
|
||||
setRenamingConversationSessionId((current) => (current === sessionId ? null : current));
|
||||
}
|
||||
}, [
|
||||
activeConversation,
|
||||
editingConversationTitle,
|
||||
messageApi,
|
||||
setConversationItems,
|
||||
setEditingConversationTitle,
|
||||
setIsEditingConversationTitle,
|
||||
setRenamingConversationSessionId,
|
||||
]);
|
||||
|
||||
const handleDeleteConversation = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
await chatGateway.deleteConversation(sessionId);
|
||||
const remaining = conversationItems.filter((entry) => entry.sessionId !== sessionId);
|
||||
sessionMessageCacheRef.current.delete(sessionId);
|
||||
setConversationItems(remaining);
|
||||
|
||||
if (sessionId === activeSessionId) {
|
||||
replaceChatSessionInUrl('');
|
||||
chatConnectionGateway.resetLastReceivedEventId('');
|
||||
setActiveSessionId('');
|
||||
setMessages([]);
|
||||
setRequestItems([]);
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
setCopiedMessageId(null);
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
setIsResourceStripOpen(false);
|
||||
setIsConversationPaneClosed(false);
|
||||
setIsMobileConversationView(!isMobileViewport);
|
||||
} else if (requestedSessionId === sessionId) {
|
||||
replaceChatSessionInUrl(activeSessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '대화방 삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
conversationItems,
|
||||
isMobileViewport,
|
||||
messageApi,
|
||||
replaceChatSessionInUrl,
|
||||
requestedSessionId,
|
||||
sessionMessageCacheRef,
|
||||
setActivePreviewId,
|
||||
setActiveSessionId,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setConversationItems,
|
||||
setCopiedMessageId,
|
||||
setDraft,
|
||||
setIsConversationPaneClosed,
|
||||
setIsMobileConversationView,
|
||||
setIsPreviewModalOpen,
|
||||
setIsResourceStripOpen,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
setRequestItems,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
cancelPendingRequest,
|
||||
deleteStoredRequest,
|
||||
handleDeleteConversation,
|
||||
handleRenameConversation,
|
||||
removeQueuedComposerRequest,
|
||||
retryPendingRequest,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useConversationRoomData as useConversationRoomController } from './useConversationRoomData';
|
||||
307
src/app/main/chatV2/hooks/useConversationRoomData.ts
Normal file
307
src/app/main/chatV2/hooks/useConversationRoomData.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
|
||||
import { mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type {
|
||||
ChatConversationRequest,
|
||||
ChatConversationSummary,
|
||||
ChatMessage,
|
||||
} from '../../mainChatPanel/types';
|
||||
|
||||
const INITIAL_CONVERSATION_MESSAGE_LIMIT = 3;
|
||||
const OLDER_CONVERSATION_MESSAGE_PAGE_SIZE = 20;
|
||||
|
||||
function getCachedSessionMessages(cache: Map<string, ChatMessage[]>, sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
return cache.get(normalizedSessionId) ?? [];
|
||||
}
|
||||
|
||||
function getBestAvailableSessionMessages(
|
||||
cache: Map<string, ChatMessage[]>,
|
||||
sessionId: string,
|
||||
currentSessionId: string,
|
||||
currentMessages: ChatMessage[],
|
||||
) {
|
||||
const cachedMessages = getCachedSessionMessages(cache, sessionId);
|
||||
|
||||
if (sessionId !== currentSessionId || currentMessages.length === 0) {
|
||||
return cachedMessages;
|
||||
}
|
||||
|
||||
return mergeRecoveredChatMessages(cachedMessages, currentMessages);
|
||||
}
|
||||
|
||||
type UseConversationRoomDataOptions = {
|
||||
activeSessionId: string;
|
||||
connectionState: 'connecting' | 'connected' | 'disconnected';
|
||||
shouldBlockConversationWhileLoading: (sessionId: string) => boolean;
|
||||
captureViewportRestoreSnapshot: () => void;
|
||||
sessionMessageCacheRef: MutableRefObject<Map<string, ChatMessage[]>>;
|
||||
messagesRef: MutableRefObject<ChatMessage[]>;
|
||||
pendingViewportRestoreRef: MutableRefObject<boolean>;
|
||||
shouldRestoreConversationAfterReconnectRef: MutableRefObject<boolean>;
|
||||
setConversationItems: Dispatch<SetStateAction<ChatConversationSummary[]>>;
|
||||
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||
setRequestItems: Dispatch<SetStateAction<ChatConversationRequest[]>>;
|
||||
setConversationLoadingLabel: Dispatch<SetStateAction<string>>;
|
||||
setIsConversationContentLoading: Dispatch<SetStateAction<boolean>>;
|
||||
setIsDeferringAuxiliaryChatRequests: Dispatch<SetStateAction<boolean>>;
|
||||
setHasOlderMessages: Dispatch<SetStateAction<boolean>>;
|
||||
setOldestLoadedMessageId: Dispatch<SetStateAction<number | null>>;
|
||||
setIsLoadingOlderMessages: Dispatch<SetStateAction<boolean>>;
|
||||
queueViewportPrependRestore: (previousScrollHeight: number, previousScrollTop: number) => void;
|
||||
viewportRef: MutableRefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export function useConversationRoomData({
|
||||
activeSessionId,
|
||||
connectionState,
|
||||
shouldBlockConversationWhileLoading,
|
||||
captureViewportRestoreSnapshot,
|
||||
sessionMessageCacheRef,
|
||||
messagesRef,
|
||||
pendingViewportRestoreRef,
|
||||
shouldRestoreConversationAfterReconnectRef,
|
||||
setConversationItems,
|
||||
setMessages,
|
||||
setRequestItems,
|
||||
setConversationLoadingLabel,
|
||||
setIsConversationContentLoading,
|
||||
setIsDeferringAuxiliaryChatRequests,
|
||||
setHasOlderMessages,
|
||||
setOldestLoadedMessageId,
|
||||
setIsLoadingOlderMessages,
|
||||
queueViewportPrependRestore,
|
||||
viewportRef,
|
||||
}: UseConversationRoomDataOptions) {
|
||||
useEffect(() => {
|
||||
if (!activeSessionId.trim()) {
|
||||
setMessages([]);
|
||||
setRequestItems([]);
|
||||
setIsConversationContentLoading(false);
|
||||
setIsLoadingOlderMessages(false);
|
||||
setHasOlderMessages(false);
|
||||
setOldestLoadedMessageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
const requestedSessionId = activeSessionId;
|
||||
|
||||
const loadConversationDetail = async () => {
|
||||
captureViewportRestoreSnapshot();
|
||||
pendingViewportRestoreRef.current = true;
|
||||
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
|
||||
setIsConversationContentLoading(shouldBlockConversationWhileLoading(requestedSessionId));
|
||||
setIsDeferringAuxiliaryChatRequests(true);
|
||||
|
||||
try {
|
||||
const response = await chatGateway.getConversationDetail(requestedSessionId, {
|
||||
limit: INITIAL_CONVERSATION_MESSAGE_LIMIT,
|
||||
});
|
||||
|
||||
if (!isCancelled && response.item.sessionId === requestedSessionId) {
|
||||
setConversationItems((previous) => {
|
||||
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
|
||||
if (!exists) {
|
||||
return [response.item, ...previous];
|
||||
}
|
||||
|
||||
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
|
||||
});
|
||||
|
||||
const baseMessages = getBestAvailableSessionMessages(
|
||||
sessionMessageCacheRef.current,
|
||||
requestedSessionId,
|
||||
activeSessionId,
|
||||
messagesRef.current,
|
||||
);
|
||||
const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
|
||||
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
|
||||
setMessages(nextMessages);
|
||||
setRequestItems(response.requests);
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
const cachedMessages = getBestAvailableSessionMessages(
|
||||
sessionMessageCacheRef.current,
|
||||
requestedSessionId,
|
||||
activeSessionId,
|
||||
messagesRef.current,
|
||||
);
|
||||
|
||||
if (cachedMessages.length > 0) {
|
||||
setMessages(cachedMessages);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsConversationContentLoading(false);
|
||||
setIsDeferringAuxiliaryChatRequests(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadConversationDetail();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [
|
||||
activeSessionId,
|
||||
captureViewportRestoreSnapshot,
|
||||
messagesRef,
|
||||
pendingViewportRestoreRef,
|
||||
sessionMessageCacheRef,
|
||||
setConversationItems,
|
||||
setConversationLoadingLabel,
|
||||
setIsConversationContentLoading,
|
||||
setIsDeferringAuxiliaryChatRequests,
|
||||
setHasOlderMessages,
|
||||
setMessages,
|
||||
setOldestLoadedMessageId,
|
||||
setRequestItems,
|
||||
shouldBlockConversationWhileLoading,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState !== 'connected' || !shouldRestoreConversationAfterReconnectRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
const requestedSessionId = activeSessionId;
|
||||
|
||||
const restoreConversationAfterReconnect = async () => {
|
||||
setIsDeferringAuxiliaryChatRequests(true);
|
||||
|
||||
try {
|
||||
const response = await chatGateway.getConversationDetail(requestedSessionId, {
|
||||
limit: Math.max(INITIAL_CONVERSATION_MESSAGE_LIMIT, messagesRef.current.length || 0),
|
||||
});
|
||||
|
||||
if (!isCancelled && response.item.sessionId === requestedSessionId) {
|
||||
const baseMessages = getBestAvailableSessionMessages(
|
||||
sessionMessageCacheRef.current,
|
||||
requestedSessionId,
|
||||
activeSessionId,
|
||||
messagesRef.current,
|
||||
);
|
||||
const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
|
||||
const hasMessageDiff = nextMessages !== baseMessages;
|
||||
|
||||
if (hasMessageDiff) {
|
||||
captureViewportRestoreSnapshot();
|
||||
pendingViewportRestoreRef.current = true;
|
||||
setConversationLoadingLabel('채팅방을 다시 연결하고 내용을 복구하는 중입니다.');
|
||||
setIsConversationContentLoading(true);
|
||||
}
|
||||
|
||||
setConversationItems((previous) => {
|
||||
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
|
||||
if (!exists) {
|
||||
return [response.item, ...previous];
|
||||
}
|
||||
|
||||
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
|
||||
});
|
||||
setRequestItems(response.requests);
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
|
||||
if (hasMessageDiff) {
|
||||
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
|
||||
setMessages(nextMessages);
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!isCancelled) {
|
||||
setIsConversationContentLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setIsConversationContentLoading(false);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
shouldRestoreConversationAfterReconnectRef.current = false;
|
||||
if (!pendingViewportRestoreRef.current) {
|
||||
setIsConversationContentLoading(false);
|
||||
}
|
||||
setIsDeferringAuxiliaryChatRequests(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void restoreConversationAfterReconnect();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [
|
||||
activeSessionId,
|
||||
captureViewportRestoreSnapshot,
|
||||
connectionState,
|
||||
messagesRef,
|
||||
pendingViewportRestoreRef,
|
||||
sessionMessageCacheRef,
|
||||
setConversationItems,
|
||||
setConversationLoadingLabel,
|
||||
setIsConversationContentLoading,
|
||||
setIsDeferringAuxiliaryChatRequests,
|
||||
setHasOlderMessages,
|
||||
setMessages,
|
||||
setOldestLoadedMessageId,
|
||||
setRequestItems,
|
||||
shouldRestoreConversationAfterReconnectRef,
|
||||
]);
|
||||
|
||||
const loadOlderMessages = async () => {
|
||||
const requestedSessionId = activeSessionId.trim();
|
||||
const oldestVisibleMessageId = messagesRef.current[0]?.id ?? null;
|
||||
|
||||
if (!requestedSessionId || oldestVisibleMessageId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingOlderMessages(true);
|
||||
|
||||
try {
|
||||
const response = await chatGateway.getConversationDetail(requestedSessionId, {
|
||||
limit: OLDER_CONVERSATION_MESSAGE_PAGE_SIZE,
|
||||
beforeMessageId: oldestVisibleMessageId,
|
||||
});
|
||||
|
||||
if (response.item.sessionId !== requestedSessionId || response.messages.length === 0) {
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = viewportRef.current;
|
||||
const previousScrollHeight = viewport?.scrollHeight ?? 0;
|
||||
const previousScrollTop = viewport?.scrollTop ?? 0;
|
||||
const nextMessages = mergeRecoveredChatMessages(messagesRef.current, response.messages);
|
||||
|
||||
queueViewportPrependRestore(previousScrollHeight, previousScrollTop);
|
||||
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
|
||||
setMessages(nextMessages);
|
||||
setRequestItems(response.requests);
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
} finally {
|
||||
setIsLoadingOlderMessages(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loadOlderMessages,
|
||||
};
|
||||
}
|
||||
206
src/app/main/chatV2/hooks/useConversationViewController.ts
Normal file
206
src/app/main/chatV2/hooks/useConversationViewController.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ChatComposerAttachment, ChatMessage } from '../../mainChatPanel/types';
|
||||
|
||||
type PreviewItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
kind: 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
|
||||
source: 'message' | 'context';
|
||||
};
|
||||
|
||||
type UseConversationViewControllerOptions = {
|
||||
activeSessionId: string;
|
||||
activeView: 'chat' | 'runtime' | 'errors';
|
||||
previewItems: PreviewItem[];
|
||||
selectedChatTypeId: string | null;
|
||||
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
|
||||
sessionMessageCacheRef: { current: Map<string, ChatMessage[]> };
|
||||
setActiveSystemStatus: (value: string | null) => void;
|
||||
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
|
||||
setCopiedMessageId: (value: number | null) => void;
|
||||
setDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||
setIsResourceStripOpen: (value: boolean) => void;
|
||||
setIsSystemStatusPending: (value: boolean) => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
||||
};
|
||||
|
||||
export function useConversationViewController({
|
||||
activeSessionId,
|
||||
activeView,
|
||||
composerRef,
|
||||
previewItems,
|
||||
selectedChatTypeId,
|
||||
sessionMessageCacheRef,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setCopiedMessageId,
|
||||
setDraft,
|
||||
setIsResourceStripOpen,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
}: UseConversationViewControllerOptions) {
|
||||
const previousSessionIdRef = useRef(activeSessionId);
|
||||
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
||||
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
|
||||
const [previewText, setPreviewText] = useState('');
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState('');
|
||||
const [previewContentType, setPreviewContentType] = useState('');
|
||||
|
||||
const activePreview = previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const hasSessionChanged = previousSessionIdRef.current !== activeSessionId;
|
||||
|
||||
if (!hasSessionChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousSessionIdRef.current = activeSessionId;
|
||||
|
||||
setMessages(sessionMessageCacheRef.current.get(activeSessionId)?.slice() ?? []);
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
setCopiedMessageId(null);
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
setIsResourceStripOpen(false);
|
||||
}, [
|
||||
activeSessionId,
|
||||
sessionMessageCacheRef,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setCopiedMessageId,
|
||||
setDraft,
|
||||
setIsResourceStripOpen,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePreviewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewItems.some((item) => item.id === activePreviewId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
}, [activePreviewId, previewItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviewModalOpen || !activePreview) {
|
||||
setPreviewText('');
|
||||
setPreviewError('');
|
||||
setPreviewContentType('');
|
||||
setIsPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activePreview.kind === 'image' || activePreview.kind === 'video' || activePreview.kind === 'pdf') {
|
||||
setPreviewText('');
|
||||
setPreviewError('');
|
||||
setPreviewContentType('');
|
||||
setIsPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setIsPreviewLoading(true);
|
||||
setPreviewError('');
|
||||
setPreviewContentType('');
|
||||
|
||||
fetch(activePreview.url, {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
setPreviewContentType(response.headers.get('content-type') ?? '');
|
||||
const text = await response.text();
|
||||
setPreviewText(text.slice(0, 20000));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewText('');
|
||||
setPreviewContentType('');
|
||||
setPreviewError(error instanceof Error ? error.message : 'preview를 가져오지 못했습니다.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [activePreview, isPreviewModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== 'chat') {
|
||||
return;
|
||||
}
|
||||
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
}, [activeView, composerRef, selectedChatTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== 'chat') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
const isTextEntryTarget =
|
||||
event.target instanceof HTMLElement &&
|
||||
(event.target.isContentEditable ||
|
||||
['input', 'textarea', 'select'].includes(event.target.tagName.toLowerCase()) ||
|
||||
event.target.closest('[contenteditable="true"]'));
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey || isTextEntryTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === '/') {
|
||||
event.preventDefault();
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.length === 1) {
|
||||
event.preventDefault();
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
setDraft((previous) => previous + event.key);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleWindowKeyDown);
|
||||
};
|
||||
}, [activeView, composerRef, setDraft]);
|
||||
|
||||
return {
|
||||
activePreview,
|
||||
activePreviewId,
|
||||
isPreviewLoading,
|
||||
isPreviewModalOpen,
|
||||
previewContentType,
|
||||
previewError,
|
||||
previewText,
|
||||
setActivePreviewId,
|
||||
setIsPreviewModalOpen,
|
||||
};
|
||||
}
|
||||
406
src/app/main/chatV2/hooks/useConversationViewportController.ts
Normal file
406
src/app/main/chatV2/hooks/useConversationViewportController.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { useCallback, useEffect, useRef, useState, type TouchEvent } from 'react';
|
||||
import type {
|
||||
ChatConversationSummary,
|
||||
ChatMessage,
|
||||
ChatRuntimeSnapshot,
|
||||
} from '../../mainChatPanel/types';
|
||||
|
||||
type UseConversationViewportControllerOptions = {
|
||||
activeConversation: ChatConversationSummary | null;
|
||||
activeQueuedComposerRequestsCount: number;
|
||||
activeRuntimeStatus: string | null;
|
||||
chatMessageCount: number;
|
||||
chatMessageSyncKey: string;
|
||||
connectionState: 'connecting' | 'connected' | 'disconnected';
|
||||
isConversationContentLoading: boolean;
|
||||
messages: ChatMessage[];
|
||||
runtimeSnapshot: ChatRuntimeSnapshot | null;
|
||||
viewportRef: { current: HTMLDivElement | null };
|
||||
hasOlderMessages: boolean;
|
||||
isLoadingOlderMessages: boolean;
|
||||
onLoadOlderMessages: () => void | Promise<void>;
|
||||
mapJobStatusLabel: (
|
||||
item: Pick<ChatConversationSummary, 'currentJobStatus' | 'currentJobMessage' | 'currentQueueSize'>,
|
||||
) => string;
|
||||
mapSystemStatusMessage: (text: string) => string | null;
|
||||
isActivityLogMessage: (message: ChatMessage) => boolean;
|
||||
};
|
||||
|
||||
export function useConversationViewportController({
|
||||
activeConversation,
|
||||
activeQueuedComposerRequestsCount,
|
||||
activeRuntimeStatus,
|
||||
chatMessageCount,
|
||||
chatMessageSyncKey,
|
||||
connectionState,
|
||||
isConversationContentLoading,
|
||||
messages,
|
||||
runtimeSnapshot,
|
||||
viewportRef,
|
||||
hasOlderMessages,
|
||||
isLoadingOlderMessages,
|
||||
onLoadOlderMessages,
|
||||
mapJobStatusLabel,
|
||||
mapSystemStatusMessage,
|
||||
isActivityLogMessage,
|
||||
}: UseConversationViewportControllerOptions) {
|
||||
const [activeSystemStatus, setActiveSystemStatus] = useState<string | null>(null);
|
||||
const [isSystemStatusPending, setIsSystemStatusPending] = useState(false);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const systemStatusTimerRef = useRef<number | null>(null);
|
||||
const restoreAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const pendingViewportRestoreRef = useRef(false);
|
||||
const pendingPrependRestoreRef = useRef<{
|
||||
previousScrollHeight: number;
|
||||
previousScrollTop: number;
|
||||
} | null>(null);
|
||||
const viewportRestoreSnapshotRef = useRef<{
|
||||
shouldStickToBottom: boolean;
|
||||
offsetFromBottom: number;
|
||||
} | null>(null);
|
||||
const touchStartYRef = useRef<number | null>(null);
|
||||
const touchPullActiveRef = useRef(false);
|
||||
const [pullToLoadDistance, setPullToLoadDistance] = useState(0);
|
||||
const [isPullToLoadArmed, setIsPullToLoadArmed] = useState(false);
|
||||
|
||||
const clearSystemStatusTimer = useCallback(() => {
|
||||
if (systemStatusTimerRef.current !== null) {
|
||||
window.clearTimeout(systemStatusTimerRef.current);
|
||||
systemStatusTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollViewportToBottom = useCallback(
|
||||
(behavior: ScrollBehavior = 'smooth') => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewport.scrollTo({
|
||||
top: viewport.scrollHeight,
|
||||
behavior,
|
||||
});
|
||||
},
|
||||
[viewportRef],
|
||||
);
|
||||
|
||||
const scheduleViewportBottomSync = useCallback(
|
||||
(frameCount = 6) => {
|
||||
if (restoreAutoScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
|
||||
restoreAutoScrollFrameRef.current = null;
|
||||
}
|
||||
|
||||
const run = (remainingFrames: number) => {
|
||||
restoreAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
if (!shouldStickToBottomRef.current || isConversationContentLoading) {
|
||||
restoreAutoScrollFrameRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setShowScrollToBottom(false);
|
||||
scrollViewportToBottom('auto');
|
||||
|
||||
if (remainingFrames <= 1) {
|
||||
restoreAutoScrollFrameRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
run(remainingFrames - 1);
|
||||
});
|
||||
};
|
||||
|
||||
run(frameCount);
|
||||
},
|
||||
[isConversationContentLoading, scrollViewportToBottom],
|
||||
);
|
||||
|
||||
const handleViewportScroll = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingDistance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
const isNearBottom = remainingDistance <= 24;
|
||||
|
||||
shouldStickToBottomRef.current = isNearBottom;
|
||||
setShowScrollToBottom(!isNearBottom);
|
||||
}, [viewportRef]);
|
||||
|
||||
const captureViewportRestoreSnapshot = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport) {
|
||||
viewportRestoreSnapshotRef.current = {
|
||||
shouldStickToBottom: shouldStickToBottomRef.current,
|
||||
offsetFromBottom: 0,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
viewportRestoreSnapshotRef.current = {
|
||||
shouldStickToBottom: shouldStickToBottomRef.current,
|
||||
offsetFromBottom: Math.max(0, viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight),
|
||||
};
|
||||
}, [viewportRef]);
|
||||
|
||||
const queueViewportPrependRestore = useCallback((previousScrollHeight: number, previousScrollTop: number) => {
|
||||
pendingPrependRestoreRef.current = {
|
||||
previousScrollHeight,
|
||||
previousScrollTop,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldStickToBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollViewportToBottom(chatMessageCount > 1 ? 'smooth' : 'auto');
|
||||
}, [chatMessageCount, chatMessageSyncKey, scrollViewportToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConversationContentLoading || !shouldStickToBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleViewportBottomSync();
|
||||
|
||||
return () => {
|
||||
if (restoreAutoScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
|
||||
restoreAutoScrollFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeConversation?.sessionId, chatMessageSyncKey, isConversationContentLoading, scheduleViewportBottomSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingPrependRestore = pendingPrependRestoreRef.current;
|
||||
|
||||
if (!pendingPrependRestore) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPrependRestoreRef.current = null;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextScrollTop =
|
||||
pendingPrependRestore.previousScrollTop + (viewport.scrollHeight - pendingPrependRestore.previousScrollHeight);
|
||||
viewport.scrollTop = Math.max(0, nextScrollTop);
|
||||
handleViewportScroll();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [chatMessageCount, chatMessageSyncKey, handleViewportScroll, viewportRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConversationContentLoading || !pendingViewportRestoreRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const restoreSnapshot = viewportRestoreSnapshotRef.current;
|
||||
|
||||
pendingViewportRestoreRef.current = false;
|
||||
viewportRestoreSnapshotRef.current = null;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!restoreSnapshot || restoreSnapshot.shouldStickToBottom) {
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
scrollViewportToBottom('auto');
|
||||
return;
|
||||
}
|
||||
|
||||
shouldStickToBottomRef.current = false;
|
||||
viewport.scrollTop = Math.max(
|
||||
0,
|
||||
viewport.scrollHeight - viewport.clientHeight - restoreSnapshot.offsetFromBottom,
|
||||
);
|
||||
handleViewportScroll();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [
|
||||
chatMessageCount,
|
||||
handleViewportScroll,
|
||||
isConversationContentLoading,
|
||||
scrollViewportToBottom,
|
||||
viewportRef,
|
||||
]);
|
||||
|
||||
const handleViewportTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport || viewport.scrollTop > 0 || !hasOlderMessages || isLoadingOlderMessages) {
|
||||
touchStartYRef.current = null;
|
||||
touchPullActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
touchStartYRef.current = event.touches[0]?.clientY ?? null;
|
||||
touchPullActiveRef.current = true;
|
||||
}, [hasOlderMessages, isLoadingOlderMessages, viewportRef]);
|
||||
|
||||
const handleViewportTouchMove = useCallback((event: TouchEvent<HTMLDivElement>) => {
|
||||
if (!touchPullActiveRef.current || touchStartYRef.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = viewportRef.current;
|
||||
const currentY = event.touches[0]?.clientY ?? null;
|
||||
|
||||
if (!viewport || currentY == null || viewport.scrollTop > 0) {
|
||||
touchPullActiveRef.current = false;
|
||||
touchStartYRef.current = null;
|
||||
setPullToLoadDistance(0);
|
||||
setIsPullToLoadArmed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaY = Math.max(0, currentY - touchStartYRef.current);
|
||||
const nextDistance = Math.min(96, deltaY * 0.45);
|
||||
|
||||
if (nextDistance > 0) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setPullToLoadDistance(nextDistance);
|
||||
setIsPullToLoadArmed(nextDistance >= 52);
|
||||
}, [viewportRef]);
|
||||
|
||||
const resetPullToLoad = useCallback(() => {
|
||||
touchPullActiveRef.current = false;
|
||||
touchStartYRef.current = null;
|
||||
setPullToLoadDistance(0);
|
||||
setIsPullToLoadArmed(false);
|
||||
}, []);
|
||||
|
||||
const handleViewportTouchEnd = useCallback(() => {
|
||||
const shouldLoadOlder = touchPullActiveRef.current && isPullToLoadArmed && hasOlderMessages && !isLoadingOlderMessages;
|
||||
|
||||
resetPullToLoad();
|
||||
|
||||
if (shouldLoadOlder) {
|
||||
void onLoadOlderMessages();
|
||||
}
|
||||
}, [hasOlderMessages, isLoadingOlderMessages, isPullToLoadArmed, onLoadOlderMessages, resetPullToLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState === 'disconnected') {
|
||||
clearSystemStatusTimer();
|
||||
setActiveSystemStatus('워크서버 연결이 끊어졌습니다. 실제 처리 상태를 다시 확인하는 중입니다.');
|
||||
setIsSystemStatusPending(false);
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
if (activeRuntimeStatus) {
|
||||
clearSystemStatusTimer();
|
||||
setActiveSystemStatus(activeRuntimeStatus);
|
||||
setIsSystemStatusPending(true);
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
if (activeConversation?.currentJobStatus && !runtimeSnapshot) {
|
||||
clearSystemStatusTimer();
|
||||
setActiveSystemStatus(mapJobStatusLabel(activeConversation));
|
||||
setIsSystemStatusPending(
|
||||
activeConversation.currentJobStatus === 'queued' || activeConversation.currentJobStatus === 'started',
|
||||
);
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
if (activeQueuedComposerRequestsCount > 0) {
|
||||
clearSystemStatusTimer();
|
||||
setActiveSystemStatus(`실제 대기열 ${activeQueuedComposerRequestsCount}건`);
|
||||
setIsSystemStatusPending(true);
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
clearSystemStatusTimer();
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
|
||||
if (!latestMessage || isActivityLogMessage(latestMessage) || latestMessage.author !== 'system') {
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
const isTerminalStatus =
|
||||
latestMessage.text.startsWith('요청 처리가 끝났습니다.') ||
|
||||
latestMessage.text.startsWith('즉시 요청 처리가 끝났습니다.') ||
|
||||
latestMessage.text.startsWith('요청 처리 중 오류가 발생했습니다.');
|
||||
|
||||
const nextStatus = mapSystemStatusMessage(latestMessage.text);
|
||||
if (!nextStatus || isTerminalStatus) {
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
setActiveSystemStatus(nextStatus);
|
||||
setIsSystemStatusPending(true);
|
||||
|
||||
return clearSystemStatusTimer;
|
||||
}, [
|
||||
activeConversation,
|
||||
activeQueuedComposerRequestsCount,
|
||||
activeRuntimeStatus,
|
||||
clearSystemStatusTimer,
|
||||
connectionState,
|
||||
isActivityLogMessage,
|
||||
mapJobStatusLabel,
|
||||
mapSystemStatusMessage,
|
||||
messages,
|
||||
runtimeSnapshot,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSystemStatusTimer();
|
||||
|
||||
if (restoreAutoScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [clearSystemStatusTimer]);
|
||||
|
||||
return {
|
||||
activeSystemStatus,
|
||||
captureViewportRestoreSnapshot,
|
||||
handleViewportScroll,
|
||||
isSystemStatusPending,
|
||||
pendingViewportRestoreRef,
|
||||
scrollViewportToBottom,
|
||||
setActiveSystemStatus,
|
||||
setIsSystemStatusPending,
|
||||
setShowScrollToBottom,
|
||||
shouldStickToBottomRef,
|
||||
showScrollToBottom,
|
||||
handleViewportTouchEnd,
|
||||
handleViewportTouchMove,
|
||||
handleViewportTouchStart,
|
||||
isPullToLoadArmed,
|
||||
pullToLoadDistance,
|
||||
queueViewportPrependRestore,
|
||||
};
|
||||
}
|
||||
171
src/app/main/chatV2/hooks/useNotificationCenterData.ts
Normal file
171
src/app/main/chatV2/hooks/useNotificationCenterData.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
NOTIFICATION_MESSAGES_UPDATED_EVENT,
|
||||
deleteNotificationMessage,
|
||||
fetchNotificationMessage,
|
||||
fetchNotificationMessages,
|
||||
updateNotificationMessageReadState,
|
||||
type NotificationMessageItem,
|
||||
} from '../../notificationApi';
|
||||
import { useUnreadCounts } from './useUnreadCounts';
|
||||
|
||||
function mergeMessageItem(items: NotificationMessageItem[], nextItem: NotificationMessageItem) {
|
||||
const hasItem = items.some((item) => item.id === nextItem.id);
|
||||
|
||||
if (!hasItem) {
|
||||
return [nextItem, ...items];
|
||||
}
|
||||
|
||||
return items.map((item) => (item.id === nextItem.id ? nextItem : item));
|
||||
}
|
||||
|
||||
export function useNotificationCenterData(drawerOpen: boolean) {
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<NotificationMessageItem[]>([]);
|
||||
const [selectedMessage, setSelectedMessage] = useState<NotificationMessageItem | null>(null);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [toggleReadLoading, setToggleReadLoading] = useState(false);
|
||||
const [deletingMessageId, setDeletingMessageId] = useState<number | null>(null);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const { notificationUnreadCount: unreadCount, refreshNotificationUnreadCount } = useUnreadCounts();
|
||||
|
||||
const refreshUnreadCount = async () => {
|
||||
await refreshNotificationUnreadCount();
|
||||
};
|
||||
|
||||
const loadMessages = async () => {
|
||||
setListLoading(true);
|
||||
setListError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchNotificationMessages({ limit: 30 });
|
||||
setMessages(response.items);
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : '알림 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyUpdatedMessage = (nextItem: NotificationMessageItem) => {
|
||||
setSelectedMessage(nextItem);
|
||||
setMessages((current) => {
|
||||
const wasUnread = current.find((item) => item.id === nextItem.id)?.read === false;
|
||||
const nextItems = mergeMessageItem(current, nextItem);
|
||||
|
||||
if (wasUnread !== nextItem.read) {
|
||||
void refreshNotificationUnreadCount();
|
||||
}
|
||||
|
||||
return nextItems;
|
||||
});
|
||||
};
|
||||
|
||||
const openMessageDetail = async (id: number) => {
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
setDetailError(null);
|
||||
setSelectedMessage(null);
|
||||
|
||||
try {
|
||||
const detail = await fetchNotificationMessage(id);
|
||||
setSelectedMessage(detail);
|
||||
|
||||
if (!detail.read) {
|
||||
const updated = await updateNotificationMessageReadState(id, true);
|
||||
applyUpdatedMessage(updated);
|
||||
}
|
||||
} catch (error) {
|
||||
setDetailError(error instanceof Error ? error.message : '알림 상세를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleReadState = async () => {
|
||||
if (!selectedMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
setToggleReadLoading(true);
|
||||
|
||||
try {
|
||||
const updated = await updateNotificationMessageReadState(selectedMessage.id, !selectedMessage.read);
|
||||
applyUpdatedMessage(updated);
|
||||
} catch (error) {
|
||||
setDetailError(error instanceof Error ? error.message : '읽음 상태를 변경하지 못했습니다.');
|
||||
} finally {
|
||||
setToggleReadLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async (id: number) => {
|
||||
if (deletingMessageId === id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingMessageId(id);
|
||||
setListError(null);
|
||||
|
||||
try {
|
||||
await deleteNotificationMessage(id);
|
||||
setMessages((current) => current.filter((item) => item.id !== id));
|
||||
void refreshNotificationUnreadCount();
|
||||
|
||||
if (selectedMessage?.id === id) {
|
||||
setSelectedMessage(null);
|
||||
setDetailOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : '알림을 삭제하지 못했습니다.');
|
||||
} finally {
|
||||
setDeletingMessageId((current) => (current === id ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshUnreadCount();
|
||||
|
||||
const handleMessagesUpdated = () => {
|
||||
void refreshUnreadCount();
|
||||
|
||||
if (drawerOpen) {
|
||||
void loadMessages();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(NOTIFICATION_MESSAGES_UPDATED_EVENT, handleMessagesUpdated);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(NOTIFICATION_MESSAGES_UPDATED_EVENT, handleMessagesUpdated);
|
||||
};
|
||||
}, [drawerOpen, refreshNotificationUnreadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadMessages();
|
||||
}, [drawerOpen]);
|
||||
|
||||
return {
|
||||
unreadCount,
|
||||
detailOpen,
|
||||
setDetailOpen,
|
||||
messages,
|
||||
selectedMessage,
|
||||
listLoading,
|
||||
detailLoading,
|
||||
toggleReadLoading,
|
||||
deletingMessageId,
|
||||
listError,
|
||||
detailError,
|
||||
loadMessages,
|
||||
openMessageDetail,
|
||||
handleToggleReadState,
|
||||
handleDeleteMessage,
|
||||
};
|
||||
}
|
||||
1
src/app/main/chatV2/hooks/useNotificationController.ts
Normal file
1
src/app/main/chatV2/hooks/useNotificationController.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useNotificationCenterData as useNotificationController } from './useNotificationCenterData';
|
||||
1
src/app/main/chatV2/hooks/useRuntimeController.ts
Normal file
1
src/app/main/chatV2/hooks/useRuntimeController.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useRuntimeData as useRuntimeController } from './useRuntimeData';
|
||||
94
src/app/main/chatV2/hooks/useRuntimeData.ts
Normal file
94
src/app/main/chatV2/hooks/useRuntimeData.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { chatConnectionGateway } from '../data/chatConnectionGateway';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type { ChatRuntimeJobDetail, ChatRuntimeSnapshot } from '../../mainChatPanel/types';
|
||||
|
||||
type UseRuntimeDataOptions = {
|
||||
activeSessionId: string;
|
||||
isDeferringAuxiliaryChatRequests: boolean;
|
||||
};
|
||||
|
||||
export function useRuntimeData({
|
||||
activeSessionId,
|
||||
isDeferringAuxiliaryChatRequests,
|
||||
}: UseRuntimeDataOptions) {
|
||||
const [runtimeSnapshot, setRuntimeSnapshot] = useState<ChatRuntimeSnapshot | null>(null);
|
||||
const [runtimeJobDetail, setRuntimeJobDetail] = useState<ChatRuntimeJobDetail | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const loadRuntimeSnapshot = async () => {
|
||||
try {
|
||||
const snapshot = await chatGateway.fetchRuntimeSnapshot();
|
||||
|
||||
if (!isCancelled) {
|
||||
setRuntimeSnapshot(snapshot);
|
||||
chatConnectionGateway.setSharedRuntimeSnapshot(snapshot);
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setRuntimeSnapshot(null);
|
||||
chatConnectionGateway.setSharedRuntimeSnapshot(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadRuntimeSnapshot();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSessionId.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDeferringAuxiliaryChatRequests) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
const syncRuntimeSnapshotForActiveSession = async () => {
|
||||
try {
|
||||
const snapshot = await chatGateway.fetchRuntimeSnapshot();
|
||||
|
||||
if (!isCancelled) {
|
||||
setRuntimeSnapshot(snapshot);
|
||||
chatConnectionGateway.setSharedRuntimeSnapshot(snapshot);
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setRuntimeSnapshot(null);
|
||||
chatConnectionGateway.setSharedRuntimeSnapshot(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void syncRuntimeSnapshotForActiveSession();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [activeSessionId, isDeferringAuxiliaryChatRequests]);
|
||||
|
||||
const handleRuntimeEvent = (snapshot: ChatRuntimeSnapshot) => {
|
||||
setRuntimeSnapshot(snapshot);
|
||||
chatConnectionGateway.setSharedRuntimeSnapshot(snapshot);
|
||||
};
|
||||
|
||||
const handleRuntimeDetailEvent = (detail: ChatRuntimeJobDetail) => {
|
||||
setRuntimeJobDetail(detail);
|
||||
};
|
||||
|
||||
return {
|
||||
runtimeSnapshot,
|
||||
runtimeJobDetail,
|
||||
setRuntimeSnapshot,
|
||||
handleRuntimeEvent,
|
||||
handleRuntimeDetailEvent,
|
||||
};
|
||||
}
|
||||
143
src/app/main/chatV2/hooks/useUnreadCounts.ts
Normal file
143
src/app/main/chatV2/hooks/useUnreadCounts.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
fetchNotificationMessages,
|
||||
NOTIFICATION_MESSAGES_UPDATED_EVENT,
|
||||
} from '../../notificationApi';
|
||||
import {
|
||||
CHAT_CONVERSATIONS_UPDATED_EVENT,
|
||||
readChatConversationsUpdatedEvent,
|
||||
} from '../data/chatClientEvents';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
|
||||
type UseUnreadCountsResult = {
|
||||
chatUnreadCount: number;
|
||||
notificationUnreadCount: number;
|
||||
refreshChatUnreadCount: () => Promise<void>;
|
||||
refreshNotificationUnreadCount: () => Promise<void>;
|
||||
};
|
||||
|
||||
type SharedUnreadCountsState = {
|
||||
chatUnreadCount: number;
|
||||
notificationUnreadCount: number;
|
||||
hasLoaded: boolean;
|
||||
chatRequestPromise: Promise<void> | null;
|
||||
notificationRequestPromise: Promise<void> | null;
|
||||
subscribers: Set<() => void>;
|
||||
};
|
||||
|
||||
const sharedUnreadCountsState: SharedUnreadCountsState = {
|
||||
chatUnreadCount: 0,
|
||||
notificationUnreadCount: 0,
|
||||
hasLoaded: false,
|
||||
chatRequestPromise: null,
|
||||
notificationRequestPromise: null,
|
||||
subscribers: new Set(),
|
||||
};
|
||||
|
||||
function emitUnreadCounts() {
|
||||
sharedUnreadCountsState.subscribers.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeUnreadCounts(listener: () => void) {
|
||||
sharedUnreadCountsState.subscribers.add(listener);
|
||||
|
||||
return () => {
|
||||
sharedUnreadCountsState.subscribers.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshChatUnreadCount() {
|
||||
if (sharedUnreadCountsState.chatRequestPromise) {
|
||||
return sharedUnreadCountsState.chatRequestPromise;
|
||||
}
|
||||
|
||||
sharedUnreadCountsState.chatRequestPromise = (async () => {
|
||||
try {
|
||||
const items = await chatGateway.listConversations();
|
||||
sharedUnreadCountsState.chatUnreadCount = items.filter((item) => item.hasUnreadResponse).length;
|
||||
} catch {
|
||||
sharedUnreadCountsState.chatUnreadCount = 0;
|
||||
} finally {
|
||||
sharedUnreadCountsState.chatRequestPromise = null;
|
||||
emitUnreadCounts();
|
||||
}
|
||||
})();
|
||||
|
||||
return sharedUnreadCountsState.chatRequestPromise;
|
||||
}
|
||||
|
||||
async function refreshNotificationUnreadCount() {
|
||||
if (sharedUnreadCountsState.notificationRequestPromise) {
|
||||
return sharedUnreadCountsState.notificationRequestPromise;
|
||||
}
|
||||
|
||||
sharedUnreadCountsState.notificationRequestPromise = (async () => {
|
||||
try {
|
||||
const response = await fetchNotificationMessages({ limit: 1 });
|
||||
sharedUnreadCountsState.notificationUnreadCount = response.unreadCount;
|
||||
} catch {
|
||||
// Keep the previous badge count when the server is temporarily unavailable.
|
||||
} finally {
|
||||
sharedUnreadCountsState.notificationRequestPromise = null;
|
||||
emitUnreadCounts();
|
||||
}
|
||||
})();
|
||||
|
||||
return sharedUnreadCountsState.notificationRequestPromise;
|
||||
}
|
||||
|
||||
export function useUnreadCounts(): UseUnreadCountsResult {
|
||||
const [chatUnreadCount, setChatUnreadCount] = useState(sharedUnreadCountsState.chatUnreadCount);
|
||||
const [notificationUnreadCount, setNotificationUnreadCount] = useState(sharedUnreadCountsState.notificationUnreadCount);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeUnreadCounts(() => {
|
||||
setChatUnreadCount(sharedUnreadCountsState.chatUnreadCount);
|
||||
setNotificationUnreadCount(sharedUnreadCountsState.notificationUnreadCount);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sharedUnreadCountsState.hasLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedUnreadCountsState.hasLoaded = true;
|
||||
void refreshChatUnreadCount();
|
||||
void refreshNotificationUnreadCount();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleNotificationMessagesUpdated = () => {
|
||||
void refreshNotificationUnreadCount();
|
||||
};
|
||||
|
||||
const handleChatConversationsUpdated = (event: Event) => {
|
||||
const detail = readChatConversationsUpdatedEvent(event);
|
||||
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedUnreadCountsState.chatUnreadCount = detail.items.filter((item) => item.hasUnreadResponse).length;
|
||||
emitUnreadCounts();
|
||||
};
|
||||
|
||||
window.addEventListener(NOTIFICATION_MESSAGES_UPDATED_EVENT, handleNotificationMessagesUpdated);
|
||||
window.addEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleChatConversationsUpdated as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(NOTIFICATION_MESSAGES_UPDATED_EVENT, handleNotificationMessagesUpdated);
|
||||
window.removeEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleChatConversationsUpdated as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
chatUnreadCount,
|
||||
notificationUnreadCount,
|
||||
refreshChatUnreadCount,
|
||||
refreshNotificationUnreadCount,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user