Spaces:
Paused
Paused
| import { | |
| createContext, | |
| useCallback, | |
| useContext, | |
| useEffect, | |
| useMemo, | |
| useReducer, | |
| useRef, | |
| type ReactNode, | |
| } from 'react'; | |
| import { MAX_CONCURRENT_UPLOADS, isTerminal, type UploadTask } from './uploadTypes'; | |
| import { knowledgeBaseApi } from '@/api'; | |
| import type { KnowledgeDocumentStatus } from '@/api'; | |
| /** | |
| * Public contract the surrounding app sees. Exposed via the React | |
| * context value. | |
| */ | |
| interface UploadContextValue { | |
| tasks: UploadTask[]; | |
| /** Enqueue one or more files against a knowledge base. */ | |
| enqueue: (knowledgeBaseId: string, files: File[]) => UploadTask[]; | |
| /** | |
| * Abort an upload task. Effects depend on the current phase: | |
| * | |
| * - `queued` — removed from the queue. | |
| * - `uploading` — XHR is aborted, then the task transitions to | |
| * `cancelled`. | |
| * - server-side phases — no-op (the worker has already taken over; | |
| * route through the regular document-delete flow instead). | |
| * - terminal phases — equivalent to `dismiss`. | |
| */ | |
| cancel: (taskId: string) => void; | |
| /** Drop a terminal task from the list (UI dismissal). */ | |
| dismiss: (taskId: string) => void; | |
| /** Drop every terminal task for a knowledge base. */ | |
| clearFinishedForKb: (knowledgeBaseId: string) => void; | |
| /** | |
| * Filtered tasks for a knowledge base id. Cached by reference per | |
| * (knowledgeBaseId, tasks-list-identity) so consumers can pass it | |
| * straight into React dependency arrays. | |
| */ | |
| tasksForKb: (knowledgeBaseId: string) => UploadTask[]; | |
| /** | |
| * Apply server-side status snapshots from the polling hook. | |
| * Lookup is keyed by `documentId`; tasks without a documentId | |
| * (still uploading) are skipped. | |
| */ | |
| applyServerStatuses: ( | |
| knowledgeBaseId: string, | |
| items: Array<{ | |
| id: string; | |
| status: KnowledgeDocumentStatus; | |
| error: string | null; | |
| }>, | |
| ) => void; | |
| /** | |
| * Document ids the consumer should be polling. Filtered to tasks | |
| * that have a documentId AND a non-terminal server-side phase. | |
| */ | |
| pollableDocumentIds: (knowledgeBaseId: string) => string[]; | |
| } | |
| const UploadContext = createContext<UploadContextValue | null>(null); | |
| // ───────── Reducer ──────────────────────────────────────────────────── | |
| type Action = | |
| | { type: 'ADD'; tasks: UploadTask[] } | |
| | { type: 'START_UPLOAD'; taskId: string } | |
| | { type: 'PROGRESS'; taskId: string; loaded: number; total: number } | |
| | { | |
| type: 'UPLOAD_DONE'; | |
| taskId: string; | |
| documentId: string; | |
| status: KnowledgeDocumentStatus; | |
| } | |
| | { type: 'UPLOAD_FAILED'; taskId: string; error: string } | |
| | { type: 'CANCEL'; taskId: string } | |
| | { type: 'REMOVE'; taskId: string } | |
| | { | |
| type: 'SERVER_STATUS'; | |
| knowledgeBaseId: string; | |
| items: Array<{ | |
| id: string; | |
| status: KnowledgeDocumentStatus; | |
| error: string | null; | |
| }>; | |
| } | |
| | { type: 'CLEAR_FINISHED'; knowledgeBaseId: string }; | |
| function reducer(state: UploadTask[], action: Action): UploadTask[] { | |
| switch (action.type) { | |
| case 'ADD': | |
| return [...state, ...action.tasks]; | |
| case 'START_UPLOAD': | |
| return state.map((t) => | |
| t.taskId === action.taskId ? { ...t, phase: 'uploading', loaded: 0 } : t, | |
| ); | |
| case 'PROGRESS': | |
| return state.map((t) => | |
| t.taskId === action.taskId | |
| ? { | |
| ...t, | |
| loaded: action.loaded, | |
| // Allow upload progress to refine an unknown size | |
| // (browser couldn't compute it at enqueue time). | |
| size: t.size || action.total, | |
| } | |
| : t, | |
| ); | |
| case 'UPLOAD_DONE': | |
| return state.map((t) => | |
| t.taskId === action.taskId | |
| ? { | |
| ...t, | |
| documentId: action.documentId, | |
| phase: action.status, | |
| loaded: t.size, | |
| } | |
| : t, | |
| ); | |
| case 'UPLOAD_FAILED': | |
| return state.map((t) => | |
| t.taskId === action.taskId ? { ...t, phase: 'error', error: action.error } : t, | |
| ); | |
| case 'CANCEL': | |
| return state.map((t) => | |
| t.taskId === action.taskId ? { ...t, phase: 'cancelled' } : t, | |
| ); | |
| case 'REMOVE': | |
| return state.filter((t) => t.taskId !== action.taskId); | |
| case 'SERVER_STATUS': { | |
| const byId = new Map(action.items.map((i) => [i.id, i])); | |
| return state.map((t) => { | |
| if (t.knowledgeBaseId !== action.knowledgeBaseId) return t; | |
| if (!t.documentId) return t; | |
| const match = byId.get(t.documentId); | |
| if (!match) return t; | |
| // Client-side terminal phases (`cancelled`) win — the | |
| // server might still drive the doc to `ready`, but the | |
| // user has signalled they no longer care. | |
| if (t.phase === 'cancelled') return t; | |
| if (t.phase === match.status && t.error === match.error) { | |
| return t; | |
| } | |
| return { | |
| ...t, | |
| phase: match.status, | |
| error: match.error, | |
| }; | |
| }); | |
| } | |
| case 'CLEAR_FINISHED': | |
| return state.filter( | |
| (t) => t.knowledgeBaseId !== action.knowledgeBaseId || !isTerminal(t.phase), | |
| ); | |
| } | |
| } | |
| function newTaskId(): string { | |
| if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { | |
| return crypto.randomUUID(); | |
| } | |
| return `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | |
| } | |
| interface UploadProviderProps { | |
| children: ReactNode; | |
| } | |
| /** | |
| * Mutable side-state for XHR controllers and scheduling flags. | |
| * | |
| * Lazy-initialised inside `useRef` (the factory form) so we never read | |
| * `.current` during the initial render — and because the structure is | |
| * a fixed shape of mutable maps, we can stash everything in a single | |
| * `useRef` without violating the "no reading refs during render" rule: | |
| * the reducer is the source of truth for render-relevant state, and | |
| * these maps hold only opaque side-effects (Files, AbortControllers). | |
| */ | |
| interface UploadProviderRefs { | |
| /** Map of taskId → File payload (kept off state; not serialisable). */ | |
| files: Map<string, File>; | |
| /** Map of taskId → AbortController to cancel the in-flight XHR. */ | |
| controllers: Map<string, AbortController>; | |
| /** Tasks the scheduler has already kicked off this lifetime. */ | |
| started: Set<string>; | |
| } | |
| export function UploadProvider({ children }: UploadProviderProps) { | |
| const [tasks, dispatch] = useReducer(reducer, [] as UploadTask[]); | |
| const refsRef = useRef<UploadProviderRefs | null>(null); | |
| if (refsRef.current === null) { | |
| refsRef.current = { | |
| files: new Map(), | |
| controllers: new Map(), | |
| started: new Set(), | |
| }; | |
| } | |
| const startUpload = useCallback((task: UploadTask) => { | |
| const refs = refsRef.current!; | |
| const file = refs.files.get(task.taskId); | |
| if (!file) { | |
| dispatch({ | |
| type: 'UPLOAD_FAILED', | |
| taskId: task.taskId, | |
| error: 'Internal error: missing file payload.', | |
| }); | |
| return; | |
| } | |
| const controller = new AbortController(); | |
| refs.controllers.set(task.taskId, controller); | |
| dispatch({ type: 'START_UPLOAD', taskId: task.taskId }); | |
| knowledgeBaseApi | |
| .uploadDocument(task.knowledgeBaseId, file, { | |
| signal: controller.signal, | |
| onProgress: ({ loaded, total }) => { | |
| dispatch({ | |
| type: 'PROGRESS', | |
| taskId: task.taskId, | |
| loaded, | |
| total, | |
| }); | |
| }, | |
| }) | |
| .then((response) => { | |
| dispatch({ | |
| type: 'UPLOAD_DONE', | |
| taskId: task.taskId, | |
| documentId: response.document_id, | |
| status: response.status, | |
| }); | |
| }) | |
| .catch((err: unknown) => { | |
| if (err instanceof DOMException && err.name === 'AbortError') { | |
| // Already handled by the cancel() path. | |
| return; | |
| } | |
| const message = err instanceof Error ? err.message : 'Upload failed.'; | |
| dispatch({ | |
| type: 'UPLOAD_FAILED', | |
| taskId: task.taskId, | |
| error: message, | |
| }); | |
| }) | |
| .finally(() => { | |
| refs.controllers.delete(task.taskId); | |
| refs.files.delete(task.taskId); | |
| }); | |
| }, []); | |
| // Scheduler — fires after every state change. Idempotent thanks to | |
| // `refs.started`, so StrictMode's double-effect is harmless. | |
| useEffect(() => { | |
| const refs = refsRef.current!; | |
| const running = tasks.filter((t) => t.phase === 'uploading').length; | |
| const queued = tasks.filter((t) => t.phase === 'queued'); | |
| const slots = MAX_CONCURRENT_UPLOADS - running; | |
| if (slots <= 0 || queued.length === 0) return; | |
| for (const task of queued.slice(0, slots)) { | |
| if (refs.started.has(task.taskId)) continue; | |
| refs.started.add(task.taskId); | |
| startUpload(task); | |
| } | |
| }, [tasks, startUpload]); | |
| const enqueue = useCallback((knowledgeBaseId: string, files: File[]): UploadTask[] => { | |
| const refs = refsRef.current!; | |
| const now = Date.now(); | |
| const newTasks = files.map((file): UploadTask => { | |
| const taskId = newTaskId(); | |
| refs.files.set(taskId, file); | |
| return { | |
| taskId, | |
| knowledgeBaseId, | |
| filename: file.name, | |
| size: file.size, | |
| documentId: null, | |
| phase: 'queued', | |
| loaded: 0, | |
| error: null, | |
| createdAt: now, | |
| }; | |
| }); | |
| dispatch({ type: 'ADD', tasks: newTasks }); | |
| return newTasks; | |
| }, []); | |
| const cancel = useCallback( | |
| (taskId: string) => { | |
| const refs = refsRef.current!; | |
| const task = tasks.find((t) => t.taskId === taskId); | |
| if (!task) return; | |
| if (task.phase === 'queued') { | |
| refs.files.delete(taskId); | |
| dispatch({ type: 'REMOVE', taskId }); | |
| return; | |
| } | |
| if (task.phase === 'uploading') { | |
| refs.controllers.get(taskId)?.abort(); | |
| dispatch({ type: 'CANCEL', taskId }); | |
| return; | |
| } | |
| if (isTerminal(task.phase)) { | |
| dispatch({ type: 'REMOVE', taskId }); | |
| } | |
| // Server-side phases are not cancellable here; the caller | |
| // should use the regular document-delete API instead. | |
| }, | |
| [tasks], | |
| ); | |
| const dismiss = useCallback((taskId: string) => { | |
| dispatch({ type: 'REMOVE', taskId }); | |
| }, []); | |
| const clearFinishedForKb = useCallback((knowledgeBaseId: string) => { | |
| dispatch({ type: 'CLEAR_FINISHED', knowledgeBaseId }); | |
| }, []); | |
| const applyServerStatuses = useCallback<UploadContextValue['applyServerStatuses']>( | |
| (knowledgeBaseId, items) => { | |
| dispatch({ type: 'SERVER_STATUS', knowledgeBaseId, items }); | |
| }, | |
| [], | |
| ); | |
| // `tasksForKb` and `pollableDocumentIds` need stable references per | |
| // (knowledgeBaseId, tasks) tuple so downstream effects don't churn. | |
| const byKb = useMemo(() => { | |
| const out = new Map<string, UploadTask[]>(); | |
| for (const t of tasks) { | |
| const list = out.get(t.knowledgeBaseId); | |
| if (list) list.push(t); | |
| else out.set(t.knowledgeBaseId, [t]); | |
| } | |
| return out; | |
| }, [tasks]); | |
| const tasksForKb = useCallback( | |
| (knowledgeBaseId: string): UploadTask[] => byKb.get(knowledgeBaseId) ?? [], | |
| [byKb], | |
| ); | |
| const pollableDocumentIds = useCallback( | |
| (knowledgeBaseId: string): string[] => { | |
| const list = byKb.get(knowledgeBaseId); | |
| if (!list) return []; | |
| const out: string[] = []; | |
| for (const t of list) { | |
| if (!t.documentId) continue; | |
| // `uploading` / `queued` have no documentId yet; only | |
| // server-side non-terminal phases are pollable. | |
| if ( | |
| t.phase === 'pending' || | |
| t.phase === 'parsing' || | |
| t.phase === 'chunking' || | |
| t.phase === 'indexing' | |
| ) { | |
| out.push(t.documentId); | |
| } | |
| } | |
| return out; | |
| }, | |
| [byKb], | |
| ); | |
| // Warn before navigation when in-flight tasks would be lost. | |
| // `uploading` tasks die with the page; server-side phases survive, | |
| // so they do not trigger the warning. | |
| useEffect(() => { | |
| const hasInFlightUpload = tasks.some( | |
| (t) => t.phase === 'queued' || t.phase === 'uploading', | |
| ); | |
| if (!hasInFlightUpload) return; | |
| const handler = (e: BeforeUnloadEvent) => { | |
| e.preventDefault(); | |
| // Chrome / Edge require returnValue to be set. | |
| e.returnValue = ''; | |
| }; | |
| window.addEventListener('beforeunload', handler); | |
| return () => window.removeEventListener('beforeunload', handler); | |
| }, [tasks]); | |
| const value = useMemo<UploadContextValue>( | |
| () => ({ | |
| tasks, | |
| enqueue, | |
| cancel, | |
| dismiss, | |
| clearFinishedForKb, | |
| tasksForKb, | |
| applyServerStatuses, | |
| pollableDocumentIds, | |
| }), | |
| [ | |
| tasks, | |
| enqueue, | |
| cancel, | |
| dismiss, | |
| clearFinishedForKb, | |
| tasksForKb, | |
| applyServerStatuses, | |
| pollableDocumentIds, | |
| ], | |
| ); | |
| return <UploadContext.Provider value={value}>{children}</UploadContext.Provider>; | |
| } | |
| export function useUploadContext(): UploadContextValue { | |
| const ctx = useContext(UploadContext); | |
| if (!ctx) { | |
| throw new Error('useUploadContext must be used inside <UploadProvider>'); | |
| } | |
| return ctx; | |
| } | |