From 2e30997519e514b768f4d394523b8673125f426a Mon Sep 17 00:00:00 2001 From: Mehdi Chaid Date: Sun, 18 Oct 2026 18:58:35 -0400 Subject: [PATCH] feat: favorite flakes (#402) --- flake_io.py | 13 +++++++++++++ flake_server.py | 3 ++- tests/test_portable_flakes.py | 7 +++++++ web/api.js | 1 + web/flake-modal.js | 24 +++++++++++++++++++++--- web/list-editors.js | 1 + web/pickers.js | 13 ++++++++++--- web/utils.js | 8 ++++++++ web/widgets/flake-combo.js | 23 +++++++++++++++-------- web/widgets/flake-stack.js | 23 +++++++++++++++-------- 10 files changed, 93 insertions(+), 23 deletions(-) diff --git a/flake_io.py b/flake_io.py index 211aeb6..36d974b 100644 --- a/flake_io.py +++ b/flake_io.py @@ -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() diff --git a/flake_server.py b/flake_server.py index 97e1c96..da0973d 100644 --- a/flake_server.py +++ b/flake_server.py @@ -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") diff --git a/tests/test_portable_flakes.py b/tests/test_portable_flakes.py index 0b4a32c..2673da6 100644 --- a/tests/test_portable_flakes.py +++ b/tests/test_portable_flakes.py @@ -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. diff --git a/web/api.js b/web/api.js index 85e3029..b783413 100644 --- a/web/api.js +++ b/web/api.js @@ -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; diff --git a/web/flake-modal.js b/web/flake-modal.js index 05f2c8c..6ec8f6b 100644 --- a/web/flake-modal.js +++ b/web/flake-modal.js @@ -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) { diff --git a/web/list-editors.js b/web/list-editors.js index cde07c4..56c217e 100644 --- a/web/list-editors.js +++ b/web/list-editors.js @@ -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; } diff --git a/web/pickers.js b/web/pickers.js index 7ff8e72..5b9780e 100644 --- a/web/pickers.js +++ b/web/pickers.js @@ -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; diff --git a/web/utils.js b/web/utils.js index 84c00d9..ca3494b 100644 --- a/web/utils.js +++ b/web/utils.js @@ -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). diff --git a/web/widgets/flake-combo.js b/web/widgets/flake-combo.js index ebdd801..5673478 100644 --- a/web/widgets/flake-combo.js +++ b/web/widgets/flake-combo.js @@ -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(); } diff --git a/web/widgets/flake-stack.js b/web/widgets/flake-stack.js index 4a3e59f..de87283 100644 --- a/web/widgets/flake-stack.js +++ b/web/widgets/flake-stack.js @@ -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(); }