Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
)
81 changes: 81 additions & 0 deletions apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts
Original file line number Diff line number Diff line change
@@ -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' },
})
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand All @@ -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 <PreviewError label='document' error={error} />

const showSkeleton =
!hasRenderedPreview &&
((streamingContent !== undefined && rendering) || (streamingContent === undefined && isLoading))
!hasRenderedPreview && (streamingContent !== undefined || isLoading || rendering)
Comment thread
waleedlatif1 marked this conversation as resolved.

return (
<div className='relative h-full w-full overflow-auto bg-[var(--surface-1)]'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -128,7 +123,14 @@ export function FileViewer({
}

if (category === 'pptx-previewable') {
return <PptxPreview file={file} workspaceId={workspaceId} streamingContent={streamingContent} />
return (
<PptxPreview
key={file.id}
file={file}
workspaceId={workspaceId}
streamingContent={streamingContent}
/>
)
}

if (category === 'xlsx-previewable') {
Expand Down Expand Up @@ -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<string | null>(null)

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

try {
setRendering(true)
setRenderError(null)
Comment thread
waleedlatif1 marked this conversation as resolved.

const response = await fetch(`/api/workspaces/${workspaceId}/pdf/preview`, {
method: 'POST',
Expand All @@ -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)
Expand All @@ -228,8 +223,6 @@ const IframePreview = memo(function IframePreview({
[streamingBuffer]
)

if (renderError) return <PreviewError label='PDF' error={renderError} />

if (streamingContent !== undefined) {
if (!streamingSource) {
return <div className='relative flex flex-1 overflow-hidden'>{PDF_PAGE_SKELETON}</div>
Expand Down Expand Up @@ -361,7 +354,7 @@ const UnsupportedPreview = memo(function UnsupportedPreview({

return (
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
<p className='font-medium text-[14px] text-[var(--text-body)]'>
<p className='font-medium text-[14px] text-[var(--text-primary)]'>
Preview not available{ext ? ` for .${ext} files` : ' for this file'}
</p>
<p className='text-[13px] text-[var(--text-muted)]'>
Expand Down
Original file line number Diff line number Diff line change
@@ -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')

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -136,7 +123,7 @@ async function getPptxRenderSize(
}
}

export function PptxPreview({
export const PptxPreview = memo(function PptxPreview({
file,
workspaceId,
streamingContent,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 })
}
Comment thread
waleedlatif1 marked this conversation as resolved.
} finally {
if (!cancelled) setRendering(false)
Expand Down Expand Up @@ -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 <PreviewError label='presentation' error={error} />

if (loading && slides.length === 0) {
if ((loading || streamingContent !== undefined) && slides.length === 0) {
return PPTX_SLIDE_SKELETON
}

Expand All @@ -287,4 +268,4 @@ export function PptxPreview({
</div>
</div>
)
}
})
Loading
Loading