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
This commit is contained in:
Siddharth Ganesan
2025-08-05 17:01:53 -07:00
committed by GitHub
parent 062e2a2c40
commit 94368eb1c2
15 changed files with 971 additions and 69 deletions

View File

@@ -0,0 +1,132 @@
export interface FileAttachment {
id: string
s3_key: string
filename: string
media_type: string
size: number
}
export interface AnthropicMessageContent {
type: 'text' | 'image' | 'document'
text?: string
source?: {
type: 'base64'
media_type: string
data: string
}
}
/**
* Mapping of MIME types to Anthropic content types
*/
export const MIME_TYPE_MAPPING: Record<string, 'image' | 'document'> = {
// Images
'image/jpeg': 'image',
'image/jpg': 'image',
'image/png': 'image',
'image/gif': 'image',
'image/webp': 'image',
'image/svg+xml': 'image',
// Documents
'application/pdf': 'document',
'text/plain': 'document',
'text/csv': 'document',
'application/json': 'document',
'application/xml': 'document',
'text/xml': 'document',
'text/html': 'document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document', // .docx
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document', // .xlsx
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document', // .pptx
'application/msword': 'document', // .doc
'application/vnd.ms-excel': 'document', // .xls
'application/vnd.ms-powerpoint': 'document', // .ppt
'text/markdown': 'document',
'application/rtf': 'document',
}
/**
* Get the Anthropic content type for a given MIME type
*/
export function getAnthropicContentType(mimeType: string): 'image' | 'document' | null {
return MIME_TYPE_MAPPING[mimeType.toLowerCase()] || null
}
/**
* Check if a MIME type is supported by Anthropic
*/
export function isSupportedFileType(mimeType: string): boolean {
return mimeType.toLowerCase() in MIME_TYPE_MAPPING
}
/**
* Convert a file buffer to base64
*/
export function bufferToBase64(buffer: Buffer): string {
return buffer.toString('base64')
}
/**
* Create Anthropic message content from file data
*/
export function createAnthropicFileContent(
fileBuffer: Buffer,
mimeType: string
): AnthropicMessageContent | null {
const contentType = getAnthropicContentType(mimeType)
if (!contentType) {
return null
}
return {
type: contentType,
source: {
type: 'base64',
media_type: mimeType,
data: bufferToBase64(fileBuffer),
},
}
}
/**
* Extract file extension from filename
*/
export function getFileExtension(filename: string): string {
const lastDot = filename.lastIndexOf('.')
return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ''
}
/**
* Get MIME type from file extension (fallback if not provided)
*/
export function getMimeTypeFromExtension(extension: string): string {
const extensionMimeMap: Record<string, string> = {
// Images
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
// Documents
pdf: 'application/pdf',
txt: 'text/plain',
csv: 'text/csv',
json: 'application/json',
xml: 'application/xml',
html: 'text/html',
htm: 'text/html',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
doc: 'application/msword',
xls: 'application/vnd.ms-excel',
ppt: 'application/vnd.ms-powerpoint',
md: 'text/markdown',
rtf: 'application/rtf',
}
return extensionMimeMap[extension.toLowerCase()] || 'application/octet-stream'
}

View File

@@ -13,12 +13,25 @@ import { getCopilotModel } from '@/lib/copilot/config'
import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { downloadFile } from '@/lib/uploads'
import { downloadFromS3WithConfig } from '@/lib/uploads/s3/s3-client'
import { S3_COPILOT_CONFIG, USE_S3_STORAGE } from '@/lib/uploads/setup'
import { db } from '@/db'
import { copilotChats } from '@/db/schema'
import { executeProviderRequest } from '@/providers'
import { createAnthropicFileContent, isSupportedFileType } from './file-utils'
const logger = createLogger('CopilotChatAPI')
// Schema for file attachments
const FileAttachmentSchema = z.object({
id: z.string(),
s3_key: z.string(),
filename: z.string(),
media_type: z.string(),
size: z.number(),
})
// Schema for chat messages
const ChatMessageSchema = z.object({
message: z.string().min(1, 'Message is required'),
@@ -29,6 +42,7 @@ const ChatMessageSchema = z.object({
createNewChat: z.boolean().optional().default(false),
stream: z.boolean().optional().default(true),
implicitFeedback: z.string().optional(),
fileAttachments: z.array(FileAttachmentSchema).optional(),
})
// Sim Agent API configuration
@@ -145,6 +159,7 @@ export async function POST(req: NextRequest) {
createNewChat,
stream,
implicitFeedback,
fileAttachments,
} = ChatMessageSchema.parse(body)
logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
@@ -195,15 +210,91 @@ export async function POST(req: NextRequest) {
}
}
// Process file attachments if present
const processedFileContents: any[] = []
if (fileAttachments && fileAttachments.length > 0) {
logger.info(`[${tracker.requestId}] Processing ${fileAttachments.length} file attachments`)
for (const attachment of fileAttachments) {
try {
// Check if file type is supported
if (!isSupportedFileType(attachment.media_type)) {
logger.warn(`[${tracker.requestId}] Unsupported file type: ${attachment.media_type}`)
continue
}
// Download file from S3
logger.info(`[${tracker.requestId}] Downloading file: ${attachment.s3_key}`)
let fileBuffer: Buffer
if (USE_S3_STORAGE) {
fileBuffer = await downloadFromS3WithConfig(attachment.s3_key, S3_COPILOT_CONFIG)
} else {
// Fallback to generic downloadFile for other storage providers
fileBuffer = await downloadFile(attachment.s3_key)
}
// Convert to Anthropic format
const fileContent = createAnthropicFileContent(fileBuffer, attachment.media_type)
if (fileContent) {
processedFileContents.push(fileContent)
logger.info(
`[${tracker.requestId}] Processed file: ${attachment.filename} (${attachment.media_type})`
)
}
} catch (error) {
logger.error(
`[${tracker.requestId}] Failed to process file ${attachment.filename}:`,
error
)
// Continue processing other files
}
}
}
// Build messages array for sim agent with conversation history
const messages = []
// Add conversation history
// Add conversation history (need to rebuild these with file support if they had attachments)
for (const msg of conversationHistory) {
messages.push({
role: msg.role,
content: msg.content,
})
if (msg.fileAttachments && msg.fileAttachments.length > 0) {
// This is a message with file attachments - rebuild with content array
const content: any[] = [{ type: 'text', text: msg.content }]
// Process file attachments for historical messages
for (const attachment of msg.fileAttachments) {
try {
if (isSupportedFileType(attachment.media_type)) {
let fileBuffer: Buffer
if (USE_S3_STORAGE) {
fileBuffer = await downloadFromS3WithConfig(attachment.s3_key, S3_COPILOT_CONFIG)
} else {
// Fallback to generic downloadFile for other storage providers
fileBuffer = await downloadFile(attachment.s3_key)
}
const fileContent = createAnthropicFileContent(fileBuffer, attachment.media_type)
if (fileContent) {
content.push(fileContent)
}
}
} catch (error) {
logger.error(
`[${tracker.requestId}] Failed to process historical file ${attachment.filename}:`,
error
)
}
}
messages.push({
role: msg.role,
content,
})
} else {
// Regular text-only message
messages.push({
role: msg.role,
content: msg.content,
})
}
}
// Add implicit feedback if provided
@@ -214,11 +305,27 @@ export async function POST(req: NextRequest) {
})
}
// Add current user message
messages.push({
role: 'user',
content: message,
})
// Add current user message with file attachments
if (processedFileContents.length > 0) {
// Message with files - use content array format
const content: any[] = [{ type: 'text', text: message }]
// Add file contents
for (const fileContent of processedFileContents) {
content.push(fileContent)
}
messages.push({
role: 'user',
content,
})
} else {
// Text-only message
messages.push({
role: 'user',
content: message,
})
}
// Start title generation in parallel if this is a new chat with first message
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
@@ -270,6 +377,7 @@ export async function POST(req: NextRequest) {
role: 'user',
content: message,
timestamp: new Date().toISOString(),
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
}
// Create a pass-through stream that captures the response
@@ -590,6 +698,7 @@ export async function POST(req: NextRequest) {
role: 'user',
content: message,
timestamp: new Date().toISOString(),
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
}
const assistantMessage = {

View File

@@ -24,6 +24,17 @@ const UpdateMessagesSchema = z.object({
timestamp: z.string(),
toolCalls: z.array(z.any()).optional(),
contentBlocks: z.array(z.any()).optional(),
fileAttachments: z
.array(
z.object({
id: z.string(),
s3_key: z.string(),
filename: z.string(),
media_type: z.string(),
size: z.number(),
})
)
.optional(),
})
),
})

View File

@@ -9,9 +9,11 @@ import { getS3Client, sanitizeFilenameForMetadata } from '@/lib/uploads/s3/s3-cl
import {
BLOB_CHAT_CONFIG,
BLOB_CONFIG,
BLOB_COPILOT_CONFIG,
BLOB_KB_CONFIG,
S3_CHAT_CONFIG,
S3_CONFIG,
S3_COPILOT_CONFIG,
S3_KB_CONFIG,
} from '@/lib/uploads/setup'
import { createErrorResponse, createOptionsResponse } from '@/app/api/files/utils'
@@ -22,9 +24,11 @@ interface PresignedUrlRequest {
fileName: string
contentType: string
fileSize: number
userId?: string
chatId?: string
}
type UploadType = 'general' | 'knowledge-base' | 'chat'
type UploadType = 'general' | 'knowledge-base' | 'chat' | 'copilot'
class PresignedUrlError extends Error {
constructor(
@@ -58,7 +62,7 @@ export async function POST(request: NextRequest) {
throw new ValidationError('Invalid JSON in request body')
}
const { fileName, contentType, fileSize } = data
const { fileName, contentType, fileSize, userId, chatId } = data
if (!fileName?.trim()) {
throw new ValidationError('fileName is required and cannot be empty')
@@ -83,7 +87,16 @@ export async function POST(request: NextRequest) {
? 'knowledge-base'
: uploadTypeParam === 'chat'
? 'chat'
: 'general'
: uploadTypeParam === 'copilot'
? 'copilot'
: 'general'
// Validate copilot-specific requirements
if (uploadType === 'copilot') {
if (!userId?.trim()) {
throw new ValidationError('userId is required for copilot uploads')
}
}
if (!isUsingCloudStorage()) {
throw new StorageConfigError(
@@ -96,9 +109,9 @@ export async function POST(request: NextRequest) {
switch (storageProvider) {
case 's3':
return await handleS3PresignedUrl(fileName, contentType, fileSize, uploadType)
return await handleS3PresignedUrl(fileName, contentType, fileSize, uploadType, userId)
case 'blob':
return await handleBlobPresignedUrl(fileName, contentType, fileSize, uploadType)
return await handleBlobPresignedUrl(fileName, contentType, fileSize, uploadType, userId)
default:
throw new StorageConfigError(`Unknown storage provider: ${storageProvider}`)
}
@@ -126,7 +139,8 @@ async function handleS3PresignedUrl(
fileName: string,
contentType: string,
fileSize: number,
uploadType: UploadType
uploadType: UploadType,
userId?: string
) {
try {
const config =
@@ -134,15 +148,26 @@ async function handleS3PresignedUrl(
? S3_KB_CONFIG
: uploadType === 'chat'
? S3_CHAT_CONFIG
: S3_CONFIG
: uploadType === 'copilot'
? S3_COPILOT_CONFIG
: S3_CONFIG
if (!config.bucket || !config.region) {
throw new StorageConfigError(`S3 configuration missing for ${uploadType} uploads`)
}
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const prefix = uploadType === 'knowledge-base' ? 'kb/' : uploadType === 'chat' ? 'chat/' : ''
const uniqueKey = `${prefix}${Date.now()}-${uuidv4()}-${safeFileName}`
let prefix = ''
if (uploadType === 'knowledge-base') {
prefix = 'kb/'
} else if (uploadType === 'chat') {
prefix = 'chat/'
} else if (uploadType === 'copilot') {
prefix = `${userId}/`
}
const uniqueKey = `${prefix}${uuidv4()}-${safeFileName}`
const sanitizedOriginalName = sanitizeFilenameForMetadata(fileName)
@@ -155,6 +180,9 @@ async function handleS3PresignedUrl(
metadata.purpose = 'knowledge-base'
} else if (uploadType === 'chat') {
metadata.purpose = 'chat'
} else if (uploadType === 'copilot') {
metadata.purpose = 'copilot'
metadata.userId = userId || ''
}
const command = new PutObjectCommand({
@@ -210,7 +238,8 @@ async function handleBlobPresignedUrl(
fileName: string,
contentType: string,
fileSize: number,
uploadType: UploadType
uploadType: UploadType,
userId?: string
) {
try {
const config =
@@ -218,7 +247,9 @@ async function handleBlobPresignedUrl(
? BLOB_KB_CONFIG
: uploadType === 'chat'
? BLOB_CHAT_CONFIG
: BLOB_CONFIG
: uploadType === 'copilot'
? BLOB_COPILOT_CONFIG
: BLOB_CONFIG
if (
!config.accountName ||
@@ -229,8 +260,17 @@ async function handleBlobPresignedUrl(
}
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const prefix = uploadType === 'knowledge-base' ? 'kb/' : uploadType === 'chat' ? 'chat/' : ''
const uniqueKey = `${prefix}${Date.now()}-${uuidv4()}-${safeFileName}`
let prefix = ''
if (uploadType === 'knowledge-base') {
prefix = 'kb/'
} else if (uploadType === 'chat') {
prefix = 'chat/'
} else if (uploadType === 'copilot') {
prefix = `${userId}/`
}
const uniqueKey = `${prefix}${uuidv4()}-${safeFileName}`
const blobServiceClient = getBlobServiceClient()
const containerClient = blobServiceClient.getContainerClient(config.containerName)
@@ -282,6 +322,9 @@ async function handleBlobPresignedUrl(
uploadHeaders['x-ms-meta-purpose'] = 'knowledge-base'
} else if (uploadType === 'chat') {
uploadHeaders['x-ms-meta-purpose'] = 'chat'
} else if (uploadType === 'copilot') {
uploadHeaders['x-ms-meta-purpose'] = 'copilot'
uploadHeaders['x-ms-meta-userid'] = encodeURIComponent(userId || '')
}
return NextResponse.json({

View File

@@ -58,7 +58,11 @@ export async function GET(
if (isUsingCloudStorage() || isCloudPath) {
// Extract the actual key (remove 's3/' or 'blob/' prefix if present)
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
return await handleCloudProxy(cloudKey)
// Get bucket type from query parameter
const bucketType = request.nextUrl.searchParams.get('bucket')
return await handleCloudProxy(cloudKey, bucketType)
}
// Use local handler for local files
@@ -152,12 +156,37 @@ async function downloadKBFile(cloudKey: string): Promise<Buffer> {
/**
* Proxy cloud file through our server
*/
async function handleCloudProxy(cloudKey: string): Promise<NextResponse> {
async function handleCloudProxy(
cloudKey: string,
bucketType?: string | null
): Promise<NextResponse> {
try {
// Check if this is a KB file (starts with 'kb/')
const isKBFile = cloudKey.startsWith('kb/')
const fileBuffer = isKBFile ? await downloadKBFile(cloudKey) : await downloadFile(cloudKey)
let fileBuffer: Buffer
if (isKBFile) {
fileBuffer = await downloadKBFile(cloudKey)
} else if (bucketType === 'copilot') {
// Download from copilot-specific bucket
const storageProvider = getStorageProvider()
if (storageProvider === 's3') {
const { downloadFromS3WithConfig } = await import('@/lib/uploads/s3/s3-client')
const { S3_COPILOT_CONFIG } = await import('@/lib/uploads/setup')
fileBuffer = await downloadFromS3WithConfig(cloudKey, S3_COPILOT_CONFIG)
} else if (storageProvider === 'blob') {
// For Azure Blob, use the default downloadFile for now
// TODO: Add downloadFromBlobWithConfig when needed
fileBuffer = await downloadFile(cloudKey)
} else {
fileBuffer = await downloadFile(cloudKey)
}
} else {
// Default bucket
fileBuffer = await downloadFile(cloudKey)
}
// Extract the original filename from the key (last part after last /)
const originalFilename = cloudKey.split('/').pop() || 'download'

View File

@@ -1,7 +1,17 @@
'use client'
import { type FC, memo, useEffect, useMemo, useRef, useState } from 'react'
import { Check, Clipboard, Loader2, RotateCcw, ThumbsDown, ThumbsUp, X } from 'lucide-react'
import {
Check,
Clipboard,
FileText,
Image,
Loader2,
RotateCcw,
ThumbsDown,
ThumbsUp,
X,
} from 'lucide-react'
import { InlineToolCall } from '@/lib/copilot/tools/inline-tool-call'
import { createLogger } from '@/lib/logs/console/logger'
import { usePreviewStore } from '@/stores/copilot/preview-store'
@@ -38,6 +48,107 @@ const StreamingIndicator = memo(() => (
StreamingIndicator.displayName = 'StreamingIndicator'
// File attachment display component
interface FileAttachmentDisplayProps {
fileAttachments: any[]
}
const FileAttachmentDisplay = memo(({ fileAttachments }: FileAttachmentDisplayProps) => {
// Cache for file URLs to avoid re-fetching on every render
const [fileUrls, setFileUrls] = useState<Record<string, string>>({})
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
}
const getFileIcon = (mediaType: string) => {
if (mediaType.startsWith('image/')) {
return <Image className='h-5 w-5 text-muted-foreground' />
}
if (mediaType.includes('pdf')) {
return <FileText className='h-5 w-5 text-red-500' />
}
if (mediaType.includes('text') || mediaType.includes('json') || mediaType.includes('xml')) {
return <FileText className='h-5 w-5 text-blue-500' />
}
return <FileText className='h-5 w-5 text-muted-foreground' />
}
const getFileUrl = (file: any) => {
const cacheKey = file.s3_key
if (fileUrls[cacheKey]) {
return fileUrls[cacheKey]
}
// Generate URL only once and cache it
const url = `/api/files/serve/s3/${encodeURIComponent(file.s3_key)}?bucket=copilot`
setFileUrls((prev) => ({ ...prev, [cacheKey]: url }))
return url
}
const handleFileClick = (file: any) => {
// Use cached URL or generate it
const serveUrl = getFileUrl(file)
// Open the file in a new tab
window.open(serveUrl, '_blank')
}
const isImageFile = (mediaType: string) => {
return mediaType.startsWith('image/')
}
return (
<>
{fileAttachments.map((file) => (
<div
key={file.id}
className='group relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-border/50 bg-muted/20 transition-all hover:bg-muted/40'
onClick={() => handleFileClick(file)}
title={`${file.filename} (${formatFileSize(file.size)})`}
>
{isImageFile(file.media_type) ? (
// For images, show actual thumbnail
<img
src={getFileUrl(file)}
alt={file.filename}
className='h-full w-full object-cover'
onError={(e) => {
// If image fails to load, replace with icon
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent) {
const iconContainer = document.createElement('div')
iconContainer.className =
'flex items-center justify-center w-full h-full bg-background/50'
iconContainer.innerHTML =
'<svg class="h-5 w-5 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>'
parent.appendChild(iconContainer)
}
}}
/>
) : (
// For other files, show icon centered
<div className='flex h-full w-full items-center justify-center bg-background/50'>
{getFileIcon(file.media_type)}
</div>
)}
{/* Hover overlay effect */}
<div className='pointer-events-none absolute inset-0 bg-black/10 opacity-0 transition-opacity group-hover:opacity-100' />
</div>
))}
</>
)
})
FileAttachmentDisplay.displayName = 'FileAttachmentDisplay'
// Smooth streaming text component with typewriter effect
interface SmoothStreamingTextProps {
content: string
@@ -481,8 +592,18 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
if (isUser) {
return (
<div className='w-full py-2'>
{/* File attachments displayed above the message, completely separate from message box width */}
{message.fileAttachments && message.fileAttachments.length > 0 && (
<div className='mb-1 flex justify-end'>
<div className='flex flex-wrap gap-1.5'>
<FileAttachmentDisplay fileAttachments={message.fileAttachments} />
</div>
</div>
)}
<div className='flex justify-end'>
<div className='max-w-[80%]'>
{/* Message content in purple box */}
<div
className='rounded-[10px] px-3 py-2'
style={{ backgroundColor: 'rgba(128, 47, 255, 0.08)' }}
@@ -491,6 +612,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
<WordWrap text={message.content} />
</div>
</div>
{/* Checkpoints below message */}
{hasCheckpoints && (
<div className='mt-1 flex justify-end'>
{showRestoreConfirmation ? (

View File

@@ -8,13 +8,43 @@ import {
useRef,
useState,
} from 'react'
import { ArrowUp, Loader2, MessageCircle, Package, X } from 'lucide-react'
import {
ArrowUp,
FileText,
Image,
Loader2,
MessageCircle,
Package,
Paperclip,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { useSession } from '@/lib/auth-client'
import { cn } from '@/lib/utils'
import { useCopilotStore } from '@/stores/copilot/store'
export interface MessageFileAttachment {
id: string
s3_key: string
filename: string
media_type: string
size: number
}
interface AttachedFile {
id: string
name: string
size: number
type: string
path: string
key?: string // Add key field to store the actual S3 key
uploading: boolean
previewUrl?: string // For local preview of images before upload
}
interface UserInputProps {
onSubmit: (message: string) => void
onSubmit: (message: string, fileAttachments?: MessageFileAttachment[]) => void
onAbort?: () => void
disabled?: boolean
isLoading?: boolean
@@ -49,7 +79,15 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
ref
) => {
const [internalMessage, setInternalMessage] = useState('')
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
// Drag and drop state
const [isDragging, setIsDragging] = useState(false)
const [dragCounter, setDragCounter] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const { data: session } = useSession()
const { currentChat, workflowId } = useCopilotStore()
// Expose focus method to parent
useImperativeHandle(
@@ -76,17 +114,190 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}, [message])
// Cleanup preview URLs on unmount
useEffect(() => {
return () => {
attachedFiles.forEach((f) => {
if (f.previewUrl) {
URL.revokeObjectURL(f.previewUrl)
}
})
}
}, [])
// Drag and drop handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => {
const newCount = prev + 1
if (newCount === 1) {
setIsDragging(true)
}
return newCount
})
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => {
const newCount = prev - 1
if (newCount === 0) {
setIsDragging(false)
}
return newCount
})
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
// Add visual feedback for valid drop zone
e.dataTransfer.dropEffect = 'copy'
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
setDragCounter(0)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
await processFiles(e.dataTransfer.files)
}
}
// Process dropped or selected files
const processFiles = async (fileList: FileList) => {
const userId = session?.user?.id
if (!userId) {
console.error('User ID not available for file upload')
return
}
// Process files one by one
for (const file of Array.from(fileList)) {
// Create a preview URL for images
let previewUrl: string | undefined
if (file.type.startsWith('image/')) {
previewUrl = URL.createObjectURL(file)
}
// Create a temporary file entry with uploading state
const tempFile: AttachedFile = {
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
path: '',
uploading: true,
previewUrl,
}
setAttachedFiles((prev) => [...prev, tempFile])
try {
// Request presigned URL
const presignedResponse = await fetch('/api/files/presigned?type=copilot', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
fileSize: file.size,
userId,
}),
})
if (!presignedResponse.ok) {
throw new Error('Failed to get presigned URL')
}
const presignedData = await presignedResponse.json()
// Upload file to S3
console.log('Uploading to S3:', presignedData.presignedUrl)
const uploadResponse = await fetch(presignedData.presignedUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type,
},
body: file,
})
console.log('S3 Upload response status:', uploadResponse.status)
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text()
console.error('S3 Upload failed:', errorText)
throw new Error(`Failed to upload file: ${uploadResponse.status} ${errorText}`)
}
// Update file entry with success
setAttachedFiles((prev) =>
prev.map((f) =>
f.id === tempFile.id
? {
...f,
path: presignedData.fileInfo.path,
key: presignedData.fileInfo.key, // Store the actual S3 key
uploading: false,
}
: f
)
)
} catch (error) {
console.error('File upload failed:', error)
// Remove failed upload
setAttachedFiles((prev) => prev.filter((f) => f.id !== tempFile.id))
}
}
}
const handleSubmit = () => {
const trimmedMessage = message.trim()
if (!trimmedMessage || disabled || isLoading) return
onSubmit(trimmedMessage)
// Clear the message after submit
// Check for failed uploads and show user feedback
const failedUploads = attachedFiles.filter((f) => !f.uploading && !f.key)
if (failedUploads.length > 0) {
console.error(
'Some files failed to upload:',
failedUploads.map((f) => f.name)
)
}
// Convert attached files to the format expected by the API
const fileAttachments = attachedFiles
.filter((f) => !f.uploading && f.key) // Only include successfully uploaded files with keys
.map((f) => ({
id: f.id,
s3_key: f.key!, // Use the actual S3 key stored from the upload response
filename: f.name,
media_type: f.type,
size: f.size,
}))
onSubmit(trimmedMessage, fileAttachments)
// Clean up preview URLs before clearing
attachedFiles.forEach((f) => {
if (f.previewUrl) {
URL.revokeObjectURL(f.previewUrl)
}
})
// Clear the message and files after submit
if (controlledValue !== undefined) {
onControlledChange?.('')
} else {
setInternalMessage('')
}
setAttachedFiles([])
}
const handleAbort = () => {
@@ -111,6 +322,67 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}
const handleFileSelect = () => {
fileInputRef.current?.click()
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
await processFiles(files)
// Clear the input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const removeFile = (fileId: string) => {
// Clean up preview URL if it exists
const file = attachedFiles.find((f) => f.id === fileId)
if (file?.previewUrl) {
URL.revokeObjectURL(file.previewUrl)
}
setAttachedFiles((prev) => prev.filter((f) => f.id !== fileId))
}
const handleFileClick = (file: AttachedFile) => {
// If file has been uploaded and has an S3 key, open the S3 URL
if (file.key) {
const serveUrl = `/api/files/serve/s3/${encodeURIComponent(file.key)}?bucket=copilot`
window.open(serveUrl, '_blank')
} else if (file.previewUrl) {
// If file hasn't been uploaded yet but has a preview URL, open that
window.open(file.previewUrl, '_blank')
}
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`
}
const isImageFile = (type: string) => {
return type.startsWith('image/')
}
const getFileIcon = (mediaType: string) => {
if (mediaType.startsWith('image/')) {
return <Image className='h-5 w-5 text-muted-foreground' />
}
if (mediaType.includes('pdf')) {
return <FileText className='h-5 w-5 text-red-500' />
}
if (mediaType.includes('text') || mediaType.includes('json') || mediaType.includes('xml')) {
return <FileText className='h-5 w-5 text-blue-500' />
}
return <FileText className='h-5 w-5 text-muted-foreground' />
}
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
const showAbortButton = isLoading && onAbort
@@ -130,23 +402,93 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
return (
<div className={cn('relative flex-none pb-4', className)}>
<div className='rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] p-2 shadow-xs dark:border-[#414141] dark:bg-[#202020]'>
<div
className={cn(
'rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] p-2 shadow-xs transition-all duration-200 dark:border-[#414141] dark:bg-[#202020]',
isDragging &&
'border-[#802FFF] bg-purple-50/50 dark:border-[#802FFF] dark:bg-purple-950/20'
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Attached Files Display with Thumbnails */}
{attachedFiles.length > 0 && (
<div className='mb-2 flex flex-wrap gap-1.5'>
{attachedFiles.map((file) => (
<div
key={file.id}
className='group relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-border/50 bg-muted/20 transition-all hover:bg-muted/40'
title={`${file.name} (${formatFileSize(file.size)})`}
onClick={() => handleFileClick(file)}
>
{isImageFile(file.type) && file.previewUrl ? (
// For images, show actual thumbnail
<img
src={file.previewUrl}
alt={file.name}
className='h-full w-full object-cover'
/>
) : isImageFile(file.type) && file.key ? (
// For uploaded images without preview URL, use S3 URL
<img
src={`/api/files/serve/s3/${encodeURIComponent(file.key)}?bucket=copilot`}
alt={file.name}
className='h-full w-full object-cover'
/>
) : (
// For other files, show icon centered
<div className='flex h-full w-full items-center justify-center bg-background/50'>
{getFileIcon(file.type)}
</div>
)}
{/* Loading overlay */}
{file.uploading && (
<div className='absolute inset-0 flex items-center justify-center bg-black/50'>
<Loader2 className='h-4 w-4 animate-spin text-white' />
</div>
)}
{/* Remove button */}
{!file.uploading && (
<Button
variant='ghost'
size='icon'
onClick={(e) => {
e.stopPropagation()
removeFile(file.id)
}}
className='absolute top-0.5 right-0.5 h-5 w-5 bg-black/50 text-white opacity-0 transition-opacity hover:bg-black/70 group-hover:opacity-100'
>
<X className='h-3 w-3' />
</Button>
)}
{/* Hover overlay effect */}
<div className='pointer-events-none absolute inset-0 bg-black/10 opacity-0 transition-opacity group-hover:opacity-100' />
</div>
))}
</div>
)}
{/* Textarea Field */}
<Textarea
ref={textareaRef}
value={message}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
placeholder={isDragging ? 'Drop files here...' : placeholder}
disabled={disabled}
rows={1}
className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
style={{ height: 'auto' }}
/>
{/* Bottom Row: Mode Selector + Send Button */}
{/* Bottom Row: Mode Selector + Attach Button + Send Button */}
<div className='flex items-center justify-between'>
{/* Mode Selector Tag */}
{/* Left side: Mode Selector */}
<Button
variant='ghost'
size='sm'
@@ -158,36 +500,61 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
<span className='capitalize'>{mode}</span>
</Button>
{/* Send Button */}
{showAbortButton ? (
{/* Right side: Attach Button + Send Button */}
<div className='flex items-center gap-1'>
{/* Attach Button */}
<Button
onClick={handleAbort}
disabled={isAborting}
variant='ghost'
size='icon'
className='h-6 w-6 rounded-full bg-red-500 text-white transition-all duration-200 hover:bg-red-600'
title='Stop generation'
onClick={handleFileSelect}
disabled={disabled || isLoading}
className='h-6 w-6 text-muted-foreground hover:text-foreground'
title='Attach file'
>
{isAborting ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<X className='h-3 w-3' />
)}
<Paperclip className='h-3 w-3' />
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!canSubmit}
size='icon'
className='h-6 w-6 rounded-full bg-[#802FFF] text-white shadow-[0_0_0_0_#802FFF] transition-all duration-200 hover:bg-[#7028E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
{isLoading ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<ArrowUp className='h-3 w-3' />
)}
</Button>
)}
{/* Send Button */}
{showAbortButton ? (
<Button
onClick={handleAbort}
disabled={isAborting}
size='icon'
className='h-6 w-6 rounded-full bg-red-500 text-white transition-all duration-200 hover:bg-red-600'
title='Stop generation'
>
{isAborting ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<X className='h-3 w-3' />
)}
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!canSubmit}
size='icon'
className='h-6 w-6 rounded-full bg-[#802FFF] text-white shadow-[0_0_0_0_#802FFF] transition-all duration-200 hover:bg-[#7028E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
{isLoading ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<ArrowUp className='h-3 w-3' />
)}
</Button>
)}
</div>
</div>
{/* Hidden File Input */}
<input
ref={fileInputRef}
type='file'
onChange={handleFileChange}
className='hidden'
accept='.pdf,.doc,.docx,.txt,.md,.png,.jpg,.jpeg,.gif,.svg'
multiple
/>
</div>
</div>
)

View File

@@ -12,7 +12,10 @@ import {
CopilotWelcome,
UserInput,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import type { UserInputRef } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
import type {
MessageFileAttachment,
UserInputRef,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
import { COPILOT_TOOL_IDS } from '@/stores/copilot/constants'
import { usePreviewStore } from '@/stores/copilot/preview-store'
import { useCopilotStore } from '@/stores/copilot/store'
@@ -251,12 +254,16 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
// Handle message submission
const handleSubmit = useCallback(
async (query: string) => {
async (query: string, fileAttachments?: MessageFileAttachment[]) => {
if (!query || isSendingMessage || !activeWorkflowId) return
try {
await sendMessage(query, { stream: true })
logger.info('Sent message:', query)
await sendMessage(query, { stream: true, fileAttachments })
logger.info(
'Sent message:',
query,
fileAttachments ? `with ${fileAttachments.length} attachments` : ''
)
} catch (error) {
logger.error('Failed to send message:', error)
}

View File

@@ -37,6 +37,17 @@ export interface CopilotChat {
updatedAt: Date
}
/**
* File attachment interface for message requests
*/
export interface MessageFileAttachment {
id: string
s3_key: string
filename: string
media_type: string
size: number
}
/**
* Request interface for sending messages
*/
@@ -49,6 +60,7 @@ export interface SendMessageRequest {
createNewChat?: boolean
stream?: boolean
implicitFeedback?: string
fileAttachments?: MessageFileAttachment[]
abortSignal?: AbortSignal
}

View File

@@ -96,6 +96,7 @@ export const env = createEnv({
S3_LOGS_BUCKET_NAME: z.string().optional(), // S3 bucket for storing logs
S3_KB_BUCKET_NAME: z.string().optional(), // S3 bucket for knowledge base files
S3_CHAT_BUCKET_NAME: z.string().optional(), // S3 bucket for chat logos
S3_COPILOT_BUCKET_NAME: z.string().optional(), // S3 bucket for copilot files
// Cloud Storage - Azure Blob
AZURE_ACCOUNT_NAME: z.string().optional(), // Azure storage account name
@@ -104,6 +105,7 @@ export const env = createEnv({
AZURE_STORAGE_CONTAINER_NAME: z.string().optional(), // Azure container for general files
AZURE_STORAGE_KB_CONTAINER_NAME: z.string().optional(), // Azure container for knowledge base files
AZURE_STORAGE_CHAT_CONTAINER_NAME: z.string().optional(), // Azure container for chat logos
AZURE_STORAGE_COPILOT_CONTAINER_NAME: z.string().optional(), // Azure container for copilot files
// Data Retention
FREE_PLAN_LOG_RETENTION_DAYS: z.string().optional(), // Log retention days for free plan users

View File

@@ -229,6 +229,30 @@ export async function downloadFromS3(key: string) {
})
}
/**
* Download a file from S3 with custom bucket configuration
* @param key S3 object key
* @param customConfig Custom S3 configuration
* @returns File buffer
*/
export async function downloadFromS3WithConfig(key: string, customConfig: CustomS3Config) {
const command = new GetObjectCommand({
Bucket: customConfig.bucket,
Key: key,
})
const response = await getS3Client().send(command)
const stream = response.Body as any
// Convert stream to buffer
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = []
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
stream.on('end', () => resolve(Buffer.concat(chunks)))
stream.on('error', reject)
})
}
/**
* Delete a file from S3
* @param key S3 object key

View File

@@ -69,9 +69,15 @@ if (typeof process !== 'undefined') {
if (USE_BLOB_STORAGE && env.AZURE_STORAGE_KB_CONTAINER_NAME) {
logger.info(`Azure Blob knowledge base container: ${env.AZURE_STORAGE_KB_CONTAINER_NAME}`)
}
if (USE_BLOB_STORAGE && env.AZURE_STORAGE_COPILOT_CONTAINER_NAME) {
logger.info(`Azure Blob copilot container: ${env.AZURE_STORAGE_COPILOT_CONTAINER_NAME}`)
}
if (USE_S3_STORAGE && env.S3_KB_BUCKET_NAME) {
logger.info(`S3 knowledge base bucket: ${env.S3_KB_BUCKET_NAME}`)
}
if (USE_S3_STORAGE && env.S3_COPILOT_BUCKET_NAME) {
logger.info(`S3 copilot bucket: ${env.S3_COPILOT_BUCKET_NAME}`)
}
}
export default ensureUploadsDirectory

View File

@@ -60,6 +60,18 @@ export const BLOB_CHAT_CONFIG = {
containerName: env.AZURE_STORAGE_CHAT_CONTAINER_NAME || '',
}
export const S3_COPILOT_CONFIG = {
bucket: env.S3_COPILOT_BUCKET_NAME || '',
region: env.AWS_REGION || '',
}
export const BLOB_COPILOT_CONFIG = {
accountName: env.AZURE_ACCOUNT_NAME || '',
accountKey: env.AZURE_ACCOUNT_KEY || '',
connectionString: env.AZURE_CONNECTION_STRING || '',
containerName: env.AZURE_STORAGE_COPILOT_CONTAINER_NAME || '',
}
export async function ensureUploadsDirectory() {
if (USE_S3_STORAGE) {
logger.info('Using S3 storage, skipping local uploads directory creation')

View File

@@ -7,7 +7,12 @@ import { toolRegistry } from '@/lib/copilot/tools'
import { createLogger } from '@/lib/logs/console/logger'
import { COPILOT_TOOL_DISPLAY_NAMES } from '@/stores/constants'
import { COPILOT_TOOL_IDS } from './constants'
import type { CopilotMessage, CopilotStore, WorkflowCheckpoint } from './types'
import type {
CopilotMessage,
CopilotStore,
MessageFileAttachment,
WorkflowCheckpoint,
} from './types'
const logger = createLogger('CopilotStore')
@@ -143,14 +148,18 @@ const initialState = {
}
/**
* Helper function to create a new user messagenow let
* Helper function to create a new user message
*/
function createUserMessage(content: string): CopilotMessage {
function createUserMessage(
content: string,
fileAttachments?: MessageFileAttachment[]
): CopilotMessage {
return {
id: crypto.randomUUID(),
role: 'user',
content,
timestamp: new Date().toISOString(),
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
}
}
@@ -213,6 +222,8 @@ function validateMessagesForLLM(messages: CopilotMessage[]): any[] {
...(msg.toolCalls && msg.toolCalls.length > 0 && { toolCalls: msg.toolCalls }),
...(msg.contentBlocks &&
msg.contentBlocks.length > 0 && { contentBlocks: msg.contentBlocks }),
...(msg.fileAttachments &&
msg.fileAttachments.length > 0 && { fileAttachments: msg.fileAttachments }),
}
})
.filter((msg) => {
@@ -1685,7 +1696,7 @@ export const useCopilotStore = create<CopilotStore>()(
// Send a message
sendMessage: async (message: string, options = {}) => {
const { workflowId, currentChat, mode, revertState } = get()
const { stream = true } = options
const { stream = true, fileAttachments } = options
if (!workflowId) {
logger.warn('Cannot send message: no workflow ID set')
@@ -1696,7 +1707,7 @@ export const useCopilotStore = create<CopilotStore>()(
const abortController = new AbortController()
set({ isSendingMessage: true, error: null, abortController })
const userMessage = createUserMessage(message)
const userMessage = createUserMessage(message, fileAttachments)
const streamingMessage = createStreamingMessage()
// Handle message history rewriting if we're in revert state
@@ -1752,6 +1763,7 @@ export const useCopilotStore = create<CopilotStore>()(
mode,
createNewChat: !currentChat,
stream,
fileAttachments: options.fileAttachments,
abortSignal: abortController.signal,
})

View File

@@ -60,6 +60,17 @@ export interface ToolCallContentBlock {
export type ContentBlock = TextContentBlock | ToolCallContentBlock
/**
* File attachment interface for copilot messages
*/
export interface MessageFileAttachment {
id: string
s3_key: string
filename: string
media_type: string
size: number
}
/**
* Copilot message interface
*/
@@ -71,6 +82,7 @@ export interface CopilotMessage {
citations?: Citation[]
toolCalls?: CopilotToolCall[]
contentBlocks?: ContentBlock[] // New chronological content structure
fileAttachments?: MessageFileAttachment[] // File attachments
}
/**
@@ -132,6 +144,7 @@ export interface CreateChatOptions {
*/
export interface SendMessageOptions {
stream?: boolean
fileAttachments?: MessageFileAttachment[]
}
/**