Skip to content

Commit 78007c1

Browse files
committed
feat(motheship): add docx support
1 parent bac1d5e commit 78007c1

File tree

10 files changed

+601
-46
lines changed

10 files changed

+601
-46
lines changed

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

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { createLogger } from '@sim/logger'
44
import type { NextRequest } from 'next/server'
55
import { NextResponse } from 'next/server'
66
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
7-
import { generatePptxFromCode } from '@/lib/execution/pptx-vm'
7+
import {
8+
generateDocxFromCode,
9+
generatePdfFromCode,
10+
generatePptxFromCode,
11+
} from '@/lib/execution/doc-vm'
812
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
913
import type { StorageContext } from '@/lib/uploads/config'
1014
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -22,47 +26,73 @@ import {
2226
const logger = createLogger('FilesServeAPI')
2327

2428
const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04])
29+
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]) // %PDF-
30+
31+
interface CompilableFormat {
32+
magic: Buffer
33+
compile: (code: string, workspaceId: string) => Promise<Buffer>
34+
contentType: string
35+
}
36+
37+
const COMPILABLE_FORMATS: Record<string, CompilableFormat> = {
38+
'.pptx': {
39+
magic: ZIP_MAGIC,
40+
compile: generatePptxFromCode,
41+
contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
42+
},
43+
'.docx': {
44+
magic: ZIP_MAGIC,
45+
compile: generateDocxFromCode,
46+
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
47+
},
48+
'.pdf': {
49+
magic: PDF_MAGIC,
50+
compile: generatePdfFromCode,
51+
contentType: 'application/pdf',
52+
},
53+
}
2554

26-
const MAX_COMPILED_PPTX_CACHE = 10
27-
const compiledPptxCache = new Map<string, Buffer>()
55+
const MAX_COMPILED_DOC_CACHE = 10
56+
const compiledDocCache = new Map<string, Buffer>()
2857

2958
function compiledCacheSet(key: string, buffer: Buffer): void {
30-
if (compiledPptxCache.size >= MAX_COMPILED_PPTX_CACHE) {
31-
compiledPptxCache.delete(compiledPptxCache.keys().next().value as string)
59+
if (compiledDocCache.size >= MAX_COMPILED_DOC_CACHE) {
60+
compiledDocCache.delete(compiledDocCache.keys().next().value as string)
3261
}
33-
compiledPptxCache.set(key, buffer)
62+
compiledDocCache.set(key, buffer)
3463
}
3564

36-
async function compilePptxIfNeeded(
65+
async function compileDocumentIfNeeded(
3766
buffer: Buffer,
3867
filename: string,
3968
workspaceId?: string,
4069
raw?: boolean
4170
): Promise<{ buffer: Buffer; contentType: string }> {
42-
const isPptx = filename.toLowerCase().endsWith('.pptx')
43-
if (raw || !isPptx || buffer.subarray(0, 4).equals(ZIP_MAGIC)) {
71+
if (raw) return { buffer, contentType: getContentType(filename) }
72+
73+
const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase()
74+
const format = COMPILABLE_FORMATS[ext]
75+
if (!format) return { buffer, contentType: getContentType(filename) }
76+
77+
const magicLen = format.magic.length
78+
if (buffer.length >= magicLen && buffer.subarray(0, magicLen).equals(format.magic)) {
4479
return { buffer, contentType: getContentType(filename) }
4580
}
4681

4782
const code = buffer.toString('utf-8')
4883
const cacheKey = createHash('sha256')
84+
.update(ext)
4985
.update(code)
5086
.update(workspaceId ?? '')
5187
.digest('hex')
52-
const cached = compiledPptxCache.get(cacheKey)
88+
const cached = compiledDocCache.get(cacheKey)
5389
if (cached) {
54-
return {
55-
buffer: cached,
56-
contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
57-
}
90+
return { buffer: cached, contentType: format.contentType }
5891
}
5992

60-
const compiled = await generatePptxFromCode(code, workspaceId || '')
93+
const compiled = await format.compile(code, workspaceId || '')
6194
compiledCacheSet(cacheKey, compiled)
62-
return {
63-
buffer: compiled,
64-
contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
65-
}
95+
return { buffer: compiled, contentType: format.contentType }
6696
}
6797

6898
const STORAGE_KEY_PREFIX_RE = /^\d{13}-[a-z0-9]{7}-/
@@ -169,7 +199,7 @@ async function handleLocalFile(
169199
const segment = filename.split('/').pop() || filename
170200
const displayName = stripStorageKeyPrefix(segment)
171201
const workspaceId = getWorkspaceIdForCompile(filename)
172-
const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded(
202+
const { buffer: fileBuffer, contentType } = await compileDocumentIfNeeded(
173203
rawBuffer,
174204
displayName,
175205
workspaceId,
@@ -226,7 +256,7 @@ async function handleCloudProxy(
226256
const segment = cloudKey.split('/').pop() || 'download'
227257
const displayName = stripStorageKeyPrefix(segment)
228258
const workspaceId = getWorkspaceIdForCompile(cloudKey)
229-
const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded(
259+
const { buffer: fileBuffer, contentType } = await compileDocumentIfNeeded(
230260
rawBuffer,
231261
displayName,
232262
workspaceId,

apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { getSession } from '@/lib/auth'
4-
import { generatePptxFromCode } from '@/lib/execution/pptx-vm'
4+
import { generatePptxFromCode } from '@/lib/execution/doc-vm'
55
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
66

77
export const dynamic = 'force-dynamic'

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

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,34 +44,44 @@ const TEXT_EDITABLE_EXTENSIONS = new Set([
4444
'svg',
4545
])
4646

47-
const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf'])
47+
const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf', 'text/x-pdflibjs'])
4848
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
4949

5050
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
5151
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
5252

5353
const PPTX_PREVIEWABLE_MIME_TYPES = new Set([
5454
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
55+
'text/x-pptxgenjs',
5556
])
5657
const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx'])
5758

59+
const DOCX_PREVIEWABLE_MIME_TYPES = new Set([
60+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
61+
'text/x-docxjs',
62+
])
63+
const DOCX_PREVIEWABLE_EXTENSIONS = new Set(['docx'])
64+
5865
type FileCategory =
5966
| 'text-editable'
6067
| 'iframe-previewable'
6168
| 'image-previewable'
6269
| 'pptx-previewable'
70+
| 'docx-previewable'
6371
| 'unsupported'
6472

6573
function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
6674
if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable'
6775
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
6876
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
77+
if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable'
6978
if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable'
7079

7180
const ext = getFileExtension(filename)
7281
if (TEXT_EDITABLE_EXTENSIONS.has(ext)) return 'text-editable'
7382
if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable'
7483
if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable'
84+
if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable'
7585
if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable'
7686

7787
return 'unsupported'
@@ -138,6 +148,10 @@ export function FileViewer({
138148
return <ImagePreview file={file} />
139149
}
140150

151+
if (category === 'docx-previewable') {
152+
return <DocxPreview file={file} workspaceId={workspaceId} />
153+
}
154+
141155
if (category === 'pptx-previewable') {
142156
return <PptxPreview file={file} workspaceId={workspaceId} streamingContent={streamingContent} />
143157
}
@@ -181,7 +195,14 @@ function TextEditor({
181195
isLoading,
182196
error,
183197
dataUpdatedAt,
184-
} = useWorkspaceFileContent(workspaceId, file.id, file.key, file.type === 'text/x-pptxgenjs')
198+
} = useWorkspaceFileContent(
199+
workspaceId,
200+
file.id,
201+
file.key,
202+
file.type === 'text/x-pptxgenjs' ||
203+
file.type === 'text/x-docxjs' ||
204+
file.type === 'text/x-pdflibjs'
205+
)
185206

186207
const updateContent = useUpdateWorkspaceFileContent()
187208
const updateContentRef = useRef(updateContent)
@@ -551,6 +572,71 @@ const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileR
551572
)
552573
})
553574

575+
const DocxPreview = memo(function DocxPreview({
576+
file,
577+
workspaceId,
578+
}: {
579+
file: WorkspaceFileRecord
580+
workspaceId: string
581+
}) {
582+
const containerRef = useRef<HTMLDivElement>(null)
583+
const {
584+
data: fileData,
585+
isLoading,
586+
error: fetchError,
587+
} = useWorkspaceFileBinary(workspaceId, file.id, file.key)
588+
const [renderError, setRenderError] = useState<string | null>(null)
589+
590+
useEffect(() => {
591+
if (!containerRef.current || !fileData) return
592+
593+
let cancelled = false
594+
595+
async function render() {
596+
try {
597+
const { renderAsync } = await import('docx-preview')
598+
if (cancelled || !containerRef.current) return
599+
containerRef.current.innerHTML = ''
600+
await renderAsync(fileData, containerRef.current, undefined, {
601+
inWrapper: true,
602+
ignoreWidth: false,
603+
ignoreHeight: false,
604+
})
605+
} catch (err) {
606+
if (!cancelled) {
607+
const msg = err instanceof Error ? err.message : 'Failed to render document'
608+
logger.error('DOCX render failed', { error: msg })
609+
setRenderError(msg)
610+
}
611+
}
612+
}
613+
614+
render()
615+
return () => {
616+
cancelled = true
617+
}
618+
}, [fileData])
619+
620+
if (isLoading) {
621+
return (
622+
<div className='flex h-full items-center justify-center'>
623+
<Skeleton className='h-[200px] w-[80%]' />
624+
</div>
625+
)
626+
}
627+
628+
if (fetchError || renderError) {
629+
return (
630+
<div className='flex h-full flex-col items-center justify-center gap-2 text-[var(--text-muted)]'>
631+
<p className='text-[13px]'>Failed to preview document</p>
632+
<p className='text-[11px]'>{renderError || 'Could not load file'}</p>
633+
</div>
634+
)
635+
}
636+
637+
return <div ref={containerRef} className='h-full w-full overflow-auto bg-white' />
638+
})
639+
554640
const pptxSlideCache = new Map<string, string[]>()
555641

556642
function pptxCacheKey(fileId: string, dataUpdatedAt: number, byteLength: number): string {

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,12 @@ export const ResourceContent = memo(function ResourceContent({
8383
}, [streamingFile])
8484
const syntheticFile = useMemo(() => {
8585
const ext = getFileExtension(streamFileName)
86-
const type = ext === 'pptx' ? 'text/x-pptxgenjs' : getMimeTypeFromExtension(ext)
86+
const SOURCE_MIME_MAP: Record<string, string> = {
87+
pptx: 'text/x-pptxgenjs',
88+
docx: 'text/x-docxjs',
89+
pdf: 'text/x-pdflibjs',
90+
}
91+
const type = SOURCE_MIME_MAP[ext] ?? getMimeTypeFromExtension(ext)
8792
return {
8893
id: 'streaming-file',
8994
workspaceId,

0 commit comments

Comments
 (0)