feat: enhance message handling with deduplication and resync functionality
This commit is contained in:
@@ -39,7 +39,17 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
}, [permissions, onMessageContentChange]);
|
}, [permissions, onMessageContentChange]);
|
||||||
|
|
||||||
const displayMessages = React.useMemo(() => {
|
const displayMessages = React.useMemo(() => {
|
||||||
return messages.filter((message) => !isFullySyntheticMessage(message.parts));
|
const seenIds = new Set<string>();
|
||||||
|
return messages.filter((message) => {
|
||||||
|
const messageId = message.info?.id;
|
||||||
|
if (typeof messageId === 'string') {
|
||||||
|
if (seenIds.has(messageId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seenIds.add(messageId);
|
||||||
|
}
|
||||||
|
return !isFullySyntheticMessage(message.parts);
|
||||||
|
});
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const { getContextForMessage } = useTurnGrouping(displayMessages);
|
const { getContextForMessage } = useTurnGrouping(displayMessages);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ declare global {
|
|||||||
|
|
||||||
const ENABLE_EMPTY_RESPONSE_DETECTION = false;
|
const ENABLE_EMPTY_RESPONSE_DETECTION = false;
|
||||||
const TEXT_SHRINK_TOLERANCE = 50;
|
const TEXT_SHRINK_TOLERANCE = 50;
|
||||||
|
const RESYNC_DEBOUNCE_MS = 750;
|
||||||
|
|
||||||
const textLengthCache = new WeakMap<Part[], number>();
|
const textLengthCache = new WeakMap<Part[], number>();
|
||||||
const computeTextLength = (parts: Part[] | undefined | null): number => {
|
const computeTextLength = (parts: Part[] | undefined | null): number => {
|
||||||
@@ -160,6 +161,32 @@ export const useEventStream = () => {
|
|||||||
[setEventStreamStatus]
|
[setEventStreamStatus]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resyncMessages = React.useCallback(
|
||||||
|
(sessionId: string, reason: string) => {
|
||||||
|
if (!sessionId) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (resyncInFlightRef.current) {
|
||||||
|
return resyncInFlightRef.current;
|
||||||
|
}
|
||||||
|
if (now - lastResyncAtRef.current < RESYNC_DEBOUNCE_MS) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
const task = loadMessages(sessionId)
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn(`[useEventStream] Failed to resync messages (${reason}):`, error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
resyncInFlightRef.current = null;
|
||||||
|
lastResyncAtRef.current = Date.now();
|
||||||
|
});
|
||||||
|
resyncInFlightRef.current = task;
|
||||||
|
return task;
|
||||||
|
},
|
||||||
|
[loadMessages]
|
||||||
|
);
|
||||||
|
|
||||||
const bootstrapState = React.useCallback(
|
const bootstrapState = React.useCallback(
|
||||||
async (reason: string) => {
|
async (reason: string) => {
|
||||||
if (streamDebugEnabled()) {
|
if (streamDebugEnabled()) {
|
||||||
@@ -168,13 +195,13 @@ export const useEventStream = () => {
|
|||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadSessions(),
|
loadSessions(),
|
||||||
currentSessionId ? loadMessages(currentSessionId) : Promise.resolve(),
|
currentSessionId ? resyncMessages(currentSessionId, reason) : Promise.resolve(),
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[useEventStream] Bootstrap failed:', reason, error);
|
console.warn('[useEventStream] Bootstrap failed:', reason, error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentSessionId, loadMessages, loadSessions]
|
[currentSessionId, loadSessions, resyncMessages]
|
||||||
);
|
);
|
||||||
|
|
||||||
const trackMessage = React.useCallback((messageId: string, event?: string, extraData?: Record<string, unknown>) => {
|
const trackMessage = React.useCallback((messageId: string, event?: string, extraData?: Record<string, unknown>) => {
|
||||||
@@ -196,6 +223,8 @@ export const useEventStream = () => {
|
|||||||
const metadataRefreshTimestampsRef = React.useRef<Map<string, number>>(new Map());
|
const metadataRefreshTimestampsRef = React.useRef<Map<string, number>>(new Map());
|
||||||
const sessionRefreshTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
const sessionRefreshTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||||
const isCleaningUpRef = React.useRef(false);
|
const isCleaningUpRef = React.useRef(false);
|
||||||
|
const resyncInFlightRef = React.useRef<Promise<void> | null>(null);
|
||||||
|
const lastResyncAtRef = React.useRef(0);
|
||||||
|
|
||||||
const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => {
|
const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => {
|
||||||
if (typeof document === 'undefined') return 'visible';
|
if (typeof document === 'undefined') return 'visible';
|
||||||
@@ -980,7 +1009,7 @@ export const useEventStream = () => {
|
|||||||
const sessionId = currentSessionIdRef.current;
|
const sessionId = currentSessionIdRef.current;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loadMessages(sessionId)
|
resyncMessages(sessionId, 'sse_reconnected')
|
||||||
.then(() => requestSessionMetadataRefresh(sessionId))
|
.then(() => requestSessionMetadataRefresh(sessionId))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
|
console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
|
||||||
@@ -1029,7 +1058,7 @@ export const useEventStream = () => {
|
|||||||
stopStream,
|
stopStream,
|
||||||
publishStatus,
|
publishStatus,
|
||||||
checkConnection,
|
checkConnection,
|
||||||
loadMessages,
|
resyncMessages,
|
||||||
requestSessionMetadataRefresh,
|
requestSessionMetadataRefresh,
|
||||||
requestSessionListRefresh,
|
requestSessionListRefresh,
|
||||||
completeStreamingMessage,
|
completeStreamingMessage,
|
||||||
@@ -1126,10 +1155,11 @@ export const useEventStream = () => {
|
|||||||
if (pendingResumeRef.current || !unsubscribeRef.current) {
|
if (pendingResumeRef.current || !unsubscribeRef.current) {
|
||||||
console.info('[useEventStream] Visibility restored, triggering soft refresh...');
|
console.info('[useEventStream] Visibility restored, triggering soft refresh...');
|
||||||
const sessionId = currentSessionIdRef.current;
|
const sessionId = currentSessionIdRef.current;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
loadMessages(sessionId).catch(() => {});
|
resyncMessages(sessionId, 'visibility_restore').catch(() => {});
|
||||||
requestSessionMetadataRefresh(sessionId);
|
requestSessionMetadataRefresh(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadSessions();
|
void loadSessions();
|
||||||
void refreshSessionActivityStatus();
|
void refreshSessionActivityStatus();
|
||||||
publishStatus('connecting', 'Resuming stream');
|
publishStatus('connecting', 'Resuming stream');
|
||||||
@@ -1153,7 +1183,7 @@ export const useEventStream = () => {
|
|||||||
const sessionId = currentSessionIdRef.current;
|
const sessionId = currentSessionIdRef.current;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
requestSessionMetadataRefresh(sessionId);
|
requestSessionMetadataRefresh(sessionId);
|
||||||
loadMessages(sessionId)
|
resyncMessages(sessionId, 'window_focus')
|
||||||
.then(() => console.info('[useEventStream] Messages refreshed on focus'))
|
.then(() => console.info('[useEventStream] Messages refreshed on focus'))
|
||||||
.catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err));
|
.catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,58 @@ const mergePreferExistingParts = (existing: Part[] = [], incoming: Part[] = []):
|
|||||||
return merged;
|
return merged;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeDuplicateMessage = (
|
||||||
|
existing: { info: any; parts: Part[] },
|
||||||
|
incoming: { info: any; parts: Part[] }
|
||||||
|
): { info: any; parts: Part[] } => {
|
||||||
|
const existingParts = Array.isArray(existing.parts) ? existing.parts : [];
|
||||||
|
const incomingParts = Array.isArray(incoming.parts) ? incoming.parts : [];
|
||||||
|
const existingLen = computePartsTextLength(existingParts);
|
||||||
|
const incomingLen = computePartsTextLength(incomingParts);
|
||||||
|
const existingStop = hasStopReasonStop(existingParts);
|
||||||
|
const incomingStop = hasStopReasonStop(incomingParts);
|
||||||
|
|
||||||
|
let parts = incomingParts;
|
||||||
|
if (existingStop && existingLen >= incomingLen) {
|
||||||
|
parts = mergePreferExistingParts(existingParts, incomingParts);
|
||||||
|
} else if (incomingStop && incomingLen >= existingLen) {
|
||||||
|
parts = mergePreferExistingParts(incomingParts, existingParts);
|
||||||
|
} else if (existingLen >= incomingLen) {
|
||||||
|
parts = existingParts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...incoming,
|
||||||
|
info: {
|
||||||
|
...existing.info,
|
||||||
|
...incoming.info,
|
||||||
|
},
|
||||||
|
parts,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const dedupeMessagesById = (messages: { info: any; parts: Part[] }[]) => {
|
||||||
|
const deduped: { info: any; parts: Part[] }[] = [];
|
||||||
|
const indexById = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const messageId = typeof message?.info?.id === "string" ? message.info.id : null;
|
||||||
|
if (!messageId) {
|
||||||
|
deduped.push(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existingIndex = indexById.get(messageId);
|
||||||
|
if (existingIndex === undefined) {
|
||||||
|
indexById.set(messageId, deduped.length);
|
||||||
|
deduped.push(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
deduped[existingIndex] = mergeDuplicateMessage(deduped[existingIndex], message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped;
|
||||||
|
};
|
||||||
|
|
||||||
const computeMaxTrimmedHeadId = (removed: Array<{ info: any }>, previous?: string): string | undefined => {
|
const computeMaxTrimmedHeadId = (removed: Array<{ info: any }>, previous?: string): string | undefined => {
|
||||||
let maxId = previous;
|
let maxId = previous;
|
||||||
let maxSortable = previous ? extractSortableId(previous) : null;
|
let maxSortable = previous ? extractSortableId(previous) : null;
|
||||||
@@ -372,7 +424,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const mergedMessages = normalizedMessages;
|
const mergedMessages = dedupeMessagesById(normalizedMessages);
|
||||||
|
|
||||||
const previousIds = new Set(previousMessages.map((msg) => msg.info.id));
|
const previousIds = new Set(previousMessages.map((msg) => msg.info.id));
|
||||||
const nextIds = new Set(mergedMessages.map((msg) => msg.info.id));
|
const nextIds = new Set(mergedMessages.map((msg) => msg.info.id));
|
||||||
@@ -1849,7 +1901,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const mergedMessages = normalizedMessages;
|
const mergedMessages = dedupeMessagesById(normalizedMessages);
|
||||||
|
|
||||||
const previousIds = new Set(previousMessages.map((msg) => msg.info.id));
|
const previousIds = new Set(previousMessages.map((msg) => msg.info.id));
|
||||||
const nextIds = new Set(mergedMessages.map((msg) => msg.info.id));
|
const nextIds = new Set(mergedMessages.map((msg) => msg.info.id));
|
||||||
@@ -2187,13 +2239,15 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const updatedMessages = [...newMessages, ...currentMessages];
|
const updatedMessages = [...newMessages, ...currentMessages];
|
||||||
|
const dedupedMessages = dedupeMessagesById(updatedMessages);
|
||||||
const newMessagesMap = new Map(state.messages);
|
const newMessagesMap = new Map(state.messages);
|
||||||
newMessagesMap.set(sessionId, updatedMessages);
|
newMessagesMap.set(sessionId, dedupedMessages);
|
||||||
|
|
||||||
|
const addedCount = Math.max(0, dedupedMessages.length - currentMessages.length);
|
||||||
const newMemoryState = new Map(state.sessionMemoryState);
|
const newMemoryState = new Map(state.sessionMemoryState);
|
||||||
newMemoryState.set(sessionId, {
|
newMemoryState.set(sessionId, {
|
||||||
...memoryState,
|
...memoryState,
|
||||||
viewportAnchor: memoryState.viewportAnchor + newMessages.length,
|
viewportAnchor: memoryState.viewportAnchor + addedCount,
|
||||||
hasMoreAbove: indexInAll - loadCount > 0,
|
hasMoreAbove: indexInAll - loadCount > 0,
|
||||||
totalAvailableMessages: allMessages.length,
|
totalAvailableMessages: allMessages.length,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user