mirror of
https://github.com/coder/coder.git
synced 2026-06-06 22:48:19 +00:00
feat(site/src/pages/AgentsPage/components/ChatModelAdminPanel): add duplicate model action (#24728)
> Mux is acting on Mike's behalf. Adds explicit star, edit, and duplicate actions to each Agents model configuration row, replacing the chevron-only affordance. Duplicate opens a prefilled create form backed by the existing create mutation when the provider can manage models. The form copies editable model fields and provider config while clearing default status so saving a duplicate does not change the current default model.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { type FC, useState } from "react";
|
||||
import type { FC } from "react";
|
||||
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||
@@ -251,10 +251,6 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
||||
isDeletingModel,
|
||||
modelMutationError,
|
||||
}) => {
|
||||
const [requestedProvider, setRequestedProvider] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// ── Sorted model configs ───────────────────────────────────
|
||||
const modelConfigs = (modelConfigsData ?? []).slice().sort((a, b) => {
|
||||
const cmp = a.provider.localeCompare(b.provider);
|
||||
@@ -268,20 +264,6 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
||||
modelCatalogData,
|
||||
);
|
||||
|
||||
// Derive the effective selected provider from user intent + available
|
||||
// providers. This avoids a useEffect + setState cycle that would cause
|
||||
// an extra render with a stale value.
|
||||
const selectedProvider =
|
||||
requestedProvider &&
|
||||
providerStates.some((ps) => ps.provider === requestedProvider)
|
||||
? requestedProvider
|
||||
: (providerStates[0]?.provider ?? null);
|
||||
|
||||
const selectedProviderState = selectedProvider
|
||||
? (providerStates.find((ps) => ps.provider === selectedProvider) ?? null)
|
||||
: null;
|
||||
|
||||
// ── Derived state ──────────────────────────────────────────
|
||||
const providerConfigsUnavailable = providerConfigsData === null;
|
||||
const modelConfigsUnavailable = modelConfigsData === null;
|
||||
|
||||
@@ -306,16 +288,12 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
||||
onCreateProvider={onCreateProvider}
|
||||
onUpdateProvider={onUpdateProvider}
|
||||
onDeleteProvider={onDeleteProvider}
|
||||
onSelectedProviderChange={setRequestedProvider}
|
||||
/>
|
||||
) : (
|
||||
<ModelsSection
|
||||
sectionLabel={sectionLabel}
|
||||
sectionDescription={sectionDescription}
|
||||
providerStates={providerStates}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedProviderState={selectedProviderState}
|
||||
onSelectedProviderChange={setRequestedProvider}
|
||||
modelConfigs={modelConfigs}
|
||||
modelConfigsUnavailable={modelConfigsUnavailable}
|
||||
isCreating={isCreatingModel}
|
||||
|
||||
@@ -75,6 +75,8 @@ const validationSchema = Yup.object({
|
||||
interface ModelFormProps {
|
||||
/** When set, the form is in "edit" mode for the given model. */
|
||||
editingModel?: TypesGen.ChatModelConfig;
|
||||
/** When set without editingModel, the form creates from this model. */
|
||||
duplicateSourceModel?: TypesGen.ChatModelConfig;
|
||||
providerStates: readonly ProviderState[];
|
||||
selectedProvider: string | null;
|
||||
selectedProviderState: ProviderState | null;
|
||||
@@ -95,6 +97,7 @@ interface ModelFormProps {
|
||||
|
||||
export const ModelForm: FC<ModelFormProps> = ({
|
||||
editingModel,
|
||||
duplicateSourceModel,
|
||||
providerStates,
|
||||
selectedProvider,
|
||||
selectedProviderState,
|
||||
@@ -107,8 +110,13 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
onCancel,
|
||||
onDeleteModel,
|
||||
}) => {
|
||||
const initialModel = editingModel ?? duplicateSourceModel;
|
||||
const isEditing = Boolean(editingModel);
|
||||
const isDefaultModel = isEditing && editingModel?.is_default === true;
|
||||
const isDuplicating = Boolean(duplicateSourceModel) && !isEditing;
|
||||
const initialValues = {
|
||||
...buildInitialModelFormValues(initialModel),
|
||||
...(isDuplicating && { isDefault: false }),
|
||||
};
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showPricing, setShowPricing] = useState(false);
|
||||
const [showProviderConfig, setShowProviderConfig] = useState(false);
|
||||
@@ -119,9 +127,17 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
(selectedProviderState.hasEffectiveAPIKey ||
|
||||
selectedProviderState.providerConfig.allow_user_api_key),
|
||||
);
|
||||
const formTitle = isEditing
|
||||
? "Edit Model"
|
||||
: isDuplicating
|
||||
? "Duplicate Model"
|
||||
: "Add Model";
|
||||
const formDescription = isDuplicating
|
||||
? "Review the copied settings, then save to create a new model."
|
||||
: undefined;
|
||||
|
||||
const form = useFormik<ModelFormValues>({
|
||||
initialValues: buildInitialModelFormValues(editingModel),
|
||||
initialValues,
|
||||
validationSchema,
|
||||
validateOnMount: true,
|
||||
validateOnBlur: false,
|
||||
@@ -137,7 +153,7 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
);
|
||||
|
||||
const buildResult = buildModelConfigFromForm(
|
||||
selectedProviderState?.provider,
|
||||
selectedProvider,
|
||||
values.config,
|
||||
);
|
||||
if (Object.keys(buildResult.fieldErrors).length > 0) return;
|
||||
@@ -174,12 +190,13 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
|
||||
await onUpdateModel(editingModel.id, req);
|
||||
} else {
|
||||
if (!selectedProviderState?.providerConfig) return;
|
||||
if (!selectedProvider || !selectedProviderState?.providerConfig) return;
|
||||
|
||||
const req: TypesGen.CreateChatModelConfigRequest = {
|
||||
provider: selectedProviderState.provider,
|
||||
provider: selectedProvider,
|
||||
model: trimmedModel,
|
||||
enabled: true,
|
||||
enabled: values.enabled,
|
||||
is_default: values.isDefault,
|
||||
...(parsedContextLimit !== null && {
|
||||
context_limit: parsedContextLimit,
|
||||
}),
|
||||
@@ -189,9 +206,6 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
...(trimmedDisplayName && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
...(values.isDefault && {
|
||||
is_default: true,
|
||||
}),
|
||||
...(builtModelConfig && {
|
||||
model_config: builtModelConfig,
|
||||
}),
|
||||
@@ -208,13 +222,14 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
const getFieldHelpers = getFormHelpers(form);
|
||||
|
||||
const modelConfigFormBuildResult = buildModelConfigFromForm(
|
||||
selectedProviderState?.provider,
|
||||
selectedProvider,
|
||||
form.values.config,
|
||||
);
|
||||
|
||||
const hasFieldErrors =
|
||||
Object.keys(modelConfigFormBuildResult.fieldErrors).length > 0;
|
||||
const defaultModelDisableGuard = isDefaultModel && form.values.enabled;
|
||||
const defaultModelDisableGuard =
|
||||
isEditing && form.values.isDefault && form.values.enabled;
|
||||
|
||||
// ── Provider select (shared across all form states) ───────
|
||||
|
||||
@@ -229,7 +244,7 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
<Select
|
||||
value={selectedProvider ?? ""}
|
||||
onValueChange={onSelectedProviderChange}
|
||||
disabled={isEditing || providerStates.length === 0}
|
||||
disabled={isEditing || isDuplicating || providerStates.length === 0}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="providerSelect"
|
||||
@@ -257,7 +272,7 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
<div>
|
||||
<BackButton onClick={onCancel} />
|
||||
<h2 className="m-0 text-lg font-medium text-content-primary">
|
||||
{isEditing ? "Edit Model" : "Add Model"}
|
||||
{formTitle}
|
||||
</h2>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
<div className="space-y-3">{providerSelect}</div>
|
||||
@@ -271,15 +286,15 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
<div>
|
||||
<BackButton onClick={onCancel} />
|
||||
<h2 className="m-0 text-lg font-medium text-content-primary">
|
||||
Add Model
|
||||
{formTitle}
|
||||
</h2>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
<div className="space-y-3">
|
||||
{providerSelect}
|
||||
<p className="text-sm text-content-secondary">
|
||||
{!selectedProviderState.providerConfig
|
||||
? "Create a managed provider config on the Providers tab before adding models."
|
||||
: "Set an API key for this provider on the Providers tab before adding models."}
|
||||
? "Create a managed provider config on the Providers tab before managing models."
|
||||
: "Set an API key for this provider on the Providers tab before managing models."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -295,7 +310,18 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
{/* Back */}
|
||||
<BackButton onClick={onCancel} /> {/* Header - editable display name */}
|
||||
<BackButton onClick={onCancel} />
|
||||
<div className="mb-4">
|
||||
<h2 className="m-0 text-lg font-medium text-content-primary">
|
||||
{formTitle}
|
||||
</h2>
|
||||
{formDescription && (
|
||||
<p className="m-0 mt-1 text-sm text-content-secondary">
|
||||
{formDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Header - editable display name */}
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedProviderState && (
|
||||
<ProviderIcon
|
||||
@@ -309,10 +335,7 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
className="invisible col-start-1 row-start-1 whitespace-pre text-lg font-medium"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{form.values.displayName ||
|
||||
(isEditing
|
||||
? (editingModel?.model ?? "Model name")
|
||||
: "Model name")}
|
||||
{form.values.displayName || initialModel?.model || "Model name"}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
@@ -320,14 +343,12 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
disabled={isSaving}
|
||||
spellCheck={false}
|
||||
className="col-start-1 row-start-1 m-0 min-w-0 border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0"
|
||||
placeholder={
|
||||
isEditing ? (editingModel?.model ?? "Model name") : "Model name"
|
||||
}
|
||||
placeholder={initialModel?.model ?? "Model name"}
|
||||
/>
|
||||
</div>
|
||||
<PencilIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</div>{" "}
|
||||
{editingModel && (
|
||||
{initialModel && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="ml-auto inline-flex">
|
||||
@@ -633,7 +654,11 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
disabled={isSaving || !form.isValid || hasFieldErrors}
|
||||
>
|
||||
{isSaving && <Spinner className="h-4 w-4" loading />}{" "}
|
||||
{isEditing ? "Save" : "Add model"}
|
||||
{isEditing
|
||||
? "Save"
|
||||
: isDuplicating
|
||||
? "Create duplicate"
|
||||
: "Add model"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+352
-4
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { TooltipProvider } from "#/components/Tooltip/Tooltip";
|
||||
import type { ProviderState } from "./ChatModelAdminPanel";
|
||||
@@ -31,6 +31,19 @@ const providerState: ProviderState = {
|
||||
baseURL: "",
|
||||
};
|
||||
|
||||
const providerStateWithoutAPIKey: ProviderState = {
|
||||
...providerState,
|
||||
providerConfig: {
|
||||
...providerState.providerConfig!,
|
||||
has_api_key: false,
|
||||
central_api_key_enabled: false,
|
||||
allow_user_api_key: false,
|
||||
},
|
||||
hasManagedAPIKey: false,
|
||||
hasCatalogAPIKey: false,
|
||||
hasEffectiveAPIKey: false,
|
||||
};
|
||||
|
||||
const baseModelConfig: TypesGen.ChatModelConfig = {
|
||||
id: "model-config-id",
|
||||
provider: "openai",
|
||||
@@ -44,15 +57,47 @@ const baseModelConfig: TypesGen.ChatModelConfig = {
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const disabledModelConfig: TypesGen.ChatModelConfig = {
|
||||
...baseModelConfig,
|
||||
id: "disabled-model-config-id",
|
||||
model: "gpt-4.1-disabled",
|
||||
display_name: "GPT-4.1 Disabled",
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
const defaultModelConfig: TypesGen.ChatModelConfig = {
|
||||
...baseModelConfig,
|
||||
id: "default-model-config-id",
|
||||
model: "gpt-4o",
|
||||
display_name: "GPT-4o",
|
||||
is_default: true,
|
||||
};
|
||||
|
||||
const duplicateSourceModel: TypesGen.ChatModelConfig = {
|
||||
...baseModelConfig,
|
||||
id: "duplicate-source-model-id",
|
||||
model: "gpt-4.1-default",
|
||||
display_name: "GPT-4.1 Default",
|
||||
is_default: true,
|
||||
context_limit: 200000,
|
||||
compression_threshold: 65,
|
||||
model_config: {
|
||||
max_output_tokens: 4096,
|
||||
provider_options: {
|
||||
openai: {
|
||||
max_tool_calls: 4,
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ModelsSection> = {
|
||||
title: "pages/AgentsPage/ChatModelAdminPanel/ModelsSection",
|
||||
component: ModelsSection,
|
||||
args: {
|
||||
sectionLabel: "Models",
|
||||
providerStates: [providerState],
|
||||
selectedProvider: "openai",
|
||||
selectedProviderState: providerState,
|
||||
onSelectedProviderChange: fn(),
|
||||
modelConfigs: [baseModelConfig],
|
||||
modelConfigsUnavailable: false,
|
||||
isCreating: false,
|
||||
@@ -104,3 +149,306 @@ export const HidesPricingWarningForExplicitZeroPricing: Story = {
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ShowsExplicitRowActions: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
const rowButton = canvas.getByRole("button", {
|
||||
name: "Open model: GPT-4.1",
|
||||
});
|
||||
const starButton = canvas.getByRole("button", {
|
||||
name: "Set as default model: GPT-4.1",
|
||||
});
|
||||
const editButton = canvas.getByRole("button", {
|
||||
name: "Edit model: GPT-4.1",
|
||||
});
|
||||
const copyButton = canvas.getByRole("button", {
|
||||
name: "Duplicate model: GPT-4.1",
|
||||
});
|
||||
|
||||
await expect(starButton).toBeVisible();
|
||||
await expect(editButton).toBeVisible();
|
||||
await expect(copyButton).toBeVisible();
|
||||
rowButton.focus();
|
||||
await expect(rowButton).toHaveFocus();
|
||||
await user.tab();
|
||||
await expect(starButton).toHaveFocus();
|
||||
await user.tab();
|
||||
await expect(editButton).toHaveFocus();
|
||||
await user.tab();
|
||||
await expect(copyButton).toHaveFocus();
|
||||
},
|
||||
};
|
||||
|
||||
export const OpensDuplicateFormWithoutCreating: Story = {
|
||||
args: {
|
||||
modelConfigs: [duplicateSourceModel],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", {
|
||||
name: "Duplicate model: GPT-4.1 Default",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
|
||||
expect(args.onCreateModel).not.toHaveBeenCalled();
|
||||
expect(args.onUpdateModel).not.toHaveBeenCalled();
|
||||
expect(canvas.getByDisplayValue("GPT-4.1 Default")).toBeVisible();
|
||||
expect(canvas.getByLabelText(/Model Identifier/)).toHaveValue(
|
||||
"gpt-4.1-default",
|
||||
);
|
||||
expect(canvas.getByLabelText(/Context Limit/)).toHaveValue("200000");
|
||||
const enabledSwitch = canvas.getByRole("switch", { name: "Enabled" });
|
||||
expect(enabledSwitch).toBeChecked();
|
||||
expect(enabledSwitch).toBeEnabled();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: /Advanced/ }));
|
||||
expect(canvas.getByLabelText(/Compression Threshold/)).toHaveValue("65");
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /Provider Configuration/ }),
|
||||
);
|
||||
expect(canvas.getByLabelText("Max Tool Calls")).toHaveValue("4");
|
||||
},
|
||||
};
|
||||
|
||||
export const AbandonsDuplicateWithoutSaving: Story = {
|
||||
args: {
|
||||
modelConfigs: [duplicateSourceModel],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const copyButtonName = "Duplicate model: GPT-4.1 Default";
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: copyButtonName }));
|
||||
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Cancel" }));
|
||||
await expect(
|
||||
canvas.findByRole("button", { name: copyButtonName }),
|
||||
).resolves.toBeVisible();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: copyButtonName }));
|
||||
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Back" }));
|
||||
await expect(
|
||||
canvas.findByRole("button", { name: copyButtonName }),
|
||||
).resolves.toBeVisible();
|
||||
expect(args.onCreateModel).not.toHaveBeenCalled();
|
||||
expect(args.onUpdateModel).not.toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const SavesDuplicateAsCreateRequest: Story = {
|
||||
args: {
|
||||
modelConfigs: [duplicateSourceModel],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", {
|
||||
name: "Duplicate model: GPT-4.1 Default",
|
||||
}),
|
||||
);
|
||||
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
|
||||
|
||||
const modelInput = canvas.getByLabelText(/Model Identifier/);
|
||||
await userEvent.clear(modelInput);
|
||||
await userEvent.type(modelInput, "gpt-4.1-copy");
|
||||
const displayNameInput = canvas.getByDisplayValue("GPT-4.1 Default");
|
||||
await userEvent.clear(displayNameInput);
|
||||
await userEvent.type(displayNameInput, "GPT-4.1 Copy");
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Create duplicate" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(args.onCreateModel).toHaveBeenCalledTimes(1));
|
||||
expect(args.onUpdateModel).not.toHaveBeenCalled();
|
||||
|
||||
const createModelMock = args.onCreateModel as ReturnType<typeof fn>;
|
||||
const createReq = createModelMock.mock.calls[0]?.[0];
|
||||
if (!createReq) {
|
||||
throw new Error("Expected create request.");
|
||||
}
|
||||
expect(createReq).toEqual({
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-copy",
|
||||
display_name: "GPT-4.1 Copy",
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
context_limit: 200000,
|
||||
compression_threshold: 65,
|
||||
model_config: {
|
||||
max_output_tokens: 4096,
|
||||
provider_options: {
|
||||
openai: {
|
||||
max_tool_calls: 4,
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SavesNonDefaultDuplicateWithEditableEnabled: Story = {
|
||||
args: {
|
||||
modelConfigs: [baseModelConfig],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Duplicate model: GPT-4.1" }),
|
||||
);
|
||||
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
|
||||
|
||||
const enabledSwitch = canvas.getByRole("switch", { name: "Enabled" });
|
||||
expect(enabledSwitch).toBeChecked();
|
||||
expect(enabledSwitch).toBeEnabled();
|
||||
await userEvent.click(enabledSwitch);
|
||||
|
||||
const modelInput = canvas.getByLabelText(/Model Identifier/);
|
||||
await userEvent.clear(modelInput);
|
||||
await userEvent.type(modelInput, "gpt-4.1-copy");
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Create duplicate" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(args.onCreateModel).toHaveBeenCalledTimes(1));
|
||||
expect(args.onUpdateModel).not.toHaveBeenCalled();
|
||||
|
||||
const createModelMock = args.onCreateModel as ReturnType<typeof fn>;
|
||||
expect(createModelMock.mock.calls[0]?.[0]).toEqual({
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-copy",
|
||||
display_name: "GPT-4.1",
|
||||
enabled: false,
|
||||
is_default: false,
|
||||
context_limit: 128000,
|
||||
compression_threshold: 80,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SavesDisabledDuplicateWithEditableEnabled: Story = {
|
||||
args: {
|
||||
modelConfigs: [disabledModelConfig],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", {
|
||||
name: "Duplicate model: GPT-4.1 Disabled",
|
||||
}),
|
||||
);
|
||||
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
|
||||
|
||||
const enabledSwitch = canvas.getByRole("switch", { name: "Enabled" });
|
||||
expect(enabledSwitch).not.toBeChecked();
|
||||
expect(enabledSwitch).toBeEnabled();
|
||||
await userEvent.click(enabledSwitch);
|
||||
|
||||
const modelInput = canvas.getByLabelText(/Model Identifier/);
|
||||
await userEvent.clear(modelInput);
|
||||
await userEvent.type(modelInput, "gpt-4.1-disabled-copy");
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Create duplicate" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(args.onCreateModel).toHaveBeenCalledTimes(1));
|
||||
const createModelMock = args.onCreateModel as ReturnType<typeof fn>;
|
||||
expect(createModelMock.mock.calls[0]?.[0]).toEqual({
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-disabled-copy",
|
||||
display_name: "GPT-4.1 Disabled",
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
context_limit: 128000,
|
||||
compression_threshold: 80,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const DisablesDuplicateWhenProviderCannotManageModels: Story = {
|
||||
args: {
|
||||
providerStates: [providerStateWithoutAPIKey],
|
||||
modelConfigs: [baseModelConfig],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const duplicateButton = canvas.getByRole("button", {
|
||||
name: "Duplicate model: GPT-4.1",
|
||||
});
|
||||
|
||||
expect(duplicateButton).toHaveAttribute("aria-disabled", "true");
|
||||
await userEvent.click(duplicateButton);
|
||||
expect(canvas.queryByText("Duplicate Model")).not.toBeInTheDocument();
|
||||
expect(args.onCreateModel).not.toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const RowActionsDoNotOpenRowBody: Story = {
|
||||
args: {
|
||||
modelConfigs: [baseModelConfig, defaultModelConfig, disabledModelConfig],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const updateModelMock = args.onUpdateModel as ReturnType<typeof fn>;
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", {
|
||||
name: "Set as default model: GPT-4.1",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(args.onUpdateModel).toHaveBeenCalledTimes(1));
|
||||
expect(updateModelMock.mock.calls[0]).toEqual([
|
||||
"model-config-id",
|
||||
{ is_default: true },
|
||||
]);
|
||||
expect(canvas.queryByText("Edit Model")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Default model: GPT-4o" }),
|
||||
);
|
||||
expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
|
||||
expect(canvas.queryByText("Edit Model")).not.toBeInTheDocument();
|
||||
|
||||
const disabledStarButton = canvas.getByRole("button", {
|
||||
name: "Set as default model: GPT-4.1 Disabled",
|
||||
});
|
||||
expect(disabledStarButton).toHaveAttribute("aria-disabled", "true");
|
||||
await userEvent.click(disabledStarButton);
|
||||
expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
|
||||
expect(canvas.queryByText("Edit Model")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Duplicate model: GPT-4.1" }),
|
||||
);
|
||||
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
|
||||
expect(args.onCreateModel).not.toHaveBeenCalled();
|
||||
expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
|
||||
expect(canvas.queryByText("Edit Model")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Back" }));
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: "Edit model: GPT-4.1" }),
|
||||
);
|
||||
await expect(canvas.findByText("Edit Model")).resolves.toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
PinIcon,
|
||||
CopyIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
@@ -31,15 +32,30 @@ import { hasCustomPricing } from "./pricingFields";
|
||||
type ModelView =
|
||||
| { mode: "list" }
|
||||
| { mode: "add"; provider: string }
|
||||
| { mode: "edit"; model: TypesGen.ChatModelConfig };
|
||||
| { mode: "edit"; model: TypesGen.ChatModelConfig }
|
||||
| { mode: "duplicate"; sourceModel: TypesGen.ChatModelConfig };
|
||||
|
||||
const MODEL_VIEW_PARAMS = ["model", "newModel", "duplicate"] as const;
|
||||
type ModelViewParam = (typeof MODEL_VIEW_PARAMS)[number];
|
||||
|
||||
const clearModelViewParams = (params: URLSearchParams) => {
|
||||
for (const param of MODEL_VIEW_PARAMS) {
|
||||
params.delete(param);
|
||||
}
|
||||
};
|
||||
|
||||
const canManageProviderModels = (providerState: ProviderState | undefined) => {
|
||||
return Boolean(
|
||||
providerState?.providerConfig &&
|
||||
(providerState.hasEffectiveAPIKey ||
|
||||
providerState.providerConfig.allow_user_api_key),
|
||||
);
|
||||
};
|
||||
|
||||
interface ModelsSectionProps {
|
||||
sectionLabel?: string;
|
||||
sectionDescription?: string;
|
||||
providerStates: readonly ProviderState[];
|
||||
selectedProvider: string | null;
|
||||
selectedProviderState: ProviderState | null;
|
||||
onSelectedProviderChange: (provider: string) => void;
|
||||
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
||||
modelConfigsUnavailable: boolean;
|
||||
isCreating: boolean;
|
||||
@@ -59,9 +75,6 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
sectionLabel,
|
||||
sectionDescription,
|
||||
providerStates,
|
||||
selectedProvider,
|
||||
selectedProviderState,
|
||||
onSelectedProviderChange,
|
||||
modelConfigs,
|
||||
modelConfigsUnavailable,
|
||||
isCreating,
|
||||
@@ -90,6 +103,13 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
const model = modelConfigs.find((m) => m.id === editModelId);
|
||||
return model ? { mode: "edit", model } : { mode: "list" };
|
||||
}
|
||||
const duplicateModelId = searchParams.get("duplicate");
|
||||
if (duplicateModelId) {
|
||||
const sourceModel = modelConfigs.find((m) => m.id === duplicateModelId);
|
||||
return sourceModel
|
||||
? { mode: "duplicate", sourceModel }
|
||||
: { mode: "list" };
|
||||
}
|
||||
const addProvider = searchParams.get("newModel");
|
||||
if (addProvider) {
|
||||
return { mode: "add", provider: addProvider };
|
||||
@@ -97,12 +117,25 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
return { mode: "list" };
|
||||
})();
|
||||
|
||||
const setModelViewParam = (
|
||||
param: ModelViewParam,
|
||||
value: string,
|
||||
options?: { replace?: boolean },
|
||||
) => {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
clearModelViewParams(nextParams);
|
||||
nextParams.set(param, value);
|
||||
setSearchParams(nextParams, {
|
||||
replace: options?.replace,
|
||||
state: options?.replace ? location.state : { pushed: true },
|
||||
});
|
||||
};
|
||||
|
||||
// Clear model-related search params and return to the list.
|
||||
const clearModelView = () => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("model");
|
||||
next.delete("newModel");
|
||||
clearModelViewParams(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -118,8 +151,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("model");
|
||||
next.delete("newModel");
|
||||
clearModelViewParams(next);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
@@ -128,32 +160,42 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
};
|
||||
|
||||
// When the form is open it takes over the full panel.
|
||||
if (view.mode === "add" || view.mode === "edit") {
|
||||
if (
|
||||
view.mode === "add" ||
|
||||
view.mode === "edit" ||
|
||||
view.mode === "duplicate"
|
||||
) {
|
||||
const editingModel = view.mode === "edit" ? view.model : undefined;
|
||||
|
||||
const getEffectiveProvider = () => {
|
||||
if (editingModel) {
|
||||
return editingModel.provider;
|
||||
}
|
||||
if (view.mode === "add") {
|
||||
return view.provider;
|
||||
}
|
||||
return selectedProvider;
|
||||
};
|
||||
|
||||
const effectiveProvider = getEffectiveProvider();
|
||||
const effectiveProviderState = effectiveProvider
|
||||
? (providerStates.find((ps) => ps.provider === effectiveProvider) ?? null)
|
||||
: selectedProviderState;
|
||||
const duplicateSourceModel =
|
||||
view.mode === "duplicate" ? view.sourceModel : undefined;
|
||||
const effectiveProvider =
|
||||
view.mode === "edit"
|
||||
? view.model.provider
|
||||
: view.mode === "duplicate"
|
||||
? view.sourceModel.provider
|
||||
: view.provider;
|
||||
const effectiveProviderState =
|
||||
providerStates.find((ps) => ps.provider === effectiveProvider) ?? null;
|
||||
const formKey =
|
||||
view.mode === "edit"
|
||||
? `edit:${view.model.id}`
|
||||
: view.mode === "duplicate"
|
||||
? `duplicate:${view.sourceModel.id}`
|
||||
: `add:${view.provider}`;
|
||||
|
||||
return (
|
||||
<ModelForm
|
||||
key={editingModel?.id ?? effectiveProvider ?? "new"}
|
||||
key={formKey}
|
||||
editingModel={editingModel}
|
||||
duplicateSourceModel={duplicateSourceModel}
|
||||
providerStates={providerStates}
|
||||
selectedProvider={effectiveProvider}
|
||||
selectedProviderState={effectiveProviderState}
|
||||
onSelectedProviderChange={onSelectedProviderChange}
|
||||
onSelectedProviderChange={(provider) => {
|
||||
if (view.mode === "add") {
|
||||
setModelViewParam("newModel", provider, { replace: true });
|
||||
}
|
||||
}}
|
||||
modelConfigsUnavailable={modelConfigsUnavailable}
|
||||
isSaving={isCreating || isUpdating}
|
||||
isDeleting={isDeleting}
|
||||
@@ -182,11 +224,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
|
||||
// Only show providers that have a deployment key configured or allow
|
||||
// end users to bring their own key.
|
||||
const addableProviders = providerStates.filter(
|
||||
(ps) =>
|
||||
ps.providerConfig &&
|
||||
(ps.hasEffectiveAPIKey || ps.providerConfig.allow_user_api_key),
|
||||
);
|
||||
const addableProviders = providerStates.filter(canManageProviderModels);
|
||||
|
||||
const addButton = addableProviders.length > 0 && (
|
||||
<DropdownMenu>
|
||||
@@ -202,11 +240,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
<DropdownMenuItem
|
||||
key={ps.provider}
|
||||
onClick={() => {
|
||||
onSelectedProviderChange(ps.provider);
|
||||
setSearchParams(
|
||||
{ newModel: ps.provider },
|
||||
{ state: { pushed: true } },
|
||||
);
|
||||
setModelViewParam("newModel", ps.provider);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
@@ -219,7 +253,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
);
|
||||
|
||||
const handleSetDefault = (modelConfig: TypesGen.ChatModelConfig) => {
|
||||
if (modelConfig.is_default || !modelConfig.enabled) return;
|
||||
if (isUpdating || modelConfig.is_default || !modelConfig.enabled) return;
|
||||
void onUpdateModel(modelConfig.id, { is_default: true });
|
||||
};
|
||||
|
||||
@@ -253,6 +287,17 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
const showPricingWarning = !hasCustomPricing(
|
||||
modelConfig.model_config,
|
||||
);
|
||||
const modelName = modelConfig.display_name || modelConfig.model;
|
||||
const starLabel = modelConfig.is_default
|
||||
? `Default model: ${modelName}`
|
||||
: `Set as default model: ${modelName}`;
|
||||
const starUnavailable =
|
||||
isUpdating || modelConfig.is_default || !modelConfig.enabled;
|
||||
const providerState = providerStates.find(
|
||||
(ps) => ps.provider === modelConfig.provider,
|
||||
);
|
||||
const duplicateUnavailable =
|
||||
!canManageProviderModels(providerState);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -262,16 +307,11 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
i > 0 && "border-0 border-t border-solid border-border/50",
|
||||
)}
|
||||
>
|
||||
{/* Clickable row content */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSearchParams(
|
||||
{ model: modelConfig.id },
|
||||
{ state: { pushed: true } },
|
||||
)
|
||||
}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 bg-transparent border-0 p-0 text-left"
|
||||
onClick={() => setModelViewParam("model", modelConfig.id)}
|
||||
aria-label={`Open model: ${modelName}`}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 border-0 bg-transparent p-0 text-left"
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={modelConfig.provider}
|
||||
@@ -286,7 +326,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
: "text-content-primary",
|
||||
)}
|
||||
>
|
||||
{modelConfig.display_name || modelConfig.model}
|
||||
{modelName}
|
||||
</span>
|
||||
{showPricingWarning && (
|
||||
<span className="mt-1 flex items-center gap-1 text-xs text-content-warning">
|
||||
@@ -301,51 +341,85 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
{/* Pin for default */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSetDefault(modelConfig);
|
||||
}}
|
||||
aria-disabled={
|
||||
isUpdating ||
|
||||
modelConfig.is_default ||
|
||||
!modelConfig.enabled
|
||||
}
|
||||
aria-label={
|
||||
modelConfig.is_default
|
||||
? "Default model"
|
||||
: "Set as default model"
|
||||
}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center bg-transparent border-0 p-0 transition-colors",
|
||||
modelConfig.is_default
|
||||
? "text-content-primary"
|
||||
: !modelConfig.enabled
|
||||
? "cursor-not-allowed text-content-secondary/30"
|
||||
: "cursor-pointer text-content-secondary/30 hover:text-content-secondary",
|
||||
)}
|
||||
>
|
||||
<PinIcon
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleSetDefault(modelConfig);
|
||||
}}
|
||||
aria-disabled={starUnavailable}
|
||||
aria-label={starLabel}
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
modelConfig.is_default && "fill-current",
|
||||
"hover:bg-surface-secondary",
|
||||
starUnavailable &&
|
||||
"cursor-not-allowed text-content-secondary/40 hover:bg-transparent hover:text-content-secondary/40",
|
||||
modelConfig.is_default && "text-content-primary",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{!modelConfig.enabled
|
||||
? "Cannot set a disabled model as default"
|
||||
: modelConfig.is_default
|
||||
? "Pinned as default for new conversations"
|
||||
: "Pin as default for new conversations"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ChevronRightIcon className="h-5 w-5 shrink-0 text-content-secondary" />
|
||||
>
|
||||
<StarIcon
|
||||
className={cn(
|
||||
modelConfig.is_default && "fill-current",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{!modelConfig.enabled
|
||||
? "Cannot set a disabled model as default"
|
||||
: modelConfig.is_default
|
||||
? "Default for new conversations"
|
||||
: "Set as default for new conversations"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setModelViewParam("model", modelConfig.id);
|
||||
}}
|
||||
aria-label={`Edit model: ${modelName}`}
|
||||
className="hover:bg-surface-secondary"
|
||||
>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit model</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (duplicateUnavailable) return;
|
||||
setModelViewParam("duplicate", modelConfig.id);
|
||||
}}
|
||||
aria-disabled={duplicateUnavailable}
|
||||
aria-label={`Duplicate model: ${modelName}`}
|
||||
className={cn(
|
||||
"hover:bg-surface-secondary",
|
||||
duplicateUnavailable &&
|
||||
"cursor-not-allowed text-content-secondary/40 hover:bg-transparent hover:text-content-secondary/40",
|
||||
)}
|
||||
>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{duplicateUnavailable
|
||||
? "Set an API key for this provider before duplicating models"
|
||||
: "Duplicate model"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -25,7 +25,6 @@ interface ProvidersSectionProps {
|
||||
req: TypesGen.UpdateChatProviderConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteProvider: (providerConfigId: string) => Promise<void>;
|
||||
onSelectedProviderChange: (provider: string) => void;
|
||||
}
|
||||
|
||||
export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||
@@ -37,7 +36,6 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||
onCreateProvider,
|
||||
onUpdateProvider,
|
||||
onDeleteProvider,
|
||||
onSelectedProviderChange,
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -141,7 +139,6 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||
key={providerState.provider}
|
||||
aria-label={providerState.label}
|
||||
onClick={() => {
|
||||
onSelectedProviderChange(providerState.provider);
|
||||
setSearchParams(
|
||||
{ provider: providerState.provider },
|
||||
{ state: { pushed: true } },
|
||||
|
||||
@@ -221,18 +221,18 @@ export const extractModelConfigFormState = (
|
||||
// ── Build initial model form values ────────────────────────────
|
||||
|
||||
export const buildInitialModelFormValues = (
|
||||
editingModel?: TypesGen.ChatModelConfig,
|
||||
sourceModel?: TypesGen.ChatModelConfig,
|
||||
): ModelFormValues => ({
|
||||
model: editingModel?.model ?? "",
|
||||
displayName: editingModel?.display_name ?? "",
|
||||
enabled: editingModel?.enabled ?? true,
|
||||
contextLimit: editingModel ? String(editingModel.context_limit) : "",
|
||||
compressionThreshold: editingModel
|
||||
? String(editingModel.compression_threshold)
|
||||
model: sourceModel?.model ?? "",
|
||||
displayName: sourceModel?.display_name ?? "",
|
||||
enabled: sourceModel?.enabled ?? true,
|
||||
contextLimit: sourceModel ? String(sourceModel.context_limit) : "",
|
||||
compressionThreshold: sourceModel
|
||||
? String(sourceModel.compression_threshold)
|
||||
: "",
|
||||
isDefault: editingModel?.is_default ?? false,
|
||||
config: editingModel
|
||||
? extractModelConfigFormState(editingModel)
|
||||
isDefault: sourceModel?.is_default ?? false,
|
||||
config: sourceModel
|
||||
? extractModelConfigFormState(sourceModel)
|
||||
: structuredClone(emptyModelConfigFormState),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user