Spaces:
Paused
Paused
| import React, { useState } from 'react'; | |
| import { | |
| CheckCircle, | |
| AlertTriangle, | |
| Upload, | |
| ExternalLink, | |
| Clock, | |
| Shield, | |
| File, | |
| FileImage, | |
| FileCode, | |
| FileText, | |
| FileJson, | |
| FileVideo, | |
| FileAudio, | |
| FileArchive, | |
| FolderOpen, | |
| Copy, | |
| Check, | |
| Lock, | |
| Database, | |
| Table, | |
| } from 'lucide-react'; | |
| import { ToolViewProps } from './types'; | |
| import { formatTimestamp, getToolTitle, normalizeContentToString, extractToolData } from './utils'; | |
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { Button } from '@/components/ui/button'; | |
| import { ScrollArea } from "@/components/ui/scroll-area"; | |
| import { LoadingState } from './shared/LoadingState'; | |
| import { cn } from '@/lib/utils'; | |
| import { toast } from 'sonner'; | |
| interface UploadData { | |
| file_path: string | null; | |
| bucket_name: string | null; | |
| custom_filename: string | null; | |
| } | |
| interface UploadResult { | |
| message?: string; | |
| storage_path?: string; | |
| file_size?: string; | |
| secure_url?: string; | |
| expires_at?: string; | |
| success?: boolean; | |
| } | |
| function extractUploadData(assistantContent: any, toolContent: any): { | |
| uploadData: UploadData; | |
| uploadResult: UploadResult | null; | |
| rawContent: string | null; | |
| } { | |
| let uploadData: UploadData = { | |
| file_path: null, | |
| bucket_name: null, | |
| custom_filename: null, | |
| }; | |
| let uploadResult: UploadResult | null = null; | |
| let rawContent: string | null = null; | |
| // Extract parameters from assistant content | |
| const assistantStr = normalizeContentToString(assistantContent); | |
| if (assistantStr) { | |
| try { | |
| const parsed = JSON.parse(assistantStr); | |
| if (parsed.parameters) { | |
| uploadData.file_path = parsed.parameters.file_path || null; | |
| uploadData.bucket_name = parsed.parameters.bucket_name || 'file-uploads'; | |
| uploadData.custom_filename = parsed.parameters.custom_filename || null; | |
| } | |
| } catch (e) { | |
| // Try regex extraction as fallback | |
| const filePathMatch = assistantStr.match(/file_path["']\s*:\s*["']([^"']+)["']/); | |
| const bucketMatch = assistantStr.match(/bucket_name["']\s*:\s*["']([^"']+)["']/); | |
| const filenameMatch = assistantStr.match(/custom_filename["']\s*:\s*["']([^"']+)["']/); | |
| if (filePathMatch) uploadData.file_path = filePathMatch[1]; | |
| if (bucketMatch) uploadData.bucket_name = bucketMatch[1]; | |
| if (filenameMatch) uploadData.custom_filename = filenameMatch[1]; | |
| } | |
| } | |
| // Extract result from tool content | |
| const toolStr = normalizeContentToString(toolContent); | |
| if (toolStr) { | |
| rawContent = toolStr; | |
| try { | |
| const parsed = JSON.parse(toolStr); | |
| // Handle nested tool_execution structure | |
| let resultData = null; | |
| if (parsed.tool_execution && parsed.tool_execution.result) { | |
| resultData = parsed.tool_execution.result; | |
| } else if (parsed.output) { | |
| resultData = parsed; | |
| } | |
| if (resultData) { | |
| // Parse the output message to extract structured data | |
| const output = resultData.output || ''; | |
| uploadResult = { | |
| message: output, | |
| success: resultData.success !== undefined ? resultData.success : true, | |
| }; | |
| // Extract structured data from the output message | |
| if (typeof output === 'string') { | |
| const storageMatch = output.match(/📁 Storage: ([^\n]+)/); | |
| const sizeMatch = output.match(/📏 Size: ([^\n]+)/); | |
| const urlMatch = output.match(/🔗 Secure Access URL: ([^\n]+)/); | |
| const expiresMatch = output.match(/⏰ URL expires: ([^\n]+)/); | |
| if (storageMatch) uploadResult.storage_path = storageMatch[1]; | |
| if (sizeMatch) uploadResult.file_size = sizeMatch[1]; | |
| if (urlMatch) uploadResult.secure_url = urlMatch[1]; | |
| if (expiresMatch) uploadResult.expires_at = expiresMatch[1]; | |
| } | |
| } | |
| } catch (e) { | |
| // If parsing fails, treat as plain text result | |
| uploadResult = { | |
| message: toolStr, | |
| success: !toolStr.toLowerCase().includes('error') && !toolStr.toLowerCase().includes('failed'), | |
| }; | |
| } | |
| } | |
| return { uploadData, uploadResult, rawContent }; | |
| } | |
| export function UploadFileToolView({ | |
| assistantContent, | |
| toolContent, | |
| assistantTimestamp, | |
| toolTimestamp, | |
| isSuccess = true, | |
| isStreaming = false, | |
| name = 'upload-file', | |
| }: ToolViewProps) { | |
| const [isCopyingUrl, setIsCopyingUrl] = useState(false); | |
| const [isCopyingPath, setIsCopyingPath] = useState(false); | |
| const { uploadData, uploadResult, rawContent } = extractUploadData(assistantContent, toolContent); | |
| const toolTitle = getToolTitle(name); | |
| const actualIsSuccess = uploadResult?.success !== undefined ? uploadResult.success : isSuccess; | |
| const copyToClipboard = async (text: string, type: 'url' | 'path') => { | |
| try { | |
| await navigator.clipboard.writeText(text); | |
| if (type === 'url') { | |
| setIsCopyingUrl(true); | |
| setTimeout(() => setIsCopyingUrl(false), 2000); | |
| } else { | |
| setIsCopyingPath(true); | |
| setTimeout(() => setIsCopyingPath(false), 2000); | |
| } | |
| toast.success(`${type === 'url' ? 'Secure URL' : 'Storage path'} copied to clipboard!`); | |
| } catch (err) { | |
| toast.error('Failed to copy to clipboard'); | |
| } | |
| }; | |
| const getFileName = (filePath: string | null) => { | |
| if (!filePath) return 'Unknown file'; | |
| return filePath.split('/').pop() || filePath; | |
| }; | |
| const getFileExtension = (filename: string) => { | |
| const ext = filename.split('.').pop()?.toLowerCase() || ''; | |
| return ext; | |
| }; | |
| const getFileIcon = (filename: string) => { | |
| const ext = getFileExtension(filename); | |
| if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return FileImage; | |
| if (['js', 'ts', 'jsx', 'tsx', 'py', 'html', 'css', 'json'].includes(ext)) return FileCode; | |
| if (['txt', 'md', 'doc', 'docx', 'pdf'].includes(ext)) return FileText; | |
| if (['csv', 'xlsx', 'xls'].includes(ext)) return Table; | |
| if (['mp4', 'mov', 'avi', 'webm'].includes(ext)) return FileVideo; | |
| if (['mp3', 'wav', 'ogg', 'flac'].includes(ext)) return FileAudio; | |
| if (['zip', 'rar', 'tar', 'gz'].includes(ext)) return FileArchive; | |
| if (ext === 'json') return FileJson; | |
| return File; | |
| }; | |
| const formatFileSize = (sizeStr: string | undefined) => { | |
| if (!sizeStr) return 'Unknown size'; | |
| return sizeStr; | |
| }; | |
| const formatExpiryTime = (expiryStr: string | undefined) => { | |
| if (!expiryStr) return 'Unknown expiry'; | |
| try { | |
| const date = new Date(expiryStr); | |
| const now = new Date(); | |
| const diffHours = Math.round((date.getTime() - now.getTime()) / (1000 * 60 * 60)); | |
| return `${expiryStr} (${diffHours}h remaining)`; | |
| } catch { | |
| return expiryStr; | |
| } | |
| }; | |
| const fileName = getFileName(uploadData.file_path); | |
| const FileIcon = getFileIcon(fileName); | |
| return ( | |
| <Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card"> | |
| <CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2"> | |
| <div className="flex flex-row items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <div className="relative p-2 rounded-lg bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 border border-emerald-500/20"> | |
| <Upload className="w-5 h-5 text-emerald-500 dark:text-emerald-400" /> | |
| </div> | |
| <div> | |
| <CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100"> | |
| {toolTitle} | |
| </CardTitle> | |
| </div> | |
| </div> | |
| {!isStreaming && ( | |
| <Badge | |
| variant="secondary" | |
| className={ | |
| actualIsSuccess | |
| ? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300" | |
| : "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300" | |
| } | |
| > | |
| {actualIsSuccess ? ( | |
| <CheckCircle className="h-3.5 w-3.5 mr-1" /> | |
| ) : ( | |
| <AlertTriangle className="h-3.5 w-3.5 mr-1" /> | |
| )} | |
| {actualIsSuccess ? 'Upload successful' : 'Upload failed'} | |
| </Badge> | |
| )} | |
| </div> | |
| </CardHeader> | |
| <CardContent className="p-0 h-full flex-1 overflow-hidden relative"> | |
| {isStreaming ? ( | |
| <LoadingState | |
| icon={Upload} | |
| iconColor="text-emerald-500 dark:text-emerald-400" | |
| bgColor="bg-gradient-to-b from-emerald-100 to-emerald-50 shadow-inner dark:from-emerald-800/40 dark:to-emerald-900/60 dark:shadow-emerald-950/20" | |
| title="Uploading File" | |
| filePath={uploadData.file_path || 'Preparing upload...'} | |
| showProgress={true} | |
| progressText="Uploading to secure storage..." | |
| /> | |
| ) : ( | |
| <ScrollArea className="h-full w-full"> | |
| <div className="p-4"> | |
| {actualIsSuccess && uploadResult ? ( | |
| <div className="space-y-4"> | |
| <div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg shadow-sm"> | |
| <div className="p-3"> | |
| <div className="grid grid-cols-2 gap-3 text-sm"> | |
| <div className="flex items-center gap-2"> | |
| <Database className="h-4 w-4 text-blue-600 dark:text-blue-400" /> | |
| <span className="text-zinc-700 dark:text-zinc-300"> | |
| {uploadData.bucket_name || 'file-uploads'} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <File className="h-4 w-4 text-purple-600 dark:text-purple-400" /> | |
| <span className="text-zinc-700 dark:text-zinc-300"> | |
| {formatFileSize(uploadResult.file_size)} | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {uploadResult.secure_url && ( | |
| <div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg shadow-sm"> | |
| <div className="p-3"> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <div className="w-6 h-6 rounded bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center"> | |
| <ExternalLink className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" /> | |
| </div> | |
| <span className="text-sm font-medium text-zinc-900 dark:text-zinc-100"> | |
| Secure Access URL | |
| </span> | |
| <Badge variant="outline" className="text-xs h-5 px-1.5 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800"> | |
| Private | |
| </Badge> | |
| </div> | |
| <div className="bg-zinc-50 dark:bg-zinc-800 rounded p-2 mb-3"> | |
| <code className="text-xs font-mono text-zinc-700 dark:text-zinc-300 break-all"> | |
| {uploadResult.secure_url} | |
| </code> | |
| </div> | |
| <div className="flex items-center justify-between mb-3"> | |
| <span className="text-xs text-zinc-500 dark:text-zinc-400"> | |
| 🔐 Private | |
| </span> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => copyToClipboard(uploadResult.secure_url!, 'url')} | |
| className="h-6 px-2 text-xs" | |
| > | |
| {isCopyingUrl ? ( | |
| <Check className="h-3 w-3 text-emerald-600" /> | |
| ) : ( | |
| <Copy className="h-3 w-3" /> | |
| )} | |
| <span>{isCopyingUrl ? 'Copied!' : 'Copy'}</span> | |
| </Button> | |
| </div> | |
| <Button | |
| onClick={() => window.open(uploadResult.secure_url, '_blank')} | |
| size="sm" | |
| > | |
| <ExternalLink className="h-3.5 w-3.5" /> | |
| Open Secure File | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <div className="space-y-4"> | |
| <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <AlertTriangle className="h-5 w-5 text-red-600" /> | |
| <h3 className="font-medium text-red-900 dark:text-red-100"> | |
| Upload Failed | |
| </h3> | |
| </div> | |
| <p className="text-sm text-red-700 dark:text-red-300"> | |
| {uploadResult?.message || 'The file upload encountered an error.'} | |
| </p> | |
| </div> | |
| {rawContent && ( | |
| <div className="bg-zinc-100 dark:bg-neutral-900 rounded-lg overflow-hidden border border-zinc-200/20"> | |
| <div className="bg-accent px-4 py-2 flex items-center gap-2"> | |
| <AlertTriangle className="h-4 w-4 text-zinc-600 dark:text-zinc-400" /> | |
| <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300"> | |
| Error Details | |
| </span> | |
| </div> | |
| <div className="p-4 max-h-96 overflow-auto scrollbar-hide"> | |
| <pre className="text-xs text-zinc-600 dark:text-zinc-300 font-mono whitespace-pre-wrap break-all"> | |
| {rawContent} | |
| </pre> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </ScrollArea> | |
| )} | |
| </CardContent> | |
| </Card> | |
| ); | |
| } |