mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Comprehensive telemetry quality improvements across Sentry and PostHog, grounded in an audit of the live data plus current best-practice research. Sentry: job_id/instance_id tags, operational fingerprinting, PII-safe settings context on bug events, web tag population + extension-noise filtering, an early-crash buffer, http status/method kept on breadcrumbs, and a gated-off-by-default performance-tracing re-enable (tracesSampler that zeroes db/redis/queue-poll root spans + drops the Redis integration) with worker job spans and canonical-host cron monitors. PostHog: history_change SPA pageviews, instance_id super property for fleet rollups, enriched tool_used (formats, byte sizes, is_batch, execution_hint, real error_kind taxonomy), the previously-dead result_saved/batch_processed/ai_bundle_prompted events fired, search click-through, editor + Automate authoring + auth instrumentation, a before_send PII boundary, and minimal opt-in landing-site pageviews.
739 lines
24 KiB
TypeScript
739 lines
24 KiB
TypeScript
import { ANALYTICS_EVENTS, apiToolPath, PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "@/contexts/i18n-context";
|
|
import { formatHeaders, parseApiError } from "@/lib/api";
|
|
import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes";
|
|
import { generateId } from "@/lib/utils";
|
|
import { useFileStore } from "@/stores/file-store";
|
|
|
|
interface ProcessResult {
|
|
jobId: string;
|
|
downloadUrl: string;
|
|
previewUrl?: string;
|
|
originalSize: number;
|
|
processedSize: number;
|
|
savedFileId?: string;
|
|
warning?: string;
|
|
}
|
|
|
|
export interface ToolProgress {
|
|
phase: "idle" | "uploading" | "processing" | "complete";
|
|
percent: number;
|
|
stage?: string;
|
|
elapsed: number;
|
|
}
|
|
|
|
const IDLE_PROGRESS: ToolProgress = {
|
|
phase: "idle",
|
|
percent: 0,
|
|
elapsed: 0,
|
|
};
|
|
|
|
// AI tools return 202 and deliver results via SSE (not XHR response).
|
|
const AI_PYTHON_TOOLS = new Set<string>(PYTHON_SIDECAR_TOOLS);
|
|
|
|
// Tools that are not Python sidecar but still need an extended XHR timeout.
|
|
const LONG_RUNNING_TOOLS = new Set<string>(["content-aware-resize", "ai-canvas-expand"]);
|
|
|
|
const UPLOAD_WEIGHT = 15;
|
|
const SSE_STALL_TIMEOUT_MS = 300_000;
|
|
|
|
/** Extension to MIME type for batch ZIP blob construction. Falls back to undefined (generic). */
|
|
const MIME_BY_EXT: Record<string, string> = {
|
|
png: "image/png",
|
|
jpg: "image/jpeg",
|
|
jpeg: "image/jpeg",
|
|
gif: "image/gif",
|
|
webp: "image/webp",
|
|
avif: "image/avif",
|
|
svg: "image/svg+xml",
|
|
mp4: "video/mp4",
|
|
webm: "video/webm",
|
|
mov: "video/quicktime",
|
|
ogv: "video/ogg",
|
|
mp3: "audio/mpeg",
|
|
wav: "audio/wav",
|
|
ogg: "audio/ogg",
|
|
flac: "audio/flac",
|
|
m4a: "audio/mp4",
|
|
aac: "audio/aac",
|
|
pdf: "application/pdf",
|
|
txt: "text/plain",
|
|
csv: "text/csv",
|
|
json: "application/json",
|
|
xml: "application/xml",
|
|
html: "text/html",
|
|
zip: "application/zip",
|
|
};
|
|
|
|
export function useToolProcessor(toolId: string) {
|
|
const { t } = useTranslation();
|
|
const {
|
|
processing,
|
|
error,
|
|
processedUrl,
|
|
originalSize,
|
|
processedSize,
|
|
setProcessing,
|
|
setError,
|
|
setActiveJob,
|
|
} = useFileStore();
|
|
|
|
const [progress, setProgress] = useState<ToolProgress>(IDLE_PROGRESS);
|
|
const [warning, setWarning] = useState<string | null>(null);
|
|
// Extra fields the route spreads into the result envelope (e.g. histogram
|
|
// bins, lqip dataUri, AI detection counts). Null until a job completes.
|
|
const [resultPayload, setResultPayload] = useState<Record<string, unknown> | null>(null);
|
|
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const stallTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const activeJobIdRef = useRef<string | null>(null);
|
|
const asyncModeRef = useRef(false);
|
|
|
|
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
|
|
const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
|
|
|
|
const clearActiveJob = useCallback(() => {
|
|
activeJobIdRef.current = null;
|
|
setActiveJob(null, null);
|
|
}, [setActiveJob]);
|
|
|
|
const cancelCurrentJob = useCallback(async () => {
|
|
const jobId = activeJobIdRef.current;
|
|
if (!jobId) return;
|
|
try {
|
|
await fetch(`/api/v1/jobs/${jobId}/cancel`, {
|
|
method: "POST",
|
|
headers: formatHeaders(),
|
|
});
|
|
} catch {
|
|
// Cancel request failed; SSE handler or stall timeout will clean up
|
|
}
|
|
}, []);
|
|
|
|
const reconnectSSE = useCallback(() => {
|
|
const jobId = activeJobIdRef.current;
|
|
if (!jobId) return;
|
|
if (eventSourceRef.current && eventSourceRef.current.readyState === EventSource.OPEN) {
|
|
return;
|
|
}
|
|
|
|
if (eventSourceRef.current) {
|
|
eventSourceRef.current.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
|
|
try {
|
|
const es = new EventSource(`/api/v1/jobs/${jobId}/progress`);
|
|
eventSourceRef.current = es;
|
|
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "heartbeat") {
|
|
if (asyncModeRef.current && stallTimerRef.current) {
|
|
clearTimeout(stallTimerRef.current);
|
|
stallTimerRef.current = setTimeout(() => {
|
|
if (eventSourceRef.current) {
|
|
eventSourceRef.current.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
clearActiveJob();
|
|
setError(
|
|
"Processing timed out with no progress for 5 minutes. Try again or use a smaller file.",
|
|
);
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
}, SSE_STALL_TIMEOUT_MS);
|
|
}
|
|
return;
|
|
}
|
|
if (data.type !== "single") return;
|
|
|
|
if (asyncModeRef.current && stallTimerRef.current) {
|
|
clearTimeout(stallTimerRef.current);
|
|
stallTimerRef.current = setTimeout(() => {
|
|
if (eventSourceRef.current) {
|
|
eventSourceRef.current.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
clearActiveJob();
|
|
setError(
|
|
"Processing timed out with no progress for 5 minutes. Try again or use a smaller file.",
|
|
);
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
}, SSE_STALL_TIMEOUT_MS);
|
|
}
|
|
|
|
if (data.phase === "complete" && data.result) {
|
|
if (stallTimerRef.current) clearTimeout(stallTimerRef.current);
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
es.close();
|
|
eventSourceRef.current = null;
|
|
clearActiveJob();
|
|
|
|
const result = data.result as ProcessResult;
|
|
setWarning(result.warning ?? null);
|
|
setResultPayload(result as unknown as Record<string, unknown>);
|
|
const idx = useFileStore.getState().selectedIndex;
|
|
useFileStore.getState().updateEntry(idx, {
|
|
processedUrl: result.downloadUrl,
|
|
processedPreviewUrl: result.previewUrl ?? null,
|
|
processedFilename: null,
|
|
status: "completed",
|
|
originalSize: result.originalSize,
|
|
processedSize: result.processedSize,
|
|
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
|
|
});
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
return;
|
|
}
|
|
|
|
if (data.phase === "failed") {
|
|
if (stallTimerRef.current) clearTimeout(stallTimerRef.current);
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
es.close();
|
|
eventSourceRef.current = null;
|
|
clearActiveJob();
|
|
setError(data.error || "Processing failed");
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
return;
|
|
}
|
|
|
|
if (typeof data.percent === "number") {
|
|
const scaled = UPLOAD_WEIGHT + (data.percent / 100) * (100 - UPLOAD_WEIGHT);
|
|
setProgress((prev) => ({
|
|
...prev,
|
|
phase: "processing",
|
|
percent: Math.max(prev.percent, scaled),
|
|
stage: data.stage,
|
|
}));
|
|
}
|
|
} catch {
|
|
// Ignore malformed SSE
|
|
}
|
|
};
|
|
|
|
es.onerror = () => {
|
|
if (!asyncModeRef.current) {
|
|
es.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
};
|
|
} catch {
|
|
// EventSource creation failed
|
|
}
|
|
}, [clearActiveJob, setError, setProcessing]);
|
|
|
|
// Reconnect SSE when tab becomes visible again (mobile tab recovery)
|
|
useEffect(() => {
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState !== "visible") return;
|
|
if (!activeJobIdRef.current) return;
|
|
if (eventSourceRef.current && eventSourceRef.current.readyState === EventSource.OPEN) {
|
|
return;
|
|
}
|
|
setTimeout(() => reconnectSSE(), 500);
|
|
};
|
|
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
|
return () => {
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
if (eventSourceRef.current) eventSourceRef.current.close();
|
|
if (xhrRef.current) xhrRef.current.abort();
|
|
if (abortRef.current) abortRef.current.abort();
|
|
if (stallTimerRef.current) clearTimeout(stallTimerRef.current);
|
|
};
|
|
}, [reconnectSSE]);
|
|
|
|
const processFiles = useCallback(
|
|
(files: File[], settings: Record<string, unknown>) => {
|
|
if (files.length === 0) {
|
|
setError("No files selected");
|
|
return;
|
|
}
|
|
|
|
import("@/lib/analytics").then(({ track }) => {
|
|
track(ANALYTICS_EVENTS.TOOL_STARTED, {
|
|
tool_id: toolId,
|
|
is_batch: false,
|
|
file_count: files.length,
|
|
});
|
|
});
|
|
|
|
const capturedIndex = useFileStore.getState().selectedIndex;
|
|
|
|
setError(null);
|
|
setWarning(null);
|
|
setResultPayload(null);
|
|
useFileStore.getState().updateEntry(capturedIndex, {
|
|
processedUrl: null,
|
|
processedPreviewUrl: null,
|
|
processedFilename: null,
|
|
status: "processing",
|
|
error: null,
|
|
});
|
|
setProcessing(true);
|
|
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
|
|
|
const startTime = Date.now();
|
|
elapsedRef.current = setInterval(() => {
|
|
setProgress((prev) => ({
|
|
...prev,
|
|
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
|
}));
|
|
}, 1000);
|
|
|
|
const clientJobId = generateId();
|
|
activeJobIdRef.current = clientJobId;
|
|
asyncModeRef.current = false;
|
|
let asyncMode = false;
|
|
|
|
const clearStallTimer = () => {
|
|
if (stallTimerRef.current) {
|
|
clearTimeout(stallTimerRef.current);
|
|
stallTimerRef.current = null;
|
|
}
|
|
};
|
|
|
|
const resetStallTimer = () => {
|
|
clearStallTimer();
|
|
stallTimerRef.current = setTimeout(() => {
|
|
if (eventSourceRef.current) {
|
|
eventSourceRef.current.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
clearActiveJob();
|
|
useFileStore.getState().updateEntry(capturedIndex, {
|
|
status: "failed",
|
|
error: "Processing timed out",
|
|
});
|
|
setError(
|
|
"Processing timed out with no progress for 5 minutes. Try again or use a smaller file.",
|
|
);
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
}, SSE_STALL_TIMEOUT_MS);
|
|
};
|
|
|
|
// Open SSE for real-time progress from the server (all tools)
|
|
try {
|
|
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
|
eventSourceRef.current = es;
|
|
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "heartbeat") {
|
|
if (asyncMode) resetStallTimer();
|
|
return;
|
|
}
|
|
if (data.type !== "single") return;
|
|
|
|
if (asyncMode) resetStallTimer();
|
|
|
|
// AI tools deliver results via SSE (they return 202 from the XHR)
|
|
if (data.phase === "complete" && data.result) {
|
|
clearStallTimer();
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
es.close();
|
|
eventSourceRef.current = null;
|
|
clearActiveJob();
|
|
|
|
const result = data.result as ProcessResult;
|
|
setWarning(result.warning ?? null);
|
|
setResultPayload(result as unknown as Record<string, unknown>);
|
|
useFileStore.getState().updateEntry(capturedIndex, {
|
|
processedUrl: result.downloadUrl,
|
|
processedPreviewUrl: result.previewUrl ?? null,
|
|
processedFilename: null,
|
|
status: "completed",
|
|
originalSize: result.originalSize,
|
|
processedSize: result.processedSize,
|
|
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
|
|
});
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
return;
|
|
}
|
|
|
|
if (data.phase === "failed" && asyncMode) {
|
|
clearStallTimer();
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
es.close();
|
|
eventSourceRef.current = null;
|
|
clearActiveJob();
|
|
setError(data.error || "Processing failed");
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
return;
|
|
}
|
|
|
|
if (typeof data.percent === "number") {
|
|
const scaled = UPLOAD_WEIGHT + (data.percent / 100) * (100 - UPLOAD_WEIGHT);
|
|
setProgress((prev) => ({
|
|
...prev,
|
|
phase: "processing",
|
|
percent: Math.max(prev.percent, scaled),
|
|
stage: data.stage,
|
|
}));
|
|
}
|
|
} catch {
|
|
// Ignore malformed SSE
|
|
}
|
|
};
|
|
|
|
es.onerror = () => {
|
|
if (!asyncMode) {
|
|
es.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
};
|
|
} catch {
|
|
// EventSource creation failed -- proceed without SSE
|
|
}
|
|
|
|
// Build form data
|
|
const cleanSettings = { ...settings };
|
|
const bgImageFile = cleanSettings._bgImageFile as File | undefined;
|
|
delete cleanSettings._bgImageFile;
|
|
|
|
const formData = new FormData();
|
|
if (MULTI_FILE_TOOLS.has(toolId) && files.length > 1) {
|
|
for (const f of files) formData.append("file", f);
|
|
} else {
|
|
formData.append("file", files[capturedIndex] ?? files[0]);
|
|
}
|
|
formData.append("settings", JSON.stringify(cleanSettings));
|
|
if (bgImageFile) {
|
|
formData.append("backgroundImage", bgImageFile);
|
|
}
|
|
formData.append("clientJobId", clientJobId);
|
|
|
|
const capturedEntry = useFileStore.getState().entries[capturedIndex];
|
|
if (capturedEntry?.serverFileId) {
|
|
formData.append("fileId", capturedEntry.serverFileId);
|
|
}
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhrRef.current = xhr;
|
|
|
|
xhr.timeout = isAiTool || LONG_RUNNING_TOOLS.has(toolId) ? 600_000 : 120_000;
|
|
|
|
xhr.upload.onprogress = (event) => {
|
|
if (event.lengthComputable) {
|
|
const uploadPercent = (event.loaded / event.total) * UPLOAD_WEIGHT;
|
|
setProgress((prev) => {
|
|
if (prev.phase !== "uploading") return prev;
|
|
return { ...prev, percent: uploadPercent };
|
|
});
|
|
}
|
|
};
|
|
|
|
xhr.upload.onload = () => {
|
|
setProgress((prev) => ({
|
|
...prev,
|
|
phase: "processing",
|
|
percent: UPLOAD_WEIGHT,
|
|
stage: "Processing...",
|
|
}));
|
|
};
|
|
|
|
xhr.onload = () => {
|
|
if (xhr.status === 202) {
|
|
asyncMode = true;
|
|
asyncModeRef.current = true;
|
|
setActiveJob(clientJobId, cancelCurrentJob);
|
|
resetStallTimer();
|
|
return;
|
|
}
|
|
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
if (eventSourceRef.current) {
|
|
eventSourceRef.current.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
try {
|
|
const result: ProcessResult = JSON.parse(xhr.responseText);
|
|
setWarning(result.warning ?? null);
|
|
setResultPayload(result as unknown as Record<string, unknown>);
|
|
useFileStore.getState().updateEntry(capturedIndex, {
|
|
processedUrl: result.downloadUrl,
|
|
processedPreviewUrl: result.previewUrl ?? null,
|
|
processedFilename: null,
|
|
status: "completed",
|
|
originalSize: result.originalSize,
|
|
processedSize: result.processedSize,
|
|
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
|
|
});
|
|
} catch {
|
|
setError("Invalid response from server");
|
|
}
|
|
} else {
|
|
try {
|
|
const body = JSON.parse(xhr.responseText);
|
|
const parsed = parseApiError(body, xhr.status);
|
|
if (typeof parsed === "object" && parsed.type === "feature_not_installed") {
|
|
setError(
|
|
`${toolName} requires the "${parsed.featureName}" feature. Enable it in Settings → AI Features.`,
|
|
);
|
|
} else {
|
|
setError(parsed as string);
|
|
}
|
|
} catch {
|
|
setError(`Processing failed: ${xhr.status}`);
|
|
}
|
|
}
|
|
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
clearActiveJob();
|
|
};
|
|
|
|
xhr.onerror = () => {
|
|
clearStallTimer();
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
if (eventSourceRef.current) {
|
|
eventSourceRef.current.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
setError("Processing was interrupted. Retry when reconnected.");
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
clearActiveJob();
|
|
};
|
|
|
|
xhr.ontimeout = () => {
|
|
clearStallTimer();
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
if (eventSourceRef.current) {
|
|
eventSourceRef.current.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
setError("Request timed out - the server may be overloaded. Try again.");
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
clearActiveJob();
|
|
};
|
|
|
|
xhr.open("POST", apiToolPath(toolId));
|
|
formatHeaders().forEach((value, key) => {
|
|
xhr.setRequestHeader(key, value);
|
|
});
|
|
xhr.send(formData);
|
|
},
|
|
[
|
|
toolId,
|
|
isAiTool,
|
|
setProcessing,
|
|
setError,
|
|
setActiveJob,
|
|
clearActiveJob,
|
|
cancelCurrentJob,
|
|
toolName,
|
|
],
|
|
);
|
|
|
|
const processAllFiles = useCallback(
|
|
async (files: File[], settings: Record<string, unknown>) => {
|
|
if (files.length === 0) {
|
|
setError("No files selected");
|
|
return;
|
|
}
|
|
|
|
import("@/lib/analytics").then(({ track }) => {
|
|
track(ANALYTICS_EVENTS.TOOL_STARTED, {
|
|
tool_id: toolId,
|
|
is_batch: true,
|
|
file_count: files.length,
|
|
});
|
|
});
|
|
|
|
if (files.length === 1) {
|
|
processFiles(files, settings);
|
|
return;
|
|
}
|
|
|
|
// batch_processed fires once for the batch as a unit (distinct from the N
|
|
// per-file tool_used events), so batch usage is separable from single runs.
|
|
const trackBatch = (status: "completed" | "failed") =>
|
|
void import("@/lib/analytics").then(({ track }) =>
|
|
track(ANALYTICS_EVENTS.BATCH_PROCESSED, {
|
|
tool_id: toolId,
|
|
file_count: files.length,
|
|
status,
|
|
}),
|
|
);
|
|
|
|
const { updateEntry, setBatchZip } = useFileStore.getState();
|
|
|
|
setError(null);
|
|
setProcessing(true);
|
|
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
|
|
|
const startTime = Date.now();
|
|
elapsedRef.current = setInterval(() => {
|
|
setProgress((prev) => ({ ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) }));
|
|
}, 1000);
|
|
|
|
const clientJobId = generateId();
|
|
activeJobIdRef.current = clientJobId;
|
|
|
|
// Open SSE before upload for real-time progress
|
|
try {
|
|
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
|
eventSourceRef.current = es;
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === "batch") {
|
|
const pct =
|
|
data.totalFiles > 0 ? 15 + (data.completedFiles / data.totalFiles) * 85 : 15;
|
|
setProgress((prev) => ({
|
|
...prev,
|
|
phase: "processing",
|
|
percent: pct,
|
|
stage: data.currentFile
|
|
? `Processing ${data.currentFile} (${data.completedFiles}/${data.totalFiles})`
|
|
: `Processing ${data.completedFiles}/${data.totalFiles}`,
|
|
}));
|
|
}
|
|
} catch {
|
|
/* ignore malformed SSE */
|
|
}
|
|
};
|
|
es.onerror = () => {
|
|
es.close();
|
|
eventSourceRef.current = null;
|
|
};
|
|
} catch {
|
|
/* SSE failed, proceed without */
|
|
}
|
|
|
|
const formData = new FormData();
|
|
for (const file of files) formData.append("file", file);
|
|
formData.append("settings", JSON.stringify(settings));
|
|
formData.append("clientJobId", clientJobId);
|
|
|
|
try {
|
|
abortRef.current = new AbortController();
|
|
const response = await fetch(`${apiToolPath(toolId)}/batch`, {
|
|
method: "POST",
|
|
headers: formatHeaders(),
|
|
body: formData,
|
|
signal: abortRef.current.signal,
|
|
});
|
|
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
if (eventSourceRef.current) {
|
|
eventSourceRef.current.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
let errorMsg: string;
|
|
try {
|
|
const body = JSON.parse(text);
|
|
const parsed = parseApiError(body, response.status);
|
|
if (typeof parsed === "object" && parsed.type === "feature_not_installed") {
|
|
errorMsg = `${toolName} requires the "${parsed.featureName}" feature. Enable it in Settings → AI Features.`;
|
|
} else {
|
|
errorMsg = parsed as string;
|
|
}
|
|
} catch {
|
|
errorMsg = `Batch processing failed: ${response.status}`;
|
|
}
|
|
setError(errorMsg);
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
trackBatch("failed");
|
|
return;
|
|
}
|
|
|
|
const zipBlob = await response.blob();
|
|
setBatchZip(zipBlob, `batch-${toolId}.zip`);
|
|
|
|
// Extract files from ZIP using fflate
|
|
const { unzipSync } = await import("fflate");
|
|
const zipBuffer = new Uint8Array((await zipBlob.arrayBuffer()) as ArrayBuffer);
|
|
const extracted = unzipSync(zipBuffer);
|
|
|
|
const entries = useFileStore.getState().entries;
|
|
let fileResults: Record<string, string> = {};
|
|
try {
|
|
fileResults = JSON.parse(
|
|
decodeURIComponent(response.headers.get("X-File-Results") ?? "%7B%7D"),
|
|
);
|
|
} catch {
|
|
// Malformed header - fall back to empty mapping, all entries marked failed
|
|
}
|
|
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const processedName = fileResults[String(i)];
|
|
if (processedName && extracted[processedName]) {
|
|
const ext = processedName.split(".").pop()?.toLowerCase() ?? "";
|
|
const blobType = MIME_BY_EXT[ext];
|
|
const blob = new Blob(
|
|
[extracted[processedName] as BlobPart],
|
|
blobType ? { type: blobType } : undefined,
|
|
);
|
|
updateEntry(i, {
|
|
processedUrl: URL.createObjectURL(blob),
|
|
processedFilename: processedName,
|
|
processedSize: blob.size,
|
|
status: "completed",
|
|
error: null,
|
|
});
|
|
} else {
|
|
updateEntry(i, { status: "failed", error: "File not found in batch results" });
|
|
}
|
|
}
|
|
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
clearActiveJob();
|
|
trackBatch("completed");
|
|
} catch (err) {
|
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
if (eventSourceRef.current) {
|
|
eventSourceRef.current.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
setError(err instanceof Error ? err.message : "Batch processing failed");
|
|
setProcessing(false);
|
|
setProgress(IDLE_PROGRESS);
|
|
clearActiveJob();
|
|
trackBatch("failed");
|
|
}
|
|
},
|
|
[toolId, processFiles, setProcessing, setError, clearActiveJob, toolName],
|
|
);
|
|
|
|
return {
|
|
processFiles,
|
|
processAllFiles,
|
|
cancelCurrentJob,
|
|
processing,
|
|
error: error === "Canceled" ? t.tools.processing.canceled : error,
|
|
warning,
|
|
downloadUrl: processedUrl,
|
|
originalSize,
|
|
processedSize,
|
|
progress,
|
|
resultPayload,
|
|
};
|
|
}
|