suna / frontend /src /components /thread /tool-views /UploadFileToolView.tsx
R-Kentaren's picture
Upload folder using huggingface_hub
4efde5d verified
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>
);
}