diff --git a/frontend/src/hooks/useBotsSync.ts b/frontend/src/hooks/useBotsSync.ts index 685157c..e79d64a 100644 --- a/frontend/src/hooks/useBotsSync.ts +++ b/frontend/src/hooks/useBotsSync.ts @@ -88,6 +88,48 @@ function normalizeMessageId(raw: unknown): number | undefined { return i > 0 ? i : undefined; } +function mergeLatestMessages(existing: ChatMessage[], latestPage: ChatMessage[]): ChatMessage[] { + if (latestPage.length <= 0) { + return existing.slice(-300); + } + + const latestIds = latestPage + .map((msg) => normalizeMessageId(msg.id)) + .filter((id): id is number => typeof id === 'number'); + const oldestLatestId = latestIds.length > 0 ? Math.min(...latestIds) : null; + const latestIdSet = new Set(latestIds); + const latestFallbackKeys = new Set( + latestPage + .filter((msg) => !normalizeMessageId(msg.id)) + .map((msg) => `k:${msg.role}:${msg.ts}:${msg.text}`), + ); + + const keptExisting = existing.filter((msg) => { + const id = normalizeMessageId(msg.id); + if (id && oldestLatestId !== null && id >= oldestLatestId) { + return latestIdSet.has(id); + } + if (!id && latestFallbackKeys.has(`k:${msg.role}:${msg.ts}:${msg.text}`)) { + return false; + } + return true; + }); + + const mergedMap = new Map(); + [...keptExisting, ...latestPage].forEach((msg) => { + const id = normalizeMessageId(msg.id); + const key = id ? `id:${id}` : `k:${msg.role}:${msg.ts}:${msg.text}`; + mergedMap.set(key, msg); + }); + + return Array.from(mergedMap.values()) + .sort((a, b) => { + if (a.ts !== b.ts) return a.ts - b.ts; + return Number(a.id || 0) - Number(b.id || 0); + }) + .slice(-300); +} + function normalizeChannelName(raw: unknown): string { const channel = String(raw || '').trim().toLowerCase(); if (channel === 'dashboard_channel' || channel === 'dashboard-channel') return 'dashboard'; @@ -195,19 +237,8 @@ export function useBotsSync(forcedBotId?: string, enableMonitorSockets: boolean .filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0) .slice(-300); - // Keep already lazy-loaded history; only merge/refresh the latest page. const existing = (activeBotsRef.current[target]?.messages || []).filter((m) => (m.kind || 'final') !== 'progress'); - const mergedMap = new Map(); - [...existing, ...latestPage].forEach((msg) => { - const key = msg.id ? `id:${msg.id}` : `k:${msg.role}:${msg.ts}:${msg.text}`; - if (!mergedMap.has(key)) mergedMap.set(key, msg); - }); - const messages = Array.from(mergedMap.values()) - .sort((a, b) => { - if (a.ts !== b.ts) return a.ts - b.ts; - return Number(a.id || 0) - Number(b.id || 0); - }) - .slice(-300); + const messages = mergeLatestMessages(existing, latestPage); setBotMessages(target, messages); const lastUser = [...messages].reverse().find((m) => m.role === 'user'); diff --git a/frontend/src/modules/dashboard/hooks/useDashboardChatMessageActions.ts b/frontend/src/modules/dashboard/hooks/useDashboardChatMessageActions.ts index 3892785..c815858 100644 --- a/frontend/src/modules/dashboard/hooks/useDashboardChatMessageActions.ts +++ b/frontend/src/modules/dashboard/hooks/useDashboardChatMessageActions.ts @@ -112,42 +112,7 @@ export function useDashboardChatMessageActions({ return matched?.id || null; }, [hydrateLatestMessages, selectedBotId]); - const removeConversationMessageLocally = useCallback((message: ChatMessage, deletedMessageId: number) => { - if (!selectedBotId) return; - const originalMessageId = Number(message.id); - const hasOriginalId = Number.isFinite(originalMessageId) && originalMessageId > 0; - const idsToRemove = new Set([deletedMessageId]); - if (hasOriginalId) { - idsToRemove.add(originalMessageId); - } - - const scrollBox = chatScrollRef.current; - const prevTop = scrollBox?.scrollTop ?? null; - const normalizedTargetText = message.role === 'user' - ? normalizeUserMessageText(message.text) - : normalizeAssistantMessageText(message.text); - const targetAttachments = JSON.stringify(message.attachments || []); - - const nextMessages = messages.filter((row) => { - const rowId = Number(row.id); - if (Number.isFinite(rowId) && rowId > 0) { - return !idsToRemove.has(rowId); - } - if (hasOriginalId || row.role !== message.role) { - return true; - } - const normalizedRowText = row.role === 'user' - ? normalizeUserMessageText(row.text) - : normalizeAssistantMessageText(row.text); - return !( - normalizedRowText === normalizedTargetText - && JSON.stringify(row.attachments || []) === targetAttachments - && Math.abs((row.ts || 0) - (message.ts || 0)) <= 1000 - ); - }); - - setBotMessages(selectedBotId, nextMessages); - + const preserveChatScrollAfterMessagesChange = useCallback((prevTop: number | null) => { if (prevTop === null || chatAutoFollowRef.current) return; requestAnimationFrame(() => { const box = chatScrollRef.current; @@ -155,7 +120,33 @@ export function useDashboardChatMessageActions({ const maxTop = Math.max(0, box.scrollHeight - box.clientHeight); box.scrollTop = Math.min(prevTop, maxTop); }); - }, [chatAutoFollowRef, chatScrollRef, messages, selectedBotId, setBotMessages]); + }, [chatAutoFollowRef, chatScrollRef]); + + const removeConversationMessageLocally = useCallback((deletedMessageId: number, originalMessageId?: number) => { + if (!selectedBotId) return; + const originalId = Number(originalMessageId); + const idsToRemove = new Set([deletedMessageId]); + if (Number.isFinite(originalId) && originalId > 0) { + idsToRemove.add(originalId); + } + + const scrollBox = chatScrollRef.current; + const prevTop = scrollBox?.scrollTop ?? null; + const nextMessages = messages.filter((row) => { + const rowId = Number(row.id); + return !(Number.isFinite(rowId) && rowId > 0 && idsToRemove.has(rowId)); + }); + setBotMessages(selectedBotId, nextMessages); + preserveChatScrollAfterMessagesChange(prevTop); + }, [chatScrollRef, messages, preserveChatScrollAfterMessagesChange, selectedBotId, setBotMessages]); + + const refreshConversationMessages = useCallback(async () => { + if (!selectedBotId) return; + const scrollBox = chatScrollRef.current; + const prevTop = scrollBox?.scrollTop ?? null; + await hydrateLatestMessages(selectedBotId); + preserveChatScrollAfterMessagesChange(prevTop); + }, [chatScrollRef, hydrateLatestMessages, preserveChatScrollAfterMessagesChange, selectedBotId]); const deleteConversationMessageOnServer = useCallback(async (messageId: number) => { await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${messageId}`); @@ -166,7 +157,9 @@ export function useDashboardChatMessageActions({ notify(t.deleteMessagePending, { tone: 'warning' }); return; } - let targetMessageId = Number(message.id); + const originalMessageId = Number(message.id); + const hasOriginalMessageId = Number.isFinite(originalMessageId) && originalMessageId > 0; + let targetMessageId = originalMessageId; if (!Number.isFinite(targetMessageId) || targetMessageId <= 0) { targetMessageId = Number(await resolveMessageIdFromLatest(message)); } @@ -189,7 +182,11 @@ export function useDashboardChatMessageActions({ setDeletingMessageIdMap((prev) => ({ ...prev, [targetMessageId]: true })); try { await deleteConversationMessageOnServer(targetMessageId); - removeConversationMessageLocally(message, targetMessageId); + if (hasOriginalMessageId) { + removeConversationMessageLocally(targetMessageId, originalMessageId); + } else { + await refreshConversationMessages(); + } notify(t.deleteMessageDone, { tone: 'success' }); } catch (error: unknown) { notify(resolveApiErrorMessage(error, t.deleteMessageFail), { tone: 'error' }); @@ -200,7 +197,7 @@ export function useDashboardChatMessageActions({ return next; }); } - }, [confirm, deleteConversationMessageOnServer, deletingMessageIdMap, notify, removeConversationMessageLocally, resolveMessageIdFromLatest, selectedBotId, t]); + }, [confirm, deleteConversationMessageOnServer, deletingMessageIdMap, notify, refreshConversationMessages, removeConversationMessageLocally, resolveMessageIdFromLatest, selectedBotId, t]); return { deleteConversationMessage,