mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +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>
|
||||
);
|
||||
}
|
||||
|
|
@ -285,6 +285,98 @@ function asNonEmptyString(value: unknown): string | null {
|
|||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function RunInvocationCard({
|
||||
payload,
|
||||
censorUsernameInLogs,
|
||||
}: {
|
||||
payload: Record<string, unknown>;
|
||||
censorUsernameInLogs: boolean;
|
||||
}) {
|
||||
const commandLine = [
|
||||
typeof payload.command === "string" ? payload.command : null,
|
||||
...(Array.isArray(payload.commandArgs)
|
||||
? payload.commandArgs.filter((value): value is string => typeof value === "string")
|
||||
: []),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(" ");
|
||||
|
||||
const hasAdvancedDetails =
|
||||
commandLine.length > 0
|
||||
|| (Array.isArray(payload.commandNotes) && payload.commandNotes.length > 0)
|
||||
|| payload.prompt !== undefined
|
||||
|| payload.context !== undefined
|
||||
|| payload.env !== undefined;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Invocation</div>
|
||||
{typeof payload.adapterType === "string" && (
|
||||
<div className="text-xs"><span className="text-muted-foreground">Adapter: </span>{payload.adapterType}</div>
|
||||
)}
|
||||
{typeof payload.cwd === "string" && (
|
||||
<div className="text-xs break-all"><span className="text-muted-foreground">Working dir: </span><span className="font-mono">{payload.cwd}</span></div>
|
||||
)}
|
||||
{hasAdvancedDetails && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group">
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-data-[state=open]:rotate-90" />
|
||||
Details
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2 space-y-2">
|
||||
{commandLine && (
|
||||
<div className="text-xs break-all">
|
||||
<span className="text-muted-foreground">Command: </span>
|
||||
<span className="font-mono">{commandLine}</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(payload.commandNotes) && payload.commandNotes.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Command notes</div>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{payload.commandNotes
|
||||
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
.map((note, idx) => (
|
||||
<li key={`${idx}-${note}`} className="text-xs break-all font-mono">
|
||||
{note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{payload.prompt !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{typeof payload.prompt === "string"
|
||||
? redactPathText(payload.prompt, censorUsernameInLogs)
|
||||
: JSON.stringify(redactPathValue(payload.prompt, censorUsernameInLogs), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{payload.context !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Context</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(redactPathValue(payload.context, censorUsernameInLogs), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{payload.env !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Environment</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{formatEnvForDisplay(payload.env, censorUsernameInLogs)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseStoredLogContent(content: string): RunLogChunk[] {
|
||||
const parsed: RunLogChunk[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
|
|
@ -3707,68 +3799,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
censorUsernameInLogs={censorUsernameInLogs}
|
||||
/>
|
||||
{adapterInvokePayload && (
|
||||
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Invocation</div>
|
||||
{typeof adapterInvokePayload.adapterType === "string" && (
|
||||
<div className="text-xs"><span className="text-muted-foreground">Adapter: </span>{adapterInvokePayload.adapterType}</div>
|
||||
)}
|
||||
{typeof adapterInvokePayload.cwd === "string" && (
|
||||
<div className="text-xs break-all"><span className="text-muted-foreground">Working dir: </span><span className="font-mono">{adapterInvokePayload.cwd}</span></div>
|
||||
)}
|
||||
{typeof adapterInvokePayload.command === "string" && (
|
||||
<div className="text-xs break-all">
|
||||
<span className="text-muted-foreground">Command: </span>
|
||||
<span className="font-mono">
|
||||
{[
|
||||
adapterInvokePayload.command,
|
||||
...(Array.isArray(adapterInvokePayload.commandArgs)
|
||||
? adapterInvokePayload.commandArgs.filter((v): v is string => typeof v === "string")
|
||||
: []),
|
||||
].join(" ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(adapterInvokePayload.commandNotes) && adapterInvokePayload.commandNotes.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Command notes</div>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{adapterInvokePayload.commandNotes
|
||||
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
.map((note, idx) => (
|
||||
<li key={`${idx}-${note}`} className="text-xs break-all font-mono">
|
||||
{note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{adapterInvokePayload.prompt !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{typeof adapterInvokePayload.prompt === "string"
|
||||
? redactPathText(adapterInvokePayload.prompt, censorUsernameInLogs)
|
||||
: JSON.stringify(redactPathValue(adapterInvokePayload.prompt, censorUsernameInLogs), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{adapterInvokePayload.context !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Context</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(redactPathValue(adapterInvokePayload.context, censorUsernameInLogs), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{adapterInvokePayload.env !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Environment</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{formatEnvForDisplay(adapterInvokePayload.env, censorUsernameInLogs)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RunInvocationCard payload={adapterInvokePayload} censorUsernameInLogs={censorUsernameInLogs} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -20,17 +20,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react";
|
||||
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude",
|
||||
codex_local: "Codex",
|
||||
gemini_local: "Gemini",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
hermes_local: "Hermes",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
|
||||
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
||||
|
||||
|
|
@ -263,7 +253,7 @@ export function Agents() {
|
|||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
{getAdapterLabel(agent.adapterType)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
|
|
@ -364,7 +354,7 @@ function OrgTreeNode({
|
|||
{agent && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
{getAdapterLabel(agent.adapterType)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Field, adapterLabels } from "../components/agent-config-primitives";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||
import { getUIAdapter, listUIAdapters } from "../adapters";
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
|
@ -514,7 +515,7 @@ function ConflictResolutionList({
|
|||
|
||||
const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = listUIAdapters().map((adapter) => ({
|
||||
value: adapter.type,
|
||||
label: adapterLabels[adapter.type] ?? adapter.label,
|
||||
label: adapterLabels[adapter.type] ?? getAdapterLabel(adapter.type),
|
||||
}));
|
||||
|
||||
// ── Adapter picker for imported agents ───────────────────────────────
|
||||
|
|
|
|||
|
|
@ -12,20 +12,9 @@ import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
|||
type JoinType = "human" | "agent";
|
||||
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
gemini_local: "Gemini CLI (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
pi_local: "Pi (local)",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
hermes_local: "Hermes Agent",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
|
||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
|
||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
|
||||
|
||||
function dateTime(value: string) {
|
||||
return new Date(value).toLocaleString();
|
||||
|
|
@ -279,7 +268,7 @@ export function InviteLandingPage() {
|
|||
>
|
||||
{joinAdapterOptions.map((type) => (
|
||||
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
|
||||
{adapterLabels[type]}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
|
||||
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import { cn, agentUrl } from "../lib/utils";
|
|||
import { roleLabels } from "../components/agent-config-primitives";
|
||||
import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm";
|
||||
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { getUIAdapter, listUIAdapters } from "../adapters";
|
||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
import { ReportsToPicker } from "../components/ReportsToPicker";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
|
|
@ -28,16 +29,9 @@ import {
|
|||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
|
||||
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"gemini_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
"hermes_local",
|
||||
"openclaw_gateway",
|
||||
]);
|
||||
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>(
|
||||
listUIAdapters().map((adapter) => adapter.type as CreateConfigValues["adapterType"]),
|
||||
);
|
||||
|
||||
function createValuesForAdapterType(
|
||||
adapterType: CreateConfigValues["adapterType"],
|
||||
|
|
|
|||
|
|
@ -116,17 +116,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L
|
|||
|
||||
// ── Status dot colors (raw hex for SVG) ─────────────────────────────────
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude",
|
||||
codex_local: "Codex",
|
||||
gemini_local: "Gemini",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
hermes_local: "Hermes",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
|
||||
const statusDotColor: Record<string, string> = {
|
||||
running: "#22d3ee",
|
||||
|
|
@ -426,7 +416,7 @@ export function OrgChart() {
|
|||
</span>
|
||||
{agent && (
|
||||
<span className="text-[10px] text-muted-foreground/60 font-mono leading-tight mt-1">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
{getAdapterLabel(agent.adapterType)}
|
||||
</span>
|
||||
)}
|
||||
{agent && agent.capabilities && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue