mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
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:
parent
0651f48f6c
commit
4efe018a8f
6 changed files with 336 additions and 40 deletions
90
ui/src/adapters/disabled-overrides-store.ts
Normal file
90
ui/src/adapters/disabled-overrides-store.ts
Normal 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();
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue