Add file attachment support to agent chat messaging (#19517)

## Summary
This PR adds support for attaching files to agent chat messages. Users
can now upload files when sending messages to the AI agent, and these
files are properly processed, stored, and made available to the agent
with signed URLs.

## Key Changes

- **File attachment input**: Added `fileIds` parameter to the
`sendChatMessage` GraphQL mutation to accept file IDs from the client
- **File processing**: Implemented `buildFilePartsFromIds()` method to
convert file IDs into file UI parts with signed URLs
- **Message composition**: Updated user messages to include both text
and file parts when files are attached
- **File URL signing**: Integrated `FileUrlService` to generate signed
URLs for files in the AgentChat folder, ensuring secure access
- **Message persistence**: Files are now included in the message parts
stored in the database and retrieved when loading conversation history
- **File metadata mapping**: Enhanced `mapDBPartToUIMessagePart()` to
properly extract MIME types from file entities and include file IDs

## Implementation Details

- Files are fetched from the database using the provided file IDs and
workspace context
- Each file is converted to an `ExtendedFileUIPart` with proper metadata
(filename, MIME type, signed URL, and file ID)
- When loading messages from the database, file parts are enhanced with
signed URLs to ensure they remain accessible
- The `loadMessagesFromDB()` method now requires the workspace ID to
properly sign file URLs
- File attachments are seamlessly integrated into the existing message
part system alongside text content

https://claude.ai/code/session_01TAdN1gBzeiYELX4XDrrYY1

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-04-09 22:37:03 +02:00
committed by GitHub
parent d2f51cc939
commit 2bb939b4b5
12 changed files with 394 additions and 26 deletions
@@ -1,4 +1,7 @@
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import {
type ExtendedFileUIPart,
type ExtendedUIMessagePart,
} from 'twenty-shared/ai';
import { type AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
@@ -24,12 +27,11 @@ export const mapDBPartToUIMessagePart = (
case 'file':
return {
type: 'file',
mediaType: part.fileFilename?.endsWith('.png')
? 'image/png'
: 'application/octet-stream',
mediaType: part.file?.mimeType ?? 'application/octet-stream',
filename: part.fileFilename ?? '',
url: '',
};
fileId: part.fileId ?? '',
} as ExtendedFileUIPart;
case 'source-url':
return {
type: 'source-url',
@@ -127,6 +127,8 @@ export class AgentChatResolver {
browsingContext: BrowsingContextType | null,
@Args('modelId', { type: () => String, nullable: true })
modelId: string | undefined,
@Args('fileIds', { type: () => [UUIDScalarType], nullable: true })
fileIds: string[] | null,
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SendChatMessageResultDTO> {
@@ -174,6 +176,7 @@ export class AgentChatResolver {
threadId,
text,
id: messageId,
fileIds: fileIds ?? undefined,
workspaceId: workspace.id,
});
@@ -194,6 +197,7 @@ export class AgentChatResolver {
workspace,
text,
messageId,
fileIds: fileIds ?? undefined,
});
return {
@@ -2,8 +2,16 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { generateId } from 'ai';
import { type Repository } from 'typeorm';
import {
type ExtendedFileUIPart,
type ExtendedUIMessagePart,
isExtendedFileUIPart,
} from 'twenty-shared/ai';
import { FileFolder } from 'twenty-shared/types';
import { In, Like, type Repository } from 'typeorm';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@@ -32,6 +40,7 @@ export type StreamAgentChatOptions = {
browsingContext: BrowsingContextType | null;
modelId?: string;
messageId?: string;
fileIds?: string[];
};
@Injectable()
@@ -41,10 +50,13 @@ export class AgentChatStreamingService {
constructor(
@InjectRepository(AgentChatThreadEntity)
private readonly threadRepository: Repository<AgentChatThreadEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
@InjectMessageQueue(MessageQueue.aiStreamQueue)
private readonly messageQueueService: MessageQueueService,
private readonly agentChatService: AgentChatService,
private readonly eventPublisherService: AgentChatEventPublisherService,
private readonly fileUrlService: FileUrlService,
) {}
async streamAgentChat({
@@ -55,6 +67,7 @@ export class AgentChatStreamingService {
browsingContext,
modelId,
messageId,
fileIds,
}: StreamAgentChatOptions): Promise<{ streamId: string; messageId: string }> {
const thread = await this.threadRepository.findOne({
where: {
@@ -70,12 +83,19 @@ export class AgentChatStreamingService {
);
}
const fileParts = await this.buildFilePartsFromIds(fileIds, workspace.id);
const userMessageParts: ExtendedUIMessagePart[] = [
{ type: 'text' as const, text },
...fileParts,
];
const savedUserMessage = await this.agentChatService.addMessage({
threadId,
id: messageId,
uiMessage: {
role: AgentMessageRole.USER,
parts: [{ type: 'text' as const, text }],
parts: userMessageParts,
},
workspaceId: workspace.id,
});
@@ -83,6 +103,7 @@ export class AgentChatStreamingService {
const previousMessages = await this.loadMessagesFromDB(
threadId,
userWorkspaceId,
workspace.id,
);
const streamId = generateId();
@@ -98,7 +119,7 @@ export class AgentChatStreamingService {
browsingContext,
modelId,
lastUserMessageText: text,
lastUserMessageParts: [{ type: 'text', text }],
lastUserMessageParts: userMessageParts,
hasTitle: !!thread.title,
conversationSizeTokens: thread.conversationSize,
existingTurnId: savedUserMessage.turnId ?? undefined,
@@ -129,8 +150,19 @@ export class AgentChatStreamingService {
const textPart = nextQueued.parts?.find((part) => part.type === 'text');
const messageText = textPart?.textContent ?? '';
const fileParts = (nextQueued.parts ?? [])
.filter((part) => part.type === 'file')
.map(
(part): ExtendedFileUIPart => ({
type: 'file',
mediaType: part.file?.mimeType ?? 'application/octet-stream',
filename: part.fileFilename ?? '',
url: '',
fileId: part.fileId ?? '',
}),
);
if (messageText === '') {
if (messageText === '' && fileParts.length === 0) {
await this.agentChatService.deleteQueuedMessage(nextQueued.id);
return;
@@ -159,12 +191,19 @@ export class AgentChatStreamingService {
});
const [uiMessages, thread] = await Promise.all([
this.loadMessagesFromDB(threadId, userWorkspaceId),
this.loadMessagesFromDB(threadId, userWorkspaceId, workspaceId),
this.threadRepository.findOneByOrFail({ id: threadId }),
]);
const streamId = generateId();
const lastUserMessageParts: ExtendedUIMessagePart[] = [
...(messageText !== ''
? [{ type: 'text' as const, text: messageText }]
: []),
...fileParts,
];
await this.messageQueueService.add<StreamAgentChatJobData>(
STREAM_AGENT_CHAT_JOB_NAME,
{
@@ -175,7 +214,7 @@ export class AgentChatStreamingService {
messages: uiMessages,
browsingContext: null,
lastUserMessageText: messageText,
lastUserMessageParts: [{ type: 'text', text: messageText }],
lastUserMessageParts,
hasTitle,
conversationSizeTokens: thread.conversationSize,
existingTurnId: turnId,
@@ -187,7 +226,11 @@ export class AgentChatStreamingService {
});
}
private async loadMessagesFromDB(threadId: string, userWorkspaceId: string) {
private async loadMessagesFromDB(
threadId: string,
userWorkspaceId: string,
workspaceId: string,
) {
const allMessages = await this.agentChatService.getMessagesForThread(
threadId,
userWorkspaceId,
@@ -198,8 +241,50 @@ export class AgentChatStreamingService {
.map((message) => ({
id: message.id,
role: message.role as 'user' | 'assistant' | 'system',
parts: mapDBPartsToUIMessageParts(message.parts ?? []),
parts: mapDBPartsToUIMessageParts(message.parts ?? []).map((part) => {
if (isExtendedFileUIPart(part as Record<string, unknown>)) {
const filePart = part as ExtendedFileUIPart;
return {
...filePart,
url: this.fileUrlService.signFileByIdUrl({
fileId: filePart.fileId,
workspaceId,
fileFolder: FileFolder.AgentChat,
}),
} as ExtendedFileUIPart;
}
return part;
}),
createdAt: message.createdAt,
}));
}
private async buildFilePartsFromIds(
fileIds: string[] | undefined,
workspaceId: string,
): Promise<ExtendedUIMessagePart[]> {
if (!fileIds || fileIds.length === 0) {
return [];
}
const files = await this.fileRepository.find({
where: {
id: In(fileIds),
workspaceId,
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,
}),
);
}
}
@@ -2,10 +2,11 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ExtendedUIMessage } from 'twenty-shared/ai';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import type { UIDataTypes, UIMessagePart, UITools } from 'ai';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
import {
AgentMessageEntity,
@@ -47,6 +48,8 @@ export class AgentChatService {
private readonly messageRepository: Repository<AgentMessageEntity>,
@InjectRepository(AgentMessagePartEntity)
private readonly messagePartRepository: Repository<AgentMessagePartEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
private readonly titleGenerationService: AgentTitleGenerationService,
private readonly workspaceEventBroadcaster: WorkspaceEventBroadcaster,
) {}
@@ -181,11 +184,13 @@ export class AgentChatService {
threadId,
text,
id,
fileIds,
workspaceId,
}: {
threadId: string;
text: string;
id?: string;
fileIds?: string[];
workspaceId: string;
}): Promise<AgentMessageEntity> {
const message = this.messageRepository.create({
@@ -200,15 +205,34 @@ export class AgentChatService {
const savedMessage = await this.messageRepository.save(message);
const part = this.messagePartRepository.create({
messageId: savedMessage.id,
orderIndex: 0,
type: 'text',
textContent: text,
workspaceId,
});
const files =
fileIds && fileIds.length > 0
? await this.fileRepository.find({
where: { id: In(fileIds), workspaceId },
})
: [];
await this.messagePartRepository.save(part);
const parts = [
this.messagePartRepository.create({
messageId: savedMessage.id,
orderIndex: 0,
type: 'text',
textContent: text,
workspaceId,
}),
...files.map((file, index) =>
this.messagePartRepository.create({
messageId: savedMessage.id,
orderIndex: index + 1,
type: 'file',
fileId: file.id,
fileFilename: file.path.split('/').pop() ?? null,
workspaceId,
}),
),
];
await this.messagePartRepository.save(parts);
return savedMessage;
}
@@ -220,7 +244,7 @@ export class AgentChatService {
status: AgentMessageStatus.QUEUED,
},
order: { createdAt: 'ASC' },
relations: ['parts'],
relations: ['parts', 'parts.file'],
});
}