chore: exclude local resource artifacts from main sync
This commit is contained in:
@@ -39,6 +39,7 @@ export function ConversationRoomPane({
|
||||
|
||||
return (
|
||||
<ChatConversationView
|
||||
sessionId={sessionId}
|
||||
viewportRef={viewportRef}
|
||||
composerRef={composerRef}
|
||||
visibleMessages={messages}
|
||||
@@ -77,6 +78,8 @@ export function ConversationRoomPane({
|
||||
onSend={() => {}}
|
||||
onSendImmediate={() => {}}
|
||||
onToggleSendWithoutContext={() => {}}
|
||||
isImmediateSendPinned={false}
|
||||
onToggleImmediateSendPinned={() => {}}
|
||||
onClearDraft={() => {}}
|
||||
onScrollToBottom={() => {}}
|
||||
onToggleResourceStrip={() => {}}
|
||||
|
||||
@@ -36,8 +36,10 @@ export type ChatGateway = {
|
||||
createConversation: (args: {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
requestBadgeLabel?: string | null;
|
||||
chatTypeId?: string | null;
|
||||
lastChatTypeId?: string | null;
|
||||
generalSectionName?: string | null;
|
||||
contextLabel?: string;
|
||||
contextDescription?: string;
|
||||
notifyOffline?: boolean;
|
||||
@@ -49,6 +51,7 @@ export type ChatGateway = {
|
||||
Pick<
|
||||
ChatConversationSummary,
|
||||
| 'title'
|
||||
| 'requestBadgeLabel'
|
||||
| 'chatTypeId'
|
||||
| 'lastChatTypeId'
|
||||
| 'generalSectionName'
|
||||
|
||||
221
src/app/main/chatV2/hooks/conversationListMerge.ts
Normal file
221
src/app/main/chatV2/hooks/conversationListMerge.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||
import { resolveConversationUnreadMergeState } from '../../mainChatPanel/conversationUnread';
|
||||
|
||||
function shouldPreserveRequestMetadata(
|
||||
previousItem: Pick<ChatConversationSummary, 'currentRequestId'>,
|
||||
nextItem: Pick<ChatConversationSummary, 'currentRequestId'>,
|
||||
) {
|
||||
const previousRequestId = previousItem.currentRequestId?.trim() || '';
|
||||
const nextRequestId = nextItem.currentRequestId?.trim() || '';
|
||||
return Boolean(previousRequestId && previousRequestId === nextRequestId);
|
||||
}
|
||||
|
||||
function toConversationSortTime(value: string | null | undefined) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function getConversationLastMessageSortTime(item: ChatConversationSummary) {
|
||||
const lastMessageTime = toConversationSortTime(item.lastMessageAt);
|
||||
|
||||
if (lastMessageTime > 0) {
|
||||
return lastMessageTime;
|
||||
}
|
||||
|
||||
return Math.max(toConversationSortTime(item.createdAt), toConversationSortTime(item.updatedAt));
|
||||
}
|
||||
|
||||
function pickPreferredConversationSummary(left: ChatConversationSummary, right: ChatConversationSummary) {
|
||||
const leftTime = getConversationLastMessageSortTime(left);
|
||||
const rightTime = getConversationLastMessageSortTime(right);
|
||||
|
||||
if (rightTime !== leftTime) {
|
||||
return rightTime > leftTime ? right : left;
|
||||
}
|
||||
|
||||
const leftUpdatedAt = toConversationSortTime(left.updatedAt);
|
||||
const rightUpdatedAt = toConversationSortTime(right.updatedAt);
|
||||
|
||||
if (rightUpdatedAt !== leftUpdatedAt) {
|
||||
return rightUpdatedAt > leftUpdatedAt ? right : left;
|
||||
}
|
||||
|
||||
return right;
|
||||
}
|
||||
|
||||
function mergeConversationSummaries(existing: ChatConversationSummary, incoming: ChatConversationSummary) {
|
||||
const preferred = pickPreferredConversationSummary(existing, incoming);
|
||||
const fallback = preferred === existing ? incoming : existing;
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
...preferred,
|
||||
clientId: preferred.clientId ?? fallback.clientId,
|
||||
isDraftOnly: preferred.isDraftOnly ?? fallback.isDraftOnly,
|
||||
title: preferred.title.trim() || fallback.title.trim(),
|
||||
requestBadgeLabel: preferred.requestBadgeLabel?.trim() || fallback.requestBadgeLabel?.trim() || null,
|
||||
chatTypeId: preferred.chatTypeId?.trim() || fallback.chatTypeId?.trim() || null,
|
||||
lastChatTypeId: preferred.lastChatTypeId?.trim() || fallback.lastChatTypeId?.trim() || null,
|
||||
generalSectionName: preferred.generalSectionName?.trim() || fallback.generalSectionName?.trim() || null,
|
||||
contextLabel: preferred.contextLabel?.trim() || fallback.contextLabel?.trim() || null,
|
||||
contextDescription: preferred.contextDescription?.trim() || fallback.contextDescription?.trim() || null,
|
||||
notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline,
|
||||
hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming),
|
||||
currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null,
|
||||
currentJobStatus: preferred.currentJobStatus ?? fallback.currentJobStatus,
|
||||
currentJobMessage: preferred.currentJobMessage?.trim() || fallback.currentJobMessage?.trim() || null,
|
||||
currentQueueSize: Math.max(preferred.currentQueueSize ?? 0, fallback.currentQueueSize ?? 0),
|
||||
currentStatusUpdatedAt:
|
||||
preferred.currentStatusUpdatedAt?.trim() || fallback.currentStatusUpdatedAt?.trim() || null,
|
||||
isPendingWork: preferred.isPendingWork ?? fallback.isPendingWork,
|
||||
pendingWorkReason: preferred.pendingWorkReason ?? fallback.pendingWorkReason,
|
||||
lastRequestPreview: preferred.lastRequestPreview.trim() || fallback.lastRequestPreview.trim(),
|
||||
lastMessagePreview: preferred.lastMessagePreview.trim() || fallback.lastMessagePreview.trim(),
|
||||
lastResponsePreview: preferred.lastResponsePreview.trim() || fallback.lastResponsePreview.trim(),
|
||||
createdAt: preferred.createdAt.trim() || fallback.createdAt.trim(),
|
||||
updatedAt: preferred.updatedAt.trim() || fallback.updatedAt.trim(),
|
||||
lastMessageAt: preferred.lastMessageAt?.trim() || fallback.lastMessageAt?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function sortChatConversationSummaries(items: ChatConversationSummary[]) {
|
||||
const dedupedItems = items.reduce<ChatConversationSummary[]>((result, item) => {
|
||||
const sessionId = item.sessionId.trim();
|
||||
|
||||
if (!sessionId) {
|
||||
result.push(item);
|
||||
return result;
|
||||
}
|
||||
|
||||
const existingIndex = result.findIndex((candidate) => candidate.sessionId.trim() === sessionId);
|
||||
|
||||
if (existingIndex < 0) {
|
||||
result.push(item);
|
||||
return result;
|
||||
}
|
||||
|
||||
const nextItems = [...result];
|
||||
nextItems[existingIndex] = mergeConversationSummaries(nextItems[existingIndex] as ChatConversationSummary, item);
|
||||
return nextItems;
|
||||
}, []);
|
||||
|
||||
return dedupedItems.sort((left, right) => {
|
||||
const leftTime = getConversationLastMessageSortTime(left);
|
||||
const rightTime = getConversationLastMessageSortTime(right);
|
||||
|
||||
if (rightTime !== leftTime) {
|
||||
return rightTime - leftTime;
|
||||
}
|
||||
|
||||
return left.sessionId.localeCompare(right.sessionId, 'ko-KR');
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeConversationItemsPreservingRequestedSession(
|
||||
nextItems: ChatConversationSummary[],
|
||||
previousItems: ChatConversationSummary[],
|
||||
requestedSessionId: string,
|
||||
) {
|
||||
const previousBySessionId = new Map(previousItems.map((item) => [item.sessionId, item] as const));
|
||||
const normalizedNextItems = nextItems.map((item) => {
|
||||
const previousItem = previousBySessionId.get(item.sessionId);
|
||||
|
||||
if (!previousItem) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const preserveRequestMetadata = shouldPreserveRequestMetadata(previousItem, item);
|
||||
|
||||
const chatTypeId = item.chatTypeId?.trim() || previousItem.chatTypeId?.trim() || null;
|
||||
const lastChatTypeId =
|
||||
item.lastChatTypeId?.trim() ||
|
||||
chatTypeId ||
|
||||
previousItem.lastChatTypeId?.trim() ||
|
||||
previousItem.chatTypeId?.trim() ||
|
||||
null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
title: preserveRequestMetadata
|
||||
? previousItem.title.trim() || item.title.trim()
|
||||
: item.title.trim() || previousItem.title.trim(),
|
||||
requestBadgeLabel: preserveRequestMetadata
|
||||
? previousItem.requestBadgeLabel?.trim() || item.requestBadgeLabel?.trim() || null
|
||||
: item.requestBadgeLabel?.trim() || previousItem.requestBadgeLabel?.trim() || null,
|
||||
chatTypeId,
|
||||
lastChatTypeId,
|
||||
generalSectionName: item.generalSectionName?.trim() || previousItem.generalSectionName?.trim() || null,
|
||||
contextLabel: item.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
|
||||
contextDescription: item.contextDescription?.trim() || null,
|
||||
lastRequestPreview: item.lastRequestPreview.trim() || previousItem.lastRequestPreview.trim(),
|
||||
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
||||
lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(),
|
||||
currentRequestId:
|
||||
item.currentRequestId?.trim() ||
|
||||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentRequestId : null) ||
|
||||
null,
|
||||
currentJobStatus:
|
||||
item.currentJobStatus ??
|
||||
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
|
||||
? previousItem.currentJobStatus
|
||||
: null),
|
||||
currentJobMessage:
|
||||
item.currentJobMessage?.trim() ||
|
||||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentJobMessage?.trim() : '') ||
|
||||
null,
|
||||
currentQueueSize:
|
||||
item.currentQueueSize > 0
|
||||
? item.currentQueueSize
|
||||
: item.currentJobStatus === 'queued'
|
||||
? Math.max(1, previousItem.currentQueueSize)
|
||||
: previousItem.currentJobStatus === 'queued' && item.currentJobStatus == null
|
||||
? Math.max(1, previousItem.currentQueueSize)
|
||||
: item.currentQueueSize,
|
||||
currentStatusUpdatedAt:
|
||||
item.currentStatusUpdatedAt ||
|
||||
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
|
||||
? previousItem.currentStatusUpdatedAt
|
||||
: null),
|
||||
};
|
||||
});
|
||||
const normalizedRequestedSessionId = requestedSessionId.trim();
|
||||
const nextSessionIds = new Set(normalizedNextItems.map((item) => item.sessionId));
|
||||
const preservedTransientItems = previousItems.filter((item) => {
|
||||
if (!item.sessionId || nextSessionIds.has(item.sessionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return item.isDraftOnly || item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
|
||||
});
|
||||
|
||||
if (!normalizedRequestedSessionId) {
|
||||
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
|
||||
}
|
||||
|
||||
const hasRequestedSession = normalizedNextItems.some((item) => item.sessionId === normalizedRequestedSessionId);
|
||||
|
||||
if (hasRequestedSession) {
|
||||
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
|
||||
}
|
||||
|
||||
const preservedRequestedSession =
|
||||
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
|
||||
|
||||
if (!preservedRequestedSession) {
|
||||
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
|
||||
}
|
||||
|
||||
const hasPreservedRequestedSession = preservedTransientItems.some(
|
||||
(item) => item.sessionId === preservedRequestedSession.sessionId,
|
||||
);
|
||||
|
||||
if (hasPreservedRequestedSession) {
|
||||
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
|
||||
}
|
||||
|
||||
return sortChatConversationSummaries([preservedRequestedSession, ...preservedTransientItems, ...normalizedNextItems]);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
||||
import { buildComposerFilePickKey } from '../../mainChatPanel/composerFilePickKey';
|
||||
|
||||
export type ComposerFilePickResult = {
|
||||
items: {
|
||||
@@ -11,19 +12,26 @@ export type ComposerFilePickResult = {
|
||||
}[];
|
||||
};
|
||||
|
||||
function buildComposerFilePickKey(file: File) {
|
||||
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
|
||||
}
|
||||
|
||||
type PendingChatRequest = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
text: string;
|
||||
mode: 'queue' | 'direct';
|
||||
origin?: 'composer' | 'prompt';
|
||||
parentRequestId?: string | null;
|
||||
omitPromptHistory?: boolean;
|
||||
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;
|
||||
};
|
||||
@@ -31,9 +39,20 @@ type PendingChatRequest = {
|
||||
type PendingContextConfirm = {
|
||||
mode: 'queue' | 'direct';
|
||||
text: string;
|
||||
origin?: 'composer' | 'prompt';
|
||||
parentRequestId?: string | null;
|
||||
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;
|
||||
@@ -43,6 +62,15 @@ 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 = {
|
||||
@@ -64,6 +92,7 @@ type UseConversationComposerControllerOptions = {
|
||||
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[]>>;
|
||||
@@ -80,6 +109,7 @@ type UseConversationComposerControllerOptions = {
|
||||
requestedAt?: string,
|
||||
options?: {
|
||||
requestId?: string;
|
||||
requestOrigin?: 'composer' | 'prompt';
|
||||
mode?: 'queue' | 'direct';
|
||||
queueSize?: number;
|
||||
jobMessage?: string | null;
|
||||
@@ -95,6 +125,7 @@ type UseConversationComposerControllerOptions = {
|
||||
previous: ChatComposerAttachment[],
|
||||
next: ChatComposerAttachment[],
|
||||
) => ChatComposerAttachment[];
|
||||
ensureSessionReady?: (sessionId: string) => Promise<boolean>;
|
||||
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
|
||||
scrollViewportToBottom: () => void;
|
||||
};
|
||||
@@ -104,6 +135,14 @@ type SendMessageOptions = {
|
||||
draftText?: string;
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -115,6 +154,7 @@ export function useConversationComposerController({
|
||||
composerRef,
|
||||
messagesRef,
|
||||
pendingRequestsRef,
|
||||
promptRequestIdsRef,
|
||||
shouldStickToBottomRef,
|
||||
setDraft,
|
||||
setComposerAttachments,
|
||||
@@ -133,11 +173,13 @@ export function useConversationComposerController({
|
||||
buildOutgoingMessageText,
|
||||
summarizeRecentContext,
|
||||
mergeComposerAttachments,
|
||||
ensureSessionReady,
|
||||
sendChatRequest,
|
||||
scrollViewportToBottom,
|
||||
}: UseConversationComposerControllerOptions) {
|
||||
const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] }));
|
||||
const activeComposerUploadCountRef = useRef(0);
|
||||
const latestComposerUploadAttemptByKeyRef = useRef(new Map<string, string>());
|
||||
|
||||
const handleComposerFilesPicked = useCallback(
|
||||
async (files: File[]): Promise<ComposerFilePickResult> => {
|
||||
@@ -145,6 +187,13 @@ export function useConversationComposerController({
|
||||
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;
|
||||
|
||||
@@ -160,6 +209,12 @@ export function useConversationComposerController({
|
||||
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;
|
||||
@@ -218,6 +273,7 @@ export function useConversationComposerController({
|
||||
activeSessionId,
|
||||
composerUploadQueueRef,
|
||||
createLocalMessage,
|
||||
latestComposerUploadAttemptByKeyRef,
|
||||
mergeComposerAttachments,
|
||||
setComposerAttachments,
|
||||
setIsComposerAttachmentUploading,
|
||||
@@ -235,22 +291,60 @@ export function useConversationComposerController({
|
||||
}, [composerRef, scrollViewportToBottom]);
|
||||
|
||||
const executeSendMessage = useCallback(
|
||||
(request: PendingContextConfirm) => {
|
||||
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription, omitPromptHistory } = request;
|
||||
const requestId = `client-${Date.now().toString(36)}`;
|
||||
async (request: PendingContextConfirm) => {
|
||||
const {
|
||||
mode,
|
||||
text,
|
||||
origin,
|
||||
parentRequestId,
|
||||
chatTypeId,
|
||||
chatTypeLabel,
|
||||
chatTypeDescription,
|
||||
chatTypeBaseDescription,
|
||||
defaultContextIds,
|
||||
defaultContexts,
|
||||
customContextTitle,
|
||||
customContextContent,
|
||||
omitPromptHistory,
|
||||
} = request;
|
||||
|
||||
if (ensureSessionReady) {
|
||||
setActiveSystemStatus('새 채팅방 준비 중...');
|
||||
setIsSystemStatusPending(true);
|
||||
const isSessionReady = await ensureSessionReady(activeSessionId);
|
||||
|
||||
if (!isSessionReady) {
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const requestId = createClientRequestId();
|
||||
const outgoingRequest: PendingChatRequest = {
|
||||
sessionId: activeSessionId,
|
||||
requestId,
|
||||
text,
|
||||
mode,
|
||||
origin: origin ?? 'composer',
|
||||
parentRequestId: parentRequestId?.trim() || null,
|
||||
omitPromptHistory: omitPromptHistory === true,
|
||||
chatTypeId,
|
||||
chatTypeLabel,
|
||||
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 = {
|
||||
@@ -260,11 +354,13 @@ export function useConversationComposerController({
|
||||
};
|
||||
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
|
||||
'# 상태: 요청을 접수했습니다.',
|
||||
'# 진행: 대기열 등록을 준비하고 있습니다.',
|
||||
'# 진행: 순서를 기다리기 전에 요청 내용을 정리하고 있습니다.',
|
||||
]);
|
||||
upsertRequestItem({
|
||||
sessionId: activeSessionId,
|
||||
requestId,
|
||||
requestOrigin: origin ?? 'composer',
|
||||
parentRequestId: parentRequestId?.trim() || null,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 등록',
|
||||
userMessageId: optimisticUserMessage.id,
|
||||
@@ -280,6 +376,7 @@ export function useConversationComposerController({
|
||||
});
|
||||
syncConversationPreviewForRequest(activeSessionId, text, queuedAt, {
|
||||
requestId,
|
||||
requestOrigin: origin ?? 'composer',
|
||||
mode: 'queue',
|
||||
queueSize: 1,
|
||||
jobMessage: '대기열 등록 중',
|
||||
@@ -301,11 +398,13 @@ export function useConversationComposerController({
|
||||
};
|
||||
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
|
||||
'# 상태: 즉시 요청을 접수했습니다.',
|
||||
'# 진행: 즉시 실행 대기 중입니다.',
|
||||
'# 진행: 요청 내용을 정리한 뒤 답변을 준비합니다.',
|
||||
]);
|
||||
upsertRequestItem({
|
||||
sessionId: activeSessionId,
|
||||
requestId,
|
||||
requestOrigin: origin ?? 'composer',
|
||||
parentRequestId: parentRequestId?.trim() || null,
|
||||
status: 'accepted',
|
||||
statusMessage: '요청을 접수했습니다.',
|
||||
userMessageId: optimisticUserMessage.id,
|
||||
@@ -321,6 +420,7 @@ export function useConversationComposerController({
|
||||
});
|
||||
syncConversationPreviewForRequest(activeSessionId, text, new Date().toISOString(), {
|
||||
requestId,
|
||||
requestOrigin: origin ?? 'composer',
|
||||
mode: 'direct',
|
||||
queueSize: 0,
|
||||
jobMessage: '즉시 요청 실행 대기 중',
|
||||
@@ -367,13 +467,16 @@ export function useConversationComposerController({
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
focusComposerAfterSend();
|
||||
return true;
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
createActivityLogPlaceholder,
|
||||
createChatMessage,
|
||||
ensureSessionReady,
|
||||
focusComposerAfterSend,
|
||||
pendingRequestsRef,
|
||||
promptRequestIdsRef,
|
||||
sendChatRequest,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
@@ -422,6 +525,11 @@ export function useConversationComposerController({
|
||||
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,
|
||||
});
|
||||
@@ -434,6 +542,11 @@ export function useConversationComposerController({
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { sortChatConversationSummaries } from '../../mainChatPanel';
|
||||
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||
import { emitChatConversationsUpdated } from '../data/chatClientEvents';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import { mergeConversationItemsPreservingRequestedSession } from './conversationListMerge';
|
||||
|
||||
type UseConversationListDataOptions = {
|
||||
requestedSessionId: string;
|
||||
@@ -18,94 +18,6 @@ type UseConversationListDataResult = {
|
||||
setConversationSearch: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
function mergeConversationItemsPreservingRequestedSession(
|
||||
nextItems: ChatConversationSummary[],
|
||||
previousItems: ChatConversationSummary[],
|
||||
requestedSessionId: string,
|
||||
) {
|
||||
const previousBySessionId = new Map(previousItems.map((item) => [item.sessionId, item] as const));
|
||||
const normalizedNextItems = nextItems.map((item) => {
|
||||
const previousItem = previousBySessionId.get(item.sessionId);
|
||||
|
||||
if (!previousItem) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const chatTypeId = item.chatTypeId?.trim() || previousItem.chatTypeId?.trim() || null;
|
||||
const lastChatTypeId =
|
||||
item.lastChatTypeId?.trim() ||
|
||||
chatTypeId ||
|
||||
previousItem.lastChatTypeId?.trim() ||
|
||||
previousItem.chatTypeId?.trim() ||
|
||||
null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
chatTypeId,
|
||||
lastChatTypeId,
|
||||
generalSectionName: item.generalSectionName?.trim() || previousItem.generalSectionName?.trim() || null,
|
||||
contextLabel: item.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
|
||||
contextDescription: item.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
|
||||
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
||||
lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(),
|
||||
currentRequestId:
|
||||
item.currentRequestId?.trim() ||
|
||||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentRequestId : null) ||
|
||||
null,
|
||||
currentJobStatus:
|
||||
item.currentJobStatus ??
|
||||
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
|
||||
? previousItem.currentJobStatus
|
||||
: null),
|
||||
currentJobMessage:
|
||||
item.currentJobMessage?.trim() ||
|
||||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentJobMessage?.trim() : '') ||
|
||||
null,
|
||||
currentQueueSize:
|
||||
item.currentQueueSize > 0
|
||||
? item.currentQueueSize
|
||||
: item.currentJobStatus === 'queued'
|
||||
? Math.max(1, previousItem.currentQueueSize)
|
||||
: previousItem.currentJobStatus === 'queued' && item.currentJobStatus == null
|
||||
? Math.max(1, previousItem.currentQueueSize)
|
||||
: item.currentQueueSize,
|
||||
currentStatusUpdatedAt:
|
||||
item.currentStatusUpdatedAt ||
|
||||
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
|
||||
? previousItem.currentStatusUpdatedAt
|
||||
: null),
|
||||
};
|
||||
});
|
||||
const normalizedRequestedSessionId = requestedSessionId.trim();
|
||||
const nextSessionIds = new Set(normalizedNextItems.map((item) => item.sessionId));
|
||||
const preservedTransientItems = previousItems.filter((item) => {
|
||||
if (!item.sessionId || nextSessionIds.has(item.sessionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return item.isDraftOnly || item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
|
||||
});
|
||||
|
||||
if (!normalizedRequestedSessionId) {
|
||||
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
|
||||
}
|
||||
|
||||
const hasRequestedSession = normalizedNextItems.some((item) => item.sessionId === normalizedRequestedSessionId);
|
||||
|
||||
if (hasRequestedSession) {
|
||||
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
|
||||
}
|
||||
|
||||
const preservedRequestedSession =
|
||||
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
|
||||
|
||||
if (!preservedRequestedSession) {
|
||||
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
|
||||
}
|
||||
|
||||
return sortChatConversationSummaries([preservedRequestedSession, ...preservedTransientItems, ...normalizedNextItems]);
|
||||
}
|
||||
|
||||
export function useConversationListData({
|
||||
requestedSessionId,
|
||||
enabled = true,
|
||||
@@ -139,7 +51,6 @@ export function useConversationListData({
|
||||
|
||||
setConversationItems((previous) => {
|
||||
const nextItems = mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId);
|
||||
emitChatConversationsUpdated(nextItems);
|
||||
return nextItems;
|
||||
});
|
||||
} catch {
|
||||
@@ -177,6 +88,10 @@ export function useConversationListData({
|
||||
};
|
||||
}, [enabled, loadConversationItems]);
|
||||
|
||||
useEffect(() => {
|
||||
emitChatConversationsUpdated(conversationItems);
|
||||
}, [conversationItems]);
|
||||
|
||||
return {
|
||||
conversationItems,
|
||||
setConversationItems,
|
||||
|
||||
@@ -18,6 +18,14 @@ type PendingChatRequest = {
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeBaseDescription?: string;
|
||||
defaultContexts?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}>;
|
||||
customContextTitle?: string | null;
|
||||
customContextContent?: string | null;
|
||||
retryCount: number;
|
||||
failed: boolean;
|
||||
};
|
||||
@@ -281,7 +289,18 @@ export function useConversationRoomActionsController({
|
||||
setIsEditingConversationTitle(false);
|
||||
|
||||
try {
|
||||
const item = await chatGateway.renameConversation(sessionId, trimmedTitle);
|
||||
const item = activeConversation.isDraftOnly
|
||||
? await chatGateway.createConversation({
|
||||
sessionId,
|
||||
title: trimmedTitle,
|
||||
chatTypeId: activeConversation.chatTypeId,
|
||||
lastChatTypeId: activeConversation.lastChatTypeId,
|
||||
generalSectionName: activeConversation.generalSectionName,
|
||||
contextLabel: activeConversation.contextLabel ?? undefined,
|
||||
contextDescription: activeConversation.contextDescription ?? undefined,
|
||||
notifyOffline: activeConversation.notifyOffline,
|
||||
})
|
||||
: await chatGateway.renameConversation(sessionId, trimmedTitle);
|
||||
setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)));
|
||||
setEditingConversationTitle(item.title);
|
||||
} catch (error) {
|
||||
@@ -306,7 +325,16 @@ export function useConversationRoomActionsController({
|
||||
const handleDeleteConversation = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
await chatGateway.deleteConversation(sessionId);
|
||||
const targetConversation = conversationItems.find((entry) => entry.sessionId === sessionId) ?? null;
|
||||
|
||||
if (!targetConversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetConversation.isDraftOnly) {
|
||||
await chatGateway.deleteConversation(sessionId);
|
||||
}
|
||||
|
||||
const remaining = conversationItems.filter((entry) => entry.sessionId !== sessionId);
|
||||
sessionMessageCacheRef.current.delete(sessionId);
|
||||
setConversationItems(remaining);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
|
||||
import { mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
|
||||
import { mergeConversationRequestStatusMessage, mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
|
||||
import { sortChatConversationSummaries } from '../../mainChatPanel';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type {
|
||||
@@ -29,10 +29,12 @@ function mergeConversationRequests(
|
||||
|
||||
const nextUserText = item.userText.trim() || previousItem.userText.trim();
|
||||
const nextResponseText = item.responseText.trim() || previousItem.responseText.trim();
|
||||
const nextStatusMessage = item.statusMessage?.trim() || previousItem.statusMessage?.trim() || null;
|
||||
const nextStatusMessage = mergeConversationRequestStatusMessage(previousItem, item);
|
||||
|
||||
return {
|
||||
...item,
|
||||
requestOrigin: item.requestOrigin ?? previousItem.requestOrigin ?? null,
|
||||
parentRequestId: item.parentRequestId?.trim() || previousItem.parentRequestId?.trim() || null,
|
||||
statusMessage: nextStatusMessage,
|
||||
userMessageId: item.userMessageId ?? previousItem.userMessageId,
|
||||
userText: nextUserText,
|
||||
@@ -72,8 +74,17 @@ type UseConversationRoomDataOptions = {
|
||||
setIsLoadingOlderMessages: Dispatch<SetStateAction<boolean>>;
|
||||
queueViewportPrependRestore: (previousScrollHeight: number, previousScrollTop: number) => void;
|
||||
viewportRef: MutableRefObject<HTMLDivElement | null>;
|
||||
onMissingConversation?: (sessionId: string) => void;
|
||||
};
|
||||
|
||||
function isMissingConversationError(error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /채팅방을 찾을 수 없습니다|404\b|not found/i.test(error.message);
|
||||
}
|
||||
|
||||
export function useConversationRoomData({
|
||||
activeSessionId,
|
||||
activeConversationIsDraftOnly = false,
|
||||
@@ -98,6 +109,7 @@ export function useConversationRoomData({
|
||||
setIsLoadingOlderMessages,
|
||||
queueViewportPrependRestore,
|
||||
viewportRef,
|
||||
onMissingConversation,
|
||||
}: UseConversationRoomDataOptions) {
|
||||
const previousSessionIdRef = useRef('');
|
||||
|
||||
@@ -209,8 +221,20 @@ export function useConversationRoomData({
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
if (cachedMessages.length === 0 && isMissingConversationError(error)) {
|
||||
sessionMessageCacheRef.current.delete(requestedSessionId);
|
||||
setConversationItems((previous) => previous.filter((item) => item.sessionId !== requestedSessionId));
|
||||
setMessages([]);
|
||||
setRequestItems((previous) => previous.filter((item) => item.sessionId !== requestedSessionId));
|
||||
setHasOlderMessages(false);
|
||||
setOldestLoadedMessageId(null);
|
||||
setConversationLoadingLabel('삭제되었거나 만료된 채팅방입니다.');
|
||||
onMissingConversation?.(requestedSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages(cachedMessages);
|
||||
setHasOlderMessages(false);
|
||||
setOldestLoadedMessageId(cachedMessages[0]?.id ?? null);
|
||||
@@ -250,6 +274,7 @@ export function useConversationRoomData({
|
||||
setHasOlderMessages,
|
||||
setMessages,
|
||||
setOldestLoadedMessageId,
|
||||
onMissingConversation,
|
||||
setRequestItems,
|
||||
]);
|
||||
|
||||
|
||||
@@ -337,6 +337,36 @@ export function useConversationViewportController({
|
||||
resetPullToLoad,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleViewportInteractionReset = () => {
|
||||
resetPullToLoad();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
resetPullToLoad();
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
resetPullToLoad();
|
||||
handleViewportScroll();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleViewportInteractionReset);
|
||||
window.addEventListener('pageshow', handleViewportInteractionReset);
|
||||
window.addEventListener('blur', handleViewportInteractionReset);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleViewportInteractionReset);
|
||||
window.removeEventListener('pageshow', handleViewportInteractionReset);
|
||||
window.removeEventListener('blur', handleViewportInteractionReset);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [handleViewportScroll, resetPullToLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState === 'disconnected') {
|
||||
clearSystemStatusTimer();
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '../data/chatClientEvents';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
|
||||
const UNREAD_COUNT_REFRESH_INTERVAL_MS = 15_000;
|
||||
|
||||
type UseUnreadCountsResult = {
|
||||
chatUnreadCount: number;
|
||||
notificationUnreadCount: number;
|
||||
@@ -134,6 +136,40 @@ export function useUnreadCounts(): UseUnreadCountsResult {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshAllUnreadCounts = () => {
|
||||
void refreshChatUnreadCount();
|
||||
void refreshNotificationUnreadCount();
|
||||
};
|
||||
|
||||
const handleVisibilityOrFocus = () => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshAllUnreadCounts();
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshAllUnreadCounts();
|
||||
}, UNREAD_COUNT_REFRESH_INTERVAL_MS);
|
||||
|
||||
window.addEventListener('focus', handleVisibilityOrFocus);
|
||||
window.addEventListener('pageshow', handleVisibilityOrFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityOrFocus);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener('focus', handleVisibilityOrFocus);
|
||||
window.removeEventListener('pageshow', handleVisibilityOrFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityOrFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
chatUnreadCount,
|
||||
notificationUnreadCount,
|
||||
|
||||
Reference in New Issue
Block a user