Fast_deploy / ComfyUI-MiniMaxH3-Context-Loop /web /h3_chain_run_manager.js
dzmdd's picture
Upload folder using huggingface_hub (part 3)
8fb6433 verified
Raw History Blame Contribute Delete
35.3 kB
import {app} from "/scripts/app.js";
import {api} from "/scripts/api.js";
import {
ASSET_ROLES,
MAX_ASSET_BINDINGS,
applyAssetBinding,
assetInputNumber,
collectAssetBindings,
nodeType,
} from "./h3_run_assets_core.mjs?v=0.6.2";
import {
runArchiveOptionLabel,
runManagerIdentity,
} from "./h3_run_manager_core.mjs?v=0.6.2";
import {
refreshRestoredPlanEditors,
restoreConnectedPolicyInputs,
} from "./h3_plan_restore_core.mjs?v=0.6.6";
const NODE_NAME = "MiniMaxH3ChainRunManager";
const PLAN_NAME = "MiniMaxH3ChainPlan";
const PLAN_NAMES = new Set([PLAN_NAME, "MiniMaxH3ChainPlanModern"]);
const ASSET_WIDGETS = [
"archive_images", "archive_audio", "archive_video", "asset_bindings_json",
];
function upstreamPlanNode(start) {
const queue = [start];
const seen = new Set();
while (queue.length) {
const node = queue.shift();
if (!node || seen.has(node)) continue;
seen.add(node);
if (node !== start && PLAN_NAMES.has(nodeType(node))) return node;
for (const input of node.inputs ?? []) {
if (input.link == null) continue;
const link = node.graph?.links?.[input.link];
const parent = link ? node.graph?.getNodeById?.(link.origin_id) : null;
if (parent) queue.push(parent);
}
}
return null;
}
function widgetByName(node, name) {
return node?.widgets?.find((item) => item.name === name);
}
function element(tag, className = "", text = undefined) {
const item = document.createElement(tag);
if (className) item.className = className;
if (text !== undefined) item.textContent = text;
return item;
}
function button(label, title, action) {
const item = element("button", "", label);
item.type = "button";
item.title = title;
item.addEventListener("click", action);
return item;
}
function injectStyles() {
if (document.getElementById("h3-run-manager-style")) return;
const style = document.createElement("style");
style.id = "h3-run-manager-style";
style.textContent = `
.h3rm-root { --h3rm-bg:color-mix(in srgb,var(--comfy-menu-bg,#202124) 91%,#111827);
--h3rm-panel:var(--comfy-input-bg,#15171d); --h3rm-border:var(--border-color,#586174);
--h3rm-text:var(--input-text,#eceef5); --h3rm-muted:color-mix(in srgb,var(--h3rm-text) 58%,transparent);
box-sizing:border-box; width:100%; height:100%; min-height:210px; display:flex;
flex-direction:column; gap:9px; overflow:auto; padding:10px; border:1px solid var(--h3rm-border);
border-radius:8px; background:var(--h3rm-bg); color:var(--h3rm-text);
font:12px/1.4 system-ui,sans-serif; }
.h3rm-root *, .h3rm-root *::before, .h3rm-root *::after { box-sizing:border-box; }
.h3rm-title { font-size:15px; font-weight:750; }
.h3rm-identity { display:grid; grid-template-columns:1fr 1fr; gap:6px; }
.h3rm-identity > span { min-width:0; padding:5px 7px; border:1px solid var(--h3rm-border);
border-radius:5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.h3rm-identity-active { color:#b8d2ff; }
.h3rm-identity-selected { color:var(--h3rm-muted); }
.h3rm-identity-selected.h3rm-same { color:#a8e6b1; border-color:#4d8a58; }
.h3rm-section { padding:8px; border:1px solid color-mix(in srgb,var(--h3rm-border) 72%,transparent);
border-radius:6px; background:var(--h3rm-panel); }
.h3rm-section-title { display:flex; justify-content:space-between; align-items:center;
gap:8px; margin-bottom:6px; font-weight:700; }
.h3rm-policy { display:flex; flex-wrap:wrap; gap:8px; color:var(--h3rm-muted); font-weight:400; }
.h3rm-policy label { display:inline-flex; align-items:center; gap:4px; }
.h3rm-policy input { margin:0; }
.h3rm-assets { display:flex; flex-direction:column; gap:5px; }
.h3rm-asset { display:grid; grid-template-columns:minmax(120px,1fr) 125px;
align-items:center; gap:6px; min-width:0; }
.h3rm-asset-main { min-width:0; overflow:hidden; text-overflow:ellipsis;
white-space:nowrap; }
.h3rm-asset-path { display:block; color:var(--h3rm-muted); font-size:11px;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.h3rm-asset select { min-width:0; width:100%; padding:4px; border:1px solid var(--h3rm-border);
border-radius:5px; background:var(--h3rm-bg); color:var(--h3rm-text); }
.h3rm-empty { color:var(--h3rm-muted); }
.h3rm-select { width:100%; min-width:0; padding:7px 8px; border:1px solid var(--h3rm-border);
border-radius:6px; background:var(--h3rm-panel); color:var(--h3rm-text); }
.h3rm-details { min-height:48px; padding:8px; border:1px solid color-mix(in srgb,var(--h3rm-border) 72%,transparent);
border-radius:6px; background:var(--h3rm-panel); color:var(--h3rm-muted);
white-space:pre-wrap; overflow-wrap:anywhere; }
.h3rm-actions { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
.h3rm-actions button { padding:6px 9px; border:1px solid var(--h3rm-border); border-radius:6px;
background:var(--h3rm-panel); color:var(--h3rm-text); cursor:pointer; }
.h3rm-actions button:hover { border-color:#7fa8ff; }
.h3rm-actions button:disabled { cursor:not-allowed; opacity:.45; }
.h3rm-load { font-weight:700; border-color:#6d91d8 !important; }
.h3rm-status { min-width:0; flex:1 1 170px; color:var(--h3rm-muted); text-align:right;
white-space:pre-wrap; overflow-wrap:anywhere; }
.h3rm-error { color:#ffb3b3; }
`;
document.head.appendChild(style);
}
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 1) return "0 KB";
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function collapseWidget(widget) {
if (!widget) return;
widget._h3OriginalType ??= widget.type;
widget._h3OriginalComputeSize ??= widget.computeSize;
widget._h3OriginalDraw ??= widget.draw;
widget.hidden = true;
widget.type = "hidden";
widget.computeSize = () => [0, -4];
// Some ComfyUI canvas/front-end combinations still invoke draw() for a
// widget after its type and geometry have been collapsed. Keep the value
// serializable, but make the internal archive state visually inert.
widget.draw = () => {};
for (const item of new Set([widget.inputEl, widget.element])) {
if (!item?.style) continue;
item.style.setProperty("display", "none", "important");
item.style.setProperty("pointer-events", "none", "important");
item.setAttribute?.("aria-hidden", "true");
}
if (widget.element && widget.id && typeof widget.onRemove === "function") {
widget.onRemove();
}
}
function removeLegacyStatusOutput(node) {
const index = node.outputs?.findIndex((output) => output.name === "asset_status") ?? -1;
if (index >= 0) node.removeOutput?.(index);
}
function stabilizeAssetInputs(node) {
const connected = (node.inputs ?? []).filter((input) =>
assetInputNumber(input) != null && input.link != null);
const used = new Set(connected.map((input) => assetInputNumber(input)));
let next = 0;
while (used.has(next) && next < MAX_ASSET_BINDINGS) next += 1;
for (let index = (node.inputs?.length ?? 0) - 1; index >= 0; index -= 1) {
const input = node.inputs[index];
const number = assetInputNumber(input);
if (number != null && input.link == null && number !== next) {
node.removeInput(index);
}
}
if (next < MAX_ASSET_BINDINGS
&& !node.inputs?.some((input) => input.name === `asset_${next}`)) {
node.addInput(`asset_${next}`, "*");
}
const empty = node.inputs?.find((input) =>
assetInputNumber(input) === next && input.link == null);
if (empty) empty.label = "Connect loader asset";
node.graph?.setDirtyCanvas?.(true, true);
}
function localTime(value) {
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date.toLocaleString() : "unknown time";
}
async function jsonRequest(path) {
const response = await api.fetchApi(path);
let payload = {};
try {
payload = await response.json();
} catch (_error) {
// Preserve a useful HTTP error when a proxy emits a non-JSON body.
}
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
return payload;
}
function applyPlanInputs(planNode, inputs, policyInputs = {}) {
if (!planNode) throw new Error("Connect this Run Manager to the active H3 Chain Plan.");
if (!inputs || typeof inputs !== "object") throw new Error("The saved run has no Plan inputs.");
const names = Object.keys(inputs).sort((left, right) =>
Number(left === "plan_json") - Number(right === "plan_json"));
const applied = [];
const unavailable = [];
const graph = planNode.graph;
if (!graph || graph.getNodeById?.(planNode.id) !== planNode) {
throw new Error("The target Plan is no longer in its workflow.");
}
graph?.beforeChange?.();
try {
for (const name of names) {
const widget = widgetByName(planNode, name);
if (!widget) {
unavailable.push(name);
continue;
}
widget.value = inputs[name];
widget.callback?.(inputs[name]);
applied.push(name);
}
} finally {
graph?.afterChange?.();
}
if (!applied.includes("plan_json")) {
throw new Error("The connected Plan does not expose an editable plan_json widget.");
}
const policies = restoreConnectedPolicyInputs(
planNode, policyInputs, inputs);
refreshRestoredPlanEditors(planNode);
graph.setDirtyCanvas?.(true, true);
return {applied, unavailable, policies};
}
function mount(node) {
if (node._h3RunManagerMounted || typeof node.addDOMWidget !== "function") return;
node._h3RunManagerMounted = true;
injectStyles();
removeLegacyStatusOutput(node);
const root = element("div", "h3rm-root");
for (const eventName of [
"pointerdown", "pointerup", "mousedown", "mouseup", "click", "dblclick",
]) root.addEventListener(eventName, (event) => event.stopPropagation());
root.addEventListener("wheel", (event) => event.stopPropagation());
for (const name of ASSET_WIDGETS) collapseWidget(widgetByName(node, name));
const state = {
runs: [], selected: "", busy: false, bindings: [], watchedSources: new Set(),
watchedPlanWidget: null,
disposed: false, restoreEpoch: 0,
};
const title = element("div", "h3rm-title", "H3 Run Manager");
const identity = element("div", "h3rm-identity");
const activeIdentity = element("span", "h3rm-identity-active");
const selectedIdentity = element("span", "h3rm-identity-selected");
identity.append(activeIdentity, selectedIdentity);
const select = element("select", "h3rm-select");
select.title = "Saved projects discovered under the ComfyUI host's output/h3_chains folder.";
const details = element("div", "h3rm-details", "Loading saved runs…");
const assetSection = element("div", "h3rm-section");
const assetHeader = element("div", "h3rm-section-title");
const assetTitle = element("span", "", "Reference assets");
const assetList = element("div", "h3rm-assets");
const policies = element("div", "h3rm-policy");
const actions = element("div", "h3rm-actions");
const status = element("span", "h3rm-status");
function policyCheckbox(widgetName, label) {
const wrap = element("label");
const control = element("input");
control.type = "checkbox";
const widget = widgetByName(node, widgetName);
control.checked = Boolean(widget?.value);
control.addEventListener("change", () => {
if (widget) {
widget.value = control.checked;
widget.callback?.(control.checked);
}
node.graph?.setDirtyCanvas?.(true, true);
});
wrap.append(control, document.createTextNode(label));
return wrap;
}
policies.append(
policyCheckbox("archive_images", "Archive images"),
policyCheckbox("archive_audio", "Archive audio"),
policyCheckbox("archive_video", "Archive video"),
);
assetHeader.append(assetTitle, policies);
function activeRunName() {
const planNode = upstreamPlanNode(node);
return String(widgetByName(planNode, "run_name")?.value ?? "").trim();
}
const activeRunChanged = () => {
state.restoreEpoch += 1;
const defer = window.queueMicrotask ?? ((callback) => window.setTimeout(callback, 0));
defer(() => { if (!state.disposed) renderSelection(); });
};
function updatePlanWatch() {
const planWidget = widgetByName(upstreamPlanNode(node), "run_name") ?? null;
if (state.watchedPlanWidget === planWidget) return;
state.watchedPlanWidget?._h3RunManagerWatchers?.delete(activeRunChanged);
state.watchedPlanWidget = planWidget;
if (!planWidget) return;
planWidget._h3RunManagerWatchers ??= new Set();
if (!planWidget._h3RunManagerWatchWrapped) {
planWidget._h3RunManagerWatchWrapped = true;
const changed = planWidget.callback;
planWidget.callback = function () {
const result = changed?.apply(this, arguments);
for (const listener of this._h3RunManagerWatchers ?? []) listener();
return result;
};
}
planWidget._h3RunManagerWatchers.add(activeRunChanged);
}
function renderActiveRun() {
updatePlanWatch();
const runName = activeRunName();
assetTitle.textContent = runName
? `Reference assets → ${runName}`
: "Reference assets → no active run_name";
assetTitle.title = runName
? `Save/update assets writes to the connected Plan run “${runName}”.`
: "Set run_name on the connected Plan before saving assets.";
}
function writeBindingsWidget() {
const widget = widgetByName(node, "asset_bindings_json");
if (!widget) return;
const value = JSON.stringify(state.bindings);
if (widget.value === value) return;
widget.value = value;
widget.callback?.(value);
node.graph?.setDirtyCanvas?.(true, true);
}
const sourceChanged = () => {
const defer = window.queueMicrotask ?? ((callback) => window.setTimeout(callback, 0));
defer(() => { if (!state.disposed) syncAssetBindings(); });
};
function updateSourceWatches() {
const current = new Set();
for (const input of node.inputs ?? []) {
if (assetInputNumber(input) == null || input.link == null) continue;
const link = node.graph?.links?.[input.link];
const source = link ? node.graph?.getNodeById?.(link.origin_id) : null;
if (!source) continue;
current.add(source);
source._h3AssetWatchers ??= new Set();
if (!source._h3AssetWatchWrapped) {
source._h3AssetWatchWrapped = true;
const changed = source.onWidgetChanged;
source.onWidgetChanged = function () {
const result = changed?.apply(this, arguments);
for (const listener of this._h3AssetWatchers ?? []) listener();
return result;
};
}
source._h3AssetWatchers.add(sourceChanged);
}
for (const binding of state.bindings) {
const source = node.graph?.getNodeById?.(binding.node_id)
?? node.graph?.getNodeById?.(Number(binding.node_id));
if (!source || current.has(source)) continue;
current.add(source);
source._h3AssetWatchers ??= new Set();
if (!source._h3AssetWatchWrapped) {
source._h3AssetWatchWrapped = true;
const changed = source.onWidgetChanged;
source.onWidgetChanged = function () {
const result = changed?.apply(this, arguments);
for (const listener of this._h3AssetWatchers ?? []) listener();
return result;
};
}
source._h3AssetWatchers.add(sourceChanged);
}
for (const source of state.watchedSources) {
if (!current.has(source)) source._h3AssetWatchers?.delete(sourceChanged);
}
state.watchedSources = current;
}
function renderAssetBindings() {
assetList.replaceChildren();
if (!state.bindings.length) {
assetList.append(element(
"div", "h3rm-empty",
"Connect an image, video, or audio loader to the asset socket.",
));
return;
}
for (const binding of state.bindings) {
const row = element("div", "h3rm-asset");
const main = element("div", "h3rm-asset-main", binding.label);
const path = element(
"span", "h3rm-asset-path",
binding.original_value || "No loader filename detected",
);
main.title = `${binding.node_type} #${binding.node_id}`;
path.title = binding.original_value || main.title;
main.append(path);
const role = element("select");
for (const [value, label] of ASSET_ROLES) {
const option = element("option", "", label);
option.value = value;
role.append(option);
}
role.value = binding.role;
role.addEventListener("change", () => {
node.properties ??= {};
node.properties.h3_asset_roles ??= {};
node.properties.h3_asset_roles[binding.binding_id] = role.value;
syncAssetBindings();
});
row.append(main, role);
assetList.append(row);
}
}
function syncAssetBindings() {
if (state.disposed) return;
state.bindings = collectAssetBindings(node);
updateSourceWatches();
writeBindingsWidget();
renderAssetBindings();
}
function selectedRun() {
return state.runs.find((item) => item.run_name === state.selected) ?? null;
}
function setBusy(value) {
if (state.disposed) return;
state.busy = Boolean(value);
select.disabled = state.busy;
refresh.disabled = state.busy;
load.disabled = state.busy || !selectedRun()?.restorable;
open.disabled = state.busy || !selectedRun();
saveAssets.disabled = state.busy || !state.bindings.length;
}
function renderSelection() {
if (state.disposed) return;
renderActiveRun();
const run = selectedRun();
const runIdentity = runManagerIdentity(activeRunName(), run);
activeIdentity.textContent = runIdentity.activeLabel;
activeIdentity.title = "Generation and asset saving use this run_name from the connected Plan.";
selectedIdentity.textContent = runIdentity.selectedLabel;
selectedIdentity.title = runIdentity.same
? "The selected saved archive matches the connected Plan."
: "Selection alone does not change the Plan. Load the selected archive to apply it.";
selectedIdentity.classList.toggle("h3rm-same", runIdentity.same);
load.textContent = runIdentity.loadLabel;
saveAssets.textContent = "Save assets to active Plan";
saveAssets.title = runIdentity.saveLabel;
for (const option of select.options ?? []) {
const optionRun = state.runs.find(
(item) => item.run_name === option.value);
if (optionRun) option.textContent = runArchiveOptionLabel(
optionRun, runIdentity.active);
}
if (!run) {
details.textContent = state.runs.length
? "Select a saved H3 run." : "No saved H3 runs were found.";
load.disabled = true;
open.disabled = true;
return;
}
const scenes = run.scene_count == null ? "unknown scenes" : `${run.scene_count} scenes`;
const source = Object.entries(run.sources ?? {}).filter(([, ready]) => ready)
.map(([name]) => name.replace("_", " ")).join(", ") || "no archive";
details.textContent =
`${scenes} · ${run.checkpoint_count} checkpoints · ${run.asset_count ?? 0} assets · ${formatBytes(run.archive_bytes)}\n` +
`Modified ${localTime(run.modified_at)} · ${source}`;
load.disabled = state.busy || !run.restorable;
open.disabled = state.busy;
}
async function refreshRuns(preferredRunName = "") {
setBusy(true);
status.className = "h3rm-status";
status.textContent = "Scanning host output…";
try {
const payload = await jsonRequest("/minimax_h3_context_loop/runs");
if (state.disposed) return;
const previous = state.selected;
state.runs = Array.isArray(payload.runs) ? payload.runs : [];
const active = activeRunName();
const candidates = [preferredRunName, previous, active];
state.selected = candidates.find((candidate) =>
candidate && state.runs.some((item) => item.run_name === candidate))
?? state.runs.find((item) => item.restorable)?.run_name
?? state.runs[0]?.run_name ?? "";
select.replaceChildren();
for (const run of state.runs) {
const option = element(
"option", "", runArchiveOptionLabel(run, active));
option.value = run.run_name;
select.append(option);
}
select.value = state.selected;
status.textContent = `${state.runs.length} saved archive${state.runs.length === 1 ? "" : "s"}`;
} catch (error) {
state.runs = [];
state.selected = "";
select.replaceChildren();
status.className = "h3rm-status h3rm-error";
status.textContent = error?.message || String(error);
} finally {
setBusy(false);
renderSelection();
}
}
function captureRestoreOperation(planNode, run) {
return {
graph:node.graph, activeGraph:app.graph, planNode, run:run.run_name,
epoch:state.restoreEpoch,
planValues:JSON.stringify((planNode.widgets ?? []).map(
(widget) => [widget.name, widget.value],
)),
};
}
function requireCurrentRestore(operation) {
const graph = operation.graph;
if (state.disposed || state.restoreEpoch !== operation.epoch
|| !graph || node.graph !== graph || app.graph !== operation.activeGraph
|| graph.getNodeById?.(node.id) !== node
|| graph.getNodeById?.(operation.planNode.id) !== operation.planNode
|| upstreamPlanNode(node) !== operation.planNode
|| selectedRun()?.run_name !== operation.run
|| JSON.stringify((operation.planNode.widgets ?? []).map(
(widget) => [widget.name, widget.value],
)) !== operation.planValues) {
throw new Error(
"Archive load cancelled: the workflow, connected Plan, or Run " +
"changed while loading. Select the intended archive and load it again.",
);
}
}
async function loadRun() {
const run = selectedRun();
const planNode = upstreamPlanNode(node);
if (!run || !planNode || state.busy || state.disposed) {
if (!planNode) {
status.className = "h3rm-status h3rm-error";
status.textContent = "Connect the active Plan first.";
}
return;
}
const current = String(widgetByName(planNode, "run_name")?.value ?? "").trim();
const assetNotice = run.asset_count
? ` It will also attempt to restore ${run.asset_count} loader asset${run.asset_count === 1 ? "" : "s"}.`
: "";
const message = `Load saved run “${run.run_name}” into the connected Plan?\n\n` +
`This replaces all active scene prompts, archived Plan settings, and connected 0.5 policies${current ? ` from “${current}”` : ""}.${assetNotice}`;
if (!window.confirm(message)) return;
const operation = captureRestoreOperation(planNode, run);
setBusy(true);
status.className = "h3rm-status";
status.textContent = "Loading archive…";
try {
const query = new URLSearchParams({run_name: run.run_name});
const payload = await jsonRequest(`/minimax_h3_context_loop/run?${query}`);
// Never apply a delayed archive to a different tab, replacement
// node with the same ID, or Plan edited since the load began.
requireCurrentRestore(operation);
const result = applyPlanInputs(
planNode, payload.plan_inputs, payload.policy_inputs);
const assetResults = [];
const graph = operation.graph;
graph?.beforeChange?.();
try {
for (const binding of payload.assets?.bindings ?? []) {
assetResults.push(applyAssetBinding(graph, binding));
}
} finally {
graph?.afterChange?.();
}
const assetFailures = assetResults.filter((item) => !item.applied);
const assetApplied = assetResults.length - assetFailures.length;
const warning = [
...(payload.warnings ?? []),
...(result.unavailable.length
? [`Unavailable current widgets: ${result.unavailable.join(", ")}`] : []),
...(result.policies.unavailable.length
? [`Unavailable 0.5 policies: ${result.policies.unavailable.join(", ")}`] : []),
...assetFailures.map((item) =>
`${item.binding?.label ?? "Asset"}: ${item.reason.replaceAll("_", " ")}`),
];
status.className = warning.length
? "h3rm-status h3rm-error" : "h3rm-status";
status.textContent = warning.length
? `Active Plan is now “${run.run_name}”: loaded ${payload.scene_count ?? "saved"} scenes and ${assetApplied} assets · ${warning.join(" · ")}`
: `Active Plan is now “${run.run_name}”: loaded ${payload.scene_count ?? "saved"} scenes and ${assetApplied} assets`;
syncAssetBindings();
renderSelection();
graph?.setDirtyCanvas?.(true, true);
} catch (error) {
status.className = "h3rm-status h3rm-error";
status.textContent = error?.message || String(error);
} finally {
setBusy(false);
}
}
async function openRunFolder() {
const run = selectedRun();
if (!run || state.busy) return;
setBusy(true);
status.className = "h3rm-status";
status.textContent = "Opening run folder…";
try {
const response = await api.fetchApi(
"/minimax_h3_context_loop/open-run-folder",
{
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({run_name: run.run_name}),
},
);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
if (payload.opened) status.textContent = "Opened on ComfyUI host";
else {
try {
await navigator.clipboard.writeText(payload.path);
status.textContent = "Host path copied";
} catch (_error) {
status.textContent = payload.path;
}
status.title = `${payload.path}${payload.error ? `\n${payload.error}` : ""}`;
}
} catch (error) {
status.className = "h3rm-status h3rm-error";
status.textContent = error?.message || String(error);
} finally {
setBusy(false);
}
}
async function saveRunAssets() {
const planNode = upstreamPlanNode(node);
const runName = String(widgetByName(planNode, "run_name")?.value ?? "").trim();
if (!planNode || !runName || !state.bindings.length || state.busy) {
status.className = "h3rm-status h3rm-error";
status.textContent = !planNode
? "Connect the active Plan first."
: !runName
? "Set run_name on the connected Plan first."
: "Connect at least one loader asset.";
return;
}
syncAssetBindings();
setBusy(true);
status.className = "h3rm-status";
status.textContent = "Saving asset manifest…";
try {
const response = await api.fetchApi(
"/minimax_h3_context_loop/run-assets",
{
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
run_name: runName,
bindings: state.bindings,
archive_images: Boolean(widgetByName(node, "archive_images")?.value),
archive_audio: Boolean(widgetByName(node, "archive_audio")?.value),
archive_video: Boolean(widgetByName(node, "archive_video")?.value),
}),
},
);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
const warning = payload.warnings?.length
? ` · ${payload.warnings.join(" · ")}` : "";
const savedRunName = String(payload.run_name || runName);
await refreshRuns(savedRunName);
status.className = warning ? "h3rm-status h3rm-error" : "h3rm-status";
status.textContent = `Saved ${payload.asset_count} bindings to “${savedRunName}”, ${payload.archived_asset_count ?? 0} archived${warning}`;
} catch (error) {
status.className = "h3rm-status h3rm-error";
status.textContent = error?.message || String(error);
} finally {
setBusy(false);
}
}
select.addEventListener("change", () => {
state.restoreEpoch += 1;
state.selected = select.value;
status.className = "h3rm-status";
status.textContent = "";
renderSelection();
});
const load = button("Load selected archive into Plan", "Replace the connected Plan after confirmation", () => {
void loadRun();
});
load.classList.add("h3rm-load");
const refresh = button("Refresh", "Rescan output/h3_chains on the ComfyUI host", () => {
void refreshRuns();
});
const open = button("Open selected folder", "Open the selected archive folder on the ComfyUI host", () => {
void openRunFolder();
});
const saveAssets = button(
"Save assets to active Plan",
"Write loader paths and enabled fallback copies into the active run folder",
() => { void saveRunAssets(); },
);
actions.append(load, refresh, open, saveAssets, status);
assetSection.append(assetHeader, assetList);
root.append(title, identity, select, details, assetSection, actions);
const widget = node.addDOMWidget("h3_run_manager", "h3-run-manager", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 340,
});
widget.serialize = false;
node.setSize?.([
Math.max(Number(node.size?.[0]) || 0, 520),
Math.max(Number(node.size?.[1]) || 0, 500),
]);
const connectionsChanged = node.onConnectionsChange;
node.onConnectionsChange = function () {
state.restoreEpoch += 1;
const result = connectionsChanged?.apply(this, arguments);
window.setTimeout(() => {
if (state.disposed) return;
stabilizeAssetInputs(node);
syncAssetBindings();
renderSelection();
}, 0);
return result;
};
const serialized = node.onSerialize;
node.onSerialize = function () {
syncAssetBindings();
return serialized?.apply(this, arguments);
};
const removed = node.onRemoved;
node.onRemoved = function () {
state.disposed = true;
state.restoreEpoch += 1;
for (const source of state.watchedSources) {
source._h3AssetWatchers?.delete(sourceChanged);
}
state.watchedSources.clear();
state.watchedPlanWidget?._h3RunManagerWatchers?.delete(activeRunChanged);
state.watchedPlanWidget = null;
return removed?.apply(this, arguments);
};
node._h3RunManagerRefresh = () => {
if (state.disposed) return;
removeLegacyStatusOutput(node);
for (const name of ASSET_WIDGETS) collapseWidget(widgetByName(node, name));
stabilizeAssetInputs(node);
syncAssetBindings();
renderSelection();
};
window.setTimeout(() => {
node._h3RunManagerRefresh?.();
}, 100);
void refreshRuns(activeRunName());
}
app.registerExtension({
name: "minimax_h3_context_loop.run_manager",
async beforeRegisterNodeDef(nodeTypeClass, nodeData) {
if (nodeData.name !== NODE_NAME) return;
const created = nodeTypeClass.prototype.onNodeCreated;
nodeTypeClass.prototype.onNodeCreated = function () {
const result = created?.apply(this, arguments);
window.setTimeout(() => mount(this), 0);
return result;
};
const configured = nodeTypeClass.prototype.onConfigure;
nodeTypeClass.prototype.onConfigure = function () {
const result = configured?.apply(this, arguments);
window.setTimeout(() => this._h3RunManagerRefresh?.(), 0);
return result;
};
const graphConfigured = nodeTypeClass.prototype.onGraphConfigured;
nodeTypeClass.prototype.onGraphConfigured = function () {
const result = graphConfigured?.apply(this, arguments);
window.setTimeout(() => this._h3RunManagerRefresh?.(), 0);
return result;
};
},
async nodeCreated(node) {
if (nodeType(node) === NODE_NAME) mount(node);
},
});