Files
Ewald a07562a75e refactor(ui): modularize tabs, improve model management, and enhance test coverage
- Modularize UI components into dedicated tabs (model_tab, project_tab, workflow_tab) and theme tokens
- Enhance project model discovery dynamically from registered folder paths
- Release lingering file descriptors on Windows in model downloader routes
- Add comprehensive test suites for frontend date/model resolution and backend models endpoint
- Update compiled web assets and add comfyui custom node engineering skill
2026-09-14 19:05:41 +02:00

916 lines
43 KiB
JavaScript

/**
* Model Tab component for Majoor Project Settings.
* Handles active model selection, custom model tagging, community registry search,
* and missing models repair/download utilities.
*/
import { app } from "../../../../scripts/app.js";
import { toast } from "../../mjr/toast.js";
import { psConfirm } from "../../mjr/dialog.js";
import { getSerializedWorkflow } from "../../mjr/graph.js";
import { buildFingerprintCache, getFingerprintCacheStatus, getModels, searchRegistry, downloadModels, getDownloadStatus, resolveModelRecipes, saveModelRecipes, scanModelCandidates, fetchJSON, } from "../../mjr/api.js";
import { scanMissingModelsFromGraph, scanMissingModelsWithStats, applyFixesToGraph, } from "../../mjr/model_fixer.js";
import { getKindOptions, typeHintToKind, normalizeKey, collectNoteRecipes, } 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 { createCard, createButton, createInput, createSelect, createRow, createSwitch, createDetails, createBadge, THEME, } from "../theme.js";
export function buildModelTab(ctx) {
const { state, updateUI, updateHealth, fixerState } = ctx;
const container = document.createElement("div");
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.gap = THEME.gap;
// -------------------------------------------------------------
// 1. Model Configuration Card
// -------------------------------------------------------------
const modelCard = createCard("Model Configuration");
const customInput = createInput("Custom model name", state.customModelText || "");
const customTagPreview = document.createElement("div");
customTagPreview.style.fontSize = "10px";
customTagPreview.style.color = THEME.primaryColor;
customTagPreview.textContent = "Tag: -";
const customWrap = document.createElement("div");
customWrap.style.display = state.useCustomModel ? "flex" : "none";
customWrap.style.flexDirection = "column";
customWrap.style.gap = "4px";
customWrap.appendChild(createRow("Custom Model Name", customInput));
customWrap.appendChild(customTagPreview);
const customModelSwitch = createSwitch("Use custom model (UPPERCASE)", state.useCustomModel === true, (checked) => {
state.useCustomModel = checked;
if (checked && !state.customModelText) {
state.customModelText = state.detectedModelTag || "";
customInput.value = state.customModelText;
}
customWrap.style.display = checked ? "flex" : "none";
saveState(state);
updateTagPreview();
updateUI();
});
const detectedRow = document.createElement("div");
detectedRow.style.display = "flex";
detectedRow.style.alignItems = "center";
detectedRow.style.justifyContent = "space-between";
detectedRow.style.gap = "6px";
const detectedLabel = document.createElement("div");
detectedLabel.style.fontSize = "11px";
detectedLabel.style.color = THEME.accentWarning;
detectedLabel.textContent = `Detected in workflow: ${state.detectedModelTag || "None"}`;
const useDetectedBtn = createButton("Use as Custom", "secondary", "pi pi-copy");
useDetectedBtn.style.padding = "2px 6px";
useDetectedBtn.style.fontSize = "9px";
useDetectedBtn.style.display = state.detectedModelTag ? "inline-flex" : "none";
useDetectedBtn.addEventListener("click", () => {
if (!state.detectedModelTag)
return;
state.useCustomModel = true;
state.customModelText = state.detectedModelTag;
customInput.value = state.customModelText;
const cb = customModelSwitch.querySelector("input[type='checkbox']");
if (cb)
cb.checked = true;
customWrap.style.display = "flex";
saveState(state);
updateTagPreview();
updateUI();
toast("info", "Custom Model", `Set to ${state.detectedModelTag}`);
});
detectedRow.appendChild(detectedLabel);
detectedRow.appendChild(useDetectedBtn);
const modelSelect = createSelect([{ value: "", label: "Loading models..." }]);
const modelToggleBtn = createButton(state.showMoreModels ? "Fewer" : "More", "secondary");
modelToggleBtn.style.padding = "4px 8px";
modelToggleBtn.style.fontSize = "10px";
const modelRow = document.createElement("div");
modelRow.style.display = "flex";
modelRow.style.gap = "6px";
modelRow.style.alignItems = "flex-end";
const modelSelectWrap = createRow("Default Model", modelSelect);
modelSelectWrap.style.flex = "1";
modelRow.appendChild(modelSelectWrap);
modelRow.appendChild(modelToggleBtn);
const updateTagPreview = () => {
const raw = (state.customModelText || "").trim();
if (!raw) {
customTagPreview.textContent = "Tag: -";
}
else {
const tag = raw.replace(/[^a-zA-Z0-9_-]/g, "_").toUpperCase();
customTagPreview.textContent = `Tag: ${tag}`;
}
};
updateTagPreview();
customInput.addEventListener("input", () => {
state.customModelText = customInput.value;
saveState(state);
updateTagPreview();
});
const loadModelsList = () => {
getModels()
.then((resp) => {
const cats = (resp.categories || {});
const priority = cats.diffusion_models?.length ? cats.diffusion_models : cats.checkpoints || [];
const extra = [];
for (const [key, list] of Object.entries(cats)) {
if (key !== "diffusion_models" && key !== "checkpoints" && Array.isArray(list)) {
extra.push(...list);
}
}
const toShow = state.showMoreModels ? [...priority, ...extra] : priority;
modelSelect.innerHTML = "";
const defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = "Default (use detected)";
modelSelect.appendChild(defaultOpt);
for (const m of toShow) {
const opt = document.createElement("option");
opt.value = m;
opt.textContent = m;
modelSelect.appendChild(opt);
}
if (state.modelSelection) {
modelSelect.value = state.modelSelection;
}
})
.catch((err) => {
console.warn("[mjr] Failed to fetch models:", err);
});
};
modelToggleBtn.addEventListener("click", () => {
state.showMoreModels = !state.showMoreModels;
modelToggleBtn.textContent = state.showMoreModels ? "Fewer" : "More";
saveState(state);
loadModelsList();
});
modelSelect.addEventListener("change", () => {
state.modelSelection = modelSelect.value;
saveState(state);
updateUI();
});
modelCard.appendChild(detectedRow);
modelCard.appendChild(customModelSwitch);
modelCard.appendChild(modelRow);
modelCard.appendChild(customWrap);
loadModelsList();
// -------------------------------------------------------------
// 2. Missing Models Scanner & Fix Utilities
// -------------------------------------------------------------
const fixerCard = createCard("Missing Models Utilities");
const fixerStatusBadge = createBadge("Not scanned", THEME.textMuted);
const fixerHeader = document.createElement("div");
fixerHeader.style.display = "flex";
fixerHeader.style.alignItems = "center";
fixerHeader.style.justifyContent = "space-between";
fixerHeader.style.fontSize = "11px";
fixerHeader.appendChild(document.createTextNode("Workflow Status:"));
fixerHeader.appendChild(fixerStatusBadge);
const scanBtn = createButton("Scan Graph", "secondary", "pi pi-search");
const fixBtn = createButton("Fix Missing", "primary", "pi pi-check-circle");
fixBtn.style.background = "linear-gradient(135deg, rgba(16, 185, 129, 0.22) 0%, rgba(5, 150, 105, 0.15) 100%)";
fixBtn.style.borderColor = "rgba(52, 211, 153, 0.45)";
fixBtn.style.color = "#d1fae5";
const downloadBtn = createButton("Download Missing", "primary", "pi pi-download");
downloadBtn.style.background = "linear-gradient(135deg, rgba(56, 189, 248, 0.22) 0%, rgba(37, 99, 235, 0.15) 100%)";
downloadBtn.style.borderColor = "rgba(56, 189, 248, 0.45)";
downloadBtn.style.color = "#e0f2fe";
const fixerBtnGrid = document.createElement("div");
fixerBtnGrid.style.display = "grid";
fixerBtnGrid.style.gridTemplateColumns = "1fr 1fr";
fixerBtnGrid.style.gap = "6px";
fixerBtnGrid.appendChild(scanBtn);
fixerBtnGrid.appendChild(fixBtn);
// Scan Results list container (inspired by ComfyUI workflow overview)
const scanResultsWrap = document.createElement("div");
scanResultsWrap.style.display = "flex";
scanResultsWrap.style.flexDirection = "column";
scanResultsWrap.style.gap = "8px";
scanResultsWrap.style.marginTop = "4px";
let lastScanCandidates = [];
let lastFoundOnDiskCount = 0;
const refreshMissingStatus = async () => {
try {
const { missing, total } = scanMissingModelsWithStats();
fixerState.scanned = true;
fixerState.missing = missing.length;
fixerState.total = total;
if (total === 0) {
fixerStatusBadge.textContent = "No model widgets";
fixerStatusBadge.style.color = THEME.textMuted;
scanResultsWrap.innerHTML = "";
lastScanCandidates = [];
lastFoundOnDiskCount = 0;
updateHealth();
return;
}
if (missing.length === 0) {
fixerStatusBadge.textContent = "All models found";
fixerStatusBadge.style.color = THEME.accentSuccess;
fixerStatusBadge.style.background = "rgba(74, 222, 128, 0.08)";
fixerStatusBadge.style.borderColor = "rgba(74, 222, 128, 0.2)";
scanResultsWrap.innerHTML = "";
lastScanCandidates = [];
lastFoundOnDiskCount = 0;
updateHealth();
return;
}
// Query candidate models from disk across ComfyUI folders
let candidateResults = [];
try {
const resp = await scanModelCandidates(missing);
candidateResults = resp?.results || [];
lastScanCandidates = candidateResults;
}
catch (err) {
console.warn("[mjr] Failed to scan candidates from backend:", err);
}
const resultMap = new Map();
for (const res of candidateResults) {
const key = `${res.missing_value || ""}::${res.type_hint || "unknown"}`;
resultMap.set(key, {
candidates: res.candidates || [],
exact_match_wrong_folder: res.exact_match_wrong_folder || null,
});
}
const analyzedList = [];
let foundOnDiskCount = 0;
for (const item of missing) {
const key = `${item.missing_value}::${item.type_hint || "unknown"}`;
const resData = resultMap.get(key) || { candidates: [], exact_match_wrong_folder: null };
const candidates = resData.candidates || [];
const wrongFolder = resData.exact_match_wrong_folder;
// Check 1: In-options subfolder match detected during graph scan
if (item.suggested_fix || item.found_on_disk_path) {
analyzedList.push({
entry: item,
status: "found_on_disk",
targetPath: String(item.suggested_fix || item.found_on_disk_path),
wrongFolder: null,
});
foundOnDiskCount++;
continue;
}
// Check 2: Exact match on disk from scan_candidates
const exactMatch = candidates.find((c) => Number(c.score || 0) === 100);
if (exactMatch && !exactMatch.in_wrong_folder) {
analyzedList.push({
entry: item,
status: "found_on_disk",
targetPath: String(exactMatch.relpath || exactMatch.basename),
wrongFolder: null,
});
foundOnDiskCount++;
continue;
}
// Check 3: Found in wrong folder on disk
if (wrongFolder) {
analyzedList.push({
entry: item,
status: "wrong_folder",
targetPath: String(wrongFolder.relpath || wrongFolder.basename),
wrongFolder,
});
foundOnDiskCount++;
continue;
}
// Check 4: Missing from disk
analyzedList.push({
entry: item,
status: "missing",
targetPath: null,
wrongFolder: null,
});
}
lastFoundOnDiskCount = foundOnDiskCount;
// Update header badge
if (foundOnDiskCount > 0) {
fixerStatusBadge.textContent = `${missing.length} missing (${foundOnDiskCount} found on disk)`;
fixerStatusBadge.style.color = THEME.accentWarning;
fixerStatusBadge.style.background = "rgba(251, 191, 36, 0.08)";
fixerStatusBadge.style.borderColor = "rgba(251, 191, 36, 0.2)";
}
else {
fixerStatusBadge.textContent = `${missing.length} missing / ${total}`;
fixerStatusBadge.style.color = THEME.accentError;
fixerStatusBadge.style.background = "rgba(239, 68, 68, 0.08)";
fixerStatusBadge.style.borderColor = "rgba(239, 68, 68, 0.2)";
}
// Render scan results list
scanResultsWrap.innerHTML = "";
const summaryBar = document.createElement("div");
summaryBar.style.display = "flex";
summaryBar.style.alignItems = "center";
summaryBar.style.justifyContent = "space-between";
summaryBar.style.padding = "6px 8px";
summaryBar.style.borderRadius = THEME.radiusSm;
summaryBar.style.background = "rgba(255, 255, 255, 0.03)";
summaryBar.style.border = `1px solid ${THEME.borderColor}`;
const summaryText = document.createElement("div");
summaryText.style.fontSize = "11px";
summaryText.style.color = THEME.textColor;
summaryText.textContent = `${missing.length} missing model(s) detected:`;
summaryBar.appendChild(summaryText);
if (foundOnDiskCount > 0) {
const fixAllBtn = createButton(`Fix All Found (${foundOnDiskCount})`, "primary", "pi pi-check-circle");
fixAllBtn.style.padding = "3px 8px";
fixAllBtn.style.fontSize = "10px";
fixAllBtn.addEventListener("click", async () => {
const fixesToApply = [];
for (const a of analyzedList) {
if (a.status === "found_on_disk" && a.targetPath) {
fixesToApply.push({
node_id: a.entry.node_id,
widget_name: a.entry.widget_name,
new_value: a.targetPath,
});
}
else if (a.status === "wrong_folder" && a.wrongFolder) {
try {
const moveData = await fetchJSON("/mjr_models/move_to_correct_folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source_kind: a.wrongFolder.kind,
source_relpath: a.wrongFolder.relpath,
target_kind: a.wrongFolder.expected_kind,
}),
});
if (moveData?.ok && moveData.target_relpath) {
fixesToApply.push({
node_id: a.entry.node_id,
widget_name: a.entry.widget_name,
new_value: moveData.target_relpath,
});
}
}
catch (_) { }
}
}
if (fixesToApply.length > 0) {
const count = applyFixesToGraph(fixesToApply);
toast("success", "Models Fixed", `Applied ${count} fix(es) to graph.`);
await refreshMissingStatus();
}
});
summaryBar.appendChild(fixAllBtn);
}
scanResultsWrap.appendChild(summaryBar);
const itemsList = document.createElement("div");
itemsList.style.display = "flex";
itemsList.style.flexDirection = "column";
itemsList.style.gap = "6px";
itemsList.style.maxHeight = "220px";
itemsList.style.overflowY = "auto";
for (const item of analyzedList) {
const row = document.createElement("div");
row.style.padding = "6px 8px";
row.style.borderRadius = THEME.radiusSm;
row.style.background = "rgba(12, 14, 18, 0.6)";
row.style.border = "1px solid rgba(255, 255, 255, 0.08)";
row.style.display = "flex";
row.style.flexDirection = "column";
row.style.gap = "4px";
// Top line: Node title + widget name + crosshair focus button
const topRow = document.createElement("div");
topRow.style.display = "flex";
topRow.style.alignItems = "center";
topRow.style.justifyContent = "space-between";
const nodeTitle = document.createElement("span");
nodeTitle.style.fontWeight = "600";
nodeTitle.style.fontSize = "11px";
nodeTitle.style.color = THEME.textColor;
nodeTitle.textContent = `${item.entry.node_title} · ${item.entry.widget_name}`;
// Focus crosshair icon (centers on node in canvas)
const focusBtn = document.createElement("button");
focusBtn.className = "mjr-ps-btn";
focusBtn.title = "Focus node in canvas";
focusBtn.style.padding = "2px 6px";
focusBtn.style.fontSize = "10px";
focusBtn.style.background = "transparent";
focusBtn.style.border = "none";
focusBtn.style.color = THEME.textMuted;
focusBtn.style.cursor = "pointer";
focusBtn.innerHTML = `<i class="pi pi-crosshairs"></i>`;
focusBtn.addEventListener("click", () => {
const targetNode = item.entry.node || (item.entry.node_id != null ? app?.graph?.getNodeById(item.entry.node_id) : null);
if (targetNode) {
if (typeof app?.canvas?.centerOnNode === "function") {
app.canvas.centerOnNode(targetNode);
}
if (typeof app?.canvas?.selectNode === "function") {
app.canvas.selectNode(targetNode, false);
}
if (typeof app?.graph?.setDirtyCanvas === "function") {
app.graph.setDirtyCanvas(true, true);
}
}
});
topRow.appendChild(nodeTitle);
topRow.appendChild(focusBtn);
// Model name
const modelNameText = document.createElement("div");
modelNameText.style.fontSize = "11px";
modelNameText.style.fontFamily = "monospace";
modelNameText.style.color = THEME.primaryColor;
modelNameText.style.wordBreak = "break-all";
modelNameText.textContent = item.entry.missing_value;
// Bottom line: status pill + action button
const bottomRow = document.createElement("div");
bottomRow.style.display = "flex";
bottomRow.style.alignItems = "center";
bottomRow.style.justifyContent = "space-between";
bottomRow.style.gap = "6px";
bottomRow.style.marginTop = "2px";
const statusTag = document.createElement("span");
statusTag.style.fontSize = "10px";
statusTag.style.padding = "1px 6px";
statusTag.style.borderRadius = "4px";
if (item.status === "found_on_disk") {
statusTag.textContent = `Found on disk: ${item.targetPath}`;
statusTag.style.background = "rgba(74, 222, 128, 0.15)";
statusTag.style.border = "1px solid rgba(74, 222, 128, 0.35)";
statusTag.style.color = "#a7f3d0";
const singleFixBtn = createButton("Fix", "primary", "pi pi-check");
singleFixBtn.style.padding = "2px 8px";
singleFixBtn.style.fontSize = "10px";
singleFixBtn.addEventListener("click", async () => {
if (item.targetPath) {
const count = applyFixesToGraph([{
node_id: item.entry.node_id,
widget_name: item.entry.widget_name,
new_value: item.targetPath,
}]);
if (count > 0) {
toast("success", "Model Fixed", `Updated to ${item.targetPath}`);
await refreshMissingStatus();
}
}
});
bottomRow.appendChild(statusTag);
bottomRow.appendChild(singleFixBtn);
}
else if (item.status === "wrong_folder" && item.wrongFolder) {
statusTag.textContent = `In ${item.wrongFolder.kind}/ → Move to ${item.wrongFolder.expected_kind}/`;
statusTag.style.background = "rgba(251, 191, 36, 0.15)";
statusTag.style.border = "1px solid rgba(251, 191, 36, 0.35)";
statusTag.style.color = "#fde68a";
const moveFixBtn = createButton("Move & Fix", "secondary", "pi pi-folder");
moveFixBtn.style.padding = "2px 8px";
moveFixBtn.style.fontSize = "10px";
moveFixBtn.addEventListener("click", async () => {
moveFixBtn.disabled = true;
moveFixBtn.textContent = "Moving...";
try {
const moveData = await fetchJSON("/mjr_models/move_to_correct_folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source_kind: item.wrongFolder.kind,
source_relpath: item.wrongFolder.relpath,
target_kind: item.wrongFolder.expected_kind,
}),
});
if (moveData?.ok && moveData.target_relpath) {
applyFixesToGraph([{
node_id: item.entry.node_id,
widget_name: item.entry.widget_name,
new_value: moveData.target_relpath,
}]);
toast("success", "Moved & Fixed", `Moved to ${item.wrongFolder.expected_kind}/ and updated.`);
await refreshMissingStatus();
}
else {
throw new Error("Move failed");
}
}
catch (e) {
toast("error", "Move Failed", String(e));
moveFixBtn.disabled = false;
}
});
bottomRow.appendChild(statusTag);
bottomRow.appendChild(moveFixBtn);
}
else {
statusTag.textContent = "Not on disk";
statusTag.style.background = "rgba(239, 68, 68, 0.15)";
statusTag.style.border = "1px solid rgba(239, 68, 68, 0.35)";
statusTag.style.color = "#fca5a5";
const dlBtn = createButton("Download", "ghost", "pi pi-download");
dlBtn.style.padding = "2px 8px";
dlBtn.style.fontSize = "10px";
dlBtn.addEventListener("click", () => {
downloadBtn.click();
});
bottomRow.appendChild(statusTag);
bottomRow.appendChild(dlBtn);
}
row.appendChild(topRow);
row.appendChild(modelNameText);
row.appendChild(bottomRow);
itemsList.appendChild(row);
}
scanResultsWrap.appendChild(itemsList);
updateHealth();
}
catch (err) {
console.error("[mjr] Error in refreshMissingStatus:", err);
}
};
scanBtn.addEventListener("click", async () => {
await refreshMissingStatus();
if (fixerState.missing === 0) {
toast("success", "Model Scan", "All graph models are present.");
}
else if (lastFoundOnDiskCount > 0) {
toast("warn", "Model Scan", `${fixerState.missing} missing detected: ${lastFoundOnDiskCount} found on disk and ready to fix!`);
}
else {
toast("warn", "Model Scan", `${fixerState.missing} missing model(s) detected.`);
}
});
fixBtn.addEventListener("click", async () => {
const missing = scanMissingModelsFromGraph();
if (!missing || missing.length === 0) {
toast("info", "Fix Models", "No missing models detected in graph.");
return;
}
const fixes = await showModelFixerDialog({ missing, results: lastScanCandidates });
if (fixes && fixes.length > 0) {
const fixedCount = applyFixesToGraph(fixes);
if (fixedCount > 0) {
toast("success", "Models Updated", `Updated ${fixedCount} model widget(s).`);
}
await refreshMissingStatus();
updateHealth();
}
});
let isDownloading = 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;
};
downloadBtn.addEventListener("click", async () => {
if (isDownloading) {
toast("warn", "Download in Progress", "Please wait for current downloads to finish.");
return;
}
const originalBtnText = downloadBtn.textContent || "Download Missing";
downloadBtn.disabled = true;
downloadBtn.textContent = "Scanning...";
try {
const stats = scanMissingModelsWithStats();
const missing = uniqueMissingForDownload(stats.missing);
if (!missing.length) {
toast("info", "No Missing Models", "All model widgets match installed files.");
return;
}
// Fetch workflow notes and stored recipes
const workflowJson = getSerializedWorkflow();
const noteRecipes = collectNoteRecipes(workflowJson);
const payload = missing.map((item) => ({
missing_value: item.missing_value,
type_hint: item.type_hint,
}));
const resolvedMap = new Map();
try {
const resolved = await resolveModelRecipes(payload);
for (const entry of (resolved?.resolved || [])) {
const key = normalizeKey(String(entry.key || entry.missing_value || ""));
resolvedMap.set(key, entry);
}
}
catch (_) { }
// Check existing models on disk
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;
}
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() || "");
};
const allEntries = missing.map((item) => {
const key = normalizeKey(String(item.missing_value || ""));
const stored = resolvedMap.get(key) || {};
const fallbackKind = String(stored.kind || typeHintToKind(String(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;
}
else if (recipe) {
recipe = { ...recipe, source: "stored" };
prefilled = true;
}
const kind = String(stored.kind || fallbackKind);
const filename = String(recipe?.filename || key);
const exists = modelExists(kind, filename);
return {
missing_value: item.missing_value,
type_hint: item.type_hint,
key,
kind,
recipe,
prefilled,
exists,
};
});
const dialogEntries = allEntries.filter((e) => !e.exists);
const alreadyInstalled = allEntries.length - dialogEntries.length;
if (dialogEntries.length === 0) {
if (alreadyInstalled > 0) {
toast("success", "Models Present", `${alreadyInstalled} model(s) already exist in your models folder.`);
}
else {
toast("info", "No Models to Download", "All required models are present.");
}
return;
}
const result = await showModelDownloaderDialog({
entries: dialogEntries,
kindOptions: getKindOptions(),
existingMap,
});
if (!result || !result.items || !result.items.length) {
return;
}
// Save recipes if requested
const saveItems = [];
if (result.remember && result.saveItems?.length) {
saveItems.push(...result.saveItems);
}
for (const item of result.items) {
const entry = dialogEntries.find((e) => e.key === item.key);
if (entry?.recipe && typeof entry.recipe === "object" && entry.recipe.source === "workflow_note") {
saveItems.push(item);
}
}
if (saveItems.length) {
const seenKeys = new Set();
const deduped = [];
for (const item of saveItems) {
const obj = item;
if (!obj || !obj.key || seenKeys.has(obj.key))
continue;
seenKeys.add(obj.key);
deduped.push(item);
}
if (deduped.length) {
try {
await saveModelRecipes(deduped);
}
catch (_) { }
}
}
// Launch download job
isDownloading = true;
downloadBtn.disabled = true;
downloadBtn.textContent = "Queueing...";
const resp = await downloadModels(result.items);
const jobId = resp?.job_id;
if (!jobId) {
throw new Error(String(resp?.error || "Download job not created"));
}
downloadBtn.textContent = "Downloading 0%...";
let finalStatus = null;
while (true) {
await new Promise((r) => setTimeout(r, 1000));
try {
const status = (await getDownloadStatus(jobId));
const progress = status?.progress;
const pct = progress?.pct ?? 0;
downloadBtn.textContent = `Downloading ${pct}%...`;
if (status?.state === "done" || status?.state === "error") {
finalStatus = status;
break;
}
}
catch (err) {
console.warn("[mjr] download status error:", err);
break;
}
}
const summary = (finalStatus?.summary || {});
const downloaded = Number(summary.downloaded || 0);
const errors = Number(summary.errors || 0);
const skipped = Number(summary.skipped || 0);
if (finalStatus?.state === "error" || errors > 0) {
toast("warn", "Download Complete", `Downloaded ${downloaded}, skipped ${skipped}, errors: ${errors}`);
}
else {
toast("success", "Download Complete", `Downloaded ${downloaded} model(s).`);
}
loadModelsList();
if (typeof app?.refreshComboInNodes === "function") {
try {
app.refreshComboInNodes();
}
catch (_) { }
}
if (typeof app?.graph?.setDirtyCanvas === "function") {
app.graph.setDirtyCanvas(true, true);
}
refreshMissingStatus();
updateHealth();
if (downloaded > 0) {
const runFix = await psConfirm({
title: "Fix Missing Models?",
message: `Successfully downloaded ${downloaded} model(s).\n\nWould you like to auto-update missing model widgets now?`,
});
if (runFix) {
const remainingMissing = scanMissingModelsFromGraph();
if (remainingMissing && remainingMissing.length > 0) {
const fixes = await showModelFixerDialog({ missing: remainingMissing });
if (fixes && fixes.length > 0) {
const fixedCount = applyFixesToGraph(fixes);
if (fixedCount > 0) {
toast("success", "Models Updated", `Updated ${fixedCount} model widget(s).`);
}
refreshMissingStatus();
updateHealth();
}
}
}
}
}
catch (err) {
console.error("[mjr] Download error:", err);
toast("error", "Download Error", String(err?.message || err));
}
finally {
isDownloading = false;
downloadBtn.disabled = false;
downloadBtn.textContent = originalBtnText;
}
});
fixerCard.appendChild(fixerHeader);
fixerCard.appendChild(fixerBtnGrid);
fixerCard.appendChild(scanResultsWrap);
fixerCard.appendChild(downloadBtn);
// -------------------------------------------------------------
// 3. Community Model Registry Search
// -------------------------------------------------------------
const registrySection = createDetails("Community Model Registry", false);
const searchInput = createInput("Search models on HF, CivitAI...", "");
const searchBtn = createButton("Search", "secondary", "pi pi-search");
const contributeBtn = createButton("Contribute Recipe", "ghost", "pi pi-share-alt");
const searchBar = document.createElement("div");
searchBar.style.display = "flex";
searchBar.style.gap = "6px";
searchBar.appendChild(searchInput);
searchBar.appendChild(searchBtn);
const resultsContainer = document.createElement("div");
resultsContainer.style.display = "flex";
resultsContainer.style.flexDirection = "column";
resultsContainer.style.gap = "6px";
resultsContainer.style.maxHeight = "200px";
resultsContainer.style.overflowY = "auto";
const performRegistrySearch = async () => {
const q = searchInput.value.trim();
if (!q)
return;
resultsContainer.innerHTML = "";
searchBtn.disabled = true;
try {
const resp = await searchRegistry(q, 6);
const results = (resp.results || []);
if (results.length === 0) {
const empty = document.createElement("div");
empty.textContent = "No community models matched.";
empty.style.fontSize = "11px";
empty.style.color = THEME.textMuted;
empty.style.textAlign = "center";
empty.style.padding = "10px";
resultsContainer.appendChild(empty);
}
else {
for (const item of results) {
const itemCard = document.createElement("div");
itemCard.style.padding = "6px 8px";
itemCard.style.borderRadius = THEME.radiusSm;
itemCard.style.background = "rgba(255, 255, 255, 0.03)";
itemCard.style.border = `1px solid ${THEME.borderColor}`;
itemCard.style.fontSize = "11px";
const title = document.createElement("div");
title.textContent = item.name || item.filename || "Model";
title.style.fontWeight = "600";
title.style.color = THEME.primaryColor;
const meta = document.createElement("div");
meta.textContent = `${item.platform || "direct"} | ${item.type || "checkpoint"}`;
meta.style.fontSize = "10px";
meta.style.color = THEME.textMuted;
itemCard.appendChild(title);
itemCard.appendChild(meta);
resultsContainer.appendChild(itemCard);
}
}
}
catch (e) {
toast("error", "Search failed", String(e));
}
finally {
searchBtn.disabled = false;
}
};
searchBtn.addEventListener("click", () => void performRegistrySearch());
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter")
void performRegistrySearch();
});
contributeBtn.addEventListener("click", () => {
showModelContributionDialog();
});
registrySection.appendChild(searchBar);
registrySection.appendChild(resultsContainer);
registrySection.appendChild(contributeBtn);
// -------------------------------------------------------------
// 4. Advanced Fingerprint Cache Section
// -------------------------------------------------------------
const fingerprintSection = createDetails("Model Fingerprint Cache", false);
const fpStatusLabel = document.createElement("div");
fpStatusLabel.style.fontSize = "11px";
fpStatusLabel.style.color = THEME.textMuted;
fpStatusLabel.textContent = "Cache: checking...";
const buildFpBtn = createButton("Build / Refresh Cache", "secondary", "pi pi-database");
const refreshFpStatus = () => {
getFingerprintCacheStatus()
.then((res) => {
fpStatusLabel.textContent = `Indexed: ${res.count || 0} models | Last updated: ${res.updated_at || "Never"}`;
})
.catch(() => {
fpStatusLabel.textContent = "Cache: unavailable";
});
};
refreshFpStatus();
buildFpBtn.addEventListener("click", async () => {
buildFpBtn.disabled = true;
fpStatusLabel.textContent = "Building cache (hashing headers)...";
try {
const resp = await buildFingerprintCache({ force: true });
toast("success", "Cache built", `Hashed ${resp.hashed || 0} models (${resp.reused || 0} reused).`);
refreshFpStatus();
}
catch (e) {
toast("error", "Build failed", String(e));
}
finally {
buildFpBtn.disabled = false;
}
});
fingerprintSection.appendChild(fpStatusLabel);
fingerprintSection.appendChild(buildFpBtn);
// Assemble Tab (Workflow Models & Fixer first for high visibility)
container.appendChild(fixerCard);
container.appendChild(modelCard);
container.appendChild(registrySection);
container.appendChild(fingerprintSection);
const updateModelSection = () => {
detectedLabel.textContent = `Detected in workflow: ${state.detectedModelTag || "None"}`;
useDetectedBtn.style.display = state.detectedModelTag ? "inline-flex" : "none";
updateTagPreview();
};
return {
element: container,
refreshMissingStatus,
updateModelSection,
};
}
//# sourceMappingURL=model_tab.js.map