mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
feat(files): extract PDF viewer behind SSR boundary and polish file preview
## Core architectural fix
Move all react-pdf / pdfjs-dist code into a new pdf-viewer.tsx module and
import it exclusively via next/dynamic({ ssr: false }). pdfjs-dist v5
references DOMMatrix at module evaluation time, which crashed SSR. The
previous workaround (a DOMMatrix polyfill in instrumentation.ts) is removed
in favour of this proper hard module boundary.
## PDF viewer improvements
- Cursor-anchored zoom: Ctrl/⌘+wheel and trackpad-pinch now zoom toward the
cursor instead of the top-left corner. Toolbar ± buttons anchor to the
viewport centre. Uses the canonical scroll-adjust formula used by map and
canvas viewers.
- Horizontal scroll: dropping flex-col from the scroll container lets the
zoomed pages wrapper overflow naturally and produces a horizontal scrollbar
at zoom > 1×.
- Loading skeleton: replaced the conditional inline skeleton with an
absolute inset-0 overlay so it fills the scroll container correctly in all
layout contexts.
- Shadow tokens: fixed shadow-[var(--shadow-medium)] and
shadow-[var(--shadow-card)] to use the Tailwind utility classes
shadow-medium and shadow-card directly.
## File viewer cleanup
- data-table.tsx: wrap setInputRef in useCallback([]) so the ref callback
has a stable identity across renders. Previously the inline function got a
new identity on every keystroke (because editValue state changed), causing
React to teardown/remount the ref and re-run node.select() on every
character typed.
- preview-panel.tsx: keep useMemo on ctxValue passed to Context.Provider —
Context uses Object.is, so a new object every render causes unnecessary
consumer re-renders.
- resource-content.tsx: remove unnecessary useCallback/useMemo wrappers on
handlers and derived values that have no memoization observers.
## API route
- Wrap content route with withRouteHandler for automatic request-ID tracking
via AsyncLocalStorage; remove manual generateRequestId() calls.
- Add resourceName to audit record; add encoding param support (base64 /
utf-8).
## Query hooks
- Include key (storage object key) in both useWorkspaceFileContent and
useWorkspaceFileBinary query key tuples so the cache is correctly busted
when a file is re-uploaded with a new storage key.
## Other
- Add Suspense boundaries to files/page.tsx and files/[fileId]/page.tsx
(required for useSearchParams inside the Files component).
- Add mmd to SUPPORTED_CODE_EXTENSIONS (Mermaid diagrams).
- Add https: to CSP img-src.
- Remove ==== separator comments from lib/copilot/constants.ts.
- New dependencies: pdfjs-dist 5.4.296, mermaid 11.14.0,
monaco-editor 0.55.1, @monaco-editor/react 4.7.0.
This commit is contained in:
@@ -2,7 +2,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -17,7 +16,6 @@ const logger = createLogger('WorkspaceFileContentAPI')
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const { id: workspaceId, fileId } = await params
|
||||
|
||||
try {
|
||||
@@ -32,20 +30,19 @@ export const PUT = withRouteHandler(
|
||||
workspaceId
|
||||
)
|
||||
if (userPermission !== 'admin' && userPermission !== 'write') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`)
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { content } = body as { content: string }
|
||||
const { content, encoding } = body as { content: string; encoding?: 'base64' | 'utf-8' }
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
return NextResponse.json({ error: 'Content must be a string' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(content, 'utf-8')
|
||||
const buffer =
|
||||
encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8')
|
||||
|
||||
const maxFileSizeBytes = 50 * 1024 * 1024
|
||||
if (buffer.length > maxFileSizeBytes) {
|
||||
@@ -62,7 +59,7 @@ export const PUT = withRouteHandler(
|
||||
buffer
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Updated content for workspace file: ${updatedFile.name}`)
|
||||
logger.info(`Updated content for workspace file: ${updatedFile.name}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
@@ -89,9 +86,9 @@ export const PUT = withRouteHandler(
|
||||
const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500
|
||||
|
||||
if (status === 500) {
|
||||
logger.error(`[${requestId}] Error updating file content:`, error)
|
||||
logger.error('Error updating file content:', error)
|
||||
} else {
|
||||
logger.warn(`[${requestId}] ${errorMessage}`)
|
||||
logger.warn(errorMessage)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, error: errorMessage }, { status })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { Files } from '../files'
|
||||
|
||||
@@ -6,4 +7,10 @@ export const metadata: Metadata = {
|
||||
robots: { index: false },
|
||||
}
|
||||
|
||||
export default Files
|
||||
export default function FilesFilePage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<Files />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,63 @@
|
||||
import { memo } from 'react'
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface EditConfig {
|
||||
onCellChange: (row: number, col: number, value: string) => void
|
||||
onHeaderChange: (col: number, value: string) => void
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
headers: string[]
|
||||
rows: string[][]
|
||||
editConfig?: EditConfig
|
||||
}
|
||||
|
||||
export const DataTable = memo(function DataTable({ headers, rows }: DataTableProps) {
|
||||
type EditingCell = { row: number; col: number } | null
|
||||
|
||||
export const DataTable = memo(function DataTable({ headers, rows, editConfig }: DataTableProps) {
|
||||
const [editingCell, setEditingCell] = useState<EditingCell>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
|
||||
const setInputRef = useCallback((node: HTMLInputElement | null) => {
|
||||
if (node) {
|
||||
node.focus()
|
||||
node.select()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startEdit = (row: number, col: number, currentValue: string) => {
|
||||
if (!editConfig) return
|
||||
setEditingCell({ row, col })
|
||||
setEditValue(currentValue)
|
||||
}
|
||||
|
||||
const commitEdit = () => {
|
||||
if (!editingCell || !editConfig) return
|
||||
const { row, col } = editingCell
|
||||
if (row === -1) {
|
||||
editConfig.onHeaderChange(col, editValue)
|
||||
} else {
|
||||
editConfig.onCellChange(row, col, editValue)
|
||||
}
|
||||
setEditingCell(null)
|
||||
}
|
||||
|
||||
const cancelEdit = () => setEditingCell(null)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
commitEdit()
|
||||
} else if (e.key === 'Escape') {
|
||||
cancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
const isEditing = (row: number, col: number) =>
|
||||
editingCell?.row === row && editingCell?.col === col
|
||||
|
||||
return (
|
||||
<div className='overflow-x-auto rounded-md border border-[var(--border)]'>
|
||||
<table className='w-full border-collapse text-[13px]'>
|
||||
@@ -14,9 +66,24 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro
|
||||
{headers.map((header, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className='whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'
|
||||
className={cn(
|
||||
'whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]',
|
||||
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-3)]'
|
||||
)}
|
||||
onClick={() => editConfig && startEdit(-1, i, String(header ?? ''))}
|
||||
>
|
||||
{String(header ?? '')}
|
||||
{isEditing(-1, i) ? (
|
||||
<input
|
||||
ref={setInputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='w-full min-w-[60px] bg-transparent font-semibold text-[12px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset'
|
||||
/>
|
||||
) : (
|
||||
String(header ?? '')
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -25,8 +92,26 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} className='border-[var(--border)] border-t'>
|
||||
{headers.map((_, ci) => (
|
||||
<td key={ci} className='whitespace-nowrap px-3 py-2 text-[var(--text-secondary)]'>
|
||||
{String(row[ci] ?? '')}
|
||||
<td
|
||||
key={ci}
|
||||
className={cn(
|
||||
'whitespace-nowrap px-3 py-2 text-[var(--text-secondary)]',
|
||||
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-2)]'
|
||||
)}
|
||||
onClick={() => editConfig && startEdit(ri, ci, String(row[ci] ?? ''))}
|
||||
>
|
||||
{isEditing(ri, ci) ? (
|
||||
<input
|
||||
ref={setInputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='w-full min-w-[60px] bg-transparent text-[13px] text-[var(--text-secondary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset'
|
||||
/>
|
||||
) : (
|
||||
String(row[ci] ?? '')
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf'
|
||||
import 'react-pdf/dist/Page/TextLayer.css'
|
||||
import { Button, Skeleton } from '@/components/emcn'
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).href
|
||||
|
||||
const logger = createLogger('PdfViewer')
|
||||
|
||||
const PDF_ZOOM_MIN = 0.5
|
||||
const PDF_ZOOM_MAX = 3
|
||||
const PDF_ZOOM_DEFAULT = 1
|
||||
const PDF_ZOOM_STEP = 1.25
|
||||
const PDF_PAGE_MAX_WIDTH = 816
|
||||
const PDF_VIEWER_PADDING = 24
|
||||
|
||||
export type PdfDocumentSource =
|
||||
| { kind: 'url'; url: string }
|
||||
| { kind: 'buffer'; buffer: ArrayBuffer }
|
||||
|
||||
interface PdfViewerCoreProps {
|
||||
source: PdfDocumentSource
|
||||
filename: string
|
||||
}
|
||||
|
||||
const PDF_SKELETON = (
|
||||
<div className='absolute inset-0 flex flex-col items-center gap-4 overflow-y-auto bg-[var(--surface-1)] p-6'>
|
||||
{[0, 1].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='w-full max-w-[640px] shrink-0 rounded-md bg-[var(--surface-2)] p-8 shadow-medium'
|
||||
style={{ aspectRatio: '1 / 1.414' }}
|
||||
>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<Skeleton className='h-[14px] w-[60%]' />
|
||||
<Skeleton className='h-[14px] w-[80%]' />
|
||||
<Skeleton className='h-[14px] w-[55%]' />
|
||||
<Skeleton className='mt-2 h-[14px] w-[75%]' />
|
||||
<Skeleton className='h-[14px] w-[65%]' />
|
||||
<Skeleton className='h-[14px] w-[85%]' />
|
||||
<Skeleton className='h-[14px] w-[50%]' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
function PdfError({ error }: { error: string }) {
|
||||
return (
|
||||
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
|
||||
<p className='font-medium text-[14px] text-[var(--text-body)]'>Failed to preview PDF</p>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: PdfViewerCoreProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const paddingWrapperRef = useRef<HTMLDivElement>(null)
|
||||
const pagesWrapperRef = useRef<HTMLDivElement>(null)
|
||||
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const pageWidthRef = useRef<number | undefined>(undefined)
|
||||
|
||||
const zoomRef = useRef(PDF_ZOOM_DEFAULT)
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const [pageCount, setPageCount] = useState(0)
|
||||
const [isDocumentReady, setIsDocumentReady] = useState(false)
|
||||
const [displayZoom, setDisplayZoom] = useState(PDF_ZOOM_DEFAULT)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const sourceValue = source.kind === 'url' ? source.url : source.buffer
|
||||
const file = useMemo(
|
||||
() => (source.kind === 'url' ? source.url : { data: new Uint8Array(source.buffer) }),
|
||||
[sourceValue]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
setContainerWidth(entry.contentRect.width)
|
||||
})
|
||||
observer.observe(container)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const pageWidth =
|
||||
containerWidth > 0
|
||||
? Math.min(containerWidth - 2 * PDF_VIEWER_PADDING, PDF_PAGE_MAX_WIDTH)
|
||||
: undefined
|
||||
pageWidthRef.current = pageWidth
|
||||
|
||||
const applyZoomAt = useCallback((next: number, anchorX: number, anchorY: number) => {
|
||||
const container = containerRef.current
|
||||
const wrapper = pagesWrapperRef.current
|
||||
const padWrapper = paddingWrapperRef.current
|
||||
const pw = pageWidthRef.current
|
||||
if (!container || !wrapper) return
|
||||
const ratio = next / zoomRef.current
|
||||
wrapper.style.zoom = String(next)
|
||||
if (padWrapper && pw !== undefined) {
|
||||
padWrapper.style.minWidth = `${pw * next + 2 * PDF_VIEWER_PADDING}px`
|
||||
}
|
||||
// Padding is outside the zoom subtree, so offset the anchor by it before scaling.
|
||||
container.scrollLeft =
|
||||
(container.scrollLeft + anchorX - PDF_VIEWER_PADDING) * ratio + PDF_VIEWER_PADDING - anchorX
|
||||
container.scrollTop =
|
||||
(container.scrollTop + anchorY - PDF_VIEWER_PADDING) * ratio + PDF_VIEWER_PADDING - anchorY
|
||||
zoomRef.current = next
|
||||
setDisplayZoom(next)
|
||||
}, [])
|
||||
|
||||
const scrollToPage = (page: number) => {
|
||||
const wrapper = pageRefs.current[page - 1]
|
||||
if (wrapper && containerRef.current) {
|
||||
containerRef.current.scrollTo({ top: wrapper.offsetTop - 16, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || pageCount === 0) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
const pageNum = Number((entry.target as HTMLElement).dataset.page)
|
||||
if (pageNum) setCurrentPage(pageNum)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: container, threshold: 0.5 }
|
||||
)
|
||||
|
||||
for (const wrapper of pageRefs.current) {
|
||||
if (wrapper) observer.observe(wrapper)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [pageCount])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey) return
|
||||
e.preventDefault()
|
||||
|
||||
const next = Math.min(
|
||||
PDF_ZOOM_MAX,
|
||||
Math.max(PDF_ZOOM_MIN, zoomRef.current * (1 - e.deltaY * 0.005))
|
||||
)
|
||||
const rect = container.getBoundingClientRect()
|
||||
applyZoomAt(next, e.clientX - rect.left, e.clientY - rect.top)
|
||||
}
|
||||
|
||||
container.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => container.removeEventListener('wheel', onWheel)
|
||||
}, [applyZoomAt])
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
{pageCount > 0 && !loadError && (
|
||||
<div className='flex shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-1)] px-3 py-1.5'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
const prev = Math.max(1, currentPage - 1)
|
||||
setCurrentPage(prev)
|
||||
scrollToPage(prev)
|
||||
}}
|
||||
disabled={currentPage <= 1}
|
||||
className='h-6 w-6 p-0 text-[var(--text-icon)]'
|
||||
aria-label='Previous page'
|
||||
>
|
||||
<ChevronLeft className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
<span className='min-w-[5rem] text-center text-[12px] text-[var(--text-secondary)]'>
|
||||
{currentPage} / {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
const next = Math.min(pageCount, currentPage + 1)
|
||||
setCurrentPage(next)
|
||||
scrollToPage(next)
|
||||
}}
|
||||
disabled={currentPage >= pageCount}
|
||||
className='h-6 w-6 p-0 text-[var(--text-icon)]'
|
||||
aria-label='Next page'
|
||||
>
|
||||
<ChevronRight className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
const c = containerRef.current
|
||||
applyZoomAt(
|
||||
Math.max(PDF_ZOOM_MIN, zoomRef.current / PDF_ZOOM_STEP),
|
||||
c ? c.clientWidth / 2 : 0,
|
||||
c ? c.clientHeight / 2 : 0
|
||||
)
|
||||
}}
|
||||
disabled={displayZoom <= PDF_ZOOM_MIN}
|
||||
className='h-6 w-6 p-0 text-[var(--text-icon)]'
|
||||
aria-label='Zoom out'
|
||||
>
|
||||
<ZoomOut className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
<span className='min-w-[3rem] text-center text-[12px] text-[var(--text-secondary)]'>
|
||||
{Math.round(displayZoom * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
const c = containerRef.current
|
||||
applyZoomAt(
|
||||
Math.min(PDF_ZOOM_MAX, zoomRef.current * PDF_ZOOM_STEP),
|
||||
c ? c.clientWidth / 2 : 0,
|
||||
c ? c.clientHeight / 2 : 0
|
||||
)
|
||||
}}
|
||||
disabled={displayZoom >= PDF_ZOOM_MAX}
|
||||
className='h-6 w-6 p-0 text-[var(--text-icon)]'
|
||||
aria-label='Zoom in'
|
||||
>
|
||||
<ZoomIn className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='relative flex flex-1 items-start overflow-auto bg-[var(--surface-1)]'
|
||||
>
|
||||
{!isDocumentReady && PDF_SKELETON}
|
||||
<ReactPdfDocument
|
||||
file={file}
|
||||
onLoadSuccess={({ numPages }) => {
|
||||
setPageCount(numPages)
|
||||
setCurrentPage(1)
|
||||
setIsDocumentReady(true)
|
||||
}}
|
||||
onLoadError={(err) => {
|
||||
logger.error('PDF load failed', { error: err.message })
|
||||
setLoadError(err.message)
|
||||
setIsDocumentReady(true)
|
||||
}}
|
||||
error={<PdfError error={loadError ?? 'Failed to load PDF'} />}
|
||||
className='mx-auto'
|
||||
>
|
||||
<div
|
||||
ref={paddingWrapperRef}
|
||||
style={{
|
||||
padding: PDF_VIEWER_PADDING,
|
||||
minWidth:
|
||||
pageWidth !== undefined
|
||||
? `${pageWidth * displayZoom + 2 * PDF_VIEWER_PADDING}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div ref={pagesWrapperRef} style={{ width: pageWidth }}>
|
||||
{Array.from({ length: pageCount }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
pageRefs.current[i] = el
|
||||
}}
|
||||
data-page={i + 1}
|
||||
className='mb-4 overflow-clip rounded-md shadow-medium'
|
||||
>
|
||||
<ReactPdfPage
|
||||
pageNumber={i + 1}
|
||||
width={pageWidth}
|
||||
className='!overflow-clip [&_.textLayer]:!overflow-clip'
|
||||
renderTextLayer
|
||||
renderAnnotationLayer={false}
|
||||
aria-label={`${filename} page ${i + 1}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ReactPdfDocument>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,23 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createContext, memo, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import matter from 'gray-matter'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import 'streamdown/styles.css'
|
||||
import { Checkbox } from '@/components/emcn'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { Checkbox, CopyCodeButton, highlight, languages } from '@/components/emcn'
|
||||
import '@/components/emcn/components/code/code.css'
|
||||
import 'prismjs/components/prism-bash'
|
||||
import 'prismjs/components/prism-css'
|
||||
import 'prismjs/components/prism-markup'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-typescript'
|
||||
import 'prismjs/components/prism-yaml'
|
||||
import 'prismjs/components/prism-sql'
|
||||
import 'prismjs/components/prism-python'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
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'
|
||||
@@ -26,13 +29,14 @@ interface HastNode {
|
||||
position?: { start?: { offset?: number } }
|
||||
}
|
||||
|
||||
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | null
|
||||
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | 'mermaid' | null
|
||||
|
||||
const PREVIEWABLE_MIME_TYPES: Record<string, PreviewType> = {
|
||||
'text/markdown': 'markdown',
|
||||
'text/html': 'html',
|
||||
'text/csv': 'csv',
|
||||
'image/svg+xml': 'svg',
|
||||
'text/x-mermaid': 'mermaid',
|
||||
}
|
||||
|
||||
const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
|
||||
@@ -41,6 +45,7 @@ const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
|
||||
htm: 'html',
|
||||
csv: 'csv',
|
||||
svg: 'svg',
|
||||
mmd: 'mermaid',
|
||||
}
|
||||
|
||||
/** All extensions that have a rich preview renderer. */
|
||||
@@ -80,11 +85,58 @@ 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} />
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
|
||||
const CALLOUT_TYPES = new Set(['NOTE', 'TIP', 'WARNING', 'IMPORTANT', 'CAUTION'])
|
||||
|
||||
function remarkCallouts() {
|
||||
return (tree: { type: string; children?: unknown[] }) => {
|
||||
function processNode(node: { type: string; children?: unknown[] }) {
|
||||
if (!node.children) return
|
||||
for (const child of node.children) {
|
||||
processNode(child as { type: string; children?: unknown[] })
|
||||
const c = child as {
|
||||
type: string
|
||||
children?: unknown[]
|
||||
data?: { hName?: string; hProperties?: Record<string, string> }
|
||||
}
|
||||
if (c.type !== 'blockquote') continue
|
||||
const first = (c.children?.[0] ?? null) as {
|
||||
type: string
|
||||
children?: unknown[]
|
||||
} | null
|
||||
if (!first || first.type !== 'paragraph') continue
|
||||
const firstText = (first.children?.[0] ?? null) as {
|
||||
type: string
|
||||
value?: string
|
||||
} | null
|
||||
if (!firstText || firstText.type !== 'text' || !firstText.value) continue
|
||||
const match = firstText.value.match(/^\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]\s?/i)
|
||||
if (!match) continue
|
||||
const calloutType = match[1].toUpperCase()
|
||||
if (!CALLOUT_TYPES.has(calloutType)) continue
|
||||
|
||||
c.data ??= {}
|
||||
c.data.hProperties = { ...(c.data.hProperties ?? {}), 'data-callout': calloutType }
|
||||
|
||||
const remainder = firstText.value.slice(match[0].length)
|
||||
if (remainder) {
|
||||
firstText.value = remainder
|
||||
} else if (first.children && first.children.length === 1) {
|
||||
c.children?.shift()
|
||||
} else {
|
||||
first.children?.shift()
|
||||
}
|
||||
}
|
||||
}
|
||||
processNode(tree)
|
||||
}
|
||||
}
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkBreaks, remarkCallouts]
|
||||
const REHYPE_PLUGINS = [rehypeSlug]
|
||||
|
||||
/**
|
||||
@@ -101,6 +153,153 @@ const CheckboxIndexCtx = createContext(-1)
|
||||
|
||||
const NavigateCtx = createContext<((path: string) => void) | null>(null)
|
||||
|
||||
const CALLOUT_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; borderColor: string; bgColor: string; textColor: string; iconColor: string }
|
||||
> = {
|
||||
NOTE: {
|
||||
label: 'Note',
|
||||
borderColor: 'border-blue-400/60',
|
||||
bgColor: 'bg-blue-400/10',
|
||||
textColor: 'text-[var(--text-primary)]',
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
TIP: {
|
||||
label: 'Tip',
|
||||
borderColor: 'border-emerald-400/60',
|
||||
bgColor: 'bg-emerald-400/10',
|
||||
textColor: 'text-[var(--text-primary)]',
|
||||
iconColor: 'text-emerald-500',
|
||||
},
|
||||
WARNING: {
|
||||
label: 'Warning',
|
||||
borderColor: 'border-amber-400/60',
|
||||
bgColor: 'bg-amber-400/10',
|
||||
textColor: 'text-[var(--text-primary)]',
|
||||
iconColor: 'text-amber-500',
|
||||
},
|
||||
IMPORTANT: {
|
||||
label: 'Important',
|
||||
borderColor: 'border-violet-400/60',
|
||||
bgColor: 'bg-violet-400/10',
|
||||
textColor: 'text-[var(--text-primary)]',
|
||||
iconColor: 'text-violet-500',
|
||||
},
|
||||
CAUTION: {
|
||||
label: 'Caution',
|
||||
borderColor: 'border-red-400/60',
|
||||
bgColor: 'bg-red-400/10',
|
||||
textColor: 'text-[var(--text-primary)]',
|
||||
iconColor: 'text-red-500',
|
||||
},
|
||||
}
|
||||
|
||||
const CALLOUT_ICONS: Record<string, string> = {
|
||||
NOTE: 'ℹ',
|
||||
TIP: '💡',
|
||||
WARNING: '⚠',
|
||||
IMPORTANT: '❕',
|
||||
CAUTION: '🛑',
|
||||
}
|
||||
|
||||
const LANG_ALIASES: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
jsx: 'javascript',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
html: 'markup',
|
||||
xml: 'markup',
|
||||
yml: 'yaml',
|
||||
py: 'python',
|
||||
}
|
||||
|
||||
function CalloutBlock({ type, children }: { type: string; children?: React.ReactNode }) {
|
||||
const config = CALLOUT_CONFIG[type]
|
||||
if (!config) {
|
||||
return (
|
||||
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'my-4 rounded-lg border-l-4 px-4 py-3 text-[14px]',
|
||||
config.borderColor,
|
||||
config.bgColor
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('mb-1 flex items-center gap-1.5 font-semibold text-[13px]', config.iconColor)}
|
||||
>
|
||||
<span>{CALLOUT_ICONS[type]}</span>
|
||||
<span>{config.label}</span>
|
||||
</div>
|
||||
<div className={cn('break-words leading-[1.6]', config.textColor)}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MermaidDiagram = memo(function MermaidDiagram({ definition }: { definition: string }) {
|
||||
const [svg, setSvg] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const idRef = useRef(`mermaid-${generateShortId(8)}`)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
let cancelled = false
|
||||
|
||||
async function render() {
|
||||
try {
|
||||
const { default: mermaid } = await import('mermaid')
|
||||
if (cancelled) return
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
theme: 'default',
|
||||
})
|
||||
|
||||
const { svg: rendered } = await mermaid.render(idRef.current, definition.trim())
|
||||
if (!cancelled) {
|
||||
setSvg(rendered)
|
||||
setError(null)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to render diagram')
|
||||
setSvg(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSvg(null)
|
||||
setError(null)
|
||||
render()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [definition])
|
||||
|
||||
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-body)]'>Diagram error: </span>
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!svg) {
|
||||
return <div className='my-4 h-[100px] animate-pulse rounded-lg bg-[var(--surface-2)]' />
|
||||
}
|
||||
|
||||
return <div className='my-4 overflow-auto rounded-lg' dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
})
|
||||
|
||||
const STATIC_MARKDOWN_COMPONENTS = {
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
|
||||
@@ -139,32 +338,104 @@ const STATIC_MARKDOWN_COMPONENTS = {
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h5
|
||||
id={id}
|
||||
className='mt-3 mb-1 break-words font-semibold text-[13px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h6
|
||||
id={id}
|
||||
className='mt-3 mb-1 break-words font-medium text-[12px] text-[var(--text-secondary)] first:mt-0'
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
),
|
||||
inlineCode: ({ children }: { children?: React.ReactNode }) => {
|
||||
if (typeof children === 'string' && children.includes('\n')) {
|
||||
return (
|
||||
<code className='my-4 block overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-[var(--surface-5)] p-4 font-mono text-[13px] text-[var(--text-primary)] leading-[1.6]'>
|
||||
<code className='my-4 block overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-[var(--surface-5)] p-4 font-mono text-[var(--text-primary)] leading-[1.6]'>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[13px] text-[var(--caution)]'>
|
||||
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[var(--caution)]'>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
|
||||
const langMatch = className?.match(/language-(\w+)/)
|
||||
const langRaw = langMatch?.[1] ?? ''
|
||||
const codeString = extractTextContent(children)
|
||||
|
||||
if (langRaw === 'mermaid') {
|
||||
return <MermaidDiagram definition={codeString} />
|
||||
}
|
||||
|
||||
if (!codeString) {
|
||||
return (
|
||||
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[var(--caution)]'>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
const resolved = LANG_ALIASES[langRaw] || langRaw || 'javascript'
|
||||
const grammar = languages[resolved] || languages.javascript
|
||||
const html = grammar ? highlight(codeString.trimEnd(), grammar, resolved) : null
|
||||
|
||||
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)]'>{langRaw || 'code'}</span>
|
||||
<CopyCodeButton
|
||||
code={codeString}
|
||||
className='-mr-1 text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||
/>
|
||||
</div>
|
||||
<div className='code-editor-theme bg-[var(--surface-5)]'>
|
||||
{html ? (
|
||||
<pre
|
||||
className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] leading-[1.6]'
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<pre className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] text-[var(--text-primary)] leading-[1.6]'>
|
||||
<code>{codeString.trimEnd()}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
strong: ({ children }: { children?: React.ReactNode }) => (
|
||||
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
|
||||
<strong className='break-words font-semibold'>{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children?: React.ReactNode }) => (
|
||||
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
|
||||
),
|
||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
|
||||
{children}
|
||||
</blockquote>
|
||||
em: ({ children }: { children?: React.ReactNode }) => <em className='break-words'>{children}</em>,
|
||||
del: ({ children }: { children?: React.ReactNode }) => (
|
||||
<del className='line-through opacity-50'>{children}</del>
|
||||
),
|
||||
blockquote: ({
|
||||
children,
|
||||
'data-callout': calloutType,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
'data-callout'?: string
|
||||
}) => {
|
||||
if (calloutType && CALLOUT_TYPES.has(calloutType)) {
|
||||
return <CalloutBlock type={calloutType}>{children}</CalloutBlock>
|
||||
}
|
||||
return (
|
||||
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
},
|
||||
hr: () => <hr className='my-6 border-[var(--border)]' />,
|
||||
img: ({ src, alt }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img
|
||||
@@ -299,36 +570,33 @@ function isInternalHref(
|
||||
|
||||
function AnchorRenderer({ href, children }: { href?: string; children?: React.ReactNode }) {
|
||||
const navigate = useContext(NavigateCtx)
|
||||
const parsed = useMemo(() => (href ? isInternalHref(href) : null), [href])
|
||||
const parsed = href ? isInternalHref(href) : null
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!parsed || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!parsed || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||
|
||||
e.preventDefault()
|
||||
e.preventDefault()
|
||||
|
||||
if (parsed.pathname === '' && parsed.hash) {
|
||||
const el = document.getElementById(parsed.hash.slice(1))
|
||||
if (el) {
|
||||
const container = el.closest('.overflow-auto') as HTMLElement | null
|
||||
if (container) {
|
||||
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
if (parsed.pathname === '' && parsed.hash) {
|
||||
const el = document.getElementById(parsed.hash.slice(1))
|
||||
if (el) {
|
||||
const container = el.closest('.overflow-auto') as HTMLElement | null
|
||||
if (container) {
|
||||
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const destination = parsed.pathname + parsed.hash
|
||||
if (navigate) {
|
||||
navigate(destination)
|
||||
} else {
|
||||
window.location.assign(destination)
|
||||
}
|
||||
},
|
||||
[parsed, navigate]
|
||||
)
|
||||
const destination = parsed.pathname + parsed.hash
|
||||
if (navigate) {
|
||||
navigate(destination)
|
||||
} else {
|
||||
window.location.assign(destination)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
@@ -352,6 +620,30 @@ const MARKDOWN_COMPONENTS = {
|
||||
input: InputRenderer,
|
||||
}
|
||||
|
||||
function FrontMatterCard({ data }: { data: Record<string, unknown> }) {
|
||||
const entries = Object.entries(data)
|
||||
if (entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='mb-6 rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-4 py-3 text-[13px]'>
|
||||
<dl className='flex flex-col gap-1.5'>
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key} className='flex gap-2 break-words'>
|
||||
<dt className='shrink-0 font-medium text-[var(--text-secondary)]'>{key}:</dt>
|
||||
<dd className='text-[var(--text-primary)]'>
|
||||
{Array.isArray(value)
|
||||
? value.join(', ')
|
||||
: value instanceof Date
|
||||
? value.toISOString().split('T')[0]
|
||||
: String(value ?? '')}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MarkdownPreview = memo(function MarkdownPreview({
|
||||
content,
|
||||
isStreaming = false,
|
||||
@@ -363,22 +655,28 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
}) {
|
||||
const { push: navigate } = useRouter()
|
||||
const { ref: autoScrollRef } = useAutoScroll(isStreaming)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const contentRef = useRef(content)
|
||||
contentRef.current = content
|
||||
|
||||
const { frontMatterData, markdownContent } = useMemo(() => {
|
||||
if (isStreaming) return { frontMatterData: null, markdownContent: content }
|
||||
try {
|
||||
const parsed = matter(content)
|
||||
const hasFrontMatter = Object.keys(parsed.data).length > 0
|
||||
return {
|
||||
frontMatterData: hasFrontMatter ? parsed.data : null,
|
||||
markdownContent: hasFrontMatter ? parsed.content : content,
|
||||
}
|
||||
} catch {
|
||||
return { frontMatterData: null, markdownContent: content }
|
||||
}
|
||||
}, [content, isStreaming])
|
||||
|
||||
const ctxValue = useMemo(
|
||||
() => (onCheckboxToggle ? { contentRef, onToggle: onCheckboxToggle } : null),
|
||||
[onCheckboxToggle]
|
||||
)
|
||||
const setScrollRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
scrollContainerRef.current = node
|
||||
autoScrollRef(node)
|
||||
},
|
||||
[autoScrollRef]
|
||||
)
|
||||
|
||||
const hasScrolledToHash = useRef(false)
|
||||
useEffect(() => {
|
||||
@@ -398,37 +696,27 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
|
||||
const streamdownMode = isStreaming ? undefined : 'static'
|
||||
|
||||
if (onCheckboxToggle) {
|
||||
return (
|
||||
<NavigateCtx.Provider value={navigate}>
|
||||
<MarkdownCheckboxCtx.Provider value={ctxValue}>
|
||||
<div ref={setScrollRef} className='h-full overflow-auto p-6'>
|
||||
<Streamdown
|
||||
mode={streamdownMode}
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{content}
|
||||
</Streamdown>
|
||||
</div>
|
||||
</MarkdownCheckboxCtx.Provider>
|
||||
</NavigateCtx.Provider>
|
||||
)
|
||||
}
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<NavigateCtx.Provider value={navigate}>
|
||||
<div ref={setScrollRef} className='h-full overflow-auto p-6'>
|
||||
<Streamdown
|
||||
mode={streamdownMode}
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{content}
|
||||
</Streamdown>
|
||||
</div>
|
||||
{onCheckboxToggle ? (
|
||||
<MarkdownCheckboxCtx.Provider value={ctxValue}>{body}</MarkdownCheckboxCtx.Provider>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</NavigateCtx.Provider>
|
||||
)
|
||||
})
|
||||
@@ -497,9 +785,7 @@ function buildHtmlPreviewDocument(content: string): string {
|
||||
}
|
||||
|
||||
const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) {
|
||||
// Run inline HTML/JS in an isolated iframe while blocking any navigation
|
||||
// that would replace the preview with another document.
|
||||
const wrappedContent = useMemo(() => buildHtmlPreviewDocument(content), [content])
|
||||
const wrappedContent = buildHtmlPreviewDocument(content)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isRenderable, setIsRenderable] = useState(false)
|
||||
const [resumeNonce, setResumeNonce] = useState(0)
|
||||
@@ -570,12 +856,8 @@ const HtmlPreview = memo(function HtmlPreview({ content }: { content: string })
|
||||
)
|
||||
})
|
||||
|
||||
const SvgPreview = memo(function SvgPreview({ content }: { content: string }) {
|
||||
const wrappedContent = useMemo(
|
||||
() =>
|
||||
`<!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>`,
|
||||
[content]
|
||||
)
|
||||
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'>
|
||||
@@ -587,7 +869,15 @@ const SvgPreview = memo(function SvgPreview({ content }: { content: string }) {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function MermaidFilePreview({ content }: { content: string }) {
|
||||
return (
|
||||
<div className='h-full overflow-auto p-6'>
|
||||
<MermaidDiagram definition={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
|
||||
const { headers, rows } = useMemo(() => parseCsv(content), [content])
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Columns2,
|
||||
@@ -136,7 +136,10 @@ export function Files() {
|
||||
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const isNewFile = searchParams.get('new') === '1'
|
||||
const workspaceId = params?.workspaceId as string
|
||||
|
||||
const fileIdFromRoute =
|
||||
typeof params?.fileId === 'string' && params.fileId.length > 0 ? params.fileId : null
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
@@ -178,6 +181,8 @@ export function Files() {
|
||||
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
||||
const dragCounterRef = useRef(0)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const debouncedSearchTerm = useDebounce(inputValue, 200)
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
@@ -192,6 +197,7 @@ export function Files() {
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>(() => {
|
||||
if (isNewFile) return 'editor'
|
||||
if (fileIdFromRoute) {
|
||||
const file = files.find((f) => f.id === fileIdFromRoute)
|
||||
if (file && isPreviewable(file)) return 'preview'
|
||||
@@ -201,7 +207,7 @@ export function Files() {
|
||||
})
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [contextMenuFile, setContextMenuFile] = useState<WorkspaceFileRecord | null>(null)
|
||||
const contextMenuFileRef = useRef<WorkspaceFileRecord | null>(null)
|
||||
const [deleteTargetFile, setDeleteTargetFile] = useState<WorkspaceFileRecord | null>(null)
|
||||
|
||||
const listRename = useInlineRename({
|
||||
@@ -365,27 +371,26 @@ export function Files() {
|
||||
filteredFiles,
|
||||
])
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const list = e.target.files
|
||||
if (!list || list.length === 0 || !workspaceId) return
|
||||
const uploadFiles = useCallback(
|
||||
async (filesToUpload: File[]) => {
|
||||
if (!workspaceId || filesToUpload.length === 0) return
|
||||
|
||||
const unsupported: string[] = []
|
||||
const allowedFiles = filesToUpload.filter((f) => {
|
||||
const ext = getFileExtension(f.name)
|
||||
const ok = SUPPORTED_EXTENSIONS.includes(ext as (typeof SUPPORTED_EXTENSIONS)[number])
|
||||
if (!ok) unsupported.push(f.name)
|
||||
return ok
|
||||
})
|
||||
|
||||
if (unsupported.length > 0) {
|
||||
logger.warn('Unsupported file types skipped:', unsupported)
|
||||
}
|
||||
|
||||
if (allowedFiles.length === 0) return
|
||||
|
||||
try {
|
||||
setUploading(true)
|
||||
|
||||
const filesToUpload = Array.from(list)
|
||||
const unsupported: string[] = []
|
||||
const allowedFiles = filesToUpload.filter((f) => {
|
||||
const ext = getFileExtension(f.name)
|
||||
const ok = SUPPORTED_EXTENSIONS.includes(ext as (typeof SUPPORTED_EXTENSIONS)[number])
|
||||
if (!ok) unsupported.push(f.name)
|
||||
return ok
|
||||
})
|
||||
|
||||
if (unsupported.length > 0) {
|
||||
logger.warn('Unsupported file types skipped:', unsupported)
|
||||
}
|
||||
|
||||
setUploadProgress({ completed: 0, total: allowedFiles.length })
|
||||
|
||||
for (let i = 0; i < allowedFiles.length; i++) {
|
||||
@@ -401,14 +406,42 @@ export function Files() {
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadProgress({ completed: 0, total: 0 })
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
[workspaceId, uploadFile]
|
||||
)
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const list = e.target.files
|
||||
if (!list || list.length === 0) return
|
||||
await uploadFiles(Array.from(list))
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
dragCounterRef.current++
|
||||
if (e.dataTransfer.types.includes('Files')) setIsDraggingOver(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
dragCounterRef.current--
|
||||
if (dragCounterRef.current === 0) setIsDraggingOver(false)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
dragCounterRef.current = 0
|
||||
setIsDraggingOver(false)
|
||||
const dropped = Array.from(e.dataTransfer.files)
|
||||
if (dropped.length > 0) await uploadFiles(dropped)
|
||||
}
|
||||
|
||||
const handleDownload = useCallback(async (file: WorkspaceFileRecord) => {
|
||||
try {
|
||||
await downloadWorkspaceFile(file)
|
||||
@@ -527,13 +560,13 @@ export function Files() {
|
||||
]
|
||||
)
|
||||
|
||||
const handleDiscardChanges = useCallback(() => {
|
||||
const handleDiscardChanges = () => {
|
||||
setShowUnsavedChangesAlert(false)
|
||||
setIsDirty(false)
|
||||
setSaveStatus('idle')
|
||||
setPreviewMode('editor')
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
}, [router, workspaceId])
|
||||
}
|
||||
|
||||
const creatingFileRef = useRef(creatingFile)
|
||||
creatingFileRef.current = creatingFile
|
||||
@@ -558,7 +591,7 @@ export function Files() {
|
||||
const fileId = result.file?.id
|
||||
if (fileId) {
|
||||
justCreatedFileIdRef.current = fileId
|
||||
router.push(`/workspace/${workspaceId}/files/${fileId}`)
|
||||
router.push(`/workspace/${workspaceId}/files/${fileId}?new=1`)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to create file:', err)
|
||||
@@ -571,16 +604,13 @@ export function Files() {
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
const file = filesRef.current.find((f) => f.id === rowId)
|
||||
if (file) {
|
||||
setContextMenuFile(file)
|
||||
contextMenuFileRef.current = file
|
||||
openContextMenu(e)
|
||||
}
|
||||
},
|
||||
[openContextMenu]
|
||||
)
|
||||
|
||||
const contextMenuFileRef = useRef(contextMenuFile)
|
||||
contextMenuFileRef.current = contextMenuFile
|
||||
|
||||
const handleContextMenuOpen = useCallback(() => {
|
||||
const file = contextMenuFileRef.current
|
||||
if (!file) return
|
||||
@@ -632,7 +662,7 @@ export function Files() {
|
||||
if (fileIdFromRoute !== prevFileIdRef.current) {
|
||||
prevFileIdRef.current = fileIdFromRoute
|
||||
const isJustCreated =
|
||||
fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute
|
||||
isNewFile || (fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute)
|
||||
if (justCreatedFileIdRef.current && !isJustCreated) {
|
||||
justCreatedFileIdRef.current = null
|
||||
}
|
||||
@@ -649,6 +679,12 @@ export function Files() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewFile && fileIdFromRoute) {
|
||||
router.replace(`/workspace/${workspaceId}/files/${fileIdFromRoute}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!fileIdFromRouteRef.current) return
|
||||
@@ -774,34 +810,25 @@ export function Files() {
|
||||
|
||||
const canEdit = userPermissions.canEdit === true
|
||||
|
||||
const searchConfig: SearchConfig = useMemo(
|
||||
() => ({
|
||||
value: inputValue,
|
||||
onChange: setInputValue,
|
||||
onClearAll: () => setInputValue(''),
|
||||
placeholder: 'Search files...',
|
||||
}),
|
||||
[inputValue]
|
||||
)
|
||||
const searchConfig: SearchConfig = {
|
||||
value: inputValue,
|
||||
onChange: setInputValue,
|
||||
onClearAll: () => setInputValue(''),
|
||||
placeholder: 'Search files...',
|
||||
}
|
||||
|
||||
const createConfig = useMemo(
|
||||
() => ({
|
||||
label: 'New file',
|
||||
onClick: handleCreateFile,
|
||||
disabled: uploading || creatingFile || !canEdit,
|
||||
}),
|
||||
[handleCreateFile, uploading, creatingFile, canEdit]
|
||||
)
|
||||
const createConfig = {
|
||||
label: 'New file',
|
||||
onClick: handleCreateFile,
|
||||
disabled: uploading || creatingFile || !canEdit,
|
||||
}
|
||||
|
||||
const uploadButtonLabel = useMemo(
|
||||
() =>
|
||||
uploading && uploadProgress.total > 0
|
||||
? `${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: uploading
|
||||
? 'Uploading...'
|
||||
: 'Upload',
|
||||
[uploading, uploadProgress.completed, uploadProgress.total]
|
||||
)
|
||||
const uploadButtonLabel =
|
||||
uploading && uploadProgress.total > 0
|
||||
? `${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: uploading
|
||||
? 'Uploading...'
|
||||
: 'Upload'
|
||||
|
||||
const headerActionsConfig = useMemo(
|
||||
() => [
|
||||
@@ -818,39 +845,7 @@ export function Files() {
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
}, [router, workspaceId])
|
||||
|
||||
const loadingBreadcrumbs = useMemo(
|
||||
() => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }],
|
||||
[handleNavigateToFiles]
|
||||
)
|
||||
|
||||
const typeDisplayLabel = useMemo(() => {
|
||||
if (typeFilter.length === 0) return 'All'
|
||||
if (typeFilter.length === 1) {
|
||||
const labels: Record<string, string> = {
|
||||
document: 'Documents',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
}
|
||||
return labels[typeFilter[0]] ?? typeFilter[0]
|
||||
}
|
||||
return `${typeFilter.length} selected`
|
||||
}, [typeFilter])
|
||||
|
||||
const sizeDisplayLabel = useMemo(() => {
|
||||
if (sizeFilter.length === 0) return 'All'
|
||||
if (sizeFilter.length === 1) {
|
||||
const labels: Record<string, string> = { small: 'Small', medium: 'Medium', large: 'Large' }
|
||||
return labels[sizeFilter[0]] ?? sizeFilter[0]
|
||||
}
|
||||
return `${sizeFilter.length} selected`
|
||||
}, [sizeFilter])
|
||||
|
||||
const uploadedByDisplayLabel = useMemo(() => {
|
||||
if (uploadedByFilter.length === 0) return 'All'
|
||||
if (uploadedByFilter.length === 1)
|
||||
return members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member'
|
||||
return `${uploadedByFilter.length} members`
|
||||
}, [uploadedByFilter, members])
|
||||
const loadingBreadcrumbs = [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }]
|
||||
|
||||
const memberOptions: ComboboxOption[] = useMemo(
|
||||
() =>
|
||||
@@ -893,8 +888,33 @@ export function Files() {
|
||||
const hasActiveFilters =
|
||||
typeFilter.length > 0 || sizeFilter.length > 0 || uploadedByFilter.length > 0
|
||||
|
||||
const filterContent = useMemo(
|
||||
() => (
|
||||
const filterContent = useMemo(() => {
|
||||
const typeDisplayLabel =
|
||||
typeFilter.length === 0
|
||||
? 'All'
|
||||
: typeFilter.length === 1
|
||||
? (({ document: 'Documents', audio: 'Audio', video: 'Video' } as Record<string, string>)[
|
||||
typeFilter[0]
|
||||
] ?? typeFilter[0])
|
||||
: `${typeFilter.length} selected`
|
||||
|
||||
const sizeDisplayLabel =
|
||||
sizeFilter.length === 0
|
||||
? 'All'
|
||||
: sizeFilter.length === 1
|
||||
? (({ small: 'Small', medium: 'Medium', large: 'Large' } as Record<string, string>)[
|
||||
sizeFilter[0]
|
||||
] ?? sizeFilter[0])
|
||||
: `${sizeFilter.length} selected`
|
||||
|
||||
const uploadedByDisplayLabel =
|
||||
uploadedByFilter.length === 0
|
||||
? 'All'
|
||||
: uploadedByFilter.length === 1
|
||||
? (members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member')
|
||||
: `${uploadedByFilter.length} members`
|
||||
|
||||
return (
|
||||
<div className='flex w-[240px] flex-col gap-3 p-3'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>File Type</span>
|
||||
@@ -974,18 +994,8 @@ export function Files() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[
|
||||
typeFilter,
|
||||
sizeFilter,
|
||||
uploadedByFilter,
|
||||
memberOptions,
|
||||
typeDisplayLabel,
|
||||
sizeDisplayLabel,
|
||||
uploadedByDisplayLabel,
|
||||
hasActiveFilters,
|
||||
]
|
||||
)
|
||||
)
|
||||
}, [typeFilter, sizeFilter, uploadedByFilter, memberOptions, members, hasActiveFilters])
|
||||
|
||||
const filterTags: FilterTag[] = useMemo(() => {
|
||||
const tags: FilterTag[] = []
|
||||
@@ -1027,8 +1037,24 @@ export function Files() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<ResourceHeader icon={FilesIcon} breadcrumbs={loadingBreadcrumbs} />
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<Skeleton className='h-[16px] w-[200px]' />
|
||||
<div className='flex flex-1 flex-col items-center gap-4 overflow-y-auto bg-[var(--surface-1)] p-6'>
|
||||
{[0, 1].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='w-full max-w-[640px] shrink-0 rounded-md bg-white p-8 shadow-[var(--shadow-medium)]'
|
||||
style={{ aspectRatio: '1 / 1.414' }}
|
||||
>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<Skeleton className='h-[14px] w-[60%]' />
|
||||
<Skeleton className='h-[14px] w-[80%]' />
|
||||
<Skeleton className='h-[14px] w-[55%]' />
|
||||
<Skeleton className='mt-2 h-[14px] w-[75%]' />
|
||||
<Skeleton className='h-[14px] w-[65%]' />
|
||||
<Skeleton className='h-[14px] w-[85%]' />
|
||||
<Skeleton className='h-[14px] w-[50%]' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1049,7 +1075,7 @@ export function Files() {
|
||||
workspaceId={workspaceId}
|
||||
canEdit={canEdit}
|
||||
previewMode={previewMode}
|
||||
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
|
||||
autoFocus={isNewFile || justCreatedFileIdRef.current === selectedFile.id}
|
||||
onDirtyChange={setIsDirty}
|
||||
onSaveStatusChange={setSaveStatus}
|
||||
saveRef={saveRef}
|
||||
@@ -1087,7 +1113,13 @@ export function Files() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='relative flex h-full flex-col overflow-hidden'
|
||||
onDragEnter={canEdit ? handleDragEnter : undefined}
|
||||
onDragLeave={canEdit ? handleDragLeave : undefined}
|
||||
onDragOver={canEdit ? handleDragOver : undefined}
|
||||
onDrop={canEdit ? handleDrop : undefined}
|
||||
>
|
||||
<Resource
|
||||
icon={FilesIcon}
|
||||
title='Files'
|
||||
@@ -1103,6 +1135,19 @@ export function Files() {
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
isLoading={isLoading}
|
||||
onContextMenu={handleContentContextMenu}
|
||||
overlay={
|
||||
isDraggingOver ? (
|
||||
<div className='pointer-events-none absolute inset-0 z-50 flex flex-col items-center justify-center gap-2 border border-[var(--accent)] border-dashed bg-[var(--surface-4)] transition-colors'>
|
||||
<Upload className='h-5 w-5 text-[var(--accent)]' />
|
||||
<div className='flex flex-col gap-0.5 text-center'>
|
||||
<p className='font-medium text-[14px] text-[var(--accent)]'>Drop to upload</p>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
Release files here to add them to this workspace
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<FilesListContextMenu
|
||||
@@ -1143,7 +1188,7 @@ export function Files() {
|
||||
accept={ACCEPT_ATTR}
|
||||
multiple
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { Files } from './files'
|
||||
|
||||
@@ -6,4 +7,10 @@ export const metadata: Metadata = {
|
||||
robots: { index: false },
|
||||
}
|
||||
|
||||
export default Files
|
||||
export default function FilesPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<Files />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { lazy, memo, Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { formatDuration } from '@sim/utils/formatting'
|
||||
import { Square } from 'lucide-react'
|
||||
@@ -128,7 +128,6 @@ export const ResourceContent = memo(function ResourceContent({
|
||||
}
|
||||
}, [workspaceId, streamFileName])
|
||||
|
||||
const streamingFileMode: 'append' | 'replace' = 'replace'
|
||||
const disableStreamingAutoScroll = previewSession?.operation === 'patch'
|
||||
const rawPreviewText = previewSession?.previewText
|
||||
const streamingPreviewText =
|
||||
@@ -144,10 +143,9 @@ export const ResourceContent = memo(function ResourceContent({
|
||||
canEdit={false}
|
||||
previewMode={previewMode ?? 'preview'}
|
||||
streamingContent={streamingPreviewText}
|
||||
streamingMode={streamingFileMode}
|
||||
streamingMode='replace'
|
||||
disableStreamingAutoScroll={disableStreamingAutoScroll}
|
||||
previewContextKey={previewContextKey}
|
||||
useCodeRendererForCodeFiles
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
@@ -172,7 +170,7 @@ export const ResourceContent = memo(function ResourceContent({
|
||||
streamingContent={
|
||||
previewSession?.fileId === resource.id ? streamingPreviewText : undefined
|
||||
}
|
||||
streamingMode={streamingFileMode}
|
||||
streamingMode='replace'
|
||||
disableStreamingAutoScroll={disableStreamingAutoScroll}
|
||||
previewContextKey={previewContextKey}
|
||||
/>
|
||||
@@ -257,7 +255,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
|
||||
const isRunButtonDisabled =
|
||||
!isExecuting && !effectivePermissions.canRead && !effectivePermissions.isLoading
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
const handleRun = async () => {
|
||||
setActiveWorkflow(workflowId)
|
||||
|
||||
if (isExecuting) {
|
||||
@@ -274,19 +272,11 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
|
||||
}
|
||||
|
||||
await handleRunWorkflow()
|
||||
}, [
|
||||
handleCancelExecution,
|
||||
handleRunWorkflow,
|
||||
isExecuting,
|
||||
navigateToSettings,
|
||||
setActiveWorkflow,
|
||||
usageExceeded,
|
||||
workflowId,
|
||||
])
|
||||
}
|
||||
|
||||
const handleOpenWorkflow = useCallback(() => {
|
||||
const handleOpenWorkflow = () => {
|
||||
window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank')
|
||||
}, [workspaceId, workflowId])
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -340,9 +330,9 @@ export function EmbeddedKnowledgeBaseActions({
|
||||
}: EmbeddedKnowledgeBaseActionsProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleOpenKnowledgeBase = useCallback(() => {
|
||||
const handleOpenKnowledgeBase = () => {
|
||||
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
|
||||
}, [router, workspaceId, knowledgeBaseId])
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
@@ -375,18 +365,18 @@ function EmbeddedFileActions({ workspaceId, fileId }: EmbeddedFileActionsProps)
|
||||
const { data: files = [] } = useWorkspaceFiles(workspaceId)
|
||||
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
const handleDownload = async () => {
|
||||
if (!file) return
|
||||
try {
|
||||
await downloadWorkspaceFile(file)
|
||||
} catch (err) {
|
||||
fileLogger.error('Failed to download file:', err)
|
||||
}
|
||||
}, [file])
|
||||
}
|
||||
|
||||
const handleOpenInFiles = useCallback(() => {
|
||||
const handleOpenInFiles = () => {
|
||||
router.push(`/workspace/${workspaceId}/files/${encodeURIComponent(fileId)}`)
|
||||
}, [router, workspaceId, fileId])
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -432,10 +422,7 @@ interface EmbeddedWorkflowProps {
|
||||
|
||||
function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) {
|
||||
const { data: workflowList, isPending: isWorkflowsPending } = useWorkflows(workspaceId)
|
||||
const workflowExists = useMemo(
|
||||
() => (workflowList ?? []).some((w) => w.id === workflowId),
|
||||
[workflowList, workflowId]
|
||||
)
|
||||
const workflowExists = (workflowList ?? []).some((w) => w.id === workflowId)
|
||||
const hasLoadError = useWorkflowRegistry(
|
||||
(state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId
|
||||
)
|
||||
@@ -514,7 +501,6 @@ function EmbeddedFile({
|
||||
streamingContent={streamingContent}
|
||||
disableStreamingAutoScroll={disableStreamingAutoScroll}
|
||||
previewContextKey={previewContextKey}
|
||||
useCodeRendererForCodeFiles
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -529,15 +515,8 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {
|
||||
const { data: folderList, isPending: isFoldersPending } = useFolders(workspaceId)
|
||||
const { data: workflowList = [] } = useWorkflows(workspaceId)
|
||||
|
||||
const folder = useMemo(
|
||||
() => (folderList ?? []).find((f) => f.id === folderId),
|
||||
[folderList, folderId]
|
||||
)
|
||||
|
||||
const folderWorkflows = useMemo(
|
||||
() => workflowList.filter((w) => w.folderId === folderId),
|
||||
[workflowList, folderId]
|
||||
)
|
||||
const folder = (folderList ?? []).find((f) => f.id === folderId)
|
||||
const folderWorkflows = workflowList.filter((w) => w.folderId === folderId)
|
||||
|
||||
if (isFoldersPending) return LOADING_SKELETON
|
||||
|
||||
@@ -604,20 +583,14 @@ function EmbeddedLog({ logId }: EmbeddedLogProps) {
|
||||
return filterHiddenOutputKeys(executionData.finalOutput) as Record<string, unknown>
|
||||
}, [log?.executionData])
|
||||
|
||||
const isWorkflowExecutionLog = useMemo(() => {
|
||||
if (!log) return false
|
||||
return (
|
||||
(log.trigger === 'manual' && !!log.duration) ||
|
||||
(log.executionData?.enhanced && log.executionData?.traceSpans)
|
||||
)
|
||||
}, [log])
|
||||
const isWorkflowExecutionLog =
|
||||
!!log &&
|
||||
((log.trigger === 'manual' && !!log.duration) ||
|
||||
!!(log.executionData?.enhanced && log.executionData?.traceSpans))
|
||||
|
||||
const hasCostInfo = isWorkflowExecutionLog && log?.cost
|
||||
|
||||
const formattedTimestamp = useMemo(
|
||||
() => (log ? formatDate(log.createdAt) : null),
|
||||
[log?.createdAt]
|
||||
)
|
||||
const formattedTimestamp = log ? formatDate(log.createdAt) : null
|
||||
|
||||
if (isLoading) return LOADING_SKELETON
|
||||
|
||||
@@ -874,10 +847,10 @@ export function EmbeddedLogActions({ workspaceId, logId }: EmbeddedLogActionsPro
|
||||
const router = useRouter()
|
||||
const { data: log } = useLogDetail(logId)
|
||||
|
||||
const handleOpenInLogs = useCallback(() => {
|
||||
const handleOpenInLogs = () => {
|
||||
const param = log?.executionId ? `?executionId=${log.executionId}` : ''
|
||||
router.push(`/workspace/${workspaceId}/logs${param}`)
|
||||
}, [router, workspaceId, log?.executionId])
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
|
||||
@@ -101,7 +101,9 @@ async function fetchWorkspaceFileContent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workspace file content as text
|
||||
* Hook to fetch workspace file content as text.
|
||||
* `key` (the storage object key) is included in the query key so that a new
|
||||
* storage key (e.g. after a file is re-uploaded) correctly busts the cache.
|
||||
*/
|
||||
export function useWorkspaceFileContent(
|
||||
workspaceId: string,
|
||||
@@ -110,7 +112,7 @@ export function useWorkspaceFileContent(
|
||||
raw?: boolean
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: workspaceFilesKeys.content(workspaceId, fileId, raw ? 'raw' : 'text'),
|
||||
queryKey: [...workspaceFilesKeys.content(workspaceId, fileId, raw ? 'raw' : 'text'), key],
|
||||
queryFn: ({ signal }) => fetchWorkspaceFileContent(key, signal, raw),
|
||||
enabled: !!workspaceId && !!fileId && !!key,
|
||||
staleTime: 30 * 1000,
|
||||
@@ -127,12 +129,12 @@ async function fetchWorkspaceFileBinary(key: string, signal?: AbortSignal): Prom
|
||||
|
||||
/**
|
||||
* Hook to fetch workspace file content as binary (ArrayBuffer).
|
||||
* Shares the same query key as useWorkspaceFileContent so cache
|
||||
* invalidation from file updates triggers a refetch automatically.
|
||||
* `key` (the storage object key) is included in the query key so that a new
|
||||
* storage key (e.g. after a file is re-uploaded) correctly busts the cache.
|
||||
*/
|
||||
export function useWorkspaceFileBinary(workspaceId: string, fileId: string, key: string) {
|
||||
return useQuery({
|
||||
queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'binary'),
|
||||
queryKey: [...workspaceFilesKeys.content(workspaceId, fileId, 'binary'), key],
|
||||
queryFn: ({ signal }) => fetchWorkspaceFileBinary(key, signal),
|
||||
enabled: !!workspaceId && !!fileId && !!key,
|
||||
staleTime: 30 * 1000,
|
||||
@@ -210,10 +212,8 @@ export function useUploadWorkspaceFile() {
|
||||
|
||||
return data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate files list to refetch
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() })
|
||||
// Invalidate storage info to update usage
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -229,17 +229,18 @@ interface UpdateFileContentParams {
|
||||
workspaceId: string
|
||||
fileId: string
|
||||
content: string
|
||||
encoding?: 'base64' | 'utf-8'
|
||||
}
|
||||
|
||||
export function useUpdateWorkspaceFileContent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, fileId, content }: UpdateFileContentParams) => {
|
||||
mutationFn: async ({ workspaceId, fileId, content, encoding }: UpdateFileContentParams) => {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/files/${fileId}/content`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
body: JSON.stringify(encoding ? { content, encoding } : { content }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* This is the main entry point for OpenTelemetry instrumentation.
|
||||
* It delegates to runtime-specific instrumentation modules.
|
||||
*/
|
||||
|
||||
export async function register() {
|
||||
// Load Node.js-specific instrumentation
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
|
||||
@@ -10,27 +10,15 @@ export const SIM_AGENT_API_URL =
|
||||
? rawAgentUrl
|
||||
: SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeouts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default timeout for the copilot orchestration stream loop (60 min). */
|
||||
export const ORCHESTRATION_TIMEOUT_MS = 3_600_000
|
||||
|
||||
/** Timeout for the client-side streaming response handler (60 min). */
|
||||
export const STREAM_TIMEOUT_MS = 3_600_000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream resume
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** SessionStorage key for persisting active stream metadata across page reloads. */
|
||||
export const STREAM_STORAGE_KEY = 'copilot_active_stream'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Copilot API paths (client-side fetch targets)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** POST — send a chat message through the unified mothership chat surface. */
|
||||
export const MOTHERSHIP_CHAT_API_PATH = '/api/mothership/chat'
|
||||
|
||||
@@ -39,18 +27,9 @@ export const COPILOT_CONFIRM_API_PATH = '/api/copilot/confirm'
|
||||
|
||||
/** POST — forward diff-accepted/rejected stats to the copilot backend. */
|
||||
export const COPILOT_STATS_API_PATH = '/api/copilot/stats'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dedup limits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum entries in the in-memory SSE tool-event dedup cache. */
|
||||
export const STREAM_BUFFER_MAX_DEDUP_ENTRIES = 1_000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool result size limits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Approximate max inline tool-result budget before artifact/error handling takes over. */
|
||||
export const TOOL_RESULT_MAX_INLINE_TOKENS = 50_000
|
||||
|
||||
@@ -61,10 +40,6 @@ export const TOOL_RESULT_ESTIMATED_CHARS_PER_TOKEN = 4
|
||||
export const TOOL_RESULT_MAX_INLINE_CHARS =
|
||||
TOOL_RESULT_MAX_INLINE_TOKENS * TOOL_RESULT_ESTIMATED_CHARS_PER_TOKEN
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Copilot modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const COPILOT_MODES = ['ask', 'build', 'plan'] as const
|
||||
|
||||
export const COPILOT_REQUEST_MODES = ['ask', 'build', 'plan', 'agent'] as const
|
||||
|
||||
@@ -65,6 +65,7 @@ const STATIC_IMG_SRC = [
|
||||
"'self'",
|
||||
'data:',
|
||||
'blob:',
|
||||
'https:',
|
||||
'https://*.googleusercontent.com',
|
||||
'https://*.google.com',
|
||||
'https://*.atlassian.com',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -77,6 +77,7 @@ export const SUPPORTED_CODE_EXTENSIONS = [
|
||||
'editorconfig',
|
||||
'prettierrc',
|
||||
'eslintrc',
|
||||
'mmd',
|
||||
] as const
|
||||
|
||||
export type SupportedCodeExtension = (typeof SUPPORTED_CODE_EXTENSIONS)[number]
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"@linear/sdk": "40.0.0",
|
||||
"@marsidev/react-turnstile": "1.4.2",
|
||||
"@modelcontextprotocol/sdk": "1.25.3",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-jaeger": "2.1.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
|
||||
@@ -153,7 +154,9 @@
|
||||
"lucide-react": "^0.479.0",
|
||||
"mammoth": "^1.9.0",
|
||||
"marked": "17.0.4",
|
||||
"mermaid": "11.14.0",
|
||||
"micromatch": "4.0.8",
|
||||
"monaco-editor": "0.55.1",
|
||||
"mongodb": "6.19.0",
|
||||
"mysql2": "3.14.3",
|
||||
"neo4j-driver": "6.0.1",
|
||||
@@ -166,6 +169,7 @@
|
||||
"openai": "^4.91.1",
|
||||
"papaparse": "5.5.3",
|
||||
"pdf-lib": "1.17.1",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"postgres": "^3.4.5",
|
||||
"posthog-js": "1.364.4",
|
||||
"posthog-node": "5.28.9",
|
||||
@@ -176,6 +180,7 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-joyride": "2.9.3",
|
||||
"react-pdf": "10.4.1",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"react-window": "2.2.3",
|
||||
"reactflow": "^11.11.4",
|
||||
|
||||
33
bun.lock
33
bun.lock
@@ -117,6 +117,7 @@
|
||||
"@linear/sdk": "40.0.0",
|
||||
"@marsidev/react-turnstile": "1.4.2",
|
||||
"@modelcontextprotocol/sdk": "1.25.3",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-jaeger": "2.1.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
|
||||
@@ -207,7 +208,9 @@
|
||||
"lucide-react": "^0.479.0",
|
||||
"mammoth": "^1.9.0",
|
||||
"marked": "17.0.4",
|
||||
"mermaid": "11.14.0",
|
||||
"micromatch": "4.0.8",
|
||||
"monaco-editor": "0.55.1",
|
||||
"mongodb": "6.19.0",
|
||||
"mysql2": "3.14.3",
|
||||
"neo4j-driver": "6.0.1",
|
||||
@@ -220,6 +223,7 @@
|
||||
"openai": "^4.91.1",
|
||||
"papaparse": "5.5.3",
|
||||
"pdf-lib": "1.17.1",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"postgres": "^3.4.5",
|
||||
"posthog-js": "1.364.4",
|
||||
"posthog-node": "5.28.9",
|
||||
@@ -230,6 +234,7 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-joyride": "2.9.3",
|
||||
"react-pdf": "10.4.1",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"react-window": "2.2.3",
|
||||
"reactflow": "^11.11.4",
|
||||
@@ -1017,6 +1022,10 @@
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="],
|
||||
|
||||
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
|
||||
|
||||
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
|
||||
|
||||
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.97", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.97", "@napi-rs/canvas-darwin-arm64": "0.1.97", "@napi-rs/canvas-darwin-x64": "0.1.97", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", "@napi-rs/canvas-linux-arm64-musl": "0.1.97", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-musl": "0.1.97", "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ=="],
|
||||
@@ -3001,8 +3010,12 @@
|
||||
|
||||
"mailchecker": ["mailchecker@6.0.20", "", {}, "sha512-mZ3kmtfXzGj06prtNm6d8an7D++Kf1G4jEkPZ1QQyhknYNLkmGoMtfaNPNHJU6E8J+Bm3AcZlIIfq5D6L4MS2g=="],
|
||||
|
||||
"make-cancellable-promise": ["make-cancellable-promise@2.0.0", "", {}, "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw=="],
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
"make-event-props": ["make-event-props@2.0.0", "", {}, "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw=="],
|
||||
|
||||
"mammoth": ["mammoth@1.12.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="],
|
||||
|
||||
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
|
||||
@@ -3057,6 +3070,8 @@
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"merge-refs": ["merge-refs@2.0.0", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
@@ -3175,6 +3190,8 @@
|
||||
|
||||
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
|
||||
|
||||
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
|
||||
|
||||
"mongodb": ["mongodb@6.19.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.1.9", "bson": "^6.10.4", "mongodb-connection-string-url": "^3.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=6.0.0 <7", "snappy": "^7.3.2", "socks": "^2.7.1" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-H3GtYujOJdeKIMLKBT9PwlDhGrQfplABNF1G904w6r5ZXKWyv77aB0X9B+rhmaAwjtllHzaEkvi9mkGVZxs2Bw=="],
|
||||
|
||||
"mongodb-connection-string-url": ["mongodb-connection-string-url@3.0.2", "", { "dependencies": { "@types/whatwg-url": "^11.0.2", "whatwg-url": "^14.1.0 || ^13.0.0" } }, "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA=="],
|
||||
@@ -3363,7 +3380,7 @@
|
||||
|
||||
"pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="],
|
||||
|
||||
"pdfjs-dist": ["pdfjs-dist@5.5.207", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.95", "node-readable-to-web-readable-stream": "^0.4.2" } }, "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw=="],
|
||||
"pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="],
|
||||
|
||||
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
||||
|
||||
@@ -3513,6 +3530,8 @@
|
||||
|
||||
"react-medium-image-zoom": ["react-medium-image-zoom@5.4.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g=="],
|
||||
|
||||
"react-pdf": ["react-pdf@10.4.1", "", { "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", "make-cancellable-promise": "^2.0.0", "make-event-props": "^2.0.0", "merge-refs": "^2.0.0", "pdfjs-dist": "5.4.296", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA=="],
|
||||
|
||||
"react-promise-suspense": ["react-promise-suspense@0.3.4", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
@@ -3771,6 +3790,8 @@
|
||||
|
||||
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
|
||||
|
||||
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
@@ -3873,6 +3894,8 @@
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
@@ -4037,6 +4060,8 @@
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
|
||||
|
||||
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
@@ -5389,6 +5414,10 @@
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
|
||||
|
||||
"monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||
|
||||
"neo4j-driver-bolt-connection/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
@@ -5403,6 +5432,8 @@
|
||||
|
||||
"oauth2-mock-server/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
|
||||
|
||||
"officeparser/pdfjs-dist": ["pdfjs-dist@5.5.207", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.95", "node-readable-to-web-readable-stream": "^0.4.2" } }, "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw=="],
|
||||
|
||||
"ollama-ai-provider-v2/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
Reference in New Issue
Block a user