mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
feat(adapters): declarative config-schema API and UI for plugin adapters
Cherry-picked from feat/externalize-hermes-adapter. Resolved conflicts: kept Hermes as built-in on phase1 branch.
This commit is contained in:
parent
f884cbab78
commit
69a1593ff8
8 changed files with 392 additions and 10 deletions
|
|
@ -10,6 +10,7 @@ import { hermesLocalUIAdapter } from "./hermes-local";
|
|||
import { processUIAdapter } from "./process";
|
||||
import { httpUIAdapter } from "./http";
|
||||
import { loadDynamicParser } from "./dynamic-loader";
|
||||
import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fields";
|
||||
|
||||
const uiAdapters: UIAdapterModule[] = [];
|
||||
const adaptersByType = new Map<string, UIAdapterModule>();
|
||||
|
|
@ -60,7 +61,6 @@ export function getUIAdapter(type: string): UIAdapterModule {
|
|||
const builtIn = adaptersByType.get(type);
|
||||
|
||||
if (!builtIn) {
|
||||
// No built-in adapter — fall through to the external-only path.
|
||||
let loadStarted = false;
|
||||
return {
|
||||
type,
|
||||
|
|
@ -74,16 +74,16 @@ export function getUIAdapter(type: string): UIAdapterModule {
|
|||
type,
|
||||
label: type,
|
||||
parseStdoutLine: parser,
|
||||
ConfigFields: processUIAdapter.ConfigFields,
|
||||
buildAdapterConfig: processUIAdapter.buildAdapterConfig,
|
||||
ConfigFields: SchemaConfigFields,
|
||||
buildAdapterConfig: buildSchemaAdapterConfig,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return processUIAdapter.parseStdoutLine(line, ts);
|
||||
},
|
||||
ConfigFields: processUIAdapter.ConfigFields,
|
||||
buildAdapterConfig: processUIAdapter.buildAdapterConfig,
|
||||
ConfigFields: SchemaConfigFields,
|
||||
buildAdapterConfig: buildSchemaAdapterConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -117,16 +117,16 @@ export function syncExternalAdapters(
|
|||
type,
|
||||
label,
|
||||
parseStdoutLine: parser,
|
||||
ConfigFields: processUIAdapter.ConfigFields,
|
||||
buildAdapterConfig: processUIAdapter.buildAdapterConfig,
|
||||
ConfigFields: SchemaConfigFields,
|
||||
buildAdapterConfig: buildSchemaAdapterConfig,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return processUIAdapter.parseStdoutLine(line, ts);
|
||||
},
|
||||
ConfigFields: processUIAdapter.ConfigFields,
|
||||
buildAdapterConfig: processUIAdapter.buildAdapterConfig,
|
||||
ConfigFields: SchemaConfigFields,
|
||||
buildAdapterConfig: buildSchemaAdapterConfig,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
259
ui/src/adapters/schema-config-fields.tsx
Normal file
259
ui/src/adapters/schema-config-fields.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { useState, useEffect } 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";
|
||||
|
||||
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 selectClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema cache (module-level, survives re-renders)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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) {
|
||||
set?.({
|
||||
adapterSchemaValues: {
|
||||
...values?.adapterSchemaValues,
|
||||
[field.key]: value,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
mark("adapterConfig", field.key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{schema.fields.map((field) => {
|
||||
switch (field.type) {
|
||||
case "select":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<select
|
||||
className={selectClass}
|
||||
value={String(readValue(field) ?? "")}
|
||||
onChange={(e) => writeValue(field, e.target.value)}
|
||||
>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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 "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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue