2026-03-31 20:21:13 +01:00
|
|
|
/**
|
|
|
|
|
* 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";
|
2026-04-04 14:04:33 -05:00
|
|
|
import type { StatefulStdoutParser, StdoutLineParser, StdoutParserFactory } from "./types";
|
|
|
|
|
|
|
|
|
|
interface DynamicParserModule {
|
|
|
|
|
parseStdoutLine: StdoutLineParser;
|
|
|
|
|
createStdoutParser?: StdoutParserFactory;
|
|
|
|
|
}
|
2026-03-31 20:21:13 +01:00
|
|
|
|
|
|
|
|
// Cache of dynamically loaded parsers by adapter type.
|
|
|
|
|
// Once loaded, the parser is reused for all runs of that adapter type.
|
2026-04-04 14:04:33 -05:00
|
|
|
const dynamicParserCache = new Map<string, DynamicParserModule>();
|
2026-03-31 20:21:13 +01:00
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
*/
|
2026-04-04 14:04:33 -05:00
|
|
|
export async function loadDynamicParser(adapterType: string): Promise<DynamicParserModule | null> {
|
2026-03-31 20:21:13 +01:00
|
|
|
// 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);
|
|
|
|
|
|
2026-04-04 14:04:33 -05:00
|
|
|
let parserModule: DynamicParserModule;
|
2026-03-31 20:21:13 +01:00
|
|
|
|
|
|
|
|
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") {
|
2026-04-04 14:04:33 -05:00
|
|
|
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;
|
|
|
|
|
}),
|
|
|
|
|
};
|
2026-03-31 20:21:13 +01:00
|
|
|
} else if (typeof mod.parseStdoutLine === "function") {
|
2026-04-04 14:04:33 -05:00
|
|
|
parserModule = {
|
|
|
|
|
parseStdoutLine: mod.parseStdoutLine as StdoutLineParser,
|
|
|
|
|
};
|
2026-03-31 20:21:13 +01:00
|
|
|
} 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
|
2026-04-04 14:04:33 -05:00
|
|
|
dynamicParserCache.set(adapterType, parserModule);
|
2026-03-31 20:21:13 +01:00
|
|
|
console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`);
|
2026-04-04 14:04:33 -05:00
|
|
|
return parserModule;
|
2026-03-31 20:21:13 +01:00
|
|
|
} 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;
|
|
|
|
|
}
|