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..cbc7c7514c
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts
@@ -0,0 +1,90 @@
+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 { 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'
+
+export const dynamic = 'force-dynamic'
+export const runtime = 'nodejs'
+
+const logger = createLogger('WorkspaceFileCompiledCheckAPI')
+
+/**
+ * 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 = BINARY_DOC_TASKS[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/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..a07706da0f
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts
@@ -0,0 +1,81 @@
+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 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 {
+ 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/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..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
@@ -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')
@@ -94,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',
@@ -136,12 +130,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 +146,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/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
index 6ac5593405..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
@@ -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') {
@@ -160,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
@@ -173,7 +174,6 @@ const IframePreview = memo(function IframePreview({
try {
setRendering(true)
- setRenderError(null)
const response = await fetch(`/api/workspaces/${workspaceId}/pdf/preview`, {
method: 'POST',
@@ -196,12 +196,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,8 +223,6 @@ const IframePreview = memo(function IframePreview({
[streamingBuffer]
)
- if (renderError) return
-
if (streamingContent !== undefined) {
if (!streamingSource) {
return {PDF_PAGE_SKELETON}
@@ -361,7 +354,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 41ac2a1a22..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
@@ -1,16 +1,12 @@
'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'
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) {
@@ -136,7 +123,7 @@ async function getPptxRenderSize(
}
}
-export function PptxPreview({
+export const PptxPreview = memo(function PptxPreview({
file,
workspaceId,
streamingContent,
@@ -169,7 +156,6 @@ export function PptxPreview({
if (cancelled) return
try {
setRendering(true)
- setRenderError(null)
const response = await fetch(`/api/workspaces/${workspaceId}/pptx/preview`, {
method: 'POST',
@@ -197,12 +183,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 +245,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
}
@@ -287,4 +268,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 de0c59f531..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}
)
@@ -19,20 +21,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) => (
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..3c1ebac6c5
--- /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 ''
+ // srgbClr uses val=; sysClr has val="windowText" but lastClr holds the fallback hex
+ 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 {
+ 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 theme = parseThemeXml(await themeFile.async('string'))
+ const summary: DocumentStyleSummary = { format: ext, theme }
+
+ if (ext === 'docx') {
+ const stylesFile = zip.file('word/styles.xml')
+ if (stylesFile) {
+ const styles = parseDocxStyles(await stylesFile.async('string'))
+ if (styles && styles.length > 0) summary.styles = styles
+ }
+ }
+
+ 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..bc0d1af5f1 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'
@@ -57,10 +58,14 @@ 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'
import {
+ downloadWorkspaceFile,
findWorkspaceFileRecord,
+ getWorkspaceFile,
listWorkspaceFiles,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
@@ -310,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
@@ -448,9 +455,78 @@ 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` — 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 taskId = BINARY_DOC_TASKS[ext]
+ 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 })
+ 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) {
+ const fileId = styleMatch[1]
+ try {
+ const record = await getWorkspaceFile(this._workspaceId, fileId)
+ if (!record) 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
+ 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
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',
+}