Ctrl K
ComunidadGuías…

frontend/src/hooks/useMultiFileProcessor.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * useMultiFileProcessor — queue-and-batch hook for tools that accept N PDFs.
 *
 * Design notes:
 *
 *   - Most PDF tool endpoints take a single `file=` field. So we don't try to
 *     coerce the backend; we just loop client-side, one request per file, and
 *     accumulate the result blobs.
 *
 *   - Concurrency is bounded (default 3). PDF endpoints are CPU-bound on the
 *     server (PyMuPDF rendering, image rasterisation). Three in flight lines
 *     up nicely with a typical 4-core worker without head-of-line blocking.
 *
 *   - Per-file status is tracked in a parallel array — UI components render
 *     "queued / running / done / failed" badges off it.
 *
 *   - Partial-failure retry: rerun only files whose status is "failed", or
 *     with `run(opts, "transient")` only those that failed for a reason that
 *     can pass (connection, time limit, rate limit, server fault). A file the
 *     tool refused would fail the same way again. Successful blobs stay intact.
 *
 *   - A file the caller's filter refuses is never dropped silently: addFiles
 *     returns it and, unless told otherwise, says so in a toast that names the
 *     file and the tool that takes it.
 *
 *   - When N=1 the caller can opt to download the raw blob directly (no zip).
 *     We expose the blobs as `results`; the helper `downloadAll()` does the
 *     "N=1 → blob, N>1 → zip" branching so most call sites stay tiny.
 */
import { useCallback, useRef, useState } from "react";
import { uploadFile, downloadBlob, buildOutputFilename, chooseDownloadFilename, hasUserMessage, type UploadOptions } from "@/lib/api";
import { buildZip } from "@/lib/zip";
import { friendlyError } from "@/lib/utils";
import { emitToolRun, isTransientFailure, runOutcome, toolErrorKind, type ToolErrorKind } from "@/lib/toolRun";
import { reportRejectedFiles } from "@/lib/report-rejected-files";

export type FileStatus = "queued" | "running" | "done" | "failed";

export interface FileEntry {
    id: string;
    file: File;
    name: string;
    size: number;
    status: FileStatus;
    error?: string;
    /** Why the file failed, in the fixed categories of lib/toolRun.ts. */
    errorKind?: ToolErrorKind;
    /** A failure trying again could fix: connection, time limit, rate limit
     *  or a server fault. False when the tool refused the file itself. */
    retryable?: boolean;
    /** Server-provided filename from Content-Disposition, if any. */
    outName?: string;
    /** Set once the upload succeeds. */
    blob?: Blob;
    /** Custom response headers we surface so tools like Highlight can read
     *  per-file metadata (e.g. X-Highlight-Hits). */
    headers?: Record<string, string>;
}

export interface ProcessOptions {
    /** Backend endpoint. Will be prefixed with /api/. */
    endpoint: string;
    /** Form-data params sent with every file. */
    params?: Record<string, string | number | boolean>;
    /** Output extension for the per-file download name (e.g. "pdf", "docx"). */
    outputExt: string;
    /** Suffix added to the source filename stem (e.g. "compressed"). */
    outputSuffix: string | null;
    /** Concurrency cap. Default 3. */
    concurrency?: number;
    /** Per-upload options (timeout, retries) passed through to uploadFile. */
    uploadOptions?: UploadOptions;
    /** Client-side processor — when set, files never leave the browser:
     *  the worker calls this instead of POSTing to `endpoint`. */
    localProcess?: (file: File) => Promise<LocalResult>;
}

export interface LocalResult {
    blob: Blob;
    outName?: string;
    /** Set when the processor handled the file but could not do its job on it
     *  (an image with no watermark to remove, say) and returned it unchanged.
     *  The file still counts as done on the page; the run's usage signal
     *  counts it as a failure of this kind, so such misses stay visible. */
    unchanged?: ToolErrorKind;
}

export interface UseMultiFileProcessorResult {
    entries: FileEntry[];
    /** True while any file is still queued or running. */
    busy: boolean;
    /** True once all entries have a terminal status (done or failed). */
    finished: boolean;
    /** Count of entries with status === "done". */
    doneCount: number;
    /** Count of entries with status === "failed". */
    failedCount: number;
    /** Count of failed entries that trying again could fix. */
    retryableCount: number;
    /** Queue the files the filter accepts and return the ones it refused.
     *  Refused files are reported in a toast unless `report` is false, for
     *  callers that show them beside their own intake. */
    addFiles: (files: FileList | File[], filter?: (f: File) => boolean, options?: { report?: boolean }) => File[];
    removeFile: (id: string) => void;
    clearAll: () => void;
    /** Reorder by moving the entry at `from` to `to`. */
    reorder: (from: number, to: number) => void;
    /** Start processing all queued + failed files; `true` retries only the
     *  failed ones, `"transient"` only those that trying again could fix.
     *  Resolves with the entries this run took, as it left them. Read the
     *  outcome from that, not from `entries` or the counts: a callback made in
     *  an earlier render still holds that render's values after the await. */
    run: (opts: ProcessOptions, retryOnly?: boolean | "transient") => Promise<FileEntry[]>;
    /** Trigger browser download. Zips if N>1, downloads single blob if N=1. */
    downloadAll: (archiveBaseName: string) => void;
    /** Reset everything back to empty. */
    reset: () => void;
}

let counter = 0;

function makeEntry(file: File): FileEntry {
    return {
        id: `${Date.now().toString(36)}-${++counter}`,
        file,
        name: file.name,
        size: file.size,
        status: "queued",
    };
}

export function useMultiFileProcessor(): UseMultiFileProcessorResult {
    const [entries, setEntries] = useState<FileEntry[]>([]);
    // React defers state-updater callbacks, so code that "reads" state by
    // passing through an updater sees nothing until the next render — which
    // silently emptied run()'s work list. The ref mirror is written
    // synchronously on every mutation and is the source of truth for all
    // imperative reads; setEntries only feeds the render.
    const entriesRef = useRef<FileEntry[]>([]);
    const mutate = useCallback((updater: (prev: FileEntry[]) => FileEntry[]) => {
        entriesRef.current = updater(entriesRef.current);
        setEntries(entriesRef.current);
    }, []);
    // The `inFlight` ref lets the caller call `run()` again without races —
    // we just refuse to start a second pass while one is going.
    const inFlight = useRef(false);

    const addFiles = useCallback((fl: FileList | File[], filter?: (f: File) => boolean, options?: { report?: boolean }) => {
        const arr = Array.from(fl);
        const accepted = filter ? arr.filter(filter) : arr;
        const rejected = filter ? arr.filter(file => !accepted.includes(file)) : [];
        if (rejected.length && options?.report !== false) reportRejectedFiles(rejected);
        if (accepted.length) mutate(prev => [...prev, ...accepted.map(makeEntry)]);
        return rejected;
    }, [mutate]);

    const removeFile = useCallback((id: string) => {
        mutate(prev => prev.filter(e => e.id !== id));
    }, [mutate]);

    const clearAll = useCallback(() => mutate(() => []), [mutate]);

    const reorder = useCallback((from: number, to: number) => {
        mutate(prev => {
            if (from === to || from < 0 || to < 0 || from >= prev.length || to >= prev.length) return prev;
            const next = [...prev];
            const [moved] = next.splice(from, 1);
            next.splice(to, 0, moved);
            return next;
        });
    }, [mutate]);

    const reset = useCallback(() => {
        inFlight.current = false;
        mutate(() => []);
    }, [mutate]);

    const run = useCallback(async (opts: ProcessOptions, retryOnly: boolean | "transient" = false): Promise<FileEntry[]> => {
        if (inFlight.current) return [];
        inFlight.current = true;

        // Snapshot from the ref — synchronous and stale-closure-free.
        const targetIds = entriesRef.current
            .filter(e => retryOnly === "transient" ? e.status === "failed" && e.retryable
                : retryOnly ? e.status === "failed" : (e.status === "queued" || e.status === "failed"))
            .map(e => e.id);
        // Mark them as queued (clears prior error states for retry path).
        mutate(prev => prev.map(e => targetIds.includes(e.id) ? { ...e, status: "queued", error: undefined, errorKind: undefined, retryable: undefined } : e));

        // Tiny semaphore — N workers pull from a shared cursor.
        const concurrency = Math.max(1, opts.concurrency ?? 3);
        let cursor = 0;
        // The first failure names the run's error category in the usage signal.
        let firstFailure: unknown = null;
        // Files a local processor returned unchanged, with why (LocalResult.unchanged).
        const unchanged = new Map<string, ToolErrorKind>();
        const ids = targetIds; // captured

        const worker = async () => {
            while (true) {
                const idx = cursor++;
                if (idx >= ids.length) return;
                const id = ids[idx];

                // Grab the file from the ref (it might have been removed since)
                const entry = entriesRef.current.find(x => x.id === id);
                const file: File | null = entry?.file ?? null;
                if (!file) continue;
                mutate(prev => prev.map(x => x.id === id ? { ...x, status: "running" } : x));

                try {
                    if (opts.localProcess) {
                        const out = await opts.localProcess(file as File);
                        if (out.unchanged) unchanged.set(id, out.unchanged);
                        const outName = out.outName || buildOutputFilename((file as File).name, opts.outputSuffix, opts.outputExt);
                        mutate(prev => prev.map(x => x.id === id
                            ? { ...x, status: "done", blob: out.blob, outName, headers: {} }
                            : x,
                        ));
                        continue;
                    }
                    const res = await uploadFile(opts.endpoint, file as File, opts.params, opts.uploadOptions);
                    const blob = await res.blob();
                    // Pull server-supplied filename if present; otherwise build one.
                    const cd = res.headers.get("Content-Disposition") || "";
                    let serverName: string | null = null;
                    const utf8 = cd.match(/filename\*=UTF-8''([^;]+)/i);
                    if (utf8?.[1]) {
                        try { serverName = decodeURIComponent(utf8[1].trim()); } catch { serverName = utf8[1].trim(); }
                    } else {
                        const ascii = cd.match(/filename=(["']?)([^"';]+)\1/i);
                        if (ascii?.[2]) serverName = ascii[2].trim();
                    }
                    // As every other download: a generic server name such as
                    // "converted.docx" does not replace the file's own.
                    const outName = chooseDownloadFilename(
                        buildOutputFilename((file as File).name, opts.outputSuffix, opts.outputExt), serverName);

                    // Capture all response headers — small cost, lets callers
                    // read tool-specific metadata (e.g. X-Highlight-Hits) without
                    // a second round trip.
                    const headers: Record<string, string> = {};
                    res.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });

                    mutate(prev => prev.map(x => x.id === id
                        ? { ...x, status: "done", blob, outName, headers }
                        : x,
                    ));
                } catch (e: unknown) {
                    firstFailure ??= e;
                    const raw = e instanceof Error ? e.message : "Failed";
                    const msg = hasUserMessage(e) ? raw : friendlyError(raw, "Processing failed");
                    const kind = toolErrorKind(e);
                    // A cancelled file can simply run again.
                    const retryable = kind === "cancelled" || isTransientFailure(e);
                    mutate(prev => prev.map(x => x.id === id
                        ? { ...x, status: "failed", error: msg, errorKind: kind === "cancelled" ? undefined : kind, retryable }
                        : x,
                    ));
                }
            }
        };

        const workers: Promise<void>[] = [];
        for (let i = 0; i < Math.min(concurrency, ids.length); i++) workers.push(worker());
        await Promise.all(workers);

        // One usage signal per run, counting only the files this run touched.
        // A file returned unchanged counts as a failure there, never as a success.
        const touched = entriesRef.current.filter(e => ids.includes(e.id));
        const done = touched.filter(e => e.status === "done").length;
        const failed = touched.filter(e => e.status === "failed").length;
        const missed = touched.filter(e => e.status === "done" && unchanged.has(e.id));
        const outcome = runOutcome(done - missed.length, failed + missed.length);
        const missKind = firstFailure === null && missed.length ? unchanged.get(missed[0].id) : undefined;
        if (outcome) emitToolRun({ mode: "single", outcome, files: done + failed, ...(missKind ? { errorKind: missKind } : {}) }, firstFailure);

        inFlight.current = false;
        return touched;
    }, [mutate]);

    const downloadAll = useCallback((archiveBaseName: string) => {
        const done = entriesRef.current.filter(e => e.status === "done" && e.blob);
        if (done.length === 0) return;
        if (done.length === 1) {
            const e = done[0];
            downloadBlob(e.blob!, e.outName || e.name);
            return;
        }
        // N>1 → zip them
        const buildAndDownload = async () => {
            const items = await Promise.all(done.map(async e => ({
                name: e.outName || e.name,
                data: new Uint8Array(await e.blob!.arrayBuffer()),
            })));
            const zipBlob = buildZip(items);
            downloadBlob(zipBlob, archiveBaseName.endsWith(".zip") ? archiveBaseName : `${archiveBaseName}.zip`);
        };
        void buildAndDownload();
    }, []);

    // Derive aggregate counts. Cheap to recompute every render.
    const doneCount = entries.filter(e => e.status === "done").length;
    const failedCount = entries.filter(e => e.status === "failed").length;
    const retryableCount = entries.filter(e => e.status === "failed" && e.retryable).length;
    const busy = entries.some(e => e.status === "queued" || e.status === "running") && inFlight.current;
    const finished = entries.length > 0 && entries.every(e => e.status === "done" || e.status === "failed");

    return {
        entries,
        busy,
        finished,
        doneCount,
        failedCount,
        retryableCount,
        addFiles,
        removeFile,
        clearAll,
        reorder,
        run,
        downloadAll,
        reset,
    };
}