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>; setIsComposerAttachmentUploading: (value: boolean) => void; setMessages: React.Dispatch>; 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({ items: [] })); const activeComposerUploadCountRef = useRef(0); const handleComposerFilesPicked = useCallback( async (files: File[]): Promise => { if (files.length === 0) { return { items: [] }; } const uploadBatch = async (): Promise => { 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, }; }