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

815 lines
27 KiB
TypeScript

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<string> };
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,
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<boolean>;
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<ComposerFilePickResult>({ items: [] }));
const activeComposerUploadCountRef = useRef(0);
const latestComposerUploadAttemptByKeyRef = useRef(new Map<string, string>());
const activeComposerSubmissionKeyRef = useRef<string | null>(null);
const recentComposerSubmissionRef = useRef<RecentComposerSubmission | null>(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<ComposerFilePickResult> => {
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<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) => {
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,
};
}