UI kit
RagUpload
IngestionReact · Next.jsA drag-and-drop zone for document ingestion. POSTs each file as multipart/form-data with real upload-progress bars. Supply the uploader prop to stage to S3/GCS or a presigned URL instead of a direct POST.
<RagUpload />Live preview
Drop documents or click to upload
PDF · TXT · MD · DOCX · HTML
Installation
No npm dependencies — just React + Tailwind CSS. There are two ways to use it:
- React — copy the source below; it calls your pipeline directly from the browser.
- Next.js — switch to the Next.js tab in the source below; it keeps your pipeline URL + key server-side behind a Route Handler (adds a couple of
.env.localvars).
Usage
usage.tsx
import { RagUpload } from "@/components/RagUpload";
export default function Example() {
return (
<RagUpload endpoint="https://api.example.com/ingest" />
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
endpointrequired | string | — | Ingestion URL. POSTs multipart/form-data (field "file"). |
acceptedTypes | string | ".pdf,.txt,.md,.docx,.html" | Comma-separated accept list. |
apiKey | string | — | Sent as X-API-Key when your serve.py has API_KEY set. |
onComplete | (file: File) => void | — | Called when a file finishes uploading. |
uploader | Uploader | — | Override how a file is uploaded (presigned URL, S3, testing). |
Component source copy-paste ready
RagUpload.tsx
"use client";
import { useRef, useState } from "react";
interface UploadFile {
name: string;
size: number;
progress: number;
status: "uploading" | "done" | "error";
}
/** Uploads one file, reporting 0–100 progress. Resolve on success, reject on failure. */
export type Uploader = (file: File, onProgress: (pct: number) => void) => Promise<void>;
interface RagUploadProps {
/** Ingestion endpoint. By default, POSTs multipart/form-data (field "file"). */
endpoint: string;
/** Comma-separated accept list. Default: ".pdf,.txt,.md,.docx,.html". */
acceptedTypes?: string;
/** Sent as the X-API-Key header when your serve.py has API_KEY set. */
apiKey?: string;
onComplete?: (file: File) => void;
/** Override how a file is uploaded (e.g. to a presigned URL, or for testing). */
uploader?: Uploader;
className?: string;
}
/** Default uploader — multipart POST with real upload-progress events via XHR. */
function makeDefaultUploader(endpoint: string, apiKey?: string): Uploader {
return (file, onProgress) =>
new Promise<void>((resolve, reject) => {
const form = new FormData();
form.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", endpoint);
if (apiKey) xhr.setRequestHeader("X-API-Key", apiKey);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => (xhr.status < 400 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)));
xhr.onerror = () => reject(new Error("network error"));
xhr.send(form);
});
}
/**
* RagUpload — drag-and-drop document ingestion with a live per-file progress
* bar. By default it POSTs each file as multipart/form-data to `endpoint`;
* supply `uploader` to stage to S3/GCS or a presigned URL instead.
*/
export function RagUpload({
endpoint,
acceptedTypes = ".pdf,.txt,.md,.docx,.html",
apiKey,
onComplete,
uploader,
className = "",
}: RagUploadProps) {
const [files, setFiles] = useState<UploadFile[]>([]);
const [drag, setDrag] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
function patch(name: string, p: Partial<UploadFile>) {
setFiles((f) => f.map((x) => (x.name === name ? { ...x, ...p } : x)));
}
function start(fileList: FileList | null) {
if (!fileList) return;
const upload = uploader ?? makeDefaultUploader(endpoint, apiKey);
Array.from(fileList).forEach((file) => {
setFiles((f) => [...f, { name: file.name, size: file.size, progress: 0, status: "uploading" }]);
upload(file, (pct) => patch(file.name, { progress: pct }))
.then(() => {
patch(file.name, { progress: 100, status: "done" });
onComplete?.(file);
})
.catch(() => patch(file.name, { status: "error" }));
});
}
return (
<div className={className}>
<div
onDragOver={(e) => {
e.preventDefault();
setDrag(true);
}}
onDragLeave={() => setDrag(false)}
onDrop={(e) => {
e.preventDefault();
setDrag(false);
start(e.dataTransfer.files);
}}
onClick={() => inputRef.current?.click()}
className={`flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-10 text-center transition ${
drag ? "border-zinc-500 bg-zinc-50 dark:bg-zinc-900" : "border-zinc-300 dark:border-zinc-700"
}`}
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-zinc-400">
<path d="M12 16V4m0 0 4 4m-4-4-4 4" />
<path d="M20 16.5A2.5 2.5 0 0 1 17.5 19h-11A2.5 2.5 0 0 1 4 16.5" />
</svg>
<p className="text-sm font-medium text-zinc-600 dark:text-zinc-300">Drop documents or click to upload</p>
<p className="text-xs text-zinc-400">
{acceptedTypes.replaceAll(".", "").toUpperCase().replaceAll(",", " · ")}
</p>
<input
ref={inputRef}
type="file"
multiple
accept={acceptedTypes}
className="hidden"
onChange={(e) => start(e.target.files)}
/>
</div>
{files.length > 0 && (
<ul className="mt-3 flex flex-col gap-2">
{files.map((f, i) => (
<li key={i} className="rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-800">
<div className="mb-1 flex items-center justify-between text-xs">
<span className="truncate font-medium text-zinc-700 dark:text-zinc-200">{f.name}</span>
<span className={f.status === "error" ? "text-red-500" : f.status === "done" ? "text-emerald-500" : "text-zinc-400"}>
{f.status === "done" ? "Done" : f.status === "error" ? "Failed" : `${f.progress}%`}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<div
className={`h-full rounded-full transition-all ${
f.status === "error" ? "bg-red-400" : f.status === "done" ? "bg-emerald-500" : "bg-zinc-500"
}`}
style={{ width: `${f.progress}%` }}
/>
</div>
</li>
))}
</ul>
)}
</div>
);
}
1 · Add to .env.local
RAG_INGEST_ENDPOINT=https://your-pipeline.onrender.com/ingest RAG_API_KEY=
RAG_INGEST_ENDPOINT— Your pipeline's ingestion URLRAG_API_KEY— Optional — only if your serve.py sets API_KEY
The Route Handler receives the upload and forwards it server-side. Adapt it to stage to S3/GCS if your pipeline ingests from object storage.
2 · Component
components/RagUpload.tsx
"use client";
import { useRef, useState } from "react";
interface UploadFile {
name: string;
size: number;
progress: number;
status: "uploading" | "done" | "error";
}
interface RagUploadProps {
/** Local route handler that forwards the upload. Default: "/api/ingest". */
route?: string;
acceptedTypes?: string;
onComplete?: (file: File) => void;
className?: string;
}
/**
* RagUpload (Next.js) — drag-and-drop ingestion that POSTs each file to a local
* Route Handler (app/api/ingest/route.ts), which forwards it to your backend
* server-side. Upload progress comes from real XHR upload events.
*/
export function RagUpload({ route = "/api/ingest", acceptedTypes = ".pdf,.txt,.md,.docx,.html", onComplete, className = "" }: RagUploadProps) {
const [files, setFiles] = useState<UploadFile[]>([]);
const [drag, setDrag] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
function patch(name: string, p: Partial<UploadFile>) {
setFiles((f) => f.map((x) => (x.name === name ? { ...x, ...p } : x)));
}
function start(fileList: FileList | null) {
if (!fileList) return;
Array.from(fileList).forEach((file) => {
setFiles((f) => [...f, { name: file.name, size: file.size, progress: 0, status: "uploading" }]);
const form = new FormData();
form.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", route);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) patch(file.name, { progress: Math.round((e.loaded / e.total) * 100) });
};
xhr.onload = () => {
patch(file.name, { progress: 100, status: xhr.status < 400 ? "done" : "error" });
if (xhr.status < 400) onComplete?.(file);
};
xhr.onerror = () => patch(file.name, { status: "error" });
xhr.send(form);
});
}
return (
<div className={className}>
<div
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={(e) => { e.preventDefault(); setDrag(false); start(e.dataTransfer.files); }}
onClick={() => inputRef.current?.click()}
className={`flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-10 text-center transition ${
drag ? "border-zinc-500 bg-zinc-50 dark:bg-zinc-900" : "border-zinc-300 dark:border-zinc-700"
}`}
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-zinc-400">
<path d="M12 16V4m0 0 4 4m-4-4-4 4" />
<path d="M20 16.5A2.5 2.5 0 0 1 17.5 19h-11A2.5 2.5 0 0 1 4 16.5" />
</svg>
<p className="text-sm font-medium text-zinc-600 dark:text-zinc-300">Drop documents or click to upload</p>
<p className="text-xs text-zinc-400">{acceptedTypes.replaceAll(".", "").toUpperCase().replaceAll(",", " · ")}</p>
<input ref={inputRef} type="file" multiple accept={acceptedTypes} className="hidden" onChange={(e) => start(e.target.files)} />
</div>
{files.length > 0 && (
<ul className="mt-3 flex flex-col gap-2">
{files.map((f, i) => (
<li key={i} className="rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-800">
<div className="mb-1 flex items-center justify-between text-xs">
<span className="truncate font-medium text-zinc-700 dark:text-zinc-200">{f.name}</span>
<span className={f.status === "error" ? "text-red-500" : f.status === "done" ? "text-emerald-500" : "text-zinc-400"}>
{f.status === "done" ? "Done" : f.status === "error" ? "Failed" : `${f.progress}%`}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<div
className={`h-full rounded-full transition-all ${f.status === "error" ? "bg-red-400" : f.status === "done" ? "bg-emerald-500" : "bg-zinc-500"}`}
style={{ width: `${f.progress}%` }}
/>
</div>
</li>
))}
</ul>
)}
</div>
);
}
3 · Route handler
app/api/ingest/route.ts
// app/api/ingest/route.ts
//
// Receives a file upload from the browser and forwards it to your ingestion
// backend server-side. Adapt the forwarding to your pipeline: many serve.py
// ingest routes take a { path } pointing at staged files (e.g. after an S3
// upload), so you may want to save the file / push to object storage here and
// then call the pipeline's /ingest. RAG_API_KEY stays on the server.
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
const endpoint = process.env.RAG_INGEST_ENDPOINT;
if (!endpoint) {
return new Response("RAG_INGEST_ENDPOINT is not set", { status: 500 });
}
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return Response.json({ error: "no file" }, { status: 400 });
}
// Forward the upload to your ingestion backend (swap for S3/GCS staging if
// your pipeline ingests from object storage rather than a direct upload).
const forward = new FormData();
forward.append("file", file, file.name);
const upstream = await fetch(endpoint, {
method: "POST",
headers: { ...(process.env.RAG_API_KEY ? { "X-API-Key": process.env.RAG_API_KEY } : {}) },
body: forward,
});
return new Response(await upstream.text(), { status: upstream.status });
}