From cd9ae9c55b96f56234ce73e4045c856f208c22d5 Mon Sep 17 00:00:00 2001
From: danohn <82357071+danohn@users.noreply.github.com>
Date: Tue, 17 Feb 2026 14:53:00 +1100
Subject: [PATCH 1/8] Plan modularizing App.jsx
---
package-lock.json | 347 ++++++++-
package.json | 6 +-
src/App.jsx | 839 ++-------------------
src/features/templates/TemplateBrowser.jsx | 261 +++++++
src/features/templates/TemplateModals.jsx | 195 +++++
src/lib/apiUrl.js | 29 +
src/lib/apiUrl.test.js | 28 +
src/lib/modelRequirements.js | 98 +++
src/lib/modelRequirements.test.js | 53 ++
src/lib/templateIndex.js | 150 ++++
src/lib/templateIndex.test.js | 41 +
src/lib/workflowPrompt.js | 77 ++
src/lib/workflowPrompt.test.js | 39 +
13 files changed, 1376 insertions(+), 787 deletions(-)
create mode 100644 src/features/templates/TemplateBrowser.jsx
create mode 100644 src/features/templates/TemplateModals.jsx
create mode 100644 src/lib/apiUrl.js
create mode 100644 src/lib/apiUrl.test.js
create mode 100644 src/lib/modelRequirements.js
create mode 100644 src/lib/modelRequirements.test.js
create mode 100644 src/lib/templateIndex.js
create mode 100644 src/lib/templateIndex.test.js
create mode 100644 src/lib/workflowPrompt.js
create mode 100644 src/lib/workflowPrompt.test.js
diff --git a/package-lock.json b/package-lock.json
index b3022b1..29de69b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,8 @@
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
- "vite": "^7.3.1"
+ "vite": "^7.3.1",
+ "vitest": "^4.0.18"
}
},
"node_modules/@alloc/quick-lru": {
@@ -1165,6 +1166,13 @@
"win32"
]
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@tailwindcss/node": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
@@ -1481,6 +1489,24 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1509,6 +1535,127 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
+ "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "chai": "^6.2.1",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
+ "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.0.18",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
+ "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
+ "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.0.18",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
+ "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
+ "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
+ "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/autoprefixer": {
"version": "10.4.24",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
@@ -1611,6 +1758,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1680,6 +1837,13 @@
"node": ">=10.13.0"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -1732,6 +1896,26 @@
"node": ">=6"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2153,6 +2337,24 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2345,6 +2547,13 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2355,6 +2564,20 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -2376,6 +2599,23 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2393,6 +2633,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
+ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2499,6 +2749,101 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
+ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.0.18",
+ "@vitest/mocker": "4.0.18",
+ "@vitest/pretty-format": "4.0.18",
+ "@vitest/runner": "4.0.18",
+ "@vitest/snapshot": "4.0.18",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^3.10.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.18",
+ "@vitest/browser-preview": "4.0.18",
+ "@vitest/browser-webdriverio": "4.0.18",
+ "@vitest/ui": "4.0.18",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index 747e086..545fc96 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run"
},
"dependencies": {
"react": "^19.2.4",
@@ -18,6 +19,7 @@
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
- "vite": "^7.3.1"
+ "vite": "^7.3.1",
+ "vitest": "^4.0.18"
}
}
diff --git a/src/App.jsx b/src/App.jsx
index ca5a268..ea62d83 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -4,10 +4,15 @@ import defaultWorkflow from '../01_get_started_text_to_image.json'
import useApiConfig from './hooks/useApiConfig'
import useWorkflowConfig from './hooks/useWorkflowConfig'
import useGeneration from './hooks/useGeneration'
+import TemplateBrowser from './features/templates/TemplateBrowser'
+import TemplateModals from './features/templates/TemplateModals'
+import { buildApiUrlFromParts, normalizeBaseUrl, parseApiUrlParts } from './lib/apiUrl'
+import { collectWorkflowModelRequirements } from './lib/modelRequirements'
+import { extractIndexedTemplates, extractWorkflowTemplates } from './lib/templateIndex'
+import { analyzeWorkflowPromptInputs, extractPromptFromGraph } from './lib/workflowPrompt'
const REMOTE_TEMPLATES_BASE_URL = 'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates'
const REMOTE_TEMPLATES_INDEX_URL = `${REMOTE_TEMPLATES_BASE_URL}/index.json`
-const EXCLUDED_TEMPLATE_TAGS = new Set(['api'])
export default function App() {
const navigate = useNavigate()
@@ -136,36 +141,6 @@ export default function App() {
setNegativePromptText(promptInfo.defaultNegativePrompt)
}, [workflow])
- function normalizeBaseUrl(url) {
- return url.trim().replace(/\/prompt\/?$/, '')
- }
-
- function parseApiUrlParts(url) {
- const fallback = { protocol: 'http', host: '', port: '8188' }
- const raw = (url || '').trim()
- if (!raw) return fallback
-
- try {
- const normalized = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`
- const parsed = new URL(normalized)
- return {
- protocol: parsed.protocol.replace(':', '') || 'http',
- host: parsed.hostname || '',
- port: parsed.port || '8188',
- }
- } catch (_) {
- return fallback
- }
- }
-
- function buildApiUrlFromParts({ protocol, host, port }) {
- const cleanHost = String(host || '').trim()
- if (!cleanHost) return ''
- const cleanProtocol = protocol === 'https' ? 'https' : 'http'
- const cleanPort = String(port || '').trim()
- return cleanPort ? `${cleanProtocol}://${cleanHost}:${cleanPort}` : `${cleanProtocol}://${cleanHost}`
- }
-
function syncApiFieldsFromUrl(url) {
const parsed = parseApiUrlParts(url)
setApiProtocol(parsed.protocol)
@@ -184,73 +159,6 @@ export default function App() {
return () => clearTimeout(id)
}, [toast])
- function analyzeWorkflowPromptInputs(graph) {
- if (!graph || typeof graph !== 'object') {
- return { mode: 'single', defaultPrompt: '', defaultNegativePrompt: '' }
- }
-
- const positiveRefs = new Set()
- const negativeRefs = new Set()
- for (const node of Object.values(graph)) {
- const classType = String(node?.class_type || '').toLowerCase()
- if (!classType.includes('ksampler')) continue
- const positive = node?.inputs?.positive
- const negative = node?.inputs?.negative
- if (Array.isArray(positive) && positive.length > 0) positiveRefs.add(String(positive[0]))
- if (Array.isArray(negative) && negative.length > 0) negativeRefs.add(String(negative[0]))
- }
-
- const promptLikeNodes = []
- for (const [nodeId, node] of Object.entries(graph)) {
- if (!node || typeof node !== 'object') continue
- const textValue = node?.inputs?.text
- if (typeof textValue !== 'string') continue
- const classType = String(node?.class_type || '').toLowerCase()
- const title = String(node?._meta?.title || '').toLowerCase()
- const looksLikePromptNode = classType.includes('cliptextencode') || title.includes('prompt')
- if (!looksLikePromptNode) continue
- const isNegative = title.includes('negative') || title.includes('neg')
- promptLikeNodes.push({ id: String(nodeId), text: textValue, isNegative })
- }
-
- const firstTextForRefs = (refs) => {
- for (const ref of refs) {
- const textValue = graph?.[ref]?.inputs?.text
- if (typeof textValue === 'string') return textValue
- }
- return ''
- }
-
- const mappedPrompt = firstTextForRefs(positiveRefs)
- const mappedNegative = firstTextForRefs(negativeRefs)
- if (positiveRefs.size > 0 || negativeRefs.size > 0) {
- return {
- mode: negativeRefs.size > 0 ? 'dual' : 'single',
- defaultPrompt: mappedPrompt || '',
- defaultNegativePrompt: mappedNegative || '',
- }
- }
-
- if (promptLikeNodes.length === 0) {
- return { mode: 'single', defaultPrompt: '', defaultNegativePrompt: '' }
- }
- if (promptLikeNodes.length === 1) {
- return { mode: 'single', defaultPrompt: promptLikeNodes[0].text || '', defaultNegativePrompt: '' }
- }
-
- const negativeNode = promptLikeNodes.find((node) => node.isNegative)
- const positiveNode = promptLikeNodes.find((node) => !node.isNegative) || promptLikeNodes[0]
- if (negativeNode && positiveNode) {
- return {
- mode: 'dual',
- defaultPrompt: positiveNode.text || '',
- defaultNegativePrompt: negativeNode.text || '',
- }
- }
-
- return { mode: 'single', defaultPrompt: positiveNode.text || '', defaultNegativePrompt: '' }
- }
-
function openSettingsPage() {
setSettingsUrl(apiUrl)
syncApiFieldsFromUrl(apiUrl)
@@ -338,153 +246,6 @@ export default function App() {
setInputImageName('')
}
- function extractWorkflowTemplates(raw) {
- if (!raw || typeof raw !== 'object') return []
-
- const templates = []
- const pushTemplate = (id, label, workflowData, extra = {}) => {
- if (workflowData && typeof workflowData === 'object') {
- templates.push({
- id,
- label,
- title: extra.title || label,
- description: extra.description || '',
- mediaType: extra.mediaType || 'unknown',
- tags: Array.isArray(extra.tags) ? extra.tags : [],
- models: Array.isArray(extra.models) ? extra.models : [],
- openSource: extra.openSource,
- usage: typeof extra.usage === 'number' ? extra.usage : 0,
- date: typeof extra.date === 'string' ? extra.date : '',
- io: extra.io && typeof extra.io === 'object' ? extra.io : null,
- tutorialUrl: typeof extra.tutorialUrl === 'string' ? extra.tutorialUrl : '',
- requiresCustomNodes: Array.isArray(extra.requiresCustomNodes) ? extra.requiresCustomNodes : [],
- includeOnDistributions: Array.isArray(extra.includeOnDistributions) ? extra.includeOnDistributions : [],
- searchRank: typeof extra.searchRank === 'number' ? extra.searchRank : undefined,
- size: typeof extra.size === 'number' ? extra.size : undefined,
- vram: typeof extra.vram === 'number' ? extra.vram : undefined,
- status: typeof extra.status === 'string' ? extra.status : '',
- category: extra.category || 'Templates',
- categoryGroup: extra.categoryGroup || 'Templates',
- isEssential: Boolean(extra.isEssential),
- thumbnailUrl: extra.thumbnailUrl || null,
- workflowUrl: null,
- workflow: workflowData,
- source: 'server',
- })
- }
- }
-
- for (const [groupKey, groupValue] of Object.entries(raw)) {
- if (Array.isArray(groupValue)) {
- for (let i = 0; i < groupValue.length; i++) {
- const item = groupValue[i]
- if (!item || typeof item !== 'object') continue
- const workflowData = item.workflow || item.prompt || item.data?.workflow || item.data?.prompt
- const label = item.name || item.title || `${groupKey} template ${i + 1}`
- pushTemplate(`${groupKey}:${label}:${i}`, `${groupKey}: ${label}`, workflowData, {
- title: item.title || label,
- description: item.description || '',
- mediaType: item.mediaType || item.type || 'unknown',
- tags: item.tags,
- models: item.models,
- openSource: item.openSource,
- usage: item.usage,
- date: item.date,
- io: item.io,
- tutorialUrl: item.tutorialUrl,
- requiresCustomNodes: item.requiresCustomNodes,
- includeOnDistributions: item.includeOnDistributions,
- searchRank: item.searchRank,
- size: item.size,
- vram: item.vram,
- status: item.status,
- category: groupKey,
- categoryGroup: 'Custom',
- })
- }
- } else if (groupValue && typeof groupValue === 'object') {
- for (const [templateKey, templateValue] of Object.entries(groupValue)) {
- if (!templateValue || typeof templateValue !== 'object') continue
- const workflowData = templateValue.workflow || templateValue.prompt || templateValue.data?.workflow || templateValue.data?.prompt
- const label = templateValue.name || templateValue.title || templateKey
- pushTemplate(`${groupKey}:${templateKey}`, `${groupKey}: ${label}`, workflowData, {
- title: templateValue.title || label,
- description: templateValue.description || '',
- mediaType: templateValue.mediaType || templateValue.type || 'unknown',
- tags: templateValue.tags,
- models: templateValue.models,
- openSource: templateValue.openSource,
- usage: templateValue.usage,
- date: templateValue.date,
- io: templateValue.io,
- tutorialUrl: templateValue.tutorialUrl,
- requiresCustomNodes: templateValue.requiresCustomNodes,
- includeOnDistributions: templateValue.includeOnDistributions,
- searchRank: templateValue.searchRank,
- size: templateValue.size,
- vram: templateValue.vram,
- status: templateValue.status,
- category: groupKey,
- categoryGroup: 'Custom',
- })
- }
- }
- }
-
- return templates
- }
-
- function extractIndexedTemplates(raw, templatesBaseUrl, source) {
- if (!Array.isArray(raw)) return []
-
- const templates = []
- for (const section of raw) {
- if (!section || typeof section !== 'object') continue
- const sectionTitle = section.title || section.category || section.type || 'Templates'
- const sectionTemplates = Array.isArray(section.templates) ? section.templates : []
- for (const template of sectionTemplates) {
- if (!template || typeof template !== 'object') continue
- const tags = Array.isArray(template.tags) ? template.tags : []
- const hasExcludedTag = tags.some((tag) => EXCLUDED_TEMPLATE_TAGS.has(String(tag || '').trim().toLowerCase()))
- const isApiBased = hasExcludedTag || template.openSource === false
- if (isApiBased) continue
- const name = template.name
- if (!name || typeof name !== 'string') continue
- const displayName = template.title || template.name
- const mediaSubtype = template.mediaSubtype || 'webp'
- templates.push({
- id: `${source}:${sectionTitle}:${name}`,
- label: `${sectionTitle}: ${displayName}`,
- title: displayName,
- description: template.description || '',
- mediaType: template.mediaType || section.type || 'unknown',
- tags,
- models: Array.isArray(template.models) ? template.models : [],
- openSource: template.openSource,
- usage: typeof template.usage === 'number' ? template.usage : 0,
- date: typeof template.date === 'string' ? template.date : '',
- io: template.io && typeof template.io === 'object' ? template.io : null,
- tutorialUrl: typeof template.tutorialUrl === 'string' ? template.tutorialUrl : '',
- requiresCustomNodes: Array.isArray(template.requiresCustomNodes) ? template.requiresCustomNodes : [],
- includeOnDistributions: Array.isArray(template.includeOnDistributions) ? template.includeOnDistributions : [],
- searchRank: typeof template.searchRank === 'number' ? template.searchRank : undefined,
- size: typeof template.size === 'number' ? template.size : undefined,
- vram: typeof template.vram === 'number' ? template.vram : undefined,
- status: typeof template.status === 'string' ? template.status : '',
- category: sectionTitle,
- categoryGroup: section.category || 'Templates',
- isEssential: Boolean(section.isEssential),
- thumbnailUrl: `${templatesBaseUrl}/${encodeURIComponent(name)}-1.${encodeURIComponent(mediaSubtype)}`,
- source,
- workflowUrl: `${templatesBaseUrl}/${encodeURIComponent(name)}.json`,
- workflow: null,
- })
- }
- }
-
- return templates
- }
-
async function fetchTemplateIndex(indexUrl, templatesBaseUrl, source) {
const res = await fetch(indexUrl)
if (!res.ok) {
@@ -567,116 +328,6 @@ export default function App() {
}
}
- function extractPromptFromGraph(graph) {
- if (!graph || typeof graph !== 'object') return ''
- for (const node of Object.values(graph)) {
- const classType = String(node?.class_type || '').toLowerCase()
- if (classType.includes('cliptextencode') && typeof node?.inputs?.text === 'string') {
- return node.inputs.text
- }
- }
- return ''
- }
-
- function extractRequiredModelsFromWorkflow(workflowData) {
- const required = []
- const seenNodes = new Set()
- const dedupe = new Set()
-
- function walk(value) {
- if (!value || typeof value !== 'object') return
- if (seenNodes.has(value)) return
- seenNodes.add(value)
-
- if (Array.isArray(value.models)) {
- for (const model of value.models) {
- if (!model || typeof model !== 'object') continue
- const name = typeof model.name === 'string' ? model.name : ''
- const directory = typeof model.directory === 'string' ? model.directory : ''
- const url = typeof model.url === 'string' ? model.url : ''
- if (!name) continue
- const key = `${directory}::${name}::${url}`
- if (dedupe.has(key)) continue
- dedupe.add(key)
- required.push({ name, directory, url })
- }
- }
-
- if (Array.isArray(value)) {
- for (const item of value) walk(item)
- return
- }
- for (const nested of Object.values(value)) walk(nested)
- }
-
- walk(workflowData)
- return required
- }
-
- function inferModelDirectory(inputName, classType) {
- const key = String(inputName || '').toLowerCase()
- const type = String(classType || '').toLowerCase()
- if (key === 'ckpt_name') return 'checkpoints'
- if (key === 'unet_name') return 'diffusion_models'
- if (key === 'vae_name') return 'vae'
- if (key === 'lora_name') return 'loras'
- if (key === 'control_net_name') return 'controlnet'
- if (key === 'style_model_name') return 'style_models'
- if (key === 'clip_name') return type.includes('dualclip') || type.includes('cliploader') ? 'text_encoders' : null
- if (key.endsWith('_name')) return null
- return null
- }
-
- function extractNamedLoaderModelsFromWorkflow(workflowData) {
- if (!workflowData || typeof workflowData !== 'object' || Array.isArray(workflowData)) return []
- const requirements = []
- const dedupe = new Set()
-
- for (const node of Object.values(workflowData)) {
- if (!node || typeof node !== 'object') continue
- const inputs = node.inputs
- if (!inputs || typeof inputs !== 'object') continue
- const classType = String(node.class_type || '')
-
- for (const [inputName, inputValue] of Object.entries(inputs)) {
- if (typeof inputValue !== 'string') continue
- const directory = inferModelDirectory(inputName, classType)
- if (!directory) continue
- const key = `${directory}::${inputValue}`
- if (dedupe.has(key)) continue
- dedupe.add(key)
- requirements.push({
- name: inputValue,
- directory,
- url: '',
- })
- }
- }
-
- return requirements
- }
-
- function collectWorkflowModelRequirements(workflowData) {
- const metadataModels = extractRequiredModelsFromWorkflow(workflowData)
- const namedModels = extractNamedLoaderModelsFromWorkflow(workflowData)
- const merged = new Map()
-
- for (const model of [...metadataModels, ...namedModels]) {
- const directory = String(model?.directory || '').toLowerCase()
- const name = String(model?.name || '')
- if (!name || !directory) continue
- const key = `${directory}::${name.toLowerCase()}`
- const existing = merged.get(key)
- if (!existing) {
- merged.set(key, { ...model, directory, name })
- } else if (!existing.url && model.url) {
- merged.set(key, { ...existing, url: model.url })
- }
- }
-
- return Array.from(merged.values())
- }
-
async function evaluateWorkflowPrerequisites(workflowData) {
const baseUrl = normalizeBaseUrl(apiUrl)
const inventory = await fetchModelInventory(baseUrl)
@@ -1619,245 +1270,39 @@ export default function App() {
filteredTemplates,
} = getTemplateBrowserData()
return (
-
-
Server Templates
-
- {serverTemplates.length > 0
- ? templateSource === 'local-index'
- ? `${serverTemplates.length} templates available from /templates/index.json`
- : templateSource === 'remote'
- ? `${serverTemplates.length} templates available from remote index`
- : `${serverTemplates.length} templates available from server`
- : 'No templates available from server or remote index'}
-
-
-
-
- Templates
-
- setSelectedTemplateCategory('all')}
- className={`w-full text-left px-3 py-2 rounded-lg text-sm ${
- selectedTemplateCategory === 'all'
- ? 'bg-slate-200 text-slate-900 font-medium'
- : 'text-slate-700 hover:bg-slate-100'
- }`}
- >
- All Templates
-
- setSelectedTemplateCategory('popular')}
- className={`w-full text-left px-3 py-2 rounded-lg text-sm ${
- selectedTemplateCategory === 'popular'
- ? 'bg-slate-200 text-slate-900 font-medium'
- : 'text-slate-700 hover:bg-slate-100'
- }`}
- >
- Popular
-
-
-
- {sidebarCategories.map((group) => (
-
-
- {group.groupName}
-
-
- {group.categories.map((categoryName) => (
- setSelectedTemplateCategory(categoryName)}
- className={`w-full text-left px-3 py-2 rounded-lg text-sm ${
- selectedTemplateCategory === categoryName
- ? 'bg-slate-200 text-slate-900 font-medium'
- : 'text-slate-700 hover:bg-slate-100'
- }`}
- >
- {categoryName}
-
- ))}
-
-
- ))}
-
-
-
-
-
- setTemplateSearch(e.target.value)}
- placeholder="Search templates"
- className="min-w-[240px] flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
- />
- {
- setTemplateSearch('')
- setTemplateModelFilter('')
- setTemplateTagFilter('')
- setTemplateSort('default')
- }}
- className="px-3 py-2 bg-slate-100 text-slate-800 text-sm rounded-lg font-medium hover:bg-slate-200"
- >
- Clear Filters
-
-
-
-
setTemplateModelFilter(e.target.value)}
- className="px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
- >
- Model Filter
- {availableTemplateModels.map((model) => (
- {model}
- ))}
-
-
setTemplateTagFilter(e.target.value)}
- className="px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
- >
- Tasks
- {availableTemplateTags.map((tag) => (
- {tag}
- ))}
-
-
- Runs On: ComfyUI
-
-
setTemplateSort(e.target.value)}
- disabled={selectedTemplateCategory === 'popular'}
- className="px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900 disabled:opacity-50"
- >
- Default
- Popular
- Newest
- Alphabetical
-
-
-
- Showing {filteredTemplates.length} of {serverTemplates.length} templates
-
- {filteredTemplates.length > 0 ? (
-
- {filteredTemplates.map((template) => (
-
- {template.thumbnailUrl ? (
-
- ) : (
-
- )}
-
-
setSelectedTemplateDetails(template)}
- className="w-full text-left text-sm font-semibold text-slate-900 line-clamp-2 hover:underline"
- >
- {template.title || template.label}
-
-
setSelectedTemplateDetails(template)}
- className="w-full text-left text-xs text-slate-500 mt-1 line-clamp-1 hover:text-slate-700 hover:underline"
- >
- {template.description || 'No description provided'}
-
-
- {(template.category || template.mediaType || 'unknown')} · In:{' '}
- {Array.isArray(template?.io?.inputs) && template.io.inputs.length > 0
- ? Array.from(new Set(template.io.inputs.map((entry) => entry?.mediaType).filter(Boolean))).join(', ')
- : (template.mediaType || 'unknown')}
- {' '}· Out:{' '}
- {Array.isArray(template?.io?.outputs) && template.io.outputs.length > 0
- ? Array.from(new Set(template.io.outputs.map((entry) => entry?.mediaType).filter(Boolean))).join(', ')
- : 'unknown'}
-
-
- {Array.isArray(template.tags) && template.tags.length > 0 ? (
- <>
- {template.tags.slice(0, 2).map((tag) => (
-
- {tag}
-
- ))}
- {template.tags.length > 2 && (
-
- +{template.tags.length - 2}
-
- )}
- >
- ) : (
- No tags
- )}
-
-
-
setSelectedTemplateDetails(template)}
- className="w-full text-center text-[11px] text-slate-600 hover:text-slate-900 underline decoration-dotted"
- >
- Details
-
-
{
- setSelectedPrereqTemplateId(template.id)
- await checkTemplateModels(template.id)
- }}
- disabled={modelCheckByTemplate[template.id]?.loading || isLoadingModelInventory}
- className="mt-2 w-full px-3 py-2 bg-slate-100 text-slate-800 text-sm rounded-lg font-medium hover:bg-slate-200 disabled:opacity-60"
- >
- {modelCheckByTemplate[template.id]?.loading
- ? 'Checking prerequisites...'
- : isLoadingModelInventory
- ? 'Loading model inventory...'
- : 'Check Prerequisites'}
-
-
applyTemplate(template.id)}
- disabled={applyingTemplateId === template.id}
- className="mt-2 w-full px-3 py-2 bg-slate-900 text-white text-sm rounded-lg font-medium hover:bg-slate-800 disabled:opacity-60"
- >
- {applyingTemplateId === template.id ? 'Applying...' : 'Apply Template'}
-
- {modelCheckByTemplate[template.id]?.error && (
-
{modelCheckByTemplate[template.id].error}
- )}
- {modelCheckByTemplate[template.id] && !modelCheckByTemplate[template.id]?.loading && !modelCheckByTemplate[template.id]?.error && (
-
- {modelCheckByTemplate[template.id].missing.length === 0
- ? 'All required models available'
- : `${modelCheckByTemplate[template.id].missing.length} missing of ${modelCheckByTemplate[template.id].total} required`}
-
- )}
-
-
-
- ))}
-
- ) : (
-
- {serverTemplates.length > 0 ? 'No templates match your filters.' : 'No templates loaded yet.'}
-
- )}
-
-
-
-
+ {
+ setTemplateSearch('')
+ setTemplateModelFilter('')
+ setTemplateTagFilter('')
+ setTemplateSort('default')
+ }}
+ onModelFilterChange={setTemplateModelFilter}
+ onTagFilterChange={setTemplateTagFilter}
+ onSortChange={setTemplateSort}
+ onOpenDetails={setSelectedTemplateDetails}
+ onCheckPrerequisites={async (templateId) => {
+ setSelectedPrereqTemplateId(templateId)
+ await checkTemplateModels(templateId)
+ }}
+ onApplyTemplate={applyTemplate}
+ />
)
}
@@ -1869,196 +1314,22 @@ export default function App() {
? modelCheckByTemplate[selectedPrereqTemplateId]
: null
return (
- <>
- {selectedTemplateDetails && (
-
-
-
-
-
- {selectedTemplateDetails.title || selectedTemplateDetails.label}
-
-
- {selectedTemplateDetails.categoryGroup || 'Templates'} / {selectedTemplateDetails.category || 'General'}
-
-
-
setSelectedTemplateDetails(null)}
- className="px-2 py-1 text-sm rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
- >
- Close
-
-
-
- {selectedTemplateDetails.thumbnailUrl && (
-
- )}
-
-
-
-
Description
-
- {selectedTemplateDetails.description || 'No description provided'}
-
-
-
-
-
-
Generation Type
-
{selectedTemplateDetails.category || selectedTemplateDetails.mediaType || 'unknown'}
-
-
-
Input Type
-
- {Array.isArray(selectedTemplateDetails?.io?.inputs) && selectedTemplateDetails.io.inputs.length > 0
- ? Array.from(new Set(selectedTemplateDetails.io.inputs.map((entry) => entry?.mediaType).filter(Boolean))).join(', ')
- : (selectedTemplateDetails.mediaType || 'unknown')}
-
-
-
-
Output Type
-
- {Array.isArray(selectedTemplateDetails?.io?.outputs) && selectedTemplateDetails.io.outputs.length > 0
- ? Array.from(new Set(selectedTemplateDetails.io.outputs.map((entry) => entry?.mediaType).filter(Boolean))).join(', ')
- : 'unknown'}
-
-
-
-
Source
-
{selectedTemplateDetails.source || 'unknown'}
-
-
-
Date
-
{selectedTemplateDetails.date || 'unknown'}
-
-
-
Usage
-
{typeof selectedTemplateDetails.usage === 'number' ? selectedTemplateDetails.usage : 'unknown'}
-
-
-
- {Array.isArray(selectedTemplateDetails.models) && selectedTemplateDetails.models.length > 0 && (
-
-
Models
-
{selectedTemplateDetails.models.join(', ')}
-
- )}
-
- {Array.isArray(selectedTemplateDetails.tags) && selectedTemplateDetails.tags.length > 0 && (
-
-
Tags
-
{selectedTemplateDetails.tags.join(', ')}
-
- )}
-
- {Array.isArray(selectedTemplateDetails.requiresCustomNodes) && selectedTemplateDetails.requiresCustomNodes.length > 0 && (
-
-
Required Custom Nodes
-
{selectedTemplateDetails.requiresCustomNodes.join(', ')}
-
- )}
-
- {selectedTemplateDetails.tutorialUrl && (
-
- )}
-
-
-
- )}
-
- {selectedPrereqTemplate && (
-
-
-
-
-
Prerequisites
-
- {selectedPrereqTemplate.title || selectedPrereqTemplate.label}
-
-
-
setSelectedPrereqTemplateId(null)}
- className="px-2 py-1 text-sm rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
- >
- Close
-
-
-
- {!selectedPrereqResult || selectedPrereqResult.loading ? (
-
Checking prerequisites...
- ) : selectedPrereqResult.error ? (
-
{selectedPrereqResult.error}
- ) : (
-
-
- {selectedPrereqResult.missing.length === 0
- ? `All ${selectedPrereqResult.total} required models are available`
- : `${selectedPrereqResult.missing.length} missing of ${selectedPrereqResult.total} required models`}
-
- {selectedPrereqResult.missing.length > 0 && (
-
- {selectedPrereqResult.missing.map((model) => (
-
-
- {(model.directory || 'unknown')} / {model.name}
-
-
- {model.url ? (
- <>
-
- Download
-
-
{
- if (!model.url) return
- try {
- await navigator.clipboard.writeText(model.url)
- setError('Model URL copied to clipboard')
- } catch (_) {
- setError('Failed to copy model URL')
- }
- }}
- className="px-2 py-1 text-xs rounded-md bg-slate-200 text-slate-800 hover:bg-slate-300"
- >
- Copy URL
-
- >
- ) : (
-
No download URL provided
- )}
-
-
- ))}
-
- )}
-
- )}
-
-
- )}
- >
+ setSelectedTemplateDetails(null)}
+ onClosePrereq={() => setSelectedPrereqTemplateId(null)}
+ onCopyModelUrl={async (url) => {
+ if (!url) return
+ try {
+ await navigator.clipboard.writeText(url)
+ setError('Model URL copied to clipboard')
+ } catch (_) {
+ setError('Failed to copy model URL')
+ }
+ }}
+ />
)
}
diff --git a/src/features/templates/TemplateBrowser.jsx b/src/features/templates/TemplateBrowser.jsx
new file mode 100644
index 0000000..448cee1
--- /dev/null
+++ b/src/features/templates/TemplateBrowser.jsx
@@ -0,0 +1,261 @@
+import React from 'react'
+
+export default function TemplateBrowser({
+ serverTemplates,
+ templateSource,
+ selectedTemplateCategory,
+ templateSort,
+ templateSearch,
+ templateModelFilter,
+ templateTagFilter,
+ sidebarCategories,
+ availableTemplateModels,
+ availableTemplateTags,
+ filteredTemplates,
+ modelCheckByTemplate,
+ isLoadingModelInventory,
+ applyingTemplateId,
+ onSelectCategory,
+ onSearchChange,
+ onClearFilters,
+ onModelFilterChange,
+ onTagFilterChange,
+ onSortChange,
+ onOpenDetails,
+ onCheckPrerequisites,
+ onApplyTemplate,
+}) {
+ return (
+
+
Server Templates
+
+ {serverTemplates.length > 0
+ ? templateSource === 'local-index'
+ ? `${serverTemplates.length} templates available from /templates/index.json`
+ : templateSource === 'remote'
+ ? `${serverTemplates.length} templates available from remote index`
+ : `${serverTemplates.length} templates available from server`
+ : 'No templates available from server or remote index'}
+
+
+
+
+ Templates
+
+ onSelectCategory('all')}
+ className={`w-full text-left px-3 py-2 rounded-lg text-sm ${
+ selectedTemplateCategory === 'all'
+ ? 'bg-slate-200 text-slate-900 font-medium'
+ : 'text-slate-700 hover:bg-slate-100'
+ }`}
+ >
+ All Templates
+
+ onSelectCategory('popular')}
+ className={`w-full text-left px-3 py-2 rounded-lg text-sm ${
+ selectedTemplateCategory === 'popular'
+ ? 'bg-slate-200 text-slate-900 font-medium'
+ : 'text-slate-700 hover:bg-slate-100'
+ }`}
+ >
+ Popular
+
+
+
+ {sidebarCategories.map((group) => (
+
+
+ {group.groupName}
+
+
+ {group.categories.map((categoryName) => (
+ onSelectCategory(categoryName)}
+ className={`w-full text-left px-3 py-2 rounded-lg text-sm ${
+ selectedTemplateCategory === categoryName
+ ? 'bg-slate-200 text-slate-900 font-medium'
+ : 'text-slate-700 hover:bg-slate-100'
+ }`}
+ >
+ {categoryName}
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+ onSearchChange(e.target.value)}
+ placeholder="Search templates"
+ className="min-w-[240px] flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
+ />
+
+ Clear Filters
+
+
+
+
onModelFilterChange(e.target.value)}
+ className="px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
+ >
+ Model Filter
+ {availableTemplateModels.map((model) => (
+ {model}
+ ))}
+
+
onTagFilterChange(e.target.value)}
+ className="px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
+ >
+ Tasks
+ {availableTemplateTags.map((tag) => (
+ {tag}
+ ))}
+
+
+ Runs On: ComfyUI
+
+
onSortChange(e.target.value)}
+ disabled={selectedTemplateCategory === 'popular'}
+ className="px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900 disabled:opacity-50"
+ >
+ Default
+ Popular
+ Newest
+ Alphabetical
+
+
+
+ Showing {filteredTemplates.length} of {serverTemplates.length} templates
+
+ {filteredTemplates.length > 0 ? (
+
+ {filteredTemplates.map((template) => (
+
+ {template.thumbnailUrl ? (
+
+ ) : (
+
+ )}
+
+
onOpenDetails(template)}
+ className="w-full text-left text-sm font-semibold text-slate-900 line-clamp-2 hover:underline"
+ >
+ {template.title || template.label}
+
+
onOpenDetails(template)}
+ className="w-full text-left text-xs text-slate-500 mt-1 line-clamp-1 hover:text-slate-700 hover:underline"
+ >
+ {template.description || 'No description provided'}
+
+
+ {(template.category || template.mediaType || 'unknown')} · In:{' '}
+ {Array.isArray(template?.io?.inputs) && template.io.inputs.length > 0
+ ? Array.from(new Set(template.io.inputs.map((entry) => entry?.mediaType).filter(Boolean))).join(', ')
+ : (template.mediaType || 'unknown')}
+ {' '}· Out:{' '}
+ {Array.isArray(template?.io?.outputs) && template.io.outputs.length > 0
+ ? Array.from(new Set(template.io.outputs.map((entry) => entry?.mediaType).filter(Boolean))).join(', ')
+ : 'unknown'}
+
+
+ {Array.isArray(template.tags) && template.tags.length > 0 ? (
+ <>
+ {template.tags.slice(0, 2).map((tag) => (
+
+ {tag}
+
+ ))}
+ {template.tags.length > 2 && (
+
+ +{template.tags.length - 2}
+
+ )}
+ >
+ ) : (
+ No tags
+ )}
+
+
+
onOpenDetails(template)}
+ className="w-full text-center text-[11px] text-slate-600 hover:text-slate-900 underline decoration-dotted"
+ >
+ Details
+
+
onCheckPrerequisites(template.id)}
+ disabled={modelCheckByTemplate[template.id]?.loading || isLoadingModelInventory}
+ className="mt-2 w-full px-3 py-2 bg-slate-100 text-slate-800 text-sm rounded-lg font-medium hover:bg-slate-200 disabled:opacity-60"
+ >
+ {modelCheckByTemplate[template.id]?.loading
+ ? 'Checking prerequisites...'
+ : isLoadingModelInventory
+ ? 'Loading model inventory...'
+ : 'Check Prerequisites'}
+
+
onApplyTemplate(template.id)}
+ disabled={applyingTemplateId === template.id}
+ className="mt-2 w-full px-3 py-2 bg-slate-900 text-white text-sm rounded-lg font-medium hover:bg-slate-800 disabled:opacity-60"
+ >
+ {applyingTemplateId === template.id ? 'Applying...' : 'Apply Template'}
+
+ {modelCheckByTemplate[template.id]?.error && (
+
{modelCheckByTemplate[template.id].error}
+ )}
+ {modelCheckByTemplate[template.id] && !modelCheckByTemplate[template.id]?.loading && !modelCheckByTemplate[template.id]?.error && (
+
+ {modelCheckByTemplate[template.id].missing.length === 0
+ ? 'All required models available'
+ : `${modelCheckByTemplate[template.id].missing.length} missing of ${modelCheckByTemplate[template.id].total} required`}
+
+ )}
+
+
+
+ ))}
+
+ ) : (
+
+ {serverTemplates.length > 0 ? 'No templates match your filters.' : 'No templates loaded yet.'}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/features/templates/TemplateModals.jsx b/src/features/templates/TemplateModals.jsx
new file mode 100644
index 0000000..d7713f4
--- /dev/null
+++ b/src/features/templates/TemplateModals.jsx
@@ -0,0 +1,195 @@
+import React from 'react'
+
+export default function TemplateModals({
+ selectedTemplateDetails,
+ selectedPrereqTemplate,
+ selectedPrereqResult,
+ onCloseDetails,
+ onClosePrereq,
+ onCopyModelUrl,
+}) {
+ return (
+ <>
+ {selectedTemplateDetails && (
+
+
+
+
+
+ {selectedTemplateDetails.title || selectedTemplateDetails.label}
+
+
+ {selectedTemplateDetails.categoryGroup || 'Templates'} / {selectedTemplateDetails.category || 'General'}
+
+
+
+ Close
+
+
+
+ {selectedTemplateDetails.thumbnailUrl && (
+
+ )}
+
+
+
+
Description
+
+ {selectedTemplateDetails.description || 'No description provided'}
+
+
+
+
+
+
Generation Type
+
{selectedTemplateDetails.category || selectedTemplateDetails.mediaType || 'unknown'}
+
+
+
Input Type
+
+ {Array.isArray(selectedTemplateDetails?.io?.inputs) && selectedTemplateDetails.io.inputs.length > 0
+ ? Array.from(new Set(selectedTemplateDetails.io.inputs.map((entry) => entry?.mediaType).filter(Boolean))).join(', ')
+ : (selectedTemplateDetails.mediaType || 'unknown')}
+
+
+
+
Output Type
+
+ {Array.isArray(selectedTemplateDetails?.io?.outputs) && selectedTemplateDetails.io.outputs.length > 0
+ ? Array.from(new Set(selectedTemplateDetails.io.outputs.map((entry) => entry?.mediaType).filter(Boolean))).join(', ')
+ : 'unknown'}
+
+
+
+
Source
+
{selectedTemplateDetails.source || 'unknown'}
+
+
+
Date
+
{selectedTemplateDetails.date || 'unknown'}
+
+
+
Usage
+
{typeof selectedTemplateDetails.usage === 'number' ? selectedTemplateDetails.usage : 'unknown'}
+
+
+
+ {Array.isArray(selectedTemplateDetails.models) && selectedTemplateDetails.models.length > 0 && (
+
+
Models
+
{selectedTemplateDetails.models.join(', ')}
+
+ )}
+
+ {Array.isArray(selectedTemplateDetails.tags) && selectedTemplateDetails.tags.length > 0 && (
+
+
Tags
+
{selectedTemplateDetails.tags.join(', ')}
+
+ )}
+
+ {Array.isArray(selectedTemplateDetails.requiresCustomNodes) && selectedTemplateDetails.requiresCustomNodes.length > 0 && (
+
+
Required Custom Nodes
+
{selectedTemplateDetails.requiresCustomNodes.join(', ')}
+
+ )}
+
+ {selectedTemplateDetails.tutorialUrl && (
+
+ )}
+
+
+
+ )}
+
+ {selectedPrereqTemplate && (
+
+
+
+
+
Prerequisites
+
+ {selectedPrereqTemplate.title || selectedPrereqTemplate.label}
+
+
+
+ Close
+
+
+
+ {!selectedPrereqResult || selectedPrereqResult.loading ? (
+
Checking prerequisites...
+ ) : selectedPrereqResult.error ? (
+
{selectedPrereqResult.error}
+ ) : (
+
+
+ {selectedPrereqResult.missing.length === 0
+ ? `All ${selectedPrereqResult.total} required models are available`
+ : `${selectedPrereqResult.missing.length} missing of ${selectedPrereqResult.total} required models`}
+
+ {selectedPrereqResult.missing.length > 0 && (
+
+ {selectedPrereqResult.missing.map((model) => (
+
+
+ {(model.directory || 'unknown')} / {model.name}
+
+
+ {model.url ? (
+ <>
+
+ Download
+
+
onCopyModelUrl(model.url)}
+ className="px-2 py-1 text-xs rounded-md bg-slate-200 text-slate-800 hover:bg-slate-300"
+ >
+ Copy URL
+
+ >
+ ) : (
+
No download URL provided
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ )}
+ >
+ )
+}
diff --git a/src/lib/apiUrl.js b/src/lib/apiUrl.js
new file mode 100644
index 0000000..588cc5f
--- /dev/null
+++ b/src/lib/apiUrl.js
@@ -0,0 +1,29 @@
+export function normalizeBaseUrl(url) {
+ return String(url || '').trim().replace(/\/prompt\/?$/, '')
+}
+
+export function parseApiUrlParts(url) {
+ const fallback = { protocol: 'http', host: '', port: '8188' }
+ const raw = String(url || '').trim()
+ if (!raw) return fallback
+
+ try {
+ const normalized = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`
+ const parsed = new URL(normalized)
+ return {
+ protocol: parsed.protocol.replace(':', '') || 'http',
+ host: parsed.hostname || '',
+ port: parsed.port || '8188',
+ }
+ } catch (_) {
+ return fallback
+ }
+}
+
+export function buildApiUrlFromParts({ protocol, host, port }) {
+ const cleanHost = String(host || '').trim()
+ if (!cleanHost) return ''
+ const cleanProtocol = protocol === 'https' ? 'https' : 'http'
+ const cleanPort = String(port || '').trim()
+ return cleanPort ? `${cleanProtocol}://${cleanHost}:${cleanPort}` : `${cleanProtocol}://${cleanHost}`
+}
diff --git a/src/lib/apiUrl.test.js b/src/lib/apiUrl.test.js
new file mode 100644
index 0000000..eb34168
--- /dev/null
+++ b/src/lib/apiUrl.test.js
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { buildApiUrlFromParts, normalizeBaseUrl, parseApiUrlParts } from './apiUrl'
+
+describe('apiUrl helpers', () => {
+ it('normalizes /prompt suffix', () => {
+ expect(normalizeBaseUrl('http://host:8188/prompt')).toBe('http://host:8188')
+ expect(normalizeBaseUrl('http://host:8188/prompt/')).toBe('http://host:8188')
+ })
+
+ it('parses url parts with defaults', () => {
+ expect(parseApiUrlParts('https://example.com:9000')).toEqual({
+ protocol: 'https',
+ host: 'example.com',
+ port: '9000',
+ })
+ expect(parseApiUrlParts('example.local')).toEqual({
+ protocol: 'http',
+ host: 'example.local',
+ port: '8188',
+ })
+ })
+
+ it('builds url from parts', () => {
+ expect(buildApiUrlFromParts({ protocol: 'https', host: 'foo', port: '443' })).toBe('https://foo:443')
+ expect(buildApiUrlFromParts({ protocol: 'http', host: 'bar', port: '' })).toBe('http://bar')
+ expect(buildApiUrlFromParts({ protocol: 'http', host: '', port: '8188' })).toBe('')
+ })
+})
diff --git a/src/lib/modelRequirements.js b/src/lib/modelRequirements.js
new file mode 100644
index 0000000..414e0a3
--- /dev/null
+++ b/src/lib/modelRequirements.js
@@ -0,0 +1,98 @@
+export function extractRequiredModelsFromWorkflow(workflowData) {
+ const required = []
+ const seenNodes = new Set()
+ const dedupe = new Set()
+
+ function walk(value) {
+ if (!value || typeof value !== 'object') return
+ if (seenNodes.has(value)) return
+ seenNodes.add(value)
+
+ if (Array.isArray(value.models)) {
+ for (const model of value.models) {
+ if (!model || typeof model !== 'object') continue
+ const name = typeof model.name === 'string' ? model.name : ''
+ const directory = typeof model.directory === 'string' ? model.directory : ''
+ const url = typeof model.url === 'string' ? model.url : ''
+ if (!name) continue
+ const key = `${directory}::${name}::${url}`
+ if (dedupe.has(key)) continue
+ dedupe.add(key)
+ required.push({ name, directory, url })
+ }
+ }
+
+ if (Array.isArray(value)) {
+ for (const item of value) walk(item)
+ return
+ }
+ for (const nested of Object.values(value)) walk(nested)
+ }
+
+ walk(workflowData)
+ return required
+}
+
+export function inferModelDirectory(inputName, classType) {
+ const key = String(inputName || '').toLowerCase()
+ const type = String(classType || '').toLowerCase()
+ if (key === 'ckpt_name') return 'checkpoints'
+ if (key === 'unet_name') return 'diffusion_models'
+ if (key === 'vae_name') return 'vae'
+ if (key === 'lora_name') return 'loras'
+ if (key === 'control_net_name') return 'controlnet'
+ if (key === 'style_model_name') return 'style_models'
+ if (key === 'clip_name') return type.includes('dualclip') || type.includes('cliploader') ? 'text_encoders' : null
+ if (key.endsWith('_name')) return null
+ return null
+}
+
+export function extractNamedLoaderModelsFromWorkflow(workflowData) {
+ if (!workflowData || typeof workflowData !== 'object' || Array.isArray(workflowData)) return []
+ const requirements = []
+ const dedupe = new Set()
+
+ for (const node of Object.values(workflowData)) {
+ if (!node || typeof node !== 'object') continue
+ const inputs = node.inputs
+ if (!inputs || typeof inputs !== 'object') continue
+ const classType = String(node.class_type || '')
+
+ for (const [inputName, inputValue] of Object.entries(inputs)) {
+ if (typeof inputValue !== 'string') continue
+ const directory = inferModelDirectory(inputName, classType)
+ if (!directory) continue
+ const key = `${directory}::${inputValue}`
+ if (dedupe.has(key)) continue
+ dedupe.add(key)
+ requirements.push({
+ name: inputValue,
+ directory,
+ url: '',
+ })
+ }
+ }
+
+ return requirements
+}
+
+export function collectWorkflowModelRequirements(workflowData) {
+ const metadataModels = extractRequiredModelsFromWorkflow(workflowData)
+ const namedModels = extractNamedLoaderModelsFromWorkflow(workflowData)
+ const merged = new Map()
+
+ for (const model of [...metadataModels, ...namedModels]) {
+ const directory = String(model?.directory || '').toLowerCase()
+ const name = String(model?.name || '')
+ if (!name || !directory) continue
+ const key = `${directory}::${name.toLowerCase()}`
+ const existing = merged.get(key)
+ if (!existing) {
+ merged.set(key, { ...model, directory, name })
+ } else if (!existing.url && model.url) {
+ merged.set(key, { ...existing, url: model.url })
+ }
+ }
+
+ return Array.from(merged.values())
+}
diff --git a/src/lib/modelRequirements.test.js b/src/lib/modelRequirements.test.js
new file mode 100644
index 0000000..48ba847
--- /dev/null
+++ b/src/lib/modelRequirements.test.js
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import {
+ collectWorkflowModelRequirements,
+ extractNamedLoaderModelsFromWorkflow,
+ extractRequiredModelsFromWorkflow,
+ inferModelDirectory,
+} from './modelRequirements'
+
+describe('modelRequirements helpers', () => {
+ it('infers directories from loader inputs', () => {
+ expect(inferModelDirectory('ckpt_name', 'CheckpointLoaderSimple')).toBe('checkpoints')
+ expect(inferModelDirectory('vae_name', 'VAELoader')).toBe('vae')
+ expect(inferModelDirectory('clip_name', 'DualCLIPLoader')).toBe('text_encoders')
+ expect(inferModelDirectory('unknown_name', 'CustomNode')).toBeNull()
+ })
+
+ it('extracts metadata model declarations recursively', () => {
+ const workflow = {
+ a: {
+ models: [
+ { name: 'a.safetensors', directory: 'checkpoints', url: 'https://x/a' },
+ { name: 'a.safetensors', directory: 'checkpoints', url: 'https://x/a' },
+ ],
+ },
+ }
+ const result = extractRequiredModelsFromWorkflow(workflow)
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({ name: 'a.safetensors', directory: 'checkpoints', url: 'https://x/a' })
+ })
+
+ it('collects and deduplicates metadata + named loader requirements', () => {
+ const workflow = {
+ '4': {
+ class_type: 'CheckpointLoaderSimple',
+ inputs: { ckpt_name: 'v1.safetensors' },
+ },
+ meta: {
+ models: [
+ { name: 'v1.safetensors', directory: 'checkpoints', url: 'https://example/v1' },
+ { name: 'vae.safetensors', directory: 'vae', url: '' },
+ ],
+ },
+ }
+
+ const named = extractNamedLoaderModelsFromWorkflow(workflow)
+ expect(named).toContainEqual({ name: 'v1.safetensors', directory: 'checkpoints', url: '' })
+
+ const merged = collectWorkflowModelRequirements(workflow)
+ expect(merged).toHaveLength(2)
+ expect(merged).toContainEqual({ name: 'v1.safetensors', directory: 'checkpoints', url: 'https://example/v1' })
+ expect(merged).toContainEqual({ name: 'vae.safetensors', directory: 'vae', url: '' })
+ })
+})
diff --git a/src/lib/templateIndex.js b/src/lib/templateIndex.js
new file mode 100644
index 0000000..251aa4f
--- /dev/null
+++ b/src/lib/templateIndex.js
@@ -0,0 +1,150 @@
+const DEFAULT_EXCLUDED_TEMPLATE_TAGS = new Set(['api'])
+
+export function extractWorkflowTemplates(raw) {
+ if (!raw || typeof raw !== 'object') return []
+
+ const templates = []
+ const pushTemplate = (id, label, workflowData, extra = {}) => {
+ if (workflowData && typeof workflowData === 'object') {
+ templates.push({
+ id,
+ label,
+ title: extra.title || label,
+ description: extra.description || '',
+ mediaType: extra.mediaType || 'unknown',
+ tags: Array.isArray(extra.tags) ? extra.tags : [],
+ models: Array.isArray(extra.models) ? extra.models : [],
+ openSource: extra.openSource,
+ usage: typeof extra.usage === 'number' ? extra.usage : 0,
+ date: typeof extra.date === 'string' ? extra.date : '',
+ io: extra.io && typeof extra.io === 'object' ? extra.io : null,
+ tutorialUrl: typeof extra.tutorialUrl === 'string' ? extra.tutorialUrl : '',
+ requiresCustomNodes: Array.isArray(extra.requiresCustomNodes) ? extra.requiresCustomNodes : [],
+ includeOnDistributions: Array.isArray(extra.includeOnDistributions) ? extra.includeOnDistributions : [],
+ searchRank: typeof extra.searchRank === 'number' ? extra.searchRank : undefined,
+ size: typeof extra.size === 'number' ? extra.size : undefined,
+ vram: typeof extra.vram === 'number' ? extra.vram : undefined,
+ status: typeof extra.status === 'string' ? extra.status : '',
+ category: extra.category || 'Templates',
+ categoryGroup: extra.categoryGroup || 'Templates',
+ isEssential: Boolean(extra.isEssential),
+ thumbnailUrl: extra.thumbnailUrl || null,
+ workflowUrl: null,
+ workflow: workflowData,
+ source: 'server',
+ })
+ }
+ }
+
+ for (const [groupKey, groupValue] of Object.entries(raw)) {
+ if (Array.isArray(groupValue)) {
+ for (let i = 0; i < groupValue.length; i++) {
+ const item = groupValue[i]
+ if (!item || typeof item !== 'object') continue
+ const workflowData = item.workflow || item.prompt || item.data?.workflow || item.data?.prompt
+ const label = item.name || item.title || `${groupKey} template ${i + 1}`
+ pushTemplate(`${groupKey}:${label}:${i}`, `${groupKey}: ${label}`, workflowData, {
+ title: item.title || label,
+ description: item.description || '',
+ mediaType: item.mediaType || item.type || 'unknown',
+ tags: item.tags,
+ models: item.models,
+ openSource: item.openSource,
+ usage: item.usage,
+ date: item.date,
+ io: item.io,
+ tutorialUrl: item.tutorialUrl,
+ requiresCustomNodes: item.requiresCustomNodes,
+ includeOnDistributions: item.includeOnDistributions,
+ searchRank: item.searchRank,
+ size: item.size,
+ vram: item.vram,
+ status: item.status,
+ category: groupKey,
+ categoryGroup: 'Custom',
+ })
+ }
+ } else if (groupValue && typeof groupValue === 'object') {
+ for (const [templateKey, templateValue] of Object.entries(groupValue)) {
+ if (!templateValue || typeof templateValue !== 'object') continue
+ const workflowData = templateValue.workflow || templateValue.prompt || templateValue.data?.workflow || templateValue.data?.prompt
+ const label = templateValue.name || templateValue.title || templateKey
+ pushTemplate(`${groupKey}:${templateKey}`, `${groupKey}: ${label}`, workflowData, {
+ title: templateValue.title || label,
+ description: templateValue.description || '',
+ mediaType: templateValue.mediaType || templateValue.type || 'unknown',
+ tags: templateValue.tags,
+ models: templateValue.models,
+ openSource: templateValue.openSource,
+ usage: templateValue.usage,
+ date: templateValue.date,
+ io: templateValue.io,
+ tutorialUrl: templateValue.tutorialUrl,
+ requiresCustomNodes: templateValue.requiresCustomNodes,
+ includeOnDistributions: templateValue.includeOnDistributions,
+ searchRank: templateValue.searchRank,
+ size: templateValue.size,
+ vram: templateValue.vram,
+ status: templateValue.status,
+ category: groupKey,
+ categoryGroup: 'Custom',
+ })
+ }
+ }
+ }
+
+ return templates
+}
+
+export function extractIndexedTemplates(raw, templatesBaseUrl, source, excludedTags = DEFAULT_EXCLUDED_TEMPLATE_TAGS) {
+ if (!Array.isArray(raw)) return []
+
+ const templates = []
+ for (const section of raw) {
+ if (!section || typeof section !== 'object') continue
+ const sectionTitle = section.title || section.category || section.type || 'Templates'
+ const sectionTemplates = Array.isArray(section.templates) ? section.templates : []
+ for (const template of sectionTemplates) {
+ if (!template || typeof template !== 'object') continue
+ const tags = Array.isArray(template.tags) ? template.tags : []
+ const hasExcludedTag = tags.some((tag) => excludedTags.has(String(tag || '').trim().toLowerCase()))
+ const isApiBased = hasExcludedTag || template.openSource === false
+ if (isApiBased) continue
+ const name = template.name
+ if (!name || typeof name !== 'string') continue
+ const displayName = template.title || template.name
+ const mediaSubtype = template.mediaSubtype || 'webp'
+ templates.push({
+ id: `${source}:${sectionTitle}:${name}`,
+ label: `${sectionTitle}: ${displayName}`,
+ title: displayName,
+ description: template.description || '',
+ mediaType: template.mediaType || section.type || 'unknown',
+ tags,
+ models: Array.isArray(template.models) ? template.models : [],
+ openSource: template.openSource,
+ usage: typeof template.usage === 'number' ? template.usage : 0,
+ date: typeof template.date === 'string' ? template.date : '',
+ io: template.io && typeof template.io === 'object' ? template.io : null,
+ tutorialUrl: typeof template.tutorialUrl === 'string' ? template.tutorialUrl : '',
+ requiresCustomNodes: Array.isArray(template.requiresCustomNodes) ? template.requiresCustomNodes : [],
+ includeOnDistributions: Array.isArray(template.includeOnDistributions) ? template.includeOnDistributions : [],
+ searchRank: typeof template.searchRank === 'number' ? template.searchRank : undefined,
+ size: typeof template.size === 'number' ? template.size : undefined,
+ vram: typeof template.vram === 'number' ? template.vram : undefined,
+ status: typeof template.status === 'string' ? template.status : '',
+ category: sectionTitle,
+ categoryGroup: section.category || 'Templates',
+ isEssential: Boolean(section.isEssential),
+ thumbnailUrl: `${templatesBaseUrl}/${encodeURIComponent(name)}-1.${encodeURIComponent(mediaSubtype)}`,
+ source,
+ workflowUrl: `${templatesBaseUrl}/${encodeURIComponent(name)}.json`,
+ workflow: null,
+ })
+ }
+ }
+
+ return templates
+}
+
+export { DEFAULT_EXCLUDED_TEMPLATE_TAGS }
diff --git a/src/lib/templateIndex.test.js b/src/lib/templateIndex.test.js
new file mode 100644
index 0000000..b512d52
--- /dev/null
+++ b/src/lib/templateIndex.test.js
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { extractIndexedTemplates, extractWorkflowTemplates } from './templateIndex'
+
+describe('templateIndex helpers', () => {
+ it('extracts workflow templates from grouped object', () => {
+ const raw = {
+ UseCases: {
+ t1: {
+ title: 'Template One',
+ workflow: { a: 1 },
+ tags: ['Image'],
+ },
+ },
+ }
+
+ const result = extractWorkflowTemplates(raw)
+ expect(result).toHaveLength(1)
+ expect(result[0].title).toBe('Template One')
+ expect(result[0].workflow).toEqual({ a: 1 })
+ })
+
+ it('filters API templates from index and builds urls', () => {
+ const raw = [
+ {
+ title: 'Image',
+ type: 'Image',
+ templates: [
+ { name: 'open-source', title: 'Open Source', tags: ['Image'], openSource: true },
+ { name: 'api-only', title: 'API Only', tags: ['API'], openSource: true },
+ { name: 'closed', title: 'Closed', tags: ['Image'], openSource: false },
+ ],
+ },
+ ]
+
+ const result = extractIndexedTemplates(raw, 'https://templates.example', 'remote')
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBeUndefined()
+ expect(result[0].title).toBe('Open Source')
+ expect(result[0].workflowUrl).toBe('https://templates.example/open-source.json')
+ })
+})
diff --git a/src/lib/workflowPrompt.js b/src/lib/workflowPrompt.js
new file mode 100644
index 0000000..b7b1bbe
--- /dev/null
+++ b/src/lib/workflowPrompt.js
@@ -0,0 +1,77 @@
+export function analyzeWorkflowPromptInputs(graph) {
+ if (!graph || typeof graph !== 'object') {
+ return { mode: 'single', defaultPrompt: '', defaultNegativePrompt: '' }
+ }
+
+ const positiveRefs = new Set()
+ const negativeRefs = new Set()
+ for (const node of Object.values(graph)) {
+ const classType = String(node?.class_type || '').toLowerCase()
+ if (!classType.includes('ksampler')) continue
+ const positive = node?.inputs?.positive
+ const negative = node?.inputs?.negative
+ if (Array.isArray(positive) && positive.length > 0) positiveRefs.add(String(positive[0]))
+ if (Array.isArray(negative) && negative.length > 0) negativeRefs.add(String(negative[0]))
+ }
+
+ const promptLikeNodes = []
+ for (const [nodeId, node] of Object.entries(graph)) {
+ if (!node || typeof node !== 'object') continue
+ const textValue = node?.inputs?.text
+ if (typeof textValue !== 'string') continue
+ const classType = String(node?.class_type || '').toLowerCase()
+ const title = String(node?._meta?.title || '').toLowerCase()
+ const looksLikePromptNode = classType.includes('cliptextencode') || title.includes('prompt')
+ if (!looksLikePromptNode) continue
+ const isNegative = title.includes('negative') || title.includes('neg')
+ promptLikeNodes.push({ id: String(nodeId), text: textValue, isNegative })
+ }
+
+ const firstTextForRefs = (refs) => {
+ for (const ref of refs) {
+ const textValue = graph?.[ref]?.inputs?.text
+ if (typeof textValue === 'string') return textValue
+ }
+ return ''
+ }
+
+ const mappedPrompt = firstTextForRefs(positiveRefs)
+ const mappedNegative = firstTextForRefs(negativeRefs)
+ if (positiveRefs.size > 0 || negativeRefs.size > 0) {
+ return {
+ mode: negativeRefs.size > 0 ? 'dual' : 'single',
+ defaultPrompt: mappedPrompt || '',
+ defaultNegativePrompt: mappedNegative || '',
+ }
+ }
+
+ if (promptLikeNodes.length === 0) {
+ return { mode: 'single', defaultPrompt: '', defaultNegativePrompt: '' }
+ }
+ if (promptLikeNodes.length === 1) {
+ return { mode: 'single', defaultPrompt: promptLikeNodes[0].text || '', defaultNegativePrompt: '' }
+ }
+
+ const negativeNode = promptLikeNodes.find((node) => node.isNegative)
+ const positiveNode = promptLikeNodes.find((node) => !node.isNegative) || promptLikeNodes[0]
+ if (negativeNode && positiveNode) {
+ return {
+ mode: 'dual',
+ defaultPrompt: positiveNode.text || '',
+ defaultNegativePrompt: negativeNode.text || '',
+ }
+ }
+
+ return { mode: 'single', defaultPrompt: positiveNode.text || '', defaultNegativePrompt: '' }
+}
+
+export function extractPromptFromGraph(graph) {
+ if (!graph || typeof graph !== 'object') return ''
+ for (const node of Object.values(graph)) {
+ const classType = String(node?.class_type || '').toLowerCase()
+ if (classType.includes('cliptextencode') && typeof node?.inputs?.text === 'string') {
+ return node.inputs.text
+ }
+ }
+ return ''
+}
diff --git a/src/lib/workflowPrompt.test.js b/src/lib/workflowPrompt.test.js
new file mode 100644
index 0000000..ae0806b
--- /dev/null
+++ b/src/lib/workflowPrompt.test.js
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+import { analyzeWorkflowPromptInputs, extractPromptFromGraph } from './workflowPrompt'
+
+describe('workflowPrompt helpers', () => {
+ it('extracts dual prompt mode from ksampler wiring', () => {
+ const graph = {
+ '3': { class_type: 'KSampler', inputs: { positive: ['6', 0], negative: ['7', 0] } },
+ '6': { class_type: 'CLIPTextEncode', inputs: { text: 'good prompt' } },
+ '7': { class_type: 'CLIPTextEncode', inputs: { text: 'bad prompt' } },
+ }
+
+ expect(analyzeWorkflowPromptInputs(graph)).toEqual({
+ mode: 'dual',
+ defaultPrompt: 'good prompt',
+ defaultNegativePrompt: 'bad prompt',
+ })
+ })
+
+ it('falls back to single prompt when only one prompt node exists', () => {
+ const graph = {
+ '1': { class_type: 'CLIPTextEncode', inputs: { text: 'solo' }, _meta: { title: 'CLIP Text Encode (Prompt)' } },
+ }
+
+ expect(analyzeWorkflowPromptInputs(graph)).toEqual({
+ mode: 'single',
+ defaultPrompt: 'solo',
+ defaultNegativePrompt: '',
+ })
+ })
+
+ it('extracts first cliptextencode prompt', () => {
+ const graph = {
+ '1': { class_type: 'OtherNode', inputs: { text: 'ignore me' } },
+ '2': { class_type: 'CLIPTextEncode', inputs: { text: 'hello world' } },
+ }
+
+ expect(extractPromptFromGraph(graph)).toBe('hello world')
+ })
+})
From 3f681e53366c3ada8dc1a7117e50245eb4c410f5 Mon Sep 17 00:00:00 2001
From: danohn <82357071+danohn@users.noreply.github.com>
Date: Tue, 17 Feb 2026 15:01:55 +1100
Subject: [PATCH 2/8] Plan modularizing App.jsx
---
src/App.jsx | 733 +++------------------
src/features/onboarding/OnboardingPage.jsx | 240 +++++++
src/features/settings/SettingsPage.jsx | 471 +++++++++++++
3 files changed, 790 insertions(+), 654 deletions(-)
create mode 100644 src/features/onboarding/OnboardingPage.jsx
create mode 100644 src/features/settings/SettingsPage.jsx
diff --git a/src/App.jsx b/src/App.jsx
index ea62d83..9a0aab6 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -6,6 +6,8 @@ import useWorkflowConfig from './hooks/useWorkflowConfig'
import useGeneration from './hooks/useGeneration'
import TemplateBrowser from './features/templates/TemplateBrowser'
import TemplateModals from './features/templates/TemplateModals'
+import OnboardingPage from './features/onboarding/OnboardingPage'
+import SettingsPage from './features/settings/SettingsPage'
import { buildApiUrlFromParts, normalizeBaseUrl, parseApiUrlParts } from './lib/apiUrl'
import { collectWorkflowModelRequirements } from './lib/modelRequirements'
import { extractIndexedTemplates, extractWorkflowTemplates } from './lib/templateIndex'
@@ -786,6 +788,16 @@ export default function App() {
await runOpsAction('free', { unload_models: true, free_memory: true })
}
+ async function copyModelUrl(url) {
+ if (!url) return
+ try {
+ await navigator.clipboard.writeText(url)
+ setError('Model URL copied to clipboard')
+ } catch (_) {
+ setError('Failed to copy model URL')
+ }
+ }
+
function handleSaveSettings() {
saveApiSettings()
}
@@ -1320,671 +1332,84 @@ export default function App() {
selectedPrereqResult={selectedPrereqResult}
onCloseDetails={() => setSelectedTemplateDetails(null)}
onClosePrereq={() => setSelectedPrereqTemplateId(null)}
- onCopyModelUrl={async (url) => {
- if (!url) return
- try {
- await navigator.clipboard.writeText(url)
- setError('Model URL copied to clipboard')
- } catch (_) {
- setError('Failed to copy model URL')
- }
- }}
+ onCopyModelUrl={copyModelUrl}
/>
)
}
function renderSettingsPage() {
- const isApiDirty = normalizeBaseUrl(settingsUrl) !== normalizeBaseUrl(apiUrl)
- const canSaveSettings = isApiDirty
- const featureEntries = (() => {
- if (!serverFeatures || typeof serverFeatures !== 'object') return []
- const rows = []
- const walk = (prefix, value) => {
- if (value && typeof value === 'object' && !Array.isArray(value)) {
- for (const [nestedKey, nestedValue] of Object.entries(value)) {
- walk(prefix ? `${prefix}.${nestedKey}` : nestedKey, nestedValue)
- }
- return
- }
- rows.push([prefix, value])
- }
- walk('', serverFeatures)
- return rows
- })()
-
- const prettyFeatureLabel = (key) =>
- key
- .split('.')
- .map((segment) =>
- segment
- .replace(/_/g, ' ')
- .replace(/\b\w/g, (match) => match.toUpperCase())
- )
- .join(' / ')
-
- const prettyFeatureValue = (value, key) => {
- if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled'
- if (typeof value === 'number') {
- if (key.includes('max_upload_size')) {
- if (value >= 1024 * 1024) return `${Math.round((value / (1024 * 1024)) * 10) / 10} MB`
- }
- return String(value)
- }
- if (value == null) return 'N/A'
- return String(value)
- }
- const formatBytes = (bytes) => {
- if (typeof bytes !== 'number' || Number.isNaN(bytes)) return 'N/A'
- const gb = bytes / (1024 * 1024 * 1024)
- if (gb >= 1) return `${Math.round(gb * 10) / 10} GB`
- const mb = bytes / (1024 * 1024)
- return `${Math.round(mb)} MB`
- }
- const formatDeviceName = (rawName, fallbackIndex) => {
- if (!rawName || typeof rawName !== 'string') return `Device ${fallbackIndex}`
- let name = rawName
- if (name.includes(': ')) {
- name = name.split(': ')[0]
- }
- if (name.includes(' ')) {
- const firstSpace = name.indexOf(' ')
- const maybePrefix = name.slice(0, firstSpace)
- if (/^[a-z]+:\d+$/i.test(maybePrefix)) {
- name = name.slice(firstSpace + 1)
- }
- }
- return name.trim() || `Device ${fallbackIndex}`
- }
- const formatExtensionLabel = (ext, idx) => {
- if (typeof ext === 'string') return ext
- if (ext && typeof ext === 'object') {
- return ext.name || ext.title || ext.id || `Extension ${idx + 1}`
- }
- return `Extension ${idx + 1}`
- }
-
return (
-
-
-
-
-
Settings
-
Configure your server, workflow, models, and operations.
-
-
navigate('/')}
- className="px-4 py-2 bg-slate-100 text-slate-800 rounded-lg hover:bg-slate-200"
- >
- Back to App
-
-
-
-
- {!canCloseSettings && (
-
- Complete setup to continue: add your API URL and workflow JSON.
-
- )}
-
-
- ComfyUI API
-
-
Host or IP
-
updateApiSettings({ host: e.target.value })}
- placeholder="10.18.20.10 or comfy.local"
- className="w-full px-4 py-2 border border-slate-300 text-slate-900 rounded-lg focus:outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-900 focus:ring-opacity-10"
- />
-
- Default connection uses http on port 8188 .
-
-
setShowAdvancedApi((current) => !current)}
- className="text-xs px-2 py-1 rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
- >
- {showAdvancedApi ? 'Hide Advanced URL Options' : 'Show Advanced URL Options'}
-
- {showAdvancedApi && (
-
-
-
- Protocol
- updateApiSettings({ protocol: e.target.value })}
- className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
- >
- http
- https
-
-
-
- Port
- updateApiSettings({ port: e.target.value.replace(/[^0-9]/g, '') })}
- placeholder="8188"
- className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
- />
-
-
-
- )}
-
- Resolved URL: {settingsUrl || 'not set'}
-
-
-
-
- {isTestingConnection ? 'Testing...' : 'Test Connection'}
-
-
- Save API
-
-
- {connectionStatus && (
-
- {connectionStatus.message}
-
- )}
- {canCloseSettings && isApiDirty && (
-
- You have unsaved API changes.
-
- )}
-
-
-
- Workflow
- {hasConfiguredWorkflow ? `Selected: ${workflowName}` : 'No workflow selected yet'}
-
-
- Upload JSON
-
-
-
- Use Sample
-
-
- runWorkflowHealthCheck()}
- disabled={isCheckingWorkflowHealth || !hasConfiguredWorkflow}
- className="px-3 py-2 bg-slate-100 text-slate-800 text-sm rounded-lg font-medium hover:bg-slate-200 disabled:opacity-50"
- >
- {isCheckingWorkflowHealth ? 'Checking prerequisites...' : 'Check Prerequisites'}
-
- {workflowHealth && (
-
-
Workflow Health
-
- {workflowHealth.message}
-
- {workflowHealth.total > 0 && (
-
- {workflowHealth.missing.length === 0
- ? `All ${workflowHealth.total} required models are available`
- : `${workflowHealth.missing.length} missing of ${workflowHealth.total} required models`}
-
- )}
- {Array.isArray(workflowHealth.missing) && workflowHealth.missing.length > 0 && (
-
- {workflowHealth.missing.map((model) => (
-
-
- {(model.directory || 'unknown')} / {model.name}
-
-
- {model.url ? (
- <>
-
- Download
-
-
{
- if (!model.url) return
- try {
- await navigator.clipboard.writeText(model.url)
- setError('Model URL copied to clipboard')
- } catch (_) {
- setError('Failed to copy model URL')
- }
- }}
- className="px-2 py-1 text-xs rounded-md bg-slate-200 text-slate-800 hover:bg-slate-300"
- >
- Copy URL
-
- >
- ) : (
-
No download URL provided
- )}
-
-
- ))}
-
- )}
-
- )}
-
- {renderTemplateBrowser()}
-
-
-
-
-
Server Dashboard
-
- Refresh
-
-
- {isLoadingServerData ? (
- Loading server data...
- ) : serverDataError ? (
- {serverDataError}
- ) : (
-
-
-
-
ComfyUI Version
-
{serverSystemStats?.system?.comfyui_version || 'unknown'}
-
-
-
GPU Devices
-
- {Array.isArray(serverSystemStats?.devices) ? serverSystemStats.devices.length : 0}
-
-
-
-
-
-
Extensions
-
{serverExtensions.length}
-
- {serverExtensions.length > 0 && (
-
setShowExtensions((current) => !current)}
- className="text-xs px-2 py-0.5 rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
- >
- {showExtensions ? 'Hide' : 'Show'}
-
- )}
-
-
-
-
Server History Items
-
{serverHistoryCount ?? 'unknown'}
-
-
- {showExtensions && serverExtensions.length > 0 && (
-
-
- {serverExtensions.map((ext, idx) => (
-
- {formatExtensionLabel(ext, idx)}
-
- ))}
-
-
- )}
- {Array.isArray(serverSystemStats?.devices) && serverSystemStats.devices.length > 0 && (
-
-
Compute Devices
-
- {serverSystemStats.devices.map((device, idx) => (
-
-
{formatDeviceName(device?.name, idx)}
-
-
Type: {device?.type || 'unknown'}
-
Index: {typeof device?.index === 'number' ? device.index : idx}
-
VRAM total: {formatBytes(device?.vram_total)}
-
VRAM free: {formatBytes(device?.vram_free)}
-
Torch VRAM total: {formatBytes(device?.torch_vram_total)}
-
Torch VRAM free: {formatBytes(device?.torch_vram_free)}
-
-
- ))}
-
-
- )}
- {featureEntries.length > 0 ? (
-
-
Features
-
- {featureEntries.map(([key, rawValue]) => (
-
-
{prettyFeatureLabel(key)}
-
- {prettyFeatureValue(rawValue, key)}
-
-
- ))}
-
-
- ) : (
-
Features: none reported
- )}
-
- )}
-
-
-
- Danger Zone
-
-
-
-
Clear Pending Queue
-
Remove all queued pending jobs that have not started yet.
-
-
- Clear pending
-
-
-
-
-
-
Interrupt Running Execution
-
Stop currently running generation jobs on the server.
-
-
- Interrupt execution
-
-
-
-
-
-
Clear Server History
-
Delete historical job records from the ComfyUI server.
-
-
- Clear history
-
-
-
-
-
-
Free VRAM / Unload Models
-
Release runtime memory and unload models to recover from memory pressure.
-
-
- Free memory
-
-
-
- {opsActionStatus && (
-
- {opsActionStatus.message}
-
- )}
-
-
-
-
-
+ navigate('/')}
+ />
)
}
function renderOnboardingPage() {
- const isApiDirty = normalizeBaseUrl(settingsUrl) !== normalizeBaseUrl(apiUrl)
- const canGoToWorkflowStep = hasConfiguredApiUrl
-
return (
-
-
-
-
-
-
Getting Started
-
Step {onboardingStep} of 2
-
-
- setOnboardingStep(1)}
- disabled={onboardingStep === 1}
- className="px-3 py-2 text-sm rounded-lg bg-slate-100 text-slate-800 hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed"
- >
- ←
-
- setOnboardingStep(2)}
- disabled={!canGoToWorkflowStep || onboardingStep === 2}
- className="px-3 py-2 text-sm rounded-lg bg-slate-100 text-slate-800 hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed"
- >
- →
-
-
-
-
- {onboardingStep === 1 ? (
-
- Connect to ComfyUI
-
-
Host or IP
-
updateApiSettings({ host: e.target.value })}
- placeholder="10.18.20.10 or comfy.local"
- className="w-full px-4 py-2 border border-slate-300 text-slate-900 rounded-lg focus:outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-900 focus:ring-opacity-10"
- />
-
- Default connection uses http on port 8188 .
-
-
setShowAdvancedApi((current) => !current)}
- className="text-xs px-2 py-1 rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
- >
- {showAdvancedApi ? 'Hide Advanced URL Options' : 'Show Advanced URL Options'}
-
- {showAdvancedApi && (
-
-
-
- Protocol
- updateApiSettings({ protocol: e.target.value })}
- className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
- >
- http
- https
-
-
-
- Port
- updateApiSettings({ port: e.target.value.replace(/[^0-9]/g, '') })}
- placeholder="8188"
- className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
- />
-
-
-
- )}
-
- Resolved URL: {settingsUrl || 'not set'}
-
-
-
-
- {isTestingConnection ? 'Testing...' : 'Test Connection'}
-
-
- Save and Continue →
-
-
- {connectionStatus && (
-
- {connectionStatus.message}
-
- )}
- {isApiDirty && (
- You have unsaved API changes.
- )}
-
- ) : (
-
- Choose Workflow
- {hasConfiguredWorkflow ? `Selected: ${workflowName}` : 'No workflow selected yet'}
-
-
- Upload JSON
-
-
-
- Use Sample
-
-
- runWorkflowHealthCheck()}
- disabled={isCheckingWorkflowHealth || !hasConfiguredWorkflow}
- className="px-3 py-2 bg-slate-100 text-slate-800 text-sm rounded-lg font-medium hover:bg-slate-200 disabled:opacity-50"
- >
- {isCheckingWorkflowHealth ? 'Checking prerequisites...' : 'Check Prerequisites'}
-
- {workflowHealth && (
-
-
Workflow Prerequisites
-
- {workflowHealth.message}
-
- {workflowHealth.total > 0 && (
-
- {workflowHealth.missing.length === 0
- ? `All ${workflowHealth.total} required models are available`
- : `${workflowHealth.missing.length} missing of ${workflowHealth.total} required models`}
-
- )}
- {Array.isArray(workflowHealth.missing) && workflowHealth.missing.length > 0 && (
-
- {workflowHealth.missing.map((model) => (
-
-
- {(model.directory || 'unknown')} / {model.name}
-
-
- {model.url ? (
- <>
-
- Download
-
-
{
- if (!model.url) return
- try {
- await navigator.clipboard.writeText(model.url)
- setError('Model URL copied to clipboard')
- } catch (_) {
- setError('Failed to copy model URL')
- }
- }}
- className="px-2 py-1 text-xs rounded-md bg-slate-200 text-slate-800 hover:bg-slate-300"
- >
- Copy URL
-
- >
- ) : (
-
No download URL provided
- )}
-
-
- ))}
-
- )}
-
- )}
- {renderTemplateBrowser()}
-
- setOnboardingStep(1)}
- className="px-3 py-2 bg-slate-100 text-slate-800 text-sm rounded-lg font-medium hover:bg-slate-200 transition-colors"
- >
- ← Back
-
-
- Finish Setup
-
-
-
- )}
-
-
-
+
)
}
diff --git a/src/features/onboarding/OnboardingPage.jsx b/src/features/onboarding/OnboardingPage.jsx
new file mode 100644
index 0000000..6f18331
--- /dev/null
+++ b/src/features/onboarding/OnboardingPage.jsx
@@ -0,0 +1,240 @@
+import React from 'react'
+import { normalizeBaseUrl } from '../../lib/apiUrl'
+
+export default function OnboardingPage({
+ settingsUrl,
+ apiUrl,
+ hasConfiguredApiUrl,
+ onboardingStep,
+ setOnboardingStep,
+ apiHost,
+ updateApiSettings,
+ showAdvancedApi,
+ setShowAdvancedApi,
+ apiProtocol,
+ apiPort,
+ handleTestConnection,
+ isTestingConnection,
+ handleOnboardingSaveAndContinue,
+ connectionStatus,
+ hasConfiguredWorkflow,
+ workflowName,
+ handleWorkflowUpload,
+ handleUseSampleWorkflow,
+ runWorkflowHealthCheck,
+ isCheckingWorkflowHealth,
+ workflowHealth,
+ onCopyModelUrl,
+ renderTemplateBrowser,
+ handleOnboardingFinish,
+}) {
+ const isApiDirty = normalizeBaseUrl(settingsUrl) !== normalizeBaseUrl(apiUrl)
+ const canGoToWorkflowStep = hasConfiguredApiUrl
+
+ return (
+
+
+
+
+
+
Getting Started
+
Step {onboardingStep} of 2
+
+
+ setOnboardingStep(1)}
+ disabled={onboardingStep === 1}
+ className="px-3 py-2 text-sm rounded-lg bg-slate-100 text-slate-800 hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed"
+ >
+ ←
+
+ setOnboardingStep(2)}
+ disabled={!canGoToWorkflowStep || onboardingStep === 2}
+ className="px-3 py-2 text-sm rounded-lg bg-slate-100 text-slate-800 hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed"
+ >
+ →
+
+
+
+
+ {onboardingStep === 1 ? (
+
+ Connect to ComfyUI
+
+
Host or IP
+
updateApiSettings({ host: e.target.value })}
+ placeholder="10.18.20.10 or comfy.local"
+ className="w-full px-4 py-2 border border-slate-300 text-slate-900 rounded-lg focus:outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-900 focus:ring-opacity-10"
+ />
+
+ Default connection uses http on port 8188 .
+
+
setShowAdvancedApi((current) => !current)}
+ className="text-xs px-2 py-1 rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
+ >
+ {showAdvancedApi ? 'Hide Advanced URL Options' : 'Show Advanced URL Options'}
+
+ {showAdvancedApi && (
+
+
+
+ Protocol
+ updateApiSettings({ protocol: e.target.value })}
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
+ >
+ http
+ https
+
+
+
+ Port
+ updateApiSettings({ port: e.target.value.replace(/[^0-9]/g, '') })}
+ placeholder="8188"
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
+ />
+
+
+
+ )}
+
+ Resolved URL: {settingsUrl || 'not set'}
+
+
+
+
+ {isTestingConnection ? 'Testing...' : 'Test Connection'}
+
+
+ Save and Continue →
+
+
+ {connectionStatus && (
+
+ {connectionStatus.message}
+
+ )}
+ {isApiDirty && (
+ You have unsaved API changes.
+ )}
+
+ ) : (
+
+ Choose Workflow
+ {hasConfiguredWorkflow ? `Selected: ${workflowName}` : 'No workflow selected yet'}
+
+
+ Upload JSON
+
+
+
+ Use Sample
+
+
+ runWorkflowHealthCheck()}
+ disabled={isCheckingWorkflowHealth || !hasConfiguredWorkflow}
+ className="px-3 py-2 bg-slate-100 text-slate-800 text-sm rounded-lg font-medium hover:bg-slate-200 disabled:opacity-50"
+ >
+ {isCheckingWorkflowHealth ? 'Checking prerequisites...' : 'Check Prerequisites'}
+
+ {workflowHealth && (
+
+
Workflow Prerequisites
+
+ {workflowHealth.message}
+
+ {workflowHealth.total > 0 && (
+
+ {workflowHealth.missing.length === 0
+ ? `All ${workflowHealth.total} required models are available`
+ : `${workflowHealth.missing.length} missing of ${workflowHealth.total} required models`}
+
+ )}
+ {Array.isArray(workflowHealth.missing) && workflowHealth.missing.length > 0 && (
+
+ {workflowHealth.missing.map((model) => (
+
+
+ {(model.directory || 'unknown')} / {model.name}
+
+
+ {model.url ? (
+ <>
+
+ Download
+
+
onCopyModelUrl(model.url)}
+ className="px-2 py-1 text-xs rounded-md bg-slate-200 text-slate-800 hover:bg-slate-300"
+ >
+ Copy URL
+
+ >
+ ) : (
+
No download URL provided
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+ {renderTemplateBrowser()}
+
+ setOnboardingStep(1)}
+ className="px-3 py-2 bg-slate-100 text-slate-800 text-sm rounded-lg font-medium hover:bg-slate-200 transition-colors"
+ >
+ ← Back
+
+
+ Finish Setup
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/features/settings/SettingsPage.jsx b/src/features/settings/SettingsPage.jsx
new file mode 100644
index 0000000..6fe4e9f
--- /dev/null
+++ b/src/features/settings/SettingsPage.jsx
@@ -0,0 +1,471 @@
+import React from 'react'
+import { normalizeBaseUrl } from '../../lib/apiUrl'
+
+export default function SettingsPage({
+ apiUrl,
+ settingsUrl,
+ canCloseSettings,
+ apiHost,
+ updateApiSettings,
+ showAdvancedApi,
+ setShowAdvancedApi,
+ apiProtocol,
+ apiPort,
+ handleTestConnection,
+ isTestingConnection,
+ handleSaveSettings,
+ connectionStatus,
+ hasConfiguredWorkflow,
+ workflowName,
+ handleWorkflowUpload,
+ handleUseSampleWorkflow,
+ runWorkflowHealthCheck,
+ isCheckingWorkflowHealth,
+ workflowHealth,
+ onCopyModelUrl,
+ renderTemplateBrowser,
+ fetchServerData,
+ isLoadingServerData,
+ serverDataError,
+ serverSystemStats,
+ serverExtensions,
+ showExtensions,
+ setShowExtensions,
+ serverHistoryCount,
+ serverFeatures,
+ handleClearPendingQueue,
+ handleInterruptExecution,
+ handleClearServerHistory,
+ handleFreeMemory,
+ opsActionStatus,
+ onBackToApp,
+}) {
+ const isApiDirty = normalizeBaseUrl(settingsUrl) !== normalizeBaseUrl(apiUrl)
+ const canSaveSettings = isApiDirty
+
+ const featureEntries = (() => {
+ if (!serverFeatures || typeof serverFeatures !== 'object') return []
+ const rows = []
+ const walk = (prefix, value) => {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ for (const [nestedKey, nestedValue] of Object.entries(value)) {
+ walk(prefix ? `${prefix}.${nestedKey}` : nestedKey, nestedValue)
+ }
+ return
+ }
+ rows.push([prefix, value])
+ }
+ walk('', serverFeatures)
+ return rows
+ })()
+
+ const prettyFeatureLabel = (key) =>
+ key
+ .split('.')
+ .map((segment) =>
+ segment
+ .replace(/_/g, ' ')
+ .replace(/\b\w/g, (match) => match.toUpperCase())
+ )
+ .join(' / ')
+
+ const prettyFeatureValue = (value, key) => {
+ if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled'
+ if (typeof value === 'number') {
+ if (key.includes('max_upload_size')) {
+ if (value >= 1024 * 1024) return `${Math.round((value / (1024 * 1024)) * 10) / 10} MB`
+ }
+ return String(value)
+ }
+ if (value == null) return 'N/A'
+ return String(value)
+ }
+
+ const formatBytes = (bytes) => {
+ if (typeof bytes !== 'number' || Number.isNaN(bytes)) return 'N/A'
+ const gb = bytes / (1024 * 1024 * 1024)
+ if (gb >= 1) return `${Math.round(gb * 10) / 10} GB`
+ const mb = bytes / (1024 * 1024)
+ return `${Math.round(mb)} MB`
+ }
+
+ const formatDeviceName = (rawName, fallbackIndex) => {
+ if (!rawName || typeof rawName !== 'string') return `Device ${fallbackIndex}`
+ let name = rawName
+ if (name.includes(': ')) {
+ name = name.split(': ')[0]
+ }
+ if (name.includes(' ')) {
+ const firstSpace = name.indexOf(' ')
+ const maybePrefix = name.slice(0, firstSpace)
+ if (/^[a-z]+:\d+$/i.test(maybePrefix)) {
+ name = name.slice(firstSpace + 1)
+ }
+ }
+ return name.trim() || `Device ${fallbackIndex}`
+ }
+
+ const formatExtensionLabel = (ext, idx) => {
+ if (typeof ext === 'string') return ext
+ if (ext && typeof ext === 'object') {
+ return ext.name || ext.title || ext.id || `Extension ${idx + 1}`
+ }
+ return `Extension ${idx + 1}`
+ }
+
+ return (
+
+
+
+
+
Settings
+
Configure your server, workflow, models, and operations.
+
+
+ Back to App
+
+
+
+
+ {!canCloseSettings && (
+
+ Complete setup to continue: add your API URL and workflow JSON.
+
+ )}
+
+
+ ComfyUI API
+
+
Host or IP
+
updateApiSettings({ host: e.target.value })}
+ placeholder="10.18.20.10 or comfy.local"
+ className="w-full px-4 py-2 border border-slate-300 text-slate-900 rounded-lg focus:outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-900 focus:ring-opacity-10"
+ />
+
+ Default connection uses http on port 8188 .
+
+
setShowAdvancedApi((current) => !current)}
+ className="text-xs px-2 py-1 rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
+ >
+ {showAdvancedApi ? 'Hide Advanced URL Options' : 'Show Advanced URL Options'}
+
+ {showAdvancedApi && (
+
+
+
+ Protocol
+ updateApiSettings({ protocol: e.target.value })}
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
+ >
+ http
+ https
+
+
+
+ Port
+ updateApiSettings({ port: e.target.value.replace(/[^0-9]/g, '') })}
+ placeholder="8188"
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-900 bg-white focus:outline-none focus:border-slate-900"
+ />
+
+
+
+ )}
+
+ Resolved URL: {settingsUrl || 'not set'}
+
+
+
+
+ {isTestingConnection ? 'Testing...' : 'Test Connection'}
+
+
+ Save API
+
+
+ {connectionStatus && (
+
+ {connectionStatus.message}
+
+ )}
+ {canCloseSettings && isApiDirty && (
+
+ You have unsaved API changes.
+
+ )}
+
+
+
+ Workflow
+ {hasConfiguredWorkflow ? `Selected: ${workflowName}` : 'No workflow selected yet'}
+
+
+ Upload JSON
+
+
+
+ Use Sample
+
+
+ runWorkflowHealthCheck()}
+ disabled={isCheckingWorkflowHealth || !hasConfiguredWorkflow}
+ className="px-3 py-2 bg-slate-100 text-slate-800 text-sm rounded-lg font-medium hover:bg-slate-200 disabled:opacity-50"
+ >
+ {isCheckingWorkflowHealth ? 'Checking prerequisites...' : 'Check Prerequisites'}
+
+ {workflowHealth && (
+
+
Workflow Health
+
+ {workflowHealth.message}
+
+ {workflowHealth.total > 0 && (
+
+ {workflowHealth.missing.length === 0
+ ? `All ${workflowHealth.total} required models are available`
+ : `${workflowHealth.missing.length} missing of ${workflowHealth.total} required models`}
+
+ )}
+ {Array.isArray(workflowHealth.missing) && workflowHealth.missing.length > 0 && (
+
+ {workflowHealth.missing.map((model) => (
+
+
+ {(model.directory || 'unknown')} / {model.name}
+
+
+ {model.url ? (
+ <>
+
+ Download
+
+
onCopyModelUrl(model.url)}
+ className="px-2 py-1 text-xs rounded-md bg-slate-200 text-slate-800 hover:bg-slate-300"
+ >
+ Copy URL
+
+ >
+ ) : (
+
No download URL provided
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {renderTemplateBrowser()}
+
+
+
+
+
Server Dashboard
+
+ Refresh
+
+
+ {isLoadingServerData ? (
+ Loading server data...
+ ) : serverDataError ? (
+ {serverDataError}
+ ) : (
+
+
+
+
ComfyUI Version
+
{serverSystemStats?.system?.comfyui_version || 'unknown'}
+
+
+
GPU Devices
+
+ {Array.isArray(serverSystemStats?.devices) ? serverSystemStats.devices.length : 0}
+
+
+
+
+
+
Extensions
+
{serverExtensions.length}
+
+ {serverExtensions.length > 0 && (
+
setShowExtensions((current) => !current)}
+ className="text-xs px-2 py-0.5 rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
+ >
+ {showExtensions ? 'Hide' : 'Show'}
+
+ )}
+
+
+
+
Server History Items
+
{serverHistoryCount ?? 'unknown'}
+
+
+ {showExtensions && serverExtensions.length > 0 && (
+
+
+ {serverExtensions.map((ext, idx) => (
+
+ {formatExtensionLabel(ext, idx)}
+
+ ))}
+
+
+ )}
+ {Array.isArray(serverSystemStats?.devices) && serverSystemStats.devices.length > 0 && (
+
+
Compute Devices
+
+ {serverSystemStats.devices.map((device, idx) => (
+
+
{formatDeviceName(device?.name, idx)}
+
+
Type: {device?.type || 'unknown'}
+
Index: {typeof device?.index === 'number' ? device.index : idx}
+
VRAM total: {formatBytes(device?.vram_total)}
+
VRAM free: {formatBytes(device?.vram_free)}
+
Torch VRAM total: {formatBytes(device?.torch_vram_total)}
+
Torch VRAM free: {formatBytes(device?.torch_vram_free)}
+
+
+ ))}
+
+
+ )}
+ {featureEntries.length > 0 ? (
+
+
Features
+
+ {featureEntries.map(([key, rawValue]) => (
+
+
{prettyFeatureLabel(key)}
+
+ {prettyFeatureValue(rawValue, key)}
+
+
+ ))}
+
+
+ ) : (
+
Features: none reported
+ )}
+
+ )}
+
+
+
+ Danger Zone
+
+
+
+
Clear Pending Queue
+
Remove all queued pending jobs that have not started yet.
+
+
+ Clear pending
+
+
+
+
+
+
Interrupt Running Execution
+
Stop currently running generation jobs on the server.
+
+
+ Interrupt execution
+
+
+
+
+
+
Clear Server History
+
Delete historical job records from the ComfyUI server.
+
+
+ Clear history
+
+
+
+
+
+
Free VRAM / Unload Models
+
Release runtime memory and unload models to recover from memory pressure.
+
+
+ Free memory
+
+
+
+ {opsActionStatus && (
+
+ {opsActionStatus.message}
+
+ )}
+
+
+
+
+
+ )
+}
From 076a1ad76328c6baca443c19ca94d26ccca1fec6 Mon Sep 17 00:00:00 2001
From: danohn <82357071+danohn@users.noreply.github.com>
Date: Tue, 17 Feb 2026 15:06:47 +1100
Subject: [PATCH 3/8] Replace inline home page with Home
---
src/App.jsx | 379 ++++----------------------------
src/features/home/HomePage.jsx | 383 +++++++++++++++++++++++++++++++++
2 files changed, 424 insertions(+), 338 deletions(-)
create mode 100644 src/features/home/HomePage.jsx
diff --git a/src/App.jsx b/src/App.jsx
index 9a0aab6..d9fee95 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -8,6 +8,7 @@ import TemplateBrowser from './features/templates/TemplateBrowser'
import TemplateModals from './features/templates/TemplateModals'
import OnboardingPage from './features/onboarding/OnboardingPage'
import SettingsPage from './features/settings/SettingsPage'
+import HomePage from './features/home/HomePage'
import { buildApiUrlFromParts, normalizeBaseUrl, parseApiUrlParts } from './lib/apiUrl'
import { collectWorkflowModelRequirements } from './lib/modelRequirements'
import { extractIndexedTemplates, extractWorkflowTemplates } from './lib/templateIndex'
@@ -845,344 +846,46 @@ export default function App() {
function renderHomePage() {
return (
-
-
-
-
-
-
ComfyUI
-
Generate images from text
-
-
-
- {imageSrc && (
-
- {isLoading ? (
-
- ) : error ? (
-
- ) : (
-
- )}
-
- )}
-
- {isLoading && !imageSrc && (
-
- )}
-
- {error && !imageSrc && (
-
- )}
-
-
-
-
-
-
Connected to: {apiUrl || 'Not configured'}
-
-
-
-
-
Recent Jobs
-
- Refresh
-
-
-
- {isLoadingRecentJobs ? (
-
- Loading server history...
-
- ) : recentJobsError ? (
-
- {recentJobsError}
-
- ) : serverRecentJobs.length === 0 ? (
-
- No server history yet.
-
- ) : (
-
- {serverRecentJobs.map((job) => (
-
-
-
-
{job.title || `Job ${String(job.id).slice(0, 8)}`}
-
{job.prompt || 'Prompt unavailable'}
-
{new Date(job.createdAt).toLocaleString()}
-
-
-
{
- if (selectedJobId === job.id) {
- setSelectedJobId(null)
- setJobDetail(null)
- setJobDetailError(null)
- return
- }
- fetchJobDetail(job.id)
- }}
- className={`px-2 py-1 text-xs rounded-md border ${selectedJobId === job.id ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-700 border-slate-300 hover:bg-slate-100'}`}
- >
- {selectedJobId === job.id ? 'Hide' : 'Details'}
-
-
- {job.status}
-
- {job.imageSrc ? (
-
showHistoryImage(job.imageSrc)}
- className="shrink-0 rounded-md overflow-hidden border border-slate-200 hover:border-slate-400 transition-colors"
- title="Open image preview"
- >
-
-
- ) : (
-
- )}
-
-
-
- ))}
-
- )}
-
- {(selectedJobId || isLoadingJobDetail || jobDetailError || jobDetail) && (
-
-
-
Job Detail
-
- {selectedJobId && (
- {selectedJobId}
- )}
- {
- setSelectedJobId(null)
- setJobDetail(null)
- setJobDetailError(null)
- }}
- className="text-xs text-slate-600 hover:text-slate-900"
- >
- Close
-
-
-
- {isLoadingJobDetail ? (
-
Loading job details...
- ) : jobDetailError ? (
-
{jobDetailError}
- ) : jobDetail ? (
-
-
Status: {jobDetail.status}
- {jobDetail.workflowId &&
Workflow ID: {jobDetail.workflowId}
}
- {typeof jobDetail.outputsCount === 'number' &&
Output count: {jobDetail.outputsCount}
}
- {typeof jobDetail.createTime === 'number' && (
-
Created: {new Date(jobDetail.createTime * 1000).toLocaleString()}
- )}
- {typeof jobDetail.updateTime === 'number' && (
-
Updated: {new Date(jobDetail.updateTime * 1000).toLocaleString()}
- )}
- {jobDetail.executionError && (
-
-
Execution Error
-
{jobDetail.executionError.exception_message || 'Unknown execution error'}
-
- )}
-
- Raw payload
-
- {JSON.stringify(jobDetail.raw, null, 2)}
-
-
-
- ) : null}
-
- )}
-
-
-
- {showWelcome && (
-
-
-
Welcome to ComfyUI Frontend
-
- This app needs two things before you can generate images: your ComfyUI API URL and a workflow JSON file.
-
-
-
1. Add your ComfyUI server URL
-
2. Upload your workflow JSON or use the sample workflow
-
-
- Get Started
-
-
-
- )}
-
+
)
}
diff --git a/src/features/home/HomePage.jsx b/src/features/home/HomePage.jsx
new file mode 100644
index 0000000..42df169
--- /dev/null
+++ b/src/features/home/HomePage.jsx
@@ -0,0 +1,383 @@
+import React from 'react'
+
+export default function HomePage({
+ openSettingsPage,
+ imageSrc,
+ isLoading,
+ statusMessage,
+ error,
+ handleGenerate,
+ hasConfiguredApiUrl,
+ hasConfiguredWorkflow,
+ workflowName,
+ queueState,
+ canCloseSettings,
+ openOnboardingPage,
+ promptInputMode,
+ handleInputImageChange,
+ inputImageName,
+ clearInputImage,
+ promptText,
+ setPromptText,
+ handleCancelRun,
+ currentPromptId,
+ negativePromptText,
+ setNegativePromptText,
+ apiUrl,
+ fetchRecentHistory,
+ isLoadingRecentJobs,
+ recentJobsError,
+ serverRecentJobs,
+ selectedJobId,
+ setSelectedJobId,
+ setJobDetail,
+ setJobDetailError,
+ fetchJobDetail,
+ showHistoryImage,
+ isLoadingJobDetail,
+ jobDetailError,
+ jobDetail,
+ showWelcome,
+ handleStartOnboarding,
+}) {
+ return (
+
+
+
+
+
+
ComfyUI
+
Generate images from text
+
+
+
+ {imageSrc && (
+
+ {isLoading ? (
+
+ ) : error ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {isLoading && !imageSrc && (
+
+ )}
+
+ {error && !imageSrc && (
+
+ )}
+
+
+
+
+
+ API: {hasConfiguredApiUrl ? 'Configured' : 'Missing'}
+
+
+ Workflow: {hasConfiguredWorkflow ? workflowName : 'Missing'}
+
+ {typeof queueState.running === 'number' && typeof queueState.pending === 'number' && (
+
+ Queue: {queueState.running} running, {queueState.pending} pending
+
+ )}
+
+ {!canCloseSettings && (
+
+ Complete Setup
+
+ )}
+
+
+ {promptInputMode === 'dual' && (
+ Prompt
+ )}
+
+
+
+ Add input image
+
+
+
+ {inputImageName && (
+
+
+ {inputImageName}
+
+
+ Remove
+
+
+ )}
+
setPromptText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleGenerate(e)
+ }
+ }}
+ placeholder={promptInputMode === 'dual' ? 'Positive prompt' : 'What would you like to generate?'}
+ className={`w-full px-6 py-4 pt-20 bg-white border border-slate-300 text-slate-900 placeholder-slate-500 rounded-lg focus:outline-none focus:border-slate-300 focus:ring-2 focus:ring-slate-900 focus:ring-opacity-10 resize-none ${promptInputMode === 'dual' ? 'text-sm' : 'text-lg'}`}
+ rows="3"
+ disabled={isLoading}
+ />
+
+ {isLoading && (
+
+ Cancel
+
+ )}
+
+ {isLoading ? 'Generating' : 'Generate'}
+
+
+
+ {promptInputMode === 'dual' && (
+
+ Negative Prompt
+ setNegativePromptText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleGenerate(e)
+ }
+ }}
+ placeholder="Negative prompt"
+ className="w-full px-4 py-3 bg-white border border-slate-300 text-slate-900 placeholder-slate-500 rounded-lg focus:outline-none focus:border-slate-300 focus:ring-2 focus:ring-slate-900 focus:ring-opacity-10 resize-none text-sm"
+ rows="2"
+ disabled={isLoading}
+ />
+
+ )}
+
+ Press Enter to send, Shift+Enter for new line
+
+
+
+
+
+
Connected to: {apiUrl || 'Not configured'}
+
+
+
+
+
Recent Jobs
+
+ Refresh
+
+
+
+ {isLoadingRecentJobs ? (
+
+ Loading server history...
+
+ ) : recentJobsError ? (
+
+ {recentJobsError}
+
+ ) : serverRecentJobs.length === 0 ? (
+
+ No server history yet.
+
+ ) : (
+
+ {serverRecentJobs.map((job) => (
+
+
+
+
{job.title || `Job ${String(job.id).slice(0, 8)}`}
+
{job.prompt || 'Prompt unavailable'}
+
{new Date(job.createdAt).toLocaleString()}
+
+
+
{
+ if (selectedJobId === job.id) {
+ setSelectedJobId(null)
+ setJobDetail(null)
+ setJobDetailError(null)
+ return
+ }
+ fetchJobDetail(job.id)
+ }}
+ className={`px-2 py-1 text-xs rounded-md border ${selectedJobId === job.id ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-700 border-slate-300 hover:bg-slate-100'}`}
+ >
+ {selectedJobId === job.id ? 'Hide' : 'Details'}
+
+
+ {job.status}
+
+ {job.imageSrc ? (
+
showHistoryImage(job.imageSrc)}
+ className="shrink-0 rounded-md overflow-hidden border border-slate-200 hover:border-slate-400 transition-colors"
+ title="Open image preview"
+ >
+
+
+ ) : (
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ {(selectedJobId || isLoadingJobDetail || jobDetailError || jobDetail) && (
+
+
+
Job Detail
+
+ {selectedJobId && (
+ {selectedJobId}
+ )}
+ {
+ setSelectedJobId(null)
+ setJobDetail(null)
+ setJobDetailError(null)
+ }}
+ className="text-xs text-slate-600 hover:text-slate-900"
+ >
+ Close
+
+
+
+ {isLoadingJobDetail ? (
+
Loading job details...
+ ) : jobDetailError ? (
+
{jobDetailError}
+ ) : jobDetail ? (
+
+
Status: {jobDetail.status}
+ {jobDetail.workflowId &&
Workflow ID: {jobDetail.workflowId}
}
+ {typeof jobDetail.outputsCount === 'number' &&
Output count: {jobDetail.outputsCount}
}
+ {typeof jobDetail.createTime === 'number' && (
+
Created: {new Date(jobDetail.createTime * 1000).toLocaleString()}
+ )}
+ {typeof jobDetail.updateTime === 'number' && (
+
Updated: {new Date(jobDetail.updateTime * 1000).toLocaleString()}
+ )}
+ {jobDetail.executionError && (
+
+
Execution Error
+
{jobDetail.executionError.exception_message || 'Unknown execution error'}
+
+ )}
+
+ Raw payload
+
+ {JSON.stringify(jobDetail.raw, null, 2)}
+
+
+
+ ) : null}
+
+ )}
+
+
+
+ {showWelcome && (
+
+
+
Welcome to ComfyUI Frontend
+
+ This app needs two things before you can generate images: your ComfyUI API URL and a workflow JSON file.
+
+
+
1. Add your ComfyUI server URL
+
2. Upload your workflow JSON or use the sample workflow
+
+
+ Get Started
+
+
+
+ )}
+
+ )
+}
From 20141cbb1bca10dd0fd55010e071eaa6cbb1bede Mon Sep 17 00:00:00 2001
From: danohn <82357071+danohn@users.noreply.github.com>
Date: Tue, 17 Feb 2026 15:11:41 +1100
Subject: [PATCH 4/8] Include recent job hook
---
src/App.jsx | 332 ++----------------
src/features/templates/templateBrowserData.js | 98 ++++++
src/hooks/useRecentJobs.js | 235 +++++++++++++
3 files changed, 359 insertions(+), 306 deletions(-)
create mode 100644 src/features/templates/templateBrowserData.js
create mode 100644 src/hooks/useRecentJobs.js
diff --git a/src/App.jsx b/src/App.jsx
index d9fee95..e2843ae 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,7 +1,8 @@
-import React, { useEffect, useRef, useState } from 'react'
+import React, { useEffect, useState } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import defaultWorkflow from '../01_get_started_text_to_image.json'
import useApiConfig from './hooks/useApiConfig'
+import useRecentJobs from './hooks/useRecentJobs'
import useWorkflowConfig from './hooks/useWorkflowConfig'
import useGeneration from './hooks/useGeneration'
import TemplateBrowser from './features/templates/TemplateBrowser'
@@ -9,10 +10,11 @@ import TemplateModals from './features/templates/TemplateModals'
import OnboardingPage from './features/onboarding/OnboardingPage'
import SettingsPage from './features/settings/SettingsPage'
import HomePage from './features/home/HomePage'
+import { getTemplateBrowserData } from './features/templates/templateBrowserData'
import { buildApiUrlFromParts, normalizeBaseUrl, parseApiUrlParts } from './lib/apiUrl'
import { collectWorkflowModelRequirements } from './lib/modelRequirements'
import { extractIndexedTemplates, extractWorkflowTemplates } from './lib/templateIndex'
-import { analyzeWorkflowPromptInputs, extractPromptFromGraph } from './lib/workflowPrompt'
+import { analyzeWorkflowPromptInputs } from './lib/workflowPrompt'
const REMOTE_TEMPLATES_BASE_URL = 'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates'
const REMOTE_TEMPLATES_INDEX_URL = `${REMOTE_TEMPLATES_BASE_URL}/index.json`
@@ -56,16 +58,8 @@ export default function App() {
const [modelInventory, setModelInventory] = useState(null)
const [isLoadingModelInventory, setIsLoadingModelInventory] = useState(false)
const [opsActionStatus, setOpsActionStatus] = useState(null)
- const [serverRecentJobs, setServerRecentJobs] = useState([])
- const [isLoadingRecentJobs, setIsLoadingRecentJobs] = useState(false)
- const [recentJobsError, setRecentJobsError] = useState(null)
- const [selectedJobId, setSelectedJobId] = useState(null)
- const [jobDetail, setJobDetail] = useState(null)
- const [isLoadingJobDetail, setIsLoadingJobDetail] = useState(false)
- const [jobDetailError, setJobDetailError] = useState(null)
const [toast, setToast] = useState(null)
const [onboardingStep, setOnboardingStep] = useState(1)
- const jobPromptCacheRef = useRef({})
const {
apiUrl,
@@ -99,6 +93,20 @@ export default function App() {
cancelCurrentRun,
generate,
} = useGeneration()
+ const {
+ serverRecentJobs,
+ isLoadingRecentJobs,
+ recentJobsError,
+ selectedJobId,
+ setSelectedJobId,
+ jobDetail,
+ setJobDetail,
+ isLoadingJobDetail,
+ jobDetailError,
+ setJobDetailError,
+ fetchRecentHistory,
+ fetchJobDetail,
+ } = useRecentJobs(apiUrl)
const canCloseSettings = hasConfiguredApiUrl && hasConfiguredWorkflow
@@ -509,213 +517,6 @@ export default function App() {
}
}
- function extractImageFromOutputObject(outputObject) {
- if (!outputObject || typeof outputObject !== 'object') return null
-
- const baseUrl = normalizeBaseUrl(apiUrl)
- const buildViewUrl = (fileObject) => {
- if (!fileObject || typeof fileObject !== 'object') return null
- const filename = fileObject.filename
- if (!filename) return null
- const subfolder = fileObject.subfolder || ''
- const type = fileObject.type || 'output'
- return `${baseUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder)}&type=${encodeURIComponent(type)}`
- }
-
- // /api/jobs preview_output is often a direct file object, not images/video arrays.
- const directUrl = buildViewUrl(outputObject)
- if (directUrl) return directUrl
-
- const imageLists = []
- if (Array.isArray(outputObject.images)) imageLists.push(outputObject.images)
- if (Array.isArray(outputObject.video)) imageLists.push(outputObject.video)
- if (Array.isArray(outputObject.audio)) imageLists.push(outputObject.audio)
-
- for (const list of imageLists) {
- if (list.length === 0) continue
- const url = buildViewUrl(list[0])
- if (url) return url
- }
-
- return null
- }
-
- function parseHistoryEntries(historyObject) {
- const entries = Object.entries(historyObject || {}).map(([promptId, record]) => {
- const graph = record?.prompt?.[2]
- const prompt = extractPromptFromGraph(graph) || '(prompt unavailable)'
- const status = record?.status?.status_str || (record?.status?.completed ? 'success' : 'unknown')
- const messages = Array.isArray(record?.status?.messages) ? record.status.messages : []
- const startMessage = messages.find((msg) => Array.isArray(msg) && msg[0] === 'execution_start')
- const startTimestamp = startMessage?.[1]?.timestamp
- const createdAt = typeof startTimestamp === 'number'
- ? new Date(startTimestamp).toISOString()
- : new Date().toISOString()
-
- let imageSrc = null
- if (record?.outputs && typeof record.outputs === 'object') {
- for (const output of Object.values(record.outputs)) {
- imageSrc = extractImageFromOutputObject(output)
- if (imageSrc) break
- }
- }
-
- return {
- id: promptId,
- prompt,
- status,
- createdAt,
- imageSrc,
- }
- })
-
- return entries
- .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
- .slice(0, 20)
- }
-
- function parseApiJobsEntries(jobsArray) {
- return (Array.isArray(jobsArray) ? jobsArray : [])
- .map((job) => {
- const jobId = job?.id || job?.prompt_id || `${Math.random()}`
- const cachedPrompt = jobPromptCacheRef.current[jobId]
- const prompt = cachedPrompt || job?.prompt || null
- const createdAt = (() => {
- if (typeof job?.create_time !== 'number') return new Date().toISOString()
- const createMs = job.create_time > 1e12 ? job.create_time : job.create_time * 1000
- return new Date(createMs).toISOString()
- })()
- const status = job?.status || 'unknown'
- const previewOutput = job?.preview_output
- const imageSrc = extractImageFromOutputObject(previewOutput)
-
- return {
- id: jobId,
- title: job?.name || `Job ${String(jobId).slice(0, 8)}`,
- prompt,
- status,
- createdAt,
- imageSrc,
- source: 'api-jobs',
- }
- })
- .slice(0, 20)
- }
-
- async function hydrateRecentJobPrompts(baseUrl, jobs) {
- const candidates = jobs.filter((job) => !job.prompt && !jobPromptCacheRef.current[job.id]).slice(0, 6)
- if (candidates.length === 0) return
-
- await Promise.all(
- candidates.map(async (job) => {
- try {
- const res = await fetch(`${baseUrl}/history/${encodeURIComponent(job.id)}`)
- if (!res.ok) return
- const detail = await res.json()
- const record = detail?.[job.id] || Object.values(detail || {})[0]
- const graph = record?.prompt?.[2]
- const extractedPrompt = extractPromptFromGraph(graph)
- if (!extractedPrompt) return
- jobPromptCacheRef.current[job.id] = extractedPrompt
- } catch (_) {
- // Ignore per-job prompt hydration failures.
- }
- })
- )
-
- setServerRecentJobs((current) =>
- current.map((job) => ({
- ...job,
- prompt: job.prompt || jobPromptCacheRef.current[job.id] || null,
- }))
- )
- }
-
- async function fetchRecentHistory() {
- if (!apiUrl) return
-
- setIsLoadingRecentJobs(true)
- setRecentJobsError(null)
- try {
- const baseUrl = normalizeBaseUrl(apiUrl)
- const jobsRes = await fetch(`${baseUrl}/api/jobs?limit=20&offset=0&sort_by=created_at&sort_order=desc`)
- if (jobsRes.ok) {
- const jobsData = await jobsRes.json()
- const parsedJobs = parseApiJobsEntries(jobsData?.jobs)
- if (parsedJobs.length > 0) {
- setServerRecentJobs(parsedJobs)
- hydrateRecentJobPrompts(baseUrl, parsedJobs)
- return
- }
- }
-
- const res = await fetch(`${baseUrl}/history`)
- if (!res.ok) {
- throw new Error(`History request failed: ${res.status}`)
- }
- const historyData = await res.json()
- setServerRecentJobs(parseHistoryEntries(historyData))
- } catch (err) {
- setRecentJobsError(String(err))
- } finally {
- setIsLoadingRecentJobs(false)
- }
- }
-
- async function fetchJobDetail(jobId) {
- if (!apiUrl || !jobId) return
-
- setSelectedJobId(jobId)
- setIsLoadingJobDetail(true)
- setJobDetailError(null)
- try {
- const baseUrl = normalizeBaseUrl(apiUrl)
- const jobsDetailRes = await fetch(`${baseUrl}/api/jobs/${encodeURIComponent(jobId)}`)
- if (jobsDetailRes.ok) {
- const detail = await jobsDetailRes.json()
- setJobDetail({
- source: 'api-jobs',
- id: detail?.id || jobId,
- status: detail?.status || 'unknown',
- createTime: detail?.create_time,
- updateTime: detail?.update_time,
- workflowId: detail?.workflow_id || null,
- outputsCount: detail?.outputs_count ?? null,
- executionError: detail?.execution_error || null,
- raw: detail,
- })
- return
- }
-
- const historyRes = await fetch(`${baseUrl}/history/${encodeURIComponent(jobId)}`)
- if (!historyRes.ok) {
- throw new Error(`Job detail request failed: ${jobsDetailRes.status}`)
- }
- const historyDetail = await historyRes.json()
- const record = historyDetail?.[jobId] || Object.values(historyDetail || {})[0]
- if (!record || typeof record !== 'object') {
- throw new Error('No detail found for selected job')
- }
-
- setJobDetail({
- source: 'history',
- id: jobId,
- status: record?.status?.status_str || (record?.status?.completed ? 'success' : 'unknown'),
- createTime: null,
- updateTime: null,
- workflowId: null,
- outputsCount: record?.outputs ? Object.keys(record.outputs).length : 0,
- executionError: null,
- raw: record,
- })
- } catch (err) {
- setJobDetail(null)
- setJobDetailError(String(err))
- } finally {
- setIsLoadingJobDetail(false)
- }
- }
-
async function applyTemplate(templateId) {
const template = serverTemplates.find((entry) => entry.id === templateId)
if (!template) {
@@ -889,101 +690,20 @@ export default function App() {
)
}
- function getTemplateBrowserData() {
- const normalizedQuery = templateSearch.trim().toLowerCase()
- const sidebarCategories = (() => {
- const groups = new Map()
- for (const template of serverTemplates) {
- const groupName = template.categoryGroup || 'Templates'
- const categoryName = template.category || 'Templates'
- if (!groups.has(groupName)) groups.set(groupName, new Set())
- groups.get(groupName).add(categoryName)
- }
- return Array.from(groups.entries())
- .map(([groupName, set]) => ({
- groupName,
- categories: Array.from(set).sort((a, b) => a.localeCompare(b)),
- }))
- .sort((a, b) => a.groupName.localeCompare(b.groupName))
- })()
- const availableTemplateModels = Array.from(
- new Set(
- serverTemplates.flatMap((template) =>
- Array.isArray(template.models) ? template.models : []
- )
- )
- ).sort((a, b) => a.localeCompare(b))
- const availableTemplateTags = Array.from(
- new Set(
- serverTemplates.flatMap((template) =>
- Array.isArray(template.tags) ? template.tags : []
- )
- )
- ).sort((a, b) => a.localeCompare(b))
- const filteredTemplates = (() => {
- let list = [...serverTemplates]
-
- if (selectedTemplateCategory !== 'all' && selectedTemplateCategory !== 'popular') {
- list = list.filter((template) => (template.category || 'Templates') === selectedTemplateCategory)
- }
- if (templateModelFilter) {
- list = list.filter((template) => (template.models || []).includes(templateModelFilter))
- }
- if (templateTagFilter) {
- list = list.filter((template) => (template.tags || []).includes(templateTagFilter))
- }
- if (normalizedQuery) {
- list = list.filter((template) => {
- const haystack = [
- template.title,
- template.label,
- template.description,
- ...(Array.isArray(template.tags) ? template.tags : []),
- ...(Array.isArray(template.models) ? template.models : []),
- template.mediaType,
- ]
- .filter(Boolean)
- .join(' ')
- .toLowerCase()
- return haystack.includes(normalizedQuery)
- })
- }
-
- const effectiveSort = selectedTemplateCategory === 'popular' ? 'popular' : templateSort
- list.sort((a, b) => {
- if (effectiveSort === 'popular') {
- return (b.usage || 0) - (a.usage || 0)
- }
- if (effectiveSort === 'newest') {
- const aTime = a.date ? new Date(a.date).getTime() : 0
- const bTime = b.date ? new Date(b.date).getTime() : 0
- return bTime - aTime
- }
- if (effectiveSort === 'alphabetical') {
- return String(a.title || a.label || '').localeCompare(String(b.title || b.label || ''))
- }
- const usageDiff = (b.usage || 0) - (a.usage || 0)
- if (usageDiff !== 0) return usageDiff
- return String(a.title || a.label || '').localeCompare(String(b.title || b.label || ''))
- })
-
- return list
- })()
- return {
- sidebarCategories,
- availableTemplateModels,
- availableTemplateTags,
- filteredTemplates,
- }
- }
-
function renderTemplateBrowser() {
const {
sidebarCategories,
availableTemplateModels,
availableTemplateTags,
filteredTemplates,
- } = getTemplateBrowserData()
+ } = getTemplateBrowserData({
+ serverTemplates,
+ templateSearch,
+ selectedTemplateCategory,
+ templateModelFilter,
+ templateTagFilter,
+ templateSort,
+ })
return (
{
+ const groups = new Map()
+ for (const template of serverTemplates) {
+ const groupName = template.categoryGroup || 'Templates'
+ const categoryName = template.category || 'Templates'
+ if (!groups.has(groupName)) groups.set(groupName, new Set())
+ groups.get(groupName).add(categoryName)
+ }
+ return Array.from(groups.entries())
+ .map(([groupName, set]) => ({
+ groupName,
+ categories: Array.from(set).sort((a, b) => a.localeCompare(b)),
+ }))
+ .sort((a, b) => a.groupName.localeCompare(b.groupName))
+ })()
+
+ const availableTemplateModels = Array.from(
+ new Set(
+ serverTemplates.flatMap((template) =>
+ Array.isArray(template.models) ? template.models : []
+ )
+ )
+ ).sort((a, b) => a.localeCompare(b))
+
+ const availableTemplateTags = Array.from(
+ new Set(
+ serverTemplates.flatMap((template) =>
+ Array.isArray(template.tags) ? template.tags : []
+ )
+ )
+ ).sort((a, b) => a.localeCompare(b))
+
+ const filteredTemplates = (() => {
+ let list = [...serverTemplates]
+
+ if (selectedTemplateCategory !== 'all' && selectedTemplateCategory !== 'popular') {
+ list = list.filter((template) => (template.category || 'Templates') === selectedTemplateCategory)
+ }
+ if (templateModelFilter) {
+ list = list.filter((template) => (template.models || []).includes(templateModelFilter))
+ }
+ if (templateTagFilter) {
+ list = list.filter((template) => (template.tags || []).includes(templateTagFilter))
+ }
+ if (normalizedQuery) {
+ list = list.filter((template) => {
+ const haystack = [
+ template.title,
+ template.label,
+ template.description,
+ ...(Array.isArray(template.tags) ? template.tags : []),
+ ...(Array.isArray(template.models) ? template.models : []),
+ template.mediaType,
+ ]
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase()
+ return haystack.includes(normalizedQuery)
+ })
+ }
+
+ const effectiveSort = selectedTemplateCategory === 'popular' ? 'popular' : templateSort
+ list.sort((a, b) => {
+ if (effectiveSort === 'popular') {
+ return (b.usage || 0) - (a.usage || 0)
+ }
+ if (effectiveSort === 'newest') {
+ const aTime = a.date ? new Date(a.date).getTime() : 0
+ const bTime = b.date ? new Date(b.date).getTime() : 0
+ return bTime - aTime
+ }
+ if (effectiveSort === 'alphabetical') {
+ return String(a.title || a.label || '').localeCompare(String(b.title || b.label || ''))
+ }
+ const usageDiff = (b.usage || 0) - (a.usage || 0)
+ if (usageDiff !== 0) return usageDiff
+ return String(a.title || a.label || '').localeCompare(String(b.title || b.label || ''))
+ })
+
+ return list
+ })()
+
+ return {
+ sidebarCategories,
+ availableTemplateModels,
+ availableTemplateTags,
+ filteredTemplates,
+ }
+}
diff --git a/src/hooks/useRecentJobs.js b/src/hooks/useRecentJobs.js
new file mode 100644
index 0000000..2201118
--- /dev/null
+++ b/src/hooks/useRecentJobs.js
@@ -0,0 +1,235 @@
+import { useCallback, useRef, useState } from 'react'
+import { normalizeBaseUrl } from '../lib/apiUrl'
+import { extractPromptFromGraph } from '../lib/workflowPrompt'
+
+export default function useRecentJobs(apiUrl) {
+ const [serverRecentJobs, setServerRecentJobs] = useState([])
+ const [isLoadingRecentJobs, setIsLoadingRecentJobs] = useState(false)
+ const [recentJobsError, setRecentJobsError] = useState(null)
+ const [selectedJobId, setSelectedJobId] = useState(null)
+ const [jobDetail, setJobDetail] = useState(null)
+ const [isLoadingJobDetail, setIsLoadingJobDetail] = useState(false)
+ const [jobDetailError, setJobDetailError] = useState(null)
+ const jobPromptCacheRef = useRef({})
+
+ const extractImageFromOutputObject = useCallback((outputObject) => {
+ if (!outputObject || typeof outputObject !== 'object') return null
+
+ const baseUrl = normalizeBaseUrl(apiUrl)
+ const buildViewUrl = (fileObject) => {
+ if (!fileObject || typeof fileObject !== 'object') return null
+ const filename = fileObject.filename
+ if (!filename) return null
+ const subfolder = fileObject.subfolder || ''
+ const type = fileObject.type || 'output'
+ return `${baseUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder)}&type=${encodeURIComponent(type)}`
+ }
+
+ const directUrl = buildViewUrl(outputObject)
+ if (directUrl) return directUrl
+
+ const imageLists = []
+ if (Array.isArray(outputObject.images)) imageLists.push(outputObject.images)
+ if (Array.isArray(outputObject.video)) imageLists.push(outputObject.video)
+ if (Array.isArray(outputObject.audio)) imageLists.push(outputObject.audio)
+
+ for (const list of imageLists) {
+ if (list.length === 0) continue
+ const url = buildViewUrl(list[0])
+ if (url) return url
+ }
+
+ return null
+ }, [apiUrl])
+
+ const parseHistoryEntries = useCallback((historyObject) => {
+ const entries = Object.entries(historyObject || {}).map(([promptId, record]) => {
+ const graph = record?.prompt?.[2]
+ const prompt = extractPromptFromGraph(graph) || '(prompt unavailable)'
+ const status = record?.status?.status_str || (record?.status?.completed ? 'success' : 'unknown')
+ const messages = Array.isArray(record?.status?.messages) ? record.status.messages : []
+ const startMessage = messages.find((msg) => Array.isArray(msg) && msg[0] === 'execution_start')
+ const startTimestamp = startMessage?.[1]?.timestamp
+ const createdAt = typeof startTimestamp === 'number'
+ ? new Date(startTimestamp).toISOString()
+ : new Date().toISOString()
+
+ let imageSrc = null
+ if (record?.outputs && typeof record.outputs === 'object') {
+ for (const output of Object.values(record.outputs)) {
+ imageSrc = extractImageFromOutputObject(output)
+ if (imageSrc) break
+ }
+ }
+
+ return {
+ id: promptId,
+ prompt,
+ status,
+ createdAt,
+ imageSrc,
+ }
+ })
+
+ return entries
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
+ .slice(0, 20)
+ }, [extractImageFromOutputObject])
+
+ const parseApiJobsEntries = useCallback((jobsArray) => {
+ return (Array.isArray(jobsArray) ? jobsArray : [])
+ .map((job) => {
+ const jobId = job?.id || job?.prompt_id || `${Math.random()}`
+ const cachedPrompt = jobPromptCacheRef.current[jobId]
+ const prompt = cachedPrompt || job?.prompt || null
+ const createdAt = (() => {
+ if (typeof job?.create_time !== 'number') return new Date().toISOString()
+ const createMs = job.create_time > 1e12 ? job.create_time : job.create_time * 1000
+ return new Date(createMs).toISOString()
+ })()
+ const status = job?.status || 'unknown'
+ const previewOutput = job?.preview_output
+ const imageSrc = extractImageFromOutputObject(previewOutput)
+
+ return {
+ id: jobId,
+ title: job?.name || `Job ${String(jobId).slice(0, 8)}`,
+ prompt,
+ status,
+ createdAt,
+ imageSrc,
+ source: 'api-jobs',
+ }
+ })
+ .slice(0, 20)
+ }, [extractImageFromOutputObject])
+
+ const hydrateRecentJobPrompts = useCallback(async (baseUrl, jobs) => {
+ const candidates = jobs.filter((job) => !job.prompt && !jobPromptCacheRef.current[job.id]).slice(0, 6)
+ if (candidates.length === 0) return
+
+ await Promise.all(
+ candidates.map(async (job) => {
+ try {
+ const res = await fetch(`${baseUrl}/history/${encodeURIComponent(job.id)}`)
+ if (!res.ok) return
+ const detail = await res.json()
+ const record = detail?.[job.id] || Object.values(detail || {})[0]
+ const graph = record?.prompt?.[2]
+ const extractedPrompt = extractPromptFromGraph(graph)
+ if (!extractedPrompt) return
+ jobPromptCacheRef.current[job.id] = extractedPrompt
+ } catch (_) {
+ // Ignore per-job prompt hydration failures.
+ }
+ })
+ )
+
+ setServerRecentJobs((current) =>
+ current.map((job) => ({
+ ...job,
+ prompt: job.prompt || jobPromptCacheRef.current[job.id] || null,
+ }))
+ )
+ }, [])
+
+ const fetchRecentHistory = useCallback(async () => {
+ if (!apiUrl) return
+
+ setIsLoadingRecentJobs(true)
+ setRecentJobsError(null)
+ try {
+ const baseUrl = normalizeBaseUrl(apiUrl)
+ const jobsRes = await fetch(`${baseUrl}/api/jobs?limit=20&offset=0&sort_by=created_at&sort_order=desc`)
+ if (jobsRes.ok) {
+ const jobsData = await jobsRes.json()
+ const parsedJobs = parseApiJobsEntries(jobsData?.jobs)
+ if (parsedJobs.length > 0) {
+ setServerRecentJobs(parsedJobs)
+ hydrateRecentJobPrompts(baseUrl, parsedJobs)
+ return
+ }
+ }
+
+ const res = await fetch(`${baseUrl}/history`)
+ if (!res.ok) {
+ throw new Error(`History request failed: ${res.status}`)
+ }
+ const historyData = await res.json()
+ setServerRecentJobs(parseHistoryEntries(historyData))
+ } catch (err) {
+ setRecentJobsError(String(err))
+ } finally {
+ setIsLoadingRecentJobs(false)
+ }
+ }, [apiUrl, hydrateRecentJobPrompts, parseApiJobsEntries, parseHistoryEntries])
+
+ const fetchJobDetail = useCallback(async (jobId) => {
+ if (!apiUrl || !jobId) return
+
+ setSelectedJobId(jobId)
+ setIsLoadingJobDetail(true)
+ setJobDetailError(null)
+ try {
+ const baseUrl = normalizeBaseUrl(apiUrl)
+ const jobsDetailRes = await fetch(`${baseUrl}/api/jobs/${encodeURIComponent(jobId)}`)
+ if (jobsDetailRes.ok) {
+ const detail = await jobsDetailRes.json()
+ setJobDetail({
+ source: 'api-jobs',
+ id: detail?.id || jobId,
+ status: detail?.status || 'unknown',
+ createTime: detail?.create_time,
+ updateTime: detail?.update_time,
+ workflowId: detail?.workflow_id || null,
+ outputsCount: detail?.outputs_count ?? null,
+ executionError: detail?.execution_error || null,
+ raw: detail,
+ })
+ return
+ }
+
+ const historyRes = await fetch(`${baseUrl}/history/${encodeURIComponent(jobId)}`)
+ if (!historyRes.ok) {
+ throw new Error(`Job detail request failed: ${jobsDetailRes.status}`)
+ }
+ const historyDetail = await historyRes.json()
+ const record = historyDetail?.[jobId] || Object.values(historyDetail || {})[0]
+ if (!record || typeof record !== 'object') {
+ throw new Error('No detail found for selected job')
+ }
+
+ setJobDetail({
+ source: 'history',
+ id: jobId,
+ status: record?.status?.status_str || (record?.status?.completed ? 'success' : 'unknown'),
+ createTime: null,
+ updateTime: null,
+ workflowId: null,
+ outputsCount: record?.outputs ? Object.keys(record.outputs).length : 0,
+ executionError: null,
+ raw: record,
+ })
+ } catch (err) {
+ setJobDetail(null)
+ setJobDetailError(String(err))
+ } finally {
+ setIsLoadingJobDetail(false)
+ }
+ }, [apiUrl])
+
+ return {
+ serverRecentJobs,
+ isLoadingRecentJobs,
+ recentJobsError,
+ selectedJobId,
+ setSelectedJobId,
+ jobDetail,
+ setJobDetail,
+ isLoadingJobDetail,
+ jobDetailError,
+ setJobDetailError,
+ fetchRecentHistory,
+ fetchJobDetail,
+ }
+}
From 4c5415a265fcb71059af5a687105e7bd41cac1c1 Mon Sep 17 00:00:00 2001
From: danohn <82357071+danohn@users.noreply.github.com>
Date: Tue, 17 Feb 2026 15:15:57 +1100
Subject: [PATCH 5/8] Integrate templates hook into App
---
src/App.jsx | 241 +++--------------
.../templates/templateBrowserData.test.js | 62 +++++
src/hooks/useTemplates.js | 243 ++++++++++++++++++
3 files changed, 337 insertions(+), 209 deletions(-)
create mode 100644 src/features/templates/templateBrowserData.test.js
create mode 100644 src/hooks/useTemplates.js
diff --git a/src/App.jsx b/src/App.jsx
index e2843ae..5e44479 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-
import defaultWorkflow from '../01_get_started_text_to_image.json'
import useApiConfig from './hooks/useApiConfig'
import useRecentJobs from './hooks/useRecentJobs'
+import useTemplates from './hooks/useTemplates'
import useWorkflowConfig from './hooks/useWorkflowConfig'
import useGeneration from './hooks/useGeneration'
import TemplateBrowser from './features/templates/TemplateBrowser'
@@ -12,13 +13,8 @@ import SettingsPage from './features/settings/SettingsPage'
import HomePage from './features/home/HomePage'
import { getTemplateBrowserData } from './features/templates/templateBrowserData'
import { buildApiUrlFromParts, normalizeBaseUrl, parseApiUrlParts } from './lib/apiUrl'
-import { collectWorkflowModelRequirements } from './lib/modelRequirements'
-import { extractIndexedTemplates, extractWorkflowTemplates } from './lib/templateIndex'
import { analyzeWorkflowPromptInputs } from './lib/workflowPrompt'
-const REMOTE_TEMPLATES_BASE_URL = 'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates'
-const REMOTE_TEMPLATES_INDEX_URL = `${REMOTE_TEMPLATES_BASE_URL}/index.json`
-
export default function App() {
const navigate = useNavigate()
const location = useLocation()
@@ -42,21 +38,8 @@ export default function App() {
const [serverExtensions, setServerExtensions] = useState([])
const [showExtensions, setShowExtensions] = useState(false)
const [serverHistoryCount, setServerHistoryCount] = useState(null)
- const [serverTemplates, setServerTemplates] = useState([])
- const [templateSource, setTemplateSource] = useState('none')
const [isLoadingServerData, setIsLoadingServerData] = useState(false)
const [serverDataError, setServerDataError] = useState(null)
- const [templateSearch, setTemplateSearch] = useState('')
- const [selectedTemplateCategory, setSelectedTemplateCategory] = useState('all')
- const [templateModelFilter, setTemplateModelFilter] = useState('')
- const [templateTagFilter, setTemplateTagFilter] = useState('')
- const [templateSort, setTemplateSort] = useState('default')
- const [selectedTemplateDetails, setSelectedTemplateDetails] = useState(null)
- const [applyingTemplateId, setApplyingTemplateId] = useState(null)
- const [modelCheckByTemplate, setModelCheckByTemplate] = useState({})
- const [selectedPrereqTemplateId, setSelectedPrereqTemplateId] = useState(null)
- const [modelInventory, setModelInventory] = useState(null)
- const [isLoadingModelInventory, setIsLoadingModelInventory] = useState(false)
const [opsActionStatus, setOpsActionStatus] = useState(null)
const [toast, setToast] = useState(null)
const [onboardingStep, setOnboardingStep] = useState(1)
@@ -93,6 +76,35 @@ export default function App() {
cancelCurrentRun,
generate,
} = useGeneration()
+ const {
+ serverTemplates,
+ templateSource,
+ templateSearch,
+ setTemplateSearch,
+ selectedTemplateCategory,
+ setSelectedTemplateCategory,
+ templateModelFilter,
+ setTemplateModelFilter,
+ templateTagFilter,
+ setTemplateTagFilter,
+ templateSort,
+ setTemplateSort,
+ selectedTemplateDetails,
+ setSelectedTemplateDetails,
+ applyingTemplateId,
+ modelCheckByTemplate,
+ selectedPrereqTemplateId,
+ setSelectedPrereqTemplateId,
+ isLoadingModelInventory,
+ loadTemplatesForBaseUrl,
+ evaluateWorkflowPrerequisites,
+ checkTemplateModels,
+ applyTemplate,
+ } = useTemplates({
+ apiUrl,
+ uploadWorkflowFile,
+ onSetError: setError,
+ })
const {
serverRecentJobs,
isLoadingRecentJobs,
@@ -257,15 +269,6 @@ export default function App() {
setInputImageName('')
}
- async function fetchTemplateIndex(indexUrl, templatesBaseUrl, source) {
- const res = await fetch(indexUrl)
- if (!res.ok) {
- throw new Error(`Template index failed: ${res.status}`)
- }
- const indexRaw = await res.json()
- return extractIndexedTemplates(indexRaw, templatesBaseUrl, source)
- }
-
async function fetchServerData() {
if (!apiUrl) return
@@ -273,12 +276,11 @@ export default function App() {
setServerDataError(null)
try {
const baseUrl = normalizeBaseUrl(apiUrl)
- const [featuresRes, statsRes, extensionsRes, historyRes, templatesRes, jobsRes] = await Promise.all([
+ const [featuresRes, statsRes, extensionsRes, historyRes, jobsRes] = await Promise.all([
fetch(`${baseUrl}/features`),
fetch(`${baseUrl}/system_stats`),
fetch(`${baseUrl}/extensions`),
fetch(`${baseUrl}/history`),
- fetch(`${baseUrl}/workflow_templates`),
fetch(`${baseUrl}/api/jobs?limit=1&offset=0`),
])
@@ -303,160 +305,14 @@ export default function App() {
setServerHistoryCount(total)
}
}
- const localTemplatesIndexUrl = `${baseUrl}/templates/index.json`
- try {
- const indexedTemplates = await fetchTemplateIndex(localTemplatesIndexUrl, `${baseUrl}/templates`, 'local-index')
- if (indexedTemplates.length > 0) {
- setServerTemplates(indexedTemplates)
- setTemplateSource('local-index')
- return
- }
- } catch (_) {
- // Continue to workflow_templates and remote fallback.
- }
-
- if (templatesRes.ok) {
- const templatesRaw = await templatesRes.json()
- const templates = extractWorkflowTemplates(templatesRaw)
- if (templates.length > 0) {
- setServerTemplates(templates)
- setTemplateSource('server')
- } else {
- const remoteTemplates = await fetchTemplateIndex(REMOTE_TEMPLATES_INDEX_URL, REMOTE_TEMPLATES_BASE_URL, 'remote')
- setServerTemplates(remoteTemplates)
- setTemplateSource(remoteTemplates.length > 0 ? 'remote' : 'none')
- }
- } else {
- const remoteTemplates = await fetchTemplateIndex(REMOTE_TEMPLATES_INDEX_URL, REMOTE_TEMPLATES_BASE_URL, 'remote')
- setServerTemplates(remoteTemplates)
- setTemplateSource(remoteTemplates.length > 0 ? 'remote' : 'none')
- }
+ await loadTemplatesForBaseUrl(baseUrl)
} catch (err) {
- setTemplateSource('none')
setServerDataError(String(err))
} finally {
setIsLoadingServerData(false)
}
}
- async function evaluateWorkflowPrerequisites(workflowData) {
- const baseUrl = normalizeBaseUrl(apiUrl)
- const inventory = await fetchModelInventory(baseUrl)
- if (!inventory) {
- throw new Error('Model inventory is still loading, please retry')
- }
-
- const requiredModels = collectWorkflowModelRequirements(workflowData)
- const missing = []
- let available = 0
- for (const model of requiredModels) {
- const folderKey = String(model.directory || '').toLowerCase()
- const nameKey = String(model.name || '').toLowerCase()
- const folderEntries = inventory[folderKey]
- const exists = folderEntries instanceof Set ? folderEntries.has(nameKey) : false
- if (exists) {
- available += 1
- } else {
- missing.push(model)
- }
- }
-
- return {
- missing,
- available,
- total: requiredModels.length,
- checkedAt: new Date().toISOString(),
- }
- }
-
- async function fetchModelInventory(baseUrl) {
- if (modelInventory) return modelInventory
- if (isLoadingModelInventory) return null
-
- setIsLoadingModelInventory(true)
- try {
- const foldersRes = await fetch(`${baseUrl}/models`)
- if (!foldersRes.ok) {
- throw new Error(`Model folders lookup failed: ${foldersRes.status}`)
- }
- const folders = await foldersRes.json()
- const folderList = Array.isArray(folders) ? folders : []
-
- const folderEntries = await Promise.all(
- folderList.map(async (folder) => {
- const folderKey = String(folder).toLowerCase()
- try {
- const res = await fetch(`${baseUrl}/models/${encodeURIComponent(folder)}`)
- if (!res.ok) return [folderKey, new Set()]
- const files = await res.json()
- const names = Array.isArray(files) ? files.map((name) => String(name).toLowerCase()) : []
- return [folderKey, new Set(names)]
- } catch (_) {
- return [folderKey, new Set()]
- }
- })
- )
-
- const inventory = Object.fromEntries(folderEntries)
- setModelInventory(inventory)
- return inventory
- } finally {
- setIsLoadingModelInventory(false)
- }
- }
-
- async function loadTemplateWorkflowData(template) {
- let workflowData = template.workflow
- if (!workflowData && template.workflowUrl) {
- const res = await fetch(template.workflowUrl)
- if (!res.ok) {
- throw new Error(`Template workflow download failed: ${res.status}`)
- }
- workflowData = await res.json()
- }
- if (!workflowData || typeof workflowData !== 'object') {
- throw new Error('Template workflow payload is invalid')
- }
- return workflowData
- }
-
- async function checkTemplateModels(templateId, options = {}) {
- if (!apiUrl) return
- const template = serverTemplates.find((entry) => entry.id === templateId)
- if (!template) return
-
- setModelCheckByTemplate((current) => ({
- ...current,
- [templateId]: { loading: true, error: null, missing: [], available: 0, total: 0 },
- }))
-
- try {
- const workflowData = options.workflowData || await loadTemplateWorkflowData(template)
- const result = await evaluateWorkflowPrerequisites(workflowData)
- const { missing, available, total, checkedAt } = result
-
- setModelCheckByTemplate((current) => ({
- ...current,
- [templateId]: {
- loading: false,
- error: null,
- missing,
- available,
- total,
- checkedAt,
- },
- }))
- return { loading: false, error: null, ...result }
- } catch (err) {
- const errorValue = String(err)
- setModelCheckByTemplate((current) => ({
- ...current,
- [templateId]: { loading: false, error: errorValue, missing: [], available: 0, total: 0 },
- }))
- return { loading: false, error: errorValue, missing: [], available: 0, total: 0 }
- }
- }
-
async function runWorkflowHealthCheck(workflowData = workflow) {
if (!apiUrl) {
setWorkflowHealth({
@@ -517,39 +373,6 @@ export default function App() {
}
}
- async function applyTemplate(templateId) {
- const template = serverTemplates.find((entry) => entry.id === templateId)
- if (!template) {
- setError('Selected template not found')
- return
- }
-
- try {
- setApplyingTemplateId(templateId)
- const workflowData = await loadTemplateWorkflowData(template)
- const check = await checkTemplateModels(templateId, { workflowData })
- if (check?.error) {
- throw new Error(check.error)
- }
-
- const hasMissingPrereqs = Array.isArray(check?.missing) && check.missing.length > 0
- if (hasMissingPrereqs) {
- setError(`Missing prerequisites: ${check.missing.length} models not installed. Install missing models before applying this template.`)
- return
- }
-
- const safeName = String(template.label || template.id).replace(/[^a-z0-9_-]+/gi, '_').slice(0, 80) || 'template'
- const blob = new Blob([JSON.stringify(workflowData)], { type: 'application/json' })
- const file = new File([blob], `${safeName}.json`, { type: 'application/json' })
- await uploadWorkflowFile(file)
- setError(null)
- } catch (err) {
- setError(`Failed to apply template: ${err.message}`)
- } finally {
- setApplyingTemplateId(null)
- }
- }
-
async function runOpsAction(actionName, body) {
if (!apiUrl) {
setOpsActionStatus({ ok: false, message: 'Configure API URL first' })
diff --git a/src/features/templates/templateBrowserData.test.js b/src/features/templates/templateBrowserData.test.js
new file mode 100644
index 0000000..16bf68b
--- /dev/null
+++ b/src/features/templates/templateBrowserData.test.js
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest'
+import { getTemplateBrowserData } from './templateBrowserData'
+
+describe('getTemplateBrowserData', () => {
+ const templates = [
+ {
+ id: '1',
+ title: 'A',
+ label: 'A',
+ description: 'First',
+ categoryGroup: 'Use Cases',
+ category: 'Image',
+ tags: ['Tag1'],
+ models: ['ModelA'],
+ mediaType: 'image',
+ usage: 10,
+ date: '2025-01-01',
+ },
+ {
+ id: '2',
+ title: 'B',
+ label: 'B',
+ description: 'Second',
+ categoryGroup: 'Use Cases',
+ category: 'Video',
+ tags: ['Tag2'],
+ models: ['ModelB'],
+ mediaType: 'video',
+ usage: 20,
+ date: '2025-02-01',
+ },
+ ]
+
+ it('builds sidebar categories and filters by category', () => {
+ const result = getTemplateBrowserData({
+ serverTemplates: templates,
+ templateSearch: '',
+ selectedTemplateCategory: 'Image',
+ templateModelFilter: '',
+ templateTagFilter: '',
+ templateSort: 'default',
+ })
+
+ expect(result.sidebarCategories[0].groupName).toBe('Use Cases')
+ expect(result.filteredTemplates).toHaveLength(1)
+ expect(result.filteredTemplates[0].id).toBe('1')
+ })
+
+ it('supports search + popular sorting', () => {
+ const result = getTemplateBrowserData({
+ serverTemplates: templates,
+ templateSearch: 'second',
+ selectedTemplateCategory: 'popular',
+ templateModelFilter: '',
+ templateTagFilter: '',
+ templateSort: 'default',
+ })
+
+ expect(result.filteredTemplates).toHaveLength(1)
+ expect(result.filteredTemplates[0].id).toBe('2')
+ })
+})
diff --git a/src/hooks/useTemplates.js b/src/hooks/useTemplates.js
new file mode 100644
index 0000000..a66b79b
--- /dev/null
+++ b/src/hooks/useTemplates.js
@@ -0,0 +1,243 @@
+import { useState } from 'react'
+import { normalizeBaseUrl } from '../lib/apiUrl'
+import { collectWorkflowModelRequirements } from '../lib/modelRequirements'
+import { extractIndexedTemplates, extractWorkflowTemplates } from '../lib/templateIndex'
+
+const REMOTE_TEMPLATES_BASE_URL = 'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates'
+const REMOTE_TEMPLATES_INDEX_URL = `${REMOTE_TEMPLATES_BASE_URL}/index.json`
+
+export default function useTemplates({ apiUrl, uploadWorkflowFile, onSetError }) {
+ const [serverTemplates, setServerTemplates] = useState([])
+ const [templateSource, setTemplateSource] = useState('none')
+ const [templateSearch, setTemplateSearch] = useState('')
+ const [selectedTemplateCategory, setSelectedTemplateCategory] = useState('all')
+ const [templateModelFilter, setTemplateModelFilter] = useState('')
+ const [templateTagFilter, setTemplateTagFilter] = useState('')
+ const [templateSort, setTemplateSort] = useState('default')
+ const [selectedTemplateDetails, setSelectedTemplateDetails] = useState(null)
+ const [applyingTemplateId, setApplyingTemplateId] = useState(null)
+ const [modelCheckByTemplate, setModelCheckByTemplate] = useState({})
+ const [selectedPrereqTemplateId, setSelectedPrereqTemplateId] = useState(null)
+ const [modelInventory, setModelInventory] = useState(null)
+ const [isLoadingModelInventory, setIsLoadingModelInventory] = useState(false)
+
+ async function fetchTemplateIndex(indexUrl, templatesBaseUrl, source) {
+ const res = await fetch(indexUrl)
+ if (!res.ok) {
+ throw new Error(`Template index failed: ${res.status}`)
+ }
+ const indexRaw = await res.json()
+ return extractIndexedTemplates(indexRaw, templatesBaseUrl, source)
+ }
+
+ async function loadTemplatesForBaseUrl(baseUrl) {
+ try {
+ const localTemplatesIndexUrl = `${baseUrl}/templates/index.json`
+ try {
+ const indexedTemplates = await fetchTemplateIndex(localTemplatesIndexUrl, `${baseUrl}/templates`, 'local-index')
+ if (indexedTemplates.length > 0) {
+ setServerTemplates(indexedTemplates)
+ setTemplateSource('local-index')
+ return
+ }
+ } catch (_) {
+ // Continue to /workflow_templates and remote fallback.
+ }
+
+ const templatesRes = await fetch(`${baseUrl}/workflow_templates`)
+ if (templatesRes.ok) {
+ const templatesRaw = await templatesRes.json()
+ const templates = extractWorkflowTemplates(templatesRaw)
+ if (templates.length > 0) {
+ setServerTemplates(templates)
+ setTemplateSource('server')
+ return
+ }
+ }
+
+ const remoteTemplates = await fetchTemplateIndex(REMOTE_TEMPLATES_INDEX_URL, REMOTE_TEMPLATES_BASE_URL, 'remote')
+ setServerTemplates(remoteTemplates)
+ setTemplateSource(remoteTemplates.length > 0 ? 'remote' : 'none')
+ } catch (err) {
+ setTemplateSource('none')
+ throw err
+ }
+ }
+
+ async function fetchModelInventory(baseUrl) {
+ if (modelInventory) return modelInventory
+ if (isLoadingModelInventory) return null
+
+ setIsLoadingModelInventory(true)
+ try {
+ const foldersRes = await fetch(`${baseUrl}/models`)
+ if (!foldersRes.ok) {
+ throw new Error(`Model folders lookup failed: ${foldersRes.status}`)
+ }
+ const folders = await foldersRes.json()
+ const folderList = Array.isArray(folders) ? folders : []
+
+ const folderEntries = await Promise.all(
+ folderList.map(async (folder) => {
+ const folderKey = String(folder).toLowerCase()
+ try {
+ const res = await fetch(`${baseUrl}/models/${encodeURIComponent(folder)}`)
+ if (!res.ok) return [folderKey, new Set()]
+ const files = await res.json()
+ const names = Array.isArray(files) ? files.map((name) => String(name).toLowerCase()) : []
+ return [folderKey, new Set(names)]
+ } catch (_) {
+ return [folderKey, new Set()]
+ }
+ })
+ )
+
+ const inventory = Object.fromEntries(folderEntries)
+ setModelInventory(inventory)
+ return inventory
+ } finally {
+ setIsLoadingModelInventory(false)
+ }
+ }
+
+ async function evaluateWorkflowPrerequisites(workflowData) {
+ const baseUrl = normalizeBaseUrl(apiUrl)
+ const inventory = await fetchModelInventory(baseUrl)
+ if (!inventory) {
+ throw new Error('Model inventory is still loading, please retry')
+ }
+
+ const requiredModels = collectWorkflowModelRequirements(workflowData)
+ const missing = []
+ let available = 0
+ for (const model of requiredModels) {
+ const folderKey = String(model.directory || '').toLowerCase()
+ const nameKey = String(model.name || '').toLowerCase()
+ const folderEntries = inventory[folderKey]
+ const exists = folderEntries instanceof Set ? folderEntries.has(nameKey) : false
+ if (exists) {
+ available += 1
+ } else {
+ missing.push(model)
+ }
+ }
+
+ return {
+ missing,
+ available,
+ total: requiredModels.length,
+ checkedAt: new Date().toISOString(),
+ }
+ }
+
+ async function loadTemplateWorkflowData(template) {
+ let workflowData = template.workflow
+ if (!workflowData && template.workflowUrl) {
+ const res = await fetch(template.workflowUrl)
+ if (!res.ok) {
+ throw new Error(`Template workflow download failed: ${res.status}`)
+ }
+ workflowData = await res.json()
+ }
+ if (!workflowData || typeof workflowData !== 'object') {
+ throw new Error('Template workflow payload is invalid')
+ }
+ return workflowData
+ }
+
+ async function checkTemplateModels(templateId, options = {}) {
+ if (!apiUrl) return
+ const template = serverTemplates.find((entry) => entry.id === templateId)
+ if (!template) return
+
+ setModelCheckByTemplate((current) => ({
+ ...current,
+ [templateId]: { loading: true, error: null, missing: [], available: 0, total: 0 },
+ }))
+
+ try {
+ const workflowData = options.workflowData || await loadTemplateWorkflowData(template)
+ const result = await evaluateWorkflowPrerequisites(workflowData)
+ const { missing, available, total, checkedAt } = result
+
+ setModelCheckByTemplate((current) => ({
+ ...current,
+ [templateId]: {
+ loading: false,
+ error: null,
+ missing,
+ available,
+ total,
+ checkedAt,
+ },
+ }))
+ return { loading: false, error: null, ...result }
+ } catch (err) {
+ const errorValue = String(err)
+ setModelCheckByTemplate((current) => ({
+ ...current,
+ [templateId]: { loading: false, error: errorValue, missing: [], available: 0, total: 0 },
+ }))
+ return { loading: false, error: errorValue, missing: [], available: 0, total: 0 }
+ }
+ }
+
+ async function applyTemplate(templateId) {
+ const template = serverTemplates.find((entry) => entry.id === templateId)
+ if (!template) {
+ onSetError?.('Selected template not found')
+ return
+ }
+
+ try {
+ setApplyingTemplateId(templateId)
+ const workflowData = await loadTemplateWorkflowData(template)
+ const check = await checkTemplateModels(templateId, { workflowData })
+ if (check?.error) {
+ throw new Error(check.error)
+ }
+
+ const hasMissingPrereqs = Array.isArray(check?.missing) && check.missing.length > 0
+ if (hasMissingPrereqs) {
+ onSetError?.(`Missing prerequisites: ${check.missing.length} models not installed. Install missing models before applying this template.`)
+ return
+ }
+
+ const safeName = String(template.label || template.id).replace(/[^a-z0-9_-]+/gi, '_').slice(0, 80) || 'template'
+ const blob = new Blob([JSON.stringify(workflowData)], { type: 'application/json' })
+ const file = new File([blob], `${safeName}.json`, { type: 'application/json' })
+ await uploadWorkflowFile(file)
+ onSetError?.(null)
+ } catch (err) {
+ onSetError?.(`Failed to apply template: ${err.message}`)
+ } finally {
+ setApplyingTemplateId(null)
+ }
+ }
+
+ return {
+ serverTemplates,
+ templateSource,
+ templateSearch,
+ setTemplateSearch,
+ selectedTemplateCategory,
+ setSelectedTemplateCategory,
+ templateModelFilter,
+ setTemplateModelFilter,
+ templateTagFilter,
+ setTemplateTagFilter,
+ templateSort,
+ setTemplateSort,
+ selectedTemplateDetails,
+ setSelectedTemplateDetails,
+ applyingTemplateId,
+ modelCheckByTemplate,
+ selectedPrereqTemplateId,
+ setSelectedPrereqTemplateId,
+ isLoadingModelInventory,
+ loadTemplatesForBaseUrl,
+ evaluateWorkflowPrerequisites,
+ checkTemplateModels,
+ applyTemplate,
+ }
+}
From 3489c606d7051f07dfca10616ac140b214a1c4b6 Mon Sep 17 00:00:00 2001
From: danohn <82357071+danohn@users.noreply.github.com>
Date: Tue, 17 Feb 2026 15:20:00 +1100
Subject: [PATCH 6/8] Consolidate server hooks in App
---
src/App.jsx | 113 +++++++----------------------------
src/hooks/useServerAdmin.js | 115 ++++++++++++++++++++++++++++++++++++
2 files changed, 136 insertions(+), 92 deletions(-)
create mode 100644 src/hooks/useServerAdmin.js
diff --git a/src/App.jsx b/src/App.jsx
index 5e44479..6b33b7d 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-
import defaultWorkflow from '../01_get_started_text_to_image.json'
import useApiConfig from './hooks/useApiConfig'
import useRecentJobs from './hooks/useRecentJobs'
+import useServerAdmin from './hooks/useServerAdmin'
import useTemplates from './hooks/useTemplates'
import useWorkflowConfig from './hooks/useWorkflowConfig'
import useGeneration from './hooks/useGeneration'
@@ -33,14 +34,6 @@ export default function App() {
const [isCheckingWorkflowHealth, setIsCheckingWorkflowHealth] = useState(false)
const [inputImageFile, setInputImageFile] = useState(null)
const [inputImageName, setInputImageName] = useState('')
- const [serverFeatures, setServerFeatures] = useState(null)
- const [serverSystemStats, setServerSystemStats] = useState(null)
- const [serverExtensions, setServerExtensions] = useState([])
- const [showExtensions, setShowExtensions] = useState(false)
- const [serverHistoryCount, setServerHistoryCount] = useState(null)
- const [isLoadingServerData, setIsLoadingServerData] = useState(false)
- const [serverDataError, setServerDataError] = useState(null)
- const [opsActionStatus, setOpsActionStatus] = useState(null)
const [toast, setToast] = useState(null)
const [onboardingStep, setOnboardingStep] = useState(1)
@@ -119,6 +112,26 @@ export default function App() {
fetchRecentHistory,
fetchJobDetail,
} = useRecentJobs(apiUrl)
+ const {
+ serverFeatures,
+ serverSystemStats,
+ serverExtensions,
+ showExtensions,
+ setShowExtensions,
+ serverHistoryCount,
+ isLoadingServerData,
+ serverDataError,
+ opsActionStatus,
+ fetchServerData,
+ handleClearPendingQueue,
+ handleInterruptExecution,
+ handleClearServerHistory,
+ handleFreeMemory,
+ } = useServerAdmin({
+ apiUrl,
+ refreshQueue,
+ loadTemplatesForBaseUrl,
+ })
const canCloseSettings = hasConfiguredApiUrl && hasConfiguredWorkflow
@@ -269,50 +282,6 @@ export default function App() {
setInputImageName('')
}
- async function fetchServerData() {
- if (!apiUrl) return
-
- setIsLoadingServerData(true)
- setServerDataError(null)
- try {
- const baseUrl = normalizeBaseUrl(apiUrl)
- const [featuresRes, statsRes, extensionsRes, historyRes, jobsRes] = await Promise.all([
- fetch(`${baseUrl}/features`),
- fetch(`${baseUrl}/system_stats`),
- fetch(`${baseUrl}/extensions`),
- fetch(`${baseUrl}/history`),
- fetch(`${baseUrl}/api/jobs?limit=1&offset=0`),
- ])
-
- if (featuresRes.ok) {
- setServerFeatures(await featuresRes.json())
- }
- if (statsRes.ok) {
- setServerSystemStats(await statsRes.json())
- }
- if (extensionsRes.ok) {
- const extensions = await extensionsRes.json()
- setServerExtensions(Array.isArray(extensions) ? extensions : [])
- }
- if (historyRes.ok) {
- const history = await historyRes.json()
- setServerHistoryCount(history && typeof history === 'object' ? Object.keys(history).length : 0)
- }
- if (jobsRes.ok) {
- const jobsData = await jobsRes.json()
- const total = jobsData?.pagination?.total
- if (typeof total === 'number') {
- setServerHistoryCount(total)
- }
- }
- await loadTemplatesForBaseUrl(baseUrl)
- } catch (err) {
- setServerDataError(String(err))
- } finally {
- setIsLoadingServerData(false)
- }
- }
-
async function runWorkflowHealthCheck(workflowData = workflow) {
if (!apiUrl) {
setWorkflowHealth({
@@ -373,46 +342,6 @@ export default function App() {
}
}
- async function runOpsAction(actionName, body) {
- if (!apiUrl) {
- setOpsActionStatus({ ok: false, message: 'Configure API URL first' })
- return
- }
-
- try {
- const baseUrl = normalizeBaseUrl(apiUrl)
- const res = await fetch(`${baseUrl}/${actionName}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- })
- if (!res.ok) {
- throw new Error(`${actionName} failed: ${res.status}`)
- }
- setOpsActionStatus({ ok: true, message: `${actionName} action completed` })
- await refreshQueue(apiUrl)
- await fetchServerData()
- } catch (err) {
- setOpsActionStatus({ ok: false, message: String(err) })
- }
- }
-
- async function handleClearPendingQueue() {
- await runOpsAction('queue', { clear: true })
- }
-
- async function handleInterruptExecution() {
- await runOpsAction('interrupt', {})
- }
-
- async function handleClearServerHistory() {
- await runOpsAction('history', { clear: true })
- }
-
- async function handleFreeMemory() {
- await runOpsAction('free', { unload_models: true, free_memory: true })
- }
-
async function copyModelUrl(url) {
if (!url) return
try {
diff --git a/src/hooks/useServerAdmin.js b/src/hooks/useServerAdmin.js
new file mode 100644
index 0000000..4d4de1a
--- /dev/null
+++ b/src/hooks/useServerAdmin.js
@@ -0,0 +1,115 @@
+import { useCallback, useState } from 'react'
+import { normalizeBaseUrl } from '../lib/apiUrl'
+
+export default function useServerAdmin({ apiUrl, refreshQueue, loadTemplatesForBaseUrl }) {
+ const [serverFeatures, setServerFeatures] = useState(null)
+ const [serverSystemStats, setServerSystemStats] = useState(null)
+ const [serverExtensions, setServerExtensions] = useState([])
+ const [showExtensions, setShowExtensions] = useState(false)
+ const [serverHistoryCount, setServerHistoryCount] = useState(null)
+ const [isLoadingServerData, setIsLoadingServerData] = useState(false)
+ const [serverDataError, setServerDataError] = useState(null)
+ const [opsActionStatus, setOpsActionStatus] = useState(null)
+
+ const fetchServerData = useCallback(async () => {
+ if (!apiUrl) return
+
+ setIsLoadingServerData(true)
+ setServerDataError(null)
+ try {
+ const baseUrl = normalizeBaseUrl(apiUrl)
+ const [featuresRes, statsRes, extensionsRes, historyRes, jobsRes] = await Promise.all([
+ fetch(`${baseUrl}/features`),
+ fetch(`${baseUrl}/system_stats`),
+ fetch(`${baseUrl}/extensions`),
+ fetch(`${baseUrl}/history`),
+ fetch(`${baseUrl}/api/jobs?limit=1&offset=0`),
+ ])
+
+ if (featuresRes.ok) {
+ setServerFeatures(await featuresRes.json())
+ }
+ if (statsRes.ok) {
+ setServerSystemStats(await statsRes.json())
+ }
+ if (extensionsRes.ok) {
+ const extensions = await extensionsRes.json()
+ setServerExtensions(Array.isArray(extensions) ? extensions : [])
+ }
+ if (historyRes.ok) {
+ const history = await historyRes.json()
+ setServerHistoryCount(history && typeof history === 'object' ? Object.keys(history).length : 0)
+ }
+ if (jobsRes.ok) {
+ const jobsData = await jobsRes.json()
+ const total = jobsData?.pagination?.total
+ if (typeof total === 'number') {
+ setServerHistoryCount(total)
+ }
+ }
+
+ await loadTemplatesForBaseUrl(baseUrl)
+ } catch (err) {
+ setServerDataError(String(err))
+ } finally {
+ setIsLoadingServerData(false)
+ }
+ }, [apiUrl, loadTemplatesForBaseUrl])
+
+ const runOpsAction = useCallback(async (actionName, body) => {
+ if (!apiUrl) {
+ setOpsActionStatus({ ok: false, message: 'Configure API URL first' })
+ return
+ }
+
+ try {
+ const baseUrl = normalizeBaseUrl(apiUrl)
+ const res = await fetch(`${baseUrl}/${actionName}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ if (!res.ok) {
+ throw new Error(`${actionName} failed: ${res.status}`)
+ }
+ setOpsActionStatus({ ok: true, message: `${actionName} action completed` })
+ await refreshQueue(apiUrl)
+ await fetchServerData()
+ } catch (err) {
+ setOpsActionStatus({ ok: false, message: String(err) })
+ }
+ }, [apiUrl, fetchServerData, refreshQueue])
+
+ const handleClearPendingQueue = useCallback(async () => {
+ await runOpsAction('queue', { clear: true })
+ }, [runOpsAction])
+
+ const handleInterruptExecution = useCallback(async () => {
+ await runOpsAction('interrupt', {})
+ }, [runOpsAction])
+
+ const handleClearServerHistory = useCallback(async () => {
+ await runOpsAction('history', { clear: true })
+ }, [runOpsAction])
+
+ const handleFreeMemory = useCallback(async () => {
+ await runOpsAction('free', { unload_models: true, free_memory: true })
+ }, [runOpsAction])
+
+ return {
+ serverFeatures,
+ serverSystemStats,
+ serverExtensions,
+ showExtensions,
+ setShowExtensions,
+ serverHistoryCount,
+ isLoadingServerData,
+ serverDataError,
+ opsActionStatus,
+ fetchServerData,
+ handleClearPendingQueue,
+ handleInterruptExecution,
+ handleClearServerHistory,
+ handleFreeMemory,
+ }
+}
From caf0bc7d2727ccf575d9bd4770001b93248efd54 Mon Sep 17 00:00:00 2001
From: danohn <82357071+danohn@users.noreply.github.com>
Date: Tue, 17 Feb 2026 15:23:08 +1100
Subject: [PATCH 7/8] Simplify App hook usage
---
src/App.jsx | 97 ++++++++++++---------------------
src/hooks/useApiSettingsForm.js | 52 ++++++++++++++++++
src/hooks/useOnboardingFlow.js | 45 +++++++++++++++
3 files changed, 131 insertions(+), 63 deletions(-)
create mode 100644 src/hooks/useApiSettingsForm.js
create mode 100644 src/hooks/useOnboardingFlow.js
diff --git a/src/App.jsx b/src/App.jsx
index 6b33b7d..bea0ac2 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import defaultWorkflow from '../01_get_started_text_to_image.json'
import useApiConfig from './hooks/useApiConfig'
+import useApiSettingsForm from './hooks/useApiSettingsForm'
+import useOnboardingFlow from './hooks/useOnboardingFlow'
import useRecentJobs from './hooks/useRecentJobs'
import useServerAdmin from './hooks/useServerAdmin'
import useTemplates from './hooks/useTemplates'
@@ -13,7 +15,7 @@ import OnboardingPage from './features/onboarding/OnboardingPage'
import SettingsPage from './features/settings/SettingsPage'
import HomePage from './features/home/HomePage'
import { getTemplateBrowserData } from './features/templates/templateBrowserData'
-import { buildApiUrlFromParts, normalizeBaseUrl, parseApiUrlParts } from './lib/apiUrl'
+import { normalizeBaseUrl } from './lib/apiUrl'
import { analyzeWorkflowPromptInputs } from './lib/workflowPrompt'
export default function App() {
@@ -25,17 +27,11 @@ export default function App() {
const [promptText, setPromptText] = useState('')
const [negativePromptText, setNegativePromptText] = useState('')
const [promptInputMode, setPromptInputMode] = useState('single')
- const [showWelcome, setShowWelcome] = useState(false)
- const [apiHost, setApiHost] = useState('')
- const [apiProtocol, setApiProtocol] = useState('http')
- const [apiPort, setApiPort] = useState('8188')
- const [showAdvancedApi, setShowAdvancedApi] = useState(false)
const [workflowHealth, setWorkflowHealth] = useState(null)
const [isCheckingWorkflowHealth, setIsCheckingWorkflowHealth] = useState(false)
const [inputImageFile, setInputImageFile] = useState(null)
const [inputImageName, setInputImageName] = useState('')
const [toast, setToast] = useState(null)
- const [onboardingStep, setOnboardingStep] = useState(1)
const {
apiUrl,
@@ -47,6 +43,22 @@ export default function App() {
saveApiUrl,
testConnection,
} = useApiConfig()
+ const {
+ apiHost,
+ apiProtocol,
+ apiPort,
+ showAdvancedApi,
+ setShowAdvancedApi,
+ syncApiFieldsFromUrl,
+ updateApiSettings,
+ prepSettingsFromApi,
+ } = useApiSettingsForm({
+ apiUrl,
+ settingsUrl,
+ setSettingsUrl,
+ isSettingsRoute,
+ isOnboardingRoute,
+ })
const {
workflow,
@@ -134,23 +146,19 @@ export default function App() {
})
const canCloseSettings = hasConfiguredApiUrl && hasConfiguredWorkflow
-
- useEffect(() => {
- if (canCloseSettings) {
- setShowWelcome(false)
- return
- }
-
- const hasSeenWelcome = localStorage.getItem('comfy_onboarding_seen') === '1'
- if (hasSeenWelcome) {
- setShowWelcome(false)
- if (!isOnboardingRoute && !isSettingsRoute) {
- navigate('/onboarding', { replace: true })
- }
- } else {
- setShowWelcome(!isOnboardingRoute && !isSettingsRoute)
- }
- }, [canCloseSettings, isOnboardingRoute, isSettingsRoute, navigate])
+ const {
+ showWelcome,
+ onboardingStep,
+ setOnboardingStep,
+ handleStartOnboarding,
+ } = useOnboardingFlow({
+ canCloseSettings,
+ isOnboardingRoute,
+ isSettingsRoute,
+ navigate,
+ apiUrl,
+ syncApiFieldsFromUrl,
+ })
useEffect(() => {
if (!hasConfiguredApiUrl) return
@@ -177,18 +185,6 @@ export default function App() {
setNegativePromptText(promptInfo.defaultNegativePrompt)
}, [workflow])
- function syncApiFieldsFromUrl(url) {
- const parsed = parseApiUrlParts(url)
- setApiProtocol(parsed.protocol)
- setApiHost(parsed.host)
- setApiPort(parsed.port)
- }
-
- useEffect(() => {
- if (!isOnboardingRoute && !isSettingsRoute) return
- syncApiFieldsFromUrl(settingsUrl)
- }, [isOnboardingRoute, isSettingsRoute])
-
useEffect(() => {
if (!toast) return
const id = setTimeout(() => setToast(null), 2500)
@@ -196,22 +192,12 @@ export default function App() {
}, [toast])
function openSettingsPage() {
- setSettingsUrl(apiUrl)
- syncApiFieldsFromUrl(apiUrl)
+ prepSettingsFromApi()
navigate('/settings')
}
function openOnboardingPage() {
- setSettingsUrl(apiUrl)
- syncApiFieldsFromUrl(apiUrl)
- navigate('/onboarding')
- }
-
- function handleStartOnboarding() {
- localStorage.setItem('comfy_onboarding_seen', '1')
- setShowWelcome(false)
- setOnboardingStep(1)
- syncApiFieldsFromUrl(apiUrl)
+ prepSettingsFromApi()
navigate('/onboarding')
}
@@ -219,21 +205,6 @@ export default function App() {
setToast({ message, type })
}
- function updateApiSettings(next) {
- const nextProtocol = next.protocol ?? apiProtocol
- const nextHost = next.host ?? apiHost
- const nextPort = next.port ?? apiPort
-
- setApiProtocol(nextProtocol)
- setApiHost(nextHost)
- setApiPort(nextPort)
- setSettingsUrl(buildApiUrlFromParts({
- protocol: nextProtocol,
- host: nextHost,
- port: nextPort,
- }))
- }
-
function saveApiSettings() {
const saveResult = saveApiUrl(settingsUrl)
if (!saveResult.ok) {
diff --git a/src/hooks/useApiSettingsForm.js b/src/hooks/useApiSettingsForm.js
new file mode 100644
index 0000000..c17e41d
--- /dev/null
+++ b/src/hooks/useApiSettingsForm.js
@@ -0,0 +1,52 @@
+import { useEffect, useState } from 'react'
+import { buildApiUrlFromParts, parseApiUrlParts } from '../lib/apiUrl'
+
+export default function useApiSettingsForm({ apiUrl, settingsUrl, setSettingsUrl, isSettingsRoute, isOnboardingRoute }) {
+ const [apiHost, setApiHost] = useState('')
+ const [apiProtocol, setApiProtocol] = useState('http')
+ const [apiPort, setApiPort] = useState('8188')
+ const [showAdvancedApi, setShowAdvancedApi] = useState(false)
+
+ function syncApiFieldsFromUrl(url) {
+ const parsed = parseApiUrlParts(url)
+ setApiProtocol(parsed.protocol)
+ setApiHost(parsed.host)
+ setApiPort(parsed.port)
+ }
+
+ useEffect(() => {
+ if (!isOnboardingRoute && !isSettingsRoute) return
+ syncApiFieldsFromUrl(settingsUrl)
+ }, [isOnboardingRoute, isSettingsRoute, settingsUrl])
+
+ function updateApiSettings(next) {
+ const nextProtocol = next.protocol ?? apiProtocol
+ const nextHost = next.host ?? apiHost
+ const nextPort = next.port ?? apiPort
+
+ setApiProtocol(nextProtocol)
+ setApiHost(nextHost)
+ setApiPort(nextPort)
+ setSettingsUrl(buildApiUrlFromParts({
+ protocol: nextProtocol,
+ host: nextHost,
+ port: nextPort,
+ }))
+ }
+
+ function prepSettingsFromApi() {
+ setSettingsUrl(apiUrl)
+ syncApiFieldsFromUrl(apiUrl)
+ }
+
+ return {
+ apiHost,
+ apiProtocol,
+ apiPort,
+ showAdvancedApi,
+ setShowAdvancedApi,
+ syncApiFieldsFromUrl,
+ updateApiSettings,
+ prepSettingsFromApi,
+ }
+}
diff --git a/src/hooks/useOnboardingFlow.js b/src/hooks/useOnboardingFlow.js
new file mode 100644
index 0000000..3219776
--- /dev/null
+++ b/src/hooks/useOnboardingFlow.js
@@ -0,0 +1,45 @@
+import { useEffect, useState } from 'react'
+
+export default function useOnboardingFlow({
+ canCloseSettings,
+ isOnboardingRoute,
+ isSettingsRoute,
+ navigate,
+ apiUrl,
+ syncApiFieldsFromUrl,
+}) {
+ const [showWelcome, setShowWelcome] = useState(false)
+ const [onboardingStep, setOnboardingStep] = useState(1)
+
+ useEffect(() => {
+ if (canCloseSettings) {
+ setShowWelcome(false)
+ return
+ }
+
+ const hasSeenWelcome = localStorage.getItem('comfy_onboarding_seen') === '1'
+ if (hasSeenWelcome) {
+ setShowWelcome(false)
+ if (!isOnboardingRoute && !isSettingsRoute) {
+ navigate('/onboarding', { replace: true })
+ }
+ } else {
+ setShowWelcome(!isOnboardingRoute && !isSettingsRoute)
+ }
+ }, [canCloseSettings, isOnboardingRoute, isSettingsRoute, navigate])
+
+ function handleStartOnboarding() {
+ localStorage.setItem('comfy_onboarding_seen', '1')
+ setShowWelcome(false)
+ setOnboardingStep(1)
+ syncApiFieldsFromUrl(apiUrl)
+ navigate('/onboarding')
+ }
+
+ return {
+ showWelcome,
+ onboardingStep,
+ setOnboardingStep,
+ handleStartOnboarding,
+ }
+}
From d006bd49ef037adf68deabc31d914fed4dc4dafa Mon Sep 17 00:00:00 2001
From: danohn <82357071+danohn@users.noreply.github.com>
Date: Tue, 17 Feb 2026 15:35:03 +1100
Subject: [PATCH 8/8] Summarise App.jsx responsibilities
---
README.md | 28 +-
src/features/generation/GenerationPanel.jsx | 182 ++++++++++
src/features/history/RecentJobsPanel.jsx | 155 +++++++++
src/features/home/HomePage.jsx | 327 +++---------------
src/features/settings/SettingsPage.jsx | 78 +----
.../templates/TemplateDetailsModal.jsx | 116 +++++++
src/features/templates/TemplateModals.jsx | 192 +---------
.../templates/TemplatePrerequisitesModal.jsx | 80 +++++
src/lib/serverFormatting.js | 70 ++++
src/lib/serverFormatting.test.js | 46 +++
10 files changed, 735 insertions(+), 539 deletions(-)
create mode 100644 src/features/generation/GenerationPanel.jsx
create mode 100644 src/features/history/RecentJobsPanel.jsx
create mode 100644 src/features/templates/TemplateDetailsModal.jsx
create mode 100644 src/features/templates/TemplatePrerequisitesModal.jsx
create mode 100644 src/lib/serverFormatting.js
create mode 100644 src/lib/serverFormatting.test.js
diff --git a/README.md b/README.md
index 13e1205..73508af 100644
--- a/README.md
+++ b/README.md
@@ -146,10 +146,30 @@ git push origin vX.Y.Z
## Project Structure
-- `src/App.jsx` route shell, settings UI, templates, dashboard, danger zone
-- `src/hooks/useApiConfig.js` API URL state, connection test, persistence
-- `src/hooks/useWorkflowConfig.js` workflow persistence and selection
-- `src/hooks/useGeneration.js` generation pipeline, websocket handling, queue and history interactions
+- `src/App.jsx` app shell and route orchestration
+- `src/features/onboarding` onboarding wizard pages
+- `src/features/settings` API/workflow/server ops settings UI
+- `src/features/templates` template browser, cards, and modals
+- `src/features/generation` generation form and runtime status UI
+- `src/features/history` server-backed recent jobs and job details UI
+- `src/features/home` home screen composition
+- `src/hooks` API/workflow/generation/template/admin state hooks
+- `src/lib` pure helpers and transforms (URLs, templates, models, formatting)
+
+## Architecture Notes
+
+The app is organized as a thin orchestration layer plus feature modules:
+
+1. `App.jsx` owns app-level state and wires hooks to pages/components.
+2. Feature components under `src/features/*` are mostly presentational and receive explicit props.
+3. Shared logic lives in hooks (`src/hooks/*`) and pure utility modules (`src/lib/*`).
+
+High-level flow:
+
+1. Onboarding/settings establish API connectivity and workflow selection.
+2. Generation uses the selected workflow + prompts and streams execution status over WebSocket.
+3. History and job details are fetched from server endpoints and rendered in dedicated history components.
+4. Templates are loaded from local/remote indexes, validated for prerequisites, and then applied into workflow state.
## Troubleshooting
diff --git a/src/features/generation/GenerationPanel.jsx b/src/features/generation/GenerationPanel.jsx
new file mode 100644
index 0000000..2a30458
--- /dev/null
+++ b/src/features/generation/GenerationPanel.jsx
@@ -0,0 +1,182 @@
+import React from 'react'
+
+export default function GenerationPanel({
+ imageSrc,
+ isLoading,
+ statusMessage,
+ error,
+ handleGenerate,
+ hasConfiguredApiUrl,
+ hasConfiguredWorkflow,
+ workflowName,
+ queueState,
+ canCloseSettings,
+ openOnboardingPage,
+ promptInputMode,
+ handleInputImageChange,
+ inputImageName,
+ clearInputImage,
+ promptText,
+ setPromptText,
+ handleCancelRun,
+ currentPromptId,
+ negativePromptText,
+ setNegativePromptText,
+ apiUrl,
+}) {
+ return (
+ <>
+
+ {imageSrc && (
+
+ {isLoading ? (
+
+ ) : error ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {isLoading && !imageSrc && (
+
+ )}
+
+ {error && !imageSrc && (
+
+ )}
+
+
+
+
+
+ API: {hasConfiguredApiUrl ? 'Configured' : 'Missing'}
+
+
+ Workflow: {hasConfiguredWorkflow ? workflowName : 'Missing'}
+
+ {typeof queueState.running === 'number' && typeof queueState.pending === 'number' && (
+
+ Queue: {queueState.running} running, {queueState.pending} pending
+
+ )}
+
+ {!canCloseSettings && (
+
+ Complete Setup
+
+ )}
+
+
+ {promptInputMode === 'dual' && (
+ Prompt
+ )}
+
+
+
+ Add input image
+
+
+
+ {inputImageName && (
+
+
+ {inputImageName}
+
+
+ Remove
+
+
+ )}
+
setPromptText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleGenerate(e)
+ }
+ }}
+ placeholder={promptInputMode === 'dual' ? 'Positive prompt' : 'What would you like to generate?'}
+ className={`w-full px-6 py-4 pt-20 bg-white border border-slate-300 text-slate-900 placeholder-slate-500 rounded-lg focus:outline-none focus:border-slate-300 focus:ring-2 focus:ring-slate-900 focus:ring-opacity-10 resize-none ${promptInputMode === 'dual' ? 'text-sm' : 'text-lg'}`}
+ rows="3"
+ disabled={isLoading}
+ />
+
+ {isLoading && (
+
+ Cancel
+
+ )}
+
+ {isLoading ? 'Generating' : 'Generate'}
+
+
+
+ {promptInputMode === 'dual' && (
+
+ Negative Prompt
+ setNegativePromptText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleGenerate(e)
+ }
+ }}
+ placeholder="Negative prompt"
+ className="w-full px-4 py-3 bg-white border border-slate-300 text-slate-900 placeholder-slate-500 rounded-lg focus:outline-none focus:border-slate-300 focus:ring-2 focus:ring-slate-900 focus:ring-opacity-10 resize-none text-sm"
+ rows="2"
+ disabled={isLoading}
+ />
+
+ )}
+
+ Press Enter to send, Shift+Enter for new line
+
+
+
+
+
+
Connected to: {apiUrl || 'Not configured'}
+
+ >
+ )
+}
diff --git a/src/features/history/RecentJobsPanel.jsx b/src/features/history/RecentJobsPanel.jsx
new file mode 100644
index 0000000..852b57a
--- /dev/null
+++ b/src/features/history/RecentJobsPanel.jsx
@@ -0,0 +1,155 @@
+import React from 'react'
+
+export default function RecentJobsPanel({
+ fetchRecentHistory,
+ isLoadingRecentJobs,
+ recentJobsError,
+ serverRecentJobs,
+ selectedJobId,
+ setSelectedJobId,
+ setJobDetail,
+ setJobDetailError,
+ fetchJobDetail,
+ showHistoryImage,
+ isLoadingJobDetail,
+ jobDetailError,
+ jobDetail,
+}) {
+ return (
+
+
+
Recent Jobs
+
+ Refresh
+
+
+
+ {isLoadingRecentJobs ? (
+
+ Loading server history...
+
+ ) : recentJobsError ? (
+
+ {recentJobsError}
+
+ ) : serverRecentJobs.length === 0 ? (
+
+ No server history yet.
+
+ ) : (
+
+ {serverRecentJobs.map((job) => (
+
+
+
+
{job.title || `Job ${String(job.id).slice(0, 8)}`}
+
{job.prompt || 'Prompt unavailable'}
+
{new Date(job.createdAt).toLocaleString()}
+
+
+
{
+ if (selectedJobId === job.id) {
+ setSelectedJobId(null)
+ setJobDetail(null)
+ setJobDetailError(null)
+ return
+ }
+ fetchJobDetail(job.id)
+ }}
+ className={`px-2 py-1 text-xs rounded-md border ${selectedJobId === job.id ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-700 border-slate-300 hover:bg-slate-100'}`}
+ >
+ {selectedJobId === job.id ? 'Hide' : 'Details'}
+
+
+ {job.status}
+
+ {job.imageSrc ? (
+
showHistoryImage(job.imageSrc)}
+ className="shrink-0 rounded-md overflow-hidden border border-slate-200 hover:border-slate-400 transition-colors"
+ title="Open image preview"
+ >
+
+
+ ) : (
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ {(selectedJobId || isLoadingJobDetail || jobDetailError || jobDetail) && (
+
+
+
Job Detail
+
+ {selectedJobId && (
+ {selectedJobId}
+ )}
+ {
+ setSelectedJobId(null)
+ setJobDetail(null)
+ setJobDetailError(null)
+ }}
+ className="text-xs text-slate-600 hover:text-slate-900"
+ >
+ Close
+
+
+
+ {isLoadingJobDetail ? (
+
Loading job details...
+ ) : jobDetailError ? (
+
{jobDetailError}
+ ) : jobDetail ? (
+
+
Status: {jobDetail.status}
+ {jobDetail.workflowId &&
Workflow ID: {jobDetail.workflowId}
}
+ {typeof jobDetail.outputsCount === 'number' &&
Output count: {jobDetail.outputsCount}
}
+ {typeof jobDetail.createTime === 'number' && (
+
Created: {new Date(jobDetail.createTime * 1000).toLocaleString()}
+ )}
+ {typeof jobDetail.updateTime === 'number' && (
+
Updated: {new Date(jobDetail.updateTime * 1000).toLocaleString()}
+ )}
+ {jobDetail.executionError && (
+
+
Execution Error
+
{jobDetail.executionError.exception_message || 'Unknown execution error'}
+
+ )}
+
+ Raw payload
+
+ {JSON.stringify(jobDetail.raw, null, 2)}
+
+
+
+ ) : null}
+
+ )}
+
+ )
+}
diff --git a/src/features/home/HomePage.jsx b/src/features/home/HomePage.jsx
index 42df169..0949dc8 100644
--- a/src/features/home/HomePage.jsx
+++ b/src/features/home/HomePage.jsx
@@ -1,4 +1,6 @@
import React from 'react'
+import GenerationPanel from '../generation/GenerationPanel'
+import RecentJobsPanel from '../history/RecentJobsPanel'
export default function HomePage({
openSettingsPage,
@@ -68,293 +70,46 @@ export default function HomePage({
Generate images from text
-
- {imageSrc && (
-
- {isLoading ? (
-
- ) : error ? (
-
- ) : (
-
- )}
-
- )}
-
- {isLoading && !imageSrc && (
-
- )}
-
- {error && !imageSrc && (
-
- )}
-
-
-
-
-
- API: {hasConfiguredApiUrl ? 'Configured' : 'Missing'}
-
-
- Workflow: {hasConfiguredWorkflow ? workflowName : 'Missing'}
-
- {typeof queueState.running === 'number' && typeof queueState.pending === 'number' && (
-
- Queue: {queueState.running} running, {queueState.pending} pending
-
- )}
-
- {!canCloseSettings && (
-
- Complete Setup
-
- )}
-
-
- {promptInputMode === 'dual' && (
- Prompt
- )}
-
-
-
- Add input image
-
-
-
- {inputImageName && (
-
-
- {inputImageName}
-
-
- Remove
-
-
- )}
-
setPromptText(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- handleGenerate(e)
- }
- }}
- placeholder={promptInputMode === 'dual' ? 'Positive prompt' : 'What would you like to generate?'}
- className={`w-full px-6 py-4 pt-20 bg-white border border-slate-300 text-slate-900 placeholder-slate-500 rounded-lg focus:outline-none focus:border-slate-300 focus:ring-2 focus:ring-slate-900 focus:ring-opacity-10 resize-none ${promptInputMode === 'dual' ? 'text-sm' : 'text-lg'}`}
- rows="3"
- disabled={isLoading}
- />
-
- {isLoading && (
-
- Cancel
-
- )}
-
- {isLoading ? 'Generating' : 'Generate'}
-
-
-
- {promptInputMode === 'dual' && (
-
- Negative Prompt
- setNegativePromptText(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- handleGenerate(e)
- }
- }}
- placeholder="Negative prompt"
- className="w-full px-4 py-3 bg-white border border-slate-300 text-slate-900 placeholder-slate-500 rounded-lg focus:outline-none focus:border-slate-300 focus:ring-2 focus:ring-slate-900 focus:ring-opacity-10 resize-none text-sm"
- rows="2"
- disabled={isLoading}
- />
-
- )}
-
- Press Enter to send, Shift+Enter for new line
-
-
-
-
-
-
Connected to: {apiUrl || 'Not configured'}
-
+
-
-
-
Recent Jobs
-
- Refresh
-
-
-
- {isLoadingRecentJobs ? (
-
- Loading server history...
-
- ) : recentJobsError ? (
-
- {recentJobsError}
-
- ) : serverRecentJobs.length === 0 ? (
-
- No server history yet.
-
- ) : (
-
- {serverRecentJobs.map((job) => (
-
-
-
-
{job.title || `Job ${String(job.id).slice(0, 8)}`}
-
{job.prompt || 'Prompt unavailable'}
-
{new Date(job.createdAt).toLocaleString()}
-
-
-
{
- if (selectedJobId === job.id) {
- setSelectedJobId(null)
- setJobDetail(null)
- setJobDetailError(null)
- return
- }
- fetchJobDetail(job.id)
- }}
- className={`px-2 py-1 text-xs rounded-md border ${selectedJobId === job.id ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-700 border-slate-300 hover:bg-slate-100'}`}
- >
- {selectedJobId === job.id ? 'Hide' : 'Details'}
-
-
- {job.status}
-
- {job.imageSrc ? (
-
showHistoryImage(job.imageSrc)}
- className="shrink-0 rounded-md overflow-hidden border border-slate-200 hover:border-slate-400 transition-colors"
- title="Open image preview"
- >
-
-
- ) : (
-
- )}
-
-
-
- ))}
-
- )}
-
- {(selectedJobId || isLoadingJobDetail || jobDetailError || jobDetail) && (
-
-
-
Job Detail
-
- {selectedJobId && (
- {selectedJobId}
- )}
- {
- setSelectedJobId(null)
- setJobDetail(null)
- setJobDetailError(null)
- }}
- className="text-xs text-slate-600 hover:text-slate-900"
- >
- Close
-
-
-
- {isLoadingJobDetail ? (
-
Loading job details...
- ) : jobDetailError ? (
-
{jobDetailError}
- ) : jobDetail ? (
-
-
Status: {jobDetail.status}
- {jobDetail.workflowId &&
Workflow ID: {jobDetail.workflowId}
}
- {typeof jobDetail.outputsCount === 'number' &&
Output count: {jobDetail.outputsCount}
}
- {typeof jobDetail.createTime === 'number' && (
-
Created: {new Date(jobDetail.createTime * 1000).toLocaleString()}
- )}
- {typeof jobDetail.updateTime === 'number' && (
-
Updated: {new Date(jobDetail.updateTime * 1000).toLocaleString()}
- )}
- {jobDetail.executionError && (
-
-
Execution Error
-
{jobDetail.executionError.exception_message || 'Unknown execution error'}
-
- )}
-
- Raw payload
-
- {JSON.stringify(jobDetail.raw, null, 2)}
-
-
-
- ) : null}
-
- )}
-
+
{showWelcome && (
diff --git a/src/features/settings/SettingsPage.jsx b/src/features/settings/SettingsPage.jsx
index 6fe4e9f..0046db5 100644
--- a/src/features/settings/SettingsPage.jsx
+++ b/src/features/settings/SettingsPage.jsx
@@ -1,5 +1,13 @@
import React from 'react'
import { normalizeBaseUrl } from '../../lib/apiUrl'
+import {
+ flattenFeatureEntries,
+ formatBytes,
+ formatDeviceName,
+ formatExtensionLabel,
+ prettyFeatureLabel,
+ prettyFeatureValue,
+} from '../../lib/serverFormatting'
export default function SettingsPage({
apiUrl,
@@ -43,75 +51,7 @@ export default function SettingsPage({
const isApiDirty = normalizeBaseUrl(settingsUrl) !== normalizeBaseUrl(apiUrl)
const canSaveSettings = isApiDirty
- const featureEntries = (() => {
- if (!serverFeatures || typeof serverFeatures !== 'object') return []
- const rows = []
- const walk = (prefix, value) => {
- if (value && typeof value === 'object' && !Array.isArray(value)) {
- for (const [nestedKey, nestedValue] of Object.entries(value)) {
- walk(prefix ? `${prefix}.${nestedKey}` : nestedKey, nestedValue)
- }
- return
- }
- rows.push([prefix, value])
- }
- walk('', serverFeatures)
- return rows
- })()
-
- const prettyFeatureLabel = (key) =>
- key
- .split('.')
- .map((segment) =>
- segment
- .replace(/_/g, ' ')
- .replace(/\b\w/g, (match) => match.toUpperCase())
- )
- .join(' / ')
-
- const prettyFeatureValue = (value, key) => {
- if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled'
- if (typeof value === 'number') {
- if (key.includes('max_upload_size')) {
- if (value >= 1024 * 1024) return `${Math.round((value / (1024 * 1024)) * 10) / 10} MB`
- }
- return String(value)
- }
- if (value == null) return 'N/A'
- return String(value)
- }
-
- const formatBytes = (bytes) => {
- if (typeof bytes !== 'number' || Number.isNaN(bytes)) return 'N/A'
- const gb = bytes / (1024 * 1024 * 1024)
- if (gb >= 1) return `${Math.round(gb * 10) / 10} GB`
- const mb = bytes / (1024 * 1024)
- return `${Math.round(mb)} MB`
- }
-
- const formatDeviceName = (rawName, fallbackIndex) => {
- if (!rawName || typeof rawName !== 'string') return `Device ${fallbackIndex}`
- let name = rawName
- if (name.includes(': ')) {
- name = name.split(': ')[0]
- }
- if (name.includes(' ')) {
- const firstSpace = name.indexOf(' ')
- const maybePrefix = name.slice(0, firstSpace)
- if (/^[a-z]+:\d+$/i.test(maybePrefix)) {
- name = name.slice(firstSpace + 1)
- }
- }
- return name.trim() || `Device ${fallbackIndex}`
- }
-
- const formatExtensionLabel = (ext, idx) => {
- if (typeof ext === 'string') return ext
- if (ext && typeof ext === 'object') {
- return ext.name || ext.title || ext.id || `Extension ${idx + 1}`
- }
- return `Extension ${idx + 1}`
- }
+ const featureEntries = flattenFeatureEntries(serverFeatures)
return (
diff --git a/src/features/templates/TemplateDetailsModal.jsx b/src/features/templates/TemplateDetailsModal.jsx
new file mode 100644
index 0000000..936f0df
--- /dev/null
+++ b/src/features/templates/TemplateDetailsModal.jsx
@@ -0,0 +1,116 @@
+import React from 'react'
+
+export default function TemplateDetailsModal({ selectedTemplateDetails, onCloseDetails }) {
+ if (!selectedTemplateDetails) return null
+
+ return (
+
+
+
+
+
+ {selectedTemplateDetails.title || selectedTemplateDetails.label}
+
+
+ {selectedTemplateDetails.categoryGroup || 'Templates'} / {selectedTemplateDetails.category || 'General'}
+
+
+
+ Close
+
+
+
+ {selectedTemplateDetails.thumbnailUrl && (
+
+ )}
+
+
+
+
Description
+
+ {selectedTemplateDetails.description || 'No description provided'}
+
+
+
+
+
+
Generation Type
+
{selectedTemplateDetails.category || selectedTemplateDetails.mediaType || 'unknown'}
+
+
+
Input Type
+
+ {Array.isArray(selectedTemplateDetails?.io?.inputs) && selectedTemplateDetails.io.inputs.length > 0
+ ? Array.from(new Set(selectedTemplateDetails.io.inputs.map((entry) => entry?.mediaType).filter(Boolean))).join(', ')
+ : (selectedTemplateDetails.mediaType || 'unknown')}
+
+
+
+
Output Type
+
+ {Array.isArray(selectedTemplateDetails?.io?.outputs) && selectedTemplateDetails.io.outputs.length > 0
+ ? Array.from(new Set(selectedTemplateDetails.io.outputs.map((entry) => entry?.mediaType).filter(Boolean))).join(', ')
+ : 'unknown'}
+
+
+
+
Source
+
{selectedTemplateDetails.source || 'unknown'}
+
+
+
Date
+
{selectedTemplateDetails.date || 'unknown'}
+
+
+
Usage
+
{typeof selectedTemplateDetails.usage === 'number' ? selectedTemplateDetails.usage : 'unknown'}
+
+
+
+ {Array.isArray(selectedTemplateDetails.models) && selectedTemplateDetails.models.length > 0 && (
+
+
Models
+
{selectedTemplateDetails.models.join(', ')}
+
+ )}
+
+ {Array.isArray(selectedTemplateDetails.tags) && selectedTemplateDetails.tags.length > 0 && (
+
+
Tags
+
{selectedTemplateDetails.tags.join(', ')}
+
+ )}
+
+ {Array.isArray(selectedTemplateDetails.requiresCustomNodes) && selectedTemplateDetails.requiresCustomNodes.length > 0 && (
+
+
Required Custom Nodes
+
{selectedTemplateDetails.requiresCustomNodes.join(', ')}
+
+ )}
+
+ {selectedTemplateDetails.tutorialUrl && (
+
+ )}
+
+
+
+ )
+}
diff --git a/src/features/templates/TemplateModals.jsx b/src/features/templates/TemplateModals.jsx
index d7713f4..6fec063 100644
--- a/src/features/templates/TemplateModals.jsx
+++ b/src/features/templates/TemplateModals.jsx
@@ -1,4 +1,6 @@
import React from 'react'
+import TemplateDetailsModal from './TemplateDetailsModal'
+import TemplatePrerequisitesModal from './TemplatePrerequisitesModal'
export default function TemplateModals({
selectedTemplateDetails,
@@ -10,186 +12,16 @@ export default function TemplateModals({
}) {
return (
<>
- {selectedTemplateDetails && (
-
-
-
-
-
- {selectedTemplateDetails.title || selectedTemplateDetails.label}
-
-
- {selectedTemplateDetails.categoryGroup || 'Templates'} / {selectedTemplateDetails.category || 'General'}
-
-
-
- Close
-
-
-
- {selectedTemplateDetails.thumbnailUrl && (
-
- )}
-
-
-
-
Description
-
- {selectedTemplateDetails.description || 'No description provided'}
-
-
-
-
-
-
Generation Type
-
{selectedTemplateDetails.category || selectedTemplateDetails.mediaType || 'unknown'}
-
-
-
Input Type
-
- {Array.isArray(selectedTemplateDetails?.io?.inputs) && selectedTemplateDetails.io.inputs.length > 0
- ? Array.from(new Set(selectedTemplateDetails.io.inputs.map((entry) => entry?.mediaType).filter(Boolean))).join(', ')
- : (selectedTemplateDetails.mediaType || 'unknown')}
-
-
-
-
Output Type
-
- {Array.isArray(selectedTemplateDetails?.io?.outputs) && selectedTemplateDetails.io.outputs.length > 0
- ? Array.from(new Set(selectedTemplateDetails.io.outputs.map((entry) => entry?.mediaType).filter(Boolean))).join(', ')
- : 'unknown'}
-
-
-
-
Source
-
{selectedTemplateDetails.source || 'unknown'}
-
-
-
Date
-
{selectedTemplateDetails.date || 'unknown'}
-
-
-
Usage
-
{typeof selectedTemplateDetails.usage === 'number' ? selectedTemplateDetails.usage : 'unknown'}
-
-
-
- {Array.isArray(selectedTemplateDetails.models) && selectedTemplateDetails.models.length > 0 && (
-
-
Models
-
{selectedTemplateDetails.models.join(', ')}
-
- )}
-
- {Array.isArray(selectedTemplateDetails.tags) && selectedTemplateDetails.tags.length > 0 && (
-
-
Tags
-
{selectedTemplateDetails.tags.join(', ')}
-
- )}
-
- {Array.isArray(selectedTemplateDetails.requiresCustomNodes) && selectedTemplateDetails.requiresCustomNodes.length > 0 && (
-
-
Required Custom Nodes
-
{selectedTemplateDetails.requiresCustomNodes.join(', ')}
-
- )}
-
- {selectedTemplateDetails.tutorialUrl && (
-
- )}
-
-
-
- )}
-
- {selectedPrereqTemplate && (
-
-
-
-
-
Prerequisites
-
- {selectedPrereqTemplate.title || selectedPrereqTemplate.label}
-
-
-
- Close
-
-
-
- {!selectedPrereqResult || selectedPrereqResult.loading ? (
-
Checking prerequisites...
- ) : selectedPrereqResult.error ? (
-
{selectedPrereqResult.error}
- ) : (
-
-
- {selectedPrereqResult.missing.length === 0
- ? `All ${selectedPrereqResult.total} required models are available`
- : `${selectedPrereqResult.missing.length} missing of ${selectedPrereqResult.total} required models`}
-
- {selectedPrereqResult.missing.length > 0 && (
-
- {selectedPrereqResult.missing.map((model) => (
-
-
- {(model.directory || 'unknown')} / {model.name}
-
-
- {model.url ? (
- <>
-
- Download
-
-
onCopyModelUrl(model.url)}
- className="px-2 py-1 text-xs rounded-md bg-slate-200 text-slate-800 hover:bg-slate-300"
- >
- Copy URL
-
- >
- ) : (
-
No download URL provided
- )}
-
-
- ))}
-
- )}
-
- )}
-
-
- )}
+
+
>
)
}
diff --git a/src/features/templates/TemplatePrerequisitesModal.jsx b/src/features/templates/TemplatePrerequisitesModal.jsx
new file mode 100644
index 0000000..e61f041
--- /dev/null
+++ b/src/features/templates/TemplatePrerequisitesModal.jsx
@@ -0,0 +1,80 @@
+import React from 'react'
+
+export default function TemplatePrerequisitesModal({
+ selectedPrereqTemplate,
+ selectedPrereqResult,
+ onClosePrereq,
+ onCopyModelUrl,
+}) {
+ if (!selectedPrereqTemplate) return null
+
+ return (
+
+
+
+
+
Prerequisites
+
+ {selectedPrereqTemplate.title || selectedPrereqTemplate.label}
+
+
+
+ Close
+
+
+
+ {!selectedPrereqResult || selectedPrereqResult.loading ? (
+
Checking prerequisites...
+ ) : selectedPrereqResult.error ? (
+
{selectedPrereqResult.error}
+ ) : (
+
+
+ {selectedPrereqResult.missing.length === 0
+ ? `All ${selectedPrereqResult.total} required models are available`
+ : `${selectedPrereqResult.missing.length} missing of ${selectedPrereqResult.total} required models`}
+
+ {selectedPrereqResult.missing.length > 0 && (
+
+ {selectedPrereqResult.missing.map((model) => (
+
+
+ {(model.directory || 'unknown')} / {model.name}
+
+
+ {model.url ? (
+ <>
+
+ Download
+
+
onCopyModelUrl(model.url)}
+ className="px-2 py-1 text-xs rounded-md bg-slate-200 text-slate-800 hover:bg-slate-300"
+ >
+ Copy URL
+
+ >
+ ) : (
+
No download URL provided
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/src/lib/serverFormatting.js b/src/lib/serverFormatting.js
new file mode 100644
index 0000000..b77b334
--- /dev/null
+++ b/src/lib/serverFormatting.js
@@ -0,0 +1,70 @@
+export function flattenFeatureEntries(serverFeatures) {
+ if (!serverFeatures || typeof serverFeatures !== 'object') return []
+ const rows = []
+ const walk = (prefix, value) => {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ for (const [nestedKey, nestedValue] of Object.entries(value)) {
+ walk(prefix ? `${prefix}.${nestedKey}` : nestedKey, nestedValue)
+ }
+ return
+ }
+ rows.push([prefix, value])
+ }
+ walk('', serverFeatures)
+ return rows
+}
+
+export function prettyFeatureLabel(key) {
+ return key
+ .split('.')
+ .map((segment) =>
+ segment
+ .replace(/_/g, ' ')
+ .replace(/\b\w/g, (match) => match.toUpperCase())
+ )
+ .join(' / ')
+}
+
+export function prettyFeatureValue(value, key = '') {
+ if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled'
+ if (typeof value === 'number') {
+ if (key.includes('max_upload_size')) {
+ if (value >= 1024 * 1024) return `${Math.round((value / (1024 * 1024)) * 10) / 10} MB`
+ }
+ return String(value)
+ }
+ if (value == null) return 'N/A'
+ return String(value)
+}
+
+export function formatBytes(bytes) {
+ if (typeof bytes !== 'number' || Number.isNaN(bytes)) return 'N/A'
+ const gb = bytes / (1024 * 1024 * 1024)
+ if (gb >= 1) return `${Math.round(gb * 10) / 10} GB`
+ const mb = bytes / (1024 * 1024)
+ return `${Math.round(mb)} MB`
+}
+
+export function formatDeviceName(rawName, fallbackIndex) {
+ if (!rawName || typeof rawName !== 'string') return `Device ${fallbackIndex}`
+ let name = rawName
+ if (name.includes(': ')) {
+ name = name.split(': ')[0]
+ }
+ if (name.includes(' ')) {
+ const firstSpace = name.indexOf(' ')
+ const maybePrefix = name.slice(0, firstSpace)
+ if (/^[a-z]+:\d+$/i.test(maybePrefix)) {
+ name = name.slice(firstSpace + 1)
+ }
+ }
+ return name.trim() || `Device ${fallbackIndex}`
+}
+
+export function formatExtensionLabel(ext, idx) {
+ if (typeof ext === 'string') return ext
+ if (ext && typeof ext === 'object') {
+ return ext.name || ext.title || ext.id || `Extension ${idx + 1}`
+ }
+ return `Extension ${idx + 1}`
+}
diff --git a/src/lib/serverFormatting.test.js b/src/lib/serverFormatting.test.js
new file mode 100644
index 0000000..65e0f23
--- /dev/null
+++ b/src/lib/serverFormatting.test.js
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import {
+ flattenFeatureEntries,
+ formatBytes,
+ formatDeviceName,
+ formatExtensionLabel,
+ prettyFeatureLabel,
+ prettyFeatureValue,
+} from './serverFormatting'
+
+describe('serverFormatting helpers', () => {
+ it('flattens nested feature objects', () => {
+ const rows = flattenFeatureEntries({
+ supports_preview_metadata: true,
+ extension: { manager: { supports_v4: true } },
+ })
+ expect(rows).toEqual([
+ ['supports_preview_metadata', true],
+ ['extension.manager.supports_v4', true],
+ ])
+ })
+
+ it('formats bytes to MB/GB', () => {
+ expect(formatBytes(1048576)).toBe('1 MB')
+ expect(formatBytes(5 * 1024 * 1024 * 1024)).toBe('5 GB')
+ expect(formatBytes(undefined)).toBe('N/A')
+ })
+
+ it('formats device names by removing prefixes', () => {
+ expect(formatDeviceName('cuda:0 NVIDIA GeForce RTX 5080 : cudaMallocAsync', 0)).toBe('NVIDIA GeForce RTX 5080')
+ expect(formatDeviceName('', 1)).toBe('Device 1')
+ })
+
+ it('formats extension labels', () => {
+ expect(formatExtensionLabel('manager', 0)).toBe('manager')
+ expect(formatExtensionLabel({ name: 'foo' }, 1)).toBe('foo')
+ expect(formatExtensionLabel({}, 2)).toBe('Extension 3')
+ })
+
+ it('formats feature labels and values', () => {
+ expect(prettyFeatureLabel('extension.manager.supports_v4')).toBe('Extension / Manager / Supports V4')
+ expect(prettyFeatureValue(true, 'supports_preview_metadata')).toBe('Enabled')
+ expect(prettyFeatureValue(104857600, 'max_upload_size')).toBe('100 MB')
+ expect(prettyFeatureValue(null, 'something')).toBe('N/A')
+ })
+})