feat: favorite flakes (#402)

This commit is contained in:
Mehdi Chaid
2026-10-18 18:58:35 -04:00
parent 6599fb02ef
commit 2e30997519
10 changed files with 93 additions and 23 deletions
+13
View File
@@ -438,6 +438,19 @@ def list_flake_tag_names(family: str | None = None) -> dict[str, list[str]]:
return result
def list_flake_favorites(family: str | None = None) -> list[str]:
"""Names of flakes saved with ``favorite: true`` (#402)."""
result: list[str] = []
for name in list_flakes(family=family):
try:
raw = read_flake_raw(name)
except Exception:
continue
if raw.get("favorite"):
result.append(name)
return result
def list_series(family: str | None = None) -> list[str]:
"""Distinct, non-empty `serie` values across flakes (sorted)."""
series: set[str] = set()
+2 -1
View File
@@ -38,10 +38,11 @@ async def _list_flakes(request: web.Request) -> web.Response:
dirs = flake_io.list_dirs(family=family)
display_names = flake_io.list_flake_display_names(family=family)
tag_names = flake_io.list_flake_tag_names(family=family)
favorites = flake_io.list_flake_favorites(family=family)
except Exception as exc:
logging.exception("[flakes] failed to list flakes")
return _server_error(str(exc))
return web.json_response({"flakes": names, "directories": dirs, "display_names": display_names, "tag_names": tag_names})
return web.json_response({"flakes": names, "directories": dirs, "display_names": display_names, "tag_names": tag_names, "favorites": favorites})
@routes.get("/flakes/drives")
+7
View File
@@ -159,6 +159,13 @@ def test_list_dirs_skips_owned_folders():
assert fio.list_dirs() == ["chars", "chars/loose"]
def test_list_flake_favorites():
with _flakes_root() as root:
_write(os.path.join(root, "chars", "fennec.yaml"), b"favorite: true\npositive: fluffy tail\n")
_write(os.path.join(root, "solo.yml"), b"favorite: false\n")
assert fio.list_flake_favorites() == ["chars/fennec"]
def _get_full_path(loras):
def get_full_path(category, name):
# Like ComfyUI: names only; an absolute path on another drive raises.
+1
View File
@@ -16,6 +16,7 @@ export async function fetchList(family = "") {
// Surface tag_names so the Load-existing-flake picker can show the
// LoRA tag line, matching the grid items (#334).
tag_names: d.tag_names || {},
favorites: Array.isArray(d.favorites) ? d.favorites : [],
}));
}
return LIST_PROMISE;
+21 -3
View File
@@ -143,13 +143,30 @@ export function openEditModal({ mode, name, data, dirs, family = "SDXL/Base" })
return window.confirm("Are you sure you want to cancel? Unsaved changes will be lost.");
};
const titleRow = document.createElement("div");
css(titleRow, "display:flex;align-items:center;gap:8px;margin:0 0 8px;");
const title = document.createElement("h3");
css(title, "margin:0 0 8px;font-size:16px;color:#fff;font-weight:500;");
css(title, "margin:0;font-size:16px;color:#fff;font-weight:500;");
title.textContent =
mode === "default" ? "Edit default flake" :
mode === "create" ? "New flake" :
`Edit ${name}`;
content.appendChild(title);
titleRow.appendChild(title);
// Favorite toggle (#402), saved as `favorite: true`.
let favorite = !!data.favorite;
if (mode !== "default") {
const favBtn = document.createElement("button");
const paintFav = () => {
favBtn.textContent = favorite ? "★" : "☆";
favBtn.title = favorite ? "Favorite (click to unfavorite)" : "Mark as favorite";
favBtn.style.color = favorite ? "#f5c518" : "#888";
};
css(favBtn, "padding:0;border:none;background:transparent;font-size:18px;line-height:1;cursor:pointer;");
favBtn.addEventListener("click", () => { favorite = !favorite; paintFav(); markDirty(); });
paintFav();
titleRow.appendChild(favBtn);
}
content.appendChild(titleRow);
// ---- Top section: name, path, cover image ----
const topSection = document.createElement("div");
@@ -1495,7 +1512,7 @@ if (!activeFields.includes("controlnets") && fieldState.controlnets._.length > 0
try {
const list = await (await fetch(`/flakes/list?family=${encodeURIComponent(currentFamily || "")}`)).json();
const flakes = (list.flakes || []).filter((n) => n !== name);
const res = await openFileLoadPicker({ flakes, directories: list.directories || [], family: currentFamily || "", displayNames: list.display_names || {}, tagNames: list.tag_names || {} });
const res = await openFileLoadPicker({ flakes, directories: list.directories || [], family: currentFamily || "", displayNames: list.display_names || {}, tagNames: list.tag_names || {}, favorites: list.favorites || [] });
return res && res.name ? res.name : null;
} catch { return null; }
}
@@ -1998,6 +2015,7 @@ if (!activeFields.includes("controlnets") && fieldState.controlnets._.length > 0
if (selectedType) ordered.flake_type = selectedType;
if (serieInput && serieInput.value.trim()) ordered.serie = serieInput.value.trim();
if (outputStemInput && outputStemInput.value.trim()) ordered.output_stem = outputStemInput.value.trim();
if (favorite) ordered.favorite = true;
// Save fields in the user-selected order so YAML key order is preserved
for (const ft of activeFields) {
+1
View File
@@ -146,6 +146,7 @@ export async function pickFlakeTarget(family, exclude) {
family: family || "",
displayNames: list.display_names || {},
tagNames: list.tag_names || {},
favorites: list.favorites || [],
});
return res && res.name ? res.name : null;
} catch { return null; }
+10 -3
View File
@@ -21,11 +21,11 @@ const withoutPresets = (names) => names.filter(n => {
return !norm.startsWith("model_presets/") && norm !== "model_presets";
});
export async function openFileLoadPicker({ flakes, directories, family = "", displayNames = {}, tagNames = {} }) {
export async function openFileLoadPicker({ flakes, directories, family = "", displayNames = {}, tagNames = {}, favorites = [] }) {
return openNamePicker({
title: "Load existing flake", kind: "flakes", noun: "flake", base: "models/flakes/",
names: withoutPresets(flakes), directories: withoutPresets(directories), family,
coverUrl: getCoverUrl,
coverUrl: getCoverUrl, favorites,
// Stacked label with a dimmer tag line, matching the grid (#297).
renderLabel: (el, name) => renderFlakeLabel(el, { name: displayNames[name] || name.split("/").pop(), tags: tagNames[name] || [] }),
});
@@ -55,7 +55,7 @@ export async function openPresetPicker({ selected = "", family = "" } = {}) {
// when the names span several drives. One drive is shown at a time, an extra
// model path's by default. Without `directories`, folders come from the names.
// Resolves to {name} or null.
function openNamePicker({ title, kind, noun, base, names, directories = null, family, selected = "", coverUrl, renderLabel }) {
function openNamePicker({ title, kind, noun, base, names, directories = null, family, selected = "", coverUrl, renderLabel, favorites = [] }) {
return new Promise((resolve) => {
const { content, footer, close, handlers } = openOverlay();
handlers.onClose = (v) => resolve(v ?? null);
@@ -219,6 +219,13 @@ function openNamePicker({ title, kind, noun, base, names, directories = null, fa
renderLabel(nameEl, name);
thumb.appendChild(nameEl);
if (favorites.includes(name)) {
const star = document.createElement("div");
star.textContent = "★";
css(star, "position:absolute;top:2px;right:4px;color:#f5c518;font-size:12px;line-height:1;text-shadow:0 1px 3px rgba(0,0,0,0.9);pointer-events:none;z-index:2;");
thumb.appendChild(star);
}
thumb.addEventListener("click", () => {
if (selectedEl) selectedEl.style.borderColor = "#444";
selectedName = name;
+8
View File
@@ -1155,6 +1155,14 @@ export function makeBypassStrike() {
return strike;
}
// Gold star at the bottom-left of a favorite flake's grid item (#402).
export function makeFavoriteStar() {
const star = document.createElement("div");
star.textContent = "★";
css(star, "position:absolute;bottom:2px;left:4px;z-index:6;color:#f5c518;font-size:12px;line-height:14px;text-shadow:0 1px 3px rgba(0,0,0,0.9);pointer-events:none;");
return star;
}
// Long-press ("hold") a combo activation checkbox to "single out": activate
// only this item and deactivate every other one. A normal click still toggles
// just this item. Used by Flake Combo and Flake Model Combo (#281).
+15 -8
View File
@@ -1,5 +1,5 @@
import {
css, ensureDefault, makeSmallButton, makeAccentButton, svgIcon, makeGridItemOverlay, makeHoverButton, makeTypeRibbon, makeBypassStrike, TYPE_COLORS,
css, ensureDefault, makeSmallButton, makeAccentButton, svgIcon, makeGridItemOverlay, makeHoverButton, makeTypeRibbon, makeBypassStrike, makeFavoriteStar, TYPE_COLORS,
_showDropIndicator, _hideDropIndicator, _hideAllDropIndicators, makeAddBlock,
makePanelDropdown, makeSmallValueSlider, variantInitials, renderFlakeLabel, splitNameAndTags, loraTagNames,
_registerOpenPanel, _unregisterOpenPanel, setWidgetHidden, attachHoldToSingleOut, effectiveVariant, variantDropdownOptions,
@@ -96,6 +96,8 @@ function makeComboBlock({ entry, idx, isActive, isGenerating, onEdit, onRemove,
block.appendChild(dup);
}
if (entry.favorite) block.appendChild(makeFavoriteStar());
// Name — stacked label: name line, dimmer tag line (#297), variant suffix.
const baseName = entry.display_name || entry.name || "(missing)";
const nameEl = document.createElement("div");
@@ -652,6 +654,7 @@ export function setupFlakeComboWidget(node) {
if (result.name && result.name !== arr[idx].name) arr[idx].name = result.name;
arr[idx]._edited_at = Date.now();
arr[idx].flake_type = result.data?.flake_type || null;
arr[idx].favorite = !!result.data?.favorite;
arr[idx].has_lora = !!(result.data && (result.data.path || (result.data.loras && result.data.loras.length > 0)));
arr[idx].display_name = result.data?.name || null;
arr[idx].tags = loraTagNames(result.data?.loras);
@@ -706,13 +709,14 @@ export function setupFlakeComboWidget(node) {
}
async function handleReplace(idx) {
const { flakes, directories, display_names, tag_names } = await fetchList(getFamily());
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), displayNames: display_names, tagNames: tag_names });
const { flakes, directories, display_names, tag_names, favorites } = await fetchList(getFamily());
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), displayNames: display_names, tagNames: tag_names, favorites });
if (!result || !result.name) return;
const arr = readAllFlakes();
let has_lora = false;
let display_name = null;
let flake_type = null;
let favorite = false;
let loras = [];
let tags = [];
try {
@@ -720,11 +724,12 @@ export function setupFlakeComboWidget(node) {
has_lora = !!(d && (d.path || (d.loras && d.loras.length > 0)));
display_name = d.name || null;
flake_type = d.flake_type || null;
favorite = !!d.favorite;
tags = loraTagNames(d.loras);
if (d.loras) loras = d.loras.map(l => l.strength ?? 1.0);
else if (d.path) loras = [d.strength ?? 1.0];
} catch {}
arr[idx] = { ...arr[idx], name: result.name, loras, variant: {}, has_lora, display_name, flake_type, tags };
arr[idx] = { ...arr[idx], name: result.name, loras, variant: {}, has_lora, display_name, flake_type, favorite, tags };
writeAllFlakes(arr);
render();
}
@@ -763,19 +768,20 @@ export function setupFlakeComboWidget(node) {
flake_type = flake_type || d.flake_type || null;
} catch {}
}
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type, tags: loraTagNames(result.data?.loras) });
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type, favorite: !!result.data?.favorite, tags: loraTagNames(result.data?.loras) });
writeAllFlakes(arr);
render();
}
async function handleLoad() {
const { flakes, directories, display_names, tag_names } = await fetchList(getFamily());
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), displayNames: display_names, tagNames: tag_names });
const { flakes, directories, display_names, tag_names, favorites } = await fetchList(getFamily());
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), displayNames: display_names, tagNames: tag_names, favorites });
if (!result || !result.name) return;
const arr = readAllFlakes();
let has_lora = false;
let display_name = null;
let flake_type = null;
let favorite = false;
let loras = [];
let tags = [];
try {
@@ -783,11 +789,12 @@ export function setupFlakeComboWidget(node) {
has_lora = !!(d && (d.path || (d.loras && d.loras.length > 0)));
display_name = d.name || null;
flake_type = d.flake_type || null;
favorite = !!d.favorite;
tags = loraTagNames(d.loras);
if (d.loras) loras = d.loras.map(l => l.strength ?? 1.0);
else if (d.path) loras = [d.strength ?? 1.0];
} catch {}
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type, tags });
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type, favorite, tags });
writeAllFlakes(arr);
render();
}
+15 -8
View File
@@ -1,5 +1,5 @@
import {
css, ensureDefault, makeSmallButton, makeAccentButton, svgIcon, makeGridItemOverlay, makeHoverButton, makeTypeRibbon, makeBypassStrike, TYPE_COLORS,
css, ensureDefault, makeSmallButton, makeAccentButton, svgIcon, makeGridItemOverlay, makeHoverButton, makeTypeRibbon, makeBypassStrike, makeFavoriteStar, TYPE_COLORS,
_showDropIndicator, _hideDropIndicator, _hideAllDropIndicators, makeAddBlock,
makePanelDropdown, makeSmallValueSlider, variantInitials, renderFlakeLabel, splitNameAndTags,
_registerOpenPanel, _unregisterOpenPanel, setWidgetHidden, effectiveVariant, variantDropdownOptions,
@@ -97,6 +97,8 @@ function makeBlock({ entry, idx, onEdit, onRemove, onReplace, onToggleBypass, on
block.appendChild(dup);
}
if (!isDefault && entry.favorite) block.appendChild(makeFavoriteStar());
// Name — stacked label: name line, dimmer tag line (#297), variant suffix.
const fullName = isDefault ? "Default" : (entry.display_name || entry.name || "(missing)");
const nameEl = document.createElement("div");
@@ -688,6 +690,7 @@ export function setupFlakeWidget(node) {
if (result.name && result.name !== arr[idx].name) arr[idx].name = result.name;
arr[idx]._edited_at = Date.now();
arr[idx].flake_type = result.data?.flake_type || null;
arr[idx].favorite = !!result.data?.favorite;
if (result.data?.name) arr[idx].display_name = appendLoraTags(result.data.name, result.data.loras);
arr[idx].has_lora = !!(result.data && (result.data.path || (result.data.loras && result.data.loras.length > 0)));
// Clear per-instance overrides so the grid item reflects the new
@@ -734,23 +737,25 @@ export function setupFlakeWidget(node) {
async function handleReplace(idx) {
if (idx === 0) return;
const { flakes, directories, display_names, tag_names } = await fetchList(getFamily());
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), displayNames: display_names, tagNames: tag_names });
const { flakes, directories, display_names, tag_names, favorites } = await fetchList(getFamily());
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), displayNames: display_names, tagNames: tag_names, favorites });
if (!result || !result.name) return;
const arr = readEntries();
let has_lora = false;
let display_name = null;
let flake_type = null;
let favorite = false;
let loras = [];
try {
const d = await fetchFlake(result.name);
has_lora = !!(d && (d.path || (d.loras && d.loras.length > 0)));
display_name = appendLoraTags(d.name || null, d.loras);
flake_type = d.flake_type || null;
favorite = !!d.favorite;
if (d.loras) loras = d.loras.map(l => l.strength ?? 1.0);
else if (d.path) loras = [d.strength ?? 1.0];
} catch {}
arr[idx] = { name: result.name, loras, variant: {}, has_lora, display_name, flake_type };
arr[idx] = { name: result.name, loras, variant: {}, has_lora, display_name, flake_type, favorite };
writeEntries(arr);
render();
}
@@ -791,29 +796,31 @@ export function setupFlakeWidget(node) {
} catch {}
}
display_name = appendLoraTags(display_name, result.data?.loras);
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type });
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type, favorite: !!result.data?.favorite });
writeEntries(arr);
render();
}
async function handleLoad() {
const { flakes, directories, display_names, tag_names } = await fetchList(getFamily());
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), displayNames: display_names, tagNames: tag_names });
const { flakes, directories, display_names, tag_names, favorites } = await fetchList(getFamily());
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), displayNames: display_names, tagNames: tag_names, favorites });
if (!result || !result.name) return;
const arr = readEntries();
let has_lora = false;
let display_name = null;
let flake_type = null;
let favorite = false;
let loras = [];
try {
const d = await fetchFlake(result.name);
has_lora = !!(d && (d.path || (d.loras && d.loras.length > 0)));
display_name = appendLoraTags(d.name || null, d.loras);
flake_type = d.flake_type || null;
favorite = !!d.favorite;
if (d.loras) loras = d.loras.map(l => l.strength ?? 1.0);
else if (d.path) loras = [d.strength ?? 1.0];
} catch {}
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type });
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type, favorite });
writeEntries(arr);
render();
}