feat: favorite flakes (#402)
This commit is contained in:
+13
@@ -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
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user