fix(ai-chat) - upload files (#20681)

closes https://github.com/twentyhq/twenty/issues/20437

bonus : persist file filename for UI display
This commit is contained in:
Etienne
2026-05-18 17:50:02 +02:00
committed by GitHub
parent 132d997474
commit 89579f5225
11 changed files with 160 additions and 91 deletions
@@ -0,0 +1,12 @@
import { Field, InputType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class FileAttachmentInput {
@Field(() => UUIDScalarType)
id: string;
@Field()
filename: string;
}
@@ -27,6 +27,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AgentMessageDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/agent-message.dto';
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
import { AgentChatThreadDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto';
import { FileAttachmentInput } from 'src/engine/metadata-modules/ai/ai-chat/dtos/file-attachment.input';
import { AiSystemPromptPreviewDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/ai-system-prompt-preview.dto';
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';
@@ -113,8 +114,11 @@ export class AgentChatResolver {
browsingContext: BrowsingContextType | null,
@Args('modelId', { type: () => String, nullable: true })
modelId: string | undefined,
@Args('fileIds', { type: () => [UUIDScalarType], nullable: true })
fileIds: string[] | null,
@Args('fileAttachments', {
type: () => [FileAttachmentInput],
nullable: true,
})
fileAttachments: FileAttachmentInput[] | null,
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SendChatMessageResultDTO> {
@@ -157,7 +161,7 @@ export class AgentChatResolver {
threadId,
text,
id: messageId,
fileIds: fileIds ?? undefined,
fileAttachments: fileAttachments ?? undefined,
workspaceId: workspace.id,
userWorkspaceId,
});
@@ -179,7 +183,7 @@ export class AgentChatResolver {
workspace,
text,
messageId,
fileIds: fileIds ?? undefined,
fileAttachments: fileAttachments ?? undefined,
});
return {
@@ -21,18 +21,19 @@ import {
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 {
AiException,
AiExceptionCode,
} from 'src/engine/metadata-modules/ai/ai.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 } 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';
import { AiChatFileAttachment } from 'src/engine/metadata-modules/ai/ai-chat/types/ai-chat-file-attachment.type';
import {
AiException,
AiExceptionCode,
} from 'src/engine/metadata-modules/ai/ai.exception';
export type StreamAgentChatOptions = {
type StreamAgentChatOptions = {
threadId: string;
userWorkspaceId: string;
workspace: WorkspaceEntity;
@@ -40,7 +41,7 @@ export type StreamAgentChatOptions = {
browsingContext: BrowsingContextType | null;
modelId?: string;
messageId?: string;
fileIds?: string[];
fileAttachments?: AiChatFileAttachment[];
};
@Injectable()
@@ -67,7 +68,7 @@ export class AgentChatStreamingService {
browsingContext,
modelId,
messageId,
fileIds,
fileAttachments,
}: StreamAgentChatOptions): Promise<{ streamId: string; messageId: string }> {
const thread = await this.threadRepository.findOne({
where: {
@@ -83,7 +84,10 @@ export class AgentChatStreamingService {
);
}
const fileParts = await this.buildFilePartsFromIds(fileIds, workspace.id);
const fileParts = await this.buildFilePartsFromAttachments(
fileAttachments,
workspace.id,
);
const userMessageParts: ExtendedUIMessagePart[] = [
{ type: 'text' as const, text },
@@ -281,30 +285,40 @@ export class AgentChatStreamingService {
);
}
private async buildFilePartsFromIds(
fileIds: string[] | undefined,
private async buildFilePartsFromAttachments(
fileAttachments: AiChatFileAttachment[] | undefined,
workspaceId: string,
): Promise<ExtendedUIMessagePart[]> {
if (!fileIds || fileIds.length === 0) {
if (!fileAttachments || fileAttachments.length === 0) {
return [];
}
const files = await this.fileRepository.find({
const fileIds = fileAttachments.map((attachment) => attachment.id);
const validFiles = await this.fileRepository.find({
where: {
id: In(fileIds),
workspaceId,
path: Like(`%/${FileFolder.AgentChat}/%`),
path: Like(`${FileFolder.AgentChat}/%`),
},
});
return files.map(
(file): ExtendedFileUIPart => ({
type: 'file' as const,
mediaType: file.mimeType,
filename: file.path.split('/').pop() ?? file.path,
url: '',
fileId: file.id,
}),
);
const validFileIds = new Set(validFiles.map((file) => file.id));
return fileAttachments
.filter((attachment) => validFileIds.has(attachment.id))
.map((attachment): ExtendedFileUIPart => {
const file = validFiles.find(
(validFile) => validFile.id === attachment.id,
);
return {
type: 'file' as const,
mediaType: file?.mimeType ?? 'application/octet-stream',
filename: attachment.filename,
url: '',
fileId: attachment.id,
};
});
}
}
@@ -23,6 +23,7 @@ import {
} from 'src/engine/metadata-modules/ai/ai.exception';
import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service';
import { AiChatFileAttachment } from 'src/engine/metadata-modules/ai/ai-chat/types/ai-chat-file-attachment.type';
import { AgentTitleGenerationService } from './agent-title-generation.service';
const serializeThreadForBroadcast = (
@@ -252,14 +253,14 @@ export class AgentChatService {
threadId,
text,
id,
fileIds,
fileAttachments,
workspaceId,
userWorkspaceId,
}: {
threadId: string;
text: string;
id?: string;
fileIds?: string[];
fileAttachments?: AiChatFileAttachment[];
workspaceId: string;
userWorkspaceId: string;
}): Promise<AgentMessageEntity> {
@@ -277,13 +278,19 @@ export class AgentChatService {
const savedMessageId = (id ?? insertResult.identifiers[0].id) as string;
const files =
fileIds && fileIds.length > 0
const validFiles =
fileAttachments && fileAttachments.length > 0
? await this.fileRepository.find({
where: { id: In(fileIds), workspaceId },
where: {
id: In(fileAttachments.map((attachment) => attachment.id)),
workspaceId,
},
select: ['id'],
})
: [];
const validFileIds = new Set(validFiles.map((file) => file.id));
const parts = [
{
messageId: savedMessageId,
@@ -292,14 +299,16 @@ export class AgentChatService {
textContent: text,
workspaceId,
},
...files.map((file, index) => ({
messageId: savedMessageId,
orderIndex: index + 1,
type: 'file',
fileId: file.id,
fileFilename: file.path.split('/').pop() ?? null,
workspaceId,
})),
...(fileAttachments ?? [])
.filter((attachment) => validFileIds.has(attachment.id))
.map((attachment, index) => ({
messageId: savedMessageId,
orderIndex: index + 1,
type: 'file',
fileId: attachment.id,
fileFilename: attachment.filename,
workspaceId,
})),
];
await this.messagePartRepository.insert(parts);
@@ -0,0 +1,4 @@
export type AiChatFileAttachment = {
id: string;
filename: string;
};