Files
ai-code-app/src/app/main/chatV2/hooks/useConversationComposerController.ts

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,
};
}