From 4efe018a8feb317a0dfd26f4068dba6a5fc7a783 Mon Sep 17 00:00:00 2001 From: HenkDz Date: Sat, 4 Apr 2026 12:40:39 +0100 Subject: [PATCH] fix(ui): external adapter UI parser can now override builtin parsers Builtin adapter types (hermes_local, openclaw_gateway, etc.) could not be overridden by external adapters on the UI side. The registry always returned the built-in parser, ignoring the external ui-parser.js shipped by packages like hermes-paperclip-adapter. Changes: - registry.ts: full override lifecycle with generation guard for stale loads - disabled-overrides-store.ts: client-side override pause state with useSyncExternalStore reactivity (persisted to localStorage) - use-disabled-adapters.ts: subscribe to override store changes - AdapterManager.tsx: separate controls for override pause (client-side) vs menu visibility (server-side), virtual builtin rows with badges - adapters.ts: allow reload/reinstall of builtin types when overridden --- server/src/routes/adapters.ts | 6 +- ui/src/adapters/disabled-overrides-store.ts | 90 +++++++++++++ ui/src/adapters/hermes-local/index.ts | 6 +- ui/src/adapters/registry.ts | 132 ++++++++++++++++++-- ui/src/adapters/use-disabled-adapters.ts | 14 ++- ui/src/pages/AdapterManager.tsx | 128 ++++++++++++++++--- 6 files changed, 336 insertions(+), 40 deletions(-) create mode 100644 ui/src/adapters/disabled-overrides-store.ts diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 09761412..ab84a4bc 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -437,8 +437,8 @@ export function adapterRoutes() { const type = req.params.type; - // Built-in adapters cannot be reloaded - if (BUILTIN_ADAPTER_TYPES.has(type)) { + // Built-in adapters cannot be reloaded unless overridden by an external one + if (BUILTIN_ADAPTER_TYPES.has(type) && !getAdapterPluginByType(type)) { res.status(400).json({ error: "Cannot reload built-in adapter." }); return; } @@ -489,7 +489,7 @@ export function adapterRoutes() { const type = req.params.type; - if (BUILTIN_ADAPTER_TYPES.has(type)) { + if (BUILTIN_ADAPTER_TYPES.has(type) && !getAdapterPluginByType(type)) { res.status(400).json({ error: "Cannot reinstall built-in adapter." }); return; } diff --git a/ui/src/adapters/disabled-overrides-store.ts b/ui/src/adapters/disabled-overrides-store.ts new file mode 100644 index 00000000..aaf3f7a7 --- /dev/null +++ b/ui/src/adapters/disabled-overrides-store.ts @@ -0,0 +1,90 @@ +/** + * Client-side store for disabled external adapter overrides. + * + * When an external adapter overrides a builtin type, the user may want to + * pause the override (use the builtin parser) without hiding the type from + * menus entirely. This is separate from the server's per-type `disabled` + * flag which controls menu visibility. + * + * Persisted to localStorage so it survives page reloads. + * + * Implements the React external store pattern (subscribe/getSnapshot) + * so that components using useSyncExternalStore re-render on changes. + */ + +const STORAGE_KEY = "paperclip:disabled-overrides"; + +let disabledOverrides = new Set(); + +// ── React external store plumbing ──────────────────────────────────── + +/** Monotonically increasing version — changes on every mutation. */ +let snapshotVersion = 0; + +const listeners = new Set<() => void>(); + +/** Subscribe to store changes (for useSyncExternalStore). */ +export function subscribeToOverrides(callback: () => void): () => void { + listeners.add(callback); + return () => listeners.delete(callback); +} + +/** + * Return a value that changes whenever the store changes. + * React compares this with Object.is to decide whether to re-render. + */ +export function getOverridesSnapshot(): number { + return snapshotVersion; +} + +function emitChange(): void { + snapshotVersion++; + for (const fn of listeners) fn(); +} + +// ── Public API ─────────────────────────────────────────────────────── + +/** Check if the external override for a builtin type is paused. */ +export function isOverrideDisabled(type: string): boolean { + return disabledOverrides.has(type); +} + +/** Pause or resume an external override. */ +export function setOverrideDisabled(type: string, disabled: boolean): void { + if (disabled) { + disabledOverrides.add(type); + } else { + disabledOverrides.delete(type); + } + persist(); + emitChange(); +} + +/** Get all types with paused overrides (sync read). */ +export function getDisabledOverrides(): Set { + return disabledOverrides; +} + +// ── Persistence ────────────────────────────────────────────────────── + +function persist(): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify([...disabledOverrides])); + } catch { + // localStorage unavailable — no-op + } +} + +function hydrate(): void { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + disabledOverrides = new Set(JSON.parse(raw)); + } + } catch { + // corrupt or unavailable — start empty + } +} + +// Hydrate on module load +hydrate(); diff --git a/ui/src/adapters/hermes-local/index.ts b/ui/src/adapters/hermes-local/index.ts index 93d9ed6a..c037a747 100644 --- a/ui/src/adapters/hermes-local/index.ts +++ b/ui/src/adapters/hermes-local/index.ts @@ -1,12 +1,12 @@ import type { UIAdapterModule } from "../types"; import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui"; -import { HermesLocalConfigFields } from "./config-fields"; +import { SchemaConfigFields, buildSchemaAdapterConfig } from "../schema-config-fields"; import { buildHermesConfig } from "hermes-paperclip-adapter/ui"; export const hermesLocalUIAdapter: UIAdapterModule = { type: "hermes_local", label: "Hermes Agent", parseStdoutLine: parseHermesStdoutLine, - ConfigFields: HermesLocalConfigFields, - buildAdapterConfig: buildHermesConfig, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildSchemaAdapterConfig, }; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 89e15b1e..8ce7c566 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -9,12 +9,28 @@ import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { hermesLocalUIAdapter } from "./hermes-local"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; -import { loadDynamicParser } from "./dynamic-loader"; +import { loadDynamicParser, invalidateDynamicParser } from "./dynamic-loader"; import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fields"; const uiAdapters: UIAdapterModule[] = []; const adaptersByType = new Map(); +// Types registered at module load time — allowed to be overridden by +// external adapters that ship their own ui-parser.js via the server. +const builtinTypes = new Set(); + +// Original builtin adapters stored for restoration when external overrides +// are deactivated or removed. +const builtinAdaptersByType = new Map(); + +// Tracks which builtin types currently have an active external override. +const activeExternalOverrides = new Set(); + +// Generation counter to discard stale dynamic parser loads. When an override +// is deactivated while a load is in-flight, the generation is bumped and the +// stale result is discarded in its .then() handler. +const overrideGeneration = new Map(); + // Subscriber list — components can register to be notified when adapters change // (e.g., when a dynamic parser replaces a placeholder). const adapterChangeListeners = new Set<() => void>(); @@ -42,6 +58,8 @@ function registerBuiltInUIAdapters() { processUIAdapter, httpUIAdapter, ]) { + builtinTypes.add(adapter.type); + builtinAdaptersByType.set(adapter.type, adapter); registerUIAdapter(adapter); } } @@ -106,20 +124,108 @@ export function getUIAdapter(type: string): UIAdapterModule { } /** - * Ensure external adapter types (from the server's /api/adapters response) - * are registered in the UI adapter list so they appear in dropdowns. + * Keep the UI adapter registry in sync with the server's adapter list. * - * For each type not already registered, creates a placeholder module that - * uses the process adapter defaults and kicks off dynamic parser loading. - * Once the parser resolves, the placeholder is replaced with the real one. + * Two concerns: + * + * 1. **Builtin overrides** — when an external adapter ships a ui-parser.js for a + * builtin type, the external parser takes priority. When the external is + * disabled or removed the original builtin parser is restored transparently. + * A generation counter guards against stale loads that resolve after the + * override has been torn down. + * + * 2. **Non-builtin externals** — register a bridge adapter that lazily loads the + * dynamic parser on first stdout line, falling back to the generic process + * adapter. Once the parser resolves the bridge is replaced. */ export function syncExternalAdapters( - serverAdapters: { type: string; label: string }[], + serverAdapters: { + type: string; + label: string; + disabled?: boolean; + /** When true, the external override for a builtin type is client-side paused. */ + overrideDisabled?: boolean; + }[], ): void { + const enabledExternalTypes = new Set( + serverAdapters.filter((a) => !a.disabled && !a.overrideDisabled).map((a) => a.type), + ); + const allExternalTypes = new Set( + serverAdapters.map((a) => a.type), + ); + + // ── Builtin override lifecycle ────────────────────────────────────────── + + for (const builtinType of builtinTypes) { + const originalBuiltin = builtinAdaptersByType.get(builtinType); + if (!originalBuiltin) continue; + + const hasExternal = allExternalTypes.has(builtinType); + const externalEnabled = enabledExternalTypes.has(builtinType); + const wasOverridden = activeExternalOverrides.has(builtinType); + + if (hasExternal && externalEnabled && !wasOverridden) { + // Activate: external just became active → replace builtin with bridge. + activeExternalOverrides.add(builtinType); + + const gen = (overrideGeneration.get(builtinType) ?? 0) + 1; + overrideGeneration.set(builtinType, gen); + + let loadStarted = false; + const fallbackParser = originalBuiltin.parseStdoutLine; + const externalEntry = serverAdapters.find((a) => a.type === builtinType); + const label = externalEntry?.label ?? builtinType; + + registerUIAdapter({ + type: builtinType, + label, + parseStdoutLine: (line: string, ts: string) => { + if (!loadStarted) { + loadStarted = true; + loadDynamicParser(builtinType).then((parser) => { + // Discard if the override was torn down while the load was in-flight. + if (parser && overrideGeneration.get(builtinType) === gen) { + registerUIAdapter({ + type: builtinType, + label, + parseStdoutLine: parser, + ConfigFields: originalBuiltin.ConfigFields, + buildAdapterConfig: originalBuiltin.buildAdapterConfig, + }); + } + }); + } + return fallbackParser(line, ts); + }, + ConfigFields: originalBuiltin.ConfigFields, + buildAdapterConfig: originalBuiltin.buildAdapterConfig, + }); + } else if ((!hasExternal || !externalEnabled) && wasOverridden) { + // Deactivate: external disabled or removed → restore builtin. + activeExternalOverrides.delete(builtinType); + overrideGeneration.delete(builtinType); + invalidateDynamicParser(builtinType); + registerUIAdapter(originalBuiltin); + } + } + + // ── Non-builtin externals ─────────────────────────────────────────────── + for (const { type, label } of serverAdapters) { - if (adaptersByType.has(type)) continue; + if (builtinTypes.has(type)) continue; // handled above + + const existing = adaptersByType.get(type); + + // If this type already has an externally-loaded dynamic parser, skip — + // it was loaded from disk on a previous sync. Only re-trigger loading + // when the server returns a new external adapter that hasn't been loaded yet. + if (existing && existing !== processUIAdapter) continue; let loadStarted = false; + // Use the existing built-in parser as fallback (if any) so we don't + // regress to the generic process parser while the dynamic one loads. + const fallbackParser = existing?.parseStdoutLine ?? processUIAdapter.parseStdoutLine; + registerUIAdapter({ type, label, @@ -132,16 +238,16 @@ export function syncExternalAdapters( type, label, parseStdoutLine: parser, - ConfigFields: SchemaConfigFields, - buildAdapterConfig: buildSchemaAdapterConfig, + ConfigFields: existing?.ConfigFields ?? SchemaConfigFields, + buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig, }); } }); } - return processUIAdapter.parseStdoutLine(line, ts); + return fallbackParser(line, ts); }, - ConfigFields: SchemaConfigFields, - buildAdapterConfig: buildSchemaAdapterConfig, + ConfigFields: existing?.ConfigFields ?? SchemaConfigFields, + buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig, }); } } diff --git a/ui/src/adapters/use-disabled-adapters.ts b/ui/src/adapters/use-disabled-adapters.ts index 9810d094..f2634a6a 100644 --- a/ui/src/adapters/use-disabled-adapters.ts +++ b/ui/src/adapters/use-disabled-adapters.ts @@ -1,7 +1,8 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useSyncExternalStore } from "react"; import { useQuery } from "@tanstack/react-query"; import { adaptersApi } from "@/api/adapters"; import { setDisabledAdapterTypes } from "@/adapters/disabled-store"; +import { isOverrideDisabled, subscribeToOverrides, getOverridesSnapshot } from "@/adapters/disabled-overrides-store"; import { syncExternalAdapters } from "@/adapters/registry"; import { queryKeys } from "@/lib/queryKeys"; @@ -23,6 +24,10 @@ export function useDisabledAdaptersSync(): Set { staleTime: 5 * 60 * 1000, }); + // Subscribe to the client-side override store so that + // syncExternalAdapters() re-runs when overrides are toggled. + useSyncExternalStore(subscribeToOverrides, getOverridesSnapshot); + // Eagerly register external adapter types in the UI registry so that // consumers calling listUIAdapters() in the same render cycle see them. // This is idempotent — already-registered types are skipped. @@ -30,7 +35,12 @@ export function useDisabledAdaptersSync(): Set { syncExternalAdapters( adapters .filter((a) => a.source === "external") - .map((a) => ({ type: a.type, label: a.label })), + .map((a) => ({ + type: a.type, + label: a.label, + disabled: a.disabled, + overrideDisabled: a.overriddenBuiltin ? isOverrideDisabled(a.type) : undefined, + })), ); } diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index da85689d..d583e28a 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -4,7 +4,7 @@ * 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 { 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"; @@ -32,6 +32,7 @@ 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, @@ -43,6 +44,13 @@ function AdapterRow({ 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; @@ -53,6 +61,11 @@ function AdapterRow({ 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 (
  • @@ -78,9 +91,14 @@ function AdapterRow({ Overrides built-in )} + {overriddenBy && ( + + Overridden by {overriddenBy} + + )} {adapter.disabled && ( - Hidden from menus + {disabledBadgeLabel ?? "Hidden from menus"} )} @@ -121,7 +139,9 @@ function AdapterRow({ variant="outline" size="icon-sm" className="h-8 w-8" - title={adapter.disabled ? "Show in agent menus" : "Hide from agent menus"} + title={adapter.disabled + ? (toggleTitleEnabled ?? "Show in agent menus") + : (toggleTitleDisabled ?? "Hide from agent menus")} disabled={isToggling} onClick={() => onToggle(adapter.type, !adapter.disabled)} > @@ -238,6 +258,11 @@ export function AdapterManager() { 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); @@ -284,7 +309,9 @@ export function AdapterManager() { const removeMutation = useMutation({ mutationFn: (type: string) => adaptersApi.remove(type), - onSuccess: () => { + onSuccess: (_result, type) => { + // Clean up client-side override state when the external is removed. + setOverrideDisabled(type, false); invalidate(); pushToast({ title: "Adapter removed", tone: "success" }); }, @@ -341,6 +368,27 @@ export function AdapterManager() { 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; @@ -496,20 +544,44 @@ export function AdapterManager() { ) : (
      - {externalAdapters.map((adapter) => ( - 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} - /> - ))} + {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} + /> + ); + })}
    )} @@ -521,7 +593,7 @@ export function AdapterManager() {

    Built-in Adapters

    - {builtinAdapters.length === 0 ? ( + {builtinAdapters.length === 0 && overriddenBuiltins.length === 0 ? (
    No built-in adapters found.
    ) : (
      @@ -535,6 +607,24 @@ export function AdapterManager() { isToggling={isMutating} /> ))} + {overriddenBuiltins.map((virtual) => ( + toggleMutation.mutate({ type, disabled })} + onRemove={() => {}} + isToggling={isMutating} + overriddenBy={virtual.overridePaused ? undefined : virtual.overriddenBy} + /> + ))}
    )}