mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add general subagent model override (#24610)
Adds a deployment-wide admin override for general delegated subagents.
## What changed
- store the general override in `site_configs` and expose it through the
shared `agent-model-override/{context}` API
- apply the general override when spawning delegated general subagents,
while preserving the existing Explore override behavior
- reuse a shared Agents settings form for the general and Explore
override sections
## Validation
- `make gen`
- `go test ./coderd -run 'TestChatModelOverrides'`
- `go test ./coderd/x/chatd -run
'TestSpawnAgent_(GeneralUsesConfiguredModelOverride|GeneralOverrideLogsAndFallsBackWhenCredentialsUnavailable|GeneralOverrideLogsAndFallsBackWhenProviderDisabled)'`
- `pnpm -C site lint:types`
- `pnpm -C site test:storybook --
AgentSettingsAgentsPageView.stories.tsx`
- `make lint`
- `make pre-commit`
> Mux is acting on Mike's behalf.
This commit is contained in:
+13
-11
@@ -3257,20 +3257,22 @@ class ExperimentalApiMethods {
|
||||
);
|
||||
};
|
||||
|
||||
getChatExploreModelOverride =
|
||||
async (): Promise<TypesGen.ChatExploreModelOverrideResponse> => {
|
||||
const response =
|
||||
await this.axios.get<TypesGen.ChatExploreModelOverrideResponse>(
|
||||
"/api/experimental/chats/config/explore-model-override",
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
getChatAgentModelOverride = async (
|
||||
context: TypesGen.ChatAgentModelOverrideContext,
|
||||
): Promise<TypesGen.ChatAgentModelOverrideResponse> => {
|
||||
const response =
|
||||
await this.axios.get<TypesGen.ChatAgentModelOverrideResponse>(
|
||||
`/api/experimental/chats/config/agent-model-override/${encodeURIComponent(context)}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
updateChatExploreModelOverride = async (
|
||||
req: TypesGen.UpdateChatExploreModelOverrideRequest,
|
||||
updateChatAgentModelOverride = async (
|
||||
context: TypesGen.ChatAgentModelOverrideContext,
|
||||
req: TypesGen.UpdateChatAgentModelOverrideRequest,
|
||||
): Promise<void> => {
|
||||
await this.axios.put(
|
||||
"/api/experimental/chats/config/explore-model-override",
|
||||
`/api/experimental/chats/config/agent-model-override/${encodeURIComponent(context)}`,
|
||||
req,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1127,23 +1127,6 @@ export const updateChatPlanModeInstructions = (queryClient: QueryClient) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const chatExploreModelOverrideKey = ["chat-explore-model-override"] as const;
|
||||
|
||||
export const chatExploreModelOverride = () => ({
|
||||
queryKey: chatExploreModelOverrideKey,
|
||||
queryFn: () => API.experimental.getChatExploreModelOverride(),
|
||||
});
|
||||
|
||||
export const updateChatExploreModelOverride = (queryClient: QueryClient) => ({
|
||||
mutationFn: (req: TypesGen.UpdateChatExploreModelOverrideRequest) =>
|
||||
API.experimental.updateChatExploreModelOverride(req),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: chatExploreModelOverrideKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const chatDesktopEnabledKey = ["chat-desktop-enabled"] as const;
|
||||
|
||||
export const chatDesktopEnabled = () => ({
|
||||
|
||||
Generated
+28
-23
@@ -1272,6 +1272,25 @@ export interface Chat {
|
||||
readonly children: readonly Chat[];
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
export type ChatAgentModelOverrideContext = "explore" | "general";
|
||||
|
||||
export const ChatAgentModelOverrideContexts: ChatAgentModelOverrideContext[] = [
|
||||
"explore",
|
||||
"general",
|
||||
];
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* ChatAgentModelOverrideResponse is the response body for the chat agent
|
||||
* model override configuration endpoint.
|
||||
*/
|
||||
export interface ChatAgentModelOverrideResponse {
|
||||
readonly context: ChatAgentModelOverrideContext;
|
||||
readonly model_config_id: string;
|
||||
readonly is_malformed: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
export type ChatBusyBehavior = "interrupt" | "queue";
|
||||
|
||||
@@ -1590,20 +1609,6 @@ export interface ChatDiffStatus {
|
||||
readonly stale_at?: string;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* ChatExploreModelOverrideResponse is the response body for the Explore
|
||||
* subagent model override configuration endpoint.
|
||||
*/
|
||||
export interface ChatExploreModelOverrideResponse {
|
||||
readonly model_config_id?: string;
|
||||
/**
|
||||
* HasMalformedOverride reports whether the saved override is malformed and
|
||||
* is currently being treated as unset.
|
||||
*/
|
||||
readonly has_malformed_override: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* ChatFileMetadata contains lightweight metadata about a file
|
||||
@@ -7657,6 +7662,15 @@ export interface UpdateAppearanceConfig {
|
||||
readonly announcement_banners: readonly BannerConfig[];
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* UpdateChatAgentModelOverrideRequest is the request body for updating the
|
||||
* chat agent model override configuration endpoint.
|
||||
*/
|
||||
export interface UpdateChatAgentModelOverrideRequest {
|
||||
readonly model_config_id: string;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* UpdateChatDebugLoggingAllowUsersRequest is the admin request to
|
||||
@@ -7674,15 +7688,6 @@ export interface UpdateChatDesktopEnabledRequest {
|
||||
readonly enable_desktop: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* UpdateChatExploreModelOverrideRequest is the request body for updating the
|
||||
* Explore subagent model override configuration endpoint.
|
||||
*/
|
||||
export interface UpdateChatExploreModelOverrideRequest {
|
||||
readonly model_config_id?: string;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* UpdateChatModelConfigRequest updates a chat model config.
|
||||
|
||||
@@ -1,34 +1,83 @@
|
||||
import type { FC } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import {
|
||||
chatExploreModelOverride,
|
||||
chatModelConfigs,
|
||||
updateChatExploreModelOverride,
|
||||
} from "#/api/queries/chats";
|
||||
type QueryClient,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "react-query";
|
||||
import { API } from "#/api/api";
|
||||
import { chatModelConfigs } from "#/api/queries/chats";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { AgentSettingsAgentsPageView } from "./AgentSettingsAgentsPageView";
|
||||
|
||||
const generalOverrideContext: TypesGen.ChatAgentModelOverrideContext =
|
||||
"general";
|
||||
const exploreOverrideContext: TypesGen.ChatAgentModelOverrideContext =
|
||||
"explore";
|
||||
|
||||
const chatAgentModelOverrideKey = (
|
||||
context: TypesGen.ChatAgentModelOverrideContext,
|
||||
) => ["chat-agent-model-override", context] as const;
|
||||
|
||||
const chatAgentModelOverrideQuery = (
|
||||
context: TypesGen.ChatAgentModelOverrideContext,
|
||||
) => ({
|
||||
queryKey: chatAgentModelOverrideKey(context),
|
||||
queryFn: () => API.experimental.getChatAgentModelOverride(context),
|
||||
});
|
||||
|
||||
const updateChatAgentModelOverrideMutation = (
|
||||
queryClient: QueryClient,
|
||||
context: TypesGen.ChatAgentModelOverrideContext,
|
||||
) => ({
|
||||
mutationFn: (req: TypesGen.UpdateChatAgentModelOverrideRequest) =>
|
||||
API.experimental.updateChatAgentModelOverride(context, req),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: chatAgentModelOverrideKey(context),
|
||||
exact: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const AgentSettingsAgentsPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const queryClient = useQueryClient();
|
||||
const canEditDeploymentConfig = permissions.editDeploymentConfig;
|
||||
|
||||
const generalModelOverrideQuery = useQuery({
|
||||
...chatAgentModelOverrideQuery(generalOverrideContext),
|
||||
enabled: canEditDeploymentConfig,
|
||||
});
|
||||
const exploreModelOverrideQuery = useQuery({
|
||||
...chatExploreModelOverride(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
...chatAgentModelOverrideQuery(exploreOverrideContext),
|
||||
enabled: canEditDeploymentConfig,
|
||||
});
|
||||
const modelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const saveGeneralModelOverrideMutation = useMutation(
|
||||
updateChatAgentModelOverrideMutation(queryClient, generalOverrideContext),
|
||||
);
|
||||
const saveExploreModelOverrideMutation = useMutation(
|
||||
updateChatExploreModelOverride(queryClient),
|
||||
updateChatAgentModelOverrideMutation(queryClient, exploreOverrideContext),
|
||||
);
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
<RequirePermission isFeatureVisible={canEditDeploymentConfig}>
|
||||
<AgentSettingsAgentsPageView
|
||||
generalModelOverrideData={generalModelOverrideQuery.data}
|
||||
exploreModelOverrideData={exploreModelOverrideQuery.data}
|
||||
modelConfigsData={modelConfigsQuery.data}
|
||||
modelConfigsError={modelConfigsQuery.error}
|
||||
isLoadingModelConfigs={modelConfigsQuery.isLoading}
|
||||
onSaveGeneralModelOverride={saveGeneralModelOverrideMutation.mutate}
|
||||
isSavingGeneralModelOverride={
|
||||
saveGeneralModelOverrideMutation.isPending
|
||||
}
|
||||
isSaveGeneralModelOverrideError={
|
||||
saveGeneralModelOverrideMutation.isError
|
||||
}
|
||||
onSaveExploreModelOverride={saveExploreModelOverrideMutation.mutate}
|
||||
isSavingExploreModelOverride={
|
||||
saveExploreModelOverrideMutation.isPending
|
||||
|
||||
@@ -6,208 +6,294 @@ import {
|
||||
type AgentSettingsAgentsPageViewProps,
|
||||
} from "./AgentSettingsAgentsPageView";
|
||||
|
||||
const baseArgs: AgentSettingsAgentsPageViewProps = {
|
||||
exploreModelOverrideData: {
|
||||
has_malformed_override: false,
|
||||
},
|
||||
modelConfigsData: [],
|
||||
const OVERRIDE_MALFORMED_WARNING =
|
||||
"The saved override is malformed and is being treated as unset. Click Save to clear it.";
|
||||
const UNAVAILABLE_SAVED_MODEL_WARNING =
|
||||
"The saved model is no longer enabled and will be ignored until you choose a new override.";
|
||||
|
||||
const buildModelConfig = (
|
||||
overrides: Partial<TypesGen.ChatModelConfig>,
|
||||
): TypesGen.ChatModelConfig => ({
|
||||
id: "model-default",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
display_name: "GPT 4.1 Mini",
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
context_limit: 1_000_000,
|
||||
compression_threshold: 70,
|
||||
created_at: "2026-03-12T12:00:00.000Z",
|
||||
updated_at: "2026-03-12T12:00:00.000Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildOverrideData = (
|
||||
context: TypesGen.ChatAgentModelOverrideContext,
|
||||
overrides: Partial<TypesGen.ChatAgentModelOverrideResponse> = {},
|
||||
): TypesGen.ChatAgentModelOverrideResponse => ({
|
||||
context,
|
||||
model_config_id: "",
|
||||
is_malformed: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const generalModelConfig = buildModelConfig({
|
||||
id: "model-general-gpt-4.1-mini",
|
||||
display_name: "GPT 4.1 Mini",
|
||||
});
|
||||
|
||||
const claudeSonnetModelConfig = buildModelConfig({
|
||||
id: "model-claude-sonnet-4",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4",
|
||||
display_name: "Claude Sonnet 4",
|
||||
context_limit: 200_000,
|
||||
});
|
||||
|
||||
const exploreFallbackModelConfig = buildModelConfig({
|
||||
id: "model-explore-blank-display",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
display_name: "",
|
||||
context_limit: 200_000,
|
||||
});
|
||||
|
||||
const generalDisabledModelConfig = buildModelConfig({
|
||||
id: "model-general-disabled",
|
||||
model: "gpt-4.1-legacy",
|
||||
display_name: "GPT 4.1 Legacy",
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const exploreDisabledModelConfig = buildModelConfig({
|
||||
id: "model-explore-disabled",
|
||||
provider: "anthropic",
|
||||
model: "claude-haiku-legacy",
|
||||
display_name: "Claude Haiku Legacy",
|
||||
enabled: false,
|
||||
context_limit: 200_000,
|
||||
});
|
||||
|
||||
const allModelConfigs: TypesGen.ChatModelConfig[] = [
|
||||
generalModelConfig,
|
||||
claudeSonnetModelConfig,
|
||||
exploreFallbackModelConfig,
|
||||
generalDisabledModelConfig,
|
||||
exploreDisabledModelConfig,
|
||||
];
|
||||
|
||||
const makeArgs = (
|
||||
overrides: Partial<AgentSettingsAgentsPageViewProps> = {},
|
||||
): AgentSettingsAgentsPageViewProps => ({
|
||||
generalModelOverrideData: buildOverrideData("general"),
|
||||
exploreModelOverrideData: buildOverrideData("explore"),
|
||||
modelConfigsData: allModelConfigs,
|
||||
modelConfigsError: undefined,
|
||||
isLoadingModelConfigs: false,
|
||||
onSaveGeneralModelOverride: fn(),
|
||||
isSavingGeneralModelOverride: false,
|
||||
isSaveGeneralModelOverrideError: false,
|
||||
onSaveExploreModelOverride: fn(),
|
||||
isSavingExploreModelOverride: false,
|
||||
isSaveExploreModelOverrideError: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const getSection = async (
|
||||
canvasElement: HTMLElement,
|
||||
headingName: string,
|
||||
): Promise<HTMLElement> => {
|
||||
const canvas = within(canvasElement);
|
||||
const heading = await canvas.findByRole("heading", { name: headingName });
|
||||
const section = heading.closest("section");
|
||||
if (!(section instanceof HTMLElement)) {
|
||||
throw new Error(
|
||||
`Expected ${headingName} heading to live inside a section.`,
|
||||
);
|
||||
}
|
||||
return section;
|
||||
};
|
||||
|
||||
const selectModelInSection = async (
|
||||
section: HTMLElement,
|
||||
canvasElement: HTMLElement,
|
||||
currentSelectionName: string | RegExp,
|
||||
optionName: string,
|
||||
) => {
|
||||
const trigger = within(section).getByRole("combobox", {
|
||||
name: currentSelectionName,
|
||||
});
|
||||
await userEvent.click(trigger);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await body.findByRole("option", { name: optionName }));
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "pages/AgentsPage/AgentSettingsAgentsPageView",
|
||||
component: AgentSettingsAgentsPageView,
|
||||
args: baseArgs,
|
||||
args: makeArgs(),
|
||||
} satisfies Meta<typeof AgentSettingsAgentsPageView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentSettingsAgentsPageView>;
|
||||
|
||||
export const ExploreModelOverrideSetting: Story = {
|
||||
args: {
|
||||
exploreModelOverrideData: {
|
||||
model_config_id: "model-explore-1",
|
||||
has_malformed_override: false,
|
||||
},
|
||||
modelConfigsData: [
|
||||
{
|
||||
id: "model-explore-1",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
display_name: "GPT 4.1 Mini",
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
context_limit: 1_000_000,
|
||||
compression_threshold: 70,
|
||||
created_at: "2026-03-12T12:00:00.000Z",
|
||||
updated_at: "2026-03-12T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "model-explore-2",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4",
|
||||
display_name: "Claude Sonnet 4",
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
context_limit: 200_000,
|
||||
compression_threshold: 70,
|
||||
created_at: "2026-03-12T12:00:00.000Z",
|
||||
updated_at: "2026-03-12T12:00:00.000Z",
|
||||
},
|
||||
] as TypesGen.ChatModelConfig[],
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText("Agents");
|
||||
await canvas.findByText("Explore subagent model");
|
||||
const trigger = canvas.getByRole("combobox", {
|
||||
name: /gpt 4.1 mini/i,
|
||||
});
|
||||
await userEvent.click(trigger);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(
|
||||
await body.findByRole("option", { name: "Claude Sonnet 4" }),
|
||||
);
|
||||
const form = trigger.closest("form");
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
throw new Error("Expected Explore model selector to live inside a form.");
|
||||
}
|
||||
const saveButton = within(form).getByRole("button", { name: "Save" });
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(saveButton);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
|
||||
{ model_config_id: "model-explore-2" },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ExploreModelOverrideAllowsExplicitClear: Story = {
|
||||
args: {
|
||||
exploreModelOverrideData: {
|
||||
model_config_id: "model-explore-clear",
|
||||
has_malformed_override: false,
|
||||
},
|
||||
modelConfigsData: [
|
||||
{
|
||||
id: "model-explore-clear",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
display_name: "GPT 4.1 Mini",
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
context_limit: 1_000_000,
|
||||
compression_threshold: 70,
|
||||
created_at: "2026-03-12T12:00:00.000Z",
|
||||
updated_at: "2026-03-12T12:00:00.000Z",
|
||||
},
|
||||
] as TypesGen.ChatModelConfig[],
|
||||
onSaveExploreModelOverride: fn(),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const clearButton = await canvas.findByRole("button", { name: "Clear" });
|
||||
const form = clearButton.closest("form");
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
throw new Error(
|
||||
"Expected Explore model clear button to live inside a form.",
|
||||
);
|
||||
}
|
||||
|
||||
const saveButton = within(form).getByRole("button", { name: "Save" });
|
||||
await userEvent.click(clearButton);
|
||||
expect(args.onSaveExploreModelOverride).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(saveButton);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ExploreModelOverrideClearsMalformedSavedValue: Story = {
|
||||
args: {
|
||||
exploreModelOverrideData: {
|
||||
has_malformed_override: true,
|
||||
},
|
||||
modelConfigsData: [],
|
||||
onSaveExploreModelOverride: fn(),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText(
|
||||
"The saved override is malformed and is being treated as unset. Click Save to clear it.",
|
||||
);
|
||||
const clearButton = await canvas.findByRole("button", { name: "Clear" });
|
||||
const form = clearButton.closest("form");
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
throw new Error(
|
||||
"Expected Explore model clear button to live inside a form.",
|
||||
);
|
||||
}
|
||||
|
||||
const saveButton = within(form).getByRole("button", { name: "Save" });
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(clearButton);
|
||||
expect(args.onSaveExploreModelOverride).not.toHaveBeenCalled();
|
||||
await userEvent.click(saveButton);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ExploreModelOverrideFallsBackToModelName: Story = {
|
||||
args: {
|
||||
exploreModelOverrideData: {
|
||||
model_config_id: "model-explore-empty-name",
|
||||
has_malformed_override: false,
|
||||
},
|
||||
modelConfigsData: [
|
||||
{
|
||||
id: "model-explore-empty-name",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
display_name: "",
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
context_limit: 200_000,
|
||||
compression_threshold: 70,
|
||||
created_at: "2026-03-12T12:00:00.000Z",
|
||||
updated_at: "2026-03-12T12:00:00.000Z",
|
||||
},
|
||||
] as TypesGen.ChatModelConfig[],
|
||||
},
|
||||
export const AllOverridesUnset: Story = {
|
||||
args: makeArgs(),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const trigger = await canvas.findByRole("combobox", {
|
||||
name: /claude-sonnet-4-20250514/i,
|
||||
});
|
||||
expect(trigger).toHaveTextContent("claude-sonnet-4-20250514");
|
||||
await userEvent.click(trigger);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await canvas.findByText("Agents");
|
||||
|
||||
const headings = await canvas.findAllByRole("heading", { level: 3 });
|
||||
expect(headings.map((heading) => heading.textContent?.trim())).toEqual([
|
||||
"General model",
|
||||
"Explore subagent model",
|
||||
]);
|
||||
|
||||
for (const headingName of ["General model", "Explore subagent model"]) {
|
||||
const section = await getSection(canvasElement, headingName);
|
||||
expect(
|
||||
within(section).getByRole("combobox", { name: "Use chat default" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(section).getByRole("button", { name: "Save" }),
|
||||
).toBeDisabled();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const EachOverrideSetToEnabledModel: Story = {
|
||||
args: makeArgs({
|
||||
generalModelOverrideData: buildOverrideData("general", {
|
||||
model_config_id: generalModelConfig.id,
|
||||
}),
|
||||
exploreModelOverrideData: buildOverrideData("explore", {
|
||||
model_config_id: exploreFallbackModelConfig.id,
|
||||
}),
|
||||
}),
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const generalSection = await getSection(canvasElement, "General model");
|
||||
const exploreSection = await getSection(
|
||||
canvasElement,
|
||||
"Explore subagent model",
|
||||
);
|
||||
|
||||
expect(
|
||||
await body.findByRole("option", {
|
||||
name: "claude-sonnet-4-20250514",
|
||||
within(exploreSection).getByRole("combobox", {
|
||||
name: /claude-sonnet-4-20250514/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
).toHaveTextContent("claude-sonnet-4-20250514");
|
||||
|
||||
await selectModelInSection(
|
||||
generalSection,
|
||||
canvasElement,
|
||||
/gpt 4\.1 mini/i,
|
||||
"Claude Sonnet 4",
|
||||
);
|
||||
const generalSaveButton = within(generalSection).getByRole("button", {
|
||||
name: "Save",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(generalSaveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(generalSaveButton);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveGeneralModelOverride).toHaveBeenCalledWith(
|
||||
{ model_config_id: claudeSonnetModelConfig.id },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
const exploreClearButton = within(exploreSection).getByRole("button", {
|
||||
name: "Clear",
|
||||
});
|
||||
await userEvent.click(exploreClearButton);
|
||||
const exploreSaveButton = within(exploreSection).getByRole("button", {
|
||||
name: "Save",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(exploreSaveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(exploreSaveButton);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
|
||||
{ model_config_id: "" },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const MalformedOverridesRemainClearableAndSaveable: Story = {
|
||||
args: makeArgs({
|
||||
generalModelOverrideData: buildOverrideData("general", {
|
||||
is_malformed: true,
|
||||
}),
|
||||
exploreModelOverrideData: buildOverrideData("explore", {
|
||||
is_malformed: true,
|
||||
}),
|
||||
}),
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const generalSection = await getSection(canvasElement, "General model");
|
||||
const exploreSection = await getSection(
|
||||
canvasElement,
|
||||
"Explore subagent model",
|
||||
);
|
||||
|
||||
for (const section of [generalSection, exploreSection]) {
|
||||
await within(section).findByText(OVERRIDE_MALFORMED_WARNING);
|
||||
}
|
||||
|
||||
const generalSaveButton = within(generalSection).getByRole("button", {
|
||||
name: "Save",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(generalSaveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(generalSaveButton);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveGeneralModelOverride).toHaveBeenCalledWith(
|
||||
{ model_config_id: "" },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
const exploreSaveButton = within(exploreSection).getByRole("button", {
|
||||
name: "Save",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(exploreSaveButton).toBeEnabled();
|
||||
});
|
||||
await userEvent.click(exploreSaveButton);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith(
|
||||
{ model_config_id: "" },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const UnavailableSavedModels: Story = {
|
||||
args: makeArgs({
|
||||
generalModelOverrideData: buildOverrideData("general", {
|
||||
model_config_id: generalDisabledModelConfig.id,
|
||||
}),
|
||||
exploreModelOverrideData: buildOverrideData("explore", {
|
||||
model_config_id: exploreDisabledModelConfig.id,
|
||||
}),
|
||||
}),
|
||||
play: async ({ canvasElement }) => {
|
||||
const generalSection = await getSection(canvasElement, "General model");
|
||||
const exploreSection = await getSection(
|
||||
canvasElement,
|
||||
"Explore subagent model",
|
||||
);
|
||||
|
||||
for (const section of [generalSection, exploreSection]) {
|
||||
await within(section).findByText(UNAVAILABLE_SAVED_MODEL_WARNING);
|
||||
expect(
|
||||
within(section).getByRole("combobox", { name: "Unavailable model" }),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { ExploreModelOverrideSettings } from "./components/ExploreModelOverrideSettings";
|
||||
import { SectionHeader } from "./components/SectionHeader";
|
||||
import {
|
||||
type MutationCallbacks,
|
||||
SubagentModelOverrideSettings,
|
||||
} from "./components/SubagentModelOverrideSettings";
|
||||
|
||||
interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
type SaveModelOverride = (
|
||||
req: TypesGen.UpdateChatAgentModelOverrideRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
|
||||
export interface AgentSettingsAgentsPageViewProps {
|
||||
exploreModelOverrideData:
|
||||
| TypesGen.ChatExploreModelOverrideResponse
|
||||
| undefined;
|
||||
generalModelOverrideData?: TypesGen.ChatAgentModelOverrideResponse;
|
||||
exploreModelOverrideData?: TypesGen.ChatAgentModelOverrideResponse;
|
||||
modelConfigsData: TypesGen.ChatModelConfig[] | undefined;
|
||||
modelConfigsError: unknown;
|
||||
isLoadingModelConfigs: boolean;
|
||||
onSaveExploreModelOverride: (
|
||||
req: TypesGen.UpdateChatExploreModelOverrideRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
onSaveGeneralModelOverride?: SaveModelOverride;
|
||||
isSavingGeneralModelOverride?: boolean;
|
||||
isSaveGeneralModelOverrideError?: boolean;
|
||||
onSaveExploreModelOverride: SaveModelOverride;
|
||||
isSavingExploreModelOverride: boolean;
|
||||
isSaveExploreModelOverrideError: boolean;
|
||||
}
|
||||
@@ -26,37 +28,84 @@ export interface AgentSettingsAgentsPageViewProps {
|
||||
export const AgentSettingsAgentsPageView: FC<
|
||||
AgentSettingsAgentsPageViewProps
|
||||
> = ({
|
||||
generalModelOverrideData,
|
||||
exploreModelOverrideData,
|
||||
modelConfigsData,
|
||||
modelConfigsError,
|
||||
isLoadingModelConfigs,
|
||||
onSaveGeneralModelOverride,
|
||||
isSavingGeneralModelOverride = false,
|
||||
isSaveGeneralModelOverrideError = false,
|
||||
onSaveExploreModelOverride,
|
||||
isSavingExploreModelOverride,
|
||||
isSaveExploreModelOverrideError,
|
||||
}) => {
|
||||
const enabledModelConfigs = (modelConfigsData ?? []).filter(
|
||||
(modelConfig) => modelConfig.enabled,
|
||||
);
|
||||
const showGeneralModelSection =
|
||||
onSaveGeneralModelOverride !== undefined ||
|
||||
generalModelOverrideData !== undefined ||
|
||||
isSavingGeneralModelOverride ||
|
||||
isSaveGeneralModelOverrideError;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<SectionHeader
|
||||
label="Agents"
|
||||
description="Configure defaults for delegated agents and other agent-specific capabilities."
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
{showGeneralModelSection && onSaveGeneralModelOverride && (
|
||||
<section aria-label="General model" className="flex flex-col gap-3">
|
||||
<SectionHeader
|
||||
label="General model"
|
||||
description="Deployment-wide model override for delegated subagents with write capabilities, such as editing files or running commands in the workspace."
|
||||
level="section"
|
||||
/>
|
||||
<SubagentModelOverrideSettings
|
||||
title="General model"
|
||||
description="Deployment-wide model override for delegated subagents with write capabilities, such as editing files or running commands in the workspace."
|
||||
modelOverrideData={generalModelOverrideData}
|
||||
enabledModelConfigs={enabledModelConfigs}
|
||||
modelConfigsError={modelConfigsError}
|
||||
isLoading={isLoadingModelConfigs}
|
||||
onSaveModelOverride={onSaveGeneralModelOverride}
|
||||
isSaving={isSavingGeneralModelOverride}
|
||||
isSaveError={isSaveGeneralModelOverrideError}
|
||||
saveErrorMessage="Failed to save general model override."
|
||||
showHeader={false}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<section
|
||||
aria-label="Explore subagent model"
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<SectionHeader
|
||||
label="Explore subagent model"
|
||||
description="Optional deployment-wide model override for read-only Explore subagents."
|
||||
description="Deployment-wide model override for read-only Explore subagents."
|
||||
level="section"
|
||||
/>
|
||||
<ExploreModelOverrideSettings
|
||||
exploreModelOverrideData={exploreModelOverrideData}
|
||||
modelConfigs={modelConfigsData ?? []}
|
||||
<SubagentModelOverrideSettings
|
||||
title="Explore subagent model"
|
||||
description={
|
||||
<>
|
||||
Deployment-wide model override for read-only Explore subagents
|
||||
launched through the <code>spawn_agent</code> tool with a
|
||||
<code>type=explore</code> argument.
|
||||
</>
|
||||
}
|
||||
modelOverrideData={exploreModelOverrideData}
|
||||
enabledModelConfigs={enabledModelConfigs}
|
||||
modelConfigsError={modelConfigsError}
|
||||
isLoadingModelConfigs={isLoadingModelConfigs}
|
||||
onSaveExploreModelOverride={onSaveExploreModelOverride}
|
||||
isSavingExploreModelOverride={isSavingExploreModelOverride}
|
||||
isSaveExploreModelOverrideError={isSaveExploreModelOverrideError}
|
||||
isLoading={isLoadingModelConfigs}
|
||||
onSaveModelOverride={onSaveExploreModelOverride}
|
||||
isSaving={isSavingExploreModelOverride}
|
||||
isSaveError={isSaveExploreModelOverrideError}
|
||||
saveErrorMessage="Failed to save Explore model override."
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -156,7 +156,11 @@ const fixedNow = dayjs("2026-03-12T12:00:00");
|
||||
|
||||
const AgentsRouteElement = () => (
|
||||
<AgentSettingsAgentsPageView
|
||||
exploreModelOverrideData={{ has_malformed_override: false }}
|
||||
exploreModelOverrideData={{
|
||||
context: "explore",
|
||||
model_config_id: "",
|
||||
is_malformed: false,
|
||||
}}
|
||||
modelConfigsData={[]}
|
||||
modelConfigsError={undefined}
|
||||
isLoadingModelConfigs={false}
|
||||
@@ -609,10 +613,23 @@ export const WithErrorReasons: Story = {
|
||||
|
||||
const openSettingsView = async (canvasElement: HTMLElement) => {
|
||||
const canvas = within(canvasElement);
|
||||
const link = await waitFor(() =>
|
||||
canvas.getByRole("link", { name: "Settings" }),
|
||||
const settingsLink = canvas.queryByRole("link", { name: "Settings" });
|
||||
if (settingsLink) {
|
||||
await userEvent.click(settingsLink);
|
||||
return;
|
||||
}
|
||||
|
||||
const mobileMoreOptionsButton = canvas
|
||||
.getAllByRole("button", { name: "More options" })
|
||||
.find((button) => button.getAttribute("aria-haspopup") === "menu");
|
||||
if (!mobileMoreOptionsButton) {
|
||||
throw new Error("Expected a mobile More options menu button.");
|
||||
}
|
||||
await userEvent.click(mobileMoreOptionsButton);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(
|
||||
await body.findByRole("menuitem", { name: "Settings" }),
|
||||
);
|
||||
await userEvent.click(link);
|
||||
};
|
||||
|
||||
export const OpensAnalyticsForAdmins: Story = {
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription } from "#/components/Alert/Alert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import type { ModelSelectorOption } from "./ChatElements/ModelSelector";
|
||||
import { ModelSelector } from "./ChatElements/ModelSelector";
|
||||
|
||||
interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
interface ExploreModelOverrideSettingsProps {
|
||||
exploreModelOverrideData:
|
||||
| TypesGen.ChatExploreModelOverrideResponse
|
||||
| undefined;
|
||||
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
||||
modelConfigsError: unknown;
|
||||
isLoadingModelConfigs: boolean;
|
||||
onSaveExploreModelOverride: (
|
||||
req: TypesGen.UpdateChatExploreModelOverrideRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSavingExploreModelOverride: boolean;
|
||||
isSaveExploreModelOverrideError: boolean;
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
const toModelSelectorOption = (
|
||||
modelConfig: TypesGen.ChatModelConfig,
|
||||
): ModelSelectorOption => ({
|
||||
id: modelConfig.id,
|
||||
provider: modelConfig.provider,
|
||||
model: modelConfig.model,
|
||||
displayName: modelConfig.display_name.trim() || modelConfig.model,
|
||||
contextLimit: modelConfig.context_limit,
|
||||
});
|
||||
|
||||
export const ExploreModelOverrideSettings: FC<
|
||||
ExploreModelOverrideSettingsProps
|
||||
> = ({
|
||||
exploreModelOverrideData,
|
||||
modelConfigs,
|
||||
modelConfigsError,
|
||||
isLoadingModelConfigs,
|
||||
onSaveExploreModelOverride,
|
||||
isSavingExploreModelOverride,
|
||||
isSaveExploreModelOverrideError,
|
||||
showHeader = true,
|
||||
}) => {
|
||||
const hasLoadedExploreModelOverride = exploreModelOverrideData !== undefined;
|
||||
const enabledModelOptions = modelConfigs
|
||||
.filter((modelConfig) => modelConfig.enabled)
|
||||
.map(toModelSelectorOption);
|
||||
|
||||
const form = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
model_config_id: exploreModelOverrideData?.model_config_id ?? "",
|
||||
},
|
||||
onSubmit: (values, { resetForm }) => {
|
||||
onSaveExploreModelOverride(
|
||||
{
|
||||
model_config_id: values.model_config_id || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
resetForm({ values });
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const isUnavailableSavedModel =
|
||||
form.values.model_config_id !== "" &&
|
||||
!enabledModelOptions.some(
|
||||
(option) => option.id === form.values.model_config_id,
|
||||
);
|
||||
const hasMalformedOverride =
|
||||
exploreModelOverrideData?.has_malformed_override ?? false;
|
||||
const isExploreModelOverrideDisabled =
|
||||
isSavingExploreModelOverride ||
|
||||
isLoadingModelConfigs ||
|
||||
!hasLoadedExploreModelOverride;
|
||||
const canSaveExploreModelOverride =
|
||||
hasLoadedExploreModelOverride && (form.dirty || hasMalformedOverride);
|
||||
|
||||
return (
|
||||
<form className="space-y-2" onSubmit={form.handleSubmit}>
|
||||
{showHeader && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
Explore subagent model
|
||||
</h3>
|
||||
</div>
|
||||
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
|
||||
Optional deployment-wide model override for read-only Explore
|
||||
subagents spawned with <code>spawn_agent</code> using
|
||||
<code>type=explore</code>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="rounded-lg border border-border bg-surface-primary px-3 py-2">
|
||||
<ModelSelector
|
||||
options={enabledModelOptions}
|
||||
value={form.values.model_config_id}
|
||||
onValueChange={(value) =>
|
||||
form.setFieldValue("model_config_id", value)
|
||||
}
|
||||
disabled={isExploreModelOverrideDisabled}
|
||||
placeholder={
|
||||
isUnavailableSavedModel ? "Unavailable model" : "Use chat default"
|
||||
}
|
||||
emptyMessage={
|
||||
isLoadingModelConfigs
|
||||
? "Loading models..."
|
||||
: "No enabled models found."
|
||||
}
|
||||
className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-sm"
|
||||
contentClassName="min-w-[18rem]"
|
||||
/>
|
||||
</div>
|
||||
{isUnavailableSavedModel && (
|
||||
<Alert severity="warning">
|
||||
<AlertDescription>
|
||||
The saved model is no longer enabled and will be ignored until you
|
||||
choose a new override.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{hasMalformedOverride && (
|
||||
<Alert severity="warning">
|
||||
<AlertDescription>
|
||||
The saved override is malformed and is being treated as unset. Click
|
||||
Save to clear it.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{Boolean(modelConfigsError) && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to load model configs.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
form.setFieldValue("model_config_id", "");
|
||||
}}
|
||||
disabled={isExploreModelOverrideDisabled}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={
|
||||
isExploreModelOverrideDisabled || !canSaveExploreModelOverride
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{isSaveExploreModelOverrideError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save Explore model override.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useFormik } from "formik";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription } from "#/components/Alert/Alert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import type { ModelSelectorOption } from "./ChatElements/ModelSelector";
|
||||
import { ModelSelector } from "./ChatElements/ModelSelector";
|
||||
|
||||
export interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
interface ModelOverrideData {
|
||||
readonly model_config_id: string;
|
||||
readonly is_malformed: boolean;
|
||||
}
|
||||
|
||||
interface UpdateModelOverrideRequest {
|
||||
readonly model_config_id: string;
|
||||
}
|
||||
|
||||
interface SubagentModelOverrideSettingsProps {
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
modelOverrideData: ModelOverrideData | undefined;
|
||||
enabledModelConfigs: readonly TypesGen.ChatModelConfig[];
|
||||
modelConfigsError: unknown;
|
||||
isLoading: boolean;
|
||||
onSaveModelOverride: (
|
||||
req: UpdateModelOverrideRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSaving: boolean;
|
||||
isSaveError: boolean;
|
||||
saveErrorMessage: string;
|
||||
showHeader?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const toModelSelectorOption = (
|
||||
modelConfig: TypesGen.ChatModelConfig,
|
||||
): ModelSelectorOption => ({
|
||||
id: modelConfig.id,
|
||||
provider: modelConfig.provider,
|
||||
model: modelConfig.model,
|
||||
displayName: modelConfig.display_name.trim() || modelConfig.model,
|
||||
contextLimit: modelConfig.context_limit,
|
||||
});
|
||||
|
||||
export const SubagentModelOverrideSettings: FC<
|
||||
SubagentModelOverrideSettingsProps
|
||||
> = ({
|
||||
title,
|
||||
description,
|
||||
modelOverrideData,
|
||||
enabledModelConfigs,
|
||||
modelConfigsError,
|
||||
isLoading,
|
||||
onSaveModelOverride,
|
||||
isSaving,
|
||||
isSaveError,
|
||||
saveErrorMessage,
|
||||
showHeader = true,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const hasLoadedModelOverride = modelOverrideData !== undefined;
|
||||
const enabledModelOptions = enabledModelConfigs.map(toModelSelectorOption);
|
||||
|
||||
const form = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
model_config_id: modelOverrideData?.model_config_id ?? "",
|
||||
},
|
||||
onSubmit: (values, { resetForm }) => {
|
||||
onSaveModelOverride(
|
||||
{
|
||||
model_config_id: values.model_config_id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
resetForm({ values });
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const isUnavailableSavedModel =
|
||||
form.values.model_config_id !== "" &&
|
||||
!enabledModelOptions.some(
|
||||
(option) => option.id === form.values.model_config_id,
|
||||
);
|
||||
const isMalformedOverride = modelOverrideData?.is_malformed ?? false;
|
||||
const isModelOverrideDisabled =
|
||||
disabled || isSaving || isLoading || !hasLoadedModelOverride;
|
||||
const canSaveModelOverride =
|
||||
hasLoadedModelOverride && (form.dirty || isMalformedOverride);
|
||||
|
||||
return (
|
||||
<form aria-label={title} className="space-y-2" onSubmit={form.handleSubmit}>
|
||||
{showHeader && (
|
||||
<>
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
|
||||
{description}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<ModelSelector
|
||||
options={enabledModelOptions}
|
||||
value={form.values.model_config_id}
|
||||
onValueChange={(value) => form.setFieldValue("model_config_id", value)}
|
||||
disabled={isModelOverrideDisabled}
|
||||
placeholder={
|
||||
isUnavailableSavedModel ? "Unavailable model" : "Use chat default"
|
||||
}
|
||||
emptyMessage={
|
||||
isLoading ? "Loading models..." : "No enabled models found."
|
||||
}
|
||||
className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-sm"
|
||||
contentClassName="min-w-[18rem]"
|
||||
/>
|
||||
{isUnavailableSavedModel && (
|
||||
<Alert severity="warning">
|
||||
<AlertDescription>
|
||||
The saved model is no longer enabled and will be ignored until you
|
||||
choose a new override.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isMalformedOverride && (
|
||||
<Alert severity="warning">
|
||||
<AlertDescription>
|
||||
The saved override is malformed and is being treated as unset. Click
|
||||
Save to clear it.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{Boolean(modelConfigsError) && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to load model configs.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
form.setFieldValue("model_config_id", "");
|
||||
}}
|
||||
disabled={isModelOverrideDisabled}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={isModelOverrideDisabled || !canSaveModelOverride}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{isSaveError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{saveErrorMessage}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user