UI features for RAG ingest

This commit is contained in:
Ishaan Jaffer 2026-01-26 15:17:03 -08:00
parent 75eec14691
commit 240ed0308a
7 changed files with 740 additions and 10 deletions

View file

@ -6948,6 +6948,49 @@ export const vectorStoreUpdateCall = async (accessToken: string, formValues: Rec
}
};
export const ragIngestCall = async (
accessToken: string,
file: File,
customLlmProvider: string,
vectorStoreId?: string
): Promise<any> => {
try {
let url = proxyBaseUrl ? `${proxyBaseUrl}/rag/ingest` : `/rag/ingest`;
const formData = new FormData();
formData.append("file", file);
const ingestOptions = {
ingest_options: {
vector_store: {
custom_llm_provider: customLlmProvider,
...(vectorStoreId && { vector_store_id: vectorStoreId }),
},
},
};
formData.append("request", JSON.stringify(ingestOptions));
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || error.detail || "Failed to ingest document");
}
return await response.json();
} catch (error) {
console.error("Error ingesting document:", error);
throw error;
}
};
export const getEmailEventSettings = async (accessToken: string): Promise<EmailEventSettingsResponse> => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/email/event_settings` : `/email/event_settings`;

View file

@ -0,0 +1,156 @@
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import CreateVectorStore from "./CreateVectorStore";
import * as networking from "../networking";
// Mock the networking module
vi.mock("../networking", () => ({
ragIngestCall: vi.fn(),
}));
// Mock NotificationsManager
vi.mock("../molecules/notifications_manager", () => ({
default: {
success: vi.fn(),
fromBackend: vi.fn(),
},
}));
// Mock ProviderLogo
vi.mock("../molecules/models/ProviderLogo", () => ({
ProviderLogo: ({ provider, className }: { provider: string; className?: string }) => (
<div className={className} data-testid={`provider-logo-${provider}`}>
{provider}
</div>
),
}));
describe("CreateVectorStore", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render the component successfully", () => {
render(<CreateVectorStore accessToken="test-token" />);
expect(screen.getByText("Create Vector Store")).toBeInTheDocument();
expect(screen.getByText("Step 1: Upload Documents")).toBeInTheDocument();
expect(screen.getByText("Step 2: Select Provider")).toBeInTheDocument();
});
it("should display upload area with correct text", () => {
render(<CreateVectorStore accessToken="test-token" />);
expect(screen.getByText("Click or drag files to this area to upload")).toBeInTheDocument();
expect(screen.getByText(/Support for single or bulk upload/)).toBeInTheDocument();
});
it("should have provider selection dropdown", () => {
render(<CreateVectorStore accessToken="test-token" />);
expect(screen.getByText("Provider")).toBeInTheDocument();
});
it("should have create button disabled initially when no documents", () => {
render(<CreateVectorStore accessToken="test-token" />);
const createButton = screen.getByRole("button", { name: /Create Vector Store/i });
expect(createButton).toBeDisabled();
});
it("should show uploaded documents table when files are added", async () => {
render(<CreateVectorStore accessToken="test-token" />);
// Create a mock file
const file = new File(["test content"], "test.pdf", { type: "application/pdf" });
// Find the upload input (it's hidden but accessible)
const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
if (uploadInput) {
fireEvent.change(uploadInput, { target: { files: [file] } });
}
});
await waitFor(() => {
expect(screen.getByText("Uploaded Documents (1)")).toBeInTheDocument();
});
});
it("should call ragIngestCall when create button is clicked", async () => {
const mockRagIngestCall = vi.spyOn(networking, "ragIngestCall");
mockRagIngestCall.mockResolvedValue({
id: "test-id",
status: "completed",
vector_store_id: "vs_123",
file_id: "file_123",
});
const onSuccess = vi.fn();
render(<CreateVectorStore accessToken="test-token" onSuccess={onSuccess} />);
// Create a mock file
const file = new File(["test content"], "test.pdf", { type: "application/pdf" });
const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
if (uploadInput) {
fireEvent.change(uploadInput, { target: { files: [file] } });
}
});
// Wait for file to be added
await waitFor(() => {
expect(screen.getByText("Uploaded Documents (1)")).toBeInTheDocument();
});
// Click create button
const createButton = screen.getByRole("button", { name: /Create Vector Store/i });
await act(async () => {
fireEvent.click(createButton);
});
await waitFor(() => {
expect(mockRagIngestCall).toHaveBeenCalledWith("test-token", expect.any(File), "openai", undefined);
});
});
it("should display success message after successful creation", async () => {
const mockRagIngestCall = vi.spyOn(networking, "ragIngestCall");
mockRagIngestCall.mockResolvedValue({
id: "test-id",
status: "completed",
vector_store_id: "vs_123",
file_id: "file_123",
});
render(<CreateVectorStore accessToken="test-token" />);
// Create and upload a mock file
const file = new File(["test content"], "test.pdf", { type: "application/pdf" });
const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
if (uploadInput) {
fireEvent.change(uploadInput, { target: { files: [file] } });
}
});
await waitFor(() => {
expect(screen.getByText("Uploaded Documents (1)")).toBeInTheDocument();
});
// Click create button
const createButton = screen.getByRole("button", { name: /Create Vector Store/i });
await act(async () => {
fireEvent.click(createButton);
});
await waitFor(() => {
expect(screen.getByText("Vector Store Created Successfully")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,273 @@
import React, { useState } from "react";
import { Card, Title, Text } from "@tremor/react";
import { Upload, Button, Select, Form, message, Alert } from "antd";
import { InboxOutlined } from "@ant-design/icons";
import type { UploadProps } from "antd";
import { ragIngestCall } from "../networking";
import { DocumentUpload, RAGIngestResponse } from "./types";
import DocumentsTable from "./DocumentsTable";
import { Providers, provider_map } from "../provider_info_helpers";
import { ProviderLogo } from "../molecules/models/ProviderLogo";
import NotificationsManager from "../molecules/notifications_manager";
const { Dragger } = Upload;
interface CreateVectorStoreProps {
accessToken: string | null;
onSuccess?: (vectorStoreId: string) => void;
}
const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSuccess }) => {
const [form] = Form.useForm();
const [documents, setDocuments] = useState<DocumentUpload[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<string>("openai");
const [ingestResults, setIngestResults] = useState<RAGIngestResponse[]>([]);
const uploadProps: UploadProps = {
name: "file",
multiple: true,
accept: ".pdf,.txt,.docx,.md,.doc",
beforeUpload: (file) => {
const isValidType = [
"application/pdf",
"text/plain",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
"text/markdown",
].includes(file.type);
if (!isValidType) {
message.error(`${file.name} is not a supported file type. Please upload PDF, TXT, DOCX, or MD files.`);
return Upload.LIST_IGNORE;
}
const isLt50M = file.size / 1024 / 1024 < 50;
if (!isLt50M) {
message.error(`${file.name} must be smaller than 50MB!`);
return Upload.LIST_IGNORE;
}
const newDoc: DocumentUpload = {
uid: file.uid,
name: file.name,
status: "done",
size: file.size,
type: file.type,
originFileObj: file,
};
setDocuments((prev) => [...prev, newDoc]);
return false; // Prevent auto upload
},
onRemove: (file) => {
setDocuments((prev) => prev.filter((doc) => doc.uid !== file.uid));
},
fileList: documents.map((doc) => ({
uid: doc.uid,
name: doc.name,
status: doc.status,
size: doc.size,
})),
showUploadList: false, // We'll use our custom table
};
const handleRemoveDocument = (uid: string) => {
setDocuments((prev) => prev.filter((doc) => doc.uid !== uid));
};
const handleCreateVectorStore = async () => {
if (documents.length === 0) {
message.warning("Please upload at least one document");
return;
}
if (!selectedProvider) {
message.warning("Please select a provider");
return;
}
if (!accessToken) {
message.error("No access token available");
return;
}
setIsCreating(true);
const results: RAGIngestResponse[] = [];
let vectorStoreId: string | undefined;
try {
// Ingest each document
for (const doc of documents) {
if (!doc.originFileObj) continue;
// Update document status to uploading
setDocuments((prev) =>
prev.map((d) => (d.uid === doc.uid ? { ...d, status: "uploading" as const } : d))
);
try {
const result = await ragIngestCall(
accessToken,
doc.originFileObj,
selectedProvider,
vectorStoreId // Use the same vector store ID for subsequent uploads
);
// Store the vector store ID from the first successful ingest
if (!vectorStoreId && result.vector_store_id) {
vectorStoreId = result.vector_store_id;
}
results.push(result);
// Update document status to done
setDocuments((prev) =>
prev.map((d) => (d.uid === doc.uid ? { ...d, status: "done" as const } : d))
);
} catch (error) {
console.error(`Error ingesting ${doc.name}:`, error);
// Update document status to error
setDocuments((prev) =>
prev.map((d) => (d.uid === doc.uid ? { ...d, status: "error" as const } : d))
);
throw error; // Stop processing on first error
}
}
setIngestResults(results);
NotificationsManager.success(
`Successfully created vector store with ${results.length} document(s). Vector Store ID: ${vectorStoreId}`
);
if (onSuccess && vectorStoreId) {
onSuccess(vectorStoreId);
}
// Clear documents after successful creation
setTimeout(() => {
setDocuments([]);
setIngestResults([]);
}, 3000);
} catch (error) {
console.error("Error creating vector store:", error);
NotificationsManager.fromBackend(`Failed to create vector store: ${error}`);
} finally {
setIsCreating(false);
}
};
const supportedProviders = ["openai", "bedrock"]; // Add more as needed
return (
<div className="space-y-6">
<div>
<Title>Create Vector Store</Title>
<Text className="text-gray-500">
Upload documents and select a provider to create a new vector store with embedded content.
</Text>
</div>
{/* Upload Area */}
<Card>
<div className="mb-4">
<Text className="font-medium">Step 1: Upload Documents</Text>
<Text className="text-sm text-gray-500 block mt-1">
Upload one or more documents (PDF, TXT, DOCX, MD). Maximum file size: 50MB per file.
</Text>
</div>
<Dragger {...uploadProps}>
<p className="ant-upload-drag-icon">
<InboxOutlined style={{ fontSize: "48px", color: "#1890ff" }} />
</p>
<p className="ant-upload-text">Click or drag files to this area to upload</p>
<p className="ant-upload-hint">
Support for single or bulk upload. Supported formats: PDF, TXT, DOCX, MD
</p>
</Dragger>
</Card>
{/* Documents Table */}
{documents.length > 0 && (
<Card>
<div className="mb-4">
<Text className="font-medium">Uploaded Documents ({documents.length})</Text>
</div>
<DocumentsTable documents={documents} onRemove={handleRemoveDocument} />
</Card>
)}
{/* Provider Selection and Create Button */}
<Card>
<div className="space-y-4">
<div>
<Text className="font-medium">Step 2: Select Provider</Text>
<Text className="text-sm text-gray-500 block mt-1">
Choose the LLM provider for embedding and vector store operations.
</Text>
</div>
<Form form={form} layout="vertical">
<Form.Item label="Provider" required>
<Select
value={selectedProvider}
onChange={setSelectedProvider}
placeholder="Select a provider"
size="large"
style={{ width: "100%" }}
>
{Object.entries(Providers)
.filter(([providerEnum]) => supportedProviders.includes(provider_map[providerEnum]?.toLowerCase()))
.map(([providerEnum, providerDisplayName]) => {
const providerValue = provider_map[providerEnum]?.toLowerCase();
return (
<Select.Option key={providerEnum} value={providerValue}>
<div className="flex items-center space-x-2">
<ProviderLogo provider={providerEnum} className="w-5 h-5" />
<span>{providerDisplayName}</span>
</div>
</Select.Option>
);
})}
</Select>
</Form.Item>
</Form>
<div className="flex justify-end">
<Button
type="primary"
size="large"
onClick={handleCreateVectorStore}
loading={isCreating}
disabled={documents.length === 0 || !selectedProvider}
>
{isCreating ? "Creating Vector Store..." : "Create Vector Store"}
</Button>
</div>
</div>
</Card>
{/* Success Message */}
{ingestResults.length > 0 && (
<Alert
message="Vector Store Created Successfully"
description={
<div>
<p>
<strong>Vector Store ID:</strong> {ingestResults[0]?.vector_store_id}
</p>
<p>
<strong>Documents Ingested:</strong> {ingestResults.length}
</p>
</div>
}
type="success"
showIcon
closable
/>
)}
</div>
);
};
export default CreateVectorStore;

View file

@ -0,0 +1,102 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import DocumentsTable from "./DocumentsTable";
import { DocumentUpload } from "./types";
// Mock antd message
vi.mock("antd", async () => {
const actual = await vi.importActual("antd");
return {
...actual,
message: {
success: vi.fn(),
},
};
});
describe("DocumentsTable", () => {
const mockDocuments: DocumentUpload[] = [
{
uid: "1",
name: "test1.pdf",
status: "done",
size: 1024000,
type: "application/pdf",
},
{
uid: "2",
name: "test2.txt",
status: "uploading",
size: 2048000,
type: "text/plain",
},
{
uid: "3",
name: "test3.docx",
status: "error",
size: 512000,
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
];
it("should render the table successfully", () => {
const onRemove = vi.fn();
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
expect(screen.getByText("test1.pdf")).toBeInTheDocument();
expect(screen.getByText("test2.txt")).toBeInTheDocument();
expect(screen.getByText("test3.docx")).toBeInTheDocument();
});
it("should display correct status badges", () => {
const onRemove = vi.fn();
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
expect(screen.getByText("Ready")).toBeInTheDocument();
expect(screen.getByText("Uploading")).toBeInTheDocument();
expect(screen.getByText("Error")).toBeInTheDocument();
});
it("should display file sizes", () => {
const onRemove = vi.fn();
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
expect(screen.getByText(/1000.00 KB/)).toBeInTheDocument();
expect(screen.getByText(/2.00 MB/)).toBeInTheDocument();
expect(screen.getByText(/500.00 KB/)).toBeInTheDocument();
});
it("should call onRemove when delete button is clicked", () => {
const onRemove = vi.fn();
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
const deleteButtons = screen.getAllByLabelText(/remove/i);
act(() => {
fireEvent.click(deleteButtons[0]);
});
expect(onRemove).toHaveBeenCalledWith("1");
});
it("should show empty state when no documents", () => {
const onRemove = vi.fn();
render(<DocumentsTable documents={[]} onRemove={onRemove} />);
expect(screen.getByText(/No documents uploaded yet/)).toBeInTheDocument();
});
it("should have action buttons for each document", () => {
const onRemove = vi.fn();
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
// Each document should have 3 action buttons (view, copy, delete)
const viewButtons = screen.getAllByLabelText(/view details/i);
const copyButtons = screen.getAllByLabelText(/copy id/i);
const deleteButtons = screen.getAllByLabelText(/remove/i);
expect(viewButtons).toHaveLength(3);
expect(copyButtons).toHaveLength(3);
expect(deleteButtons).toHaveLength(3);
});
});

View file

@ -0,0 +1,98 @@
import React from "react";
import { Table, Badge, Tooltip, message } from "antd";
import { EyeOutlined, CopyOutlined, DeleteOutlined } from "@ant-design/icons";
import { DocumentUpload } from "./types";
interface DocumentsTableProps {
documents: DocumentUpload[];
onRemove: (uid: string) => void;
}
const DocumentsTable: React.FC<DocumentsTableProps> = ({ documents, onRemove }) => {
const handleCopyId = (uid: string) => {
navigator.clipboard.writeText(uid);
message.success("Document ID copied to clipboard");
};
const getStatusBadge = (status: DocumentUpload["status"]) => {
const statusConfig = {
uploading: { color: "blue", text: "Uploading" },
done: { color: "green", text: "Ready" },
error: { color: "red", text: "Error" },
removed: { color: "default", text: "Removed" },
};
const config = statusConfig[status];
return <Badge color={config.color} text={config.text} />;
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return "-";
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(2)} KB`;
return `${(kb / 1024).toFixed(2)} MB`;
};
const columns = [
{
title: "Name",
dataIndex: "name",
key: "name",
render: (name: string, record: DocumentUpload) => (
<div className="flex items-center space-x-2">
<span className="text-sm">{name}</span>
{record.size && <span className="text-xs text-gray-400">({formatFileSize(record.size)})</span>}
</div>
),
},
{
title: "Status",
dataIndex: "status",
key: "status",
width: 150,
render: (status: DocumentUpload["status"]) => getStatusBadge(status),
},
{
title: "Actions",
key: "actions",
width: 120,
render: (_: any, record: DocumentUpload) => (
<div className="flex items-center space-x-2">
<Tooltip title="View details">
<EyeOutlined
className="cursor-pointer text-gray-600 hover:text-blue-500"
onClick={() => console.log("View", record)}
/>
</Tooltip>
<Tooltip title="Copy ID">
<CopyOutlined
className="cursor-pointer text-gray-600 hover:text-blue-500"
onClick={() => handleCopyId(record.uid)}
/>
</Tooltip>
<Tooltip title="Remove">
<DeleteOutlined
className="cursor-pointer text-gray-600 hover:text-red-500"
onClick={() => onRemove(record.uid)}
/>
</Tooltip>
</div>
),
},
];
return (
<Table
dataSource={documents}
columns={columns}
rowKey="uid"
pagination={false}
locale={{
emptyText: "No documents uploaded yet. Upload documents above to get started.",
}}
size="small"
/>
);
};
export default DocumentsTable;

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { Icon, Button as TremorButton, Col, Text, Grid } from "@tremor/react";
import { Icon, Button as TremorButton, Col, Text, Grid, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import { RefreshIcon } from "@heroicons/react/outline";
import { vectorStoreListCall, vectorStoreDeleteCall, credentialListCall, CredentialItem } from "../networking";
import { VectorStore } from "./types";
@ -7,6 +7,7 @@ import VectorStoreTable from "./VectorStoreTable";
import VectorStoreForm from "./VectorStoreForm";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
import VectorStoreInfoView from "./vector_store_info";
import CreateVectorStore from "./CreateVectorStore";
import { isAdminRole } from "@/utils/roles";
import NotificationsManager from "../molecules/notifications_manager";
@ -101,6 +102,12 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
fetchVectorStores();
};
const handleVectorStoreCreated = (vectorStoreId: string) => {
console.log("Vector store created:", vectorStoreId);
fetchVectorStores();
// Optionally switch to the manage tab
};
useEffect(() => {
fetchVectorStores();
fetchCredentials();
@ -134,18 +141,40 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
</div>
<Text className="mb-4">
<p>You can use vector stores to store and retrieve LLM embeddings..</p>
<p>You can use vector stores to store and retrieve LLM embeddings.</p>
</Text>
<TremorButton className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
+ Add Vector Store
</TremorButton>
<TabGroup>
<TabList className="mb-6">
<Tab>Create Vector Store</Tab>
<Tab>Manage Vector Stores</Tab>
</TabList>
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
<Col numColSpan={1}>
<VectorStoreTable data={vectorStores} onView={handleView} onEdit={handleEdit} onDelete={handleDelete} />
</Col>
</Grid>
<TabPanels>
{/* Tab 1: Create Vector Store */}
<TabPanel>
<CreateVectorStore accessToken={accessToken} onSuccess={handleVectorStoreCreated} />
</TabPanel>
{/* Tab 2: Manage Vector Stores */}
<TabPanel>
<TremorButton className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
+ Add Vector Store
</TremorButton>
<Grid numItems={1} className="gap-2 pt-2 pb-2 w-full mt-2">
<Col numColSpan={1}>
<VectorStoreTable
data={vectorStores}
onView={handleView}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</Col>
</Grid>
</TabPanel>
</TabPanels>
</TabGroup>
{/* Create Vector Store Modal */}
<VectorStoreForm

View file

@ -41,3 +41,32 @@ export interface VectorStoreListResponse {
current_page: number;
total_pages: number;
}
// Document ingestion types
export interface DocumentUpload {
uid: string;
name: string;
status: "uploading" | "done" | "error" | "removed";
size?: number;
type?: string;
originFileObj?: File;
}
export interface RAGIngestRequest {
file_url?: string;
file_id?: string;
ingest_options: {
vector_store: {
custom_llm_provider: string;
vector_store_id?: string;
};
};
}
export interface RAGIngestResponse {
id: string;
status: "completed" | "processing" | "failed";
vector_store_id: string;
file_id: string;
error?: string;
}