fd7387928c
## Summary - **Queue messages while streaming**: Messages sent during active AI streaming are queued server-side and auto-flushed when the current stream completes. Frontend renders queued messages optimistically in a dedicated queue UI. - **Drop `@ai-sdk/react` + `resumable-stream`**: Replace the dual HTTP SSE + AI SDK client architecture with a single GraphQL SSE subscription per thread. All events (token chunks, message persistence, queue updates, errors) flow through Redis PubSub → GraphQL subscription. - **Server-driven architecture**: The server decides whether to queue or stream (via `POST /:threadId/message`). The frontend mirrors this decision for optimistic rendering but defers to the server response. - **Reuse AI SDK accumulation logic**: `readUIMessageStream` from the `ai` package handles chunk-to-message accumulation on the frontend, avoiding a custom 780-line accumulator. ## Key files **Backend:** - `agent-chat-event-publisher.service.ts` — publishes events to Redis PubSub - `agent-chat-subscription.resolver.ts` — GraphQL subscription resolver - `stream-agent-chat.job.ts` — publishes chunks via PubSub instead of resumable-stream - `agent-chat.controller.ts` — unified `POST /:threadId/message` endpoint **Frontend:** - `useAgentChatSubscription.ts` — subscribes to `onAgentChatEvent`, bridges to `readUIMessageStream` - `useAgentChat.ts` — send/stop/optimistic rendering (no more AI SDK) - `AgentChatStreamSubscriptionEffect.tsx` — replaces `AgentChatAiSdkStreamEffect.tsx` ## Test plan - [ ] Send message on new thread → optimistic render, streaming response appears - [ ] Send message while streaming → queued instantly (no flash in main thread) - [ ] Queued message auto-flushes after current stream completes - [ ] Remove queued message via queue UI - [ ] Stop streaming mid-response - [ ] Leave chat idle for several minutes → streaming still works after (SSE client recycling) - [ ] Token refresh during session → requests succeed (authenticated fetch) - [ ] Switch threads while streaming → clean subscription handoff Made with [Cursor](https://cursor.com) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
133 lines
4.8 KiB
TypeScript
133 lines
4.8 KiB
TypeScript
import { useCallback, useMemo } from 'react';
|
|
import { useStore } from 'jotai';
|
|
import { type AgentChatSubscriptionEvent } from 'twenty-shared/ai';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
|
import { AGENT_CHAT_UNKNOWN_THREAD_ID } from '@/ai/constants/AgentChatUnknownThreadId';
|
|
import { agentChatFirstLiveSeqState } from '@/ai/states/agentChatFirstLiveSeqState';
|
|
import { agentChatHandleEventCallbackState } from '@/ai/states/agentChatHandleEventCallbackState';
|
|
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
|
|
import { agentChatFetchedMessagesComponentFamilyState } from '@/ai/states/agentChatFetchedMessagesComponentFamilyState';
|
|
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
|
|
import { agentChatQueuedMessagesComponentFamilyState } from '@/ai/states/agentChatQueuedMessagesComponentFamilyState';
|
|
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
|
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
|
|
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
|
|
import { useQueryWithCallbacks } from '@/apollo/hooks/useQueryWithCallbacks';
|
|
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
|
|
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
|
import { useSetAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentFamilyState';
|
|
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
|
import {
|
|
GetChatMessagesDocument,
|
|
type GetChatMessagesQuery,
|
|
} from '~/generated-metadata/graphql';
|
|
|
|
export const AgentChatMessagesFetchEffect = () => {
|
|
const store = useStore();
|
|
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
|
|
|
const isNewThread = useMemo(
|
|
() =>
|
|
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY ||
|
|
currentAIChatThread === AGENT_CHAT_UNKNOWN_THREAD_ID,
|
|
[currentAIChatThread],
|
|
);
|
|
|
|
const setAgentChatMessagesLoading = useSetAtomState(
|
|
agentChatMessagesLoadingState,
|
|
);
|
|
|
|
const setSkipMessagesSkeletonUntilLoaded = useSetAtomState(
|
|
skipMessagesSkeletonUntilLoadedState,
|
|
);
|
|
|
|
const setAgentChatFetchedMessages = useSetAtomComponentFamilyState(
|
|
agentChatFetchedMessagesComponentFamilyState,
|
|
{ threadId: currentAIChatThread },
|
|
);
|
|
|
|
const setAgentChatQueuedMessages = useSetAtomComponentFamilyState(
|
|
agentChatQueuedMessagesComponentFamilyState,
|
|
{ threadId: currentAIChatThread },
|
|
);
|
|
|
|
const handleFirstLoad = useCallback(
|
|
(_data: GetChatMessagesQuery) => {
|
|
setSkipMessagesSkeletonUntilLoaded(false);
|
|
},
|
|
[setSkipMessagesSkeletonUntilLoaded],
|
|
);
|
|
|
|
const handleDataLoaded = useCallback(
|
|
(data: GetChatMessagesQuery) => {
|
|
const uiMessages = mapDBMessagesToUIMessages(data.chatMessages ?? []);
|
|
setAgentChatFetchedMessages(
|
|
uiMessages.filter((message) => message.status !== 'queued'),
|
|
);
|
|
setAgentChatQueuedMessages(
|
|
uiMessages.filter((message) => message.status === 'queued'),
|
|
);
|
|
|
|
const catchup = data.chatStreamCatchupChunks;
|
|
|
|
if (!isDefined(catchup) || catchup.chunks.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const handleEvent = store.get(agentChatHandleEventCallbackState.atom);
|
|
|
|
if (!isDefined(handleEvent)) {
|
|
return;
|
|
}
|
|
|
|
const firstLiveSeq = store.get(agentChatFirstLiveSeqState.atom);
|
|
|
|
for (let index = 0; index < catchup.chunks.length; index++) {
|
|
const chunkSeq = index + 1;
|
|
|
|
if (firstLiveSeq !== null && chunkSeq >= firstLiveSeq) {
|
|
break;
|
|
}
|
|
|
|
handleEvent({
|
|
type: 'stream-chunk',
|
|
chunk: catchup.chunks[index],
|
|
seq: chunkSeq,
|
|
} as AgentChatSubscriptionEvent);
|
|
}
|
|
},
|
|
[setAgentChatFetchedMessages, setAgentChatQueuedMessages, store],
|
|
);
|
|
|
|
const handleLoadingChange = useCallback(
|
|
(loading: boolean) => {
|
|
setAgentChatMessagesLoading(loading);
|
|
},
|
|
[setAgentChatMessagesLoading],
|
|
);
|
|
|
|
const { refetch: refetchAgentChatMessages } = useQueryWithCallbacks(
|
|
GetChatMessagesDocument,
|
|
{
|
|
variables: { threadId: currentAIChatThread },
|
|
skip: !isDefined(currentAIChatThread) || isNewThread,
|
|
onFirstLoad: handleFirstLoad,
|
|
onDataLoaded: handleDataLoaded,
|
|
onLoadingChange: handleLoadingChange,
|
|
},
|
|
);
|
|
|
|
const handleRefetchMessages = useCallback(() => {
|
|
refetchAgentChatMessages();
|
|
}, [refetchAgentChatMessages]);
|
|
|
|
useListenToBrowserEvent({
|
|
eventName: AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME,
|
|
onBrowserEvent: handleRefetchMessages,
|
|
});
|
|
|
|
return null;
|
|
};
|