mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-12 22:42:13 +02:00
feat(model-connections): enhance model connection functionality with preview and selection features
This commit is contained in:
parent
356f0e56c5
commit
407f2a9612
20 changed files with 630 additions and 429 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ApiKeyField, ConnectFormFooter } from "./connect-fields";
|
||||
import { ApiKeyField } from "./connect-fields";
|
||||
import {
|
||||
isValidAzureTargetUri,
|
||||
type ProviderConnectFormProps,
|
||||
|
|
@ -12,48 +12,43 @@ import {
|
|||
* Azure OpenAI connect form. The user pastes a single Target URI, which we parse
|
||||
* into api base, api version, and the deployment name (seeded as the model).
|
||||
*/
|
||||
export function AzureConnectForm({ isPending, onCancel, onSubmit }: ProviderConnectFormProps) {
|
||||
export function AzureConnectForm({ onDraftChange }: ProviderConnectFormProps) {
|
||||
const [targetUri, setTargetUri] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const canSubmit = isValidAzureTargetUri(targetUri) && Boolean(apiKey.trim());
|
||||
|
||||
function handleSubmit() {
|
||||
useEffect(() => {
|
||||
const parsed = parseAzureTargetUri(targetUri);
|
||||
onSubmit({
|
||||
base_url: parsed?.origin ?? null,
|
||||
api_key: apiKey || null,
|
||||
extra: parsed?.apiVersion ? { api_version: parsed.apiVersion } : {},
|
||||
seedModelId: parsed?.deploymentName || undefined,
|
||||
});
|
||||
}
|
||||
onDraftChange(
|
||||
{
|
||||
base_url: parsed?.origin ?? null,
|
||||
api_key: apiKey || null,
|
||||
extra: parsed?.apiVersion ? { api_version: parsed.apiVersion } : {},
|
||||
seedModelId: parsed?.deploymentName || undefined,
|
||||
},
|
||||
canSubmit
|
||||
);
|
||||
}, [apiKey, canSubmit, onDraftChange, targetUri]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Target URI</Label>
|
||||
<Input
|
||||
value={targetUri}
|
||||
onChange={(event) => setTargetUri(event.target.value)}
|
||||
placeholder="https://your-resource.cognitiveservices.azure.com/openai/deployments/deployment-name/chat/completions?api-version=2025-01-01-preview"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste your endpoint target URI from Azure OpenAI (including API base, deployment name,
|
||||
and API version).
|
||||
</p>
|
||||
</div>
|
||||
<ApiKeyField
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
placeholder="Paste your API key from Azure"
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Target URI</Label>
|
||||
<Input
|
||||
value={targetUri}
|
||||
onChange={(event) => setTargetUri(event.target.value)}
|
||||
placeholder="https://your-resource.cognitiveservices.azure.com/openai/deployments/deployment-name/chat/completions?api-version=2025-01-01-preview"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste your endpoint target URI from Azure OpenAI (including API base, deployment name, and
|
||||
API version).
|
||||
</p>
|
||||
</div>
|
||||
<ConnectFormFooter
|
||||
onCancel={onCancel}
|
||||
onSubmit={handleSubmit}
|
||||
canSubmit={canSubmit}
|
||||
isPending={isPending}
|
||||
<ApiKeyField
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
placeholder="Paste your API key from Azure"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ConnectFormFooter } from "./connect-fields";
|
||||
import { ApiKeyField } from "./connect-fields";
|
||||
import {
|
||||
AWS_REGION_OPTIONS,
|
||||
BEDROCK_AUTH_ACCESS_KEY,
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
* Amazon Bedrock connect form. Region + auth method drive which AWS credentials
|
||||
* are collected; everything rides along in `extra.litellm_params`.
|
||||
*/
|
||||
export function BedrockConnectForm({ isPending, onCancel, onSubmit }: ProviderConnectFormProps) {
|
||||
export function BedrockConnectForm({ onDraftChange }: ProviderConnectFormProps) {
|
||||
const [region, setRegion] = useState("");
|
||||
const [authMethod, setAuthMethod] = useState(BEDROCK_AUTH_ACCESS_KEY);
|
||||
const [accessKeyId, setAccessKeyId] = useState("");
|
||||
|
|
@ -39,7 +39,7 @@ export function BedrockConnectForm({ isPending, onCancel, onSubmit }: ProviderCo
|
|||
return true;
|
||||
})();
|
||||
|
||||
function handleSubmit() {
|
||||
useEffect(() => {
|
||||
const params: Record<string, string> = { aws_region_name: region };
|
||||
if (authMethod === BEDROCK_AUTH_ACCESS_KEY) {
|
||||
params.aws_access_key_id = accessKeyId;
|
||||
|
|
@ -47,88 +47,74 @@ export function BedrockConnectForm({ isPending, onCancel, onSubmit }: ProviderCo
|
|||
} else if (authMethod === BEDROCK_AUTH_LONG_TERM_API_KEY) {
|
||||
params.aws_bearer_token_bedrock = bearerToken;
|
||||
}
|
||||
onSubmit({ base_url: null, api_key: null, extra: { litellm_params: params } });
|
||||
}
|
||||
onDraftChange({ base_url: null, api_key: null, extra: { litellm_params: params } }, canSubmit);
|
||||
}, [accessKeyId, authMethod, bearerToken, canSubmit, onDraftChange, region, secretAccessKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>AWS Region</Label>
|
||||
<Select value={region || undefined} onValueChange={setRegion}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AWS_REGION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Authentication Method</Label>
|
||||
<Select value={authMethod} onValueChange={setAuthMethod}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={BEDROCK_AUTH_IAM}>Environment IAM Role</SelectItem>
|
||||
<SelectItem value={BEDROCK_AUTH_ACCESS_KEY}>Access Key</SelectItem>
|
||||
<SelectItem value={BEDROCK_AUTH_LONG_TERM_API_KEY}>Long-term API Key</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{authMethod === BEDROCK_AUTH_ACCESS_KEY ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>AWS Access Key ID</Label>
|
||||
<Input
|
||||
value={accessKeyId}
|
||||
onChange={(event) => setAccessKeyId(event.target.value)}
|
||||
placeholder="AKIAIOSFODNN7EXAMPLE"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>AWS Secret Access Key</Label>
|
||||
<Input
|
||||
value={secretAccessKey}
|
||||
onChange={(event) => setSecretAccessKey(event.target.value)}
|
||||
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{authMethod === BEDROCK_AUTH_LONG_TERM_API_KEY ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>AWS Region</Label>
|
||||
<Select value={region || undefined} onValueChange={setRegion}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AWS_REGION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Authentication Method</Label>
|
||||
<Select value={authMethod} onValueChange={setAuthMethod}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={BEDROCK_AUTH_IAM}>Environment IAM Role</SelectItem>
|
||||
<SelectItem value={BEDROCK_AUTH_ACCESS_KEY}>Access Key</SelectItem>
|
||||
<SelectItem value={BEDROCK_AUTH_LONG_TERM_API_KEY}>Long-term API Key</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{authMethod === BEDROCK_AUTH_ACCESS_KEY ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Long-term API Key</Label>
|
||||
<Label>AWS Access Key ID</Label>
|
||||
<Input
|
||||
value={bearerToken}
|
||||
onChange={(event) => setBearerToken(event.target.value)}
|
||||
placeholder="Your long-term API key"
|
||||
type="password"
|
||||
value={accessKeyId}
|
||||
onChange={(event) => setAccessKeyId(event.target.value)}
|
||||
placeholder="AKIAIOSFODNN7EXAMPLE"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{authMethod === BEDROCK_AUTH_IAM ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
SurfSense will use the IAM role attached to the environment it's running in to
|
||||
authenticate.
|
||||
</p>
|
||||
) : null}
|
||||
<ApiKeyField
|
||||
value={secretAccessKey}
|
||||
onChange={setSecretAccessKey}
|
||||
label="AWS Secret Access Key"
|
||||
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{authMethod === BEDROCK_AUTH_LONG_TERM_API_KEY ? (
|
||||
<ApiKeyField
|
||||
value={bearerToken}
|
||||
onChange={setBearerToken}
|
||||
label="Long-term API Key"
|
||||
placeholder="Your long-term API key"
|
||||
/>
|
||||
) : null}
|
||||
{authMethod === BEDROCK_AUTH_IAM ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add Bedrock model IDs from the provider's settings after connecting.
|
||||
SurfSense will use the IAM role attached to the environment it's running in to
|
||||
authenticate.
|
||||
</p>
|
||||
</div>
|
||||
<ConnectFormFooter
|
||||
onCancel={onCancel}
|
||||
onSubmit={handleSubmit}
|
||||
canSubmit={canSubmit}
|
||||
isPending={isPending}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add Bedrock model IDs from the provider's settings after connecting.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogFooter } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -43,15 +45,31 @@ export function ApiKeyField({
|
|||
label = "API Key",
|
||||
placeholder = "API key",
|
||||
}: ApiKeyFieldProps) {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
type="password"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
type={showApiKey ? "text" : "password"}
|
||||
className="pr-11"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-1/2 right-1 size-8 -translate-y-1/2 text-muted-foreground"
|
||||
onClick={() => setShowApiKey((current) => !current)}
|
||||
disabled={!value}
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -71,7 +89,7 @@ export function ConnectFormFooter({
|
|||
isPending,
|
||||
}: ConnectFormFooterProps) {
|
||||
return (
|
||||
<DialogFooter className="mt-6">
|
||||
<DialogFooter className="shrink-0 border-t bg-popover px-6 py-4">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ import { Separator } from "@/components/ui/separator";
|
|||
import type {
|
||||
ConnectionRead,
|
||||
ConnectionUpdateRequest,
|
||||
ModelRead,
|
||||
} from "@/contracts/types/model-connections.types";
|
||||
import type { SelectableModel } from "./model-utils";
|
||||
import { ModelsSelectionPanel } from "./models-selection-panel";
|
||||
import { providerIcon } from "./provider-metadata";
|
||||
|
||||
|
|
@ -101,17 +101,22 @@ export function ConnectionSettingsDialog({
|
|||
});
|
||||
}
|
||||
|
||||
function handleToggleModel(model: ModelRead, enabled: boolean) {
|
||||
function handleToggleModel(model: SelectableModel, enabled: boolean) {
|
||||
if (typeof model.id !== "number") return;
|
||||
updateModel.mutate({
|
||||
id: model.id,
|
||||
data: { enabled },
|
||||
});
|
||||
}
|
||||
|
||||
function handleBulkToggle(models: ModelRead[], enabled: boolean) {
|
||||
function handleBulkToggle(models: SelectableModel[], enabled: boolean) {
|
||||
const modelIds = models
|
||||
.map((model) => model.id)
|
||||
.filter((id): id is number => typeof id === "number");
|
||||
if (modelIds.length === 0) return;
|
||||
bulkUpdateModels.mutate({
|
||||
connectionId: connection.id,
|
||||
data: { model_ids: models.map((model) => model.id), enabled },
|
||||
data: { model_ids: modelIds, enabled },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -184,12 +189,7 @@ export function ConnectionSettingsDialog({
|
|||
onChange={(event) => setAllowlistText(event.target.value)}
|
||||
placeholder="Comma-separated, e.g. anthropic/claude-sonnet-4-5, google/gemini-2.5-pro"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={saveAllowlist}
|
||||
disabled={updateConnection.isPending}
|
||||
>
|
||||
<Button size="sm" onClick={saveAllowlist} disabled={updateConnection.isPending}>
|
||||
Save filter
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { ApiBaseUrlField, ApiKeyField, ConnectFormFooter } from "./connect-fields";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ApiBaseUrlField, ApiKeyField } from "./connect-fields";
|
||||
import type { ProviderConnectFormProps } from "./provider-metadata";
|
||||
|
||||
/**
|
||||
|
|
@ -11,41 +11,31 @@ export function DefaultConnectForm({
|
|||
provider,
|
||||
defaultBaseUrl,
|
||||
baseUrlRequired,
|
||||
isPending,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onDraftChange,
|
||||
}: ProviderConnectFormProps) {
|
||||
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const isOllama = provider === "ollama_chat";
|
||||
const canSubmit = !(baseUrlRequired && !baseUrl.trim());
|
||||
|
||||
function handleSubmit() {
|
||||
onSubmit({ base_url: baseUrl || null, api_key: apiKey || null, extra: {} });
|
||||
}
|
||||
useEffect(() => {
|
||||
onDraftChange({ base_url: baseUrl || null, api_key: apiKey || null, extra: {} }, canSubmit);
|
||||
}, [apiKey, baseUrl, canSubmit, onDraftChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ApiBaseUrlField
|
||||
value={baseUrl}
|
||||
onChange={setBaseUrl}
|
||||
optional={!baseUrlRequired}
|
||||
placeholder={defaultBaseUrl}
|
||||
/>
|
||||
<ApiKeyField
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
label={isOllama ? "API Key (optional)" : "API Key"}
|
||||
placeholder={isOllama ? "Optional for Ollama" : "API key"}
|
||||
/>
|
||||
</div>
|
||||
<ConnectFormFooter
|
||||
onCancel={onCancel}
|
||||
onSubmit={handleSubmit}
|
||||
canSubmit={canSubmit}
|
||||
isPending={isPending}
|
||||
<div className="flex flex-col gap-4">
|
||||
<ApiBaseUrlField
|
||||
value={baseUrl}
|
||||
onChange={setBaseUrl}
|
||||
optional={!baseUrlRequired}
|
||||
placeholder={defaultBaseUrl}
|
||||
/>
|
||||
</>
|
||||
<ApiKeyField
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
label={isOllama ? "API Key (optional)" : "API Key"}
|
||||
placeholder={isOllama ? "Optional for Ollama" : "API key"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ModelRead } from "@/contracts/types/model-connections.types";
|
||||
import type { ModelPreviewRead, ModelRead } from "@/contracts/types/model-connections.types";
|
||||
|
||||
export type ModelCapabilityFilter = "chat" | "vision" | "image_gen";
|
||||
|
||||
|
|
@ -8,17 +8,22 @@ export const MODEL_CAPABILITY_FILTERS: { key: ModelCapabilityFilter; label: stri
|
|||
{ key: "image_gen", label: "Image" },
|
||||
];
|
||||
|
||||
export function modelLabel(model: ModelRead) {
|
||||
export type SelectableModel = (ModelRead | ModelPreviewRead) & {
|
||||
id?: number | string;
|
||||
connection_id?: number;
|
||||
};
|
||||
|
||||
export function modelLabel(model: SelectableModel) {
|
||||
return model.display_name || model.model_id;
|
||||
}
|
||||
|
||||
export function capability(model: ModelRead, key: ModelCapabilityFilter) {
|
||||
export function capability(model: SelectableModel, key: ModelCapabilityFilter) {
|
||||
if (key === "chat") return Boolean(model.supports_chat);
|
||||
if (key === "vision") return Boolean(model.supports_image_input);
|
||||
return Boolean(model.supports_image_generation);
|
||||
}
|
||||
|
||||
export function capabilityLabels(model: ModelRead) {
|
||||
export function capabilityLabels(model: SelectableModel) {
|
||||
return MODEL_CAPABILITY_FILTERS.filter((filter) => capability(model, filter.key))
|
||||
.map((filter) => filter.label.toLowerCase())
|
||||
.join(", ");
|
||||
|
|
|
|||
|
|
@ -4,17 +4,17 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ModelRead } from "@/contracts/types/model-connections.types";
|
||||
import {
|
||||
capability,
|
||||
capabilityLabels,
|
||||
MODEL_CAPABILITY_FILTERS,
|
||||
type ModelCapabilityFilter,
|
||||
modelLabel,
|
||||
type SelectableModel,
|
||||
} from "./model-utils";
|
||||
|
||||
interface ModelsSelectionPanelProps {
|
||||
models: ModelRead[];
|
||||
models: SelectableModel[];
|
||||
description?: string;
|
||||
emptyMessage?: string;
|
||||
manualInputPlaceholder?: string;
|
||||
|
|
@ -25,8 +25,8 @@ interface ModelsSelectionPanelProps {
|
|||
isBulkUpdating?: boolean;
|
||||
onRefresh?: () => void;
|
||||
onAddManual?: (modelId: string) => void;
|
||||
onToggleModel?: (model: ModelRead, enabled: boolean) => void;
|
||||
onBulkToggle?: (models: ModelRead[], enabled: boolean) => void;
|
||||
onToggleModel?: (model: SelectableModel, enabled: boolean) => void;
|
||||
onBulkToggle?: (models: SelectableModel[], enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function ModelsSelectionPanel({
|
||||
|
|
@ -166,7 +166,7 @@ export function ModelsSelectionPanel({
|
|||
<div className="space-y-2">
|
||||
{filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
key={model.id ?? model.model_id}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-background"
|
||||
>
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type {
|
||||
ConnectionRead,
|
||||
ModelProviderRead,
|
||||
ModelRead,
|
||||
} from "@/contracts/types/model-connections.types";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { ModelProviderRead } from "@/contracts/types/model-connections.types";
|
||||
import { AzureConnectForm } from "./azure-connect-form";
|
||||
import { BedrockConnectForm } from "./bedrock-connect-form";
|
||||
import { ConnectFormFooter } from "./connect-fields";
|
||||
import { DefaultConnectForm } from "./default-connect-form";
|
||||
import type { SelectableModel } from "./model-utils";
|
||||
import { ModelsSelectionPanel } from "./models-selection-panel";
|
||||
import {
|
||||
type ConnectionDraft,
|
||||
|
|
@ -32,17 +30,12 @@ interface ProviderConnectDialogProps {
|
|||
selectedProvider?: ModelProviderRead;
|
||||
isPending: boolean;
|
||||
onSubmit: (draft: ConnectionDraft) => void;
|
||||
connectedConnection?: ConnectionRead | null;
|
||||
connectModels?: ModelRead[];
|
||||
isDiscoveringModels?: boolean;
|
||||
isAddingManualModel?: boolean;
|
||||
isUpdatingModel?: boolean;
|
||||
isBulkUpdatingModels?: boolean;
|
||||
onRefreshModels?: () => void;
|
||||
onAddManualModel?: (modelId: string) => void;
|
||||
onToggleModel?: (model: ModelRead, enabled: boolean) => void;
|
||||
onBulkToggleModels?: (models: ModelRead[], enabled: boolean) => void;
|
||||
onDone?: () => void;
|
||||
previewModels?: SelectableModel[];
|
||||
isPreviewingModels?: boolean;
|
||||
onPreviewModels?: (draft: ConnectionDraft) => void;
|
||||
onAddPreviewModel?: (modelId: string) => void;
|
||||
onTogglePreviewModel?: (model: SelectableModel, enabled: boolean) => void;
|
||||
onBulkTogglePreviewModels?: (models: SelectableModel[], enabled: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -57,97 +50,93 @@ export function ProviderConnectDialog({
|
|||
selectedProvider,
|
||||
isPending,
|
||||
onSubmit,
|
||||
connectedConnection,
|
||||
connectModels = [],
|
||||
isDiscoveringModels = false,
|
||||
isAddingManualModel = false,
|
||||
isUpdatingModel = false,
|
||||
isBulkUpdatingModels = false,
|
||||
onRefreshModels,
|
||||
onAddManualModel,
|
||||
onToggleModel,
|
||||
onBulkToggleModels,
|
||||
onDone,
|
||||
previewModels = [],
|
||||
isPreviewingModels = false,
|
||||
onPreviewModels,
|
||||
onAddPreviewModel,
|
||||
onTogglePreviewModel,
|
||||
onBulkTogglePreviewModels,
|
||||
}: ProviderConnectDialogProps) {
|
||||
const meta = providerDisplay(provider);
|
||||
const isModelSelectionStep = Boolean(connectedConnection);
|
||||
const isAzure = provider === "azure";
|
||||
const isBedrock = provider === "bedrock";
|
||||
const isVertex = provider === "vertex_ai";
|
||||
const [currentDraft, setCurrentDraft] = useState<ConnectionDraft>({
|
||||
base_url: null,
|
||||
api_key: null,
|
||||
extra: {},
|
||||
});
|
||||
const [canSubmit, setCanSubmit] = useState(false);
|
||||
|
||||
const handleDraftChange = useCallback((draft: ConnectionDraft, nextCanSubmit: boolean) => {
|
||||
setCurrentDraft(draft);
|
||||
setCanSubmit(nextCanSubmit);
|
||||
}, []);
|
||||
|
||||
const formProps: ProviderConnectFormProps = {
|
||||
provider,
|
||||
defaultBaseUrl: providerDefaultBaseUrl(provider, selectedProvider?.default_base_url),
|
||||
baseUrlRequired: Boolean(selectedProvider?.base_url_required),
|
||||
isPending,
|
||||
onCancel: () => onOpenChange(false),
|
||||
onSubmit,
|
||||
onDraftChange: handleDraftChange,
|
||||
};
|
||||
|
||||
const modelDescription = (() => {
|
||||
if (isAzure) {
|
||||
return "Select the models to enable for Azure OpenAI";
|
||||
}
|
||||
if (isBedrock) {
|
||||
return "Select the models to enable for Amazon Bedrock";
|
||||
}
|
||||
if (isVertex) {
|
||||
return "Select the models to enable for Gemini";
|
||||
}
|
||||
return "Select the models to enable for this provider";
|
||||
})();
|
||||
|
||||
const canRefreshModels = !isAzure && !isVertex && (!isBedrock || canSubmit);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={`flex max-h-[90vh] ${
|
||||
isModelSelectionStep ? "max-w-2xl" : "max-w-xl"
|
||||
} flex-col overflow-hidden bg-popover p-0 text-popover-foreground`}
|
||||
>
|
||||
<DialogContent className="flex h-[85vh] max-h-[760px] min-h-[640px] max-w-2xl flex-col overflow-hidden bg-popover p-0 text-popover-foreground">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
{providerIcon(provider, "size-5")}
|
||||
<div>
|
||||
<DialogTitle>
|
||||
{isModelSelectionStep ? `Select ${meta.name} models` : `Connect ${meta.name}`}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isModelSelectionStep
|
||||
? selectedProvider?.discovery === "static"
|
||||
? "Choose from known model IDs or add one manually."
|
||||
: "Choose which discovered models should be available in this search space."
|
||||
: meta.subtitle}
|
||||
</DialogDescription>
|
||||
<DialogTitle>Connect {meta.name}</DialogTitle>
|
||||
<DialogDescription>{meta.subtitle}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{isModelSelectionStep ? (
|
||||
<>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<ModelsSelectionPanel
|
||||
models={connectModels}
|
||||
description={
|
||||
selectedProvider?.discovery === "static"
|
||||
? "These are known model IDs for this provider. Select the ones to make available."
|
||||
: "Select models to make available for this provider."
|
||||
}
|
||||
emptyMessage={
|
||||
isDiscoveringModels
|
||||
? "Discovering models..."
|
||||
: "No models found. You can refresh discovery or add a model ID manually."
|
||||
}
|
||||
isRefreshing={isDiscoveringModels}
|
||||
isAddingManual={isAddingManualModel}
|
||||
isUpdatingModel={isUpdatingModel}
|
||||
isBulkUpdating={isBulkUpdatingModels}
|
||||
refreshLabel={`Refresh ${meta.name} models`}
|
||||
onRefresh={onRefreshModels}
|
||||
onAddManual={onAddManualModel}
|
||||
onToggleModel={onToggleModel}
|
||||
onBulkToggle={onBulkToggleModels}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="shrink-0 border-t bg-popover px-6 py-4">
|
||||
<Button onClick={onDone}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<div className="overflow-y-auto px-6 py-5">
|
||||
{provider === "azure" ? (
|
||||
<AzureConnectForm {...formProps} />
|
||||
) : provider === "bedrock" ? (
|
||||
<BedrockConnectForm {...formProps} />
|
||||
) : provider === "vertex_ai" ? (
|
||||
<VertexConnectForm {...formProps} />
|
||||
) : (
|
||||
<DefaultConnectForm {...formProps} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-5">
|
||||
{provider === "azure" ? (
|
||||
<AzureConnectForm {...formProps} />
|
||||
) : provider === "bedrock" ? (
|
||||
<BedrockConnectForm {...formProps} />
|
||||
) : provider === "vertex_ai" ? (
|
||||
<VertexConnectForm {...formProps} />
|
||||
) : (
|
||||
<DefaultConnectForm {...formProps} />
|
||||
)}
|
||||
|
||||
<Separator className="bg-muted-foreground/20" />
|
||||
|
||||
<ModelsSelectionPanel
|
||||
models={previewModels}
|
||||
description={modelDescription}
|
||||
isRefreshing={isPreviewingModels}
|
||||
refreshLabel={`Refresh ${meta.name} models`}
|
||||
onRefresh={canRefreshModels ? () => onPreviewModels?.(currentDraft) : undefined}
|
||||
onAddManual={onAddPreviewModel}
|
||||
onToggleModel={onTogglePreviewModel}
|
||||
onBulkToggle={onBulkTogglePreviewModels}
|
||||
/>
|
||||
</div>
|
||||
<ConnectFormFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSubmit={() => onSubmit(currentDraft)}
|
||||
canSubmit={canSubmit}
|
||||
isPending={isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -133,7 +133,5 @@ export interface ProviderConnectFormProps {
|
|||
provider: string;
|
||||
defaultBaseUrl: string;
|
||||
baseUrlRequired: boolean;
|
||||
isPending: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (draft: ConnectionDraft) => void;
|
||||
onDraftChange: (draft: ConnectionDraft, canSubmit: boolean) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
|
|
@ -8,7 +8,6 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ConnectFormFooter } from "./connect-fields";
|
||||
import {
|
||||
type ProviderConnectFormProps,
|
||||
VERTEX_AUTH_SERVICE_ACCOUNT,
|
||||
|
|
@ -21,7 +20,7 @@ import {
|
|||
* credentials JSON file (read into a string); workload identity collects a
|
||||
* project id. Credentials ride along in `extra.litellm_params`.
|
||||
*/
|
||||
export function VertexConnectForm({ isPending, onCancel, onSubmit }: ProviderConnectFormProps) {
|
||||
export function VertexConnectForm({ onDraftChange }: ProviderConnectFormProps) {
|
||||
const [authMethod, setAuthMethod] = useState(VERTEX_AUTH_SERVICE_ACCOUNT);
|
||||
const [location, setLocation] = useState(VERTEX_DEFAULT_LOCATION);
|
||||
const [credentials, setCredentials] = useState("");
|
||||
|
|
@ -35,7 +34,7 @@ export function VertexConnectForm({ isPending, onCancel, onSubmit }: ProviderCon
|
|||
setCredentials(await file.text());
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
useEffect(() => {
|
||||
const params: Record<string, string> = {};
|
||||
if (location) params.vertex_location = location;
|
||||
if (authMethod === VERTEX_AUTH_SERVICE_ACCOUNT) {
|
||||
|
|
@ -43,85 +42,77 @@ export function VertexConnectForm({ isPending, onCancel, onSubmit }: ProviderCon
|
|||
} else if (project) {
|
||||
params.vertex_project = project;
|
||||
}
|
||||
onSubmit({ base_url: null, api_key: null, extra: { litellm_params: params } });
|
||||
}
|
||||
onDraftChange({ base_url: null, api_key: null, extra: { litellm_params: params } }, canSubmit);
|
||||
}, [authMethod, canSubmit, credentials, location, onDraftChange, project]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Authentication Method</Label>
|
||||
<Select value={authMethod} onValueChange={setAuthMethod}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={VERTEX_AUTH_SERVICE_ACCOUNT}>Service Account JSON</SelectItem>
|
||||
<SelectItem value={VERTEX_AUTH_WORKLOAD_IDENTITY}>Workload Identity (GKE)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Google Cloud Region Name</Label>
|
||||
<Input
|
||||
value={location}
|
||||
onChange={(event) => setLocation(event.target.value)}
|
||||
placeholder={VERTEX_DEFAULT_LOCATION}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Region where your Google Vertex AI models are hosted.
|
||||
</p>
|
||||
</div>
|
||||
{authMethod === VERTEX_AUTH_SERVICE_ACCOUNT ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Service Account JSON</Label>
|
||||
<Input
|
||||
id="vertex-service-account-json"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={(event) => handleCredentialsFile(event.target.files?.[0])}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="vertex-service-account-json"
|
||||
className="flex min-h-28 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-muted-foreground/40 bg-muted/20 px-4 py-6 text-center transition-colors hover:border-muted-foreground/70 hover:bg-muted/40"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{credentials ? "Service account JSON selected" : "Upload service account JSON"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Choose a .json file from Google Cloud
|
||||
</span>
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{credentials
|
||||
? "Credentials file loaded."
|
||||
: "Attach your service account key JSON from Google Cloud."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>GCP Project ID</Label>
|
||||
<Input
|
||||
value={project}
|
||||
onChange={(event) => setProject(event.target.value)}
|
||||
placeholder="my-vertex-project"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The GCP project where Vertex AI is enabled.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Authentication Method</Label>
|
||||
<Select value={authMethod} onValueChange={setAuthMethod}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={VERTEX_AUTH_SERVICE_ACCOUNT}>Service Account JSON</SelectItem>
|
||||
<SelectItem value={VERTEX_AUTH_WORKLOAD_IDENTITY}>Workload Identity (GKE)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Google Cloud Region Name</Label>
|
||||
<Input
|
||||
value={location}
|
||||
onChange={(event) => setLocation(event.target.value)}
|
||||
placeholder={VERTEX_DEFAULT_LOCATION}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add Vertex AI model IDs from the provider's settings after connecting.
|
||||
Region where your Google Vertex AI models are hosted.
|
||||
</p>
|
||||
</div>
|
||||
<ConnectFormFooter
|
||||
onCancel={onCancel}
|
||||
onSubmit={handleSubmit}
|
||||
canSubmit={canSubmit}
|
||||
isPending={isPending}
|
||||
/>
|
||||
</>
|
||||
{authMethod === VERTEX_AUTH_SERVICE_ACCOUNT ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Service Account JSON</Label>
|
||||
<Input
|
||||
id="vertex-service-account-json"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={(event) => handleCredentialsFile(event.target.files?.[0])}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="vertex-service-account-json"
|
||||
className="flex min-h-28 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-muted-foreground/40 bg-muted/20 px-4 py-6 text-center transition-colors hover:border-muted-foreground/70 hover:bg-muted/40"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{credentials ? "Service account JSON selected" : "Upload service account JSON"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Choose a .json file from Google Cloud
|
||||
</span>
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{credentials
|
||||
? "Credentials file loaded."
|
||||
: "Attach your service account key JSON from Google Cloud."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>GCP Project ID</Label>
|
||||
<Input
|
||||
value={project}
|
||||
onChange={(event) => setProject(event.target.value)}
|
||||
placeholder="my-vertex-project"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The GCP project where Vertex AI is enabled.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add Vertex AI model IDs from the provider's settings after connecting.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue