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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user