815 lines
27 KiB
TypeScript
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,
|
|
};
|
|
}
|