From 704d06fcfd1d8a281e25998f8df6cfe8ed5b9cfb Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 12:21:15 -0700 Subject: [PATCH 01/30] v0 --- .../app/api/files/serve/[...path]/route.ts | 58 +++++- .../components/file-viewer/file-viewer.tsx | 187 +++++++++++++++++- .../components/file-viewer/preview-panel.tsx | 1 + .../resource-content/resource-content.tsx | 46 +++-- .../[workspaceId]/home/hooks/use-chat.ts | 10 +- .../w/[workflowId]/components/panel/panel.tsx | 36 ++-- apps/sim/hooks/queries/workspace-files.ts | 53 ++++- .../tools/server/files/workspace-file.ts | 141 ++++++++++++- apps/sim/lib/copilot/tools/shared/schemas.ts | 6 +- apps/sim/lib/copilot/vfs/file-reader.ts | 25 ++- .../contexts/copilot/copilot-file-manager.ts | 2 + .../workspace/workspace-file-manager.ts | 10 +- apps/sim/package.json | 5 +- bun.lock | 17 ++ 14 files changed, 524 insertions(+), 73 deletions(-) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 94d882a86e8..58a455c0d8c 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generatePptxFromCode } from '@/lib/copilot/tools/server/files/workspace-file' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' @@ -18,6 +19,27 @@ import { const logger = createLogger('FilesServeAPI') +const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]) + +async function compilePptxIfNeeded( + buffer: Buffer, + filename: string, + workspaceId?: string, + raw?: boolean +): Promise<{ buffer: Buffer; contentType: string }> { + const isPptx = filename.toLowerCase().endsWith('.pptx') + if (raw || !isPptx || buffer.subarray(0, 4).equals(ZIP_MAGIC)) { + return { buffer, contentType: getContentType(filename) } + } + + const code = buffer.toString('utf-8') + const compiled = await generatePptxFromCode(code, workspaceId || '') + return { + buffer: compiled, + contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + } +} + const STORAGE_KEY_PREFIX_RE = /^\d{13}-[a-z0-9]{7}-/ function stripStorageKeyPrefix(segment: string): string { @@ -44,6 +66,7 @@ export async function GET( const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath const contextParam = request.nextUrl.searchParams.get('context') + const raw = request.nextUrl.searchParams.get('raw') === '1' const context = contextParam || (isCloudPath ? inferContextFromKey(cloudKey) : undefined) @@ -68,10 +91,10 @@ export async function GET( const userId = authResult.userId if (isUsingCloudStorage()) { - return await handleCloudProxy(cloudKey, userId, contextParam) + return await handleCloudProxy(cloudKey, userId, contextParam, raw) } - return await handleLocalFile(cloudKey, userId) + return await handleLocalFile(cloudKey, userId, raw) } catch (error) { logger.error('Error serving file:', error) @@ -83,7 +106,11 @@ export async function GET( } } -async function handleLocalFile(filename: string, userId: string): Promise { +async function handleLocalFile( + filename: string, + userId: string, + raw: boolean +): Promise { try { const contextParam: StorageContext | undefined = inferContextFromKey(filename) as | StorageContext @@ -108,10 +135,15 @@ async function handleLocalFile(filename: string, userId: string): Promise { try { let context: StorageContext @@ -156,12 +189,12 @@ async function handleCloudProxy( throw new FileNotFoundError(`File not found: ${cloudKey}`) } - let fileBuffer: Buffer + let rawBuffer: Buffer if (context === 'copilot') { - fileBuffer = await CopilotFiles.downloadCopilotFile(cloudKey) + rawBuffer = await CopilotFiles.downloadCopilotFile(cloudKey) } else { - fileBuffer = await downloadFile({ + rawBuffer = await downloadFile({ key: cloudKey, context, }) @@ -169,7 +202,12 @@ async function handleCloudProxy( const segment = cloudKey.split('/').pop() || 'download' const displayName = stripStorageKeyPrefix(segment) - const contentType = getContentType(displayName) + const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded( + rawBuffer, + displayName, + undefined, + raw + ) logger.info('Cloud file served', { userId, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index a0894b41a94..132ed82529e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -8,6 +8,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { useUpdateWorkspaceFileContent, + useWorkspaceFileBinary, useWorkspaceFileContent, } from '@/hooks/queries/workspace-files' import { useAutosave } from '@/hooks/use-autosave' @@ -48,17 +49,29 @@ const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf']) const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']) -type FileCategory = 'text-editable' | 'iframe-previewable' | 'image-previewable' | 'unsupported' +const PPTX_PREVIEWABLE_MIME_TYPES = new Set([ + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', +]) +const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx']) + +type FileCategory = + | 'text-editable' + | 'iframe-previewable' + | 'image-previewable' + | 'pptx-previewable' + | 'unsupported' function resolveFileCategory(mimeType: string | null, filename: string): FileCategory { if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable' if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable' if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable' + if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable' const ext = getFileExtension(filename) if (TEXT_EDITABLE_EXTENSIONS.has(ext)) return 'text-editable' if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable' if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable' + if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable' return 'unsupported' } @@ -124,6 +137,10 @@ export function FileViewer({ return } + if (category === 'pptx-previewable') { + return + } + return } @@ -163,7 +180,12 @@ function TextEditor({ isLoading, error, dataUpdatedAt, - } = useWorkspaceFileContent(workspaceId, file.id, file.key) + } = useWorkspaceFileContent( + workspaceId, + file.id, + file.key, + file.type === 'text/x-pptxgenjs' + ) const updateContent = useUpdateWorkspaceFileContent() @@ -417,6 +439,167 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) { ) } +function PptxPreview({ + file, + workspaceId, + streamingContent, +}: { + file: WorkspaceFileRecord + workspaceId: string + streamingContent?: string +}) { + const { + data: fileData, + isLoading: isFetching, + error: fetchError, + dataUpdatedAt, + } = useWorkspaceFileBinary(workspaceId, file.id, file.key) + + const [slides, setSlides] = useState([]) + const [rendering, setRendering] = useState(false) + const [renderError, setRenderError] = useState(null) + + useEffect(() => { + let cancelled = false + + async function render() { + try { + setRendering(true) + setRenderError(null) + + if (streamingContent !== undefined) { + const PptxGenJS = (await import('pptxgenjs')).default + const pptx = new PptxGenJS() + const fn = new Function('pptx', `return (async () => { ${streamingContent} })()`) + await fn(pptx) + const arrayBuffer = (await pptx.write({ outputType: 'arraybuffer' })) as ArrayBuffer + if (cancelled) return + const { PPTXViewer } = await import('pptxviewjs') + const data = new Uint8Array(arrayBuffer) + const probe = document.createElement('canvas') + const probeViewer = new PPTXViewer({ canvas: probe }) + await probeViewer.loadFile(data) + const count = probeViewer.getSlideCount() + if (cancelled || count === 0) return + const dpr = window.devicePixelRatio || 1 + const W = Math.round(1920 * dpr) + const H = Math.round(1080 * dpr) + const images: string[] = [] + for (let i = 0; i < count; i++) { + if (cancelled) break + const canvas = document.createElement('canvas') + canvas.width = W + canvas.height = H + const viewer = new PPTXViewer({ canvas }) + await viewer.loadFile(data) + if (i > 0) await viewer.goToSlide(i) + else await viewer.render() + images.push(canvas.toDataURL('image/png')) + } + if (!cancelled) setSlides(images) + return + } + + if (!fileData) return + const { PPTXViewer } = await import('pptxviewjs') + if (cancelled) return + + const data = new Uint8Array(fileData!) + const probe = document.createElement('canvas') + const probeViewer = new PPTXViewer({ canvas: probe }) + await probeViewer.loadFile(data) + const count = probeViewer.getSlideCount() + if (cancelled || count === 0) return + + const dpr = window.devicePixelRatio || 1 + const W = Math.round(1920 * dpr) + const H = Math.round(1080 * dpr) + const images: string[] = [] + + for (let i = 0; i < count; i++) { + if (cancelled) break + const canvas = document.createElement('canvas') + canvas.width = W + canvas.height = H + const viewer = new PPTXViewer({ canvas }) + await viewer.loadFile(data) + if (i > 0) await viewer.goToSlide(i) + else await viewer.render() + images.push(canvas.toDataURL('image/png')) + } + + if (!cancelled) setSlides(images) + } catch (err) { + if (!cancelled) { + const msg = err instanceof Error ? err.message : 'Failed to render presentation' + logger.error('PPTX render failed', { error: msg }) + setRenderError(msg) + } + } finally { + if (!cancelled) setRendering(false) + } + } + + render() + return () => { + cancelled = true + } + }, [fileData, dataUpdatedAt, streamingContent]) + + const error = fetchError + ? fetchError instanceof Error + ? fetchError.message + : 'Failed to load file' + : renderError + const loading = isFetching || rendering + + if (error) { + return ( +
+

+ Failed to preview presentation +

+

{error}

+
+ ) + } + + if (loading && slides.length === 0) { + return ( +
+
+
+

Loading presentation...

+
+
+ ) + } + + return ( +
+
+ {slides.map((src, i) => ( + {`Slide + ))} +
+
+ ) +} + function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) { const ext = getFileExtension(file.name) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 0a7be9495ef..6cb4c19c6d9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -327,3 +327,4 @@ function parseCsvLine(line: string, delimiter: string): string[] { fields.push(current.trim()) return fields } + diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 472bc6ca0cb..93c8176c71a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -10,7 +10,7 @@ import { markRunToolManuallyStopped, reportManualRunToolStop, } from '@/lib/copilot/client-sse/run-tool-execution' -import { downloadWorkspaceFile } from '@/lib/uploads/utils/file-utils' +import { downloadWorkspaceFile, getMimeTypeFromExtension, getFileExtension } from '@/lib/uploads/utils/file-utils' import { FileViewer, type PreviewMode, @@ -64,35 +64,43 @@ export const ResourceContent = memo(function ResourceContent({ streamingFile, }: ResourceContentProps) { const streamFileName = streamingFile?.fileName || 'file.md' - const streamingExtractedContent = useMemo( - () => (streamingFile ? extractFileContent(streamingFile.content) : ''), - [streamingFile] - ) - const syntheticFile = useMemo( - () => ({ + const streamingExtractedContent = useMemo(() => { + if (!streamingFile) return undefined + const extracted = extractFileContent(streamingFile.content) + return extracted.length > 0 ? extracted : undefined + }, [streamingFile]) + const syntheticFile = useMemo(() => { + const ext = getFileExtension(streamFileName) + const type = ext === 'pptx' ? 'text/x-pptxgenjs' : getMimeTypeFromExtension(ext) + return { id: 'streaming-file', workspaceId, name: streamFileName, key: '', path: '', size: 0, - type: 'text/plain', + type, uploadedBy: '', uploadedAt: STREAMING_EPOCH, - }), - [workspaceId, streamFileName] - ) + } + }, [workspaceId, streamFileName]) if (streamingFile && resource.id === 'streaming-file') { return (
- + {streamingExtractedContent !== undefined ? ( + + ) : ( +
+

Processing file...

+
+ )}
) } @@ -108,7 +116,7 @@ export const ResourceContent = memo(function ResourceContent({ workspaceId={workspaceId} fileId={resource.id} previewMode={previewMode} - streamingContent={streamingFile ? extractFileContent(streamingFile.content) : undefined} + streamingContent={streamingExtractedContent} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 048634f080a..0bd9551c8f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -987,15 +987,19 @@ export function useChat( onResourceEventRef.current?.() if (resource.type === 'workflow') { + const registry = useWorkflowRegistry.getState() const wasRegistered = ensureWorkflowInRegistry( resource.id, resource.title, workspaceId ) if (wasAdded && wasRegistered) { - useWorkflowRegistry.getState().setActiveWorkflow(resource.id) - } else { - useWorkflowRegistry.getState().loadWorkflowState(resource.id) + registry.setActiveWorkflow(resource.id) + } else if ( + registry.activeWorkflowId !== resource.id || + registry.hydration.phase !== 'ready' + ) { + registry.loadWorkflowState(resource.id) } } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index baf45ada2ff..cdeb54a94a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -75,6 +75,7 @@ import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { getWorkflowWithValues } from '@/stores/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('Panel') /** @@ -290,18 +291,29 @@ export const Panel = memo(function Panel() { [copilotChatId, loadCopilotChats] ) - const onToolResult = useCallback( - (toolName: string, success: boolean, _result: unknown) => { - if (toolName === 'edit_workflow' && success && activeWorkflowId) { - fetch(`/api/workflows/${activeWorkflowId}/state`) - .then((res) => (res.ok ? res.json() : null)) - .then((freshState) => { - if (freshState) { - useWorkflowDiffStore.getState().setProposedChanges(freshState) - } + const handleCopilotToolResult = useCallback( + (toolName: string, success: boolean, output: unknown) => { + if (toolName !== 'edit_workflow' || !success) return + const workflowId = activeWorkflowId || useWorkflowRegistry.getState().activeWorkflowId + if (!workflowId) return + + fetch(`/api/workflows/${workflowId}/state`) + .then((res) => { + if (!res.ok) throw new Error(`State fetch failed: ${res.status}`) + return res.json() + }) + .then((freshState) => { + const diffStore = useWorkflowDiffStore.getState() + return diffStore.setProposedChanges(freshState as WorkflowState, undefined, { + skipPersist: true, }) - .catch(() => {}) - } + }) + .catch((err) => { + logger.error('Failed to fetch/apply edit_workflow state', { + error: err instanceof Error ? err.message : String(err), + workflowId, + }) + }) }, [activeWorkflowId] ) @@ -320,8 +332,8 @@ export const Panel = memo(function Panel() { apiPath: '/api/copilot/chat', stopPath: '/api/mothership/chat/stop', workflowId: activeWorkflowId || undefined, - onToolResult, onTitleUpdate: loadCopilotChats, + onToolResult: handleCopilotToolResult, }) const handleCopilotNewChat = useCallback(() => { diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 2ac4d7b9d3e..1fe5349d74b 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -16,8 +16,11 @@ export const workspaceFilesKeys = { list: (workspaceId: string, scope: WorkspaceFileQueryScope = 'active') => [...workspaceFilesKeys.lists(), workspaceId, scope] as const, contents: () => [...workspaceFilesKeys.all, 'content'] as const, - content: (workspaceId: string, fileId: string) => - [...workspaceFilesKeys.contents(), workspaceId, fileId] as const, + content: ( + workspaceId: string, + fileId: string, + mode: 'text' | 'raw' | 'binary' = 'text' + ) => [...workspaceFilesKeys.contents(), workspaceId, fileId, mode] as const, storageInfo: () => [...workspaceFilesKeys.all, 'storageInfo'] as const, } @@ -66,8 +69,12 @@ export function useWorkspaceFiles(workspaceId: string, scope: WorkspaceFileQuery /** * Fetch file content as text via the serve URL */ -async function fetchWorkspaceFileContent(key: string, signal?: AbortSignal): Promise { - const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&t=${Date.now()}` +async function fetchWorkspaceFileContent( + key: string, + signal?: AbortSignal, + raw?: boolean +): Promise { + const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&t=${Date.now()}${raw ? '&raw=1' : ''}` const response = await fetch(serveUrl, { signal, cache: 'no-store' }) if (!response.ok) { @@ -80,10 +87,40 @@ async function fetchWorkspaceFileContent(key: string, signal?: AbortSignal): Pro /** * Hook to fetch workspace file content as text */ -export function useWorkspaceFileContent(workspaceId: string, fileId: string, key: string) { +export function useWorkspaceFileContent( + workspaceId: string, + fileId: string, + key: string, + raw?: boolean +) { return useQuery({ - queryKey: workspaceFilesKeys.content(workspaceId, fileId), - queryFn: ({ signal }) => fetchWorkspaceFileContent(key, signal), + queryKey: workspaceFilesKeys.content(workspaceId, fileId, raw ? 'raw' : 'text'), + queryFn: ({ signal }) => fetchWorkspaceFileContent(key, signal, raw), + enabled: !!workspaceId && !!fileId && !!key, + staleTime: 30 * 1000, + refetchOnWindowFocus: 'always', + }) +} + +async function fetchWorkspaceFileBinary( + key: string, + signal?: AbortSignal +): Promise { + const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&t=${Date.now()}` + const response = await fetch(serveUrl, { signal, cache: 'no-store' }) + if (!response.ok) throw new Error('Failed to fetch file content') + return response.arrayBuffer() +} + +/** + * Hook to fetch workspace file content as binary (ArrayBuffer). + * Shares the same query key as useWorkspaceFileContent so cache + * invalidation from file updates triggers a refetch automatically. + */ +export function useWorkspaceFileBinary(workspaceId: string, fileId: string, key: string) { + return useQuery({ + queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'binary'), + queryFn: ({ signal }) => fetchWorkspaceFileBinary(key, signal), enabled: !!workspaceId && !!fileId && !!key, staleTime: 30 * 1000, refetchOnWindowFocus: 'always', @@ -202,7 +239,7 @@ export function useUpdateWorkspaceFileContent() { }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ - queryKey: workspaceFilesKeys.content(variables.workspaceId, variables.fileId), + queryKey: [...workspaceFilesKeys.contents(), variables.workspaceId, variables.fileId], }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 75c6abe6110..ea73fab7f7c 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -1,8 +1,10 @@ +import PptxGenJS from 'pptxgenjs' 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, + downloadWorkspaceFile as downloadWsFile, getWorkspaceFile, renameWorkspaceFile, updateWorkspaceFileContent, @@ -11,12 +13,33 @@ import { const logger = createLogger('WorkspaceFileServerTool') +const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' +const PPTX_SOURCE_MIME = 'text/x-pptxgenjs' + const EXT_TO_MIME: Record = { '.txt': 'text/plain', '.md': 'text/markdown', '.html': 'text/html', '.json': 'application/json', '.csv': 'text/csv', + '.pptx': PPTX_MIME, +} + +export async function generatePptxFromCode(code: string, workspaceId: string): Promise { + const pptx = new PptxGenJS() + + async function getFileBase64(fileId: string): Promise { + const record = await getWorkspaceFile(workspaceId, fileId) + if (!record) throw new Error(`File not found: ${fileId}`) + const buffer = await downloadWsFile(record) + const mime = record.type || 'image/png' + return `data:${mime};base64,${buffer.toString('base64')}` + } + + const fn = new Function('pptx', 'getFileBase64', `return (async () => { ${code} })()`) + await fn(pptx, getFileBase64) + const output = await pptx.write({ outputType: 'nodebuffer' }) + return output as Buffer } function inferContentType(fileName: string, explicitType?: string): string { @@ -58,8 +81,28 @@ export const workspaceFileServerTool: BaseServerTool).fileId as string | undefined + const edits = (args as Record).edits as + | { search: string; replace: string }[] + | undefined + + if (!fileId) { + return { success: false, message: 'fileId is required for patch operation' } + } + if (!edits || !Array.isArray(edits) || edits.length === 0) { + return { success: false, message: 'edits array is required for patch operation' } + } + + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) { + return { success: false, message: `File with ID "${fileId}" not found` } + } + + const currentBuffer = await downloadWsFile(fileRecord) + let content = currentBuffer.toString('utf-8') + + for (const edit of edits) { + const idx = content.indexOf(edit.search) + if (idx === -1) { + return { + success: false, + message: `Patch failed: search string not found in file "${fileRecord.name}". Search: "${edit.search.slice(0, 100)}${edit.search.length > 100 ? '...' : ''}"`, + } + } + content = content.slice(0, idx) + edit.replace + content.slice(idx + edit.search.length) + } + + const isPptxPatch = fileRecord.name?.toLowerCase().endsWith('.pptx') + if (isPptxPatch) { + try { + await generatePptxFromCode(content, workspaceId) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { + success: false, + message: `Patched PPTX code failed to compile: ${msg}. Fix the edits and retry.`, + } + } + } + + const patchedBuffer = Buffer.from(content, 'utf-8') + await updateWorkspaceFileContent( + workspaceId, + fileId, + context.userId, + patchedBuffer, + isPptxPatch ? PPTX_SOURCE_MIME : undefined + ) + + logger.info('Workspace file patched via copilot', { + fileId, + name: fileRecord.name, + editCount: edits.length, + userId: context.userId, + }) + + return { + success: true, + message: `File "${fileRecord.name}" patched successfully (${edits.length} edit${edits.length > 1 ? 's' : ''} applied)`, + data: { + id: fileId, + name: fileRecord.name, + size: patchedBuffer.length, + }, + } + } + default: return { success: false, - message: `Unknown operation: ${operation}. Supported: write, update, rename, delete. Use the filesystem to list/read files.`, + message: `Unknown operation: ${operation}. Supported: write, update, patch, rename, delete.`, } } } catch (error) { diff --git a/apps/sim/lib/copilot/tools/shared/schemas.ts b/apps/sim/lib/copilot/tools/shared/schemas.ts index 26a2f391134..8fd717f1aac 100644 --- a/apps/sim/lib/copilot/tools/shared/schemas.ts +++ b/apps/sim/lib/copilot/tools/shared/schemas.ts @@ -173,7 +173,7 @@ export type UserTableResult = z.infer // workspace_file - shared schema used by server tool and Go catalog export const WorkspaceFileArgsSchema = z.object({ - operation: z.enum(['write', 'update', 'delete', 'rename']), + operation: z.enum(['write', 'update', 'delete', 'rename', 'patch']), args: z .object({ fileId: z.string().optional(), @@ -181,8 +181,10 @@ export const WorkspaceFileArgsSchema = z.object({ content: z.string().optional(), contentType: z.string().optional(), workspaceId: z.string().optional(), - /** New name for the file (required for rename operation) */ newName: z.string().optional(), + edits: z + .array(z.object({ search: z.string(), replace: z.string() })) + .optional(), }) .optional(), }) diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index b22e2395c7a..87e37fdc72d 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -14,6 +14,7 @@ const TEXT_TYPES = new Set([ 'text/markdown', 'text/html', 'text/xml', + 'text/x-pptxgenjs', 'application/json', 'application/xml', 'application/javascript', @@ -72,6 +73,19 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_TEXT_READ_BYTES) { + return { + content: `[File too large to display inline: ${record.name} (${record.size} bytes, limit ${MAX_TEXT_READ_BYTES})]`, + totalLines: 1, + } + } + + const buffer = await downloadWorkspaceFile(record) + const content = buffer.toString('utf-8') + return { content, totalLines: content.split('\n').length } + } + const ext = getExtension(record.name) if (PARSEABLE_EXTENSIONS.has(ext)) { const buffer = await downloadWorkspaceFile(record) @@ -100,16 +114,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_TEXT_READ_BYTES) { - return { - content: `[File too large to display inline: ${record.name} (${record.size} bytes, limit ${MAX_TEXT_READ_BYTES})]`, - totalLines: 1, - } - } - - const buffer = await downloadWorkspaceFile(record) - const content = buffer.toString('utf-8') - return { content, totalLines: content.split('\n').length } + return null } catch (err) { logger.warn('Failed to read workspace file', { fileName: record.name, diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index da809071c50..da61df38fce 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -27,6 +27,8 @@ const SUPPORTED_FILE_TYPES = [ 'application/json', 'application/xml', 'text/xml', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/x-pptxgenjs', ] /** diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 29460aad032..c3819cd1b34 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -520,7 +520,8 @@ export async function updateWorkspaceFileContent( workspaceId: string, fileId: string, userId: string, - content: Buffer + content: Buffer, + contentType?: string ): Promise { logger.info(`Updating workspace file content: ${fileId} for workspace ${workspaceId}`) @@ -537,6 +538,8 @@ export async function updateWorkspaceFileContent( } } + const nextContentType = contentType || fileRecord.type + try { const metadata: Record = { originalName: fileRecord.name, @@ -549,7 +552,7 @@ export async function updateWorkspaceFileContent( await uploadFile({ file: content, fileName: fileRecord.key, - contentType: fileRecord.type, + contentType: nextContentType, context: 'workspace', preserveKey: true, customKey: fileRecord.key, @@ -558,7 +561,7 @@ export async function updateWorkspaceFileContent( await db .update(workspaceFiles) - .set({ size: content.length }) + .set({ size: content.length, contentType: nextContentType }) .where( and( eq(workspaceFiles.id, fileId), @@ -584,6 +587,7 @@ export async function updateWorkspaceFileContent( return { ...fileRecord, size: content.length, + type: nextContentType, } } catch (error) { logger.error(`Failed to update workspace file content ${fileId}:`, error) diff --git a/apps/sim/package.json b/apps/sim/package.json index 5c3fb544b95..6dab8edcf84 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -41,13 +41,13 @@ "@azure/storage-blob": "12.27.0", "@better-auth/sso": "1.3.12", "@better-auth/stripe": "1.3.12", - "@marsidev/react-turnstile": "1.4.2", "@browserbasehq/stagehand": "^3.0.5", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@e2b/code-interpreter": "^2.0.0", "@google/genai": "1.34.0", "@hookform/resolvers": "^4.1.3", "@linear/sdk": "40.0.0", + "@marsidev/react-turnstile": "1.4.2", "@modelcontextprotocol/sdk": "1.20.2", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-jaeger": "2.1.0", @@ -92,6 +92,7 @@ "binary-extensions": "^2.0.0", "browser-image-compression": "^2.0.2", "chalk": "5.6.2", + "chart.js": "4.5.1", "cheerio": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -146,6 +147,8 @@ "postgres": "^3.4.5", "posthog-js": "1.334.1", "posthog-node": "5.9.2", + "pptxgenjs": "4.0.1", + "pptxviewjs": "1.1.8", "prismjs": "^1.30.0", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/bun.lock b/bun.lock index 9af6d37ea56..dd644444895 100644 --- a/bun.lock +++ b/bun.lock @@ -117,6 +117,7 @@ "binary-extensions": "^2.0.0", "browser-image-compression": "^2.0.2", "chalk": "5.6.2", + "chart.js": "4.5.1", "cheerio": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -171,6 +172,8 @@ "postgres": "^3.4.5", "posthog-js": "1.334.1", "posthog-node": "5.9.2", + "pptxgenjs": "4.0.1", + "pptxviewjs": "1.1.8", "prismjs": "^1.30.0", "react": "19.2.4", "react-dom": "19.2.4", @@ -802,6 +805,8 @@ "@jsonhero/path": ["@jsonhero/path@1.0.21", "", {}, "sha512-gVUDj/92acpVoJwsVJ/RuWOaHyG4oFzn898WNGQItLCTQ+hOaVlEaImhwE1WqOTf+l3dGOUkbSiVKlb3q1hd1Q=="], + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@langchain/core": ["@langchain/core@0.3.80", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", "camelcase": "6", "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", "langsmith": "^0.3.67", "mustache": "^4.2.0", "p-queue": "^6.6.2", "p-retry": "4", "uuid": "^10.0.0", "zod": "^3.25.32", "zod-to-json-schema": "^3.22.3" } }, "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA=="], "@langchain/openai": ["@langchain/openai@0.4.9", "", { "dependencies": { "js-tiktoken": "^1.0.12", "openai": "^4.87.3", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.3" }, "peerDependencies": { "@langchain/core": ">=0.3.39 <0.4.0" } }, "sha512-NAsaionRHNdqaMjVLPkFCyjUDze+OqRHghA1Cn4fPoAafz+FXcl9c7LlEl9Xo0FH6/8yiCl7Rw2t780C/SBVxQ=="], @@ -1858,6 +1863,8 @@ "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], @@ -2424,6 +2431,8 @@ "http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "^10.0.3" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="], + "https": ["https@1.0.0", "", {}, "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], @@ -3096,6 +3105,10 @@ "posthog-node": ["posthog-node@5.9.2", "", { "dependencies": { "@posthog/core": "1.2.2" } }, "sha512-oU7FbFcH5cn40nhP04cBeT67zE76EiGWjKKzDvm6IOm5P83sqM0Ij0wMJQSHp+QI6ZN7MLzb+4xfMPUEZ4q6CA=="], + "pptxgenjs": ["pptxgenjs@4.0.1", "", { "dependencies": { "@types/node": "^22.8.1", "https": "^1.0.0", "image-size": "^1.2.1", "jszip": "^3.10.1" } }, "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A=="], + + "pptxviewjs": ["pptxviewjs@1.1.8", "", { "peerDependencies": { "chart.js": ">=4.4.1", "jszip": ">=3.10.1" }, "optionalPeers": ["chart.js", "jszip"] }, "sha512-Nk3uIg1H7WkigKIKZPcTrcmV4RMpRSHvG4jWAO9aKPD1MWkOF8fwqtypsF+kzUZvIzO0BA/eKK+zNK7/R7WrDg=="], + "preact": ["preact@10.28.3", "", {}, "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA=="], "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], @@ -3142,6 +3155,8 @@ "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], + "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -4280,6 +4295,8 @@ "posthog-node/@posthog/core": ["@posthog/core@1.2.2", "", {}, "sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg=="], + "pptxgenjs/image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], + "protobufjs/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], From 8abb8846e447d816e61257072179f78ce5bece92 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 12:22:56 -0700 Subject: [PATCH 02/30] Fix ppt load --- .../components/file-viewer/file-viewer.tsx | 111 ++++++++++-------- apps/sim/hooks/queries/workspace-files.ts | 8 +- 2 files changed, 66 insertions(+), 53 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 132ed82529e..af2a9e0fb8c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -439,6 +439,39 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) { ) } +const pptxSlideCache = new Map() + +function pptxCacheKey(fileId: string, dataUpdatedAt: number): string { + return `${fileId}:${dataUpdatedAt}` +} + +async function renderPptxSlides( + data: Uint8Array, + onSlide: (src: string, index: number) => void, + cancelled: () => boolean +): Promise { + const { PPTXViewer } = await import('pptxviewjs') + if (cancelled()) return + + const W = 1920 + const H = 1080 + + const canvas = document.createElement('canvas') + canvas.width = W + canvas.height = H + const viewer = new PPTXViewer({ canvas }) + await viewer.loadFile(data) + const count = viewer.getSlideCount() + if (cancelled() || count === 0) return + + for (let i = 0; i < count; i++) { + if (cancelled()) break + if (i === 0) await viewer.render() + else await viewer.goToSlide(i) + onSlide(canvas.toDataURL('image/jpeg', 0.85), i) + } +} + function PptxPreview({ file, workspaceId, @@ -455,11 +488,19 @@ function PptxPreview({ dataUpdatedAt, } = useWorkspaceFileBinary(workspaceId, file.id, file.key) - const [slides, setSlides] = useState([]) + const cacheKey = pptxCacheKey(file.id, dataUpdatedAt) + const cached = pptxSlideCache.get(cacheKey) + + const [slides, setSlides] = useState(cached ?? []) const [rendering, setRendering] = useState(false) const [renderError, setRenderError] = useState(null) useEffect(() => { + if (cached) { + setSlides(cached) + return + } + let cancelled = false async function render() { @@ -474,61 +515,33 @@ function PptxPreview({ await fn(pptx) const arrayBuffer = (await pptx.write({ outputType: 'arraybuffer' })) as ArrayBuffer if (cancelled) return - const { PPTXViewer } = await import('pptxviewjs') const data = new Uint8Array(arrayBuffer) - const probe = document.createElement('canvas') - const probeViewer = new PPTXViewer({ canvas: probe }) - await probeViewer.loadFile(data) - const count = probeViewer.getSlideCount() - if (cancelled || count === 0) return - const dpr = window.devicePixelRatio || 1 - const W = Math.round(1920 * dpr) - const H = Math.round(1080 * dpr) const images: string[] = [] - for (let i = 0; i < count; i++) { - if (cancelled) break - const canvas = document.createElement('canvas') - canvas.width = W - canvas.height = H - const viewer = new PPTXViewer({ canvas }) - await viewer.loadFile(data) - if (i > 0) await viewer.goToSlide(i) - else await viewer.render() - images.push(canvas.toDataURL('image/png')) - } - if (!cancelled) setSlides(images) + await renderPptxSlides( + data, + (src) => { + images.push(src) + if (!cancelled) setSlides([...images]) + }, + () => cancelled + ) return } if (!fileData) return - const { PPTXViewer } = await import('pptxviewjs') - if (cancelled) return - - const data = new Uint8Array(fileData!) - const probe = document.createElement('canvas') - const probeViewer = new PPTXViewer({ canvas: probe }) - await probeViewer.loadFile(data) - const count = probeViewer.getSlideCount() - if (cancelled || count === 0) return - - const dpr = window.devicePixelRatio || 1 - const W = Math.round(1920 * dpr) - const H = Math.round(1080 * dpr) + const data = new Uint8Array(fileData) const images: string[] = [] - - for (let i = 0; i < count; i++) { - if (cancelled) break - const canvas = document.createElement('canvas') - canvas.width = W - canvas.height = H - const viewer = new PPTXViewer({ canvas }) - await viewer.loadFile(data) - if (i > 0) await viewer.goToSlide(i) - else await viewer.render() - images.push(canvas.toDataURL('image/png')) + await renderPptxSlides( + data, + (src) => { + images.push(src) + if (!cancelled) setSlides([...images]) + }, + () => cancelled + ) + if (!cancelled && images.length > 0) { + pptxSlideCache.set(cacheKey, images) } - - if (!cancelled) setSlides(images) } catch (err) { if (!cancelled) { const msg = err instanceof Error ? err.message : 'Failed to render presentation' @@ -544,7 +557,7 @@ function PptxPreview({ return () => { cancelled = true } - }, [fileData, dataUpdatedAt, streamingContent]) + }, [fileData, dataUpdatedAt, streamingContent, cacheKey, cached]) const error = fetchError ? fetchError instanceof Error diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 1fe5349d74b..a759c1bf4b0 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -106,8 +106,8 @@ async function fetchWorkspaceFileBinary( key: string, signal?: AbortSignal ): Promise { - const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&t=${Date.now()}` - const response = await fetch(serveUrl, { signal, cache: 'no-store' }) + const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace` + const response = await fetch(serveUrl, { signal }) if (!response.ok) throw new Error('Failed to fetch file content') return response.arrayBuffer() } @@ -122,8 +122,8 @@ export function useWorkspaceFileBinary(workspaceId: string, fileId: string, key: queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'binary'), queryFn: ({ signal }) => fetchWorkspaceFileBinary(key, signal), enabled: !!workspaceId && !!fileId && !!key, - staleTime: 30 * 1000, - refetchOnWindowFocus: 'always', + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, }) } From b28556fa14dafdcce81710e31ea0986e7e8e3723 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 12:32:04 -0700 Subject: [PATCH 03/30] Fixes --- .../components/file-viewer/file-viewer.tsx | 19 +++++++++++++++---- .../resource-registry/resource-registry.tsx | 2 +- apps/sim/hooks/queries/workspace-files.ts | 11 ++++++----- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index af2a9e0fb8c..9fbad53491f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -439,12 +439,22 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) { ) } +const PPTX_CACHE_MAX = 5 + const pptxSlideCache = new Map() function pptxCacheKey(fileId: string, dataUpdatedAt: number): string { return `${fileId}:${dataUpdatedAt}` } +function pptxCacheSet(key: string, slides: string[]): void { + pptxSlideCache.set(key, slides) + if (pptxSlideCache.size > PPTX_CACHE_MAX) { + const oldest = pptxSlideCache.keys().next().value + if (oldest !== undefined) pptxSlideCache.delete(oldest) + } +} + async function renderPptxSlides( data: Uint8Array, onSlide: (src: string, index: number) => void, @@ -453,8 +463,9 @@ async function renderPptxSlides( const { PPTXViewer } = await import('pptxviewjs') if (cancelled()) return - const W = 1920 - const H = 1080 + const dpr = Math.min(window.devicePixelRatio || 1, 2) + const W = Math.round(1920 * dpr) + const H = Math.round(1080 * dpr) const canvas = document.createElement('canvas') canvas.width = W @@ -540,7 +551,7 @@ function PptxPreview({ () => cancelled ) if (!cancelled && images.length > 0) { - pptxSlideCache.set(cacheKey, images) + pptxCacheSet(cacheKey, images) } } catch (err) { if (!cancelled) { @@ -557,7 +568,7 @@ function PptxPreview({ return () => { cancelled = true } - }, [fileData, dataUpdatedAt, streamingContent, cacheKey, cached]) + }, [fileData, dataUpdatedAt, streamingContent, cacheKey]) const error = fetchError ? fetchError instanceof Error diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 1586f397ff6..bf3d2db19f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -129,7 +129,7 @@ const RESOURCE_INVALIDATORS: Record< }, file: (qc, wId, id) => { qc.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) - qc.invalidateQueries({ queryKey: workspaceFilesKeys.content(wId, id) }) + qc.invalidateQueries({ queryKey: workspaceFilesKeys.contentFile(wId, id) }) qc.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, workflow: (qc, _wId) => { diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index a759c1bf4b0..b679865dad3 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -16,11 +16,13 @@ export const workspaceFilesKeys = { list: (workspaceId: string, scope: WorkspaceFileQueryScope = 'active') => [...workspaceFilesKeys.lists(), workspaceId, scope] as const, contents: () => [...workspaceFilesKeys.all, 'content'] as const, + contentFile: (workspaceId: string, fileId: string) => + [...workspaceFilesKeys.contents(), workspaceId, fileId] as const, content: ( workspaceId: string, fileId: string, mode: 'text' | 'raw' | 'binary' = 'text' - ) => [...workspaceFilesKeys.contents(), workspaceId, fileId, mode] as const, + ) => [...workspaceFilesKeys.contentFile(workspaceId, fileId), mode] as const, storageInfo: () => [...workspaceFilesKeys.all, 'storageInfo'] as const, } @@ -122,8 +124,7 @@ export function useWorkspaceFileBinary(workspaceId: string, fileId: string, key: queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'binary'), queryFn: ({ signal }) => fetchWorkspaceFileBinary(key, signal), enabled: !!workspaceId && !!fileId && !!key, - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, + staleTime: 30 * 1000, }) } @@ -239,7 +240,7 @@ export function useUpdateWorkspaceFileContent() { }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ - queryKey: [...workspaceFilesKeys.contents(), variables.workspaceId, variables.fileId], + queryKey: workspaceFilesKeys.contentFile(variables.workspaceId, variables.fileId), }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) @@ -345,7 +346,7 @@ export function useDeleteWorkspaceFile() { onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) queryClient.removeQueries({ - queryKey: workspaceFilesKeys.content(variables.workspaceId, variables.fileId), + queryKey: workspaceFilesKeys.contentFile(variables.workspaceId, variables.fileId), }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, From dde64aa46e26aa1880103259276b1455e740cef1 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 12:52:50 -0700 Subject: [PATCH 04/30] Fixes --- .../files/components/file-viewer/file-viewer.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 9fbad53491f..8216491fdb4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -439,8 +439,6 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) { ) } -const PPTX_CACHE_MAX = 5 - const pptxSlideCache = new Map() function pptxCacheKey(fileId: string, dataUpdatedAt: number): string { @@ -449,7 +447,7 @@ function pptxCacheKey(fileId: string, dataUpdatedAt: number): string { function pptxCacheSet(key: string, slides: string[]): void { pptxSlideCache.set(key, slides) - if (pptxSlideCache.size > PPTX_CACHE_MAX) { + if (pptxSlideCache.size > 5) { const oldest = pptxSlideCache.keys().next().value if (oldest !== undefined) pptxSlideCache.delete(oldest) } From 4a537ff92fcf111d2de7003875a208f936fc7d45 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 12:53:03 -0700 Subject: [PATCH 05/30] Fix lint --- apps/sim/app/api/files/serve/[...path]/route.ts | 2 +- .../files/components/file-viewer/file-viewer.tsx | 14 ++------------ .../files/components/file-viewer/preview-panel.tsx | 1 - .../resource-content/resource-content.tsx | 6 +++++- apps/sim/hooks/queries/workspace-files.ts | 12 +++--------- .../copilot/tools/server/files/workspace-file.ts | 2 +- apps/sim/lib/copilot/tools/shared/schemas.ts | 4 +--- 7 files changed, 13 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 58a455c0d8c..e8d32088173 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -163,7 +163,7 @@ async function handleCloudProxy( cloudKey: string, userId: string, contextParam?: string | null, - raw: boolean = false + raw = false ): Promise { try { let context: StorageContext diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 8216491fdb4..04dd5d9725e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -180,12 +180,7 @@ function TextEditor({ isLoading, error, dataUpdatedAt, - } = useWorkspaceFileContent( - workspaceId, - file.id, - file.key, - file.type === 'text/x-pptxgenjs' - ) + } = useWorkspaceFileContent(workspaceId, file.id, file.key, file.type === 'text/x-pptxgenjs') const updateContent = useUpdateWorkspaceFileContent() @@ -610,12 +605,7 @@ function PptxPreview({
{slides.map((src, i) => ( - {`Slide + {`Slide ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 6cb4c19c6d9..0a7be9495ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -327,4 +327,3 @@ function parseCsvLine(line: string, delimiter: string): string[] { fields.push(current.trim()) return fields } - diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 93c8176c71a..9801e89b356 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -10,7 +10,11 @@ import { markRunToolManuallyStopped, reportManualRunToolStop, } from '@/lib/copilot/client-sse/run-tool-execution' -import { downloadWorkspaceFile, getMimeTypeFromExtension, getFileExtension } from '@/lib/uploads/utils/file-utils' +import { + downloadWorkspaceFile, + getFileExtension, + getMimeTypeFromExtension, +} from '@/lib/uploads/utils/file-utils' import { FileViewer, type PreviewMode, diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index b679865dad3..c4ad910a5c7 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -18,11 +18,8 @@ export const workspaceFilesKeys = { contents: () => [...workspaceFilesKeys.all, 'content'] as const, contentFile: (workspaceId: string, fileId: string) => [...workspaceFilesKeys.contents(), workspaceId, fileId] as const, - content: ( - workspaceId: string, - fileId: string, - mode: 'text' | 'raw' | 'binary' = 'text' - ) => [...workspaceFilesKeys.contentFile(workspaceId, fileId), mode] as const, + content: (workspaceId: string, fileId: string, mode: 'text' | 'raw' | 'binary' = 'text') => + [...workspaceFilesKeys.contentFile(workspaceId, fileId), mode] as const, storageInfo: () => [...workspaceFilesKeys.all, 'storageInfo'] as const, } @@ -104,10 +101,7 @@ export function useWorkspaceFileContent( }) } -async function fetchWorkspaceFileBinary( - key: string, - signal?: AbortSignal -): Promise { +async function fetchWorkspaceFileBinary(key: string, signal?: AbortSignal): Promise { const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace` const response = await fetch(serveUrl, { signal }) if (!response.ok) throw new Error('Failed to fetch file content') diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index ea73fab7f7c..b2596cea084 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -1,5 +1,5 @@ -import PptxGenJS from 'pptxgenjs' import { createLogger } from '@sim/logger' +import PptxGenJS from 'pptxgenjs' import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' import type { WorkspaceFileArgs, WorkspaceFileResult } from '@/lib/copilot/tools/shared/schemas' import { diff --git a/apps/sim/lib/copilot/tools/shared/schemas.ts b/apps/sim/lib/copilot/tools/shared/schemas.ts index 8fd717f1aac..3a0bd704a37 100644 --- a/apps/sim/lib/copilot/tools/shared/schemas.ts +++ b/apps/sim/lib/copilot/tools/shared/schemas.ts @@ -182,9 +182,7 @@ export const WorkspaceFileArgsSchema = z.object({ contentType: z.string().optional(), workspaceId: z.string().optional(), newName: z.string().optional(), - edits: z - .array(z.object({ search: z.string(), replace: z.string() })) - .optional(), + edits: z.array(z.object({ search: z.string(), replace: z.string() })).optional(), }) .optional(), }) From aa9fc102dd8d927fe08ffb447c1cdbde184a4831 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 13:41:12 -0700 Subject: [PATCH 06/30] Fix wid --- apps/sim/app/api/files/serve/[...path]/route.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index e8d32088173..c2579156ba4 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -6,6 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generatePptxFromCode } from '@/lib/copilot/tools/server/files/workspace-file' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' +import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' @@ -46,6 +47,10 @@ function stripStorageKeyPrefix(segment: string): string { return STORAGE_KEY_PREFIX_RE.test(segment) ? segment.replace(STORAGE_KEY_PREFIX_RE, '') : segment } +function getWorkspaceIdForCompile(key: string): string | undefined { + return parseWorkspaceFileKey(key) ?? undefined +} + export async function GET( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } @@ -138,10 +143,11 @@ async function handleLocalFile( const rawBuffer = await readFile(filePath) const segment = filename.split('/').pop() || filename const displayName = stripStorageKeyPrefix(segment) + const workspaceId = getWorkspaceIdForCompile(filename) const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded( rawBuffer, displayName, - undefined, + workspaceId, raw ) @@ -202,10 +208,11 @@ async function handleCloudProxy( const segment = cloudKey.split('/').pop() || 'download' const displayName = stripStorageKeyPrefix(segment) + const workspaceId = getWorkspaceIdForCompile(cloudKey) const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded( rawBuffer, displayName, - undefined, + workspaceId, raw ) From 5954abd5ddd5d10c49c0a6ec4ae8debc7af1d488 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 15:21:32 -0700 Subject: [PATCH 07/30] Download image --- .../orchestrator/tool-executor/index.ts | 1 + apps/sim/lib/copilot/resource-extraction.ts | 2 + .../files/download-to-workspace-file.ts | 200 ++++++++++++++++++ apps/sim/lib/copilot/tools/server/router.ts | 2 + 4 files changed, 205 insertions(+) create mode 100644 apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts index 83d01040392..d5dfcb0754b 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts @@ -739,6 +739,7 @@ const SERVER_TOOLS = new Set([ 'knowledge_base', 'user_table', 'workspace_file', + 'download_to_workspace_file', 'get_execution_summary', 'get_job_logs', 'generate_visualization', diff --git a/apps/sim/lib/copilot/resource-extraction.ts b/apps/sim/lib/copilot/resource-extraction.ts index a1e9f75e3d8..94a7e9e160b 100644 --- a/apps/sim/lib/copilot/resource-extraction.ts +++ b/apps/sim/lib/copilot/resource-extraction.ts @@ -6,6 +6,7 @@ type ResourceType = MothershipResourceType const RESOURCE_TOOL_NAMES = new Set([ 'user_table', 'workspace_file', + 'download_to_workspace_file', 'create_workflow', 'edit_workflow', 'function_execute', @@ -119,6 +120,7 @@ export function extractResourcesFromToolResult( return [] } + case 'download_to_workspace_file': case 'generate_visualization': case 'generate_image': { if (result.fileId) { diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts new file mode 100644 index 00000000000..fe128473784 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -0,0 +1,200 @@ +import { createLogger } from '@sim/logger' +import { z } from 'zod' +import { + assertServerToolNotAborted, + type BaseServerTool, + type ServerToolContext, +} from '@/lib/copilot/tools/server/base-tool' +import { + getExtensionFromMimeType, + getFileExtension, + getMimeTypeFromExtension, +} from '@/lib/uploads/utils/file-utils' +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const logger = createLogger('DownloadToWorkspaceFileTool') + +const DownloadToWorkspaceFileArgsSchema = z.object({ + url: z.string().url(), + fileName: z.string().min(1).optional(), +}) + +const DownloadToWorkspaceFileResultSchema = z.object({ + success: z.boolean(), + message: z.string(), + fileId: z.string().optional(), + fileName: z.string().optional(), + downloadUrl: z.string().optional(), +}) + +type DownloadToWorkspaceFileArgs = z.infer +type DownloadToWorkspaceFileResult = z.infer + +function sanitizeFileName(fileName: string): string { + return fileName.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_').trim() +} + +function stripQueryAndHash(input: string): string { + return input.split('#')[0]?.split('?')[0] ?? input +} + +function extractFileNameFromUrl(url: string): string | undefined { + try { + const pathname = new URL(url).pathname + const lastSegment = pathname.split('/').pop() + if (!lastSegment) return undefined + const decoded = decodeURIComponent(lastSegment) + return decoded && decoded !== '/' ? decoded : undefined + } catch { + return undefined + } +} + +function extractFileNameFromContentDisposition(header: string | null): string | undefined { + if (!header) return undefined + + const utf8Match = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i) + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1].trim()) + } catch { + return utf8Match[1].trim() + } + } + + const quotedMatch = header.match(/filename\s*=\s*"([^"]+)"/i) + if (quotedMatch?.[1]) return quotedMatch[1].trim() + + const bareMatch = header.match(/filename\s*=\s*([^;]+)/i) + if (bareMatch?.[1]) return bareMatch[1].trim() + + return undefined +} + +function resolveMimeType( + responseContentType: string | null, + candidateFileName?: string, + sourceUrl?: string +): string { + const headerMime = responseContentType?.split(';')[0]?.trim().toLowerCase() + if (headerMime && headerMime !== 'application/octet-stream') { + return headerMime + } + + const fileName = candidateFileName || extractFileNameFromUrl(sourceUrl || '') + const ext = fileName ? getFileExtension(stripQueryAndHash(fileName)) : '' + return ext ? getMimeTypeFromExtension(ext) : 'application/octet-stream' +} + +function ensureFileExtension(fileName: string, mimeType: string): string { + const ext = getFileExtension(stripQueryAndHash(fileName)) + if (ext) return fileName + + const inferredExt = getExtensionFromMimeType(mimeType) + return inferredExt ? `${fileName}.${inferredExt}` : fileName +} + +function inferOutputFileName( + requestedFileName: string | undefined, + response: Response, + url: string, + mimeType: string +): string { + const preferredName = + requestedFileName || + extractFileNameFromContentDisposition(response.headers.get('content-disposition')) || + extractFileNameFromUrl(url) || + 'downloaded-file' + + const sanitized = sanitizeFileName(stripQueryAndHash(preferredName)) || 'downloaded-file' + return ensureFileExtension(sanitized, mimeType) +} + +export const downloadToWorkspaceFileServerTool: BaseServerTool< + DownloadToWorkspaceFileArgs, + DownloadToWorkspaceFileResult +> = { + name: 'download_to_workspace_file', + inputSchema: DownloadToWorkspaceFileArgsSchema, + outputSchema: DownloadToWorkspaceFileResultSchema, + + async execute( + params: DownloadToWorkspaceFileArgs, + context?: ServerToolContext + ): Promise { + if (!context?.userId) { + throw new Error('Authentication required') + } + + const workspaceId = context.workspaceId + if (!workspaceId) { + return { success: false, message: 'Workspace ID is required' } + } + + try { + assertServerToolNotAborted(context) + + const response = await fetch(params.url, { + redirect: 'follow', + signal: context.abortSignal, + }) + + if (!response.ok) { + return { + success: false, + message: `Download failed with status ${response.status} ${response.statusText}`, + } + } + + const mimeType = resolveMimeType( + response.headers.get('content-type'), + params.fileName, + response.url || params.url + ) + const fileName = inferOutputFileName( + params.fileName, + response, + response.url || params.url, + mimeType + ) + + assertServerToolNotAborted(context) + + const arrayBuffer = await response.arrayBuffer() + const fileBuffer = Buffer.from(arrayBuffer) + + if (fileBuffer.length === 0) { + return { success: false, message: 'Downloaded file is empty' } + } + + const uploaded = await uploadWorkspaceFile( + workspaceId, + context.userId, + fileBuffer, + fileName, + mimeType + ) + + logger.info('Downloaded remote file to workspace', { + sourceUrl: params.url, + resolvedUrl: response.url, + fileId: uploaded.id, + fileName: uploaded.name, + mimeType, + size: fileBuffer.length, + }) + + return { + success: true, + message: `Downloaded "${uploaded.name}" to workspace (${fileBuffer.length} bytes)`, + fileId: uploaded.id, + fileName: uploaded.name, + downloadUrl: uploaded.url, + } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to download file to workspace', { url: params.url, error: msg }) + return { success: false, message: `Failed to download file: ${msg}` } + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 73f075e0592..26a97d2b995 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -4,6 +4,7 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { downloadToWorkspaceFileServerTool } from '@/lib/copilot/tools/server/files/download-to-workspace-file' import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' import { getTriggerBlocksServerTool } from '@/lib/copilot/tools/server/blocks/get-trigger-blocks' import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' @@ -90,6 +91,7 @@ const serverToolRegistry: Record = { [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, [userTableServerTool.name]: userTableServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, + [downloadToWorkspaceFileServerTool.name]: downloadToWorkspaceFileServerTool, [generateVisualizationServerTool.name]: generateVisualizationServerTool, [generateImageServerTool.name]: generateImageServerTool, } From 77a4f2f4661478d13576b8ccf99ac25e762d9be3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 17:05:21 -0700 Subject: [PATCH 08/30] Update tools --- .../message-content/message-content.tsx | 51 +++++--- .../[workspaceId]/home/hooks/use-chat.ts | 19 ++- .../app/workspace/[workspaceId]/home/types.ts | 2 + apps/sim/lib/copilot/store-utils.ts | 121 +++++++++++++++++- 4 files changed, 169 insertions(+), 24 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index cfa7adb625a..cfb2af0f8b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -2,6 +2,8 @@ import type { ContentBlock, OptionItem, SubagentName, ToolCallData } from '../../types' import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types' +import { resolveToolDisplay } from '@/lib/copilot/store-utils' +import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry' import type { AgentGroupItem } from './components' import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } from './components' @@ -53,15 +55,40 @@ function resolveAgentLabel(key: string): string { return SUBAGENT_LABELS[key as SubagentName] ?? formatToolName(key) } +function mapToolStatusToClientState(status: ContentBlock['toolCall'] extends { status: infer T } ? T : string) { + switch (status) { + case 'success': + return ClientToolCallState.success + case 'error': + return ClientToolCallState.error + case 'cancelled': + return ClientToolCallState.cancelled + default: + return ClientToolCallState.executing + } +} + +function getOverrideDisplayTitle(tc: NonNullable): string | undefined { + if (tc.name === 'read' || tc.name.endsWith('_respond')) { + return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.id, tc.params)?.text + } + return undefined +} + function toToolData(tc: NonNullable): ToolCallData { + const overrideDisplayTitle = getOverrideDisplayTitle(tc) + const displayTitle = + overrideDisplayTitle || + tc.displayTitle || + TOOL_UI_METADATA[tc.name as keyof typeof TOOL_UI_METADATA]?.title || + formatToolName(tc.name) + return { id: tc.id, toolName: tc.name, - displayTitle: - tc.displayTitle || - TOOL_UI_METADATA[tc.name as keyof typeof TOOL_UI_METADATA]?.title || - formatToolName(tc.name), + displayTitle, status: tc.status, + params: tc.params, result: tc.result, streamingArgs: tc.streamingArgs, } @@ -81,25 +108,12 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { const block = blocks[i] if (block.type === 'subagent_text') { - if (!block.content || !group) continue - const lastItem = group.items[group.items.length - 1] - if (lastItem?.type === 'text') { - lastItem.content += block.content - } else { - group.items.push({ type: 'text', content: block.content }) - } continue } if (block.type === 'text') { if (!block.content?.trim()) continue - if (block.subagent && group && group.agentName === block.subagent) { - const lastItem = group.items[group.items.length - 1] - if (lastItem?.type === 'text') { - lastItem.content += block.content - } else { - group.items.push({ type: 'text', content: block.content }) - } + if (block.subagent) { continue } if (group) { @@ -148,6 +162,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { if (block.type === 'tool_call') { if (!block.toolCall) continue const tc = block.toolCall + if (tc.name === 'tool_search_tool_regex' || tc.name === 'grep' || tc.name === 'glob') continue const isDispatch = SUBAGENT_KEYS.has(tc.name) && !tc.calledBy if (isDispatch) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 0bd9551c8f4..31cbd34bd01 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -97,6 +97,7 @@ function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock { status: resolvedStatus, displayTitle: resolvedStatus === 'cancelled' ? 'Stopped by user' : block.toolCall.display?.text, + params: block.toolCall.params, calledBy: block.toolCall.calledBy, result: block.toolCall.result, } @@ -114,6 +115,7 @@ function mapStoredToolCall(tc: TaskStoredToolCall): ContentBlock { name: tc.name, status: resolvedStatus, displayTitle: resolvedStatus === 'cancelled' ? 'Stopped by user' : undefined, + params: tc.params, result: tc.result != null ? { @@ -736,11 +738,20 @@ export function useChat( const isPartial = data?.partial === true if (!id) break - if (name.endsWith('_respond')) break + if ( + name === 'tool_search_tool_regex' || + name === 'grep' || + name === 'glob' + ) { + break + } const ui = parsed.ui || data?.ui if (ui?.hidden) break const displayTitle = ui?.title || ui?.phaseLabel const phaseLabel = ui?.phaseLabel + const args = (data?.arguments ?? data?.input) as + | Record + | undefined if (!toolMap.has(id)) { toolMap.set(id, blocks.length) blocks.push({ @@ -751,13 +762,11 @@ export function useChat( status: 'executing', displayTitle, phaseLabel, + params: args, calledBy: activeSubagent, }, }) if (name === 'read' || isResourceToolName(name)) { - const args = (data?.arguments ?? data?.input) as - | Record - | undefined if (args) toolArgsMap.set(id, args) } } else { @@ -767,6 +776,7 @@ export function useChat( tc.name = name if (displayTitle) tc.displayTitle = displayTitle if (phaseLabel) tc.phaseLabel = phaseLabel + if (args) tc.params = args } } flush() @@ -1140,6 +1150,7 @@ export function useChat( id: block.toolCall.id, name: block.toolCall.name, state: isCancelled ? 'cancelled' : block.toolCall.status, + params: block.toolCall.params, result: block.toolCall.result, display: { text: isCancelled ? 'Stopped by user' : block.toolCall.displayTitle, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 21c6c295c40..0ba5c32ec78 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -145,6 +145,7 @@ export interface ToolCallData { toolName: string displayTitle: string status: ToolCallStatus + params?: Record result?: ToolCallResult streamingArgs?: string } @@ -155,6 +156,7 @@ export interface ToolCallInfo { status: ToolCallStatus displayTitle?: string phaseLabel?: string + params?: Record calledBy?: string result?: { success: boolean; output?: unknown; error?: string } streamingArgs?: string diff --git a/apps/sim/lib/copilot/store-utils.ts b/apps/sim/lib/copilot/store-utils.ts index ad4642dd2c8..750a2c07baf 100644 --- a/apps/sim/lib/copilot/store-utils.ts +++ b/apps/sim/lib/copilot/store-utils.ts @@ -28,11 +28,13 @@ import { type ClientToolDisplay, TOOL_DISPLAY_REGISTRY, } from '@/lib/copilot/tools/client/tool-display-registry' +import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types' const logger = createLogger('CopilotStoreUtils') -/** Respond tools are internal to copilot subagents and should never be shown in the UI */ +/** Respond tools are internal handoff tools shown with a friendly generic label. */ const HIDDEN_TOOL_SUFFIX = '_respond' +const HIDDEN_TOOL_NAMES = new Set(['tool_search_tool_regex', 'grep', 'glob']) /** UI metadata sent by the copilot on SSE tool_call events. */ export interface ServerToolUI { @@ -81,7 +83,11 @@ export function resolveToolDisplay( serverUI?: ServerToolUI ): ClientToolDisplay | undefined { if (!toolName) return undefined - if (toolName.endsWith(HIDDEN_TOOL_SUFFIX)) return undefined + if (HIDDEN_TOOL_NAMES.has(toolName)) return undefined + + const specialDisplay = specialToolDisplay(toolName, state, params) + if (specialDisplay) return specialDisplay + const entry = TOOL_DISPLAY_REGISTRY[toolName] if (!entry) { // Use copilot-provided UI as a better fallback than humanized name @@ -115,6 +121,117 @@ export function resolveToolDisplay( return humanizedFallback(toolName, state) } +function specialToolDisplay( + toolName: string, + state: ClientToolCallState, + params?: Record +): ClientToolDisplay | undefined { + if (toolName.endsWith(HIDDEN_TOOL_SUFFIX)) { + return { + text: formatRespondLabel(state), + icon: Loader2, + } + } + + const searchQuery = + readStringParam(params, 'pattern') || readStringParam(params, 'query') || readStringParam(params, 'glob') + + if ((toolName === 'grep' || toolName === 'glob') && searchQuery) { + return { + text: formatSearchingLabel(searchQuery, state), + icon: Search, + } + } + + if (toolName === 'read') { + const target = describeReadTarget(readStringParam(params, 'path')) + return { + text: formatReadingLabel(target, state), + icon: FileText, + } + } + + return undefined +} + +function formatRespondLabel(state: ClientToolCallState): string { + switch (state) { + case ClientToolCallState.success: + return 'Returned results' + case ClientToolCallState.error: + return 'Failed returning results' + case ClientToolCallState.rejected: + case ClientToolCallState.aborted: + return 'Skipped returning results' + default: + return 'Returning results' + } +} + +function readStringParam( + params: Record | undefined, + key: string +): string | undefined { + const value = params?.[key] + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function formatSearchingLabel(query: string, state: ClientToolCallState): string { + switch (state) { + case ClientToolCallState.success: + return `Searched for ${query}` + case ClientToolCallState.error: + return `Failed searching for ${query}` + case ClientToolCallState.rejected: + case ClientToolCallState.aborted: + return `Skipped searching for ${query}` + default: + return `Searching for ${query}` + } +} + +function formatReadingLabel(target: string | undefined, state: ClientToolCallState): string { + const suffix = target ? ` ${target}` : '' + switch (state) { + case ClientToolCallState.success: + return `Read${suffix}` + case ClientToolCallState.error: + return `Failed reading${suffix}` + case ClientToolCallState.rejected: + case ClientToolCallState.aborted: + return `Skipped reading${suffix}` + default: + return `Reading${suffix}` + } +} + +function describeReadTarget(path: string | undefined): string | undefined { + if (!path) return undefined + + const segments = path + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) + + if (segments.length === 0) return undefined + + const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] + if (!resourceType) { + return stripExtension(segments[segments.length - 1]) + } + + if (resourceType === 'file') { + return segments.slice(1).join('/') || segments[segments.length - 1] + } + + const resourceName = segments[1] || segments[segments.length - 1] + return stripExtension(resourceName) +} + +function stripExtension(value: string): string { + return value.replace(/\.[^/.]+$/, '') +} + /** Generates display from copilot-provided UI metadata. */ function serverUIFallback(serverUI: ServerToolUI, state: ClientToolCallState): ClientToolDisplay { const icon = resolveIcon(serverUI.icon) From d07124841a50116d1847b72f2dbd0c234aa559ff Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 17:05:33 -0700 Subject: [PATCH 09/30] Fix lint --- .../components/message-content/message-content.tsx | 11 +++++++---- .../workspace/[workspaceId]/home/hooks/use-chat.ts | 10 ++-------- apps/sim/lib/copilot/store-utils.ts | 6 ++++-- .../tools/server/files/download-to-workspace-file.ts | 2 +- apps/sim/lib/copilot/tools/server/router.ts | 2 +- 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index cfb2af0f8b6..a9288d38ce8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -1,9 +1,9 @@ 'use client' -import type { ContentBlock, OptionItem, SubagentName, ToolCallData } from '../../types' -import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types' import { resolveToolDisplay } from '@/lib/copilot/store-utils' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry' +import type { ContentBlock, OptionItem, SubagentName, ToolCallData } from '../../types' +import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types' import type { AgentGroupItem } from './components' import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } from './components' @@ -55,7 +55,9 @@ function resolveAgentLabel(key: string): string { return SUBAGENT_LABELS[key as SubagentName] ?? formatToolName(key) } -function mapToolStatusToClientState(status: ContentBlock['toolCall'] extends { status: infer T } ? T : string) { +function mapToolStatusToClientState( + status: ContentBlock['toolCall'] extends { status: infer T } ? T : string +) { switch (status) { case 'success': return ClientToolCallState.success @@ -70,7 +72,8 @@ function mapToolStatusToClientState(status: ContentBlock['toolCall'] extends { s function getOverrideDisplayTitle(tc: NonNullable): string | undefined { if (tc.name === 'read' || tc.name.endsWith('_respond')) { - return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.id, tc.params)?.text + return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.id, tc.params) + ?.text } return undefined } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 31cbd34bd01..5be537d6147 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -738,20 +738,14 @@ export function useChat( const isPartial = data?.partial === true if (!id) break - if ( - name === 'tool_search_tool_regex' || - name === 'grep' || - name === 'glob' - ) { + if (name === 'tool_search_tool_regex' || name === 'grep' || name === 'glob') { break } const ui = parsed.ui || data?.ui if (ui?.hidden) break const displayTitle = ui?.title || ui?.phaseLabel const phaseLabel = ui?.phaseLabel - const args = (data?.arguments ?? data?.input) as - | Record - | undefined + const args = (data?.arguments ?? data?.input) as Record | undefined if (!toolMap.has(id)) { toolMap.set(id, blocks.length) blocks.push({ diff --git a/apps/sim/lib/copilot/store-utils.ts b/apps/sim/lib/copilot/store-utils.ts index 750a2c07baf..ec867702207 100644 --- a/apps/sim/lib/copilot/store-utils.ts +++ b/apps/sim/lib/copilot/store-utils.ts @@ -23,12 +23,12 @@ import { Wrench, Zap, } from 'lucide-react' +import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types' import { ClientToolCallState, type ClientToolDisplay, TOOL_DISPLAY_REGISTRY, } from '@/lib/copilot/tools/client/tool-display-registry' -import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types' const logger = createLogger('CopilotStoreUtils') @@ -134,7 +134,9 @@ function specialToolDisplay( } const searchQuery = - readStringParam(params, 'pattern') || readStringParam(params, 'query') || readStringParam(params, 'glob') + readStringParam(params, 'pattern') || + readStringParam(params, 'query') || + readStringParam(params, 'glob') if ((toolName === 'grep' || toolName === 'glob') && searchQuery) { return { diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index fe128473784..dab810ab72d 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -5,12 +5,12 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getExtensionFromMimeType, getFileExtension, getMimeTypeFromExtension, } from '@/lib/uploads/utils/file-utils' -import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' const logger = createLogger('DownloadToWorkspaceFileTool') diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 26a97d2b995..83e6425719b 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -4,10 +4,10 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { downloadToWorkspaceFileServerTool } from '@/lib/copilot/tools/server/files/download-to-workspace-file' import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' import { getTriggerBlocksServerTool } from '@/lib/copilot/tools/server/blocks/get-trigger-blocks' import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' +import { downloadToWorkspaceFileServerTool } from '@/lib/copilot/tools/server/files/download-to-workspace-file' import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/workspace-file' import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' import { getJobLogsServerTool } from '@/lib/copilot/tools/server/jobs/get-job-logs' From 844c9a284b88c610bd699d3bd965c11d10d89164 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 17:07:17 -0700 Subject: [PATCH 10/30] Fix error msg --- .../components/special-tags/special-tags.tsx | 31 +++---------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 6857fc7807f..4513f95d56e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -449,33 +449,12 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) { } function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) { + const detail = data.code ? `${data.message} (${data.code})` : data.message + return ( -
-
- - - - - - - Something went wrong - -
-

- {data.message} -

- {data.code && ( - - {data.provider ? `${data.provider}:` : ''} - {data.code} - - )} -
+ + {detail} + ) } From 46e8964f2415c30099315c9d6edb045748664b4f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 17:21:17 -0700 Subject: [PATCH 11/30] Tool fixes --- .../message-content/message-content.tsx | 2 +- .../[workspaceId]/home/hooks/use-chat.ts | 2 +- apps/sim/lib/copilot/store-utils.ts | 28 +------------------ 3 files changed, 3 insertions(+), 29 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index a9288d38ce8..01285a1a290 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -165,7 +165,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { if (block.type === 'tool_call') { if (!block.toolCall) continue const tc = block.toolCall - if (tc.name === 'tool_search_tool_regex' || tc.name === 'grep' || tc.name === 'glob') continue + if (tc.name === 'tool_search_tool_regex') continue const isDispatch = SUBAGENT_KEYS.has(tc.name) && !tc.calledBy if (isDispatch) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 5be537d6147..bb26e7397a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -738,7 +738,7 @@ export function useChat( const isPartial = data?.partial === true if (!id) break - if (name === 'tool_search_tool_regex' || name === 'grep' || name === 'glob') { + if (name === 'tool_search_tool_regex') { break } const ui = parsed.ui || data?.ui diff --git a/apps/sim/lib/copilot/store-utils.ts b/apps/sim/lib/copilot/store-utils.ts index ec867702207..9447f9cbf11 100644 --- a/apps/sim/lib/copilot/store-utils.ts +++ b/apps/sim/lib/copilot/store-utils.ts @@ -34,7 +34,7 @@ const logger = createLogger('CopilotStoreUtils') /** Respond tools are internal handoff tools shown with a friendly generic label. */ const HIDDEN_TOOL_SUFFIX = '_respond' -const HIDDEN_TOOL_NAMES = new Set(['tool_search_tool_regex', 'grep', 'glob']) +const HIDDEN_TOOL_NAMES = new Set(['tool_search_tool_regex']) /** UI metadata sent by the copilot on SSE tool_call events. */ export interface ServerToolUI { @@ -133,18 +133,6 @@ function specialToolDisplay( } } - const searchQuery = - readStringParam(params, 'pattern') || - readStringParam(params, 'query') || - readStringParam(params, 'glob') - - if ((toolName === 'grep' || toolName === 'glob') && searchQuery) { - return { - text: formatSearchingLabel(searchQuery, state), - icon: Search, - } - } - if (toolName === 'read') { const target = describeReadTarget(readStringParam(params, 'path')) return { @@ -178,20 +166,6 @@ function readStringParam( return typeof value === 'string' && value.trim() ? value.trim() : undefined } -function formatSearchingLabel(query: string, state: ClientToolCallState): string { - switch (state) { - case ClientToolCallState.success: - return `Searched for ${query}` - case ClientToolCallState.error: - return `Failed searching for ${query}` - case ClientToolCallState.rejected: - case ClientToolCallState.aborted: - return `Skipped searching for ${query}` - default: - return `Searching for ${query}` - } -} - function formatReadingLabel(target: string | undefined, state: ClientToolCallState): string { const suffix = target ? ` ${target}` : '' switch (state) { From 6549a50a83d1fe276eb595d1b4a36a6e1afecb53 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 18:07:01 -0700 Subject: [PATCH 12/30] Reenable subagent stream --- .../components/agent-group/agent-group.tsx | 12 ++++++------ .../message-content/message-content.tsx | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 56c7b979889..a8f73e0e9a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -5,6 +5,7 @@ import { ChevronDown } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { ToolCallData } from '../../../../types' import { getAgentIcon } from '../../utils' +import { ChatContent } from '../chat-content/chat-content' import { ToolCallItem } from './tool-call-item' export type AgentGroupItem = @@ -16,6 +17,7 @@ interface AgentGroupProps { agentLabel: string items: AgentGroupItem[] autoCollapse?: boolean + isStreaming?: boolean } const FADE_MS = 300 @@ -25,6 +27,7 @@ export function AgentGroup({ agentLabel, items, autoCollapse = false, + isStreaming = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) const hasItems = items.length > 0 @@ -100,12 +103,9 @@ export function AgentGroup({ status={item.data.status} /> ) : ( - - {item.content.trim()} - +
+ +
) )}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 01285a1a290..68c2206639b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -111,13 +111,28 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { const block = blocks[i] if (block.type === 'subagent_text') { + if (!block.content || !group) continue + const lastItem = group.items[group.items.length - 1] + if (lastItem?.type === 'text') { + lastItem.content += block.content + } else { + group.items.push({ type: 'text', content: block.content }) + } continue } if (block.type === 'text') { if (!block.content?.trim()) continue if (block.subagent) { - continue + if (group && group.agentName === block.subagent) { + const lastItem = group.items[group.items.length - 1] + if (lastItem?.type === 'text') { + lastItem.content += block.content + } else { + group.items.push({ type: 'text', content: block.content }) + } + continue + } } if (group) { segments.push(group) @@ -358,6 +373,7 @@ export function MessageContent({ agentLabel={segment.agentLabel} items={segment.items} autoCollapse={allToolsDone && hasFollowingText} + isStreaming={isStreaming} /> ) From 98f4dfda93ade7451092a6e9879cad7469c41c01 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 18:12:03 -0700 Subject: [PATCH 13/30] Subagent stream --- .../components/agent-group/agent-group.tsx | 12 ++++++------ .../components/message-content/message-content.tsx | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index a8f73e0e9a1..56c7b979889 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -5,7 +5,6 @@ import { ChevronDown } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { ToolCallData } from '../../../../types' import { getAgentIcon } from '../../utils' -import { ChatContent } from '../chat-content/chat-content' import { ToolCallItem } from './tool-call-item' export type AgentGroupItem = @@ -17,7 +16,6 @@ interface AgentGroupProps { agentLabel: string items: AgentGroupItem[] autoCollapse?: boolean - isStreaming?: boolean } const FADE_MS = 300 @@ -27,7 +25,6 @@ export function AgentGroup({ agentLabel, items, autoCollapse = false, - isStreaming = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) const hasItems = items.length > 0 @@ -103,9 +100,12 @@ export function AgentGroup({ status={item.data.status} /> ) : ( -
- -
+ + {item.content.trim()} + ) )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 68c2206639b..afe1bd7819d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -373,7 +373,6 @@ export function MessageContent({ agentLabel={segment.agentLabel} items={segment.items} autoCollapse={allToolsDone && hasFollowingText} - isStreaming={isStreaming} /> ) From 1e7a98741b093d675d997ee30b2e8a73159f4f36 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 18:20:24 -0700 Subject: [PATCH 14/30] Fix edit workflow hydration --- .../app/workspace/[workspaceId]/home/hooks/use-chat.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index bb26e7397a3..706c104f6a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -991,19 +991,15 @@ export function useChat( onResourceEventRef.current?.() if (resource.type === 'workflow') { - const registry = useWorkflowRegistry.getState() const wasRegistered = ensureWorkflowInRegistry( resource.id, resource.title, workspaceId ) if (wasAdded && wasRegistered) { - registry.setActiveWorkflow(resource.id) - } else if ( - registry.activeWorkflowId !== resource.id || - registry.hydration.phase !== 'ready' - ) { - registry.loadWorkflowState(resource.id) + useWorkflowRegistry.getState().setActiveWorkflow(resource.id) + } else { + useWorkflowRegistry.getState().loadWorkflowState(resource.id) } } } From 0b3000ad1e36b87ea9355eae8e6b43cb32aef3a1 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 22 Mar 2026 18:50:03 -0700 Subject: [PATCH 15/30] Throw func execute error on error --- .../components/file-viewer/file-viewer.tsx | 47 ++++++++++++++++++- .../orchestrator/tool-executor/index.ts | 21 +++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 04dd5d9725e..fa046e79867 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -457,8 +457,9 @@ async function renderPptxSlides( if (cancelled()) return const dpr = Math.min(window.devicePixelRatio || 1, 2) - const W = Math.round(1920 * dpr) - const H = Math.round(1080 * dpr) + const { width, height } = await getPptxRenderSize(data, dpr) + const W = width + const H = height const canvas = document.createElement('canvas') canvas.width = W @@ -476,6 +477,48 @@ async function renderPptxSlides( } } +async function getPptxRenderSize( + data: Uint8Array, + dpr: number +): Promise<{ width: number; height: number }> { + const fallback = { + width: Math.round(1920 * dpr), + height: Math.round(1080 * dpr), + } + + try { + const JSZip = (await import('jszip')).default + const zip = await JSZip.loadAsync(data) + const presentationXml = await zip.file('ppt/presentation.xml')?.async('text') + if (!presentationXml) return fallback + + const match = presentationXml.match(/]*cx="(\d+)"[^>]*cy="(\d+)"/) + if (!match) return fallback + + const cx = Number(match[1]) + const cy = Number(match[2]) + if (!Number.isFinite(cx) || !Number.isFinite(cy) || cx <= 0 || cy <= 0) return fallback + + const aspectRatio = cx / cy + if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) return fallback + + const baseLongEdge = 1920 * dpr + if (aspectRatio >= 1) { + return { + width: Math.round(baseLongEdge), + height: Math.round(baseLongEdge / aspectRatio), + } + } + + return { + width: Math.round(baseLongEdge * aspectRatio), + height: Math.round(baseLongEdge), + } + } catch { + return fallback + } +} + function PptxPreview({ file, workspaceId, diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts index d5dfcb0754b..db8f430c0d7 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts @@ -1243,6 +1243,27 @@ async function executeServerToolDirect( chatId: context.chatId, abortSignal: context.abortSignal, }) + + const resultRecord = + result && typeof result === 'object' && !Array.isArray(result) + ? (result as Record) + : null + + // Some server tools return an explicit { success, message, ... } envelope. + // Preserve tool-level failures instead of reporting them as transport success. + if (resultRecord?.success === false) { + const message = + (typeof resultRecord.error === 'string' && resultRecord.error) || + (typeof resultRecord.message === 'string' && resultRecord.message) || + `${toolName} failed` + + return { + success: false, + error: message, + output: result, + } + } + return { success: true, output: result } } catch (error) { logger.error('Server tool execution failed', { From e21a987673b084aa8250f756359b857e79551644 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 00:07:37 -0700 Subject: [PATCH 16/30] Sandbox PPTX generation in subprocess with vm.createContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI-generated PptxGenJS code was executed via new Function() in both the server (full Node.js access) and browser (XSS risk). Replace with a dedicated Node.js subprocess (pptx-worker.cjs) that runs user code inside vm.createContext with a null-prototype sandbox — no access to process, require, Buffer, or any Node.js globals. Process-level isolation ensures a vm escape cannot reach the main process or DB. File access is brokered via IPC so the subprocess never touches the database directly, mirroring the isolated-vm worker pattern. Compilation happens lazily at serve time (compilePptxIfNeeded) rather than on write, matching industry practice for source-stored PPTX pipelines. - Add pptx-worker.cjs: sandboxed subprocess worker - Add pptx-vm.ts: orchestration, IPC bridge, file brokering - Add /api/workspaces/[id]/pptx/preview: REST-correct preview endpoint - Update serve route: compile pptxgenjs source to binary on demand - Update workspace-file.ts: remove unsafe new Function(), store source only - Update next.config.ts: include pptxgenjs in outputFileTracingIncludes - Update trigger.config.ts: add pptx-worker.cjs and pptxgenjs to build --- .../app/api/files/serve/[...path]/route.ts | 10 +- .../api/workspaces/[id]/pptx/preview/route.ts | 52 +++++ .../tools/server/files/workspace-file.ts | 72 +------ apps/sim/lib/execution/pptx-vm.ts | 179 ++++++++++++++++++ apps/sim/lib/execution/pptx-worker.cjs | 109 +++++++++++ apps/sim/next.config.ts | 8 +- apps/sim/trigger.config.ts | 8 +- 7 files changed, 360 insertions(+), 78 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts create mode 100644 apps/sim/lib/execution/pptx-vm.ts create mode 100644 apps/sim/lib/execution/pptx-worker.cjs diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index c2579156ba4..f248e7488ef 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generatePptxFromCode } from '@/lib/copilot/tools/server/files/workspace-file' +import { generatePptxFromCode } from '@/lib/execution/pptx-vm' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -47,10 +47,6 @@ function stripStorageKeyPrefix(segment: string): string { return STORAGE_KEY_PREFIX_RE.test(segment) ? segment.replace(STORAGE_KEY_PREFIX_RE, '') : segment } -function getWorkspaceIdForCompile(key: string): string | undefined { - return parseWorkspaceFileKey(key) ?? undefined -} - export async function GET( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } @@ -143,7 +139,7 @@ async function handleLocalFile( const rawBuffer = await readFile(filePath) const segment = filename.split('/').pop() || filename const displayName = stripStorageKeyPrefix(segment) - const workspaceId = getWorkspaceIdForCompile(filename) + const workspaceId = parseWorkspaceFileKey(filename) ?? undefined const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded( rawBuffer, displayName, @@ -208,7 +204,7 @@ async function handleCloudProxy( const segment = cloudKey.split('/').pop() || 'download' const displayName = stripStorageKeyPrefix(segment) - const workspaceId = getWorkspaceIdForCompile(cloudKey) + const workspaceId = parseWorkspaceFileKey(cloudKey) ?? undefined const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded( rawBuffer, displayName, diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts new file mode 100644 index 00000000000..98a5072359b --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts @@ -0,0 +1,52 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { generatePptxFromCode } from '@/lib/execution/pptx-vm' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('PptxPreviewAPI') + +/** + * POST /api/workspaces/[id]/pptx/preview + * Compile PptxGenJS source code and return the binary PPTX for streaming preview. + */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id: workspaceId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (!membership) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const body = await req.json() + const { code } = body as { code?: string } + + if (typeof code !== 'string' || code.trim().length === 0) { + return NextResponse.json({ error: 'code is required' }, { status: 400 }) + } + + const buffer = await generatePptxFromCode(code, workspaceId) + + return new NextResponse(buffer, { + status: 200, + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'Content-Length': String(buffer.length), + 'Cache-Control': 'private, no-store', + }, + }) + } catch (err) { + const message = err instanceof Error ? err.message : 'PPTX generation failed' + logger.error('PPTX preview generation failed', { error: message, workspaceId }) + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index b2596cea084..0cecbec13b3 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import PptxGenJS from 'pptxgenjs' import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' import type { WorkspaceFileArgs, WorkspaceFileResult } from '@/lib/copilot/tools/shared/schemas' import { @@ -13,7 +12,6 @@ import { const logger = createLogger('WorkspaceFileServerTool') -const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' const PPTX_SOURCE_MIME = 'text/x-pptxgenjs' const EXT_TO_MIME: Record = { @@ -22,24 +20,6 @@ const EXT_TO_MIME: Record = { '.html': 'text/html', '.json': 'application/json', '.csv': 'text/csv', - '.pptx': PPTX_MIME, -} - -export async function generatePptxFromCode(code: string, workspaceId: string): Promise { - const pptx = new PptxGenJS() - - async function getFileBase64(fileId: string): Promise { - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record) throw new Error(`File not found: ${fileId}`) - const buffer = await downloadWsFile(record) - const mime = record.type || 'image/png' - return `data:${mime};base64,${buffer.toString('base64')}` - } - - const fn = new Function('pptx', 'getFileBase64', `return (async () => { ${code} })()`) - await fn(pptx, getFileBase64) - const output = await pptx.write({ outputType: 'nodebuffer' }) - return output as Buffer } function inferContentType(fileName: string, explicitType?: string): string { @@ -81,25 +61,9 @@ export const workspaceFileServerTool: BaseServerTool { + const candidates = [ + path.join(currentDir, 'pptx-worker.cjs'), + path.join(process.cwd(), 'lib', 'execution', 'pptx-worker.cjs'), + ] + const found = candidates.find((p) => fs.existsSync(p)) + if (!found) throw new Error(`pptx-worker.cjs not found at any of: ${candidates.join(', ')}`) + return found +})() + +/** + * Generate a PPTX file by executing AI-generated PptxGenJS code in a sandboxed + * subprocess. File resources referenced by the code are fetched from workspace + * storage by the main process and delivered to the worker via IPC. + */ +export async function generatePptxFromCode(code: string, workspaceId: string): Promise { + return new Promise((resolve, reject) => { + let proc: ChildProcess | null = null + let settled = false + let startupTimer: ReturnType | null = null + let generationTimer: ReturnType | null = null + + function done(err: Error): void + function done(err: undefined, result: Buffer): void + function done(err: Error | undefined, result?: Buffer): void { + if (settled) return + settled = true + if (startupTimer) clearTimeout(startupTimer) + if (generationTimer) clearTimeout(generationTimer) + try { + proc?.removeAllListeners() + proc?.kill() + } catch { + // Ignore — process may have already exited + } + if (err) reject(err) + else resolve(result as Buffer) + } + + try { + proc = spawn('node', [WORKER_PATH], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + serialization: 'json', + }) + } catch (err) { + done(err instanceof Error ? err : new Error(String(err))) + return + } + + let stderrData = '' + proc.stderr?.on('data', (chunk: Buffer) => { + if (stderrData.length < MAX_STDERR) { + stderrData += chunk.toString() + if (stderrData.length > MAX_STDERR) stderrData = stderrData.slice(0, MAX_STDERR) + } + }) + + startupTimer = setTimeout(() => { + logger.error('PPTX worker failed to start within timeout') + done(new Error('PPTX worker failed to start')) + }, WORKER_STARTUP_TIMEOUT_MS) + + proc.on('exit', (code) => { + if (!settled) { + logger.error('PPTX worker exited unexpectedly', { code, stderr: stderrData.slice(0, 500) }) + done(new Error(`PPTX worker exited unexpectedly (code ${code})`)) + } + }) + + proc.on('error', (err) => { + logger.error('PPTX worker process error', { error: err.message }) + done(err) + }) + + proc.on('message', (rawMsg: unknown) => { + const msg = rawMsg as WorkerMessage + + if (msg.type === 'ready') { + if (startupTimer) { + clearTimeout(startupTimer) + startupTimer = null + } + generationTimer = setTimeout(() => { + logger.error('PPTX generation timed out') + done(new Error('PPTX generation timed out')) + }, GENERATION_TIMEOUT_MS) + proc!.send({ type: 'generate', code }) + return + } + + if (msg.type === 'result') { + done(undefined, Buffer.from(msg.data, 'base64')) + return + } + + if (msg.type === 'error') { + done(new Error(msg.message)) + return + } + + if (msg.type === 'getFile') { + handleFileRequest(proc!, workspaceId, msg).catch((err) => { + logger.error('Failed to handle file request from PPTX worker', { + fileId: msg.fileId, + error: err instanceof Error ? err.message : String(err), + }) + if (proc && !settled) { + try { + proc.send({ + type: 'fileResult', + fileReqId: msg.fileReqId, + error: err instanceof Error ? err.message : 'File fetch failed', + }) + } catch { + // Ignore — process may have died + } + } + }) + } + }) + }) +} + +async function handleFileRequest( + proc: ChildProcess, + workspaceId: string, + msg: Extract +): Promise { + const record = await getWorkspaceFile(workspaceId, msg.fileId) + if (!record) { + proc.send({ + type: 'fileResult', + fileReqId: msg.fileReqId, + error: `File not found: ${msg.fileId}`, + }) + return + } + + const buffer = await downloadWorkspaceFile(record) + const mime = record.type || 'image/png' + proc.send({ + type: 'fileResult', + fileReqId: msg.fileReqId, + data: `data:${mime};base64,${buffer.toString('base64')}`, + }) +} diff --git a/apps/sim/lib/execution/pptx-worker.cjs b/apps/sim/lib/execution/pptx-worker.cjs new file mode 100644 index 00000000000..b3507d8bacc --- /dev/null +++ b/apps/sim/lib/execution/pptx-worker.cjs @@ -0,0 +1,109 @@ +/** + * Node.js worker for sandboxed PPTX generation. + * Runs in a separate Node.js process, communicates with parent via IPC. + * + * Security model: PptxGenJS code from the AI runs inside vm.createContext with a + * null-prototype sandbox so it has no access to Node.js globals (process, require, + * Buffer, fs, etc.). Process-level isolation ensures that even a vm escape cannot + * reach the main Next.js process, the database, or secrets. + */ + +'use strict' + +const vm = require('node:vm') +const PptxGenJS = require('pptxgenjs') + +const EXECUTION_TIMEOUT_MS = 30_000 +const FILE_REQUEST_TIMEOUT_MS = 30_000 + +const pendingFileRequests = new Map() +let fileRequestCounter = 0 + +function sendToParent(msg) { + if (process.send && process.connected) { + process.send(msg) + return true + } + return false +} + +process.on('message', async (msg) => { + if (msg.type === 'generate') { + await handleGenerate(msg) + } else if (msg.type === 'fileResult') { + handleFileResult(msg) + } +}) + +async function handleGenerate(msg) { + const { code } = msg + + try { + const pptx = new PptxGenJS() + + // Delegates file fetches to the parent process via IPC so the subprocess + // never touches the database directly. + const getFileBase64 = (fileId) => + new Promise((resolve, reject) => { + if (typeof fileId !== 'string' || fileId.length === 0) { + reject(new Error('fileId must be a non-empty string')) + return + } + + const fileReqId = ++fileRequestCounter + const timeout = setTimeout(() => { + if (pendingFileRequests.has(fileReqId)) { + pendingFileRequests.delete(fileReqId) + reject(new Error(`File request timed out for fileId: ${fileId}`)) + } + }, FILE_REQUEST_TIMEOUT_MS) + + pendingFileRequests.set(fileReqId, { resolve, reject, timeout }) + + if (!sendToParent({ type: 'getFile', fileReqId, fileId })) { + clearTimeout(timeout) + pendingFileRequests.delete(fileReqId) + reject(new Error('Parent process disconnected')) + } + }) + + // Null-prototype sandbox: no access to Node.js globals whatsoever. + const sandbox = Object.create(null) + sandbox.pptx = pptx + sandbox.getFileBase64 = getFileBase64 + + vm.createContext(sandbox) + + // vm timeout only covers synchronous ticks; the subprocess kill timeout set + // by the parent process bounds total wall-clock time. + const promise = vm.runInContext(`(async () => { ${code} })()`, sandbox, { + timeout: EXECUTION_TIMEOUT_MS, + filename: 'pptx-code.js', + }) + await promise + + const output = await pptx.write({ outputType: 'nodebuffer' }) + const base64 = Buffer.from(output).toString('base64') + sendToParent({ type: 'result', data: base64 }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + sendToParent({ type: 'error', message }) + } +} + +function handleFileResult(msg) { + const { fileReqId, data, error } = msg + const pending = pendingFileRequests.get(fileReqId) + if (!pending) return + + clearTimeout(pending.timeout) + pendingFileRequests.delete(fileReqId) + + if (error) { + pending.reject(new Error(error)) + } else { + pending.resolve(data) + } +} + +sendToParent({ type: 'ready' }) diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 48cefec6661..a5be7229498 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -90,7 +90,13 @@ const nextConfig: NextConfig = { ], outputFileTracingIncludes: { '/api/tools/stagehand/*': ['./node_modules/ws/**/*'], - '/*': ['./node_modules/sharp/**/*', './node_modules/@img/**/*'], + '/*': [ + './node_modules/sharp/**/*', + './node_modules/@img/**/*', + // pptxgenjs is required by the PPTX worker subprocess at runtime. + // It has no static import that Next.js can trace, so we include it explicitly. + './node_modules/pptxgenjs/**/*', + ], }, experimental: { optimizeCss: true, diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 5f5a4de0034..7e98b837def 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -15,11 +15,13 @@ export default defineConfig({ }, dirs: ['./background'], build: { - external: ['isolated-vm'], + external: ['isolated-vm', 'pptxgenjs'], extensions: [ - additionalFiles({ files: ['./lib/execution/isolated-vm-worker.cjs'] }), + additionalFiles({ + files: ['./lib/execution/isolated-vm-worker.cjs', './lib/execution/pptx-worker.cjs'], + }), additionalPackages({ - packages: ['unpdf', 'pdf-lib', 'isolated-vm'], + packages: ['unpdf', 'pdf-lib', 'isolated-vm', 'pptxgenjs'], }), ], }, From cc50d1e751005aa4c776faf2f41e03b8e0f95033 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 00:09:08 -0700 Subject: [PATCH 17/30] upgrade deps, file viewer --- .../files/components/file-viewer/file-viewer.tsx | 16 +++++++++++----- bun.lock | 1 + 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index fa046e79867..a78559d8b98 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -556,11 +556,17 @@ function PptxPreview({ setRenderError(null) if (streamingContent !== undefined) { - const PptxGenJS = (await import('pptxgenjs')).default - const pptx = new PptxGenJS() - const fn = new Function('pptx', `return (async () => { ${streamingContent} })()`) - await fn(pptx) - const arrayBuffer = (await pptx.write({ outputType: 'arraybuffer' })) as ArrayBuffer + const response = await fetch(`/api/workspaces/${workspaceId}/pptx/preview`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: streamingContent }), + }) + if (!response.ok) { + const err = await response.json().catch(() => ({ error: 'Preview failed' })) + throw new Error(err.error || 'Preview failed') + } + if (cancelled) return + const arrayBuffer = await response.arrayBuffer() if (cancelled) return const data = new Uint8Array(arrayBuffer) const images: string[] = [] diff --git a/bun.lock b/bun.lock index dd644444895..e0e1a25291a 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", From 119d5a55ed08a1e07c7112029579d68f8e3c0cb8 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 00:13:06 -0700 Subject: [PATCH 18/30] Fix auth bypass, SSRF, and wrong size limit comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'patch' to workspace_file WRITE_ACTIONS — patch operation was missing, letting read-only users modify file content - Add download_to_workspace_file to WRITE_ACTIONS with '*' wildcard — tool was completely ungated, letting read-only users write workspace files - Update isActionAllowed to handle '*' (always-write tools) and undefined action (tools with no operation/action field) - Block private/internal URLs in download_to_workspace_file to prevent SSRF against RFC 1918 ranges, loopback, and cloud metadata endpoints - Fix file-reader.ts image size limit comment and error message (was 20MB, actual constant is 5MB) --- .../files/download-to-workspace-file.ts | 27 +++++++++++++++++++ apps/sim/lib/copilot/tools/server/router.ts | 27 ++++++++++++++----- apps/sim/lib/copilot/vfs/file-reader.ts | 4 +-- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index dab810ab72d..27f30a3d715 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -30,6 +30,26 @@ const DownloadToWorkspaceFileResultSchema = z.object({ type DownloadToWorkspaceFileArgs = z.infer type DownloadToWorkspaceFileResult = z.infer +function isPrivateUrl(url: string): boolean { + try { + const { hostname, protocol } = new URL(url) + if (protocol !== 'https:' && protocol !== 'http:') return true + if (hostname === 'localhost' || hostname === '::1') return true + const ipv4 = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/) + if (ipv4) { + const [a, b] = [Number(ipv4[1]), Number(ipv4[2])] + // Loopback, RFC 1918, link-local (including cloud metadata 169.254.169.254) + if (a === 127 || a === 10 || a === 0) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 169 && b === 254) return true + } + return false + } catch { + return true + } +} + function sanitizeFileName(fileName: string): string { return fileName.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_').trim() } @@ -134,6 +154,13 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< try { assertServerToolNotAborted(context) + if (isPrivateUrl(params.url)) { + return { + success: false, + message: 'Downloading from private or internal URLs is not allowed', + } + } + const response = await fetch(params.url, { redirect: 'follow', signal: context.abortSignal, diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 83e6425719b..cda4c6c7ed3 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -64,17 +64,29 @@ const WRITE_ACTIONS: Record = { manage_mcp_tool: ['add', 'edit', 'delete'], manage_skill: ['add', 'edit', 'delete'], manage_credential: ['rename', 'delete'], - workspace_file: ['write', 'update', 'delete', 'rename'], + workspace_file: ['write', 'update', 'delete', 'rename', 'patch'], + download_to_workspace_file: ['*'], generate_visualization: ['generate'], generate_image: ['generate'], } -function isActionAllowed(toolName: string, action: string, userPermission: string): boolean { - const writeActions = WRITE_ACTIONS[toolName] - if (!writeActions || !writeActions.includes(action)) return true +function isWritePermission(userPermission: string): boolean { return userPermission === 'write' || userPermission === 'admin' } +function isActionAllowed( + toolName: string, + action: string | undefined, + userPermission: string +): boolean { + const writeActions = WRITE_ACTIONS[toolName] + if (!writeActions) return true + // '*' means the tool is always a write operation regardless of action field + if (writeActions.includes('*')) return isWritePermission(userPermission) + if (action && writeActions.includes(action)) return isWritePermission(userPermission) + return true +} + /** Registry of all server tools. Tools self-declare their validation schemas. */ const serverToolRegistry: Record = { [getBlocksMetadataServerTool.name]: getBlocksMetadataServerTool, @@ -115,10 +127,11 @@ export async function routeExecution( // Action-level permission enforcement for mixed read/write tools if (context?.userPermission && WRITE_ACTIONS[toolName]) { const p = payload as Record - const action = (p?.operation ?? p?.action) as string - if (action && !isActionAllowed(toolName, action, context.userPermission)) { + const action = (p?.operation ?? p?.action) as string | undefined + if (!isActionAllowed(toolName, action, context.userPermission)) { + const actionLabel = action ? `'${action}' on ` : '' throw new Error( - `Permission denied: '${action}' on ${toolName} requires write access. You have '${context.userPermission}' permission.` + `Permission denied: ${actionLabel}${toolName} requires write access. You have '${context.userPermission}' permission.` ) } } diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index 87e37fdc72d..19e30b3ffda 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -6,7 +6,7 @@ import { isImageFileType } from '@/lib/uploads/utils/file-utils' const logger = createLogger('FileReader') const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB -const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 20 MB +const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB const TEXT_TYPES = new Set([ 'text/plain', @@ -54,7 +54,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_IMAGE_READ_BYTES) { return { - content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 20MB)]`, + content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 5MB)]`, totalLines: 1, } } From cc214cd4772d1ce098594fb5993d1ecdc7eb65fe Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 00:21:04 -0700 Subject: [PATCH 19/30] Fix Buffer not assignable to BodyInit in preview route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap Buffer in Uint8Array for NextResponse body — Buffer is not directly assignable to BodyInit in strict TypeScript mode. --- apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts index 98a5072359b..5d64309d4cd 100644 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts @@ -36,7 +36,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const buffer = await generatePptxFromCode(code, workspaceId) - return new NextResponse(buffer, { + return new NextResponse(new Uint8Array(buffer), { status: 200, headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', From c5f73a762d2b50438dc8b3911242fe8b5cb10b81 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 08:04:20 -0700 Subject: [PATCH 20/30] Fix SSRF bypass, IPv6 coverage, download size cap, and missing deps - Validate post-redirect URL to block SSRF via open redirectors - Expand IPv6 private range blocking: fe80::/10, fc00::/7, ::ffff: mapped - Add 50 MB download cap (Content-Length pre-check + post-buffer check) - Add refetchOnWindowFocus: 'always' to useWorkspaceFileBinary - Add workspaceId to PptxPreview useEffect dependency array --- .../components/file-viewer/file-viewer.tsx | 2 +- apps/sim/hooks/queries/workspace-files.ts | 1 + .../files/download-to-workspace-file.ts | 58 ++++++++++++++++--- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index a78559d8b98..ee0e9f7756c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -610,7 +610,7 @@ function PptxPreview({ return () => { cancelled = true } - }, [fileData, dataUpdatedAt, streamingContent, cacheKey]) + }, [fileData, dataUpdatedAt, streamingContent, cacheKey, workspaceId]) const error = fetchError ? fetchError instanceof Error diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index c4ad910a5c7..8bb9885fb4d 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -119,6 +119,7 @@ export function useWorkspaceFileBinary(workspaceId: string, fileId: string, key: queryFn: ({ signal }) => fetchWorkspaceFileBinary(key, signal), enabled: !!workspaceId && !!fileId && !!key, staleTime: 30 * 1000, + refetchOnWindowFocus: 'always', }) } diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index 27f30a3d715..7a49d84a387 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -30,20 +30,41 @@ const DownloadToWorkspaceFileResultSchema = z.object({ type DownloadToWorkspaceFileArgs = z.infer type DownloadToWorkspaceFileResult = z.infer +const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB + +function isPrivateIPv4(a: number, b: number): boolean { + if (a === 0 || a === 127 || a === 10) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 169 && b === 254) return true // link-local + cloud metadata + return false +} + function isPrivateUrl(url: string): boolean { try { const { hostname, protocol } = new URL(url) if (protocol !== 'https:' && protocol !== 'http:') return true - if (hostname === 'localhost' || hostname === '::1') return true + if (hostname === 'localhost') return true + + // Plain IPv4 const ipv4 = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/) if (ipv4) { - const [a, b] = [Number(ipv4[1]), Number(ipv4[2])] - // Loopback, RFC 1918, link-local (including cloud metadata 169.254.169.254) - if (a === 127 || a === 10 || a === 0) return true - if (a === 172 && b >= 16 && b <= 31) return true - if (a === 192 && b === 168) return true - if (a === 169 && b === 254) return true + return isPrivateIPv4(Number(ipv4[1]), Number(ipv4[2])) } + + // IPv6: block loopback, link-local (fe80::/10), unique local (fc00::/7), + // and IPv4-mapped (::ffff:a.b.c.d) that resolve to private IPv4 + if (hostname.includes(':')) { + const h = hostname.toLowerCase() + if (h === '::1') return true + if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) + return true // fe80::/10 link-local + if (h.startsWith('fc') || h.startsWith('fd')) return true // fc00::/7 unique local + const mapped = h.match(/^::ffff:(\d+)\.(\d+)\.(\d+)\.(\d+)$/) + if (mapped) return isPrivateIPv4(Number(mapped[1]), Number(mapped[2])) + return false + } + return false } catch { return true @@ -166,6 +187,14 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< signal: context.abortSignal, }) + // Block SSRF via redirect (e.g. initial URL passes check but redirects to internal IP) + if (response.url && response.url !== params.url && isPrivateUrl(response.url)) { + return { + success: false, + message: 'Downloading from private or internal URLs is not allowed', + } + } + if (!response.ok) { return { success: false, @@ -173,6 +202,14 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< } } + const contentLength = Number(response.headers.get('content-length') ?? Number.NaN) + if (!Number.isNaN(contentLength) && contentLength > MAX_DOWNLOAD_BYTES) { + return { + success: false, + message: `File too large (limit ${MAX_DOWNLOAD_BYTES / 1024 / 1024} MB)`, + } + } + const mimeType = resolveMimeType( response.headers.get('content-type'), params.fileName, @@ -190,6 +227,13 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< const arrayBuffer = await response.arrayBuffer() const fileBuffer = Buffer.from(arrayBuffer) + if (fileBuffer.length > MAX_DOWNLOAD_BYTES) { + return { + success: false, + message: `File too large (limit ${MAX_DOWNLOAD_BYTES / 1024 / 1024} MB)`, + } + } + if (fileBuffer.length === 0) { return { success: false, message: 'Downloaded file is empty' } } From ad2dab15334acf7f96e80cfe87ebc5c971f9cf4f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 08:10:03 -0700 Subject: [PATCH 21/30] Replace hand-rolled SSRF guard with secureFetchWithValidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation hand-rolled private-IP detection with regex, missing edge cases (octal IPs, hex IPs, full IPv6 coverage). The codebase already has secureFetchWithValidation which uses ipaddr.js, handles DNS rebinding via IP pinning, validates each redirect target, and enforces a streaming size cap — removing the need for isPrivateUrl, isPrivateIPv4, the manual pre/post-redirect checks, and the Content-Length + post-buffer size checks. --- .../files/download-to-workspace-file.ts | 95 +++---------------- 1 file changed, 11 insertions(+), 84 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index 7a49d84a387..3664b741651 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -5,6 +5,7 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getExtensionFromMimeType, @@ -14,6 +15,8 @@ import { const logger = createLogger('DownloadToWorkspaceFileTool') +const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB + const DownloadToWorkspaceFileArgsSchema = z.object({ url: z.string().url(), fileName: z.string().min(1).optional(), @@ -30,47 +33,6 @@ const DownloadToWorkspaceFileResultSchema = z.object({ type DownloadToWorkspaceFileArgs = z.infer type DownloadToWorkspaceFileResult = z.infer -const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB - -function isPrivateIPv4(a: number, b: number): boolean { - if (a === 0 || a === 127 || a === 10) return true - if (a === 172 && b >= 16 && b <= 31) return true - if (a === 192 && b === 168) return true - if (a === 169 && b === 254) return true // link-local + cloud metadata - return false -} - -function isPrivateUrl(url: string): boolean { - try { - const { hostname, protocol } = new URL(url) - if (protocol !== 'https:' && protocol !== 'http:') return true - if (hostname === 'localhost') return true - - // Plain IPv4 - const ipv4 = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/) - if (ipv4) { - return isPrivateIPv4(Number(ipv4[1]), Number(ipv4[2])) - } - - // IPv6: block loopback, link-local (fe80::/10), unique local (fc00::/7), - // and IPv4-mapped (::ffff:a.b.c.d) that resolve to private IPv4 - if (hostname.includes(':')) { - const h = hostname.toLowerCase() - if (h === '::1') return true - if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) - return true // fe80::/10 link-local - if (h.startsWith('fc') || h.startsWith('fd')) return true // fc00::/7 unique local - const mapped = h.match(/^::ffff:(\d+)\.(\d+)\.(\d+)\.(\d+)$/) - if (mapped) return isPrivateIPv4(Number(mapped[1]), Number(mapped[2])) - return false - } - - return false - } catch { - return true - } -} - function sanitizeFileName(fileName: string): string { return fileName.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_').trim() } @@ -137,13 +99,13 @@ function ensureFileExtension(fileName: string, mimeType: string): string { function inferOutputFileName( requestedFileName: string | undefined, - response: Response, + headers: { get(name: string): string | null }, url: string, mimeType: string ): string { const preferredName = requestedFileName || - extractFileNameFromContentDisposition(response.headers.get('content-disposition')) || + extractFileNameFromContentDisposition(headers.get('content-disposition')) || extractFileNameFromUrl(url) || 'downloaded-file' @@ -175,26 +137,12 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< try { assertServerToolNotAborted(context) - if (isPrivateUrl(params.url)) { - return { - success: false, - message: 'Downloading from private or internal URLs is not allowed', - } - } - - const response = await fetch(params.url, { - redirect: 'follow', - signal: context.abortSignal, + // secureFetchWithValidation handles: DNS resolution, private IP blocking (via ipaddr.js), + // SSRF-safe redirect following, and streaming size enforcement + const response = await secureFetchWithValidation(params.url, { + maxResponseBytes: MAX_DOWNLOAD_BYTES, }) - // Block SSRF via redirect (e.g. initial URL passes check but redirects to internal IP) - if (response.url && response.url !== params.url && isPrivateUrl(response.url)) { - return { - success: false, - message: 'Downloading from private or internal URLs is not allowed', - } - } - if (!response.ok) { return { success: false, @@ -202,38 +150,18 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< } } - const contentLength = Number(response.headers.get('content-length') ?? Number.NaN) - if (!Number.isNaN(contentLength) && contentLength > MAX_DOWNLOAD_BYTES) { - return { - success: false, - message: `File too large (limit ${MAX_DOWNLOAD_BYTES / 1024 / 1024} MB)`, - } - } - const mimeType = resolveMimeType( response.headers.get('content-type'), params.fileName, - response.url || params.url - ) - const fileName = inferOutputFileName( - params.fileName, - response, - response.url || params.url, - mimeType + params.url ) + const fileName = inferOutputFileName(params.fileName, response.headers, params.url, mimeType) assertServerToolNotAborted(context) const arrayBuffer = await response.arrayBuffer() const fileBuffer = Buffer.from(arrayBuffer) - if (fileBuffer.length > MAX_DOWNLOAD_BYTES) { - return { - success: false, - message: `File too large (limit ${MAX_DOWNLOAD_BYTES / 1024 / 1024} MB)`, - } - } - if (fileBuffer.length === 0) { return { success: false, message: 'Downloaded file is empty' } } @@ -248,7 +176,6 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< logger.info('Downloaded remote file to workspace', { sourceUrl: params.url, - resolvedUrl: response.url, fileId: uploaded.id, fileName: uploaded.name, mimeType, From 51b47f1fd93221081bed9bf146d69b83f6f8f9bc Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 08:22:39 -0700 Subject: [PATCH 22/30] Fix streaming preview cache ordering and patch ambiguity - PptxPreview: move streaming content check before cache check so live AI-generated previews are never blocked by a warm cache from a prior file view - workspace_file patch: reject edits where the search string matches more than one location, preventing silent wrong-location patches - workspace_file patch: remove redundant Record cast; args is already Zod-validated with the correct field types --- .../components/file-viewer/file-viewer.tsx | 10 ++++---- .../tools/server/files/workspace-file.ts | 23 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index ee0e9f7756c..5b4e3c800e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -543,11 +543,6 @@ function PptxPreview({ const [renderError, setRenderError] = useState(null) useEffect(() => { - if (cached) { - setSlides(cached) - return - } - let cancelled = false async function render() { @@ -581,6 +576,11 @@ function PptxPreview({ return } + if (cached) { + setSlides(cached) + return + } + if (!fileData) return const data = new Uint8Array(fileData) const images: string[] = [] diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 0cecbec13b3..6d335f958db 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -200,15 +200,13 @@ export const workspaceFileServerTool: BaseServerTool).fileId as string | undefined - const edits = (args as Record).edits as - | { search: string; replace: string }[] - | undefined + const fileId = args?.fileId + const edits = args?.edits if (!fileId) { return { success: false, message: 'fileId is required for patch operation' } } - if (!edits || !Array.isArray(edits) || edits.length === 0) { + if (!edits || edits.length === 0) { return { success: false, message: 'edits array is required for patch operation' } } @@ -221,14 +219,23 @@ export const workspaceFileServerTool: BaseServerTool 100 ? '...' : ''}"`, } } - content = content.slice(0, idx) + edit.replace + content.slice(idx + edit.search.length) + if (content.indexOf(edit.search, firstIdx + 1) !== -1) { + return { + success: false, + message: `Patch failed: search string is ambiguous — found at multiple locations in "${fileRecord.name}". Use a longer, unique search string.`, + } + } + content = + content.slice(0, firstIdx) + + edit.replace + + content.slice(firstIdx + edit.search.length) } const patchedBuffer = Buffer.from(content, 'utf-8') From 7ab98e288c3793055f5bec59a5c693f74bd6a7e1 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 08:42:08 -0700 Subject: [PATCH 23/30] Fix subprocess env leak, unbounded preview spawning, and dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pptx-vm: pass minimal env to worker subprocess so it cannot inherit DB URLs, API keys, or other secrets from the Next.js process on a vm.createContext escape - PptxPreview: add AbortController so in-flight preview fetch is cancelled when the effect re-runs (e.g. next SSE update), preventing unbounded concurrent subprocesses; add 500ms debounce on streaming renders to reduce subprocess churn during rapid AI generation - file-reader: remove dead code — the `if (!isReadableType)` guard on line 110 was always true (all readable types returned earlier at line 76), making the subsequent `return null` unreachable --- .../components/file-viewer/file-viewer.tsx | 17 +++++++++++++++-- apps/sim/lib/copilot/vfs/file-reader.ts | 10 +++------- apps/sim/lib/execution/pptx-vm.ts | 4 ++++ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 5b4e3c800e8..588eba6ac85 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -544,8 +544,11 @@ function PptxPreview({ useEffect(() => { let cancelled = false + const controller = new AbortController() + let debounceTimer: ReturnType | null = null async function render() { + if (cancelled) return try { setRendering(true) setRenderError(null) @@ -555,6 +558,7 @@ function PptxPreview({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: streamingContent }), + signal: controller.signal, }) if (!response.ok) { const err = await response.json().catch(() => ({ error: 'Preview failed' })) @@ -596,7 +600,7 @@ function PptxPreview({ pptxCacheSet(cacheKey, images) } } catch (err) { - if (!cancelled) { + if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) { const msg = err instanceof Error ? err.message : 'Failed to render presentation' logger.error('PPTX render failed', { error: msg }) setRenderError(msg) @@ -606,9 +610,18 @@ function PptxPreview({ } } - render() + // Debounce streaming renders so rapid SSE updates don't spawn a subprocess + // per event. Non-streaming renders (file load / cache) run immediately. + if (streamingContent !== undefined) { + debounceTimer = setTimeout(render, 500) + } else { + render() + } + return () => { cancelled = true + if (debounceTimer) clearTimeout(debounceTimer) + controller.abort() } }, [fileData, dataUpdatedAt, streamingContent, cacheKey, workspaceId]) diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index 19e30b3ffda..d3fd77aaf3a 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -107,14 +107,10 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise Date: Mon, 23 Mar 2026 08:50:39 -0700 Subject: [PATCH 24/30] Wire abort signal through to subprocess and correct security comment - generatePptxFromCode now accepts an optional AbortSignal; when the signal fires (e.g. client disconnects mid-stream), done() is called which clears timers and kills the subprocess immediately rather than waiting for the 60s timeout - preview route passes req.signal so client-side AbortController.abort() (from the streaming debounce cleanup) propagates all the way to the worker process - Correct misleading comment in pptx-worker.cjs and pptx-vm.ts: vm.createContext is NOT a sandbox when non-primitives are in scope; the real security boundary is the subprocess + minimal env --- .../api/workspaces/[id]/pptx/preview/route.ts | 2 +- apps/sim/lib/execution/pptx-vm.ts | 18 +++++++++++++++++- apps/sim/lib/execution/pptx-worker.cjs | 9 +++++---- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts index 5d64309d4cd..1f62a983e57 100644 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts @@ -34,7 +34,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'code is required' }, { status: 400 }) } - const buffer = await generatePptxFromCode(code, workspaceId) + const buffer = await generatePptxFromCode(code, workspaceId, req.signal) return new NextResponse(new Uint8Array(buffer), { status: 200, diff --git a/apps/sim/lib/execution/pptx-vm.ts b/apps/sim/lib/execution/pptx-vm.ts index 3c74cc8c92f..01f8bcf1234 100644 --- a/apps/sim/lib/execution/pptx-vm.ts +++ b/apps/sim/lib/execution/pptx-vm.ts @@ -45,8 +45,19 @@ const WORKER_PATH = (() => { * Generate a PPTX file by executing AI-generated PptxGenJS code in a sandboxed * subprocess. File resources referenced by the code are fetched from workspace * storage by the main process and delivered to the worker via IPC. + * + * Security note: `vm.createContext` is NOT a true security sandbox — objects + * injected into the context retain their prototypes, enabling escape via + * `pptx.constructor.constructor('return process')()`. The actual security + * boundary is the subprocess itself: even a full vm escape only reaches the + * subprocess's minimal env (`{ PATH }`), not the parent Next.js process, + * database, or secrets. */ -export async function generatePptxFromCode(code: string, workspaceId: string): Promise { +export async function generatePptxFromCode( + code: string, + workspaceId: string, + signal?: AbortSignal +): Promise { return new Promise((resolve, reject) => { let proc: ChildProcess | null = null let settled = false @@ -70,6 +81,11 @@ export async function generatePptxFromCode(code: string, workspaceId: string): P else resolve(result as Buffer) } + // Propagate caller abort (e.g. client disconnect) to the subprocess. + signal?.addEventListener('abort', () => done(new Error('PPTX generation cancelled')), { + once: true, + }) + try { proc = spawn('node', [WORKER_PATH], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'], diff --git a/apps/sim/lib/execution/pptx-worker.cjs b/apps/sim/lib/execution/pptx-worker.cjs index b3507d8bacc..ad316af0352 100644 --- a/apps/sim/lib/execution/pptx-worker.cjs +++ b/apps/sim/lib/execution/pptx-worker.cjs @@ -2,10 +2,11 @@ * Node.js worker for sandboxed PPTX generation. * Runs in a separate Node.js process, communicates with parent via IPC. * - * Security model: PptxGenJS code from the AI runs inside vm.createContext with a - * null-prototype sandbox so it has no access to Node.js globals (process, require, - * Buffer, fs, etc.). Process-level isolation ensures that even a vm escape cannot - * reach the main Next.js process, the database, or secrets. + * Security model: vm.createContext is NOT a true sandbox — objects with normal + * prototypes (pptx, getFileBase64) allow escape via the constructor chain. + * The actual security boundary is process-level isolation: this subprocess + * runs with a minimal env (only PATH), so a vm escape cannot reach the parent + * Next.js process, the database, or any secrets. */ 'use strict' From 5de3616ef04dc006cac2bb0616b6b4e604e7cd1a Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 08:54:52 -0700 Subject: [PATCH 25/30] Remove implementation-specific comments from pptx worker files Co-Authored-By: Claude Sonnet 4.6 --- apps/sim/lib/execution/pptx-vm.ts | 17 ++--------------- apps/sim/lib/execution/pptx-worker.cjs | 11 ----------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/apps/sim/lib/execution/pptx-vm.ts b/apps/sim/lib/execution/pptx-vm.ts index 01f8bcf1234..eb062b3c3c2 100644 --- a/apps/sim/lib/execution/pptx-vm.ts +++ b/apps/sim/lib/execution/pptx-vm.ts @@ -1,10 +1,8 @@ /** * Sandboxed PPTX generation via subprocess. * - * Mirrors the pattern used by isolated-vm.ts: user code runs in a separate - * Node.js child process so that even a vm sandbox escape cannot reach the main - * Next.js process, the database, or any secrets. File access is brokered via - * IPC — the subprocess never touches the database directly. + * User code runs in a separate Node.js child process. File access is brokered + * via IPC — the subprocess never touches the database directly. */ import { type ChildProcess, spawn } from 'node:child_process' @@ -45,13 +43,6 @@ const WORKER_PATH = (() => { * Generate a PPTX file by executing AI-generated PptxGenJS code in a sandboxed * subprocess. File resources referenced by the code are fetched from workspace * storage by the main process and delivered to the worker via IPC. - * - * Security note: `vm.createContext` is NOT a true security sandbox — objects - * injected into the context retain their prototypes, enabling escape via - * `pptx.constructor.constructor('return process')()`. The actual security - * boundary is the subprocess itself: even a full vm escape only reaches the - * subprocess's minimal env (`{ PATH }`), not the parent Next.js process, - * database, or secrets. */ export async function generatePptxFromCode( code: string, @@ -81,7 +72,6 @@ export async function generatePptxFromCode( else resolve(result as Buffer) } - // Propagate caller abort (e.g. client disconnect) to the subprocess. signal?.addEventListener('abort', () => done(new Error('PPTX generation cancelled')), { once: true, }) @@ -90,9 +80,6 @@ export async function generatePptxFromCode( proc = spawn('node', [WORKER_PATH], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'], serialization: 'json', - // Prevent the subprocess from inheriting secrets (DB URL, API keys, etc.) - // from the parent Next.js process. pptxgenjs only needs PATH to resolve - // its own require() calls. env: { PATH: process.env.PATH ?? '' } as unknown as NodeJS.ProcessEnv, }) } catch (err) { diff --git a/apps/sim/lib/execution/pptx-worker.cjs b/apps/sim/lib/execution/pptx-worker.cjs index ad316af0352..0f99a3eaef2 100644 --- a/apps/sim/lib/execution/pptx-worker.cjs +++ b/apps/sim/lib/execution/pptx-worker.cjs @@ -1,12 +1,6 @@ /** * Node.js worker for sandboxed PPTX generation. * Runs in a separate Node.js process, communicates with parent via IPC. - * - * Security model: vm.createContext is NOT a true sandbox — objects with normal - * prototypes (pptx, getFileBase64) allow escape via the constructor chain. - * The actual security boundary is process-level isolation: this subprocess - * runs with a minimal env (only PATH), so a vm escape cannot reach the parent - * Next.js process, the database, or any secrets. */ 'use strict' @@ -42,8 +36,6 @@ async function handleGenerate(msg) { try { const pptx = new PptxGenJS() - // Delegates file fetches to the parent process via IPC so the subprocess - // never touches the database directly. const getFileBase64 = (fileId) => new Promise((resolve, reject) => { if (typeof fileId !== 'string' || fileId.length === 0) { @@ -68,15 +60,12 @@ async function handleGenerate(msg) { } }) - // Null-prototype sandbox: no access to Node.js globals whatsoever. const sandbox = Object.create(null) sandbox.pptx = pptx sandbox.getFileBase64 = getFileBase64 vm.createContext(sandbox) - // vm timeout only covers synchronous ticks; the subprocess kill timeout set - // by the parent process bounds total wall-clock time. const promise = vm.runInContext(`(async () => { ${code} })()`, sandbox, { timeout: EXECUTION_TIMEOUT_MS, filename: 'pptx-code.js', From 1e87ae3931cb858158cee8dd00b9e89699780867 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 09:32:02 -0700 Subject: [PATCH 26/30] Fix pre-aborted signal, pptx-worker tracing, and binary fetch cache --- apps/sim/hooks/queries/workspace-files.ts | 2 +- apps/sim/lib/execution/pptx-vm.ts | 5 +++++ apps/sim/next.config.ts | 5 +++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 8bb9885fb4d..f1f4b7eb73f 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -103,7 +103,7 @@ export function useWorkspaceFileContent( async function fetchWorkspaceFileBinary(key: string, signal?: AbortSignal): Promise { const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace` - const response = await fetch(serveUrl, { signal }) + const response = await fetch(serveUrl, { signal, cache: 'no-store' }) if (!response.ok) throw new Error('Failed to fetch file content') return response.arrayBuffer() } diff --git a/apps/sim/lib/execution/pptx-vm.ts b/apps/sim/lib/execution/pptx-vm.ts index eb062b3c3c2..2b608783def 100644 --- a/apps/sim/lib/execution/pptx-vm.ts +++ b/apps/sim/lib/execution/pptx-vm.ts @@ -72,6 +72,11 @@ export async function generatePptxFromCode( else resolve(result as Buffer) } + if (signal?.aborted) { + reject(new Error('PPTX generation cancelled')) + return + } + signal?.addEventListener('abort', () => done(new Error('PPTX generation cancelled')), { once: true, }) diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index a5be7229498..fceb71dfa12 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -93,9 +93,10 @@ const nextConfig: NextConfig = { '/*': [ './node_modules/sharp/**/*', './node_modules/@img/**/*', - // pptxgenjs is required by the PPTX worker subprocess at runtime. - // It has no static import that Next.js can trace, so we include it explicitly. + // pptxgenjs and the PPTX worker are required at runtime by the subprocess. + // Neither has a static import that Next.js can trace, so we include them explicitly. './node_modules/pptxgenjs/**/*', + './lib/execution/pptx-worker.cjs', ], }, experimental: { From 7a293c7eeaea240c58a448f54b89f23270d271e5 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 10:32:01 -0700 Subject: [PATCH 27/30] Lazy worker path resolution, code size cap, unused param prefix --- .../sim/app/api/workspaces/[id]/pptx/preview/route.ts | 5 +++++ .../w/[workflowId]/components/panel/panel.tsx | 2 +- apps/sim/lib/execution/pptx-vm.ts | 11 +++++++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts index 1f62a983e57..e7615aba748 100644 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts @@ -34,6 +34,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'code is required' }, { status: 400 }) } + const MAX_CODE_BYTES = 512 * 1024 + if (Buffer.byteLength(code, 'utf-8') > MAX_CODE_BYTES) { + return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 }) + } + const buffer = await generatePptxFromCode(code, workspaceId, req.signal) return new NextResponse(new Uint8Array(buffer), { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index cdeb54a94a2..dcd89f2edb9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -292,7 +292,7 @@ export const Panel = memo(function Panel() { ) const handleCopilotToolResult = useCallback( - (toolName: string, success: boolean, output: unknown) => { + (toolName: string, success: boolean, _output: unknown) => { if (toolName !== 'edit_workflow' || !success) return const workflowId = activeWorkflowId || useWorkflowRegistry.getState().activeWorkflowId if (!workflowId) return diff --git a/apps/sim/lib/execution/pptx-vm.ts b/apps/sim/lib/execution/pptx-vm.ts index 2b608783def..007054df095 100644 --- a/apps/sim/lib/execution/pptx-vm.ts +++ b/apps/sim/lib/execution/pptx-vm.ts @@ -27,17 +27,20 @@ type WorkerMessage = | { type: 'error'; message: string } | { type: 'getFile'; fileReqId: number; fileId: string } -// Resolved once at module load — the path never changes at runtime. const currentDir = path.dirname(fileURLToPath(import.meta.url)) -const WORKER_PATH = (() => { +let cachedWorkerPath: string | undefined + +function getWorkerPath(): string { + if (cachedWorkerPath) return cachedWorkerPath const candidates = [ path.join(currentDir, 'pptx-worker.cjs'), path.join(process.cwd(), 'lib', 'execution', 'pptx-worker.cjs'), ] const found = candidates.find((p) => fs.existsSync(p)) if (!found) throw new Error(`pptx-worker.cjs not found at any of: ${candidates.join(', ')}`) + cachedWorkerPath = found return found -})() +} /** * Generate a PPTX file by executing AI-generated PptxGenJS code in a sandboxed @@ -82,7 +85,7 @@ export async function generatePptxFromCode( }) try { - proc = spawn('node', [WORKER_PATH], { + proc = spawn('node', [getWorkerPath()], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'], serialization: 'json', env: { PATH: process.env.PATH ?? '' } as unknown as NodeJS.ProcessEnv, From 5712ac4a25455cb32e7cd3bff8475c8fd12a9831 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 10:42:52 -0700 Subject: [PATCH 28/30] Add cache-busting timestamp to binary file fetch --- apps/sim/hooks/queries/workspace-files.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index f1f4b7eb73f..074ed3b8c8d 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -102,7 +102,7 @@ export function useWorkspaceFileContent( } async function fetchWorkspaceFileBinary(key: string, signal?: AbortSignal): Promise { - const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace` + const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&t=${Date.now()}` const response = await fetch(serveUrl, { signal, cache: 'no-store' }) if (!response.ok) throw new Error('Failed to fetch file content') return response.arrayBuffer() From 3c555af450afac0e3c4f99a4e8084ea0af29f5f9 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 11:00:21 -0700 Subject: [PATCH 29/30] Fix PPTX cache key stability and attribute-order-independent dimension parsing --- .../components/file-viewer/file-viewer.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 588eba6ac85..b4934cd51d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -436,8 +436,8 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) { const pptxSlideCache = new Map() -function pptxCacheKey(fileId: string, dataUpdatedAt: number): string { - return `${fileId}:${dataUpdatedAt}` +function pptxCacheKey(fileId: string, byteLength: number): string { + return `${fileId}:${byteLength}` } function pptxCacheSet(key: string, slides: string[]): void { @@ -492,11 +492,15 @@ async function getPptxRenderSize( const presentationXml = await zip.file('ppt/presentation.xml')?.async('text') if (!presentationXml) return fallback - const match = presentationXml.match(/]*cx="(\d+)"[^>]*cy="(\d+)"/) - if (!match) return fallback + const tagMatch = presentationXml.match(/]+>/) + if (!tagMatch) return fallback + const tag = tagMatch[0] + const cxMatch = tag.match(/\bcx="(\d+)"/) + const cyMatch = tag.match(/\bcy="(\d+)"/) + if (!cxMatch || !cyMatch) return fallback - const cx = Number(match[1]) - const cy = Number(match[2]) + const cx = Number(cxMatch[1]) + const cy = Number(cyMatch[1]) if (!Number.isFinite(cx) || !Number.isFinite(cy) || cx <= 0 || cy <= 0) return fallback const aspectRatio = cx / cy @@ -532,10 +536,9 @@ function PptxPreview({ data: fileData, isLoading: isFetching, error: fetchError, - dataUpdatedAt, } = useWorkspaceFileBinary(workspaceId, file.id, file.key) - const cacheKey = pptxCacheKey(file.id, dataUpdatedAt) + const cacheKey = pptxCacheKey(file.id, fileData?.byteLength ?? 0) const cached = pptxSlideCache.get(cacheKey) const [slides, setSlides] = useState(cached ?? []) From e97f163eb8117d8efa255e9e4e9ddb46a80e4ffe Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Mar 2026 11:07:57 -0700 Subject: [PATCH 30/30] ran lint --- .../app/api/files/serve/[...path]/route.ts | 24 +++++++++++++++++++ .../components/file-viewer/file-viewer.tsx | 1 + apps/sim/lib/copilot/tools/shared/schemas.ts | 7 +++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index f248e7488ef..65b5fff9d5c 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto' import { readFile } from 'fs/promises' import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' @@ -22,6 +23,16 @@ const logger = createLogger('FilesServeAPI') const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]) +const MAX_COMPILED_PPTX_CACHE = 10 +const compiledPptxCache = new Map() + +function compiledCacheSet(key: string, buffer: Buffer): void { + if (compiledPptxCache.size >= MAX_COMPILED_PPTX_CACHE) { + compiledPptxCache.delete(compiledPptxCache.keys().next().value as string) + } + compiledPptxCache.set(key, buffer) +} + async function compilePptxIfNeeded( buffer: Buffer, filename: string, @@ -34,7 +45,20 @@ async function compilePptxIfNeeded( } const code = buffer.toString('utf-8') + const cacheKey = createHash('sha256') + .update(code) + .update(workspaceId ?? '') + .digest('hex') + const cached = compiledPptxCache.get(cacheKey) + if (cached) { + return { + buffer: cached, + contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + } + } + const compiled = await generatePptxFromCode(code, workspaceId || '') + compiledCacheSet(cacheKey, compiled) return { buffer: compiled, contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index b4934cd51d4..7d11517703a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -589,6 +589,7 @@ function PptxPreview({ } if (!fileData) return + setSlides([]) const data = new Uint8Array(fileData) const images: string[] = [] await renderPptxSlides( diff --git a/apps/sim/lib/copilot/tools/shared/schemas.ts b/apps/sim/lib/copilot/tools/shared/schemas.ts index 3a0bd704a37..5a5cb42df45 100644 --- a/apps/sim/lib/copilot/tools/shared/schemas.ts +++ b/apps/sim/lib/copilot/tools/shared/schemas.ts @@ -182,7 +182,12 @@ export const WorkspaceFileArgsSchema = z.object({ contentType: z.string().optional(), workspaceId: z.string().optional(), newName: z.string().optional(), - edits: z.array(z.object({ search: z.string(), replace: z.string() })).optional(), + edits: z + .array(z.object({ search: z.string(), replace: z.string() })) + .describe( + 'List of search/replace pairs applied sequentially — each edit operates on the result of the previous one. Search strings must be unique within the file.' + ) + .optional(), }) .optional(), })