mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
feat(adapters): external adapter plugin system with dynamic UI parser
- Plugin loader: install/reload/remove/reinstall external adapters from npm packages or local directories - Plugin store persisted at ~/.paperclip/adapter-plugins.json - Self-healing UI parser resolution with version caching - UI: Adapter Manager page, dynamic loader, display registry with humanized names for unknown adapter types - Dev watch: exclude adapter-plugins dir from tsx watcher to prevent mid-request server restarts during reinstall - All consumer fallbacks use getAdapterLabel() for consistent display - AdapterTypeDropdown uses controlled open state for proper close behavior - Remove hermes-local from built-in UI (externalized to plugin) - Add docs for external adapters and UI parser contract
This commit is contained in:
parent
f8452a4520
commit
14d59da316
72 changed files with 4102 additions and 585 deletions
|
|
@ -3,32 +3,130 @@ 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 { processUIAdapter } from "./process";
|
||||
import { httpUIAdapter } from "./http";
|
||||
import { loadDynamicParser } from "./dynamic-loader";
|
||||
|
||||
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]),
|
||||
);
|
||||
function registerBuiltInUIAdapters() {
|
||||
for (const adapter of [
|
||||
claudeLocalUIAdapter,
|
||||
codexLocalUIAdapter,
|
||||
geminiLocalUIAdapter,
|
||||
openCodeLocalUIAdapter,
|
||||
piLocalUIAdapter,
|
||||
cursorLocalUIAdapter,
|
||||
openClawGatewayUIAdapter,
|
||||
processUIAdapter,
|
||||
httpUIAdapter,
|
||||
]) {
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
// No built-in adapter — fall through to the external-only path.
|
||||
let loadStarted = false;
|
||||
return {
|
||||
type,
|
||||
label: type,
|
||||
parseStdoutLine: (line: string, ts: string) => {
|
||||
if (!loadStarted) {
|
||||
loadStarted = true;
|
||||
loadDynamicParser(type).then((parser) => {
|
||||
if (parser) {
|
||||
registerUIAdapter({
|
||||
type,
|
||||
label: type,
|
||||
parseStdoutLine: parser,
|
||||
ConfigFields: processUIAdapter.ConfigFields,
|
||||
buildAdapterConfig: processUIAdapter.buildAdapterConfig,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return processUIAdapter.parseStdoutLine(line, ts);
|
||||
},
|
||||
ConfigFields: processUIAdapter.ConfigFields,
|
||||
buildAdapterConfig: processUIAdapter.buildAdapterConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return builtIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure external adapter types (from the server's /api/adapters response)
|
||||
* are registered in the UI adapter list so they appear in dropdowns.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function syncExternalAdapters(
|
||||
serverAdapters: { type: string; label: string }[],
|
||||
): void {
|
||||
for (const { type, label } of serverAdapters) {
|
||||
if (adaptersByType.has(type)) continue;
|
||||
|
||||
let loadStarted = false;
|
||||
registerUIAdapter({
|
||||
type,
|
||||
label,
|
||||
parseStdoutLine: (line: string, ts: string) => {
|
||||
if (!loadStarted) {
|
||||
loadStarted = true;
|
||||
loadDynamicParser(type).then((parser) => {
|
||||
if (parser) {
|
||||
registerUIAdapter({
|
||||
type,
|
||||
label,
|
||||
parseStdoutLine: parser,
|
||||
ConfigFields: processUIAdapter.ConfigFields,
|
||||
buildAdapterConfig: processUIAdapter.buildAdapterConfig,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return processUIAdapter.parseStdoutLine(line, ts);
|
||||
},
|
||||
ConfigFields: processUIAdapter.ConfigFields,
|
||||
buildAdapterConfig: processUIAdapter.buildAdapterConfig,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function listUIAdapters(): UIAdapterModule[] {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue