Download ComfyUI-MiniMaxH3-Context-Loop/web/h3_chain_run_manager.js from dzmdd/Fast_deploy: direct link, hf CLI and curl.
- Browser
- Download file 35.3 kB
-
https://huggingface.co/dzmdd/Fast_deploy/resolve/main/ComfyUI-MiniMaxH3-Context-Loop/web/h3_chain_run_manager.js
- Command line
-
hf download hf://dzmdd/Fast_deploy/ComfyUI-MiniMaxH3-Context-Loop/web/h3_chain_run_manager.js
-
curl -L -o h3_chain_run_manager.js https://huggingface.co/dzmdd/Fast_deploy/resolve/main/ComfyUI-MiniMaxH3-Context-Loop/web/h3_chain_run_manager.js
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); | |
| }, | |
| }); | |