Claude
Skills
Sign in
Back

ai-rag-app

Included with Lifetime
$97 forever

RAG application page — split-pane layout combining PDF upload, document list, RAG chat, and PDF viewer with citation-driven navigation. Click a citation to highlight the source text in the PDF viewer. Use this skill when the user says "setup RAG app", "add document chat page", "setup ai-rag-app", or "create PDF chat interface".

AI Agents

What this skill does


# AI RAG App

Full RAG application page with split-pane layout. Left side: document list + RAG chat. Right side: PDF viewer with highlighted citations. Upload PDFs, process them, ask questions, and see exact source sections highlighted in the rendered PDF.

## Prerequisites

- Next.js app with `src/` directory and App Router
- `ai-rag-ingest` skill installed (upload + parsing)
- `ai-rag-vectors` skill installed (embeddings + search)
- `ai-rag-chat` skill installed (RAG chat component + API)
- `ai-rag-viewer` skill installed (PDF viewer component)
- `auth` skill installed (protected routes)
- shadcn/ui initialized

## Installation

```bash
bunx shadcn@latest add tabs badge progress separator
```

## What Gets Created

```
src/
├── app/
│   └── (app)/
│       └── rag/
│           └── page.tsx                    # Main RAG application page
└── components/
    └── rag/
        ├── document-list.tsx              # Document list with upload + status
        ├── document-upload.tsx            # Upload form with drag-and-drop
        └── rag-layout.tsx                 # Split-pane layout orchestrator
```

## Setup Steps

### Step 1: Create `src/components/rag/document-upload.tsx`

```tsx
"use client";

import { useState, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { UploadSimple, File, CircleNotch } from "@phosphor-icons/react";

type DocumentUploadProps = {
  onUploadComplete: (documentId: string) => void;
};

export function DocumentUpload({ onUploadComplete }: DocumentUploadProps) {
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [isDragging, setIsDragging] = useState(false);
  const [isUploading, setIsUploading] = useState(false);
  const [uploadProgress, setUploadProgress] = useState("");

  const uploadFile = useCallback(
    async (file: File) => {
      if (!file.name.toLowerCase().endsWith(".pdf")) {
        return;
      }

      setIsUploading(true);
      setUploadProgress("Uploading...");

      try {
        // 1. Upload
        const formData = new FormData();
        formData.append("file", file);

        const uploadRes = await fetch("/api/rag/documents", {
          method: "POST",
          body: formData,
        });

        if (!uploadRes.ok) {
          const err = (await uploadRes.json()) as { error?: string };
          throw new Error(err.error ?? "Upload failed");
        }

        const doc = (await uploadRes.json()) as { id: string };
        setUploadProgress("Processing PDF...");

        // 2. Process
        const processRes = await fetch(
          `/api/rag/documents/${doc.id}/process`,
          { method: "POST" }
        );

        if (!processRes.ok) {
          throw new Error("Processing failed");
        }

        setUploadProgress("Generating embeddings...");

        // 3. Index
        const indexRes = await fetch(
          `/api/rag/documents/${doc.id}/index`,
          { method: "POST" }
        );

        if (!indexRes.ok) {
          throw new Error("Indexing failed");
        }

        onUploadComplete(doc.id);
      } catch {
        // Upload error handled by progress reset
      } finally {
        setIsUploading(false);
        setUploadProgress("");
      }
    },
    [onUploadComplete]
  );

  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      setIsDragging(false);

      const file = e.dataTransfer.files[0];
      if (file) uploadFile(file);
    },
    [uploadFile]
  );

  const handleFileSelect = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (file) uploadFile(file);
      // Reset input
      if (fileInputRef.current) {
        fileInputRef.current.value = "";
      }
    },
    [uploadFile]
  );

  return (
    // biome-ignore lint/a11y/noStaticElementInteractions: drop zone requires drag event handlers on div
    <div
      onDragOver={(e) => {
        e.preventDefault();
        setIsDragging(true);
      }}
      onDragLeave={() => setIsDragging(false)}
      onDrop={handleDrop}
      className={`rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
        isDragging
          ? "border-primary bg-primary/5"
          : "border-muted-foreground/25"
      }`}
    >
      {isUploading ? (
        <div className="flex flex-col items-center gap-2">
          <CircleNotch className="h-8 w-8 animate-spin text-primary" />
          <p className="text-sm text-muted-foreground">{uploadProgress}</p>
        </div>
      ) : (
        <div className="flex flex-col items-center gap-2">
          <UploadSimple className="h-8 w-8 text-muted-foreground" />
          <p className="text-sm text-muted-foreground">
            Drag & drop a PDF here, or
          </p>
          <Button
            variant="outline"
            size="sm"
            onClick={() => fileInputRef.current?.click()}
          >
            <File className="mr-2 h-4 w-4" />
            Browse Files
          </Button>
          <input
            ref={fileInputRef}
            type="file"
            accept=".pdf"
            onChange={handleFileSelect}
            className="hidden"
          />
        </div>
      )}
    </div>
  );
}
```

### Step 2: Create `src/components/rag/document-list.tsx`

```tsx
"use client";

import { useId, useEffect, useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Trash, FileText, CircleNotch } from "@phosphor-icons/react";

type DocumentItem = {
  id: string;
  title: string;
  fileName: string;
  fileSize: number;
  pageCount: number | null;
  pagesProcessed: number;
  status: "uploading" | "processing" | "ready" | "error";
  createdAt: string;
};

type DocumentListProps = {
  selectedDocumentId: string | null;
  onSelectDocument: (doc: DocumentItem) => void;
  refreshKey?: number;
};

function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  if (bytes < 1024 * 1024 * 1024)
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}

const statusColors: Record<string, string> = {
  uploading: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
  processing: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
  ready: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
  error: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};

export function DocumentList({
  selectedDocumentId,
  onSelectDocument,
  refreshKey,
}: DocumentListProps) {
  const listId = useId();
  const [documents, setDocuments] = useState<DocumentItem[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  const loadDocuments = useCallback(async () => {
    try {
      const res = await fetch("/api/rag/documents");
      if (!res.ok) return;
      const data = (await res.json()) as DocumentItem[];
      setDocuments(data);
    } catch {
      // Silently fail
    } finally {
      setIsLoading(false);
    }
  }, []);

  // biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey triggers reload
  useEffect(() => {
    loadDocuments();
  }, [loadDocuments, refreshKey]);

  const handleDelete = useCallback(
    async (e: React.MouseEvent, documentId: string) => {
      e.stopPropagation();
      try {
        const res = await fetch(`/api/rag/documents/${documentId}`, {
          method: "DELETE",
        });
        if (!res.ok) return;
        setDocuments((prev) => prev.filter((d) => d.id !== documentId));
      } catch {
        // Silently fail
      }
    },
    []
  );

  if (isLoading) {
    return (
      <div className="flex items-center justify-center p-4">
        <CircleNotch className="h-5 w-5 animate-spin text-muted-foreground" />
      </div>
    );
  }

  if (documents.length === 0) {
    return (
Files: 1
Size: 18.9 KB
Complexity: 29/100
Category: AI Agents

Related in AI Agents