Skip to content

Commit 94368eb

Browse files
authored
Feat/copilot files (#886)
* Connects to s3 * Checkpoint * File shows in message * Make files clickable * User input image * Persist thumbnails * Drag and drop files * Lint * Fix isdev * Dont re-download files on rerender
1 parent 062e2a2 commit 94368eb

File tree

15 files changed

+971
-69
lines changed

15 files changed

+971
-69
lines changed
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
export interface FileAttachment {
2+
id: string
3+
s3_key: string
4+
filename: string
5+
media_type: string
6+
size: number
7+
}
8+
9+
export interface AnthropicMessageContent {
10+
type: 'text' | 'image' | 'document'
11+
text?: string
12+
source?: {
13+
type: 'base64'
14+
media_type: string
15+
data: string
16+
}
17+
}
18+
19+
/**
20+
* Mapping of MIME types to Anthropic content types
21+
*/
22+
export const MIME_TYPE_MAPPING: Record<string, 'image' | 'document'> = {
23+
// Images
24+
'image/jpeg': 'image',
25+
'image/jpg': 'image',
26+
'image/png': 'image',
27+
'image/gif': 'image',
28+
'image/webp': 'image',
29+
'image/svg+xml': 'image',
30+
31+
// Documents
32+
'application/pdf': 'document',
33+
'text/plain': 'document',
34+
'text/csv': 'document',
35+
'application/json': 'document',
36+
'application/xml': 'document',
37+
'text/xml': 'document',
38+
'text/html': 'document',
39+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document', // .docx
40+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document', // .xlsx
41+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document', // .pptx
42+
'application/msword': 'document', // .doc
43+
'application/vnd.ms-excel': 'document', // .xls
44+
'application/vnd.ms-powerpoint': 'document', // .ppt
45+
'text/markdown': 'document',
46+
'application/rtf': 'document',
47+
}
48+
49+
/**
50+
* Get the Anthropic content type for a given MIME type
51+
*/
52+
export function getAnthropicContentType(mimeType: string): 'image' | 'document' | null {
53+
return MIME_TYPE_MAPPING[mimeType.toLowerCase()] || null
54+
}
55+
56+
/**
57+
* Check if a MIME type is supported by Anthropic
58+
*/
59+
export function isSupportedFileType(mimeType: string): boolean {
60+
return mimeType.toLowerCase() in MIME_TYPE_MAPPING
61+
}
62+
63+
/**
64+
* Convert a file buffer to base64
65+
*/
66+
export function bufferToBase64(buffer: Buffer): string {
67+
return buffer.toString('base64')
68+
}
69+
70+
/**
71+
* Create Anthropic message content from file data
72+
*/
73+
export function createAnthropicFileContent(
74+
fileBuffer: Buffer,
75+
mimeType: string
76+
): AnthropicMessageContent | null {
77+
const contentType = getAnthropicContentType(mimeType)
78+
if (!contentType) {
79+
return null
80+
}
81+
82+
return {
83+
type: contentType,
84+
source: {
85+
type: 'base64',
86+
media_type: mimeType,
87+
data: bufferToBase64(fileBuffer),
88+
},
89+
}
90+
}
91+
92+
/**
93+
* Extract file extension from filename
94+
*/
95+
export function getFileExtension(filename: string): string {
96+
const lastDot = filename.lastIndexOf('.')
97+
return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ''
98+
}
99+
100+
/**
101+
* Get MIME type from file extension (fallback if not provided)
102+
*/
103+
export function getMimeTypeFromExtension(extension: string): string {
104+
const extensionMimeMap: Record<string, string> = {
105+
// Images
106+
jpg: 'image/jpeg',
107+
jpeg: 'image/jpeg',
108+
png: 'image/png',
109+
gif: 'image/gif',
110+
webp: 'image/webp',
111+
svg: 'image/svg+xml',
112+
113+
// Documents
114+
pdf: 'application/pdf',
115+
txt: 'text/plain',
116+
csv: 'text/csv',
117+
json: 'application/json',
118+
xml: 'application/xml',
119+
html: 'text/html',
120+
htm: 'text/html',
121+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
122+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
123+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
124+
doc: 'application/msword',
125+
xls: 'application/vnd.ms-excel',
126+
ppt: 'application/vnd.ms-powerpoint',
127+
md: 'text/markdown',
128+
rtf: 'application/rtf',
129+
}
130+
131+
return extensionMimeMap[extension.toLowerCase()] || 'application/octet-stream'
132+
}

apps/sim/app/api/copilot/chat/route.ts

Lines changed: 119 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,25 @@ import { getCopilotModel } from '@/lib/copilot/config'
1313
import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts'
1414
import { env } from '@/lib/env'
1515
import { createLogger } from '@/lib/logs/console/logger'
16+
import { downloadFile } from '@/lib/uploads'
17+
import { downloadFromS3WithConfig } from '@/lib/uploads/s3/s3-client'
18+
import { S3_COPILOT_CONFIG, USE_S3_STORAGE } from '@/lib/uploads/setup'
1619
import { db } from '@/db'
1720
import { copilotChats } from '@/db/schema'
1821
import { executeProviderRequest } from '@/providers'
22+
import { createAnthropicFileContent, isSupportedFileType } from './file-utils'
1923

2024
const logger = createLogger('CopilotChatAPI')
2125

26+
// Schema for file attachments
27+
const FileAttachmentSchema = z.object({
28+
id: z.string(),
29+
s3_key: z.string(),
30+
filename: z.string(),
31+
media_type: z.string(),
32+
size: z.number(),
33+
})
34+
2235
// Schema for chat messages
2336
const ChatMessageSchema = z.object({
2437
message: z.string().min(1, 'Message is required'),
@@ -29,6 +42,7 @@ const ChatMessageSchema = z.object({
2942
createNewChat: z.boolean().optional().default(false),
3043
stream: z.boolean().optional().default(true),
3144
implicitFeedback: z.string().optional(),
45+
fileAttachments: z.array(FileAttachmentSchema).optional(),
3246
})
3347

3448
// Sim Agent API configuration
@@ -145,6 +159,7 @@ export async function POST(req: NextRequest) {
145159
createNewChat,
146160
stream,
147161
implicitFeedback,
162+
fileAttachments,
148163
} = ChatMessageSchema.parse(body)
149164

150165
logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
@@ -195,15 +210,91 @@ export async function POST(req: NextRequest) {
195210
}
196211
}
197212

213+
// Process file attachments if present
214+
const processedFileContents: any[] = []
215+
if (fileAttachments && fileAttachments.length > 0) {
216+
logger.info(`[${tracker.requestId}] Processing ${fileAttachments.length} file attachments`)
217+
218+
for (const attachment of fileAttachments) {
219+
try {
220+
// Check if file type is supported
221+
if (!isSupportedFileType(attachment.media_type)) {
222+
logger.warn(`[${tracker.requestId}] Unsupported file type: ${attachment.media_type}`)
223+
continue
224+
}
225+
226+
// Download file from S3
227+
logger.info(`[${tracker.requestId}] Downloading file: ${attachment.s3_key}`)
228+
let fileBuffer: Buffer
229+
if (USE_S3_STORAGE) {
230+
fileBuffer = await downloadFromS3WithConfig(attachment.s3_key, S3_COPILOT_CONFIG)
231+
} else {
232+
// Fallback to generic downloadFile for other storage providers
233+
fileBuffer = await downloadFile(attachment.s3_key)
234+
}
235+
236+
// Convert to Anthropic format
237+
const fileContent = createAnthropicFileContent(fileBuffer, attachment.media_type)
238+
if (fileContent) {
239+
processedFileContents.push(fileContent)
240+
logger.info(
241+
`[${tracker.requestId}] Processed file: ${attachment.filename} (${attachment.media_type})`
242+
)
243+
}
244+
} catch (error) {
245+
logger.error(
246+
`[${tracker.requestId}] Failed to process file ${attachment.filename}:`,
247+
error
248+
)
249+
// Continue processing other files
250+
}
251+
}
252+
}
253+
198254
// Build messages array for sim agent with conversation history
199255
const messages = []
200256

201-
// Add conversation history
257+
// Add conversation history (need to rebuild these with file support if they had attachments)
202258
for (const msg of conversationHistory) {
203-
messages.push({
204-
role: msg.role,
205-
content: msg.content,
206-
})
259+
if (msg.fileAttachments && msg.fileAttachments.length > 0) {
260+
// This is a message with file attachments - rebuild with content array
261+
const content: any[] = [{ type: 'text', text: msg.content }]
262+
263+
// Process file attachments for historical messages
264+
for (const attachment of msg.fileAttachments) {
265+
try {
266+
if (isSupportedFileType(attachment.media_type)) {
267+
let fileBuffer: Buffer
268+
if (USE_S3_STORAGE) {
269+
fileBuffer = await downloadFromS3WithConfig(attachment.s3_key, S3_COPILOT_CONFIG)
270+
} else {
271+
// Fallback to generic downloadFile for other storage providers
272+
fileBuffer = await downloadFile(attachment.s3_key)
273+
}
274+
const fileContent = createAnthropicFileContent(fileBuffer, attachment.media_type)
275+
if (fileContent) {
276+
content.push(fileContent)
277+
}
278+
}
279+
} catch (error) {
280+
logger.error(
281+
`[${tracker.requestId}] Failed to process historical file ${attachment.filename}:`,
282+
error
283+
)
284+
}
285+
}
286+
287+
messages.push({
288+
role: msg.role,
289+
content,
290+
})
291+
} else {
292+
// Regular text-only message
293+
messages.push({
294+
role: msg.role,
295+
content: msg.content,
296+
})
297+
}
207298
}
208299

209300
// Add implicit feedback if provided
@@ -214,11 +305,27 @@ export async function POST(req: NextRequest) {
214305
})
215306
}
216307

217-
// Add current user message
218-
messages.push({
219-
role: 'user',
220-
content: message,
221-
})
308+
// Add current user message with file attachments
309+
if (processedFileContents.length > 0) {
310+
// Message with files - use content array format
311+
const content: any[] = [{ type: 'text', text: message }]
312+
313+
// Add file contents
314+
for (const fileContent of processedFileContents) {
315+
content.push(fileContent)
316+
}
317+
318+
messages.push({
319+
role: 'user',
320+
content,
321+
})
322+
} else {
323+
// Text-only message
324+
messages.push({
325+
role: 'user',
326+
content: message,
327+
})
328+
}
222329

223330
// Start title generation in parallel if this is a new chat with first message
224331
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
@@ -270,6 +377,7 @@ export async function POST(req: NextRequest) {
270377
role: 'user',
271378
content: message,
272379
timestamp: new Date().toISOString(),
380+
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
273381
}
274382

275383
// Create a pass-through stream that captures the response
@@ -590,6 +698,7 @@ export async function POST(req: NextRequest) {
590698
role: 'user',
591699
content: message,
592700
timestamp: new Date().toISOString(),
701+
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
593702
}
594703

595704
const assistantMessage = {

apps/sim/app/api/copilot/chat/update-messages/route.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ const UpdateMessagesSchema = z.object({
2424
timestamp: z.string(),
2525
toolCalls: z.array(z.any()).optional(),
2626
contentBlocks: z.array(z.any()).optional(),
27+
fileAttachments: z
28+
.array(
29+
z.object({
30+
id: z.string(),
31+
s3_key: z.string(),
32+
filename: z.string(),
33+
media_type: z.string(),
34+
size: z.number(),
35+
})
36+
)
37+
.optional(),
2738
})
2839
),
2940
})

0 commit comments

Comments
 (0)