import { useCallback, useRef } from 'react'; import { chatGateway } from '../data/chatGateway'; import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage, ChatPromptContextRef } from '../../mainChatPanel/types'; import { buildComposerFilePickKey } from '../../mainChatPanel/composerFilePickKey'; import { shouldSkipContextConfirmForSessionToday } from '../../mainChatPanel/contextConfirmPreference'; export type ComposerFilePickResult = { items: { key: string; fileName: string; status: 'uploaded' | 'failed'; reason?: string; }[]; }; type PendingChatRequest = { sessionId: string; requestId: string; text: string; mode: 'queue' | 'direct'; origin?: 'composer' | 'prompt'; parentRequestId?: string | null; promptContextRef?: ChatPromptContextRef | null; omitPromptHistory?: boolean; codexModel: string; chatTypeId: string; chatTypeLabel: string; chatTypeDescription: string; chatTypeBaseDescription?: string; defaultContextIds?: string[]; defaultContexts?: Array<{ id: string; title: string; content: string; }>; customContextTitle?: string | null; customContextContent?: string | null; retryCount: number; failed: boolean; }; type PendingContextConfirm = { sessionId: string; mode: 'queue' | 'direct'; text: string; origin?: 'composer' | 'prompt'; parentRequestId?: string | null; promptContextRef?: ChatPromptContextRef | null; codexModel: string; chatTypeId: string; chatTypeLabel: string; chatTypeDescription: string; chatTypeBaseDescription?: string; defaultContextIds?: string[]; defaultContexts?: Array<{ id: string; title: string; content: string; }>; customContextTitle?: string | null; customContextContent?: string | null; includedContextCount: number; omittedContextCount: number; omitPromptHistory?: boolean; }; type SelectedChatType = { id: string; name: string; description: string; baseDescription?: string; defaultContextIds?: string[]; defaultContexts?: Array<{ id: string; title: string; content: string; }>; customContextTitle?: string | null; customContextContent?: string | null; } | null; type RecentContextSummary = { includedCount: number; omittedCount: number; }; type UseConversationComposerControllerOptions = { activeSessionId: string; appConfigChat: { maxContextMessages: number; maxContextChars: number; }; getDraft: () => string; composerAttachments: ChatComposerAttachment[]; isComposerAttachmentUploading: boolean; selectedCodexModel: string; selectedChatType: SelectedChatType; socketRef: { current: WebSocket | null }; composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null }; messagesRef: { current: ChatMessage[] }; pendingRequestsRef: { current: PendingChatRequest[] }; promptRequestIdsRef?: { current: Set }; 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, options?: { requestId?: string; requestOrigin?: 'composer' | 'prompt'; mode?: 'queue' | 'direct'; queueSize?: number; jobMessage?: string | null; }, ) => 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[]; ensureSessionReady?: (sessionId: string) => Promise; sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void; scrollViewportToBottom: () => void; releaseAutoScrollSuspension: () => void; }; type SendMessageOptions = { sessionId?: string; mode: 'queue' | 'direct'; draftText?: string; }; export type SendMessageResult = 'sent' | 'pending' | 'blocked'; const COMPOSER_SUBMISSION_DEDUP_WINDOW_MS = 1200; type RecentComposerSubmission = { key: string; submittedAt: number; }; function createClientRequestId() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return `client-${crypto.randomUUID()}`; } return `client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } export function useConversationComposerController({ activeSessionId, appConfigChat, getDraft, composerAttachments, isComposerAttachmentUploading, selectedCodexModel, selectedChatType, socketRef, composerRef, messagesRef, pendingRequestsRef, promptRequestIdsRef, shouldStickToBottomRef, setDraft, setComposerAttachments, setIsComposerAttachmentUploading, setMessages, setActiveSystemStatus, setIsSystemStatusPending, setShowScrollToBottom, setPendingContextConfirm, upsertRequestItem, syncConversationPreviewForRequest, updatePendingMessageStatus, createLocalMessage, createChatMessage, createActivityLogPlaceholder, buildOutgoingMessageText, summarizeRecentContext, mergeComposerAttachments, ensureSessionReady, sendChatRequest, scrollViewportToBottom, releaseAutoScrollSuspension, }: UseConversationComposerControllerOptions) { const composerUploadQueueRef = useRef(Promise.resolve({ items: [] })); const activeComposerUploadCountRef = useRef(0); const latestComposerUploadAttemptByKeyRef = useRef(new Map()); const activeComposerSubmissionKeyRef = useRef(null); const recentComposerSubmissionRef = useRef(null); const isSocketOpen = useCallback(() => { return Boolean(socketRef.current && socketRef.current.readyState === WebSocket.OPEN); }, [socketRef]); const buildComposerSubmissionKey = useCallback( ({ sessionId, mode, text, codexModel, chatTypeId, parentRequestId, omitPromptHistory, }: { sessionId: string; mode: 'queue' | 'direct'; text: string; codexModel: string; chatTypeId: string; parentRequestId?: string | null; omitPromptHistory?: boolean; }) => JSON.stringify({ sessionId: sessionId.trim(), mode, text: text.trim(), codexModel: codexModel.trim(), chatTypeId: chatTypeId.trim(), parentRequestId: parentRequestId?.trim() || null, omitPromptHistory: omitPromptHistory === true, }), [], ); const handleComposerFilesPicked = useCallback( async (files: File[]): Promise => { if (files.length === 0) { return { items: [] }; } const batchAttemptId = `composer-upload-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const fileKeys = files.map((file) => buildComposerFilePickKey(file)); fileKeys.forEach((key) => { latestComposerUploadAttemptByKeyRef.current.set(key, batchAttemptId); }); 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) => { const fileKey = fileKeys[index]; if (!fileKey || latestComposerUploadAttemptByKeyRef.current.get(fileKey) !== batchAttemptId) { return; } 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, latestComposerUploadAttemptByKeyRef, mergeComposerAttachments, setComposerAttachments, setIsComposerAttachmentUploading, setMessages, setShowScrollToBottom, shouldStickToBottomRef, ], ); const focusComposerAfterSend = useCallback(() => { window.setTimeout(() => { composerRef.current?.focus({ cursor: 'end' }); }, 0); }, [composerRef]); const scheduleViewportBottomSyncAfterSend = useCallback(() => { releaseAutoScrollSuspension(); shouldStickToBottomRef.current = true; setShowScrollToBottom(false); window.requestAnimationFrame(() => { scrollViewportToBottom(); window.requestAnimationFrame(() => { scrollViewportToBottom(); }); }); }, [releaseAutoScrollSuspension, scrollViewportToBottom, setShowScrollToBottom, shouldStickToBottomRef]); const handleExecuteSendError = useCallback( (error: unknown) => { const reason = error instanceof Error && error.message.trim() ? error.message.trim() : '요청 전송 중 오류가 발생했습니다.'; setActiveSystemStatus(null); setIsSystemStatusPending(false); setMessages((previous) => [...previous.slice(-39), createLocalMessage(reason)]); }, [createLocalMessage, setActiveSystemStatus, setIsSystemStatusPending, setMessages], ); const executeSendMessage = useCallback( async (request: PendingContextConfirm) => { const { sessionId, mode, text, origin, parentRequestId, promptContextRef, chatTypeId, codexModel, chatTypeLabel, chatTypeDescription, chatTypeBaseDescription, defaultContextIds, defaultContexts, customContextTitle, customContextContent, omitPromptHistory, } = request; const requestChatTypeId = chatTypeId.trim(); const requestChatTypeLabel = chatTypeLabel.trim() || requestChatTypeId || '기본 요청'; const targetSessionId = sessionId.trim() || activeSessionId.trim(); const submissionKey = buildComposerSubmissionKey({ mode, text, codexModel, chatTypeId: requestChatTypeId, parentRequestId, omitPromptHistory, sessionId: targetSessionId, }); if (origin !== 'prompt' && activeComposerSubmissionKeyRef.current === submissionKey) { return false; } if (origin !== 'prompt') { const recentComposerSubmission = recentComposerSubmissionRef.current; if ( recentComposerSubmission && recentComposerSubmission.key === submissionKey && Date.now() - recentComposerSubmission.submittedAt < COMPOSER_SUBMISSION_DEDUP_WINDOW_MS ) { return false; } } if (origin !== 'prompt') { activeComposerSubmissionKeyRef.current = submissionKey; recentComposerSubmissionRef.current = { key: submissionKey, submittedAt: Date.now(), }; } const shouldOptimisticallyClearComposer = origin !== 'prompt'; const previousDraft = shouldOptimisticallyClearComposer ? getDraft() : ''; const previousAttachments = shouldOptimisticallyClearComposer ? composerAttachments : []; let composerRestoreNeeded = shouldOptimisticallyClearComposer; const restoreComposerOnFailure = () => { if (!composerRestoreNeeded) { return; } composerRestoreNeeded = false; if (!getDraft().trim()) { setDraft(previousDraft); } setComposerAttachments((current) => { if (current.length > 0 || previousAttachments.length === 0) { return current; } return previousAttachments; }); }; try { if (shouldOptimisticallyClearComposer) { setDraft(''); setComposerAttachments([]); focusComposerAfterSend(); } if (!targetSessionId) { restoreComposerOnFailure(); setActiveSystemStatus(null); setIsSystemStatusPending(false); return false; } if (ensureSessionReady) { setActiveSystemStatus('새 채팅방 준비 중...'); setIsSystemStatusPending(true); const isSessionReady = await ensureSessionReady(targetSessionId); if (!isSessionReady) { restoreComposerOnFailure(); setActiveSystemStatus(null); setIsSystemStatusPending(false); return false; } } if (!isSocketOpen()) { restoreComposerOnFailure(); setActiveSystemStatus(null); setIsSystemStatusPending(false); setMessages((previous) => [ ...previous.slice(-39), createLocalMessage('워크서버 연결이 아직 준비되지 않았습니다. 연결이 복구된 뒤 다시 전송해 주세요.'), ]); return false; } const requestId = createClientRequestId(); const outgoingRequest: PendingChatRequest = { sessionId: targetSessionId, requestId, text, mode, origin: origin ?? 'composer', parentRequestId: parentRequestId?.trim() || null, promptContextRef: promptContextRef ?? null, omitPromptHistory: omitPromptHistory === true, codexModel, chatTypeId: requestChatTypeId, chatTypeLabel: requestChatTypeLabel, chatTypeDescription, chatTypeBaseDescription, defaultContextIds, defaultContexts, customContextTitle, customContextContent, retryCount: 0, failed: false, }; if (origin === 'prompt') { promptRequestIdsRef?.current.add(requestId); } 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: targetSessionId, requestId, chatTypeId: requestChatTypeId, chatTypeLabel: requestChatTypeLabel, requestOrigin: origin ?? 'composer', parentRequestId: parentRequestId?.trim() || null, status: 'queued', statusMessage: '대기열 등록', userMessageId: optimisticUserMessage.id, userText: text, responseMessageId: null, responseText: '', hasResponse: false, canDelete: false, createdAt: queuedAt, updatedAt: queuedAt, answeredAt: null, terminalAt: null, }); syncConversationPreviewForRequest(targetSessionId, text, queuedAt, { requestId, requestOrigin: origin ?? 'composer', mode: 'queue', queueSize: 1, jobMessage: '대기열 등록 중', }); shouldStickToBottomRef.current = true; setShowScrollToBottom(false); setMessages((previous) => { const nextMessages = [...previous, optimisticUserMessage]; return optimisticActivityMessage ? [...nextMessages, optimisticActivityMessage] : nextMessages; }); scheduleViewportBottomSyncAfterSend(); setActiveSystemStatus('대기열 등록 중...'); setIsSystemStatusPending(true); } else { const optimisticUserMessage: ChatMessage = { ...createChatMessage('user', text, requestId), deliveryStatus: null, retryCount: 0, }; const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [ '# 상태: 즉시 요청을 접수했습니다.', '# 진행: 요청 내용을 정리한 뒤 답변을 준비합니다.', ]); upsertRequestItem({ sessionId: targetSessionId, requestId, chatTypeId: requestChatTypeId, chatTypeLabel: requestChatTypeLabel, requestOrigin: origin ?? 'composer', parentRequestId: parentRequestId?.trim() || null, 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, }); syncConversationPreviewForRequest(targetSessionId, text, new Date().toISOString(), { requestId, requestOrigin: origin ?? 'composer', mode: 'direct', queueSize: 0, jobMessage: '즉시 요청 실행 대기 중', }); shouldStickToBottomRef.current = true; setShowScrollToBottom(false); setMessages((previous) => { const nextMessages = [...previous, optimisticUserMessage]; return optimisticActivityMessage ? [...nextMessages, optimisticActivityMessage] : nextMessages; }); scheduleViewportBottomSyncAfterSend(); 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); } composerRestoreNeeded = false; return true; } try { sendChatRequest(socketRef.current, outgoingRequest); } catch { setActiveSystemStatus('전송 재시도 중...'); pendingRequestsRef.current = [ ...pendingRequestsRef.current.filter((pending) => pending.requestId !== requestId), outgoingRequest, ]; if (mode === 'direct') { updatePendingMessageStatus(requestId, 'retrying', 0); } } composerRestoreNeeded = false; return true; } catch (error) { restoreComposerOnFailure(); throw error; } finally { if (origin !== 'prompt' && activeComposerSubmissionKeyRef.current === submissionKey) { activeComposerSubmissionKeyRef.current = null; } } }, [ activeSessionId, buildComposerSubmissionKey, composerAttachments, createActivityLogPlaceholder, createChatMessage, createLocalMessage, ensureSessionReady, focusComposerAfterSend, getDraft, isSocketOpen, pendingRequestsRef, promptRequestIdsRef, scheduleViewportBottomSyncAfterSend, sendChatRequest, setActiveSystemStatus, setComposerAttachments, setDraft, setIsSystemStatusPending, setMessages, setShowScrollToBottom, shouldStickToBottomRef, socketRef, syncConversationPreviewForRequest, updatePendingMessageStatus, upsertRequestItem, ], ); const sendMessage = useCallback( ({ sessionId, mode, draftText }: SendMessageOptions): SendMessageResult => { if (isComposerAttachmentUploading) { return 'blocked'; } const trimmed = buildOutgoingMessageText(draftText ?? getDraft(), composerAttachments).trim(); if (!trimmed) { return 'blocked'; } if (!selectedChatType) { setMessages((previous) => [ ...previous.slice(-39), createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'), ]); return 'blocked'; } if (!isSocketOpen()) { setMessages((previous) => [ ...previous.slice(-39), createLocalMessage('워크서버 연결이 아직 준비되지 않았습니다. 연결이 복구된 뒤 다시 전송해 주세요.'), ]); return 'blocked'; } const recentContext = summarizeRecentContext( messagesRef.current, appConfigChat.maxContextMessages, appConfigChat.maxContextChars, ); if (recentContext.omittedCount > 0) { const targetSessionId = sessionId?.trim() || activeSessionId.trim(); const nextRequest = { sessionId: targetSessionId, mode, text: trimmed, codexModel: selectedCodexModel, chatTypeId: selectedChatType.id, chatTypeLabel: selectedChatType.name, chatTypeDescription: selectedChatType.description, chatTypeBaseDescription: selectedChatType.baseDescription, defaultContextIds: selectedChatType.defaultContextIds, defaultContexts: selectedChatType.defaultContexts, customContextTitle: selectedChatType.customContextTitle, customContextContent: selectedChatType.customContextContent, includedContextCount: recentContext.includedCount, omittedContextCount: recentContext.omittedCount, } satisfies PendingContextConfirm; if (shouldSkipContextConfirmForSessionToday(targetSessionId)) { void executeSendMessage(nextRequest).catch(handleExecuteSendError); return 'sent'; } setPendingContextConfirm({ sessionId: targetSessionId, mode, text: trimmed, codexModel: selectedCodexModel, chatTypeId: selectedChatType.id, chatTypeLabel: selectedChatType.name, chatTypeDescription: selectedChatType.description, chatTypeBaseDescription: selectedChatType.baseDescription, defaultContextIds: selectedChatType.defaultContextIds, defaultContexts: selectedChatType.defaultContexts, customContextTitle: selectedChatType.customContextTitle, customContextContent: selectedChatType.customContextContent, includedContextCount: recentContext.includedCount, omittedContextCount: recentContext.omittedCount, }); return 'pending'; } void executeSendMessage({ sessionId: sessionId?.trim() || activeSessionId.trim(), mode, text: trimmed, codexModel: selectedCodexModel, chatTypeId: selectedChatType.id, chatTypeLabel: selectedChatType.name, chatTypeDescription: selectedChatType.description, chatTypeBaseDescription: selectedChatType.baseDescription, defaultContextIds: selectedChatType.defaultContextIds, defaultContexts: selectedChatType.defaultContexts, customContextTitle: selectedChatType.customContextTitle, customContextContent: selectedChatType.customContextContent, includedContextCount: 0, omittedContextCount: 0, }).catch(handleExecuteSendError); return 'sent'; }, [ appConfigChat.maxContextChars, appConfigChat.maxContextMessages, buildOutgoingMessageText, composerAttachments, createLocalMessage, getDraft, handleExecuteSendError, executeSendMessage, isSocketOpen, isComposerAttachmentUploading, selectedCodexModel, activeSessionId, messagesRef, selectedChatType, setMessages, setPendingContextConfirm, summarizeRecentContext, ], ); const handleSend = useCallback(() => { sendMessage({ mode: 'queue' }); }, [sendMessage]); const handleSendImmediate = useCallback(() => { sendMessage({ mode: 'direct' }); }, [sendMessage]); return { executeSendMessage, handleComposerFilesPicked, handleSend, handleSendImmediate, sendMessage, }; }