diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 0da5ec2197..9525f3a1c2 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1743,7 +1743,9 @@ export const deleteUserChatProviderKey = (queryClient: QueryClient) => ({ }, }); -const invalidateChatConfigurationQueries = async (queryClient: QueryClient) => { +export const invalidateChatConfigurationQueries = async ( + queryClient: QueryClient, +) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: chatProviderConfigsKey }), queryClient.invalidateQueries({ queryKey: chatModelConfigsKey }), diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx index dbeb16003d..e0290652cc 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx @@ -4,18 +4,31 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { Link, Navigate, useNavigate, useParams } from "react-router"; import { toast } from "sonner"; +import { API } from "#/api/api"; import { getErrorMessage } from "#/api/errors"; import { aiProvider, deleteAIProviderMutation, updateAIProviderMutation, } from "#/api/queries/aiProviders"; +import { + chatModelConfigs, + invalidateChatConfigurationQueries, +} from "#/api/queries/chats"; import { Avatar } from "#/components/Avatar/Avatar"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; -import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; +import { Input } from "#/components/Input/Input"; import { Loader } from "#/components/Loader/Loader"; import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader"; +import { Spinner } from "#/components/Spinner/Spinner"; import { Switch } from "#/components/Switch/Switch"; import { pageTitle } from "#/utils/page"; import { ProviderForm } from "../components/ProviderForm"; @@ -36,6 +49,8 @@ const UpdateProviderPageView: React.FC = () => { const navigate = useNavigate(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(""); + const [isCascadeDeleting, setIsCascadeDeleting] = useState(false); const providerQuery = useQuery({ ...aiProvider(providerId ?? ""), @@ -54,6 +69,16 @@ const UpdateProviderPageView: React.FC = () => { deleteAIProviderMutation(queryClient, providerId ?? ""), ); + const modelConfigsQuery = useQuery({ + ...chatModelConfigs(), + enabled: Boolean(providerId), + }); + + const associatedModels = (modelConfigsQuery.data ?? []).filter( + (mc) => mc.ai_provider_id === provider?.id, + ); + const associatedModelCount = associatedModels.length; + // Rendered into every non-redirect return so the document title reflects // the provider as soon as we know it; falls back to a placeholder while // the query is in flight. @@ -210,36 +235,123 @@ const UpdateProviderPageView: React.FC = () => { }} /> - { - setDeleteDialogOpen(false); + { + if (!open && !isCascadeDeleting && !deleteMutation.isPending) { + setDeleteDialogOpen(false); + setDeleteConfirmText(""); + } }} - onConfirm={() => { - deleteMutation.mutate(undefined, { - onSuccess: () => { - toast.success( - `Provider "${provider.display_name || provider.name}" deleted.`, - ); - setDeleteDialogOpen(false); - void navigate(BACK_HREF, { replace: true }); - }, - onError: (error) => { - toast.error( - getErrorMessage( - error, - `Failed to delete provider "${provider.display_name || provider.name}".`, - ), - ); - }, - }); - }} - /> + > + + + Delete provider + +
+

+ Deleting this provider is irreversible! +

+ {associatedModelCount > 0 && ( +
    +
  • + Deleting this provider will also disable{" "} + + {associatedModelCount}{" "} + {associatedModelCount === 1 ? "model" : "models"} + {" "} + from your settings. +
  • +
+ )} +

+ Type{" "} + + {provider.name} + {" "} + to confirm. +

+ setDeleteConfirmText(e.target.value)} + /> +
+ + + + +
+
); diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index 0a609de2ac..ae1d886508 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -303,7 +303,7 @@ export const ProviderForm: FC = ({ required field={getFieldHelpers("name")} label="Name" - description="Unique identifier (used in urls, can't be changed)" + description="URL identifier. Cannot be changed." className="w-full" placeholder={namePlaceholder(form.values.type)} disabled={editing} @@ -341,7 +341,7 @@ export const ProviderForm: FC = ({ required field={getFieldHelpers("name")} label="Name" - description="Unique identifier (used in urls, can't be changed)" + description="URL identifier. Cannot be changed." className="w-full" placeholder={namePlaceholder(form.values.type)} disabled={editing} diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx index 6ecb1b0cc9..ea84ab77e7 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx @@ -324,6 +324,7 @@ export const ChatModelAdminPanel: FC = ({ onCreateProvider={onCreateProvider} onUpdateProvider={onUpdateProvider} onDeleteProvider={onDeleteProvider} + onDisableModel={onUpdateModel} /> ) : ( = ({ )} {modelConfig.enabled === false && ( - + disabled )} diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx index fe817705f7..ed6c0fc474 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx @@ -8,6 +8,8 @@ import { useState, } from "react"; import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { getErrorMessage } from "#/api/errors"; import type * as TypesGen from "#/api/typesGenerated"; import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; import { Button } from "#/components/Button/Button"; @@ -45,6 +47,10 @@ interface ProviderFormProps { req: TypesGen.UpdateChatProviderConfigRequest, ) => Promise; onDeleteProvider: (providerConfigId: string) => Promise; + onDisableModel: ( + modelConfigId: string, + req: TypesGen.UpdateChatModelConfigRequest, + ) => Promise; onBack: () => void; } @@ -55,6 +61,7 @@ export const ProviderForm: FC = ({ onCreateProvider, onUpdateProvider, onDeleteProvider, + onDisableModel, onBack, }) => { const navigate = useNavigate(); @@ -80,6 +87,7 @@ export const ProviderForm: FC = ({ const [apiKeyModified, setApiKeyModified] = useState(false); const [baseURLValue, setBaseURLValue] = useState(initialValues.baseURL); const [confirmingDelete, setConfirmingDelete] = useState(false); + const [isCascadeDeleting, setIsCascadeDeleting] = useState(false); const isBedrockProvider = provider === "bedrock"; const isAPIKeyEnvManaged = isEnvPreset && !providerConfig; @@ -106,10 +114,11 @@ export const ProviderForm: FC = ({ ? "Bedrock runtime endpoint. Use the AWS region for the models this provider should call." : "Endpoint used to call this provider."; const apiKeyPlaceholder = isBedrockProvider ? "Enter bearer token" : "sk-..."; + const associatedModelCount = providerState.modelConfigs.length; const deleteProviderDescription = - "Are you sure you want to delete this provider? The provider will be " + - "disabled and hidden from new model configuration. Existing model " + - "configs that reference it remain saved but cannot run until updated."; + associatedModelCount > 0 + ? `Deleting this provider will also disable ${associatedModelCount} ${associatedModelCount === 1 ? "model" : "models"} from your settings.` + : "Are you sure you want to delete this provider? This action is irreversible."; const hasNewProviderConfiguration = !providerConfig; const isDirty = @@ -371,10 +380,29 @@ export const ProviderForm: FC = ({ void onDeleteProvider(providerConfig.id)} - isPending={isProviderMutationPending} + onConfirm={() => { + setIsCascadeDeleting(true); + const chain = providerState.modelConfigs.reduce>( + (prev, mc) => + prev.then(() => onDisableModel(mc.id, { enabled: false })), + Promise.resolve(), + ); + chain + .then(() => onDeleteProvider(providerConfig.id)) + .catch((error: unknown) => { + toast.error( + getErrorMessage(error, "Failed to delete provider."), + ); + }) + .then(() => setIsCascadeDeleting(false)); + }} + isPending={isCascadeDeleting || isProviderMutationPending} open={confirmingDelete} - onOpenChange={(open) => !open && setConfirmingDelete(false)} + onOpenChange={(open) => { + if (!open && !isCascadeDeleting && !isProviderMutationPending) { + setConfirmingDelete(false); + } + }} /> )} diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx index 56b3d293bf..eb0a68827c 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx @@ -10,6 +10,7 @@ import { type AIProviderType, AIProviderTypes, type CreateChatProviderConfigRequest, + type UpdateChatModelConfigRequest, type UpdateChatProviderConfigRequest, } from "#/api/typesGenerated"; import { Badge } from "#/components/Badge/Badge"; @@ -79,6 +80,10 @@ interface ProvidersSectionProps { req: UpdateChatProviderConfigRequest, ) => Promise; onDeleteProvider: (providerConfigId: string) => Promise; + onDisableModel: ( + modelConfigId: string, + req: UpdateChatModelConfigRequest, + ) => Promise; } export const ProvidersSection: FC = ({ @@ -90,6 +95,7 @@ export const ProvidersSection: FC = ({ onCreateProvider, onUpdateProvider, onDeleteProvider, + onDisableModel, }) => { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); @@ -198,6 +204,7 @@ export const ProvidersSection: FC = ({ } }} onBack={clearProviderView} + onDisableModel={onDisableModel} /> ); }