fix(file): zero byte codegen file format + zoomable preview wrapper + mermaid errors loopback (#4400)

* fix(file): zero byte codegen file format

* address comments

* distinguish streaming from actual empty pdf

* fix mermaid diagram state mgmt

* mermaid linter errors sent back to llm

* address comments

* fix autozoom
This commit is contained in:
Vikhyath Mondreti
2026-05-02 01:01:06 -07:00
committed by GitHub
parent 778f4a5589
commit 5f3baa422b
9 changed files with 511 additions and 152 deletions
@@ -7,6 +7,7 @@ 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 { validateMermaidSource } from '@/lib/mermaid/validate'
import { downloadWorkspaceFile, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -18,9 +19,9 @@ const logger = createLogger('WorkspaceFileCompiledCheckAPI')
/**
* GET /api/workspaces/[id]/files/[fileId]/compiled-check
*
* Compiles the saved JavaScript source of a .docx / .pptx / .pdf file and
* Compiles or validates the saved source for generated document-like files and
* returns whether it succeeds. Used by the file agent to self-verify generated
* code before finalising an edit.
* code or diagram syntax before finalising an edit.
*
* Returns:
* 200 { ok: true }
@@ -51,9 +52,10 @@ export const GET = withRouteHandler(
const ext = fileRecord.name.split('.').pop()?.toLowerCase() ?? ''
const taskId = BINARY_DOC_TASKS[ext]
if (!taskId) {
const isMermaidFile = ext === 'mmd' || ext === 'mermaid'
if (!taskId && !isMermaidFile) {
return NextResponse.json(
{ error: `Compiled check only supports .docx, .pptx, and .pdf files` },
{ error: `Compiled check only supports .docx, .pptx, .pdf, and .mmd files` },
{ status: 422 }
)
}
@@ -75,7 +77,14 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: 'File source exceeds maximum size' }, { status: 413 })
}
if (isMermaidFile) {
return NextResponse.json(await validateMermaidSource(code))
}
try {
if (!taskId) {
return NextResponse.json({ error: 'Unsupported compiled check target' }, { status: 422 })
}
await runSandboxTask(taskId, { code, workspaceId }, { ownerKey: `user:${session.user.id}` })
return NextResponse.json({ ok: true })
} catch (err) {
@@ -225,7 +225,11 @@ const IframePreview = memo(function IframePreview({
)
if (streamingContent !== undefined) {
if (!streamingSource) {
if (
!streamingSource ||
streamingSource.kind !== 'buffer' ||
streamingSource.buffer.byteLength === 0
) {
return <div className='relative flex flex-1 overflow-hidden'>{PDF_PAGE_SKELETON}</div>
}
return <PdfViewerCore key={streamingBufferSeq} source={streamingSource} filename={file.name} />
@@ -1,121 +1,21 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
import { ZoomIn, ZoomOut } from 'lucide-react'
import { Button } from '@/components/emcn'
import { memo } from 'react'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
const ZOOM_MIN = 0.25
const ZOOM_MAX = 4
const ZOOM_WHEEL_SENSITIVITY = 0.005
const ZOOM_BUTTON_FACTOR = 1.2
const clampZoom = (z: number) => Math.min(Math.max(z, ZOOM_MIN), ZOOM_MAX)
import { ZoomablePreview } from './zoomable-preview'
export const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
const [zoom, setZoom] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const isDragging = useRef(false)
const dragStart = useRef({ x: 0, y: 0 })
const offsetAtDragStart = useRef({ x: 0, y: 0 })
const offsetRef = useRef(offset)
offsetRef.current = offset
const containerRef = useRef<HTMLDivElement>(null)
const zoomIn = () => setZoom((z) => clampZoom(z * ZOOM_BUTTON_FACTOR))
const zoomOut = () => setZoom((z) => clampZoom(z / ZOOM_BUTTON_FACTOR))
useEffect(() => {
const el = containerRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
e.preventDefault()
if (e.ctrlKey || e.metaKey) {
setZoom((z) => clampZoom(z * Math.exp(-e.deltaY * ZOOM_WHEEL_SENSITIVITY)))
} else {
setOffset((o) => ({ x: o.x - e.deltaX, y: o.y - e.deltaY }))
}
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return
isDragging.current = true
dragStart.current = { x: e.clientX, y: e.clientY }
offsetAtDragStart.current = offsetRef.current
if (containerRef.current) containerRef.current.style.cursor = 'grabbing'
e.preventDefault()
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current) return
setOffset({
x: offsetAtDragStart.current.x + (e.clientX - dragStart.current.x),
y: offsetAtDragStart.current.y + (e.clientY - dragStart.current.y),
})
}
const handleMouseUp = () => {
isDragging.current = false
if (containerRef.current) containerRef.current.style.cursor = 'grab'
}
return (
<div
ref={containerRef}
className='relative flex flex-1 cursor-grab overflow-hidden bg-[var(--surface-1)]'
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className='pointer-events-none absolute inset-0 flex items-center justify-center'
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
transformOrigin: 'center center',
}}
>
<img
src={serveUrl}
alt={file.name}
className='max-h-full max-w-full select-none rounded-md object-contain'
draggable={false}
loading='eager'
/>
</div>
<div
className='absolute right-4 bottom-4 flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 shadow-card'
onMouseDown={(e) => e.stopPropagation()}
>
<Button
variant='ghost'
size='sm'
onClick={zoomOut}
disabled={zoom <= ZOOM_MIN}
className='h-6 w-6 p-0'
aria-label='Zoom out'
>
<ZoomOut className='h-3.5 w-3.5' />
</Button>
<span className='min-w-[3rem] text-center text-[11px] text-[var(--text-secondary)]'>
{Math.round(zoom * 100)}%
</span>
<Button
variant='ghost'
size='sm'
onClick={zoomIn}
disabled={zoom >= ZOOM_MAX}
className='h-6 w-6 p-0'
aria-label='Zoom in'
>
<ZoomIn className='h-3.5 w-3.5' />
</Button>
</div>
</div>
<ZoomablePreview className='flex flex-1' contentClassName='h-full w-full'>
<img
src={serveUrl}
alt={file.name}
className='max-h-full max-w-full select-none rounded-md object-contain'
draggable={false}
loading='eager'
/>
</ZoomablePreview>
)
})
@@ -79,7 +79,7 @@ export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: P
const sourceValue = source.kind === 'url' ? source.url : source.buffer
const file = useMemo(
() => (source.kind === 'url' ? source.url : { data: new Uint8Array(source.buffer) }),
() => (source.kind === 'url' ? source.url : { data: new Uint8Array(source.buffer.slice(0)) }),
[sourceValue]
)
@@ -25,6 +25,7 @@ import { extractTextContent } from '@/lib/core/utils/react-node-text'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
import { DataTable } from './data-table'
import { ZoomablePreview } from './zoomable-preview'
interface HastNode {
position?: { start?: { offset?: number } }
@@ -89,7 +90,8 @@ export const PreviewPanel = memo(function PreviewPanel({
if (previewType === 'html') return <HtmlPreview content={content} />
if (previewType === 'csv') return <CsvPreview content={content} />
if (previewType === 'svg') return <SvgPreview content={content} />
if (previewType === 'mermaid') return <MermaidFilePreview content={content} />
if (previewType === 'mermaid')
return <MermaidFilePreview content={content} isStreaming={isStreaming} />
return null
})
@@ -151,6 +153,7 @@ const MarkdownCheckboxCtx = createContext<{
contentRef: React.MutableRefObject<string>
onToggle: (index: number, checked: boolean) => void
} | null>(null)
const MermaidStreamingCtx = createContext(false)
/** Carries the resolved checkbox index from LiRenderer to InputRenderer. */
const CheckboxIndexCtx = createContext(-1)
@@ -247,17 +250,85 @@ function CalloutBlock({ type, children }: { type: string; children?: React.React
)
}
const MermaidDiagram = memo(function MermaidDiagram({ definition }: { definition: string }) {
function MermaidSourcePreview({
definition,
isRendering,
status,
}: {
definition: string
isRendering: boolean
status?: string
}) {
return (
<div className='my-4 overflow-hidden rounded-lg border border-[var(--border)]'>
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-3)] px-3 py-1.5'>
<span className='text-[11px] text-[var(--text-tertiary)]'>mermaid</span>
{(isRendering || status) && (
<span className='text-[11px] text-[var(--text-muted)]'>
{isRendering ? 'Rendering...' : status}
</span>
)}
</div>
<div className='code-editor-theme bg-[var(--surface-5)]'>
<pre className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] text-[var(--text-primary)] leading-[1.6]'>
<code>{definition}</code>
</pre>
</div>
</div>
)
}
function MermaidCodeBlockSkeleton() {
return (
<div className='my-4 overflow-hidden rounded-lg border border-[var(--border)]'>
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-3)] px-3 py-1.5'>
<span className='text-[11px] text-[var(--text-tertiary)]'>mermaid</span>
<span className='text-[11px] text-[var(--text-muted)]'>Rendering...</span>
</div>
<div className='code-editor-theme bg-[var(--surface-5)]'>
<div className='space-y-2 p-4'>
<div className='h-3 w-5/6 animate-pulse rounded bg-[var(--surface-2)]' />
<div className='h-3 w-2/3 animate-pulse rounded bg-[var(--surface-2)]' />
<div className='h-3 w-3/4 animate-pulse rounded bg-[var(--surface-2)]' />
</div>
</div>
</div>
)
}
const MermaidDiagram = memo(function MermaidDiagram({
definition,
isStreaming = false,
zoomable = false,
zoomClassName,
}: {
definition: string
isStreaming?: boolean
zoomable?: boolean
zoomClassName?: string
}) {
const [svg, setSvg] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const idRef = useRef(`mermaid-${generateShortId(8)}`)
const [isRendering, setIsRendering] = useState(false)
const [renderedDefinition, setRenderedDefinition] = useState<string | null>(null)
const trimmedDefinition = definition.trim()
useEffect(() => {
if (typeof window === 'undefined') return
if (!trimmedDefinition) {
setSvg(null)
setError(null)
setIsRendering(false)
setRenderedDefinition(null)
return
}
let cancelled = false
const renderDelay = isStreaming ? 150 : 0
async function render() {
try {
setIsRendering(true)
const { default: mermaid } = await import('mermaid')
if (cancelled) return
@@ -266,42 +337,90 @@ const MermaidDiagram = memo(function MermaidDiagram({ definition }: { definition
securityLevel: 'strict',
theme: 'default',
})
mermaid.setParseErrorHandler?.(() => undefined)
const { svg: rendered } = await mermaid.render(idRef.current, definition.trim())
if (isStreaming) {
const parsed = await mermaid.parse(trimmedDefinition, { suppressErrors: true })
if (!parsed) {
if (!cancelled) {
setError(null)
}
return
}
} else {
await mermaid.parse(trimmedDefinition)
}
const { svg: rendered } = await mermaid.render(
`mermaid-${generateShortId(8)}`,
trimmedDefinition
)
if (!cancelled) {
setSvg(rendered)
setRenderedDefinition(trimmedDefinition)
setError(null)
}
} catch (err) {
if (!cancelled) {
setError(toError(err).message || 'Failed to render diagram')
setSvg(null)
if (isStreaming) {
setError(null)
} else {
setError(toError(err).message || 'Failed to render diagram')
setSvg(null)
setRenderedDefinition(null)
}
}
} finally {
if (!cancelled) {
setIsRendering(false)
}
}
}
setSvg(null)
setError(null)
render()
const timer = window.setTimeout(() => {
render()
}, renderDelay)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [definition])
}, [trimmedDefinition, isStreaming])
if (error) {
return (
<div className='my-4 rounded-lg border border-[var(--border)] p-4 text-[13px] text-[var(--text-muted)]'>
<span className='font-medium text-[var(--text-primary)]'>Diagram error: </span>
{error}
</div>
<MermaidSourcePreview definition={definition} isRendering={false} status='Invalid diagram' />
)
}
if (!svg) {
return <div className='my-4 h-[100px] animate-pulse rounded-lg bg-[var(--surface-2)]' />
if (svg && renderedDefinition === trimmedDefinition) {
const diagram = <div dangerouslySetInnerHTML={{ __html: svg }} />
if (zoomable) {
return (
<ZoomablePreview
className={zoomClassName ?? 'my-4 h-[420px] rounded-lg'}
initialScale='fit'
resetKey={renderedDefinition}
>
{diagram}
</ZoomablePreview>
)
}
return (
<div className='my-4 overflow-auto rounded-lg' dangerouslySetInnerHTML={{ __html: svg }} />
)
}
return <div className='my-4 overflow-auto rounded-lg' dangerouslySetInnerHTML={{ __html: svg }} />
if (isStreaming) {
return <MermaidSourcePreview definition={definition} isRendering={isRendering} />
}
if (!trimmedDefinition || !svg || renderedDefinition !== trimmedDefinition) {
return <MermaidCodeBlockSkeleton />
}
return null
})
const STATIC_MARKDOWN_COMPONENTS = {
@@ -374,12 +493,13 @@ const STATIC_MARKDOWN_COMPONENTS = {
)
},
code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
const isMarkdownStreaming = useContext(MermaidStreamingCtx)
const langMatch = className?.match(/language-(\w+)/)
const langRaw = langMatch?.[1] ?? ''
const codeString = extractTextContent(children)
if (langRaw === 'mermaid') {
return <MermaidDiagram definition={codeString} />
return <MermaidDiagram definition={codeString} isStreaming={isMarkdownStreaming} />
}
if (!codeString) {
@@ -706,14 +826,16 @@ const MarkdownPreview = memo(function MarkdownPreview({
const body = (
<div ref={autoScrollRef} className='h-full overflow-auto p-6'>
{frontMatterData && <FrontMatterCard data={frontMatterData} />}
<Streamdown
mode={streamdownMode}
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={MARKDOWN_COMPONENTS}
>
{markdownContent}
</Streamdown>
<MermaidStreamingCtx.Provider value={isStreaming}>
<Streamdown
mode={streamdownMode}
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={MARKDOWN_COMPONENTS}
>
{markdownContent}
</Streamdown>
</MermaidStreamingCtx.Provider>
</div>
)
@@ -867,21 +989,26 @@ function SvgPreview({ content }: { content: string }) {
const wrappedContent = `<!DOCTYPE html><html><head><style>body{margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:transparent;}svg{max-width:100%;max-height:100vh;}</style></head><body>${content}</body></html>`
return (
<div className='h-full overflow-hidden'>
<ZoomablePreview className='h-full' contentClassName='h-full w-full'>
<iframe
srcDoc={wrappedContent}
sandbox=''
title='SVG Preview'
className='h-full w-full border-0'
/>
</div>
</ZoomablePreview>
)
}
function MermaidFilePreview({ content }: { content: string }) {
function MermaidFilePreview({ content, isStreaming }: { content: string; isStreaming?: boolean }) {
return (
<div className='h-full overflow-auto p-6'>
<MermaidDiagram definition={content} />
<MermaidDiagram
definition={content}
isStreaming={isStreaming}
zoomable
zoomClassName='h-full rounded-lg'
/>
</div>
)
}
@@ -0,0 +1,287 @@
'use client'
import type { MouseEvent, ReactNode } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ZoomIn, ZoomOut } from 'lucide-react'
import { Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
const ZOOM_MIN = 0.25
const ZOOM_MAX = 4
const ZOOM_WHEEL_SENSITIVITY = 0.005
const ZOOM_BUTTON_FACTOR = 1.2
const FIT_PADDING = 48
const clampZoom = (zoom: number) => Math.min(Math.max(zoom, ZOOM_MIN), ZOOM_MAX)
interface Offset {
x: number
y: number
}
interface Size {
width: number
height: number
}
interface ZoomablePreviewProps {
children: ReactNode
className?: string
contentClassName?: string
initialScale?: 'actual' | 'fit'
resetKey?: string | number
}
function getElementSize(element: HTMLElement | null): Size {
if (!element) return { width: 0, height: 0 }
return {
width: element.offsetWidth,
height: element.offsetHeight,
}
}
function getFitZoom(container: Size, content: Size): number {
if (container.width <= 0 || container.height <= 0 || content.width <= 0 || content.height <= 0) {
return 1
}
const availableWidth = Math.max(1, container.width - FIT_PADDING)
const availableHeight = Math.max(1, container.height - FIT_PADDING)
return clampZoom(Math.min(1, availableWidth / content.width, availableHeight / content.height))
}
function clampOffset(container: Size, content: Size, offset: Offset, zoom: number): Offset {
if (container.width <= 0 || container.height <= 0 || content.width <= 0 || content.height <= 0) {
return offset
}
const scaledWidth = content.width * zoom
const scaledHeight = content.height * zoom
const maxX = Math.max(0, (scaledWidth - container.width) / 2)
const maxY = Math.max(0, (scaledHeight - container.height) / 2)
return {
x: Math.min(Math.max(offset.x, -maxX), maxX),
y: Math.min(Math.max(offset.y, -maxY), maxY),
}
}
export function ZoomablePreview({
children,
className,
contentClassName,
initialScale = 'actual',
resetKey,
}: ZoomablePreviewProps) {
const [zoom, setZoom] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const [containerSize, setContainerSize] = useState<Size>({ width: 0, height: 0 })
const [contentSize, setContentSize] = useState<Size>({ width: 0, height: 0 })
const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const isDragging = useRef(false)
const dragStart = useRef({ x: 0, y: 0 })
const offsetAtDragStart = useRef({ x: 0, y: 0 })
const hasInteractedRef = useRef(false)
const zoomRef = useRef(zoom)
const offsetRef = useRef(offset)
const containerSizeRef = useRef(containerSize)
const contentSizeRef = useRef(contentSize)
zoomRef.current = zoom
offsetRef.current = offset
containerSizeRef.current = containerSize
contentSizeRef.current = contentSize
const applyZoom = useCallback((nextZoom: number) => {
zoomRef.current = nextZoom
setZoom(nextZoom)
setOffset((currentOffset) =>
clampOffset(containerSizeRef.current, contentSizeRef.current, currentOffset, nextZoom)
)
}, [])
const fitToView = useCallback(() => {
hasInteractedRef.current = false
const nextZoom =
initialScale === 'fit' ? getFitZoom(containerSizeRef.current, contentSizeRef.current) : 1
zoomRef.current = nextZoom
setZoom(nextZoom)
setOffset({ x: 0, y: 0 })
}, [initialScale])
const zoomIn = () => {
hasInteractedRef.current = true
applyZoom(clampZoom(zoom * ZOOM_BUTTON_FACTOR))
}
const zoomOut = () => {
hasInteractedRef.current = true
applyZoom(clampZoom(zoom / ZOOM_BUTTON_FACTOR))
}
useEffect(() => {
const el = containerRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
e.preventDefault()
if (e.ctrlKey || e.metaKey) {
hasInteractedRef.current = true
applyZoom(clampZoom(zoomRef.current * Math.exp(-e.deltaY * ZOOM_WHEEL_SENSITIVITY)))
} else {
hasInteractedRef.current = true
setOffset((currentOffset) =>
clampOffset(
containerSizeRef.current,
contentSizeRef.current,
{
x: currentOffset.x - e.deltaX,
y: currentOffset.y - e.deltaY,
},
zoomRef.current
)
)
}
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [applyZoom])
useLayoutEffect(() => {
const updateSizes = () => {
setContainerSize(getElementSize(containerRef.current))
setContentSize(getElementSize(contentRef.current))
}
updateSizes()
const container = containerRef.current
const content = contentRef.current
if (!container || !content) return
const observer = new ResizeObserver(() => {
updateSizes()
})
observer.observe(container)
observer.observe(content)
return () => observer.disconnect()
}, [])
useLayoutEffect(() => {
if (
containerSize.width <= 0 ||
containerSize.height <= 0 ||
contentSize.width <= 0 ||
contentSize.height <= 0
) {
return
}
const nextZoom =
initialScale === 'fit' && !hasInteractedRef.current
? getFitZoom(containerSize, contentSize)
: zoomRef.current
zoomRef.current = nextZoom
setZoom(nextZoom)
setOffset((currentOffset) => clampOffset(containerSize, contentSize, currentOffset, nextZoom))
}, [containerSize, contentSize, initialScale])
useLayoutEffect(() => {
hasInteractedRef.current = false
const nextZoom =
initialScale === 'fit' ? getFitZoom(containerSizeRef.current, contentSizeRef.current) : 1
zoomRef.current = nextZoom
setZoom(nextZoom)
setOffset({ x: 0, y: 0 })
}, [initialScale, resetKey])
const handleMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return
hasInteractedRef.current = true
isDragging.current = true
dragStart.current = { x: e.clientX, y: e.clientY }
offsetAtDragStart.current = offsetRef.current
if (containerRef.current) containerRef.current.style.cursor = 'grabbing'
e.preventDefault()
}
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return
setOffset(
clampOffset(
containerSizeRef.current,
contentSizeRef.current,
{
x: offsetAtDragStart.current.x + (e.clientX - dragStart.current.x),
y: offsetAtDragStart.current.y + (e.clientY - dragStart.current.y),
},
zoom
)
)
}
const handleMouseUp = () => {
isDragging.current = false
if (containerRef.current) containerRef.current.style.cursor = 'grab'
}
return (
<div
ref={containerRef}
className={cn('relative cursor-grab overflow-hidden bg-[var(--surface-1)]', className)}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<div
ref={contentRef}
className={cn('flex items-center justify-center', contentClassName)}
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
transformOrigin: 'center center',
}}
>
{children}
</div>
</div>
<div
className='absolute right-4 bottom-4 flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 shadow-card'
onMouseDown={(e) => e.stopPropagation()}
>
<Button
variant='ghost'
size='sm'
onClick={fitToView}
className='h-6 px-2 text-[11px]'
aria-label={initialScale === 'fit' ? 'Fit to view' : 'Reset zoom'}
>
{initialScale === 'fit' ? 'Fit' : 'Reset'}
</Button>
<Button
variant='ghost'
size='sm'
onClick={zoomOut}
disabled={zoom <= ZOOM_MIN}
className='h-6 w-6 p-0'
aria-label='Zoom out'
>
<ZoomOut className='h-3.5 w-3.5' />
</Button>
<span className='min-w-[3rem] text-center text-[11px] text-[var(--text-secondary)]'>
{Math.round(zoom * 100)}%
</span>
<Button
variant='ghost'
size='sm'
onClick={zoomIn}
disabled={zoom >= ZOOM_MAX}
className='h-6 w-6 p-0'
aria-label='Zoom in'
>
<ZoomIn className='h-3.5 w-3.5' />
</Button>
</div>
</div>
)
}
@@ -117,6 +117,10 @@ export const ResourceContent = memo(function ResourceContent({
const rawPreviewText = previewSession?.previewText
const streamingPreviewText =
typeof rawPreviewText === 'string' && rawPreviewText.length > 0 ? rawPreviewText : undefined
const pendingOrStreamingFilePreviewText =
previewSession?.fileId === resource.id && typeof rawPreviewText === 'string'
? rawPreviewText
: undefined
if (previewSession && resource.id === 'streaming-file') {
return (
@@ -152,9 +156,7 @@ export const ResourceContent = memo(function ResourceContent({
workspaceId={workspaceId}
fileId={resource.id}
previewMode={previewMode}
streamingContent={
previewSession?.fileId === resource.id ? streamingPreviewText : undefined
}
streamingContent={pendingOrStreamingFilePreviewText}
streamingMode='replace'
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
+11 -3
View File
@@ -61,6 +61,7 @@ import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task'
import { getKnowledgeBases } from '@/lib/knowledge/service'
import { validateMermaidSource } from '@/lib/mermaid/validate'
import { listTables } from '@/lib/table/service'
import {
downloadWorkspaceFile,
@@ -316,7 +317,7 @@ function getStaticComponentFiles(): Map<string, string> {
* files/{name}/meta.json
* files/by-id/{id}/meta.json
* files/by-id/{id}/style (dynamic — OOXML theme/font extraction for .docx/.pptx)
* files/by-id/{id}/compiled-check (dynamic — compile JS source via sandbox, returns {ok,error?})
* files/by-id/{id}/compiled-check (dynamic — compile generated source / validate diagrams, returns {ok,error?})
* jobs/{title}/meta.json
* jobs/{title}/history.json
* jobs/{title}/executions.json
@@ -457,7 +458,7 @@ export class WorkspaceVFS {
* Handles images (base64), parseable documents (PDF, etc.), and text files.
* Also handles:
* `files/by-id/{id}/style` — OOXML theme/style extraction (.docx / .pptx only)
* `files/by-id/{id}/compiled-check` — sandbox compile check for JS-source binary files
* `files/by-id/{id}/compiled-check` — compile JS-source binary files or validate Mermaid diagrams
* Returns null if the path doesn't match `files/{name}` / `files/by-id/{id}` or the file isn't found.
*/
async readFileContent(path: string): Promise<FileReadResult | null> {
@@ -470,7 +471,8 @@ export class WorkspaceVFS {
if (!record) return null
const ext = record.name.split('.').pop()?.toLowerCase() ?? ''
const taskId = BINARY_DOC_TASKS[ext]
if (!taskId) return null
const isMermaidFile = ext === 'mmd' || ext === 'mermaid'
if (!taskId && !isMermaidFile) return null
const buffer = await downloadWorkspaceFile(record)
const code = buffer.toString('utf-8')
if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) {
@@ -479,8 +481,14 @@ export class WorkspaceVFS {
totalLines: 1,
}
}
if (isMermaidFile) {
const result = await validateMermaidSource(code)
const json = JSON.stringify(result)
return { content: json, totalLines: 1 }
}
let result: { ok: boolean; error?: string; errorName?: string }
try {
if (!taskId) return null
await runSandboxTask(taskId, { code, workspaceId: this._workspaceId })
result = { ok: true }
} catch (err) {
+22
View File
@@ -0,0 +1,22 @@
import { toError } from '@sim/utils/errors'
export interface MermaidValidationResult {
ok: boolean
error?: string
errorName?: string
}
export async function validateMermaidSource(source: string): Promise<MermaidValidationResult> {
try {
const { default: mermaid } = await import('mermaid')
await mermaid.parse(source)
return { ok: true }
} catch (error) {
const err = toError(error)
return {
ok: false,
error: err.message || 'Invalid Mermaid diagram',
errorName: err.name || 'MermaidParseError',
}
}
}