feat: grid dropdown shows linked flake tunables as a second panel (#235)
When a host flake on the FlakeStack / FlakeCombo grid has a flake_link, the option dropdown now shows a SECOND panel to the right of the native panel: - Title with the linked flake's display name (and full target path in tooltip). - One slider per lora on the linked flake, defaulting in priority: per-grid override -> yaml flake_link default -> linked flake's own default. Writes go to entry.flake_link_override.lora_strengths[i]. - One dropdown per variant group on the linked flake, with the same override precedence. Writes go to entry.flake_link_override.variant. Implementation: - Restructure the option panel from a single-column popout into a two-column flex row (nativeColumn + linkedColumn). nativeColumn keeps the existing rendering; linkedColumn is display:none until loadOptions discovers a flake_link on the host. - loadOptions also fetches the linked flake's data when the host declares a flake_link, so we know its loras + variants for the linked panel. - Per-placement overrides ride along with the entry through flakes_json (FlakeStack) / _combo_flakes (FlakeCombo) without any extra wiring — JSON.stringify already includes the field. Applied to both flake-stack.js and flake-combo.js with the same two-column structure. Refs #232, closes #235
This commit is contained in:
+109
-13
@@ -105,19 +105,29 @@ function makeComboBlock({ entry, idx, isActive, isGenerating, onEdit, onRemove,
|
||||
function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVariantChange) {
|
||||
if (entry.inline) return { toggleOptionsPanel: () => {} };
|
||||
|
||||
// Options panel (hidden by default)
|
||||
// Options panel (hidden by default). Same two-column layout as FlakeStack
|
||||
// for #235 — native panel + linked flake panel when host has a flake_link.
|
||||
const panel = document.createElement("div");
|
||||
css(panel, "position:absolute;top:100%;left:50%;transform:translateX(-50%);width:160px;background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:4px;display:none;flex-direction:column;gap:3px;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,0.5);margin-top:1px;");
|
||||
css(panel, "position:absolute;top:100%;left:50%;transform:translateX(-50%);background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:0;display:none;flex-direction:row;gap:0;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,0.5);margin-top:1px;");
|
||||
panel.addEventListener("click", (e) => e.stopPropagation());
|
||||
panel.addEventListener("dblclick", (e) => e.stopPropagation());
|
||||
block.appendChild(panel);
|
||||
|
||||
const nativeColumn = document.createElement("div");
|
||||
css(nativeColumn, "width:160px;padding:4px;display:flex;flex-direction:column;gap:3px;box-sizing:border-box;");
|
||||
panel.appendChild(nativeColumn);
|
||||
|
||||
const linkedColumn = document.createElement("div");
|
||||
css(linkedColumn, "width:160px;padding:4px;display:none;flex-direction:column;gap:3px;box-sizing:border-box;border-left:1px solid #333;");
|
||||
panel.appendChild(linkedColumn);
|
||||
|
||||
let optionsLoaded = false;
|
||||
let hasOptions = null;
|
||||
let flakeData = null;
|
||||
let linkedFlakeData = null;
|
||||
|
||||
function rebuildPanel() {
|
||||
panel.replaceChildren();
|
||||
nativeColumn.replaceChildren();
|
||||
|
||||
const lorasMeta = flakeData?.loras || [];
|
||||
if (entry.loras.length > lorasMeta.length) {
|
||||
@@ -138,7 +148,7 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
const opLabel = document.createElement("div");
|
||||
opLabel.textContent = "Output Stem";
|
||||
css(opLabel, "font-size:9px;opacity:0.7;text-align:center;");
|
||||
panel.appendChild(opLabel);
|
||||
nativeColumn.appendChild(opLabel);
|
||||
const opInput = document.createElement("input");
|
||||
opInput.type = "text";
|
||||
opInput.value = outputStem;
|
||||
@@ -148,12 +158,12 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
entry._output_stem_override = opInput.value || null;
|
||||
onChanged();
|
||||
});
|
||||
panel.appendChild(opInput);
|
||||
nativeColumn.appendChild(opInput);
|
||||
|
||||
if (hasLoras) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
panel.appendChild(sep);
|
||||
nativeColumn.appendChild(sep);
|
||||
for (let i = 0; i < lorasMeta.length; i++) {
|
||||
const sliderRow = document.createElement("div");
|
||||
css(sliderRow, "padding:2px 0;");
|
||||
@@ -167,14 +177,14 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
onChanged();
|
||||
});
|
||||
sliderRow.appendChild(strSlider);
|
||||
panel.appendChild(sliderRow);
|
||||
nativeColumn.appendChild(sliderRow);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOptionGroups) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
panel.appendChild(sep);
|
||||
nativeColumn.appendChild(sep);
|
||||
for (const group of Object.keys(hasOptions)) {
|
||||
const row = document.createElement("div");
|
||||
css(row, "display:flex;flex-direction:column;gap:2px;");
|
||||
@@ -199,9 +209,92 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
if (onVariantChange) onVariantChange();
|
||||
});
|
||||
row.appendChild(dd.container);
|
||||
panel.appendChild(row);
|
||||
nativeColumn.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
rebuildLinkedPanel();
|
||||
}
|
||||
|
||||
function rebuildLinkedPanel() {
|
||||
if (!flakeData?.flake_link?.target || !linkedFlakeData) {
|
||||
linkedColumn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
linkedColumn.style.display = "flex";
|
||||
linkedColumn.replaceChildren();
|
||||
|
||||
entry.flake_link_override = entry.flake_link_override || { variant: {}, lora_strengths: [] };
|
||||
const ovr = entry.flake_link_override;
|
||||
ovr.variant = ovr.variant || {};
|
||||
ovr.lora_strengths = ovr.lora_strengths || [];
|
||||
|
||||
const yamlDefaults = flakeData.flake_link;
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.textContent = (linkedFlakeData.name || flakeData.flake_link.target).split("/").pop();
|
||||
title.title = `Linked: ${flakeData.flake_link.target}`;
|
||||
css(title, "font-size:9px;opacity:0.8;text-align:center;font-weight:600;color:#4a9eff;padding:2px 0;");
|
||||
linkedColumn.appendChild(title);
|
||||
|
||||
const linkedLoras = Array.isArray(linkedFlakeData.loras) ? linkedFlakeData.loras : [];
|
||||
if (linkedLoras.length > 0) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
linkedColumn.appendChild(sep);
|
||||
for (let i = 0; i < linkedLoras.length; i++) {
|
||||
const sliderRow = document.createElement("div");
|
||||
css(sliderRow, "padding:2px 0;");
|
||||
const name = linkedLoras[i]?.name || "LoRA";
|
||||
const label = document.createElement("div");
|
||||
label.textContent = name;
|
||||
css(label, "font-size:9px;opacity:0.7;padding:2px 0;text-align:center;");
|
||||
sliderRow.appendChild(label);
|
||||
let initial = ovr.lora_strengths[i];
|
||||
if (initial === null || initial === undefined) initial = (yamlDefaults.lora_strengths || [])[i];
|
||||
if (initial === null || initial === undefined) initial = linkedLoras[i]?.strength ?? 1.0;
|
||||
const strSlider = makeSmallValueSlider(initial, -10, 10, 0.05, (v) => {
|
||||
while (ovr.lora_strengths.length <= i) ovr.lora_strengths.push(null);
|
||||
ovr.lora_strengths[i] = v;
|
||||
onChanged();
|
||||
});
|
||||
sliderRow.appendChild(strSlider);
|
||||
linkedColumn.appendChild(sliderRow);
|
||||
}
|
||||
}
|
||||
|
||||
const linkedVariants = linkedFlakeData.variants || linkedFlakeData.options || {};
|
||||
if (Object.keys(linkedVariants).length > 0) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
linkedColumn.appendChild(sep);
|
||||
for (const group of Object.keys(linkedVariants)) {
|
||||
const row = document.createElement("div");
|
||||
css(row, "display:flex;flex-direction:column;gap:2px;");
|
||||
const gLabel = document.createElement("span");
|
||||
gLabel.textContent = group;
|
||||
css(gLabel, "font-size:9px;opacity:0.7;text-align:center;");
|
||||
row.appendChild(gLabel);
|
||||
const ddOptions = [{ value: "", label: "-" }];
|
||||
for (const ch of Object.keys(linkedVariants[group])) ddOptions.push({ value: ch, label: ch });
|
||||
const current = ovr.variant[group] || (yamlDefaults.variant || {})[group] || "";
|
||||
const dd = makePanelDropdown(ddOptions, current);
|
||||
dd.element.addEventListener("change", () => {
|
||||
if (dd.element.value) ovr.variant[group] = dd.element.value;
|
||||
else delete ovr.variant[group];
|
||||
onChanged();
|
||||
});
|
||||
row.appendChild(dd.container);
|
||||
linkedColumn.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
if (linkedLoras.length === 0 && Object.keys(linkedVariants).length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
css(empty, "font-size:9px;opacity:0.5;padding:4px;text-align:center;");
|
||||
empty.textContent = "no overrides";
|
||||
linkedColumn.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
@@ -211,6 +304,9 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
optionsLoaded = true;
|
||||
hasOptions = variants;
|
||||
flakeData = fdata;
|
||||
if (fdata?.flake_link?.target) {
|
||||
try { linkedFlakeData = await fetchFlake(fdata.flake_link.target); } catch { /* leave null */ }
|
||||
}
|
||||
rebuildPanel();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -243,20 +339,20 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
}
|
||||
|
||||
if (!optionsLoaded && entry.name) {
|
||||
panel.textContent = "";
|
||||
nativeColumn.replaceChildren();
|
||||
const loading = document.createElement("div");
|
||||
css(loading, "font-size:9px;opacity:0.5;text-align:center;padding:4px;");
|
||||
loading.textContent = "loading...";
|
||||
panel.appendChild(loading);
|
||||
nativeColumn.appendChild(loading);
|
||||
|
||||
try {
|
||||
await loadOptions();
|
||||
} catch {
|
||||
panel.replaceChildren();
|
||||
nativeColumn.replaceChildren();
|
||||
const err = document.createElement("div");
|
||||
css(err, "font-size:9px;color:#f88;padding:4px;text-align:center;");
|
||||
err.textContent = "failed to load";
|
||||
panel.appendChild(err);
|
||||
nativeColumn.appendChild(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+118
-15
@@ -115,19 +115,29 @@ function makeBlock({ entry, idx, onEdit, onRemove, onReplace, onToggleBypass, on
|
||||
function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVariantChange) {
|
||||
if (entry.inline) return { toggleOptionsPanel: () => {} };
|
||||
|
||||
// Options panel (hidden by default)
|
||||
// Options panel (hidden by default). Container is a flex row so we can add
|
||||
// a second panel to the right when the host flake has a flake_link (#235).
|
||||
const panel = document.createElement("div");
|
||||
css(panel, "position:absolute;top:100%;left:50%;transform:translateX(-50%);width:160px;background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:4px;display:none;flex-direction:column;gap:3px;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,0.5);margin-top:1px;");
|
||||
css(panel, "position:absolute;top:100%;left:50%;transform:translateX(-50%);background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:0;display:none;flex-direction:row;gap:0;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,0.5);margin-top:1px;");
|
||||
panel.addEventListener("click", (e) => e.stopPropagation());
|
||||
panel.addEventListener("dblclick", (e) => e.stopPropagation());
|
||||
block.appendChild(panel);
|
||||
|
||||
const nativeColumn = document.createElement("div");
|
||||
css(nativeColumn, "width:160px;padding:4px;display:flex;flex-direction:column;gap:3px;box-sizing:border-box;");
|
||||
panel.appendChild(nativeColumn);
|
||||
|
||||
const linkedColumn = document.createElement("div");
|
||||
css(linkedColumn, "width:160px;padding:4px;display:none;flex-direction:column;gap:3px;box-sizing:border-box;border-left:1px solid #333;");
|
||||
panel.appendChild(linkedColumn);
|
||||
|
||||
let optionsLoaded = false;
|
||||
let hasOptions = false;
|
||||
let flakeData = null;
|
||||
let linkedFlakeData = null;
|
||||
|
||||
function rebuildPanel() {
|
||||
panel.replaceChildren();
|
||||
nativeColumn.replaceChildren();
|
||||
|
||||
const lorasMeta = flakeData?.loras || [];
|
||||
// Sync override array length to match the YAML
|
||||
@@ -149,7 +159,7 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
const opLabel = document.createElement("div");
|
||||
opLabel.textContent = "Output Stem";
|
||||
css(opLabel, "font-size:9px;opacity:0.7;text-align:center;");
|
||||
panel.appendChild(opLabel);
|
||||
nativeColumn.appendChild(opLabel);
|
||||
const opInput = document.createElement("input");
|
||||
opInput.type = "text";
|
||||
opInput.value = outputStem;
|
||||
@@ -159,13 +169,13 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
entry._output_stem_override = opInput.value || null;
|
||||
onChanged();
|
||||
});
|
||||
panel.appendChild(opInput);
|
||||
nativeColumn.appendChild(opInput);
|
||||
|
||||
// 2. LoRA strength sliders
|
||||
if (hasLoras) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
panel.appendChild(sep);
|
||||
nativeColumn.appendChild(sep);
|
||||
for (let i = 0; i < lorasMeta.length; i++) {
|
||||
const sliderRow = document.createElement("div");
|
||||
css(sliderRow, "padding:2px 0;");
|
||||
@@ -179,7 +189,7 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
onChanged();
|
||||
});
|
||||
sliderRow.appendChild(strSlider);
|
||||
panel.appendChild(sliderRow);
|
||||
nativeColumn.appendChild(sliderRow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +197,7 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
if (hasOptionGroups) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
panel.appendChild(sep);
|
||||
nativeColumn.appendChild(sep);
|
||||
for (const group of Object.keys(hasOptions)) {
|
||||
const row = document.createElement("div");
|
||||
css(row, "display:flex;flex-direction:column;gap:2px;");
|
||||
@@ -212,16 +222,103 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
if (onVariantChange) onVariantChange();
|
||||
});
|
||||
row.appendChild(dd.container);
|
||||
panel.appendChild(row);
|
||||
nativeColumn.appendChild(row);
|
||||
}
|
||||
} else if (!hasLoras) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
panel.appendChild(sep);
|
||||
nativeColumn.appendChild(sep);
|
||||
const empty = document.createElement("div");
|
||||
css(empty, "font-size:9px;opacity:0.5;padding:4px;text-align:center;");
|
||||
empty.textContent = "no variants";
|
||||
panel.appendChild(empty);
|
||||
nativeColumn.appendChild(empty);
|
||||
}
|
||||
|
||||
rebuildLinkedPanel();
|
||||
}
|
||||
|
||||
// Linked flake panel (#235): shown when the host flake declares a
|
||||
// flake_link. The overrides write to entry.flake_link_override which the
|
||||
// backend's resolve() reads at execute time.
|
||||
function rebuildLinkedPanel() {
|
||||
if (!flakeData?.flake_link?.target || !linkedFlakeData) {
|
||||
linkedColumn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
linkedColumn.style.display = "flex";
|
||||
linkedColumn.replaceChildren();
|
||||
|
||||
entry.flake_link_override = entry.flake_link_override || { variant: {}, lora_strengths: [] };
|
||||
const ovr = entry.flake_link_override;
|
||||
ovr.variant = ovr.variant || {};
|
||||
ovr.lora_strengths = ovr.lora_strengths || [];
|
||||
|
||||
const yamlDefaults = flakeData.flake_link;
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.textContent = (linkedFlakeData.name || flakeData.flake_link.target).split("/").pop();
|
||||
title.title = `Linked: ${flakeData.flake_link.target}`;
|
||||
css(title, "font-size:9px;opacity:0.8;text-align:center;font-weight:600;color:#4a9eff;padding:2px 0;");
|
||||
linkedColumn.appendChild(title);
|
||||
|
||||
const linkedLoras = Array.isArray(linkedFlakeData.loras) ? linkedFlakeData.loras : [];
|
||||
if (linkedLoras.length > 0) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
linkedColumn.appendChild(sep);
|
||||
for (let i = 0; i < linkedLoras.length; i++) {
|
||||
const sliderRow = document.createElement("div");
|
||||
css(sliderRow, "padding:2px 0;");
|
||||
const name = linkedLoras[i]?.name || "LoRA";
|
||||
const label = document.createElement("div");
|
||||
label.textContent = name;
|
||||
css(label, "font-size:9px;opacity:0.7;padding:2px 0;text-align:center;");
|
||||
sliderRow.appendChild(label);
|
||||
// Effective default: per-grid override > yaml default > linked flake's own
|
||||
let initial = ovr.lora_strengths[i];
|
||||
if (initial === null || initial === undefined) initial = (yamlDefaults.lora_strengths || [])[i];
|
||||
if (initial === null || initial === undefined) initial = linkedLoras[i]?.strength ?? 1.0;
|
||||
const strSlider = makeSmallValueSlider(initial, -10, 10, 0.05, (v) => {
|
||||
while (ovr.lora_strengths.length <= i) ovr.lora_strengths.push(null);
|
||||
ovr.lora_strengths[i] = v;
|
||||
onChanged();
|
||||
});
|
||||
sliderRow.appendChild(strSlider);
|
||||
linkedColumn.appendChild(sliderRow);
|
||||
}
|
||||
}
|
||||
|
||||
const linkedVariants = linkedFlakeData.variants || linkedFlakeData.options || {};
|
||||
if (Object.keys(linkedVariants).length > 0) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
linkedColumn.appendChild(sep);
|
||||
for (const group of Object.keys(linkedVariants)) {
|
||||
const row = document.createElement("div");
|
||||
css(row, "display:flex;flex-direction:column;gap:2px;");
|
||||
const gLabel = document.createElement("span");
|
||||
gLabel.textContent = group;
|
||||
css(gLabel, "font-size:9px;opacity:0.7;text-align:center;");
|
||||
row.appendChild(gLabel);
|
||||
const ddOptions = [{ value: "", label: "-" }];
|
||||
for (const ch of Object.keys(linkedVariants[group])) ddOptions.push({ value: ch, label: ch });
|
||||
const current = ovr.variant[group] || (yamlDefaults.variant || {})[group] || "";
|
||||
const dd = makePanelDropdown(ddOptions, current);
|
||||
dd.element.addEventListener("change", () => {
|
||||
if (dd.element.value) ovr.variant[group] = dd.element.value;
|
||||
else delete ovr.variant[group];
|
||||
onChanged();
|
||||
});
|
||||
row.appendChild(dd.container);
|
||||
linkedColumn.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
if (linkedLoras.length === 0 && Object.keys(linkedVariants).length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
css(empty, "font-size:9px;opacity:0.5;padding:4px;text-align:center;");
|
||||
empty.textContent = "no overrides";
|
||||
linkedColumn.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +329,12 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
optionsLoaded = true;
|
||||
hasOptions = variants;
|
||||
flakeData = fdata;
|
||||
// Also fetch linked flake data if the host declares a flake_link.
|
||||
if (fdata?.flake_link?.target) {
|
||||
try {
|
||||
linkedFlakeData = await fetchFlake(fdata.flake_link.target);
|
||||
} catch { /* leave null — rebuildLinkedPanel hides the column */ }
|
||||
}
|
||||
rebuildPanel();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -264,20 +367,20 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
}
|
||||
|
||||
if (!optionsLoaded && entry.name) {
|
||||
panel.textContent = "";
|
||||
nativeColumn.replaceChildren();
|
||||
const loading = document.createElement("div");
|
||||
css(loading, "font-size:9px;opacity:0.5;text-align:center;padding:4px;");
|
||||
loading.textContent = "loading...";
|
||||
panel.appendChild(loading);
|
||||
nativeColumn.appendChild(loading);
|
||||
|
||||
try {
|
||||
await loadOptions();
|
||||
} catch {
|
||||
panel.replaceChildren();
|
||||
nativeColumn.replaceChildren();
|
||||
const err = document.createElement("div");
|
||||
css(err, "font-size:9px;color:#f88;padding:4px;text-align:center;");
|
||||
err.textContent = "failed to load";
|
||||
panel.appendChild(err);
|
||||
nativeColumn.appendChild(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user