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:
+7
-5
@@ -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',
|
||||
|
||||
+4
@@ -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 {
|
||||
|
||||
+93
-8
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-10
@@ -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'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user