mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site/src/pages/AgentsPage): guide users when chat providers or models are missing (#24863)
<img width="674" height="508" alt="Screenshot 2026-05-04 at 20 43 11" src="https://github.com/user-attachments/assets/de33dba9-33f5-4dbe-a1af-9bff5f048b8f" /> When the agents chat page loads with no chat providers or no chat models configured, new users currently get no in-product guidance about the missing setup step. also adds a Add model button on the provider page after a provider is setup This adds a setup notice rendered as a no dismissable modalthat explains both a provider and a model must be configured before agents can be used. The notice conditionally links to `/agents/settings/providers` and/or `/agents/settings/models` depending on which is missing, and only renders after the relevant config queries succeed (no flash during loading).
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
chatMessagesForInfiniteScroll,
|
||||
chatModelConfigs,
|
||||
chatModels,
|
||||
chatProviderConfigs,
|
||||
createChatMessage,
|
||||
deleteChatQueuedMessage,
|
||||
editChatMessage,
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
} from "./AgentChatPageView";
|
||||
import type { AgentsOutletContext } from "./AgentsPage";
|
||||
import type { ChatMessageInputRef } from "./components/AgentChatInput";
|
||||
import { AgentSetupNotice } from "./components/AgentSetupNotice";
|
||||
import { normalizeChatErrorPayload } from "./components/ChatConversation/chatError";
|
||||
import {
|
||||
getParentChatID,
|
||||
@@ -80,6 +82,7 @@ import { getModelSelectorHelp } from "./components/ModelSelectorHelp";
|
||||
import { useGitWatcher } from "./hooks/useGitWatcher";
|
||||
import { type ParsedDraft, parseStoredDraft } from "./utils/draftStorage";
|
||||
import {
|
||||
countConfiguredProviderConfigs,
|
||||
getModelOptionsFromConfigs,
|
||||
getModelSelectorPlaceholder,
|
||||
hasConfiguredModelsInCatalog,
|
||||
@@ -645,7 +648,7 @@ const AgentChatPage: FC = () => {
|
||||
scrollContainerRef,
|
||||
} = useOutletContext<AgentsOutletContext>();
|
||||
const queryClient = useQueryClient();
|
||||
const { user: currentUser } = useAuthenticated();
|
||||
const { permissions, user: currentUser } = useAuthenticated();
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const scrollToBottomRef = useRef<(() => void) | null>(null);
|
||||
const chatInputRef = useRef<ChatMessageInputRef | null>(null);
|
||||
@@ -698,6 +701,10 @@ const AgentChatPage: FC = () => {
|
||||
|
||||
const chatModelsQuery = useQuery(chatModels());
|
||||
const chatModelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const chatProviderConfigsQuery = useQuery({
|
||||
...chatProviderConfigs(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const userThresholdsQuery = useQuery(userCompactionThresholds());
|
||||
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
|
||||
const userDebugLoggingQuery = useQuery(userChatDebugLogging());
|
||||
@@ -731,6 +738,19 @@ const AgentChatPage: FC = () => {
|
||||
chatModelsQuery.data,
|
||||
);
|
||||
const modelConfigs = chatModelConfigsQuery.data ?? [];
|
||||
const providerCount =
|
||||
permissions.editDeploymentConfig &&
|
||||
chatProviderConfigsQuery.isSuccess &&
|
||||
chatModelsQuery.isSuccess
|
||||
? countConfiguredProviderConfigs(
|
||||
chatProviderConfigsQuery.data,
|
||||
chatModelsQuery.data,
|
||||
)
|
||||
: undefined;
|
||||
const modelCount =
|
||||
chatModelConfigsQuery.isSuccess && chatModelsQuery.isSuccess
|
||||
? modelOptions.length
|
||||
: undefined;
|
||||
const modelCatalog = chatModelsQuery.data;
|
||||
const isModelCatalogLoading = chatModelsQuery.isLoading;
|
||||
|
||||
@@ -1035,6 +1055,12 @@ const AgentChatPage: FC = () => {
|
||||
hasConfiguredModels,
|
||||
hasUserFixableModelProviders,
|
||||
});
|
||||
const agentSetupNotice =
|
||||
providerCount !== undefined &&
|
||||
modelCount !== undefined &&
|
||||
(providerCount === 0 || modelCount === 0) ? (
|
||||
<AgentSetupNotice providerCount={providerCount} modelCount={modelCount} />
|
||||
) : undefined;
|
||||
const isSubmissionPending =
|
||||
isSendPending || isEditPending || isInterruptPending;
|
||||
const isChatSettingsPending =
|
||||
@@ -1483,6 +1509,7 @@ const AgentChatPage: FC = () => {
|
||||
modelOptions={modelOptions}
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
modelSelectorHelp={modelSelectorHelp}
|
||||
agentSetupNotice={agentSetupNotice}
|
||||
hasModelOptions={hasModelOptions}
|
||||
isModelCatalogLoading={isModelCatalogLoading}
|
||||
planModeEnabled={planModeEnabled}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AgentChatPageNotFoundView,
|
||||
AgentChatPageView,
|
||||
} from "./AgentChatPageView";
|
||||
import { AgentSetupNotice } from "./components/AgentSetupNotice";
|
||||
import {
|
||||
createChatStore,
|
||||
useChatSelector,
|
||||
@@ -442,6 +443,96 @@ export const NoModelOptions: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const MissingProviderAndModelSetup: Story = {
|
||||
render: () => (
|
||||
<StoryAgentChatPageView
|
||||
agentSetupNotice={<AgentSetupNotice providerCount={0} modelCount={0} />}
|
||||
hasModelOptions={false}
|
||||
modelOptions={[]}
|
||||
isInputDisabled
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const dialog = within(
|
||||
body.getByRole("dialog", { name: "Welcome to Coder Agents" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dialog.getByText("Welcome to Coder Agents")).toBeVisible();
|
||||
});
|
||||
expect(dialog.getByText("Connect a chat provider")).toBeVisible();
|
||||
expect(dialog.getByText("Add a chat model")).toBeVisible();
|
||||
expect(dialog.queryByLabelText("Complete")).not.toBeInTheDocument();
|
||||
expect(
|
||||
dialog.getByRole("link", { name: "Go to Providers" }),
|
||||
).toHaveAttribute("href", "/agents/settings/providers");
|
||||
expect(dialog.getByRole("link", { name: "Go to Models" })).toHaveAttribute(
|
||||
"href",
|
||||
"/agents/settings/models",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingModelSetup: Story = {
|
||||
render: () => (
|
||||
<StoryAgentChatPageView
|
||||
agentSetupNotice={<AgentSetupNotice providerCount={1} modelCount={0} />}
|
||||
hasModelOptions={false}
|
||||
modelOptions={[]}
|
||||
isInputDisabled
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const dialog = within(
|
||||
body.getByRole("dialog", { name: "Welcome to Coder Agents" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dialog.getByText("Welcome to Coder Agents")).toBeVisible();
|
||||
});
|
||||
expect(dialog.getByText("Connect a chat provider")).toBeVisible();
|
||||
expect(dialog.getByText("Add a chat model")).toBeVisible();
|
||||
expect(dialog.getAllByLabelText("Complete")).toHaveLength(1);
|
||||
expect(
|
||||
dialog.getByRole("link", { name: "Go to Providers" }),
|
||||
).toHaveAttribute("href", "/agents/settings/providers");
|
||||
expect(dialog.getByRole("link", { name: "Go to Models" })).toHaveAttribute(
|
||||
"href",
|
||||
"/agents/settings/models",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingProviderSetup: Story = {
|
||||
render: () => (
|
||||
<StoryAgentChatPageView
|
||||
agentSetupNotice={<AgentSetupNotice providerCount={0} modelCount={1} />}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const dialog = within(
|
||||
body.getByRole("dialog", { name: "Welcome to Coder Agents" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dialog.getByText("Welcome to Coder Agents")).toBeVisible();
|
||||
});
|
||||
expect(dialog.getByText("Connect a chat provider")).toBeVisible();
|
||||
expect(dialog.getByText("Add a chat model")).toBeVisible();
|
||||
expect(dialog.getAllByLabelText("Complete")).toHaveLength(1);
|
||||
expect(
|
||||
dialog.getByRole("link", { name: "Go to Providers" }),
|
||||
).toHaveAttribute("href", "/agents/settings/providers");
|
||||
expect(dialog.getByRole("link", { name: "Go to Models" })).toHaveAttribute(
|
||||
"href",
|
||||
"/agents/settings/models",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithWorkspace: Story = {
|
||||
render: () => (
|
||||
<StoryAgentChatPageView
|
||||
|
||||
@@ -104,6 +104,7 @@ interface AgentChatPageViewProps {
|
||||
modelOptions: readonly ModelSelectorOption[];
|
||||
modelSelectorPlaceholder: string;
|
||||
modelSelectorHelp?: ReactNode;
|
||||
agentSetupNotice?: ReactNode;
|
||||
hasModelOptions: boolean;
|
||||
isModelCatalogLoading?: boolean;
|
||||
planModeEnabled?: boolean;
|
||||
@@ -200,6 +201,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
modelOptions,
|
||||
modelSelectorPlaceholder,
|
||||
modelSelectorHelp,
|
||||
agentSetupNotice,
|
||||
hasModelOptions,
|
||||
isModelCatalogLoading = false,
|
||||
planModeEnabled,
|
||||
@@ -534,6 +536,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
modelOptions={modelOptions}
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
modelSelectorHelp={modelSelectorHelp}
|
||||
agentSetupNotice={agentSetupNotice}
|
||||
planModeEnabled={planModeEnabled}
|
||||
onPlanModeToggle={onPlanModeToggle}
|
||||
isModelCatalogLoading={isModelCatalogLoading}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getErrorMessage } from "#/api/errors";
|
||||
import {
|
||||
chatModelConfigs,
|
||||
chatModels,
|
||||
chatProviderConfigs,
|
||||
createChat,
|
||||
mcpServerConfigs,
|
||||
userChatPersonalModelOverrides,
|
||||
@@ -19,10 +20,14 @@ import {
|
||||
type CreateChatOptions,
|
||||
} from "./components/AgentCreateForm";
|
||||
import { AgentPageHeader } from "./components/AgentPageHeader";
|
||||
import { AgentSetupNotice } from "./components/AgentSetupNotice";
|
||||
import { ChimeButton } from "./components/ChimeButton";
|
||||
import { WebPushButton } from "./components/WebPushButton";
|
||||
import { getChimeEnabled, setChimeEnabled } from "./utils/chime";
|
||||
import { getModelOptionsFromConfigs } from "./utils/modelOptions";
|
||||
import {
|
||||
countConfiguredProviderConfigs,
|
||||
getModelOptionsFromConfigs,
|
||||
} from "./utils/modelOptions";
|
||||
import { buildAgentChatPath } from "./utils/navigation";
|
||||
|
||||
const lastModelConfigIDStorageKey = "agents.last-model-config-id";
|
||||
@@ -34,6 +39,10 @@ const AgentCreatePage: FC = () => {
|
||||
|
||||
const chatModelsQuery = useQuery(chatModels());
|
||||
const chatModelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const chatProviderConfigsQuery = useQuery({
|
||||
...chatProviderConfigs(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const personalModelOverridesQuery = useQuery(
|
||||
userChatPersonalModelOverrides(),
|
||||
);
|
||||
@@ -47,6 +56,25 @@ const AgentCreatePage: FC = () => {
|
||||
chatModelConfigsQuery.data,
|
||||
chatModelsQuery.data,
|
||||
);
|
||||
const providerCount =
|
||||
permissions.editDeploymentConfig &&
|
||||
chatProviderConfigsQuery.isSuccess &&
|
||||
chatModelsQuery.isSuccess
|
||||
? countConfiguredProviderConfigs(
|
||||
chatProviderConfigsQuery.data,
|
||||
chatModelsQuery.data,
|
||||
)
|
||||
: undefined;
|
||||
const modelCount =
|
||||
chatModelConfigsQuery.isSuccess && chatModelsQuery.isSuccess
|
||||
? catalogModelOptions.length
|
||||
: undefined;
|
||||
const agentSetupNotice =
|
||||
providerCount !== undefined &&
|
||||
modelCount !== undefined &&
|
||||
(providerCount === 0 || modelCount === 0) ? (
|
||||
<AgentSetupNotice providerCount={providerCount} modelCount={modelCount} />
|
||||
) : undefined;
|
||||
|
||||
const handleCreateChat = async ({
|
||||
message,
|
||||
@@ -125,6 +153,7 @@ const AgentCreatePage: FC = () => {
|
||||
canCreateChat={permissions.createChat}
|
||||
modelCatalog={chatModelsQuery.data}
|
||||
modelOptions={catalogModelOptions}
|
||||
agentSetupNotice={agentSetupNotice}
|
||||
modelConfigs={chatModelConfigsQuery.data ?? []}
|
||||
isModelCatalogLoading={chatModelsQuery.isLoading}
|
||||
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
|
||||
|
||||
@@ -21,7 +21,10 @@ const AgentSettingsModelsPage: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Queries.
|
||||
const providerConfigsQuery = useQuery(chatProviderConfigs());
|
||||
const providerConfigsQuery = useQuery({
|
||||
...chatProviderConfigs(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const modelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const modelCatalogQuery = useQuery(chatModels());
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ const AgentSettingsProvidersPage: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Queries.
|
||||
const providerConfigsQuery = useQuery(chatProviderConfigs());
|
||||
const providerConfigsQuery = useQuery({
|
||||
...chatProviderConfigs(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const modelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const modelCatalogQuery = useQuery(chatModels());
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "#/testHelpers/entities";
|
||||
import { withDashboardProvider } from "#/testHelpers/storybook";
|
||||
import { AgentCreateForm } from "./AgentCreateForm";
|
||||
import { AgentSetupNotice } from "./AgentSetupNotice";
|
||||
|
||||
// Query key used by permittedOrganizations() in the form.
|
||||
const permittedOrgsKey = [
|
||||
@@ -466,6 +467,37 @@ export const NoModelsConfigured: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingProviderAndModelSetup: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
agentSetupNotice: <AgentSetupNotice providerCount={0} modelCount={0} />,
|
||||
modelCatalog: { providers: [] },
|
||||
modelOptions: [],
|
||||
isModelCatalogLoading: false,
|
||||
isModelConfigsLoading: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const dialog = within(
|
||||
body.getByRole("dialog", { name: "Welcome to Coder Agents" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dialog.getByText("Welcome to Coder Agents")).toBeVisible();
|
||||
});
|
||||
expect(dialog.getByText("Connect a chat provider")).toBeVisible();
|
||||
expect(dialog.getByText("Add a chat model")).toBeVisible();
|
||||
expect(dialog.queryByLabelText("Complete")).not.toBeInTheDocument();
|
||||
expect(
|
||||
dialog.getByRole("link", { name: "Go to Providers" }),
|
||||
).toHaveAttribute("href", "/agents/settings/providers");
|
||||
expect(dialog.getByRole("link", { name: "Go to Models" })).toHaveAttribute(
|
||||
"href",
|
||||
"/agents/settings/models",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const PreservesAttachmentsOnFailedSend: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { type FC, useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
@@ -122,6 +129,7 @@ interface AgentCreateFormProps {
|
||||
canCreateChat: boolean;
|
||||
modelCatalog: TypesGen.ChatModelsResponse | null | undefined;
|
||||
modelOptions: readonly ChatModelOption[];
|
||||
agentSetupNotice?: ReactNode;
|
||||
isModelCatalogLoading: boolean;
|
||||
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
||||
isModelConfigsLoading: boolean;
|
||||
@@ -142,6 +150,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
canCreateChat,
|
||||
modelCatalog,
|
||||
modelOptions,
|
||||
agentSetupNotice,
|
||||
modelConfigs,
|
||||
isModelCatalogLoading,
|
||||
isModelConfigsLoading,
|
||||
@@ -493,6 +502,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{agentSetupNotice}
|
||||
<AgentChatInput
|
||||
onSend={handleSendWithAttachments}
|
||||
placeholder="Ask Coder to build, fix bugs, or explore your project..."
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
|
||||
const agentSetupLinkClassName =
|
||||
"text-content-link transition-colors hover:text-content-link/80";
|
||||
|
||||
interface AgentSetupNoticeProps {
|
||||
providerCount: number;
|
||||
modelCount: number;
|
||||
}
|
||||
|
||||
export const AgentSetupNotice: FC<AgentSetupNoticeProps> = ({
|
||||
providerCount,
|
||||
modelCount,
|
||||
}) => {
|
||||
const hasProvider = providerCount > 0;
|
||||
const hasModel = modelCount > 0;
|
||||
|
||||
if (hasProvider && hasModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
className="w-fit max-w-[calc(100vw-2rem)] gap-8"
|
||||
onEscapeKeyDown={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="space-y-5 text-left sm:text-left">
|
||||
<DialogTitle className="text-xl">Welcome to Coder Agents</DialogTitle>
|
||||
<DialogDescription className="text-base">
|
||||
Complete 2 quick steps to get started.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 text-base text-content-secondary">
|
||||
<AgentSetupStep
|
||||
isComplete={hasProvider}
|
||||
stepNumber={1}
|
||||
label="Connect a chat provider"
|
||||
linkTo="/agents/settings/providers"
|
||||
linkText="Go to Providers"
|
||||
/>
|
||||
<AgentSetupStep
|
||||
isComplete={hasModel}
|
||||
stepNumber={2}
|
||||
label="Add a chat model"
|
||||
linkTo="/agents/settings/models"
|
||||
linkText="Go to Models"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface AgentSetupStepProps {
|
||||
isComplete: boolean;
|
||||
stepNumber: number;
|
||||
label: string;
|
||||
linkTo: string;
|
||||
linkText: string;
|
||||
}
|
||||
|
||||
const AgentSetupStep: FC<AgentSetupStepProps> = ({
|
||||
isComplete,
|
||||
stepNumber,
|
||||
label,
|
||||
linkTo,
|
||||
linkText,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="flex w-7 shrink-0 justify-end text-content-secondary">
|
||||
{isComplete ? (
|
||||
<CheckIcon
|
||||
aria-label="Complete"
|
||||
className="h-5 w-5 text-content-success"
|
||||
/>
|
||||
) : (
|
||||
`${stepNumber}.`
|
||||
)}
|
||||
</span>
|
||||
<span className="text-content-secondary">{label}</span>
|
||||
<Link to={linkTo} className={agentSetupLinkClassName}>
|
||||
{linkText}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -150,6 +150,21 @@ export const HidesPricingWarningForExplicitZeroPricing: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const LinksToProvidersFromEmptyState: Story = {
|
||||
args: {
|
||||
providerStates: [providerStateWithoutAPIKey],
|
||||
modelConfigs: [],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const providerLink = canvas.getByRole("link", { name: /provider/i });
|
||||
|
||||
await expect(canvas.getByText("No models configured yet.")).toBeVisible();
|
||||
await expect(providerLink).toBeVisible();
|
||||
expect(providerLink).toHaveAttribute("href", "/agents/settings/providers");
|
||||
},
|
||||
};
|
||||
|
||||
export const ShowsExplicitRowActions: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
import { Link, useLocation, useSearchParams } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
@@ -85,16 +85,8 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
onDeleteModel,
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Whether the current form entry was pushed by an in-app click
|
||||
// (as opposed to a direct-entry URL like a bookmark or shared link).
|
||||
// When true, navigate(-1) is safe; otherwise we fall back to
|
||||
// clearing params with replace to avoid leaving the app.
|
||||
const canGoBack =
|
||||
(location.state as { pushed?: boolean } | null)?.pushed === true;
|
||||
|
||||
// Derive the current view from URL search params so that
|
||||
// browser back/forward navigation works as expected.
|
||||
const view: ModelView = (() => {
|
||||
@@ -140,23 +132,15 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
// Navigate back to the list after a destructive or
|
||||
// completion action (create/delete) where the form entry
|
||||
// is stale. Uses navigate(-1) when safe, otherwise clears
|
||||
// the params with replace.
|
||||
const exitModelView = () => {
|
||||
if (canGoBack) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
clearModelViewParams(next);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
clearModelViewParams(next);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
// When the form is open it takes over the full panel.
|
||||
@@ -277,7 +261,14 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
{addableProviders.length > 0 && addButton}
|
||||
{addableProviders.length === 0 && (
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Connect a provider first to add models.
|
||||
Connect a{" "}
|
||||
<Link
|
||||
to="/agents/settings/providers"
|
||||
className="underline transition-colors hover:text-content-primary"
|
||||
>
|
||||
provider
|
||||
</Link>{" "}
|
||||
first to add models.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useId,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
@@ -55,6 +56,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
onDeleteProvider,
|
||||
onBack,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { provider, providerConfig, baseURL, isEnvPreset } = providerState;
|
||||
|
||||
const apiKeyInputId = useId();
|
||||
@@ -169,6 +171,17 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
isDirty &&
|
||||
hasCredentialSource &&
|
||||
(!requiresAPIKey || hasTypedAPIKey);
|
||||
const canAddModel =
|
||||
Boolean(providerConfig) &&
|
||||
(providerState.hasEffectiveAPIKey ||
|
||||
providerConfig?.allow_user_api_key === true);
|
||||
|
||||
const handleAddModel = () => {
|
||||
const params = new URLSearchParams({ newModel: provider });
|
||||
navigate(`/agents/settings/models?${params.toString()}`, {
|
||||
state: { pushed: true },
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -432,12 +445,24 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button size="lg" type="submit" disabled={!canSave}>
|
||||
{isProviderMutationPending && (
|
||||
<Spinner className="h-4 w-4" loading />
|
||||
<div className="flex items-center gap-2">
|
||||
{canAddModel && (
|
||||
<Button size="lg" type="button" onClick={handleAddModel}>
|
||||
Add model
|
||||
</Button>
|
||||
)}
|
||||
{providerConfig ? "Save changes" : "Create provider config"}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
variant={canAddModel ? "outline" : undefined}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{isProviderMutationPending && (
|
||||
<Spinner className="h-4 w-4" loading />
|
||||
)}
|
||||
{providerConfig ? "Save changes" : "Create provider config"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -161,6 +161,7 @@ interface ChatPageInputProps {
|
||||
modelOptions: readonly ModelSelectorOption[];
|
||||
modelSelectorPlaceholder: string;
|
||||
modelSelectorHelp?: ReactNode;
|
||||
agentSetupNotice?: ReactNode;
|
||||
planModeEnabled?: boolean;
|
||||
onPlanModeToggle?: (enabled: boolean) => void;
|
||||
isModelCatalogLoading?: boolean;
|
||||
@@ -228,6 +229,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
modelOptions,
|
||||
modelSelectorPlaceholder,
|
||||
modelSelectorHelp,
|
||||
agentSetupNotice,
|
||||
planModeEnabled,
|
||||
onPlanModeToggle,
|
||||
isModelCatalogLoading = false,
|
||||
@@ -393,6 +395,8 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
const isStreaming =
|
||||
hasStreamState || chatStatus === "running" || chatStatus === "pending";
|
||||
|
||||
const [chatFullWidth] = useChatFullWidth();
|
||||
|
||||
const inputElement = (
|
||||
<AgentChatInput
|
||||
onSend={(message) => {
|
||||
@@ -493,12 +497,19 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
/>
|
||||
);
|
||||
|
||||
if (!modelSelectorHelp) {
|
||||
if (!agentSetupNotice && !modelSelectorHelp) {
|
||||
return inputElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{agentSetupNotice && (
|
||||
<div
|
||||
className={cn("mx-auto w-full pb-2", chatWidthClass(chatFullWidth))}
|
||||
>
|
||||
{agentSetupNotice}
|
||||
</div>
|
||||
)}
|
||||
{inputElement}
|
||||
{modelSelectorHelp && (
|
||||
<div className="px-3 pt-1 text-2xs text-content-secondary">
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatModelConfig, ChatModelsResponse } from "#/api/typesGenerated";
|
||||
import type {
|
||||
ChatModelConfig,
|
||||
ChatModelsResponse,
|
||||
ChatProviderConfig,
|
||||
} from "#/api/typesGenerated";
|
||||
import {
|
||||
countConfiguredProviderConfigs,
|
||||
formatProviderLabel,
|
||||
getModelOptionsFromConfigs,
|
||||
getModelSelectorPlaceholder,
|
||||
getNormalizedModelRef,
|
||||
hasConfiguredProviderConfigs,
|
||||
hasUserFixableProviders,
|
||||
resolveModelOptionId,
|
||||
} from "./modelOptions";
|
||||
@@ -48,6 +54,25 @@ const createCatalog = (
|
||||
providers,
|
||||
});
|
||||
|
||||
const createProviderConfig = (
|
||||
overrides: Pick<ChatProviderConfig, "provider" | "source"> &
|
||||
Partial<ChatProviderConfig>,
|
||||
): ChatProviderConfig => {
|
||||
const { provider, source, ...rest } = overrides;
|
||||
return {
|
||||
id: "provider-config-1",
|
||||
provider,
|
||||
display_name: provider,
|
||||
enabled: true,
|
||||
has_api_key: false,
|
||||
central_api_key_enabled: true,
|
||||
allow_user_api_key: false,
|
||||
allow_central_api_key_fallback: false,
|
||||
source,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
describe("getNormalizedModelRef", () => {
|
||||
it("returns empty strings for malformed values", () => {
|
||||
expect(getNormalizedModelRef({ provider: undefined, model: null })).toEqual(
|
||||
@@ -90,6 +115,112 @@ describe("hasUserFixableProviders", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasConfiguredProviderConfigs", () => {
|
||||
it("ignores supported provider placeholders", () => {
|
||||
const catalog = createCatalog([
|
||||
{ provider: "openai", available: true, models: [] },
|
||||
]);
|
||||
|
||||
expect(
|
||||
hasConfiguredProviderConfigs(
|
||||
[createProviderConfig({ provider: "openai", source: "supported" })],
|
||||
catalog,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for database and env preset provider configs", () => {
|
||||
const catalog = createCatalog([
|
||||
{ provider: "openai", available: true, models: [] },
|
||||
]);
|
||||
|
||||
expect(
|
||||
hasConfiguredProviderConfigs(
|
||||
[createProviderConfig({ provider: "openai", source: "database" })],
|
||||
catalog,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasConfiguredProviderConfigs(
|
||||
[createProviderConfig({ provider: "openai", source: "env_preset" })],
|
||||
catalog,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes disabled and unavailable provider configs", () => {
|
||||
const catalog = createCatalog([
|
||||
{ provider: "openai", available: true, models: [] },
|
||||
{
|
||||
provider: "anthropic",
|
||||
available: false,
|
||||
unavailable_reason: "missing_api_key",
|
||||
models: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
hasConfiguredProviderConfigs(
|
||||
[
|
||||
createProviderConfig({
|
||||
provider: "openai",
|
||||
source: "database",
|
||||
enabled: false,
|
||||
}),
|
||||
createProviderConfig({
|
||||
provider: "anthropic",
|
||||
source: "database",
|
||||
}),
|
||||
],
|
||||
catalog,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countConfiguredProviderConfigs", () => {
|
||||
it("counts only enabled provider configs available in the catalog", () => {
|
||||
const catalog = createCatalog([
|
||||
{ provider: "openai", available: true, models: [] },
|
||||
{ provider: "anthropic", available: true, models: [] },
|
||||
{ provider: "google", available: true, models: [] },
|
||||
{ provider: "azure", available: true, models: [] },
|
||||
{
|
||||
provider: "bedrock",
|
||||
available: false,
|
||||
unavailable_reason: "missing_api_key",
|
||||
models: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
countConfiguredProviderConfigs(
|
||||
[
|
||||
createProviderConfig({ provider: "openai", source: "database" }),
|
||||
createProviderConfig({ provider: "anthropic", source: "env_preset" }),
|
||||
createProviderConfig({ provider: "google", source: "supported" }),
|
||||
createProviderConfig({
|
||||
provider: "azure",
|
||||
source: "database",
|
||||
enabled: false,
|
||||
}),
|
||||
createProviderConfig({ provider: "bedrock", source: "database" }),
|
||||
],
|
||||
catalog,
|
||||
),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("returns zero while provider availability is unknown", () => {
|
||||
expect(
|
||||
countConfiguredProviderConfigs(
|
||||
[createProviderConfig({ provider: "openai", source: "database" })],
|
||||
undefined,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatProviderLabel", () => {
|
||||
it("formats OpenAI compatible providers", () => {
|
||||
expect(formatProviderLabel("openai-compatible")).toBe("OpenAI-compatible");
|
||||
|
||||
@@ -39,6 +39,32 @@ type ModelOptionConfigLike =
|
||||
readonly context_limit?: unknown;
|
||||
});
|
||||
|
||||
export const hasConfiguredProviderConfigs = (
|
||||
providerConfigs: readonly TypesGen.ChatProviderConfig[] | null | undefined,
|
||||
catalog: TypesGen.ChatModelsResponse | null | undefined,
|
||||
): boolean => {
|
||||
return countConfiguredProviderConfigs(providerConfigs, catalog) > 0;
|
||||
};
|
||||
|
||||
export const countConfiguredProviderConfigs = (
|
||||
providerConfigs: readonly TypesGen.ChatProviderConfig[] | null | undefined,
|
||||
catalog: TypesGen.ChatModelsResponse | null | undefined,
|
||||
): number => {
|
||||
const availableProviders = getAvailableProviders(catalog);
|
||||
return (
|
||||
providerConfigs?.filter((providerConfig) => {
|
||||
if (
|
||||
providerConfig.source === "supported" ||
|
||||
providerConfig.enabled !== true
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const provider = asString(providerConfig.provider).trim().toLowerCase();
|
||||
return provider !== "" && availableProviders.has(provider);
|
||||
}).length ?? 0
|
||||
);
|
||||
};
|
||||
|
||||
export const getNormalizedModelRef = (
|
||||
value: ModelRefLike,
|
||||
): { readonly provider: string; readonly model: string } => {
|
||||
|
||||
Reference in New Issue
Block a user