[AI] Add thread actions, filters, and archive support (#20068)
## PR Description ### Summary - Add AI chat thread actions: rename, archive (soft-delete via `deletedAt`), and hard-delete with confirmation. - Add chat thread filtering by status (active/archived/all), group-by mode, and last activity. - Rework drawer/side-panel thread lists to share thread sections, item menus, archive icons, and empty-state behavior. - Extend server chat thread model/API with `deletedAt`, mutations, broadcasts, and archive-aware stream guards. ### Decisions - Two-stage lifecycle: Archive sets `deletedAt` (soft); Delete is a separate action on archived threads that hard-deletes the row. Aligns with Twenty's soft-delete convention (Felix's suggestion). - `lastMessageAt` is derived from `MAX(agentMessage.createdAt)` on read, not stored. List query does inline aggregation for sort; `@ResolveField` covers single-thread / mutation paths so the schema contract is honest everywhere. Matches `timeline-messaging.service.ts` precedent and the existing `totalInputCredits` / `totalOutputCredits` `@ResolveField` pattern in the same resolver. - Replaced auto-CRUD `chatThreads` (cursor-paginated Connection) with a custom `[AgentChatThreadDTO!]` resolver. Frontend metadata-store treats threads as a flat collection and filters/sorts client-side, so cursor pagination was performative. - Sending in an archived chat unarchives it optimistically on the client and authoritatively on the server. - Grouping and last-activity filtering use `lastMessageAt ?? updatedAt` so archive/rename don't bump threads in the list. - Kept metadata-store core API unchanged; AI chat uses the same local cast pattern already used by other metadata-store partial updates. https://github.com/user-attachments/assets/1b179b7b-1a2a-4a7a-aa0a-c88f6f051a87
This commit is contained in:
@@ -1,14 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { SortDirection } from '@ptc-org/nestjs-query-core';
|
||||
import {
|
||||
NestjsQueryGraphQLModule,
|
||||
PagingStrategies,
|
||||
} from '@ptc-org/nestjs-query-graphql';
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
@@ -19,8 +11,6 @@ import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-p
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module';
|
||||
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
|
||||
import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor';
|
||||
@@ -32,7 +22,6 @@ 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 { 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';
|
||||
@@ -54,33 +43,6 @@ import { SystemPromptBuilderService } from './services/system-prompt-builder.ser
|
||||
UserWorkspaceEntity,
|
||||
WorkspaceEntity,
|
||||
]),
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature([AgentChatThreadEntity]),
|
||||
PermissionsModule,
|
||||
],
|
||||
resolvers: [
|
||||
{
|
||||
EntityClass: AgentChatThreadEntity,
|
||||
DTOClass: AgentChatThreadDTO,
|
||||
pagingStrategy: PagingStrategies.CURSOR,
|
||||
read: {
|
||||
defaultSort: [
|
||||
{ field: 'updatedAt', direction: SortDirection.DESC },
|
||||
],
|
||||
one: { disabled: true },
|
||||
many: { name: 'chatThreads' },
|
||||
},
|
||||
create: { disabled: true },
|
||||
update: { disabled: true },
|
||||
delete: { disabled: true },
|
||||
guards: [
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.AI),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
AiAgentExecutionModule,
|
||||
BillingModule,
|
||||
ThrottlerModule,
|
||||
|
||||
+8
-27
@@ -1,32 +1,8 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { Field, Float, HideField, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
Authorize,
|
||||
FilterableField,
|
||||
IDField,
|
||||
} from '@ptc-org/nestjs-query-graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
|
||||
import { Field, Float, HideField, ID, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('AgentChatThread')
|
||||
@Authorize({
|
||||
authorize: (context: { req?: AuthenticatedRequest }) => {
|
||||
const userWorkspaceId = context?.req?.userWorkspaceId;
|
||||
|
||||
if (!isDefined(userWorkspaceId)) {
|
||||
throw new UnauthorizedException(
|
||||
'userWorkspaceId is required to query chat threads',
|
||||
);
|
||||
}
|
||||
|
||||
return { userWorkspaceId: { eq: userWorkspaceId } };
|
||||
},
|
||||
})
|
||||
export class AgentChatThreadDTO {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@Field(() => ID)
|
||||
id: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@@ -55,10 +31,15 @@ export class AgentChatThreadDTO {
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
|
||||
@FilterableField()
|
||||
@Field()
|
||||
updatedAt: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
deletedAt: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
lastMessageAt: Date | null;
|
||||
|
||||
@HideField()
|
||||
userWorkspaceId: string;
|
||||
}
|
||||
|
||||
+4
@@ -17,6 +17,7 @@ import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
|
||||
import { EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
|
||||
|
||||
@Entity({ name: 'agentChatThread', schema: 'core' })
|
||||
@Index('IDX_AGENT_CHAT_THREAD_ID_DELETED_AT', ['id', 'deletedAt'])
|
||||
export class AgentChatThreadEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
@@ -75,6 +76,9 @@ export class AgentChatThreadEntity {
|
||||
@OneToMany(() => AgentMessageEntity, (message) => message.thread)
|
||||
messages: EntityRelation<AgentMessageEntity[]>;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
+9
@@ -449,6 +449,15 @@ export class StreamAgentChatJob {
|
||||
return;
|
||||
}
|
||||
|
||||
const threadStatus = await this.threadRepository.findOne({
|
||||
where: { id: threadId },
|
||||
select: ['id', 'deletedAt'],
|
||||
});
|
||||
|
||||
if (!threadStatus || threadStatus.deletedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage = await userMessagePromise;
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
|
||||
+94
@@ -59,6 +59,11 @@ export class AgentChatResolver {
|
||||
private readonly threadRepository: Repository<AgentChatThreadEntity>,
|
||||
) {}
|
||||
|
||||
@Query(() => [AgentChatThreadDTO])
|
||||
async chatThreads(@AuthUserWorkspaceId() userWorkspaceId: string) {
|
||||
return this.agentChatService.getThreadsForUser(userWorkspaceId);
|
||||
}
|
||||
|
||||
@Query(() => AgentChatThreadDTO)
|
||||
async chatThread(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@@ -140,6 +145,13 @@ export class AgentChatResolver {
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(thread.deletedAt)) {
|
||||
await this.agentChatService.unarchiveThread({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(thread.activeStreamId)) {
|
||||
const queuedMessage = await this.agentChatService.queueMessage({
|
||||
threadId,
|
||||
@@ -147,6 +159,7 @@ export class AgentChatResolver {
|
||||
id: messageId,
|
||||
fileIds: fileIds ?? undefined,
|
||||
workspaceId: workspace.id,
|
||||
userWorkspaceId,
|
||||
});
|
||||
|
||||
await this.eventPublisherService.publish({
|
||||
@@ -201,6 +214,75 @@ export class AgentChatResolver {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => AgentChatThreadDTO)
|
||||
async renameChatThread(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@Args('title') title: string,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<AgentChatThreadEntity> {
|
||||
return this.agentChatService.updateThreadTitle({
|
||||
threadId: id,
|
||||
userWorkspaceId,
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => AgentChatThreadDTO)
|
||||
async archiveChatThread(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<AgentChatThreadEntity> {
|
||||
await this.cancelActiveStreamIfAny(id, userWorkspaceId);
|
||||
|
||||
return this.agentChatService.archiveThread({
|
||||
threadId: id,
|
||||
userWorkspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => AgentChatThreadDTO)
|
||||
async unarchiveChatThread(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<AgentChatThreadEntity> {
|
||||
return this.agentChatService.unarchiveThread({
|
||||
threadId: id,
|
||||
userWorkspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteChatThread(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<boolean> {
|
||||
await this.cancelActiveStreamIfAny(id, userWorkspaceId);
|
||||
|
||||
await this.agentChatService.hardDeleteThread({
|
||||
threadId: id,
|
||||
userWorkspaceId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async cancelActiveStreamIfAny(
|
||||
threadId: string,
|
||||
userWorkspaceId: string,
|
||||
): Promise<void> {
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: { id: threadId, userWorkspaceId },
|
||||
});
|
||||
|
||||
if (!isDefined(thread) || !isDefined(thread.activeStreamId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const redis = this.redisClientService.getClient();
|
||||
|
||||
await redis.publish(getCancelChannel(threadId), 'cancel');
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteQueuedChatMessage(
|
||||
@Args('messageId', { type: () => UUIDScalarType }) messageId: string,
|
||||
@@ -261,4 +343,16 @@ export class AgentChatResolver {
|
||||
totalOutputCredits(@Parent() thread: AgentChatThreadEntity): number {
|
||||
return toDisplayCredits(thread.totalOutputCredits);
|
||||
}
|
||||
|
||||
@ResolveField('lastMessageAt', () => Date, { nullable: true })
|
||||
async lastMessageAt(
|
||||
@Parent()
|
||||
thread: AgentChatThreadEntity & { lastMessageAt?: Date | null },
|
||||
): Promise<Date | null> {
|
||||
if (thread.lastMessageAt !== undefined) {
|
||||
return thread.lastMessageAt;
|
||||
}
|
||||
|
||||
return this.agentChatService.getLastMessageAtForThread(thread.id);
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -100,6 +100,11 @@ export class AgentChatStreamingService {
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
await this.agentChatService.notifyThreadActivityUpdated(
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
const previousMessages = await this.loadMessagesFromDB(
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
@@ -139,6 +144,15 @@ export class AgentChatStreamingService {
|
||||
workspaceId: string,
|
||||
hasTitle: boolean,
|
||||
): Promise<void> {
|
||||
const threadStatus = await this.threadRepository.findOne({
|
||||
where: { id: threadId },
|
||||
select: ['id', 'deletedAt'],
|
||||
});
|
||||
|
||||
if (!threadStatus || threadStatus.deletedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queuedMessages =
|
||||
await this.agentChatService.getQueuedMessages(threadId);
|
||||
|
||||
|
||||
+254
-22
@@ -1,8 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import type { UIDataTypes, UIMessagePart, UITools } from 'ai';
|
||||
@@ -16,30 +16,39 @@ import {
|
||||
} 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';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import {
|
||||
AiException,
|
||||
AiExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai.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) => ({
|
||||
const serializeThreadForBroadcast = (
|
||||
thread: AgentChatThreadEntity,
|
||||
lastMessageAt: Date | null,
|
||||
) => ({
|
||||
id: thread.id,
|
||||
title: thread.title,
|
||||
totalInputTokens: thread.totalInputTokens,
|
||||
totalOutputTokens: thread.totalOutputTokens,
|
||||
totalCacheReadTokens: thread.totalCacheReadTokens,
|
||||
totalCacheCreationTokens: thread.totalCacheCreationTokens,
|
||||
contextWindowTokens: thread.contextWindowTokens,
|
||||
conversationSize: thread.conversationSize,
|
||||
totalInputCredits: thread.totalInputCredits,
|
||||
totalOutputCredits: thread.totalOutputCredits,
|
||||
createdAt: thread.createdAt.toISOString(),
|
||||
updatedAt: thread.updatedAt.toISOString(),
|
||||
deletedAt: thread.deletedAt,
|
||||
lastMessageAt,
|
||||
createdAt: thread.createdAt,
|
||||
updatedAt: thread.updatedAt,
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class AgentChatService {
|
||||
private readonly logger = new Logger(AgentChatService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AgentChatThreadEntity)
|
||||
private readonly threadRepository: Repository<AgentChatThreadEntity>,
|
||||
@@ -78,7 +87,7 @@ export class AgentChatService {
|
||||
recordId: savedThread.id,
|
||||
recipientUserWorkspaceIds: [userWorkspaceId],
|
||||
properties: {
|
||||
after: serializeThreadForBroadcast(savedThread),
|
||||
after: serializeThreadForBroadcast(savedThread, null),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -105,6 +114,53 @@ export class AgentChatService {
|
||||
return thread;
|
||||
}
|
||||
|
||||
async getThreadsForUser(
|
||||
userWorkspaceId: string,
|
||||
): Promise<(AgentChatThreadEntity & { lastMessageAt: Date | null })[]> {
|
||||
const rankedThreads = await this.threadRepository
|
||||
.createQueryBuilder('thread')
|
||||
.select('thread.id', 'id')
|
||||
.addSelect('MAX(message.createdAt)', 'last_message_at')
|
||||
.leftJoin('thread.messages', 'message')
|
||||
.where('thread.userWorkspaceId = :userWorkspaceId', { userWorkspaceId })
|
||||
.groupBy('thread.id')
|
||||
.orderBy('last_message_at', 'DESC', 'NULLS LAST')
|
||||
.addOrderBy('thread.updatedAt', 'DESC')
|
||||
.getRawMany<{ id: string; last_message_at: Date | null }>();
|
||||
|
||||
if (rankedThreads.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rankedThreadIds = rankedThreads.map(
|
||||
(rankedThread) => rankedThread.id,
|
||||
);
|
||||
|
||||
const threads = await this.threadRepository.find({
|
||||
where: { id: In(rankedThreadIds), userWorkspaceId },
|
||||
});
|
||||
|
||||
const threadById = new Map(threads.map((thread) => [thread.id, thread]));
|
||||
|
||||
return rankedThreads.flatMap((rankedThread) => {
|
||||
const thread = threadById.get(rankedThread.id);
|
||||
|
||||
return thread
|
||||
? [{ ...thread, lastMessageAt: rankedThread.last_message_at ?? null }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
async getLastMessageAtForThread(threadId: string): Promise<Date | null> {
|
||||
const result = await this.messageRepository
|
||||
.createQueryBuilder('message')
|
||||
.select('MAX(message.createdAt)', 'last_message_at')
|
||||
.where('message.threadId = :threadId', { threadId })
|
||||
.getRawOne<{ last_message_at: Date | null }>();
|
||||
|
||||
return result?.last_message_at ?? null;
|
||||
}
|
||||
|
||||
async addMessage({
|
||||
threadId,
|
||||
uiMessage,
|
||||
@@ -198,12 +254,14 @@ export class AgentChatService {
|
||||
id,
|
||||
fileIds,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
threadId: string;
|
||||
text: string;
|
||||
id?: string;
|
||||
fileIds?: string[];
|
||||
workspaceId: string;
|
||||
userWorkspaceId: string;
|
||||
}): Promise<AgentMessageEntity> {
|
||||
const messageValues = {
|
||||
...(id ? { id } : {}),
|
||||
@@ -246,6 +304,8 @@ export class AgentChatService {
|
||||
|
||||
await this.messagePartRepository.insert(parts);
|
||||
|
||||
await this.notifyThreadActivityUpdated(threadId, userWorkspaceId);
|
||||
|
||||
return {
|
||||
id: savedMessageId,
|
||||
...messageValues,
|
||||
@@ -311,6 +371,188 @@ export class AgentChatService {
|
||||
return savedTurnId;
|
||||
}
|
||||
|
||||
async updateThreadTitle({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
title,
|
||||
}: {
|
||||
threadId: string;
|
||||
userWorkspaceId: string;
|
||||
title: string;
|
||||
}): Promise<AgentChatThreadEntity> {
|
||||
const trimmed = title.trim();
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
throw new AiException(
|
||||
'Chat thread title cannot be empty',
|
||||
AiExceptionCode.INVALID_CHAT_THREAD_TITLE,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.threadRepository.update(
|
||||
{ id: threadId, userWorkspaceId },
|
||||
{ title: trimmed },
|
||||
);
|
||||
|
||||
if (result.affected === 0) {
|
||||
throw new AiException(
|
||||
'Thread not found',
|
||||
AiExceptionCode.THREAD_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.getThreadById(threadId, userWorkspaceId);
|
||||
|
||||
await this.broadcastThreadUpdated(updated, ['title'], userWorkspaceId);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async archiveThread({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
threadId: string;
|
||||
userWorkspaceId: string;
|
||||
}): Promise<AgentChatThreadEntity> {
|
||||
const thread = await this.getThreadById(threadId, userWorkspaceId);
|
||||
|
||||
if (thread.deletedAt) {
|
||||
return thread;
|
||||
}
|
||||
|
||||
const deletedAt = new Date();
|
||||
|
||||
const result = await this.threadRepository.update(
|
||||
{ id: threadId, userWorkspaceId, deletedAt: IsNull() },
|
||||
{ deletedAt, activeStreamId: null },
|
||||
);
|
||||
|
||||
if ((result.affected ?? 0) === 0) {
|
||||
return thread;
|
||||
}
|
||||
|
||||
thread.deletedAt = deletedAt;
|
||||
thread.activeStreamId = null;
|
||||
|
||||
await this.broadcastThreadUpdated(thread, ['deletedAt'], userWorkspaceId);
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
async unarchiveThread({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
threadId: string;
|
||||
userWorkspaceId: string;
|
||||
}): Promise<AgentChatThreadEntity> {
|
||||
const thread = await this.getThreadById(threadId, userWorkspaceId);
|
||||
|
||||
if (!thread.deletedAt) {
|
||||
return thread;
|
||||
}
|
||||
|
||||
const result = await this.threadRepository.update(
|
||||
{ id: threadId, userWorkspaceId, deletedAt: Not(IsNull()) },
|
||||
{ deletedAt: null },
|
||||
);
|
||||
|
||||
if ((result.affected ?? 0) === 0) {
|
||||
return thread;
|
||||
}
|
||||
|
||||
thread.deletedAt = null;
|
||||
|
||||
await this.broadcastThreadUpdated(thread, ['deletedAt'], userWorkspaceId);
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
async hardDeleteThread({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
threadId: string;
|
||||
userWorkspaceId: string;
|
||||
}): Promise<void> {
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: { id: threadId, userWorkspaceId },
|
||||
});
|
||||
|
||||
if (!thread) {
|
||||
throw new AiException(
|
||||
'Thread not found',
|
||||
AiExceptionCode.THREAD_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.threadRepository.delete({
|
||||
id: threadId,
|
||||
userWorkspaceId,
|
||||
});
|
||||
|
||||
if ((result.affected ?? 0) === 0) {
|
||||
this.logger.warn(
|
||||
`hardDeleteThread: thread ${threadId} vanished between fetch and delete`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.workspaceEventBroadcaster.broadcast({
|
||||
workspaceId: thread.workspaceId,
|
||||
events: [
|
||||
{
|
||||
type: 'deleted',
|
||||
entityName: 'agentChatThread',
|
||||
recordId: threadId,
|
||||
recipientUserWorkspaceIds: [userWorkspaceId],
|
||||
properties: {
|
||||
before: serializeThreadForBroadcast(thread, null),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async notifyThreadActivityUpdated(
|
||||
threadId: string,
|
||||
userWorkspaceId: string,
|
||||
): Promise<void> {
|
||||
const thread = await this.getThreadById(threadId, userWorkspaceId);
|
||||
|
||||
await this.broadcastThreadUpdated(
|
||||
thread,
|
||||
['lastMessageAt'],
|
||||
userWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private async broadcastThreadUpdated(
|
||||
thread: AgentChatThreadEntity,
|
||||
updatedFields: string[],
|
||||
userWorkspaceId: string,
|
||||
): Promise<void> {
|
||||
const lastMessageAt = await this.getLastMessageAtForThread(thread.id);
|
||||
|
||||
await this.workspaceEventBroadcaster.broadcast({
|
||||
workspaceId: thread.workspaceId,
|
||||
events: [
|
||||
{
|
||||
type: 'updated',
|
||||
entityName: 'agentChatThread',
|
||||
recordId: thread.id,
|
||||
recipientUserWorkspaceIds: [userWorkspaceId],
|
||||
properties: {
|
||||
updatedFields,
|
||||
after: serializeThreadForBroadcast(thread, lastMessageAt),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async generateTitleIfNeeded({
|
||||
threadId,
|
||||
messageContent,
|
||||
@@ -336,21 +578,11 @@ export class AgentChatService {
|
||||
|
||||
await this.threadRepository.update(threadId, { title });
|
||||
|
||||
await this.workspaceEventBroadcaster.broadcast({
|
||||
workspaceId,
|
||||
events: [
|
||||
{
|
||||
type: 'updated',
|
||||
entityName: 'agentChatThread',
|
||||
recordId: threadId,
|
||||
recipientUserWorkspaceIds: [thread.userWorkspaceId],
|
||||
properties: {
|
||||
updatedFields: ['title'],
|
||||
after: serializeThreadForBroadcast({ ...thread, title }),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await this.broadcastThreadUpdated(
|
||||
{ ...thread, title },
|
||||
['title'],
|
||||
thread.userWorkspaceId,
|
||||
);
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum AiExceptionCode {
|
||||
AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED',
|
||||
INVALID_AGENT_INPUT = 'INVALID_AGENT_INPUT',
|
||||
THREAD_NOT_FOUND = 'THREAD_NOT_FOUND',
|
||||
INVALID_CHAT_THREAD_TITLE = 'INVALID_CHAT_THREAD_TITLE',
|
||||
MESSAGE_NOT_FOUND = 'MESSAGE_NOT_FOUND',
|
||||
API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED',
|
||||
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
|
||||
@@ -32,6 +33,8 @@ const getAiExceptionUserFriendlyMessage = (code: AiExceptionCode) => {
|
||||
return msg`Invalid agent input.`;
|
||||
case AiExceptionCode.THREAD_NOT_FOUND:
|
||||
return msg`Chat thread not found.`;
|
||||
case AiExceptionCode.INVALID_CHAT_THREAD_TITLE:
|
||||
return msg`Chat thread title cannot be empty.`;
|
||||
case AiExceptionCode.MESSAGE_NOT_FOUND:
|
||||
return msg`Chat message not found.`;
|
||||
case AiExceptionCode.API_KEY_NOT_CONFIGURED:
|
||||
|
||||
+1
@@ -21,6 +21,7 @@ export const aiGraphqlApiExceptionHandler = (error: Error) => {
|
||||
case AiExceptionCode.ROLE_NOT_FOUND:
|
||||
throw new NotFoundError(error);
|
||||
case AiExceptionCode.INVALID_AGENT_INPUT:
|
||||
case AiExceptionCode.INVALID_CHAT_THREAD_TITLE:
|
||||
throw new UserInputError(error);
|
||||
case AiExceptionCode.AGENT_ALREADY_EXISTS:
|
||||
throw new ConflictError(error);
|
||||
|
||||
Reference in New Issue
Block a user