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:
yuneng-jiang 2026-08-18 14:07:19 -07:00 • committed by GitHub
parent 0cc2f29c78
commit 5997ef0423
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 487 additions and 318 deletions

View file

@ -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();
});
});

View file

@ -1,4 +1,5 @@
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import CreateVectorStore from "./CreateVectorStore";
import * as networking from "@/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 });

View file

@ -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>
);
};

View file

@ -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>
);
};