/**
* @fileoverview Adapter Manager page — install, view, and manage external adapters.
*
* Adapters are simpler than plugins: no workers, no events, no manifests.
* They just register a ServerAdapterModule that provides model discovery and execution.
*/
import { useEffect, useState, useSyncExternalStore } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Cpu, Plus, Power, Trash2, FolderOpen, Package, RefreshCw, Download } from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { adaptersApi } from "@/api/adapters";
import type { AdapterInfo } from "@/api/adapters";
import { getAdapterLabel } from "@/adapters/adapter-display-registry";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
import { ChoosePathButton } from "@/components/PathInstructionsModal";
import { invalidateDynamicParser } from "@/adapters/dynamic-loader";
import { invalidateConfigSchemaCache } from "@/adapters/schema-config-fields";
import { isOverrideDisabled, setOverrideDisabled, subscribeToOverrides, getOverridesSnapshot } from "@/adapters/disabled-overrides-store";
function AdapterRow({
adapter,
canRemove,
onToggle,
onRemove,
onReload,
onReinstall,
isToggling,
isReloading,
isReinstalling,
overriddenBy,
/** Custom tooltip for the power button when adapter is enabled. */
toggleTitleEnabled,
/** Custom tooltip for the power button when adapter is disabled. */
toggleTitleDisabled,
/** Custom label for the disabled badge (defaults to "Hidden from menus"). */
disabledBadgeLabel,
}: {
adapter: AdapterInfo;
canRemove: boolean;
onToggle: (type: string, disabled: boolean) => void;
onRemove: (type: string) => void;
onReload?: (type: string) => void;
onReinstall?: (type: string) => void;
isToggling: boolean;
isReloading?: boolean;
isReinstalling?: boolean;
/** When set, shows an "Overridden by …" badge (used for builtin entries). */
overriddenBy?: string;
toggleTitleEnabled?: string;
toggleTitleDisabled?: string;
disabledBadgeLabel?: string;
}) {
return (
{adapter.label || getAdapterLabel(adapter.type)}
{adapter.source === "external" ? "External" : "Built-in"}
{adapter.source === "external" && (
adapter.isLocalPath
?
:
)}
{adapter.version && (
v{adapter.version}
)}
{adapter.overriddenBuiltin && (
Overrides built-in
)}
{overriddenBy && (
Overridden by {overriddenBy}
)}
{adapter.disabled && (
{disabledBadgeLabel ?? "Hidden from menus"}
)}
{adapter.type}
{adapter.packageName && adapter.packageName !== adapter.type && (
<> · {adapter.packageName}>
)}
{" · "}{adapter.modelsCount} models
{onReinstall && (
onReinstall(adapter.type)}
>
)}
{onReload && (
onReload(adapter.type)}
>
)}
onToggle(adapter.type, !adapter.disabled)}
>
{canRemove && (
onRemove(adapter.type)}
>
)}
);
}
function fetchNpmLatestVersion(packageName: string): Promise {
return fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, {
signal: AbortSignal.timeout(5000),
})
.then((res) => res.json())
.then((data) => (typeof data?.version === "string" ? (data.version as string) : null))
.catch(() => null);
}
function ReinstallDialog({
adapter,
open,
isReinstalling,
onConfirm,
onCancel,
}: {
adapter: AdapterInfo | null;
open: boolean;
isReinstalling: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
const { data: latestVersion, isLoading: isFetchingVersion } = useQuery({
queryKey: ["npm-latest-version", adapter?.packageName],
queryFn: () => {
if (!adapter?.packageName) return null;
return fetchNpmLatestVersion(adapter.packageName);
},
enabled: open && !!adapter?.packageName,
staleTime: 60_000,
});
const isUpToDate = adapter?.version && latestVersion && adapter.version === latestVersion;
return (
{ if (!o) onCancel(); }}>
Reinstall Adapter
This will pull the latest version of{" "}
{adapter?.packageName} from npm and hot-swap
the running adapter module. Existing agents will use the new
version on their next run.
Package
{adapter?.packageName}
Current
{adapter?.version ? `v${adapter.version}` : "unknown"}
Latest on npm
{isFetchingVersion
? "checking..."
: latestVersion
? `v${latestVersion}`
: "unavailable"}
{isUpToDate && (
Already on the latest version.
)}
Cancel
{isReinstalling ? "Reinstalling..." : "Reinstall"}
);
}
export function AdapterManager() {
const { selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { pushToast } = useToast();
// Subscribe to client-side override store so the component re-renders
// immediately when setOverrideDisabled() is called, even though the
// server query data hasn't changed.
useSyncExternalStore(subscribeToOverrides, getOverridesSnapshot);
const [installPackage, setInstallPackage] = useState("");
const [installVersion, setInstallVersion] = useState("");
const [isLocalPath, setIsLocalPath] = useState(false);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [removeType, setRemoveType] = useState(null);
const [reinstallTarget, setReinstallTarget] = useState(null);
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/instance/settings/general" },
{ label: "Adapters" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
const { data: adapters, isLoading } = useQuery({
queryKey: queryKeys.adapters.all,
queryFn: () => adaptersApi.list(),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.adapters.all });
};
const installMutation = useMutation({
mutationFn: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
adaptersApi.install(params),
onSuccess: (result) => {
invalidate();
setInstallDialogOpen(false);
setInstallPackage("");
setInstallVersion("");
setIsLocalPath(false);
pushToast({
title: "Adapter installed",
body: `Type "${result.type}" registered successfully.${result.version ? ` (v${result.version})` : ""}`,
tone: "success",
});
},
onError: (err: Error) => {
pushToast({ title: "Install failed", body: err.message, tone: "error" });
},
});
const removeMutation = useMutation({
mutationFn: (type: string) => adaptersApi.remove(type),
onSuccess: (_result, type) => {
// Clean up client-side override state when the external is removed.
setOverrideDisabled(type, false);
invalidate();
pushToast({ title: "Adapter removed", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Removal failed", body: err.message, tone: "error" });
},
});
const toggleMutation = useMutation({
mutationFn: ({ type, disabled }: { type: string; disabled: boolean }) =>
adaptersApi.setDisabled(type, disabled),
onSuccess: () => {
invalidate();
},
onError: (err: Error) => {
pushToast({ title: "Toggle failed", body: err.message, tone: "error" });
},
});
const reloadMutation = useMutation({
mutationFn: (type: string) => adaptersApi.reload(type),
onSuccess: (result) => {
invalidate();
invalidateDynamicParser(result.type);
invalidateConfigSchemaCache(result.type);
pushToast({
title: "Adapter reloaded",
body: `Type "${result.type}" reloaded.${result.version ? ` (v${result.version})` : ""}`,
tone: "success",
});
},
onError: (err: Error) => {
pushToast({ title: "Reload failed", body: err.message, tone: "error" });
},
});
const reinstallMutation = useMutation({
mutationFn: (type: string) => adaptersApi.reinstall(type),
onSuccess: (result) => {
invalidate();
invalidateDynamicParser(result.type);
invalidateConfigSchemaCache(result.type);
pushToast({
title: "Adapter reinstalled",
body: `Type "${result.type}" updated from npm.${result.version ? ` (v${result.version})` : ""}`,
tone: "success",
});
},
onError: (err: Error) => {
pushToast({ title: "Reinstall failed", body: err.message, tone: "error" });
},
});
const builtinAdapters = (adapters ?? []).filter((a) => a.source === "builtin");
const externalAdapters = (adapters ?? []).filter((a) => a.source === "external");
// External adapters that override a builtin type. The server only returns
// one entry per type (the external), so we synthesize a builtin row for
// the builtins section so users can see which builtins are affected.
// The virtual entry's disabled state reflects the TYPE's menu visibility
// (server-side disabled flag), NOT the external adapter's override state.
const overriddenBuiltins = (adapters ?? [])
.filter((a) => a.source === "external" && a.overriddenBuiltin)
.filter((a) => !builtinAdapters.some((b) => b.type === a.type))
.map((a) => ({
type: a.type,
label: getAdapterLabel(a.type),
overriddenBy: [
a.packageName,
a.version ? `v${a.version}` : undefined,
].filter(Boolean).join(" "),
// The override-paused state is client-side and independent of
// the type's server-side menu visibility.
overridePaused: isOverrideDisabled(a.type),
menuDisabled: a.disabled ?? false,
}));
if (isLoading) return Loading adapters...
;
const isMutating = installMutation.isPending || removeMutation.isPending || toggleMutation.isPending || reloadMutation.isPending || reinstallMutation.isPending;
return (
{/* Header */}
Adapters
Alpha
Install Adapter
Install External Adapter
Add an adapter from npm or a local path. The adapter package must export createServerAdapter().
{/* Source toggle */}
setIsLocalPath(false)}
>
npm package
setIsLocalPath(true)}
>
Local path
{isLocalPath ? (
/* Local path input */
) : (
/* npm package input */
<>
Package Name
setInstallPackage(e.target.value)}
/>
Version (optional)
setInstallVersion(e.target.value)}
/>
>
)}
setInstallDialogOpen(false)}>Cancel
installMutation.mutate({
packageName: installPackage,
version: installVersion || undefined,
isLocalPath,
})
}
disabled={!installPackage || installMutation.isPending}
>
{installMutation.isPending ? "Installing..." : "Install"}
{/* Alpha notice */}
External adapters are alpha.
The adapter plugin system is under active development. APIs and storage format may change.
Use the power icon to hide adapters from agent menus without removing them.
{/* External adapters */}
External Adapters
{externalAdapters.length === 0 ? (
No external adapters installed
Install an adapter package to extend model support.
) : (
{externalAdapters.map((adapter) => {
const isBuiltinOverride = adapter.overriddenBuiltin;
const overridePaused = isBuiltinOverride && isOverrideDisabled(adapter.type);
// For overridden builtins, the power button controls the
// client-side override state (not server menu visibility).
const effectiveAdapter: AdapterInfo = isBuiltinOverride
? { ...adapter, disabled: !!overridePaused }
: adapter;
return (
{
setOverrideDisabled(type, disabled);
// useSyncExternalStore handles the re-render;
// also invalidate so other components (e.g. menus)
// eventually pick up the registry change.
invalidate();
}
: (type, disabled) => toggleMutation.mutate({ type, disabled })
}
onRemove={(type) => setRemoveType(type)}
onReload={(type) => reloadMutation.mutate(type)}
onReinstall={!adapter.isLocalPath ? (type) => setReinstallTarget(adapter) : undefined}
isToggling={isBuiltinOverride ? false : toggleMutation.isPending}
isReloading={reloadMutation.isPending}
isReinstalling={reinstallMutation.isPending}
toggleTitleDisabled={isBuiltinOverride ? "Pause external override" : undefined}
toggleTitleEnabled={isBuiltinOverride ? "Resume external override" : undefined}
disabledBadgeLabel={isBuiltinOverride ? "Override paused" : undefined}
/>
);
})}
)}
{/* Built-in adapters */}
Built-in Adapters
{builtinAdapters.length === 0 && overriddenBuiltins.length === 0 ? (
No built-in adapters found.
) : (
{builtinAdapters.map((adapter) => (
toggleMutation.mutate({ type, disabled })}
onRemove={() => {}}
isToggling={isMutating}
/>
))}
{overriddenBuiltins.map((virtual) => (
toggleMutation.mutate({ type, disabled })}
onRemove={() => {}}
isToggling={isMutating}
overriddenBy={virtual.overridePaused ? undefined : virtual.overriddenBy}
/>
))}
)}
{/* Remove confirmation */}
{ if (!open) setRemoveType(null); }}
>
Remove Adapter
Are you sure you want to remove the {removeType} adapter?
It will be unregistered and removed from the adapter store.
{removeType && adapters?.find((a) => a.type === removeType)?.packageName && (
<> npm packages will be cleaned up from disk.>
)}
{" "}This action cannot be undone.
setRemoveType(null)}>Cancel
{
if (removeType) {
removeMutation.mutate(removeType, {
onSettled: () => setRemoveType(null),
});
}
}}
>
{removeMutation.isPending ? "Removing..." : "Remove"}
{/* Reinstall confirmation */}
{
if (reinstallTarget) {
reinstallMutation.mutate(reinstallTarget.type, {
onSettled: () => setReinstallTarget(null),
});
}
}}
onCancel={() => setReinstallTarget(null)}
/>
);
}