mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50: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
|
|
@ -1,6 +1,5 @@
|
|||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||
import type {
|
||||
Agent,
|
||||
AdapterEnvironmentTestResult,
|
||||
|
|
@ -46,6 +45,9 @@ import { ChoosePathButton } from "./PathInstructionsModal";
|
|||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import { ReportsToPicker } from "./ReportsToPicker";
|
||||
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
|
||||
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
|
||||
/* ---- Create mode values ---- */
|
||||
|
||||
|
|
@ -180,6 +182,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Sync disabled adapter types from server so dropdown filters them out
|
||||
const disabledTypes = useDisabledAdaptersSync();
|
||||
|
||||
const { data: availableSecrets = [] } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
|
||||
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||
|
|
@ -311,15 +316,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
const adapterType = isCreate
|
||||
? props.values.adapterType
|
||||
: overlay.adapterType ?? props.agent.adapterType;
|
||||
const isLocal =
|
||||
adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor";
|
||||
const isHermesLocal = adapterType === "hermes_local";
|
||||
const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]);
|
||||
const isLocal = !NONLOCAL_TYPES.has(adapterType);
|
||||
|
||||
const showLegacyWorkingDirectoryField =
|
||||
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||
|
|
@ -345,13 +344,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
: ["agents", "none", "detect-model", adapterType],
|
||||
queryFn: () => {
|
||||
if (!selectedCompanyId) {
|
||||
throw new Error("Select a company to detect the Hermes model");
|
||||
throw new Error("Select a company to detect the model");
|
||||
}
|
||||
return agentsApi.detectModel(selectedCompanyId, adapterType);
|
||||
},
|
||||
enabled: Boolean(selectedCompanyId && isHermesLocal),
|
||||
enabled: Boolean(selectedCompanyId && isLocal),
|
||||
});
|
||||
const detectedModel = detectedModelData?.model ?? null;
|
||||
const detectedModelCandidates = detectedModelData?.candidates ?? [];
|
||||
|
||||
const { data: companyAgents = [] } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
|
||||
|
|
@ -583,6 +583,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
<Field label="Adapter type" hint={help.adapterType}>
|
||||
<AdapterTypeDropdown
|
||||
value={adapterType}
|
||||
disabledTypes={disabledTypes}
|
||||
onChange={(t) => {
|
||||
if (isCreate) {
|
||||
// Reset all adapter-specific fields to defaults when switching adapter type
|
||||
|
|
@ -716,24 +717,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ command: v })
|
||||
: mark("adapterConfig", "command", v || undefined)
|
||||
: mark("adapterConfig", "command", v || null)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder={
|
||||
adapterType === "codex_local"
|
||||
? "codex"
|
||||
: adapterType === "gemini_local"
|
||||
? "gemini"
|
||||
: adapterType === "hermes_local"
|
||||
? "hermes"
|
||||
: adapterType === "pi_local"
|
||||
? "pi"
|
||||
: adapterType === "cursor"
|
||||
? "agent"
|
||||
: adapterType === "opencode_local"
|
||||
? "opencode"
|
||||
: "claude"
|
||||
({
|
||||
claude_local: "claude",
|
||||
codex_local: "codex",
|
||||
gemini_local: "gemini",
|
||||
pi_local: "pi",
|
||||
cursor: "agent",
|
||||
opencode_local: "opencode",
|
||||
} as Record<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
|
@ -748,18 +744,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}
|
||||
open={modelOpen}
|
||||
onOpenChange={setModelOpen}
|
||||
allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"}
|
||||
required={adapterType === "opencode_local" || adapterType === "hermes_local"}
|
||||
allowDefault={adapterType !== "opencode_local"}
|
||||
required={adapterType === "opencode_local"}
|
||||
groupByProvider={adapterType === "opencode_local"}
|
||||
creatable={adapterType === "hermes_local"}
|
||||
detectedModel={adapterType === "hermes_local" ? detectedModel : null}
|
||||
onDetectModel={adapterType === "hermes_local"
|
||||
? async () => {
|
||||
const result = await refetchDetectedModel();
|
||||
return result.data?.model ?? null;
|
||||
}
|
||||
: undefined}
|
||||
detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined}
|
||||
creatable
|
||||
detectedModel={detectedModel}
|
||||
detectedModelCandidates={[]}
|
||||
onDetectModel={async () => {
|
||||
const result = await refetchDetectedModel();
|
||||
return result.data?.model ?? null;
|
||||
}}
|
||||
detectModelLabel="Detect model"
|
||||
emptyDetectHint="No model detected. Select or enter one manually."
|
||||
/>
|
||||
{fetchedModelsError && (
|
||||
<p className="text-xs text-destructive">
|
||||
|
|
@ -831,7 +827,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ extraArgs: v })
|
||||
: mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : undefined)
|
||||
: mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : null)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
|
|
@ -1024,37 +1020,37 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
|||
|
||||
/* ---- Internal sub-components ---- */
|
||||
|
||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
|
||||
|
||||
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
||||
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
||||
...AGENT_ADAPTER_TYPES.map((t) => ({
|
||||
value: t,
|
||||
label: adapterLabels[t] ?? t,
|
||||
comingSoon: !ENABLED_ADAPTER_TYPES.has(t),
|
||||
})),
|
||||
];
|
||||
|
||||
function AdapterTypeDropdown({
|
||||
value,
|
||||
onChange,
|
||||
disabledTypes,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (type: string) => void;
|
||||
disabledTypes: Set<string>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const adapterList = useMemo(
|
||||
() =>
|
||||
listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)).filter(
|
||||
(item) => !disabledTypes.has(item.value),
|
||||
),
|
||||
[disabledTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
|
||||
<span>{adapterLabels[value] ?? value}</span>
|
||||
<span>{adapterLabels[value] ?? getAdapterLabel(value)}</span>
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||
{ADAPTER_DISPLAY_LIST.map((item) => (
|
||||
{adapterList.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
disabled={item.comingSoon}
|
||||
|
|
@ -1066,7 +1062,10 @@ function AdapterTypeDropdown({
|
|||
item.value === value && !item.comingSoon && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!item.comingSoon) onChange(item.value);
|
||||
if (!item.comingSoon) {
|
||||
onChange(item.value);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
|
|
@ -1357,8 +1356,10 @@ function ModelDropdown({
|
|||
groupByProvider,
|
||||
creatable,
|
||||
detectedModel,
|
||||
detectedModelCandidates,
|
||||
onDetectModel,
|
||||
detectModelLabel,
|
||||
emptyDetectHint,
|
||||
}: {
|
||||
models: AdapterModel[];
|
||||
value: string;
|
||||
|
|
@ -1370,8 +1371,10 @@ function ModelDropdown({
|
|||
groupByProvider: boolean;
|
||||
creatable?: boolean;
|
||||
detectedModel?: string | null;
|
||||
detectedModelCandidates?: string[];
|
||||
onDetectModel?: () => Promise<string | null>;
|
||||
detectModelLabel?: string;
|
||||
emptyDetectHint?: string;
|
||||
}) {
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
const [detectingModel, setDetectingModel] = useState(false);
|
||||
|
|
@ -1382,8 +1385,19 @@ function ModelDropdown({
|
|||
manualModel &&
|
||||
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
|
||||
);
|
||||
// Model IDs already shown as detected/candidate badges — exclude from regular list
|
||||
const promotedModelIds = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
if (detectedModel) set.add(detectedModel);
|
||||
for (const c of detectedModelCandidates ?? []) {
|
||||
if (c) set.add(c);
|
||||
}
|
||||
return set;
|
||||
}, [detectedModel, detectedModelCandidates]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
return models.filter((m) => {
|
||||
if (promotedModelIds.has(m.id)) return false;
|
||||
if (!modelSearch.trim()) return true;
|
||||
const q = modelSearch.toLowerCase();
|
||||
const provider = extractProviderId(m.id) ?? "";
|
||||
|
|
@ -1393,7 +1407,7 @@ function ModelDropdown({
|
|||
provider.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [models, modelSearch]);
|
||||
}, [models, modelSearch, promotedModelIds]);
|
||||
const groupedModels = useMemo(() => {
|
||||
if (!groupByProvider) {
|
||||
return [
|
||||
|
|
@ -1474,7 +1488,7 @@ function ModelDropdown({
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDetectModel && !detectedModel && !modelSearch.trim() && (
|
||||
{onDetectModel && !modelSearch.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
|
||||
|
|
@ -1487,10 +1501,10 @@ function ModelDropdown({
|
|||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
{detectingModel ? "Detecting..." : (detectModelLabel ?? "Detect from config")}
|
||||
{detectingModel ? "Detecting..." : detectedModel ? (detectModelLabel?.replace(/^Detect\b/, "Re-detect") ?? "Re-detect from config") : (detectModelLabel ?? "Detect from config")}
|
||||
</button>
|
||||
)}
|
||||
{value && !models.some((m) => m.id === value) && (
|
||||
{value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
|
@ -1501,7 +1515,7 @@ function ModelDropdown({
|
|||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
|
||||
{value}
|
||||
{models.find((m) => m.id === value)?.label ?? value}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
|
||||
current
|
||||
|
|
@ -1520,13 +1534,38 @@ function ModelDropdown({
|
|||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
|
||||
{detectedModel}
|
||||
{models.find((m) => m.id === detectedModel)?.label ?? detectedModel}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
|
||||
detected
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{detectedModelCandidates
|
||||
?.filter((candidate) => candidate && candidate !== detectedModel && candidate !== value)
|
||||
.map((candidate) => {
|
||||
const entry = models.find((m) => m.id === candidate);
|
||||
return (
|
||||
<button
|
||||
key={`detected-${candidate}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(candidate);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={candidate}>
|
||||
{entry?.label ?? candidate}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-sky-500/15 text-sky-400 border border-sky-500/20">
|
||||
config
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{allowDefault && (
|
||||
<button
|
||||
|
|
@ -1584,11 +1623,11 @@ function ModelDropdown({
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
{filteredModels.length === 0 && !canCreateManualModel && (
|
||||
{filteredModels.length === 0 && !canCreateManualModel && promotedModelIds.size === 0 && (
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{onDetectModel
|
||||
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
|
||||
? (emptyDetectHint ?? "No model detected yet. Enter a provider/model manually.")
|
||||
: "No models found."}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue