diff --git a/apps/web/src/app/(authenticated)/settings/agent-providers/page.tsx b/apps/web/src/app/(authenticated)/settings/agent-providers/page.tsx new file mode 100644 index 0000000..1d2b46b --- /dev/null +++ b/apps/web/src/app/(authenticated)/settings/agent-providers/page.tsx @@ -0,0 +1,528 @@ +"use client"; + +import { + useCallback, + useEffect, + useState, + type ChangeEvent, + type ReactElement, + type SyntheticEvent, +} from "react"; +import { Pencil, Trash2 } from "lucide-react"; +import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav"; +import { + createAgentProvider, + deleteAgentProvider, + fetchAgentProviders, + updateAgentProvider, + type AgentProviderConfig, + type CreateAgentProviderRequest, + type UpdateAgentProviderRequest, +} from "@/lib/api/agent-providers"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; + +interface ProviderFormData { + name: string; + provider: "openclaw"; + gatewayUrl: string; + apiToken: string; + isActive: boolean; +} + +const NAME_PATTERN = /^[a-zA-Z0-9-]+$/; + +const INITIAL_FORM: ProviderFormData = { + name: "", + provider: "openclaw", + gatewayUrl: "", + apiToken: "", + isActive: true, +}; + +function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + return fallback; +} + +function isValidHttpsUrl(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.protocol === "https:"; + } catch { + return false; + } +} + +function formatCreatedDate(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return "Unknown"; + } + + return new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }).format(parsed); +} + +function validateForm(form: ProviderFormData, isEditing: boolean): string | null { + const name = form.name.trim(); + if (name.length === 0) { + return "Name is required."; + } + + if (!NAME_PATTERN.test(name)) { + return "Name must contain only letters, numbers, and hyphens."; + } + + const gatewayUrl = form.gatewayUrl.trim(); + if (gatewayUrl.length === 0) { + return "Gateway URL is required."; + } + + if (!isValidHttpsUrl(gatewayUrl)) { + return "Gateway URL must be a valid https:// URL."; + } + + if (!isEditing && form.apiToken.trim().length === 0) { + return "API token is required when creating a provider."; + } + + return null; +} + +export default function AgentProvidersSettingsPage(): ReactElement { + const [providers, setProviders] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [editingProvider, setEditingProvider] = useState(null); + const [form, setForm] = useState(INITIAL_FORM); + const [formError, setFormError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const loadProviders = useCallback(async (showLoadingState: boolean): Promise => { + if (showLoadingState) { + setIsLoading(true); + } else { + setIsRefreshing(true); + } + + try { + const data = await fetchAgentProviders(); + setProviders(data); + setError(null); + } catch (loadError: unknown) { + setError(getErrorMessage(loadError, "Failed to load agent providers.")); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, []); + + useEffect(() => { + void loadProviders(true); + }, [loadProviders]); + + function openCreateDialog(): void { + setEditingProvider(null); + setForm(INITIAL_FORM); + setFormError(null); + setIsDialogOpen(true); + } + + function openEditDialog(provider: AgentProviderConfig): void { + setEditingProvider(provider); + setForm({ + name: provider.name, + provider: "openclaw", + gatewayUrl: provider.gatewayUrl, + apiToken: "", + isActive: provider.isActive, + }); + setFormError(null); + setIsDialogOpen(true); + } + + function closeDialog(): void { + if (isSaving) { + return; + } + + setIsDialogOpen(false); + setEditingProvider(null); + setForm(INITIAL_FORM); + setFormError(null); + } + + async function handleSubmit(event: SyntheticEvent): Promise { + event.preventDefault(); + setFormError(null); + setSuccessMessage(null); + + const validationError = validateForm(form, editingProvider !== null); + if (validationError !== null) { + setFormError(validationError); + return; + } + + const name = form.name.trim(); + const gatewayUrl = form.gatewayUrl.trim(); + const apiToken = form.apiToken.trim(); + + try { + setIsSaving(true); + + if (editingProvider) { + const updatePayload: UpdateAgentProviderRequest = { + name, + provider: form.provider, + gatewayUrl, + isActive: form.isActive, + }; + + if (apiToken.length > 0) { + updatePayload.credentials = { apiToken }; + } + + await updateAgentProvider(editingProvider.id, updatePayload); + setSuccessMessage(`Updated provider "${name}".`); + } else { + const createPayload: CreateAgentProviderRequest = { + name, + provider: form.provider, + gatewayUrl, + credentials: { apiToken }, + isActive: form.isActive, + }; + + await createAgentProvider(createPayload); + setSuccessMessage(`Added provider "${name}".`); + } + + setIsDialogOpen(false); + setEditingProvider(null); + setForm(INITIAL_FORM); + await loadProviders(false); + } catch (saveError: unknown) { + setFormError(getErrorMessage(saveError, "Unable to save agent provider.")); + } finally { + setIsSaving(false); + } + } + + async function handleDeleteProvider(): Promise { + if (!deleteTarget) { + return; + } + + try { + setIsDeleting(true); + await deleteAgentProvider(deleteTarget.id); + setSuccessMessage(`Deleted provider "${deleteTarget.name}".`); + setDeleteTarget(null); + await loadProviders(false); + } catch (deleteError: unknown) { + setError(getErrorMessage(deleteError, "Failed to delete agent provider.")); + } finally { + setIsDeleting(false); + } + } + + return ( +
+
+
+

Agent Providers

+

+ Register OpenClaw gateways and API tokens used for external agent sessions. +

+
+ +
+ + + +
+ OpenClaw Gateways + + Add one or more OpenClaw gateway endpoints and control which ones are active. + +
+
+ + +
+
+ + + {error ? ( +

+ {error} +

+ ) : null} + + {successMessage ?

{successMessage}

: null} + + {isLoading ? ( +

Loading agent providers...

+ ) : providers.length === 0 ? ( +

+ No agent providers configured yet. Add one to register an OpenClaw gateway. +

+ ) : ( + providers.map((provider) => ( +
+
+
+

{provider.name}

+ + {provider.isActive ? "Active" : "Inactive"} + + {provider.provider} +
+

+ Gateway URL: {provider.gatewayUrl} +

+

+ Created: {formatCreatedDate(provider.createdAt)} +

+
+ +
+ + +
+
+ )) + )} +
+
+ + { + if (!nextOpen) { + closeDialog(); + return; + } + + setIsDialogOpen(true); + }} + > + + + + {editingProvider ? "Edit Agent Provider" : "Add Agent Provider"} + + + Configure an OpenClaw gateway URL and API token for agent provider registration. + + + +
void handleSubmit(event)} className="space-y-4"> +
+ + ) => { + setForm((previous) => ({ ...previous, name: event.target.value })); + }} + placeholder="openclaw-primary" + maxLength={100} + disabled={isSaving} + required + /> +

+ Use letters, numbers, and hyphens only. +

+
+ +
+ + +
+ +
+ + ) => { + setForm((previous) => ({ ...previous, gatewayUrl: event.target.value })); + }} + placeholder="https://my-openclaw.example.com" + disabled={isSaving} + required + /> +
+ +
+ + ) => { + setForm((previous) => ({ ...previous, apiToken: event.target.value })); + }} + placeholder={ + editingProvider ? "Leave blank to keep existing token" : "Enter API token" + } + autoComplete="new-password" + disabled={isSaving} + /> +

+ {editingProvider + ? "Leave blank to keep the currently stored token." + : "Required when creating a provider."} +

+
+ +
+
+ +

+ Inactive providers remain saved but are excluded from routing. +

+
+ { + setForm((previous) => ({ ...previous, isActive: checked })); + }} + disabled={isSaving} + /> +
+ + {formError ? ( +

+ {formError} +

+ ) : null} + + + + + +
+
+
+ + { + if (!open && !isDeleting) { + setDeleteTarget(null); + } + }} + > + + + Delete Agent Provider + + Delete provider "{deleteTarget?.name}"? This permanently removes its gateway and token + configuration. + + + + Cancel + + {isDeleting ? "Deleting..." : "Delete Provider"} + + + + +
+ ); +} diff --git a/apps/web/src/app/(authenticated)/settings/page.tsx b/apps/web/src/app/(authenticated)/settings/page.tsx index 17ab263..09d6919 100644 --- a/apps/web/src/app/(authenticated)/settings/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/page.tsx @@ -227,6 +227,33 @@ const categories: CategoryConfig[] = [ ), }, + { + title: "Agent Providers", + description: + "Register OpenClaw gateway URLs and API tokens for external agent provider routing.", + href: "/settings/agent-providers", + accent: "var(--ms-blue-400)", + iconBg: "rgba(47, 128, 255, 0.12)", + icon: ( + + ), + }, { title: "Agent Config", description: "Choose primary and fallback models, plus optional personality/SOUL instructions.", diff --git a/apps/web/src/components/settings/FleetSettingsNav.tsx b/apps/web/src/components/settings/FleetSettingsNav.tsx index 55994e1..14262ba 100644 --- a/apps/web/src/components/settings/FleetSettingsNav.tsx +++ b/apps/web/src/components/settings/FleetSettingsNav.tsx @@ -11,6 +11,7 @@ interface FleetSettingsLink { const FLEET_SETTINGS_LINKS: FleetSettingsLink[] = [ { href: "/settings/providers", label: "Providers" }, + { href: "/settings/agent-providers", label: "Agent Providers" }, { href: "/settings/agent-config", label: "Agent Config" }, { href: "/settings/auth", label: "Authentication" }, ]; diff --git a/apps/web/src/lib/api/agent-providers.test.ts b/apps/web/src/lib/api/agent-providers.test.ts new file mode 100644 index 0000000..b21c6f3 --- /dev/null +++ b/apps/web/src/lib/api/agent-providers.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as client from "./client"; +import { + createAgentProvider, + deleteAgentProvider, + fetchAgentProviders, + updateAgentProvider, +} from "./agent-providers"; + +vi.mock("./client"); + +beforeEach((): void => { + vi.clearAllMocks(); +}); + +describe("fetchAgentProviders", (): void => { + it("calls provider list endpoint", async (): Promise => { + vi.mocked(client.apiGet).mockResolvedValueOnce([] as never); + + await fetchAgentProviders(); + + expect(client.apiGet).toHaveBeenCalledWith("/api/agent-providers"); + }); +}); + +describe("createAgentProvider", (): void => { + it("posts create payload", async (): Promise => { + vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "provider-1" } as never); + + await createAgentProvider({ + name: "openclaw-primary", + provider: "openclaw", + gatewayUrl: "https://openclaw.example.com", + credentials: { + apiToken: "top-secret", + }, + isActive: true, + }); + + expect(client.apiPost).toHaveBeenCalledWith("/api/agent-providers", { + name: "openclaw-primary", + provider: "openclaw", + gatewayUrl: "https://openclaw.example.com", + credentials: { + apiToken: "top-secret", + }, + isActive: true, + }); + }); +}); + +describe("updateAgentProvider", (): void => { + it("sends PUT request with update payload", async (): Promise => { + vi.mocked(client.apiRequest).mockResolvedValueOnce({ id: "provider-1" } as never); + + await updateAgentProvider("provider-1", { + gatewayUrl: "https://new-openclaw.example.com", + isActive: false, + }); + + expect(client.apiRequest).toHaveBeenCalledWith("/api/agent-providers/provider-1", { + method: "PUT", + body: JSON.stringify({ + gatewayUrl: "https://new-openclaw.example.com", + isActive: false, + }), + }); + }); +}); + +describe("deleteAgentProvider", (): void => { + it("calls delete endpoint", async (): Promise => { + vi.mocked(client.apiDelete).mockResolvedValueOnce(undefined as never); + + await deleteAgentProvider("provider-1"); + + expect(client.apiDelete).toHaveBeenCalledWith("/api/agent-providers/provider-1"); + }); +}); diff --git a/apps/web/src/lib/api/agent-providers.ts b/apps/web/src/lib/api/agent-providers.ts new file mode 100644 index 0000000..14f4ace --- /dev/null +++ b/apps/web/src/lib/api/agent-providers.ts @@ -0,0 +1,61 @@ +import { apiDelete, apiGet, apiPost, apiRequest } from "./client"; + +export type AgentProviderType = "openclaw"; + +export interface AgentProviderCredentials { + apiToken?: string; +} + +export interface AgentProviderConfig { + id: string; + workspaceId: string; + name: string; + provider: AgentProviderType; + gatewayUrl: string; + credentials: AgentProviderCredentials | null; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateAgentProviderRequest { + name: string; + provider: AgentProviderType; + gatewayUrl: string; + credentials: { + apiToken: string; + }; + isActive: boolean; +} + +export interface UpdateAgentProviderRequest { + name?: string; + provider?: AgentProviderType; + gatewayUrl?: string; + credentials?: AgentProviderCredentials; + isActive?: boolean; +} + +export async function fetchAgentProviders(): Promise { + return apiGet("/api/agent-providers"); +} + +export async function createAgentProvider( + data: CreateAgentProviderRequest +): Promise { + return apiPost("/api/agent-providers", data); +} + +export async function updateAgentProvider( + providerId: string, + data: UpdateAgentProviderRequest +): Promise { + return apiRequest(`/api/agent-providers/${providerId}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + +export async function deleteAgentProvider(providerId: string): Promise { + await apiDelete(`/api/agent-providers/${providerId}`); +} diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 6b5273c..4e92f09 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -18,4 +18,5 @@ export * from "./projects"; export * from "./workspaces"; export * from "./admin"; export * from "./fleet-settings"; +export * from "./agent-providers"; export * from "./activity";