Files
twenty/packages/twenty-front/src/modules/ai/components/AgentChatMessagesFetchEffect.tsx
T
Félix Malfait fd7387928c feat: queue messages + replace AI SDK with GraphQL SSE subscription (#19203)
## 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>
2026-04-02 10:10:13 +02:00

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;
};