File creation

This commit is contained in:
Siddharth Ganesan
2026-02-25 19:23:24 -08:00
parent 3cb1768a44
commit 3ad7af4b97
12 changed files with 324 additions and 572 deletions

View File

@@ -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 &quot;{search}&quot;
</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>
)

View File

@@ -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<

View File

@@ -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

View 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}`,
}
}
},
}

View File

@@ -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 {

View File

@@ -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,
}
/**

View File

@@ -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' }

View File

@@ -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(),
})

View File

@@ -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')

View File

@@ -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
*/

View File

@@ -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, ' ')
}

View File

@@ -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.