feat(ui): migrate vector_store_management to shadcn

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
cursor 2026-04-24 07:39:11 +00:00
parent b0cc244882
commit 550301679c
No known key found for this signature in database
12 changed files with 1602 additions and 1332 deletions

View file

@ -1,4 +1,5 @@
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import CreateVectorStore from "./CreateVectorStore";
import * as networking from "../networking";
@ -206,25 +207,37 @@ describe("CreateVectorStore", () => {
});
});
const selectProvider = async (user: ReturnType<typeof userEvent.setup>, providerLabel: RegExp) => {
const providerSelect = screen.getByRole("combobox");
await act(async () => {
await user.click(providerSelect);
});
await screen.findByRole("option", { name: providerLabel });
// Walk the option list with keyboard — Radix Select closes reliably on Enter
// whereas user.click on an option inside a portal does not always close in jsdom.
const options = screen.getAllByRole("option");
const index = options.findIndex((o) => providerLabel.test(o.textContent ?? ""));
for (let i = 0; i <= index; i += 1) {
await act(async () => {
await user.keyboard("{ArrowDown}");
});
}
// Select highlighted option
await act(async () => {
await user.keyboard("{Enter}");
});
// Wait for portal to tear down
await waitFor(() => {
expect(document.body.getAttribute("data-scroll-locked")).toBeNull();
});
};
it("should display S3 Vectors provider-specific fields when selected", async () => {
const user = userEvent.setup();
render(<CreateVectorStore accessToken="test-token" />);
// Find and click the provider dropdown
const providerSelect = screen.getByRole("combobox");
await selectProvider(user, /AWS S3 Vectors/);
await act(async () => {
fireEvent.mouseDown(providerSelect);
});
// Wait for dropdown options to appear
await waitFor(() => {
const s3Option = screen.queryByText("AWS S3 Vectors");
if (s3Option) {
fireEvent.click(s3Option);
}
});
// Check if S3-specific fields are displayed
await waitFor(() => {
expect(screen.queryByText("Vector Bucket Name")).toBeInTheDocument();
expect(screen.queryByText("AWS Region")).toBeInTheDocument();
@ -233,9 +246,9 @@ describe("CreateVectorStore", () => {
});
it("should validate S3 Vectors required fields before submission", async () => {
const user = userEvent.setup();
render(<CreateVectorStore accessToken="test-token" />);
// Upload a file first
const file = new File(["test content"], "test.pdf", { type: "application/pdf" });
const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement;
@ -249,28 +262,15 @@ describe("CreateVectorStore", () => {
expect(screen.getByText("Uploaded Documents (1)")).toBeInTheDocument();
});
// Select S3 Vectors provider
const providerSelect = screen.getByRole("combobox");
await selectProvider(user, /AWS S3 Vectors/);
await act(async () => {
fireEvent.mouseDown(providerSelect);
});
await waitFor(() => {
const s3Option = screen.queryByText("AWS S3 Vectors");
if (s3Option) {
fireEvent.click(s3Option);
}
});
// Try to create without filling required fields
const createButton = screen.getByRole("button", { name: /Create Vector Store/i });
await act(async () => {
fireEvent.click(createButton);
});
// Should show validation warning (mocked message.warning would be called)
// The actual validation happens in the component
// validation runs in the component; just confirm no crash
expect(screen.getByText("Uploaded Documents (1)")).toBeInTheDocument();
});
});

View file

@ -1,10 +1,26 @@
import React, { useState } from "react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Card, Title, Text } from "@tremor/react";
import { Upload, Button, Select, Form, Alert, Tooltip, Input } from "antd";
import React, { useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import MessageManager from "@/components/molecules/message_manager";
import { Inbox as InboxOutlined, Info as InfoCircleOutlined } from "lucide-react";
import type { UploadProps } from "antd";
import { cn } from "@/lib/utils";
import { Inbox, Info } from "lucide-react";
import { ragIngestCall } from "../networking";
import { DocumentUpload, RAGIngestResponse } from "./types";
import DocumentsTable from "./DocumentsTable";
@ -18,15 +34,49 @@ import {
import NotificationsManager from "../molecules/notifications_manager";
import S3VectorsConfig from "./S3VectorsConfig";
const { Dragger } = Upload;
interface CreateVectorStoreProps {
accessToken: string | null;
onSuccess?: (vectorStoreId: string) => void;
}
const ACCEPTED_TYPES = [
"application/pdf",
"text/plain",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
"text/markdown",
];
function FieldLabel({
children,
tooltip,
htmlFor,
required,
}: {
children: React.ReactNode;
tooltip: string;
htmlFor?: string;
required?: boolean;
}) {
return (
<Label htmlFor={htmlFor} className="flex items-center gap-1">
<span>
{children}
{required && <span className="text-destructive"> *</span>}
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
);
}
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");
@ -34,53 +84,35 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
const [vectorStoreDescription, setVectorStoreDescription] = useState<string>("");
const [ingestResults, setIngestResults] = useState<RAGIngestResponse[]>([]);
const [providerParams, setProviderParams] = useState<Record<string, any>>({});
const [isDragActive, setIsDragActive] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
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) {
MessageManager.error(`${file.name} is not a supported file type. Please upload PDF, TXT, DOCX, or MD files.`);
return Upload.LIST_IGNORE;
const acceptFiles = (files: FileList | File[]) => {
const accepted: DocumentUpload[] = [];
for (const file of Array.from(files)) {
if (!ACCEPTED_TYPES.includes(file.type)) {
MessageManager.error(
`${file.name} is not a supported file type. Please upload PDF, TXT, DOCX, or MD files.`,
);
continue;
}
const isLt50M = file.size / 1024 / 1024 < 50;
if (!isLt50M) {
if (file.size / 1024 / 1024 >= 50) {
MessageManager.error(`${file.name} must be smaller than 50MB!`);
return Upload.LIST_IGNORE;
continue;
}
const newDoc: DocumentUpload = {
uid: file.uid,
accepted.push({
uid: `${file.name}-${file.size}-${file.lastModified}-${Math.random()}`,
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
});
}
if (accepted.length > 0) {
setDocuments((prev) => [...prev, ...accepted]);
}
};
const handleRemoveDocument = (uid: string) => {
@ -98,7 +130,6 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
return;
}
// Validate provider-specific required fields
const requiredFields = getProviderSpecificFields(selectedProvider).filter((field) => field.required);
for (const field of requiredFields) {
if (!providerParams[field.name]) {
@ -107,7 +138,6 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
}
}
// S3 Vectors specific validation
if (selectedProvider === "s3_vectors") {
if (providerParams.vector_bucket_name && providerParams.vector_bucket_name.length < 3) {
MessageManager.warning("Vector bucket name must be at least 3 characters");
@ -129,13 +159,11 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
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))
prev.map((d) => (d.uid === doc.uid ? { ...d, status: "uploading" as const } : d)),
);
try {
@ -143,43 +171,40 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
accessToken,
doc.originFileObj,
selectedProvider,
vectorStoreId, // Use the same vector store ID for subsequent uploads
vectorStoreId,
vectorStoreName || undefined,
vectorStoreDescription || undefined,
providerParams
providerParams,
);
// 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))
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))
prev.map((d) => (d.uid === doc.uid ? { ...d, status: "error" as const } : d)),
);
throw error; // Stop processing on first error
throw error;
}
}
setIngestResults(results);
setShowSuccessAlert(true);
NotificationsManager.success(
`Successfully created vector store with ${results.length} document(s). Vector Store ID: ${vectorStoreId}`
`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([]);
@ -192,142 +217,208 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
}
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(false);
if (e.dataTransfer?.files?.length) {
acceptFiles(e.dataTransfer.files);
}
};
const renderProviderField = (field: VectorStoreFieldConfig) => {
const fieldId = `provider-field-${field.name}`;
if (field.type === "select") {
return (
<div key={field.name} className="mb-4 space-y-1">
<FieldLabel htmlFor={fieldId} tooltip={field.tooltip} required={field.required}>
{field.label}
</FieldLabel>
<Input
id={fieldId}
value={providerParams[field.name] || ""}
onChange={(e) =>
setProviderParams((prev) => ({ ...prev, [field.name]: e.target.value }))
}
placeholder={field.placeholder}
className="rounded-md"
/>
</div>
);
}
return (
<div key={field.name} className="mb-4 space-y-1">
<FieldLabel htmlFor={fieldId} tooltip={field.tooltip} required={field.required}>
{field.label}
</FieldLabel>
<Input
id={fieldId}
type={field.type === "password" ? "password" : "text"}
value={providerParams[field.name] || ""}
onChange={(e) =>
setProviderParams((prev) => ({ ...prev, [field.name]: e.target.value }))
}
placeholder={field.placeholder}
className="rounded-md"
/>
</div>
);
};
return (
<div className="space-y-6">
<div>
<Title>Create Vector Store</Title>
<Text className="text-gray-500">
<h2 className="text-2xl font-semibold m-0">Create Vector Store</h2>
<p className="text-muted-foreground text-sm m-0">
Upload documents and select a provider to create a new vector store with embedded content.
</Text>
</p>
</div>
{/* Upload Area */}
<Card>
<Card className="p-6">
<div className="mb-4">
<Text className="font-medium">Step 1: Upload Documents</Text>
<Text className="text-sm text-gray-500 block mt-1">
<p className="font-medium">Step 1: Upload Documents</p>
<p className="text-sm text-muted-foreground 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">
</div>
<div
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click();
}}
onDragOver={(e) => {
e.preventDefault();
setIsDragActive(true);
}}
onDragLeave={() => setIsDragActive(false)}
onDrop={handleDrop}
className={cn(
"flex flex-col items-center justify-center gap-2 border-2 border-dashed rounded-md p-8 cursor-pointer transition-colors",
isDragActive
? "border-primary bg-accent/50"
: "border-border hover:border-primary",
)}
>
<Inbox className="h-12 w-12 text-primary" />
<p className="text-sm font-medium">Click or drag files to this area to upload</p>
<p className="text-xs text-muted-foreground">
Support for single or bulk upload. Supported formats: PDF, TXT, DOCX, MD
</p>
</Dragger>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.txt,.docx,.md,.doc"
className="hidden"
onChange={(e) => {
if (e.target.files?.length) acceptFiles(e.target.files);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
/>
</div>
</Card>
{/* Documents Table */}
{documents.length > 0 && (
<Card>
<Card className="p-6">
<div className="mb-4">
<Text className="font-medium">Uploaded Documents ({documents.length})</Text>
<p className="font-medium">Uploaded Documents ({documents.length})</p>
</div>
<DocumentsTable documents={documents} onRemove={handleRemoveDocument} />
</Card>
)}
{/* Provider Selection and Vector Store Details */}
<Card>
<Card className="p-6">
<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">
<p className="font-medium">Step 2: Configure Vector Store</p>
<p className="text-sm text-muted-foreground mt-1">
Choose the provider and optionally provide a name and description for your vector store.
</Text>
</p>
</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>
}
>
<div className="space-y-4">
<div className="space-y-1">
<FieldLabel
htmlFor="vector-store-name"
tooltip="Optional: Give your vector store a meaningful name"
>
Vector Store Name
</FieldLabel>
<Input
id="vector-store-name"
value={vectorStoreName}
onChange={(e) => setVectorStoreName(e.target.value)}
placeholder="e.g., Product Documentation, Customer Support KB"
size="large"
className="rounded-md"
/>
</Form.Item>
</div>
<Form.Item
label={
<span>
Description{" "}
<Tooltip title="Optional: Describe what this vector store contains">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
>
<Input.TextArea
<div className="space-y-1">
<FieldLabel
htmlFor="vector-store-description"
tooltip="Optional: Describe what this vector store contains"
>
Description
</FieldLabel>
<Textarea
id="vector-store-description"
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>
</div>
<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%" }}
<div className="space-y-1">
<FieldLabel
htmlFor="vector-store-provider"
tooltip="Select the provider for embedding and vector store operations"
required
>
{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>
);
})}
Provider
</FieldLabel>
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
<SelectTrigger id="vector-store-provider" className="w-full">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{Object.entries(VectorStoreProviders).map(
([providerEnum, providerDisplayName]) => (
<SelectItem
key={providerEnum}
value={vectorStoreProviderMap[providerEnum]}
>
<div className="flex items-center space-x-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={vectorStoreProviderLogoMap[providerDisplayName]}
alt={`${providerEnum} logo`}
className="w-5 h-5"
onError={(e) => {
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-muted flex items-center justify-center text-xs";
fallbackDiv.textContent = providerDisplayName.charAt(0);
parent.replaceChild(fallbackDiv, target);
}
}}
/>
<span>{providerDisplayName}</span>
</div>
</SelectItem>
),
)}
</SelectContent>
</Select>
</Form.Item>
</div>
{/* S3 Vectors Configuration */}
{selectedProvider === "s3_vectors" && (
<S3VectorsConfig
accessToken={accessToken}
@ -336,73 +427,14 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
/>
)}
{/* Other Provider-specific fields */}
{selectedProvider !== "s3_vectors" &&
getProviderSpecificFields(selectedProvider).map((field: VectorStoreFieldConfig) => {
if (field.type === "select") {
// For embedding model selection, we'd need to fetch available models
// For now, provide a text input as fallback
return (
<Form.Item
key={field.name}
label={
<span>
{field.label}{" "}
<Tooltip title={field.tooltip}>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
required={field.required}
>
<Input
value={providerParams[field.name] || ""}
onChange={(e) =>
setProviderParams((prev) => ({ ...prev, [field.name]: e.target.value }))
}
placeholder={field.placeholder}
size="large"
className="rounded-md"
/>
</Form.Item>
);
}
return (
<Form.Item
key={field.name}
label={
<span>
{field.label}{" "}
<Tooltip title={field.tooltip}>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
required={field.required}
>
<Input
type={field.type === "password" ? "password" : "text"}
value={providerParams[field.name] || ""}
onChange={(e) =>
setProviderParams((prev) => ({ ...prev, [field.name]: e.target.value }))
}
placeholder={field.placeholder}
size="large"
className="rounded-md"
/>
</Form.Item>
);
})}
</Form>
getProviderSpecificFields(selectedProvider).map(renderProviderField)}
</div>
<div className="flex justify-end">
<Button
type="primary"
size="large"
onClick={handleCreateVectorStore}
loading={isCreating}
disabled={documents.length === 0 || !selectedProvider}
disabled={isCreating || documents.length === 0 || !selectedProvider}
>
{isCreating ? "Creating Vector Store..." : "Create Vector Store"}
</Button>
@ -410,11 +442,20 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
</div>
</Card>
{/* Success Message */}
{ingestResults.length > 0 && (
<Alert
message="Vector Store Created Successfully"
description={
{ingestResults.length > 0 && showSuccessAlert && (
<Alert>
<AlertTitle className="flex items-center justify-between">
<span>Vector Store Created Successfully</span>
<button
type="button"
onClick={() => setShowSuccessAlert(false)}
className="text-muted-foreground hover:text-foreground"
aria-label="Dismiss"
>
×
</button>
</AlertTitle>
<AlertDescription>
<div>
<p>
<strong>Vector Store ID:</strong> {ingestResults[0]?.vector_store_id}
@ -423,11 +464,8 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
<strong>Documents Ingested:</strong> {ingestResults.length}
</p>
</div>
}
type="success"
showIcon
closable
/>
</AlertDescription>
</Alert>
)}
</div>
);

View file

@ -3,16 +3,14 @@ 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(),
},
};
});
vi.mock("@/components/molecules/message_manager", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}));
describe("DocumentsTable", () => {
const mockDocuments: DocumentUpload[] = [
@ -91,7 +89,7 @@ describe("DocumentsTable", () => {
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
// Each document should have 3 action buttons (view, copy, delete)
const viewButtons = screen.getAllByLabelText(/eye/i);
const viewButtons = screen.getAllByLabelText(/view/i);
const copyButtons = screen.getAllByLabelText(/copy/i);
const deleteButtons = screen.getAllByLabelText(/delete/i);

View file

@ -1,5 +1,12 @@
import React from "react";
import { Table } from "antd";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
@ -63,97 +70,90 @@ const DocumentsTable: React.FC<DocumentsTableProps> = ({
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-muted-foreground">
({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: (_: unknown, record: DocumentUpload) => (
<div className="flex items-center space-x-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => console.log("View", record)}
className="cursor-pointer text-muted-foreground hover:text-primary"
aria-label="View details"
>
<Eye className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>View details</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleCopyId(record.uid)}
className="cursor-pointer text-muted-foreground hover:text-primary"
aria-label="Copy ID"
>
<Copy className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Copy ID</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onRemove(record.uid)}
className="cursor-pointer text-muted-foreground hover:text-destructive"
aria-label="Remove"
>
<Trash2 className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
),
},
];
if (documents.length === 0) {
return (
<div className="py-6 text-center text-sm text-muted-foreground">
No documents uploaded yet. Upload documents above to get started.
</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"
/>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-[150px]">Status</TableHead>
<TableHead className="w-[120px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.map((doc) => (
<TableRow key={doc.uid}>
<TableCell>
<div className="flex items-center space-x-2">
<span className="text-sm">{doc.name}</span>
{doc.size && (
<span className="text-xs text-muted-foreground">
({formatFileSize(doc.size)})
</span>
)}
</div>
</TableCell>
<TableCell>{getStatusBadge(doc.status)}</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => console.log("View", doc)}
className="cursor-pointer text-muted-foreground hover:text-primary"
aria-label="View details"
>
<Eye className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>View details</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleCopyId(doc.uid)}
className="cursor-pointer text-muted-foreground hover:text-primary"
aria-label="Copy ID"
>
<Copy className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Copy ID</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onRemove(doc.uid)}
className="cursor-pointer text-muted-foreground hover:text-destructive"
aria-label="Delete document"
>
<Trash2 className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};

View file

@ -1,13 +1,19 @@
import React, { useState, useEffect } from "react";
import { Form, Select } from "antd";
import React, { useState, useEffect, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Info } from "lucide-react";
import { cn } from "@/lib/utils";
import { ChevronDown, Info, Search } from "lucide-react";
import {
fetchAvailableModels,
ModelGroup,
@ -21,6 +27,119 @@ interface S3VectorsConfigProps {
onParamsChange: (params: Record<string, any>) => void;
}
interface FieldShellProps {
label: string;
required?: boolean;
tooltip: string;
htmlFor: string;
error?: string;
children: React.ReactNode;
}
function FieldShell({ label, required, tooltip, htmlFor, error, children }: FieldShellProps) {
return (
<div className="mb-4 space-y-1">
<Label htmlFor={htmlFor} className="flex items-center gap-1">
<span>
{label}
{required && <span className="text-destructive"> *</span>}
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
{children}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
interface EmbeddingSelectProps {
value: string | undefined;
onChange: (value: string) => void;
options: { label: string; value: string }[];
loading: boolean;
id: string;
}
function EmbeddingSelect({ value, onChange, options, loading, id }: EmbeddingSelectProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const filtered = useMemo(
() =>
options.filter((o) =>
query ? o.label.toLowerCase().includes(query.toLowerCase()) : true,
),
[options, query],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
id={id}
type="button"
role="combobox"
aria-expanded={open}
aria-controls={`${id}-listbox`}
className={cn(
"h-9 w-full flex items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm text-left",
)}
>
<span className={value ? "" : "text-muted-foreground"}>
{value || (loading ? "Loading models…" : "Select an embedding model")}
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
id={`${id}-listbox`}
className="w-[var(--radix-popover-trigger-width)] p-2"
>
<div className="relative mb-2">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
autoFocus
placeholder="Search models…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 pl-8"
/>
</div>
<div className="max-h-60 overflow-y-auto">
{filtered.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
{loading ? "Loading models…" : "No matches"}
</div>
) : (
filtered.map((opt) => (
<button
key={opt.value}
type="button"
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent"
onClick={() => {
onChange(opt.value);
setOpen(false);
setQuery("");
}}
>
{opt.label}
</button>
))
)}
</div>
</PopoverContent>
</Popover>
);
}
const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({
accessToken,
providerParams,
@ -36,7 +155,6 @@ const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({
setIsLoadingModels(true);
try {
const models = await fetchAvailableModels(accessToken);
// Filter for embedding models only
const embeddingOnly = models.filter((model) => model.mode === "embedding");
setEmbeddingModels(embeddingOnly);
} catch (error) {
@ -56,6 +174,17 @@ const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({
});
};
const bucketError =
providerParams.vector_bucket_name && providerParams.vector_bucket_name.length < 3
? "Bucket name must be at least 3 characters"
: undefined;
const indexError =
providerParams.index_name &&
providerParams.index_name.length > 0 &&
providerParams.index_name.length < 3
? "Index name must be at least 3 characters if provided"
: undefined;
return (
<>
<div className="mb-4 flex gap-2 items-start p-3 rounded-md bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-900 text-blue-800 dark:text-blue-200">
@ -94,146 +223,69 @@ const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({
</div>
</div>
{/* Vector Bucket Name */}
<Form.Item
label={
<span>
Vector Bucket Name{" "}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-1 h-3 w-3 inline text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
S3 bucket name for vector storage (must be at least 3
characters, lowercase letters, numbers, hyphens, and
periods only)
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
}
<FieldShell
label="Vector Bucket Name"
required
validateStatus={
providerParams.vector_bucket_name && providerParams.vector_bucket_name.length < 3
? "error"
: undefined
}
help={
providerParams.vector_bucket_name && providerParams.vector_bucket_name.length < 3
? "Bucket name must be at least 3 characters"
: undefined
}
htmlFor="s3-vector-bucket-name"
tooltip="S3 bucket name for vector storage (must be at least 3 characters, lowercase letters, numbers, hyphens, and periods only)"
error={bucketError}
>
<Input
id="s3-vector-bucket-name"
value={providerParams.vector_bucket_name || ""}
onChange={(e) => handleFieldChange("vector_bucket_name", e.target.value)}
placeholder="my-vector-bucket (min 3 chars)"
className="rounded-md"
/>
</Form.Item>
</FieldShell>
{/* Index Name (Optional) */}
<Form.Item
label={
<span>
Index Name{" "}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-1 h-3 w-3 inline text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Name for the vector index (optional, will be auto-generated
if not provided). If provided, must be at least 3
characters.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
}
validateStatus={
providerParams.index_name && providerParams.index_name.length > 0 && providerParams.index_name.length < 3
? "error"
: undefined
}
help={
providerParams.index_name && providerParams.index_name.length > 0 && providerParams.index_name.length < 3
? "Index name must be at least 3 characters if provided"
: undefined
}
<FieldShell
label="Index Name"
htmlFor="s3-index-name"
tooltip="Name for the vector index (optional, will be auto-generated if not provided). If provided, must be at least 3 characters."
error={indexError}
>
<Input
id="s3-index-name"
value={providerParams.index_name || ""}
onChange={(e) => handleFieldChange("index_name", e.target.value)}
placeholder="my-vector-index (optional, min 3 chars)"
className="rounded-md"
/>
</Form.Item>
</FieldShell>
{/* AWS Region */}
<Form.Item
label={
<span>
AWS Region{" "}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-1 h-3 w-3 inline text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
AWS region where the S3 bucket is located (e.g., us-west-2)
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
}
<FieldShell
label="AWS Region"
required
htmlFor="s3-aws-region"
tooltip="AWS region where the S3 bucket is located (e.g., us-west-2)"
>
<Input
id="s3-aws-region"
value={providerParams.aws_region_name || ""}
onChange={(e) => handleFieldChange("aws_region_name", e.target.value)}
placeholder="us-west-2"
className="rounded-md"
/>
</Form.Item>
</FieldShell>
{/* Embedding Model */}
<Form.Item
label={
<span>
Embedding Model{" "}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-1 h-3 w-3 inline text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Select the embedding model to use for vector generation
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
}
<FieldShell
label="Embedding Model"
required
htmlFor="s3-embedding-model"
tooltip="Select the embedding model to use for vector generation"
>
<Select
<EmbeddingSelect
id="s3-embedding-model"
value={providerParams.embedding_model || undefined}
onChange={(value) => handleFieldChange("embedding_model", value)}
placeholder="Select an embedding model"
size="large"
showSearch
onChange={(v) => handleFieldChange("embedding_model", v)}
loading={isLoadingModels}
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
options={embeddingModels.map((model) => ({
value: model.model_group,
label: model.model_group,
options={embeddingModels.map((m) => ({
label: m.model_group,
value: m.model_group,
}))}
style={{ width: "100%" }}
/>
</Form.Item>
</FieldShell>
</>
);
};

View file

@ -1,4 +1,5 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import TestVectorStoreTab from "./TestVectorStoreTab";
import { VectorStore } from "./types";
@ -60,31 +61,38 @@ describe("TestVectorStoreTab", () => {
expect(screen.getByTestId("tester-access-token")).toHaveTextContent("test-token");
});
it("should update VectorStoreTester when selecting different vector store", () => {
it("should update VectorStoreTester when selecting different vector store", async () => {
const user = userEvent.setup();
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
// Find the select component
const selectElement = screen.getByRole("combobox");
await act(async () => {
await user.click(selectElement);
});
// Change selection
fireEvent.mouseDown(selectElement);
// Wait for options to be rendered
await screen.findByRole("option", { name: /Test Store 2/ });
// Wait for options to appear and click the second one
const option2 = screen.getByText("Test Store 2");
fireEvent.click(option2);
// Radix Select reliably responds to keyboard navigation in jsdom
await act(async () => {
await user.keyboard("{ArrowDown}{Enter}");
});
// Verify the tester component updated
expect(screen.getByTestId("tester-vector-store-id")).toHaveTextContent("vs_456");
await vi.waitFor(() => {
expect(screen.getByTestId("tester-vector-store-id")).toHaveTextContent("vs_456");
});
});
it("should display vector store names in select options", () => {
it("should display vector store names in select options", async () => {
const user = userEvent.setup();
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
const selectElement = screen.getByRole("combobox");
fireEvent.mouseDown(selectElement);
await act(async () => {
await user.click(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();
expect(await screen.findByText("Test Store 2")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,12 @@
import React, { useState } from "react";
import { Select } from "antd";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { VectorStoreTester } from "./VectorStoreTester";
import { VectorStore } from "./types";
@ -52,30 +58,30 @@ const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({
<Select
value={selectedVectorStoreId}
onChange={setSelectedVectorStoreId}
placeholder="Select a vector store"
size="large"
style={{ width: "100%" }}
showSearch
optionFilterProp="children"
onValueChange={setSelectedVectorStoreId}
>
{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-muted-foreground font-mono">
{vs.vector_store_id}
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a vector store" />
</SelectTrigger>
<SelectContent>
{vectorStores.map((vs) => (
<SelectItem
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>
)}
</div>
</Select.Option>
))}
{vs.vector_store_name && (
<span className="text-xs text-muted-foreground font-mono">
{vs.vector_store_id}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</Card>

View file

@ -1,8 +1,31 @@
import React, { useState, useEffect } from "react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { TextInput, Button as TremorButton } from "@tremor/react";
import { Modal, Form, Select, Tooltip, Input, Alert } from "antd";
import { Info as InfoCircleOutlined } from "lucide-react";
import React, { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Info } from "lucide-react";
import { CredentialItem, vectorStoreCreateCall } from "../networking";
import {
VectorStoreProviders,
@ -22,6 +45,44 @@ interface VectorStoreFormProps {
credentials: CredentialItem[];
}
interface FormValues {
custom_llm_provider: string;
vector_store_id: string;
vector_store_name?: string;
vector_store_description?: string;
litellm_credential_name?: string | null;
[key: string]: any;
}
function LabelWithTooltip({
children,
tooltip,
required,
htmlFor,
}: {
children: React.ReactNode;
tooltip: string;
required?: boolean;
htmlFor?: string;
}) {
return (
<Label htmlFor={htmlFor} className="flex items-center gap-1">
<span>
{children}
{required && <span className="text-destructive"> *</span>}
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
);
}
const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
isVisible,
onCancel,
@ -29,11 +90,29 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
accessToken,
credentials,
}) => {
const [form] = Form.useForm();
const {
register,
handleSubmit,
control,
reset,
watch,
setValue,
formState: { errors },
} = useForm<FormValues>({
defaultValues: {
custom_llm_provider: "bedrock",
vector_store_id: "",
vector_store_name: "",
vector_store_description: "",
litellm_credential_name: null,
},
});
const [metadataJson, setMetadataJson] = useState("{}");
const [selectedProvider, setSelectedProvider] = useState("bedrock");
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const selectedProvider = watch("custom_llm_provider");
useEffect(() => {
if (!accessToken) return;
@ -51,20 +130,23 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
loadModels();
}, [accessToken]);
const handleCreate = async (formValues: any) => {
const resetAndClose = () => {
reset();
setMetadataJson("{}");
};
const handleCreate = async (formValues: FormValues) => {
if (!accessToken) return;
try {
// Parse metadata JSON
let metadata = {};
let metadata: Record<string, any> = {};
try {
metadata = metadataJson.trim() ? JSON.parse(metadataJson) : {};
} catch (e) {
} catch {
NotificationsManager.fromBackend("Invalid JSON in metadata field");
return;
}
// Prepare the payload with provider-specific fields
const payload: any = {
const payload: Record<string, any> = {
vector_store_id: formValues.vector_store_id,
custom_llm_provider: formValues.custom_llm_provider,
vector_store_name: formValues.vector_store_name,
@ -73,11 +155,9 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
litellm_credential_name: formValues.litellm_credential_name,
};
// pass all provider fields as litellm params dict
const providerFields = getProviderSpecificFields(formValues.custom_llm_provider);
const litellmParams = providerFields.reduce(
(acc, field) => {
// Special handling for Milvus: rename embedding_model to litellm_embedding_model
if (formValues.custom_llm_provider === "milvus" && field.name === "embedding_model") {
acc["litellm_embedding_model"] = formValues[field.name];
} else {
@ -92,8 +172,7 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
await vectorStoreCreateCall(accessToken, payload);
NotificationsManager.success("Vector store created successfully");
form.resetFields();
setMetadataJson("{}");
resetAndClose();
onSuccess();
} catch (error) {
console.error("Error creating vector store:", error);
@ -101,70 +180,173 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
}
};
const handleCancel = () => {
form.resetFields();
setMetadataJson("{}");
setSelectedProvider("bedrock");
const handleClose = () => {
resetAndClose();
onCancel();
};
return (
<Modal title="Add New Vector Store" open={isVisible} width={1000} footer={null} onCancel={handleCancel}>
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
<Form.Item
label={
<span>
Provider{" "}
<Tooltip title="Select the provider for this vector store">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="custom_llm_provider"
rules={[{ required: true, message: "Please select a provider" }]}
initialValue="bedrock"
>
<Select onChange={(value) => setSelectedProvider(value)}>
{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>
const embeddingModels = modelInfo
.filter((option) => option.mode === "embedding" || option.mode === null)
.map((option) => ({
value: option.model_group,
label: option.model_group,
}));
{/* PG Vector Setup Instructions */}
{selectedProvider === "pg_vector" && (
<Alert
message="PG Vector Setup Required"
description={
<div>
const renderProviderField = (field: VectorStoreFieldConfig) => {
const fieldId = `provider-field-${field.name}`;
if (field.type === "select") {
return (
<div key={field.name} className="space-y-1">
<LabelWithTooltip htmlFor={fieldId} tooltip={field.tooltip} required={field.required}>
{field.label}
</LabelWithTooltip>
<Controller
control={control}
name={field.name}
rules={
field.required
? { required: `Please select the ${field.label.toLowerCase()}` }
: undefined
}
render={({ field: rhf }) => (
<Select value={rhf.value || undefined} onValueChange={rhf.onChange}>
<SelectTrigger id={fieldId} className="w-full">
<SelectValue placeholder={field.placeholder || `Select ${field.label}`} />
</SelectTrigger>
<SelectContent>
{embeddingModels.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
No options available
</div>
) : (
embeddingModels.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
/>
{errors[field.name] && (
<p className="text-xs text-destructive">
{String((errors[field.name] as any)?.message)}
</p>
)}
</div>
);
}
return (
<div key={field.name} className="space-y-1">
<LabelWithTooltip htmlFor={fieldId} tooltip={field.tooltip} required={field.required}>
{field.label}
</LabelWithTooltip>
<Input
id={fieldId}
type={field.type || "text"}
placeholder={field.placeholder}
{...register(field.name, {
required: field.required
? `Please input the ${field.label.toLowerCase()}`
: false,
})}
/>
{errors[field.name] && (
<p className="text-xs text-destructive">
{String((errors[field.name] as any)?.message)}
</p>
)}
</div>
);
};
return (
<Dialog
open={isVisible}
onOpenChange={(open) => {
if (!open) handleClose();
}}
>
<DialogContent className="sm:max-w-[1000px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add New Vector Store</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(handleCreate)} className="space-y-4">
<div className="space-y-1">
<LabelWithTooltip
htmlFor="vs-provider"
tooltip="Select the provider for this vector store"
required
>
Provider
</LabelWithTooltip>
<Controller
control={control}
name="custom_llm_provider"
rules={{ required: "Please select a provider" }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="vs-provider" className="w-full">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{Object.entries(VectorStoreProviders).map(
([providerEnum, providerDisplayName]) => (
<SelectItem
key={providerEnum}
value={vectorStoreProviderMap[providerEnum]}
>
<div className="flex items-center space-x-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={vectorStoreProviderLogoMap[providerDisplayName]}
alt={`${providerEnum} logo`}
className="w-5 h-5"
onError={(e) => {
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-muted flex items-center justify-center text-xs";
fallbackDiv.textContent = providerDisplayName.charAt(0);
parent.replaceChild(fallbackDiv, target);
}
}}
/>
<span>{providerDisplayName}</span>
</div>
</SelectItem>
),
)}
</SelectContent>
</Select>
)}
/>
{errors.custom_llm_provider && (
<p className="text-xs text-destructive">
{errors.custom_llm_provider.message as string}
</p>
)}
</div>
{selectedProvider === "pg_vector" && (
<Alert>
<AlertTitle>PG Vector Setup Required</AlertTitle>
<AlertDescription>
<p>LiteLLM provides a server to connect to PG Vector. To use this provider:</p>
<ol style={{ marginLeft: "16px", marginTop: "8px" }}>
<ol className="ml-4 mt-2 list-decimal">
<li>
Deploy the litellm-pgvector server from:{" "}
<a href="https://github.com/BerriAI/litellm-pgvector" target="_blank" rel="noopener noreferrer">
<a
href="https://github.com/BerriAI/litellm-pgvector"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
https://github.com/BerriAI/litellm-pgvector
</a>
</li>
@ -172,28 +354,23 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
<li>Start the server and note the API base URL and API key</li>
<li>Enter those details in the fields below</li>
</ol>
</div>
}
type="info"
showIcon
style={{ marginBottom: "16px" }}
/>
)}
</AlertDescription>
</Alert>
)}
{/* Vertex RAG Engine Setup Instructions */}
{selectedProvider === "vertex_rag_engine" && (
<Alert
message="Vertex AI RAG Engine Setup"
description={
<div>
{selectedProvider === "vertex_rag_engine" && (
<Alert>
<AlertTitle>Vertex AI RAG Engine Setup</AlertTitle>
<AlertDescription>
<p>To use Vertex AI RAG Engine:</p>
<ol style={{ marginLeft: "16px", marginTop: "8px" }}>
<ol className="ml-4 mt-2 list-decimal">
<li>
Set up your Vertex AI RAG Engine corpus following the guide:{" "}
<a
href="https://cloud.google.com/vertex-ai/generative-ai/docs/rag-engine/rag-overview"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Vertex AI RAG Engine Overview
</a>
@ -202,166 +379,115 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
<li>Note the corpus ID from the Vertex AI console</li>
<li>Enter the corpus ID in the Vector Store ID field below</li>
</ol>
</div>
}
type="info"
showIcon
style={{ marginBottom: "16px" }}
/>
)}
</AlertDescription>
</Alert>
)}
<Form.Item
label={
<span>
Vector Store ID{" "}
<Tooltip title="Enter the vector store ID from your api provider">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="vector_store_id"
rules={[{ required: true, message: "Please input the vector store ID from your api provider" }]}
>
<TextInput
placeholder={
selectedProvider === "vertex_rag_engine"
? "6917529027641081856 (Get corpus ID from Vertex AI console)"
: "Enter vector store ID from your provider"
}
/>
</Form.Item>
{/* Provider-specific fields */}
{getProviderSpecificFields(selectedProvider).map((field: VectorStoreFieldConfig) => {
if (field.type === "select") {
const embeddingModels = modelInfo
.filter((option: ModelGroup) => option.mode === "embedding" || option.mode === null)
.map((option: ModelGroup) => ({
value: option.model_group,
label: option.model_group,
}));
return (
<Form.Item
key={field.name}
label={
<span>
{field.label}{" "}
<Tooltip title={field.tooltip}>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name={field.name}
rules={
field.required ? [{ required: true, message: `Please select the ${field.label.toLowerCase()}` }] : []
}
>
<Select
placeholder={field.placeholder}
showSearch={true}
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
options={embeddingModels}
style={{ width: "100%" }}
/>
</Form.Item>
);
}
return (
<Form.Item
key={field.name}
label={
<span>
{field.label}{" "}
<Tooltip title={field.tooltip}>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name={field.name}
rules={
field.required ? [{ required: true, message: `Please input the ${field.label.toLowerCase()}` }] : []
}
<div className="space-y-1">
<LabelWithTooltip
htmlFor="vs-id"
tooltip="Enter the vector store ID from your api provider"
required
>
<TextInput type={field.type || "text"} placeholder={field.placeholder} />
</Form.Item>
);
})}
Vector Store ID
</LabelWithTooltip>
<Input
id="vs-id"
placeholder={
selectedProvider === "vertex_rag_engine"
? "6917529027641081856 (Get corpus ID from Vertex AI console)"
: "Enter vector store ID from your provider"
}
{...register("vector_store_id", {
required: "Please input the vector store ID from your api provider",
})}
/>
{errors.vector_store_id && (
<p className="text-xs text-destructive">
{errors.vector_store_id.message as string}
</p>
)}
</div>
<Form.Item
label={
<span>
Vector Store Name{" "}
<Tooltip title="Custom name you want to give to the vector store, this name will be rendered on the LiteLLM UI">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="vector_store_name"
>
<TextInput />
</Form.Item>
{getProviderSpecificFields(selectedProvider).map(renderProviderField)}
<Form.Item label="Description" name="vector_store_description">
<Input.TextArea rows={4} />
</Form.Item>
<div className="space-y-1">
<LabelWithTooltip
htmlFor="vs-name"
tooltip="Custom name you want to give to the vector store, this name will be rendered on the LiteLLM UI"
>
Vector Store Name
</LabelWithTooltip>
<Input id="vs-name" {...register("vector_store_name")} />
</div>
<Form.Item
label={
<span>
Existing Credentials{" "}
<Tooltip title="Optionally select API provider credentials for this vector store eg. Bedrock API KEY">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="litellm_credential_name"
>
<Select
showSearch
placeholder="Select or search for existing credentials"
optionFilterProp="children"
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
options={[
{ value: null, label: "None" },
...credentials.map((credential) => ({
value: credential.credential_name,
label: credential.credential_name,
})),
]}
allowClear
/>
</Form.Item>
<div className="space-y-1">
<Label htmlFor="vs-description">Description</Label>
<Textarea id="vs-description" rows={4} {...register("vector_store_description")} />
</div>
<Form.Item
label={
<span>
Metadata{" "}
<Tooltip title="JSON metadata for the vector store (optional)">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
>
<Input.TextArea
rows={4}
value={metadataJson}
onChange={(e) => setMetadataJson(e.target.value)}
placeholder='{"key": "value"}'
/>
</Form.Item>
<div className="space-y-1">
<LabelWithTooltip
htmlFor="vs-credential"
tooltip="Optionally select API provider credentials for this vector store eg. Bedrock API KEY"
>
Existing Credentials
</LabelWithTooltip>
<Controller
control={control}
name="litellm_credential_name"
render={({ field }) => (
<Select
value={field.value ?? "__none__"}
onValueChange={(v) => {
field.onChange(v === "__none__" ? null : v);
setValue("litellm_credential_name", v === "__none__" ? null : v);
}}
>
<SelectTrigger id="vs-credential" className="w-full">
<SelectValue placeholder="Select or search for existing credentials" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{credentials.map((credential) => (
<SelectItem
key={credential.credential_name}
value={credential.credential_name}
>
{credential.credential_name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="flex justify-end space-x-3">
<TremorButton onClick={handleCancel} variant="secondary">
Cancel
</TremorButton>
<TremorButton variant="primary" type="submit">
Create
</TremorButton>
</div>
</Form>
</Modal>
<div className="space-y-1">
<LabelWithTooltip
htmlFor="vs-metadata"
tooltip="JSON metadata for the vector store (optional)"
>
Metadata
</LabelWithTooltip>
<Textarea
id="vs-metadata"
rows={4}
value={metadataJson}
onChange={(e) => setMetadataJson(e.target.value)}
placeholder='{"key": "value"}'
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View file

@ -1,80 +1,14 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VectorStore } from "./types";
import VectorStoreSelector from "./VectorStoreSelector";
// Mock dependencies
const mockVectorStoreListCall = vi.fn();
vi.mock("../networking", () => ({
vectorStoreListCall: (...args: any[]) => mockVectorStoreListCall(...args),
}));
// Mock antd Select component
vi.mock("antd", () => ({
Select: vi.fn(),
}));
// Import the mocked Select
import { Select as MockedSelect } from "antd";
// Configure the mock to render a simple div with data attributes
(MockedSelect as any).mockImplementation((props: any) => {
const {
onChange,
value,
placeholder,
loading,
className,
disabled,
options,
mode,
showSearch,
optionFilterProp,
style,
} = props;
return (
<div
data-testid="vector-store-select"
data-loading={loading}
data-disabled={disabled}
data-mode={mode}
data-show-search={showSearch}
data-option-filter-prop={optionFilterProp}
data-placeholder={placeholder}
data-value={value !== undefined ? JSON.stringify(value) : undefined}
data-options={JSON.stringify(options)}
className={className}
style={style}
onClick={(e: any) => {
// For testing purposes, allow simulating different selection behaviors
// The test can control this by setting data attributes on the element
const testSelection = e.target.getAttribute("data-test-selection");
if (testSelection && onChange) {
onChange(JSON.parse(testSelection));
} else if (onChange && options?.length > 0) {
// Default behavior: select first option
onChange([options[0].value]);
}
}}
>
{options?.map((opt: any) => (
<div
key={opt.value}
data-option-value={opt.value}
data-option-label={opt.label}
data-option-title={opt.title}
data-testid={`option-${opt.value}`}
>
{opt.label}
</div>
))}
</div>
);
});
// Test helpers
const mockOnChange = vi.fn();
const mockAccessToken = "test-token";
@ -98,7 +32,6 @@ const mockVectorStores: VectorStore[] = [
{
vector_store_id: "store-3",
custom_llm_provider: "pg_vector",
// No vector_store_name to test fallback to vector_store_id
vector_store_description: "Store without name",
created_at: "2024-01-03T00:00:00Z",
updated_at: "2024-01-03T00:00:00Z",
@ -110,8 +43,7 @@ const defaultProps = {
accessToken: mockAccessToken,
};
// Helper functions
const renderComponent = (props = {}) => {
const renderComponent = (props: Partial<React.ComponentProps<typeof VectorStoreSelector>> = {}) => {
return render(<VectorStoreSelector {...defaultProps} {...props} />);
};
@ -121,10 +53,14 @@ const waitForDataFetch = async () => {
});
};
const getSelectElement = () => screen.getByTestId("vector-store-select");
const getOptionElements = () =>
screen.getAllByTestId(/^vector-store-select/).filter((el) => el.hasAttribute("data-option-value"));
const openPopover = async () => {
// The trigger is the first button rendered — chip Remove buttons come after it.
const trigger = screen.getAllByRole("button")[0];
await act(async () => {
fireEvent.click(trigger);
});
return trigger;
};
describe("VectorStoreSelector", () => {
beforeEach(() => {
@ -135,63 +71,39 @@ describe("VectorStoreSelector", () => {
});
describe("Rendering", () => {
it("should render the select component", () => {
it("should render the trigger button", () => {
renderComponent();
expect(getSelectElement()).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("should render with default placeholder", () => {
it("should render with default placeholder", async () => {
renderComponent();
const select = getSelectElement();
expect(select).toHaveAttribute("data-placeholder", "Select vector stores");
await waitForDataFetch();
expect(screen.getByText("Select vector stores")).toBeInTheDocument();
});
it("should render with custom placeholder", () => {
it("should render with custom placeholder", async () => {
renderComponent({ placeholder: "Choose stores" });
const select = getSelectElement();
expect(select).toHaveAttribute("data-placeholder", "Choose stores");
await waitForDataFetch();
expect(screen.getByText("Choose stores")).toBeInTheDocument();
});
it("should apply custom className", () => {
renderComponent({ className: "custom-class" });
const select = getSelectElement();
expect(select).toHaveClass("custom-class");
const trigger = screen.getByRole("button");
expect(trigger).toHaveClass("custom-class");
});
it("should render with disabled state", () => {
it("should render as disabled when disabled prop set", () => {
renderComponent({ disabled: true });
const select = getSelectElement();
expect(select).toHaveAttribute("data-disabled", "true");
const trigger = screen.getByRole("button");
expect(trigger).toBeDisabled();
});
it("should render with enabled state by default", () => {
it("should render as enabled by default", () => {
renderComponent();
const select = getSelectElement();
expect(select).toHaveAttribute("data-disabled", "false");
});
it("should render with multiple mode", () => {
renderComponent();
const select = getSelectElement();
expect(select).toHaveAttribute("data-mode", "multiple");
});
it("should render with showSearch enabled", () => {
renderComponent();
const select = getSelectElement();
expect(select).toHaveAttribute("data-show-search", "true");
});
it("should render with optionFilterProp set to label", () => {
renderComponent();
const select = getSelectElement();
expect(select).toHaveAttribute("data-option-filter-prop", "label");
});
it("should render with full width style", () => {
renderComponent();
const select = getSelectElement();
expect(select).toHaveStyle({ width: "100%" });
const trigger = screen.getByRole("button");
expect(trigger).not.toBeDisabled();
});
});
@ -227,7 +139,7 @@ describe("VectorStoreSelector", () => {
});
});
it("should set loading state while fetching", async () => {
it("should show loading placeholder while fetching", async () => {
let resolvePromise: (value: any) => void;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
@ -235,20 +147,13 @@ describe("VectorStoreSelector", () => {
mockVectorStoreListCall.mockReturnValue(promise);
renderComponent();
const select = getSelectElement();
expect(select).toHaveAttribute("data-loading", "true");
expect(screen.getByText(/loading/i)).toBeInTheDocument();
resolvePromise!({ data: mockVectorStores });
await waitFor(() => {
expect(select).toHaveAttribute("data-loading", "false");
});
});
it("should clear loading state after successful fetch", async () => {
renderComponent();
await waitForDataFetch();
const select = getSelectElement();
expect(select).toHaveAttribute("data-loading", "false");
await waitFor(() => {
expect(screen.getByText("Select vector stores")).toBeInTheDocument();
});
});
it("should clear loading state after failed fetch", async () => {
@ -258,51 +163,43 @@ describe("VectorStoreSelector", () => {
renderComponent();
await waitForDataFetch();
const select = getSelectElement();
expect(select).toHaveAttribute("data-loading", "false");
await waitFor(() => {
expect(screen.getByText("Select vector stores")).toBeInTheDocument();
});
consoleErrorSpy.mockRestore();
});
});
describe("Options rendering", () => {
it("should render vector store options after successful fetch", async () => {
it("should render vector store options when opened", async () => {
renderComponent();
await waitForDataFetch();
await openPopover();
expect(screen.getByText("My Store (store-1)")).toBeInTheDocument();
expect(screen.getByText("Another Store (store-2)")).toBeInTheDocument();
expect(screen.getByText("store-3 (store-3)")).toBeInTheDocument();
});
it("should use vector_store_name when available for label", async () => {
it("should fall back to vector_store_id for label when name is missing", async () => {
renderComponent();
await waitForDataFetch();
await openPopover();
expect(screen.getByText("store-3 (store-3)")).toBeInTheDocument();
});
it("should use description as title attribute when available", async () => {
renderComponent();
await waitForDataFetch();
await openPopover();
const option1 = screen.getByText("My Store (store-1)");
expect(option1).toBeInTheDocument();
expect(option1).toHaveAttribute("data-option-title", "A test store");
expect(option1).toHaveAttribute("title", "A test store");
});
it("should fallback to vector_store_id when vector_store_name is missing", async () => {
renderComponent();
await waitForDataFetch();
const option3 = screen.getByText("store-3 (store-3)");
expect(option3).toBeInTheDocument();
// When vector_store_name is missing, title uses vector_store_description if available, otherwise vector_store_id
expect(option3).toHaveAttribute("data-option-title", "Store without name");
});
it("should use vector_store_description as title when available", async () => {
renderComponent();
await waitForDataFetch();
const option1 = screen.getByText("My Store (store-1)");
expect(option1).toHaveAttribute("data-option-title", "A test store");
});
it("should fallback to vector_store_id as title when vector_store_description is missing", async () => {
const storesWithoutDescription: VectorStore[] = [
it("should fall back to vector_store_id as title when description is missing", async () => {
const stores: VectorStore[] = [
{
vector_store_id: "store-no-desc",
custom_llm_provider: "openai",
@ -310,35 +207,24 @@ describe("VectorStoreSelector", () => {
updated_at: "2024-01-01T00:00:00Z",
},
];
mockVectorStoreListCall.mockResolvedValueOnce({
data: storesWithoutDescription,
});
mockVectorStoreListCall.mockResolvedValueOnce({ data: stores });
renderComponent();
await waitForDataFetch();
await openPopover();
const option = screen.getByText("store-no-desc (store-no-desc)");
expect(option).toHaveAttribute("data-option-title", "store-no-desc");
expect(option).toHaveAttribute("title", "store-no-desc");
});
it("should use vector_store_id as option value", async () => {
renderComponent();
await waitForDataFetch();
const option1 = screen.getByText("My Store (store-1)");
expect(option1).toHaveAttribute("data-option-value", "store-1");
});
it("should handle empty vector stores array", async () => {
mockVectorStoreListCall.mockResolvedValueOnce({
data: [],
});
it("should show 'No matches' when no options remain", async () => {
mockVectorStoreListCall.mockResolvedValueOnce({ data: [] });
renderComponent();
await waitForDataFetch();
await openPopover();
const options = getOptionElements();
expect(options.length).toBe(0);
expect(screen.getByText("No matches")).toBeInTheDocument();
});
it("should handle response without data property", async () => {
@ -346,79 +232,101 @@ describe("VectorStoreSelector", () => {
renderComponent();
await waitForDataFetch();
await openPopover();
const options = getOptionElements();
expect(options.length).toBe(0);
expect(screen.getByText("No matches")).toBeInTheDocument();
});
});
describe("Value prop", () => {
it("should set initial value when value prop is provided", async () => {
it("should render selected values as chips", async () => {
renderComponent({ value: ["store-1", "store-2"] });
await waitForDataFetch();
const select = getSelectElement();
const dataValue = select.getAttribute("data-value");
expect(dataValue).toBe(JSON.stringify(["store-1", "store-2"]));
await waitFor(() => {
expect(screen.getByText("My Store")).toBeInTheDocument();
expect(screen.getByText("Another Store")).toBeInTheDocument();
});
});
it("should handle empty value array", async () => {
it("should render placeholder when value is empty", async () => {
renderComponent({ value: [] });
await waitForDataFetch();
const select = getSelectElement();
const dataValue = select.getAttribute("data-value");
expect(dataValue).toBe(JSON.stringify([]));
expect(screen.getByText("Select vector stores")).toBeInTheDocument();
});
it("should handle undefined value", async () => {
it("should render placeholder when value is undefined", async () => {
renderComponent({ value: undefined });
await waitForDataFetch();
expect(screen.getByText("Select vector stores")).toBeInTheDocument();
});
const select = getSelectElement();
const dataValue = select.getAttribute("data-value");
expect(dataValue).toBeNull(); // undefined value results in no data-value attribute
it("should exclude selected stores from the option list", async () => {
renderComponent({ value: ["store-1"] });
await waitForDataFetch();
await openPopover();
expect(screen.queryByText("My Store (store-1)")).not.toBeInTheDocument();
expect(screen.getByText("Another Store (store-2)")).toBeInTheDocument();
});
});
describe("onChange callback", () => {
it("should call onChange when selection changes", async () => {
it("should call onChange when an option is selected", async () => {
renderComponent();
await waitForDataFetch();
await openPopover();
const select = getSelectElement();
// Simulate selecting store-1 by setting test data attribute
select.setAttribute("data-test-selection", '["store-1"]');
fireEvent.click(select);
const option = screen.getByText("My Store (store-1)");
await act(async () => {
fireEvent.click(option);
});
expect(mockOnChange).toHaveBeenCalledWith(["store-1"]);
});
it("should call onChange with multiple selected values", async () => {
renderComponent();
it("should append to existing selection on subsequent select", async () => {
renderComponent({ value: ["store-1"] });
await waitForDataFetch();
await openPopover();
const select = getSelectElement();
// Simulate selecting multiple values
select.setAttribute("data-test-selection", '["store-1", "store-2"]');
fireEvent.click(select);
const option = screen.getByText("Another Store (store-2)");
await act(async () => {
fireEvent.click(option);
});
expect(mockOnChange).toHaveBeenCalledWith(["store-1", "store-2"]);
});
it("should call onChange when deselecting options", async () => {
it("should call onChange when removing a selected chip", async () => {
renderComponent({ value: ["store-1", "store-2"] });
await waitForDataFetch();
const select = getSelectElement();
// Simulate deselecting store-1
select.setAttribute("data-test-selection", '["store-2"]');
fireEvent.click(select);
const removeBtn = screen.getByLabelText("Remove My Store");
await act(async () => {
fireEvent.click(removeBtn);
});
expect(mockOnChange).toHaveBeenCalledWith(["store-2"]);
});
});
describe("Filtering", () => {
it("should filter options by search query", async () => {
renderComponent();
await waitForDataFetch();
await openPopover();
const search = screen.getByPlaceholderText(/search vector stores/i);
await act(async () => {
fireEvent.change(search, { target: { value: "Another" } });
});
expect(screen.queryByText("My Store (store-1)")).not.toBeInTheDocument();
expect(screen.getByText("Another Store (store-2)")).toBeInTheDocument();
});
});
describe("Error handling", () => {
it("should handle fetch errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@ -443,21 +351,20 @@ describe("VectorStoreSelector", () => {
consoleErrorSpy.mockRestore();
});
it("should continue to work after error", async () => {
it("should continue to render after error", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockVectorStoreListCall.mockRejectedValueOnce(new Error("Network error"));
renderComponent();
await waitForDataFetch();
// Component should still render
expect(getSelectElement()).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
consoleErrorSpy.mockRestore();
});
});
describe("Edge cases", () => {
it("should handle vector stores with all optional fields missing", async () => {
it("should render minimal vector stores with only id", async () => {
const minimalStores: VectorStore[] = [
{
vector_store_id: "minimal-store",
@ -466,40 +373,36 @@ describe("VectorStoreSelector", () => {
updated_at: "2024-01-01T00:00:00Z",
},
];
mockVectorStoreListCall.mockResolvedValueOnce({
data: minimalStores,
});
mockVectorStoreListCall.mockResolvedValueOnce({ data: minimalStores });
renderComponent();
await waitForDataFetch();
await openPopover();
expect(screen.getByText("minimal-store (minimal-store)")).toBeInTheDocument();
const option = screen.getByText("minimal-store (minimal-store)");
expect(option).toHaveAttribute("data-option-title", "minimal-store");
});
it("should handle very long vector store names", async () => {
it("should render very long vector store names", async () => {
const longName = "A".repeat(200);
const longNameStores: VectorStore[] = [
{
vector_store_id: "store-long",
custom_llm_provider: "openai",
vector_store_name: "A".repeat(200),
vector_store_name: longName,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
mockVectorStoreListCall.mockResolvedValueOnce({
data: longNameStores,
});
mockVectorStoreListCall.mockResolvedValueOnce({ data: longNameStores });
renderComponent();
await waitForDataFetch();
await openPopover();
const expectedLabel = `${"A".repeat(200)} (store-long)`;
expect(screen.getByText(expectedLabel)).toBeInTheDocument();
expect(screen.getByText(`${longName} (store-long)`)).toBeInTheDocument();
});
it("should handle special characters in vector store names", async () => {
it("should render special characters in names", async () => {
const specialCharStores: VectorStore[] = [
{
vector_store_id: "store-special",
@ -509,12 +412,11 @@ describe("VectorStoreSelector", () => {
updated_at: "2024-01-01T00:00:00Z",
},
];
mockVectorStoreListCall.mockResolvedValueOnce({
data: specialCharStores,
});
mockVectorStoreListCall.mockResolvedValueOnce({ data: specialCharStores });
renderComponent();
await waitForDataFetch();
await openPopover();
expect(screen.getByText(/Store & Co\. <Test> "Quotes"/)).toBeInTheDocument();
});

View file

@ -1,7 +1,16 @@
import React, { useEffect, useState } from "react";
import { Select } from "antd";
import React, { useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { VectorStore } from "./types";
import { vectorStoreListCall } from "../networking";
interface VectorStoreSelectorProps {
onChange: (selectedVectorStores: string[]) => void;
value?: string[];
@ -11,6 +20,10 @@ interface VectorStoreSelectorProps {
disabled?: boolean;
}
/**
* Multi-select for vector stores. Uses the shadcn Popover + chip pattern
* (shared with GuardrailSelector) since shadcn has no native multi-select.
*/
const VectorStoreSelector: React.FC<VectorStoreSelectorProps> = ({
onChange,
value,
@ -21,6 +34,10 @@ const VectorStoreSelector: React.FC<VectorStoreSelectorProps> = ({
}) => {
const [vectorStores, setVectorStores] = useState<VectorStore[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const selected = useMemo(() => value ?? [], [value]);
useEffect(() => {
const fetchVectorStores = async () => {
@ -42,26 +59,109 @@ const VectorStoreSelector: React.FC<VectorStoreSelectorProps> = ({
fetchVectorStores();
}, [accessToken]);
const optionFor = (id: string) => vectorStores.find((s) => s.vector_store_id === id);
const labelFor = (store: VectorStore) =>
`${store.vector_store_name || store.vector_store_id} (${store.vector_store_id})`;
const remaining = useMemo(
() =>
vectorStores
.filter((s) => !selected.includes(s.vector_store_id))
.filter((s) => {
if (!query) return true;
const label = labelFor(s).toLowerCase();
return label.includes(query.toLowerCase());
}),
[vectorStores, selected, query],
);
return (
<div>
<Select
mode="multiple"
placeholder={placeholder}
onChange={onChange}
value={value}
loading={loading}
className={className}
allowClear
options={vectorStores.map((store) => ({
label: `${store.vector_store_name || store.vector_store_id} (${store.vector_store_id})`,
value: store.vector_store_id,
title: store.vector_store_description || store.vector_store_id,
}))}
optionFilterProp="label"
showSearch
style={{ width: "100%" }}
disabled={disabled}
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className={cn(
"min-h-9 w-full flex flex-wrap items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-sm text-left disabled:opacity-50",
className,
)}
>
{selected.length === 0 ? (
<span className="text-muted-foreground px-1">
{loading ? "Loading…" : placeholder}
</span>
) : (
selected.map((id) => {
const store = optionFor(id);
const label = store
? store.vector_store_name || store.vector_store_id
: id;
return (
<Badge
key={id}
variant="secondary"
className="gap-1"
title={store?.vector_store_description || id}
>
{label}
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onChange(selected.filter((s) => s !== id));
}}
className="inline-flex items-center"
aria-label={`Remove ${label}`}
>
<X size={12} />
</span>
</Badge>
);
})
)}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-2"
>
<Input
autoFocus
placeholder="Search vector stores…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 mb-2"
/>
<div className="max-h-60 overflow-y-auto">
{loading ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
Loading…
</div>
) : remaining.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No matches
</div>
) : (
remaining.map((store) => (
<button
key={store.vector_store_id}
type="button"
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent"
onClick={() => {
onChange([...selected, store.vector_store_id]);
setQuery("");
}}
title={store.vector_store_description || store.vector_store_id}
>
{labelFor(store)}
</button>
))
)}
</div>
</PopoverContent>
</Popover>
</div>
);
};

View file

@ -38,21 +38,28 @@ vi.mock("@tremor/react", () => ({
TableCell: ({ children, ...props }: any) => <td {...props}>{children}</td>,
}));
// Mock antd Tooltip
vi.mock("antd", () => ({
Tooltip: ({ children, title }: any) => (
<div data-testid="tooltip" data-title={title}>
// Mock shadcn Tooltip to a deterministic wrapper we can query
vi.mock("@/components/ui/tooltip", () => ({
TooltipProvider: ({ children }: any) => <>{children}</>,
Tooltip: ({ children }: any) => <div data-testid="tooltip-wrapper">{children}</div>,
TooltipTrigger: ({ children }: any) => <>{children}</>,
TooltipContent: ({ children }: any) => (
<span data-testid="tooltip" data-title={typeof children === "string" ? children : ""}>
{children}
</div>
</span>
),
}));
// Mock Heroicons
vi.mock("@heroicons/react/outline", () => ({
ChevronDownIcon: (props: any) => <div data-testid="chevron-down" {...props} />,
ChevronUpIcon: (props: any) => <div data-testid="chevron-up" {...props} />,
SwitchVerticalIcon: (props: any) => <div data-testid="switch-vertical" {...props} />,
}));
// Mock lucide sort icons to stable testids
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
ChevronDown: (props: any) => <div data-testid="chevron-down" {...props} />,
ChevronUp: (props: any) => <div data-testid="chevron-up" {...props} />,
ArrowUpDown: (props: any) => <div data-testid="switch-vertical" {...props} />,
};
});
// Test data
const mockVectorStores: VectorStore[] = [
@ -177,7 +184,7 @@ describe("VectorStoreTable", () => {
describe("Name Column", () => {
it("should render vector store name", () => {
renderComponent();
expect(screen.getByText("My OpenAI Store")).toBeInTheDocument();
expect(screen.getAllByText("My OpenAI Store").length).toBeGreaterThan(0);
});
it("should render fallback for missing name", () => {
@ -197,7 +204,7 @@ describe("VectorStoreTable", () => {
describe("Description Column", () => {
it("should render vector store description", () => {
renderComponent();
expect(screen.getByText("A store for OpenAI vectors")).toBeInTheDocument();
expect(screen.getAllByText("A store for OpenAI vectors").length).toBeGreaterThan(0);
});
it("should render fallback for missing description", () => {
@ -343,7 +350,7 @@ describe("VectorStoreTable", () => {
renderComponent();
const headerCells = screen.getAllByRole("columnheader");
const actionsHeader = headerCells[headerCells.length - 1];
expect(actionsHeader).toHaveClass("sticky", "right-0", "bg-white");
expect(actionsHeader).toHaveClass("sticky", "right-0", "bg-background");
});
it("should apply sticky styling to action cells", () => {
@ -352,7 +359,7 @@ describe("VectorStoreTable", () => {
rows.forEach((row) => {
const cells = row.querySelectorAll("td");
const lastCell = cells[cells.length - 1];
expect(lastCell).toHaveClass("sticky", "right-0", "bg-white");
expect(lastCell).toHaveClass("sticky", "right-0", "bg-background");
});
});
});

View file

@ -1,14 +1,24 @@
import React, { useCallback, useState, useEffect } from "react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import {
Form,
Input as AntInput,
Select as Select2,
} from "antd";
import React, { useCallback, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
@ -17,10 +27,10 @@ import {
} from "@/components/ui/tooltip";
import { ArrowLeft, Info } from "lucide-react";
import {
vectorStoreInfoCall,
vectorStoreUpdateCall,
credentialListCall,
CredentialItem,
vectorStoreInfoCall,
vectorStoreUpdateCall,
} from "../networking";
import { VectorStore } from "./types";
import {
@ -39,6 +49,43 @@ interface VectorStoreInfoViewProps {
editVectorStore: boolean;
}
interface EditFormValues {
vector_store_id: string;
vector_store_name?: string;
vector_store_description?: string;
custom_llm_provider: string;
litellm_credential_name?: string | null;
}
function LabelWithTooltip({
children,
tooltip,
required,
htmlFor,
}: {
children: React.ReactNode;
tooltip: string;
required?: boolean;
htmlFor?: string;
}) {
return (
<Label htmlFor={htmlFor} className="flex items-center gap-1">
<span>
{children}
{required && <span className="text-destructive"> *</span>}
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
);
}
const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
vectorStoreId,
onClose,
@ -46,12 +93,27 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
is_admin,
editVectorStore,
}) => {
const [form] = Form.useForm();
const [vectorStoreDetails, setVectorStoreDetails] = useState<VectorStore | null>(null);
const [isEditing, setIsEditing] = useState<boolean>(editVectorStore);
const [metadataString, setMetadataString] = useState<string>("{}");
const [credentials, setCredentials] = useState<CredentialItem[]>([]);
const {
register,
handleSubmit,
control,
reset,
formState: { errors },
} = useForm<EditFormValues>({
defaultValues: {
vector_store_id: "",
vector_store_name: "",
vector_store_description: "",
custom_llm_provider: "bedrock",
litellm_credential_name: null,
},
});
const fetchVectorStoreDetails = useCallback(async () => {
if (!accessToken) return;
try {
@ -59,7 +121,6 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
if (response && response.vector_store) {
setVectorStoreDetails(response.vector_store);
// If metadata exists and is an object, stringify it for display/editing
if (response.vector_store.vector_store_metadata) {
const metadata =
typeof response.vector_store.vector_store_metadata === "string"
@ -69,21 +130,20 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
}
if (editVectorStore) {
form.setFieldsValue({
reset({
vector_store_id: response.vector_store.vector_store_id,
custom_llm_provider: response.vector_store.custom_llm_provider,
vector_store_name: response.vector_store.vector_store_name,
vector_store_description: response.vector_store.vector_store_description,
litellm_credential_name: null,
});
}
}
} catch (error) {
console.error("Error fetching vector store details:", error);
NotificationsManager.fromBackend(
"Error fetching vector store details: " + error,
);
NotificationsManager.fromBackend("Error fetching vector store details: " + error);
}
}, [accessToken, editVectorStore, form, vectorStoreId]);
}, [accessToken, editVectorStore, reset, vectorStoreId]);
const fetchCredentials = useCallback(async () => {
if (!accessToken) return;
@ -100,16 +160,13 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
fetchCredentials();
}, [fetchVectorStoreDetails, fetchCredentials]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSave = async (values: any) => {
const handleSave = async (values: EditFormValues) => {
if (!accessToken) return;
try {
// Parse the metadata JSON string
let metadata = {};
let metadata: Record<string, any> = {};
try {
metadata = metadataString ? JSON.parse(metadataString) : {};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_e) {
} catch {
NotificationsManager.fromBackend("Invalid JSON in metadata field");
return;
}
@ -156,315 +213,291 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
)}
</div>
<TabGroup>
<TabList className="mb-6">
<Tab>Details</Tab>
<Tab>Test Vector Store</Tab>
</TabList>
<Tabs defaultValue="details">
<TabsList className="mb-6">
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="test">Test Vector Store</TabsTrigger>
</TabsList>
<TabPanels>
{/* Details Tab */}
<TabPanel>
{isEditing ? (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Edit Vector Store</h2>
</div>
<Card className="p-4">
<Form
form={form}
onFinish={handleSave}
layout="vertical"
initialValues={vectorStoreDetails}
>
<Form.Item
label="Vector Store ID"
name="vector_store_id"
rules={[
{
required: true,
message: "Please input a vector store ID",
},
]}
>
<AntInput disabled />
</Form.Item>
<Form.Item
label="Vector Store Name"
name="vector_store_name"
>
<AntInput />
</Form.Item>
<Form.Item
label="Description"
name="vector_store_description"
>
<AntInput.TextArea rows={4} />
</Form.Item>
<Form.Item
label={
<span>
Provider{" "}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-1 h-3 w-3 inline text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
Select the provider for this vector store
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
}
name="custom_llm_provider"
rules={[{ required: true, message: "Please select a provider" }]}
>
<Select2>
{Object.entries(Providers).map(([providerEnum, providerDisplayName]) => {
// Currently only showing Bedrock since it's the only supported provider
if (providerEnum === "Bedrock") {
return (
<Select2.Option
key={providerEnum}
value={provider_map[providerEnum]}
>
<div className="flex items-center space-x-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={providerLogoMap[providerDisplayName]}
alt={`${providerEnum} logo`}
className="w-5 h-5"
onError={(e) => {
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-muted flex items-center justify-center text-xs";
fallbackDiv.textContent =
providerDisplayName.charAt(0);
parent.replaceChild(
fallbackDiv,
target,
);
}
}}
/>
<span>{providerDisplayName}</span>
</div>
</Select2.Option>
);
}
return null;
})}
</Select2>
</Form.Item>
<div className="mb-4">
<p className="text-sm text-muted-foreground mb-2">
Either select existing credentials OR enter provider
credentials below
</p>
</div>
<Form.Item
label="Existing Credentials"
name="litellm_credential_name"
>
<Select2
showSearch
placeholder="Select or search for existing credentials"
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase())
}
options={[
{ value: null, label: "None" },
...credentials.map((credential) => ({
value: credential.credential_name,
label: credential.credential_name,
})),
]}
allowClear
/>
</Form.Item>
<div className="flex items-center my-4">
<div className="flex-grow border-t border-border"></div>
<span className="px-4 text-muted-foreground text-sm">
OR
</span>
<div className="flex-grow border-t border-border"></div>
</div>
<Form.Item
label={
<span>
Metadata{" "}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-1 h-3 w-3 inline text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
JSON metadata for the vector store
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
}
>
<AntInput.TextArea
rows={4}
value={metadataString}
onChange={(e) => setMetadataString(e.target.value)}
placeholder='{"key": "value"}'
/>
</Form.Item>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => setIsEditing(false)}
>
Cancel
</Button>
<Button type="submit">Save Changes</Button>
</div>
</Form>
</Card>
<TabsContent value="details">
{isEditing ? (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Edit Vector Store</h2>
</div>
) : (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">
Vector Store Details
</h2>
{is_admin && (
<Button onClick={() => setIsEditing(true)}>
Edit Vector Store
<Card className="p-4">
<form onSubmit={handleSubmit(handleSave)} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="vs-edit-id">
Vector Store ID <span className="text-destructive">*</span>
</Label>
<Input
id="vs-edit-id"
disabled
{...register("vector_store_id", {
required: "Please input a vector store ID",
})}
/>
{errors.vector_store_id && (
<p className="text-xs text-destructive">
{errors.vector_store_id.message as string}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="vs-edit-name">Vector Store Name</Label>
<Input id="vs-edit-name" {...register("vector_store_name")} />
</div>
<div className="space-y-1">
<Label htmlFor="vs-edit-description">Description</Label>
<Textarea
id="vs-edit-description"
rows={4}
{...register("vector_store_description")}
/>
</div>
<div className="space-y-1">
<LabelWithTooltip
htmlFor="vs-edit-provider"
tooltip="Select the provider for this vector store"
required
>
Provider
</LabelWithTooltip>
<Controller
control={control}
name="custom_llm_provider"
rules={{ required: "Please select a provider" }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="vs-edit-provider" className="w-full">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{Object.entries(Providers)
.filter(([providerEnum]) => providerEnum === "Bedrock")
.map(([providerEnum, providerDisplayName]) => (
<SelectItem
key={providerEnum}
value={provider_map[providerEnum]}
>
<div className="flex items-center space-x-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={providerLogoMap[providerDisplayName]}
alt={`${providerEnum} logo`}
className="w-5 h-5"
onError={(e) => {
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-muted flex items-center justify-center text-xs";
fallbackDiv.textContent =
providerDisplayName.charAt(0);
parent.replaceChild(fallbackDiv, target);
}
}}
/>
<span>{providerDisplayName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.custom_llm_provider && (
<p className="text-xs text-destructive">
{errors.custom_llm_provider.message as string}
</p>
)}
</div>
<div className="mb-4">
<p className="text-sm text-muted-foreground mb-2">
Either select existing credentials OR enter provider credentials below
</p>
</div>
<div className="space-y-1">
<Label htmlFor="vs-edit-credential">Existing Credentials</Label>
<Controller
control={control}
name="litellm_credential_name"
render={({ field }) => (
<Select
value={field.value ?? "__none__"}
onValueChange={(v) =>
field.onChange(v === "__none__" ? null : v)
}
>
<SelectTrigger id="vs-edit-credential" className="w-full">
<SelectValue placeholder="Select or search for existing credentials" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{credentials.map((credential) => (
<SelectItem
key={credential.credential_name}
value={credential.credential_name}
>
{credential.credential_name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="flex items-center my-4">
<div className="flex-grow border-t border-border"></div>
<span className="px-4 text-muted-foreground text-sm">OR</span>
<div className="flex-grow border-t border-border"></div>
</div>
<div className="space-y-1">
<LabelWithTooltip
htmlFor="vs-edit-metadata"
tooltip="JSON metadata for the vector store"
>
Metadata
</LabelWithTooltip>
<Textarea
id="vs-edit-metadata"
rows={4}
value={metadataString}
onChange={(e) => setMetadataString(e.target.value)}
placeholder='{"key": "value"}'
/>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => setIsEditing(false)}
>
Cancel
</Button>
)}
</div>
<Card className="p-4">
<div className="space-y-4">
<div>
<p className="font-medium">ID</p>
<p>{vectorStoreDetails.vector_store_id}</p>
</div>
<div>
<p className="font-medium">Name</p>
<p>{vectorStoreDetails.vector_store_name || "-"}</p>
</div>
<div>
<p className="font-medium">Description</p>
<p>
{vectorStoreDetails.vector_store_description || "-"}
</p>
</div>
<div>
<p className="font-medium">Provider</p>
<div className="flex items-center space-x-2 mt-1">
{(() => {
const provider =
vectorStoreDetails.custom_llm_provider || "bedrock";
const { displayName, logo } = (() => {
const enumKey = Object.keys(provider_map).find(
(key) =>
provider_map[key].toLowerCase() ===
provider.toLowerCase(),
);
if (!enumKey) {
return { displayName: provider, logo: "" };
}
const displayName =
Providers[enumKey as keyof typeof Providers];
const logo = providerLogoMap[displayName];
return { displayName, logo };
})();
return (
<>
{logo && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logo}
alt={`${displayName} logo`}
className="w-5 h-5"
onError={(e) => {
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-muted flex items-center justify-center text-xs";
fallbackDiv.textContent =
displayName.charAt(0);
parent.replaceChild(fallbackDiv, target);
}
}}
/>
)}
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300">
{displayName}
</Badge>
</>
<Button type="submit">Save Changes</Button>
</div>
</form>
</Card>
</div>
) : (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Vector Store Details</h2>
{is_admin && (
<Button onClick={() => setIsEditing(true)}>Edit Vector Store</Button>
)}
</div>
<Card className="p-4">
<div className="space-y-4">
<div>
<p className="font-medium">ID</p>
<p>{vectorStoreDetails.vector_store_id}</p>
</div>
<div>
<p className="font-medium">Name</p>
<p>{vectorStoreDetails.vector_store_name || "-"}</p>
</div>
<div>
<p className="font-medium">Description</p>
<p>{vectorStoreDetails.vector_store_description || "-"}</p>
</div>
<div>
<p className="font-medium">Provider</p>
<div className="flex items-center space-x-2 mt-1">
{(() => {
const provider =
vectorStoreDetails.custom_llm_provider || "bedrock";
const { displayName, logo } = (() => {
const enumKey = Object.keys(provider_map).find(
(key) =>
provider_map[key].toLowerCase() === provider.toLowerCase(),
);
})()}
</div>
</div>
<div>
<p className="font-medium">Metadata</p>
<div className="bg-muted p-3 rounded mt-2 font-mono text-xs overflow-auto max-h-48">
<pre>{metadataString}</pre>
</div>
</div>
<div>
<p className="font-medium">Created</p>
<p>
{vectorStoreDetails.created_at
? new Date(
vectorStoreDetails.created_at,
).toLocaleString()
: "-"}
</p>
</div>
<div>
<p className="font-medium">Last Updated</p>
<p>
{vectorStoreDetails.updated_at
? new Date(
vectorStoreDetails.updated_at,
).toLocaleString()
: "-"}
</p>
if (!enumKey) {
return { displayName: provider, logo: "" };
}
const displayName = Providers[enumKey as keyof typeof Providers];
const logo = providerLogoMap[displayName];
return { displayName, logo };
})();
return (
<>
{logo && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logo}
alt={`${displayName} logo`}
className="w-5 h-5"
onError={(e) => {
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-muted flex items-center justify-center text-xs";
fallbackDiv.textContent = displayName.charAt(0);
parent.replaceChild(fallbackDiv, target);
}
}}
/>
)}
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300">
{displayName}
</Badge>
</>
);
})()}
</div>
</div>
</Card>
</div>
)}
</TabPanel>
<div>
<p className="font-medium">Metadata</p>
<div className="bg-muted p-3 rounded mt-2 font-mono text-xs overflow-auto max-h-48">
<pre>{metadataString}</pre>
</div>
</div>
<div>
<p className="font-medium">Created</p>
<p>
{vectorStoreDetails.created_at
? new Date(vectorStoreDetails.created_at).toLocaleString()
: "-"}
</p>
</div>
<div>
<p className="font-medium">Last Updated</p>
<p>
{vectorStoreDetails.updated_at
? new Date(vectorStoreDetails.updated_at).toLocaleString()
: "-"}
</p>
</div>
</div>
</Card>
</div>
)}
</TabsContent>
{/* Test Tab */}
<TabPanel>
<VectorStoreTester vectorStoreId={vectorStoreDetails.vector_store_id} accessToken={accessToken || ""} />
</TabPanel>
</TabPanels>
</TabGroup>
<TabsContent value="test">
<VectorStoreTester
vectorStoreId={vectorStoreDetails.vector_store_id}
accessToken={accessToken || ""}
/>
</TabsContent>
</Tabs>
</div>
);
};