feat(models): missing requirements are a compact list above the grid

One line each with a + on the right, dismissible (persisted, returns as a one-line link).
Satisfied, optional and the guided-wiring rules fold away.
This commit is contained in:
digital-garbage
2026-08-14 23:11:14 +03:00
parent e1a907a93b
commit ec90c19d2f
2 changed files with 98 additions and 40 deletions
+83 -37
View File
@@ -1183,71 +1183,114 @@
return slotProducesType(req.type, null);
}
// Dismiss state for the requirements list. Persisted, because a pipeline someone is
// deliberately leaving incomplete stays incomplete across reloads, and re-nagging every
// time the panel opens is exactly what the dismiss is for.
const REQ_HIDE_KEY = "funpack_models_req_hidden";
function reqPanelHidden(next) {
try {
if (next === undefined) return localStorage.getItem(REQ_HIDE_KEY) === "1";
if (next) localStorage.setItem(REQ_HIDE_KEY, "1");
else localStorage.removeItem(REQ_HIDE_KEY);
} catch (_) { /* private mode: the list just stays visible */ }
return !!next;
}
function requirementsPanel() {
const sec = el("div", "req-panel");
if (config.disable_core) {
sec.append(el("div", "req-title", "Custom workflow"));
// No FunPack core means no FunPack requirements — say so once, briefly, and get out
// of the way. Everything else in this panel still works on the user's own nodes.
sec.append(el("div", "req-empty",
"Built-in FunPack pipeline is off. Wire your workflow nodes below; FunPack loader requirements do not apply."));
"Custom workflow — the built-in pipeline is off, so FunPack's loader requirements "
+ "do not apply. Wire your own nodes below."));
return sec;
}
if (pipelineLocked()) {
if (reqPanelHidden()) {
const missingNow = requirements.filter((r) => r.required && !requirementSatisfied(r));
if (!missingNow.length) return sec; // nothing to say; stay quiet
const line = el("div", "req-collapsed");
line.append(el("span", "req-dot", "✕"));
line.append(el("span", null, `${missingNow.length} missing in pipeline`));
const show = el("button", "btn ghost tiny", "Show");
show.onclick = () => { reqPanelHidden(false); render(); };
line.append(show);
sec.append(line);
return sec;
}
// The wiring rules are reference material, not a warning — folded away so the first
// thing in this panel is the short list of what is actually missing.
function guidedRulesFold() {
// The latent and audio path differ by family — H3 makes both streams in one node
// that feeds the sampler directly, so pointing at Studio · latent here would send
// the user to a port its graph does not even have.
const h3 = familyKey() === "minimax_h3";
sec.append(el("div", "req-hint guided-hint",
const det = el("details", "req-fold");
det.append(el("summary", "req-fold-sum", "Guided wiring rules"));
det.append(el("div", "req-hint guided-hint",
(h3
? "Guided wiring: the AV LATENT → Chain Sampler · latent_template (Empty MiniMax H3 "
? "The AV LATENT → Chain Sampler · latent_template (Empty MiniMax H3 "
+ "AV Latent makes both streams). Audio VAE → VAE Decode Audio · vae, and optionally "
+ "also Chain Sampler · audio_vae to encode audio references. "
: "Guided wiring: LATENT → Studio · latent (core forwards to Concat · video_latent). "
: "LATENT → Studio · latent (core forwards to Concat · video_latent). "
+ "Audio latent → Concat · audio_latent only. ")
+ "IMAGE → Studio · source_image (Input Image Processing defaults to Timeline scene image). "
+ "MODEL/CLIP may chain through LoRA. "
+ "Enable Full control for manual rewiring."));
return det;
}
sec.append(el("div", "req-title", "Pipeline requirements"));
if (!requirements.length) {
sec.append(el("div", "req-title", "Pipeline requirements"));
sec.append(el("div", "req-empty", "Requirements not loaded — open Models to refresh."));
return sec;
}
const required = requirements.filter((r) => r.required);
const optional = requirements.filter((r) => !r.required);
const allOk = required.every(requirementSatisfied);
const missing = required.filter((r) => !requirementSatisfied(r));
const allOk = !missing.length;
required.forEach((req) => {
// One line per requirement: dot, name, type, and — only when it is missing and we know
// which role fills it — a + on the right. The hint used to be a paragraph inside the
// row, which made every missing item a block three times the height of a satisfied one.
function reqRow(req, cls) {
const ok = requirementSatisfied(req);
const row = el("div", "req-row" + (ok ? " ok" : " missing"));
row.append(el("span", "req-dot", ok ? "✓" : "✕"));
const lbl = el("span", "req-label", req.label);
const badge = el("span", "req-type", req.type);
row.append(lbl); row.append(badge);
if (!ok) {
const hint = el("div", "req-hint", req.hint);
row.append(hint);
if (req.role_hint) {
const addBtn = el("button", "btn ghost tiny req-add", "+ Add");
addBtn.onclick = () => openNodeSetup(req.role_hint);
row.append(addBtn);
}
const row = el("div", "req-row" + cls + (ok ? " ok" : (req.required ? " missing" : "")));
row.append(el("span", "req-dot", ok ? "✓" : (req.required ? "✕" : "·")));
row.append(el("span", "req-label", req.label));
row.append(el("span", "req-type", req.type));
if (req.hint) row.title = req.hint;
if (!ok && req.required && req.role_hint) {
const addBtn = el("button", "btn ghost tiny req-add", "+");
addBtn.title = `Add a ${req.label} — ${req.hint || "picks the node for you"}`;
addBtn.onclick = () => openNodeSetup(req.role_hint);
row.append(addBtn);
}
sec.append(row);
});
return row;
}
if (optional.length) {
const optHead = el("div", "req-opt-head", "Optional");
sec.append(optHead);
optional.forEach((req) => {
const ok = requirementSatisfied(req);
const row = el("div", "req-row opt" + (ok ? " ok" : ""));
row.append(el("span", "req-dot", ok ? "✓" : "·"));
row.append(el("span", "req-label", req.label));
row.append(el("span", "req-type", req.type));
sec.append(row);
});
const head = el("div", "req-head");
head.append(el("span", "req-title", missing.length
? `Missing in pipeline · ${missing.length}` : "Pipeline requirements"));
// Dismissable, because a half-built pipeline the user is deliberately leaving half-built
// should not keep shouting. It comes back as a one-line link, never silently.
const hide = el("button", "req-dismiss", "✕");
hide.title = "Hide this list";
hide.onclick = () => { reqPanelHidden(true); render(); };
head.append(hide);
sec.append(head);
// Missing first: it is the only part that needs doing.
missing.forEach((req) => sec.append(reqRow(req, "")));
const satisfied = required.filter(requirementSatisfied);
if (satisfied.length || optional.length) {
const det = el("details", "req-fold");
det.open = false;
det.append(el("summary", "req-fold-sum",
`Satisfied${optional.length ? " and optional" : ""} · ${satisfied.length + optional.length}`));
satisfied.forEach((req) => det.append(reqRow(req, "")));
optional.forEach((req) => det.append(reqRow(req, " opt")));
sec.append(det);
}
if (allOk && config.slots.length) {
@@ -1262,6 +1305,7 @@
sec.append(bar);
}
if (pipelineLocked()) sec.append(guidedRulesFold());
return sec;
}
@@ -1750,8 +1794,10 @@
pane.append(banner);
}
pane.append(familySection());
pane.append(nodeGrid(v));
// Above the node grid: what is missing is what the user came here to fix, and it used
// to sit below a grid tall enough to push it off screen.
pane.append(requirementsPanel());
pane.append(nodeGrid(v));
pane.append(coreSection());
return pane;
}
+15 -3
View File
@@ -1479,9 +1479,19 @@ body.tl-spacing { cursor: grabbing; user-select: none; }
/* config validation banner */
/* Pipeline requirements panel */
.req-panel { margin-bottom: 18px; }
.req-title { font-size: 11px; color: var(--teal); margin-bottom: 8px; font-weight: 600;}
.req-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.req-title { font-size: 11px; color: var(--teal); font-weight: 600; }
.req-dismiss { margin-left: auto; background: none; border: 0; cursor: pointer;
color: var(--faint); font-size: 12px; line-height: 1; padding: 2px 4px; border-radius: 4px; }
.req-dismiss:hover { color: var(--fg); background: var(--line); }
.req-collapsed { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
.req-collapsed .req-dot { color: var(--danger); }
.req-fold { margin-top: 6px; }
.req-fold-sum { font-size: 11px; color: var(--faint); cursor: pointer; padding: 4px 0; }
.req-opt-head { font-size: 11px; color: var(--faint); margin: 8px 0 4px; font-weight: 600;}
.req-row { display: flex; align-items: flex-start; gap: 8px; padding: 6px 10px; border-radius: 6px; margin-bottom: 4px; font-size: 12px; flex-wrap: wrap; }
/* One line, never wrapping: the row is a status, not a paragraph. */
.req-row { display: flex; align-items: center; gap: 8px; padding: 5px 10px; border-radius: 6px; margin-bottom: 3px; font-size: 12px; }
.req-row .req-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.req-row.ok { background: color-mix(in srgb, var(--teal) 8%, transparent); }
.req-row.missing { background: color-mix(in srgb, var(--danger) 8%, transparent); border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); }
.req-row.opt { background: transparent; opacity: 0.7; }
@@ -1491,7 +1501,9 @@ body.tl-spacing { cursor: grabbing; user-select: none; }
.req-label { flex: 1; }
.req-type { font-family: var(--font-mono); font-size: 10px; color: var(--faint); flex: 0 0 auto; padding: 1px 5px; border: 1px solid var(--line); border-radius: 3px; }
.req-hint { width: 100%; margin-top: 3px; margin-left: 22px; font-size: 11px; color: var(--muted); }
.req-add { flex: 0 0 auto; }
/* Square, so it reads as an action on the row rather than a second label. */
.req-add { flex: 0 0 auto; width: 20px; height: 20px; padding: 0; line-height: 1;
display: inline-flex; align-items: center; justify-content: center; font-size: 14px; }
.req-empty { color: var(--faint); font-size: 12px; padding: 8px 0; }
.valid-banner { display: flex; align-items: center; gap: 9px; padding: 9px 12px; border-radius: 8px; margin-bottom: 14px; font-size: 12px; border: 1px solid var(--line); }