Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(files): streaming preview invariant + OOXML style extraction#4335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
c32360fa0aa8ec0b607ab82c4c5824d5306d0eacf1118484a5798b8711c6d7dFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| } | ||
| ) |
| 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 |
|---|---|---|
| @@ -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 <PptxPreview file={file} workspaceId={workspaceId} streamingContent={streamingContent} /> | ||
| return ( | ||
| <PptxPreview | ||
| key={file.id} | ||
| file={file} | ||
| workspaceId={workspaceId} | ||
| streamingContent={streamingContent} | ||
| /> | ||
| ) | ||
| } | ||
| 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<string | null>(null) | ||
| useEffect(() => { | ||
| if (streamingContent === undefined) return | ||
| @@ -173,7 +174,6 @@ const IframePreview = memo(function IframePreview({ | ||
| try { | ||
| setRendering(true) | ||
| setRenderError(null) | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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 <PreviewError label='PDF' error={renderError} /> | ||
| if (streamingContent !== undefined) { | ||
| if (!streamingSource) { | ||
| return <div className='relative flex flex-1 overflow-hidden'>{PDF_PAGE_SKELETON}</div> | ||
| @@ -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)]'> | ||
| 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') | ||
| @@ -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 }) | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } 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 <PreviewError label='presentation' error={error} /> | ||
| if (loading && slides.length === 0) { | ||
| if ((loading || streamingContent !== undefined) && slides.length === 0) { | ||
| return PPTX_SLIDE_SKELETON | ||
| } | ||
| @@ -287,4 +268,4 @@ export function PptxPreview({ | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
| }) | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.