comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
180 lines • 6.83 kB
JavaScript
import { createHash } from "node:crypto";
/**
* Canonicalize an externally supplied ComfyUI directory type before it can
* participate in asset identity. Unknown values are unsafe to preserve: the
* /view consumer only accepts these three types, so they deliberately fall
* back to output just as the history consumer does.
*/
export function normalizeAssetType(type) {
return type === "input" || type === "temp" || type === "output" ? type : "output";
}
/**
* Return an asset ref with one canonical type and a matching public URL.
* Invalid test/downtime URLs are retained; the production URL builder always
* supplies an absolute URL and gets its type query corrected here.
*/
export function normalizeAssetImage(img) {
const type = normalizeAssetType(img.type);
let url = img.url;
try {
const parsed = new URL(img.url);
parsed.searchParams.set("type", type);
url = parsed.toString();
}
catch {
// The registry does not fetch this URL. Keep opaque test/custom URLs
// usable while still canonicalizing their identity metadata.
}
return { ...img, type, url };
}
const DEFAULT_TTL_MS = (() => {
const raw = process.env.COMFYUI_ASSET_TTL_HOURS;
const hours = raw ? Number(raw) : 24;
return Number.isFinite(hours) && hours > 0 ? hours * 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
})();
const state = {
records: new Map(),
config: { ttlMs: DEFAULT_TTL_MS, now: Date.now },
};
function makeAssetId(promptId, img) {
const type = normalizeAssetType(img.type);
const hash = createHash("sha256")
.update(`${promptId}\0${img.filename}\0${img.subfolder}\0${type}`)
.digest("hex");
return `a_${hash.slice(0, 8)}`;
}
function deepCloneWorkflow(wf) {
return JSON.parse(JSON.stringify(wf));
}
function isExpired(record) {
return state.config.now() - record.createdAt >= state.config.ttlMs;
}
function sourcePriority(source) {
// A live observation by this process is stronger provenance than a later
// best-effort recovery from /history. This is the arbitration rule for the
// async reconcile/watch race; equal sources are first-writer-wins.
return source === "watched" ? 2 : 1;
}
export const AssetRegistry = {
/**
* Register all images produced by a completed prompt.
* Returns records newly inserted or upgraded by this call. Existing records
* win ties, and watched provenance wins a history-reconcile attempt for the
* same canonical identity.
*/
register({ promptId, workflow, outputs, source, createdAt, createdAtSource }) {
const snapshot = deepCloneWorkflow(workflow);
const registered = [];
const recordSource = source ?? "watched";
for (const output of outputs) {
for (const img of output.images) {
const normalizedImg = normalizeAssetImage(img);
const assetId = makeAssetId(promptId, normalizedImg);
const existing = state.records.get(assetId);
if (existing && !isExpired(existing) && sourcePriority(existing.source) >= sourcePriority(recordSource)) {
continue;
}
const record = {
assetId,
promptId,
nodeId: output.node_id,
filename: normalizedImg.filename,
subfolder: normalizedImg.subfolder,
type: normalizedImg.type,
url: normalizedImg.url,
workflow: snapshot,
createdAt: createdAt ?? state.config.now(),
createdAtSource: createdAtSource ?? "observed",
source: recordSource,
};
state.records.set(assetId, record);
registered.push(record);
}
}
return registered;
},
/** True when a live (unexpired) record exists for this prompt + image. */
has(promptId, img) {
const record = state.records.get(makeAssetId(promptId, img));
return record !== undefined && !isExpired(record);
},
/** Look up a live record by the canonical prompt + image reference. */
find(promptId, img) {
const assetId = makeAssetId(promptId, img);
const record = state.records.get(assetId);
if (!record)
return undefined;
if (isExpired(record)) {
state.records.delete(assetId);
return undefined;
}
return record;
},
/** Look up a record by id. Returns undefined for missing or expired. */
get(assetId) {
const record = state.records.get(assetId);
if (!record)
return undefined;
if (isExpired(record)) {
state.records.delete(assetId);
return undefined;
}
return record;
},
/** List records newest-first. */
list({ limit, since } = {}) {
const all = [...state.records.values()].filter((r) => !isExpired(r));
const filtered = since !== undefined ? all.filter((r) => r.createdAt >= since) : all;
filtered.sort((a, b) => b.createdAt - a.createdAt);
return limit !== undefined ? filtered.slice(0, limit) : filtered;
},
/** Remove expired records. Returns number pruned. */
prune() {
let count = 0;
for (const [id, record] of state.records) {
if (isExpired(record)) {
state.records.delete(id);
count++;
}
}
return count;
},
/** Test/diagnostic helper — wipe all records. */
clear() {
state.records.clear();
},
/** Test/diagnostic helper — override ttl and clock. */
configure(opts) {
if (opts.ttlMs !== undefined)
state.config.ttlMs = opts.ttlMs;
if (opts.now !== undefined)
state.config.now = opts.now;
},
/** Inspect current size (debug only). */
size() {
return state.records.size;
},
};
/**
* Apply a flat override map to every node input in a workflow.
* For each (key, value) in overrides, sets node.inputs[key] = value on any node
* that already has that input. Returns a new workflow; does not mutate input.
*
* Example: { cfg: 8, seed: 12345 } → updates KSampler-style nodes only.
*/
export function applyOverrides(workflow, overrides) {
const next = deepCloneWorkflow(workflow);
if (!overrides)
return next;
for (const node of Object.values(next)) {
if (!node.inputs)
continue;
for (const [key, value] of Object.entries(overrides)) {
if (key in node.inputs) {
node.inputs[key] = value;
}
}
}
return next;
}
//# sourceMappingURL=asset-registry.js.map