## 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
2.9 KiB
TypeScript
133 lines
2.9 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
|
|
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
|
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
|
|
|
|
@Injectable()
|
|
export class SubscriptionService {
|
|
constructor(private readonly redisClient: RedisClientService) {}
|
|
|
|
private getSubscriptionChannel({
|
|
channel,
|
|
workspaceId,
|
|
}: {
|
|
channel: SubscriptionChannel;
|
|
workspaceId: string;
|
|
}) {
|
|
return `${channel}:${workspaceId}`;
|
|
}
|
|
|
|
private getEventStreamChannel({
|
|
workspaceId,
|
|
eventStreamChannelId,
|
|
}: {
|
|
workspaceId: string;
|
|
eventStreamChannelId: string;
|
|
}) {
|
|
return `${SubscriptionChannel.EVENT_STREAM_CHANNEL}:${workspaceId}:${eventStreamChannelId}`;
|
|
}
|
|
|
|
async subscribe({
|
|
channel,
|
|
workspaceId,
|
|
}: {
|
|
channel: SubscriptionChannel;
|
|
workspaceId: string;
|
|
}) {
|
|
const client = this.redisClient.getPubSubClient();
|
|
|
|
return client.asyncIterator(
|
|
this.getSubscriptionChannel({ channel, workspaceId }),
|
|
);
|
|
}
|
|
|
|
async subscribeToEventStream({
|
|
workspaceId,
|
|
eventStreamChannelId,
|
|
}: {
|
|
workspaceId: string;
|
|
eventStreamChannelId: string;
|
|
}) {
|
|
const client = this.redisClient.getPubSubClient();
|
|
|
|
return client.asyncIterator(
|
|
this.getEventStreamChannel({ workspaceId, eventStreamChannelId }),
|
|
);
|
|
}
|
|
|
|
async publish<T>({
|
|
channel,
|
|
payload,
|
|
workspaceId,
|
|
}: {
|
|
channel: SubscriptionChannel;
|
|
payload: T;
|
|
workspaceId: string;
|
|
}): Promise<void> {
|
|
const client = this.redisClient.getPubSubClient();
|
|
|
|
await client.publish(
|
|
this.getSubscriptionChannel({ channel, workspaceId }),
|
|
payload,
|
|
);
|
|
}
|
|
|
|
async publishToEventStream<T>({
|
|
workspaceId,
|
|
eventStreamChannelId,
|
|
payload,
|
|
}: {
|
|
workspaceId: string;
|
|
eventStreamChannelId: string;
|
|
payload: T;
|
|
}): Promise<void> {
|
|
const client = this.redisClient.getPubSubClient();
|
|
|
|
await client.publish(
|
|
this.getEventStreamChannel({ workspaceId, eventStreamChannelId }),
|
|
payload,
|
|
);
|
|
}
|
|
|
|
private getAgentChatChannel({
|
|
workspaceId,
|
|
threadId,
|
|
}: {
|
|
workspaceId: string;
|
|
threadId: string;
|
|
}) {
|
|
return `${SubscriptionChannel.AGENT_CHAT_CHANNEL}:${workspaceId}:${threadId}`;
|
|
}
|
|
|
|
async subscribeToAgentChat({
|
|
workspaceId,
|
|
threadId,
|
|
}: {
|
|
workspaceId: string;
|
|
threadId: string;
|
|
}) {
|
|
const client = this.redisClient.getPubSubClient();
|
|
|
|
return client.asyncIterator(
|
|
this.getAgentChatChannel({ workspaceId, threadId }),
|
|
);
|
|
}
|
|
|
|
async publishToAgentChat<T>({
|
|
workspaceId,
|
|
threadId,
|
|
payload,
|
|
}: {
|
|
workspaceId: string;
|
|
threadId: string;
|
|
payload: T;
|
|
}): Promise<void> {
|
|
const client = this.redisClient.getPubSubClient();
|
|
|
|
await client.publish(
|
|
this.getAgentChatChannel({ workspaceId, threadId }),
|
|
payload,
|
|
);
|
|
}
|
|
}
|