2180 lines
91 KiB
JavaScript
2180 lines
91 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { joinRel, makeKindToken, mediaDir, titlePathJS, token3Tag, yymmddJS } from "./mjr/utils.js";
|
|
import { ensureStyles, toast } from "./mjr/toast.js";
|
|
import { buildFingerprintCache, downloadModels, fetchJSON, getDownloadStatus, getFingerprintCacheStatus, getModels, listExistingNames, previewTemplate, resolveModelRecipes, saveModelRecipes, scanModelCandidates, searchRegistry, } from "./mjr/api.js";
|
|
import { psConfirm, psPrompt } from "./mjr/dialog.js";
|
|
import { isSaveLikeNode, patchSaveNodes, stampGraphProjectSignature, } from "./mjr/patch.js";
|
|
import { applyFixesToGraph, scanMissingModelsWithStats, } from "./mjr/model_fixer.js";
|
|
import { collectNoteRecipes, getKindOptions, normalizeKey, typeHintToKind } from "./mjr/model_downloader.js";
|
|
import { showModelFixerDialog } from "./mjr/ui_model_fixer_dialog.js";
|
|
import { showModelDownloaderDialog } from "./mjr/ui_model_downloader_dialog.js";
|
|
import { showModelContributionDialog } from "./mjr/model_contribution_dialog.js";
|
|
import { saveState } from "./state_manager.js";
|
|
import { getSerializedWorkflow } from "./mjr/graph.js";
|
|
import { extractNoteTexts } from "./mjr/workflow_note_recipes.js";
|
|
import { debug } from "./mjr/log.js";
|
|
const TEMPLATE_TOKENS = ["{BASE}", "{MEDIA}", "{DATE}", "{MODEL}", "{NAME}", "{KIND}"];
|
|
const PROJECT_REFRESH_THROTTLE_MS = 5000;
|
|
const PROJECT_REFRESH_ERROR_BACKOFF_MS = 10000;
|
|
const SETTING_AUTO_FIX_AFTER_DOWNLOAD = "mjr_project.auto_fix_after_download";
|
|
const SETTING_AUTO_SCAN_MISSING_ON_OPEN = "mjr_project.auto_scan_missing_on_open";
|
|
const SETTING_AUTO_REMEMBER_NOTE_SOURCES = "mjr_project.auto_remember_note_sources";
|
|
const SETTING_NO_PROJECT_STATUS_BLACK = "mjr_project.status_black_no_project";
|
|
const toBool = (value, fallback) => {
|
|
if (value == null)
|
|
return fallback;
|
|
if (typeof value === "boolean")
|
|
return value;
|
|
if (typeof value === "number")
|
|
return value !== 0;
|
|
if (typeof value === "string") {
|
|
const v = value.trim().toLowerCase();
|
|
if (["true", "1", "yes", "on"].includes(v))
|
|
return true;
|
|
if (["false", "0", "no", "off"].includes(v))
|
|
return false;
|
|
}
|
|
return fallback;
|
|
};
|
|
const getSettingBool = (id, fallback) => {
|
|
const raw = app?.ui?.settings?.settingsValues?.[id];
|
|
return toBool(raw, fallback);
|
|
};
|
|
function parseHexColor(hex) {
|
|
const m = String(hex || "").trim().match(/^#([0-9a-fA-F]{6})$/);
|
|
if (!m)
|
|
return null;
|
|
const v = m[1];
|
|
return [
|
|
parseInt(v.slice(0, 2), 16),
|
|
parseInt(v.slice(2, 4), 16),
|
|
parseInt(v.slice(4, 6), 16),
|
|
];
|
|
}
|
|
function colorWithAlpha(hex, alpha) {
|
|
const rgb = parseHexColor(hex);
|
|
if (!rgb)
|
|
return hex;
|
|
const a = Math.max(0, Math.min(1, Number(alpha)));
|
|
return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${a})`;
|
|
}
|
|
function makeStatus(state) {
|
|
const det = state.detected;
|
|
const sameProject = det &&
|
|
det.mode !== "none" &&
|
|
((det.project_id && det.project_id === state.projectId) ||
|
|
(det.project_folder && det.project_folder === state.projectFolder));
|
|
const mismatch = det &&
|
|
det.mode !== "none" &&
|
|
!sameProject &&
|
|
(det.project_id || det.project_folder);
|
|
if (state.lastError) {
|
|
return { color: "#b33a3a", text: `Error: ${state.lastError}`, level: "red" };
|
|
}
|
|
if (state.graphIsEmpty) {
|
|
return { color: "#b33a3a", text: "Empty graph, set or create a project", level: "red" };
|
|
}
|
|
if (!state.projectId) {
|
|
if (getSettingBool(SETTING_NO_PROJECT_STATUS_BLACK, false)) {
|
|
return { color: "#000000", text: "No project (test)", level: "black" };
|
|
}
|
|
return { color: "#666666", text: "No active project", level: "gray" };
|
|
}
|
|
if (state.projectExists === false) {
|
|
return {
|
|
color: "#b33a3a",
|
|
text: `Missing project folder: ${state.projectFolder || state.projectId}`,
|
|
level: "red",
|
|
};
|
|
}
|
|
if (state.workflowChecked && !state.workflowHasSignature) {
|
|
return {
|
|
color: "#c77c2a",
|
|
text: "Active project, workflow unassigned",
|
|
level: "orange",
|
|
};
|
|
}
|
|
if (mismatch) {
|
|
return {
|
|
color: "#c77c2a",
|
|
text: `Project mismatch: ${det.project_folder || det.project_id}`,
|
|
level: "orange",
|
|
};
|
|
}
|
|
return {
|
|
color: "#2f8f4e",
|
|
text: `Active: ${state.projectFolder} (${state.projectId})`,
|
|
level: "green",
|
|
};
|
|
}
|
|
const MENU_BADGE_ID = "mjr-project-status-badge";
|
|
const MENU_ACTION_SELECTORS = [
|
|
".comfy-menu-actions",
|
|
".comfy-menu-no-drag",
|
|
".comfy-topbar-actions",
|
|
".comfy-menu",
|
|
".workflow-tabs-container",
|
|
".workflow-tabs-container .flex",
|
|
".menu-actions"
|
|
];
|
|
let menuBadgeInterval = null;
|
|
let menuBadgePendingState = null;
|
|
let menuBadgeLogTimestamp = 0;
|
|
function findMenuActionsContainer() {
|
|
for (const selector of MENU_ACTION_SELECTORS) {
|
|
const candidate = document.querySelector(selector);
|
|
if (candidate) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function ensureMenuStatusBadge(actions, text, color) {
|
|
if (!actions)
|
|
return null;
|
|
let badge = document.getElementById(MENU_BADGE_ID);
|
|
if (!badge) {
|
|
badge = document.createElement("div");
|
|
badge.id = MENU_BADGE_ID;
|
|
badge.style.display = "inline-flex";
|
|
badge.style.alignItems = "center";
|
|
badge.style.justifyContent = "center";
|
|
badge.style.padding = "4px 10px";
|
|
badge.style.borderRadius = "999px";
|
|
badge.style.fontSize = "11px";
|
|
badge.style.fontWeight = "600";
|
|
badge.style.border = "1px solid transparent";
|
|
badge.style.marginLeft = "8px";
|
|
badge.style.gap = "4px";
|
|
badge.style.transition = "background 0.3s ease, border 0.3s ease";
|
|
badge.style.cursor = "default";
|
|
actions.appendChild(badge);
|
|
}
|
|
if (text)
|
|
badge.textContent = text;
|
|
if (color) {
|
|
badge.style.background = color;
|
|
badge.style.borderColor = color;
|
|
}
|
|
return badge;
|
|
}
|
|
function updateMenuStatusBadge(state) {
|
|
menuBadgePendingState = state;
|
|
const actions = findMenuActionsContainer();
|
|
if (!actions) {
|
|
if (!menuBadgeInterval) {
|
|
menuBadgeInterval = setInterval(() => {
|
|
if (menuBadgePendingState) {
|
|
updateMenuStatusBadge(menuBadgePendingState);
|
|
}
|
|
}, 500);
|
|
}
|
|
const now = Date.now();
|
|
if (now - menuBadgeLogTimestamp > 5000) {
|
|
console.info(`[mjr] Waiting for topbar badge target (${MENU_ACTION_SELECTORS.join(", ")}) project=${state?.projectId || "none"} folder=${state?.projectFolder || "none"}`);
|
|
menuBadgeLogTimestamp = now;
|
|
}
|
|
console.debug("[mjr] comfy menu actions not found yet, retrying badge soon", state);
|
|
return;
|
|
}
|
|
if (menuBadgeInterval) {
|
|
clearInterval(menuBadgeInterval);
|
|
menuBadgeInterval = null;
|
|
}
|
|
const badgeText = state.projectId
|
|
? state.projectExists === false
|
|
? "Projet introuvable"
|
|
: `Projet: ${state.projectFolder || state.projectId}`
|
|
: "Pas de projet actif";
|
|
const badgeColor = state.projectId
|
|
? state.projectExists === false
|
|
? "rgba(179, 58, 58, 0.9)"
|
|
: "rgba(47, 143, 78, 0.95)"
|
|
: "rgba(102, 102, 102, 0.8)";
|
|
ensureMenuStatusBadge(actions, badgeText, badgeColor);
|
|
menuBadgePendingState = null;
|
|
}
|
|
export function buildPanel(el, state, actions) {
|
|
const { createAndActivateProject, setActiveProjectById, saveWorkflowToProject } = actions || {};
|
|
el.innerHTML = "";
|
|
ensureStyles();
|
|
const UI_GAP = "8px";
|
|
const container = document.createElement("div");
|
|
container.style.display = "flex";
|
|
container.style.flexDirection = "column";
|
|
container.style.gap = UI_GAP;
|
|
container.style.fontSize = "12px";
|
|
container.style.color = "#ddd";
|
|
container.style.fontFamily = "inherit";
|
|
container.style.padding = "8px 14px 16px";
|
|
const section = (label) => {
|
|
const h = document.createElement("div");
|
|
h.textContent = label;
|
|
h.style.fontWeight = "600";
|
|
h.style.marginTop = UI_GAP;
|
|
h.style.marginBottom = UI_GAP;
|
|
h.style.paddingLeft = "2px";
|
|
h.style.textTransform = "uppercase";
|
|
h.style.letterSpacing = "0.6px";
|
|
h.style.color = "#8fb9ff";
|
|
return h;
|
|
};
|
|
const divider = (thick = false) => {
|
|
const d = document.createElement("div");
|
|
d.style.height = thick ? "3px" : "1px";
|
|
d.style.background = "rgba(255,255,255,0.08)";
|
|
d.style.margin = "0";
|
|
return d;
|
|
};
|
|
const detailsSection = (title) => {
|
|
const details = document.createElement("details");
|
|
details.style.marginTop = "0";
|
|
details.style.display = "flex";
|
|
details.style.flexDirection = "column";
|
|
details.style.gap = UI_GAP;
|
|
const summary = document.createElement("summary");
|
|
summary.textContent = title;
|
|
summary.style.cursor = "pointer";
|
|
summary.style.opacity = "0.9";
|
|
summary.style.textTransform = "uppercase";
|
|
summary.style.letterSpacing = "0.6px";
|
|
summary.style.color = "#8fb9ff";
|
|
summary.style.fontWeight = "600";
|
|
summary.style.paddingLeft = "2px";
|
|
details.appendChild(summary);
|
|
details._summary = summary;
|
|
return details;
|
|
};
|
|
const makeLabel = (labelText) => {
|
|
const label = document.createElement("label");
|
|
label.textContent = labelText;
|
|
label.style.opacity = "0.85";
|
|
label.style.marginLeft = "2px";
|
|
label.style.fontSize = "11px";
|
|
label.style.lineHeight = "1.2";
|
|
return label;
|
|
};
|
|
const styleField = (inputEl) => {
|
|
inputEl.style.width = "100%";
|
|
inputEl.style.boxSizing = "border-box";
|
|
inputEl.style.padding = "6px 8px";
|
|
inputEl.style.borderRadius = "8px";
|
|
inputEl.style.border = "1px solid rgba(255,255,255,0.12)";
|
|
inputEl.style.background = "rgba(0,0,0,0.25)";
|
|
inputEl.style.color = "#eee";
|
|
inputEl.style.outline = "none";
|
|
};
|
|
const row = (labelText, inputEl) => {
|
|
const wrap = document.createElement("div");
|
|
wrap.style.display = "flex";
|
|
wrap.style.flexDirection = "column";
|
|
wrap.style.gap = UI_GAP;
|
|
wrap.appendChild(makeLabel(labelText));
|
|
styleField(inputEl);
|
|
wrap.appendChild(inputEl);
|
|
return wrap;
|
|
};
|
|
const groupRow = (labelText, contentEl) => {
|
|
const wrap = document.createElement("div");
|
|
wrap.style.display = "flex";
|
|
wrap.style.flexDirection = "column";
|
|
wrap.style.gap = UI_GAP;
|
|
wrap.appendChild(makeLabel(labelText));
|
|
wrap.appendChild(contentEl);
|
|
return wrap;
|
|
};
|
|
const btn = (text) => {
|
|
const b = document.createElement("button");
|
|
b.textContent = text;
|
|
b.className = "mjr-ps-btn";
|
|
b.style.padding = "7px 8px";
|
|
b.style.borderRadius = "8px";
|
|
b.style.border = "1px solid rgba(46, 136, 255, 0.65)";
|
|
b.style.background = "rgba(46, 136, 255, 0.25)";
|
|
b.style.color = "#eee";
|
|
b.style.cursor = "pointer";
|
|
b.style.margin = "0";
|
|
b.style.lineHeight = "1.2";
|
|
b.style.whiteSpace = "nowrap";
|
|
return b;
|
|
};
|
|
// Status bar
|
|
const statusBar = document.createElement("div");
|
|
statusBar.style.display = "flex";
|
|
statusBar.style.alignItems = "center";
|
|
statusBar.style.gap = UI_GAP;
|
|
statusBar.style.padding = "6px 8px";
|
|
statusBar.style.borderRadius = "8px";
|
|
statusBar.style.position = "sticky";
|
|
statusBar.style.top = "0";
|
|
statusBar.style.zIndex = "2";
|
|
statusBar.style.background = "rgba(10, 12, 16, 0.72)";
|
|
statusBar.style.backdropFilter = "blur(6px)";
|
|
statusBar.style.cursor = "pointer";
|
|
statusBar.title = "Toggle workflow project panel";
|
|
const statusDot = document.createElement("div");
|
|
statusDot.style.width = "8px";
|
|
statusDot.style.height = "8px";
|
|
statusDot.style.borderRadius = "50%";
|
|
const statusText = document.createElement("div");
|
|
statusText.style.opacity = "0.95";
|
|
const updateStatus = () => {
|
|
const s = makeStatus(state);
|
|
const level = s.level || "gray";
|
|
statusBar.style.background = colorWithAlpha(s.color, 0.92);
|
|
statusBar.style.border = `1px solid ${colorWithAlpha(s.color, 0.35)}`;
|
|
statusDot.style.background = s.color;
|
|
statusText.textContent = s.text;
|
|
if (workflowSaveWrap) {
|
|
workflowSaveWrap.style.border = `1px solid ${colorWithAlpha(s.color, 0.4)}`;
|
|
workflowSaveWrap.style.background = colorWithAlpha(s.color, 0.12);
|
|
}
|
|
if (workflowWrap) {
|
|
const emphasize = level === "orange" && state.workflowChecked && !state.workflowHasSignature;
|
|
workflowWrap.style.border = emphasize
|
|
? `1px solid ${colorWithAlpha("#c77c2a", 0.7)}`
|
|
: "1px solid rgba(255,255,255,0.12)";
|
|
workflowWrap.style.background = emphasize
|
|
? colorWithAlpha("#c77c2a", 0.1)
|
|
: "rgba(15, 17, 22, 0.75)";
|
|
}
|
|
updateMenuStatusBadge(state);
|
|
};
|
|
statusBar.appendChild(statusDot);
|
|
statusBar.appendChild(statusText);
|
|
statusBar.addEventListener("click", () => {
|
|
state.workflowPanelOpen = !state.workflowPanelOpen;
|
|
saveState(state);
|
|
updateWorkflowBlock();
|
|
});
|
|
const updateResolve = () => {
|
|
const det = state.detected;
|
|
let show = true;
|
|
if (!det || det.mode === "none") {
|
|
show = false;
|
|
}
|
|
const sameProject = det &&
|
|
det.mode !== "none" &&
|
|
((det.project_id && det.project_id === state.projectId) ||
|
|
(det.project_folder && det.project_folder === state.projectFolder));
|
|
if (sameProject) {
|
|
show = false;
|
|
}
|
|
if (!show) {
|
|
resolveWrap.style.display = "none";
|
|
return;
|
|
}
|
|
if (state.workflowPanelOpen) {
|
|
state.workflowPanelOpen = false;
|
|
saveState(state);
|
|
}
|
|
const mode = det.mode || "none";
|
|
const wfFolder = det.project_folder || "none";
|
|
resolveInfo.textContent = `Workflow project: ${wfFolder} (mode: ${mode})`;
|
|
resolveActive.textContent = `Active project: ${state.projectFolder || "none"}`;
|
|
resolveWrap.style.display = "flex";
|
|
const canSwitch = mode !== "none" && (det.project_id || det.project_folder);
|
|
switchBtn.disabled = !canSwitch;
|
|
switchBtn.style.opacity = canSwitch ? "1" : "0.5";
|
|
const canAssign = !!state.projectId;
|
|
assignBtn.disabled = !canAssign;
|
|
assignBtn.style.opacity = canAssign ? "1" : "0.5";
|
|
};
|
|
const resolveWrap = document.createElement("div");
|
|
resolveWrap.style.display = "none";
|
|
resolveWrap.style.flexDirection = "column";
|
|
resolveWrap.style.gap = UI_GAP;
|
|
resolveWrap.style.padding = "8px 10px";
|
|
resolveWrap.style.borderRadius = "8px";
|
|
resolveWrap.style.border = "1px solid rgba(255,255,255,0.12)";
|
|
resolveWrap.style.background = "rgba(15, 17, 22, 0.75)";
|
|
resolveWrap.style.backdropFilter = "blur(6px)";
|
|
const resolveTitle = document.createElement("div");
|
|
resolveTitle.textContent = "Workflow project";
|
|
resolveTitle.style.textTransform = "uppercase";
|
|
resolveTitle.style.letterSpacing = "0.6px";
|
|
resolveTitle.style.fontWeight = "600";
|
|
resolveTitle.style.color = "#8fb9ff";
|
|
resolveTitle.style.marginBottom = "0";
|
|
const resolveInfo = document.createElement("div");
|
|
resolveInfo.style.opacity = "0.9";
|
|
const resolveActive = document.createElement("div");
|
|
resolveActive.style.opacity = "0.85";
|
|
const resolveBtns = document.createElement("div");
|
|
resolveBtns.style.display = "flex";
|
|
resolveBtns.style.gap = UI_GAP;
|
|
resolveBtns.style.flexWrap = "wrap";
|
|
resolveBtns.style.justifyContent = "center";
|
|
resolveBtns.style.alignItems = "center";
|
|
resolveBtns.style.marginTop = "0";
|
|
const switchBtn = btn("Switch to detected project");
|
|
const assignBtn = btn("Assign to active project");
|
|
const createBtn = btn("Create new project");
|
|
const dismissBtn = btn("Dismiss");
|
|
for (const b of [switchBtn, assignBtn, createBtn, dismissBtn]) {
|
|
b.style.fontSize = "11px";
|
|
b.style.padding = "6px 8px";
|
|
b.style.margin = "0";
|
|
}
|
|
resolveBtns.appendChild(switchBtn);
|
|
resolveBtns.appendChild(assignBtn);
|
|
resolveBtns.appendChild(createBtn);
|
|
resolveBtns.appendChild(dismissBtn);
|
|
resolveWrap.appendChild(resolveTitle);
|
|
resolveWrap.appendChild(resolveInfo);
|
|
resolveWrap.appendChild(resolveActive);
|
|
resolveWrap.appendChild(resolveBtns);
|
|
const workflowWrap = document.createElement("div");
|
|
workflowWrap.style.display = "none";
|
|
workflowWrap.style.flexDirection = "column";
|
|
workflowWrap.style.gap = UI_GAP;
|
|
workflowWrap.style.padding = "8px 10px";
|
|
workflowWrap.style.borderRadius = "8px";
|
|
workflowWrap.style.border = "1px solid rgba(255,255,255,0.12)";
|
|
workflowWrap.style.background = "rgba(15, 17, 22, 0.75)";
|
|
workflowWrap.style.backdropFilter = "blur(6px)";
|
|
const workflowTitle = document.createElement("div");
|
|
workflowTitle.textContent = "Workflow Project";
|
|
workflowTitle.style.textTransform = "uppercase";
|
|
workflowTitle.style.letterSpacing = "0.6px";
|
|
workflowTitle.style.fontWeight = "600";
|
|
workflowTitle.style.color = "#8fb9ff";
|
|
workflowTitle.style.marginBottom = "0";
|
|
const pendingProjectNameInput = document.createElement("input");
|
|
pendingProjectNameInput.type = "text";
|
|
pendingProjectNameInput.placeholder = "Project name";
|
|
pendingProjectNameInput.value = state.pendingProjectName || "";
|
|
const pendingSelect = document.createElement("select");
|
|
pendingSelect.className = "mjr-ps-select";
|
|
pendingSelect.style.width = "100%";
|
|
pendingSelect.style.padding = "6px 8px";
|
|
pendingSelect.style.borderRadius = "8px";
|
|
pendingSelect.style.border = "1px solid rgba(255,255,255,0.12)";
|
|
pendingSelect.style.color = "#f2f4f8";
|
|
const wfCreateBtn = btn("Create & Activate");
|
|
const wfActivateBtn = btn("Activate Selected");
|
|
const wfAssignBtn = btn("Assign workflow to active");
|
|
for (const b of [wfCreateBtn, wfActivateBtn, wfAssignBtn]) {
|
|
b.style.fontSize = "11px";
|
|
b.style.padding = "6px 8px";
|
|
b.style.margin = "0";
|
|
}
|
|
const wfButtons = document.createElement("div");
|
|
wfButtons.style.display = "flex";
|
|
wfButtons.style.gap = UI_GAP;
|
|
wfButtons.style.flexWrap = "wrap";
|
|
wfButtons.style.justifyContent = "center";
|
|
wfButtons.style.alignItems = "center";
|
|
wfButtons.style.marginTop = "0";
|
|
wfButtons.appendChild(wfCreateBtn);
|
|
wfButtons.appendChild(wfActivateBtn);
|
|
wfButtons.appendChild(wfAssignBtn);
|
|
workflowWrap.appendChild(workflowTitle);
|
|
workflowWrap.appendChild(row("Project name", pendingProjectNameInput));
|
|
workflowWrap.appendChild(row("Or select existing project", pendingSelect));
|
|
workflowWrap.appendChild(wfButtons);
|
|
let refreshProjectsList = () => { };
|
|
const updateWorkflowBlock = () => {
|
|
const show = !!state.workflowPanelOpen;
|
|
workflowWrap.style.display = show ? "flex" : "none";
|
|
if (show) {
|
|
refreshProjectsList();
|
|
pendingProjectNameInput.value = state.pendingProjectName || "";
|
|
if (state.pendingSelectProjectId) {
|
|
pendingSelect.value = state.pendingSelectProjectId;
|
|
}
|
|
}
|
|
};
|
|
const autoPatchNewWrap = document.createElement("label");
|
|
autoPatchNewWrap.style.display = "flex";
|
|
autoPatchNewWrap.style.alignItems = "center";
|
|
autoPatchNewWrap.style.gap = UI_GAP;
|
|
autoPatchNewWrap.style.cursor = "pointer";
|
|
const autoPatchNewCb = document.createElement("input");
|
|
autoPatchNewCb.type = "checkbox";
|
|
autoPatchNewCb.checked = state.autoPatchNewNodes !== false;
|
|
const autoPatchNewText = document.createElement("span");
|
|
autoPatchNewText.textContent = "Auto patch new nodes";
|
|
autoPatchNewText.style.opacity = "0.9";
|
|
autoPatchNewWrap.appendChild(autoPatchNewCb);
|
|
autoPatchNewWrap.appendChild(autoPatchNewText);
|
|
const autoSwitchWrap = document.createElement("label");
|
|
autoSwitchWrap.style.display = "flex";
|
|
autoSwitchWrap.style.alignItems = "center";
|
|
autoSwitchWrap.style.gap = UI_GAP;
|
|
autoSwitchWrap.style.cursor = "pointer";
|
|
const autoSwitchCb = document.createElement("input");
|
|
autoSwitchCb.type = "checkbox";
|
|
autoSwitchCb.checked = state.autoSwitchTrusted === true;
|
|
const autoSwitchText = document.createElement("span");
|
|
autoSwitchText.textContent = "Auto-switch trusted signature";
|
|
autoSwitchText.style.opacity = "0.9";
|
|
autoSwitchWrap.appendChild(autoSwitchCb);
|
|
autoSwitchWrap.appendChild(autoSwitchText);
|
|
// Model section (collapsible)
|
|
const modelSection = detailsSection("Model");
|
|
const modelSummary = modelSection._summary;
|
|
const detectedModelLabel = document.createElement("div");
|
|
detectedModelLabel.style.opacity = "0.9";
|
|
detectedModelLabel.style.fontSize = "11px";
|
|
detectedModelLabel.style.color = "#f2d98a";
|
|
detectedModelLabel.style.textAlign = "left";
|
|
detectedModelLabel.style.marginLeft = "2px";
|
|
detectedModelLabel.style.marginTop = "0";
|
|
detectedModelLabel.textContent = "Detected: -";
|
|
const useCustomWrap = document.createElement("label");
|
|
useCustomWrap.style.display = "flex";
|
|
useCustomWrap.style.alignItems = "center";
|
|
useCustomWrap.style.gap = UI_GAP;
|
|
useCustomWrap.style.cursor = "pointer";
|
|
const useCustomCb = document.createElement("input");
|
|
useCustomCb.type = "checkbox";
|
|
useCustomCb.checked = state.useCustomModel === true;
|
|
const useCustomText = document.createElement("span");
|
|
useCustomText.textContent = "Use custom model (UPPERCASE)";
|
|
useCustomText.style.opacity = "0.9";
|
|
useCustomWrap.appendChild(useCustomCb);
|
|
useCustomWrap.appendChild(useCustomText);
|
|
const modelSelect = document.createElement("select");
|
|
modelSelect.className = "mjr-ps-select";
|
|
modelSelect.style.width = "100%";
|
|
modelSelect.style.padding = "6px 8px";
|
|
modelSelect.style.borderRadius = "8px";
|
|
modelSelect.style.border = "1px solid rgba(255,255,255,0.12)";
|
|
modelSelect.style.color = "#f2f4f8";
|
|
const modelRow = document.createElement("div");
|
|
modelRow.style.display = "flex";
|
|
modelRow.style.gap = UI_GAP;
|
|
const modelToggleBtn = btn(state.showMoreModels ? "Show less" : "Show more");
|
|
modelToggleBtn.style.padding = "4px 6px";
|
|
modelToggleBtn.style.fontSize = "10px";
|
|
modelToggleBtn.style.flex = "0 0 auto";
|
|
modelToggleBtn.style.margin = "0";
|
|
modelToggleBtn.style.alignSelf = "flex-end";
|
|
const modelSelectWrap = row("Default Model", modelSelect);
|
|
modelSelectWrap.style.flex = "1 1 auto";
|
|
modelRow.appendChild(modelSelectWrap);
|
|
const modelEmptyMsg = document.createElement("div");
|
|
modelEmptyMsg.style.opacity = "0.8";
|
|
modelEmptyMsg.style.display = "none";
|
|
modelEmptyMsg.style.textAlign = "center";
|
|
modelEmptyMsg.style.fontSize = "11px";
|
|
modelEmptyMsg.style.color = "#f2d98a";
|
|
modelEmptyMsg.textContent = "No models detected (check folders)";
|
|
const customModelInput = document.createElement("input");
|
|
customModelInput.type = "text";
|
|
customModelInput.placeholder = "Custom model name";
|
|
customModelInput.value = state.customModelText || "";
|
|
const customModelWrap = row("Custom Model (UPPERCASE)", customModelInput);
|
|
customModelWrap.style.display = state.useCustomModel ? "block" : "none";
|
|
customModelWrap.style.flex = "1 1 auto";
|
|
const customModelPreview = document.createElement("div");
|
|
customModelPreview.style.opacity = "0.85";
|
|
customModelPreview.style.fontSize = "9px";
|
|
customModelPreview.style.color = "#f2d98a";
|
|
customModelPreview.style.textAlign = "center";
|
|
customModelPreview.textContent = "Custom tag: -";
|
|
customModelWrap.appendChild(customModelPreview);
|
|
const modelCustomRow = document.createElement("div");
|
|
modelCustomRow.style.display = "flex";
|
|
modelCustomRow.style.gap = UI_GAP;
|
|
modelCustomRow.style.alignItems = "flex-end";
|
|
modelCustomRow.appendChild(customModelWrap);
|
|
modelCustomRow.appendChild(modelToggleBtn);
|
|
modelSection.appendChild(detectedModelLabel);
|
|
modelSection.appendChild(useCustomWrap);
|
|
modelSection.appendChild(modelRow);
|
|
modelSection.appendChild(modelCustomRow);
|
|
modelSection.appendChild(modelEmptyMsg);
|
|
// Workflow section
|
|
const workflowNameInput = document.createElement("input");
|
|
workflowNameInput.type = "text";
|
|
workflowNameInput.placeholder = "Workflow name (optional)";
|
|
workflowNameInput.value = state.workflowName || "";
|
|
const workflowAssetInput = document.createElement("input");
|
|
workflowAssetInput.type = "text";
|
|
workflowAssetInput.placeholder = "Asset folder (optional)";
|
|
workflowAssetInput.value = state.workflowAsset || "";
|
|
const workflowPreviewLabel = document.createElement("div");
|
|
workflowPreviewLabel.style.opacity = "0.85";
|
|
workflowPreviewLabel.style.textAlign = "left";
|
|
workflowPreviewLabel.style.fontSize = "9px";
|
|
workflowPreviewLabel.style.color = "#f2d98a";
|
|
workflowPreviewLabel.style.lineHeight = "1.35";
|
|
workflowPreviewLabel.style.wordBreak = "break-word";
|
|
workflowPreviewLabel.textContent = "Next save: -";
|
|
const saveWorkflowBtn = btn("Save Workflow to Project");
|
|
saveWorkflowBtn.style.alignSelf = "center";
|
|
saveWorkflowBtn.style.margin = "0 auto";
|
|
saveWorkflowBtn.style.display = "block";
|
|
const autoHookWrap = document.createElement("label");
|
|
autoHookWrap.style.display = "flex";
|
|
autoHookWrap.style.alignItems = "center";
|
|
autoHookWrap.style.gap = UI_GAP;
|
|
autoHookWrap.style.cursor = "pointer";
|
|
const autoHookCb = document.createElement("input");
|
|
autoHookCb.type = "checkbox";
|
|
autoHookCb.checked = state.autoHookSave !== false;
|
|
const autoHookText = document.createElement("span");
|
|
autoHookText.textContent = "Auto-hook Ctrl+S";
|
|
autoHookText.style.opacity = "0.9";
|
|
autoHookWrap.appendChild(autoHookCb);
|
|
autoHookWrap.appendChild(autoHookText);
|
|
// Custom output section (collapsible)
|
|
const customOutputSection = detailsSection("Custom Output (optional)");
|
|
customOutputSection.style.display = "flex";
|
|
customOutputSection.style.flexDirection = "column";
|
|
customOutputSection.style.gap = UI_GAP;
|
|
const kindSelect = document.createElement("select");
|
|
kindSelect.className = "mjr-ps-select";
|
|
for (const [value, label] of [
|
|
["asset", "Asset"],
|
|
["shot", "Shot"],
|
|
]) {
|
|
const opt = document.createElement("option");
|
|
opt.value = value;
|
|
opt.textContent = label;
|
|
kindSelect.appendChild(opt);
|
|
}
|
|
kindSelect.value = state.kind || "asset";
|
|
const mediaSelect = document.createElement("select");
|
|
mediaSelect.className = "mjr-ps-select";
|
|
for (const [value, label] of [
|
|
["images", "Images"],
|
|
["videos", "Videos"],
|
|
]) {
|
|
const opt = document.createElement("option");
|
|
opt.value = value;
|
|
opt.textContent = label;
|
|
mediaSelect.appendChild(opt);
|
|
}
|
|
mediaSelect.value = state.media || "images";
|
|
const nameInput = document.createElement("input");
|
|
nameInput.type = "text";
|
|
nameInput.placeholder = "Asset/shot name";
|
|
nameInput.value = state.name || "";
|
|
styleField(nameInput);
|
|
const existingNamesSelect = document.createElement("select");
|
|
existingNamesSelect.className = "mjr-ps-select";
|
|
existingNamesSelect.style.display = "none";
|
|
styleField(existingNamesSelect);
|
|
const nameStack = document.createElement("div");
|
|
nameStack.style.display = "flex";
|
|
nameStack.style.flexDirection = "column";
|
|
nameStack.style.gap = UI_GAP;
|
|
nameStack.style.width = "100%";
|
|
nameStack.appendChild(existingNamesSelect);
|
|
nameStack.appendChild(nameInput);
|
|
const autoPatchWrap = document.createElement("label");
|
|
autoPatchWrap.style.display = "flex";
|
|
autoPatchWrap.style.alignItems = "center";
|
|
autoPatchWrap.style.gap = UI_GAP;
|
|
autoPatchWrap.style.cursor = "pointer";
|
|
const autoPatchCb = document.createElement("input");
|
|
autoPatchCb.type = "checkbox";
|
|
autoPatchCb.checked = state.autoPatch !== false;
|
|
const autoPatchText = document.createElement("span");
|
|
autoPatchText.textContent = "Auto patch outputs";
|
|
autoPatchText.style.opacity = "0.9";
|
|
autoPatchWrap.appendChild(autoPatchCb);
|
|
autoPatchWrap.appendChild(autoPatchText);
|
|
const createApplyBtn = btn("Create + Apply");
|
|
const patchNowBtn = btn("Patch now");
|
|
const buttonRow = document.createElement("div");
|
|
buttonRow.style.display = "flex";
|
|
buttonRow.style.gap = UI_GAP;
|
|
buttonRow.style.justifyContent = "center";
|
|
buttonRow.style.alignItems = "center";
|
|
buttonRow.style.marginTop = "0";
|
|
buttonRow.style.marginBottom = "0";
|
|
buttonRow.appendChild(createApplyBtn);
|
|
buttonRow.appendChild(patchNowBtn);
|
|
const targetLabel = document.createElement("div");
|
|
targetLabel.style.opacity = "0.85";
|
|
targetLabel.style.textAlign = "left";
|
|
targetLabel.style.fontSize = "9px";
|
|
targetLabel.style.color = "#f2d98a";
|
|
targetLabel.style.lineHeight = "1.35";
|
|
targetLabel.style.wordBreak = "break-word";
|
|
const updateTargetLabel = () => {
|
|
if (state.lastRelDir) {
|
|
targetLabel.textContent = `Target: ${state.lastRelDir} | Prefix: ${state.lastPrefix}`;
|
|
}
|
|
else {
|
|
targetLabel.textContent = "Target: none (create an output)";
|
|
}
|
|
};
|
|
const rollOutputDateIfNeeded = () => {
|
|
const today = yymmddJS();
|
|
const prefix = String(state.lastPrefix || "");
|
|
const m = prefix.match(/^(\d{6})_/);
|
|
const oldDate = (m ? m[1] : "") ||
|
|
String(state.lastRelDir || "").replace(/\\/g, "/").match(/(?:^|\/)(\d{6})(?:\/|$)/)?.[1] ||
|
|
"";
|
|
if (!oldDate || oldDate === today)
|
|
return false;
|
|
if (String(state.lastRelDir || "")) {
|
|
const rel = String(state.lastRelDir || "").replace(/\\/g, "/");
|
|
const re = new RegExp(`(^|/)${oldDate}(/|$)`, "g");
|
|
state.lastRelDir = rel.replace(re, `$1${today}$2`);
|
|
}
|
|
if (prefix.startsWith(`${oldDate}_`)) {
|
|
state.lastPrefix = `${today}${prefix.slice(6)}`;
|
|
}
|
|
saveState(state);
|
|
updateTargetLabel();
|
|
return true;
|
|
};
|
|
rollOutputDateIfNeeded();
|
|
updateTargetLabel();
|
|
// Advanced template
|
|
const advanced = document.createElement("details");
|
|
advanced.style.marginTop = "0";
|
|
advanced.style.display = "flex";
|
|
advanced.style.flexDirection = "column";
|
|
advanced.style.gap = UI_GAP;
|
|
const summary = document.createElement("summary");
|
|
summary.textContent = "Advanced";
|
|
summary.style.cursor = "pointer";
|
|
summary.style.opacity = "0.85";
|
|
summary.style.textTransform = "uppercase";
|
|
summary.style.letterSpacing = "0.6px";
|
|
summary.style.color = "#8fb9ff";
|
|
const templateInput = document.createElement("input");
|
|
templateInput.type = "text";
|
|
templateInput.placeholder = "{BASE}/{MEDIA}/{DATE}/{NAME}/{MODEL}";
|
|
templateInput.value =
|
|
state.pathTemplate || "{BASE}/{MEDIA}/{DATE}/{NAME}/{MODEL}";
|
|
const templateTokens = document.createElement("div");
|
|
templateTokens.style.opacity = "0.85";
|
|
templateTokens.style.fontSize = "8px";
|
|
templateTokens.style.color = "#f2d98a";
|
|
templateTokens.style.textAlign = "left";
|
|
templateTokens.style.lineHeight = "1.35";
|
|
templateTokens.style.wordBreak = "break-word";
|
|
templateTokens.textContent = `Tokens: ${TEMPLATE_TOKENS.join(" ")}`;
|
|
const previewLabel = document.createElement("div");
|
|
previewLabel.style.opacity = "0.85";
|
|
previewLabel.style.fontSize = "8px";
|
|
previewLabel.style.color = "#f2d98a";
|
|
previewLabel.style.textAlign = "left";
|
|
previewLabel.style.lineHeight = "1.35";
|
|
previewLabel.style.wordBreak = "break-word";
|
|
previewLabel.textContent = "Preview: -";
|
|
advanced.appendChild(summary);
|
|
advanced.appendChild(row("Path Template", templateInput));
|
|
advanced.appendChild(templateTokens);
|
|
advanced.appendChild(previewLabel);
|
|
const workflowSaveWrap = detailsSection("Workflow");
|
|
workflowSaveWrap.open = true;
|
|
workflowSaveWrap.style.padding = "8px 10px";
|
|
workflowSaveWrap.style.borderRadius = "8px";
|
|
workflowSaveWrap.style.border = "1px solid rgba(255,255,255,0.12)";
|
|
workflowSaveWrap.style.background = "rgba(15, 17, 22, 0.75)";
|
|
workflowSaveWrap.style.backdropFilter = "blur(6px)";
|
|
const workflowSaveContent = document.createElement("div");
|
|
workflowSaveContent.style.display = "flex";
|
|
workflowSaveContent.style.flexDirection = "column";
|
|
workflowSaveContent.style.gap = UI_GAP;
|
|
workflowSaveContent.appendChild(row("Workflow name", workflowNameInput));
|
|
workflowSaveContent.appendChild(row("Asset folder", workflowAssetInput));
|
|
workflowSaveContent.appendChild(workflowPreviewLabel);
|
|
workflowSaveContent.appendChild(autoHookWrap);
|
|
workflowSaveContent.appendChild(saveWorkflowBtn);
|
|
workflowSaveWrap.appendChild(workflowSaveContent);
|
|
const workflowUtilsWrap = detailsSection("Workflow Utilities");
|
|
workflowUtilsWrap.open = true;
|
|
workflowUtilsWrap.style.padding = "8px 10px";
|
|
workflowUtilsWrap.style.borderRadius = "8px";
|
|
workflowUtilsWrap.style.border = "1px solid rgba(255,255,255,0.12)";
|
|
workflowUtilsWrap.style.background = "rgba(15, 17, 22, 0.75)";
|
|
workflowUtilsWrap.style.backdropFilter = "blur(6px)";
|
|
const workflowUtilsContent = document.createElement("div");
|
|
workflowUtilsContent.style.display = "flex";
|
|
workflowUtilsContent.style.flexDirection = "column";
|
|
workflowUtilsContent.style.gap = UI_GAP;
|
|
const fixMissingBtn = btn("Fix Missing Models");
|
|
fixMissingBtn.style.alignSelf = "flex-start";
|
|
const downloadMissingBtn = btn("Download Missing Models");
|
|
downloadMissingBtn.style.alignSelf = "flex-start";
|
|
const utilsButtonRow = document.createElement("div");
|
|
utilsButtonRow.style.display = "flex";
|
|
utilsButtonRow.style.gap = UI_GAP;
|
|
utilsButtonRow.style.justifyContent = "flex-start";
|
|
utilsButtonRow.style.alignItems = "center";
|
|
utilsButtonRow.style.flexWrap = "wrap";
|
|
utilsButtonRow.appendChild(fixMissingBtn);
|
|
utilsButtonRow.appendChild(downloadMissingBtn);
|
|
const fixerStatus = document.createElement("div");
|
|
fixerStatus.style.opacity = "0.85";
|
|
fixerStatus.style.fontSize = "9px";
|
|
fixerStatus.style.color = "#f2d98a";
|
|
fixerStatus.style.lineHeight = "1.35";
|
|
fixerStatus.style.marginLeft = "2px";
|
|
const downloadStatus = document.createElement("div");
|
|
downloadStatus.style.display = "flex";
|
|
downloadStatus.style.flexDirection = "column";
|
|
downloadStatus.style.gap = "4px";
|
|
downloadStatus.style.opacity = "0.85";
|
|
downloadStatus.style.fontSize = "9px";
|
|
downloadStatus.style.color = "#f2d98a";
|
|
downloadStatus.style.lineHeight = "1.35";
|
|
downloadStatus.style.marginLeft = "2px";
|
|
const downloadStatusText = document.createElement("div");
|
|
downloadStatus.appendChild(downloadStatusText);
|
|
const downloadProgressWrap = document.createElement("div");
|
|
downloadProgressWrap.style.height = "6px";
|
|
downloadProgressWrap.style.width = "100%";
|
|
downloadProgressWrap.style.borderRadius = "999px";
|
|
downloadProgressWrap.style.border = "1px solid rgba(255,255,255,0.12)";
|
|
downloadProgressWrap.style.background = "rgba(0,0,0,0.25)";
|
|
downloadProgressWrap.style.overflow = "hidden";
|
|
const downloadProgressBar = document.createElement("div");
|
|
downloadProgressBar.style.height = "100%";
|
|
downloadProgressBar.style.width = "0%";
|
|
downloadProgressBar.style.background = "rgba(46, 136, 255, 0.7)";
|
|
downloadProgressBar.style.transition = "width 0.2s ease";
|
|
downloadProgressWrap.appendChild(downloadProgressBar);
|
|
downloadStatus.appendChild(downloadProgressWrap);
|
|
const fingerprintDetails = detailsSection("Advanced (Fingerprint)");
|
|
fingerprintDetails.style.marginTop = "0";
|
|
const fingerprintContent = document.createElement("div");
|
|
fingerprintContent.style.display = "flex";
|
|
fingerprintContent.style.flexDirection = "column";
|
|
fingerprintContent.style.gap = UI_GAP;
|
|
const buildFingerprintBtn = btn("Build Model Fingerprint Cache");
|
|
buildFingerprintBtn.style.alignSelf = "flex-start";
|
|
fingerprintContent.appendChild(buildFingerprintBtn);
|
|
fingerprintDetails.appendChild(fingerprintContent);
|
|
workflowUtilsContent.appendChild(utilsButtonRow);
|
|
workflowUtilsContent.appendChild(fixerStatus);
|
|
workflowUtilsContent.appendChild(downloadStatus);
|
|
workflowUtilsContent.appendChild(fingerprintDetails);
|
|
workflowUtilsWrap.appendChild(workflowUtilsContent);
|
|
container.appendChild(statusBar);
|
|
container.appendChild(workflowWrap);
|
|
container.appendChild(resolveWrap);
|
|
container.appendChild(divider());
|
|
container.appendChild(modelSection);
|
|
const registrySection = detailsSection("Community Registry");
|
|
registrySection.style.display = "flex";
|
|
registrySection.style.flexDirection = "column";
|
|
registrySection.style.gap = "10px";
|
|
const registryInput = document.createElement("input");
|
|
registryInput.placeholder = "search model name or alias";
|
|
registryInput.style.width = "100%";
|
|
registryInput.style.padding = "6px 8px";
|
|
registryInput.style.borderRadius = "8px";
|
|
registryInput.style.border = "1px solid rgba(255,255,255,0.15)";
|
|
registryInput.style.background = "rgba(255,255,255,0.04)";
|
|
registryInput.style.color = "#f4f6fb";
|
|
registryInput.style.fontSize = "13px";
|
|
const registryActions = document.createElement("div");
|
|
registryActions.style.display = "flex";
|
|
registryActions.style.gap = "8px";
|
|
registryActions.style.flexWrap = "wrap";
|
|
const registrySearchBtn = btn("Search registry");
|
|
const registryContributeBtn = btn("Contribute URL");
|
|
registryActions.appendChild(registrySearchBtn);
|
|
registryActions.appendChild(registryContributeBtn);
|
|
const registryStatus = document.createElement("div");
|
|
registryStatus.style.fontSize = "12px";
|
|
registryStatus.style.display = "block";
|
|
registryStatus.style.minHeight = "18px";
|
|
registryStatus.style.opacity = "0.8";
|
|
const registryResultsList = document.createElement("div");
|
|
registryResultsList.style.display = "flex";
|
|
registryResultsList.style.flexDirection = "column";
|
|
registryResultsList.style.gap = "10px";
|
|
const renderRegistryResults = (results) => {
|
|
registryResultsList.innerHTML = "";
|
|
if (!results || results.length === 0) {
|
|
registryResultsList.textContent = "No registry matches yet.";
|
|
return;
|
|
}
|
|
for (const item of results) {
|
|
const card = document.createElement("div");
|
|
card.style.border = "1px solid rgba(255,255,255,0.15)";
|
|
card.style.borderRadius = "10px";
|
|
card.style.padding = "10px";
|
|
card.style.display = "flex";
|
|
card.style.flexDirection = "column";
|
|
card.style.gap = "4px";
|
|
card.style.background = "rgba(255,255,255,0.02)";
|
|
const titleRow = document.createElement("div");
|
|
titleRow.style.display = "flex";
|
|
titleRow.style.justifyContent = "space-between";
|
|
titleRow.style.alignItems = "baseline";
|
|
const title = document.createElement("strong");
|
|
title.textContent = item.name || "Unnamed model";
|
|
title.style.fontSize = "14px";
|
|
title.style.wordBreak = "break-word";
|
|
const score = document.createElement("span");
|
|
score.textContent = `${Math.round(item.match_score || 0)}%`;
|
|
score.style.color = "#a9ff8f";
|
|
score.style.fontSize = "12px";
|
|
titleRow.appendChild(title);
|
|
titleRow.appendChild(score);
|
|
const meta = document.createElement("div");
|
|
meta.style.fontSize = "11px";
|
|
meta.style.opacity = "0.8";
|
|
meta.textContent = `${item.platform || "community"} · ${item.type || "checkpoint"}`;
|
|
const footer = document.createElement("div");
|
|
footer.style.display = "flex";
|
|
footer.style.gap = "8px";
|
|
if (item.url) {
|
|
const openBtn = document.createElement("button");
|
|
openBtn.textContent = "Open";
|
|
openBtn.className = "mjr-ps-btn";
|
|
openBtn.style.flex = "1";
|
|
openBtn.addEventListener("click", () => window.open(item.url, "_blank"));
|
|
footer.appendChild(openBtn);
|
|
}
|
|
const copyBtn = document.createElement("button");
|
|
copyBtn.textContent = "Copy URL";
|
|
copyBtn.className = "mjr-ps-btn";
|
|
copyBtn.style.flex = "1";
|
|
copyBtn.addEventListener("click", async () => {
|
|
if (item.url) {
|
|
await navigator.clipboard.writeText(item.url);
|
|
toast("success", "Copied", "URL added to clipboard.");
|
|
}
|
|
});
|
|
footer.appendChild(copyBtn);
|
|
card.appendChild(titleRow);
|
|
card.appendChild(meta);
|
|
card.appendChild(footer);
|
|
registryResultsList.appendChild(card);
|
|
}
|
|
};
|
|
renderRegistryResults([]);
|
|
const triggerRegistrySearch = async () => {
|
|
const query = registryInput.value.trim();
|
|
if (!query) {
|
|
registryStatus.textContent = "Enter a query to search the registry.";
|
|
return;
|
|
}
|
|
registryStatus.textContent = "Searching registry...";
|
|
registrySearchBtn.disabled = true;
|
|
try {
|
|
const resp = await searchRegistry(query, 6);
|
|
const results = resp?.results || [];
|
|
registryStatus.textContent = `Found ${results.length} result${results.length !== 1 ? "s" : ""}.`;
|
|
renderRegistryResults(results);
|
|
}
|
|
catch (error) {
|
|
console.error("[registry search]", error);
|
|
registryStatus.textContent = "Registry search failed.";
|
|
}
|
|
finally {
|
|
registrySearchBtn.disabled = false;
|
|
}
|
|
};
|
|
registrySearchBtn.addEventListener("click", triggerRegistrySearch);
|
|
registryInput.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
triggerRegistrySearch();
|
|
}
|
|
});
|
|
registryContributeBtn.addEventListener("click", () => showModelContributionDialog());
|
|
registrySection.appendChild(registryInput);
|
|
registrySection.appendChild(registryActions);
|
|
registrySection.appendChild(registryStatus);
|
|
registrySection.appendChild(registryResultsList);
|
|
container.appendChild(registrySection);
|
|
container.appendChild(autoPatchNewWrap);
|
|
container.appendChild(autoSwitchWrap);
|
|
container.appendChild(divider());
|
|
container.appendChild(workflowSaveWrap);
|
|
container.appendChild(workflowUtilsWrap);
|
|
container.appendChild(divider());
|
|
customOutputSection.appendChild(row("Kind", kindSelect));
|
|
customOutputSection.appendChild(row("Media", mediaSelect));
|
|
customOutputSection.appendChild(groupRow("Name", nameStack));
|
|
customOutputSection.appendChild(autoPatchWrap);
|
|
customOutputSection.appendChild(buttonRow);
|
|
customOutputSection.appendChild(targetLabel);
|
|
container.appendChild(customOutputSection);
|
|
container.appendChild(divider());
|
|
container.appendChild(advanced);
|
|
el.appendChild(container);
|
|
updateStatus();
|
|
updateResolve();
|
|
updateWorkflowBlock();
|
|
const fixerState = {
|
|
missing: 0,
|
|
fixed: 0,
|
|
total: 0,
|
|
cacheCount: 0,
|
|
cacheUpdatedAt: "",
|
|
cacheError: "",
|
|
scanned: false,
|
|
lastScanToken: -1,
|
|
};
|
|
const downloadState = {
|
|
state: "idle",
|
|
message: "",
|
|
progress: { current: 0, total: 0, pct: 0 },
|
|
};
|
|
const updateFixerStatus = () => {
|
|
const cacheText = fixerState.cacheError
|
|
? "error"
|
|
: `${fixerState.cacheCount} / ${fixerState.cacheUpdatedAt || "-"}`;
|
|
const missingText = fixerState.scanned ? fixerState.missing : "-";
|
|
const fixedText = fixerState.scanned ? fixerState.fixed : "-";
|
|
fixerStatus.textContent = `Missing: ${missingText} | Fixed: ${fixedText} | Cache: ${cacheText}`;
|
|
const total = fixerState.total || 0;
|
|
let color = "#666666";
|
|
if (fixerState.scanned) {
|
|
if (total > 0 && fixerState.missing === 0) {
|
|
color = "#2f8f4e";
|
|
}
|
|
else if (total > 0 && fixerState.missing > 0 && fixerState.missing < total) {
|
|
color = "#c77c2a";
|
|
}
|
|
else if (total > 0 && fixerState.missing >= total) {
|
|
color = "#b33a3a";
|
|
}
|
|
}
|
|
workflowUtilsWrap.style.border = `1px solid ${colorWithAlpha(color, 0.4)}`;
|
|
workflowUtilsWrap.style.background = colorWithAlpha(color, 0.12);
|
|
};
|
|
const updateDownloadStatus = () => {
|
|
const rawState = downloadState.state || "idle";
|
|
const stateLabel = rawState === "queued" ? "downloading" : rawState;
|
|
const pct = downloadState.progress?.pct || 0;
|
|
const pctText = pct ? ` ${pct}%` : "";
|
|
const msg = downloadState.message ? ` | ${downloadState.message}` : "";
|
|
downloadStatusText.textContent = `Download status: ${stateLabel}${pctText}${msg}`;
|
|
let barPct = Math.max(0, Math.min(100, Number(pct) || 0));
|
|
if (rawState === "done" && barPct === 0) {
|
|
barPct = 100;
|
|
}
|
|
if (rawState === "downloading" && barPct === 0 && downloadState.progress?.current) {
|
|
barPct = 5;
|
|
}
|
|
downloadProgressBar.style.width = `${barPct}%`;
|
|
if (rawState === "error") {
|
|
downloadProgressBar.style.background = "rgba(179, 58, 58, 0.7)";
|
|
}
|
|
else if (rawState === "done") {
|
|
downloadProgressBar.style.background = "rgba(47, 143, 78, 0.7)";
|
|
}
|
|
else if (rawState === "idle") {
|
|
downloadProgressBar.style.background = "rgba(255, 255, 255, 0.2)";
|
|
}
|
|
else {
|
|
downloadProgressBar.style.background = "rgba(46, 136, 255, 0.7)";
|
|
}
|
|
};
|
|
const refreshFingerprintStatus = async () => {
|
|
try {
|
|
const resp = await getFingerprintCacheStatus();
|
|
fixerState.cacheCount = Number(resp?.count || 0);
|
|
fixerState.cacheUpdatedAt = String(resp?.updated_at || "");
|
|
fixerState.cacheError = "";
|
|
}
|
|
catch (e) {
|
|
fixerState.cacheError = "error";
|
|
}
|
|
updateFixerStatus();
|
|
};
|
|
updateFixerStatus();
|
|
updateDownloadStatus();
|
|
let missingScanTimer = null;
|
|
const refreshMissingStatus = (force = false) => {
|
|
const autoScan = getSettingBool(SETTING_AUTO_SCAN_MISSING_ON_OPEN, true);
|
|
if (!force && !autoScan) {
|
|
updateFixerStatus();
|
|
return;
|
|
}
|
|
const scanToken = Number(state.graphLoadToken || 0);
|
|
if (!force && fixerState.scanned && fixerState.lastScanToken === scanToken) {
|
|
updateFixerStatus();
|
|
return;
|
|
}
|
|
if (missingScanTimer) {
|
|
clearTimeout(missingScanTimer);
|
|
missingScanTimer = null;
|
|
}
|
|
missingScanTimer = setTimeout(() => {
|
|
try {
|
|
const stats = scanMissingModelsWithStats();
|
|
fixerState.missing = stats.missing.length;
|
|
fixerState.total = stats.total || 0;
|
|
fixerState.scanned = true;
|
|
fixerState.lastScanToken = scanToken;
|
|
if (fixerState.missing === 0) {
|
|
fixerState.fixed = 0;
|
|
}
|
|
}
|
|
finally {
|
|
updateFixerStatus();
|
|
}
|
|
}, 0);
|
|
};
|
|
const isEditableTarget = (target) => {
|
|
if (!target)
|
|
return false;
|
|
const tag = String(target.tagName || "").toLowerCase();
|
|
return target.isContentEditable || tag === "input" || tag === "textarea" || tag === "select";
|
|
};
|
|
container.addEventListener("keydown", (event) => {
|
|
const key = String(event.key || "").toLowerCase();
|
|
if (event.altKey && !event.ctrlKey && !event.metaKey) {
|
|
if (key === "a") {
|
|
event.preventDefault();
|
|
createApplyBtn.click();
|
|
}
|
|
else if (key === "p") {
|
|
event.preventDefault();
|
|
patchNowBtn.click();
|
|
}
|
|
}
|
|
});
|
|
const resolveModel = () => {
|
|
if (useCustomCb.checked) {
|
|
const tag = token3Tag(customModelInput.value, true);
|
|
return {
|
|
modelRaw: customModelInput.value || "Unknown",
|
|
modelUpper: true,
|
|
tag: tag || "UNKNOWN",
|
|
};
|
|
}
|
|
if (state.detectedModelTag) {
|
|
return {
|
|
modelRaw: state.detectedModelTag,
|
|
modelUpper: false,
|
|
tag: state.detectedModelTag,
|
|
};
|
|
}
|
|
const sel = modelSelect.value || "Unknown";
|
|
const tag = token3Tag(sel, false);
|
|
return { modelRaw: sel, modelUpper: false, tag: tag || "Unknown" };
|
|
};
|
|
const buildWorkflowName = () => {
|
|
const modelInfo = resolveModel();
|
|
const modelTag = modelInfo.tag || "Model";
|
|
const assetName = titlePathJS(nameInput.value || "Asset");
|
|
return `${yymmddJS()}_${modelTag}_${assetName}`;
|
|
};
|
|
const buildWorkflowPreview = () => {
|
|
const base = buildWorkflowName();
|
|
const next = base && state.workflowLastBase === base
|
|
? Number(state.workflowNextSuffix || 1)
|
|
: 1;
|
|
const suffix = String(next).padStart(4, "0");
|
|
return `${base}_${suffix}.json`;
|
|
};
|
|
let existingNamesToken = 0;
|
|
let lastExistingKey = "";
|
|
const refreshExistingNames = async (force = false) => {
|
|
const projectId = state.projectId || "";
|
|
const media = mediaSelect.value || "images";
|
|
const key = `${projectId}::${media}`;
|
|
if (!force && key === lastExistingKey)
|
|
return;
|
|
lastExistingKey = key;
|
|
existingNamesSelect.innerHTML = "";
|
|
const emptyOpt = document.createElement("option");
|
|
emptyOpt.value = "";
|
|
emptyOpt.textContent = "-- Select existing name --";
|
|
existingNamesSelect.appendChild(emptyOpt);
|
|
if (!projectId) {
|
|
existingNamesSelect.style.display = "none";
|
|
return;
|
|
}
|
|
const requestToken = (existingNamesToken += 1);
|
|
try {
|
|
const names = await listExistingNames(projectId, media);
|
|
if (requestToken !== existingNamesToken)
|
|
return;
|
|
if (!names.length) {
|
|
existingNamesSelect.style.display = "none";
|
|
return;
|
|
}
|
|
for (const name of names) {
|
|
const opt = document.createElement("option");
|
|
opt.value = name;
|
|
opt.textContent = name;
|
|
existingNamesSelect.appendChild(opt);
|
|
}
|
|
if (nameInput.value && names.includes(nameInput.value)) {
|
|
existingNamesSelect.value = nameInput.value;
|
|
}
|
|
else {
|
|
existingNamesSelect.value = "";
|
|
}
|
|
existingNamesSelect.style.display = "block";
|
|
}
|
|
catch (_) {
|
|
if (requestToken !== existingNamesToken)
|
|
return;
|
|
existingNamesSelect.style.display = "none";
|
|
}
|
|
};
|
|
let previewToken = 0;
|
|
const updatePreview = async () => {
|
|
const baseRel = state.projectFolder
|
|
? `PROJECTS/${state.projectFolder}`
|
|
: "PROJECTS/<PROJECT_FOLDER>";
|
|
const modelInfo = resolveModel();
|
|
const tokens = {
|
|
BASE: baseRel,
|
|
MEDIA: mediaDir(mediaSelect.value),
|
|
DATE: yymmddJS(),
|
|
MODEL: String(modelInfo.tag || ""),
|
|
NAME: titlePathJS(nameInput.value || "Name"),
|
|
KIND: makeKindToken(kindSelect.value),
|
|
};
|
|
const template = String(templateInput.value || "").trim();
|
|
const requestToken = (previewToken += 1);
|
|
detectedModelLabel.textContent = `Detected: ${state.detectedModelTag || "-"}`;
|
|
customModelPreview.textContent = useCustomCb.checked
|
|
? `Custom tag: ${modelInfo.tag}`
|
|
: "Custom tag: -";
|
|
if (modelSummary) {
|
|
const activeTag = modelInfo.tag || "-";
|
|
modelSummary.textContent = `MODEL (${activeTag})`;
|
|
}
|
|
const assetFolder = titlePathJS(workflowAssetInput.value || "");
|
|
const workflowFile = buildWorkflowPreview();
|
|
const workflowRel = assetFolder
|
|
? `03_WORKFLOWS/${assetFolder}/${workflowFile}`
|
|
: `03_WORKFLOWS/${workflowFile}`;
|
|
workflowPreviewLabel.textContent = `Next save: ${workflowRel}`;
|
|
modelSelect.disabled = !!state.detectedModelTag && !useCustomCb.checked;
|
|
modelSelect.style.opacity = modelSelect.disabled ? "0.6" : "1";
|
|
if (!template) {
|
|
previewLabel.textContent = "Preview: -";
|
|
return;
|
|
}
|
|
previewLabel.textContent = "Preview: ...";
|
|
try {
|
|
const resp = await previewTemplate(template, tokens);
|
|
if (requestToken !== previewToken)
|
|
return;
|
|
const preview = resp?.preview || "";
|
|
previewLabel.textContent = preview ? `Preview: ${preview}` : "Preview: -";
|
|
}
|
|
catch (err) {
|
|
if (requestToken !== previewToken)
|
|
return;
|
|
const previewError = String(err?.message || err || "Invalid template");
|
|
previewLabel.textContent = `Preview: ${previewError}`;
|
|
}
|
|
};
|
|
const updateState = () => {
|
|
state.workflowName = workflowNameInput.value;
|
|
state.workflowAsset = workflowAssetInput.value;
|
|
state.kind = kindSelect.value;
|
|
state.media = mediaSelect.value;
|
|
state.name = nameInput.value;
|
|
state.customModelText = customModelInput.value;
|
|
state.modelSelection = modelSelect.value;
|
|
state.autoPatch = autoPatchCb.checked;
|
|
state.autoPatchNewNodes = autoPatchNewCb.checked;
|
|
state.autoSwitchTrusted = autoSwitchCb.checked;
|
|
state.autoHookSave = autoHookCb.checked;
|
|
state.useCustomModel = useCustomCb.checked;
|
|
state.modelUpper = useCustomCb.checked;
|
|
state.pendingProjectName = pendingProjectNameInput.value;
|
|
state.pendingSelectProjectId = pendingSelect.value;
|
|
state.pathTemplate = templateInput.value;
|
|
customModelWrap.style.display = useCustomCb.checked ? "block" : "none";
|
|
saveState(state);
|
|
updateStatus();
|
|
updateResolve();
|
|
updateWorkflowBlock();
|
|
updatePreview();
|
|
if (!state.lastRelDir) {
|
|
targetLabel.textContent = "Target: none (create an output)";
|
|
}
|
|
};
|
|
const setWorkflowName = (value) => {
|
|
workflowNameInput.value = value || "";
|
|
state.workflowName = workflowNameInput.value;
|
|
saveState(state);
|
|
updatePreview();
|
|
};
|
|
const resetForProjectChange = () => {
|
|
state.projectName = "";
|
|
state.workflowName = "";
|
|
state.workflowAsset = "";
|
|
state.workflowLastBase = "";
|
|
state.workflowNextSuffix = 1;
|
|
state.workflowPanelOpen = false;
|
|
state.name = "";
|
|
state.kind = "asset";
|
|
state.media = "images";
|
|
state.modelSelection = "";
|
|
state.useCustomModel = false;
|
|
state.customModelText = "";
|
|
state.modelUpper = false;
|
|
state.lastRelDir = "";
|
|
state.lastPrefix = "";
|
|
state.pathTemplate = "{BASE}/{MEDIA}/{DATE}/{NAME}/{MODEL}";
|
|
workflowNameInput.value = "";
|
|
workflowAssetInput.value = "";
|
|
nameInput.value = "";
|
|
kindSelect.value = "asset";
|
|
mediaSelect.value = "images";
|
|
if (modelSelect.options.length > 0) {
|
|
modelSelect.selectedIndex = 0;
|
|
}
|
|
useCustomCb.checked = false;
|
|
customModelInput.value = "";
|
|
customModelWrap.style.display = "none";
|
|
templateInput.value = state.pathTemplate;
|
|
targetLabel.textContent = "Target: none (create an output)";
|
|
updateState();
|
|
updatePreview();
|
|
};
|
|
// Assign UI helper functions after all are defined
|
|
state._ui = {
|
|
updateStatus,
|
|
updateResolve,
|
|
updatePreview,
|
|
updateWorkflowBlock,
|
|
updateTargetLabel,
|
|
resetForProjectChange,
|
|
buildWorkflowName,
|
|
setWorkflowName,
|
|
refreshExistingNames,
|
|
refreshProjectsList: (force = false) => refreshProjectsList(force),
|
|
refreshMissingStatus,
|
|
};
|
|
// Store full project list for search filtering
|
|
let allProjects = [];
|
|
let lastProjectsRefreshAt = 0;
|
|
let projectRefreshErrorCount = 0;
|
|
const updateProjectDropdowns = (filteredList) => {
|
|
pendingSelect.innerHTML = "";
|
|
const pendingEmpty = document.createElement("option");
|
|
pendingEmpty.value = "";
|
|
pendingEmpty.textContent = "-";
|
|
pendingSelect.appendChild(pendingEmpty);
|
|
for (const p of filteredList) {
|
|
const opt = document.createElement("option");
|
|
opt.value = p.project_id;
|
|
opt.dataset.folder = p.folder || "";
|
|
opt.dataset.exists = p.exists === false ? "false" : "true";
|
|
const missingTag = p.exists === false ? " (missing)" : "";
|
|
const archivedTag = p.archived ? " [archived]" : "";
|
|
opt.textContent = `${p.folder || p.project_id} (${p.project_id})${missingTag}${archivedTag}`;
|
|
pendingSelect.appendChild(opt);
|
|
}
|
|
};
|
|
refreshProjectsList = async (force = false) => {
|
|
const now = Date.now();
|
|
// Apply exponential backoff on errors
|
|
const throttleMs = projectRefreshErrorCount > 0
|
|
? PROJECT_REFRESH_ERROR_BACKOFF_MS * Math.min(projectRefreshErrorCount, 5)
|
|
: PROJECT_REFRESH_THROTTLE_MS;
|
|
if (!force && now - lastProjectsRefreshAt < throttleMs)
|
|
return;
|
|
lastProjectsRefreshAt = now;
|
|
try {
|
|
const resp = await fetchJSON("/mjr_project/list");
|
|
allProjects = resp.projects || [];
|
|
state.lastError = "";
|
|
projectRefreshErrorCount = 0; // Reset error count on success
|
|
updateProjectDropdowns(allProjects);
|
|
if (state.projectId) {
|
|
const active = allProjects.find((p) => p.project_id === state.projectId);
|
|
state.projectExists = active ? active.exists !== false : false;
|
|
}
|
|
if (state.pendingSelectProjectId) {
|
|
pendingSelect.value = state.pendingSelectProjectId;
|
|
}
|
|
updateStatus();
|
|
updateResolve();
|
|
updateWorkflowBlock();
|
|
}
|
|
catch (e) {
|
|
projectRefreshErrorCount++;
|
|
const errorMsg = String(e.message || e);
|
|
state.lastError = errorMsg;
|
|
updateStatus();
|
|
// Only show toast on first error to avoid spam
|
|
if (projectRefreshErrorCount === 1) {
|
|
toast("error", "Projects list failed", errorMsg);
|
|
}
|
|
console.error("[mjr] Project list refresh failed:", e);
|
|
}
|
|
};
|
|
workflowNameInput.addEventListener("input", updateState);
|
|
workflowAssetInput.addEventListener("input", updateState);
|
|
kindSelect.addEventListener("change", updateState);
|
|
mediaSelect.addEventListener("change", () => {
|
|
updateState();
|
|
refreshExistingNames(true);
|
|
});
|
|
nameInput.addEventListener("input", updateState);
|
|
existingNamesSelect.addEventListener("change", () => {
|
|
nameInput.value = existingNamesSelect.value || "";
|
|
updateState();
|
|
});
|
|
customModelInput.addEventListener("input", updateState);
|
|
useCustomCb.addEventListener("change", updateState);
|
|
autoPatchCb.addEventListener("change", updateState);
|
|
autoPatchNewCb.addEventListener("change", updateState);
|
|
autoSwitchCb.addEventListener("change", updateState);
|
|
autoHookCb.addEventListener("change", updateState);
|
|
templateInput.addEventListener("input", updateState);
|
|
pendingProjectNameInput.addEventListener("input", updateState);
|
|
pendingSelect.addEventListener("change", updateState);
|
|
const runFixMissingModels = async () => {
|
|
debug("[MJR] Fix Missing Models clicked");
|
|
const originalLabel = fixMissingBtn.textContent;
|
|
fixMissingBtn.disabled = true;
|
|
fixMissingBtn.textContent = "Scanning...";
|
|
try {
|
|
debug("[MJR] Scanning for missing models...");
|
|
const stats = scanMissingModelsWithStats();
|
|
debug("[MJR] Scan results:", stats);
|
|
const missing = stats.missing;
|
|
fixerState.missing = missing.length;
|
|
fixerState.total = stats.total || 0;
|
|
fixerState.scanned = true;
|
|
fixerState.lastScanToken = Number(state.graphLoadToken || 0);
|
|
fixerState.fixed = 0;
|
|
updateFixerStatus();
|
|
if (!missing.length) {
|
|
debug("[MJR] No missing models found");
|
|
toast("info", "No missing models", "All model widgets match available options.");
|
|
return;
|
|
}
|
|
const payload = missing.map((item) => ({
|
|
missing_value: item.missing_value,
|
|
type_hint: item.type_hint,
|
|
}));
|
|
debug("[MJR] Sending scan request to API:", payload);
|
|
const resp = await scanModelCandidates(payload);
|
|
debug("[MJR] API response:", resp);
|
|
const results = Array.isArray(resp?.results) ? resp.results : [];
|
|
const resultMap = new Map();
|
|
for (const entry of results) {
|
|
const key = `${String(entry?.missing_value || "")}::${String(entry?.type_hint || "unknown")}`;
|
|
resultMap.set(key, Array.isArray(entry?.candidates) ? entry.candidates : []);
|
|
}
|
|
const autoFixes = [];
|
|
const autoFixedKeys = new Set();
|
|
for (const entry of missing) {
|
|
const key = `${String(entry?.missing_value || "")}::${String(entry?.type_hint || "unknown")}`;
|
|
const candidates = resultMap.get(key) || [];
|
|
const exact = candidates.filter((c) => Number(c?.score || 0) === 100 && !c?.in_wrong_folder && c?.reason !== "wrong_folder");
|
|
if (exact.length === 1) {
|
|
const relpath = String(exact[0]?.relpath || "");
|
|
if (relpath) {
|
|
autoFixes.push({
|
|
node_id: entry?.node_id,
|
|
widget_name: entry?.widget_name,
|
|
new_value: relpath,
|
|
});
|
|
autoFixedKeys.add(`${entry?.node_id ?? ""}::${entry?.widget_name ?? ""}::${entry?.missing_value ?? ""}`);
|
|
}
|
|
}
|
|
}
|
|
let fixedCount = 0;
|
|
let remaining = missing.filter((entry) => {
|
|
const key = `${entry?.node_id ?? ""}::${entry?.widget_name ?? ""}::${entry?.missing_value ?? ""}`;
|
|
return !autoFixedKeys.has(key);
|
|
});
|
|
if (autoFixes.length) {
|
|
fixedCount = applyFixesToGraph(autoFixes);
|
|
const postStats = scanMissingModelsWithStats();
|
|
remaining = postStats.missing;
|
|
fixerState.missing = remaining.length;
|
|
fixerState.total = postStats.total || 0;
|
|
fixerState.scanned = true;
|
|
fixerState.lastScanToken = Number(state.graphLoadToken || 0);
|
|
fixerState.fixed = fixedCount;
|
|
updateFixerStatus();
|
|
}
|
|
if (!remaining.length) {
|
|
if (fixedCount > 0) {
|
|
toast("success", "Fix applied", `Updated ${fixedCount} widget${fixedCount !== 1 ? "s" : ""}.`);
|
|
}
|
|
else {
|
|
toast("info", "No missing models", "All model widgets match available options.");
|
|
}
|
|
return;
|
|
}
|
|
const fixes = await showModelFixerDialog({
|
|
missing: remaining,
|
|
results,
|
|
});
|
|
debug("[MJR] User selected fixes:", fixes);
|
|
if (!fixes) {
|
|
if (fixedCount > 0) {
|
|
toast("success", "Auto-fix applied", `Updated ${fixedCount} widget${fixedCount !== 1 ? "s" : ""}.`);
|
|
}
|
|
else {
|
|
toast("info", "Fix cancelled", "No changes applied.");
|
|
}
|
|
return;
|
|
}
|
|
const fixed = applyFixesToGraph(fixes);
|
|
debug("[MJR] Applied fixes count:", fixed);
|
|
const postStats = scanMissingModelsWithStats();
|
|
fixerState.missing = postStats.missing.length;
|
|
fixerState.total = postStats.total || 0;
|
|
fixerState.scanned = true;
|
|
fixerState.lastScanToken = Number(state.graphLoadToken || 0);
|
|
fixerState.fixed = fixedCount + fixed;
|
|
updateFixerStatus();
|
|
if (fixed + fixedCount > 0) {
|
|
toast("success", "Fix applied", `Updated ${fixed + fixedCount} widget${fixed + fixedCount !== 1 ? "s" : ""}.`);
|
|
}
|
|
else {
|
|
toast("warn", "No changes", "No widgets were updated.");
|
|
}
|
|
}
|
|
catch (e) {
|
|
console.error("[MJR] Fix missing models error:", e);
|
|
toast("error", "Fix failed", String(e?.message || e));
|
|
}
|
|
finally {
|
|
fixMissingBtn.textContent = originalLabel;
|
|
fixMissingBtn.disabled = false;
|
|
}
|
|
};
|
|
const uniqueMissingForDownload = (missing) => {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const item of missing || []) {
|
|
const key = `${item.missing_value}::${item.type_hint || ""}`;
|
|
if (seen.has(key))
|
|
continue;
|
|
seen.add(key);
|
|
out.push(item);
|
|
}
|
|
return out;
|
|
};
|
|
const basename = (value) => normalizeKey(value);
|
|
const pollDownloadJob = async (jobId) => {
|
|
while (true) {
|
|
try {
|
|
const status = await getDownloadStatus(jobId);
|
|
downloadState.state = status?.state || "downloading";
|
|
downloadState.message = status?.message || "";
|
|
downloadState.progress = status?.progress || { current: 0, total: 0, pct: 0 };
|
|
updateDownloadStatus();
|
|
if (downloadState.state === "done" || downloadState.state === "error") {
|
|
return status;
|
|
}
|
|
}
|
|
catch (e) {
|
|
downloadState.state = "error";
|
|
downloadState.message = "status check failed";
|
|
updateDownloadStatus();
|
|
return null;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
}
|
|
};
|
|
const runDownloadMissingModels = async () => {
|
|
debug("[MJR] Download Missing Models clicked");
|
|
if (downloadState.state === "downloading") {
|
|
debug("[MJR] Download already in progress");
|
|
toast("warn", "Download in progress", "Please wait for the current job to finish.");
|
|
return;
|
|
}
|
|
const originalLabel = downloadMissingBtn.textContent;
|
|
downloadMissingBtn.disabled = true;
|
|
downloadMissingBtn.textContent = "Scanning...";
|
|
downloadState.state = "idle";
|
|
downloadState.message = "scanning";
|
|
updateDownloadStatus();
|
|
try {
|
|
debug("[MJR] Scanning for missing models...");
|
|
const stats = scanMissingModelsWithStats();
|
|
debug("[MJR] Scan results:", stats);
|
|
const missingAll = stats.missing;
|
|
const missing = uniqueMissingForDownload(missingAll);
|
|
debug("[MJR] Unique missing for download:", missing);
|
|
fixerState.missing = missingAll.length;
|
|
fixerState.total = stats.total || 0;
|
|
fixerState.scanned = true;
|
|
fixerState.lastScanToken = Number(state.graphLoadToken || 0);
|
|
updateFixerStatus();
|
|
if (!missing.length) {
|
|
debug("[MJR] No missing models found");
|
|
downloadState.state = "idle";
|
|
downloadState.message = "";
|
|
updateDownloadStatus();
|
|
toast("info", "No missing models", "All model widgets match available options.");
|
|
return;
|
|
}
|
|
const payload = missing.map((item) => ({
|
|
missing_value: item.missing_value,
|
|
type_hint: item.type_hint,
|
|
}));
|
|
const workflowJson = getSerializedWorkflow();
|
|
const noteTexts = extractNoteTexts(workflowJson);
|
|
const noteRecipes = collectNoteRecipes(workflowJson);
|
|
const resolved = await resolveModelRecipes(payload);
|
|
const resolvedMap = new Map();
|
|
for (const entry of resolved?.resolved || []) {
|
|
const key = basename(entry.key || entry.missing_value);
|
|
resolvedMap.set(key, entry);
|
|
}
|
|
const kindOptions = getKindOptions();
|
|
let existingMap = null;
|
|
try {
|
|
const modelResp = await getModels();
|
|
const categories = modelResp?.categories || {};
|
|
existingMap = new Map();
|
|
for (const [kind, items] of Object.entries(categories)) {
|
|
if (!Array.isArray(items))
|
|
continue;
|
|
const set = new Set();
|
|
for (const item of items) {
|
|
const raw = String(item || "").replace(/\\/g, "/").toLowerCase();
|
|
if (!raw)
|
|
continue;
|
|
set.add(raw);
|
|
const base = raw.split("/").pop();
|
|
if (base)
|
|
set.add(base);
|
|
}
|
|
existingMap.set(kind, set);
|
|
}
|
|
}
|
|
catch (_) {
|
|
existingMap = null;
|
|
}
|
|
// Helper to check if model exists
|
|
const modelExists = (kind, filename) => {
|
|
if (!existingMap || !kind || !filename)
|
|
return false;
|
|
const set = existingMap.get(kind);
|
|
if (!set)
|
|
return false;
|
|
const normalized = String(filename || "").replace(/\\/g, "/").toLowerCase();
|
|
return set.has(normalized) || set.has(normalized.split("/").pop());
|
|
};
|
|
let matches = 0;
|
|
const allEntries = missing.map((item) => {
|
|
const key = basename(item.missing_value);
|
|
const stored = resolvedMap.get(key) || {};
|
|
const fallbackKind = stored.kind || typeHintToKind(item.type_hint);
|
|
const noteRecipe = noteRecipes.get(key);
|
|
let recipe = stored.recipe || null;
|
|
let prefilled = false;
|
|
if (noteRecipe) {
|
|
recipe = noteRecipe;
|
|
if (recipe?.kind === "unknown" && fallbackKind) {
|
|
recipe = { ...recipe, kind: fallbackKind };
|
|
}
|
|
prefilled = true;
|
|
matches += 1;
|
|
}
|
|
else if (recipe) {
|
|
recipe = { ...recipe, source: "stored" };
|
|
prefilled = true;
|
|
}
|
|
const kind = stored.kind || fallbackKind;
|
|
const filename = recipe?.filename || key;
|
|
const exists = modelExists(kind, filename);
|
|
return {
|
|
missing_value: item.missing_value,
|
|
key,
|
|
kind,
|
|
recipe,
|
|
prefilled,
|
|
exists, // Flag to track if model already downloaded
|
|
};
|
|
});
|
|
// Filter to only show truly missing models (not already downloaded)
|
|
const dialogEntries = allEntries.filter((entry) => !entry.exists);
|
|
const alreadyDownloaded = allEntries.length - dialogEntries.length;
|
|
const entryMap = new Map(dialogEntries.map((entry) => [entry.key, entry]));
|
|
console.debug("[mjr] download filter", {
|
|
total: allEntries.length,
|
|
missing: dialogEntries.length,
|
|
alreadyDownloaded,
|
|
notes: noteTexts.length,
|
|
recipes: noteRecipes.size,
|
|
matches,
|
|
});
|
|
if (dialogEntries.length === 0) {
|
|
downloadState.state = "idle";
|
|
downloadState.message = "";
|
|
updateDownloadStatus();
|
|
if (alreadyDownloaded > 0) {
|
|
toast("success", "All models downloaded", `${alreadyDownloaded} model${alreadyDownloaded !== 1 ? 's' : ''} already in models folder.`);
|
|
}
|
|
else {
|
|
toast("info", "No downloads needed", "No models to download.");
|
|
}
|
|
return;
|
|
}
|
|
const dialogResult = await showModelDownloaderDialog({
|
|
entries: dialogEntries,
|
|
kindOptions,
|
|
existingMap,
|
|
});
|
|
if (!dialogResult) {
|
|
downloadState.state = "idle";
|
|
downloadState.message = "";
|
|
updateDownloadStatus();
|
|
return;
|
|
}
|
|
const items = dialogResult.items || [];
|
|
if (!items.length) {
|
|
downloadState.state = "idle";
|
|
downloadState.message = "";
|
|
updateDownloadStatus();
|
|
toast("warn", "No downloads", "Select at least one entry to download.");
|
|
return;
|
|
}
|
|
const autoRememberNotes = getSettingBool(SETTING_AUTO_REMEMBER_NOTE_SOURCES, true);
|
|
const saveItems = [];
|
|
if (dialogResult.remember && dialogResult.saveItems?.length) {
|
|
saveItems.push(...dialogResult.saveItems);
|
|
}
|
|
if (autoRememberNotes) {
|
|
for (const item of items) {
|
|
const entry = entryMap.get(item.key);
|
|
if (entry?.recipe?.source === "workflow_note") {
|
|
saveItems.push(item);
|
|
}
|
|
}
|
|
}
|
|
if (saveItems.length) {
|
|
const seenKeys = new Set();
|
|
const deduped = [];
|
|
for (const item of saveItems) {
|
|
if (!item || !item.key)
|
|
continue;
|
|
if (seenKeys.has(item.key))
|
|
continue;
|
|
seenKeys.add(item.key);
|
|
deduped.push(item);
|
|
}
|
|
if (deduped.length) {
|
|
await saveModelRecipes(deduped);
|
|
}
|
|
}
|
|
const resp = await downloadModels(items);
|
|
const jobId = resp?.job_id;
|
|
if (!jobId) {
|
|
throw new Error("download job not created");
|
|
}
|
|
downloadState.state = "downloading";
|
|
downloadState.message = "queued";
|
|
downloadState.progress = { current: 0, total: 0, pct: 0 };
|
|
updateDownloadStatus();
|
|
downloadMissingBtn.textContent = "Downloading...";
|
|
const finalStatus = await pollDownloadJob(jobId);
|
|
const summary = finalStatus?.summary || {};
|
|
const downloaded = Number(summary.downloaded || 0);
|
|
const errors = Number(summary.errors || 0);
|
|
const skipped = Number(summary.skipped || 0);
|
|
if (downloadState.state === "error") {
|
|
toast("error", "Download finished", `Downloaded ${downloaded}, skipped ${skipped}, errors ${errors}`);
|
|
}
|
|
else {
|
|
toast("success", "Download finished", `Downloaded ${downloaded}, skipped ${skipped}, errors ${errors}`);
|
|
}
|
|
try {
|
|
loadModels();
|
|
}
|
|
catch (_) { }
|
|
try {
|
|
app.graph?.setDirtyCanvas?.(true, true);
|
|
}
|
|
catch (_) { }
|
|
const autoFix = getSettingBool(SETTING_AUTO_FIX_AFTER_DOWNLOAD, true);
|
|
if (autoFix) {
|
|
await runFixMissingModels();
|
|
}
|
|
else {
|
|
const runFix = await psConfirm({
|
|
title: "Run Fix Missing Models?",
|
|
message: "Downloads finished. Run Fix Missing Models now?",
|
|
});
|
|
if (runFix) {
|
|
await runFixMissingModels();
|
|
}
|
|
else {
|
|
toast("info", "Fix skipped", "Re-open model dropdowns if they do not refresh.");
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
downloadState.state = "error";
|
|
downloadState.message = "download failed";
|
|
updateDownloadStatus();
|
|
toast("error", "Download failed", String(e?.message || e));
|
|
}
|
|
finally {
|
|
downloadMissingBtn.textContent = originalLabel;
|
|
downloadMissingBtn.disabled = false;
|
|
}
|
|
};
|
|
fixMissingBtn.addEventListener("click", runFixMissingModels);
|
|
downloadMissingBtn.addEventListener("click", runDownloadMissingModels);
|
|
buildFingerprintBtn.addEventListener("click", async () => {
|
|
const originalLabel = buildFingerprintBtn.textContent;
|
|
buildFingerprintBtn.disabled = true;
|
|
buildFingerprintBtn.textContent = "Building...";
|
|
try {
|
|
const resp = await buildFingerprintCache({});
|
|
const count = Number(resp?.count || 0);
|
|
fixerState.cacheCount = count;
|
|
fixerState.cacheUpdatedAt = String(resp?.updated_at || "");
|
|
fixerState.cacheError = "";
|
|
updateFixerStatus();
|
|
const hashed = Number(resp?.hashed || 0);
|
|
const reused = Number(resp?.reused || 0);
|
|
toast("success", "Cache built", `Items: ${count} | Hashed: ${hashed} | Reused: ${reused}`);
|
|
}
|
|
catch (e) {
|
|
toast("error", "Cache build failed", String(e?.message || e));
|
|
}
|
|
finally {
|
|
buildFingerprintBtn.textContent = originalLabel;
|
|
buildFingerprintBtn.disabled = false;
|
|
refreshFingerprintStatus();
|
|
}
|
|
});
|
|
saveWorkflowBtn.addEventListener("click", async () => {
|
|
updateState();
|
|
const wfName = workflowNameInput.value.trim();
|
|
await saveWorkflowToProject(state, wfName);
|
|
});
|
|
switchBtn.addEventListener("click", async () => {
|
|
const det = state.detected;
|
|
if (!det || det.mode === "none") {
|
|
toast("warn", "No detected project", "Nothing to switch to.");
|
|
return;
|
|
}
|
|
try {
|
|
if (det.project_id) {
|
|
await setActiveProjectById(state, det.project_id);
|
|
}
|
|
else if (det.project_folder) {
|
|
const resp = await fetchJSON("/mjr_project/set", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ project_name: det.project_folder, create_base: true }),
|
|
});
|
|
state.projectId = resp.project_id;
|
|
state.projectFolder = resp.project_folder;
|
|
state.projectExists = true;
|
|
state.lastError = "";
|
|
saveState(state);
|
|
resetForProjectChange();
|
|
refreshProjectsList(true);
|
|
toast("success", "Project active", resp.project_folder);
|
|
}
|
|
else {
|
|
toast("warn", "No detected project", "No folder available to switch.");
|
|
return;
|
|
}
|
|
state.detected = null;
|
|
updateResolve();
|
|
state.workflowHasSignature = true;
|
|
updateWorkflowBlock();
|
|
stampGraphProjectSignature(app, state);
|
|
}
|
|
catch (e) {
|
|
state.lastError = String(e.message || e);
|
|
updateStatus();
|
|
toast("error", "Switch failed", state.lastError);
|
|
}
|
|
});
|
|
const assignWorkflowToActive = async () => {
|
|
if (!state.projectId || !state.projectFolder) {
|
|
toast("error", "No active project", "Set a project first.");
|
|
return;
|
|
}
|
|
stampGraphProjectSignature(app, state);
|
|
state.workflowHasSignature = true;
|
|
state.workflowPanelOpen = false;
|
|
toast("success", "Workflow assigned", state.projectFolder);
|
|
const doPatch = await psConfirm({
|
|
title: "Patch save paths?",
|
|
message: "Patch save paths to the active project?",
|
|
});
|
|
if (doPatch) {
|
|
const base = `PROJECTS/${state.projectFolder}`;
|
|
const imgRel = joinRel(base, mediaDir("images"));
|
|
const vidRel = joinRel(base, mediaDir("videos"));
|
|
const patchedImages = await patchSaveNodes(app, imgRel, "", "images");
|
|
const patchedVideos = await patchSaveNodes(app, vidRel, "", "videos");
|
|
const patched = patchedImages + patchedVideos;
|
|
if (patched > 0) {
|
|
toast("success", "Patch applied", `Updated ${patched} nodes.`);
|
|
}
|
|
else {
|
|
toast("warn", "No nodes patched", "No save-like nodes were updated.");
|
|
}
|
|
}
|
|
else {
|
|
toast("info", "Patch skipped", "Save paths were not changed.");
|
|
}
|
|
state.detected = null;
|
|
updateStatus();
|
|
updateResolve();
|
|
updateWorkflowBlock();
|
|
};
|
|
assignBtn.addEventListener("click", assignWorkflowToActive);
|
|
createBtn.addEventListener("click", async () => {
|
|
const det = state.detected;
|
|
const suggested = det?.project_folder || "New_Project";
|
|
const entered = await psPrompt({
|
|
title: "Create project",
|
|
message: "Enter a project name",
|
|
defaultValue: suggested,
|
|
});
|
|
if (entered === null) {
|
|
toast("info", "Create cancelled", "No project name provided.");
|
|
return;
|
|
}
|
|
const projectName = String(entered || "").trim();
|
|
if (!projectName) {
|
|
toast("error", "Validation", "Project name is required.");
|
|
return;
|
|
}
|
|
try {
|
|
await createAndActivateProject(state, projectName, {
|
|
updateStatus,
|
|
updateResolve,
|
|
updateWorkflowBlock,
|
|
resetForProjectChange,
|
|
updatePreview,
|
|
refreshProjectsList,
|
|
});
|
|
state.detected = null;
|
|
updateResolve();
|
|
}
|
|
catch (e) {
|
|
state.lastError = String(e.message || e);
|
|
updateStatus();
|
|
toast("error", "Create failed", state.lastError);
|
|
}
|
|
});
|
|
dismissBtn.addEventListener("click", () => {
|
|
state.detected = null;
|
|
updateResolve();
|
|
});
|
|
wfCreateBtn.addEventListener("click", async () => {
|
|
const name = pendingProjectNameInput.value.trim();
|
|
if (!name) {
|
|
toast("error", "Validation", "Project name is required.");
|
|
return;
|
|
}
|
|
try {
|
|
await createAndActivateProject(state, name, {
|
|
updateStatus,
|
|
updateResolve,
|
|
updateWorkflowBlock,
|
|
resetForProjectChange,
|
|
updatePreview,
|
|
refreshProjectsList,
|
|
});
|
|
}
|
|
catch (e) {
|
|
state.lastError = String(e.message || e);
|
|
updateStatus();
|
|
toast("error", "Create failed", state.lastError);
|
|
}
|
|
});
|
|
wfActivateBtn.addEventListener("click", async () => {
|
|
const projectId = pendingSelect.value;
|
|
if (!projectId) {
|
|
toast("error", "Validation", "Select a project first.");
|
|
return;
|
|
}
|
|
try {
|
|
await setActiveProjectById(state, projectId);
|
|
state.workflowHasSignature = true;
|
|
updateWorkflowBlock();
|
|
stampGraphProjectSignature(app, state);
|
|
}
|
|
catch (e) {
|
|
state.lastError = String(e.message || e);
|
|
updateStatus();
|
|
toast("error", "Activate failed", state.lastError);
|
|
}
|
|
});
|
|
wfAssignBtn.addEventListener("click", assignWorkflowToActive);
|
|
modelSelect.addEventListener("change", updateState);
|
|
const loadModels = async () => {
|
|
try {
|
|
const resp = await fetchJSON("/mjr_project/models");
|
|
const categories = resp.categories || {};
|
|
modelSelect.innerHTML = "";
|
|
const primaryCats = ["diffusion_models", "checkpoints"];
|
|
const extraCats = Object.keys(categories).filter((c) => !primaryCats.includes(c));
|
|
const addGroup = (cat) => {
|
|
const items = categories[cat] || [];
|
|
if (!Array.isArray(items) || items.length === 0)
|
|
return 0;
|
|
const group = document.createElement("optgroup");
|
|
group.label = cat;
|
|
for (const item of items) {
|
|
const opt = document.createElement("option");
|
|
opt.value = item;
|
|
opt.textContent = item;
|
|
group.appendChild(opt);
|
|
}
|
|
modelSelect.appendChild(group);
|
|
return items.length;
|
|
};
|
|
let total = 0;
|
|
for (const cat of primaryCats) {
|
|
total += addGroup(cat);
|
|
}
|
|
if (state.showMoreModels) {
|
|
for (const cat of extraCats) {
|
|
total += addGroup(cat);
|
|
}
|
|
}
|
|
if (total === 0) {
|
|
const opt = document.createElement("option");
|
|
opt.value = "Unknown";
|
|
opt.textContent = "Unknown";
|
|
modelSelect.appendChild(opt);
|
|
}
|
|
modelEmptyMsg.style.display = total === 0 ? "block" : "none";
|
|
if (state.modelSelection) {
|
|
modelSelect.value = state.modelSelection;
|
|
}
|
|
if (!modelSelect.value && modelSelect.options.length > 0) {
|
|
modelSelect.selectedIndex = 0;
|
|
}
|
|
updateState();
|
|
}
|
|
catch (e) {
|
|
state.lastError = String(e.message || e);
|
|
updateStatus();
|
|
toast("error", "Model list failed", state.lastError);
|
|
console.error(e);
|
|
}
|
|
};
|
|
modelToggleBtn.addEventListener("click", () => {
|
|
state.showMoreModels = !state.showMoreModels;
|
|
modelToggleBtn.textContent = state.showMoreModels ? "Show less" : "Show more";
|
|
saveState(state);
|
|
loadModels();
|
|
});
|
|
createApplyBtn.addEventListener("click", async () => {
|
|
if (!state.projectId) {
|
|
state.lastError = "set project first";
|
|
updateStatus();
|
|
toast("error", "Validation", "Set a project first.");
|
|
return;
|
|
}
|
|
const name = nameInput.value.trim();
|
|
if (!name) {
|
|
state.lastError = "name required";
|
|
updateStatus();
|
|
toast("error", "Validation", "Name is required.");
|
|
return;
|
|
}
|
|
if (useCustomCb.checked && !customModelInput.value.trim()) {
|
|
toast("error", "Validation", "Custom model is required.");
|
|
return;
|
|
}
|
|
const modelInfo = resolveModel();
|
|
const modelName = modelInfo.modelRaw || "Unknown";
|
|
try {
|
|
const resp = await fetchJSON("/mjr_project/create_custom_out", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
project_id: state.projectId,
|
|
kind: kindSelect.value,
|
|
name,
|
|
media: mediaSelect.value,
|
|
model: modelName,
|
|
model_upper: modelInfo.modelUpper === true,
|
|
date: yymmddJS(),
|
|
template: templateInput.value,
|
|
}),
|
|
});
|
|
state.lastRelDir = resp.rel_dir;
|
|
state.lastPrefix = resp.filename_prefix;
|
|
state.media = resp.media || mediaSelect.value;
|
|
state.kind = resp.kind || kindSelect.value;
|
|
state.projectExists = true;
|
|
state.workflowHasSignature = true;
|
|
state.lastError = "";
|
|
saveState(state);
|
|
updateStatus();
|
|
updateWorkflowBlock();
|
|
targetLabel.textContent = `Target: ${resp.rel_dir} | Prefix: ${resp.filename_prefix}`;
|
|
if (autoPatchCb.checked) {
|
|
const patched = await patchSaveNodes(app, resp.rel_dir, resp.filename_prefix, resp.media);
|
|
if (patched > 0) {
|
|
toast("success", "Patch applied", `Updated ${patched} nodes.`);
|
|
}
|
|
else {
|
|
toast("warn", "No nodes patched", "No save-like nodes were updated.");
|
|
}
|
|
}
|
|
stampGraphProjectSignature(app, state);
|
|
toast("success", "Folder created", resp.rel_dir);
|
|
refreshProjectsList(true);
|
|
}
|
|
catch (e) {
|
|
state.lastError = String(e.message || e);
|
|
updateStatus();
|
|
toast("error", "Create failed", state.lastError);
|
|
console.error(e);
|
|
}
|
|
});
|
|
patchNowBtn.addEventListener("click", async () => {
|
|
rollOutputDateIfNeeded();
|
|
if (!state.lastRelDir || !state.lastPrefix) {
|
|
targetLabel.textContent = "Target: none (create an output)";
|
|
toast("warn", "No target", "Create an output first.");
|
|
return;
|
|
}
|
|
// Count save nodes first
|
|
const saveNodes = (app.graph?._nodes || []).filter(n => isSaveLikeNode(n));
|
|
const nodeCount = saveNodes.length;
|
|
if (nodeCount === 0) {
|
|
toast("warn", "No nodes to patch", "No save-like nodes found in workflow.");
|
|
return;
|
|
}
|
|
// Ask for confirmation
|
|
const confirmed = await psConfirm({
|
|
title: "Patch save paths?",
|
|
message: `Update ${nodeCount} save node${nodeCount !== 1 ? "s" : ""} to use the project path?\n\n` +
|
|
`Path: ${state.lastRelDir}\n` +
|
|
`Prefix: ${state.lastPrefix}\n\n` +
|
|
`This can be undone with Ctrl+Z.`,
|
|
});
|
|
if (!confirmed) {
|
|
toast("info", "Cancelled", "No nodes were modified.");
|
|
return;
|
|
}
|
|
const patched = await patchSaveNodes(app, state.lastRelDir, state.lastPrefix, state.media);
|
|
if (patched > 0) {
|
|
toast("success", "Patch applied", `Updated ${patched} node${patched !== 1 ? 's' : ''}.`);
|
|
}
|
|
else {
|
|
toast("warn", "No nodes patched", "No save-like nodes were updated.");
|
|
}
|
|
stampGraphProjectSignature(app, state);
|
|
});
|
|
loadModels();
|
|
refreshProjectsList(true);
|
|
refreshExistingNames(true);
|
|
updatePreview();
|
|
refreshFingerprintStatus();
|
|
if (getSettingBool(SETTING_AUTO_SCAN_MISSING_ON_OPEN, true)) {
|
|
refreshMissingStatus(true);
|
|
}
|
|
else {
|
|
updateFixerStatus();
|
|
}
|
|
}
|
|
//# sourceMappingURL=ui_components.js.map
|