Skip to content

Commit 94f5411

Browse files
authored
fix(files): streaming preview invariant + OOXML style extraction (#4335)
* fix(files): suppress transient streaming preview errors for docx and pptx * 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. * chore(files): polish style extraction — type narrowing + empty-styles guard 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. * fix(files): tighten streaming preview invariant and component consistency - 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 * fix(files): remove setRenderError(null) from PPTX and PDF streaming paths * feat(files): add compiled-check endpoint and VFS path for binary document self-verification * fix(files): remove dead renderError state from IframePreview * refactor(files): hoist BINARY_DOC_TASKS to module scope in compiled-check route and VFS handler * fix(files): deduplicate BINARY_DOC_TASKS and add size guard to VFS compiled-check
1 parent 52c93d4 commit 94f5411

10 files changed

Lines changed: 452 additions & 81 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { getSession } from '@/lib/auth'
5+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
7+
import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task'
8+
import { downloadWorkspaceFile, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
9+
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
10+
11+
export const dynamic = 'force-dynamic'
12+
export const runtime = 'nodejs'
13+
14+
const logger = createLogger('WorkspaceFileCompiledCheckAPI')
15+
16+
/**
17+
* GET /api/workspaces/[id]/files/[fileId]/compiled-check
18+
*
19+
* Compiles the saved JavaScript source of a .docx / .pptx / .pdf file and
20+
* returns whether it succeeds. Used by the file agent to self-verify generated
21+
* code before finalising an edit.
22+
*
23+
* Returns:
24+
* 200 { ok: true }
25+
* 200 { ok: false, error: string, errorName: string } — user code error
26+
* 4xx on auth / missing file / unsupported extension
27+
* 500 on system (sandbox infra) failure
28+
*/
29+
export const GET = withRouteHandler(
30+
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
31+
const { id: workspaceId, fileId } = await params
32+
33+
const session = await getSession()
34+
if (!session?.user?.id) {
35+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
36+
}
37+
38+
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
39+
if (!membership) {
40+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
41+
}
42+
43+
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
44+
if (!fileRecord) {
45+
return NextResponse.json({ error: 'File not found' }, { status: 404 })
46+
}
47+
48+
const ext = fileRecord.name.split('.').pop()?.toLowerCase() ?? ''
49+
const taskId = BINARY_DOC_TASKS[ext]
50+
if (!taskId) {
51+
return NextResponse.json(
52+
{ error: `Compiled check only supports .docx, .pptx, and .pdf files` },
53+
{ status: 422 }
54+
)
55+
}
56+
57+
let buffer: Buffer
58+
try {
59+
buffer = await downloadWorkspaceFile(fileRecord)
60+
} catch (err) {
61+
logger.error('Failed to download file for compiled check', {
62+
fileId,
63+
error: toError(err).message,
64+
})
65+
return NextResponse.json({ error: 'Failed to read file' }, { status: 500 })
66+
}
67+
68+
const code = buffer.toString('utf-8')
69+
70+
if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) {
71+
return NextResponse.json({ error: 'File source exceeds maximum size' }, { status: 413 })
72+
}
73+
74+
try {
75+
await runSandboxTask(taskId, { code, workspaceId }, { ownerKey: `user:${session.user.id}` })
76+
return NextResponse.json({ ok: true })
77+
} catch (err) {
78+
if (err instanceof SandboxUserCodeError) {
79+
logger.info('Compiled check failed with user code error', {
80+
fileId,
81+
taskId,
82+
error: toError(err).message,
83+
errorName: err.name,
84+
})
85+
return NextResponse.json({ ok: false, error: toError(err).message, errorName: err.name })
86+
}
87+
throw err
88+
}
89+
}
90+
)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { getSession } from '@/lib/auth'
5+
import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { downloadWorkspaceFile, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
8+
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
9+
10+
export const dynamic = 'force-dynamic'
11+
export const runtime = 'nodejs'
12+
13+
const logger = createLogger('WorkspaceFileStyleAPI')
14+
15+
/**
16+
* GET /api/workspaces/[id]/files/[fileId]/style
17+
* Extract a compact JSON style summary from an uploaded .docx or .pptx file.
18+
* Uses OOXML theme XML to return theme colors, font pair, and named styles.
19+
* Only works on binary OOXML files (ZIP format) — not on JS source files.
20+
*/
21+
export const GET = withRouteHandler(
22+
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
23+
const { id: workspaceId, fileId } = await params
24+
25+
const session = await getSession()
26+
if (!session?.user?.id) {
27+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
28+
}
29+
30+
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
31+
if (!membership) {
32+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
33+
}
34+
35+
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
36+
if (!fileRecord) {
37+
return NextResponse.json({ error: 'File not found' }, { status: 404 })
38+
}
39+
40+
const rawExt = fileRecord.name.split('.').pop()?.toLowerCase()
41+
if (rawExt !== 'docx' && rawExt !== 'pptx') {
42+
return NextResponse.json(
43+
{ error: 'Style extraction only supports .docx and .pptx files' },
44+
{ status: 422 }
45+
)
46+
}
47+
const ext: 'docx' | 'pptx' = rawExt
48+
49+
let buffer: Buffer
50+
try {
51+
buffer = await downloadWorkspaceFile(fileRecord)
52+
} catch (err) {
53+
logger.error('Failed to download file for style extraction', {
54+
fileId,
55+
error: toError(err).message,
56+
})
57+
return NextResponse.json({ error: 'Failed to read file' }, { status: 500 })
58+
}
59+
60+
const summary = await extractDocumentStyle(buffer, ext)
61+
if (!summary) {
62+
return NextResponse.json(
63+
{
64+
error:
65+
'File is not a compiled binary document — style extraction requires an uploaded or compiled .docx/.pptx file',
66+
},
67+
{ status: 422 }
68+
)
69+
}
70+
71+
logger.info('Extracted style summary via API', {
72+
fileId,
73+
format: ext,
74+
themeName: summary.theme.name,
75+
})
76+
77+
return NextResponse.json(summary, {
78+
headers: { 'Cache-Control': 'private, max-age=300' },
79+
})
80+
}
81+
)

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,7 @@ import { toError } from '@sim/utils/errors'
66
import { cn } from '@/lib/core/utils/cn'
77
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
88
import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files'
9-
import {
10-
PDF_PAGE_SKELETON,
11-
PreviewError,
12-
resolvePreviewError,
13-
shouldSuppressStreamingDocumentError,
14-
} from './preview-shared'
9+
import { PDF_PAGE_SKELETON, PreviewError, resolvePreviewError } from './preview-shared'
1510

1611
const logger = createLogger('DocxPreview')
1712

@@ -145,7 +140,6 @@ export const DocxPreview = memo(function DocxPreview({
145140

146141
try {
147142
setRendering(true)
148-
setRenderError(null)
149143

150144
const response = await fetch(`/api/workspaces/${workspaceId}/docx/preview`, {
151145
method: 'POST',
@@ -184,12 +178,7 @@ export const DocxPreview = memo(function DocxPreview({
184178
setHasRenderedPreview(true)
185179
}
186180
const msg = toError(err).message || 'Failed to render document'
187-
if (previousHtml || shouldSuppressStreamingDocumentError(msg)) {
188-
logger.info('Suppressing transient DOCX streaming preview error', { error: msg })
189-
} else {
190-
logger.error('DOCX render failed', { error: msg })
191-
setRenderError(msg)
192-
}
181+
logger.info('Transient DOCX streaming preview error (suppressed)', { error: msg })
193182
}
194183
} finally {
195184
if (!cancelled) {
@@ -205,17 +194,11 @@ export const DocxPreview = memo(function DocxPreview({
205194
}
206195
}, [streamingContent, workspaceId, applyPostRenderStyling])
207196

208-
const error =
209-
hasRenderedPreview && streamingContent !== undefined
210-
? null
211-
: streamingContent !== undefined
212-
? renderError
213-
: resolvePreviewError(fetchError, renderError)
197+
const error = streamingContent !== undefined ? null : resolvePreviewError(fetchError, renderError)
214198
if (error) return <PreviewError label='document' error={error} />
215199

216200
const showSkeleton =
217-
!hasRenderedPreview &&
218-
((streamingContent !== undefined && rendering) || (streamingContent === undefined && isLoading))
201+
!hasRenderedPreview && (streamingContent !== undefined || isLoading || rendering)
219202

220203
return (
221204
<div className='relative h-full w-full overflow-auto bg-[var(--surface-1)]'>

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,7 @@ import { ImagePreview } from './image-preview'
1818
import type { PdfDocumentSource } from './pdf-viewer'
1919
import { PptxPreview } from './pptx-preview'
2020
import { resolvePreviewType } from './preview-panel'
21-
import {
22-
PDF_PAGE_SKELETON,
23-
PreviewError,
24-
resolvePreviewError,
25-
shouldSuppressStreamingDocumentError,
26-
} from './preview-shared'
21+
import { PDF_PAGE_SKELETON, PreviewError, resolvePreviewError } from './preview-shared'
2722
import { TextEditor } from './text-editor'
2823
import { XlsxPreview } from './xlsx-preview'
2924

@@ -128,7 +123,14 @@ export function FileViewer({
128123
}
129124

130125
if (category === 'pptx-previewable') {
131-
return <PptxPreview file={file} workspaceId={workspaceId} streamingContent={streamingContent} />
126+
return (
127+
<PptxPreview
128+
key={file.id}
129+
file={file}
130+
workspaceId={workspaceId}
131+
streamingContent={streamingContent}
132+
/>
133+
)
132134
}
133135

134136
if (category === 'xlsx-previewable') {
@@ -160,7 +162,6 @@ const IframePreview = memo(function IframePreview({
160162
const streamingBufferSeqRef = useRef(0)
161163
const [streamingBufferSeq, setStreamingBufferSeq] = useState(0)
162164
const [rendering, setRendering] = useState(false)
163-
const [renderError, setRenderError] = useState<string | null>(null)
164165

165166
useEffect(() => {
166167
if (streamingContent === undefined) return
@@ -173,7 +174,6 @@ const IframePreview = memo(function IframePreview({
173174

174175
try {
175176
setRendering(true)
176-
setRenderError(null)
177177

178178
const response = await fetch(`/api/workspaces/${workspaceId}/pdf/preview`, {
179179
method: 'POST',
@@ -196,12 +196,7 @@ const IframePreview = memo(function IframePreview({
196196
} catch (err) {
197197
if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) {
198198
const msg = toError(err).message || 'Failed to render PDF'
199-
if (streamingBufferRef.current || shouldSuppressStreamingDocumentError(msg)) {
200-
logger.info('Suppressing transient PDF streaming preview error', { error: msg })
201-
} else {
202-
logger.error('PDF render failed', { error: msg })
203-
setRenderError(msg)
204-
}
199+
logger.info('Transient PDF streaming preview error (suppressed)', { error: msg })
205200
}
206201
} finally {
207202
if (!cancelled) setRendering(false)
@@ -228,8 +223,6 @@ const IframePreview = memo(function IframePreview({
228223
[streamingBuffer]
229224
)
230225

231-
if (renderError) return <PreviewError label='PDF' error={renderError} />
232-
233226
if (streamingContent !== undefined) {
234227
if (!streamingSource) {
235228
return <div className='relative flex flex-1 overflow-hidden'>{PDF_PAGE_SKELETON}</div>
@@ -361,7 +354,7 @@ const UnsupportedPreview = memo(function UnsupportedPreview({
361354

362355
return (
363356
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
364-
<p className='font-medium text-[14px] text-[var(--text-body)]'>
357+
<p className='font-medium text-[14px] text-[var(--text-primary)]'>
365358
Preview not available{ext ? ` for .${ext} files` : ' for this file'}
366359
</p>
367360
<p className='text-[13px] text-[var(--text-muted)]'>

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
'use client'
22

3-
import { useEffect, useState } from 'react'
3+
import { memo, useEffect, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { toError } from '@sim/utils/errors'
66
import { Skeleton } from '@/components/emcn'
77
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
88
import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files'
9-
import {
10-
PreviewError,
11-
resolvePreviewError,
12-
shouldSuppressStreamingDocumentError,
13-
} from './preview-shared'
9+
import { PreviewError, resolvePreviewError } from './preview-shared'
1410

1511
const logger = createLogger('PptxPreview')
1612

@@ -44,15 +40,6 @@ function pptxCacheKey(fileId: string, dataUpdatedAt: number, byteLength: number)
4440
return `${fileId}:${dataUpdatedAt}:${byteLength}`
4541
}
4642

47-
function shouldSuppressStreamingPptxError(message: string): boolean {
48-
return (
49-
shouldSuppressStreamingDocumentError(message) ||
50-
message.includes('SyntaxError: Invalid or unexpected token') ||
51-
message.includes('PPTX generation cancelled') ||
52-
message.includes('SyntaxError: Unexpected end of input')
53-
)
54-
}
55-
5643
function pptxCacheSet(key: string, slides: string[]): void {
5744
pptxSlideCache.set(key, slides)
5845
if (pptxSlideCache.size > 5) {
@@ -136,7 +123,7 @@ async function getPptxRenderSize(
136123
}
137124
}
138125

139-
export function PptxPreview({
126+
export const PptxPreview = memo(function PptxPreview({
140127
file,
141128
workspaceId,
142129
streamingContent,
@@ -169,7 +156,6 @@ export function PptxPreview({
169156
if (cancelled) return
170157
try {
171158
setRendering(true)
172-
setRenderError(null)
173159

174160
const response = await fetch(`/api/workspaces/${workspaceId}/pptx/preview`, {
175161
method: 'POST',
@@ -197,12 +183,7 @@ export function PptxPreview({
197183
} catch (err) {
198184
if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) {
199185
const msg = toError(err).message || 'Failed to render presentation'
200-
if (shouldSuppressStreamingPptxError(msg)) {
201-
logger.info('Suppressing transient PPTX streaming preview error', { error: msg })
202-
} else {
203-
logger.error('PPTX render failed', { error: msg })
204-
setRenderError(msg)
205-
}
186+
logger.info('Transient PPTX streaming preview error (suppressed)', { error: msg })
206187
}
207188
} finally {
208189
if (!cancelled) setRendering(false)
@@ -264,12 +245,12 @@ export function PptxPreview({
264245
}
265246
}, [fileData, streamingContent, cacheKey])
266247

267-
const error = resolvePreviewError(fetchError, renderError)
248+
const error = streamingContent !== undefined ? null : resolvePreviewError(fetchError, renderError)
268249
const loading = isFetching || rendering
269250

270251
if (error) return <PreviewError label='presentation' error={error} />
271252

272-
if (loading && slides.length === 0) {
253+
if ((loading || streamingContent !== undefined) && slides.length === 0) {
273254
return PPTX_SLIDE_SKELETON
274255
}
275256

@@ -287,4 +268,4 @@ export function PptxPreview({
287268
</div>
288269
</div>
289270
)
290-
}
271+
})

0 commit comments

Comments
 (0)