[Feat] Add UI for /rag/ingest API - upload docs, pdfs etc to create vector stores (#19822)

* feat: _save_vector_store_to_db_from_rag_ingest

* UI features for RAG ingest

* fix: Endpoints

* ragIngestCall

* _save_vector_store_to_db_from_rag_ingest

* fix: rag_ingest Code QA CHECK

* UI fixes unit tests
This commit is contained in:
Ishaan Jaff
2026-01-26 19:23:43 -08:00
committed by GitHub
parent f95572e3ed
commit 52d73c2c16
13 changed files with 1374 additions and 75 deletions
+198
View File
@@ -26,6 +26,184 @@ from litellm.proxy.common_utils.http_parsing_utils import (
router = APIRouter()
def _build_file_metadata_entry(
response: Any,
file_data: Optional[Tuple[str, bytes, str]] = None,
file_url: Optional[str] = None,
) -> Dict[str, Any]:
"""
Build a file metadata entry for storing in vector_store_metadata.
Args:
response: The response from litellm.aingest containing file_id
file_data: Optional tuple of (filename, content, content_type)
file_url: Optional URL if file was ingested from URL
Returns:
Dictionary with file metadata (file_id, filename, file_url, ingested_at, etc.)
"""
from datetime import datetime, timezone
# Extract file_id from response
file_id = None
if hasattr(response, "get"):
file_id = response.get("file_id")
elif hasattr(response, "file_id"):
file_id = response.file_id
# Extract file information from file_data tuple
filename = None
file_size = None
content_type = None
if file_data:
filename = file_data[0]
file_size = len(file_data[1]) if len(file_data) > 1 else None
content_type = file_data[2] if len(file_data) > 2 else None
# Build file metadata entry
file_entry = {
"file_id": file_id,
"filename": filename,
"file_url": file_url,
"ingested_at": datetime.now(timezone.utc).isoformat(),
}
# Add optional fields if available
if file_size is not None:
file_entry["file_size"] = file_size
if content_type is not None:
file_entry["content_type"] = content_type
return file_entry
async def _save_vector_store_to_db_from_rag_ingest(
response: Any,
ingest_options: Dict[str, Any],
prisma_client,
user_api_key_dict: UserAPIKeyAuth,
file_data: Optional[Tuple[str, bytes, str]] = None,
file_url: Optional[str] = None,
) -> None:
"""
Helper function to save a newly created vector store from RAG ingest to the database.
This function:
- Extracts vector store ID and config from the ingest response
- Checks if the vector store already exists in the database
- Creates a new database entry if it doesn't exist
- Adds the vector store to the registry
Args:
response: The response from litellm.aingest()
ingest_options: The ingest options containing vector store config
prisma_client: The Prisma database client
user_api_key_dict: User API key authentication info
"""
from litellm.proxy.vector_store_endpoints.management_endpoints import (
create_vector_store_in_db,
)
# Handle both dict and object responses
if hasattr(response, "get"):
vector_store_id = response.get("vector_store_id")
elif hasattr(response, "vector_store_id"):
vector_store_id = response.vector_store_id
else:
verbose_proxy_logger.warning(
f"Unable to extract vector_store_id from response type: {type(response)}"
)
return
if vector_store_id is None or not isinstance(vector_store_id, str):
verbose_proxy_logger.warning(
"Vector store ID is None or not a string, skipping database save"
)
return
vector_store_config = ingest_options.get("vector_store", {})
custom_llm_provider = vector_store_config.get("custom_llm_provider")
# Extract litellm_vector_store_params for custom name and description
litellm_vector_store_params = ingest_options.get("litellm_vector_store_params", {})
custom_vector_store_name = litellm_vector_store_params.get("vector_store_name")
custom_vector_store_description = litellm_vector_store_params.get("vector_store_description")
# Build file metadata entry using helper
file_entry = _build_file_metadata_entry(
response=response,
file_data=file_data,
file_url=file_url,
)
try:
# Check if vector store already exists in database
existing_vector_store = (
await prisma_client.db.litellm_managedvectorstorestable.find_unique(
where={"vector_store_id": vector_store_id}
)
)
# Only create if it doesn't exist
if existing_vector_store is None:
verbose_proxy_logger.info(
f"Saving newly created vector store {vector_store_id} to database"
)
# Initialize metadata with first file
initial_metadata = {
"ingested_files": [file_entry]
}
# Use custom name if provided, otherwise default
vector_store_name = custom_vector_store_name or f"RAG Vector Store - {vector_store_id[:8]}"
vector_store_description = custom_vector_store_description or "Created via RAG ingest endpoint"
await create_vector_store_in_db(
vector_store_id=vector_store_id,
custom_llm_provider=custom_llm_provider or "openai",
prisma_client=prisma_client,
vector_store_name=vector_store_name,
vector_store_description=vector_store_description,
vector_store_metadata=initial_metadata,
)
verbose_proxy_logger.info(
f"Vector store {vector_store_id} saved to database successfully"
)
else:
verbose_proxy_logger.info(
f"Vector store {vector_store_id} already exists, appending file to metadata"
)
# Update existing vector store with new file
existing_metadata = existing_vector_store.vector_store_metadata or {}
if isinstance(existing_metadata, str):
import json
existing_metadata = json.loads(existing_metadata)
ingested_files = existing_metadata.get("ingested_files", [])
ingested_files.append(file_entry)
existing_metadata["ingested_files"] = ingested_files
# Update the vector store
from litellm.proxy.utils import safe_dumps
await prisma_client.db.litellm_managedvectorstorestable.update(
where={"vector_store_id": vector_store_id},
data={"vector_store_metadata": safe_dumps(existing_metadata)}
)
verbose_proxy_logger.info(
f"Added file {file_entry.get('filename') or file_entry.get('file_url', 'Unknown')} to vector store {vector_store_id} metadata"
)
except Exception as db_error:
# Log the error but don't fail the request since ingestion succeeded
verbose_proxy_logger.exception(
f"Failed to save vector store {vector_store_id} to database: {db_error}"
)
async def parse_rag_ingest_request(
request: Request,
) -> Tuple[Dict[str, Any], Optional[Tuple[str, bytes, str]], Optional[str], Optional[str]]:
@@ -158,6 +336,7 @@ async def rag_ingest(
add_litellm_data_to_request,
general_settings,
llm_router,
prisma_client,
proxy_config,
version,
)
@@ -189,6 +368,25 @@ async def rag_ingest(
**request_data,
)
# Save vector store to database if it was newly created and prisma_client is available
verbose_proxy_logger.debug(
f"RAG Ingest - Checking database save conditions: prisma_client={prisma_client is not None}, response={response is not None}, response_type={type(response)}"
)
if prisma_client is not None and response is not None:
await _save_vector_store_to_db_from_rag_ingest(
response=response,
ingest_options=ingest_options,
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
file_data=file_data,
file_url=file_url,
)
else:
verbose_proxy_logger.warning(
f"Skipping database save: prisma_client={prisma_client is not None}, response={response is not None}"
)
return response
except HTTPException:
@@ -133,6 +133,112 @@ async def _resolve_embedding_config_from_db(
return None
########################################################
# Helper Functions
########################################################
async def create_vector_store_in_db(
vector_store_id: str,
custom_llm_provider: str,
prisma_client,
vector_store_name: Optional[str] = None,
vector_store_description: Optional[str] = None,
vector_store_metadata: Optional[Dict] = None,
litellm_params: Optional[Dict] = None,
litellm_credential_name: Optional[str] = None,
) -> LiteLLM_ManagedVectorStore:
"""
Helper function to create a vector store in the database.
This function handles:
- Checking if vector store already exists
- Creating the vector store in the database
- Adding it to the vector store registry
Returns:
LiteLLM_ManagedVectorStore: The created vector store object
Raises:
HTTPException: If vector store already exists or database error occurs
"""
from litellm.types.router import GenericLiteLLMParams
if prisma_client is None:
raise HTTPException(status_code=500, detail="Database not connected")
# Check if vector store already exists
existing_vector_store = (
await prisma_client.db.litellm_managedvectorstorestable.find_unique(
where={"vector_store_id": vector_store_id}
)
)
if existing_vector_store is not None:
raise HTTPException(
status_code=400,
detail=f"Vector store with ID {vector_store_id} already exists",
)
# Prepare data for database
data_to_create: Dict[str, Any] = {
"vector_store_id": vector_store_id,
"custom_llm_provider": custom_llm_provider,
}
if vector_store_name is not None:
data_to_create["vector_store_name"] = vector_store_name
if vector_store_description is not None:
data_to_create["vector_store_description"] = vector_store_description
if vector_store_metadata is not None:
data_to_create["vector_store_metadata"] = safe_dumps(vector_store_metadata)
if litellm_credential_name is not None:
data_to_create["litellm_credential_name"] = litellm_credential_name
# Handle litellm_params - always provide at least an empty dict
if litellm_params:
# Auto-resolve embedding config if embedding model is provided but config is not
embedding_model = litellm_params.get("litellm_embedding_model")
if embedding_model and not litellm_params.get("litellm_embedding_config"):
resolved_config = await _resolve_embedding_config_from_db(
embedding_model=embedding_model,
prisma_client=prisma_client
)
if resolved_config:
litellm_params["litellm_embedding_config"] = resolved_config
verbose_proxy_logger.info(
f"Auto-resolved embedding config for model {embedding_model}"
)
litellm_params_dict = GenericLiteLLMParams(
**litellm_params
).model_dump(exclude_none=True)
data_to_create["litellm_params"] = safe_dumps(litellm_params_dict)
else:
# Provide empty dict if no litellm_params provided
data_to_create["litellm_params"] = safe_dumps({})
# Create in database
_new_vector_store = (
await prisma_client.db.litellm_managedvectorstorestable.create(
data=data_to_create
)
)
new_vector_store: LiteLLM_ManagedVectorStore = LiteLLM_ManagedVectorStore(
**_new_vector_store.model_dump()
)
# Add vector store to registry
if litellm.vector_store_registry is not None:
litellm.vector_store_registry.add_vector_store_to_registry(
vector_store=new_vector_store
)
verbose_proxy_logger.info(
f"Vector store {vector_store_id} created in database successfully"
)
return new_vector_store
########################################################
# Management Endpoints
########################################################
@@ -156,71 +262,34 @@ async def new_vector_store(
- vector_store_metadata: Optional[Dict] - Additional metadata for the vector store
"""
from litellm.proxy.proxy_server import prisma_client
from litellm.types.router import GenericLiteLLMParams
if prisma_client is None:
raise HTTPException(status_code=500, detail="Database not connected")
try:
# Check if vector store already exists
existing_vector_store = (
await prisma_client.db.litellm_managedvectorstorestable.find_unique(
where={"vector_store_id": vector_store.get("vector_store_id")}
)
)
if existing_vector_store is not None:
vector_store_id = vector_store.get("vector_store_id")
custom_llm_provider = vector_store.get("custom_llm_provider")
if not vector_store_id or not custom_llm_provider:
raise HTTPException(
status_code=400,
detail=f"Vector store with ID {vector_store.get('vector_store_id')} already exists",
)
if vector_store.get("vector_store_metadata") is not None:
vector_store["vector_store_metadata"] = safe_dumps(
vector_store.get("vector_store_metadata")
)
# Safely handle JSON serialization of litellm_params
litellm_params_json: Optional[str] = None
_input_litellm_params: dict = vector_store.get("litellm_params", {}) or {}
if _input_litellm_params is not None:
# Auto-resolve embedding config if embedding model is provided but config is not
embedding_model = _input_litellm_params.get("litellm_embedding_model")
if embedding_model and not _input_litellm_params.get("litellm_embedding_config"):
resolved_config = await _resolve_embedding_config_from_db(
embedding_model=embedding_model,
prisma_client=prisma_client
)
if resolved_config:
_input_litellm_params["litellm_embedding_config"] = resolved_config
verbose_proxy_logger.info(
f"Auto-resolved embedding config for model {embedding_model}"
)
litellm_params_dict = GenericLiteLLMParams(
**_input_litellm_params
).model_dump(exclude_none=True)
litellm_params_json = safe_dumps(litellm_params_dict)
del vector_store["litellm_params"]
_new_vector_store = (
await prisma_client.db.litellm_managedvectorstorestable.create(
data={
**vector_store,
"litellm_params": litellm_params_json,
}
detail="vector_store_id and custom_llm_provider are required"
)
# Extract and validate metadata
metadata = vector_store.get("vector_store_metadata")
validated_metadata: Optional[Dict] = None
if metadata is not None and isinstance(metadata, dict):
validated_metadata = metadata
new_vector_store = await create_vector_store_in_db(
vector_store_id=vector_store_id,
custom_llm_provider=custom_llm_provider,
prisma_client=prisma_client,
vector_store_name=vector_store.get("vector_store_name"),
vector_store_description=vector_store.get("vector_store_description"),
vector_store_metadata=validated_metadata,
litellm_params=vector_store.get("litellm_params"),
litellm_credential_name=vector_store.get("litellm_credential_name"),
)
new_vector_store: LiteLLM_ManagedVectorStore = LiteLLM_ManagedVectorStore(
**_new_vector_store.model_dump()
)
# Add vector store to registry
if litellm.vector_store_registry is not None:
litellm.vector_store_registry.add_vector_store_to_registry(
vector_store=new_vector_store
)
return {
"status": "success",
"message": f"Vector store {vector_store.get('vector_store_id')} created successfully",
@@ -6948,6 +6948,62 @@ export const vectorStoreUpdateCall = async (accessToken: string, formValues: Rec
}
};
export const ragIngestCall = async (
accessToken: string,
file: File,
customLlmProvider: string,
vectorStoreId?: string,
vectorStoreName?: string,
vectorStoreDescription?: string
): Promise<any> => {
try {
let url = proxyBaseUrl ? `${proxyBaseUrl}/rag/ingest` : `/rag/ingest`;
const formData = new FormData();
formData.append("file", file);
const ingestOptions: any = {
ingest_options: {
vector_store: {
custom_llm_provider: customLlmProvider,
...(vectorStoreId && { vector_store_id: vectorStoreId }),
},
},
};
// Add litellm_vector_store_params if name or description provided
if (vectorStoreName || vectorStoreDescription) {
ingestOptions.ingest_options.litellm_vector_store_params = {};
if (vectorStoreName) {
ingestOptions.ingest_options.litellm_vector_store_params.vector_store_name = vectorStoreName;
}
if (vectorStoreDescription) {
ingestOptions.ingest_options.litellm_vector_store_params.vector_store_description = vectorStoreDescription;
}
}
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`;
@@ -0,0 +1,166 @@
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 vector_store_providers
vi.mock("../vector_store_providers", () => ({
VectorStoreProviders: {
BEDROCK: "Amazon Bedrock",
OPENAI: "OpenAI",
AZURE_OPENAI: "Azure OpenAI",
},
vectorStoreProviderMap: {
BEDROCK: "bedrock",
OPENAI: "openai",
AZURE_OPENAI: "azure_openai",
},
vectorStoreProviderLogoMap: {
"Amazon Bedrock": "https://example.com/bedrock.png",
"OpenAI": "https://example.com/openai.png",
"Azure OpenAI": "https://example.com/azure.png",
},
}));
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), "bedrock", 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();
});
});
});
@@ -0,0 +1,340 @@
import React, { useState } from "react";
import { Card, Title, Text } from "@tremor/react";
import { Upload, Button, Select, Form, message, Alert, Tooltip, Input } from "antd";
import { InboxOutlined, InfoCircleOutlined } from "@ant-design/icons";
import type { UploadProps } from "antd";
import { ragIngestCall } from "../networking";
import { DocumentUpload, RAGIngestResponse } from "./types";
import DocumentsTable from "./DocumentsTable";
import {
VectorStoreProviders,
vectorStoreProviderLogoMap,
vectorStoreProviderMap,
} from "../vector_store_providers";
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>("bedrock");
const [vectorStoreName, setVectorStoreName] = useState<string>("");
const [vectorStoreDescription, setVectorStoreDescription] = useState<string>("");
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
vectorStoreName || undefined,
vectorStoreDescription || undefined
);
// 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);
}
};
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 Vector Store Details */}
<Card>
<div className="space-y-4">
<div>
<Text className="font-medium">Step 2: Configure Vector Store</Text>
<Text className="text-sm text-gray-500 block mt-1">
Choose the provider and optionally provide a name and description for your vector store.
</Text>
</div>
<Form form={form} layout="vertical">
<Form.Item
label={
<span>
Vector Store Name{" "}
<Tooltip title="Optional: Give your vector store a meaningful name">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
>
<Input
value={vectorStoreName}
onChange={(e) => setVectorStoreName(e.target.value)}
placeholder="e.g., Product Documentation, Customer Support KB"
size="large"
className="rounded-md"
/>
</Form.Item>
<Form.Item
label={
<span>
Description{" "}
<Tooltip title="Optional: Describe what this vector store contains">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
>
<Input.TextArea
value={vectorStoreDescription}
onChange={(e) => setVectorStoreDescription(e.target.value)}
placeholder="e.g., Contains all product documentation and user guides"
rows={2}
size="large"
className="rounded-md"
/>
</Form.Item>
<Form.Item
label={
<span>
Provider{" "}
<Tooltip title="Select the provider for embedding and vector store operations">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
required
>
<Select
value={selectedProvider}
onChange={setSelectedProvider}
placeholder="Select a provider"
size="large"
style={{ width: "100%" }}
>
{Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => {
return (
<Select.Option key={providerEnum} value={vectorStoreProviderMap[providerEnum]}>
<div className="flex items-center space-x-2">
<img
src={vectorStoreProviderLogoMap[providerDisplayName]}
alt={`${providerEnum} logo`}
className="w-5 h-5"
onError={(e) => {
// Create a div with provider initial as fallback
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
const fallbackDiv = document.createElement("div");
fallbackDiv.className =
"w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center text-xs";
fallbackDiv.textContent = providerDisplayName.charAt(0);
parent.replaceChild(fallbackDiv, target);
}
}}
/>
<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;
@@ -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(/delete/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(/eye/i);
const copyButtons = screen.getAllByLabelText(/copy/i);
const deleteButtons = screen.getAllByLabelText(/delete/i);
expect(viewButtons).toHaveLength(3);
expect(copyButtons).toHaveLength(3);
expect(deleteButtons).toHaveLength(3);
});
});
@@ -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;
@@ -0,0 +1,90 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import TestVectorStoreTab from "./TestVectorStoreTab";
import { VectorStore } from "./types";
// Mock VectorStoreTester component
vi.mock("./VectorStoreTester", () => ({
VectorStoreTester: ({ vectorStoreId, accessToken }: { vectorStoreId: string; accessToken: string }) => (
<div data-testid="vector-store-tester">
<div data-testid="tester-vector-store-id">{vectorStoreId}</div>
<div data-testid="tester-access-token">{accessToken}</div>
</div>
),
}));
const mockVectorStores: VectorStore[] = [
{
vector_store_id: "vs_123",
custom_llm_provider: "openai",
vector_store_name: "Test Store 1",
vector_store_description: "Description 1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
vector_store_id: "vs_456",
custom_llm_provider: "bedrock",
vector_store_name: "Test Store 2",
vector_store_description: "Description 2",
created_at: "2024-01-02T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
},
];
describe("TestVectorStoreTab", () => {
it("should render the component successfully", () => {
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
expect(screen.getByText("Select Vector Store")).toBeInTheDocument();
expect(screen.getByText("Choose a vector store to test search queries against")).toBeInTheDocument();
});
it("should show message when no access token", () => {
render(<TestVectorStoreTab accessToken={null} vectorStores={mockVectorStores} />);
expect(screen.getByText("Access token is required to test vector stores.")).toBeInTheDocument();
});
it("should show message when no vector stores available", () => {
render(<TestVectorStoreTab accessToken="test-token" vectorStores={[]} />);
expect(screen.getByText("No vector stores available. Create one first to test it.")).toBeInTheDocument();
});
it("should render VectorStoreTester with first vector store by default", () => {
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
expect(screen.getByTestId("vector-store-tester")).toBeInTheDocument();
expect(screen.getByTestId("tester-vector-store-id")).toHaveTextContent("vs_123");
expect(screen.getByTestId("tester-access-token")).toHaveTextContent("test-token");
});
it("should update VectorStoreTester when selecting different vector store", () => {
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
// Find the select component
const selectElement = screen.getByRole("combobox");
// Change selection
fireEvent.mouseDown(selectElement);
// Wait for options to appear and click the second one
const option2 = screen.getByText("Test Store 2");
fireEvent.click(option2);
// Verify the tester component updated
expect(screen.getByTestId("tester-vector-store-id")).toHaveTextContent("vs_456");
});
it("should display vector store names in select options", () => {
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
const selectElement = screen.getByRole("combobox");
fireEvent.mouseDown(selectElement);
// Use getAllByText since the selected value also shows the name
expect(screen.getAllByText("Test Store 1").length).toBeGreaterThan(0);
expect(screen.getByText("Test Store 2")).toBeInTheDocument();
});
});
@@ -0,0 +1,75 @@
import React, { useState } from "react";
import { Card, Select, Typography } from "antd";
import { VectorStoreTester } from "./VectorStoreTester";
import { VectorStore } from "./types";
const { Text, Title } = Typography;
interface TestVectorStoreTabProps {
accessToken: string | null;
vectorStores: VectorStore[];
}
const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, vectorStores }) => {
const [selectedVectorStoreId, setSelectedVectorStoreId] = useState<string | undefined>(
vectorStores.length > 0 ? vectorStores[0].vector_store_id : undefined
);
if (!accessToken) {
return (
<Card>
<Text type="secondary">Access token is required to test vector stores.</Text>
</Card>
);
}
if (vectorStores.length === 0) {
return (
<Card>
<div className="text-center py-8">
<Text type="secondary">No vector stores available. Create one first to test it.</Text>
</div>
</Card>
);
}
return (
<div className="space-y-4">
<Card>
<div className="space-y-4">
<div>
<Title level={5}>Select Vector Store</Title>
<Text type="secondary">Choose a vector store to test search queries against</Text>
</div>
<Select
value={selectedVectorStoreId}
onChange={setSelectedVectorStoreId}
placeholder="Select a vector store"
size="large"
style={{ width: "100%" }}
showSearch
optionFilterProp="children"
>
{vectorStores.map((vs) => (
<Select.Option key={vs.vector_store_id} value={vs.vector_store_id}>
<div className="flex flex-col">
<span className="font-medium">{vs.vector_store_name || vs.vector_store_id}</span>
{vs.vector_store_name && (
<span className="text-xs text-gray-500 font-mono">{vs.vector_store_id}</span>
)}
</div>
</Select.Option>
))}
</Select>
</div>
</Card>
{selectedVectorStoreId && (
<VectorStoreTester vectorStoreId={selectedVectorStoreId} accessToken={accessToken} />
)}
</div>
);
};
export default TestVectorStoreTab;
@@ -130,9 +130,9 @@ describe("VectorStoreTable", () => {
expect(screen.getByText("Provider")).toBeInTheDocument();
expect(screen.getByText("Created At")).toBeInTheDocument();
expect(screen.getByText("Updated At")).toBeInTheDocument();
// Check that we have the expected number of header cells (6 data + 1 actions)
// Check that we have the expected number of header cells (7 data + 1 actions)
const headers = screen.getAllByRole("columnheader");
expect(headers).toHaveLength(7);
expect(headers).toHaveLength(8);
});
it("should render all vector store rows", () => {
@@ -183,7 +183,7 @@ describe("VectorStoreTable", () => {
it("should render fallback for missing name", () => {
renderComponent();
const fallbackElements = screen.getAllByText("-");
expect(fallbackElements.length).toBe(2); // One for missing name, one for missing description
expect(fallbackElements.length).toBe(3); // One for missing name, one for missing description, one for missing files
});
it("should wrap name in tooltip", () => {
@@ -203,7 +203,7 @@ describe("VectorStoreTable", () => {
it("should render fallback for missing description", () => {
renderComponent();
const fallbackElements = screen.getAllByText("-");
expect(fallbackElements.length).toBe(2); // One for missing name, one for missing description
expect(fallbackElements.length).toBe(3); // One for missing name, one for missing description, one for missing files
});
it("should wrap description in tooltip", () => {
@@ -386,7 +386,7 @@ describe("VectorStoreTable", () => {
it("should span all columns in empty state", () => {
renderComponent({ data: [] });
const emptyCell = screen.getByText("No vector stores found").closest("td");
expect(emptyCell).toHaveAttribute("colSpan", "7"); // 6 data columns + 1 actions column
expect(emptyCell).toHaveAttribute("colSpan", "8"); // 7 data columns + 1 actions column
});
});
@@ -403,7 +403,7 @@ describe("VectorStoreTable", () => {
renderComponent({ data: minimalData });
expect(screen.getByText("minimal")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(2); // Name and description fallbacks
expect(screen.getAllByText("-")).toHaveLength(3); // Name, description, and files fallbacks
});
it("should handle single vector store", () => {
@@ -66,6 +66,32 @@ const VectorStoreTable: React.FC<VectorStoreTableProps> = ({ data, onView, onEdi
);
},
},
{
header: "Files",
accessorKey: "vector_store_metadata",
cell: ({ row }) => {
const vectorStore = row.original;
const ingestedFiles = vectorStore.vector_store_metadata?.ingested_files || [];
if (ingestedFiles.length === 0) {
return <span className="text-xs text-gray-400">-</span>;
}
const filenames = ingestedFiles
.map((file) => file.filename || file.file_url || "Unknown")
.join(", ");
const displayText = ingestedFiles.length === 1
? ingestedFiles[0].filename || ingestedFiles[0].file_url || "1 file"
: `${ingestedFiles.length} files`;
return (
<Tooltip title={filenames}>
<span className="text-xs text-blue-600">{displayText}</span>
</Tooltip>
);
},
},
{
header: "Provider",
accessorKey: "custom_llm_provider",
@@ -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,8 @@ 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 TestVectorStoreTab from "./TestVectorStoreTab";
import { isAdminRole } from "@/utils/roles";
import NotificationsManager from "../molecules/notifications_manager";
@@ -101,6 +103,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 +142,46 @@ 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>
<Tab>Test Vector Store</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>
{/* Tab 3: Test Vector Store */}
<TabPanel>
<TestVectorStoreTab accessToken={accessToken} vectorStores={vectorStores} />
</TabPanel>
</TabPanels>
</TabGroup>
{/* Create Vector Store Modal */}
<VectorStoreForm
@@ -1,9 +1,23 @@
export interface IngestedFile {
file_id?: string;
filename?: string;
file_url?: string;
ingested_at: string;
file_size?: number;
content_type?: string;
}
export interface VectorStoreMetadata {
ingested_files?: IngestedFile[];
[key: string]: any;
}
export interface VectorStore {
vector_store_id: string;
custom_llm_provider: string;
vector_store_name?: string;
vector_store_description?: string;
vector_store_metadata?: Record<string, any>;
vector_store_metadata?: VectorStoreMetadata;
created_at: string;
updated_at: string;
created_by?: string;
@@ -41,3 +55,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;
}