574 lines
17 KiB
TypeScript
574 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import Link from "@/components/routing/app-link";
|
|
import { useParams, useRouter, useSearchParams } from "@/lib/routing/client-router";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Card, CardContent } from "@kandev/ui/card";
|
|
import { Separator } from "@kandev/ui/separator";
|
|
import { useToast } from "@/components/toast-provider";
|
|
import { useAgentSaveContributor } from "./agent-save-contributor";
|
|
import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin";
|
|
import type {
|
|
Agent,
|
|
AgentDiscovery,
|
|
AvailableAgent,
|
|
PermissionSetting,
|
|
ModelConfig,
|
|
} from "@/lib/types/http";
|
|
import { buildDefaultPermissions } from "@/lib/agent-permissions";
|
|
import { seedDefaultCLIFlags } from "@/lib/cli-flags";
|
|
import { generateUUID } from "@/lib/utils";
|
|
import { agentProfileId as toAgentProfileId } from "@/lib/types/ids";
|
|
import type { AgentProfileKind } from "@/lib/types/agent-profile";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
|
|
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
|
|
import { useSecrets } from "@/hooks/domains/settings/use-secrets";
|
|
import { deleteAgentAction } from "@/app/actions/agents";
|
|
import { SettingsRedirect } from "@/src/settings-route-helpers";
|
|
import { saveNewAgent, saveExistingAgent, isProfileDirty } from "./agent-save-helpers";
|
|
import type { DraftProfile, DraftAgent } from "./agent-save-helpers";
|
|
import { AgentHeader, ProfilesCard } from "./agent-setup-parts";
|
|
import { isHandledApiError } from "@/lib/api/client";
|
|
|
|
const defaultMcpConfig: NonNullable<DraftProfile["mcp_config"]> = {
|
|
enabled: false,
|
|
servers: '{\n "mcpServers": {}\n}',
|
|
dirty: false,
|
|
error: null,
|
|
};
|
|
|
|
type AgentSetupFormProps = {
|
|
initialAgent: DraftAgent;
|
|
savedAgent: Agent | null;
|
|
discoveryAgent: AgentDiscovery | undefined;
|
|
onToastError: (error: unknown) => void;
|
|
isCreateMode?: boolean;
|
|
};
|
|
|
|
const createDraftProfile = (
|
|
agentId: string,
|
|
agentDisplayName: string,
|
|
defaultModel: string,
|
|
permissionSettings?: Record<string, PermissionSetting>,
|
|
kind: AgentProfileKind = "concrete",
|
|
): DraftProfile => ({
|
|
id: toAgentProfileId(`draft-${generateUUID()}`),
|
|
kind,
|
|
dynamic: kind === "dynamic" ? { version: 1, candidates: [] } : undefined,
|
|
agentId,
|
|
name: "",
|
|
agentDisplayName,
|
|
model: defaultModel,
|
|
...buildDefaultPermissions(permissionSettings ?? {}),
|
|
cliPassthrough: false,
|
|
cursorMcpAuthEnabled: true,
|
|
cliFlags: seedDefaultCLIFlags(permissionSettings ?? {}),
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
isNew: true,
|
|
mcp_config: { ...defaultMcpConfig },
|
|
});
|
|
|
|
const cloneAgent = (agent: Agent): DraftAgent => ({
|
|
...agent,
|
|
workspace_id: agent.workspace_id ?? null,
|
|
mcp_config_path: agent.mcp_config_path ?? "",
|
|
profiles: agent.profiles.map((profile) => ({ ...profile })) as DraftProfile[],
|
|
});
|
|
|
|
const ensureProfiles = (
|
|
agent: DraftAgent,
|
|
agentDisplayName: string,
|
|
defaultModel: string,
|
|
permissionSettings?: Record<string, PermissionSetting>,
|
|
): DraftAgent => {
|
|
if (agent.profiles.length > 0) return agent;
|
|
return {
|
|
...agent,
|
|
profiles: [
|
|
createDraftProfile(
|
|
agent.id,
|
|
agentDisplayName,
|
|
defaultModel,
|
|
permissionSettings,
|
|
agent.name === "dynamic" ? "dynamic" : "concrete",
|
|
),
|
|
],
|
|
};
|
|
};
|
|
|
|
function useAgentFormState(
|
|
initialAgent: DraftAgent,
|
|
savedAgent: Agent | null,
|
|
availableAgents: AvailableAgent[],
|
|
) {
|
|
const [draftAgent, setDraftAgent] = useState<DraftAgent>(initialAgent);
|
|
const [_saveStatus, setSaveStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
|
|
|
const resolveDisplayName = (name: string) =>
|
|
availableAgents.find((item: AvailableAgent) => item.name === name)?.display_name ?? "";
|
|
|
|
const currentAvailableAgent = useMemo(() => {
|
|
return availableAgents.find((item: AvailableAgent) => item.name === draftAgent.name) ?? null;
|
|
}, [availableAgents, draftAgent.name]);
|
|
|
|
const currentAgentModelConfig = useMemo(() => {
|
|
return (
|
|
currentAvailableAgent?.model_config ?? {
|
|
default_model: "",
|
|
available_models: [],
|
|
supports_dynamic_models: false,
|
|
}
|
|
);
|
|
}, [currentAvailableAgent]);
|
|
|
|
const permissionSettings = useMemo(
|
|
() => currentAvailableAgent?.permission_settings ?? {},
|
|
[currentAvailableAgent],
|
|
);
|
|
const passthroughConfig = useMemo(
|
|
() => currentAvailableAgent?.passthrough_config ?? null,
|
|
[currentAvailableAgent],
|
|
);
|
|
|
|
const hasInvalidMcpConfig = useMemo(() => {
|
|
return draftAgent.profiles.some((profile) => Boolean(profile.mcp_config?.error));
|
|
}, [draftAgent.profiles]);
|
|
|
|
const isAgentDirty = useMemo(() => {
|
|
if (!draftAgent || !savedAgent) return !savedAgent;
|
|
if ((draftAgent.workspace_id ?? null) !== (savedAgent.workspace_id ?? null)) return true;
|
|
if ((draftAgent.mcp_config_path ?? "") !== (savedAgent.mcp_config_path ?? "")) return true;
|
|
if (draftAgent.profiles.length !== savedAgent.profiles.length) return true;
|
|
const savedProfiles = new Map(savedAgent.profiles.map((p) => [p.id, p]));
|
|
for (const profile of draftAgent.profiles) {
|
|
if (profile.mcp_config?.dirty) return true;
|
|
if (!savedProfiles.has(profile.id) || isProfileDirty(profile, savedProfiles.get(profile.id)))
|
|
return true;
|
|
}
|
|
return false;
|
|
}, [draftAgent, savedAgent]);
|
|
|
|
return {
|
|
draftAgent,
|
|
setDraftAgent,
|
|
setSaveStatus,
|
|
resolveDisplayName,
|
|
currentAgentModelConfig,
|
|
permissionSettings,
|
|
passthroughConfig,
|
|
hasInvalidMcpConfig,
|
|
isAgentDirty,
|
|
};
|
|
}
|
|
|
|
function useAgentStoreSync() {
|
|
const settingsAgents = useAppStore((state) => state.settingsAgents.items);
|
|
const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
|
|
const setAgentProfiles = useAppStore((state) => state.setAgentProfiles);
|
|
|
|
const syncAgentsToStore = (nextAgents: Agent[]) => {
|
|
setSettingsAgents(nextAgents);
|
|
setAgentProfiles(
|
|
nextAgents.flatMap((agent) =>
|
|
agent.profiles.map((profile) => toAgentProfileOption(agent, profile)),
|
|
),
|
|
);
|
|
};
|
|
|
|
const upsertAgent = (agent: Agent) => {
|
|
const exists = settingsAgents.some((item: Agent) => item.id === agent.id);
|
|
syncAgentsToStore(
|
|
exists
|
|
? settingsAgents.map((item: Agent) => (item.id === agent.id ? agent : item))
|
|
: [...settingsAgents, agent],
|
|
);
|
|
};
|
|
|
|
return { upsertAgent };
|
|
}
|
|
|
|
type AgentSaveHandlersProps = {
|
|
draftAgent: DraftAgent;
|
|
savedAgent: Agent | null;
|
|
isCreateMode: boolean;
|
|
hasInvalidMcpConfig: boolean;
|
|
currentAgentModelConfig: ModelConfig;
|
|
permissionSettings: Record<string, PermissionSetting>;
|
|
resolveDisplayName: (name: string) => string;
|
|
setDraftAgent: (agent: DraftAgent | ((current: DraftAgent) => DraftAgent)) => void;
|
|
setSaveStatus: (status: "idle" | "loading" | "success" | "error") => void;
|
|
upsertAgent: (agent: Agent) => void;
|
|
onToastError: (error: unknown) => void;
|
|
replaceRoute: (path: string) => void;
|
|
};
|
|
|
|
function useAgentSaveHandlers({
|
|
draftAgent,
|
|
savedAgent,
|
|
isCreateMode,
|
|
hasInvalidMcpConfig,
|
|
currentAgentModelConfig,
|
|
permissionSettings,
|
|
resolveDisplayName,
|
|
setDraftAgent,
|
|
setSaveStatus,
|
|
upsertAgent,
|
|
onToastError,
|
|
replaceRoute,
|
|
}: AgentSaveHandlersProps) {
|
|
const { t } = useTranslation();
|
|
const handleSave = async () => {
|
|
if (draftAgent.profiles.some((p) => !p.name.trim())) {
|
|
onToastError(new Error(t("agents:profileNameRequired")));
|
|
return;
|
|
}
|
|
if (draftAgent.profiles.some((p) => p.kind !== "dynamic" && !p.model.trim())) {
|
|
onToastError(new Error(t("agents:modelRequiredForAllProfiles")));
|
|
return;
|
|
}
|
|
if (hasInvalidMcpConfig) {
|
|
onToastError(new Error(t("agents:fixInvalidMcpJson")));
|
|
return;
|
|
}
|
|
setSaveStatus("loading");
|
|
const callbacks = {
|
|
onToastError,
|
|
currentAgentModelConfig,
|
|
permissionSettings,
|
|
resolveDisplayName,
|
|
upsertAgent,
|
|
setDraftAgent,
|
|
ensureProfiles,
|
|
cloneAgent,
|
|
replaceRoute,
|
|
};
|
|
try {
|
|
let savedDraft: DraftAgent;
|
|
if (!savedAgent) {
|
|
savedDraft = await saveNewAgent(draftAgent, callbacks);
|
|
} else {
|
|
savedDraft = await saveExistingAgent(draftAgent, savedAgent, isCreateMode, callbacks);
|
|
}
|
|
setSaveStatus("success");
|
|
return savedDraft;
|
|
} catch (error) {
|
|
setSaveStatus("error");
|
|
onToastError(error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const handleDeleteAgent = async () => {
|
|
if (!savedAgent) return;
|
|
try {
|
|
await deleteAgentAction(savedAgent.id);
|
|
replaceRoute("/settings/agents");
|
|
} catch (err) {
|
|
onToastError(err);
|
|
}
|
|
};
|
|
|
|
return { handleSave, handleDeleteAgent };
|
|
}
|
|
|
|
function useProfileHandlers(
|
|
setDraftAgent: (fn: (current: DraftAgent) => DraftAgent) => void,
|
|
resolveDisplayName: (name: string) => string,
|
|
defaultModel: string,
|
|
permissionSettings: Record<string, PermissionSetting>,
|
|
) {
|
|
const [newProfileId, setNewProfileId] = useState<string | null>(null);
|
|
|
|
const handleAddProfile = () => {
|
|
const draftId = toAgentProfileId(`draft-${generateUUID()}`);
|
|
setDraftAgent((current) => ({
|
|
...current,
|
|
profiles: [
|
|
...current.profiles,
|
|
{
|
|
...createDraftProfile(
|
|
current.id,
|
|
resolveDisplayName(current.name),
|
|
defaultModel,
|
|
permissionSettings,
|
|
current.name === "dynamic" ? "dynamic" : "concrete",
|
|
),
|
|
id: draftId,
|
|
},
|
|
],
|
|
}));
|
|
setNewProfileId(draftId);
|
|
};
|
|
|
|
const handleRemoveProfile = (profileId: string) => {
|
|
setDraftAgent((current) => {
|
|
const remaining = current.profiles.filter((p) => p.id !== profileId);
|
|
return {
|
|
...current,
|
|
profiles:
|
|
remaining.length > 0
|
|
? remaining
|
|
: [
|
|
createDraftProfile(
|
|
current.id,
|
|
resolveDisplayName(current.name),
|
|
defaultModel,
|
|
permissionSettings,
|
|
current.name === "dynamic" ? "dynamic" : "concrete",
|
|
),
|
|
],
|
|
};
|
|
});
|
|
if (newProfileId === profileId) setNewProfileId(null);
|
|
};
|
|
|
|
const handleProfileChange = (profileId: string, patch: Partial<DraftProfile>) => {
|
|
setDraftAgent((current) => ({
|
|
...current,
|
|
profiles: current.profiles.map((p) => (p.id === profileId ? { ...p, ...patch } : p)),
|
|
}));
|
|
};
|
|
|
|
const handleProfileMcpChange = (
|
|
profileId: string,
|
|
patch: Partial<NonNullable<DraftProfile["mcp_config"]>>,
|
|
) => {
|
|
setDraftAgent((current) => ({
|
|
...current,
|
|
profiles: current.profiles.map((p) =>
|
|
p.id === profileId
|
|
? { ...p, mcp_config: { ...(p.mcp_config ?? defaultMcpConfig), ...patch } }
|
|
: p,
|
|
),
|
|
}));
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!newProfileId) return;
|
|
const target = document.getElementById(`profile-card-${newProfileId}`);
|
|
if (!target) return;
|
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
const timeout = setTimeout(() => setNewProfileId(null), 1200);
|
|
return () => clearTimeout(timeout);
|
|
}, [newProfileId]);
|
|
|
|
return {
|
|
newProfileId,
|
|
handleAddProfile,
|
|
handleRemoveProfile,
|
|
handleProfileChange,
|
|
handleProfileMcpChange,
|
|
};
|
|
}
|
|
|
|
function AgentSetupForm({
|
|
initialAgent,
|
|
savedAgent,
|
|
discoveryAgent,
|
|
onToastError,
|
|
isCreateMode = false,
|
|
}: AgentSetupFormProps) {
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const availableAgents = useAvailableAgents().items;
|
|
const { items: secrets } = useSecrets();
|
|
const { upsertAgent } = useAgentStoreSync();
|
|
|
|
const {
|
|
draftAgent,
|
|
setDraftAgent,
|
|
setSaveStatus,
|
|
resolveDisplayName,
|
|
currentAgentModelConfig,
|
|
permissionSettings,
|
|
passthroughConfig,
|
|
hasInvalidMcpConfig,
|
|
isAgentDirty,
|
|
} = useAgentFormState(initialAgent, savedAgent, availableAgents);
|
|
|
|
const {
|
|
newProfileId,
|
|
handleAddProfile,
|
|
handleRemoveProfile,
|
|
handleProfileChange,
|
|
handleProfileMcpChange,
|
|
} = useProfileHandlers(
|
|
setDraftAgent,
|
|
resolveDisplayName,
|
|
currentAgentModelConfig.default_model,
|
|
permissionSettings,
|
|
);
|
|
|
|
const { handleSave, handleDeleteAgent } = useAgentSaveHandlers({
|
|
draftAgent,
|
|
savedAgent,
|
|
isCreateMode,
|
|
hasInvalidMcpConfig,
|
|
currentAgentModelConfig,
|
|
permissionSettings,
|
|
resolveDisplayName,
|
|
setDraftAgent,
|
|
setSaveStatus,
|
|
upsertAgent,
|
|
onToastError,
|
|
replaceRoute: (path: string) => router.replace(path),
|
|
});
|
|
useAgentSaveContributor({
|
|
draftAgent,
|
|
savedAgent,
|
|
isCreateMode,
|
|
hasInvalidMcpConfig,
|
|
isAgentDirty,
|
|
handleSave,
|
|
t,
|
|
});
|
|
const canManage = useIsAdmin();
|
|
|
|
const displayName = draftAgent.profiles[0]?.agentDisplayName ?? draftAgent.name;
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<AgentHeader
|
|
displayName={displayName}
|
|
matchedPath={discoveryAgent?.matched_path}
|
|
isCreateMode={isCreateMode}
|
|
savedAgent={savedAgent}
|
|
showInstallationStatus={draftAgent.name !== "dynamic"}
|
|
onDelete={canManage ? handleDeleteAgent : undefined}
|
|
/>
|
|
<Separator />
|
|
<ProfilesCard
|
|
displayName={displayName}
|
|
isCreateMode={isCreateMode}
|
|
isAgentDirty={isAgentDirty}
|
|
draftAgent={draftAgent}
|
|
savedAgent={savedAgent}
|
|
newProfileId={newProfileId}
|
|
currentAgentModelConfig={currentAgentModelConfig}
|
|
permissionSettings={permissionSettings}
|
|
passthroughConfig={passthroughConfig}
|
|
secrets={secrets}
|
|
onAddProfile={handleAddProfile}
|
|
onProfileChange={handleProfileChange}
|
|
onProfileMcpChange={handleProfileMcpChange}
|
|
onRemoveProfile={handleRemoveProfile}
|
|
onToastError={onToastError}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AgentSetupPage() {
|
|
const { t } = useTranslation();
|
|
const { toast } = useToast();
|
|
const params = useParams();
|
|
const searchParams = useSearchParams();
|
|
const isCreateMode = searchParams.get("mode") === "create";
|
|
const agentKey = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId;
|
|
const decodedKey = decodeURIComponent(agentKey ?? "");
|
|
const discoveryAgents = useAppStore((state) => state.agentDiscovery.items);
|
|
const savedAgents = useAppStore((state) => state.settingsAgents.items);
|
|
const availableAgents = useAvailableAgents().items;
|
|
|
|
const discoveryAgent = useMemo(
|
|
() => discoveryAgents.find((a: AgentDiscovery) => a.name === decodedKey),
|
|
[decodedKey, discoveryAgents],
|
|
);
|
|
const savedAgent = useMemo(
|
|
() => savedAgents.find((a: Agent) => a.id === decodedKey || a.name === decodedKey) ?? null,
|
|
[decodedKey, savedAgents],
|
|
);
|
|
|
|
const initialAgent = useMemo(() => {
|
|
if (!decodedKey) return null;
|
|
const resolve = (name: string) =>
|
|
availableAgents.find((item: AvailableAgent) => item.name === name);
|
|
const dn = (name: string) => resolve(name)?.display_name ?? "";
|
|
const dm = (name: string) => resolve(name)?.model_config?.default_model ?? "";
|
|
const ps = (name: string) => resolve(name)?.permission_settings;
|
|
if (savedAgent) {
|
|
if (isCreateMode) {
|
|
return ensureProfiles(
|
|
{
|
|
...savedAgent,
|
|
workspace_id: savedAgent.workspace_id ?? null,
|
|
mcp_config_path: savedAgent.mcp_config_path ?? "",
|
|
profiles: [],
|
|
isNew: false,
|
|
},
|
|
dn(savedAgent.name),
|
|
dm(savedAgent.name),
|
|
ps(savedAgent.name),
|
|
);
|
|
}
|
|
return ensureProfiles(
|
|
cloneAgent(savedAgent),
|
|
dn(savedAgent.name),
|
|
dm(savedAgent.name),
|
|
ps(savedAgent.name),
|
|
);
|
|
}
|
|
if (discoveryAgent) {
|
|
const draft: DraftAgent = {
|
|
id: `draft-${generateUUID()}`,
|
|
name: discoveryAgent.name,
|
|
workspace_id: null,
|
|
supports_mcp: discoveryAgent.supports_mcp,
|
|
mcp_config_path: discoveryAgent.mcp_config_path ?? "",
|
|
profiles: [],
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
isNew: true,
|
|
};
|
|
return ensureProfiles(draft, dn(draft.name), dm(draft.name), ps(draft.name));
|
|
}
|
|
return null;
|
|
}, [decodedKey, discoveryAgent, savedAgent, availableAgents, isCreateMode]);
|
|
|
|
if (!initialAgent && discoveryAgents.length > 0) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="py-12 text-center">
|
|
<p className="text-sm text-muted-foreground">{t("agents:agentNotFound")}</p>
|
|
<Button className="mt-4" asChild>
|
|
<Link href="/settings/agents">{t("agents:backToAgents")}</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (!initialAgent) return null;
|
|
|
|
const handleToastError = (error: unknown) => {
|
|
if (isHandledApiError(error)) return;
|
|
toast({
|
|
title: t("agents:failedToSaveAgent"),
|
|
description: error instanceof Error ? error.message : t("agents:requestFailed"),
|
|
variant: "error",
|
|
});
|
|
};
|
|
|
|
// Saved agents have no page of their own — the Agents index shows each
|
|
// agent with its profiles inline. The route only serves creation (new agent
|
|
// from browse, or ?mode=create for a new profile).
|
|
if (savedAgent && !isCreateMode) {
|
|
return <SettingsRedirect to="/settings/agents" />;
|
|
}
|
|
|
|
return (
|
|
<AgentSetupForm
|
|
key={isCreateMode ? `create-${initialAgent.id}` : initialAgent.id}
|
|
initialAgent={initialAgent}
|
|
savedAgent={savedAgent}
|
|
discoveryAgent={discoveryAgent}
|
|
onToastError={handleToastError}
|
|
isCreateMode={isCreateMode}
|
|
/>
|
|
);
|
|
}
|