/** * IngestProgress - TUI component for batch PDF/Markdown ingestion * * Displays real-time progress for: * - File discovery * - Chunking * - Embedding generation * - Overall progress */ import * as React from "react"; import { render, Box, Text, useApp, useInput } from "ink"; import Spinner from "ink-spinner"; /** Status of a single file being processed */ export interface FileStatus { path: string; filename: string; status: "pending" | "chunking" | "embedding" | "done" | "error"; chunks?: number; error?: string; } /** Overall ingest progress state */ export interface IngestState { phase: "discovering" | "processing" | "done" | "error"; totalFiles: number; processedFiles: number; currentFile?: FileStatus; recentFiles: FileStatus[]; errors: FileStatus[]; startTime: number; endTime?: number; checkpointInProgress?: boolean; checkpointMessage?: string; lastCheckpointAt?: number; } /** Props for the IngestProgress component */ interface IngestProgressProps { state: IngestState; onCancel?: () => void; } /** * Format duration in human-readable form */ function formatDuration(ms: number): string { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (hours > 0) { return `${hours}h ${minutes % 60}m ${seconds % 60}s`; } else if (minutes > 0) { return `${minutes}m ${seconds % 60}s`; } else { return `${seconds}s`; } } /** * Calculate ETA based on current progress */ function calculateETA(state: IngestState): string { if (state.processedFiles === 0) return "calculating..."; const elapsed = Date.now() - state.startTime; const avgTimePerFile = elapsed / state.processedFiles; const remaining = state.totalFiles - state.processedFiles; const etaMs = avgTimePerFile * remaining; return formatDuration(etaMs); } /** * Progress bar component */ function ProgressBar({ percent, width = 40, }: { percent: number; width?: number; }) { const filled = Math.round((percent / 100) * width); const empty = width - filled; return ( {"█".repeat(filled)} {"░".repeat(empty)} {percent.toFixed(1)}% ); } /** * Status icon based on file status */ function StatusIcon({ status }: { status: FileStatus["status"] }) { switch (status) { case "pending": return ; case "chunking": return ( ); case "embedding": return ( ); case "done": return ; case "error": return ; } } /** * Main IngestProgress TUI component */ export function IngestProgress({ state, onCancel }: IngestProgressProps) { const { exit } = useApp(); // Handle keyboard input useInput((input, key) => { if (input === "q" || (key.ctrl && input === "c")) { onCancel?.(); exit(); } }); const percent = state.totalFiles > 0 ? (state.processedFiles / state.totalFiles) * 100 : 0; const elapsed = formatDuration(Date.now() - state.startTime); const eta = state.phase === "done" ? "-" : calculateETA(state); return ( {/* Header */} ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ PDF Brain - Batch Ingest ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ {/* Phase indicator */} Phase:{" "} {state.phase === "discovering" && ( Discovering files... )} {state.phase === "processing" && ( Processing files )} {state.phase === "done" && Complete!} {state.phase === "error" && Error} {/* Progress bar */} Progress: {/* Stats */} Files: {state.processedFiles} / {state.totalFiles} | Elapsed: {elapsed} | ETA: {eta} {/* Current file */} {state.currentFile && ( Current: {" "} {state.currentFile.filename} {state.currentFile.status === "chunking" && ( (chunking...) )} {state.currentFile.status === "embedding" && ( (embedding...) )} {state.currentFile.chunks && ( ({state.currentFile.chunks} chunks) )} )} {/* Checkpoint indicator */} {state.checkpointInProgress && state.checkpointMessage && ( {state.checkpointMessage} )} {state.lastCheckpointAt && !state.checkpointInProgress && ( Last checkpoint: {state.lastCheckpointAt} docs )} {/* Recent files */} {state.recentFiles.length > 0 && ( Recent: {state.recentFiles.slice(-5).map((file) => ( {file.filename} {file.chunks && ({file.chunks} chunks)} {file.error && - {file.error}} ))} )} {/* Errors summary */} {state.errors.length > 0 && ( Errors ({state.errors.length}): {state.errors.slice(-3).map((file) => ( ✗ {file.filename}: {file.error} ))} {state.errors.length > 3 && ( ... and {state.errors.length - 3} more )} )} {/* Footer */} Press 'q' to cancel ); } /** * Render the IngestProgress TUI * Returns controls for updating state and cleanup */ export function renderIngestProgress(initialState: IngestState) { let currentState = initialState; let rerender: ((node: React.ReactNode) => void) | null = null; let cancelled = false; const handleCancel = () => { cancelled = true; }; const { rerender: _rerender, unmount, clear, } = render(); rerender = _rerender; return { /** Update the display state */ update(newState: Partial) { currentState = { ...currentState, ...newState }; rerender?.( ); }, /** Check if user cancelled */ isCancelled() { return cancelled; }, /** Clean up the TUI */ cleanup() { clear(); unmount(); }, /** Get current state */ getState() { return currentState; }, }; } /** Create initial ingest state */ export function createInitialState(): IngestState { return { phase: "discovering", totalFiles: 0, processedFiles: 0, recentFiles: [], errors: [], startTime: Date.now(), }; }