From c32360f4e8b46f58dd117228ef3a7d0a6b747eef Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 19:48:28 -0700 Subject: [PATCH 1/9] fix(files): suppress transient streaming preview errors for docx and pptx --- .../components/file-viewer/docx-preview.tsx | 24 +++-------------- .../components/file-viewer/pptx-preview.tsx | 26 +++---------------- .../components/file-viewer/preview-shared.tsx | 14 ---------- 3 files changed, 8 insertions(+), 56 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx index a8228dfc08..35f6187d89 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx @@ -6,12 +6,7 @@ import { toError } from '@sim/utils/errors' import { cn } from '@/lib/core/utils/cn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files' -import { - PDF_PAGE_SKELETON, - PreviewError, - resolvePreviewError, - shouldSuppressStreamingDocumentError, -} from './preview-shared' +import { PDF_PAGE_SKELETON, PreviewError, resolvePreviewError } from './preview-shared' const logger = createLogger('DocxPreview') @@ -136,12 +131,7 @@ export const DocxPreview = memo(function DocxPreview({ setHasRenderedPreview(true) } const msg = toError(err).message || 'Failed to render document' - if (previousHtml || shouldSuppressStreamingDocumentError(msg)) { - logger.info('Suppressing transient DOCX streaming preview error', { error: msg }) - } else { - logger.error('DOCX render failed', { error: msg }) - setRenderError(msg) - } + logger.info('Transient DOCX streaming preview error (suppressed)', { error: msg }) } } finally { if (!cancelled) { @@ -157,17 +147,11 @@ export const DocxPreview = memo(function DocxPreview({ } }, [streamingContent, workspaceId]) - const error = - hasRenderedPreview && streamingContent !== undefined - ? null - : streamingContent !== undefined - ? renderError - : resolvePreviewError(fetchError, renderError) + const error = streamingContent !== undefined ? null : resolvePreviewError(fetchError, renderError) if (error) return const showSkeleton = - !hasRenderedPreview && - ((streamingContent !== undefined && rendering) || (streamingContent === undefined && isLoading)) + !hasRenderedPreview && (streamingContent !== undefined || isLoading || rendering) return (
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx index 41ac2a1a22..946dc30cca 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx @@ -6,11 +6,7 @@ import { toError } from '@sim/utils/errors' import { Skeleton } from '@/components/emcn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files' -import { - PreviewError, - resolvePreviewError, - shouldSuppressStreamingDocumentError, -} from './preview-shared' +import { PreviewError, resolvePreviewError } from './preview-shared' const logger = createLogger('PptxPreview') @@ -44,15 +40,6 @@ function pptxCacheKey(fileId: string, dataUpdatedAt: number, byteLength: number) return `${fileId}:${dataUpdatedAt}:${byteLength}` } -function shouldSuppressStreamingPptxError(message: string): boolean { - return ( - shouldSuppressStreamingDocumentError(message) || - message.includes('SyntaxError: Invalid or unexpected token') || - message.includes('PPTX generation cancelled') || - message.includes('SyntaxError: Unexpected end of input') - ) -} - function pptxCacheSet(key: string, slides: string[]): void { pptxSlideCache.set(key, slides) if (pptxSlideCache.size > 5) { @@ -197,12 +184,7 @@ export function PptxPreview({ } catch (err) { if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) { const msg = toError(err).message || 'Failed to render presentation' - if (shouldSuppressStreamingPptxError(msg)) { - logger.info('Suppressing transient PPTX streaming preview error', { error: msg }) - } else { - logger.error('PPTX render failed', { error: msg }) - setRenderError(msg) - } + logger.info('Transient PPTX streaming preview error (suppressed)', { error: msg }) } } finally { if (!cancelled) setRendering(false) @@ -264,12 +246,12 @@ export function PptxPreview({ } }, [fileData, streamingContent, cacheKey]) - const error = resolvePreviewError(fetchError, renderError) + const error = streamingContent !== undefined ? null : resolvePreviewError(fetchError, renderError) const loading = isFetching || rendering if (error) return - if (loading && slides.length === 0) { + if ((loading || streamingContent !== undefined) && slides.length === 0) { return PPTX_SLIDE_SKELETON } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx index de0c59f531..717ca1d0f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx @@ -19,20 +19,6 @@ export function resolvePreviewError( return renderError } -export function shouldSuppressStreamingDocumentError(message: string): boolean { - const lower = message.toLowerCase() - return ( - lower.includes('preview failed') || - lower.includes('aborterror') || - lower.includes('unexpected end') || - lower.includes('unexpected eof') || - lower.includes('invalid or unexpected token') || - lower.includes('end of central directory') || - lower.includes('corrupted zip') || - lower.includes('end of data reached') - ) -} - export const PDF_PAGE_SKELETON = (
{[0, 1].map((i) => ( From a0aa8ec462e8a8605164a3b587a5c519c6b7b840 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 22:40:09 -0700 Subject: [PATCH 2/9] feat(files): add OOXML style extraction for uploaded docx/pptx files New GET /api/workspaces/[id]/files/[fileId]/style endpoint + VFS read path files/by-id/{id}/style that returns a compact JSON style summary from an uploaded binary .docx or .pptx: theme name, 12-slot color palette, major/minor font pair, and key named styles (Normal, H1-H3, Title). Logic lives in a shared lib/copilot/vfs/document-style.ts so both the REST API and the VFS read handler reuse the same parsing code. --- .../[id]/files/[fileId]/style/route.ts | 80 ++++++++ apps/sim/lib/copilot/vfs/document-style.ts | 171 ++++++++++++++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 28 +++ 3 files changed, 279 insertions(+) create mode 100644 apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts create mode 100644 apps/sim/lib/copilot/vfs/document-style.ts diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts new file mode 100644 index 0000000000..6ab0f582ba --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { downloadWorkspaceFile, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('WorkspaceFileStyleAPI') + +/** + * GET /api/workspaces/[id]/files/[fileId]/style + * Extract a compact JSON style summary from an uploaded .docx or .pptx file. + * Uses OOXML theme XML to return theme colors, font pair, and named styles. + * Only works on binary OOXML files (ZIP format) — not on JS source files. + */ +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { + const { id: workspaceId, fileId } = await params + + 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 fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + const ext = fileRecord.name.split('.').pop()?.toLowerCase() + if (ext !== 'docx' && ext !== 'pptx') { + return NextResponse.json( + { error: 'Style extraction only supports .docx and .pptx files' }, + { status: 422 } + ) + } + + let buffer: Buffer + try { + buffer = await downloadWorkspaceFile(fileRecord) + } catch (err) { + logger.error('Failed to download file for style extraction', { + fileId, + error: toError(err).message, + }) + return NextResponse.json({ error: 'Failed to read file' }, { status: 500 }) + } + + const summary = await extractDocumentStyle(buffer, ext) + if (!summary) { + return NextResponse.json( + { + error: + 'File is not a compiled binary document — style extraction requires an uploaded or compiled .docx/.pptx file', + }, + { status: 422 } + ) + } + + logger.info('Extracted style summary via API', { + fileId, + format: ext, + themeName: summary.theme.name, + }) + + return NextResponse.json(summary, { + headers: { 'Cache-Control': 'private, max-age=300' }, + }) + } +) diff --git a/apps/sim/lib/copilot/vfs/document-style.ts b/apps/sim/lib/copilot/vfs/document-style.ts new file mode 100644 index 0000000000..5f39c0ae5c --- /dev/null +++ b/apps/sim/lib/copilot/vfs/document-style.ts @@ -0,0 +1,171 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' + +const logger = createLogger('DocumentStyle') + +// ZIP magic bytes: PK\x03\x04 +const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04] + +interface ThemeColors { + dk1: string + lt1: string + dk2: string + lt2: string + accent1: string + accent2: string + accent3: string + accent4: string + accent5: string + accent6: string + hlink: string + folHlink: string +} + +export interface DocumentStyleSummary { + format: 'docx' | 'pptx' + theme: { + name: string + colors: Partial + fonts: { major: string; minor: string } + } + styles?: Array<{ + id: string + name: string + type: string + fontSize?: number + bold?: boolean + color?: string + font?: string + }> +} + +function attr(xml: string, name: string): string { + const rx = new RegExp(`${name}="([^"]*)"`) + return rx.exec(xml)?.[1] ?? '' +} + +function between(xml: string, open: string, close: string): string { + const start = xml.indexOf(open) + if (start < 0) return '' + const end = xml.indexOf(close, start + open.length) + if (end < 0) return '' + return xml.slice(start + open.length, end) +} + +function parseColorSlot(xml: string, slot: string): string { + const inner = between(xml, ``, ``) + if (!inner) return '' + const srgb = attr(inner, 'val') + if (srgb && inner.includes('', '') + const minor = between(xml, '', '') + return { major: attr(major, 'typeface') || '', minor: attr(minor, 'typeface') || '' } +} + +function parseThemeXml(xml: string): DocumentStyleSummary['theme'] { + const clrSchemeMatch = /]*name="([^"]*)"/.exec(xml) + const slots: Array = [ + 'dk1', + 'lt1', + 'dk2', + 'lt2', + 'accent1', + 'accent2', + 'accent3', + 'accent4', + 'accent5', + 'accent6', + 'hlink', + 'folHlink', + ] + const colors: Partial = {} + for (const slot of slots) { + const hex = parseColorSlot(xml, slot) + if (hex) colors[slot] = hex + } + return { name: clrSchemeMatch?.[1] ?? '', colors, fonts: parseFontScheme(xml) } +} + +function parseDocxStyles(xml: string): DocumentStyleSummary['styles'] { + const targetIds = new Set([ + 'Normal', + 'DefaultParagraphFont', + 'Heading1', + 'Heading2', + 'Heading3', + 'Title', + 'Subtitle', + ]) + const results: DocumentStyleSummary['styles'] = [] + const blocks = xml.split('/.test(block) && !/]*w:ascii="([^"]*)"/.exec(block) + const font = fontMatch?.[1] + results.push({ + id: styleId, + name, + type: styleType, + ...(fontSize !== undefined && { fontSize }), + ...(bold && { bold }), + ...(color && { color }), + ...(font && { font }), + }) + } + return results +} + +/** + * Extract a compact style summary from a binary OOXML (.docx or .pptx) buffer. + * Returns null if the buffer is not a valid ZIP/OOXML file. + */ +export async function extractDocumentStyle( + buffer: Buffer, + ext: 'docx' | 'pptx' +): Promise { + // Verify ZIP magic bytes + if (buffer.length < 4) return null + for (let i = 0; i < 4; i++) { + if (buffer[i] !== ZIP_MAGIC[i]) return null + } + + try { + const JSZip = (await import('jszip')).default + const zip = await JSZip.loadAsync(buffer) + + const themePath = ext === 'docx' ? 'word/theme/theme1.xml' : 'ppt/theme/theme1.xml' + const themeFile = zip.file(themePath) + if (!themeFile) return null + + const themeXml = await themeFile.async('string') + const theme = parseThemeXml(themeXml) + const summary: DocumentStyleSummary = { format: ext, theme } + + if (ext === 'docx') { + const stylesFile = zip.file('word/styles.xml') + if (stylesFile) { + summary.styles = parseDocxStyles(await stylesFile.async('string')) + } + } + + return summary + } catch (err) { + logger.warn('Failed to extract document style from buffer', { error: toError(err).message }) + return null + } +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index e529c836ea..ee8c008d9e 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -20,6 +20,7 @@ import { toError } from '@sim/utils/errors' import { and, desc, eq, isNotNull, isNull, ne } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' import { buildWorkspaceMd, type WorkspaceMdData } from '@/lib/copilot/chat/workspace-context' +import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import type { DirEntry, GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations' @@ -60,7 +61,9 @@ import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { getKnowledgeBases } from '@/lib/knowledge/service' import { listTables } from '@/lib/table/service' import { + downloadWorkspaceFile, findWorkspaceFileRecord, + getWorkspaceFile, listWorkspaceFiles, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { hasWorkflowChanged } from '@/lib/workflows/comparison' @@ -448,9 +451,34 @@ export class WorkspaceVFS { /** * Attempt to read dynamic workspace file content from storage. * Handles images (base64), parseable documents (PDF, etc.), and text files. + * Also handles `files/by-id/{id}/style` for OOXML theme/style extraction. * Returns null if the path doesn't match `files/{name}` / `files/by-id/{id}` or the file isn't found. */ async readFileContent(path: string): Promise { + // Handle style extraction path: files/by-id/{id}/style + const styleMatch = path.match(/^files\/by-id\/([^/]+)\/style$/) + if (styleMatch) { + const fileId = styleMatch[1] + try { + const record = await getWorkspaceFile(this._workspaceId, fileId) + if (!record) return null + const ext = record.name.split('.').pop()?.toLowerCase() + if (ext !== 'docx' && ext !== 'pptx') return null + const buffer = await downloadWorkspaceFile(record) + const summary = await extractDocumentStyle(buffer, ext) + if (!summary) return null + const json = JSON.stringify(summary, null, 2) + return { content: json, totalLines: json.split('\n').length } + } catch (err) { + logger.warn('Failed to extract document style via VFS', { + workspaceId: this._workspaceId, + fileId, + error: toError(err).message, + }) + return null + } + } + const deletedMatch = path.match(/^recently-deleted\/files\/(.+?)(?:\/content)?$/) const activeMatch = path.match(/^files\/(.+?)(?:\/content)?$/) const match = deletedMatch || activeMatch From 0b607abeee6c9442e2aeb2e1a2ab9a7cd3323f61 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 22:46:24 -0700 Subject: [PATCH 3/9] =?UTF-8?q?chore(files):=20polish=20style=20extraction?= =?UTF-8?q?=20=E2=80=94=20type=20narrowing=20+=20empty-styles=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit 'docx' | 'pptx' type annotation after the extension guard in both route.ts and workspace-vfs.ts so TypeScript sees the narrowed type rather than string. Only set summary.styles when the parsed array is non-empty so the JSON response doesn't include "styles": []. Remove redundant inline WHAT-comments from parseColorSlot. --- .../app/api/workspaces/[id]/files/[fileId]/style/route.ts | 5 +++-- apps/sim/lib/copilot/vfs/document-style.ts | 8 ++++---- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 5 +++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts index 6ab0f582ba..a07706da0f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts @@ -37,13 +37,14 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'File not found' }, { status: 404 }) } - const ext = fileRecord.name.split('.').pop()?.toLowerCase() - if (ext !== 'docx' && ext !== 'pptx') { + const rawExt = fileRecord.name.split('.').pop()?.toLowerCase() + if (rawExt !== 'docx' && rawExt !== 'pptx') { return NextResponse.json( { error: 'Style extraction only supports .docx and .pptx files' }, { status: 422 } ) } + const ext: 'docx' | 'pptx' = rawExt let buffer: Buffer try { diff --git a/apps/sim/lib/copilot/vfs/document-style.ts b/apps/sim/lib/copilot/vfs/document-style.ts index 5f39c0ae5c..3c1ebac6c5 100644 --- a/apps/sim/lib/copilot/vfs/document-style.ts +++ b/apps/sim/lib/copilot/vfs/document-style.ts @@ -55,6 +55,7 @@ function between(xml: string, open: string, close: string): string { function parseColorSlot(xml: string, slot: string): string { const inner = between(xml, ``, ``) if (!inner) return '' + // srgbClr uses val=; sysClr has val="windowText" but lastClr holds the fallback hex const srgb = attr(inner, 'val') if (srgb && inner.includes(' { - // Verify ZIP magic bytes if (buffer.length < 4) return null for (let i = 0; i < 4; i++) { if (buffer[i] !== ZIP_MAGIC[i]) return null @@ -152,14 +152,14 @@ export async function extractDocumentStyle( const themeFile = zip.file(themePath) if (!themeFile) return null - const themeXml = await themeFile.async('string') - const theme = parseThemeXml(themeXml) + const theme = parseThemeXml(await themeFile.async('string')) const summary: DocumentStyleSummary = { format: ext, theme } if (ext === 'docx') { const stylesFile = zip.file('word/styles.xml') if (stylesFile) { - summary.styles = parseDocxStyles(await stylesFile.async('string')) + const styles = parseDocxStyles(await stylesFile.async('string')) + if (styles && styles.length > 0) summary.styles = styles } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index ee8c008d9e..ecb203b8f6 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -462,8 +462,9 @@ export class WorkspaceVFS { try { const record = await getWorkspaceFile(this._workspaceId, fileId) if (!record) return null - const ext = record.name.split('.').pop()?.toLowerCase() - if (ext !== 'docx' && ext !== 'pptx') return null + const rawExt = record.name.split('.').pop()?.toLowerCase() + if (rawExt !== 'docx' && rawExt !== 'pptx') return null + const ext: 'docx' | 'pptx' = rawExt const buffer = await downloadWorkspaceFile(record) const summary = await extractDocumentStyle(buffer, ext) if (!summary) return null From 82c4c58a14f08c8a6f2f89e2ac6d1791ad7ca806 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 23:21:11 -0700 Subject: [PATCH 4/9] fix(files): tighten streaming preview invariant and component consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply structural invariant to PDF streaming path: never surface errors while streamingContent is defined; only log at info level - Remove redundant setRenderError(null) from DOCX streaming effect — the gate at the display layer already suppresses errors during streaming - Wrap PptxPreview in memo for consistency with DocxPreview - Add key={file.id} to PptxPreview mount site (was missing, DocxPreview had it) so the component resets when the viewed file changes - Fix --text-body → --text-primary across PreviewError, UnsupportedPreview, and MermaidDiagram error label; --text-body is not a valid EMCN token --- .../components/file-viewer/docx-preview.tsx | 1 - .../components/file-viewer/file-viewer.tsx | 28 +++++++++---------- .../components/file-viewer/pptx-preview.tsx | 6 ++-- .../components/file-viewer/preview-panel.tsx | 2 +- .../components/file-viewer/preview-shared.tsx | 4 ++- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx index 35f6187d89..45a68d164a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx @@ -89,7 +89,6 @@ export const DocxPreview = memo(function DocxPreview({ try { setRendering(true) - setRenderError(null) const response = await fetch(`/api/workspaces/${workspaceId}/docx/preview`, { method: 'POST', 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 6ac5593405..55ba667401 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 @@ -18,12 +18,7 @@ import { ImagePreview } from './image-preview' import type { PdfDocumentSource } from './pdf-viewer' import { PptxPreview } from './pptx-preview' import { resolvePreviewType } from './preview-panel' -import { - PDF_PAGE_SKELETON, - PreviewError, - resolvePreviewError, - shouldSuppressStreamingDocumentError, -} from './preview-shared' +import { PDF_PAGE_SKELETON, PreviewError, resolvePreviewError } from './preview-shared' import { TextEditor } from './text-editor' import { XlsxPreview } from './xlsx-preview' @@ -128,7 +123,14 @@ export function FileViewer({ } if (category === 'pptx-previewable') { - return + return ( + + ) } if (category === 'xlsx-previewable') { @@ -196,12 +198,7 @@ const IframePreview = memo(function IframePreview({ } catch (err) { if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) { const msg = toError(err).message || 'Failed to render PDF' - if (streamingBufferRef.current || shouldSuppressStreamingDocumentError(msg)) { - logger.info('Suppressing transient PDF streaming preview error', { error: msg }) - } else { - logger.error('PDF render failed', { error: msg }) - setRenderError(msg) - } + logger.info('Transient PDF streaming preview error (suppressed)', { error: msg }) } } finally { if (!cancelled) setRendering(false) @@ -228,7 +225,8 @@ const IframePreview = memo(function IframePreview({ [streamingBuffer] ) - if (renderError) return + if (streamingContent === undefined && renderError) + return if (streamingContent !== undefined) { if (!streamingSource) { @@ -361,7 +359,7 @@ const UnsupportedPreview = memo(function UnsupportedPreview({ return (
-

+

Preview not available{ext ? ` for .${ext} files` : ' for this file'}

diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx index 946dc30cca..33b1581687 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { memo, useEffect, useState } from 'react' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { Skeleton } from '@/components/emcn' @@ -123,7 +123,7 @@ async function getPptxRenderSize( } } -export function PptxPreview({ +export const PptxPreview = memo(function PptxPreview({ file, workspaceId, streamingContent, @@ -269,4 +269,4 @@ export function PptxPreview({

) -} +}) 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 641134eddd..bf9794f1ad 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 @@ -291,7 +291,7 @@ const MermaidDiagram = memo(function MermaidDiagram({ definition }: { definition if (error) { return (
- Diagram error: + Diagram error: {error}
) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx index 717ca1d0f5..489347c70c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx @@ -5,7 +5,9 @@ import { Skeleton } from '@/components/emcn' export function PreviewError({ label, error }: { label: string; error: string }) { return (
-

Failed to preview {label}

+

+ Failed to preview {label} +

{error}

) From 24d53061abc11904edba6172d58f51fc2e635d00 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 23:25:55 -0700 Subject: [PATCH 5/9] fix(files): remove setRenderError(null) from PPTX and PDF streaming paths --- .../[workspaceId]/files/components/file-viewer/file-viewer.tsx | 1 - .../[workspaceId]/files/components/file-viewer/pptx-preview.tsx | 1 - 2 files changed, 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 55ba667401..e6f15e402e 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 @@ -175,7 +175,6 @@ const IframePreview = memo(function IframePreview({ try { setRendering(true) - setRenderError(null) const response = await fetch(`/api/workspaces/${workspaceId}/pdf/preview`, { method: 'POST', diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx index 33b1581687..5047cbc966 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx @@ -156,7 +156,6 @@ export const PptxPreview = memo(function PptxPreview({ if (cancelled) return try { setRendering(true) - setRenderError(null) const response = await fetch(`/api/workspaces/${workspaceId}/pptx/preview`, { method: 'POST', From d0eacf132fda34cb258933ef2cd4d17f10ce22cf Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 29 Apr 2026 00:08:50 -0700 Subject: [PATCH 6/9] feat(files): add compiled-check endpoint and VFS path for binary document self-verification --- .../files/[fileId]/compiled-check/route.ts | 97 +++++++++++++++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 48 ++++++++- 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts new file mode 100644 index 0000000000..8a9b18bb18 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -0,0 +1,97 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' +import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' +import { downloadWorkspaceFile, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import type { SandboxTaskId } from '@/sandbox-tasks/registry' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('WorkspaceFileCompiledCheckAPI') + +const EXT_TO_TASK: Record = { + docx: 'docx-generate', + pptx: 'pptx-generate', + pdf: 'pdf-generate', +} + +/** + * GET /api/workspaces/[id]/files/[fileId]/compiled-check + * + * Compiles the saved JavaScript source of a .docx / .pptx / .pdf file and + * returns whether it succeeds. Used by the file agent to self-verify generated + * code before finalising an edit. + * + * Returns: + * 200 { ok: true } + * 200 { ok: false, error: string, errorName: string } — user code error + * 4xx on auth / missing file / unsupported extension + * 500 on system (sandbox infra) failure + */ +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { + const { id: workspaceId, fileId } = await params + + 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 fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + const ext = fileRecord.name.split('.').pop()?.toLowerCase() ?? '' + const taskId = EXT_TO_TASK[ext] + if (!taskId) { + return NextResponse.json( + { error: `Compiled check only supports .docx, .pptx, and .pdf files` }, + { status: 422 } + ) + } + + let buffer: Buffer + try { + buffer = await downloadWorkspaceFile(fileRecord) + } catch (err) { + logger.error('Failed to download file for compiled check', { + fileId, + error: toError(err).message, + }) + return NextResponse.json({ error: 'Failed to read file' }, { status: 500 }) + } + + const code = buffer.toString('utf-8') + + if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + return NextResponse.json({ error: 'File source exceeds maximum size' }, { status: 413 }) + } + + try { + await runSandboxTask(taskId, { code, workspaceId }, { ownerKey: `user:${session.user.id}` }) + return NextResponse.json({ ok: true }) + } catch (err) { + if (err instanceof SandboxUserCodeError) { + logger.info('Compiled check failed with user code error', { + fileId, + taskId, + error: toError(err).message, + errorName: err.name, + }) + return NextResponse.json({ ok: false, error: toError(err).message, errorName: err.name }) + } + throw err + } + } +) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index ecb203b8f6..35a665d3e7 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -58,6 +58,7 @@ import { getAccessibleOAuthCredentials, } from '@/lib/credentials/environment' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' +import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' import { getKnowledgeBases } from '@/lib/knowledge/service' import { listTables } from '@/lib/table/service' import { @@ -79,6 +80,7 @@ import { } from '@/lib/workspaces/permissions/utils' import { getAllBlocks } from '@/blocks/registry' import { CONNECTOR_REGISTRY } from '@/connectors/registry' +import type { SandboxTaskId } from '@/sandbox-tasks/registry' import { tools as toolRegistry } from '@/tools/registry' import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils' import { TRIGGER_REGISTRY } from '@/triggers/registry' @@ -313,6 +315,8 @@ function getStaticComponentFiles(): Map { * tables/{name}/meta.json * files/{name}/meta.json * files/by-id/{id}/meta.json + * files/by-id/{id}/style (dynamic — OOXML theme/font extraction for .docx/.pptx) + * files/by-id/{id}/compiled-check (dynamic — compile JS source via sandbox, returns {ok,error?}) * jobs/{title}/meta.json * jobs/{title}/history.json * jobs/{title}/executions.json @@ -451,10 +455,52 @@ export class WorkspaceVFS { /** * Attempt to read dynamic workspace file content from storage. * Handles images (base64), parseable documents (PDF, etc.), and text files. - * Also handles `files/by-id/{id}/style` for OOXML theme/style extraction. + * Also handles: + * `files/by-id/{id}/style` — OOXML theme/style extraction (.docx / .pptx only) + * `files/by-id/{id}/compiled-check` — sandbox compile check for JS-source binary files * Returns null if the path doesn't match `files/{name}` / `files/by-id/{id}` or the file isn't found. */ async readFileContent(path: string): Promise { + // Handle compiled-check path: files/by-id/{id}/compiled-check + const compiledCheckMatch = path.match(/^files\/by-id\/([^/]+)\/compiled-check$/) + if (compiledCheckMatch) { + const fileId = compiledCheckMatch[1] + try { + const record = await getWorkspaceFile(this._workspaceId, fileId) + if (!record) return null + const ext = record.name.split('.').pop()?.toLowerCase() ?? '' + const EXT_TASK: Record = { + docx: 'docx-generate', + pptx: 'pptx-generate', + pdf: 'pdf-generate', + } + const taskId = EXT_TASK[ext] + if (!taskId) return null + const buffer = await downloadWorkspaceFile(record) + const code = buffer.toString('utf-8') + let result: { ok: boolean; error?: string; errorName?: string } + try { + await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) + result = { ok: true } + } catch (err) { + if (err instanceof SandboxUserCodeError) { + result = { ok: false, error: toError(err).message, errorName: err.name } + } else { + throw err + } + } + const json = JSON.stringify(result) + return { content: json, totalLines: 1 } + } catch (err) { + logger.warn('Compiled check failed via VFS', { + workspaceId: this._workspaceId, + fileId, + error: toError(err).message, + }) + return null + } + } + // Handle style extraction path: files/by-id/{id}/style const styleMatch = path.match(/^files\/by-id\/([^/]+)\/style$/) if (styleMatch) { From 118484ae4d1dd8938d6cb87679d03d18eae5b154 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 29 Apr 2026 00:13:12 -0700 Subject: [PATCH 7/9] fix(files): remove dead renderError state from IframePreview --- .../files/components/file-viewer/file-viewer.tsx | 4 ---- 1 file changed, 4 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 e6f15e402e..55b00d8e4e 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 @@ -162,7 +162,6 @@ const IframePreview = memo(function IframePreview({ const streamingBufferSeqRef = useRef(0) const [streamingBufferSeq, setStreamingBufferSeq] = useState(0) const [rendering, setRendering] = useState(false) - const [renderError, setRenderError] = useState(null) useEffect(() => { if (streamingContent === undefined) return @@ -224,9 +223,6 @@ const IframePreview = memo(function IframePreview({ [streamingBuffer] ) - if (streamingContent === undefined && renderError) - return - if (streamingContent !== undefined) { if (!streamingSource) { return
{PDF_PAGE_SKELETON}
From 5798b87b185ad2225fe7044d508d660285ed8fdc Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 29 Apr 2026 08:39:26 -0700 Subject: [PATCH 8/9] refactor(files): hoist BINARY_DOC_TASKS to module scope in compiled-check route and VFS handler --- .../[id]/files/[fileId]/compiled-check/route.ts | 4 ++-- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index 8a9b18bb18..e5091dfa38 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -14,7 +14,7 @@ export const runtime = 'nodejs' const logger = createLogger('WorkspaceFileCompiledCheckAPI') -const EXT_TO_TASK: Record = { +const BINARY_DOC_TASKS: Record = { docx: 'docx-generate', pptx: 'pptx-generate', pdf: 'pdf-generate', @@ -53,7 +53,7 @@ export const GET = withRouteHandler( } const ext = fileRecord.name.split('.').pop()?.toLowerCase() ?? '' - const taskId = EXT_TO_TASK[ext] + const taskId = BINARY_DOC_TASKS[ext] if (!taskId) { return NextResponse.json( { error: `Compiled check only supports .docx, .pptx, and .pdf files` }, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 35a665d3e7..e327f8ca26 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -87,6 +87,12 @@ import { TRIGGER_REGISTRY } from '@/triggers/registry' const logger = createLogger('WorkspaceVFS') +const BINARY_DOC_TASKS: Record = { + docx: 'docx-generate', + pptx: 'pptx-generate', + pdf: 'pdf-generate', +} + /** Static component files, computed once and shared across all VFS instances */ let staticComponentFiles: Map | null = null @@ -469,12 +475,7 @@ export class WorkspaceVFS { const record = await getWorkspaceFile(this._workspaceId, fileId) if (!record) return null const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - const EXT_TASK: Record = { - docx: 'docx-generate', - pptx: 'pptx-generate', - pdf: 'pdf-generate', - } - const taskId = EXT_TASK[ext] + const taskId = BINARY_DOC_TASKS[ext] if (!taskId) return null const buffer = await downloadWorkspaceFile(record) const code = buffer.toString('utf-8') From 11c6d7de9a45f1a172ecd691f28ce2a5e78434ff Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 29 Apr 2026 08:48:06 -0700 Subject: [PATCH 9/9] fix(files): deduplicate BINARY_DOC_TASKS and add size guard to VFS compiled-check --- .../[id]/files/[fileId]/compiled-check/route.ts | 9 +-------- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 14 +++++++------- apps/sim/lib/execution/constants.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index e5091dfa38..cbc7c7514c 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -3,23 +3,16 @@ import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' +import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' import { downloadWorkspaceFile, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' -import type { SandboxTaskId } from '@/sandbox-tasks/registry' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' const logger = createLogger('WorkspaceFileCompiledCheckAPI') -const BINARY_DOC_TASKS: Record = { - docx: 'docx-generate', - pptx: 'pptx-generate', - pdf: 'pdf-generate', -} - /** * GET /api/workspaces/[id]/files/[fileId]/compiled-check * diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index e327f8ca26..bc0d1af5f1 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -58,6 +58,7 @@ import { getAccessibleOAuthCredentials, } from '@/lib/credentials/environment' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' +import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' import { getKnowledgeBases } from '@/lib/knowledge/service' import { listTables } from '@/lib/table/service' @@ -80,19 +81,12 @@ import { } from '@/lib/workspaces/permissions/utils' import { getAllBlocks } from '@/blocks/registry' import { CONNECTOR_REGISTRY } from '@/connectors/registry' -import type { SandboxTaskId } from '@/sandbox-tasks/registry' import { tools as toolRegistry } from '@/tools/registry' import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils' import { TRIGGER_REGISTRY } from '@/triggers/registry' const logger = createLogger('WorkspaceVFS') -const BINARY_DOC_TASKS: Record = { - docx: 'docx-generate', - pptx: 'pptx-generate', - pdf: 'pdf-generate', -} - /** Static component files, computed once and shared across all VFS instances */ let staticComponentFiles: Map | null = null @@ -479,6 +473,12 @@ export class WorkspaceVFS { if (!taskId) return null const buffer = await downloadWorkspaceFile(record) const code = buffer.toString('utf-8') + if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + return { + content: JSON.stringify({ ok: false, error: 'File source exceeds maximum size' }), + totalLines: 1, + } + } let result: { ok: boolean; error?: string; errorName?: string } try { await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) diff --git a/apps/sim/lib/execution/constants.ts b/apps/sim/lib/execution/constants.ts index 2950f2b71b..f53d7d28b4 100644 --- a/apps/sim/lib/execution/constants.ts +++ b/apps/sim/lib/execution/constants.ts @@ -1,4 +1,5 @@ import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import type { SandboxTaskId } from '@/sandbox-tasks/registry' export { DEFAULT_EXECUTION_TIMEOUT_MS } @@ -11,3 +12,10 @@ export { DEFAULT_EXECUTION_TIMEOUT_MS } * while reducing memory pressure and abuse potential from oversized payloads. */ export const MAX_DOCUMENT_PREVIEW_CODE_BYTES = 1 * 1024 * 1024 + +/** Maps file extension to the sandbox task that compiles/generates that document type. */ +export const BINARY_DOC_TASKS: Record = { + docx: 'docx-generate', + pptx: 'pptx-generate', + pdf: 'pdf-generate', +}