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>
This commit is contained in:
+9
-2
@@ -13,8 +13,8 @@ export class AgentMessageDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
threadId: string;
|
||||
|
||||
@Field(() => UUIDScalarType)
|
||||
turnId: string;
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
turnId: string | null;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
agentId: string | null;
|
||||
@@ -22,9 +22,16 @@ export class AgentMessageDTO {
|
||||
@Field()
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
|
||||
@Field()
|
||||
status: 'queued' | 'sent';
|
||||
|
||||
@Field(() => [AgentMessagePartDTO])
|
||||
parts: AgentMessagePartDTO[];
|
||||
|
||||
@IsDateString()
|
||||
@Field(() => Date, { nullable: true })
|
||||
processedAt: Date | null;
|
||||
|
||||
@IsDateString()
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
|
||||
+19
-3
@@ -20,6 +20,11 @@ export enum AgentMessageRole {
|
||||
ASSISTANT = 'assistant',
|
||||
}
|
||||
|
||||
export enum AgentMessageStatus {
|
||||
QUEUED = 'queued',
|
||||
SENT = 'sent',
|
||||
}
|
||||
|
||||
@Entity('agentMessage')
|
||||
export class AgentMessageEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -35,15 +40,16 @@ export class AgentMessageEntity {
|
||||
@JoinColumn({ name: 'threadId' })
|
||||
thread: Relation<AgentChatThreadEntity>;
|
||||
|
||||
@Column('uuid')
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
@Index()
|
||||
turnId: string;
|
||||
turnId: string | null;
|
||||
|
||||
@ManyToOne(() => AgentTurnEntity, {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
@JoinColumn({ name: 'turnId' })
|
||||
turn: Relation<AgentTurnEntity>;
|
||||
turn: Relation<AgentTurnEntity> | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
@Index()
|
||||
@@ -52,9 +58,19 @@ export class AgentMessageEntity {
|
||||
@Column({ type: 'enum', enum: AgentMessageRole })
|
||||
role: AgentMessageRole;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: AgentMessageStatus,
|
||||
default: AgentMessageStatus.SENT,
|
||||
})
|
||||
status: AgentMessageStatus;
|
||||
|
||||
@OneToMany(() => AgentMessagePartEntity, (part) => part.message)
|
||||
parts: Relation<AgentMessagePartEntity[]>;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
processedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
|
||||
|
||||
import { type AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
|
||||
|
||||
// Maps TypeORM entity fields to UI message parts.
|
||||
// A parallel mapping for GraphQL DTOs exists in the frontend at:
|
||||
// packages/twenty-front/src/modules/ai/utils/mapDBPartToUIMessagePart.ts
|
||||
|
||||
export const mapDBPartToUIMessagePart = (
|
||||
part: AgentMessagePartEntity,
|
||||
): ExtendedUIMessagePart | null => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return {
|
||||
type: 'text',
|
||||
text: part.textContent ?? '',
|
||||
};
|
||||
case 'reasoning':
|
||||
return {
|
||||
type: 'reasoning',
|
||||
text: part.reasoningContent ?? '',
|
||||
state: (part.state as 'streaming' | 'done') ?? 'done',
|
||||
};
|
||||
case 'file':
|
||||
return {
|
||||
type: 'file',
|
||||
mediaType: part.fileFilename?.endsWith('.png')
|
||||
? 'image/png'
|
||||
: 'application/octet-stream',
|
||||
filename: part.fileFilename ?? '',
|
||||
url: '',
|
||||
};
|
||||
case 'source-url':
|
||||
return {
|
||||
type: 'source-url',
|
||||
sourceId: part.sourceUrlSourceId ?? '',
|
||||
url: part.sourceUrlUrl ?? '',
|
||||
title: part.sourceUrlTitle ?? '',
|
||||
providerMetadata: part.providerMetadata ?? undefined,
|
||||
};
|
||||
case 'source-document':
|
||||
return {
|
||||
type: 'source-document',
|
||||
sourceId: part.sourceDocumentSourceId ?? '',
|
||||
mediaType: part.sourceDocumentMediaType ?? '',
|
||||
title: part.sourceDocumentTitle ?? '',
|
||||
filename: part.sourceDocumentFilename ?? '',
|
||||
providerMetadata: part.providerMetadata ?? undefined,
|
||||
};
|
||||
case 'step-start':
|
||||
return {
|
||||
type: 'step-start',
|
||||
};
|
||||
case 'data-routing-status':
|
||||
return null;
|
||||
default: {
|
||||
if (part.type.includes('tool-') && part.toolCallId) {
|
||||
return {
|
||||
type: part.type,
|
||||
toolCallId: part.toolCallId,
|
||||
input: part.toolInput ?? {},
|
||||
output: part.toolOutput,
|
||||
errorText: part.errorMessage ?? '',
|
||||
state: part.state,
|
||||
} as ExtendedUIMessagePart;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
|
||||
|
||||
import { type AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
|
||||
import { mapDBPartToUIMessagePart } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart';
|
||||
|
||||
export const mapDBPartsToUIMessageParts = (
|
||||
parts: AgentMessagePartEntity[],
|
||||
): ExtendedUIMessagePart[] => {
|
||||
return parts
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
.map(mapDBPartToUIMessagePart)
|
||||
.filter((part): part is ExtendedUIMessagePart => part !== null);
|
||||
};
|
||||
@@ -33,13 +33,13 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
import { DashboardToolsModule } from 'src/modules/dashboard/tools/dashboard-tools.module';
|
||||
import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module';
|
||||
|
||||
import { AgentChatController } from './controllers/agent-chat.controller';
|
||||
import { AgentChatThreadDTO } from './dtos/agent-chat-thread.dto';
|
||||
import { AgentChatThreadEntity } from './entities/agent-chat-thread.entity';
|
||||
import { StreamAgentChatJob } from './jobs/stream-agent-chat.job';
|
||||
import { AgentChatResolver } from './resolvers/agent-chat.resolver';
|
||||
import { AgentChatSubscriptionResolver } from './resolvers/agent-chat-subscription.resolver';
|
||||
import { AgentChatCancelSubscriberService } from './services/agent-chat-cancel-subscriber.service';
|
||||
import { AgentChatResumableStreamService } from './services/agent-chat-resumable-stream.service';
|
||||
import { AgentChatEventPublisherService } from './services/agent-chat-event-publisher.service';
|
||||
import { AgentChatStreamingService } from './services/agent-chat-streaming.service';
|
||||
import { AgentChatService } from './services/agent-chat.service';
|
||||
import { AgentTitleGenerationService } from './services/agent-title-generation.service';
|
||||
@@ -101,11 +101,11 @@ import { SystemPromptBuilderService } from './services/system-prompt-builder.ser
|
||||
DashboardToolsModule,
|
||||
WorkflowToolsModule,
|
||||
],
|
||||
controllers: [AgentChatController],
|
||||
providers: [
|
||||
AgentChatCancelSubscriberService,
|
||||
AgentChatEventPublisherService,
|
||||
AgentChatResolver,
|
||||
AgentChatResumableStreamService,
|
||||
AgentChatSubscriptionResolver,
|
||||
AgentChatService,
|
||||
AgentChatStreamingService,
|
||||
AgentTitleGenerationService,
|
||||
|
||||
-183
@@ -1,183 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { UI_MESSAGE_STREAM_HEADERS } from 'ai';
|
||||
import type { Response } from 'express';
|
||||
import type { ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
|
||||
import { AgentRestApiExceptionFilter } from 'src/engine/metadata-modules/ai/ai-agent/filters/agent-api-exception.filter';
|
||||
import type { BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { getCancelChannel } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-cancel-channel.util';
|
||||
import { AgentChatResumableStreamService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-resumable-stream.service';
|
||||
import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
|
||||
@Controller('rest/agent-chat')
|
||||
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
|
||||
@UseFilters(
|
||||
RestApiExceptionFilter,
|
||||
AgentRestApiExceptionFilter,
|
||||
BillingRestApiExceptionFilter,
|
||||
)
|
||||
export class AgentChatController {
|
||||
constructor(
|
||||
private readonly agentStreamingService: AgentChatStreamingService,
|
||||
private readonly resumableStreamService: AgentChatResumableStreamService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly redisClientService: RedisClientService,
|
||||
@InjectRepository(AgentChatThreadEntity)
|
||||
private readonly threadRepository: Repository<AgentChatThreadEntity>,
|
||||
) {}
|
||||
|
||||
@Post('stream')
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI))
|
||||
async streamAgentChat(
|
||||
@Body()
|
||||
body: {
|
||||
threadId: string;
|
||||
messages: ExtendedUIMessage[];
|
||||
browsingContext?: BrowsingContextType | null;
|
||||
modelId?: string;
|
||||
},
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
if (this.aiModelRegistryService.getAvailableModels().length === 0) {
|
||||
throw new AgentException(
|
||||
'No AI models are available. Configure at least one AI provider.',
|
||||
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedModelId = body.modelId ?? workspace.smartModel;
|
||||
|
||||
this.aiModelRegistryService.validateModelAvailability(
|
||||
resolvedModelId,
|
||||
workspace,
|
||||
);
|
||||
|
||||
if (this.twentyConfigService.get('IS_BILLING_ENABLED')) {
|
||||
const canBill = await this.billingService.canBillMeteredProduct(
|
||||
workspace.id,
|
||||
BillingProductKey.WORKFLOW_NODE_EXECUTION,
|
||||
);
|
||||
|
||||
if (!canBill) {
|
||||
throw new BillingException(
|
||||
'Credits exhausted',
|
||||
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.agentStreamingService.streamAgentChat({
|
||||
threadId: body.threadId,
|
||||
messages: body.messages,
|
||||
browsingContext: body.browsingContext ?? null,
|
||||
modelId: body.modelId,
|
||||
userWorkspaceId,
|
||||
workspace,
|
||||
response,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':threadId/stream')
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI))
|
||||
async resumeAgentChatStream(
|
||||
@Param('threadId') threadId: string,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: { id: threadId, userWorkspaceId },
|
||||
});
|
||||
|
||||
if (!isDefined(thread) || !isDefined(thread.activeStreamId)) {
|
||||
response.status(204).end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const resumedNodeReadable =
|
||||
await this.resumableStreamService.resumeExistingStreamAsNodeReadable(
|
||||
thread.activeStreamId,
|
||||
);
|
||||
|
||||
if (!isDefined(resumedNodeReadable)) {
|
||||
response.status(204).end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, UI_MESSAGE_STREAM_HEADERS);
|
||||
resumedNodeReadable.pipe(response);
|
||||
}
|
||||
|
||||
@Delete(':threadId/stream')
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI))
|
||||
async stopAgentChatStream(
|
||||
@Param('threadId') threadId: string,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
) {
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: { id: threadId, userWorkspaceId },
|
||||
});
|
||||
|
||||
if (!isDefined(thread) || !isDefined(thread.activeStreamId)) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Publish a cancel signal via Redis pub/sub. The BullMQ worker
|
||||
// processing this thread's stream subscribes to this channel and
|
||||
// will abort the LLM connection when the message arrives — stopping
|
||||
// token generation and billing immediately.
|
||||
const redis = this.redisClientService.getClient();
|
||||
|
||||
await redis.publish(getCancelChannel(threadId), 'cancel');
|
||||
|
||||
await this.threadRepository.update(
|
||||
{ id: threadId, userWorkspaceId },
|
||||
{ activeStreamId: null },
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
// Typed as JSON because the payload is AgentChatSubscriptionEvent
|
||||
// (a discriminated union defined in twenty-shared).
|
||||
@ObjectType('AgentChatEvent')
|
||||
export class AgentChatEventDTO {
|
||||
@Field(() => String)
|
||||
threadId: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
event: Record<string, unknown>;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
@ObjectType('ChatStreamCatchupChunks')
|
||||
export class ChatStreamCatchupChunksDTO {
|
||||
@Field(() => [GraphQLJSON])
|
||||
chunks: Record<string, unknown>[];
|
||||
|
||||
@Field(() => Int)
|
||||
maxSeq: number;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('SendChatMessageResult')
|
||||
export class SendChatMessageResultDTO {
|
||||
@Field(() => String)
|
||||
messageId: string;
|
||||
|
||||
@Field(() => Boolean)
|
||||
queued: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
streamId?: string;
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const STREAM_AGENT_CHAT_JOB_NAME = 'StreamAgentChatJob';
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
ExtendedUIMessage,
|
||||
ExtendedUIMessagePart,
|
||||
} from 'twenty-shared/ai';
|
||||
|
||||
import type { BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
|
||||
|
||||
export type StreamAgentChatJobData = {
|
||||
threadId: string;
|
||||
streamId: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
messages: ExtendedUIMessage[];
|
||||
browsingContext: BrowsingContextType | null;
|
||||
modelId?: string;
|
||||
lastUserMessageText: string;
|
||||
lastUserMessageParts: ExtendedUIMessagePart[];
|
||||
hasTitle: boolean;
|
||||
existingTurnId?: string;
|
||||
};
|
||||
+103
-46
@@ -1,7 +1,7 @@
|
||||
import { Logger, Scope } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { createUIMessageStream, JsonToSseTransformStream } from 'ai';
|
||||
import { createUIMessageStream } from 'ai';
|
||||
import type {
|
||||
CodeExecutionData,
|
||||
ExtendedUIMessage,
|
||||
@@ -10,37 +10,27 @@ import type {
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AgentChatCancelSubscriberService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-cancel-subscriber.service';
|
||||
import { AgentChatEventPublisherService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-event-publisher.service';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { AgentMessageRole } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
|
||||
import type { BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
|
||||
import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service';
|
||||
import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/utils/compute-cost-breakdown.util';
|
||||
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
|
||||
import { extractCacheCreationTokens } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
|
||||
import type { AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { AgentChatResumableStreamService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-resumable-stream.service';
|
||||
import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service';
|
||||
import { ChatExecutionService } from 'src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service';
|
||||
import { getCancelChannel } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-cancel-channel.util';
|
||||
|
||||
export const STREAM_AGENT_CHAT_JOB_NAME = 'StreamAgentChatJob';
|
||||
import { STREAM_AGENT_CHAT_JOB_NAME } from './stream-agent-chat-job-name.constant';
|
||||
import { type StreamAgentChatJobData } from './stream-agent-chat-job.types';
|
||||
|
||||
export type StreamAgentChatJobData = {
|
||||
threadId: string;
|
||||
streamId: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
messages: ExtendedUIMessage[];
|
||||
browsingContext: BrowsingContextType | null;
|
||||
modelId?: string;
|
||||
lastUserMessageText: string;
|
||||
lastUserMessageParts: ExtendedUIMessagePart[];
|
||||
hasTitle: boolean;
|
||||
};
|
||||
export { STREAM_AGENT_CHAT_JOB_NAME, type StreamAgentChatJobData };
|
||||
|
||||
@Processor({ queueName: MessageQueue.aiStreamQueue, scope: Scope.REQUEST })
|
||||
export class StreamAgentChatJob {
|
||||
@@ -53,8 +43,9 @@ export class StreamAgentChatJob {
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
private readonly agentChatService: AgentChatService,
|
||||
private readonly chatExecutionService: ChatExecutionService,
|
||||
private readonly resumableStreamService: AgentChatResumableStreamService,
|
||||
private readonly eventPublisherService: AgentChatEventPublisherService,
|
||||
private readonly cancelSubscriberService: AgentChatCancelSubscriberService,
|
||||
private readonly agentChatStreamingService: AgentChatStreamingService,
|
||||
) {}
|
||||
|
||||
@Process(STREAM_AGENT_CHAT_JOB_NAME)
|
||||
@@ -65,9 +56,14 @@ export class StreamAgentChatJob {
|
||||
|
||||
if (!workspace) {
|
||||
this.logger.error(`Workspace ${data.workspaceId} not found`);
|
||||
await this.resumableStreamService.writeStreamError(data.streamId, {
|
||||
code: 'WORKSPACE_NOT_FOUND',
|
||||
message: `Workspace ${data.workspaceId} not found`,
|
||||
await this.eventPublisherService.publish({
|
||||
threadId: data.threadId,
|
||||
workspaceId: data.workspaceId,
|
||||
event: {
|
||||
type: 'stream-error',
|
||||
code: 'WORKSPACE_NOT_FOUND',
|
||||
message: `Workspace ${data.workspaceId} not found`,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -86,11 +82,18 @@ export class StreamAgentChatJob {
|
||||
this.logger.error(
|
||||
`Stream ${data.streamId} failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
await this.resumableStreamService
|
||||
.writeStreamError(data.streamId, {
|
||||
code: 'STREAM_EXECUTION_FAILED',
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Stream execution failed',
|
||||
await this.eventPublisherService
|
||||
.publish({
|
||||
threadId: data.threadId,
|
||||
workspaceId: data.workspaceId,
|
||||
event: {
|
||||
type: 'stream-error',
|
||||
code: 'STREAM_EXECUTION_FAILED',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Stream execution failed',
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
@@ -105,6 +108,21 @@ export class StreamAgentChatJob {
|
||||
})
|
||||
.execute()
|
||||
.catch(() => {});
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
await this.agentChatStreamingService
|
||||
.flushNextQueuedMessage(
|
||||
data.threadId,
|
||||
data.userWorkspaceId,
|
||||
data.workspaceId,
|
||||
data.hasTitle,
|
||||
)
|
||||
.catch((error) => {
|
||||
this.logger.error(
|
||||
`Failed to flush queued message for thread ${data.threadId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,26 +131,34 @@ export class StreamAgentChatJob {
|
||||
workspace: WorkspaceEntity,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const userMessagePromise = this.agentChatService.addMessage({
|
||||
threadId: data.threadId,
|
||||
uiMessage: {
|
||||
role: AgentMessageRole.USER,
|
||||
parts: data.lastUserMessageParts.filter(
|
||||
(part): part is ExtendedUIMessagePart =>
|
||||
part.type === 'text' || part.type === 'file',
|
||||
),
|
||||
},
|
||||
});
|
||||
// When processing a promoted queued message, the user message already
|
||||
// exists in the DB with a turn — skip persisting it again.
|
||||
const userMessagePromise = data.existingTurnId
|
||||
? Promise.resolve({ turnId: data.existingTurnId })
|
||||
: this.agentChatService.addMessage({
|
||||
threadId: data.threadId,
|
||||
uiMessage: {
|
||||
role: AgentMessageRole.USER,
|
||||
parts: data.lastUserMessageParts.filter(
|
||||
(part): part is ExtendedUIMessagePart =>
|
||||
part.type === 'text' || part.type === 'file',
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
userMessagePromise.catch(() => {});
|
||||
|
||||
const titlePromise = data.hasTitle
|
||||
? Promise.resolve(null)
|
||||
: this.agentChatService
|
||||
.generateTitleIfNeeded(data.threadId, data.lastUserMessageText)
|
||||
.generateTitleIfNeeded({
|
||||
threadId: data.threadId,
|
||||
messageContent: data.lastUserMessageText,
|
||||
workspaceId: data.workspaceId,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
await this.buildAndPipeStream({
|
||||
await this.buildAndPublishStream({
|
||||
workspace,
|
||||
data,
|
||||
userMessagePromise,
|
||||
@@ -141,7 +167,7 @@ export class StreamAgentChatJob {
|
||||
});
|
||||
}
|
||||
|
||||
private async buildAndPipeStream({
|
||||
private async buildAndPublishStream({
|
||||
workspace,
|
||||
data,
|
||||
userMessagePromise,
|
||||
@@ -150,7 +176,7 @@ export class StreamAgentChatJob {
|
||||
}: {
|
||||
workspace: WorkspaceEntity;
|
||||
data: StreamAgentChatJobData;
|
||||
userMessagePromise: Promise<{ turnId: string }>;
|
||||
userMessagePromise: Promise<{ turnId: string | null }>;
|
||||
titlePromise: Promise<string | null>;
|
||||
abortSignal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
@@ -164,6 +190,14 @@ export class StreamAgentChatJob {
|
||||
let lastStepConversationSize = 0;
|
||||
let totalCacheCreationTokens = 0;
|
||||
|
||||
// onFinish fires before the uiStream is fully drained. We use this
|
||||
// promise to coordinate: the IIFE waits for DB persist to complete
|
||||
// before publishing message-persisted (after all chunks).
|
||||
let resolveStreamFinished: () => void;
|
||||
const streamFinishedPromise = new Promise<void>((res) => {
|
||||
resolveStreamFinished = res;
|
||||
});
|
||||
|
||||
abortSignal.addEventListener('abort', () => resolve(), { once: true });
|
||||
|
||||
const uiStream = createUIMessageStream<ExtendedUIMessage>({
|
||||
@@ -233,7 +267,7 @@ export class StreamAgentChatJob {
|
||||
userMessagePromise,
|
||||
});
|
||||
await titleWritePromise;
|
||||
resolve();
|
||||
resolveStreamFinished();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
@@ -244,11 +278,34 @@ export class StreamAgentChatJob {
|
||||
},
|
||||
});
|
||||
|
||||
const sseStream = uiStream.pipeThrough(new JsonToSseTransformStream());
|
||||
// Publish all chunks first, then signal completion. This guarantees
|
||||
// message-persisted arrives after every stream-chunk on the client.
|
||||
(async () => {
|
||||
try {
|
||||
for await (const chunk of uiStream) {
|
||||
await this.eventPublisherService.publish({
|
||||
threadId: data.threadId,
|
||||
workspaceId: data.workspaceId,
|
||||
event: {
|
||||
type: 'stream-chunk',
|
||||
chunk: chunk as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.resumableStreamService
|
||||
.createResumableStream(data.streamId, () => sseStream)
|
||||
.catch(reject);
|
||||
await streamFinishedPromise;
|
||||
|
||||
await this.eventPublisherService.publish({
|
||||
threadId: data.threadId,
|
||||
workspaceId: data.workspaceId,
|
||||
event: { type: 'message-persisted', messageId: data.threadId },
|
||||
});
|
||||
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -358,7 +415,7 @@ export class StreamAgentChatJob {
|
||||
};
|
||||
lastStepConversationSize: number;
|
||||
modelConfig: AIModelConfig;
|
||||
userMessagePromise: Promise<{ turnId: string }>;
|
||||
userMessagePromise: Promise<{ turnId: string | null }>;
|
||||
}): Promise<void> {
|
||||
if (responseMessage.parts.length === 0) {
|
||||
return;
|
||||
@@ -369,7 +426,7 @@ export class StreamAgentChatJob {
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
uiMessage: responseMessage,
|
||||
turnId: userMessage.turnId,
|
||||
turnId: userMessage.turnId ?? undefined,
|
||||
});
|
||||
|
||||
await this.threadRepository.update(threadId, {
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
|
||||
import { AgentChatEventDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-event.dto';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
@MetadataResolver()
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
|
||||
export class AgentChatSubscriptionResolver {
|
||||
constructor(
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
@InjectRepository(AgentChatThreadEntity)
|
||||
private readonly threadRepository: Repository<AgentChatThreadEntity>,
|
||||
) {}
|
||||
|
||||
@Subscription(() => AgentChatEventDTO, {
|
||||
filter: (
|
||||
payload: { onAgentChatEvent: AgentChatEventDTO },
|
||||
variables: { threadId: string },
|
||||
) => {
|
||||
return payload.onAgentChatEvent.threadId === variables.threadId;
|
||||
},
|
||||
})
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI))
|
||||
async onAgentChatEvent(
|
||||
@Args('threadId', { type: () => UUIDScalarType }) threadId: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
) {
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: { id: threadId, userWorkspaceId },
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
if (!isDefined(thread)) {
|
||||
throw new AgentException(
|
||||
'Thread not found',
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
return this.subscriptionService.subscribeToAgentChat({
|
||||
workspaceId: workspace.id,
|
||||
threadId,
|
||||
});
|
||||
}
|
||||
}
|
||||
+202
-3
@@ -8,11 +8,23 @@ import {
|
||||
ResolveField,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
@@ -23,12 +35,23 @@ import {
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
|
||||
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
|
||||
import { AgentMessageDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/agent-message.dto';
|
||||
import { AgentChatThreadDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto';
|
||||
import { AISystemPromptPreviewDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/ai-system-prompt-preview.dto';
|
||||
import { type AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { ChatStreamCatchupChunksDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/chat-stream-catchup-chunks.dto';
|
||||
import { SendChatMessageResultDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/send-chat-message-result.dto';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { AgentChatEventPublisherService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-event-publisher.service';
|
||||
import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service';
|
||||
import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service';
|
||||
import { getCancelChannel } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-cancel-channel.util';
|
||||
import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
@@ -39,7 +62,15 @@ import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-ch
|
||||
export class AgentChatResolver {
|
||||
constructor(
|
||||
private readonly agentChatService: AgentChatService,
|
||||
private readonly agentChatStreamingService: AgentChatStreamingService,
|
||||
private readonly eventPublisherService: AgentChatEventPublisherService,
|
||||
private readonly systemPromptBuilderService: SystemPromptBuilderService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly redisClientService: RedisClientService,
|
||||
@InjectRepository(AgentChatThreadEntity)
|
||||
private readonly threadRepository: Repository<AgentChatThreadEntity>,
|
||||
) {}
|
||||
|
||||
@Query(() => AgentChatThreadDTO)
|
||||
@@ -63,10 +94,178 @@ export class AgentChatResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@Query(() => ChatStreamCatchupChunksDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async chatStreamCatchupChunks(
|
||||
@Args('threadId', { type: () => UUIDScalarType }) threadId: string,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
) {
|
||||
await this.agentChatService.getThreadById(threadId, userWorkspaceId);
|
||||
|
||||
return this.eventPublisherService.getAccumulatedChunks(threadId);
|
||||
}
|
||||
|
||||
@Mutation(() => AgentChatThreadDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async createChatThread(@AuthUserWorkspaceId() userWorkspaceId: string) {
|
||||
return this.agentChatService.createThread(userWorkspaceId);
|
||||
async createChatThread(
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
) {
|
||||
return this.agentChatService.createThread({
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => SendChatMessageResultDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async sendChatMessage(
|
||||
@Args('threadId', { type: () => UUIDScalarType }) threadId: string,
|
||||
@Args('text') text: string,
|
||||
@Args('messageId', { type: () => UUIDScalarType }) messageId: string,
|
||||
@Args('browsingContext', { type: () => GraphQLJSON, nullable: true })
|
||||
browsingContext: BrowsingContextType | null,
|
||||
@Args('modelId', { type: () => String, nullable: true })
|
||||
modelId: string | undefined,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<SendChatMessageResultDTO> {
|
||||
if (this.aiModelRegistryService.getAvailableModels().length === 0) {
|
||||
throw new AgentException(
|
||||
'No AI models are available. Configure at least one AI provider.',
|
||||
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedModelId = modelId ?? workspace.smartModel;
|
||||
|
||||
this.aiModelRegistryService.validateModelAvailability(
|
||||
resolvedModelId,
|
||||
workspace,
|
||||
);
|
||||
|
||||
if (this.twentyConfigService.get('IS_BILLING_ENABLED')) {
|
||||
const canBill = await this.billingService.canBillMeteredProduct(
|
||||
workspace.id,
|
||||
BillingProductKey.WORKFLOW_NODE_EXECUTION,
|
||||
);
|
||||
|
||||
if (!canBill) {
|
||||
throw new BillingException(
|
||||
'Credits exhausted',
|
||||
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: { id: threadId, userWorkspaceId },
|
||||
});
|
||||
|
||||
if (!isDefined(thread)) {
|
||||
throw new AgentException(
|
||||
'Thread not found',
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(thread.activeStreamId)) {
|
||||
const queuedMessage = await this.agentChatService.queueMessage({
|
||||
threadId,
|
||||
text,
|
||||
id: messageId,
|
||||
});
|
||||
|
||||
await this.eventPublisherService.publish({
|
||||
threadId,
|
||||
workspaceId: workspace.id,
|
||||
event: { type: 'queue-updated' },
|
||||
});
|
||||
|
||||
return { messageId: queuedMessage.id, queued: true };
|
||||
}
|
||||
|
||||
const result = await this.agentChatStreamingService.streamAgentChat({
|
||||
threadId,
|
||||
browsingContext: browsingContext ?? null,
|
||||
modelId,
|
||||
userWorkspaceId,
|
||||
workspace,
|
||||
text,
|
||||
messageId,
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: result.messageId,
|
||||
queued: false,
|
||||
streamId: result.streamId,
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async stopAgentChatStream(
|
||||
@Args('threadId', { type: () => UUIDScalarType }) threadId: string,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<boolean> {
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: { id: threadId, userWorkspaceId },
|
||||
});
|
||||
|
||||
if (!isDefined(thread) || !isDefined(thread.activeStreamId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const redis = this.redisClientService.getClient();
|
||||
|
||||
await redis.publish(getCancelChannel(threadId), 'cancel');
|
||||
|
||||
await this.threadRepository.update(
|
||||
{ id: threadId, userWorkspaceId },
|
||||
{ activeStreamId: null },
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async deleteQueuedChatMessage(
|
||||
@Args('messageId', { type: () => UUIDScalarType }) messageId: string,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
const message = await this.agentChatService.findQueuedMessage(messageId);
|
||||
|
||||
if (!isDefined(message)) {
|
||||
throw new AgentException(
|
||||
'Queued message not found',
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: { id: message.threadId, userWorkspaceId },
|
||||
});
|
||||
|
||||
if (!isDefined(thread)) {
|
||||
throw new AgentException(
|
||||
'Thread not found',
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
const deleted = await this.agentChatService.deleteQueuedMessage(messageId);
|
||||
|
||||
if (deleted) {
|
||||
await this.eventPublisherService.publish({
|
||||
threadId: message.threadId,
|
||||
workspaceId: workspace.id,
|
||||
event: { type: 'queue-updated' },
|
||||
});
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
@Query(() => AISystemPromptPreviewDTO)
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type AgentChatSubscriptionEvent } from 'twenty-shared/ai';
|
||||
|
||||
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
|
||||
const STREAM_CHUNKS_TTL_SECONDS = 3600;
|
||||
|
||||
@Injectable()
|
||||
export class AgentChatEventPublisherService {
|
||||
constructor(
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly redisClientService: RedisClientService,
|
||||
) {}
|
||||
|
||||
private getStreamChunksKey(threadId: string): string {
|
||||
return `agent-chat-stream-chunks:${threadId}`;
|
||||
}
|
||||
|
||||
async publish({
|
||||
threadId,
|
||||
workspaceId,
|
||||
event,
|
||||
}: {
|
||||
threadId: string;
|
||||
workspaceId: string;
|
||||
event: AgentChatSubscriptionEvent;
|
||||
}): Promise<void> {
|
||||
let publishedEvent = event;
|
||||
|
||||
if (event.type === 'stream-chunk') {
|
||||
const redis = this.redisClientService.getClient();
|
||||
const key = this.getStreamChunksKey(threadId);
|
||||
|
||||
// RPUSH returns the new list length — use it as a 1-based sequence number
|
||||
const seq = await redis.rpush(key, JSON.stringify(event.chunk));
|
||||
await redis.expire(key, STREAM_CHUNKS_TTL_SECONDS);
|
||||
|
||||
publishedEvent = { ...event, seq };
|
||||
} else if (event.type === 'message-persisted') {
|
||||
const redis = this.redisClientService.getClient();
|
||||
await redis.del(this.getStreamChunksKey(threadId));
|
||||
}
|
||||
|
||||
await this.subscriptionService.publishToAgentChat({
|
||||
workspaceId,
|
||||
threadId,
|
||||
payload: {
|
||||
onAgentChatEvent: {
|
||||
threadId,
|
||||
event: publishedEvent,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAccumulatedChunks(threadId: string): Promise<{
|
||||
chunks: Record<string, unknown>[];
|
||||
maxSeq: number;
|
||||
}> {
|
||||
const redis = this.redisClientService.getClient();
|
||||
const rawChunks = await redis.lrange(
|
||||
this.getStreamChunksKey(threadId),
|
||||
0,
|
||||
-1,
|
||||
);
|
||||
|
||||
return {
|
||||
chunks: rawChunks.map((raw) => JSON.parse(raw)),
|
||||
maxSeq: rawChunks.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
import { type ReadableStream as NodeWebReadableStream } from 'stream/web';
|
||||
|
||||
import type { Redis } from 'ioredis';
|
||||
import { createResumableStreamContext } from 'resumable-stream/ioredis';
|
||||
|
||||
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||
|
||||
@Injectable()
|
||||
export class AgentChatResumableStreamService implements OnModuleDestroy {
|
||||
private streamContext: ReturnType<typeof createResumableStreamContext>;
|
||||
private streamPublisher: Redis;
|
||||
private streamSubscriber: Redis;
|
||||
private redisClient: Redis;
|
||||
|
||||
constructor(private readonly redisClientService: RedisClientService) {
|
||||
const baseClient = this.redisClientService.getClient();
|
||||
|
||||
this.streamPublisher = baseClient.duplicate();
|
||||
this.streamSubscriber = baseClient.duplicate();
|
||||
this.redisClient = baseClient.duplicate();
|
||||
|
||||
this.streamContext = createResumableStreamContext({
|
||||
waitUntil: () => {},
|
||||
publisher: this.streamPublisher,
|
||||
subscriber: this.streamSubscriber,
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.streamPublisher.quit();
|
||||
await this.streamSubscriber.quit();
|
||||
await this.redisClient.quit();
|
||||
}
|
||||
|
||||
async createResumableStream(
|
||||
streamId: string,
|
||||
streamFactory: () => ReadableStream<string>,
|
||||
) {
|
||||
const resumableStream = await this.streamContext.createNewResumableStream(
|
||||
streamId,
|
||||
streamFactory,
|
||||
);
|
||||
|
||||
if (!resumableStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the stream to completion in the background so chunks are
|
||||
// published to Redis and available for later resume consumers.
|
||||
const reader = resumableStream.getReader();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stream interrupted — chunks already published are still resumable.
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async resumeExistingStreamAsNodeReadable(
|
||||
streamId: string,
|
||||
): Promise<Readable | null> {
|
||||
const webStream = await this.streamContext.resumeExistingStream(streamId);
|
||||
|
||||
if (!webStream) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Readable.fromWeb(webStream as NodeWebReadableStream);
|
||||
}
|
||||
|
||||
async writeStreamError(
|
||||
streamId: string,
|
||||
error: { code: string; message: string },
|
||||
): Promise<void> {
|
||||
await this.redisClient.set(
|
||||
`ai-stream:error:${streamId}`,
|
||||
JSON.stringify(error),
|
||||
'EX',
|
||||
60,
|
||||
);
|
||||
}
|
||||
|
||||
async readStreamError(
|
||||
streamId: string,
|
||||
): Promise<{ code: string; message: string } | null> {
|
||||
const raw = await this.redisClient.get(`ai-stream:error:${streamId}`);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
}
|
||||
+116
-81
@@ -1,10 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { type Readable } from 'stream';
|
||||
|
||||
import { generateId, UI_MESSAGE_STREAM_HEADERS } from 'ai';
|
||||
import { type Response } from 'express';
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
import { generateId } from 'ai';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
@@ -16,27 +13,27 @@ import {
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
|
||||
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import {
|
||||
STREAM_AGENT_CHAT_JOB_NAME,
|
||||
type StreamAgentChatJobData,
|
||||
} from 'src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat.job';
|
||||
|
||||
import { AgentChatResumableStreamService } from './agent-chat-resumable-stream.service';
|
||||
AgentMessageRole,
|
||||
AgentMessageStatus,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
|
||||
import { mapDBPartsToUIMessageParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartsToUIMessageParts';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { STREAM_AGENT_CHAT_JOB_NAME } from 'src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat-job-name.constant';
|
||||
import { type StreamAgentChatJobData } from 'src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat-job.types';
|
||||
import { AgentChatEventPublisherService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-event-publisher.service';
|
||||
import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service';
|
||||
|
||||
export type StreamAgentChatOptions = {
|
||||
threadId: string;
|
||||
userWorkspaceId: string;
|
||||
workspace: WorkspaceEntity;
|
||||
response: Response;
|
||||
messages: ExtendedUIMessage[];
|
||||
text: string;
|
||||
browsingContext: BrowsingContextType | null;
|
||||
modelId?: string;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
const STREAM_READY_TIMEOUT_MS = 5_000;
|
||||
const STREAM_READY_POLL_INTERVAL_MS = 50;
|
||||
|
||||
@Injectable()
|
||||
export class AgentChatStreamingService {
|
||||
private readonly logger = new Logger(AgentChatStreamingService.name);
|
||||
@@ -46,18 +43,19 @@ export class AgentChatStreamingService {
|
||||
private readonly threadRepository: Repository<AgentChatThreadEntity>,
|
||||
@InjectMessageQueue(MessageQueue.aiStreamQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
private readonly resumableStreamService: AgentChatResumableStreamService,
|
||||
private readonly agentChatService: AgentChatService,
|
||||
private readonly eventPublisherService: AgentChatEventPublisherService,
|
||||
) {}
|
||||
|
||||
async streamAgentChat({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
workspace,
|
||||
messages,
|
||||
text,
|
||||
browsingContext,
|
||||
response,
|
||||
modelId,
|
||||
}: StreamAgentChatOptions) {
|
||||
messageId,
|
||||
}: StreamAgentChatOptions): Promise<{ streamId: string; messageId: string }> {
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: {
|
||||
id: threadId,
|
||||
@@ -72,10 +70,21 @@ export class AgentChatStreamingService {
|
||||
);
|
||||
}
|
||||
|
||||
const savedUserMessage = await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
id: messageId,
|
||||
uiMessage: {
|
||||
role: AgentMessageRole.USER,
|
||||
parts: [{ type: 'text' as const, text }],
|
||||
},
|
||||
});
|
||||
|
||||
const previousMessages = await this.loadMessagesFromDB(
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
const streamId = generateId();
|
||||
const lastUserMessage = messages[messages.length - 1];
|
||||
const lastUserText =
|
||||
lastUserMessage?.parts.find((part) => part.type === 'text')?.text ?? '';
|
||||
|
||||
await this.messageQueueService.add<StreamAgentChatJobData>(
|
||||
STREAM_AGENT_CHAT_JOB_NAME,
|
||||
@@ -84,12 +93,13 @@ export class AgentChatStreamingService {
|
||||
streamId,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
messages,
|
||||
messages: previousMessages,
|
||||
browsingContext,
|
||||
modelId,
|
||||
lastUserMessageText: lastUserText,
|
||||
lastUserMessageParts: lastUserMessage?.parts ?? [],
|
||||
lastUserMessageText: text,
|
||||
lastUserMessageParts: [{ type: 'text', text }],
|
||||
hasTitle: !!thread.title,
|
||||
existingTurnId: savedUserMessage.turnId ?? undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -97,67 +107,92 @@ export class AgentChatStreamingService {
|
||||
activeStreamId: streamId,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.waitForResumableStream(streamId);
|
||||
|
||||
if ('error' in result) {
|
||||
response.status(500).json(result.error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.readable) {
|
||||
this.logger.error(
|
||||
`Stream ${streamId} did not become available within timeout`,
|
||||
);
|
||||
response
|
||||
.status(500)
|
||||
.json({ code: 'WORKER_UNREACHABLE', message: 'Stream timed out' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, UI_MESSAGE_STREAM_HEADERS);
|
||||
result.readable.pipe(response);
|
||||
} catch (error) {
|
||||
response.end();
|
||||
throw error;
|
||||
}
|
||||
return { streamId, messageId: savedUserMessage.id };
|
||||
}
|
||||
|
||||
private async waitForResumableStream(
|
||||
streamId: string,
|
||||
): Promise<
|
||||
| { readable: Readable }
|
||||
| { error: { code: string; message: string } }
|
||||
| { readable: null }
|
||||
> {
|
||||
const maxAttempts = Math.ceil(
|
||||
STREAM_READY_TIMEOUT_MS / STREAM_READY_POLL_INTERVAL_MS,
|
||||
async flushNextQueuedMessage(
|
||||
threadId: string,
|
||||
userWorkspaceId: string,
|
||||
workspaceId: string,
|
||||
hasTitle: boolean,
|
||||
): Promise<void> {
|
||||
const queuedMessages =
|
||||
await this.agentChatService.getQueuedMessages(threadId);
|
||||
|
||||
const nextQueued = queuedMessages[0];
|
||||
|
||||
if (!nextQueued) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textPart = nextQueued.parts?.find((part) => part.type === 'text');
|
||||
const messageText = textPart?.textContent ?? '';
|
||||
|
||||
if (messageText === '') {
|
||||
await this.agentChatService.deleteQueuedMessage(nextQueued.id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const turnId = await this.agentChatService.promoteQueuedMessage(
|
||||
nextQueued.id,
|
||||
threadId,
|
||||
);
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const streamError =
|
||||
await this.resumableStreamService.readStreamError(streamId);
|
||||
|
||||
if (streamError) {
|
||||
return { error: streamError };
|
||||
}
|
||||
|
||||
const readable =
|
||||
await this.resumableStreamService.resumeExistingStreamAsNodeReadable(
|
||||
streamId,
|
||||
);
|
||||
|
||||
if (readable) {
|
||||
return { readable };
|
||||
}
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, STREAM_READY_POLL_INTERVAL_MS),
|
||||
);
|
||||
if (turnId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { readable: null };
|
||||
await this.eventPublisherService.publish({
|
||||
threadId,
|
||||
workspaceId,
|
||||
event: { type: 'queue-updated' },
|
||||
});
|
||||
|
||||
await this.eventPublisherService.publish({
|
||||
threadId,
|
||||
workspaceId,
|
||||
event: { type: 'message-persisted', messageId: nextQueued.id },
|
||||
});
|
||||
|
||||
const uiMessages = await this.loadMessagesFromDB(threadId, userWorkspaceId);
|
||||
|
||||
const streamId = generateId();
|
||||
|
||||
await this.messageQueueService.add<StreamAgentChatJobData>(
|
||||
STREAM_AGENT_CHAT_JOB_NAME,
|
||||
{
|
||||
threadId,
|
||||
streamId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
messages: uiMessages,
|
||||
browsingContext: null,
|
||||
lastUserMessageText: messageText,
|
||||
lastUserMessageParts: [{ type: 'text', text: messageText }],
|
||||
hasTitle,
|
||||
existingTurnId: turnId,
|
||||
},
|
||||
);
|
||||
|
||||
await this.threadRepository.update(threadId, {
|
||||
activeStreamId: streamId,
|
||||
});
|
||||
}
|
||||
|
||||
private async loadMessagesFromDB(threadId: string, userWorkspaceId: string) {
|
||||
const allMessages = await this.agentChatService.getMessagesForThread(
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
return allMessages
|
||||
.filter((message) => message.status !== AgentMessageStatus.QUEUED)
|
||||
.map((message) => ({
|
||||
id: message.id,
|
||||
role: message.role as 'user' | 'assistant' | 'system',
|
||||
parts: mapDBPartsToUIMessageParts(message.parts ?? []),
|
||||
createdAt: message.createdAt,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+156
-6
@@ -10,6 +10,7 @@ import { AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-
|
||||
import {
|
||||
AgentMessageEntity,
|
||||
AgentMessageRole,
|
||||
AgentMessageStatus,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
|
||||
import { AgentTurnEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-turn.entity';
|
||||
import { mapUIMessagePartsToDBParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts';
|
||||
@@ -18,9 +19,23 @@ import {
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service';
|
||||
|
||||
import { AgentTitleGenerationService } from './agent-title-generation.service';
|
||||
|
||||
const serializeThreadForBroadcast = (thread: AgentChatThreadEntity) => ({
|
||||
id: thread.id,
|
||||
title: thread.title,
|
||||
totalInputTokens: thread.totalInputTokens,
|
||||
totalOutputTokens: thread.totalOutputTokens,
|
||||
contextWindowTokens: thread.contextWindowTokens,
|
||||
conversationSize: thread.conversationSize,
|
||||
totalInputCredits: thread.totalInputCredits,
|
||||
totalOutputCredits: thread.totalOutputCredits,
|
||||
createdAt: thread.createdAt.toISOString(),
|
||||
updatedAt: thread.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class AgentChatService {
|
||||
constructor(
|
||||
@@ -33,14 +48,37 @@ export class AgentChatService {
|
||||
@InjectRepository(AgentMessagePartEntity)
|
||||
private readonly messagePartRepository: Repository<AgentMessagePartEntity>,
|
||||
private readonly titleGenerationService: AgentTitleGenerationService,
|
||||
private readonly workspaceEventBroadcaster: WorkspaceEventBroadcaster,
|
||||
) {}
|
||||
|
||||
async createThread(userWorkspaceId: string) {
|
||||
async createThread({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const thread = this.threadRepository.create({
|
||||
userWorkspaceId,
|
||||
});
|
||||
|
||||
return this.threadRepository.save(thread);
|
||||
const savedThread = await this.threadRepository.save(thread);
|
||||
|
||||
await this.workspaceEventBroadcaster.broadcast({
|
||||
workspaceId,
|
||||
events: [
|
||||
{
|
||||
type: 'created',
|
||||
entityName: 'agentChatThread',
|
||||
recordId: savedThread.id,
|
||||
properties: {
|
||||
after: serializeThreadForBroadcast(savedThread),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return savedThread;
|
||||
}
|
||||
|
||||
async getThreadById(threadId: string, userWorkspaceId: string) {
|
||||
@@ -66,12 +104,14 @@ export class AgentChatService {
|
||||
uiMessage,
|
||||
agentId,
|
||||
turnId,
|
||||
id,
|
||||
}: {
|
||||
threadId: string;
|
||||
uiMessage: Omit<ExtendedUIMessage, 'id'>;
|
||||
uiMessageParts?: UIMessagePart<UIDataTypes, UITools>[];
|
||||
agentId?: string;
|
||||
turnId?: string;
|
||||
id?: string;
|
||||
}) {
|
||||
let actualTurnId = turnId;
|
||||
|
||||
@@ -87,10 +127,12 @@ export class AgentChatService {
|
||||
}
|
||||
|
||||
const message = this.messageRepository.create({
|
||||
...(id ? { id } : {}),
|
||||
threadId,
|
||||
turnId: actualTurnId,
|
||||
role: uiMessage.role as AgentMessageRole,
|
||||
agentId: agentId ?? null,
|
||||
processedAt: new Date(),
|
||||
});
|
||||
|
||||
const savedMessage = await this.messageRepository.save(message);
|
||||
@@ -124,18 +166,111 @@ export class AgentChatService {
|
||||
|
||||
return this.messageRepository.find({
|
||||
where: { threadId },
|
||||
order: { createdAt: 'ASC' },
|
||||
order: { processedAt: { direction: 'ASC', nulls: 'LAST' } },
|
||||
relations: ['parts', 'parts.file'],
|
||||
});
|
||||
}
|
||||
|
||||
async generateTitleIfNeeded(
|
||||
async queueMessage({
|
||||
threadId,
|
||||
text,
|
||||
id,
|
||||
}: {
|
||||
threadId: string;
|
||||
text: string;
|
||||
id?: string;
|
||||
}): Promise<AgentMessageEntity> {
|
||||
const message = this.messageRepository.create({
|
||||
...(id ? { id } : {}),
|
||||
threadId,
|
||||
turnId: null,
|
||||
role: AgentMessageRole.USER,
|
||||
agentId: null,
|
||||
status: AgentMessageStatus.QUEUED,
|
||||
});
|
||||
|
||||
const savedMessage = await this.messageRepository.save(message);
|
||||
|
||||
const part = this.messagePartRepository.create({
|
||||
messageId: savedMessage.id,
|
||||
orderIndex: 0,
|
||||
type: 'text',
|
||||
textContent: text,
|
||||
});
|
||||
|
||||
await this.messagePartRepository.save(part);
|
||||
|
||||
return savedMessage;
|
||||
}
|
||||
|
||||
async getQueuedMessages(threadId: string): Promise<AgentMessageEntity[]> {
|
||||
return this.messageRepository.find({
|
||||
where: {
|
||||
threadId,
|
||||
status: AgentMessageStatus.QUEUED,
|
||||
},
|
||||
order: { createdAt: 'ASC' },
|
||||
relations: ['parts'],
|
||||
});
|
||||
}
|
||||
|
||||
async findQueuedMessage(
|
||||
messageId: string,
|
||||
): Promise<AgentMessageEntity | null> {
|
||||
return this.messageRepository.findOne({
|
||||
where: { id: messageId, status: AgentMessageStatus.QUEUED },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteQueuedMessage(messageId: string): Promise<boolean> {
|
||||
const result = await this.messageRepository.delete({
|
||||
id: messageId,
|
||||
status: AgentMessageStatus.QUEUED,
|
||||
});
|
||||
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
async promoteQueuedMessage(
|
||||
messageId: string,
|
||||
threadId: string,
|
||||
messageContent: string,
|
||||
): Promise<string | null> {
|
||||
const turn = this.turnRepository.create({
|
||||
threadId,
|
||||
agentId: null,
|
||||
});
|
||||
|
||||
const savedTurn = await this.turnRepository.save(turn);
|
||||
|
||||
const result = await this.messageRepository.update(
|
||||
{ id: messageId, threadId, status: AgentMessageStatus.QUEUED },
|
||||
{
|
||||
status: AgentMessageStatus.SENT,
|
||||
processedAt: new Date(),
|
||||
turnId: savedTurn.id,
|
||||
},
|
||||
);
|
||||
|
||||
if ((result.affected ?? 0) === 0) {
|
||||
await this.turnRepository.delete(savedTurn.id);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return savedTurn.id;
|
||||
}
|
||||
|
||||
async generateTitleIfNeeded({
|
||||
threadId,
|
||||
messageContent,
|
||||
workspaceId,
|
||||
}: {
|
||||
threadId: string;
|
||||
messageContent: string;
|
||||
workspaceId: string;
|
||||
}): Promise<string | null> {
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: { id: threadId },
|
||||
select: ['id', 'title'],
|
||||
});
|
||||
|
||||
if (!thread || thread.title || !messageContent) {
|
||||
@@ -147,6 +282,21 @@ export class AgentChatService {
|
||||
|
||||
await this.threadRepository.update(threadId, { title });
|
||||
|
||||
await this.workspaceEventBroadcaster.broadcast({
|
||||
workspaceId,
|
||||
events: [
|
||||
{
|
||||
type: 'updated',
|
||||
entityName: 'agentChatThread',
|
||||
recordId: threadId,
|
||||
properties: {
|
||||
updatedFields: ['title'],
|
||||
after: serializeThreadForBroadcast({ ...thread, title }),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user