mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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} />
|
||||
|
||||
+11
-111
@@ -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]
|
||||
)
|
||||
|
||||
|
||||
+156
-29
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+287
@@ -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>
|
||||
)
|
||||
}
|
||||
+5
-3
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user