Skip to content

Commit 704d06f

Browse files
committed
v0
1 parent 9d6a7f3 commit 704d06f

File tree

14 files changed

+524
-73
lines changed

14 files changed

+524
-73
lines changed

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
33
import type { NextRequest } from 'next/server'
44
import { NextResponse } from 'next/server'
55
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6+
import { generatePptxFromCode } from '@/lib/copilot/tools/server/files/workspace-file'
67
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
78
import type { StorageContext } from '@/lib/uploads/config'
89
import { downloadFile } from '@/lib/uploads/core/storage-service'
@@ -18,6 +19,27 @@ import {
1819

1920
const logger = createLogger('FilesServeAPI')
2021

22+
const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04])
23+
24+
async function compilePptxIfNeeded(
25+
buffer: Buffer,
26+
filename: string,
27+
workspaceId?: string,
28+
raw?: boolean
29+
): Promise<{ buffer: Buffer; contentType: string }> {
30+
const isPptx = filename.toLowerCase().endsWith('.pptx')
31+
if (raw || !isPptx || buffer.subarray(0, 4).equals(ZIP_MAGIC)) {
32+
return { buffer, contentType: getContentType(filename) }
33+
}
34+
35+
const code = buffer.toString('utf-8')
36+
const compiled = await generatePptxFromCode(code, workspaceId || '')
37+
return {
38+
buffer: compiled,
39+
contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
40+
}
41+
}
42+
2143
const STORAGE_KEY_PREFIX_RE = /^\d{13}-[a-z0-9]{7}-/
2244

2345
function stripStorageKeyPrefix(segment: string): string {
@@ -44,6 +66,7 @@ export async function GET(
4466
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
4567

4668
const contextParam = request.nextUrl.searchParams.get('context')
69+
const raw = request.nextUrl.searchParams.get('raw') === '1'
4770

4871
const context = contextParam || (isCloudPath ? inferContextFromKey(cloudKey) : undefined)
4972

@@ -68,10 +91,10 @@ export async function GET(
6891
const userId = authResult.userId
6992

7093
if (isUsingCloudStorage()) {
71-
return await handleCloudProxy(cloudKey, userId, contextParam)
94+
return await handleCloudProxy(cloudKey, userId, contextParam, raw)
7295
}
7396

74-
return await handleLocalFile(cloudKey, userId)
97+
return await handleLocalFile(cloudKey, userId, raw)
7598
} catch (error) {
7699
logger.error('Error serving file:', error)
77100

@@ -83,7 +106,11 @@ export async function GET(
83106
}
84107
}
85108

86-
async function handleLocalFile(filename: string, userId: string): Promise<NextResponse> {
109+
async function handleLocalFile(
110+
filename: string,
111+
userId: string,
112+
raw: boolean
113+
): Promise<NextResponse> {
87114
try {
88115
const contextParam: StorageContext | undefined = inferContextFromKey(filename) as
89116
| StorageContext
@@ -108,10 +135,15 @@ async function handleLocalFile(filename: string, userId: string): Promise<NextRe
108135
throw new FileNotFoundError(`File not found: ${filename}`)
109136
}
110137

111-
const fileBuffer = await readFile(filePath)
138+
const rawBuffer = await readFile(filePath)
112139
const segment = filename.split('/').pop() || filename
113140
const displayName = stripStorageKeyPrefix(segment)
114-
const contentType = getContentType(displayName)
141+
const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded(
142+
rawBuffer,
143+
displayName,
144+
undefined,
145+
raw
146+
)
115147

116148
logger.info('Local file served', { userId, filename, size: fileBuffer.length })
117149

@@ -130,7 +162,8 @@ async function handleLocalFile(filename: string, userId: string): Promise<NextRe
130162
async function handleCloudProxy(
131163
cloudKey: string,
132164
userId: string,
133-
contextParam?: string | null
165+
contextParam?: string | null,
166+
raw: boolean = false
134167
): Promise<NextResponse> {
135168
try {
136169
let context: StorageContext
@@ -156,20 +189,25 @@ async function handleCloudProxy(
156189
throw new FileNotFoundError(`File not found: ${cloudKey}`)
157190
}
158191

159-
let fileBuffer: Buffer
192+
let rawBuffer: Buffer
160193

161194
if (context === 'copilot') {
162-
fileBuffer = await CopilotFiles.downloadCopilotFile(cloudKey)
195+
rawBuffer = await CopilotFiles.downloadCopilotFile(cloudKey)
163196
} else {
164-
fileBuffer = await downloadFile({
197+
rawBuffer = await downloadFile({
165198
key: cloudKey,
166199
context,
167200
})
168201
}
169202

170203
const segment = cloudKey.split('/').pop() || 'download'
171204
const displayName = stripStorageKeyPrefix(segment)
172-
const contentType = getContentType(displayName)
205+
const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded(
206+
rawBuffer,
207+
displayName,
208+
undefined,
209+
raw
210+
)
173211

174212
logger.info('Cloud file served', {
175213
userId,

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

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
88
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
99
import {
1010
useUpdateWorkspaceFileContent,
11+
useWorkspaceFileBinary,
1112
useWorkspaceFileContent,
1213
} from '@/hooks/queries/workspace-files'
1314
import { useAutosave } from '@/hooks/use-autosave'
@@ -48,17 +49,29 @@ const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
4849
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
4950
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
5051

51-
type FileCategory = 'text-editable' | 'iframe-previewable' | 'image-previewable' | 'unsupported'
52+
const PPTX_PREVIEWABLE_MIME_TYPES = new Set([
53+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
54+
])
55+
const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx'])
56+
57+
type FileCategory =
58+
| 'text-editable'
59+
| 'iframe-previewable'
60+
| 'image-previewable'
61+
| 'pptx-previewable'
62+
| 'unsupported'
5263

5364
function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
5465
if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable'
5566
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
5667
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
68+
if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable'
5769

5870
const ext = getFileExtension(filename)
5971
if (TEXT_EDITABLE_EXTENSIONS.has(ext)) return 'text-editable'
6072
if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable'
6173
if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable'
74+
if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable'
6275

6376
return 'unsupported'
6477
}
@@ -124,6 +137,10 @@ export function FileViewer({
124137
return <ImagePreview file={file} />
125138
}
126139

140+
if (category === 'pptx-previewable') {
141+
return <PptxPreview file={file} workspaceId={workspaceId} streamingContent={streamingContent} />
142+
}
143+
127144
return <UnsupportedPreview file={file} />
128145
}
129146

@@ -163,7 +180,12 @@ function TextEditor({
163180
isLoading,
164181
error,
165182
dataUpdatedAt,
166-
} = useWorkspaceFileContent(workspaceId, file.id, file.key)
183+
} = useWorkspaceFileContent(
184+
workspaceId,
185+
file.id,
186+
file.key,
187+
file.type === 'text/x-pptxgenjs'
188+
)
167189

168190
const updateContent = useUpdateWorkspaceFileContent()
169191

@@ -417,6 +439,167 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
417439
)
418440
}
419441

442+
function PptxPreview({
443+
file,
444+
workspaceId,
445+
streamingContent,
446+
}: {
447+
file: WorkspaceFileRecord
448+
workspaceId: string
449+
streamingContent?: string
450+
}) {
451+
const {
452+
data: fileData,
453+
isLoading: isFetching,
454+
error: fetchError,
455+
dataUpdatedAt,
456+
} = useWorkspaceFileBinary(workspaceId, file.id, file.key)
457+
458+
const [slides, setSlides] = useState<string[]>([])
459+
const [rendering, setRendering] = useState(false)
460+
const [renderError, setRenderError] = useState<string | null>(null)
461+
462+
useEffect(() => {
463+
let cancelled = false
464+
465+
async function render() {
466+
try {
467+
setRendering(true)
468+
setRenderError(null)
469+
470+
if (streamingContent !== undefined) {
471+
const PptxGenJS = (await import('pptxgenjs')).default
472+
const pptx = new PptxGenJS()
473+
const fn = new Function('pptx', `return (async () => { ${streamingContent} })()`)
474+
await fn(pptx)
475+
const arrayBuffer = (await pptx.write({ outputType: 'arraybuffer' })) as ArrayBuffer
476+
if (cancelled) return
477+
const { PPTXViewer } = await import('pptxviewjs')
478+
const data = new Uint8Array(arrayBuffer)
479+
const probe = document.createElement('canvas')
480+
const probeViewer = new PPTXViewer({ canvas: probe })
481+
await probeViewer.loadFile(data)
482+
const count = probeViewer.getSlideCount()
483+
if (cancelled || count === 0) return
484+
const dpr = window.devicePixelRatio || 1
485+
const W = Math.round(1920 * dpr)
486+
const H = Math.round(1080 * dpr)
487+
const images: string[] = []
488+
for (let i = 0; i < count; i++) {
489+
if (cancelled) break
490+
const canvas = document.createElement('canvas')
491+
canvas.width = W
492+
canvas.height = H
493+
const viewer = new PPTXViewer({ canvas })
494+
await viewer.loadFile(data)
495+
if (i > 0) await viewer.goToSlide(i)
496+
else await viewer.render()
497+
images.push(canvas.toDataURL('image/png'))
498+
}
499+
if (!cancelled) setSlides(images)
500+
return
501+
}
502+
503+
if (!fileData) return
504+
const { PPTXViewer } = await import('pptxviewjs')
505+
if (cancelled) return
506+
507+
const data = new Uint8Array(fileData!)
508+
const probe = document.createElement('canvas')
509+
const probeViewer = new PPTXViewer({ canvas: probe })
510+
await probeViewer.loadFile(data)
511+
const count = probeViewer.getSlideCount()
512+
if (cancelled || count === 0) return
513+
514+
const dpr = window.devicePixelRatio || 1
515+
const W = Math.round(1920 * dpr)
516+
const H = Math.round(1080 * dpr)
517+
const images: string[] = []
518+
519+
for (let i = 0; i < count; i++) {
520+
if (cancelled) break
521+
const canvas = document.createElement('canvas')
522+
canvas.width = W
523+
canvas.height = H
524+
const viewer = new PPTXViewer({ canvas })
525+
await viewer.loadFile(data)
526+
if (i > 0) await viewer.goToSlide(i)
527+
else await viewer.render()
528+
images.push(canvas.toDataURL('image/png'))
529+
}
530+
531+
if (!cancelled) setSlides(images)
532+
} catch (err) {
533+
if (!cancelled) {
534+
const msg = err instanceof Error ? err.message : 'Failed to render presentation'
535+
logger.error('PPTX render failed', { error: msg })
536+
setRenderError(msg)
537+
}
538+
} finally {
539+
if (!cancelled) setRendering(false)
540+
}
541+
}
542+
543+
render()
544+
return () => {
545+
cancelled = true
546+
}
547+
}, [fileData, dataUpdatedAt, streamingContent])
548+
549+
const error = fetchError
550+
? fetchError instanceof Error
551+
? fetchError.message
552+
: 'Failed to load file'
553+
: renderError
554+
const loading = isFetching || rendering
555+
556+
if (error) {
557+
return (
558+
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
559+
<p className='font-medium text-[14px] text-[var(--text-body)]'>
560+
Failed to preview presentation
561+
</p>
562+
<p className='text-[13px] text-[var(--text-muted)]'>{error}</p>
563+
</div>
564+
)
565+
}
566+
567+
if (loading && slides.length === 0) {
568+
return (
569+
<div className='flex flex-1 items-center justify-center bg-[var(--surface-1)]'>
570+
<div className='flex flex-col items-center gap-[8px]'>
571+
<div
572+
className='h-[18px] w-[18px] animate-spin rounded-full'
573+
style={{
574+
background:
575+
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
576+
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
577+
WebkitMask:
578+
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
579+
}}
580+
/>
581+
<p className='text-[13px] text-[var(--text-muted)]'>Loading presentation...</p>
582+
</div>
583+
</div>
584+
)
585+
}
586+
587+
return (
588+
<div className='flex-1 overflow-y-auto bg-[var(--surface-1)] p-[24px]'>
589+
<div className='mx-auto flex max-w-[960px] flex-col gap-[16px]'>
590+
{slides.map((src, i) => (
591+
<img
592+
key={i}
593+
src={src}
594+
alt={`Slide ${i + 1}`}
595+
className='w-full rounded-md shadow-lg'
596+
/>
597+
))}
598+
</div>
599+
</div>
600+
)
601+
}
602+
420603
function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
421604
const ext = getFileExtension(file.name)
422605

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,3 +327,4 @@ function parseCsvLine(line: string, delimiter: string): string[] {
327327
fields.push(current.trim())
328328
return fields
329329
}
330+

0 commit comments

Comments
 (0)