mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
File creation
This commit is contained in:
@@ -1,534 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ArrowDown, Files as FilesIcon, Loader2, Plus, Search, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
useDeleteWorkspaceFile,
|
||||
useStorageInfo,
|
||||
useUploadWorkspaceFile,
|
||||
useWorkspaceFiles,
|
||||
} from '@/hooks/queries/workspace-files'
|
||||
|
||||
const logger = createLogger('FilesView')
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
const SUPPORTED_EXTENSIONS = [
|
||||
'pdf',
|
||||
'csv',
|
||||
'doc',
|
||||
'docx',
|
||||
'txt',
|
||||
'md',
|
||||
'xlsx',
|
||||
'xls',
|
||||
'html',
|
||||
'htm',
|
||||
'pptx',
|
||||
'ppt',
|
||||
'json',
|
||||
'yaml',
|
||||
'yml',
|
||||
'mp3',
|
||||
'm4a',
|
||||
'wav',
|
||||
'webm',
|
||||
'ogg',
|
||||
'flac',
|
||||
'aac',
|
||||
'opus',
|
||||
'mp4',
|
||||
'mov',
|
||||
'avi',
|
||||
'mkv',
|
||||
] as const
|
||||
const ACCEPT_ATTR =
|
||||
'.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.yaml,.yml,.mp3,.m4a,.wav,.webm,.ogg,.flac,.aac,.opus,.mp4,.mov,.avi,.mkv'
|
||||
|
||||
const PLAN_NAMES = {
|
||||
enterprise: 'Enterprise',
|
||||
team: 'Team',
|
||||
pro: 'Pro',
|
||||
free: 'Free',
|
||||
} as const
|
||||
import { Files as FilesIcon } from 'lucide-react'
|
||||
import { Files } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/files/files'
|
||||
|
||||
export function FilesView() {
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
|
||||
const { data: files = [] } = useWorkspaceFiles(workspaceId)
|
||||
const { data: storageInfo } = useStorageInfo(isBillingEnabled)
|
||||
const uploadFile = useUploadWorkspaceFile()
|
||||
const deleteFile = useDeleteWorkspaceFile()
|
||||
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [failedFiles, setFailedFiles] = useState<string[]>([])
|
||||
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
|
||||
const [downloadingFileId, setDownloadingFileId] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { userPermissions, permissionsLoading } = useWorkspacePermissionsContext()
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const list = e.target.files
|
||||
if (!list || list.length === 0 || !workspaceId) return
|
||||
|
||||
try {
|
||||
setUploading(true)
|
||||
setFailedFiles([])
|
||||
|
||||
const filesToUpload = Array.from(list)
|
||||
const unsupported: string[] = []
|
||||
const allowedFiles = filesToUpload.filter((f) => {
|
||||
const ext = getFileExtension(f.name)
|
||||
const ok = SUPPORTED_EXTENSIONS.includes(ext as (typeof SUPPORTED_EXTENSIONS)[number])
|
||||
if (!ok) unsupported.push(f.name)
|
||||
return ok
|
||||
})
|
||||
|
||||
setUploadProgress({ completed: 0, total: allowedFiles.length })
|
||||
const failed: string[] = [...unsupported]
|
||||
|
||||
for (let i = 0; i < allowedFiles.length; i++) {
|
||||
const selectedFile = allowedFiles[i]
|
||||
try {
|
||||
await uploadFile.mutateAsync({ workspaceId, file: selectedFile })
|
||||
setUploadProgress({ completed: i + 1, total: allowedFiles.length })
|
||||
} catch (err) {
|
||||
logger.error('Error uploading file:', err)
|
||||
failed.push(selectedFile.name)
|
||||
}
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
setFailedFiles(failed)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error uploading file:', error)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadProgress({ completed: 0, total: 0 })
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (file: WorkspaceFileRecord) => {
|
||||
if (!workspaceId || downloadingFileId === file.id) return
|
||||
|
||||
setDownloadingFileId(file.id)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/files/${file.id}/download`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get download URL')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success || !data.downloadUrl) {
|
||||
throw new Error('Invalid download response')
|
||||
}
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = data.downloadUrl
|
||||
link.download = data.fileName || file.name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} catch (error) {
|
||||
logger.error('Error downloading file:', error)
|
||||
} finally {
|
||||
setDownloadingFileId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (file: WorkspaceFileRecord) => {
|
||||
if (!workspaceId) return
|
||||
|
||||
try {
|
||||
await deleteFile.mutateAsync({
|
||||
workspaceId,
|
||||
fileId: file.id,
|
||||
fileSize: file.size,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error deleting file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | string): string => {
|
||||
const d = new Date(date)
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const yy = String(d.getFullYear()).slice(2)
|
||||
return `${mm}/${dd}/${yy}`
|
||||
}
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
if (!search) return files
|
||||
const q = search.toLowerCase()
|
||||
return files.filter((f) => f.name.toLowerCase().includes(q))
|
||||
}, [files, search])
|
||||
|
||||
const truncateMiddle = (text: string, start = 24, end = 12) => {
|
||||
if (!text) return ''
|
||||
if (text.length <= start + end + 3) return text
|
||||
return `${text.slice(0, start)}...${text.slice(-end)}`
|
||||
}
|
||||
|
||||
const formatStorageSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
const planName = storageInfo?.plan || 'free'
|
||||
const displayPlanName = PLAN_NAMES[planName as keyof typeof PLAN_NAMES] || 'Free'
|
||||
|
||||
const renderTableSkeleton = () => (
|
||||
<Table className='table-fixed text-[13px]'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-[56%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
|
||||
<Skeleton className='h-[12px] w-[40px]' />
|
||||
</TableHead>
|
||||
<TableHead className='w-[14%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
|
||||
<Skeleton className='h-[12px] w-[28px]' />
|
||||
</TableHead>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
|
||||
<Skeleton className='h-[12px] w-[56px]' />
|
||||
</TableHead>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
|
||||
<Skeleton className='h-[12px] w-[48px]' />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<TableRow key={i} className='hover:bg-transparent'>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[180px]' />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px]'>
|
||||
<Skeleton className='h-[12px] w-[48px]' />
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px]'>
|
||||
<Skeleton className='h-[12px] w-[56px]' />
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Skeleton className='h-[28px] w-[28px] rounded-[4px]' />
|
||||
<Skeleton className='h-[28px] w-[28px] rounded-[4px]' />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-auto bg-white px-[24px] pt-[28px] pb-[24px] dark:bg-[var(--bg)]'>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#6B7280] bg-[#F3F4F6] dark:border-[#4B5563] dark:bg-[#1F2937]'>
|
||||
<FilesIcon className='h-[14px] w-[14px] text-[#6B7280] dark:text-[#9CA3AF]' />
|
||||
</div>
|
||||
<h1 className='font-medium text-[18px]'>Files</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
|
||||
Upload and manage workspace files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search + Upload */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-4)] px-[8px]'>
|
||||
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
<Input
|
||||
placeholder='Search'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={permissionsLoading}
|
||||
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{(permissionsLoading || userPermissions.canEdit) && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
className='hidden'
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading || permissionsLoading}
|
||||
accept={ACCEPT_ATTR}
|
||||
multiple
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUploadClick}
|
||||
disabled={uploading || permissionsLoading}
|
||||
variant='default'
|
||||
className='h-[32px] rounded-[6px]'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
{uploading && uploadProgress.total > 0
|
||||
? `${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: uploading
|
||||
? 'Uploading...'
|
||||
: 'Upload'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Table */}
|
||||
<div className='mt-[16px] min-h-0 flex-1'>
|
||||
{permissionsLoading ? (
|
||||
renderTableSkeleton()
|
||||
) : files.length === 0 && failedFiles.length === 0 ? (
|
||||
<div className='flex h-[200px] items-center justify-center text-[13px] text-[var(--text-muted)]'>
|
||||
No files uploaded yet
|
||||
</div>
|
||||
) : filteredFiles.length === 0 && failedFiles.length === 0 ? (
|
||||
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
|
||||
No files found matching "{search}"
|
||||
</div>
|
||||
) : (
|
||||
<Table className='table-fixed text-[13px]'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-[56%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
|
||||
Name
|
||||
</TableHead>
|
||||
<TableHead className='w-[14%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
|
||||
Size
|
||||
</TableHead>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
|
||||
Uploaded
|
||||
</TableHead>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[12px] text-[var(--text-secondary)]'>
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{failedFiles.map((fileName, index) => {
|
||||
const Icon = getDocumentIcon('', fileName)
|
||||
return (
|
||||
<TableRow
|
||||
key={`failed-${fileName}-${index}`}
|
||||
className='hover:bg-[var(--surface-2)]'
|
||||
>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
<Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-error)]' />
|
||||
<span
|
||||
className='min-w-0 truncate text-[14px] text-[var(--text-error)]'
|
||||
title={fileName}
|
||||
>
|
||||
{truncateMiddle(fileName)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px] text-[var(--text-error)]'>
|
||||
—
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px] text-[var(--text-error)]'>
|
||||
—
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() =>
|
||||
setFailedFiles((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
className='h-[28px] w-[28px] p-0'
|
||||
aria-label={`Dismiss ${fileName}`}
|
||||
>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{filteredFiles.map((file) => {
|
||||
const Icon = getDocumentIcon(file.type || '', file.name)
|
||||
return (
|
||||
<TableRow key={file.id} className='hover:bg-[var(--surface-2)]'>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
<Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
|
||||
<button
|
||||
onClick={() => handleDownload(file)}
|
||||
disabled={downloadingFileId === file.id}
|
||||
className='min-w-0 truncate text-left font-normal text-[14px] text-[var(--text-primary)] hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50'
|
||||
title={file.name}
|
||||
>
|
||||
{truncateMiddle(file.name)}
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
|
||||
{formatFileSize(file.size)}
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
|
||||
{formatDate(file.uploadedAt)}
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleDownload(file)}
|
||||
className='h-[28px] w-[28px] p-0'
|
||||
disabled={downloadingFileId === file.id}
|
||||
aria-label={`Download ${file.name}`}
|
||||
>
|
||||
{downloadingFileId === file.id ? (
|
||||
<Loader2 className='h-[14px] w-[14px] animate-spin' />
|
||||
) : (
|
||||
<ArrowDown className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Download file</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{userPermissions.canEdit && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleDelete(file)}
|
||||
className='h-[28px] w-[28px] p-0'
|
||||
disabled={deleteFile.isPending}
|
||||
aria-label={`Delete ${file.name}`}
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Delete file</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Storage Info */}
|
||||
{isBillingEnabled &&
|
||||
(permissionsLoading ? (
|
||||
<div className='flex flex-col gap-[8px] pt-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[14px] w-[32px] rounded-[2px]' />
|
||||
<div className='h-[14px] w-[1.5px] bg-[var(--divider)]' />
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Skeleton className='h-[12px] w-[40px] rounded-[2px]' />
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
/
|
||||
</span>
|
||||
<Skeleton className='h-[12px] w-[32px] rounded-[2px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-[3px]'>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[6px] flex-1 rounded-[2px]' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
storageInfo && (
|
||||
<div className='flex flex-col gap-[8px] pt-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
{displayPlanName}
|
||||
</span>
|
||||
<div className='h-[14px] w-[1.5px] bg-[var(--divider)]' />
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'>
|
||||
{formatStorageSize(storageInfo.usedBytes)}
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
/
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'>
|
||||
{formatStorageSize(storageInfo.limitBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-[3px]'>
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const filledCount = Math.ceil(
|
||||
(Math.min(storageInfo.percentUsed, 100) / 100) * 12
|
||||
)
|
||||
const isFilled = i < filledCount
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-[6px] flex-1 rounded-[2px]',
|
||||
isFilled ? 'bg-[var(--brand-secondary)]' : 'bg-[var(--surface-5)]'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex items-center gap-3 border-b border-[var(--border)] px-6 py-4'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-md bg-[var(--surface-3)]'>
|
||||
<FilesIcon className='h-4 w-4 text-[var(--text-secondary)]' />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className='text-base font-medium text-[var(--text-primary)]'>Files</h1>
|
||||
<p className='text-xs text-[var(--text-muted)]'>
|
||||
Workspace files accessible across all workflows
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-1 overflow-hidden px-6 py-4'>
|
||||
<Files />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -281,6 +281,7 @@ const SERVER_TOOLS = new Set<string>([
|
||||
'run_workflow_until_block',
|
||||
'run_block',
|
||||
'run_from_block',
|
||||
'workspace_file',
|
||||
])
|
||||
|
||||
const SIM_WORKFLOW_TOOL_HANDLERS: Record<
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
|
||||
import {
|
||||
downloadWorkspaceFile,
|
||||
listWorkspaceFiles,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { getOrMaterializeVFS } from '@/lib/copilot/vfs'
|
||||
|
||||
const logger = createLogger('VfsTools')
|
||||
@@ -97,6 +101,14 @@ export async function executeVfsRead(
|
||||
params.limit as number | undefined
|
||||
)
|
||||
if (!result) {
|
||||
// Dynamic content fetch for workspace files: read("files/lit-rock.json")
|
||||
// resolves to the actual file content from storage.
|
||||
const fileContent = await tryReadWorkspaceFile(path, workspaceId)
|
||||
if (fileContent) {
|
||||
logger.debug('vfs_read resolved workspace file', { path, totalLines: fileContent.totalLines })
|
||||
return { success: true, output: fileContent }
|
||||
}
|
||||
|
||||
const suggestions = vfs.suggestSimilar(path)
|
||||
logger.warn('vfs_read file not found', { path, suggestions })
|
||||
const hint =
|
||||
@@ -116,6 +128,67 @@ export async function executeVfsRead(
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_FILE_READ_BYTES = 512 * 1024 // 512 KB
|
||||
|
||||
const TEXT_TYPES = new Set([
|
||||
'text/plain', 'text/csv', 'text/markdown', 'text/html', 'text/xml',
|
||||
'application/json', 'application/xml', 'application/javascript',
|
||||
])
|
||||
|
||||
function isReadableType(contentType: string): boolean {
|
||||
return TEXT_TYPES.has(contentType) || contentType.startsWith('text/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a VFS path like "files/lit-rock.json" to actual workspace file content.
|
||||
* Matches by original filename against the workspace_files table.
|
||||
*/
|
||||
async function tryReadWorkspaceFile(
|
||||
path: string,
|
||||
workspaceId: string
|
||||
): Promise<{ content: string; totalLines: number } | null> {
|
||||
// Match "files/{name}" or "files/{name}/content" patterns
|
||||
const match = path.match(/^files\/(.+?)(?:\/content)?$/)
|
||||
if (!match) return null
|
||||
const fileName = match[1]
|
||||
|
||||
// Skip if it's a meta.json path (handled by normal VFS)
|
||||
if (fileName.endsWith('/meta.json') || path.endsWith('/meta.json')) return null
|
||||
|
||||
try {
|
||||
const files = await listWorkspaceFiles(workspaceId)
|
||||
const record = files.find(
|
||||
(f) => f.name === fileName || f.name.normalize('NFC') === fileName.normalize('NFC')
|
||||
)
|
||||
if (!record) return null
|
||||
|
||||
if (!isReadableType(record.type)) {
|
||||
return {
|
||||
content: `[Binary file: ${record.name} (${record.type}, ${record.size} bytes). Cannot display as text.]`,
|
||||
totalLines: 1,
|
||||
}
|
||||
}
|
||||
|
||||
if (record.size > MAX_FILE_READ_BYTES) {
|
||||
return {
|
||||
content: `[File too large to display inline: ${record.name} (${record.size} bytes, limit ${MAX_FILE_READ_BYTES}). Use workspace_file read with fileId "${record.id}" to read it.]`,
|
||||
totalLines: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = await downloadWorkspaceFile(record)
|
||||
const content = buffer.toString('utf-8')
|
||||
return { content, totalLines: content.split('\n').length }
|
||||
} catch (err) {
|
||||
logger.warn('Failed to read workspace file content', {
|
||||
path,
|
||||
fileName,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeVfsList(
|
||||
params: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
|
||||
120
apps/sim/lib/copilot/tools/server/files/workspace-file.ts
Normal file
120
apps/sim/lib/copilot/tools/server/files/workspace-file.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
|
||||
import type { WorkspaceFileArgs, WorkspaceFileResult } from '@/lib/copilot/tools/shared/schemas'
|
||||
import {
|
||||
deleteWorkspaceFile,
|
||||
getWorkspaceFile,
|
||||
uploadWorkspaceFile,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
|
||||
const logger = createLogger('WorkspaceFileServerTool')
|
||||
|
||||
export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, WorkspaceFileResult> = {
|
||||
name: 'workspace_file',
|
||||
async execute(
|
||||
params: WorkspaceFileArgs,
|
||||
context?: ServerToolContext
|
||||
): Promise<WorkspaceFileResult> {
|
||||
if (!context?.userId) {
|
||||
logger.error('Unauthorized attempt to access workspace files')
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
const { operation, args = {} } = params
|
||||
const workspaceId =
|
||||
context.workspaceId || ((args as Record<string, unknown>).workspaceId as string | undefined)
|
||||
|
||||
if (!workspaceId) {
|
||||
return { success: false, message: 'Workspace ID is required' }
|
||||
}
|
||||
|
||||
try {
|
||||
switch (operation) {
|
||||
case 'write': {
|
||||
const fileName = (args as Record<string, unknown>).fileName as string | undefined
|
||||
const content = (args as Record<string, unknown>).content as string | undefined
|
||||
const contentType = ((args as Record<string, unknown>).contentType as string) || 'text/plain'
|
||||
|
||||
if (!fileName) {
|
||||
return { success: false, message: 'fileName is required for write operation' }
|
||||
}
|
||||
if (content === undefined || content === null) {
|
||||
return { success: false, message: 'content is required for write operation' }
|
||||
}
|
||||
|
||||
const fileBuffer = Buffer.from(content, 'utf-8')
|
||||
const result = await uploadWorkspaceFile(
|
||||
workspaceId,
|
||||
context.userId,
|
||||
fileBuffer,
|
||||
fileName,
|
||||
contentType
|
||||
)
|
||||
|
||||
logger.info('Workspace file written via copilot', {
|
||||
fileId: result.id,
|
||||
name: fileName,
|
||||
size: fileBuffer.length,
|
||||
contentType,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `File "${fileName}" created successfully (${fileBuffer.length} bytes)`,
|
||||
data: {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
contentType,
|
||||
size: fileBuffer.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const fileId = (args as Record<string, unknown>).fileId as string | undefined
|
||||
if (!fileId) {
|
||||
return { success: false, message: 'fileId is required for delete operation' }
|
||||
}
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!fileRecord) {
|
||||
return { success: false, message: `File with ID "${fileId}" not found` }
|
||||
}
|
||||
|
||||
await deleteWorkspaceFile(workspaceId, fileId)
|
||||
|
||||
logger.info('Workspace file deleted via copilot', {
|
||||
fileId,
|
||||
name: fileRecord.name,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `File "${fileRecord.name}" deleted successfully`,
|
||||
data: { id: fileId, name: fileRecord.name },
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
message: `Unknown operation: ${operation}. Supported: write, delete. Use the filesystem to list/read files.`,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error('Error in workspace_file tool', {
|
||||
operation,
|
||||
error: errorMessage,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to ${operation} file: ${errorMessage}`,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
createKnowledgeBase,
|
||||
deleteKnowledgeBase,
|
||||
getKnowledgeBaseById,
|
||||
getKnowledgeBases,
|
||||
updateKnowledgeBase,
|
||||
} from '@/lib/knowledge/service'
|
||||
import {
|
||||
@@ -92,31 +91,6 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
|
||||
}
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
const knowledgeBases = await getKnowledgeBases(context.userId, args.workspaceId)
|
||||
|
||||
logger.info('Knowledge bases listed via copilot', {
|
||||
count: knowledgeBases.length,
|
||||
userId: context.userId,
|
||||
workspaceId: args.workspaceId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Found ${knowledgeBases.length} knowledge base(s)`,
|
||||
data: knowledgeBases.map((kb) => ({
|
||||
id: kb.id,
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
workspaceId: kb.workspaceId,
|
||||
docCount: kb.docCount,
|
||||
tokenCount: kb.tokenCount,
|
||||
createdAt: kb.createdAt,
|
||||
updatedAt: kb.updatedAt,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
case 'get': {
|
||||
if (!args.knowledgeBaseId) {
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/s
|
||||
import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base'
|
||||
import { makeApiRequestServerTool } from '@/lib/copilot/tools/server/other/make-api-request'
|
||||
import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online'
|
||||
import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/workspace-file'
|
||||
import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table'
|
||||
import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials'
|
||||
import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables'
|
||||
@@ -31,6 +32,7 @@ const serverToolRegistry: Record<string, BaseServerTool> = {
|
||||
[makeApiRequestServerTool.name]: makeApiRequestServerTool,
|
||||
[knowledgeBaseServerTool.name]: knowledgeBaseServerTool,
|
||||
[userTableServerTool.name]: userTableServerTool,
|
||||
[workspaceFileServerTool.name]: workspaceFileServerTool,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getRowById,
|
||||
getTableById,
|
||||
insertRow,
|
||||
listTables,
|
||||
queryRows,
|
||||
updateRow,
|
||||
updateRowsByFilter,
|
||||
@@ -62,19 +61,6 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
|
||||
}
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
if (!workspaceId) {
|
||||
return { success: false, message: 'Workspace ID is required' }
|
||||
}
|
||||
|
||||
const tables = await listTables(workspaceId)
|
||||
return {
|
||||
success: true,
|
||||
message: `Found ${tables.length} table(s)`,
|
||||
data: { tables, totalCount: tables.length },
|
||||
}
|
||||
}
|
||||
|
||||
case 'get': {
|
||||
if (!args.tableId) {
|
||||
return { success: false, message: 'Table ID is required' }
|
||||
|
||||
@@ -23,7 +23,6 @@ export type GetTriggerBlocksResultType = z.infer<typeof GetTriggerBlocksResult>
|
||||
export const KnowledgeBaseArgsSchema = z.object({
|
||||
operation: z.enum([
|
||||
'create',
|
||||
'list',
|
||||
'get',
|
||||
'query',
|
||||
'update',
|
||||
@@ -78,7 +77,6 @@ export type KnowledgeBaseResult = z.infer<typeof KnowledgeBaseResultSchema>
|
||||
export const UserTableArgsSchema = z.object({
|
||||
operation: z.enum([
|
||||
'create',
|
||||
'list',
|
||||
'get',
|
||||
'get_schema',
|
||||
'delete',
|
||||
@@ -116,6 +114,28 @@ export const UserTableResultSchema = z.object({
|
||||
})
|
||||
export type UserTableResult = z.infer<typeof UserTableResultSchema>
|
||||
|
||||
// workspace_file - shared schema used by server tool and Go catalog
|
||||
export const WorkspaceFileArgsSchema = z.object({
|
||||
operation: z.enum(['write', 'delete']),
|
||||
args: z
|
||||
.object({
|
||||
fileId: z.string().optional(),
|
||||
fileName: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
contentType: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
export type WorkspaceFileArgs = z.infer<typeof WorkspaceFileArgsSchema>
|
||||
|
||||
export const WorkspaceFileResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
data: z.any().optional(),
|
||||
})
|
||||
export type WorkspaceFileResult = z.infer<typeof WorkspaceFileResultSchema>
|
||||
|
||||
export const GetBlockOutputsInput = z.object({
|
||||
blockIds: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
@@ -201,7 +201,22 @@ export function read(
|
||||
offset?: number,
|
||||
limit?: number
|
||||
): ReadResult | null {
|
||||
const content = files.get(path)
|
||||
let content = files.get(path)
|
||||
|
||||
// Fallback: normalize Unicode and retry for encoding mismatches
|
||||
if (content === undefined) {
|
||||
const normalized = path.normalize('NFC')
|
||||
content = files.get(normalized)
|
||||
if (content === undefined) {
|
||||
for (const [key, value] of files) {
|
||||
if (key.normalize('NFC') === normalized) {
|
||||
content = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content === undefined) return null
|
||||
|
||||
const lines = content.split('\n')
|
||||
|
||||
@@ -126,6 +126,29 @@ export function serializeDocuments(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize workspace file metadata for VFS files/{name}/meta.json
|
||||
*/
|
||||
export function serializeFileMeta(file: {
|
||||
id: string
|
||||
name: string
|
||||
contentType: string
|
||||
size: number
|
||||
uploadedAt: Date
|
||||
}): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
contentType: file.contentType,
|
||||
size: file.size,
|
||||
uploadedAt: file.uploadedAt.toISOString(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize table metadata for VFS tables/{name}/meta.json
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
workflowMcpServer,
|
||||
workflowMcpTool,
|
||||
workspaceEnvironment,
|
||||
workspaceFiles,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, count, desc, eq, isNull } from 'drizzle-orm'
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
serializeDeployments,
|
||||
serializeDocuments,
|
||||
serializeEnvironmentVariables,
|
||||
serializeFileMeta,
|
||||
serializeIntegrationSchema,
|
||||
serializeKBMeta,
|
||||
serializeRecentExecutions,
|
||||
@@ -189,6 +191,7 @@ function getStaticComponentFiles(): Map<string, string> {
|
||||
* knowledgebases/{name}/meta.json
|
||||
* knowledgebases/{name}/documents.json
|
||||
* tables/{name}/meta.json
|
||||
* files/{name}/meta.json
|
||||
* custom-tools/{name}.json
|
||||
* environment/credentials.json
|
||||
* environment/api-keys.json
|
||||
@@ -211,6 +214,7 @@ export class WorkspaceVFS {
|
||||
this.materializeWorkflows(workspaceId, userId),
|
||||
this.materializeKnowledgeBases(workspaceId),
|
||||
this.materializeTables(workspaceId),
|
||||
this.materializeFiles(workspaceId),
|
||||
this.materializeEnvironment(workspaceId, userId),
|
||||
this.materializeCustomTools(workspaceId),
|
||||
])
|
||||
@@ -451,6 +455,43 @@ export class WorkspaceVFS {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize all workspace files (metadata only, no file content).
|
||||
*/
|
||||
private async materializeFiles(workspaceId: string): Promise<void> {
|
||||
try {
|
||||
const fileRows = await db
|
||||
.select({
|
||||
id: workspaceFiles.id,
|
||||
originalName: workspaceFiles.originalName,
|
||||
contentType: workspaceFiles.contentType,
|
||||
size: workspaceFiles.size,
|
||||
uploadedAt: workspaceFiles.uploadedAt,
|
||||
})
|
||||
.from(workspaceFiles)
|
||||
.where(eq(workspaceFiles.workspaceId, workspaceId))
|
||||
|
||||
for (const file of fileRows) {
|
||||
const safeName = sanitizeName(file.originalName)
|
||||
this.files.set(
|
||||
`files/${safeName}/meta.json`,
|
||||
serializeFileMeta({
|
||||
id: file.id,
|
||||
name: file.originalName,
|
||||
contentType: file.contentType,
|
||||
size: file.size,
|
||||
uploadedAt: file.uploadedAt,
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to materialize files', {
|
||||
workspaceId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all deployment configurations for a single workflow.
|
||||
* Returns null if the workflow has no deployments of any kind.
|
||||
@@ -689,9 +730,14 @@ export async function getOrMaterializeVFS(
|
||||
|
||||
/**
|
||||
* Sanitize a name for use as a VFS path segment.
|
||||
* Uses the raw name as-is — only trims whitespace and replaces forward
|
||||
* slashes (which would break path hierarchy).
|
||||
* Normalizes Unicode to NFC, collapses whitespace, strips control
|
||||
* characters, and replaces forward slashes (path separators).
|
||||
*/
|
||||
function sanitizeName(name: string): string {
|
||||
return name.trim().replace(/\//g, '-')
|
||||
return name
|
||||
.normalize('NFC')
|
||||
.trim()
|
||||
.replace(/[\x00-\x1f\x7f]/g, '')
|
||||
.replace(/\//g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ workflows/{name}/
|
||||
knowledgebases/{name}/
|
||||
meta.json — KB identity, embedding config, stats
|
||||
documents.json — document metadata
|
||||
files/{name}/
|
||||
meta.json — uploaded file metadata (name, type, size)
|
||||
custom-tools/{name}.json — custom tool schema + code preview
|
||||
environment/
|
||||
credentials.json — connected OAuth providers
|
||||
@@ -96,7 +98,7 @@ Two context files are auto-injected into your system prompt above. You MUST keep
|
||||
### WORKSPACE.md — You MUST keep this current
|
||||
|
||||
**On your FIRST turn**: if Workspace Context above shows "(none discovered yet)", scan the workspace immediately:
|
||||
1. Run \`glob("workflows/*/meta.json")\`, \`glob("knowledgebases/*/meta.json")\`, \`glob("tables/*/meta.json")\`, \`read("environment/credentials.json")\`
|
||||
1. Run \`glob("workflows/*/meta.json")\`, \`glob("knowledgebases/*/meta.json")\`, \`glob("tables/*/meta.json")\`, \`glob("files/*/meta.json")\`, \`read("environment/credentials.json")\`
|
||||
2. Write the results via \`context_write("WORKSPACE.md", content)\`
|
||||
|
||||
Do this silently as your first action — do NOT ask the user for permission.
|
||||
|
||||
Reference in New Issue
Block a user