mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
committed by
GitHub
parent
062e2a2c40
commit
94368eb1c2
132
apps/sim/app/api/copilot/chat/file-utils.ts
Normal file
132
apps/sim/app/api/copilot/chat/file-utils.ts
Normal 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'
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user