Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View File

@@ -0,0 +1,403 @@
import { useCallback } from 'react';
import { chatGateway } from '../data/chatGateway';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
type PendingChatRequest = {
sessionId: string;
requestId: string;
text: string;
mode: 'queue' | 'direct';
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
retryCount: number;
failed: boolean;
};
type PendingContextConfirm = {
mode: 'queue' | 'direct';
text: string;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
includedContextCount: number;
omittedContextCount: number;
};
type SelectedChatType = {
id: string;
name: string;
description: string;
isTemplate: boolean;
} | 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;
setStoredChatSessionLastTypeId: (sessionId: string, chatTypeId: string) => 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,
setStoredChatSessionLastTypeId,
upsertRequestItem,
syncConversationPreviewForRequest,
updatePendingMessageStatus,
createLocalMessage,
createChatMessage,
createActivityLogPlaceholder,
buildOutgoingMessageText,
summarizeRecentContext,
mergeComposerAttachments,
sendChatRequest,
scrollViewportToBottom,
}: UseConversationComposerControllerOptions) {
const handleComposerFilesPicked = useCallback(
async (files: File[]) => {
if (files.length === 0 || isComposerAttachmentUploading) {
return;
}
setIsComposerAttachmentUploading(true);
const uploadResults = await Promise.allSettled(
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
);
const uploadedItems: ChatComposerAttachment[] = [];
const failedFileNames: string[] = [];
uploadResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
uploadedItems.push(result.value);
return;
}
failedFileNames.push(files[index]?.name || `파일 ${index + 1}`);
});
if (uploadedItems.length > 0) {
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
}
if (failedFileNames.length > 0) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage(`파일 업로드에 실패했습니다: ${failedFileNames.join(', ')}`),
]);
}
setIsComposerAttachmentUploading(false);
},
[
activeSessionId,
createLocalMessage,
isComposerAttachmentUploading,
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, chatTypeIsTemplate } = request;
const requestId = `client-${Date.now().toString(36)}`;
const outgoingRequest: PendingChatRequest = {
sessionId: activeSessionId,
requestId,
text,
mode,
chatTypeId,
chatTypeLabel,
chatTypeDescription,
chatTypeIsTemplate,
retryCount: 0,
failed: false,
};
setStoredChatSessionLastTypeId(activeSessionId, chatTypeId);
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,
setStoredChatSessionLastTypeId,
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;
}
if (!selectedChatType.isTemplate) {
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,
chatTypeIsTemplate: false,
includedContextCount: recentContext.includedCount,
omittedContextCount: recentContext.omittedCount,
});
return;
}
}
executeSendMessage({
mode,
text: trimmed,
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
chatTypeIsTemplate: selectedChatType.isTemplate,
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,
};
}