mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
feat(adapters): external adapter plugin system with dynamic UI parser
- Plugin loader: install/reload/remove/reinstall external adapters from npm packages or local directories - Plugin store persisted at ~/.paperclip/adapter-plugins.json - Self-healing UI parser resolution with version caching - UI: Adapter Manager page, dynamic loader, display registry with humanized names for unknown adapter types - Dev watch: exclude adapter-plugins dir from tsx watcher to prevent mid-request server restarts during reinstall - All consumer fallbacks use getAdapterLabel() for consistent display - AdapterTypeDropdown uses controlled open state for proper close behavior - Remove hermes-local from built-in UI (externalized to plugin) - Add docs for external adapters and UI parser contract
This commit is contained in:
parent
f8452a4520
commit
14d59da316
72 changed files with 4102 additions and 585 deletions
483
ui/src/pages/AdapterManager.tsx
Normal file
483
ui/src/pages/AdapterManager.tsx
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
/**
|
||||
* @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";
|
||||
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
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);
|
||||
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) => reinstallMutation.mutate(type) : 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue