UNPKG

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
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