mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08: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 type * as TypesGen from "#/api/typesGenerated";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||||
@@ -251,10 +251,6 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
|||||||
isDeletingModel,
|
isDeletingModel,
|
||||||
modelMutationError,
|
modelMutationError,
|
||||||
}) => {
|
}) => {
|
||||||
const [requestedProvider, setRequestedProvider] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Sorted model configs ───────────────────────────────────
|
// ── Sorted model configs ───────────────────────────────────
|
||||||
const modelConfigs = (modelConfigsData ?? []).slice().sort((a, b) => {
|
const modelConfigs = (modelConfigsData ?? []).slice().sort((a, b) => {
|
||||||
const cmp = a.provider.localeCompare(b.provider);
|
const cmp = a.provider.localeCompare(b.provider);
|
||||||
@@ -268,20 +264,6 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
|||||||
modelCatalogData,
|
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 providerConfigsUnavailable = providerConfigsData === null;
|
||||||
const modelConfigsUnavailable = modelConfigsData === null;
|
const modelConfigsUnavailable = modelConfigsData === null;
|
||||||
|
|
||||||
@@ -306,16 +288,12 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
|||||||
onCreateProvider={onCreateProvider}
|
onCreateProvider={onCreateProvider}
|
||||||
onUpdateProvider={onUpdateProvider}
|
onUpdateProvider={onUpdateProvider}
|
||||||
onDeleteProvider={onDeleteProvider}
|
onDeleteProvider={onDeleteProvider}
|
||||||
onSelectedProviderChange={setRequestedProvider}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ModelsSection
|
<ModelsSection
|
||||||
sectionLabel={sectionLabel}
|
sectionLabel={sectionLabel}
|
||||||
sectionDescription={sectionDescription}
|
sectionDescription={sectionDescription}
|
||||||
providerStates={providerStates}
|
providerStates={providerStates}
|
||||||
selectedProvider={selectedProvider}
|
|
||||||
selectedProviderState={selectedProviderState}
|
|
||||||
onSelectedProviderChange={setRequestedProvider}
|
|
||||||
modelConfigs={modelConfigs}
|
modelConfigs={modelConfigs}
|
||||||
modelConfigsUnavailable={modelConfigsUnavailable}
|
modelConfigsUnavailable={modelConfigsUnavailable}
|
||||||
isCreating={isCreatingModel}
|
isCreating={isCreatingModel}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ const validationSchema = Yup.object({
|
|||||||
interface ModelFormProps {
|
interface ModelFormProps {
|
||||||
/** When set, the form is in "edit" mode for the given model. */
|
/** When set, the form is in "edit" mode for the given model. */
|
||||||
editingModel?: TypesGen.ChatModelConfig;
|
editingModel?: TypesGen.ChatModelConfig;
|
||||||
|
/** When set without editingModel, the form creates from this model. */
|
||||||
|
duplicateSourceModel?: TypesGen.ChatModelConfig;
|
||||||
providerStates: readonly ProviderState[];
|
providerStates: readonly ProviderState[];
|
||||||
selectedProvider: string | null;
|
selectedProvider: string | null;
|
||||||
selectedProviderState: ProviderState | null;
|
selectedProviderState: ProviderState | null;
|
||||||
@@ -95,6 +97,7 @@ interface ModelFormProps {
|
|||||||
|
|
||||||
export const ModelForm: FC<ModelFormProps> = ({
|
export const ModelForm: FC<ModelFormProps> = ({
|
||||||
editingModel,
|
editingModel,
|
||||||
|
duplicateSourceModel,
|
||||||
providerStates,
|
providerStates,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedProviderState,
|
selectedProviderState,
|
||||||
@@ -107,8 +110,13 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onDeleteModel,
|
onDeleteModel,
|
||||||
}) => {
|
}) => {
|
||||||
|
const initialModel = editingModel ?? duplicateSourceModel;
|
||||||
const isEditing = Boolean(editingModel);
|
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 [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [showPricing, setShowPricing] = useState(false);
|
const [showPricing, setShowPricing] = useState(false);
|
||||||
const [showProviderConfig, setShowProviderConfig] = useState(false);
|
const [showProviderConfig, setShowProviderConfig] = useState(false);
|
||||||
@@ -119,9 +127,17 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
(selectedProviderState.hasEffectiveAPIKey ||
|
(selectedProviderState.hasEffectiveAPIKey ||
|
||||||
selectedProviderState.providerConfig.allow_user_api_key),
|
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>({
|
const form = useFormik<ModelFormValues>({
|
||||||
initialValues: buildInitialModelFormValues(editingModel),
|
initialValues,
|
||||||
validationSchema,
|
validationSchema,
|
||||||
validateOnMount: true,
|
validateOnMount: true,
|
||||||
validateOnBlur: false,
|
validateOnBlur: false,
|
||||||
@@ -137,7 +153,7 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const buildResult = buildModelConfigFromForm(
|
const buildResult = buildModelConfigFromForm(
|
||||||
selectedProviderState?.provider,
|
selectedProvider,
|
||||||
values.config,
|
values.config,
|
||||||
);
|
);
|
||||||
if (Object.keys(buildResult.fieldErrors).length > 0) return;
|
if (Object.keys(buildResult.fieldErrors).length > 0) return;
|
||||||
@@ -174,12 +190,13 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
|
|
||||||
await onUpdateModel(editingModel.id, req);
|
await onUpdateModel(editingModel.id, req);
|
||||||
} else {
|
} else {
|
||||||
if (!selectedProviderState?.providerConfig) return;
|
if (!selectedProvider || !selectedProviderState?.providerConfig) return;
|
||||||
|
|
||||||
const req: TypesGen.CreateChatModelConfigRequest = {
|
const req: TypesGen.CreateChatModelConfigRequest = {
|
||||||
provider: selectedProviderState.provider,
|
provider: selectedProvider,
|
||||||
model: trimmedModel,
|
model: trimmedModel,
|
||||||
enabled: true,
|
enabled: values.enabled,
|
||||||
|
is_default: values.isDefault,
|
||||||
...(parsedContextLimit !== null && {
|
...(parsedContextLimit !== null && {
|
||||||
context_limit: parsedContextLimit,
|
context_limit: parsedContextLimit,
|
||||||
}),
|
}),
|
||||||
@@ -189,9 +206,6 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
...(trimmedDisplayName && {
|
...(trimmedDisplayName && {
|
||||||
display_name: trimmedDisplayName,
|
display_name: trimmedDisplayName,
|
||||||
}),
|
}),
|
||||||
...(values.isDefault && {
|
|
||||||
is_default: true,
|
|
||||||
}),
|
|
||||||
...(builtModelConfig && {
|
...(builtModelConfig && {
|
||||||
model_config: builtModelConfig,
|
model_config: builtModelConfig,
|
||||||
}),
|
}),
|
||||||
@@ -208,13 +222,14 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
const getFieldHelpers = getFormHelpers(form);
|
const getFieldHelpers = getFormHelpers(form);
|
||||||
|
|
||||||
const modelConfigFormBuildResult = buildModelConfigFromForm(
|
const modelConfigFormBuildResult = buildModelConfigFromForm(
|
||||||
selectedProviderState?.provider,
|
selectedProvider,
|
||||||
form.values.config,
|
form.values.config,
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasFieldErrors =
|
const hasFieldErrors =
|
||||||
Object.keys(modelConfigFormBuildResult.fieldErrors).length > 0;
|
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) ───────
|
// ── Provider select (shared across all form states) ───────
|
||||||
|
|
||||||
@@ -229,7 +244,7 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
<Select
|
<Select
|
||||||
value={selectedProvider ?? ""}
|
value={selectedProvider ?? ""}
|
||||||
onValueChange={onSelectedProviderChange}
|
onValueChange={onSelectedProviderChange}
|
||||||
disabled={isEditing || providerStates.length === 0}
|
disabled={isEditing || isDuplicating || providerStates.length === 0}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
id="providerSelect"
|
id="providerSelect"
|
||||||
@@ -257,7 +272,7 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<BackButton onClick={onCancel} />
|
<BackButton onClick={onCancel} />
|
||||||
<h2 className="m-0 text-lg font-medium text-content-primary">
|
<h2 className="m-0 text-lg font-medium text-content-primary">
|
||||||
{isEditing ? "Edit Model" : "Add Model"}
|
{formTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||||
<div className="space-y-3">{providerSelect}</div>
|
<div className="space-y-3">{providerSelect}</div>
|
||||||
@@ -271,15 +286,15 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<BackButton onClick={onCancel} />
|
<BackButton onClick={onCancel} />
|
||||||
<h2 className="m-0 text-lg font-medium text-content-primary">
|
<h2 className="m-0 text-lg font-medium text-content-primary">
|
||||||
Add Model
|
{formTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{providerSelect}
|
{providerSelect}
|
||||||
<p className="text-sm text-content-secondary">
|
<p className="text-sm text-content-secondary">
|
||||||
{!selectedProviderState.providerConfig
|
{!selectedProviderState.providerConfig
|
||||||
? "Create a managed provider config 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 adding models."}
|
: "Set an API key for this provider on the Providers tab before managing models."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,7 +310,18 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-col">
|
<div className="flex min-h-full flex-col">
|
||||||
{/* Back */}
|
{/* 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">
|
<div className="flex items-center gap-3">
|
||||||
{selectedProviderState && (
|
{selectedProviderState && (
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -309,10 +335,7 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
className="invisible col-start-1 row-start-1 whitespace-pre text-lg font-medium"
|
className="invisible col-start-1 row-start-1 whitespace-pre text-lg font-medium"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{form.values.displayName ||
|
{form.values.displayName || initialModel?.model || "Model name"}
|
||||||
(isEditing
|
|
||||||
? (editingModel?.model ?? "Model name")
|
|
||||||
: "Model name")}
|
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -320,14 +343,12 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
spellCheck={false}
|
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"
|
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={
|
placeholder={initialModel?.model ?? "Model name"}
|
||||||
isEditing ? (editingModel?.model ?? "Model name") : "Model name"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PencilIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
<PencilIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||||
</div>{" "}
|
</div>{" "}
|
||||||
{editingModel && (
|
{initialModel && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="ml-auto inline-flex">
|
<span className="ml-auto inline-flex">
|
||||||
@@ -633,7 +654,11 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
disabled={isSaving || !form.isValid || hasFieldErrors}
|
disabled={isSaving || !form.isValid || hasFieldErrors}
|
||||||
>
|
>
|
||||||
{isSaving && <Spinner className="h-4 w-4" loading />}{" "}
|
{isSaving && <Spinner className="h-4 w-4" loading />}{" "}
|
||||||
{isEditing ? "Save" : "Add model"}
|
{isEditing
|
||||||
|
? "Save"
|
||||||
|
: isDuplicating
|
||||||
|
? "Create duplicate"
|
||||||
|
: "Add model"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+352
-4
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
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 type * as TypesGen from "#/api/typesGenerated";
|
||||||
import { TooltipProvider } from "#/components/Tooltip/Tooltip";
|
import { TooltipProvider } from "#/components/Tooltip/Tooltip";
|
||||||
import type { ProviderState } from "./ChatModelAdminPanel";
|
import type { ProviderState } from "./ChatModelAdminPanel";
|
||||||
@@ -31,6 +31,19 @@ const providerState: ProviderState = {
|
|||||||
baseURL: "",
|
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 = {
|
const baseModelConfig: TypesGen.ChatModelConfig = {
|
||||||
id: "model-config-id",
|
id: "model-config-id",
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
@@ -44,15 +57,47 @@ const baseModelConfig: TypesGen.ChatModelConfig = {
|
|||||||
updated_at: "2025-01-01T00:00:00Z",
|
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> = {
|
const meta: Meta<typeof ModelsSection> = {
|
||||||
title: "pages/AgentsPage/ChatModelAdminPanel/ModelsSection",
|
title: "pages/AgentsPage/ChatModelAdminPanel/ModelsSection",
|
||||||
component: ModelsSection,
|
component: ModelsSection,
|
||||||
args: {
|
args: {
|
||||||
sectionLabel: "Models",
|
sectionLabel: "Models",
|
||||||
providerStates: [providerState],
|
providerStates: [providerState],
|
||||||
selectedProvider: "openai",
|
|
||||||
selectedProviderState: providerState,
|
|
||||||
onSelectedProviderChange: fn(),
|
|
||||||
modelConfigs: [baseModelConfig],
|
modelConfigs: [baseModelConfig],
|
||||||
modelConfigsUnavailable: false,
|
modelConfigsUnavailable: false,
|
||||||
isCreating: false,
|
isCreating: false,
|
||||||
@@ -104,3 +149,306 @@ export const HidesPricingWarningForExplicitZeroPricing: Story = {
|
|||||||
).not.toBeInTheDocument();
|
).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 {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronRightIcon,
|
CopyIcon,
|
||||||
PinIcon,
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
StarIcon,
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
@@ -31,15 +32,30 @@ import { hasCustomPricing } from "./pricingFields";
|
|||||||
type ModelView =
|
type ModelView =
|
||||||
| { mode: "list" }
|
| { mode: "list" }
|
||||||
| { mode: "add"; provider: string }
|
| { 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 {
|
interface ModelsSectionProps {
|
||||||
sectionLabel?: string;
|
sectionLabel?: string;
|
||||||
sectionDescription?: string;
|
sectionDescription?: string;
|
||||||
providerStates: readonly ProviderState[];
|
providerStates: readonly ProviderState[];
|
||||||
selectedProvider: string | null;
|
|
||||||
selectedProviderState: ProviderState | null;
|
|
||||||
onSelectedProviderChange: (provider: string) => void;
|
|
||||||
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
||||||
modelConfigsUnavailable: boolean;
|
modelConfigsUnavailable: boolean;
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
@@ -59,9 +75,6 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
sectionLabel,
|
sectionLabel,
|
||||||
sectionDescription,
|
sectionDescription,
|
||||||
providerStates,
|
providerStates,
|
||||||
selectedProvider,
|
|
||||||
selectedProviderState,
|
|
||||||
onSelectedProviderChange,
|
|
||||||
modelConfigs,
|
modelConfigs,
|
||||||
modelConfigsUnavailable,
|
modelConfigsUnavailable,
|
||||||
isCreating,
|
isCreating,
|
||||||
@@ -90,6 +103,13 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
const model = modelConfigs.find((m) => m.id === editModelId);
|
const model = modelConfigs.find((m) => m.id === editModelId);
|
||||||
return model ? { mode: "edit", model } : { mode: "list" };
|
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");
|
const addProvider = searchParams.get("newModel");
|
||||||
if (addProvider) {
|
if (addProvider) {
|
||||||
return { mode: "add", provider: addProvider };
|
return { mode: "add", provider: addProvider };
|
||||||
@@ -97,12 +117,25 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
return { mode: "list" };
|
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.
|
// Clear model-related search params and return to the list.
|
||||||
const clearModelView = () => {
|
const clearModelView = () => {
|
||||||
setSearchParams((prev) => {
|
setSearchParams((prev) => {
|
||||||
const next = new URLSearchParams(prev);
|
const next = new URLSearchParams(prev);
|
||||||
next.delete("model");
|
clearModelViewParams(next);
|
||||||
next.delete("newModel");
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -118,8 +151,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
setSearchParams(
|
setSearchParams(
|
||||||
(prev) => {
|
(prev) => {
|
||||||
const next = new URLSearchParams(prev);
|
const next = new URLSearchParams(prev);
|
||||||
next.delete("model");
|
clearModelViewParams(next);
|
||||||
next.delete("newModel");
|
|
||||||
return next;
|
return next;
|
||||||
},
|
},
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
@@ -128,32 +160,42 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// When the form is open it takes over the full panel.
|
// 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 editingModel = view.mode === "edit" ? view.model : undefined;
|
||||||
|
const duplicateSourceModel =
|
||||||
const getEffectiveProvider = () => {
|
view.mode === "duplicate" ? view.sourceModel : undefined;
|
||||||
if (editingModel) {
|
const effectiveProvider =
|
||||||
return editingModel.provider;
|
view.mode === "edit"
|
||||||
}
|
? view.model.provider
|
||||||
if (view.mode === "add") {
|
: view.mode === "duplicate"
|
||||||
return view.provider;
|
? view.sourceModel.provider
|
||||||
}
|
: view.provider;
|
||||||
return selectedProvider;
|
const effectiveProviderState =
|
||||||
};
|
providerStates.find((ps) => ps.provider === effectiveProvider) ?? null;
|
||||||
|
const formKey =
|
||||||
const effectiveProvider = getEffectiveProvider();
|
view.mode === "edit"
|
||||||
const effectiveProviderState = effectiveProvider
|
? `edit:${view.model.id}`
|
||||||
? (providerStates.find((ps) => ps.provider === effectiveProvider) ?? null)
|
: view.mode === "duplicate"
|
||||||
: selectedProviderState;
|
? `duplicate:${view.sourceModel.id}`
|
||||||
|
: `add:${view.provider}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModelForm
|
<ModelForm
|
||||||
key={editingModel?.id ?? effectiveProvider ?? "new"}
|
key={formKey}
|
||||||
editingModel={editingModel}
|
editingModel={editingModel}
|
||||||
|
duplicateSourceModel={duplicateSourceModel}
|
||||||
providerStates={providerStates}
|
providerStates={providerStates}
|
||||||
selectedProvider={effectiveProvider}
|
selectedProvider={effectiveProvider}
|
||||||
selectedProviderState={effectiveProviderState}
|
selectedProviderState={effectiveProviderState}
|
||||||
onSelectedProviderChange={onSelectedProviderChange}
|
onSelectedProviderChange={(provider) => {
|
||||||
|
if (view.mode === "add") {
|
||||||
|
setModelViewParam("newModel", provider, { replace: true });
|
||||||
|
}
|
||||||
|
}}
|
||||||
modelConfigsUnavailable={modelConfigsUnavailable}
|
modelConfigsUnavailable={modelConfigsUnavailable}
|
||||||
isSaving={isCreating || isUpdating}
|
isSaving={isCreating || isUpdating}
|
||||||
isDeleting={isDeleting}
|
isDeleting={isDeleting}
|
||||||
@@ -182,11 +224,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
|
|
||||||
// Only show providers that have a deployment key configured or allow
|
// Only show providers that have a deployment key configured or allow
|
||||||
// end users to bring their own key.
|
// end users to bring their own key.
|
||||||
const addableProviders = providerStates.filter(
|
const addableProviders = providerStates.filter(canManageProviderModels);
|
||||||
(ps) =>
|
|
||||||
ps.providerConfig &&
|
|
||||||
(ps.hasEffectiveAPIKey || ps.providerConfig.allow_user_api_key),
|
|
||||||
);
|
|
||||||
|
|
||||||
const addButton = addableProviders.length > 0 && (
|
const addButton = addableProviders.length > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -202,11 +240,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={ps.provider}
|
key={ps.provider}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelectedProviderChange(ps.provider);
|
setModelViewParam("newModel", ps.provider);
|
||||||
setSearchParams(
|
|
||||||
{ newModel: ps.provider },
|
|
||||||
{ state: { pushed: true } },
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
@@ -219,7 +253,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSetDefault = (modelConfig: TypesGen.ChatModelConfig) => {
|
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 });
|
void onUpdateModel(modelConfig.id, { is_default: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -253,6 +287,17 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
const showPricingWarning = !hasCustomPricing(
|
const showPricingWarning = !hasCustomPricing(
|
||||||
modelConfig.model_config,
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -262,16 +307,11 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
i > 0 && "border-0 border-t border-solid border-border/50",
|
i > 0 && "border-0 border-t border-solid border-border/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Clickable row content */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => setModelViewParam("model", modelConfig.id)}
|
||||||
setSearchParams(
|
aria-label={`Open model: ${modelName}`}
|
||||||
{ model: modelConfig.id },
|
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 border-0 bg-transparent p-0 text-left"
|
||||||
{ 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"
|
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
provider={modelConfig.provider}
|
provider={modelConfig.provider}
|
||||||
@@ -286,7 +326,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
: "text-content-primary",
|
: "text-content-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{modelConfig.display_name || modelConfig.model}
|
{modelName}
|
||||||
</span>
|
</span>
|
||||||
{showPricingWarning && (
|
{showPricingWarning && (
|
||||||
<span className="mt-1 flex items-center gap-1 text-xs text-content-warning">
|
<span className="mt-1 flex items-center gap-1 text-xs text-content-warning">
|
||||||
@@ -301,51 +341,85 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{/* Pin for default */}
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="icon"
|
||||||
onClick={(e) => {
|
variant="subtle"
|
||||||
e.stopPropagation();
|
onClick={(event) => {
|
||||||
handleSetDefault(modelConfig);
|
event.stopPropagation();
|
||||||
}}
|
handleSetDefault(modelConfig);
|
||||||
aria-disabled={
|
}}
|
||||||
isUpdating ||
|
aria-disabled={starUnavailable}
|
||||||
modelConfig.is_default ||
|
aria-label={starLabel}
|
||||||
!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
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4",
|
"hover:bg-surface-secondary",
|
||||||
modelConfig.is_default && "fill-current",
|
starUnavailable &&
|
||||||
|
"cursor-not-allowed text-content-secondary/40 hover:bg-transparent hover:text-content-secondary/40",
|
||||||
|
modelConfig.is_default && "text-content-primary",
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
</button>
|
<StarIcon
|
||||||
</TooltipTrigger>
|
className={cn(
|
||||||
<TooltipContent side="left">
|
modelConfig.is_default && "fill-current",
|
||||||
{!modelConfig.enabled
|
)}
|
||||||
? "Cannot set a disabled model as default"
|
/>
|
||||||
: modelConfig.is_default
|
</Button>
|
||||||
? "Pinned as default for new conversations"
|
</TooltipTrigger>
|
||||||
: "Pin as default for new conversations"}
|
<TooltipContent side="top">
|
||||||
</TooltipContent>
|
{!modelConfig.enabled
|
||||||
</Tooltip>
|
? "Cannot set a disabled model as default"
|
||||||
<ChevronRightIcon className="h-5 w-5 shrink-0 text-content-secondary" />
|
: 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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ interface ProvidersSectionProps {
|
|||||||
req: TypesGen.UpdateChatProviderConfigRequest,
|
req: TypesGen.UpdateChatProviderConfigRequest,
|
||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
onDeleteProvider: (providerConfigId: string) => Promise<void>;
|
onDeleteProvider: (providerConfigId: string) => Promise<void>;
|
||||||
onSelectedProviderChange: (provider: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||||
@@ -37,7 +36,6 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
|||||||
onCreateProvider,
|
onCreateProvider,
|
||||||
onUpdateProvider,
|
onUpdateProvider,
|
||||||
onDeleteProvider,
|
onDeleteProvider,
|
||||||
onSelectedProviderChange,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -141,7 +139,6 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
|||||||
key={providerState.provider}
|
key={providerState.provider}
|
||||||
aria-label={providerState.label}
|
aria-label={providerState.label}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelectedProviderChange(providerState.provider);
|
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
{ provider: providerState.provider },
|
{ provider: providerState.provider },
|
||||||
{ state: { pushed: true } },
|
{ state: { pushed: true } },
|
||||||
|
|||||||
@@ -221,18 +221,18 @@ export const extractModelConfigFormState = (
|
|||||||
// ── Build initial model form values ────────────────────────────
|
// ── Build initial model form values ────────────────────────────
|
||||||
|
|
||||||
export const buildInitialModelFormValues = (
|
export const buildInitialModelFormValues = (
|
||||||
editingModel?: TypesGen.ChatModelConfig,
|
sourceModel?: TypesGen.ChatModelConfig,
|
||||||
): ModelFormValues => ({
|
): ModelFormValues => ({
|
||||||
model: editingModel?.model ?? "",
|
model: sourceModel?.model ?? "",
|
||||||
displayName: editingModel?.display_name ?? "",
|
displayName: sourceModel?.display_name ?? "",
|
||||||
enabled: editingModel?.enabled ?? true,
|
enabled: sourceModel?.enabled ?? true,
|
||||||
contextLimit: editingModel ? String(editingModel.context_limit) : "",
|
contextLimit: sourceModel ? String(sourceModel.context_limit) : "",
|
||||||
compressionThreshold: editingModel
|
compressionThreshold: sourceModel
|
||||||
? String(editingModel.compression_threshold)
|
? String(sourceModel.compression_threshold)
|
||||||
: "",
|
: "",
|
||||||
isDefault: editingModel?.is_default ?? false,
|
isDefault: sourceModel?.is_default ?? false,
|
||||||
config: editingModel
|
config: sourceModel
|
||||||
? extractModelConfigFormState(editingModel)
|
? extractModelConfigFormState(sourceModel)
|
||||||
: structuredClone(emptyModelConfigFormState),
|
: structuredClone(emptyModelConfigFormState),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user