feat: enhance platform independence and integrate ComfyUI lifecycle management

- Added cross-platform process termination in PlatformUtils.
- Improved PathResolver with robust subfolder normalization.
- Integrated ComfyUI lifecycle service (Start/Stop/Restart) using ProcessHandle.
- Updated ComfyUI Bridge with improved UI refresh and sync logic.
- Added comprehensive integration and diagnostic tests.
- Updated documentation with process control details.
This commit is contained in:
Thomas
2026-05-14 16:48:43 +02:00
parent 2dd823792c
commit 2a10ef9d80
34 changed files with 2587 additions and 269 deletions
+32
View File
@@ -30,6 +30,38 @@ The "Bridge" is a small extension for ComfyUI that adds a "Send to Downloader" b
2. Rename it to `comfyui-model-downloader`.
3. Restart ComfyUI.
## ⚙️ ComfyUI Process Control (Dynamic Discovery)
The application can manage your ComfyUI server life cycle (Start/Stop/Restart) completely **autonomously**. It dynamically determines the correct paths and commands for **Windows, Linux, and macOS**.
### How Discovery Works
The app scans your system to find ComfyUI without manual input:
1. **System Variables:** Evaluates `COMFYUI_PATH`, `COMFYUI_HOME`, or `PINOKIO_BIN`.
2. **Context Recognition:** Detects if the downloader is running within or near a ComfyUI directory.
3. **Environment Heuristics:** Automatically locates `.venv` or `python` environments and standard installation paths (like `%USERPROFILE%\ComfyUI` or Pinokio folders).
4. **Automatic Port Sync:** Adjusts the launch command's port based on your configured Server URL.
### Reference Examples (Auto-Detected)
While usually not required, here is what the app typically discovers on different systems:
#### 🪟 Windows (Pinokio)
* **Python:** `...\comfyuidata\.venv\Scripts\python.exe`
* **Main:** `...\ComfyUI\resources\ComfyUI\main.py`
* **Default Port:** `8000`
#### 🐧 Linux (Manual Venv)
* **Python:** `./venv/bin/python3`
* **Main:** `./main.py`
* **Default Port:** `8188`
#### 🍎 macOS (Apple Silicon)
* **Python:** `python3` (System)
* **Main:** `/Users/name/ComfyUI/main.py`
*Note: You can manually override these in **🔌 Comfy Control -> Advanced Settings** if you have a non-standard setup.*
---
## 🚀 Getting Started
Choose one of the following three ways to install and run the application:
+28 -22
View File
@@ -2,42 +2,48 @@
import server
import folder_paths
import os
import asyncio
import importlib
from aiohttp import web
print("\033[95m[Model-Downloader] UI Extension Bridge active\033[0m")
print("\033[95m[CMFD] UI Extension Bridge active\033[0m")
USER_MODEL_PATH = r"C:\AI\comfyuidata\models"
if os.path.exists(USER_MODEL_PATH):
try:
pass
except: pass
@server.PromptServer.instance.routes.post("/kippster/model-downloaded")
async def model_downloaded(request):
print("\033[92m🔥 [Model-Downloader] PING ERHALTEN! Bereite UI-Refresh vor...\033[0m")
@server.PromptServer.instance.routes.post("/cmfd/refresh-models")
async def refresh_models(request):
data = await request.json() if request.has_body else {}
force_reload = data.get("force_reload", False)
print(f"\033[92m🔥 [CMFD] Refresh signal received (Force Reload: {force_reload})\033[0m")
# Wait for OS
await asyncio.sleep(1)
try:
# 1. Wir löschen nur die Caches, machen aber KEINEN eigenen Scan mehr!
# Das verhindert, dass wir einen noch unfertigen Windows-Ordner scannen und den Fehler cachen.
# Invalidate
if hasattr(folder_paths, "invalidate_all_cached_folders"):
folder_paths.invalidate_all_cached_folders()
if hasattr(folder_paths, "filename_list_cache"):
folder_paths.filename_list_cache.clear()
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
folder_paths.cache_helper.clear()
if hasattr(folder_paths, "cache") and hasattr(folder_paths.cache, "clear"):
folder_paths.cache.clear()
try:
importlib.reload(folder_paths)
except Exception:
pass
# 2. Object Info zurücksetzen (zwingt ComfyUI beim nächsten UI-Klick zum Neuladen)
# Object Info Reset
server.PromptServer.instance.object_info = None
except Exception as e:
print(f"[Model-Downloader] Fehler beim Cache-Reset: {e}")
print(f"[CMFD] Error: {e}")
# 3. Signal an das Frontend senden
server.PromptServer.instance.send_sync("kippster-refresh-ui", {"message": "ready"})
# One-way broadcast to frontend
server.PromptServer.instance.send_sync("cmfd-refresh-ui", {
"status": "ok",
"force_reload": force_reload
})
return web.json_response({"status": "success"})
return web.json_response({"status": "ok"})
WEB_DIRECTORY = "web"
NODE_CLASS_MAPPINGS = {}
+5
View File
@@ -86,6 +86,11 @@
<artifactId>flatlaf-extras</artifactId>
<version>3.7.1</version>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>2.4</version>
</dependency>
</dependencies>
<build>
+403 -89
View File
@@ -11,6 +11,7 @@ import de.tki.comfymodels.service.impl.GeminiAIService;
import de.tki.comfymodels.service.impl.ModelListService;
import de.tki.comfymodels.service.impl.ModelHashRegistry;
import de.tki.comfymodels.ui.DotGridPanel;
import de.tki.comfymodels.ui.ComfyWebPanel;
import com.formdev.flatlaf.FlatDarkLaf;
import com.formdev.flatlaf.FlatLightLaf;
import com.formdev.flatlaf.FlatLaf;
@@ -55,6 +56,9 @@ public class Main extends JFrame {
private final IModelValidator modelValidator;
private final de.tki.comfymodels.service.impl.RestBridgeService restBridge;
private final de.tki.comfymodels.service.impl.ArchiveService archiveService;
private final de.tki.comfymodels.service.IComfyLifecycleService lifecycleService;
private final de.tki.comfymodels.service.impl.ComfyDiagnosticService diagnosticService;
private de.tki.comfymodels.ui.ComfyWebPanel comfyWebPanel;
@Autowired
private ConfigService configService;
@@ -76,8 +80,10 @@ public class Main extends JFrame {
private JCheckBox backgroundCheck;
private JCheckBox shutdownCheck;
private JCheckBox restartCheck;
private JCheckBox darkCheck;
private JLabel activeAiModelLabel;
private JLabel lifecycleStatusLabel;
private JTextArea jsonInputArea;
private DefaultTableModel tableModel;
private JLabel statusLabel;
@@ -89,7 +95,9 @@ public class Main extends JFrame {
public Main(IModelAnalyzer analyzer, IDownloadManager downloadManager,
IWorkflowService workflowService, IModelSearchService searchService,
IModelValidator modelValidator, de.tki.comfymodels.service.impl.RestBridgeService restBridge,
de.tki.comfymodels.service.impl.ArchiveService archiveService) {
de.tki.comfymodels.service.impl.ArchiveService archiveService,
de.tki.comfymodels.service.IComfyLifecycleService lifecycleService,
de.tki.comfymodels.service.impl.ComfyDiagnosticService diagnosticService) {
this.analyzer = analyzer;
this.downloadManager = downloadManager;
this.workflowService = workflowService;
@@ -97,6 +105,8 @@ public class Main extends JFrame {
this.modelValidator = modelValidator;
this.restBridge = restBridge;
this.archiveService = archiveService;
this.lifecycleService = lifecycleService;
this.diagnosticService = diagnosticService;
}
public void launch(String[] args) {
@@ -148,6 +158,7 @@ public class Main extends JFrame {
if (configService.isBackgroundModeEnabled()) {
setVisible(false);
} else {
lifecycleService.stop();
downloadManager.stop();
System.exit(0);
}
@@ -282,10 +293,7 @@ public class Main extends JFrame {
MenuItem showItem = new MenuItem("Show UI");
showItem.addActionListener(e -> setVisible(true));
MenuItem exitItem = new MenuItem("Exit");
exitItem.addActionListener(e -> {
downloadManager.stop();
System.exit(0);
});
exitItem.addActionListener(e -> performAppExit());
popup.add(showItem);
popup.addSeparator();
@@ -350,6 +358,7 @@ public class Main extends JFrame {
try {
configService.unlock(pass);
configService.autoDiscoverPaths(); // Ensure paths are discovered after unlock
restBridge.setApiToken(configService.getApiToken());
syncBridgeFiles(); // NEW: Automatically sync code & token on every unlock
@@ -366,9 +375,110 @@ public class Main extends JFrame {
}
}
private void showLifecycleDialog() {
JDialog dialog = new JDialog(this, "ComfyUI Control", true);
dialog.setLayout(new BorderLayout());
dialog.setSize(880, 420);
dialog.setLocationRelativeTo(this);
JPanel content = new JPanel(new GridBagLayout());
content.setBorder(BorderFactory.createEmptyBorder(25, 25, 25, 25));
GridBagConstraints gbc = new GridBagConstraints();
gbc.fill = GridBagConstraints.HORIZONTAL;
gbc.gridx = 0; gbc.gridy = 0; gbc.weightx = 1.0;
JLabel titleLabel = new JLabel("ComfyUI Server Status");
titleLabel.setFont(new Font("SansSerif", Font.BOLD, 18));
titleLabel.setHorizontalAlignment(SwingConstants.CENTER);
content.add(titleLabel, gbc);
gbc.gridy++;
gbc.insets = new Insets(25, 0, 25, 0);
lifecycleStatusLabel = new JLabel(lifecycleService.getStatus());
lifecycleStatusLabel.setFont(new Font("SansSerif", Font.BOLD, 16));
lifecycleStatusLabel.setHorizontalAlignment(SwingConstants.CENTER);
content.add(lifecycleStatusLabel, gbc);
gbc.gridy++;
gbc.insets = new Insets(10, 0, 20, 0);
JPanel controlButtons = new JPanel(new FlowLayout(FlowLayout.CENTER, 15, 10));
JButton startBtn = new JButton("▶ Start");
startBtn.setPreferredSize(new Dimension(110, 40));
startBtn.addActionListener(e -> {
configService.autoDiscoverPaths();
startComfyAndReload();
});
JButton stopBtn = new JButton("⏹ Stop");
stopBtn.setPreferredSize(new Dimension(110, 40));
stopBtn.addActionListener(e -> lifecycleService.stop());
JButton restartBtn = new JButton("🔄 Restart");
restartBtn.setPreferredSize(new Dimension(110, 40));
restartBtn.addActionListener(e -> {
configService.autoDiscoverPaths();
new Thread(() -> {
lifecycleService.stop();
try { Thread.sleep(2000); } catch (InterruptedException ignored) {}
startComfyAndReload();
}).start();
});
JButton openBrowserBtn = new JButton("🌐 Open Interface");
openBrowserBtn.setPreferredSize(new Dimension(150, 40));
openBrowserBtn.addActionListener(e -> {
try {
Desktop.getDesktop().browse(new java.net.URI(configService.getComfyUIUrl()));
} catch (Exception ex) {
JOptionPane.showMessageDialog(dialog, "Could not open browser: " + ex.getMessage());
}
});
controlButtons.add(startBtn);
controlButtons.add(stopBtn);
controlButtons.add(restartBtn);
controlButtons.add(openBrowserBtn);
content.add(controlButtons, gbc);
// Timer to update status
Timer timer = new Timer(1000, e -> {
lifecycleStatusLabel.setText(lifecycleService.getStatus());
if (lifecycleService.isRunning()) {
lifecycleStatusLabel.setForeground(new Color(0, 150, 0));
startBtn.setEnabled(false);
stopBtn.setEnabled(true);
} else {
lifecycleStatusLabel.setForeground(Color.RED);
startBtn.setEnabled(true);
stopBtn.setEnabled(false);
}
});
timer.start();
dialog.addWindowListener(new java.awt.event.WindowAdapter() {
@Override public void windowClosing(java.awt.event.WindowEvent e) { timer.stop(); }
});
gbc.gridy++;
gbc.weighty = 1.0;
content.add(new JPanel(), gbc);
JPanel bottom = new JPanel(new FlowLayout(FlowLayout.RIGHT));
bottom.setBorder(BorderFactory.createEmptyBorder(0, 10, 10, 10));
JButton close = new JButton("Close");
close.addActionListener(e -> dialog.dispose());
bottom.add(close);
dialog.add(content, BorderLayout.CENTER);
dialog.add(bottom, BorderLayout.SOUTH);
dialog.setVisible(true);
}
private void loadSettingsIntoUI() {
if (backgroundCheck != null) backgroundCheck.setSelected(configService.isBackgroundModeEnabled());
if (shutdownCheck != null) shutdownCheck.setSelected(configService.isShutdownAfterDownloadEnabled());
if (restartCheck != null) restartCheck.setSelected(configService.isRestartAfterDownloadEnabled());
if (darkCheck != null) darkCheck.setSelected(configService.isDarkMode());
}
@@ -382,23 +492,42 @@ public class Main extends JFrame {
private void initUI() {
loadIcon();
setTitle("ComfyUIModel-Downloader");
setSize(1450, 900);
setSize(1450, 950);
setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
getRootPane().putClientProperty("flatlaf.useWindowDecorations", true);
JPanel mainContainer = new DotGridPanel(new BorderLayout(10, 10));
mainContainer.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
setContentPane(mainContainer);
// --- PREPARE TABBED INTERFACE ---
JTabbedPane mainTabs = new JTabbedPane();
mainTabs.setFont(new Font("SansSerif", Font.BOLD, 13));
comfyWebPanel = new de.tki.comfymodels.ui.ComfyWebPanel(configService.getComfyUIUrl());
// --- NEW COMPACT TOOLBAR ---
// --- TAB 1: Model Manager (Original UI) ---
JPanel managerPanel = new JPanel(new BorderLayout(10, 10));
managerPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
// --- TOOLBAR ---
JPanel toolbar = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 5));
toolbar.setBorder(BorderFactory.createEtchedBorder());
JButton settingsBtn = new JButton("⚙ Settings...");
settingsBtn.addActionListener(e -> showSettingsMenu(settingsBtn));
JButton lifecycleBtn = new JButton("🔌 Comfy Control...");
lifecycleBtn.addActionListener(e -> showLifecycleDialog());
JButton startServerBtn = new JButton("▶ Start ComfyUI");
startServerBtn.setToolTipText("Starts the ComfyUI server in the background");
startServerBtn.addActionListener(e -> {
if (lifecycleService.isRunning()) {
JOptionPane.showMessageDialog(this, "ComfyUI is already running.", "Info", JOptionPane.INFORMATION_MESSAGE);
} else {
startComfyAndReload();
updateComfyTabVisibility(mainTabs);
}
});
JButton verifyBtn = new JButton("🔍 Fast Verify");
verifyBtn.addActionListener(e -> verifyLocalModels(false));
@@ -419,10 +548,51 @@ public class Main extends JFrame {
JButton helpBtn = new JButton("ℹ Help");
helpBtn.addActionListener(e -> showHelpDialog());
JButton diagnosticBtn = new JButton("🩺 Diagnostic");
diagnosticBtn.addActionListener(e -> {
if (modelsToDownload == null || modelsToDownload.isEmpty()) {
JOptionPane.showMessageDialog(this, "Load a workflow first to perform a diagnostic check.");
return;
}
List<String> missing = diagnosticService.getMissingModels(modelsToDownload);
if (missing.isEmpty()) {
JOptionPane.showMessageDialog(this, "✅ All workflow models are visible to ComfyUI!", "Diagnostic Passed", JOptionPane.INFORMATION_MESSAGE);
} else {
String list = String.join("\n- ", missing);
List<Object> optionsList = new ArrayList<>();
optionsList.add("🚀 Restart ComfyUI (Full Service Restart)");
boolean canStart = !lifecycleService.isRunning() &&
configService.getComfyLaunchCommand() != null &&
!configService.getComfyLaunchCommand().trim().isEmpty();
if (canStart) {
optionsList.add("▶ Start ComfyUI");
}
optionsList.add("Ignore");
Object[] options = optionsList.toArray();
int choice = JOptionPane.showOptionDialog(this,
"❌ ComfyUI still reports these models as missing:\n- " + list + "\n\n" +
"A restart is required for ComfyUI to detect new models.",
"Diagnostic Failed", JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE, null, options, options[0]);
if (choice == 0) {
performFullServiceRestart();
} else if (canStart && choice == 1) {
lifecycleService.start();
JOptionPane.showMessageDialog(this, "ComfyUI is being started. Please wait a few moments for it to initialize.", "Starting Service", JOptionPane.INFORMATION_MESSAGE);
}
}
});
backgroundCheck = new JCheckBox("Stay in Background");
backgroundCheck.addActionListener(e -> configService.setBackgroundModeEnabled(backgroundCheck.isSelected()));
shutdownCheck = new JCheckBox("Shutdown after Queue");
shutdownCheck.addActionListener(e -> configService.setShutdownAfterDownloadEnabled(shutdownCheck.isSelected()));
restartCheck = new JCheckBox("Full Restart after Queue");
restartCheck.addActionListener(e -> configService.setRestartAfterDownloadEnabled(restartCheck.isSelected()));
darkCheck = new JCheckBox("Dark Mode");
darkCheck.addActionListener(e -> {
boolean isDark = darkCheck.isSelected();
@@ -435,6 +605,7 @@ public class Main extends JFrame {
});
toolbar.add(settingsBtn);
toolbar.add(lifecycleBtn);
toolbar.add(new JSeparator(JSeparator.VERTICAL));
toolbar.add(verifyBtn);
toolbar.add(optimizeBtn);
@@ -442,6 +613,7 @@ public class Main extends JFrame {
toolbar.add(new JSeparator(JSeparator.VERTICAL));
toolbar.add(backgroundCheck);
toolbar.add(shutdownCheck);
toolbar.add(restartCheck);
toolbar.add(darkCheck);
JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
@@ -504,13 +676,15 @@ public class Main extends JFrame {
splitPane.setTopComponent(jsonPanel);
splitPane.setBottomComponent(tablePanel);
splitPane.setDividerLocation(300);
splitPane.setDividerLocation(350);
JPanel bottomPanel = new JPanel(new BorderLayout());
statusLabel = new JLabel("Ready");
statusLabel.setName("statusLabel");
JPanel progressPanel = new JPanel(new GridLayout(2, 1));
activeAiModelLabel = new JLabel("Active AI: Loading...");
activeAiModelLabel.setName("activeAiModelLabel");
activeAiModelLabel.setFont(new Font("SansSerif", Font.ITALIC, 11));
activeAiModelLabel.setForeground(Color.GRAY);
progressPanel.add(statusLabel);
@@ -518,14 +692,17 @@ public class Main extends JFrame {
JPanel actionButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT));
downloadButton = new JButton("Start Queue");
downloadButton.setName("downloadButton");
downloadButton.setEnabled(false);
downloadButton.addActionListener(e -> startDownloadQueue());
pauseButton = new JButton("Pause");
pauseButton.setName("pauseButton");
pauseButton.addActionListener(e -> {
downloadManager.togglePause();
pauseButton.setText(downloadManager.isPaused() ? "Resume" : "Pause");
});
stopButton = new JButton("Stop");
stopButton.setName("stopButton");
stopButton.addActionListener(e -> downloadManager.stop());
actionButtons.add(downloadButton);
actionButtons.add(pauseButton);
@@ -534,9 +711,50 @@ public class Main extends JFrame {
bottomPanel.add(progressPanel, BorderLayout.CENTER);
bottomPanel.add(actionButtons, BorderLayout.EAST);
mainContainer.add(toolbar, BorderLayout.NORTH);
mainContainer.add(splitPane, BorderLayout.CENTER);
mainContainer.add(bottomPanel, BorderLayout.SOUTH);
managerPanel.add(toolbar, BorderLayout.NORTH);
managerPanel.add(splitPane, BorderLayout.CENTER);
managerPanel.add(bottomPanel, BorderLayout.SOUTH);
setContentPane(managerPanel);
}
private void startComfyAndReload() {
new Thread(() -> {
lifecycleService.start();
// Wait for health
for (int i = 0; i < 30; i++) {
if (lifecycleService.isHealthy()) {
if (comfyWebPanel != null) {
comfyWebPanel.reload();
}
return;
}
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
}
}).start();
}
private void updateComfyTabVisibility(JTabbedPane tabs) {
boolean isRunning = lifecycleService.isRunning();
boolean isHealthy = lifecycleService.isHealthy();
// Only show if actually running or healthy
boolean shouldShow = isRunning || isHealthy;
int comfyTabIndex = tabs.indexOfComponent(comfyWebPanel);
if (shouldShow && comfyTabIndex == -1) {
System.out.println("✅ [UI] Showing ComfyUI Tab (Running=" + isRunning + ", Healthy=" + isHealthy + ")");
tabs.addTab("🎨 ComfyUI Interface", comfyWebPanel);
comfyWebPanel.loadUrl(configService.getComfyUIUrl()); // Force reload when re-added
tabs.revalidate();
tabs.repaint();
} else if (!shouldShow && comfyTabIndex != -1) {
System.out.println("🚫 [UI] Hiding ComfyUI Tab (Running=" + isRunning + ", Healthy=" + isHealthy + ")");
tabs.removeTabAt(comfyTabIndex);
tabs.revalidate();
tabs.repaint();
}
}
private void showSettingsMenu(JButton parent) {
@@ -555,10 +773,7 @@ public class Main extends JFrame {
helpItem.addActionListener(e -> showHelpDialog());
JMenuItem exitItem = new JMenuItem("❌ Exit Application");
exitItem.addActionListener(e -> {
downloadManager.stop();
System.exit(0);
});
exitItem.addActionListener(e -> performAppExit());
menu.add(pathsItem);
menu.add(apiItem);
@@ -572,10 +787,16 @@ public class Main extends JFrame {
menu.show(parent, 0, parent.getHeight());
}
private void performAppExit() {
lifecycleService.stop();
downloadManager.stop();
System.exit(0);
}
private void showPathsDialog() {
JDialog dialog = new JDialog(this, "Directory Settings", true);
dialog.setLayout(new BorderLayout());
dialog.setSize(600, 300);
dialog.setSize(600, 480);
dialog.setLocationRelativeTo(this);
JPanel panel = new JPanel(new GridBagLayout());
@@ -585,7 +806,7 @@ public class Main extends JFrame {
gbc.gridx = 0; gbc.gridy = 0; gbc.weightx = 1.0;
gbc.insets = new Insets(0, 0, 5, 0);
panel.add(new JLabel("ComfyUI Models Path:"), gbc);
panel.add(new JLabel("ComfyUI Models Path (Target for downloads):"), gbc);
gbc.gridy++;
JPanel row1 = new JPanel(new BorderLayout(5, 0));
@@ -604,7 +825,7 @@ public class Main extends JFrame {
gbc.gridy++;
gbc.insets = new Insets(10, 0, 5, 0);
panel.add(new JLabel("Archive Path:"), gbc);
panel.add(new JLabel("Archive Path (Offload storage):"), gbc);
gbc.gridy++;
gbc.insets = new Insets(0, 0, 5, 0);
@@ -622,6 +843,55 @@ public class Main extends JFrame {
row2.add(browse2, BorderLayout.EAST);
panel.add(row2, gbc);
gbc.gridy++;
gbc.insets = new Insets(10, 0, 5, 0);
panel.add(new JLabel("ComfyUI Main Directory (contains main.py):"), gbc);
gbc.gridy++;
gbc.insets = new Insets(0, 0, 5, 0);
JPanel row3 = new JPanel(new BorderLayout(5, 0));
JTextField field3 = new JTextField(configService.getComfyUIPath());
JButton browse3 = new JButton("Browse...");
row3.add(field3, BorderLayout.CENTER);
row3.add(browse3, BorderLayout.EAST);
panel.add(row3, gbc);
gbc.gridy++;
gbc.insets = new Insets(10, 0, 5, 0);
panel.add(new JLabel("Python Executable Path:"), gbc);
gbc.gridy++;
gbc.insets = new Insets(0, 0, 5, 0);
JPanel row4 = new JPanel(new BorderLayout(5, 0));
JTextField field4 = new JTextField(configService.getPythonPath());
JButton browse4 = new JButton("Browse...");
row4.add(field4, BorderLayout.CENTER);
row4.add(browse4, BorderLayout.EAST);
panel.add(row4, gbc);
browse3.addActionListener(e -> {
JFileChooser chooser = new JFileChooser();
chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
if (chooser.showOpenDialog(dialog) == JFileChooser.APPROVE_OPTION) {
String path = chooser.getSelectedFile().getAbsolutePath();
field3.setText(path);
// Auto-fill Python Path if possible
String discoveredPython = configService.discoverPython(path);
if (discoveredPython != null && !discoveredPython.equals("python") && !discoveredPython.equals("python3")) {
field4.setText(discoveredPython);
}
}
});
browse4.addActionListener(e -> {
JFileChooser chooser = new JFileChooser();
chooser.setFileSelectionMode(JFileChooser.FILES_ONLY);
if (chooser.showOpenDialog(dialog) == JFileChooser.APPROVE_OPTION) {
field4.setText(chooser.getSelectedFile().getAbsolutePath());
}
});
gbc.gridy++;
gbc.weighty = 1.0;
panel.add(new JPanel(), gbc);
@@ -633,6 +903,13 @@ public class Main extends JFrame {
save.addActionListener(e -> {
configService.setModelsPath(field1.getText().trim());
configService.setArchivePath(field2.getText().trim());
configService.setComfyUIPath(field3.getText().trim());
configService.setPythonPath(field4.getText().trim());
// Re-generate launch command with new paths
configService.setComfyLaunchCommand("");
configService.autoDiscoverPaths();
statusLabel.setText("Settings updated.");
analyzeJsonContent();
dialog.dispose();
@@ -713,7 +990,7 @@ public class Main extends JFrame {
for (int i = 0; i < rowCount; i++) {
if (selected[i]) {
String currentStatus = (String) tableModel.getValueAt(i, 7);
if ("📦 Found in Archive".equals(currentStatus)) {
if ("📦 Archived".equals(currentStatus)) {
final int idx = i;
ModelInfo info = modelsToDownload.get(idx);
String folder = info.getSave_path() != null ? info.getSave_path() : (info.getType() != null ? info.getType() : de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName());
@@ -748,9 +1025,6 @@ public class Main extends JFrame {
}
}
// After all restores, notify ComfyUI
downloadManager.notifyComfyUI();
// After restores, start the actual download manager for the remaining models
SwingUtilities.invokeLater(() -> {
// Update selected array (some might have been unselected by restore)
@@ -769,7 +1043,8 @@ public class Main extends JFrame {
() -> SwingUtilities.invokeLater(() -> {
downloadButton.setEnabled(true);
statusLabel.setText("Queue finished.");
if (configService.isShutdownAfterDownloadEnabled()) performSystemShutdown();
triggerPostOperationActions();
})
);
});
@@ -1016,24 +1291,41 @@ public class Main extends JFrame {
String normalizedFolder = archiveService.normalizeFolder(folder);
// 1. Primary path check (Standard Models Path)
Path local = "root".equals(normalizedFolder) ? Paths.get(base, info.getName()) : Paths.get(base, normalizedFolder, info.getName());
Path archivedPath = "root".equals(normalizedFolder) ? Paths.get(archive, info.getName()) : Paths.get(archive, normalizedFolder, info.getName());
boolean exists = Files.exists(local) && Files.isRegularFile(local);
boolean inArchive = Files.exists(archivedPath) && Files.isRegularFile(archivedPath);
boolean sizeMismatch = false;
// Initial size check for primary location
if (exists && info.getByteSize() > 0) {
try {
if (Files.size(local) != info.getByteSize()) {
sizeMismatch = true;
exists = false;
}
} catch (IOException e) { exists = false; }
// 2. Primary archive check (Standard Archive Path)
boolean inArchive = false;
Path archivedPath = null;
if (archive != null && !archive.trim().isEmpty()) {
archivedPath = "root".equals(normalizedFolder) ? Paths.get(archive, info.getName()) : Paths.get(archive, normalizedFolder, info.getName());
inArchive = Files.exists(archivedPath) && Files.isRegularFile(archivedPath);
}
// Fallback: Recursive search in ARCHIVE if not found at primary location
boolean sizeMismatch = false;
// 3. Robust existence and archive cross-check (Safety Guard)
if (archive != null && !archive.isEmpty()) {
try {
Path absArchive = Paths.get(archive).toAbsolutePath().normalize();
// If 'local' is actually inside the archive, it's NOT a local active model
if (exists && local.toAbsolutePath().normalize().startsWith(absArchive)) {
exists = false;
inArchive = true;
}
// If we found it in the primary archive location, verify size if known
if (inArchive && info.getByteSize() > 0) {
if (Files.size(archivedPath) != info.getByteSize()) {
inArchive = false; // Size mismatch in archive doesn't count as "found"
}
}
} catch (Exception ignored) {}
}
// 4. Fallback: Recursive search in ARCHIVE if not found at primary archive location
if (!inArchive && archive != null && !archive.isEmpty()) {
java.util.Optional<Path> foundInArchive = localScanner.findModelWithPrefSize(Paths.get(archive), info.getName(), info.getByteSize());
if (foundInArchive.isPresent()) {
@@ -1042,26 +1334,26 @@ public class Main extends JFrame {
}
}
// Fallback: Recursive search in LOCAL MODELS if not found OR size mismatch at primary location
// 5. Fallback: Recursive search in LOCAL MODELS if not found OR size mismatch at primary location
if ((!exists || sizeMismatch) && base != null && !base.isEmpty()) {
java.util.Optional<Path> foundLocally = localScanner.findModelWithPrefSize(Paths.get(base), info.getName(), info.getByteSize());
java.util.Optional<Path> foundLocally = localScanner.findModelWithPrefSizeAndType(Paths.get(base), info.getName(), info.getByteSize(), type);
if (foundLocally.isPresent()) {
Path potentialLocal = foundLocally.get();
try {
long potSize = Files.size(potentialLocal);
// Only accept if size matches OR we don't know the remote size yet
if (info.getByteSize() <= 0 || potSize == info.getByteSize()) {
local = potentialLocal;
exists = true;
sizeMismatch = false; // Found a correct one!
sizeMismatch = false;
// Update normalizedFolder to the actual location found locally
Path root = Paths.get(base);
Path rel = root.relativize(local);
if (rel.getParent() != null) {
normalizedFolder = rel.getParent().toString().replace("\\", "/");
Path root = Paths.get(base).toAbsolutePath().normalize();
Path absPotential = potentialLocal.toAbsolutePath().normalize();
if (absPotential.startsWith(root)) {
Path rel = root.relativize(absPotential);
normalizedFolder = (rel.getParent() != null) ? rel.getParent().toString().replace("\\", "/") : "root";
} else {
normalizedFolder = "root";
normalizedFolder = "extra/" + potentialLocal.getParent().getFileName();
}
info.setSave_path(normalizedFolder);
}
@@ -1069,44 +1361,12 @@ public class Main extends JFrame {
}
}
// Safety check: if it exists at 'local' but 'local' is inside the archive folder, it's NOT locally existing in the right place
if (exists && archive != null && !archive.isEmpty() &&
local.toAbsolutePath().toString().toLowerCase().startsWith(new File(archive).getAbsolutePath().toLowerCase())) {
exists = false;
inArchive = true;
}
// Rigorous 'Already exists' check: must match byteSize if known (redundant but safe)
if (exists && info.getByteSize() > 0) {
try {
long localSize = Files.size(local);
if (localSize != info.getByteSize()) {
sizeMismatch = true;
exists = false;
}
} catch (IOException e) {
exists = false;
}
}
// Populate size from local or archive if it exists and currently unknown
if ((exists || inArchive || sizeMismatch) && ("Unknown".equals(info.getSize()) || info.getByteSize() <= 0)) {
try {
long bytes = Files.size(exists ? local : (sizeMismatch ? local : archivedPath));
if (bytes > 0) {
if (info.getByteSize() <= 0) info.setByteSize(bytes);
if ("Unknown".equals(info.getSize())) info.setSize(searchService.formatSize(bytes));
}
} catch (Exception e) {}
}
// 6. Final Status Determination
String status;
if (exists && inArchive) {
status = "✅ Already exists (Archived copy found)";
} else if (exists) {
if (exists) {
status = "✅ Already exists";
} else if (inArchive) {
status = "📦 Found in Archive";
status = "📦 Archived";
} else if (sizeMismatch) {
status = "🔄 Size Mismatch";
} else if (info.getUrl().equals("MISSING")) {
@@ -1148,7 +1408,7 @@ public class Main extends JFrame {
// Re-verify status with new remote size info
String currentStatus = (String) tableModel.getValueAt(idx, 7);
if (currentStatus.contains("Already exists") || "🔄 Size Mismatch".equals(currentStatus) || "📦 Found in Archive".equals(currentStatus)) {
if (currentStatus.contains("Already exists") || "🔄 Size Mismatch".equals(currentStatus) || "📦 Archived".equals(currentStatus)) {
String type = info.getType() != null ? info.getType() : de.tki.comfymodels.domain.ModelFolder.CHECKPOINTS.getDefaultFolderName();
String folder = info.getSave_path() != null ? info.getSave_path() : type;
String base = configService.getModelsPath();
@@ -1191,13 +1451,13 @@ public class Main extends JFrame {
boolean shouldSelect = false;
if (localSizeMatch) {
newStatus = inArchive ? "✅ Already exists (Archived copy found)" : "✅ Already exists";
newStatus = "✅ Already exists";
shouldSelect = false;
} else if (localExists) {
newStatus = "🔄 Size Mismatch";
shouldSelect = true;
} else if (inArchive) {
newStatus = "📦 Found in Archive";
newStatus = "📦 Archived";
shouldSelect = true;
} else {
newStatus = "✅ Known Good";
@@ -1267,11 +1527,12 @@ public class Main extends JFrame {
if (exists) {
newStatus = "✅ Already exists";
} else if (inArchive) {
newStatus = "📦 Found in Archive";
newStatus = "📦 Archived";
} else {
newStatus = "✅ Known Good";
}
tableModel.setValueAt(newStatus, idx, 7);
tableModel.setValueAt(!exists, idx, 0); // Keep selected for download/restore if NOT local
}),
@@ -1603,6 +1864,59 @@ public class Main extends JFrame {
ArchiveWorkItem(int i, String f, String n, String s) { index = i; folder = f; name = n; size = s; }
}
private void performFullServiceRestart() {
new Thread(() -> {
SwingUtilities.invokeLater(() -> {
statusLabel.setText("🚀 Triggering Full Service Restart...");
if (tableModel != null) {
statusLabel.setText("🚀 Refreshing local library & Restarting ComfyUI...");
}
});
// 1. Core logic: Stop -> Discover -> Reload Config -> Start
configService.autoDiscoverPaths();
lifecycleService.restart();
// 2. Wait for health and notify user in status label
for (int i = 0; i < 45; i++) {
if (lifecycleService.isHealthy()) {
SwingUtilities.invokeLater(() -> {
statusLabel.setText("🔄 Syncing interface...");
// 3. Reload integrated browser
if (comfyWebPanel != null) {
comfyWebPanel.reload();
}
// 4. Trigger external browser reload via Bridge (with a small delay for reconnect)
new Thread(() -> {
try { Thread.sleep(3000); } catch (InterruptedException ignored) {}
downloadManager.notifyComfyUI(true);
}).start();
// 5. Refresh Model Table (Local existence checks)
analyzeJsonContent();
statusLabel.setText("✅ Ready");
});
return;
}
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
}
SwingUtilities.invokeLater(() -> statusLabel.setText("⚠️ ComfyUI restart taking longer than expected. Check Control Panel."));
}).start();
}
private void triggerPostOperationActions() {
SwingUtilities.invokeLater(() -> {
// Standardized Full Reload (replacing all soft reloads)
performFullServiceRestart();
// Shutdown (only if enabled)
if (configService.isShutdownAfterDownloadEnabled()) performSystemShutdown();
});
}
private void showModalProgressDialog(String title, String statusPrefix, List<Integer> selectedRows,
DefaultTableModel sourceModel, DefaultTableModel targetModel, TaskExecutor executor) {
JDialog progressDialog = new JDialog(this, title, true);
@@ -1668,7 +1982,7 @@ public class Main extends JFrame {
for (Object[] rowData : rowsToMove) targetModel.addRow(rowData);
analyzeJsonContent();
downloadManager.notifyComfyUI();
triggerPostOperationActions();
JOptionPane.showMessageDialog(this, finalSuccess + " models successfully " + title.toLowerCase() + "ed.",
"Operation Complete", JOptionPane.INFORMATION_MESSAGE);
@@ -0,0 +1,33 @@
package de.tki.comfymodels.service;
public interface IComfyLifecycleService {
/**
* Starts the ComfyUI process using the configured command and working directory.
*/
void start();
/**
* Stops the ComfyUI process forcefully.
*/
void stop();
/**
* Restarts the ComfyUI process (Stop -> Start).
*/
void restart();
/**
* Returns the current status of the ComfyUI process.
*/
String getStatus();
/**
* Returns true if the ComfyUI process is currently running.
*/
boolean isRunning();
/**
* Performs a health check by pinging the ComfyUI API.
*/
boolean isHealthy();
}
@@ -16,6 +16,11 @@ public interface IDownloadManager {
*/
void notifyComfyUI();
/**
* Triggers a refresh signal and optionally forces a browser reload.
*/
void notifyComfyUI(boolean forceReload);
/**
* Returns a map of current statuses for each model in the queue.
*/
@@ -35,31 +35,61 @@ public class ArchiveService {
}
public Map<String, List<ModelInfo>> getModelsGroupedByFolder() {
return scanDirectory(configService.getModelsPath(), true);
Map<String, List<ModelInfo>> allModels = new TreeMap<>();
java.util.Set<String> seenPaths = new java.util.HashSet<>();
// 1. Scan Standard Models Path
Map<String, List<ModelInfo>> standardModels = scanDirectory(configService.getModelsPath(), true, seenPaths);
allModels.putAll(standardModels);
// 2. Scan Extra Model Paths
Map<String, List<Path>> extraPaths = pathResolver.getAllExtraModelPaths();
for (Map.Entry<String, List<Path>> entry : extraPaths.entrySet()) {
String type = entry.getKey();
for (Path extraPath : entry.getValue()) {
if (Files.exists(extraPath) && Files.isDirectory(extraPath)) {
Map<String, List<ModelInfo>> extraModels = scanDirectory(extraPath.toString(), true, seenPaths);
// Merge extra models.
for (Map.Entry<String, List<ModelInfo>> extraEntry : extraModels.entrySet()) {
String folder = extraEntry.getKey();
String targetFolder = "root".equals(folder) ? type : type + "/" + folder;
allModels.computeIfAbsent(targetFolder, k -> new ArrayList<>()).addAll(extraEntry.getValue());
}
}
}
}
return allModels;
}
public Map<String, List<ModelInfo>> getArchivedModelsGroupedByFolder() {
return scanDirectory(configService.getArchivePath(), false);
String archivePath = configService.getArchivePath();
if (archivePath == null || archivePath.trim().isEmpty()) {
return new TreeMap<>();
}
return scanDirectory(archivePath, false, new java.util.HashSet<>());
}
private Map<String, List<ModelInfo>> scanDirectory(String pathStr, boolean excludeArchived) {
private Map<String, List<ModelInfo>> scanDirectory(String pathStr, boolean excludeArchived, java.util.Set<String> seenPaths) {
Map<String, List<ModelInfo>> grouped = new TreeMap<>();
if (pathStr == null || pathStr.isEmpty()) return grouped;
Path root = Paths.get(pathStr);
Path root = Paths.get(pathStr).toAbsolutePath().normalize();
if (!Files.exists(root)) {
System.err.println("Scan directory does not exist: " + pathStr);
return grouped;
}
try (Stream<Path> walk = Files.walk(root)) {
walk.filter(p -> {
try {
// Ignore hidden directories and .venv / venv
// Ignore hidden directories and .venv / venv / archive (if excluding)
Path relativeToRoot = root.relativize(p);
for (Path part : relativeToRoot) {
String name = part.toString();
if (name.startsWith(".") || name.equalsIgnoreCase("venv") || name.equalsIgnoreCase(".venv") || name.equalsIgnoreCase("__pycache__")) {
String name = part.toString().toLowerCase();
if (name.startsWith(".") || name.equals("venv") || name.equals(".venv") || name.equals("__pycache__")) {
return false;
}
if (excludeArchived && (name.equals("archive") || name.equals("archived"))) {
return false;
}
}
@@ -68,11 +98,12 @@ public class ArchiveService {
return false;
}
})
.filter(p -> Files.isRegularFile(p))
.filter(Files::isRegularFile)
.filter(this::isSupportedModel)
.filter(p -> !excludeArchived || !isAlreadyArchived(p))
.forEach(p -> {
if (!Files.exists(p)) return; // Double check existence
String absPath = p.toAbsolutePath().normalize().toString().toLowerCase();
if (!seenPaths.add(absPath)) return;
Path relative = root.relativize(p);
String folder = relative.getParent() != null ? relative.getParent().toString().replace("\\", "/") : "root";
@@ -84,6 +115,7 @@ public class ArchiveService {
try {
long bytes = Files.size(p);
info.setByteSize(bytes);
double mb = bytes / (1024.0 * 1024.0);
if (mb > 1024) {
info.setSize(String.format("%.2f GB", mb / 1024.0));
@@ -98,7 +130,6 @@ public class ArchiveService {
});
} catch (IOException e) {
System.err.println("Error walking directory " + pathStr + ": " + e.getMessage());
e.printStackTrace();
}
return grouped;
@@ -112,39 +143,76 @@ public class ArchiveService {
private boolean isAlreadyArchived(Path p) {
String archivePathStr = configService.getArchivePath();
if (archivePathStr == null || archivePathStr.isEmpty()) return false;
Path archivePath = Paths.get(archivePathStr).toAbsolutePath();
return p.toAbsolutePath().startsWith(archivePath);
try {
Path archivePath = Paths.get(archivePathStr).toAbsolutePath().normalize();
Path targetPath = p.toAbsolutePath().normalize();
return targetPath.startsWith(archivePath);
} catch (Exception e) {
return false;
}
}
public void moveToArchiveWithProgress(String relativePath, LongConsumer progressUpdate) throws IOException {
String modelsPath = configService.getModelsPath();
String archivePath = configService.getArchivePath();
if (modelsPath == null || archivePath == null) throw new IOException("Paths not configured");
Path source = Paths.get(modelsPath, relativePath);
Path target = Paths.get(archivePath, relativePath);
if (!Files.exists(source)) throw new IOException("Source not found: " + source);
Files.createDirectories(target.getParent());
moveWithProgress(source, target, progressUpdate);
Path p = Paths.get(relativePath);
String filename = p.getFileName().toString();
String folder = p.getParent() != null ? p.getParent().toString().replace("\\", "/") : "root";
moveToArchiveWithProgress(folder, filename, progressUpdate);
}
public void moveToArchive(String relativePath) throws IOException {
moveToArchiveWithProgress(relativePath, null);
}
public void moveToArchiveWithProgress(String folder, String filename, LongConsumer progressUpdate) throws IOException {
String normFolder = normalizeFolder(folder);
String relPath = "root".equals(normFolder) ? filename : Paths.get(normFolder, filename).toString();
moveToArchiveWithProgress(relPath, progressUpdate);
}
public void moveToArchive(String folder, String filename) throws IOException {
moveToArchiveWithProgress(folder, filename, null);
}
public void moveToArchiveWithProgress(String folder, String filename, LongConsumer progressUpdate) throws IOException {
String archivePathStr = configService.getArchivePath();
if (archivePathStr == null) throw new IOException("Archive path not configured");
Path archivePath = Paths.get(archivePathStr);
String normFolder = normalizeFolder(folder);
// Find source path by checking standard and extra paths
Path source = findActualSourcePath(normFolder, filename);
if (source == null || !Files.exists(source)) {
throw new IOException("Source model not found: " + filename + " in folder " + normFolder);
}
// Target in archive follows the relative structure
String relPath = "root".equals(normFolder) ? filename : Paths.get(normFolder, filename).toString();
Path target = archivePath.resolve(relPath);
Files.createDirectories(target.getParent());
moveWithProgress(source, target, progressUpdate);
}
private Path findActualSourcePath(String folder, String filename) {
String modelsPathStr = configService.getModelsPath();
if (modelsPathStr == null) return null;
Path modelsPath = Paths.get(modelsPathStr);
// 1. Check standard path
Path standardPath = "root".equals(folder) ? modelsPath.resolve(filename) : modelsPath.resolve(folder).resolve(filename);
if (Files.exists(standardPath)) return standardPath;
// 2. Check extra paths for this type/folder
List<Path> extraPaths = pathResolver.getModelPaths(folder);
for (Path extraRoot : extraPaths) {
// Since extraRoot often ALREADY points to the type folder (e.g. .../checkpoints)
// we check both extraRoot/filename AND extraRoot/folder/filename
Path p1 = extraRoot.resolve(filename);
if (Files.exists(p1)) return p1;
Path p2 = extraRoot.resolve(folder).resolve(filename);
if (Files.exists(p2)) return p2;
}
return null;
}
public boolean restoreFromArchiveWithProgress(String folder, String filename, LongConsumer progressUpdate) {
String modelsPathStr = configService.getModelsPath();
String archivePathStr = configService.getArchivePath();
@@ -156,20 +224,25 @@ public class ArchiveService {
String normFolder = normalizeFolder(folder);
// Primary location with case-insensitive resolution
// Primary location in archive
Path archivedFolder = "root".equals(normFolder) ? archivePath : pathResolver.resolveCaseInsensitiveRecursive(archivePath, normFolder);
Path archived = archivedFolder.resolve(filename);
// Determine target folder: prefer extra paths if configured
Path targetFolder = "root".equals(normFolder) ? modelsPath : modelsPath.resolve(normFolder);
List<Path> extraPaths = pathResolver.getModelPaths(normFolder);
if (!extraPaths.isEmpty()) {
targetFolder = extraPaths.get(0);
}
Path target = targetFolder.resolve(filename);
// Fallback: If not at primary, check root of archive
// Fallback: If not at primary archive location, check root of archive or search
if (!Files.exists(archived)) {
Path rootArchived = archivePath.resolve(filename);
if (Files.exists(rootArchived)) {
archived = rootArchived;
} else {
// Secondary Fallback: Check if it exists ANYWHERE in the archive (shallow search)
try (Stream<Path> walk = Files.walk(archivePath, 2)) {
java.util.Optional<Path> found = walk.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().equalsIgnoreCase(filename))
@@ -177,7 +250,7 @@ public class ArchiveService {
if (found.isPresent()) {
archived = found.get();
} else {
System.err.println("Restore failed: " + filename + " not found in archive (checked " + normFolder + " and root)");
System.err.println("Restore failed: " + filename + " not found in archive");
return false;
}
} catch (IOException e) {
@@ -0,0 +1,95 @@
package de.tki.comfymodels.service.impl;
import de.tki.comfymodels.domain.ModelInfo;
import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
@Service
public class ComfyDiagnosticService {
@Autowired
private ConfigService configService;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
/**
* Checks if a specific model is currently known to ComfyUI.
* Queries /object_info to see if the model appears in the checkpoint/lora lists.
*/
public boolean isModelAvailableInComfy(String modelName, String type) {
String url = configService.getComfyUIUrl();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url + "/object_info"))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) return false;
JSONObject info = new JSONObject(response.body());
// Typical node names for models
String[] nodesToCheck = {"CheckpointLoaderSimple", "LoraLoader", "CheckpointLoader"};
for (String node : nodesToCheck) {
if (info.has(node)) {
JSONObject nodeInfo = info.getJSONObject(node);
if (nodeInfo.has("input")) {
JSONObject input = nodeInfo.getJSONObject("input");
if (input.has("required")) {
JSONObject required = input.getJSONObject("required");
for (String key : required.keySet()) {
Object val = required.get(key);
if (val instanceof org.json.JSONArray) {
org.json.JSONArray outerArray = (org.json.JSONArray) val;
if (outerArray.length() > 0) {
Object firstElement = outerArray.get(0);
if (firstElement instanceof org.json.JSONArray) {
org.json.JSONArray options = (org.json.JSONArray) firstElement;
for (int i = 0; i < options.length(); i++) {
if (options.get(i) instanceof String && options.getString(i).equals(modelName)) return true;
}
} else if (firstElement instanceof String) {
// Handle cases where the array itself is the list of options
for (int i = 0; i < outerArray.length(); i++) {
if (outerArray.get(i) instanceof String && outerArray.getString(i).equals(modelName)) return true;
}
}
}
}
}
}
}
}
}
} catch (Exception e) {
System.err.println("[Diagnostic] API Check failed: " + e.getMessage());
}
return false;
}
/**
* Bulk check for a list of models.
*/
public List<String> getMissingModels(List<ModelInfo> models) {
List<String> missing = new ArrayList<>();
for (ModelInfo info : models) {
if (!isModelAvailableInComfy(info.getName(), info.getType())) {
missing.add(info.getName());
}
}
return missing;
}
}
@@ -0,0 +1,200 @@
package de.tki.comfymodels.service.impl;
import de.tki.comfymodels.service.IComfyLifecycleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicReference;
@Service
public class ComfyLifecycleService implements IComfyLifecycleService {
@Autowired
private ConfigService configService;
private Process comfyProcess;
private final AtomicReference<String> status = new AtomicReference<>("Stopped");
private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(500)).build();
@Override
public synchronized void start() {
if (isRunning()) {
status.set("Already Running");
return;
}
String command = configService.getComfyLaunchCommand();
String workingDir = configService.getComfyWorkingDir();
if (command == null || command.trim().isEmpty()) {
status.set("Error: No launch command configured");
return;
}
try {
status.set("Starting...");
java.util.List<String> commandList = new java.util.ArrayList<>();
if (de.tki.comfymodels.util.PlatformUtils.isWindows()) {
commandList.add("cmd");
commandList.add("/c");
// Wrap the entire command in extra quotes for Windows cmd /c quirk
commandList.add("\"" + command + "\"");
} else {
commandList.add("sh");
commandList.add("-c");
commandList.add(command);
}
System.out.println("🚀 [Lifecycle] Starting command: " + String.join(" ", commandList));
if (workingDir != null) System.out.println("📁 [Lifecycle] In directory: " + workingDir);
ProcessBuilder pb = new ProcessBuilder(commandList);
if (workingDir != null && !workingDir.isEmpty()) {
pb.directory(new File(workingDir));
}
pb.redirectErrorStream(true);
comfyProcess = pb.start();
// Quietly consume output to prevent process buffer stalls
new Thread(() -> {
try (java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(comfyProcess.getInputStream()))) {
while (reader.readLine() != null) {
// Just consume, don't print
}
} catch (java.io.IOException ignored) {}
}).start();
status.set("Running (PID: " + comfyProcess.pid() + ")");
// Background thread to wait for exit
new Thread(() -> {
try {
int exitCode = comfyProcess.waitFor();
status.set("Stopped (Exit Code: " + exitCode + ")");
comfyProcess = null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
} catch (IOException e) {
status.set("Error: " + e.getMessage());
}
}
@Override
public synchronized void stop() {
status.set("Stopping...");
// 1. Kill internal process if managed
if (comfyProcess != null) {
try {
comfyProcess.descendants().forEach(ProcessHandle::destroyForcibly);
comfyProcess.destroyForcibly();
comfyProcess.waitFor();
} catch (Exception ignored) {}
comfyProcess = null;
}
// 2. Kill by port fallback (for external/orphaned instances)
if (isHealthy()) {
killProcessOnPort();
}
status.set("Stopped");
}
private void killProcessOnPort() {
try {
String url = configService.getComfyUIUrl();
int port = 8000;
try {
port = Integer.parseInt(url.substring(url.lastIndexOf(":") + 1));
} catch (Exception ignored) {}
if (de.tki.comfymodels.util.PlatformUtils.isWindows()) {
// Find PID on port and kill it
Process p = Runtime.getRuntime().exec("cmd /c netstat -ano | findstr :" + port);
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(p.getInputStream()));
String line = reader.readLine();
if (line != null) {
String[] parts = line.trim().split("\\s+");
long pid = Long.parseLong(parts[parts.length - 1]);
ProcessHandle.of(pid).ifPresent(ProcessHandle::destroyForcibly);
}
} else {
// Linux/Mac fallback
Runtime.getRuntime().exec("sh -c fuser -k " + port + "/tcp");
}
} catch (Exception e) {
System.err.println("Failed to kill process on port: " + e.getMessage());
}
}
@Override
public void restart() {
stop();
try { Thread.sleep(2000); } catch (InterruptedException ignored) {}
// Reload extra paths in case they were changed before restart
configService.loadExtraModelPaths();
start();
// Optional: Wait for health check in background
new Thread(() -> {
status.set("Restarting (Waiting for API...)");
for (int i = 0; i < 30; i++) { // Wait up to 30 seconds
if (isHealthy()) {
status.set("Running & Healthy");
return;
}
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
}
status.set("Running (Healthy Check Timeout)");
}).start();
}
@Override
public String getStatus() {
String current = status.get();
if (current.startsWith("Stopped") || current.startsWith("Error")) {
if (isHealthy()) {
return "Running (External Instance)";
}
}
return current;
}
@Override
public boolean isRunning() {
return (comfyProcess != null && comfyProcess.isAlive()) || isHealthy();
}
@Override
public boolean isHealthy() {
String url = configService.getComfyUIUrl();
if (url == null || url.isEmpty()) return false;
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url + "/system_stats"))
.timeout(Duration.ofMillis(1000))
.GET().build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
boolean ok = response.statusCode() == 200;
if (!ok) System.err.println("⚠️ [Lifecycle] Health check failed for " + url + " - Status: " + response.statusCode());
return ok;
} catch (Exception e) {
// Silently fail for regular polling, but keep diagnostic info ready if needed
return false;
}
}
}
@@ -3,12 +3,17 @@ package de.tki.comfymodels.service.impl;
import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.yaml.snakeyaml.Yaml;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
@Service
public class ConfigService {
@@ -29,7 +34,275 @@ public class ConfigService {
@jakarta.annotation.PostConstruct
public void init() {
autoDiscoverPaths();
pathResolver.setComfyUIRoot(getComfyUIPath());
loadExtraModelPaths();
}
public void loadExtraModelPaths() {
pathResolver.clearExtraModelPaths();
String comfyRoot = getComfyUIPath();
if (comfyRoot == null || comfyRoot.isEmpty()) return;
Path extraPathsFile = Paths.get(comfyRoot).resolve("extra_model_paths.yaml");
if (!Files.exists(extraPathsFile)) {
// Check AppData common location as provided in user hint
String userHome = System.getProperty("user.home");
extraPathsFile = Paths.get(userHome, "AppData/Roaming/ComfyUI/extra_models_config.yaml");
}
if (Files.exists(extraPathsFile)) {
System.out.println("📄 [Config] Loading extra model paths from: " + extraPathsFile.toAbsolutePath());
try (InputStream is = new FileInputStream(extraPathsFile.toFile())) {
Yaml yaml = new Yaml();
Map<String, Object> data = yaml.load(is);
if (data != null) {
for (Map.Entry<String, Object> entry : data.entrySet()) {
if (entry.getValue() instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> section = (Map<String, Object>) entry.getValue();
String basePathStr = (String) section.get("base_path");
if (basePathStr == null) continue;
// base_path can be relative to the YAML file
Path yamlDir = extraPathsFile.getParent();
Path basePath = Paths.get(basePathStr);
if (!basePath.isAbsolute()) {
basePath = yamlDir.resolve(basePath).toAbsolutePath().normalize();
}
System.out.println("📂 [Config] Section '" + entry.getKey() + "' base_path: " + basePath);
for (Map.Entry<String, Object> config : section.entrySet()) {
if (config.getKey().equals("base_path")) continue;
String type = config.getKey();
Object value = config.getValue();
if (value instanceof String) {
String[] folders = ((String) value).split("\\R");
for (String folder : folders) {
String trimmed = folder.trim();
if (!trimmed.isEmpty()) {
Path fullPath = basePath.resolve(trimmed).toAbsolutePath().normalize();
if (!Files.exists(fullPath)) {
System.err.println(" ⚠️ [Config] Warning: Path does not exist: " + fullPath);
}
pathResolver.addExtraModelPath(type, fullPath);
System.out.println(" -> Mapping [" + type + "] to: " + fullPath);
}
}
}
}
}
}
}
} catch (Exception e) {
System.err.println("❌ [Config] Error parsing extra_model_paths.yaml: " + e.getMessage());
}
}
}
public void autoDiscoverPaths() {
String root = getComfyUIPath();
String userHome = System.getProperty("user.home");
String currentDir = System.getProperty("user.dir");
System.out.println("🔍 [Config] Starting auto-discovery. Current Root: " + root);
// 1. VALIDATE AND DISCOVER COMFYUI ROOT
boolean rootValid = !root.isEmpty() && new File(root, "main.py").exists();
if (!rootValid && !root.isEmpty()) {
System.out.println("⚠️ [Config] main.py not found in current root. Checking subdirectories...");
// Check for nested Pinokio structure: resources/ComfyUI
File nested = new File(root, "resources/ComfyUI");
if (new File(nested, "main.py").exists()) {
root = nested.getAbsolutePath();
rootValid = true;
System.out.println("✨ [Config] Found main.py in nested Pinokio path: " + root);
setComfyUIPath(root);
} else {
// Current root is definitively wrong, reset it to allow re-discovery
System.out.println("🚫 [Config] Current root is invalid. Resetting for re-discovery.");
root = "";
}
}
if (root.isEmpty()) {
// Priority 1: Environment Variables
String[] envVars = {"COMFYUI_PATH", "COMFYUI_HOME", "PINOKIO_BIN"};
for (String var : envVars) {
String val = System.getenv(var);
if (val != null && !val.isEmpty()) {
File f = new File(val);
if (new File(f, "main.py").exists()) {
root = f.getAbsolutePath();
System.out.println("✨ [Config] Found Root via Env Var " + var + ": " + root);
break;
}
}
}
// Priority 2: Check if we are running INSIDE a ComfyUI directory
if (root.isEmpty()) {
File possibleRoot = findMainPyNearby(new File(currentDir));
if (possibleRoot != null) {
root = possibleRoot.getAbsolutePath();
System.out.println("✨ [Config] Found Root via Nearby Search: " + root);
}
}
// Priority 3: Check common Pinokio/Home locations
if (root.isEmpty()) {
String[] commonPatterns = {
"C:\\AI\\ComfyUI\\resources\\ComfyUI",
"C:\\AI\\ComfyUI",
userHome + "\\AppData\\Roaming\\pinokio\\bin\\ComfyUI",
userHome + "/pinokio/bin/ComfyUI",
userHome + "/ComfyUI",
userHome + "/AI/ComfyUI"
};
for (String p : commonPatterns) {
File f = new File(p);
if (new File(f, "main.py").exists()) {
root = f.getAbsolutePath();
System.out.println("✨ [Config] Found Root via Pattern: " + root);
break;
} else {
// Try nested resources/ComfyUI for patterns too
File nested = new File(f, "resources/ComfyUI");
if (new File(nested, "main.py").exists()) {
root = nested.getAbsolutePath();
System.out.println("✨ [Config] Found Root via Nested Pattern: " + root);
break;
}
}
}
}
if (!root.isEmpty()) {
setComfyUIPath(root);
}
}
// 2. DISCOVER MODELS DIR
String currentModelsPath = settings.optString("models_path", "");
if (currentModelsPath.isEmpty() || currentModelsPath.equals(PathResolver.MODELS_DIR)) {
// Check for Pinokio data structure: sibling of ComfyUI's main installation folder
// e.g. C:\AI\ComfyUI (binary) -> C:\AI\comfyuidata\models (data)
File comfyFolder = new File(root);
while (comfyFolder != null) {
File parent = comfyFolder.getParentFile();
if (parent != null) {
File dataModels = new File(parent, "comfyuidata/models");
if (dataModels.exists() && dataModels.isDirectory()) {
System.out.println("✨ [Config] Found models via Pinokio Data Path: " + dataModels.getAbsolutePath());
setModelsPath(dataModels.getAbsolutePath());
break;
}
// Try case-insensitive variant or simplified name
File altData = new File(parent, "data/models");
if (altData.exists() && altData.isDirectory()) {
System.out.println("✨ [Config] Found models via Data Path: " + altData.getAbsolutePath());
setModelsPath(altData.getAbsolutePath());
break;
}
}
comfyFolder = comfyFolder.getParentFile();
if (comfyFolder != null && comfyFolder.getName().equalsIgnoreCase("AI")) break; // Don't go too high
}
}
// 3. DISCOVER WORKING DIR
if ((getComfyWorkingDir().isEmpty() || !rootValid) && !root.isEmpty()) {
setComfyWorkingDir(root);
System.out.println("📁 [Config] Set Working Dir: " + root);
}
// 3. DISCOVER PYTHON & CONSTRUCT COMMAND
// Always re-validate command if root changed or command is empty
String python = getPythonPath();
if (!isPythonValid(python)) {
python = discoverPython(root);
if (isPythonValid(python) && !python.equals("python") && !python.equals("python3")) {
setPythonPath(python);
}
}
if ((getComfyLaunchCommand().isEmpty() || !rootValid) && !root.isEmpty()) {
File mainPy = new File(root, "main.py");
if (mainPy.exists()) {
int port = getComfyUIUrl().contains(":8000") ? 8000 : 8188;
String cmd = String.format("\"%s\" \"%s\" --listen 127.0.0.1 --port %d --enable-manager",
python, mainPy.getAbsolutePath(), port);
setComfyLaunchCommand(cmd);
System.out.println("🚀 [Config] Generated Launch Command: " + cmd);
} else {
System.err.println("❌ [Config] Could not find main.py in " + root);
}
} else if (!getComfyLaunchCommand().isEmpty()) {
System.out.println("✅ [Config] Launch command already present and likely valid: " + getComfyLaunchCommand());
}
// 4. RELOAD EXTRA PATHS
loadExtraModelPaths();
}
private boolean isPythonValid(String python) {
if (python == null || python.isEmpty()) return false;
if (python.equalsIgnoreCase("python") || python.equalsIgnoreCase("python3")) return true;
return new File(python).exists();
}
private File findMainPyNearby(File start) {
File current = start;
while (current != null) {
if (new File(current, "main.py").exists()) return current;
if (current.getName().equalsIgnoreCase("custom_nodes")) {
File parent = current.getParentFile();
if (parent != null && new File(parent, "main.py").exists()) return parent;
}
current = current.getParentFile();
}
return null;
}
public String discoverPython(String comfyRoot) {
System.out.println("🐍 [Config] Searching for Python in/near: " + comfyRoot);
// Priority 1: Check for .venv in comfy root or parent (Pinokio style)
File[] venvLocations = {
new File(comfyRoot, ".venv"),
new File(new File(comfyRoot).getParentFile(), ".venv"),
new File(new File(comfyRoot).getParentFile().getParentFile(), ".venv"), // Try 2 levels up
new File("C:\\AI\\comfyuidata\\.venv")
};
for (File venv : venvLocations) {
if (venv != null && venv.exists()) {
System.out.println("📂 [Config] Found venv at: " + venv.getAbsolutePath());
boolean isWin = isWindows();
File bin = new File(venv, isWin ? "Scripts/python.exe" : "bin/python3");
if (bin.exists()) return bin.getAbsolutePath();
bin = new File(venv, isWin ? "Scripts/python" : "bin/python");
if (bin.exists()) return bin.getAbsolutePath();
}
}
// Priority 2: Check for python_embeded (Portable ComfyUI style)
File portablePython = new File(new File(comfyRoot).getParentFile(), "python_embeded/python.exe");
if (portablePython.exists()) {
System.out.println("📂 [Config] Found portable python at: " + portablePython.getAbsolutePath());
return portablePython.getAbsolutePath();
}
System.out.println("⚠️ [Config] No venv found, falling back to system python.");
return isWindows() ? "python" : "python3";
}
private boolean isWindows() {
return System.getProperty("os.name").toLowerCase().contains("win");
}
/**
@@ -152,9 +425,23 @@ public class ConfigService {
settings.put("comfyui_path", path);
pathResolver.setComfyUIRoot(path);
save();
loadExtraModelPaths(); // Ensure extra paths are reloaded when root changes
}
public String getComfyUIUrl() { return settings.optString("comfyui_url", "http://127.0.0.1:8188"); }
public String getPythonPath() { return settings.optString("python_path", ""); }
public void setPythonPath(String path) { settings.put("python_path", path); save(); }
public String getComfyLaunchCommand() { return settings.optString("comfy_launch_command", ""); }
public void setComfyLaunchCommand(String cmd) { settings.put("comfy_launch_command", cmd); save(); }
public String getComfyWorkingDir() { return settings.optString("comfy_working_dir", ""); }
public void setComfyWorkingDir(String dir) { settings.put("comfy_working_dir", dir); save(); }
public boolean isRestartAfterDownloadEnabled() { return settings.optBoolean("restart_after_download", false); }
public void setRestartAfterDownloadEnabled(boolean enabled) { settings.put("restart_after_download", enabled); save(); }
public String getComfyUIUrl() { return settings.optString("comfyui_url", "http://127.0.0.1:8000"); }
public void setComfyUIUrl(String url) {
if (url != null && !url.isEmpty()) {
String sanitized = url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
@@ -122,8 +122,13 @@ public class DefaultDownloadManager implements IDownloadManager {
// NEU: Benachrichtigt ComfyUI
@Override
public void notifyComfyUI() {
notifyComfyUI(false);
}
@Override
public void notifyComfyUI(boolean forceReload) {
String comfyUrl = configService.getComfyUIUrl();
if (sendRefreshPing(comfyUrl)) {
if (sendRefreshPing(comfyUrl, forceReload)) {
return;
}
@@ -133,23 +138,25 @@ public class DefaultDownloadManager implements IDownloadManager {
if (discoveredUrl != null) {
System.out.println("✨ [Model-Downloader] ComfyUI automatisch gefunden unter: " + discoveredUrl);
configService.setComfyUIUrl(discoveredUrl);
sendRefreshPing(discoveredUrl);
sendRefreshPing(discoveredUrl, forceReload);
} else {
System.err.println("❌ [Model-Downloader] ComfyUI konnte nicht automatisch gefunden werden. Bitte stelle sicher, dass es läuft.");
}
}
private boolean sendRefreshPing(String url) {
private boolean sendRefreshPing(String url, boolean forceReload) {
try {
String json = "{\"force_reload\": " + forceReload + "}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url + "/kippster/model-downloaded"))
.uri(URI.create(url + "/cmfd/refresh-models"))
.timeout(java.time.Duration.ofSeconds(2))
.POST(HttpRequest.BodyPublishers.noBody())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
System.out.println("✅ [Model-Downloader] ComfyUI (" + url + ") erfolgreich benachrichtigt.");
System.out.println("✅ [Model-Downloader] ComfyUI (" + url + ") erfolgreich benachrichtigt (Force: " + forceReload + ").");
return true;
}
} catch (Exception ignored) {}
@@ -222,6 +229,10 @@ public class DefaultDownloadManager implements IDownloadManager {
}
long existingFileSize = (file.exists() && !isActuallyInArchive) ? file.length() : 0;
Path partFile = targetFile.resolveSibling(targetFile.getFileName().toString() + ".cmfd");
File pFile = partFile.toFile();
long existingPartSize = pFile.exists() ? pFile.length() : 0;
if (waitForPauseAndCheckSelection(index, statusUpdater)) return;
@@ -276,13 +287,19 @@ public class DefaultDownloadManager implements IDownloadManager {
downloadBuilder.header("Authorization", "Bearer " + hfToken);
}
if (existingFileSize > 0) downloadBuilder.header("Range", "bytes=" + existingFileSize + "-");
if (existingPartSize > 0) downloadBuilder.header("Range", "bytes=" + existingPartSize + "-");
HttpResponse<InputStream> response = httpClient.send(downloadBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
int statusCode = response.statusCode();
if (statusCode == 416) {
safeUpdateStatus(index, "✅ Already exists", statusUpdater);
// Part might be complete but not moved
if (existingPartSize == totalRemoteSize) {
Files.move(partFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
safeUpdateStatus(index, "✅ Finished", statusUpdater);
} else {
safeUpdateStatus(index, "✅ Already exists", statusUpdater);
}
return;
}
@@ -292,19 +309,19 @@ public class DefaultDownloadManager implements IDownloadManager {
}
long contentLen = response.headers().firstValueAsLong("Content-Length").orElse(0L);
long totalBytes = (statusCode == 206) ? contentLen + existingFileSize : contentLen;
long totalBytes = (statusCode == 206) ? contentLen + existingPartSize : contentLen;
if (totalBytes > 0 && totalBytes < 5000 && info.getName().endsWith(".safetensors")) {
safeUpdateStatus(index, "❌ LFS Stub detected (Token?)", statusUpdater);
return;
}
if (statusCode == 200) existingFileSize = 0;
if (statusCode == 200) existingPartSize = 0;
try (InputStream is = response.body(); RandomAccessFile raf = new RandomAccessFile(file, "rw")) {
raf.seek(existingFileSize);
try (InputStream is = response.body(); RandomAccessFile raf = new RandomAccessFile(pFile, "rw")) {
raf.seek(existingPartSize);
byte[] buffer = new byte[65536];
long downloaded = existingFileSize;
long downloaded = existingPartSize;
int read;
long lastUpdate = 0;
while ((read = is.read(buffer)) != -1) {
@@ -332,19 +349,21 @@ public class DefaultDownloadManager implements IDownloadManager {
if (isStopped || !isSelected(index)) {
safeUpdateStatus(index, !isSelected(index) ? "Skipped (Unchecked)" : "Stopped", statusUpdater);
} else {
long finalSize = file.length();
long finalSize = pFile.length();
boolean sizeMismatch = totalBytes > 0 && finalSize < totalBytes;
boolean corruptedSafetensor = info.getName().endsWith(".safetensors") && (finalSize % 2 != 0);
if ((sizeMismatch || corruptedSafetensor) && retryCount < 1) {
safeUpdateStatus(index, "🔄 Verification failed, redownloading...", statusUpdater);
file.delete();
pFile.delete();
downloadWithResumeInternal(info, targetFile, index, statusUpdater, retryCount + 1);
} else if (sizeMismatch) {
safeUpdateStatus(index, "❌ Incomplete (" + formatSize(finalSize) + "/" + formatSize(totalBytes) + ")", statusUpdater);
} else if (corruptedSafetensor) {
safeUpdateStatus(index, "❌ Corrupted (Odd Size)", statusUpdater);
} else {
// Atomic move to final destination
Files.move(partFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
safeUpdateStatus(index, "✅ Finished", statusUpdater);
}
}
@@ -24,71 +24,116 @@ import java.util.stream.Stream;
public class LocalModelScanner {
private final ConfigService configService;
private final PathResolver pathResolver;
private final Map<String, Path> modelCache = new HashMap<>();
@Autowired
public LocalModelScanner(ConfigService configService) {
public LocalModelScanner(ConfigService configService, PathResolver pathResolver) {
this.configService = configService;
this.pathResolver = pathResolver;
}
/**
* Scans the configured models directory for supported model files.
*
* @return A list of ModelInfo objects representing the found local models.
* Scans all configured models directories (standard and extra) for supported model files.
*/
public List<ModelInfo> scanLocalModels() {
List<ModelInfo> foundModels = new ArrayList<>();
java.util.Set<String> seenPaths = new java.util.HashSet<>();
// 1. Scan Standard Models Path
String modelsPathStr = configService.getModelsPath();
if (modelsPathStr == null || modelsPathStr.isEmpty()) {
return foundModels;
if (modelsPathStr != null && !modelsPathStr.isEmpty()) {
scanDirectory(Paths.get(modelsPathStr), foundModels, seenPaths, null);
}
Path rootPath = Paths.get(modelsPathStr);
if (!Files.exists(rootPath) || !Files.isDirectory(rootPath)) {
return foundModels;
}
modelCache.clear();
try (Stream<Path> walk = Files.walk(rootPath)) {
List<Path> files = walk
.filter(Files::isRegularFile)
.filter(this::isSupportedModelFile)
.filter(p -> !isIgnored(p, rootPath))
.collect(Collectors.toList());
for (Path file : files) {
modelCache.put(file.getFileName().toString().toLowerCase(), file);
foundModels.add(createModelInfo(file, rootPath));
// 2. Scan Extra Model Paths (from extra_model_paths.yaml)
Map<String, List<Path>> extraPaths = pathResolver.getAllExtraModelPaths();
for (Map.Entry<String, List<Path>> entry : extraPaths.entrySet()) {
String type = entry.getKey();
for (Path extraPath : entry.getValue()) {
if (Files.exists(extraPath) && Files.isDirectory(extraPath)) {
scanDirectory(extraPath, foundModels, seenPaths, type);
}
}
} catch (IOException e) {
System.err.println("Error scanning local models: " + e.getMessage());
}
return foundModels;
}
private void scanDirectory(Path rootPath, List<ModelInfo> foundModels, java.util.Set<String> seenPaths, String forcedType) {
Path absRoot = rootPath.toAbsolutePath().normalize();
try (Stream<Path> walk = Files.walk(absRoot)) {
List<Path> files = walk
.filter(Files::isRegularFile)
.filter(this::isSupportedModelFile)
.filter(p -> !isIgnored(p, absRoot))
.collect(Collectors.toList());
for (Path file : files) {
String absPath = file.toAbsolutePath().normalize().toString().toLowerCase();
if (seenPaths.add(absPath)) {
modelCache.put(file.getFileName().toString().toLowerCase(), file);
foundModels.add(createModelInfo(file, absRoot, forcedType));
}
}
} catch (IOException e) {
System.err.println("Error scanning directory " + absRoot + ": " + e.getMessage());
}
}
/**
* Finds a local model by its filename, regardless of subfolder.
* Finds a local model by its filename, searching across all configured paths.
*/
public Optional<Path> findModel(String filename) {
return findModelWithPrefSize(null, filename, -1);
}
/**
* Finds a model by its filename recursively, searching all configured paths and prioritizing matches with the preferred size.
*/
public Optional<Path> findModelWithPrefSize(Path specificRoot, String filename, long preferredSize) {
return findModelWithPrefSizeAndType(specificRoot, filename, preferredSize, null);
}
public Optional<Path> findModelWithPrefSizeAndType(Path specificRoot, String filename, long preferredSize, String type) {
if (filename == null) return Optional.empty();
// 1. Search in specific root if provided
if (specificRoot != null) {
Optional<Path> found = searchInDirectory(specificRoot, filename, preferredSize);
if (found.isPresent()) return found;
}
// 2. Search in Type-Specific Extra Paths (High Priority)
if (type != null) {
List<Path> typePaths = pathResolver.getModelPaths(type);
for (Path p : typePaths) {
Optional<Path> found = searchInDirectory(p, filename, preferredSize);
if (found.isPresent()) return found;
}
}
// 3. Search in Standard Models Path
String modelsPathStr = configService.getModelsPath();
if (modelsPathStr == null || modelsPathStr.isEmpty()) return Optional.empty();
return findModelInDirectory(Paths.get(modelsPathStr), filename);
}
if (modelsPathStr != null && !modelsPathStr.isEmpty()) {
Optional<Path> found = searchInDirectory(Paths.get(modelsPathStr), filename, preferredSize);
if (found.isPresent()) return found;
}
/**
* Finds a model by its filename recursively starting from the given root directory.
*/
public Optional<Path> findModelInDirectory(Path root, String filename) {
return findModelWithPrefSize(root, filename, -1);
}
/**
* Finds a model by its filename recursively, prioritizing matches with the preferred size.
*/
public Optional<Path> findModelWithPrefSize(Path root, String filename, long preferredSize) {
if (filename == null || root == null || !Files.exists(root)) return Optional.empty();
// 4. Search in ALL Extra Model Paths (Fallback)
Map<String, List<Path>> extraPaths = pathResolver.getAllExtraModelPaths();
for (List<Path> paths : extraPaths.values()) {
for (Path p : paths) {
Optional<Path> found = searchInDirectory(p, filename, preferredSize);
if (found.isPresent()) return found;
}
}
return Optional.empty();
}
private Optional<Path> searchInDirectory(Path root, String filename, long preferredSize) {
if (root == null || !Files.exists(root)) return Optional.empty();
try (Stream<Path> walk = Files.walk(root)) {
List<Path> matches = walk.filter(Files::isRegularFile)
.filter(p -> !isIgnored(p, root))
@@ -97,7 +142,6 @@ public class LocalModelScanner {
if (matches.isEmpty()) return Optional.empty();
// 1. Try to find a match with exact size if preferredSize is known
if (preferredSize > 0) {
for (Path p : matches) {
try {
@@ -105,25 +149,23 @@ public class LocalModelScanner {
} catch (IOException ignored) {}
}
}
// 2. Return first match if no size match found or size unknown
return Optional.of(matches.get(0));
} catch (IOException e) {
System.err.println("Error searching in " + root + ": " + e.getMessage());
return Optional.empty();
}
}
private boolean isSupportedModelFile(Path path) {
String name = path.getFileName().toString().toLowerCase();
return name.endsWith(".safetensors") || name.endsWith(".ckpt") || name.endsWith(".pt");
return name.endsWith(".safetensors") || name.endsWith(".sft") || name.endsWith(".ckpt") ||
name.endsWith(".pth") || name.endsWith(".pt") || name.endsWith(".bin");
}
private boolean isIgnored(Path path, Path rootPath) {
// 1. If we are already searching inside an archive folder (e.g. E:\Archive),
// we should not filter out anything based on the "archive" name.
String rootName = rootPath.getFileName() != null ? rootPath.getFileName().toString().toLowerCase() : "";
if (rootName.contains("archive")) {
if (rootName.contains("archive") || rootName.contains("archived")) {
return false;
}
@@ -131,31 +173,31 @@ public class LocalModelScanner {
try {
String archivePathStr = configService.getArchivePath();
if (archivePathStr != null && !archivePathStr.isEmpty()) {
Path archivePath = Paths.get(archivePathStr).toAbsolutePath();
if (path.toAbsolutePath().startsWith(archivePath)) {
Path archivePath = Paths.get(archivePathStr).toAbsolutePath().normalize();
Path targetPath = path.toAbsolutePath().normalize();
if (targetPath.startsWith(archivePath)) {
return true;
}
}
} catch (Exception ignored) {}
// 3. Fallback: ignore any subfolder named "archive" or ".venv"
int rootCount = rootPath.getNameCount();
for (int i = rootCount; i < path.getNameCount(); i++) {
for (int i = 0; i < path.getNameCount(); i++) {
String part = path.getName(i).toString().toLowerCase();
if (part.equals("archive") || part.equals(".venv")) {
if (part.equals("archive") || part.equals("archived") || part.equals(".venv") || part.equals("venv")) {
return true;
}
}
return false;
}
private ModelInfo createModelInfo(Path file, Path rootPath) {
private ModelInfo createModelInfo(Path file, Path rootPath, String forcedType) {
String fileName = file.getFileName().toString();
Path relativePath = rootPath.relativize(file);
// Derive type from the first folder after root
String type = "unknown";
if (relativePath.getNameCount() > 1) {
String type = (forcedType != null) ? forcedType : "unknown";
if (forcedType == null && relativePath.getNameCount() > 1) {
type = relativePath.getName(0).toString();
}
@@ -7,6 +7,10 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
@Component
@@ -17,6 +21,7 @@ public class PathResolver {
public static final String CUSTOM_NODES_DIR = "custom_nodes";
private String comfyUIRoot;
private final Map<String, List<Path>> extraModelPaths = new HashMap<>();
public void setComfyUIRoot(String root) {
this.comfyUIRoot = root;
@@ -26,6 +31,59 @@ public class PathResolver {
return comfyUIRoot;
}
public void addExtraModelPath(String type, Path path) {
extraModelPaths.computeIfAbsent(type.toLowerCase(), k -> new ArrayList<>()).add(path);
}
public void clearExtraModelPaths() {
extraModelPaths.clear();
}
public List<Path> getModelPaths(String type) {
List<Path> paths = new ArrayList<>();
if (type == null) return paths;
String inputType = type.replace("\\", "/");
String normalizedType = inputType.toLowerCase();
// Handle common ComfyUI type aliases/groupings
List<String> typesToSearch = new ArrayList<>();
typesToSearch.add(normalizedType);
if (normalizedType.equals("unet") || normalizedType.equals("diffusion_models")) {
typesToSearch.add("unet");
typesToSearch.add("diffusion_models");
} else if (normalizedType.equals("clip") || normalizedType.equals("text_encoders")) {
typesToSearch.add("clip");
typesToSearch.add("text_encoders");
}
for (String t : typesToSearch) {
List<Path> extras = extraModelPaths.get(t);
if (extras != null) paths.addAll(extras);
}
// NEW: Prefix matching for subfolders
// If type is "checkpoints/SD15" and we have an extra path for "checkpoints"
if (paths.isEmpty() && normalizedType.contains("/")) {
for (Map.Entry<String, List<Path>> entry : extraModelPaths.entrySet()) {
String extraType = entry.getKey();
if (normalizedType.startsWith(extraType + "/")) {
String subPath = inputType.substring(extraType.length() + 1);
for (Path root : entry.getValue()) {
paths.add(root.resolve(subPath));
}
}
}
}
return paths;
}
public Map<String, List<Path>> getAllExtraModelPaths() {
return new HashMap<>(extraModelPaths);
}
/**
* Resolves a path. If it's absolute, returns it.
* If relative, resolves against ComfyUI root.
@@ -0,0 +1,84 @@
package de.tki.comfymodels.ui;
import javax.swing.*;
import java.awt.*;
import java.net.URI;
/**
* A simplified panel that provides a link to open ComfyUI in the system browser.
* Replaces the problematic WebView for better compatibility with modern ComfyUI frontends.
*/
public class ComfyWebPanel extends JPanel {
private String currentUrl;
public ComfyWebPanel(String initialUrl) {
this.currentUrl = initialUrl;
setLayout(new GridBagLayout());
initComponents();
}
private void initComponents() {
removeAll();
GridBagConstraints gbc = new GridBagConstraints();
gbc.gridx = 0;
gbc.gridy = 0;
gbc.insets = new Insets(10, 10, 10, 10);
JLabel iconLabel = new JLabel("🎨");
iconLabel.setFont(new Font("SansSerif", Font.PLAIN, 64));
add(iconLabel, gbc);
gbc.gridy++;
JLabel titleLabel = new JLabel("ComfyUI Interface is Ready");
titleLabel.setFont(new Font("SansSerif", Font.BOLD, 20));
add(titleLabel, gbc);
gbc.gridy++;
JLabel infoLabel = new JLabel("The modern ComfyUI v1 interface works best in a full browser.");
infoLabel.setFont(new Font("SansSerif", Font.PLAIN, 14));
infoLabel.setForeground(Color.GRAY);
add(infoLabel, gbc);
gbc.gridy++;
gbc.insets = new Insets(30, 10, 10, 10);
JButton openBtn = new JButton("🌍 Open ComfyUI in Browser");
openBtn.setFont(new Font("SansSerif", Font.BOLD, 16));
openBtn.setPreferredSize(new Dimension(300, 50));
openBtn.addActionListener(e -> openInBrowser());
add(openBtn, gbc);
gbc.gridy++;
gbc.insets = new Insets(5, 10, 10, 10);
JLabel urlLabel = new JLabel(currentUrl);
urlLabel.setFont(new Font("Monospaced", Font.PLAIN, 12));
urlLabel.setForeground(new Color(100, 150, 255));
add(urlLabel, gbc);
revalidate();
repaint();
}
private void openInBrowser() {
try {
Desktop.getDesktop().browse(new URI(currentUrl));
} catch (Exception e) {
JOptionPane.showMessageDialog(this, "Could not open browser: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
}
}
/**
* Updates the URL and refreshes the UI.
*/
public void loadUrl(String url) {
this.currentUrl = url;
initComponents();
}
/**
* For compatibility with previous calls, though no longer strictly needed for WebView.
*/
public void reload() {
initComponents();
}
}
@@ -47,6 +47,20 @@ public class PlatformUtils {
return null;
}
public static void killProcessByName(String name) {
try {
if (isWindows()) {
if (!name.endsWith(".exe")) name += ".exe";
Runtime.getRuntime().exec("taskkill /F /IM " + name + " /T");
} else {
// Linux/Mac
Runtime.getRuntime().exec("pkill -f " + name);
}
} catch (IOException e) {
System.err.println("Failed to kill process " + name + ": " + e.getMessage());
}
}
public static boolean isSystemTraySupported() {
try {
return java.awt.SystemTray.isSupported();
+40 -1
View File
@@ -2,9 +2,48 @@
import server
import folder_paths
import os
import asyncio
import importlib
from aiohttp import web
print("\033[95m[Model-Downloader] UI Extension Bridge active\033[0m")
print("\033[95m[CMFD] UI Extension Bridge active\033[0m")
@server.PromptServer.instance.routes.post("/cmfd/refresh-models")
async def refresh_models(request):
data = await request.json() if request.has_body else {}
force_reload = data.get("force_reload", False)
print(f"\033[92m🔥 [CMFD] Refresh signal received (Force Reload: {force_reload})\033[0m")
# Wait for OS
await asyncio.sleep(1)
try:
# Invalidate
if hasattr(folder_paths, "invalidate_all_cached_folders"):
folder_paths.invalidate_all_cached_folders()
if hasattr(folder_paths, "filename_list_cache"):
folder_paths.filename_list_cache.clear()
try:
importlib.reload(folder_paths)
except Exception:
pass
# Object Info Reset
server.PromptServer.instance.object_info = None
except Exception as e:
print(f"[CMFD] Error: {e}")
# One-way broadcast to frontend
server.PromptServer.instance.send_sync("cmfd-refresh-ui", {
"status": "ok",
"force_reload": force_reload
})
return web.json_response({"status": "ok"})
WEB_DIRECTORY = "web"
NODE_CLASS_MAPPINGS = {}
@@ -21,29 +21,66 @@ const initializeExtension = async () => {
};
await loadConfig();
// Event Listener for UI Refresh
api.addEventListener("cmfd-refresh-ui", async (event) => {
const data = event.detail || {};
const forceReload = data.force_reload === true;
console.log(`%c🔥 [CMFD] Refresh Signal Received. Force Reload: ${forceReload}`, "background: #ff0000; color: #fff; font-size: 14px;");
if (forceReload) {
window.location.reload();
return;
}
try {
// 1. Clear Frontend Caches
api.nodeDefs = null;
// 2. Trigger standard ComfyUI refresh
if (app.refresh) {
await app.refresh();
}
// 3. Update existing combo widgets
const nodeDefs = await api.getNodeDefs();
if (app.graph && app.graph._nodes) {
for (const node of app.graph._nodes) {
const def = nodeDefs[node.type];
if (!def || !node.widgets) continue;
for (const widget of node.widgets) {
if (widget.type === "combo") {
const inputDef = def.input?.required?.[widget.name] || def.input?.optional?.[widget.name];
if (inputDef && Array.isArray(inputDef[0])) {
widget.options.values = inputDef[0];
}
}
}
node.setDirtyCanvas(true, true);
}
}
console.log("[CMFD] UI Sync Complete.");
} catch (e) {
console.error("[CMFD] UI Refresh Failed:", e);
}
});
const addUI = () => {
if (document.getElementById("tki-downloader-fab")) return;
const fab = document.createElement("div");
fab.id = "tki-downloader-fab";
fab.innerHTML = "🚀";
fab.title = "An Model Downloader senden";
fab.style = "position:fixed; bottom:30px; right:30px; z-index:10000; cursor:pointer; font-size:30px; background:#ffcc00; border-radius:50%; width:60px; height:60px; display:flex; align-items:center; justify-content:center; box-shadow:0 0 20px rgba(0,0,0,0.5); border: 2px solid white; transition: transform 0.2s;";
fab.onmouseover = () => fab.style.transform = "scale(1.1)";
fab.onmouseout = () => fab.style.transform = "scale(1.0)";
fab.onclick = async () => {
if (!app?.graph) return;
try {
const response = await fetch("http://127.0.0.1:12345/import", {
await fetch("http://127.0.0.1:12345/import", {
method: "POST", mode: "cors",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiToken}` },
body: JSON.stringify(app.graph.serialize())
});
if (response.ok) {
if (app.ui?.dialog?.show) app.ui.dialog.show("🚀 Workflow an Downloader gesendet!");
}
} catch (e) {
alert("❌ Downloader App nicht erreichbar!");
}
} catch (e) {}
};
document.body.appendChild(fab);
};
@@ -3,6 +3,7 @@ package de.tki.comfymodels;
import de.tki.comfymodels.domain.ModelInfo;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.LocalModelScanner;
import de.tki.comfymodels.service.impl.PathResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -26,13 +27,16 @@ class SubfolderRecognitionTest {
private ConfigService configService;
private PathResolver pathResolver;
@BeforeEach
void setUp() {
configService = new ConfigService(null, null) {
pathResolver = new PathResolver();
configService = new ConfigService(null, pathResolver) {
@Override public String getModelsPath() { return tempModels.toString(); }
@Override public String getArchivePath() { return "archive_mock"; }
};
localScanner = new LocalModelScanner(configService);
localScanner = new LocalModelScanner(configService, pathResolver);
}
@Test
@@ -44,7 +48,7 @@ class SubfolderRecognitionTest {
Files.createFile(modelFile);
// Test the generic recursive search method
Optional<Path> found = localScanner.findModelInDirectory(tempModels, "wan2.2_test.safetensors");
Optional<Path> found = localScanner.findModelWithPrefSize(tempModels, "wan2.2_test.safetensors", -1);
assertTrue(found.isPresent(), "Model should be found in deep subfolder");
assertEquals(modelFile.toAbsolutePath(), found.get().toAbsolutePath(), "Found path should match actual file path");
@@ -85,18 +89,18 @@ class SubfolderRecognitionTest {
Files.write(archivedFile, new byte[1234]);
// Mock config to have separate models and archive paths
ConfigService dualConfig = new ConfigService(null, null) {
ConfigService dualConfig = new ConfigService(null, pathResolver) {
@Override public String getModelsPath() { return tempModels.toString(); }
@Override public String getArchivePath() { return archiveDir.toString(); }
};
LocalModelScanner dualScanner = new LocalModelScanner(dualConfig);
LocalModelScanner dualScanner = new LocalModelScanner(dualConfig, pathResolver);
// Scan in models dir - should NOT find it
Optional<Path> foundLocal = dualScanner.findModelInDirectory(tempModels, "exclusive_model.safetensors");
Optional<Path> foundLocal = dualScanner.findModelWithPrefSize(tempModels, "exclusive_model.safetensors", -1);
assertFalse(foundLocal.isPresent(), "Should not find archived model in local models scan");
// Scan in archive dir - SHOULD find it
Optional<Path> foundArchive = dualScanner.findModelInDirectory(archiveDir, "exclusive_model.safetensors");
Optional<Path> foundArchive = dualScanner.findModelWithPrefSize(archiveDir, "exclusive_model.safetensors", -1);
assertTrue(foundArchive.isPresent(), "Should find model in archive scan");
}
}
@@ -0,0 +1,86 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.ComfyLifecycleService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import java.io.File;
import static org.junit.jupiter.api.Assertions.*;
/**
* Integration test to verify the full ComfyUI server lifecycle:
* Auto-discovery -> Start -> Reachability Check -> Stop -> Reachability Check.
*/
public class ComfyLifecycleIntegrationTest {
@Test
public void testFullServerLifecycle() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(TestConfig.class);
ConfigService config = context.getBean(ConfigService.class);
IComfyLifecycleService lifecycle = context.getBean(IComfyLifecycleService.class);
System.out.println("\n--- [START] Lifecycle Integration Test ---");
// 1. Discovery Phase
System.out.println("Step 1: Running path discovery...");
// Simulate real environment conditions
config.autoDiscoverPaths();
String root = config.getComfyUIPath();
String command = config.getComfyLaunchCommand();
String models = config.getModelsPath();
System.out.println("Detected Root: " + root);
System.out.println("Detected Command: " + command);
System.out.println("Detected Models: " + models);
assertNotNull(root, "ComfyUI Root should not be null");
assertFalse(root.isEmpty(), "ComfyUI Root should not be empty");
assertTrue(new File(root, "main.py").exists(), "main.py MUST exist in discovered root: " + root);
assertNotNull(command, "Launch command should not be null");
assertFalse(command.isEmpty(), "Launch command should not be empty");
assertNotNull(models, "Models path should not be null");
assertTrue(new File(models).exists(), "Models path MUST exist: " + models);
// 2. Startup Phase
System.out.println("Step 2: Starting server...");
lifecycle.start();
// Wait for server to become healthy (API response)
boolean started = false;
for (int i = 0; i < 30; i++) {
System.out.println("Waiting for health (Attempt " + (i + 1) + "/30)... Status: " + lifecycle.getStatus());
if (lifecycle.isHealthy()) {
started = true;
break;
}
Thread.sleep(2000); // 2s steps for ComfyUI
}
assertTrue(started, "Server should be healthy after start. Current Status: " + lifecycle.getStatus());
assertTrue(lifecycle.isRunning(), "Server should be running according to Lifecycle Service");
// 3. Stop Phase
System.out.println("Step 3: Stopping server...");
lifecycle.stop();
// Wait a bit for port release
Thread.sleep(5000);
assertFalse(lifecycle.isHealthy(), "Server should NO LONGER be healthy after stop");
assertFalse(lifecycle.isRunning(), "Server should NO LONGER be running after stop");
System.out.println("--- [SUCCESS] Lifecycle Integration Test PASSED ---\n");
context.close();
}
@Configuration
@ComponentScan("de.tki.comfymodels")
public static class TestConfig {
// Standard scan picks up all components
}
}
@@ -41,7 +41,7 @@ public class ComprehensiveStatusFlowTest {
configService.setModelsPath(modelsDir.toString());
configService.setArchivePath(archiveDir.toString());
localScanner = new LocalModelScanner(configService);
localScanner = new LocalModelScanner(configService, pathResolver);
archiveService = new ArchiveService(configService, pathResolver);
analyzer = new ComfyModelAnalyzer();
@@ -76,23 +76,23 @@ public class ComprehensiveStatusFlowTest {
Files.write(modelPath, new byte[2000]); // Update to 2KB
assertEquals("✅ Already exists", getStatus(results.get(0), true, false, 2000));
// 5. State: Found in Archive (Removed locally, moved to archive)
// 5. State: Archived (Removed locally, moved to archive)
Files.delete(modelPath);
Path archivePath = archiveDir.resolve("checkpoints").resolve(modelName);
Files.createDirectories(archivePath.getParent());
Files.write(archivePath, new byte[2000]);
assertEquals("📦 Found in Archive", getStatus(results.get(0), false, true, 0));
// 6. State: Already exists (Archived copy found) - Exists in both
assertEquals("📦 Archived", getStatus(results.get(0), false, true, 0));
// 6. State: Already exists (Size match, even if archived)
Files.write(modelPath, new byte[2000]);
assertEquals("✅ Already exists (Archived copy found)", getStatus(results.get(0), true, true, 2000));
}
assertEquals("✅ Already exists", getStatus(results.get(0), true, true, 2000));
}
/**
* Helper to simulate the status logic from Main.java analyzeJsonContent()
*/
private String getStatus(ModelInfo info, boolean existsLocally, boolean existsInArchive, long localSize) {
/**
* Helper to simulate the status logic from Main.java analyzeJsonContent()
*/
private String getStatus(ModelInfo info, boolean existsLocally, boolean existsInArchive, long localSize) {
boolean sizeMismatch = false;
if (existsLocally && info.getByteSize() > 0) {
if (localSize != info.getByteSize()) {
@@ -101,13 +101,13 @@ public class ComprehensiveStatusFlowTest {
}
}
if (existsLocally && existsInArchive) return "✅ Already exists (Archived copy found)";
if (existsLocally) return "✅ Already exists";
if (existsInArchive) return "📦 Found in Archive";
if (existsInArchive) return "📦 Archived";
if (sizeMismatch) return "🔄 Size Mismatch";
if (info.getUrl().equals("MISSING")) return "Idle";
return "✅ Known Good";
}
}
}
}
@@ -0,0 +1,93 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ConfigService;
import org.json.JSONObject;
import org.json.JSONArray;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
public class DeepModelDiagnosticTest {
@Test
public void inspectNodeOptions() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ConfigService configService = context.getBean(ConfigService.class);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
String targetModel = "ltx2.3-transition.safetensors";
String url = configService.getComfyUIUrl() + "/object_info";
System.out.println("--- DEEP API INSPECTION ---");
System.out.println("Target: " + targetModel);
System.out.println("Querying: " + url);
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
System.out.println("❌ API Error: " + response.statusCode());
return;
}
JSONObject allNodes = new JSONObject(response.body());
String[] nodesToInspect = {"LoraLoader", "CheckpointLoaderSimple", "CheckpointLoader", "LoraLoaderModelOnly"};
boolean foundInAny = false;
for (String nodeName : nodesToInspect) {
if (allNodes.has(nodeName)) {
System.out.println("\nChecking Node: " + nodeName);
JSONObject node = allNodes.getJSONObject(nodeName);
if (node.has("input")) {
JSONObject input = node.getJSONObject("input");
JSONObject required = input.optJSONObject("required");
if (required != null) {
for (String key : required.keySet()) {
JSONArray param = required.optJSONArray(key);
if (param != null && param.length() > 0 && param.get(0) instanceof JSONArray) {
JSONArray options = param.getJSONArray(0);
System.out.println(" Parameter '" + key + "' has " + options.length() + " options.");
List<String> matches = new ArrayList<>();
for (int i = 0; i < options.length(); i++) {
String opt = options.getString(i);
if (opt.toLowerCase().contains("ltx") || opt.equalsIgnoreCase(targetModel)) {
matches.add(opt);
}
}
if (!matches.isEmpty()) {
System.out.println(" 🎯 POTENTIAL MATCHES FOUND:");
for (String m : matches) {
System.out.println(" - " + m);
if (m.equals(targetModel)) foundInAny = true;
}
} else {
System.out.println(" (No LTX-related models in this list)");
}
}
}
}
}
} else {
System.out.println("\nNode not found: " + nodeName);
}
}
if (foundInAny) {
System.out.println("\n✅ VERIFIED: The model is technically present in the API options.");
System.out.println("If it is missing in the GUI, it might be a caching issue in the browser or a custom node conflict.");
} else {
System.out.println("\n❌ FAILED: The model is NOT present in any examined node parameters.");
}
context.close();
}
}
@@ -0,0 +1,172 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.domain.ModelInfo;
import de.tki.comfymodels.service.impl.ArchiveService;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.LocalModelScanner;
import de.tki.comfymodels.service.impl.PathResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class ExtraModelPathsIntegrationTest {
@TempDir
Path tempDir;
private LocalModelScanner scanner;
private ConfigService configService;
private PathResolver pathResolver;
private ArchiveService archiveService;
@BeforeEach
public void setup() {
pathResolver = new PathResolver();
// Custom ConfigService to use our tempDir for everything and avoid complex setup
configService = new ConfigService(null, pathResolver) {
private String comfyPath = "";
private String modelsPath = "";
private String archivePath = "";
@Override public String getComfyUIPath() { return comfyPath; }
@Override public void setComfyUIPath(String path) { this.comfyPath = path; pathResolver.setComfyUIRoot(path); }
@Override public String getModelsPath() { return modelsPath; }
@Override public void setModelsPath(String path) { this.modelsPath = path; }
@Override public String getArchivePath() { return archivePath; }
@Override public void setArchivePath(String path) { this.archivePath = path; }
@Override public String getAppDataPath() { return tempDir.toString(); }
@Override public void save() { /* avoid encryption issues in test */ }
@Override public boolean isUnlocked() { return true; }
};
scanner = new LocalModelScanner(configService, pathResolver);
archiveService = new ArchiveService(configService, pathResolver);
}
@Test
public void testExtraModelPathsDiscovery() throws IOException {
// 1. Setup ComfyUI Root and extra_model_paths.yaml
Path comfyRoot = tempDir.resolve("ComfyUI");
Files.createDirectories(comfyRoot);
configService.setComfyUIPath(comfyRoot.toString());
Path extraModelsDir = tempDir.resolve("ExternalModels");
Path checkpointsDir = extraModelsDir.resolve("my_checkpoints");
Files.createDirectories(checkpointsDir);
// Create a model file in the extra path
Path modelFile = checkpointsDir.resolve("extra_model.safetensors");
Files.writeString(modelFile, "dummy");
// Create the YAML
String yamlContent =
"my_extra_path:\n" +
" base_path: " + extraModelsDir.toAbsolutePath().toString().replace("\\", "/") + "\n" +
" checkpoints: my_checkpoints\n";
Files.writeString(comfyRoot.resolve("extra_model_paths.yaml"), yamlContent);
// 2. Load the extra paths
configService.loadExtraModelPaths();
// 3. Verify PathResolver knows about it
List<Path> paths = pathResolver.getModelPaths("checkpoints");
assertFalse(paths.isEmpty(), "Should have found extra checkpoints path");
assertTrue(paths.stream().anyMatch(p -> p.toAbsolutePath().equals(checkpointsDir.toAbsolutePath())), "Should contain the correct checkpoints dir");
// 4. Run Scanner and verify discovery
List<ModelInfo> results = scanner.scanLocalModels();
boolean found = results.stream().anyMatch(m -> m.getName().equals("extra_model.safetensors"));
assertTrue(found, "Scanner should find model in extra path");
ModelInfo info = results.stream().filter(m -> m.getName().equals("extra_model.safetensors")).findFirst().get();
assertEquals("checkpoints", info.getType(), "Should correctly identify as checkpoint");
}
@Test
public void testArchiveRestorationToExtraPath() throws IOException {
// 1. Setup ComfyUI Root and extra_model_paths.yaml
Path comfyRoot = tempDir.resolve("ComfyUI");
Files.createDirectories(comfyRoot);
configService.setComfyUIPath(comfyRoot.toString());
Path extraModelsDir = tempDir.resolve("ExternalModels");
Path checkpointsDir = extraModelsDir.resolve("my_checkpoints");
Files.createDirectories(checkpointsDir);
// Create the YAML mapping checkpoints to ExternalModels/my_checkpoints
String yamlContent =
"my_extra_path:\n" +
" base_path: " + extraModelsDir.toAbsolutePath().toString().replace("\\", "/") + "\n" +
" checkpoints: my_checkpoints\n";
Files.writeString(comfyRoot.resolve("extra_model_paths.yaml"), yamlContent);
configService.loadExtraModelPaths();
// 2. Setup Archive
Path archiveRoot = tempDir.resolve("Archive");
Files.createDirectories(archiveRoot);
configService.setArchivePath(archiveRoot.toString());
// Put a model in the archive structure
Path archivedModel = archiveRoot.resolve("checkpoints").resolve("archived_extra.safetensors");
Files.createDirectories(archivedModel.getParent());
Files.writeString(archivedModel, "dummy model data");
// 3. Run restoration for "checkpoints" folder
boolean success = archiveService.restoreFromArchiveWithProgress("checkpoints", "archived_extra.safetensors", null);
assertTrue(success, "Restoration should be successful");
// 4. VERIFY: Should be in checkpointsDir (extra path)
Path expectedTarget = checkpointsDir.resolve("archived_extra.safetensors");
assertTrue(Files.exists(expectedTarget), "Model should be restored to the extra checkpoints path: " + expectedTarget);
assertFalse(Files.exists(archivedModel), "Source should be deleted from archive after successful move");
}
@Test
public void testArchiveRestorationToNestedExtraPath() throws IOException {
// Setup same as above but with nested folder
Path comfyRoot = tempDir.resolve("ComfyUI");
Files.createDirectories(comfyRoot);
configService.setComfyUIPath(comfyRoot.toString());
Path extraModelsDir = tempDir.resolve("ExternalModels");
Path checkpointsDir = extraModelsDir.resolve("checkpoints");
Path sd15Dir = checkpointsDir.resolve("SD15");
Files.createDirectories(sd15Dir);
String yamlContent =
"my_extra_path:\n" +
" base_path: " + extraModelsDir.toAbsolutePath().toString().replace("\\", "/") + "\n" +
" checkpoints: checkpoints\n";
Files.writeString(comfyRoot.resolve("extra_model_paths.yaml"), yamlContent);
configService.loadExtraModelPaths();
// Setup Archive
Path archiveRoot = tempDir.resolve("Archive");
Files.createDirectories(archiveRoot);
configService.setArchivePath(archiveRoot.toString());
// Model in archive: checkpoints/SD15/nested.safetensors
Path archivedModel = archiveRoot.resolve("checkpoints/SD15/nested.safetensors");
Files.createDirectories(archivedModel.getParent());
Files.writeString(archivedModel, "nested data");
// Run restoration for "checkpoints/SD15"
boolean success = archiveService.restoreFromArchiveWithProgress("checkpoints/SD15", "nested.safetensors", null);
assertTrue(success, "Nested restoration should be successful");
// VERIFY: Should be in sd15Dir (extra path + subfolder)
Path expectedTarget = sd15Dir.resolve("nested.safetensors");
assertTrue(Files.exists(expectedTarget), "Model should be restored to the nested extra path: " + expectedTarget);
}
}
@@ -0,0 +1,57 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.ComfyLifecycleService;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
public class FinalDampfhammerTest {
@Test
public void executeRestartAndVerify() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ComfyLifecycleService lifecycleService = context.getBean(ComfyLifecycleService.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
System.out.println("--- 🔨 EXECUTING FINAL DAMPFHAMMER RESTART ---");
// 1. Force Kill
System.out.println("⏹ Terminating existing Python processes...");
Runtime.getRuntime().exec("taskkill /F /IM python.exe /T").waitFor();
Thread.sleep(2000);
// 2. Start via Service
System.out.println("▶ Starting ComfyUI...");
lifecycleService.start();
// 3. Robust Health Check (Wait up to 90 seconds)
System.out.println("⏳ Waiting for API (http://127.0.0.1:8000)...");
boolean ready = false;
for (int i = 0; i < 90; i++) {
if (lifecycleService.isHealthy()) {
ready = true;
break;
}
if (i % 10 == 0) System.out.println("Still waiting... (" + i + "s)");
Thread.sleep(1000);
}
if (ready) {
System.out.println("✅ ComfyUI is ONLINE and HEALTHY.");
// 4. Final verification of the specific model
String model = "ltx2.3-transition.safetensors";
if (diagnosticService.isModelAvailableInComfy(model, "loras")) {
System.out.println("🎯 TARGET ACHIEVED: '" + model + "' is recognized by ComfyUI!");
} else {
System.out.println("❌ Model still missing. This implies the file is not in 'models/loras' or extra_model_paths is misconfigured.");
}
} else {
System.out.println("❌ ComfyUI failed to respond. Please check your console/logs.");
}
context.close();
}
}
@@ -25,14 +25,15 @@ public class LocalModelScannerTest {
@BeforeEach
public void setup() {
PathResolver pathResolver = new PathResolver();
// Use a simple stub for ConfigService to avoid Mockito issues on Java 25
configService = new ConfigService(null, new PathResolver()) {
configService = new ConfigService(null, pathResolver) {
@Override
public String getModelsPath() {
return tempDir.toString();
}
};
scanner = new LocalModelScanner(configService);
scanner = new LocalModelScanner(configService, pathResolver);
}
@Test
@@ -0,0 +1,60 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class ModelPresenceVerificationTest {
@Test
public void verifyModelPresence() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
String modelName = "ltx2.3-transition.safetensors";
String type = "loras";
System.out.println("--- Starting Automated Refresh Loop for: " + modelName + " ---");
boolean found = false;
for (int i = 1; i <= 5; i++) {
System.out.println("Attempt " + i + ": Triggering Refresh...");
// Trigger Refresh
try {
HttpRequest refreshReq = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:8000/cmfd/refresh-models"))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
client.send(refreshReq, HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
System.out.println("Refresh ping failed (Bridge maybe not installed?): " + e.getMessage());
}
// Wait for processing
Thread.sleep(3000);
// Check Presence
if (diagnosticService.isModelAvailableInComfy(modelName, type)) {
System.out.println("✅ SUCCESS: Model '" + modelName + "' is now visible in ComfyUI!");
found = true;
break;
} else {
System.out.println("❌ Model not found yet.");
}
}
if (!found) {
System.out.println("⚠️ FAILURE: Model still not visible after 5 attempts.");
}
context.close();
}
}
@@ -0,0 +1,139 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.Main;
import de.tki.comfymodels.domain.ModelInfo;
import de.tki.comfymodels.service.impl.*;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
import javax.swing.*;
import javax.swing.table.DefaultTableModel;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
public class ModelStatusLogicIntegrationTest {
@TempDir
Path tempDir;
private Main main;
private ConfigService configService;
private PathResolver pathResolver;
private ArchiveService archiveService;
private LocalModelScanner localScanner;
private ComfyModelAnalyzer analyzer;
@BeforeEach
public void setup() throws Exception {
pathResolver = new PathResolver();
configService = new ConfigService(null, pathResolver) {
private String modelsPath = "";
private String archivePath = "";
@Override public String getModelsPath() { return modelsPath; }
@Override public void setModelsPath(String path) { this.modelsPath = path; }
@Override public String getArchivePath() { return archivePath; }
@Override public void setArchivePath(String path) { this.archivePath = path; }
@Override public String getAppDataPath() { return tempDir.toString(); }
@Override public boolean isUnlocked() { return true; }
@Override public void save() {}
};
analyzer = Mockito.mock(ComfyModelAnalyzer.class);
localScanner = new LocalModelScanner(configService, pathResolver);
archiveService = new ArchiveService(configService, pathResolver);
main = new Main(
analyzer,
Mockito.mock(DefaultDownloadManager.class),
Mockito.mock(WorkflowService.class),
Mockito.mock(ModelSearchService.class),
Mockito.mock(ModelValidator.class),
Mockito.mock(RestBridgeService.class),
archiveService,
Mockito.mock(ComfyLifecycleService.class),
Mockito.mock(ComfyDiagnosticService.class)
);
// Inject dependencies via reflection since they are @Autowired in Main
setField(main, "configService", configService);
setField(main, "localScanner", localScanner);
setField(main, "pathResolver", pathResolver);
setField(main, "archiveService", archiveService);
// Initialize enough UI for analyzeJsonContent to work
setField(main, "tableModel", new DefaultTableModel(new String[]{"S", "T", "N", "Sz", "Src", "P", "U", "Status"}, 0));
JTextArea area = new JTextArea("dummy workflow");
setField(main, "jsonInputArea", area);
setField(main, "statusLabel", new JLabel());
setField(main, "downloadButton", new JButton());
}
private void setField(Object target, String name, Object value) throws Exception {
Field f = target.getClass().getDeclaredField(name);
f.setAccessible(true);
f.set(target, value);
}
@Test
public void testStatusTransitions() throws IOException, Exception {
Path modelsDir = tempDir.resolve("models");
Files.createDirectories(modelsDir.resolve("checkpoints"));
configService.setModelsPath(modelsDir.toString());
Path archiveDir = tempDir.resolve("archive");
Files.createDirectories(archiveDir.resolve("checkpoints"));
configService.setArchivePath(archiveDir.toString());
// Prepare 3 scenarios
List<ModelInfo> models = new ArrayList<>();
models.add(new ModelInfo("checkpoints", "local_only.safetensors", "COMMUNITY"));
models.get(0).setUrl("http://example.com/1");
models.add(new ModelInfo("checkpoints", "archived_only.safetensors", "COMMUNITY"));
models.get(1).setUrl("http://example.com/2");
models.add(new ModelInfo("checkpoints", "both_locations.safetensors", "COMMUNITY"));
models.get(2).setUrl("http://example.com/3");
models.add(new ModelInfo("checkpoints", "new_model.safetensors", "COMMUNITY"));
models.get(3).setUrl("http://example.com/4");
when(analyzer.analyze(anyString(), anyString())).thenReturn(models);
// Create the files
Files.writeString(modelsDir.resolve("checkpoints/local_only.safetensors"), "data");
Files.writeString(archiveDir.resolve("checkpoints/archived_only.safetensors"), "data");
Files.writeString(modelsDir.resolve("checkpoints/both_locations.safetensors"), "data");
Files.writeString(archiveDir.resolve("checkpoints/both_locations.safetensors"), "data");
// Execute analysis
java.lang.reflect.Method method = main.getClass().getDeclaredMethod("analyzeJsonContent");
method.setAccessible(true);
method.invoke(main);
DefaultTableModel model = (DefaultTableModel) getField(main, "tableModel");
// Verify Statuses
assertEquals("✅ Already exists", model.getValueAt(0, 7), "Should be local only");
assertEquals("📦 Archived", model.getValueAt(1, 7), "Should be in archive only");
assertEquals("✅ Already exists", model.getValueAt(2, 7), "Should prioritize local over archive (obsolete status removed)");
assertEquals("✅ Known Good", model.getValueAt(3, 7), "Should be new/downloadable");
}
private Object getField(Object target, String name) throws Exception {
Field f = target.getClass().getDeclaredField(name);
f.setAccessible(true);
return f.get(target);
}
}
@@ -0,0 +1,70 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.io.InputStream;
public class NuclearFrontendRefreshTest {
@Test
public void triggerReload() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ConfigService configService = context.getBean(ConfigService.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
HttpClient client = HttpClient.newBuilder().build();
System.out.println("--- ☢️ TRIGGERING NUCLEAR FRONTEND REFRESH ---");
// 1. Sync Bridge to Disk (Ensure latest JS/Python is there)
String comfyPath = "C:\\AI\\ComfyUI\\resources\\ComfyUI";
Path bridgeDir = Paths.get(comfyPath, "custom_nodes", "comfyui-model-downloader");
System.out.println("📦 Syncing Bridge to: " + bridgeDir);
Files.createDirectories(bridgeDir.resolve("web"));
copyResource("/comfyui-bridge/__init__.py", bridgeDir.resolve("__init__.py"));
copyResource("/comfyui-bridge/web/downloader.js", bridgeDir.resolve("web/downloader.js"));
Files.writeString(bridgeDir.resolve("web/config.json"), "{\"token\": \"" + configService.getApiToken() + "\"}");
// 2. Send the "Nuclear" REST Call
System.out.println("🚀 Sending REST signal with 'force_reload: true'...");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:8000/cmfd/refresh-models"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"force_reload\": true}"))
.build();
try {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("📡 Server Response: " + response.statusCode() + " " + response.body());
} catch (Exception e) {
System.out.println("❌ Failed to reach ComfyUI: " + e.getMessage());
}
// 3. Verify Backend
String model = "ltx2.3-transition.safetensors";
if (diagnosticService.isModelAvailableInComfy(model, "loras")) {
System.out.println("✅ BACKEND VERIFIED: '" + model + "' is present in the API.");
} else {
System.out.println("❌ BACKEND FAILED: Model still not in API.");
}
context.close();
}
private void copyResource(String name, Path target) throws Exception {
try (InputStream is = getClass().getResourceAsStream(name)) {
Files.copy(is, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
@@ -0,0 +1,88 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.ComfyLifecycleService;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import de.tki.comfymodels.Main;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
public class OneClickFixTest {
@Test
public void performNuclearFix() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ConfigService configService = context.getBean(ConfigService.class);
ComfyLifecycleService lifecycleService = context.getBean(ComfyLifecycleService.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
System.out.println("--- ☢️ NUCLEAR ONE-CLICK FIX STARTING ---");
// 1. Reinstall Bridge to the correct location
String comfyPath = "C:\\AI\\ComfyUI\\resources\\ComfyUI";
Path bridgeDir = Paths.get(comfyPath, "custom_nodes", "comfyui-model-downloader");
System.out.println("📦 Reinstalling Bridge to: " + bridgeDir);
Files.createDirectories(bridgeDir);
Files.createDirectories(bridgeDir.resolve("web"));
copyResource("/comfyui-bridge/__init__.py", bridgeDir.resolve("__init__.py"));
copyResource("/comfyui-bridge/web/downloader.js", bridgeDir.resolve("web/downloader.js"));
String configJson = "{\"token\": \"" + configService.getApiToken() + "\"}";
Files.writeString(bridgeDir.resolve("web/config.json"), configJson);
System.out.println("✅ Bridge reinstalled.");
// 2. Kill existing process
System.out.println("⏹ Stopping ComfyUI...");
lifecycleService.stop();
// Fallback kill for PID 14544 just in case
Runtime.getRuntime().exec("taskkill /F /PID 14544").waitFor();
System.out.println("✅ ComfyUI stopped.");
// 3. Start via Lifecycle Service
System.out.println("▶ Starting ComfyUI...");
lifecycleService.start();
// 4. Wait for Health
System.out.println("⏳ Waiting for API to become healthy...");
boolean healthy = false;
for (int i = 0; i < 60; i++) {
if (lifecycleService.isHealthy()) {
healthy = true;
break;
}
Thread.sleep(1000);
}
if (healthy) {
System.out.println("✅ ComfyUI is back online!");
// 5. Final Diagnostic
String model = "ltx2.3-transition.safetensors";
if (diagnosticService.isModelAvailableInComfy(model, "loras")) {
System.out.println("🎯 TARGET VERIFIED: '" + model + "' is now fully recognized!");
} else {
System.out.println("❌ Diagnostic failed even after restart. Please check if the file is in 'models/loras'.");
}
} else {
System.out.println("❌ ComfyUI failed to start within 60 seconds.");
}
context.close();
}
private void copyResource(String name, Path target) throws Exception {
try (InputStream is = getClass().getResourceAsStream(name)) {
Files.copy(is, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
@@ -0,0 +1,33 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import de.tki.comfymodels.service.impl.ConfigService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import static org.junit.jupiter.api.Assertions.*;
/**
* Rapid Diagnostic Test for Refresh Mechanisms.
* This test uses the ComfyDiagnosticService to verify if ComfyUI actually sees
* the models after our refresh calls.
*/
public class RefreshDiagnosticTest {
@Test
public void testModelVisibility() {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
System.out.println("--- ComfyUI Diagnostic Scan ---");
boolean reachable = diagnosticService.isModelAvailableInComfy("non_existent_model_xyz", "checkpoint");
// If the API is up, it should at least return false for a non-existent model
// If it throws an exception or timeout, the API is down.
System.out.println("API Reachable: " + !reachable);
context.close();
}
}
@@ -3,6 +3,7 @@ package de.tki.comfymodels.service;
import de.tki.comfymodels.domain.ModelInfo;
import de.tki.comfymodels.service.impl.ArchiveService;
import de.tki.comfymodels.service.impl.LocalModelScanner;
import de.tki.comfymodels.service.impl.PathResolver;
import de.tki.comfymodels.service.impl.RestBridgeService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -31,7 +32,7 @@ class RestBridgeServiceTest {
private final String testToken = "test-token";
private static class LocalModelScannerStub extends LocalModelScanner {
public LocalModelScannerStub() { super(null); }
public LocalModelScannerStub() { super(null, new PathResolver()); }
@Override
public java.util.List<ModelInfo> scanLocalModels() {
return Collections.singletonList(new ModelInfo("checkpoints", "local_test.safetensors", "LOCAL"));
@@ -28,9 +28,9 @@ public class RestorationStatusTest {
private Path tempArchiveDir;
// Status Constants (as used in Main.java)
public static final String STATUS_FOUND_IN_ARCHIVE = "📦 Found in Archive";
public static final String STATUS_ARCHIVED = "📦 Archived";
public static final String STATUS_RESTORING = "📦 Restoring...";
public static final String STATUS_RESTORED = "✅ Restored";
public static final String STATUS_RESTORED = "✅ Already exists";
public static final String STATUS_RESTORE_FAILED = "❌ Restore Failed";
@BeforeEach
@@ -76,8 +76,8 @@ public class RestorationStatusTest {
List<String> statusSequence = new ArrayList<>();
// Initial state (determined by UI/Logic)
statusSequence.add(STATUS_FOUND_IN_ARCHIVE);
assertEquals(STATUS_FOUND_IN_ARCHIVE, statusSequence.get(0));
statusSequence.add(STATUS_ARCHIVED);
assertEquals(STATUS_ARCHIVED, statusSequence.get(0));
// 3. Act: Start Restoration
statusSequence.add(STATUS_RESTORING);
@@ -109,7 +109,7 @@ public class RestorationStatusTest {
// 2. Define transitions tracker
List<String> statusSequence = new ArrayList<>();
statusSequence.add(STATUS_FOUND_IN_ARCHIVE);
statusSequence.add(STATUS_ARCHIVED);
statusSequence.add(STATUS_RESTORING);
// 3. Act: Attempt Restoration
@@ -0,0 +1,48 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class StabilityValidationTest {
@Test
public void validateStability() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
System.out.println("--- 🛡️ STARTING STABILITY VALIDATION ---");
for (int i = 1; i <= 3; i++) {
System.out.println("Cycle " + i + ": Triggering Refresh...");
HttpRequest refreshReq = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:8000/cmfd/refresh-models"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"force_reload\": false}"))
.build();
HttpResponse<String> response = client.send(refreshReq, HttpResponse.BodyHandlers.ofString());
System.out.println("📡 Server Response: " + response.statusCode());
Thread.sleep(2000);
// Check if API is still alive
if (diagnosticService.isModelAvailableInComfy("ltx2.3-transition.safetensors", "loras")) {
System.out.println("✅ Cycle " + i + ": Server is healthy and model is visible.");
} else {
System.out.println("❌ Cycle " + i + ": Model visibility check failed.");
}
}
System.out.println("--- 🛡️ STABILITY VALIDATION COMPLETE ---");
context.close();
}
}
@@ -0,0 +1,86 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.ComfyLifecycleService;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.io.InputStream;
public class UltimateGuiSyncTest {
@Test
public void ensureGuiSyncs() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ConfigService configService = context.getBean(ConfigService.class);
ComfyLifecycleService lifecycleService = context.getBean(ComfyLifecycleService.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
HttpClient client = HttpClient.newBuilder().build();
System.out.println("--- 🚀 ULTIMATE GUI SYNC SEQUENCE ---");
// 1. HARD REINSTALL OF BRIDGE FILES
String comfyPath = "C:\\AI\\ComfyUI\\resources\\ComfyUI";
Path bridgeDir = Paths.get(comfyPath, "custom_nodes", "comfyui-model-downloader");
System.out.println("📦 Enforcing Latest Bridge in: " + bridgeDir);
Files.createDirectories(bridgeDir.resolve("web"));
copyResource("/comfyui-bridge/__init__.py", bridgeDir.resolve("__init__.py"));
copyResource("/comfyui-bridge/web/downloader.js", bridgeDir.resolve("web/downloader.js"));
Files.writeString(bridgeDir.resolve("web/config.json"), "{\"token\": \"" + configService.getApiToken() + "\"}");
// 2. FORCE RESTART BACKEND (To load new Python bridge)
System.out.println("⏹ Terminating ComfyUI for Clean State...");
Runtime.getRuntime().exec("taskkill /F /IM python.exe /T").waitFor();
Thread.sleep(2000);
System.out.println("▶ Starting ComfyUI...");
lifecycleService.start();
// 3. WAIT FOR BACKEND READINESS
System.out.println("⏳ Waiting for API...");
for (int i = 0; i < 90; i++) {
if (lifecycleService.isHealthy()) break;
Thread.sleep(1000);
}
// 4. TRIGGER NUCLEAR FRONTEND RELOAD
// This will force every open browser tab to run window.location.reload()
System.out.println("☢️ Triggering Nuclear Browser Reload...");
HttpRequest reloadReq = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:8000/cmfd/refresh-models"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"force_reload\": true}"))
.build();
try {
client.send(reloadReq, HttpResponse.BodyHandlers.ofString());
System.out.println("✅ Reload signal broadcasted to all tabs.");
} catch (Exception ignored) {}
// 5. FINAL VERIFICATION
String model = "ltx2.3-transition.safetensors";
if (diagnosticService.isModelAvailableInComfy(model, "loras")) {
System.out.println("🎯 TARGET ACHIEVED: Backend is synchronized.");
System.out.println("Check your browser now. All tabs should have reloaded and the model should be visible!");
} else {
System.out.println("❌ Critical Failure: Model still missing from API.");
}
context.close();
}
private void copyResource(String name, Path target) throws Exception {
try (InputStream is = getClass().getResourceAsStream(name)) {
Files.copy(is, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
+47 -10
View File
@@ -21,29 +21,66 @@ const initializeExtension = async () => {
};
await loadConfig();
// Event Listener for UI Refresh
api.addEventListener("cmfd-refresh-ui", async (event) => {
const data = event.detail || {};
const forceReload = data.force_reload === true;
console.log(`%c🔥 [CMFD] Refresh Signal Received. Force Reload: ${forceReload}`, "background: #ff0000; color: #fff; font-size: 14px;");
if (forceReload) {
window.location.reload();
return;
}
try {
// 1. Clear Frontend Caches
api.nodeDefs = null;
// 2. Trigger standard ComfyUI refresh
if (app.refresh) {
await app.refresh();
}
// 3. Update existing combo widgets
const nodeDefs = await api.getNodeDefs();
if (app.graph && app.graph._nodes) {
for (const node of app.graph._nodes) {
const def = nodeDefs[node.type];
if (!def || !node.widgets) continue;
for (const widget of node.widgets) {
if (widget.type === "combo") {
const inputDef = def.input?.required?.[widget.name] || def.input?.optional?.[widget.name];
if (inputDef && Array.isArray(inputDef[0])) {
widget.options.values = inputDef[0];
}
}
}
node.setDirtyCanvas(true, true);
}
}
console.log("[CMFD] UI Sync Complete.");
} catch (e) {
console.error("[CMFD] UI Refresh Failed:", e);
}
});
const addUI = () => {
if (document.getElementById("tki-downloader-fab")) return;
const fab = document.createElement("div");
fab.id = "tki-downloader-fab";
fab.innerHTML = "🚀";
fab.title = "An Model Downloader senden";
fab.style = "position:fixed; bottom:30px; right:30px; z-index:10000; cursor:pointer; font-size:30px; background:#ffcc00; border-radius:50%; width:60px; height:60px; display:flex; align-items:center; justify-content:center; box-shadow:0 0 20px rgba(0,0,0,0.5); border: 2px solid white; transition: transform 0.2s;";
fab.onmouseover = () => fab.style.transform = "scale(1.1)";
fab.onmouseout = () => fab.style.transform = "scale(1.0)";
fab.onclick = async () => {
if (!app?.graph) return;
try {
const response = await fetch("http://127.0.0.1:12345/import", {
await fetch("http://127.0.0.1:12345/import", {
method: "POST", mode: "cors",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiToken}` },
body: JSON.stringify(app.graph.serialize())
});
if (response.ok) {
if (app.ui?.dialog?.show) app.ui.dialog.show("🚀 Workflow an Downloader gesendet!");
}
} catch (e) {
alert("❌ Downloader App nicht erreichbar!");
}
} catch (e) {}
};
document.body.appendChild(fab);
};