diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 94d882a86e8..65b5fff9d5c 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -1,10 +1,13 @@ +import { createHash } from 'crypto' import { readFile } from 'fs/promises' import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generatePptxFromCode } from '@/lib/execution/pptx-vm' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' +import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' @@ -18,6 +21,50 @@ import { const logger = createLogger('FilesServeAPI') +const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]) + +const MAX_COMPILED_PPTX_CACHE = 10 +const compiledPptxCache = new Map() + +function compiledCacheSet(key: string, buffer: Buffer): void { + if (compiledPptxCache.size >= MAX_COMPILED_PPTX_CACHE) { + compiledPptxCache.delete(compiledPptxCache.keys().next().value as string) + } + compiledPptxCache.set(key, buffer) +} + +async function compilePptxIfNeeded( + buffer: Buffer, + filename: string, + workspaceId?: string, + raw?: boolean +): Promise<{ buffer: Buffer; contentType: string }> { + const isPptx = filename.toLowerCase().endsWith('.pptx') + if (raw || !isPptx || buffer.subarray(0, 4).equals(ZIP_MAGIC)) { + return { buffer, contentType: getContentType(filename) } + } + + const code = buffer.toString('utf-8') + const cacheKey = createHash('sha256') + .update(code) + .update(workspaceId ?? '') + .digest('hex') + const cached = compiledPptxCache.get(cacheKey) + if (cached) { + return { + buffer: cached, + contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + } + } + + const compiled = await generatePptxFromCode(code, workspaceId || '') + compiledCacheSet(cacheKey, compiled) + return { + buffer: compiled, + contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + } +} + const STORAGE_KEY_PREFIX_RE = /^\d{13}-[a-z0-9]{7}-/ function stripStorageKeyPrefix(segment: string): string { @@ -44,6 +91,7 @@ export async function GET( const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath const contextParam = request.nextUrl.searchParams.get('context') + const raw = request.nextUrl.searchParams.get('raw') === '1' const context = contextParam || (isCloudPath ? inferContextFromKey(cloudKey) : undefined) @@ -68,10 +116,10 @@ export async function GET( const userId = authResult.userId if (isUsingCloudStorage()) { - return await handleCloudProxy(cloudKey, userId, contextParam) + return await handleCloudProxy(cloudKey, userId, contextParam, raw) } - return await handleLocalFile(cloudKey, userId) + return await handleLocalFile(cloudKey, userId, raw) } catch (error) { logger.error('Error serving file:', error) @@ -83,7 +131,11 @@ export async function GET( } } -async function handleLocalFile(filename: string, userId: string): Promise { +async function handleLocalFile( + filename: string, + userId: string, + raw: boolean +): Promise { try { const contextParam: StorageContext | undefined = inferContextFromKey(filename) as | StorageContext @@ -108,10 +160,16 @@ async function handleLocalFile(filename: string, userId: string): Promise { try { let context: StorageContext @@ -156,12 +215,12 @@ async function handleCloudProxy( throw new FileNotFoundError(`File not found: ${cloudKey}`) } - let fileBuffer: Buffer + let rawBuffer: Buffer if (context === 'copilot') { - fileBuffer = await CopilotFiles.downloadCopilotFile(cloudKey) + rawBuffer = await CopilotFiles.downloadCopilotFile(cloudKey) } else { - fileBuffer = await downloadFile({ + rawBuffer = await downloadFile({ key: cloudKey, context, }) @@ -169,7 +228,13 @@ async function handleCloudProxy( const segment = cloudKey.split('/').pop() || 'download' const displayName = stripStorageKeyPrefix(segment) - const contentType = getContentType(displayName) + const workspaceId = parseWorkspaceFileKey(cloudKey) ?? undefined + const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded( + rawBuffer, + displayName, + workspaceId, + raw + ) logger.info('Cloud file served', { userId, diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts new file mode 100644 index 00000000000..e7615aba748 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts @@ -0,0 +1,57 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { generatePptxFromCode } from '@/lib/execution/pptx-vm' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('PptxPreviewAPI') + +/** + * POST /api/workspaces/[id]/pptx/preview + * Compile PptxGenJS source code and return the binary PPTX for streaming preview. + */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id: workspaceId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (!membership) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const body = await req.json() + const { code } = body as { code?: string } + + if (typeof code !== 'string' || code.trim().length === 0) { + return NextResponse.json({ error: 'code is required' }, { status: 400 }) + } + + const MAX_CODE_BYTES = 512 * 1024 + if (Buffer.byteLength(code, 'utf-8') > MAX_CODE_BYTES) { + return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 }) + } + + const buffer = await generatePptxFromCode(code, workspaceId, req.signal) + + return new NextResponse(new Uint8Array(buffer), { + status: 200, + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'Content-Length': String(buffer.length), + 'Cache-Control': 'private, no-store', + }, + }) + } catch (err) { + const message = err instanceof Error ? err.message : 'PPTX generation failed' + logger.error('PPTX preview generation failed', { error: message, workspaceId }) + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index a0894b41a94..7d11517703a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -8,6 +8,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { useUpdateWorkspaceFileContent, + useWorkspaceFileBinary, useWorkspaceFileContent, } from '@/hooks/queries/workspace-files' import { useAutosave } from '@/hooks/use-autosave' @@ -48,17 +49,29 @@ const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf']) const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']) -type FileCategory = 'text-editable' | 'iframe-previewable' | 'image-previewable' | 'unsupported' +const PPTX_PREVIEWABLE_MIME_TYPES = new Set([ + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', +]) +const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx']) + +type FileCategory = + | 'text-editable' + | 'iframe-previewable' + | 'image-previewable' + | 'pptx-previewable' + | 'unsupported' function resolveFileCategory(mimeType: string | null, filename: string): FileCategory { if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable' if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable' if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable' + if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable' const ext = getFileExtension(filename) if (TEXT_EDITABLE_EXTENSIONS.has(ext)) return 'text-editable' if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable' if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable' + if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable' return 'unsupported' } @@ -124,6 +137,10 @@ export function FileViewer({ return } + if (category === 'pptx-previewable') { + return + } + return } @@ -163,7 +180,7 @@ function TextEditor({ isLoading, error, dataUpdatedAt, - } = useWorkspaceFileContent(workspaceId, file.id, file.key) + } = useWorkspaceFileContent(workspaceId, file.id, file.key, file.type === 'text/x-pptxgenjs') const updateContent = useUpdateWorkspaceFileContent() @@ -417,6 +434,250 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) { ) } +const pptxSlideCache = new Map() + +function pptxCacheKey(fileId: string, byteLength: number): string { + return `${fileId}:${byteLength}` +} + +function pptxCacheSet(key: string, slides: string[]): void { + pptxSlideCache.set(key, slides) + if (pptxSlideCache.size > 5) { + const oldest = pptxSlideCache.keys().next().value + if (oldest !== undefined) pptxSlideCache.delete(oldest) + } +} + +async function renderPptxSlides( + data: Uint8Array, + onSlide: (src: string, index: number) => void, + cancelled: () => boolean +): Promise { + const { PPTXViewer } = await import('pptxviewjs') + if (cancelled()) return + + const dpr = Math.min(window.devicePixelRatio || 1, 2) + const { width, height } = await getPptxRenderSize(data, dpr) + const W = width + const H = height + + const canvas = document.createElement('canvas') + canvas.width = W + canvas.height = H + const viewer = new PPTXViewer({ canvas }) + await viewer.loadFile(data) + const count = viewer.getSlideCount() + if (cancelled() || count === 0) return + + for (let i = 0; i < count; i++) { + if (cancelled()) break + if (i === 0) await viewer.render() + else await viewer.goToSlide(i) + onSlide(canvas.toDataURL('image/jpeg', 0.85), i) + } +} + +async function getPptxRenderSize( + data: Uint8Array, + dpr: number +): Promise<{ width: number; height: number }> { + const fallback = { + width: Math.round(1920 * dpr), + height: Math.round(1080 * dpr), + } + + try { + const JSZip = (await import('jszip')).default + const zip = await JSZip.loadAsync(data) + const presentationXml = await zip.file('ppt/presentation.xml')?.async('text') + if (!presentationXml) return fallback + + const tagMatch = presentationXml.match(/]+>/) + if (!tagMatch) return fallback + const tag = tagMatch[0] + const cxMatch = tag.match(/\bcx="(\d+)"/) + const cyMatch = tag.match(/\bcy="(\d+)"/) + if (!cxMatch || !cyMatch) return fallback + + const cx = Number(cxMatch[1]) + const cy = Number(cyMatch[1]) + if (!Number.isFinite(cx) || !Number.isFinite(cy) || cx <= 0 || cy <= 0) return fallback + + const aspectRatio = cx / cy + if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) return fallback + + const baseLongEdge = 1920 * dpr + if (aspectRatio >= 1) { + return { + width: Math.round(baseLongEdge), + height: Math.round(baseLongEdge / aspectRatio), + } + } + + return { + width: Math.round(baseLongEdge * aspectRatio), + height: Math.round(baseLongEdge), + } + } catch { + return fallback + } +} + +function PptxPreview({ + file, + workspaceId, + streamingContent, +}: { + file: WorkspaceFileRecord + workspaceId: string + streamingContent?: string +}) { + const { + data: fileData, + isLoading: isFetching, + error: fetchError, + } = useWorkspaceFileBinary(workspaceId, file.id, file.key) + + const cacheKey = pptxCacheKey(file.id, fileData?.byteLength ?? 0) + const cached = pptxSlideCache.get(cacheKey) + + const [slides, setSlides] = useState(cached ?? []) + const [rendering, setRendering] = useState(false) + const [renderError, setRenderError] = useState(null) + + useEffect(() => { + let cancelled = false + const controller = new AbortController() + let debounceTimer: ReturnType | null = null + + async function render() { + if (cancelled) return + try { + setRendering(true) + setRenderError(null) + + if (streamingContent !== undefined) { + const response = await fetch(`/api/workspaces/${workspaceId}/pptx/preview`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: streamingContent }), + signal: controller.signal, + }) + if (!response.ok) { + const err = await response.json().catch(() => ({ error: 'Preview failed' })) + throw new Error(err.error || 'Preview failed') + } + if (cancelled) return + const arrayBuffer = await response.arrayBuffer() + if (cancelled) return + const data = new Uint8Array(arrayBuffer) + const images: string[] = [] + await renderPptxSlides( + data, + (src) => { + images.push(src) + if (!cancelled) setSlides([...images]) + }, + () => cancelled + ) + return + } + + if (cached) { + setSlides(cached) + return + } + + if (!fileData) return + setSlides([]) + const data = new Uint8Array(fileData) + const images: string[] = [] + await renderPptxSlides( + data, + (src) => { + images.push(src) + if (!cancelled) setSlides([...images]) + }, + () => cancelled + ) + if (!cancelled && images.length > 0) { + pptxCacheSet(cacheKey, images) + } + } catch (err) { + if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) { + const msg = err instanceof Error ? err.message : 'Failed to render presentation' + logger.error('PPTX render failed', { error: msg }) + setRenderError(msg) + } + } finally { + if (!cancelled) setRendering(false) + } + } + + // Debounce streaming renders so rapid SSE updates don't spawn a subprocess + // per event. Non-streaming renders (file load / cache) run immediately. + if (streamingContent !== undefined) { + debounceTimer = setTimeout(render, 500) + } else { + render() + } + + return () => { + cancelled = true + if (debounceTimer) clearTimeout(debounceTimer) + controller.abort() + } + }, [fileData, dataUpdatedAt, streamingContent, cacheKey, workspaceId]) + + const error = fetchError + ? fetchError instanceof Error + ? fetchError.message + : 'Failed to load file' + : renderError + const loading = isFetching || rendering + + if (error) { + return ( +
+

+ Failed to preview presentation +

+

{error}

+
+ ) + } + + if (loading && slides.length === 0) { + return ( +
+
+
+

Loading presentation...

+
+
+ ) + } + + return ( +
+
+ {slides.map((src, i) => ( + {`Slide + ))} +
+
+ ) +} + function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) { const ext = getFileExtension(file.name) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 6857fc7807f..4513f95d56e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -449,33 +449,12 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) { } function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) { + const detail = data.code ? `${data.message} (${data.code})` : data.message + return ( -
-
- - - - - - - Something went wrong - -
-

- {data.message} -

- {data.code && ( - - {data.provider ? `${data.provider}:` : ''} - {data.code} - - )} -
+ + {detail} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index cfa7adb625a..afe1bd7819d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -1,5 +1,7 @@ 'use client' +import { resolveToolDisplay } from '@/lib/copilot/store-utils' +import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry' import type { ContentBlock, OptionItem, SubagentName, ToolCallData } from '../../types' import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types' import type { AgentGroupItem } from './components' @@ -53,15 +55,43 @@ function resolveAgentLabel(key: string): string { return SUBAGENT_LABELS[key as SubagentName] ?? formatToolName(key) } +function mapToolStatusToClientState( + status: ContentBlock['toolCall'] extends { status: infer T } ? T : string +) { + switch (status) { + case 'success': + return ClientToolCallState.success + case 'error': + return ClientToolCallState.error + case 'cancelled': + return ClientToolCallState.cancelled + default: + return ClientToolCallState.executing + } +} + +function getOverrideDisplayTitle(tc: NonNullable): string | undefined { + if (tc.name === 'read' || tc.name.endsWith('_respond')) { + return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.id, tc.params) + ?.text + } + return undefined +} + function toToolData(tc: NonNullable): ToolCallData { + const overrideDisplayTitle = getOverrideDisplayTitle(tc) + const displayTitle = + overrideDisplayTitle || + tc.displayTitle || + TOOL_UI_METADATA[tc.name as keyof typeof TOOL_UI_METADATA]?.title || + formatToolName(tc.name) + return { id: tc.id, toolName: tc.name, - displayTitle: - tc.displayTitle || - TOOL_UI_METADATA[tc.name as keyof typeof TOOL_UI_METADATA]?.title || - formatToolName(tc.name), + displayTitle, status: tc.status, + params: tc.params, result: tc.result, streamingArgs: tc.streamingArgs, } @@ -93,14 +123,16 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { if (block.type === 'text') { if (!block.content?.trim()) continue - if (block.subagent && group && group.agentName === block.subagent) { - const lastItem = group.items[group.items.length - 1] - if (lastItem?.type === 'text') { - lastItem.content += block.content - } else { - group.items.push({ type: 'text', content: block.content }) + if (block.subagent) { + if (group && group.agentName === block.subagent) { + const lastItem = group.items[group.items.length - 1] + if (lastItem?.type === 'text') { + lastItem.content += block.content + } else { + group.items.push({ type: 'text', content: block.content }) + } + continue } - continue } if (group) { segments.push(group) @@ -148,6 +180,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { if (block.type === 'tool_call') { if (!block.toolCall) continue const tc = block.toolCall + if (tc.name === 'tool_search_tool_regex') continue const isDispatch = SUBAGENT_KEYS.has(tc.name) && !tc.calledBy if (isDispatch) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 472bc6ca0cb..9801e89b356 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -10,7 +10,11 @@ import { markRunToolManuallyStopped, reportManualRunToolStop, } from '@/lib/copilot/client-sse/run-tool-execution' -import { downloadWorkspaceFile } from '@/lib/uploads/utils/file-utils' +import { + downloadWorkspaceFile, + getFileExtension, + getMimeTypeFromExtension, +} from '@/lib/uploads/utils/file-utils' import { FileViewer, type PreviewMode, @@ -64,35 +68,43 @@ export const ResourceContent = memo(function ResourceContent({ streamingFile, }: ResourceContentProps) { const streamFileName = streamingFile?.fileName || 'file.md' - const streamingExtractedContent = useMemo( - () => (streamingFile ? extractFileContent(streamingFile.content) : ''), - [streamingFile] - ) - const syntheticFile = useMemo( - () => ({ + const streamingExtractedContent = useMemo(() => { + if (!streamingFile) return undefined + const extracted = extractFileContent(streamingFile.content) + return extracted.length > 0 ? extracted : undefined + }, [streamingFile]) + const syntheticFile = useMemo(() => { + const ext = getFileExtension(streamFileName) + const type = ext === 'pptx' ? 'text/x-pptxgenjs' : getMimeTypeFromExtension(ext) + return { id: 'streaming-file', workspaceId, name: streamFileName, key: '', path: '', size: 0, - type: 'text/plain', + type, uploadedBy: '', uploadedAt: STREAMING_EPOCH, - }), - [workspaceId, streamFileName] - ) + } + }, [workspaceId, streamFileName]) if (streamingFile && resource.id === 'streaming-file') { return (
- + {streamingExtractedContent !== undefined ? ( + + ) : ( +
+

Processing file...

+
+ )}
) } @@ -108,7 +120,7 @@ export const ResourceContent = memo(function ResourceContent({ workspaceId={workspaceId} fileId={resource.id} previewMode={previewMode} - streamingContent={streamingFile ? extractFileContent(streamingFile.content) : undefined} + streamingContent={streamingExtractedContent} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 1586f397ff6..bf3d2db19f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -129,7 +129,7 @@ const RESOURCE_INVALIDATORS: Record< }, file: (qc, wId, id) => { qc.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) - qc.invalidateQueries({ queryKey: workspaceFilesKeys.content(wId, id) }) + qc.invalidateQueries({ queryKey: workspaceFilesKeys.contentFile(wId, id) }) qc.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, workflow: (qc, _wId) => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 048634f080a..706c104f6a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -97,6 +97,7 @@ function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock { status: resolvedStatus, displayTitle: resolvedStatus === 'cancelled' ? 'Stopped by user' : block.toolCall.display?.text, + params: block.toolCall.params, calledBy: block.toolCall.calledBy, result: block.toolCall.result, } @@ -114,6 +115,7 @@ function mapStoredToolCall(tc: TaskStoredToolCall): ContentBlock { name: tc.name, status: resolvedStatus, displayTitle: resolvedStatus === 'cancelled' ? 'Stopped by user' : undefined, + params: tc.params, result: tc.result != null ? { @@ -736,11 +738,14 @@ export function useChat( const isPartial = data?.partial === true if (!id) break - if (name.endsWith('_respond')) break + if (name === 'tool_search_tool_regex') { + break + } const ui = parsed.ui || data?.ui if (ui?.hidden) break const displayTitle = ui?.title || ui?.phaseLabel const phaseLabel = ui?.phaseLabel + const args = (data?.arguments ?? data?.input) as Record | undefined if (!toolMap.has(id)) { toolMap.set(id, blocks.length) blocks.push({ @@ -751,13 +756,11 @@ export function useChat( status: 'executing', displayTitle, phaseLabel, + params: args, calledBy: activeSubagent, }, }) if (name === 'read' || isResourceToolName(name)) { - const args = (data?.arguments ?? data?.input) as - | Record - | undefined if (args) toolArgsMap.set(id, args) } } else { @@ -767,6 +770,7 @@ export function useChat( tc.name = name if (displayTitle) tc.displayTitle = displayTitle if (phaseLabel) tc.phaseLabel = phaseLabel + if (args) tc.params = args } } flush() @@ -1136,6 +1140,7 @@ export function useChat( id: block.toolCall.id, name: block.toolCall.name, state: isCancelled ? 'cancelled' : block.toolCall.status, + params: block.toolCall.params, result: block.toolCall.result, display: { text: isCancelled ? 'Stopped by user' : block.toolCall.displayTitle, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 21c6c295c40..0ba5c32ec78 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -145,6 +145,7 @@ export interface ToolCallData { toolName: string displayTitle: string status: ToolCallStatus + params?: Record result?: ToolCallResult streamingArgs?: string } @@ -155,6 +156,7 @@ export interface ToolCallInfo { status: ToolCallStatus displayTitle?: string phaseLabel?: string + params?: Record calledBy?: string result?: { success: boolean; output?: unknown; error?: string } streamingArgs?: string diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index baf45ada2ff..dcd89f2edb9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -75,6 +75,7 @@ import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { getWorkflowWithValues } from '@/stores/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('Panel') /** @@ -290,18 +291,29 @@ export const Panel = memo(function Panel() { [copilotChatId, loadCopilotChats] ) - const onToolResult = useCallback( - (toolName: string, success: boolean, _result: unknown) => { - if (toolName === 'edit_workflow' && success && activeWorkflowId) { - fetch(`/api/workflows/${activeWorkflowId}/state`) - .then((res) => (res.ok ? res.json() : null)) - .then((freshState) => { - if (freshState) { - useWorkflowDiffStore.getState().setProposedChanges(freshState) - } + const handleCopilotToolResult = useCallback( + (toolName: string, success: boolean, _output: unknown) => { + if (toolName !== 'edit_workflow' || !success) return + const workflowId = activeWorkflowId || useWorkflowRegistry.getState().activeWorkflowId + if (!workflowId) return + + fetch(`/api/workflows/${workflowId}/state`) + .then((res) => { + if (!res.ok) throw new Error(`State fetch failed: ${res.status}`) + return res.json() + }) + .then((freshState) => { + const diffStore = useWorkflowDiffStore.getState() + return diffStore.setProposedChanges(freshState as WorkflowState, undefined, { + skipPersist: true, }) - .catch(() => {}) - } + }) + .catch((err) => { + logger.error('Failed to fetch/apply edit_workflow state', { + error: err instanceof Error ? err.message : String(err), + workflowId, + }) + }) }, [activeWorkflowId] ) @@ -320,8 +332,8 @@ export const Panel = memo(function Panel() { apiPath: '/api/copilot/chat', stopPath: '/api/mothership/chat/stop', workflowId: activeWorkflowId || undefined, - onToolResult, onTitleUpdate: loadCopilotChats, + onToolResult: handleCopilotToolResult, }) const handleCopilotNewChat = useCallback(() => { diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 2ac4d7b9d3e..074ed3b8c8d 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -16,8 +16,10 @@ export const workspaceFilesKeys = { list: (workspaceId: string, scope: WorkspaceFileQueryScope = 'active') => [...workspaceFilesKeys.lists(), workspaceId, scope] as const, contents: () => [...workspaceFilesKeys.all, 'content'] as const, - content: (workspaceId: string, fileId: string) => + contentFile: (workspaceId: string, fileId: string) => [...workspaceFilesKeys.contents(), workspaceId, fileId] as const, + content: (workspaceId: string, fileId: string, mode: 'text' | 'raw' | 'binary' = 'text') => + [...workspaceFilesKeys.contentFile(workspaceId, fileId), mode] as const, storageInfo: () => [...workspaceFilesKeys.all, 'storageInfo'] as const, } @@ -66,8 +68,12 @@ export function useWorkspaceFiles(workspaceId: string, scope: WorkspaceFileQuery /** * Fetch file content as text via the serve URL */ -async function fetchWorkspaceFileContent(key: string, signal?: AbortSignal): Promise { - const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&t=${Date.now()}` +async function fetchWorkspaceFileContent( + key: string, + signal?: AbortSignal, + raw?: boolean +): Promise { + const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&t=${Date.now()}${raw ? '&raw=1' : ''}` const response = await fetch(serveUrl, { signal, cache: 'no-store' }) if (!response.ok) { @@ -80,10 +86,37 @@ async function fetchWorkspaceFileContent(key: string, signal?: AbortSignal): Pro /** * Hook to fetch workspace file content as text */ -export function useWorkspaceFileContent(workspaceId: string, fileId: string, key: string) { +export function useWorkspaceFileContent( + workspaceId: string, + fileId: string, + key: string, + raw?: boolean +) { + return useQuery({ + queryKey: workspaceFilesKeys.content(workspaceId, fileId, raw ? 'raw' : 'text'), + queryFn: ({ signal }) => fetchWorkspaceFileContent(key, signal, raw), + enabled: !!workspaceId && !!fileId && !!key, + staleTime: 30 * 1000, + refetchOnWindowFocus: 'always', + }) +} + +async function fetchWorkspaceFileBinary(key: string, signal?: AbortSignal): Promise { + const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&t=${Date.now()}` + const response = await fetch(serveUrl, { signal, cache: 'no-store' }) + if (!response.ok) throw new Error('Failed to fetch file content') + return response.arrayBuffer() +} + +/** + * 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. + */ +export function useWorkspaceFileBinary(workspaceId: string, fileId: string, key: string) { return useQuery({ - queryKey: workspaceFilesKeys.content(workspaceId, fileId), - queryFn: ({ signal }) => fetchWorkspaceFileContent(key, signal), + queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'binary'), + queryFn: ({ signal }) => fetchWorkspaceFileBinary(key, signal), enabled: !!workspaceId && !!fileId && !!key, staleTime: 30 * 1000, refetchOnWindowFocus: 'always', @@ -202,7 +235,7 @@ export function useUpdateWorkspaceFileContent() { }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ - queryKey: workspaceFilesKeys.content(variables.workspaceId, variables.fileId), + queryKey: workspaceFilesKeys.contentFile(variables.workspaceId, variables.fileId), }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) @@ -308,7 +341,7 @@ export function useDeleteWorkspaceFile() { onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) queryClient.removeQueries({ - queryKey: workspaceFilesKeys.content(variables.workspaceId, variables.fileId), + queryKey: workspaceFilesKeys.contentFile(variables.workspaceId, variables.fileId), }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts index 83d01040392..db8f430c0d7 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts @@ -739,6 +739,7 @@ const SERVER_TOOLS = new Set([ 'knowledge_base', 'user_table', 'workspace_file', + 'download_to_workspace_file', 'get_execution_summary', 'get_job_logs', 'generate_visualization', @@ -1242,6 +1243,27 @@ async function executeServerToolDirect( chatId: context.chatId, abortSignal: context.abortSignal, }) + + const resultRecord = + result && typeof result === 'object' && !Array.isArray(result) + ? (result as Record) + : null + + // Some server tools return an explicit { success, message, ... } envelope. + // Preserve tool-level failures instead of reporting them as transport success. + if (resultRecord?.success === false) { + const message = + (typeof resultRecord.error === 'string' && resultRecord.error) || + (typeof resultRecord.message === 'string' && resultRecord.message) || + `${toolName} failed` + + return { + success: false, + error: message, + output: result, + } + } + return { success: true, output: result } } catch (error) { logger.error('Server tool execution failed', { diff --git a/apps/sim/lib/copilot/resource-extraction.ts b/apps/sim/lib/copilot/resource-extraction.ts index a1e9f75e3d8..94a7e9e160b 100644 --- a/apps/sim/lib/copilot/resource-extraction.ts +++ b/apps/sim/lib/copilot/resource-extraction.ts @@ -6,6 +6,7 @@ type ResourceType = MothershipResourceType const RESOURCE_TOOL_NAMES = new Set([ 'user_table', 'workspace_file', + 'download_to_workspace_file', 'create_workflow', 'edit_workflow', 'function_execute', @@ -119,6 +120,7 @@ export function extractResourcesFromToolResult( return [] } + case 'download_to_workspace_file': case 'generate_visualization': case 'generate_image': { if (result.fileId) { diff --git a/apps/sim/lib/copilot/store-utils.ts b/apps/sim/lib/copilot/store-utils.ts index ad4642dd2c8..9447f9cbf11 100644 --- a/apps/sim/lib/copilot/store-utils.ts +++ b/apps/sim/lib/copilot/store-utils.ts @@ -23,6 +23,7 @@ import { Wrench, Zap, } from 'lucide-react' +import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types' import { ClientToolCallState, type ClientToolDisplay, @@ -31,8 +32,9 @@ import { const logger = createLogger('CopilotStoreUtils') -/** Respond tools are internal to copilot subagents and should never be shown in the UI */ +/** Respond tools are internal handoff tools shown with a friendly generic label. */ const HIDDEN_TOOL_SUFFIX = '_respond' +const HIDDEN_TOOL_NAMES = new Set(['tool_search_tool_regex']) /** UI metadata sent by the copilot on SSE tool_call events. */ export interface ServerToolUI { @@ -81,7 +83,11 @@ export function resolveToolDisplay( serverUI?: ServerToolUI ): ClientToolDisplay | undefined { if (!toolName) return undefined - if (toolName.endsWith(HIDDEN_TOOL_SUFFIX)) return undefined + if (HIDDEN_TOOL_NAMES.has(toolName)) return undefined + + const specialDisplay = specialToolDisplay(toolName, state, params) + if (specialDisplay) return specialDisplay + const entry = TOOL_DISPLAY_REGISTRY[toolName] if (!entry) { // Use copilot-provided UI as a better fallback than humanized name @@ -115,6 +121,93 @@ export function resolveToolDisplay( return humanizedFallback(toolName, state) } +function specialToolDisplay( + toolName: string, + state: ClientToolCallState, + params?: Record +): ClientToolDisplay | undefined { + if (toolName.endsWith(HIDDEN_TOOL_SUFFIX)) { + return { + text: formatRespondLabel(state), + icon: Loader2, + } + } + + if (toolName === 'read') { + const target = describeReadTarget(readStringParam(params, 'path')) + return { + text: formatReadingLabel(target, state), + icon: FileText, + } + } + + return undefined +} + +function formatRespondLabel(state: ClientToolCallState): string { + switch (state) { + case ClientToolCallState.success: + return 'Returned results' + case ClientToolCallState.error: + return 'Failed returning results' + case ClientToolCallState.rejected: + case ClientToolCallState.aborted: + return 'Skipped returning results' + default: + return 'Returning results' + } +} + +function readStringParam( + params: Record | undefined, + key: string +): string | undefined { + const value = params?.[key] + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function formatReadingLabel(target: string | undefined, state: ClientToolCallState): string { + const suffix = target ? ` ${target}` : '' + switch (state) { + case ClientToolCallState.success: + return `Read${suffix}` + case ClientToolCallState.error: + return `Failed reading${suffix}` + case ClientToolCallState.rejected: + case ClientToolCallState.aborted: + return `Skipped reading${suffix}` + default: + return `Reading${suffix}` + } +} + +function describeReadTarget(path: string | undefined): string | undefined { + if (!path) return undefined + + const segments = path + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) + + if (segments.length === 0) return undefined + + const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] + if (!resourceType) { + return stripExtension(segments[segments.length - 1]) + } + + if (resourceType === 'file') { + return segments.slice(1).join('/') || segments[segments.length - 1] + } + + const resourceName = segments[1] || segments[segments.length - 1] + return stripExtension(resourceName) +} + +function stripExtension(value: string): string { + return value.replace(/\.[^/.]+$/, '') +} + /** Generates display from copilot-provided UI metadata. */ function serverUIFallback(serverUI: ServerToolUI, state: ClientToolCallState): ClientToolDisplay { const icon = resolveIcon(serverUI.icon) diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts new file mode 100644 index 00000000000..3664b741651 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -0,0 +1,198 @@ +import { createLogger } from '@sim/logger' +import { z } from 'zod' +import { + assertServerToolNotAborted, + type BaseServerTool, + type ServerToolContext, +} from '@/lib/copilot/tools/server/base-tool' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + getExtensionFromMimeType, + getFileExtension, + getMimeTypeFromExtension, +} from '@/lib/uploads/utils/file-utils' + +const logger = createLogger('DownloadToWorkspaceFileTool') + +const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB + +const DownloadToWorkspaceFileArgsSchema = z.object({ + url: z.string().url(), + fileName: z.string().min(1).optional(), +}) + +const DownloadToWorkspaceFileResultSchema = z.object({ + success: z.boolean(), + message: z.string(), + fileId: z.string().optional(), + fileName: z.string().optional(), + downloadUrl: z.string().optional(), +}) + +type DownloadToWorkspaceFileArgs = z.infer +type DownloadToWorkspaceFileResult = z.infer + +function sanitizeFileName(fileName: string): string { + return fileName.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_').trim() +} + +function stripQueryAndHash(input: string): string { + return input.split('#')[0]?.split('?')[0] ?? input +} + +function extractFileNameFromUrl(url: string): string | undefined { + try { + const pathname = new URL(url).pathname + const lastSegment = pathname.split('/').pop() + if (!lastSegment) return undefined + const decoded = decodeURIComponent(lastSegment) + return decoded && decoded !== '/' ? decoded : undefined + } catch { + return undefined + } +} + +function extractFileNameFromContentDisposition(header: string | null): string | undefined { + if (!header) return undefined + + const utf8Match = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i) + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1].trim()) + } catch { + return utf8Match[1].trim() + } + } + + const quotedMatch = header.match(/filename\s*=\s*"([^"]+)"/i) + if (quotedMatch?.[1]) return quotedMatch[1].trim() + + const bareMatch = header.match(/filename\s*=\s*([^;]+)/i) + if (bareMatch?.[1]) return bareMatch[1].trim() + + return undefined +} + +function resolveMimeType( + responseContentType: string | null, + candidateFileName?: string, + sourceUrl?: string +): string { + const headerMime = responseContentType?.split(';')[0]?.trim().toLowerCase() + if (headerMime && headerMime !== 'application/octet-stream') { + return headerMime + } + + const fileName = candidateFileName || extractFileNameFromUrl(sourceUrl || '') + const ext = fileName ? getFileExtension(stripQueryAndHash(fileName)) : '' + return ext ? getMimeTypeFromExtension(ext) : 'application/octet-stream' +} + +function ensureFileExtension(fileName: string, mimeType: string): string { + const ext = getFileExtension(stripQueryAndHash(fileName)) + if (ext) return fileName + + const inferredExt = getExtensionFromMimeType(mimeType) + return inferredExt ? `${fileName}.${inferredExt}` : fileName +} + +function inferOutputFileName( + requestedFileName: string | undefined, + headers: { get(name: string): string | null }, + url: string, + mimeType: string +): string { + const preferredName = + requestedFileName || + extractFileNameFromContentDisposition(headers.get('content-disposition')) || + extractFileNameFromUrl(url) || + 'downloaded-file' + + const sanitized = sanitizeFileName(stripQueryAndHash(preferredName)) || 'downloaded-file' + return ensureFileExtension(sanitized, mimeType) +} + +export const downloadToWorkspaceFileServerTool: BaseServerTool< + DownloadToWorkspaceFileArgs, + DownloadToWorkspaceFileResult +> = { + name: 'download_to_workspace_file', + inputSchema: DownloadToWorkspaceFileArgsSchema, + outputSchema: DownloadToWorkspaceFileResultSchema, + + async execute( + params: DownloadToWorkspaceFileArgs, + context?: ServerToolContext + ): Promise { + if (!context?.userId) { + throw new Error('Authentication required') + } + + const workspaceId = context.workspaceId + if (!workspaceId) { + return { success: false, message: 'Workspace ID is required' } + } + + try { + assertServerToolNotAborted(context) + + // secureFetchWithValidation handles: DNS resolution, private IP blocking (via ipaddr.js), + // SSRF-safe redirect following, and streaming size enforcement + const response = await secureFetchWithValidation(params.url, { + maxResponseBytes: MAX_DOWNLOAD_BYTES, + }) + + if (!response.ok) { + return { + success: false, + message: `Download failed with status ${response.status} ${response.statusText}`, + } + } + + const mimeType = resolveMimeType( + response.headers.get('content-type'), + params.fileName, + params.url + ) + const fileName = inferOutputFileName(params.fileName, response.headers, params.url, mimeType) + + assertServerToolNotAborted(context) + + const arrayBuffer = await response.arrayBuffer() + const fileBuffer = Buffer.from(arrayBuffer) + + if (fileBuffer.length === 0) { + return { success: false, message: 'Downloaded file is empty' } + } + + const uploaded = await uploadWorkspaceFile( + workspaceId, + context.userId, + fileBuffer, + fileName, + mimeType + ) + + logger.info('Downloaded remote file to workspace', { + sourceUrl: params.url, + fileId: uploaded.id, + fileName: uploaded.name, + mimeType, + size: fileBuffer.length, + }) + + return { + success: true, + message: `Downloaded "${uploaded.name}" to workspace (${fileBuffer.length} bytes)`, + fileId: uploaded.id, + fileName: uploaded.name, + downloadUrl: uploaded.url, + } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to download file to workspace', { url: params.url, error: msg }) + return { success: false, message: `Failed to download file: ${msg}` } + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 75c6abe6110..6d335f958db 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -3,6 +3,7 @@ import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/serv import type { WorkspaceFileArgs, WorkspaceFileResult } from '@/lib/copilot/tools/shared/schemas' import { deleteWorkspaceFile, + downloadWorkspaceFile as downloadWsFile, getWorkspaceFile, renameWorkspaceFile, updateWorkspaceFileContent, @@ -11,6 +12,8 @@ import { const logger = createLogger('WorkspaceFileServerTool') +const PPTX_SOURCE_MIME = 'text/x-pptxgenjs' + const EXT_TO_MIME: Record = { '.txt': 'text/plain', '.md': 'text/markdown', @@ -58,8 +61,12 @@ export const workspaceFileServerTool: BaseServerTool 100 ? '...' : ''}"`, + } + } + if (content.indexOf(edit.search, firstIdx + 1) !== -1) { + return { + success: false, + message: `Patch failed: search string is ambiguous — found at multiple locations in "${fileRecord.name}". Use a longer, unique search string.`, + } + } + content = + content.slice(0, firstIdx) + + edit.replace + + content.slice(firstIdx + edit.search.length) + } + + const patchedBuffer = Buffer.from(content, 'utf-8') + await updateWorkspaceFileContent( + workspaceId, + fileId, + context.userId, + patchedBuffer, + fileRecord.name?.toLowerCase().endsWith('.pptx') ? PPTX_SOURCE_MIME : undefined + ) + + logger.info('Workspace file patched via copilot', { + fileId, + name: fileRecord.name, + editCount: edits.length, + userId: context.userId, + }) + + return { + success: true, + message: `File "${fileRecord.name}" patched successfully (${edits.length} edit${edits.length > 1 ? 's' : ''} applied)`, + data: { + id: fileId, + name: fileRecord.name, + size: patchedBuffer.length, + }, + } + } + default: return { success: false, - message: `Unknown operation: ${operation}. Supported: write, update, rename, delete. Use the filesystem to list/read files.`, + message: `Unknown operation: ${operation}. Supported: write, update, patch, rename, delete.`, } } } catch (error) { diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 73f075e0592..cda4c6c7ed3 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -7,6 +7,7 @@ import { import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' import { getTriggerBlocksServerTool } from '@/lib/copilot/tools/server/blocks/get-trigger-blocks' import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' +import { downloadToWorkspaceFileServerTool } from '@/lib/copilot/tools/server/files/download-to-workspace-file' import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/workspace-file' import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' import { getJobLogsServerTool } from '@/lib/copilot/tools/server/jobs/get-job-logs' @@ -63,17 +64,29 @@ const WRITE_ACTIONS: Record = { manage_mcp_tool: ['add', 'edit', 'delete'], manage_skill: ['add', 'edit', 'delete'], manage_credential: ['rename', 'delete'], - workspace_file: ['write', 'update', 'delete', 'rename'], + workspace_file: ['write', 'update', 'delete', 'rename', 'patch'], + download_to_workspace_file: ['*'], generate_visualization: ['generate'], generate_image: ['generate'], } -function isActionAllowed(toolName: string, action: string, userPermission: string): boolean { - const writeActions = WRITE_ACTIONS[toolName] - if (!writeActions || !writeActions.includes(action)) return true +function isWritePermission(userPermission: string): boolean { return userPermission === 'write' || userPermission === 'admin' } +function isActionAllowed( + toolName: string, + action: string | undefined, + userPermission: string +): boolean { + const writeActions = WRITE_ACTIONS[toolName] + if (!writeActions) return true + // '*' means the tool is always a write operation regardless of action field + if (writeActions.includes('*')) return isWritePermission(userPermission) + if (action && writeActions.includes(action)) return isWritePermission(userPermission) + return true +} + /** Registry of all server tools. Tools self-declare their validation schemas. */ const serverToolRegistry: Record = { [getBlocksMetadataServerTool.name]: getBlocksMetadataServerTool, @@ -90,6 +103,7 @@ const serverToolRegistry: Record = { [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, [userTableServerTool.name]: userTableServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, + [downloadToWorkspaceFileServerTool.name]: downloadToWorkspaceFileServerTool, [generateVisualizationServerTool.name]: generateVisualizationServerTool, [generateImageServerTool.name]: generateImageServerTool, } @@ -113,10 +127,11 @@ export async function routeExecution( // Action-level permission enforcement for mixed read/write tools if (context?.userPermission && WRITE_ACTIONS[toolName]) { const p = payload as Record - const action = (p?.operation ?? p?.action) as string - if (action && !isActionAllowed(toolName, action, context.userPermission)) { + const action = (p?.operation ?? p?.action) as string | undefined + if (!isActionAllowed(toolName, action, context.userPermission)) { + const actionLabel = action ? `'${action}' on ` : '' throw new Error( - `Permission denied: '${action}' on ${toolName} requires write access. You have '${context.userPermission}' permission.` + `Permission denied: ${actionLabel}${toolName} requires write access. You have '${context.userPermission}' permission.` ) } } diff --git a/apps/sim/lib/copilot/tools/shared/schemas.ts b/apps/sim/lib/copilot/tools/shared/schemas.ts index 26a2f391134..5a5cb42df45 100644 --- a/apps/sim/lib/copilot/tools/shared/schemas.ts +++ b/apps/sim/lib/copilot/tools/shared/schemas.ts @@ -173,7 +173,7 @@ export type UserTableResult = z.infer // workspace_file - shared schema used by server tool and Go catalog export const WorkspaceFileArgsSchema = z.object({ - operation: z.enum(['write', 'update', 'delete', 'rename']), + operation: z.enum(['write', 'update', 'delete', 'rename', 'patch']), args: z .object({ fileId: z.string().optional(), @@ -181,8 +181,13 @@ export const WorkspaceFileArgsSchema = z.object({ content: z.string().optional(), contentType: z.string().optional(), workspaceId: z.string().optional(), - /** New name for the file (required for rename operation) */ newName: z.string().optional(), + edits: z + .array(z.object({ search: z.string(), replace: z.string() })) + .describe( + 'List of search/replace pairs applied sequentially — each edit operates on the result of the previous one. Search strings must be unique within the file.' + ) + .optional(), }) .optional(), }) diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index b22e2395c7a..d3fd77aaf3a 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -6,7 +6,7 @@ import { isImageFileType } from '@/lib/uploads/utils/file-utils' const logger = createLogger('FileReader') const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB -const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 20 MB +const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB const TEXT_TYPES = new Set([ 'text/plain', @@ -14,6 +14,7 @@ const TEXT_TYPES = new Set([ 'text/markdown', 'text/html', 'text/xml', + 'text/x-pptxgenjs', 'application/json', 'application/xml', 'application/javascript', @@ -53,7 +54,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_IMAGE_READ_BYTES) { return { - content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 20MB)]`, + content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 5MB)]`, totalLines: 1, } } @@ -72,6 +73,19 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_TEXT_READ_BYTES) { + return { + content: `[File too large to display inline: ${record.name} (${record.size} bytes, limit ${MAX_TEXT_READ_BYTES})]`, + totalLines: 1, + } + } + + const buffer = await downloadWorkspaceFile(record) + const content = buffer.toString('utf-8') + return { content, totalLines: content.split('\n').length } + } + const ext = getExtension(record.name) if (PARSEABLE_EXTENSIONS.has(ext)) { const buffer = await downloadWorkspaceFile(record) @@ -93,23 +107,10 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_TEXT_READ_BYTES) { - return { - content: `[File too large to display inline: ${record.name} (${record.size} bytes, limit ${MAX_TEXT_READ_BYTES})]`, - totalLines: 1, - } + return { + content: `[Binary file: ${record.name} (${record.type}, ${record.size} bytes). Cannot display as text.]`, + totalLines: 1, } - - const buffer = await downloadWorkspaceFile(record) - const content = buffer.toString('utf-8') - return { content, totalLines: content.split('\n').length } } catch (err) { logger.warn('Failed to read workspace file', { fileName: record.name, diff --git a/apps/sim/lib/execution/pptx-vm.ts b/apps/sim/lib/execution/pptx-vm.ts new file mode 100644 index 00000000000..007054df095 --- /dev/null +++ b/apps/sim/lib/execution/pptx-vm.ts @@ -0,0 +1,194 @@ +/** + * Sandboxed PPTX generation via subprocess. + * + * User code runs in a separate Node.js child process. File access is brokered + * via IPC — the subprocess never touches the database directly. + */ + +import { type ChildProcess, spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { createLogger } from '@sim/logger' +import { + downloadWorkspaceFile, + getWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const logger = createLogger('PptxVMExecution') + +const WORKER_STARTUP_TIMEOUT_MS = 10_000 +const GENERATION_TIMEOUT_MS = 60_000 +const MAX_STDERR = 4096 + +type WorkerMessage = + | { type: 'ready' } + | { type: 'result'; data: string } + | { type: 'error'; message: string } + | { type: 'getFile'; fileReqId: number; fileId: string } + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) +let cachedWorkerPath: string | undefined + +function getWorkerPath(): string { + if (cachedWorkerPath) return cachedWorkerPath + const candidates = [ + path.join(currentDir, 'pptx-worker.cjs'), + path.join(process.cwd(), 'lib', 'execution', 'pptx-worker.cjs'), + ] + const found = candidates.find((p) => fs.existsSync(p)) + if (!found) throw new Error(`pptx-worker.cjs not found at any of: ${candidates.join(', ')}`) + cachedWorkerPath = found + return found +} + +/** + * Generate a PPTX file by executing AI-generated PptxGenJS code in a sandboxed + * subprocess. File resources referenced by the code are fetched from workspace + * storage by the main process and delivered to the worker via IPC. + */ +export async function generatePptxFromCode( + code: string, + workspaceId: string, + signal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + let proc: ChildProcess | null = null + let settled = false + let startupTimer: ReturnType | null = null + let generationTimer: ReturnType | null = null + + function done(err: Error): void + function done(err: undefined, result: Buffer): void + function done(err: Error | undefined, result?: Buffer): void { + if (settled) return + settled = true + if (startupTimer) clearTimeout(startupTimer) + if (generationTimer) clearTimeout(generationTimer) + try { + proc?.removeAllListeners() + proc?.kill() + } catch { + // Ignore — process may have already exited + } + if (err) reject(err) + else resolve(result as Buffer) + } + + if (signal?.aborted) { + reject(new Error('PPTX generation cancelled')) + return + } + + signal?.addEventListener('abort', () => done(new Error('PPTX generation cancelled')), { + once: true, + }) + + try { + proc = spawn('node', [getWorkerPath()], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + serialization: 'json', + env: { PATH: process.env.PATH ?? '' } as unknown as NodeJS.ProcessEnv, + }) + } catch (err) { + done(err instanceof Error ? err : new Error(String(err))) + return + } + + let stderrData = '' + proc.stderr?.on('data', (chunk: Buffer) => { + if (stderrData.length < MAX_STDERR) { + stderrData += chunk.toString() + if (stderrData.length > MAX_STDERR) stderrData = stderrData.slice(0, MAX_STDERR) + } + }) + + startupTimer = setTimeout(() => { + logger.error('PPTX worker failed to start within timeout') + done(new Error('PPTX worker failed to start')) + }, WORKER_STARTUP_TIMEOUT_MS) + + proc.on('exit', (code) => { + if (!settled) { + logger.error('PPTX worker exited unexpectedly', { code, stderr: stderrData.slice(0, 500) }) + done(new Error(`PPTX worker exited unexpectedly (code ${code})`)) + } + }) + + proc.on('error', (err) => { + logger.error('PPTX worker process error', { error: err.message }) + done(err) + }) + + proc.on('message', (rawMsg: unknown) => { + const msg = rawMsg as WorkerMessage + + if (msg.type === 'ready') { + if (startupTimer) { + clearTimeout(startupTimer) + startupTimer = null + } + generationTimer = setTimeout(() => { + logger.error('PPTX generation timed out') + done(new Error('PPTX generation timed out')) + }, GENERATION_TIMEOUT_MS) + proc!.send({ type: 'generate', code }) + return + } + + if (msg.type === 'result') { + done(undefined, Buffer.from(msg.data, 'base64')) + return + } + + if (msg.type === 'error') { + done(new Error(msg.message)) + return + } + + if (msg.type === 'getFile') { + handleFileRequest(proc!, workspaceId, msg).catch((err) => { + logger.error('Failed to handle file request from PPTX worker', { + fileId: msg.fileId, + error: err instanceof Error ? err.message : String(err), + }) + if (proc && !settled) { + try { + proc.send({ + type: 'fileResult', + fileReqId: msg.fileReqId, + error: err instanceof Error ? err.message : 'File fetch failed', + }) + } catch { + // Ignore — process may have died + } + } + }) + } + }) + }) +} + +async function handleFileRequest( + proc: ChildProcess, + workspaceId: string, + msg: Extract +): Promise { + const record = await getWorkspaceFile(workspaceId, msg.fileId) + if (!record) { + proc.send({ + type: 'fileResult', + fileReqId: msg.fileReqId, + error: `File not found: ${msg.fileId}`, + }) + return + } + + const buffer = await downloadWorkspaceFile(record) + const mime = record.type || 'image/png' + proc.send({ + type: 'fileResult', + fileReqId: msg.fileReqId, + data: `data:${mime};base64,${buffer.toString('base64')}`, + }) +} diff --git a/apps/sim/lib/execution/pptx-worker.cjs b/apps/sim/lib/execution/pptx-worker.cjs new file mode 100644 index 00000000000..0f99a3eaef2 --- /dev/null +++ b/apps/sim/lib/execution/pptx-worker.cjs @@ -0,0 +1,99 @@ +/** + * Node.js worker for sandboxed PPTX generation. + * Runs in a separate Node.js process, communicates with parent via IPC. + */ + +'use strict' + +const vm = require('node:vm') +const PptxGenJS = require('pptxgenjs') + +const EXECUTION_TIMEOUT_MS = 30_000 +const FILE_REQUEST_TIMEOUT_MS = 30_000 + +const pendingFileRequests = new Map() +let fileRequestCounter = 0 + +function sendToParent(msg) { + if (process.send && process.connected) { + process.send(msg) + return true + } + return false +} + +process.on('message', async (msg) => { + if (msg.type === 'generate') { + await handleGenerate(msg) + } else if (msg.type === 'fileResult') { + handleFileResult(msg) + } +}) + +async function handleGenerate(msg) { + const { code } = msg + + try { + const pptx = new PptxGenJS() + + const getFileBase64 = (fileId) => + new Promise((resolve, reject) => { + if (typeof fileId !== 'string' || fileId.length === 0) { + reject(new Error('fileId must be a non-empty string')) + return + } + + const fileReqId = ++fileRequestCounter + const timeout = setTimeout(() => { + if (pendingFileRequests.has(fileReqId)) { + pendingFileRequests.delete(fileReqId) + reject(new Error(`File request timed out for fileId: ${fileId}`)) + } + }, FILE_REQUEST_TIMEOUT_MS) + + pendingFileRequests.set(fileReqId, { resolve, reject, timeout }) + + if (!sendToParent({ type: 'getFile', fileReqId, fileId })) { + clearTimeout(timeout) + pendingFileRequests.delete(fileReqId) + reject(new Error('Parent process disconnected')) + } + }) + + const sandbox = Object.create(null) + sandbox.pptx = pptx + sandbox.getFileBase64 = getFileBase64 + + vm.createContext(sandbox) + + const promise = vm.runInContext(`(async () => { ${code} })()`, sandbox, { + timeout: EXECUTION_TIMEOUT_MS, + filename: 'pptx-code.js', + }) + await promise + + const output = await pptx.write({ outputType: 'nodebuffer' }) + const base64 = Buffer.from(output).toString('base64') + sendToParent({ type: 'result', data: base64 }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + sendToParent({ type: 'error', message }) + } +} + +function handleFileResult(msg) { + const { fileReqId, data, error } = msg + const pending = pendingFileRequests.get(fileReqId) + if (!pending) return + + clearTimeout(pending.timeout) + pendingFileRequests.delete(fileReqId) + + if (error) { + pending.reject(new Error(error)) + } else { + pending.resolve(data) + } +} + +sendToParent({ type: 'ready' }) diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index da809071c50..da61df38fce 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -27,6 +27,8 @@ const SUPPORTED_FILE_TYPES = [ 'application/json', 'application/xml', 'text/xml', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/x-pptxgenjs', ] /** diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 29460aad032..c3819cd1b34 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -520,7 +520,8 @@ export async function updateWorkspaceFileContent( workspaceId: string, fileId: string, userId: string, - content: Buffer + content: Buffer, + contentType?: string ): Promise { logger.info(`Updating workspace file content: ${fileId} for workspace ${workspaceId}`) @@ -537,6 +538,8 @@ export async function updateWorkspaceFileContent( } } + const nextContentType = contentType || fileRecord.type + try { const metadata: Record = { originalName: fileRecord.name, @@ -549,7 +552,7 @@ export async function updateWorkspaceFileContent( await uploadFile({ file: content, fileName: fileRecord.key, - contentType: fileRecord.type, + contentType: nextContentType, context: 'workspace', preserveKey: true, customKey: fileRecord.key, @@ -558,7 +561,7 @@ export async function updateWorkspaceFileContent( await db .update(workspaceFiles) - .set({ size: content.length }) + .set({ size: content.length, contentType: nextContentType }) .where( and( eq(workspaceFiles.id, fileId), @@ -584,6 +587,7 @@ export async function updateWorkspaceFileContent( return { ...fileRecord, size: content.length, + type: nextContentType, } } catch (error) { logger.error(`Failed to update workspace file content ${fileId}:`, error) diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 48cefec6661..fceb71dfa12 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -90,7 +90,14 @@ const nextConfig: NextConfig = { ], outputFileTracingIncludes: { '/api/tools/stagehand/*': ['./node_modules/ws/**/*'], - '/*': ['./node_modules/sharp/**/*', './node_modules/@img/**/*'], + '/*': [ + './node_modules/sharp/**/*', + './node_modules/@img/**/*', + // pptxgenjs and the PPTX worker are required at runtime by the subprocess. + // Neither has a static import that Next.js can trace, so we include them explicitly. + './node_modules/pptxgenjs/**/*', + './lib/execution/pptx-worker.cjs', + ], }, experimental: { optimizeCss: true, diff --git a/apps/sim/package.json b/apps/sim/package.json index 5c3fb544b95..6dab8edcf84 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -41,13 +41,13 @@ "@azure/storage-blob": "12.27.0", "@better-auth/sso": "1.3.12", "@better-auth/stripe": "1.3.12", - "@marsidev/react-turnstile": "1.4.2", "@browserbasehq/stagehand": "^3.0.5", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@e2b/code-interpreter": "^2.0.0", "@google/genai": "1.34.0", "@hookform/resolvers": "^4.1.3", "@linear/sdk": "40.0.0", + "@marsidev/react-turnstile": "1.4.2", "@modelcontextprotocol/sdk": "1.20.2", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-jaeger": "2.1.0", @@ -92,6 +92,7 @@ "binary-extensions": "^2.0.0", "browser-image-compression": "^2.0.2", "chalk": "5.6.2", + "chart.js": "4.5.1", "cheerio": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -146,6 +147,8 @@ "postgres": "^3.4.5", "posthog-js": "1.334.1", "posthog-node": "5.9.2", + "pptxgenjs": "4.0.1", + "pptxviewjs": "1.1.8", "prismjs": "^1.30.0", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 5f5a4de0034..7e98b837def 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -15,11 +15,13 @@ export default defineConfig({ }, dirs: ['./background'], build: { - external: ['isolated-vm'], + external: ['isolated-vm', 'pptxgenjs'], extensions: [ - additionalFiles({ files: ['./lib/execution/isolated-vm-worker.cjs'] }), + additionalFiles({ + files: ['./lib/execution/isolated-vm-worker.cjs', './lib/execution/pptx-worker.cjs'], + }), additionalPackages({ - packages: ['unpdf', 'pdf-lib', 'isolated-vm'], + packages: ['unpdf', 'pdf-lib', 'isolated-vm', 'pptxgenjs'], }), ], }, diff --git a/bun.lock b/bun.lock index 9af6d37ea56..e0e1a25291a 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -117,6 +118,7 @@ "binary-extensions": "^2.0.0", "browser-image-compression": "^2.0.2", "chalk": "5.6.2", + "chart.js": "4.5.1", "cheerio": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -171,6 +173,8 @@ "postgres": "^3.4.5", "posthog-js": "1.334.1", "posthog-node": "5.9.2", + "pptxgenjs": "4.0.1", + "pptxviewjs": "1.1.8", "prismjs": "^1.30.0", "react": "19.2.4", "react-dom": "19.2.4", @@ -802,6 +806,8 @@ "@jsonhero/path": ["@jsonhero/path@1.0.21", "", {}, "sha512-gVUDj/92acpVoJwsVJ/RuWOaHyG4oFzn898WNGQItLCTQ+hOaVlEaImhwE1WqOTf+l3dGOUkbSiVKlb3q1hd1Q=="], + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@langchain/core": ["@langchain/core@0.3.80", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", "camelcase": "6", "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", "langsmith": "^0.3.67", "mustache": "^4.2.0", "p-queue": "^6.6.2", "p-retry": "4", "uuid": "^10.0.0", "zod": "^3.25.32", "zod-to-json-schema": "^3.22.3" } }, "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA=="], "@langchain/openai": ["@langchain/openai@0.4.9", "", { "dependencies": { "js-tiktoken": "^1.0.12", "openai": "^4.87.3", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.3" }, "peerDependencies": { "@langchain/core": ">=0.3.39 <0.4.0" } }, "sha512-NAsaionRHNdqaMjVLPkFCyjUDze+OqRHghA1Cn4fPoAafz+FXcl9c7LlEl9Xo0FH6/8yiCl7Rw2t780C/SBVxQ=="], @@ -1858,6 +1864,8 @@ "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], @@ -2424,6 +2432,8 @@ "http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "^10.0.3" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="], + "https": ["https@1.0.0", "", {}, "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], @@ -3096,6 +3106,10 @@ "posthog-node": ["posthog-node@5.9.2", "", { "dependencies": { "@posthog/core": "1.2.2" } }, "sha512-oU7FbFcH5cn40nhP04cBeT67zE76EiGWjKKzDvm6IOm5P83sqM0Ij0wMJQSHp+QI6ZN7MLzb+4xfMPUEZ4q6CA=="], + "pptxgenjs": ["pptxgenjs@4.0.1", "", { "dependencies": { "@types/node": "^22.8.1", "https": "^1.0.0", "image-size": "^1.2.1", "jszip": "^3.10.1" } }, "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A=="], + + "pptxviewjs": ["pptxviewjs@1.1.8", "", { "peerDependencies": { "chart.js": ">=4.4.1", "jszip": ">=3.10.1" }, "optionalPeers": ["chart.js", "jszip"] }, "sha512-Nk3uIg1H7WkigKIKZPcTrcmV4RMpRSHvG4jWAO9aKPD1MWkOF8fwqtypsF+kzUZvIzO0BA/eKK+zNK7/R7WrDg=="], + "preact": ["preact@10.28.3", "", {}, "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA=="], "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], @@ -3142,6 +3156,8 @@ "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], + "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -4280,6 +4296,8 @@ "posthog-node/@posthog/core": ["@posthog/core@1.2.2", "", {}, "sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg=="], + "pptxgenjs/image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], + "protobufjs/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],