// Model Manager Script
// Handles local model sync functionality
// Initialize model manager
async function initializeModelManager(element, getData) {
const modelTabContent = element.querySelector("#model-tab-content");
const modelLoading = element.querySelector("#model-loading");
const modelList = element.querySelector("#model-list");
// Store getData function globally for sync functionality
window.comfyDeployGetModelData = getData;
const data = getData();
if (!modelTabContent || !modelLoading || !modelList) {
console.warn("Model manager: Missing required elements", {
modelTabContent: !!modelTabContent,
modelLoading: !!modelLoading,
modelList: !!modelList,
});
return;
}
if (!data.apiKey) {
console.warn("Model manager: No API key available");
return;
}
try {
console.log("Model manager: Initializing...");
// Show initial state
showModelInitialState(modelLoading, modelList);
} catch (error) {
console.error("Model manager initialization error:", error);
showModelError(modelLoading, modelList, "Error loading model manager");
}
}
// Show initial model state
function showModelInitialState(modelLoading, modelList) {
modelLoading.style.display = "none";
modelList.style.display = "block";
modelList.innerHTML = `
`;
}
// Show error message
function showModelError(modelLoading, modelList, errorMessage) {
modelLoading.style.display = "none";
modelList.style.display = "block";
modelList.innerHTML = `
⚠️
${errorMessage}
`;
}
// Show model sync dialog
window.showModelSyncDialog = function () {
// Create dialog overlay
const overlay = document.createElement("div");
overlay.id = "model-sync-dialog-overlay";
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
// Create dialog box
const dialog = document.createElement("div");
dialog.style.cssText = `
background: #2c2c2c;
border-radius: 12px;
padding: 0;
width: 800px;
max-width: 90vw;
max-height: 85vh;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
color: #ffffff;
display: flex;
flex-direction: column;
overflow: hidden;
`;
// Create dialog content
dialog.innerHTML = `
Model Sync
Sync your local models to the cloud
×
⏳
Loading local models...
Scanning model directories
0 models selected
Cancel
Sync Models
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// Close on overlay click
overlay.addEventListener("click", function (e) {
if (e.target === overlay) {
closeModelSyncDialog();
}
});
// If an upload is in progress, show progress view instead of selection list
if (
window.__modelSyncUploading &&
Array.isArray(window.__modelSyncUploadingModels)
) {
const syncBtnEl = document.getElementById("sync-models-btn");
if (syncBtnEl) syncBtnEl.style.display = "none";
showProgressView(window.__modelSyncUploadingModels);
} else {
// Load model data (placeholder for now)
loadModelSyncData();
}
};
// Close model sync dialog
window.closeModelSyncDialog = function () {
const overlay = document.getElementById("model-sync-dialog-overlay");
if (overlay) {
overlay.remove();
}
window.currentModelSyncData = null;
};
// Load model sync data from API
async function loadModelSyncData() {
try {
// Fetch real model data from your API
const response = await fetch("/comfyui-deploy/models");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const modelsData = await response.json();
// Process and filter the data
const processedModels = processModelsData(modelsData);
// Store data globally
window.currentModelSyncData = {
models: processedModels,
selectedModels: new Set(),
};
renderModelSyncContent(processedModels);
} catch (error) {
console.error("Error loading model sync data:", error);
renderModelSyncError("Failed to load local models");
}
}
// Process models data from API response
function processModelsData(modelsData) {
const processedModels = [];
let modelIndex = 0;
// Filter out unwanted categories
const skipCategories = ["custom_nodes", "configs", "download_model_base"];
Object.entries(modelsData).forEach(([modelType, modelInfo]) => {
if (skipCategories.includes(modelType)) {
return; // Skip these categories
}
const [paths, extensions, files] = modelInfo;
// Filter files based on accepted extensions
const filteredFiles = filterFilesByExtensions(files, extensions);
// Process each file
filteredFiles.forEach((file) => {
processedModels.push({
index: modelIndex++,
name: file,
type: modelType,
paths: paths,
extensions: extensions,
// Parse nested folder structure for display
displayPath: getDisplayPath(file),
isNested: file.includes("/"),
size: "Unknown", // We don't have size info from the API
});
});
});
return processedModels;
}
// Filter files by accepted extensions
function filterFilesByExtensions(files, extensions) {
if (!files || files.length === 0) return [];
if (!extensions || extensions.length === 0) return files;
// Handle special case for "folder" extension (like diffusers)
if (extensions.includes("folder")) {
return files;
}
return files.filter((file) => {
const fileExt = "." + file.split(".").pop().toLowerCase();
return extensions.some((ext) => ext.toLowerCase() === fileExt);
});
}
// Get display path for file tree
function getDisplayPath(filePath) {
if (!filePath.includes("/")) {
return filePath; // Root level file
}
const parts = filePath.split("/");
return parts.slice(0, -1).join("/"); // Return path without filename
}
// Render model sync content
function renderModelSyncContent(models) {
const content = document.getElementById("model-sync-content");
if (!content) return;
if (models.length === 0) {
content.innerHTML = `
📂
No models found
No model files detected in local directories
`;
return;
}
// Group models by type
const groupedModels = models.reduce((acc, model) => {
if (!acc[model.type]) acc[model.type] = [];
acc[model.type].push(model);
return acc;
}, {});
const typeColors = {
checkpoints: "#3498db",
controlnet: "#e74c3c",
loras: "#f39c12",
vae: "#9b59b6",
upscale_models: "#27ae60",
text_encoders: "#e67e22",
diffusion_models: "#8e44ad",
clip_vision: "#16a085",
style_models: "#c0392b",
embeddings: "#f1c40f",
diffusers: "#34495e",
vae_approx: "#95a5a6",
gligen: "#2ecc71",
hypernetworks: "#e74c3c",
photomaker: "#9b59b6",
classifiers: "#34495e",
model_patches: "#f39c12",
audio_encoders: "#e67e22",
};
let contentHTML = `
Local Models (${models.length})
Select All
Deselect All
`;
Object.entries(groupedModels).forEach(([type, typeModels]) => {
const color = typeColors[type] || "#666";
// Build file tree structure for this type
const fileTree = buildFileTree(typeModels);
contentHTML += `
${type.replace("_", " ")} (${typeModels.length})
${renderFileTree(fileTree)}
`;
});
content.innerHTML = contentHTML;
// Enable sync button if we have models
const syncBtn = document.getElementById("sync-models-btn");
if (syncBtn) {
syncBtn.disabled = false;
syncBtn.style.background =
"linear-gradient(135deg, #3498db 0%, #2980b9 100%)";
syncBtn.style.color = "white";
syncBtn.style.cursor = "pointer";
syncBtn.style.boxShadow = "0 2px 8px rgba(52, 152, 219, 0.3)";
}
}
// Build file tree structure
function buildFileTree(models) {
const tree = {};
models.forEach((model) => {
if (model.isNested) {
const pathParts = model.name.split("/");
const fileName = pathParts.pop();
const folderPath = pathParts.join("/");
if (!tree[folderPath]) {
tree[folderPath] = [];
}
tree[folderPath].push({
...model,
displayName: fileName,
});
} else {
// Root level files
if (!tree["_root"]) {
tree["_root"] = [];
}
tree["_root"].push({
...model,
displayName: model.name,
});
}
});
return tree;
}
// Render file tree
function renderFileTree(tree) {
let html = "";
// Render root files first
if (tree["_root"]) {
tree["_root"].forEach((model) => {
html += renderModelItem(model, false);
});
}
// Render folders
Object.entries(tree).forEach(([folderPath, files]) => {
if (folderPath === "_root") return;
const filesId = `files-${folderPath.replace(/[^a-zA-Z0-9]/g, "-")}`;
html += `
▶
📁
${folderPath}
(${files.length})
`;
files.forEach((model) => {
html += renderModelItem(model, true);
});
html += `
`;
});
return html;
}
// Render individual model item
function renderModelItem(model, isNested = false) {
const isSelected =
window.currentModelSyncData?.selectedModels.has(model.index) || false;
const indentStyle = isNested ? "padding-left: 24px;" : "";
const fileName = model.displayName || model.name;
// Try to show size by probing the first valid path quickly (non-blocking)
if (!model._sizeProbeRequested) {
model._sizeProbeRequested = true;
const basePath =
(model.paths || []).find((p) => p.includes("/models/")) ||
model.paths?.[0];
if (basePath) {
const absolutePath = `${basePath}/${model.name}`;
fetch(`/comfyui-deploy/fs/stat?path=${encodeURIComponent(absolutePath)}`)
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (j && j.size) {
const sizeEl = document.querySelector(`#size-list-${model.index}`);
if (sizeEl) sizeEl.textContent = ` — ${bytesToSize(j.size)}`;
}
})
.catch(() => {});
}
}
return `
📄
${fileName}
${
model.size && model.size !== "Unknown"
? `
${model.size}
`
: ""
}
`;
}
// Render error state
function renderModelSyncError(message) {
const content = document.getElementById("model-sync-content");
if (!content) return;
content.innerHTML = `
`;
}
// Toggle folder visibility
window.toggleFolder = function (filesId, folderElement) {
const filesContainer = document.getElementById(filesId);
const folderIcon = folderElement.querySelector(".folder-icon");
if (filesContainer.style.display === "none") {
filesContainer.style.display = "block";
folderIcon.textContent = "▼";
} else {
filesContainer.style.display = "none";
folderIcon.textContent = "▶";
}
};
// Toggle model selection
window.toggleModelSelection = function (index) {
if (!window.currentModelSyncData) return;
const selectedModels = window.currentModelSyncData.selectedModels;
if (selectedModels.has(index)) {
selectedModels.delete(index);
} else {
selectedModels.add(index);
}
updateModelSelectedCount();
};
// Select all models
window.selectAllModels = function () {
if (!window.currentModelSyncData) return;
const models = window.currentModelSyncData.models || [];
window.currentModelSyncData.selectedModels = new Set(
models.map((_, index) => index)
);
// Update checkboxes
models.forEach((_, index) => {
const checkbox = document.getElementById(`model-${index}`);
if (checkbox) checkbox.checked = true;
});
updateModelSelectedCount();
};
// Deselect all models
window.deselectAllModels = function () {
if (!window.currentModelSyncData) return;
window.currentModelSyncData.selectedModels.clear();
// Update checkboxes
const models = window.currentModelSyncData.models || [];
models.forEach((_, index) => {
const checkbox = document.getElementById(`model-${index}`);
if (checkbox) checkbox.checked = false;
});
updateModelSelectedCount();
};
// Update selected count display
function updateModelSelectedCount() {
if (!window.currentModelSyncData) return;
const selectedCount = window.currentModelSyncData.selectedModels.size;
const countElement = document.querySelector("#model-selected-count");
if (countElement) {
countElement.textContent = `${selectedCount} models selected`;
}
}
// Sync selected models (placeholder)
window.syncSelectedModels = async function () {
if (!window.currentModelSyncData) return;
if (window.__modelSyncUploading) return; // prevent re-entry during upload
const selectedModels = Array.from(
window.currentModelSyncData.selectedModels
).map((index) => window.currentModelSyncData.models[index]);
// Support all model types (files only). We'll map the target folder by type.
const modelsToUpload = selectedModels;
// Switch dialog to progress view
showProgressView(modelsToUpload);
// Hide Sync button during upload and set beforeunload warning
const syncBtnEl = document.getElementById("sync-models-btn");
if (syncBtnEl) syncBtnEl.style.display = "none";
const closeBtnEl = document.getElementById("close-models-btn");
if (closeBtnEl) {
closeBtnEl.disabled = false;
closeBtnEl.textContent = "Cancel";
closeBtnEl.onclick = () => cancelActiveUpload();
}
window.__modelSyncUploading = true;
window.__modelSyncUploadingModels = modelsToUpload;
window.__modelSyncCancelRequested = false;
window.__modelSyncCurrent = null;
const beforeUnloadHandler = (e) => {
e.preventDefault();
e.returnValue = "";
return "";
};
window.addEventListener("beforeunload", beforeUnloadHandler);
// Start sequential upload
const data =
(typeof window.comfyDeployGetModelData === "function"
? window.comfyDeployGetModelData()
: {}) || {};
const apiUrl = data.apiUrl || "https://api.comfydeploy.com";
const authHeader = { Authorization: `Bearer ${data.apiKey}` };
let successCount = 0;
let failCount = 0;
for (let i = 0; i < modelsToUpload.length; i++) {
if (window.__modelSyncCancelRequested) break;
const model = modelsToUpload[i];
try {
setProgress(i, 0, "Preparing...");
// Resolve absolute path: choose a base path that contains /models/
const basePath =
(model.paths || []).find((p) => p.includes("/models/")) ||
model.paths?.[0];
if (!basePath) throw new Error("No valid base path found for model");
const absolutePath = `${basePath}/${model.name}`;
// Stat file size
const statResp = await fetch(
`/comfyui-deploy/fs/stat?path=${encodeURIComponent(absolutePath)}`
);
if (!statResp.ok) throw new Error(`stat failed: ${statResp.status}`);
const { size } = await statResp.json();
if (!size || size <= 0) throw new Error("Invalid file size");
setSize(i, size);
// Multipart init
const filenameOnly = model.name.includes("/")
? model.name.split("/").pop()
: model.name;
const initResp = await fetch(
`/comfyui-deploy/volume/file/initiate-multipart-upload`,
{
method: "POST",
headers: { ...authHeader, "Content-Type": "application/json" },
body: JSON.stringify({
filename: filenameOnly,
contentType: "application/octet-stream",
size,
api_url: apiUrl,
}),
}
);
const initData = await initResp.json();
if (!initResp.ok)
throw new Error(initData?.detail || JSON.stringify(initData));
const uploadId = initData.uploadId;
const key = initData.key;
window.__modelSyncCurrent = { index: i, uploadId, key };
const partSize = initData.partSize || 50 * 1024 * 1024;
const totalParts = Math.ceil(size / partSize);
const parts = [];
let uploaded = 0;
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
if (window.__modelSyncCancelRequested) break;
const start = (partNumber - 1) * partSize;
const end = Math.min(start + partSize, size);
// Get part upload URL via proxy
const urlResp = await fetch(
`/comfyui-deploy/volume/file/generate-part-upload-url`,
{
method: "POST",
headers: { ...authHeader, "Content-Type": "application/json" },
body: JSON.stringify({
uploadId,
key,
partNumber,
api_url: apiUrl,
}),
}
);
const urlData = await urlResp.json();
if (!urlResp.ok)
throw new Error(urlData?.detail || JSON.stringify(urlData));
// Upload this part from machine
const upResp = await fetch(
`/comfyui-deploy/volume/file/upload-part-from-path`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filePath: absolutePath,
uploadUrl: urlData.uploadUrl,
start,
end,
}),
}
);
const upData = await upResp.json();
if (!upResp.ok)
throw new Error(upData?.error || JSON.stringify(upData));
parts.push({ partNumber, eTag: upData.eTag });
uploaded = end;
const pct = Math.round((uploaded / size) * 100);
setProgress(i, pct, `${bytesToSize(uploaded)} / ${bytesToSize(size)}`);
}
if (window.__modelSyncCancelRequested) {
try {
await fetch(`/comfyui-deploy/volume/file/abort-multipart-upload`, {
method: "POST",
headers: { ...authHeader, "Content-Type": "application/json" },
body: JSON.stringify({ uploadId, key, api_url: apiUrl }),
});
} catch (_) {}
setStatus(i, "Cancelled", true);
break;
}
// Complete multipart
const compResp = await fetch(
`/comfyui-deploy/volume/file/complete-multipart-upload`,
{
method: "POST",
headers: { ...authHeader, "Content-Type": "application/json" },
body: JSON.stringify({ uploadId, key, parts, api_url: apiUrl }),
}
);
const compData = await compResp.json();
if (!compResp.ok)
throw new Error(compData?.detail || JSON.stringify(compData));
// Register model in backend (source=link from temp upload)
const rel = model.name;
const slash = rel.lastIndexOf("/");
const fileName = slash >= 0 ? rel.slice(slash + 1) : rel;
const subDir = slash >= 0 ? rel.slice(0, slash) : "";
const folderRoot = model.type;
const folderPath = `/${folderRoot}${subDir ? `/${subDir}` : ""}`;
const addResp = await fetch(`/comfyui-deploy/volume/model`, {
method: "POST",
headers: { ...authHeader, "Content-Type": "application/json" },
body: JSON.stringify({
source: "link",
folderPath,
filename: fileName,
downloadLink: `s3://${key}`,
isTemporaryUpload: true,
s3ObjectKey: key,
api_url: apiUrl,
}),
});
const addData = await addResp.json();
if (!addResp.ok)
throw new Error(addData?.detail || JSON.stringify(addData));
setProgress(i, 100, "Uploaded");
setStatus(i, "Success", false);
successCount++;
} catch (err) {
console.error("Upload failed for", model, err);
setStatus(i, (err && err.message) || "Failed", true);
failCount++;
// Try best-effort abort if we had an initialized upload; ignored if not available
// (No state kept here to simplify; continuing with next file.)
}
}
// Show summary toast
if (window.app?.extensionManager?.toast) {
window.app.extensionManager.toast.add({
severity: window.__modelSyncCancelRequested
? "warn"
: failCount > 0
? "warn"
: "success",
summary: window.__modelSyncCancelRequested
? "Model sync cancelled"
: "Model sync completed",
detail: window.__modelSyncCancelRequested
? `Cancelled. ${successCount} completed, ${failCount} failed`
: `${successCount} succeeded, ${failCount} failed`,
life: 4000,
});
}
// Cleanup UI state
window.removeEventListener("beforeunload", beforeUnloadHandler);
window.__modelSyncUploading = false;
window.__modelSyncUploadingModels = null;
window.__modelSyncCancelRequested = false;
window.__modelSyncCurrent = null;
// Replace Sync with Done button
if (syncBtnEl) {
syncBtnEl.textContent = "Done";
syncBtnEl.onclick = () => closeModelSyncDialog();
syncBtnEl.style.display = "";
syncBtnEl.style.cursor = "pointer";
syncBtnEl.disabled = false;
}
if (closeBtnEl) {
closeBtnEl.disabled = false;
closeBtnEl.textContent = "Close";
closeBtnEl.onclick = () => closeModelSyncDialog();
}
};
// Export the main function
export { initializeModelManager };
// -------------------- Progress UI helpers --------------------
function bytesToSize(bytes) {
if (!bytes && bytes !== 0) return "-";
const sizes = ["B", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 B";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
}
// Cancel current upload gracefully
async function cancelActiveUpload() {
if (!window.__modelSyncUploading) return;
window.__modelSyncCancelRequested = true;
// Visual feedback on Cancel button
const closeBtnEl = document.getElementById("close-models-btn");
if (closeBtnEl) {
closeBtnEl.disabled = true;
closeBtnEl.textContent = "Cancelling...";
closeBtnEl.style.opacity = "0.7";
closeBtnEl.style.cursor = "not-allowed";
}
// Mark current item as cancelling, if known
const curr = window.__modelSyncCurrent;
if (curr && typeof curr.index === "number") {
const status = document.getElementById(`status-${curr.index}`);
if (status) {
status.textContent = "Cancelling...";
status.style.color = "#e67e22";
}
}
}
function showProgressView(models) {
const content = document.getElementById("model-sync-content");
if (!content) return;
let html = `
Uploading ${models.length} item(s) sequentially
Do not close this window or tab while uploads are in progress.
`;
content.innerHTML = html;
const list = content.querySelector("#upload-list");
models.forEach((m, idx) => {
const displayName = m.name;
const row = document.createElement("div");
row.id = `upload-row-${idx}`;
row.style.cssText =
"border: 1px solid #333; border-radius: 6px; background: #252525; padding: 10px;";
row.innerHTML = `
0%
`;
list.appendChild(row);
});
}
function setProgress(idx, pct, detailText) {
const bar = document.getElementById(`bar-${idx}`);
const detail = document.getElementById(`detail-${idx}`);
const status = document.getElementById(`status-${idx}`);
if (bar) bar.style.width = `${Math.max(0, Math.min(100, pct))}%`;
if (detail) detail.textContent = `${pct}% • ${detailText || ""}`;
if (status) status.textContent = pct >= 100 ? "Uploaded" : "Uploading";
}
function setStatus(idx, text, isError) {
const status = document.getElementById(`status-${idx}`);
if (status) {
status.textContent = text;
status.style.color = isError ? "#e74c3c" : "#27ae60";
}
}
function setSize(idx, size) {
const el = document.getElementById(`size-${idx}`);
if (el) {
el.textContent = `— ${bytesToSize(size)}`;
}
}