mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
refactor(ui): migrate the vector store creation form to shadcn (#37353)
* refactor(ui): migrate the vector store creation form to shadcn CreateVectorStore and S3VectorsConfig were the last antd Form.Item users on the vector stores page. Both are now built from the shared Field primitives and shadcn controls, so the page picks up the design tokens and dark mode. CreateVectorStore's antd Form was inert: no Form.Item carried a name, there was no onFinish, and the submit button sat outside the form element, so the form store never held anything. Form.useForm is dropped rather than replaced with react-hook-form, and the existing imperative validation is unchanged. S3VectorsConfig's four Form.Item wrappers had no name either, so its inputs were already prop-controlled and decoupled from the parent store. The embedding model picker keeps its typeahead by moving to Combobox. The submit payload is unchanged. A new characterization suite pins it: it was written against the antd originals, proved green there first, and passes unedited against the migration. * test(ui): cover the S3 embedding model combobox end to end The migration moved this control from an antd Select with showSearch to a Combobox, and nothing exercised it: the suite pinned the payload but never loaded the option list, filtered it, or selected from it. The case drives the whole interaction. It stubs three models, one of which is a chat model, opens the list and asserts the chat model is absent, types to filter, selects the remaining embedding model and asserts it arrives in the providerParams argument. Proved green against the antd originals of both files first, then unedited against the migration. It queries the control by role rather than by label, because the antd original rendered a label with no control associated to it, which the migration fixes.
This commit is contained in:
parent
0cc2f29c78
commit
5997ef0423
4 changed files with 487 additions and 318 deletions
|
|
@ -0,0 +1,231 @@
|
|||
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 "@/components/networking";
|
||||
import * as fetchModels from "@/components/llm_calls/fetch_models";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
ragIngestCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/vector_store_providers", () => ({
|
||||
VectorStoreProviders: {
|
||||
BEDROCK: "Amazon Bedrock",
|
||||
S3Vectors: "AWS S3 Vectors",
|
||||
PGVECTOR: "PG Vector",
|
||||
},
|
||||
vectorStoreProviderMap: {
|
||||
BEDROCK: "bedrock",
|
||||
S3Vectors: "s3_vectors",
|
||||
PGVECTOR: "pg_vector",
|
||||
},
|
||||
vectorStoreProviderLogoMap: {
|
||||
"Amazon Bedrock": "https://example.com/bedrock.png",
|
||||
"AWS S3 Vectors": "https://example.com/aws.png",
|
||||
"PG Vector": "https://example.com/pg.png",
|
||||
},
|
||||
getProviderSpecificFields: vi.fn((provider: string) => {
|
||||
if (provider === "pg_vector") {
|
||||
return [
|
||||
{
|
||||
name: "api_base",
|
||||
label: "API Base",
|
||||
tooltip: "Base URL of the pgvector server",
|
||||
placeholder: "http://localhost:8000",
|
||||
required: true,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "api_key",
|
||||
label: "API Key",
|
||||
tooltip: "Secret for the pgvector server",
|
||||
placeholder: "sk-...",
|
||||
required: false,
|
||||
type: "password",
|
||||
},
|
||||
{
|
||||
name: "embedding_model",
|
||||
label: "Embedding Model",
|
||||
tooltip: "Model used to embed documents",
|
||||
placeholder: "text-embedding-3-small",
|
||||
required: false,
|
||||
type: "select",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
}));
|
||||
|
||||
const uploadFile = async (name = "test.pdf") => {
|
||||
const file = new File(["test content"], name, { type: "application/pdf" });
|
||||
const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await act(async () => {
|
||||
fireEvent.change(uploadInput, { target: { files: [file] } });
|
||||
});
|
||||
await screen.findByText(/Uploaded Documents \(1\)/);
|
||||
};
|
||||
|
||||
const pickProvider = async (label: string) => {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getAllByRole("combobox")[0];
|
||||
await user.click(trigger);
|
||||
const option = await screen.findByText(label);
|
||||
await user.click(option);
|
||||
};
|
||||
|
||||
const clickCreate = async () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /Create Vector Store/i }));
|
||||
});
|
||||
};
|
||||
|
||||
describe("CreateVectorStore submit payload characterization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchModels.fetchAvailableModels).mockResolvedValue([]);
|
||||
vi.mocked(networking.ragIngestCall).mockResolvedValue({
|
||||
id: "test-id",
|
||||
status: "completed",
|
||||
vector_store_id: "vs_123",
|
||||
file_id: "file_123",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends undefined, not empty string, for an untouched name and description", async () => {
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
await uploadFile();
|
||||
await clickCreate();
|
||||
|
||||
await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(1));
|
||||
expect(networking.ragIngestCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.any(File),
|
||||
"bedrock",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards the typed name and description verbatim", async () => {
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
await uploadFile();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g., Product Documentation, Customer Support KB"), {
|
||||
target: { value: " Product Docs " },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g., Contains all product documentation and user guides"), {
|
||||
target: { value: "All the guides" },
|
||||
});
|
||||
await clickCreate();
|
||||
|
||||
await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(1));
|
||||
expect(networking.ragIngestCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.any(File),
|
||||
"bedrock",
|
||||
undefined,
|
||||
" Product Docs ",
|
||||
"All the guides",
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("accumulates provider-specific fields into the providerParams argument", async () => {
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
await uploadFile();
|
||||
await pickProvider("PG Vector");
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("http://localhost:8000"), {
|
||||
target: { value: "http://pg.internal:8000" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("sk-..."), { target: { value: "sk-secret" } });
|
||||
await clickCreate();
|
||||
|
||||
await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(1));
|
||||
expect(networking.ragIngestCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.any(File),
|
||||
"pg_vector",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ api_base: "http://pg.internal:8000", api_key: "sk-secret" },
|
||||
);
|
||||
});
|
||||
|
||||
it("loads the S3 embedding models, filters them by typing and sends the chosen one", async () => {
|
||||
vi.mocked(fetchModels.fetchAvailableModels).mockResolvedValue([
|
||||
{ model_group: "text-embedding-3-small", mode: "embedding" },
|
||||
{ model_group: "text-embedding-3-large", mode: "embedding" },
|
||||
{ model_group: "gpt-5", mode: "chat" },
|
||||
] as Awaited<ReturnType<typeof fetchModels.fetchAvailableModels>>);
|
||||
const user = userEvent.setup();
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
await uploadFile();
|
||||
await pickProvider("AWS S3 Vectors");
|
||||
|
||||
const modelInput = screen.getAllByRole("combobox").at(-1) as HTMLElement;
|
||||
await user.click(modelInput);
|
||||
expect((await screen.findAllByText("text-embedding-3-small")).at(-1)).toBeInTheDocument();
|
||||
expect(screen.queryByText("gpt-5")).not.toBeInTheDocument();
|
||||
|
||||
await user.type(modelInput, "large");
|
||||
await user.click((await screen.findAllByText("text-embedding-3-large")).at(-1) as HTMLElement);
|
||||
await clickCreate();
|
||||
|
||||
await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(networking.ragIngestCall).mock.calls.at(-1)?.[6]).toEqual({
|
||||
embedding_model: "text-embedding-3-large",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks the submit when a required provider field is missing", async () => {
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
await uploadFile();
|
||||
await pickProvider("PG Vector");
|
||||
await clickCreate();
|
||||
|
||||
expect(networking.ragIngestCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the password provider field as a masked input", async () => {
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
await pickProvider("PG Vector");
|
||||
|
||||
expect(screen.getByPlaceholderText("sk-...")).toHaveAttribute("type", "password");
|
||||
});
|
||||
|
||||
it("reuses the vector store id returned by the first ingest for later documents", async () => {
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
|
||||
const files = [
|
||||
new File(["a"], "a.pdf", { type: "application/pdf" }),
|
||||
new File(["b"], "b.pdf", { type: "application/pdf" }),
|
||||
];
|
||||
const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await act(async () => {
|
||||
fireEvent.change(uploadInput, { target: { files } });
|
||||
});
|
||||
await screen.findByText(/Uploaded Documents \(2\)/);
|
||||
await clickCreate();
|
||||
|
||||
await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(2));
|
||||
expect(vi.mocked(networking.ragIngestCall).mock.calls[0][3]).toBeUndefined();
|
||||
expect(vi.mocked(networking.ragIngestCall).mock.calls[1][3]).toBe("vs_123");
|
||||
});
|
||||
|
||||
it("does not submit at all when no document has been uploaded", async () => {
|
||||
render(<CreateVectorStore accessToken="test-token" />);
|
||||
await clickCreate();
|
||||
|
||||
expect(networking.ragIngestCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 "@/components/networking";
|
||||
|
|
@ -204,17 +205,10 @@ describe("CreateVectorStore", () => {
|
|||
// Find and click the provider dropdown
|
||||
const providerSelect = screen.getByRole("combobox");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(providerSelect);
|
||||
});
|
||||
await userEvent.click(providerSelect);
|
||||
|
||||
// Wait for dropdown options to appear
|
||||
await waitFor(() => {
|
||||
const s3Option = screen.queryByText("AWS S3 Vectors");
|
||||
if (s3Option) {
|
||||
fireEvent.click(s3Option);
|
||||
}
|
||||
});
|
||||
await userEvent.click(await screen.findByText("AWS S3 Vectors"));
|
||||
|
||||
// Check if S3-specific fields are displayed
|
||||
await waitFor(() => {
|
||||
|
|
@ -244,16 +238,9 @@ describe("CreateVectorStore", () => {
|
|||
// Select S3 Vectors provider
|
||||
const providerSelect = screen.getByRole("combobox");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(providerSelect);
|
||||
});
|
||||
await userEvent.click(providerSelect);
|
||||
|
||||
await waitFor(() => {
|
||||
const s3Option = screen.queryByText("AWS S3 Vectors");
|
||||
if (s3Option) {
|
||||
fireEvent.click(s3Option);
|
||||
}
|
||||
});
|
||||
await userEvent.click(await screen.findByText("AWS S3 Vectors"));
|
||||
|
||||
// Try to create without filling required fields
|
||||
const createButton = screen.getByRole("button", { name: /Create Vector Store/i });
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useState } from "react";
|
||||
import { Card, Title, Text } from "@tremor/react";
|
||||
import { Upload, Button, Select, Form, Alert, Tooltip, Input } from "antd";
|
||||
import { Upload, Alert } from "antd";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { InboxOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { InboxOutlined } from "@ant-design/icons";
|
||||
import type { UploadProps } from "antd";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { ragIngestCall } from "@/components/networking";
|
||||
import { DocumentUpload, RAGIngestResponse } from "@/components/vector_store_management/types";
|
||||
import DocumentsTable from "./DocumentsTable";
|
||||
|
|
@ -15,24 +16,42 @@ import {
|
|||
VectorStoreFieldConfig,
|
||||
} from "@/components/vector_store_providers";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import S3VectorsConfig from "./S3VectorsConfig";
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
const asText = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
interface CreateVectorStoreProps {
|
||||
accessToken: string | null;
|
||||
onSuccess?: (vectorStoreId: string) => void;
|
||||
}
|
||||
|
||||
const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [documents, setDocuments] = useState<DocumentUpload[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>("bedrock");
|
||||
const [vectorStoreName, setVectorStoreName] = useState<string>("");
|
||||
const [vectorStoreDescription, setVectorStoreDescription] = useState<string>("");
|
||||
const [ingestResults, setIngestResults] = useState<RAGIngestResponse[]>([]);
|
||||
const [providerParams, setProviderParams] = useState<Record<string, any>>({});
|
||||
const [providerParams, setProviderParams] = useState<Record<string, unknown>>({});
|
||||
|
||||
const uploadProps: UploadProps = {
|
||||
name: "file",
|
||||
|
|
@ -108,11 +127,13 @@ 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) {
|
||||
const bucketName = asText(providerParams.vector_bucket_name);
|
||||
const indexName = asText(providerParams.index_name);
|
||||
if (bucketName && bucketName.length < 3) {
|
||||
toast.warning("Vector bucket name must be at least 3 characters");
|
||||
return;
|
||||
}
|
||||
if (providerParams.index_name && providerParams.index_name.length > 0 && providerParams.index_name.length < 3) {
|
||||
if (indexName && indexName.length > 0 && indexName.length < 3) {
|
||||
toast.warning("Index name must be at least 3 characters if provided");
|
||||
return;
|
||||
}
|
||||
|
|
@ -186,225 +207,165 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Title>Create Vector Store</Title>
|
||||
<Text className="text-gray-500">
|
||||
Upload documents and select a provider to create a new vector store with embedded content.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<Text className="font-medium">Step 1: Upload Documents</Text>
|
||||
<Text className="text-sm text-gray-500 block mt-1">
|
||||
Upload one or more documents (PDF, TXT, DOCX, MD). Maximum file size: 50MB per file.
|
||||
<TooltipProvider>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Title>Create Vector Store</Title>
|
||||
<Text className="text-muted-foreground">
|
||||
Upload documents and select a provider to create a new vector store with embedded content.
|
||||
</Text>
|
||||
</div>
|
||||
<Dragger {...uploadProps}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined style={{ fontSize: "48px", color: "#1890ff" }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">Click or drag files to this area to upload</p>
|
||||
<p className="ant-upload-hint">Support for single or bulk upload. Supported formats: PDF, TXT, DOCX, MD</p>
|
||||
</Dragger>
|
||||
</Card>
|
||||
|
||||
{/* Documents Table */}
|
||||
{documents.length > 0 && (
|
||||
{/* Upload Area */}
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<Text className="font-medium">Uploaded Documents ({documents.length})</Text>
|
||||
</div>
|
||||
<DocumentsTable documents={documents} onRemove={handleRemoveDocument} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Provider Selection and Vector Store Details */}
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text className="font-medium">Step 2: Configure Vector Store</Text>
|
||||
<Text className="text-sm text-gray-500 block mt-1">
|
||||
Choose the provider and optionally provide a name and description for your vector store.
|
||||
<Text className="font-medium">Step 1: Upload Documents</Text>
|
||||
<Text className="text-sm text-muted-foreground block mt-1">
|
||||
Upload one or more documents (PDF, TXT, DOCX, MD). Maximum file size: 50MB per file.
|
||||
</Text>
|
||||
</div>
|
||||
<Dragger {...uploadProps}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined style={{ fontSize: "48px", color: "#1890ff" }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">Click or drag files to this area to upload</p>
|
||||
<p className="ant-upload-hint">Support for single or bulk upload. Supported formats: PDF, TXT, DOCX, MD</p>
|
||||
</Dragger>
|
||||
</Card>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Vector Store Name{" "}
|
||||
<Tooltip title="Optional: Give your vector store a meaningful name">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={vectorStoreName}
|
||||
onChange={(e) => setVectorStoreName(e.target.value)}
|
||||
placeholder="e.g., Product Documentation, Customer Support KB"
|
||||
size="large"
|
||||
className="rounded-md"
|
||||
/>
|
||||
</Form.Item>
|
||||
{/* Documents Table */}
|
||||
{documents.length > 0 && (
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<Text className="font-medium">Uploaded Documents ({documents.length})</Text>
|
||||
</div>
|
||||
<DocumentsTable documents={documents} onRemove={handleRemoveDocument} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Description{" "}
|
||||
<Tooltip title="Optional: Describe what this vector store contains">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input.TextArea
|
||||
value={vectorStoreDescription}
|
||||
onChange={(e) => setVectorStoreDescription(e.target.value)}
|
||||
placeholder="e.g., Contains all product documentation and user guides"
|
||||
rows={2}
|
||||
size="large"
|
||||
className="rounded-md"
|
||||
/>
|
||||
</Form.Item>
|
||||
{/* Provider Selection and Vector Store Details */}
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text className="font-medium">Step 2: Configure Vector Store</Text>
|
||||
<Text className="text-sm text-muted-foreground block mt-1">
|
||||
Choose the provider and optionally provide a name and description for your vector store.
|
||||
</Text>
|
||||
</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%" }}
|
||||
>
|
||||
{Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => {
|
||||
return (
|
||||
<Select.Option key={providerEnum} value={vectorStoreProviderMap[providerEnum]}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="vector-store-name">
|
||||
{labelWithHint("Vector Store Name", "Optional: Give your vector store a meaningful name")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="vector-store-name"
|
||||
value={vectorStoreName}
|
||||
onChange={(e) => setVectorStoreName(e.target.value)}
|
||||
placeholder="e.g., Product Documentation, Customer Support KB"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="vector-store-description">
|
||||
{labelWithHint("Description", "Optional: Describe what this vector store contains")}
|
||||
</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}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="vector-store-provider">
|
||||
{labelWithHint("Provider", "Select the provider for embedding and vector store operations")}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={selectedProvider}
|
||||
onValueChange={(value: string | null) => value !== null && setSelectedProvider(value)}
|
||||
>
|
||||
<SelectTrigger id="vector-store-provider" className="w-full">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => (
|
||||
<SelectItem key={providerEnum} value={vectorStoreProviderMap[providerEnum]}>
|
||||
<Logo
|
||||
src={vectorStoreProviderLogoMap[providerDisplayName]}
|
||||
label={providerDisplayName}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<span>{providerDisplayName}</span>
|
||||
</div>
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{/* S3 Vectors Configuration */}
|
||||
{selectedProvider === "s3_vectors" && (
|
||||
<S3VectorsConfig
|
||||
accessToken={accessToken}
|
||||
providerParams={providerParams}
|
||||
onParamsChange={setProviderParams}
|
||||
/>
|
||||
)}
|
||||
{/* S3 Vectors Configuration */}
|
||||
{selectedProvider === "s3_vectors" && (
|
||||
<S3VectorsConfig
|
||||
accessToken={accessToken}
|
||||
providerParams={providerParams}
|
||||
onParamsChange={setProviderParams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
>
|
||||
{/* Other Provider-specific fields */}
|
||||
{selectedProvider !== "s3_vectors" &&
|
||||
getProviderSpecificFields(selectedProvider).map((field: VectorStoreFieldConfig) => (
|
||||
<Field key={field.name}>
|
||||
<FieldLabel htmlFor={`vector-store-${field.name}`}>
|
||||
{labelWithHint(field.label, field.tooltip)}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`vector-store-${field.name}`}
|
||||
type={field.type === "password" ? "password" : "text"}
|
||||
value={providerParams[field.name] || ""}
|
||||
value={asText(providerParams[field.name])}
|
||||
onChange={(e) => setProviderParams((prev) => ({ ...prev, [field.name]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
size="large"
|
||||
className="rounded-md"
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleCreateVectorStore}
|
||||
loading={isCreating}
|
||||
disabled={documents.length === 0 || !selectedProvider}
|
||||
>
|
||||
{isCreating ? "Creating Vector Store..." : "Create Vector Store"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Success Message */}
|
||||
{ingestResults.length > 0 && (
|
||||
<Alert
|
||||
message="Vector Store Created Successfully"
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
<strong>Vector Store ID:</strong> {ingestResults[0]?.vector_store_id}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Documents Ingested:</strong> {ingestResults.length}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleCreateVectorStore}
|
||||
disabled={isCreating || documents.length === 0 || !selectedProvider}
|
||||
>
|
||||
{isCreating && <UiLoadingSpinner className="size-4" />}
|
||||
{isCreating ? "Creating Vector Store..." : "Create Vector Store"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
type="success"
|
||||
showIcon
|
||||
closable
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Success Message */}
|
||||
{ingestResults.length > 0 && (
|
||||
<Alert
|
||||
message="Vector Store Created Successfully"
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
<strong>Vector Store ID:</strong> {ingestResults[0]?.vector_store_id}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Documents Ingested:</strong> {ingestResults.length}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
type="success"
|
||||
showIcon
|
||||
closable
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,37 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Alert, Form, Input, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Alert } from "antd";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { Field, FieldError, FieldLabel } from "@/components/shared/form/field";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface S3VectorsConfigProps {
|
||||
accessToken: string | null;
|
||||
providerParams: Record<string, any>;
|
||||
onParamsChange: (params: Record<string, any>) => void;
|
||||
providerParams: Record<string, unknown>;
|
||||
onParamsChange: (params: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const asText = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||
|
||||
const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({ accessToken, providerParams, onParamsChange }) => {
|
||||
const [embeddingModels, setEmbeddingModels] = useState<ModelGroup[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
|
|
@ -20,7 +43,6 @@ const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({ accessToken, provider
|
|||
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) {
|
||||
|
|
@ -40,9 +62,16 @@ const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({ accessToken, provider
|
|||
});
|
||||
};
|
||||
|
||||
const bucketName = asText(providerParams.vector_bucket_name);
|
||||
const indexName = asText(providerParams.index_name);
|
||||
const bucketNameError = bucketName && bucketName.length < 3 ? "Bucket name must be at least 3 characters" : undefined;
|
||||
const indexNameError =
|
||||
indexName && indexName.length > 0 && indexName.length < 3
|
||||
? "Index name must be at least 3 characters if provided"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* S3 Vectors Setup Instructions */}
|
||||
<TooltipProvider>
|
||||
<Alert
|
||||
message="AWS S3 Vectors Setup"
|
||||
description={
|
||||
|
|
@ -70,114 +99,75 @@ const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({ accessToken, provider
|
|||
style={{ marginBottom: "16px" }}
|
||||
/>
|
||||
|
||||
{/* Vector Bucket Name */}
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Vector Bucket Name{" "}
|
||||
<Tooltip title="S3 bucket name for vector storage (must be at least 3 characters, lowercase letters, numbers, hyphens, and periods only)">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
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
|
||||
}
|
||||
>
|
||||
<Field data-invalid={bucketNameError !== undefined || undefined}>
|
||||
<FieldLabel htmlFor="s3-vector-bucket-name">
|
||||
{labelWithHint(
|
||||
"Vector Bucket Name",
|
||||
"S3 bucket name for vector storage (must be at least 3 characters, lowercase letters, numbers, hyphens, and periods only)",
|
||||
)}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
value={providerParams.vector_bucket_name || ""}
|
||||
id="s3-vector-bucket-name"
|
||||
value={bucketName}
|
||||
onChange={(e) => handleFieldChange("vector_bucket_name", e.target.value)}
|
||||
placeholder="my-vector-bucket (min 3 chars)"
|
||||
size="large"
|
||||
className="rounded-md"
|
||||
aria-invalid={bucketNameError !== undefined || undefined}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FieldError>{bucketNameError}</FieldError>
|
||||
</Field>
|
||||
|
||||
{/* Index Name (Optional) */}
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Index Name{" "}
|
||||
<Tooltip title="Name for the vector index (optional, will be auto-generated if not provided). If provided, must be at least 3 characters.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</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
|
||||
}
|
||||
>
|
||||
<Field data-invalid={indexNameError !== undefined || undefined}>
|
||||
<FieldLabel htmlFor="s3-index-name">
|
||||
{labelWithHint(
|
||||
"Index Name",
|
||||
"Name for the vector index (optional, will be auto-generated if not provided). If provided, must be at least 3 characters.",
|
||||
)}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
value={providerParams.index_name || ""}
|
||||
id="s3-index-name"
|
||||
value={indexName}
|
||||
onChange={(e) => handleFieldChange("index_name", e.target.value)}
|
||||
placeholder="my-vector-index (optional, min 3 chars)"
|
||||
size="large"
|
||||
className="rounded-md"
|
||||
aria-invalid={indexNameError !== undefined || undefined}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FieldError>{indexNameError}</FieldError>
|
||||
</Field>
|
||||
|
||||
{/* AWS Region */}
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
AWS Region{" "}
|
||||
<Tooltip title="AWS region where the S3 bucket is located (e.g., us-west-2)">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
required
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="s3-aws-region-name">
|
||||
{labelWithHint("AWS Region", "AWS region where the S3 bucket is located (e.g., us-west-2)")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
value={providerParams.aws_region_name || ""}
|
||||
id="s3-aws-region-name"
|
||||
value={asText(providerParams.aws_region_name)}
|
||||
onChange={(e) => handleFieldChange("aws_region_name", e.target.value)}
|
||||
placeholder="us-west-2"
|
||||
size="large"
|
||||
className="rounded-md"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Field>
|
||||
|
||||
{/* Embedding Model */}
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Embedding Model{" "}
|
||||
<Tooltip title="Select the embedding model to use for vector generation">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
value={providerParams.embedding_model || undefined}
|
||||
onChange={(value) => handleFieldChange("embedding_model", value)}
|
||||
placeholder="Select an embedding model"
|
||||
size="large"
|
||||
showSearch
|
||||
loading={isLoadingModels}
|
||||
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
options={embeddingModels.map((model) => ({
|
||||
value: model.model_group,
|
||||
label: model.model_group,
|
||||
}))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="s3-embedding-model">
|
||||
{labelWithHint("Embedding Model", "Select the embedding model to use for vector generation")}
|
||||
</FieldLabel>
|
||||
<Combobox
|
||||
value={asText(providerParams.embedding_model) || null}
|
||||
onValueChange={(value: string | null) => value !== null && handleFieldChange("embedding_model", value)}
|
||||
items={embeddingModels.map((model) => model.model_group)}
|
||||
>
|
||||
<ComboboxInput id="s3-embedding-model" placeholder="Select an embedding model" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>{isLoadingModels ? "Loading models..." : "No embedding models found."}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(model: string) => (
|
||||
<ComboboxItem key={model} value={model}>
|
||||
{model}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</Field>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue