mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(ui): migrate vector_store_management to shadcn
Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
b0cc244882
commit
550301679c
12 changed files with 1602 additions and 1332 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue