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
This commit is contained in:
HenkDz 2026-04-04 12:40:39 +01:00
parent 0651f48f6c
commit 4efe018a8f
6 changed files with 336 additions and 40 deletions

View file

@ -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<string>();
// ── 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<string> {
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();

View file

@ -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,
};

View file

@ -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<string, UIAdapterModule>();
// 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<string>();
// Original builtin adapters stored for restoration when external overrides
// are deactivated or removed.
const builtinAdaptersByType = new Map<string, UIAdapterModule>();
// Tracks which builtin types currently have an active external override.
const activeExternalOverrides = new Set<string>();
// 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<string, number>();
// 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,
});
}
}

View file

@ -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<string> {
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<string> {
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,
})),
);
}