Backend smart foldering
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Text, ActionIcon, ScrollArea } from '@mantine/core';
|
||||
import { CardModalPhase, CARD_MODAL_TIMINGS } from '@app/hooks/useCardModalAnimation';
|
||||
@@ -40,14 +40,23 @@ export function CardExpansionModal({
|
||||
children,
|
||||
footer,
|
||||
}: CardExpansionModalProps) {
|
||||
const [viewportW, setViewportW] = useState(window.innerWidth);
|
||||
const [viewportH, setViewportH] = useState(window.innerHeight);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => { setViewportW(window.innerWidth); setViewportH(window.innerHeight); };
|
||||
window.addEventListener('resize', handler);
|
||||
return () => window.removeEventListener('resize', handler);
|
||||
}, []);
|
||||
|
||||
if (phase === 'closed' || !cardRect) return null;
|
||||
|
||||
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const modalW = Math.min(MODAL_W_REM * rootFontSize, window.innerWidth * 0.9);
|
||||
const modalH = MODAL_H_REM * rootFontSize;
|
||||
const modalW = Math.min(MODAL_W_REM * rootFontSize, viewportW * 0.9);
|
||||
const modalH = Math.min(MODAL_H_REM * rootFontSize, viewportH * 0.85);
|
||||
const headerH = HEADER_H_REM * rootFontSize;
|
||||
const finalLeft = (window.innerWidth - modalW) / 2;
|
||||
const finalTop = window.innerHeight * MODAL_TOP_FRACTION;
|
||||
const finalLeft = (viewportW - modalW) / 2;
|
||||
const finalTop = Math.min(viewportH * MODAL_TOP_FRACTION, viewportH - modalH - 16);
|
||||
|
||||
const isAtCard = phase === 'entering' || phase === 'closing-header';
|
||||
const isAtHeader = phase === 'header-open';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal, Center, Text, Box } from '@mantine/core';
|
||||
import { Modal, Center, Text, Box, Loader } from '@mantine/core';
|
||||
import { FileId } from '@app/types/fileContext';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF';
|
||||
@@ -14,15 +14,20 @@ interface FilePreviewModalProps {
|
||||
|
||||
export function FilePreviewModal({ fileId, fileName, onClose }: FilePreviewModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileId) { setFile(null); setError(false); return; }
|
||||
if (!fileId) { setFile(null); setError(false); setLoading(false); return; }
|
||||
setError(false);
|
||||
fileStorage.getStirlingFile(fileId).then(f => {
|
||||
if (f) setFile(f);
|
||||
else setError(true);
|
||||
});
|
||||
setLoading(true);
|
||||
fileStorage.getStirlingFile(fileId)
|
||||
.then(f => {
|
||||
if (f) setFile(f);
|
||||
else setError(true);
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [fileId]);
|
||||
|
||||
return (
|
||||
@@ -34,15 +39,19 @@ export function FilePreviewModal({ fileId, fileName, onClose }: FilePreviewModal
|
||||
zIndex={400}
|
||||
styles={{ body: { height: '82vh', padding: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}
|
||||
>
|
||||
{error ? (
|
||||
{loading ? (
|
||||
<Center h="100%"><Loader size="sm" /></Center>
|
||||
) : error ? (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed">Could not load file preview.</Text>
|
||||
</Center>
|
||||
) : !file ? (
|
||||
<Center h="100%"><Loader size="sm" /></Center>
|
||||
) : (
|
||||
<ViewerProvider>
|
||||
<PdfViewerToolbar />
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<LocalEmbedPDF file={file ?? undefined} fileName={fileName} />
|
||||
<LocalEmbedPDF file={file} fileName={fileName} />
|
||||
</Box>
|
||||
</ViewerProvider>
|
||||
)}
|
||||
|
||||
@@ -569,6 +569,11 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
{status === 'error' && meta?.nextRetryAt && <ReplayIcon style={{ fontSize: '0.875rem', color: '#f59e0b', flexShrink: 0 }} />}
|
||||
{status === 'pending' && <Box style={{ width: '0.5rem', height: '0.5rem', borderRadius: '50%', backgroundColor: 'var(--mantine-color-yellow-5)', flexShrink: 0 }} />}
|
||||
<Text size="xs" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>{filename}</Text>
|
||||
{status === 'error' && (meta?.failedAttempts ?? 0) > 0 && (
|
||||
<Box style={{ padding: '0.0625rem 0.3rem', borderRadius: '0.25rem', backgroundColor: 'rgba(239,68,68,0.22)', border: '0.0625rem solid rgba(239,68,68,0.45)', flexShrink: 0 }}>
|
||||
<Text style={{ fontSize: '0.625rem', fontWeight: 700, color: '#ef4444', letterSpacing: '0.03em' }}>{meta!.failedAttempts}×</Text>
|
||||
</Box>
|
||||
)}
|
||||
{meta?.nextRetryAt && (
|
||||
<RetryCountdown nextRetryAt={meta.nextRetryAt} t={t} />
|
||||
)}
|
||||
@@ -635,7 +640,9 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
{t('smartFolders.workbench.noInputFiles', 'No input files stored yet')}
|
||||
</Text>
|
||||
) : inputFiles.map((file) => (
|
||||
) : inputFiles.map((file) => {
|
||||
const inputMeta = folderRecord?.files[file.fileId];
|
||||
return (
|
||||
<Box
|
||||
key={file.fileId}
|
||||
style={{
|
||||
@@ -650,6 +657,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
>
|
||||
<FolderOpenIcon style={{ fontSize: '0.875rem', color: 'var(--mantine-color-blue-filled)', flexShrink: 0 }} />
|
||||
<Text size="sm" style={{ flex: 1, minWidth: 0, fontWeight: 500 }} lineClamp={1}>{file.name}</Text>
|
||||
{inputMeta?.addedAt && <Text size="xs" c="dimmed" style={{ fontSize: '0.6875rem', flexShrink: 0 }}>{timeAgo(new Date(inputMeta.addedAt), t)}</Text>}
|
||||
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'nowrap' }}>
|
||||
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.3rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={() => handleView(file)} title={t('smartFolders.actions.view', 'Preview')}>
|
||||
<VisibilityIcon style={{ fontSize: '1.125rem' }} />
|
||||
@@ -659,7 +667,8 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
</button>
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</CardExpansionModal>
|
||||
|
||||
@@ -689,7 +698,11 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
{t('smartFolders.workbench.noOutputFiles', 'No output files stored yet')}
|
||||
</Text>
|
||||
) : outputFiles.map((file) => (
|
||||
) : outputFiles.map((file) => {
|
||||
const processedAt = Object.values(folderRecord?.files ?? {}).find(m =>
|
||||
m.displayFileIds?.includes(file.fileId) || m.displayFileId === file.fileId
|
||||
)?.processedAt;
|
||||
return (
|
||||
<Box
|
||||
key={file.fileId}
|
||||
style={{
|
||||
@@ -704,6 +717,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
>
|
||||
<TaskAltIcon style={{ fontSize: '0.875rem', color: '#22c55e', flexShrink: 0 }} />
|
||||
<Text size="sm" style={{ flex: 1, minWidth: 0, fontWeight: 500 }} lineClamp={1}>{file.name}</Text>
|
||||
{processedAt && <Text size="xs" c="dimmed" style={{ fontSize: '0.6875rem', flexShrink: 0 }}>{timeAgo(new Date(processedAt), t)}</Text>}
|
||||
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'nowrap' }}>
|
||||
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.3rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={() => handleView(file)} title={t('smartFolders.actions.view', 'View')}>
|
||||
<VisibilityIcon style={{ fontSize: '1.125rem' }} />
|
||||
@@ -713,7 +727,8 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
</button>
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</CardExpansionModal>
|
||||
|
||||
@@ -775,6 +790,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
<Group gap="0.625rem" style={{ padding: '0.5rem 0.625rem' }} wrap="nowrap">
|
||||
<ErrorOutlineIcon style={{ fontSize: '0.875rem', color: '#ef4444', flexShrink: 0 }} />
|
||||
<Text size="sm" style={{ flex: 1, minWidth: 0, fontWeight: 500 }} lineClamp={1}>{filename}</Text>
|
||||
{meta?.lastFailedAt && <Text size="xs" c="dimmed" style={{ fontSize: '0.6875rem', flexShrink: 0 }}>{timeAgo(new Date(meta.lastFailedAt), t)}</Text>}
|
||||
{attempts > 0 && (
|
||||
<Box style={{
|
||||
padding: '0.125rem 0.375rem',
|
||||
|
||||
@@ -8,13 +8,13 @@ import SearchIcon from '@mui/icons-material/Search';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { FileId, StirlingFileStub } from '@app/types/fileContext';
|
||||
import { FileId, StirlingFileStub, createFileId, createStirlingFile, createQuickKey } from '@app/types/fileContext';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { FilePreviewModal } from '@app/components/smartFolders/FilePreviewModal';
|
||||
import { useFolderOutputIds } from '@app/hooks/useFolderOutputIds';
|
||||
import { useFolderMembership } from '@app/hooks/useFolderMembership';
|
||||
import { useAllSmartFolders } from '@app/hooks/useAllSmartFolders';
|
||||
import { iconMap } from '@app/components/tools/automate/iconMap';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
|
||||
|
||||
interface WatchFolderFileListProps {
|
||||
@@ -124,7 +124,7 @@ function FileRow({
|
||||
.filter(Boolean) as typeof folders;
|
||||
|
||||
const hasTags = isInCurrentFolder || otherFolders.length > 0;
|
||||
const hasBottomRow = !!file.size || hasTags;
|
||||
const hasBottomRow = file.size > 0 || otherFolders.length > 0;
|
||||
|
||||
const handleRowClick = (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('[data-no-select]')) return;
|
||||
@@ -319,7 +319,7 @@ function FileRow({
|
||||
paddingLeft: '1.125rem',
|
||||
}}
|
||||
>
|
||||
{file.size && (
|
||||
{file.size > 0 && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '0.625rem',
|
||||
@@ -414,21 +414,41 @@ export function WatchFolderFileList({ files, folderId, onSendToFolder, onNavigat
|
||||
const [inFoldersExpanded, setInFoldersExpanded] = useState(true);
|
||||
const [outputsExpanded, setOutputsExpanded] = useState(false);
|
||||
|
||||
const { addFiles } = useFileHandler();
|
||||
// Store files directly to IndexedDB without adding them to the active workbench state.
|
||||
// The sidebar "My Files" list refreshes automatically via the stirling:files-changed event.
|
||||
const storeFilesOnly = useCallback(async (files: File[]) => {
|
||||
for (const file of files) {
|
||||
const fileId = createFileId();
|
||||
const stub: StirlingFileStub = {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
type: file.type || 'application/pdf',
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
isLeaf: true,
|
||||
originalFileId: fileId,
|
||||
versionNumber: 1,
|
||||
toolHistory: [],
|
||||
quickKey: createQuickKey(file),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await fileStorage.storeStirlingFile(createStirlingFile(file, fileId), stub);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUploadClick = useCallback(async () => {
|
||||
const pickedFiles = await openFilesFromDisk({
|
||||
multiple: true,
|
||||
onFallbackOpen: () => uploadInputRef.current?.click(),
|
||||
});
|
||||
if (pickedFiles.length > 0) await addFiles(pickedFiles);
|
||||
}, [addFiles]);
|
||||
if (pickedFiles.length > 0) await storeFilesOnly(pickedFiles);
|
||||
}, [storeFilesOnly]);
|
||||
|
||||
const handleInputChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = Array.from(e.target.files ?? []);
|
||||
if (picked.length > 0) await addFiles(picked);
|
||||
if (picked.length > 0) await storeFilesOnly(picked);
|
||||
e.target.value = '';
|
||||
}, [addFiles]);
|
||||
}, [storeFilesOnly]);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('date-desc');
|
||||
|
||||
@@ -14,7 +14,7 @@ import { fileStorage } from '@app/services/fileStorage';
|
||||
import { folderRunStateStorage } from '@app/services/folderRunStateStorage';
|
||||
import { folderRetryScheduleStorage } from '@app/services/folderRetryScheduleStorage';
|
||||
import { smartFolderStorage } from '@app/services/smartFolderStorage';
|
||||
import { executeAutomationSequence } from '@app/utils/automationExecutor';
|
||||
import { executeBackendPipeline } from '@app/utils/automationExecutor';
|
||||
import {
|
||||
FileId,
|
||||
StirlingFileStub,
|
||||
@@ -101,16 +101,7 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
|
||||
|
||||
await folderStorage.updateFileMetadata(folder.id, inputFileId, { status: 'processing' });
|
||||
|
||||
// Step-level callbacks not needed for background folder processing
|
||||
const noop = () => {};
|
||||
const resultFiles = await executeAutomationSequence(
|
||||
automation,
|
||||
[file],
|
||||
toolRegistry as ToolRegistry,
|
||||
noop,
|
||||
noop,
|
||||
noop
|
||||
);
|
||||
const resultFiles = await executeBackendPipeline(automation, [file], toolRegistry as ToolRegistry);
|
||||
|
||||
// Load input stub for version chain info and name fallback
|
||||
const inputStub = await fileStorage.getStirlingFileStub(inputFileId as FileId);
|
||||
@@ -197,6 +188,7 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
|
||||
errorMessage: err instanceof Error ? err.message : 'Unknown error',
|
||||
failedAttempts: attempts,
|
||||
nextRetryAt,
|
||||
lastFailedAt: new Date(),
|
||||
});
|
||||
|
||||
if (willRetry) {
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface FolderFileMetadata {
|
||||
errorMessage?: string;
|
||||
failedAttempts?: number;
|
||||
nextRetryAt?: number; // ms timestamp — set when an automatic retry is scheduled
|
||||
lastFailedAt?: Date;
|
||||
name?: string; // original filename
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AUTOMATION_CONSTANTS } from '@app/constants/automation';
|
||||
import { AutomationFileProcessor } from '@app/utils/automationFileProcessor';
|
||||
import { ToolType } from '@app/hooks/tools/shared/useToolOperation';
|
||||
import { processResponse } from '@app/utils/toolResponseProcessor';
|
||||
import { getFilenameFromHeaders } from '@app/utils/fileResponseUtils';
|
||||
|
||||
/**
|
||||
* Process multi-file tool response (handles ZIP or single PDF responses)
|
||||
@@ -232,3 +233,85 @@ export const executeAutomationSequence = async (
|
||||
console.log(`\n🎉 Automation complete: ${currentFiles.length} file(s)`);
|
||||
return currentFiles;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute an automation pipeline via POST /api/v1/pipeline/handleData.
|
||||
*
|
||||
* Falls back to executeAutomationSequence for automations that contain a step requiring
|
||||
* client-side processing (e.g. Adjust Contrast, Remove Annotations, Extract Pages).
|
||||
*/
|
||||
export const executeBackendPipeline = async (
|
||||
automation: any,
|
||||
initialFiles: File[],
|
||||
toolRegistry: ToolRegistry
|
||||
): Promise<File[]> => {
|
||||
if (!automation?.operations || automation.operations.length === 0) {
|
||||
throw new Error('No operations in automation');
|
||||
}
|
||||
|
||||
// Fall back to frontend execution if any step needs client-side processing
|
||||
const needsFrontendFallback = automation.operations.some((op: any) =>
|
||||
toolRegistry[op.operation as ToolId]?.operationConfig?.customProcessor != null
|
||||
);
|
||||
if (needsFrontendFallback) {
|
||||
return executeAutomationSequence(automation, initialFiles, toolRegistry);
|
||||
}
|
||||
|
||||
// Build PipelineConfig JSON — "pipeline" is the @JsonProperty key the backend expects.
|
||||
const pipeline = automation.operations.map((op: any) => {
|
||||
const toolConfig = toolRegistry[op.operation as ToolId]?.operationConfig;
|
||||
if (!toolConfig) throw new Error(`Tool operation not supported: ${op.operation}`);
|
||||
|
||||
// Apply frontend defaults so the backend receives complete parameters
|
||||
const parameters = { ...toolConfig.defaultParameters, ...(op.parameters ?? {}) };
|
||||
|
||||
// Backend builds URL as getBaseUrl() + operation where getBaseUrl() ends with "/"
|
||||
const rawEndpoint = typeof toolConfig.endpoint === 'function'
|
||||
? toolConfig.endpoint(parameters)
|
||||
: toolConfig.endpoint;
|
||||
const operation = rawEndpoint.replace(/^\//, '');
|
||||
|
||||
return { operation, parameters };
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
for (const file of initialFiles) {
|
||||
formData.append('fileInput', file);
|
||||
}
|
||||
formData.append('json', JSON.stringify({ name: automation.name, pipeline }));
|
||||
|
||||
const response = await apiClient.post<Blob>('/api/v1/pipeline/handleData', formData, {
|
||||
responseType: 'blob',
|
||||
// Allow per-step timeout headroom proportional to the number of operations
|
||||
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT * automation.operations.length,
|
||||
});
|
||||
|
||||
const blob: Blob = response.data;
|
||||
|
||||
// Validate the response is an actual PDF or ZIP before storing it.
|
||||
// An empty or XML/HTML error body from the backend would otherwise be
|
||||
// silently stored and render as "unknown length" in the PDF viewer.
|
||||
if (blob.size === 0) {
|
||||
throw new Error('Backend pipeline returned an empty response');
|
||||
}
|
||||
const header = new Uint8Array(await blob.slice(0, 5).arrayBuffer());
|
||||
const isPdf = header[0] === 0x25 && header[1] === 0x50 && header[2] === 0x44 && header[3] === 0x46 && header[4] === 0x2D; // %PDF-
|
||||
const isZip = header[0] === 0x50 && header[1] === 0x4B; // PK
|
||||
if (!isPdf && !isZip) {
|
||||
let hint = '';
|
||||
try { hint = ` Response preview: ${await blob.slice(0, 200).text()}`; } catch { /* ignore */ }
|
||||
throw new Error(`Backend pipeline returned unexpected content (not a PDF or ZIP).${hint}`);
|
||||
}
|
||||
|
||||
const contentType: string = response.headers['content-type'] ?? '';
|
||||
|
||||
if (contentType.includes('zip')) {
|
||||
const { files } = await AutomationFileProcessor.extractAutomationZipFiles(blob);
|
||||
return files;
|
||||
}
|
||||
|
||||
const filename =
|
||||
getFilenameFromHeaders(response.headers['content-disposition'] ?? '') ??
|
||||
`${automation.name ?? 'output'}.pdf`;
|
||||
return [new File([blob], filename, { type: blob.type || 'application/pdf', lastModified: Date.now() })];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user