445 lines
14 KiB
TypeScript
445 lines
14 KiB
TypeScript
import { useCallback, useRef } from 'react';
|
|
import { chatGateway } from '../data/chatGateway';
|
|
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
|
|
|
export type ComposerFilePickResult = {
|
|
items: {
|
|
key: string;
|
|
fileName: string;
|
|
status: 'uploaded' | 'failed';
|
|
reason?: string;
|
|
}[];
|
|
};
|
|
|
|
function buildComposerFilePickKey(file: File) {
|
|
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
|
|
}
|
|
|
|
type PendingChatRequest = {
|
|
sessionId: string;
|
|
requestId: string;
|
|
text: string;
|
|
mode: 'queue' | 'direct';
|
|
chatTypeId: string;
|
|
chatTypeLabel: string;
|
|
chatTypeDescription: string;
|
|
retryCount: number;
|
|
failed: boolean;
|
|
};
|
|
|
|
type PendingContextConfirm = {
|
|
mode: 'queue' | 'direct';
|
|
text: string;
|
|
chatTypeId: string;
|
|
chatTypeLabel: string;
|
|
chatTypeDescription: string;
|
|
includedContextCount: number;
|
|
omittedContextCount: number;
|
|
};
|
|
|
|
type SelectedChatType = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
} | 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;
|
|
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,
|
|
upsertRequestItem,
|
|
syncConversationPreviewForRequest,
|
|
updatePendingMessageStatus,
|
|
createLocalMessage,
|
|
createChatMessage,
|
|
createActivityLogPlaceholder,
|
|
buildOutgoingMessageText,
|
|
summarizeRecentContext,
|
|
mergeComposerAttachments,
|
|
sendChatRequest,
|
|
scrollViewportToBottom,
|
|
}: UseConversationComposerControllerOptions) {
|
|
const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] }));
|
|
const activeComposerUploadCountRef = useRef(0);
|
|
|
|
const handleComposerFilesPicked = useCallback(
|
|
async (files: File[]): Promise<ComposerFilePickResult> => {
|
|
if (files.length === 0) {
|
|
return { items: [] };
|
|
}
|
|
|
|
const uploadBatch = async (): Promise<ComposerFilePickResult> => {
|
|
activeComposerUploadCountRef.current += 1;
|
|
|
|
if (activeComposerUploadCountRef.current === 1) {
|
|
setIsComposerAttachmentUploading(true);
|
|
}
|
|
|
|
try {
|
|
const uploadResults = await Promise.allSettled(
|
|
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
|
|
);
|
|
const uploadedItems: ChatComposerAttachment[] = [];
|
|
const failedItems: Array<{ fileName: string; reason: string }> = [];
|
|
|
|
uploadResults.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
uploadedItems.push(result.value);
|
|
return;
|
|
}
|
|
|
|
const fileName = files[index]?.name || `파일 ${index + 1}`;
|
|
const reason =
|
|
result.reason instanceof Error && result.reason.message.trim()
|
|
? result.reason.message.trim()
|
|
: '업로드 실패';
|
|
failedItems.push({ fileName, reason });
|
|
});
|
|
|
|
if (uploadedItems.length > 0) {
|
|
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
|
|
shouldStickToBottomRef.current = true;
|
|
setShowScrollToBottom(false);
|
|
}
|
|
|
|
if (failedItems.length > 0) {
|
|
setMessages((previous) => [
|
|
...previous.slice(-39),
|
|
createLocalMessage(
|
|
['파일 업로드에 실패했습니다:', ...failedItems.map((item) => `- ${item.fileName}: ${item.reason}`)].join('\n'),
|
|
),
|
|
]);
|
|
}
|
|
|
|
return {
|
|
items: uploadResults.map((result, index) => ({
|
|
key: buildComposerFilePickKey(files[index] as File),
|
|
fileName: files[index]?.name || `파일 ${index + 1}`,
|
|
status: result.status === 'fulfilled' ? 'uploaded' : 'failed',
|
|
reason:
|
|
result.status === 'fulfilled'
|
|
? undefined
|
|
: result.reason instanceof Error && result.reason.message.trim()
|
|
? result.reason.message.trim()
|
|
: '업로드 실패',
|
|
})),
|
|
};
|
|
} finally {
|
|
activeComposerUploadCountRef.current = Math.max(0, activeComposerUploadCountRef.current - 1);
|
|
|
|
if (activeComposerUploadCountRef.current === 0) {
|
|
setIsComposerAttachmentUploading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const queuedUpload = composerUploadQueueRef.current.then(uploadBatch, uploadBatch);
|
|
composerUploadQueueRef.current = queuedUpload.catch(() => ({ items: [] }));
|
|
return queuedUpload;
|
|
},
|
|
[
|
|
activeSessionId,
|
|
composerUploadQueueRef,
|
|
createLocalMessage,
|
|
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 } = request;
|
|
const requestId = `client-${Date.now().toString(36)}`;
|
|
const outgoingRequest: PendingChatRequest = {
|
|
sessionId: activeSessionId,
|
|
requestId,
|
|
text,
|
|
mode,
|
|
chatTypeId,
|
|
chatTypeLabel,
|
|
chatTypeDescription,
|
|
retryCount: 0,
|
|
failed: false,
|
|
};
|
|
|
|
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,
|
|
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;
|
|
}
|
|
|
|
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,
|
|
includedContextCount: recentContext.includedCount,
|
|
omittedContextCount: recentContext.omittedCount,
|
|
});
|
|
return;
|
|
}
|
|
|
|
executeSendMessage({
|
|
mode,
|
|
text: trimmed,
|
|
chatTypeId: selectedChatType.id,
|
|
chatTypeLabel: selectedChatType.name,
|
|
chatTypeDescription: selectedChatType.description,
|
|
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,
|
|
};
|
|
}
|