forked from farhoodlabs/paperclip
600 lines
22 KiB
TypeScript
600 lines
22 KiB
TypeScript
/**
|
|
* @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 } 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";
|
|
|
|
function AdapterRow({
|
|
adapter,
|
|
canRemove,
|
|
onToggle,
|
|
onRemove,
|
|
onReload,
|
|
onReinstall,
|
|
isToggling,
|
|
isReloading,
|
|
isReinstalling,
|
|
}: {
|
|
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;
|
|
}) {
|
|
return (
|
|
<li>
|
|
<div className="flex items-center gap-4 px-4 py-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className={cn("font-medium", adapter.disabled && "text-muted-foreground line-through")}>
|
|
{adapter.label || getAdapterLabel(adapter.type)}
|
|
</span>
|
|
<Badge variant="outline">{adapter.source === "external" ? "External" : "Built-in"}</Badge>
|
|
{adapter.overriddenBuiltin && (
|
|
<Badge variant="secondary" className="text-blue-600 border-blue-400">
|
|
Overrides built-in
|
|
</Badge>
|
|
)}
|
|
{adapter.source === "external" && (
|
|
adapter.isLocalPath
|
|
? <span title="Installed from local path"><FolderOpen className="h-4 w-4 text-amber-500" /></span>
|
|
: <span title="Installed from npm"><Package className="h-4 w-4 text-red-500" /></span>
|
|
)}
|
|
<Badge
|
|
variant="default"
|
|
className={adapter.loaded ? "bg-green-600 hover:bg-green-700" : ""}
|
|
>
|
|
{adapter.loaded ? "loaded" : "error"}
|
|
</Badge>
|
|
{adapter.version && (
|
|
<Badge variant="secondary" className="font-mono text-[10px]">
|
|
v{adapter.version}
|
|
</Badge>
|
|
)}
|
|
{adapter.disabled && (
|
|
<Badge variant="secondary" className="text-amber-600 border-amber-400">
|
|
Hidden from menus
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{adapter.type}
|
|
{adapter.packageName && adapter.packageName !== adapter.type && (
|
|
<> · {adapter.packageName}</>
|
|
)}
|
|
{" · "}{adapter.modelsCount} models
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
className="h-8 w-8"
|
|
title={adapter.disabled ? "Show in agent menus" : "Hide from agent menus"}
|
|
disabled={isToggling}
|
|
onClick={() => onToggle(adapter.type, !adapter.disabled)}
|
|
>
|
|
<Power className={cn("h-4 w-4", !adapter.disabled ? "text-green-600" : "text-muted-foreground")} />
|
|
</Button>
|
|
{onReload && (
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
className="h-8 w-8"
|
|
title="Reload adapter (hot-swap)"
|
|
disabled={isReloading}
|
|
onClick={() => onReload(adapter.type)}
|
|
>
|
|
<RefreshCw className={cn("h-4 w-4", isReloading && "animate-spin")} />
|
|
</Button>
|
|
)}
|
|
{onReinstall && (
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
className="h-8 w-8"
|
|
title="Reinstall adapter (pull latest from npm)"
|
|
disabled={isReinstalling}
|
|
onClick={() => onReinstall(adapter.type)}
|
|
>
|
|
<Download className={cn("h-4 w-4", isReinstalling && "animate-bounce")} />
|
|
</Button>
|
|
)}
|
|
{canRemove && (
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
|
title="Remove adapter"
|
|
onClick={() => onRemove(adapter.type)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function fetchNpmLatestVersion(packageName: string): Promise<string | null> {
|
|
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 (
|
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel(); }}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Reinstall Adapter</DialogTitle>
|
|
<DialogDescription>
|
|
This will pull the latest version of{" "}
|
|
<strong>{adapter?.packageName}</strong> from npm and hot-swap
|
|
the running adapter module. Existing agents will use the new
|
|
version on their next run.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="rounded-md border bg-muted/50 px-4 py-3 text-sm space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground">Package</span>
|
|
<span className="font-mono">{adapter?.packageName}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground">Current</span>
|
|
<span className="font-mono">
|
|
{adapter?.version ? `v${adapter.version}` : "unknown"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground">Latest on npm</span>
|
|
<span className="font-mono">
|
|
{isFetchingVersion
|
|
? "checking..."
|
|
: latestVersion
|
|
? `v${latestVersion}`
|
|
: "unavailable"}
|
|
</span>
|
|
</div>
|
|
{isUpToDate && (
|
|
<p className="text-xs text-muted-foreground pt-1">
|
|
Already on the latest version.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onCancel} disabled={isReinstalling}>
|
|
Cancel
|
|
</Button>
|
|
<Button disabled={isReinstalling} onClick={onConfirm}>
|
|
{isReinstalling ? "Reinstalling..." : "Reinstall"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export function AdapterManager() {
|
|
const { selectedCompany } = useCompany();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const queryClient = useQueryClient();
|
|
const { pushToast } = useToast();
|
|
|
|
const [installPackage, setInstallPackage] = useState("");
|
|
const [installVersion, setInstallVersion] = useState("");
|
|
const [isLocalPath, setIsLocalPath] = useState(false);
|
|
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
|
const [removeType, setRemoveType] = useState<string | null>(null);
|
|
const [reinstallTarget, setReinstallTarget] = useState<AdapterInfo | null>(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: () => {
|
|
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");
|
|
|
|
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading adapters...</div>;
|
|
|
|
const isMutating = installMutation.isPending || removeMutation.isPending || toggleMutation.isPending || reloadMutation.isPending || reinstallMutation.isPending;
|
|
|
|
return (
|
|
<div className="space-y-6 max-w-5xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Cpu className="h-6 w-6 text-muted-foreground" />
|
|
<h1 className="text-xl font-semibold">Adapters</h1>
|
|
<Badge variant="outline" className="text-amber-600 border-amber-400">
|
|
Alpha
|
|
</Badge>
|
|
</div>
|
|
|
|
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm" className="gap-2">
|
|
<Plus className="h-4 w-4" />
|
|
Install Adapter
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Install External Adapter</DialogTitle>
|
|
<DialogDescription>
|
|
Add an adapter from npm or a local path. The adapter package must export <code className="text-xs bg-muted px-1 py-0.5 rounded">createServerAdapter()</code>.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
{/* Source toggle */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs transition-colors",
|
|
!isLocalPath
|
|
? "border-foreground bg-accent text-foreground"
|
|
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
|
)}
|
|
onClick={() => setIsLocalPath(false)}
|
|
>
|
|
<Package className="h-3.5 w-3.5" />
|
|
npm package
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs transition-colors",
|
|
isLocalPath
|
|
? "border-foreground bg-accent text-foreground"
|
|
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
|
)}
|
|
onClick={() => setIsLocalPath(true)}
|
|
>
|
|
<FolderOpen className="h-3.5 w-3.5" />
|
|
Local path
|
|
</button>
|
|
</div>
|
|
|
|
{isLocalPath ? (
|
|
/* Local path input */
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="adapterLocalPath">Path to adapter package</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="adapterLocalPath"
|
|
className="flex-1 font-mono text-xs"
|
|
placeholder="/mnt/e/Projects/my-adapter or E:\Projects\my-adapter"
|
|
value={installPackage}
|
|
onChange={(e) => setInstallPackage(e.target.value)}
|
|
/>
|
|
<ChoosePathButton />
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Accepts Linux, WSL, and Windows paths. Windows paths are auto-converted.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
/* npm package input */
|
|
<>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="adapterPackageName">Package Name</Label>
|
|
<Input
|
|
id="adapterPackageName"
|
|
placeholder="my-paperclip-adapter"
|
|
value={installPackage}
|
|
onChange={(e) => setInstallPackage(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="adapterVersion">Version (optional)</Label>
|
|
<Input
|
|
id="adapterVersion"
|
|
placeholder="latest"
|
|
value={installVersion}
|
|
onChange={(e) => setInstallVersion(e.target.value)}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setInstallDialogOpen(false)}>Cancel</Button>
|
|
<Button
|
|
onClick={() =>
|
|
installMutation.mutate({
|
|
packageName: installPackage,
|
|
version: installVersion || undefined,
|
|
isLocalPath,
|
|
})
|
|
}
|
|
disabled={!installPackage || installMutation.isPending}
|
|
>
|
|
{installMutation.isPending ? "Installing..." : "Install"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{/* Alpha notice */}
|
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
|
<div className="flex items-start gap-3">
|
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
|
|
<div className="space-y-1 text-sm">
|
|
<p className="font-medium text-foreground">External adapters are alpha.</p>
|
|
<p className="text-muted-foreground">
|
|
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.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* External adapters */}
|
|
<section className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Cpu className="h-5 w-5 text-muted-foreground" />
|
|
<h2 className="text-base font-semibold">External Adapters</h2>
|
|
</div>
|
|
|
|
{externalAdapters.length === 0 ? (
|
|
<Card className="bg-muted/30">
|
|
<CardContent className="flex flex-col items-center justify-center py-10">
|
|
<Cpu className="h-10 w-10 text-muted-foreground mb-4" />
|
|
<p className="text-sm font-medium">No external adapters installed</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Install an adapter package to extend model support.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<ul className="divide-y rounded-md border bg-card">
|
|
{externalAdapters.map((adapter) => (
|
|
<AdapterRow
|
|
key={adapter.type}
|
|
adapter={adapter}
|
|
canRemove={true}
|
|
onToggle={(type, disabled) => toggleMutation.mutate({ type, disabled })}
|
|
onRemove={(type) => setRemoveType(type)}
|
|
onReload={(type) => reloadMutation.mutate(type)}
|
|
onReinstall={!adapter.isLocalPath ? (type) => setReinstallTarget(adapter) : undefined}
|
|
isToggling={toggleMutation.isPending}
|
|
isReloading={reloadMutation.isPending}
|
|
isReinstalling={reinstallMutation.isPending}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
{/* Built-in adapters */}
|
|
<section className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Cpu className="h-5 w-5 text-muted-foreground" />
|
|
<h2 className="text-base font-semibold">Built-in Adapters</h2>
|
|
</div>
|
|
|
|
{builtinAdapters.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground">No built-in adapters found.</div>
|
|
) : (
|
|
<ul className="divide-y rounded-md border bg-card">
|
|
{builtinAdapters.map((adapter) => (
|
|
<AdapterRow
|
|
key={adapter.type}
|
|
adapter={adapter}
|
|
canRemove={false}
|
|
onToggle={(type, disabled) => toggleMutation.mutate({ type, disabled })}
|
|
onRemove={() => {}}
|
|
isToggling={isMutating}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
{/* Remove confirmation */}
|
|
<Dialog
|
|
open={removeType !== null}
|
|
onOpenChange={(open) => { if (!open) setRemoveType(null); }}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Remove Adapter</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to remove the <strong>{removeType}</strong> 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.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setRemoveType(null)}>Cancel</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={removeMutation.isPending}
|
|
onClick={() => {
|
|
if (removeType) {
|
|
removeMutation.mutate(removeType, {
|
|
onSettled: () => setRemoveType(null),
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
{removeMutation.isPending ? "Removing..." : "Remove"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
{/* Reinstall confirmation */}
|
|
<ReinstallDialog
|
|
adapter={reinstallTarget}
|
|
open={reinstallTarget !== null}
|
|
isReinstalling={reinstallMutation.isPending}
|
|
onConfirm={() => {
|
|
if (reinstallTarget) {
|
|
reinstallMutation.mutate(reinstallTarget.type, {
|
|
onSettled: () => setReinstallTarget(null),
|
|
});
|
|
}
|
|
}}
|
|
onCancel={() => setReinstallTarget(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|