mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
Merge branch 'master' into fix/configurable-claimed-api-key-path
This commit is contained in:
commit
c171ff901c
165 changed files with 11349 additions and 1649 deletions
157
ui/src/adapters/adapter-display-registry.ts
Normal file
157
ui/src/adapters/adapter-display-registry.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Single source of truth for adapter display metadata.
|
||||
*
|
||||
* Built-in adapters have entries in `adapterDisplayMap`. External (plugin)
|
||||
* adapters get sensible defaults derived from their type string via
|
||||
* `getAdapterDisplay()`.
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Code,
|
||||
Gem,
|
||||
MousePointer2,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Cpu,
|
||||
} from "lucide-react";
|
||||
import { OpenCodeLogoIcon } from "@/components/OpenCodeLogoIcon";
|
||||
import { HermesIcon } from "@/components/HermesIcon";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type suffix parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TYPE_SUFFIXES: Record<string, string> = {
|
||||
_local: "local",
|
||||
_gateway: "gateway",
|
||||
};
|
||||
|
||||
function getTypeSuffix(type: string): string | null {
|
||||
for (const [suffix, mode] of Object.entries(TYPE_SUFFIXES)) {
|
||||
if (type.endsWith(suffix)) return mode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function withSuffix(label: string, suffix: string | null): string {
|
||||
return suffix ? `${label} (${suffix})` : label;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display metadata per adapter type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AdapterDisplayInfo {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
recommended?: boolean;
|
||||
comingSoon?: boolean;
|
||||
disabledLabel?: string;
|
||||
}
|
||||
|
||||
const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
|
||||
claude_local: {
|
||||
label: "Claude Code",
|
||||
description: "Local Claude agent",
|
||||
icon: Sparkles,
|
||||
recommended: true,
|
||||
},
|
||||
codex_local: {
|
||||
label: "Codex",
|
||||
description: "Local Codex agent",
|
||||
icon: Code,
|
||||
recommended: true,
|
||||
},
|
||||
gemini_local: {
|
||||
label: "Gemini CLI",
|
||||
description: "Local Gemini agent",
|
||||
icon: Gem,
|
||||
},
|
||||
opencode_local: {
|
||||
label: "OpenCode",
|
||||
description: "Local multi-provider agent",
|
||||
icon: OpenCodeLogoIcon,
|
||||
},
|
||||
hermes_local: {
|
||||
label: "Hermes Agent",
|
||||
description: "Local Hermes CLI agent",
|
||||
icon: HermesIcon,
|
||||
},
|
||||
pi_local: {
|
||||
label: "Pi",
|
||||
description: "Local Pi agent",
|
||||
icon: Terminal,
|
||||
},
|
||||
cursor: {
|
||||
label: "Cursor",
|
||||
description: "Local Cursor agent",
|
||||
icon: MousePointer2,
|
||||
},
|
||||
openclaw_gateway: {
|
||||
label: "OpenClaw Gateway",
|
||||
description: "Invoke OpenClaw via gateway protocol",
|
||||
icon: Bot,
|
||||
comingSoon: true,
|
||||
disabledLabel: "Configure OpenClaw within the App",
|
||||
},
|
||||
process: {
|
||||
label: "Process",
|
||||
description: "Internal process adapter",
|
||||
icon: Cpu,
|
||||
comingSoon: true,
|
||||
},
|
||||
http: {
|
||||
label: "HTTP",
|
||||
description: "Internal HTTP adapter",
|
||||
icon: Cpu,
|
||||
comingSoon: true,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function humanizeType(type: string): string {
|
||||
// Strip known type suffixes so "droid_local" → "Droid", not "Droid Local"
|
||||
let base = type;
|
||||
for (const suffix of Object.keys(TYPE_SUFFIXES)) {
|
||||
if (base.endsWith(suffix)) {
|
||||
base = base.slice(0, -suffix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return base.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function getAdapterLabel(type: string): string {
|
||||
const base = adapterDisplayMap[type]?.label ?? humanizeType(type);
|
||||
return withSuffix(base, getTypeSuffix(type));
|
||||
}
|
||||
|
||||
export function getAdapterLabels(): Record<string, string> {
|
||||
const suffixed: Record<string, string> = {};
|
||||
for (const [type, info] of Object.entries(adapterDisplayMap)) {
|
||||
suffixed[type] = withSuffix(info.label, getTypeSuffix(type));
|
||||
}
|
||||
return suffixed;
|
||||
}
|
||||
|
||||
export function getAdapterDisplay(type: string): AdapterDisplayInfo {
|
||||
const known = adapterDisplayMap[type];
|
||||
if (known) return known;
|
||||
|
||||
const suffix = getTypeSuffix(type);
|
||||
const label = withSuffix(humanizeType(type), suffix);
|
||||
return {
|
||||
label,
|
||||
description: suffix ? `External ${suffix} adapter` : "External adapter",
|
||||
icon: Cpu,
|
||||
};
|
||||
}
|
||||
|
||||
export function isKnownAdapterType(type: string): boolean {
|
||||
return type in adapterDisplayMap;
|
||||
}
|
||||
33
ui/src/adapters/disabled-store.ts
Normal file
33
ui/src/adapters/disabled-store.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Client-side store for disabled adapter types.
|
||||
*
|
||||
* Hydrated from the server's GET /api/adapters response.
|
||||
* Provides synchronous reads so module-level constants can filter against it.
|
||||
* Falls back to "nothing disabled" before the first hydration.
|
||||
*
|
||||
* Usage in components:
|
||||
* useQuery + adaptersApi.list() populates the store automatically.
|
||||
*
|
||||
* Usage in non-React code:
|
||||
* import { isAdapterTypeHidden } from "@/adapters/disabled-store";
|
||||
*/
|
||||
|
||||
let disabledTypes = new Set<string>();
|
||||
|
||||
/** Check if an adapter type is hidden from menus (sync read). */
|
||||
export function isAdapterTypeHidden(type: string): boolean {
|
||||
return disabledTypes.has(type);
|
||||
}
|
||||
|
||||
/** Get all hidden adapter types (sync read). */
|
||||
export function getHiddenAdapterTypes(): Set<string> {
|
||||
return disabledTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the store from a server response.
|
||||
* Called by components that fetch the adapters list.
|
||||
*/
|
||||
export function setDisabledAdapterTypes(types: string[]): void {
|
||||
disabledTypes = new Set(types);
|
||||
}
|
||||
122
ui/src/adapters/dynamic-loader.ts
Normal file
122
ui/src/adapters/dynamic-loader.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Dynamic UI parser loading for external adapters.
|
||||
*
|
||||
* When the Paperclip UI encounters an adapter type that doesn't have a
|
||||
* built-in parser (e.g., an external adapter loaded via the plugin system),
|
||||
* it fetches the parser JS from `/api/adapters/:type/ui-parser.js` and
|
||||
* evaluates it to create a `parseStdoutLine` function.
|
||||
*
|
||||
* The parser module must export:
|
||||
* - `parseStdoutLine(line: string, ts: string): TranscriptEntry[]`
|
||||
* - optionally `createStdoutParser(): { parseLine, reset }` for stateful parsers
|
||||
*
|
||||
* This is the bridge between the server-side plugin system and the client-side
|
||||
* UI rendering. Adapter developers ship a `dist/ui-parser.js` with zero
|
||||
* runtime dependencies, and Paperclip's UI loads it on demand.
|
||||
*/
|
||||
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
import type { StatefulStdoutParser, StdoutLineParser, StdoutParserFactory } from "./types";
|
||||
|
||||
interface DynamicParserModule {
|
||||
parseStdoutLine: StdoutLineParser;
|
||||
createStdoutParser?: StdoutParserFactory;
|
||||
}
|
||||
|
||||
// Cache of dynamically loaded parsers by adapter type.
|
||||
// Once loaded, the parser is reused for all runs of that adapter type.
|
||||
const dynamicParserCache = new Map<string, DynamicParserModule>();
|
||||
|
||||
// Track which types we've already attempted to load (to avoid repeat 404s).
|
||||
const failedLoads = new Set<string>();
|
||||
|
||||
/**
|
||||
* Dynamically load a UI parser for an adapter type from the server API.
|
||||
*
|
||||
* Fetches `/api/adapters/:type/ui-parser.js`, evaluates the module source
|
||||
* in a scoped context, and extracts the `parseStdoutLine` export.
|
||||
*
|
||||
* @returns A StdoutLineParser function, or null if unavailable.
|
||||
*/
|
||||
export async function loadDynamicParser(adapterType: string): Promise<DynamicParserModule | null> {
|
||||
// Return cached parser if already loaded
|
||||
const cached = dynamicParserCache.get(adapterType);
|
||||
if (cached) return cached;
|
||||
|
||||
// Don't retry types that previously 404'd
|
||||
if (failedLoads.has(adapterType)) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/ui-parser.js`);
|
||||
if (!response.ok) {
|
||||
failedLoads.add(adapterType);
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = await response.text();
|
||||
|
||||
// Evaluate the module source using URL.createObjectURL + dynamic import().
|
||||
// This properly supports ESM modules with `export` statements.
|
||||
// (new Function("exports", source) would fail with SyntaxError on `export` keywords.)
|
||||
const blob = new Blob([source], { type: "application/javascript" });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
let parserModule: DynamicParserModule;
|
||||
|
||||
try {
|
||||
const mod = await import(/* @vite-ignore */ blobUrl);
|
||||
|
||||
// Prefer the factory function (stateful parser) if available,
|
||||
// fall back to the static parseStdoutLine function.
|
||||
if (typeof mod.createStdoutParser === "function") {
|
||||
const createStdoutParser = mod.createStdoutParser as StdoutParserFactory;
|
||||
parserModule = {
|
||||
createStdoutParser,
|
||||
// Fallback for callers that only know about parseStdoutLine.
|
||||
parseStdoutLine:
|
||||
typeof mod.parseStdoutLine === "function"
|
||||
? (mod.parseStdoutLine as StdoutLineParser)
|
||||
: ((line: string, ts: string) => {
|
||||
const parser = createStdoutParser() as StatefulStdoutParser;
|
||||
const entries = parser.parseLine(line, ts);
|
||||
parser.reset();
|
||||
return entries;
|
||||
}),
|
||||
};
|
||||
} else if (typeof mod.parseStdoutLine === "function") {
|
||||
parserModule = {
|
||||
parseStdoutLine: mod.parseStdoutLine as StdoutLineParser,
|
||||
};
|
||||
} else {
|
||||
console.warn(`[adapter-ui-loader] Module for "${adapterType}" exports neither parseStdoutLine nor createStdoutParser`);
|
||||
failedLoads.add(adapterType);
|
||||
return null;
|
||||
}
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
// Cache for reuse
|
||||
dynamicParserCache.set(adapterType, parserModule);
|
||||
console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`);
|
||||
return parserModule;
|
||||
} catch (err) {
|
||||
console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err);
|
||||
failedLoads.add(adapterType);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a cached dynamic parser, removing it from both the parser cache
|
||||
* and the failed-loads set so that the next load attempt will try again.
|
||||
*/
|
||||
export function invalidateDynamicParser(adapterType: string): boolean {
|
||||
const wasCached = dynamicParserCache.has(adapterType);
|
||||
dynamicParserCache.delete(adapterType);
|
||||
failedLoads.delete(adapterType);
|
||||
if (wasCached) {
|
||||
console.info(`[adapter-ui-loader] Invalidated dynamic UI parser for "${adapterType}"`);
|
||||
}
|
||||
return wasCached;
|
||||
}
|
||||
|
|
@ -1,49 +1,49 @@
|
|||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
} from "../../components/agent-config-primitives";
|
||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
const instructionsFileHint =
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
||||
|
||||
export function HermesLocalConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
hideInstructionsFile,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
if (hideInstructionsFile) return null;
|
||||
return (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
} from "../../components/agent-config-primitives";
|
||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
const instructionsFileHint =
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
||||
|
||||
export function HermesLocalConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
hideInstructionsFile,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
if (hideInstructionsFile) return null;
|
||||
return (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import type { UIAdapterModule } from "../types";
|
||||
import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui";
|
||||
import { HermesLocalConfigFields } from "./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,
|
||||
};
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui";
|
||||
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: SchemaConfigFields,
|
||||
buildAdapterConfig: buildSchemaAdapterConfig,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
export { getUIAdapter, listUIAdapters } from "./registry";
|
||||
export {
|
||||
getUIAdapter,
|
||||
listUIAdapters,
|
||||
findUIAdapter,
|
||||
registerUIAdapter,
|
||||
unregisterUIAdapter,
|
||||
syncExternalAdapters,
|
||||
onAdapterChange,
|
||||
} from "./registry";
|
||||
export { buildTranscript } from "./transcript";
|
||||
export type {
|
||||
TranscriptEntry,
|
||||
|
|
|
|||
33
ui/src/adapters/metadata.test.ts
Normal file
33
ui/src/adapters/metadata.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isEnabledAdapterType, listAdapterOptions } from "./metadata";
|
||||
import type { UIAdapterModule } from "./types";
|
||||
|
||||
const externalAdapter: UIAdapterModule = {
|
||||
type: "external_test",
|
||||
label: "External Test",
|
||||
parseStdoutLine: () => [],
|
||||
ConfigFields: () => null,
|
||||
buildAdapterConfig: () => ({}),
|
||||
};
|
||||
|
||||
describe("adapter metadata", () => {
|
||||
it("treats registered external adapters as enabled by default", () => {
|
||||
expect(isEnabledAdapterType("external_test")).toBe(true);
|
||||
|
||||
expect(
|
||||
listAdapterOptions((type) => type, [externalAdapter]),
|
||||
).toEqual([
|
||||
{
|
||||
value: "external_test",
|
||||
label: "external_test",
|
||||
comingSoon: false,
|
||||
hidden: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps intentionally withheld built-in adapters marked as coming soon", () => {
|
||||
expect(isEnabledAdapterType("process")).toBe(false);
|
||||
expect(isEnabledAdapterType("http")).toBe(false);
|
||||
});
|
||||
});
|
||||
75
ui/src/adapters/metadata.ts
Normal file
75
ui/src/adapters/metadata.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Adapter metadata utilities — built on top of the display registry and UI adapter list.
|
||||
*
|
||||
* This module bridges the static display metadata with the dynamic adapter registry.
|
||||
* "Coming soon" status is derived from the display registry's `comingSoon` flag.
|
||||
* "Hidden" status comes from the disabled-adapter store (server-side toggle).
|
||||
*/
|
||||
import type { UIAdapterModule } from "./types";
|
||||
import { listUIAdapters } from "./registry";
|
||||
import { isAdapterTypeHidden } from "./disabled-store";
|
||||
import { getAdapterLabel, getAdapterDisplay } from "./adapter-display-registry";
|
||||
|
||||
export interface AdapterOptionMetadata {
|
||||
value: string;
|
||||
label: string;
|
||||
comingSoon: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export function listKnownAdapterTypes(): string[] {
|
||||
return listUIAdapters().map((adapter) => adapter.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an adapter type is enabled (not "coming soon").
|
||||
* Unknown types (external adapters) are always considered enabled.
|
||||
*/
|
||||
export function isEnabledAdapterType(type: string): boolean {
|
||||
// Check display registry first — built-in adapters like process/http are
|
||||
// intentionally withheld even though they're registered as UI adapters.
|
||||
if (getAdapterDisplay(type).comingSoon) return false;
|
||||
// All other types (registered or external) are enabled.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an adapter type is a valid choice for new agent creation.
|
||||
* Includes all registered UI adapters (built-in + external) and
|
||||
* any non-"coming soon" adapter from the display registry.
|
||||
*/
|
||||
export function isValidAdapterType(type: string): boolean {
|
||||
if (getAdapterDisplay(type).comingSoon) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build option metadata for a list of adapters (for dropdowns).
|
||||
* `labelFor` callback allows callers to override labels; defaults to display registry.
|
||||
*/
|
||||
export function listAdapterOptions(
|
||||
labelFor?: (type: string) => string,
|
||||
adapters: UIAdapterModule[] = listUIAdapters(),
|
||||
): AdapterOptionMetadata[] {
|
||||
const getLabel = labelFor ?? getAdapterLabel;
|
||||
return adapters.map((adapter) => ({
|
||||
value: adapter.type,
|
||||
label: getLabel(adapter.type),
|
||||
comingSoon: !!getAdapterDisplay(adapter.type).comingSoon,
|
||||
hidden: isAdapterTypeHidden(adapter.type),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* List UI adapters excluding those hidden via the Adapters settings page.
|
||||
*/
|
||||
export function listVisibleUIAdapters(): UIAdapterModule[] {
|
||||
return listUIAdapters().filter((a) => !isAdapterTypeHidden(a.type));
|
||||
}
|
||||
|
||||
/**
|
||||
* List visible adapter types (for non-React contexts like module-level constants).
|
||||
*/
|
||||
export function listVisibleAdapterTypes(): string[] {
|
||||
return listVisibleUIAdapters().map((a) => a.type);
|
||||
}
|
||||
51
ui/src/adapters/registry.test.ts
Normal file
51
ui/src/adapters/registry.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
import type { UIAdapterModule } from "./types";
|
||||
import {
|
||||
findUIAdapter,
|
||||
getUIAdapter,
|
||||
listUIAdapters,
|
||||
registerUIAdapter,
|
||||
unregisterUIAdapter,
|
||||
} from "./registry";
|
||||
import { processUIAdapter } from "./process";
|
||||
import { SchemaConfigFields } from "./schema-config-fields";
|
||||
|
||||
const externalUIAdapter: UIAdapterModule = {
|
||||
type: "external_test",
|
||||
label: "External Test",
|
||||
parseStdoutLine: () => [],
|
||||
ConfigFields: () => null,
|
||||
buildAdapterConfig: () => ({}),
|
||||
};
|
||||
|
||||
describe("ui adapter registry", () => {
|
||||
beforeEach(() => {
|
||||
unregisterUIAdapter("external_test");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unregisterUIAdapter("external_test");
|
||||
});
|
||||
|
||||
it("registers adapters for lookup and listing", () => {
|
||||
registerUIAdapter(externalUIAdapter);
|
||||
|
||||
expect(findUIAdapter("external_test")).toBe(externalUIAdapter);
|
||||
expect(getUIAdapter("external_test")).toBe(externalUIAdapter);
|
||||
expect(listUIAdapters().some((adapter) => adapter.type === "external_test")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the process parser for unknown types after unregistering", () => {
|
||||
registerUIAdapter(externalUIAdapter);
|
||||
|
||||
unregisterUIAdapter("external_test");
|
||||
|
||||
expect(findUIAdapter("external_test")).toBeNull();
|
||||
const fallback = getUIAdapter("external_test");
|
||||
// Unknown types return a lazy-loading wrapper (for external adapters),
|
||||
// not the process adapter directly. The type is preserved.
|
||||
expect(fallback.type).toBe("external_test");
|
||||
// But it uses the schema-based config fields for external adapter forms.
|
||||
expect(fallback.ConfigFields).toBe(SchemaConfigFields);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,32 +3,256 @@ import { claudeLocalUIAdapter } from "./claude-local";
|
|||
import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { cursorLocalUIAdapter } from "./cursor";
|
||||
import { geminiLocalUIAdapter } from "./gemini-local";
|
||||
import { hermesLocalUIAdapter } from "./hermes-local";
|
||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||
import { piLocalUIAdapter } from "./pi-local";
|
||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||
import { hermesLocalUIAdapter } from "./hermes-local";
|
||||
import { processUIAdapter } from "./process";
|
||||
import { httpUIAdapter } from "./http";
|
||||
import { loadDynamicParser, invalidateDynamicParser } from "./dynamic-loader";
|
||||
import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fields";
|
||||
|
||||
const uiAdapters: UIAdapterModule[] = [
|
||||
claudeLocalUIAdapter,
|
||||
codexLocalUIAdapter,
|
||||
geminiLocalUIAdapter,
|
||||
hermesLocalUIAdapter,
|
||||
openCodeLocalUIAdapter,
|
||||
piLocalUIAdapter,
|
||||
cursorLocalUIAdapter,
|
||||
openClawGatewayUIAdapter,
|
||||
processUIAdapter,
|
||||
httpUIAdapter,
|
||||
];
|
||||
const uiAdapters: UIAdapterModule[] = [];
|
||||
const adaptersByType = new Map<string, UIAdapterModule>();
|
||||
|
||||
const adaptersByType = new Map<string, UIAdapterModule>(
|
||||
uiAdapters.map((a) => [a.type, a]),
|
||||
);
|
||||
// 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>();
|
||||
|
||||
/** Subscribe to adapter registry changes. Returns unsubscribe function. */
|
||||
export function onAdapterChange(fn: () => void): () => void {
|
||||
adapterChangeListeners.add(fn);
|
||||
return () => adapterChangeListeners.delete(fn);
|
||||
}
|
||||
|
||||
function notifyAdapterChange(): void {
|
||||
for (const fn of adapterChangeListeners) fn();
|
||||
}
|
||||
|
||||
function registerBuiltInUIAdapters() {
|
||||
for (const adapter of [
|
||||
claudeLocalUIAdapter,
|
||||
codexLocalUIAdapter,
|
||||
geminiLocalUIAdapter,
|
||||
hermesLocalUIAdapter,
|
||||
openCodeLocalUIAdapter,
|
||||
piLocalUIAdapter,
|
||||
cursorLocalUIAdapter,
|
||||
openClawGatewayUIAdapter,
|
||||
processUIAdapter,
|
||||
httpUIAdapter,
|
||||
]) {
|
||||
builtinTypes.add(adapter.type);
|
||||
builtinAdaptersByType.set(adapter.type, adapter);
|
||||
registerUIAdapter(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerUIAdapter(adapter: UIAdapterModule): void {
|
||||
const existingIndex = uiAdapters.findIndex((entry) => entry.type === adapter.type);
|
||||
if (existingIndex >= 0) {
|
||||
uiAdapters.splice(existingIndex, 1, adapter);
|
||||
} else {
|
||||
uiAdapters.push(adapter);
|
||||
}
|
||||
adaptersByType.set(adapter.type, adapter);
|
||||
notifyAdapterChange();
|
||||
}
|
||||
|
||||
export function unregisterUIAdapter(type: string): void {
|
||||
if (type === processUIAdapter.type || type === httpUIAdapter.type) return;
|
||||
const existingIndex = uiAdapters.findIndex((entry) => entry.type === type);
|
||||
if (existingIndex >= 0) {
|
||||
uiAdapters.splice(existingIndex, 1);
|
||||
}
|
||||
adaptersByType.delete(type);
|
||||
}
|
||||
|
||||
export function findUIAdapter(type: string): UIAdapterModule | null {
|
||||
return adaptersByType.get(type) ?? null;
|
||||
}
|
||||
|
||||
registerBuiltInUIAdapters();
|
||||
|
||||
export function getUIAdapter(type: string): UIAdapterModule {
|
||||
return adaptersByType.get(type) ?? processUIAdapter;
|
||||
const builtIn = adaptersByType.get(type);
|
||||
|
||||
if (!builtIn) {
|
||||
let loadStarted = false;
|
||||
return {
|
||||
type,
|
||||
label: type,
|
||||
parseStdoutLine: (line: string, ts: string) => {
|
||||
if (!loadStarted) {
|
||||
loadStarted = true;
|
||||
loadDynamicParser(type).then((parserModule) => {
|
||||
if (parserModule) {
|
||||
registerUIAdapter({
|
||||
type,
|
||||
label: type,
|
||||
parseStdoutLine: parserModule.parseStdoutLine,
|
||||
createStdoutParser: parserModule.createStdoutParser,
|
||||
ConfigFields: SchemaConfigFields,
|
||||
buildAdapterConfig: buildSchemaAdapterConfig,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return processUIAdapter.parseStdoutLine(line, ts);
|
||||
},
|
||||
ConfigFields: SchemaConfigFields,
|
||||
buildAdapterConfig: buildSchemaAdapterConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return builtIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the UI adapter registry in sync with the server's adapter list.
|
||||
*
|
||||
* 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;
|
||||
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((parserModule) => {
|
||||
// Discard if the override was torn down while the load was in-flight.
|
||||
if (parserModule && overrideGeneration.get(builtinType) === gen) {
|
||||
registerUIAdapter({
|
||||
type: builtinType,
|
||||
label,
|
||||
parseStdoutLine: parserModule.parseStdoutLine,
|
||||
createStdoutParser: parserModule.createStdoutParser,
|
||||
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 (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,
|
||||
parseStdoutLine: (line: string, ts: string) => {
|
||||
if (!loadStarted) {
|
||||
loadStarted = true;
|
||||
loadDynamicParser(type).then((parserModule) => {
|
||||
if (parserModule) {
|
||||
registerUIAdapter({
|
||||
type,
|
||||
label,
|
||||
parseStdoutLine: parserModule.parseStdoutLine,
|
||||
createStdoutParser: parserModule.createStdoutParser,
|
||||
ConfigFields: existing?.ConfigFields ?? SchemaConfigFields,
|
||||
buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return fallbackParser(line, ts);
|
||||
},
|
||||
ConfigFields: existing?.ConfigFields ?? SchemaConfigFields,
|
||||
buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function listUIAdapters(): UIAdapterModule[] {
|
||||
|
|
|
|||
507
ui/src/adapters/schema-config-fields.tsx
Normal file
507
ui/src/adapters/schema-config-fields.tsx
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
import type { AdapterConfigSchema, ConfigFieldSchema, CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
import type { AdapterConfigFieldsProps } from "./types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
DraftNumberInput,
|
||||
DraftTextarea,
|
||||
ToggleField,
|
||||
} from "../components/agent-config-primitives";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
// ── Select field (extracted to keep hooks at component top level) ──────
|
||||
function SelectField({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selectedOpt = options.find((o) => o.value === value);
|
||||
return (
|
||||
<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={!value ? "text-muted-foreground" : ""}>
|
||||
{selectedOpt?.label ?? value ?? "Select..."}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50 ${opt.value === value ? "bg-accent" : ""}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combobox: type-to-filter dropdown with free text fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ComboboxField({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
options: { label: string; value: string; group?: string }[];
|
||||
onChange: (val: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync filter with external value when it changes (e.g. provider switch resets model)
|
||||
useEffect(() => {
|
||||
setFilter("");
|
||||
}, [value]);
|
||||
|
||||
const filtered = options.filter((opt) => {
|
||||
if (!filter) return true;
|
||||
const q = filter.toLowerCase();
|
||||
return (
|
||||
opt.value.toLowerCase().includes(q) ||
|
||||
opt.label.toLowerCase().includes(q) ||
|
||||
(opt.group && opt.group.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
const selectedOpt = options.find((o) => o.value === value);
|
||||
const displayValue = filter || selectedOpt?.value || value || "";
|
||||
|
||||
// Group filtered options by `group` field if present
|
||||
const grouped = new Map<string, typeof filtered>();
|
||||
for (const opt of filtered) {
|
||||
const g = opt.group ?? "";
|
||||
if (!grouped.has(g)) grouped.set(g, []);
|
||||
grouped.get(g)!.push(opt);
|
||||
}
|
||||
|
||||
const select = useCallback(
|
||||
(val: string) => {
|
||||
onChange(val);
|
||||
setOpen(false);
|
||||
setFilter("");
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
// If exactly one match, select it. Otherwise commit the typed value.
|
||||
if (filtered.length === 1) {
|
||||
select(filtered[0].value);
|
||||
} else if (filter) {
|
||||
select(filter);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setFilter("");
|
||||
} else if (e.key === "ArrowDown" && !open) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="flex-1 rounded-l-md border border-r-0 border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 focus:z-10"
|
||||
value={displayValue}
|
||||
placeholder={placeholder ?? "Type or select..."}
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value);
|
||||
if (!open) setOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!open) setOpen(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Delay close to allow click on option to register
|
||||
setTimeout(() => setOpen(false), 150);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Popover open={open && filtered.length > 0} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="rounded-r-md border border-border px-2 py-1.5 hover:bg-accent/50 transition-colors">
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-1 max-h-60 overflow-y-auto"
|
||||
style={{ minWidth: 280 }}
|
||||
align="start"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{Array.from(grouped.entries()).map(([group, opts]) => (
|
||||
<div key={group || "_ungrouped"}>
|
||||
{group && (
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
)}
|
||||
{opts.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50 ${
|
||||
opt.value === value ? "bg-accent" : ""
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // prevent input blur
|
||||
select(opt.value);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{filter && filtered.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||
Use "{filter}" as custom value (press Enter)
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchemaConfigFields component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const schemaCache = new Map<string, AdapterConfigSchema | null>();
|
||||
const schemaFetchInflight = new Map<string, Promise<AdapterConfigSchema | null>>();
|
||||
const failedSchemaTypes = new Set<string>();
|
||||
|
||||
async function fetchConfigSchema(adapterType: string): Promise<AdapterConfigSchema | null> {
|
||||
const cached = schemaCache.get(adapterType);
|
||||
if (cached !== undefined) return cached;
|
||||
if (failedSchemaTypes.has(adapterType)) return null;
|
||||
|
||||
const inflight = schemaFetchInflight.get(adapterType);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/config-schema`);
|
||||
if (!res.ok) {
|
||||
failedSchemaTypes.add(adapterType);
|
||||
return null;
|
||||
}
|
||||
const schema = (await res.json()) as AdapterConfigSchema;
|
||||
schemaCache.set(adapterType, schema);
|
||||
return schema;
|
||||
} catch {
|
||||
failedSchemaTypes.add(adapterType);
|
||||
return null;
|
||||
} finally {
|
||||
schemaFetchInflight.delete(adapterType);
|
||||
}
|
||||
})();
|
||||
|
||||
schemaFetchInflight.set(adapterType, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function invalidateConfigSchemaCache(adapterType: string): void {
|
||||
schemaCache.delete(adapterType);
|
||||
failedSchemaTypes.delete(adapterType);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useConfigSchema(adapterType: string): AdapterConfigSchema | null {
|
||||
const [schema, setSchema] = useState<AdapterConfigSchema | null>(
|
||||
schemaCache.get(adapterType) ?? null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchConfigSchema(adapterType).then((s) => {
|
||||
if (!cancelled) setSchema(s);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [adapterType]);
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getDefaultValue(field: ConfigFieldSchema): unknown {
|
||||
if (field.default !== undefined) return field.default;
|
||||
switch (field.type) {
|
||||
case "toggle":
|
||||
return false;
|
||||
case "number":
|
||||
return 0;
|
||||
case "text":
|
||||
case "textarea":
|
||||
return "";
|
||||
case "select":
|
||||
return field.options?.[0]?.value ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SchemaConfigFields({
|
||||
adapterType,
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
const schema = useConfigSchema(adapterType);
|
||||
|
||||
const [defaultsApplied, setDefaultsApplied] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!schema || !isCreate || defaultsApplied) return;
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const field of schema.fields) {
|
||||
const def = getDefaultValue(field);
|
||||
if (def !== undefined && def !== "") {
|
||||
defaults[field.key] = def;
|
||||
}
|
||||
}
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
set?.({
|
||||
adapterSchemaValues: { ...values?.adapterSchemaValues, ...defaults },
|
||||
});
|
||||
}
|
||||
setDefaultsApplied(true);
|
||||
}, [schema, isCreate, defaultsApplied, set, values?.adapterSchemaValues]);
|
||||
|
||||
if (!schema || schema.fields.length === 0) return null;
|
||||
|
||||
function readValue(field: ConfigFieldSchema): unknown {
|
||||
if (isCreate) {
|
||||
return values?.adapterSchemaValues?.[field.key] ?? getDefaultValue(field);
|
||||
}
|
||||
const stored = config[field.key];
|
||||
return eff("adapterConfig", field.key, (stored ?? getDefaultValue(field)) as string);
|
||||
}
|
||||
|
||||
function writeValue(field: ConfigFieldSchema, value: unknown): void {
|
||||
if (isCreate) {
|
||||
const next = {
|
||||
adapterSchemaValues: {
|
||||
...values?.adapterSchemaValues,
|
||||
[field.key]: value,
|
||||
},
|
||||
};
|
||||
|
||||
// When provider changes, auto-clear model if it's not in the new provider's list
|
||||
if (field.key === "provider" && schema) {
|
||||
const modelField = schema.fields.find((f) => f.key === "model");
|
||||
if (modelField?.meta?.providerModels) {
|
||||
const modelsByProvider = modelField.meta.providerModels as Record<string, string[]>;
|
||||
const providerModels = modelsByProvider[String(value)] ?? [];
|
||||
const currentModel = values?.adapterSchemaValues?.model;
|
||||
if (currentModel && String(value) !== "auto" && !providerModels.includes(String(currentModel))) {
|
||||
next.adapterSchemaValues.model = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set?.(next);
|
||||
} else {
|
||||
mark("adapterConfig", field.key, value);
|
||||
|
||||
// Same logic for edit mode
|
||||
if (field.key === "provider" && schema) {
|
||||
const modelField = schema.fields.find((f) => f.key === "model");
|
||||
if (modelField?.meta?.providerModels) {
|
||||
const modelsByProvider = modelField.meta.providerModels as Record<string, string[]>;
|
||||
const providerModels = modelsByProvider[String(value)] ?? [];
|
||||
const currentModel = eff("adapterConfig", "model", "");
|
||||
if (currentModel && String(value) !== "auto" && !providerModels.includes(String(currentModel))) {
|
||||
mark("adapterConfig", "model", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{schema.fields.map((field) => {
|
||||
switch (field.type) {
|
||||
case "select": {
|
||||
const currentVal = String(readValue(field) ?? "");
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<SelectField
|
||||
value={currentVal}
|
||||
options={field.options ?? []}
|
||||
onChange={(v) => writeValue(field, v)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
hint={field.hint}
|
||||
checked={readValue(field) === true}
|
||||
onChange={(v) => writeValue(field, v)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftNumberInput
|
||||
value={Number(readValue(field) ?? 0)}
|
||||
onCommit={(v) => writeValue(field, v)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftTextarea
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
case "combobox": {
|
||||
const currentVal = String(readValue(field) ?? "");
|
||||
// Dynamic options: if meta.providerModels exists, compute options
|
||||
// based on the current provider value
|
||||
let comboboxOptions = field.options ?? [];
|
||||
if (field.meta?.providerModels) {
|
||||
const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto");
|
||||
const modelsByProvider = field.meta.providerModels as Record<string, string[]>;
|
||||
if (providerVal === "auto") {
|
||||
// Auto: show all models from all providers, grouped by provider
|
||||
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
||||
const providerOptions = providerLabel?.options ?? [];
|
||||
comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) =>
|
||||
models.map((m) => ({
|
||||
label: m,
|
||||
value: m,
|
||||
group: providerOptions.find((p) => p.value === prov)?.label ?? prov,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
const providerModels = modelsByProvider[providerVal] ?? [];
|
||||
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
||||
const provName = providerLabel?.options?.find((p) => p.value === providerVal)?.label ?? providerVal;
|
||||
comboboxOptions = providerModels.map((m) => ({
|
||||
label: m,
|
||||
value: m,
|
||||
group: provName,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<ComboboxField
|
||||
value={currentVal}
|
||||
options={comboboxOptions}
|
||||
onChange={(v) => writeValue(field, v || undefined)}
|
||||
placeholder={field.hint}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
case "text":
|
||||
default:
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftInput
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build adapter config from schema values + standard CreateConfigValues fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildSchemaAdapterConfig(
|
||||
values: CreateConfigValues,
|
||||
): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
|
||||
if (values.model?.trim()) ac.model = values.model.trim();
|
||||
if (values.cwd) ac.cwd = values.cwd;
|
||||
if (values.command) ac.command = values.command;
|
||||
if (values.instructionsFilePath) ac.instructionsFilePath = values.instructionsFilePath;
|
||||
if (values.thinkingEffort) ac.thinkingEffort = values.thinkingEffort;
|
||||
|
||||
if (values.extraArgs) {
|
||||
ac.extraArgs = values.extraArgs
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (values.adapterSchemaValues) {
|
||||
Object.assign(ac, values.adapterSchemaValues);
|
||||
}
|
||||
|
||||
return ac;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildTranscript, type RunLogChunk } from "./transcript";
|
||||
import type { UIAdapterModule } from "./types";
|
||||
|
||||
describe("buildTranscript", () => {
|
||||
const ts = "2026-03-20T13:00:00.000Z";
|
||||
|
|
@ -27,4 +28,46 @@ describe("buildTranscript", () => {
|
|||
{ kind: "stderr", ts, text: "stderr /Users/d****/project" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a fresh stateful parser for each transcript build", () => {
|
||||
const statefulAdapter: UIAdapterModule = {
|
||||
type: "stateful_test",
|
||||
label: "Stateful Test",
|
||||
parseStdoutLine: (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }],
|
||||
createStdoutParser: () => {
|
||||
let pending: string | null = null;
|
||||
return {
|
||||
parseLine: (line, entryTs) => {
|
||||
if (line.startsWith("begin:")) {
|
||||
pending = line.slice("begin:".length);
|
||||
return [];
|
||||
}
|
||||
if (line === "finish" && pending) {
|
||||
const text = `completed:${pending}`;
|
||||
pending = null;
|
||||
return [{ kind: "stdout", ts: entryTs, text }];
|
||||
}
|
||||
return [{ kind: "stdout", ts: entryTs, text: `literal:${line}` }];
|
||||
},
|
||||
reset: () => {
|
||||
pending = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
ConfigFields: () => null,
|
||||
buildAdapterConfig: () => ({}),
|
||||
};
|
||||
|
||||
const first = buildTranscript(
|
||||
[{ ts, stream: "stdout", chunk: "begin:task-a\n" }],
|
||||
statefulAdapter,
|
||||
);
|
||||
const second = buildTranscript(
|
||||
[{ ts, stream: "stdout", chunk: "finish\n" }],
|
||||
statefulAdapter,
|
||||
);
|
||||
|
||||
expect(first).toEqual([]);
|
||||
expect(second).toEqual([{ kind: "stdout", ts, text: "literal:finish" }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils";
|
||||
import type { TranscriptEntry, StdoutLineParser } from "./types";
|
||||
import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from "./types";
|
||||
|
||||
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
||||
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
|
||||
|
||||
function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) {
|
||||
if (typeof source === "function") {
|
||||
return { parseLine: source, reset: null as (() => void) | null };
|
||||
}
|
||||
if (source.createStdoutParser) {
|
||||
const parser = source.createStdoutParser();
|
||||
return { parseLine: parser.parseLine, reset: parser.reset };
|
||||
}
|
||||
return { parseLine: source.parseStdoutLine, reset: null as (() => void) | null };
|
||||
}
|
||||
|
||||
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
|
||||
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
|
||||
const last = entries[entries.length - 1];
|
||||
|
|
@ -24,12 +35,13 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr
|
|||
|
||||
export function buildTranscript(
|
||||
chunks: RunLogChunk[],
|
||||
parser: StdoutLineParser,
|
||||
parserSource: StdoutLineParser | TranscriptParserSource,
|
||||
opts?: TranscriptBuildOptions,
|
||||
): TranscriptEntry[] {
|
||||
const entries: TranscriptEntry[] = [];
|
||||
let stdoutBuffer = "";
|
||||
const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? false };
|
||||
const { parseLine, reset } = resolveStdoutParser(parserSource);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.stream === "stderr") {
|
||||
|
|
@ -47,15 +59,17 @@ export function buildTranscript(
|
|||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
||||
appendTranscriptEntries(entries, parseLine(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = stdoutBuffer.trim();
|
||||
if (trailing) {
|
||||
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
||||
appendTranscriptEntries(entries, parser(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
||||
appendTranscriptEntries(entries, parseLine(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
||||
}
|
||||
|
||||
reset?.();
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,18 @@ import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
|||
// Re-export shared types so local consumers don't need to change imports
|
||||
export type { TranscriptEntry, StdoutLineParser, CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
export interface StatefulStdoutParser {
|
||||
parseLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export type StdoutParserFactory = () => StatefulStdoutParser;
|
||||
|
||||
export interface TranscriptParserSource {
|
||||
parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
|
||||
createStdoutParser?: StdoutParserFactory;
|
||||
}
|
||||
|
||||
export interface AdapterConfigFieldsProps {
|
||||
mode: "create" | "edit";
|
||||
isCreate: boolean;
|
||||
|
|
@ -24,10 +36,9 @@ export interface AdapterConfigFieldsProps {
|
|||
hideInstructionsFile?: boolean;
|
||||
}
|
||||
|
||||
export interface UIAdapterModule {
|
||||
export interface UIAdapterModule extends TranscriptParserSource {
|
||||
type: string;
|
||||
label: string;
|
||||
parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
|
||||
ConfigFields: ComponentType<AdapterConfigFieldsProps>;
|
||||
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
54
ui/src/adapters/use-disabled-adapters.ts
Normal file
54
ui/src/adapters/use-disabled-adapters.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { adaptersApi } from "@/api/adapters";
|
||||
import { setDisabledAdapterTypes } from "@/adapters/disabled-store";
|
||||
import { syncExternalAdapters } from "@/adapters/registry";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
/**
|
||||
* Fetch adapters and keep the disabled-adapter store + UI adapter registry
|
||||
* in sync with the server.
|
||||
*
|
||||
* - Registers external adapter types in the UI registry so they appear in
|
||||
* dropdowns (done eagerly during render — idempotent, no React state).
|
||||
* - Syncs the disabled-adapter store for non-React consumers (useEffect).
|
||||
*
|
||||
* Returns a reactive Set of disabled types for use as useMemo dependencies.
|
||||
* Call this at the top of any component that renders adapter menus.
|
||||
*/
|
||||
export function useDisabledAdaptersSync(): Set<string> {
|
||||
const { data: adapters } = useQuery({
|
||||
queryKey: queryKeys.adapters.all,
|
||||
queryFn: () => adaptersApi.list(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// 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.
|
||||
if (adapters) {
|
||||
syncExternalAdapters(
|
||||
adapters
|
||||
.filter((a) => a.source === "external")
|
||||
.map((a) => ({
|
||||
type: a.type,
|
||||
label: a.label,
|
||||
disabled: a.disabled,
|
||||
overrideDisabled: a.overridePaused,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Sync the disabled set to the global store for non-React code
|
||||
useEffect(() => {
|
||||
if (!adapters) return;
|
||||
setDisabledAdapterTypes(
|
||||
adapters.filter((a) => a.disabled).map((a) => a.type),
|
||||
);
|
||||
}, [adapters]);
|
||||
|
||||
return useMemo(
|
||||
() => new Set(adapters?.filter((a) => a.disabled).map((a) => a.type) ?? []),
|
||||
[adapters],
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue