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 ( -
- -

- {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'} -

-
-
- - -
-
- 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" - /> - -
-
- - -
- Runs On: ComfyUI -
- -
-

- Showing {filteredTemplates.length} of {serverTemplates.length} templates -

- {filteredTemplates.length > 0 ? ( -
- {filteredTemplates.map((template) => ( -
- {template.thumbnailUrl ? ( - {template.title - ) : ( -
- )} -
- - -

- {(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 - )} -
-
- - - - {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'} -

-
- -
- - {selectedTemplateDetails.thumbnailUrl && ( - {selectedTemplateDetails.title - )} - -
-
-

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 && ( -
-

Tutorial URL

- - {selectedTemplateDetails.tutorialUrl} - -
- )} -
-
-
- )} - - {selectedPrereqTemplate && ( -
-
-
-
-

Prerequisites

-

- {selectedPrereqTemplate.title || selectedPrereqTemplate.label} -

-
- -
- - {!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 - - - - ) : ( - 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 ( +
+ +

+ {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'} +

+
+
+ + +
+
+ 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" + /> + +
+
+ + +
+ Runs On: ComfyUI +
+ +
+

+ Showing {filteredTemplates.length} of {serverTemplates.length} templates +

+ {filteredTemplates.length > 0 ? ( +
+ {filteredTemplates.map((template) => ( +
+ {template.thumbnailUrl ? ( + {template.title + ) : ( +
+ )} +
+ + +

+ {(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 + )} +
+
+ + + + {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'} +

+
+ +
+ + {selectedTemplateDetails.thumbnailUrl && ( + {selectedTemplateDetails.title + )} + +
+
+

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} +

+
+ +
+ + {!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 + + + + ) : ( + 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.

-
- -
- -
- {!canCloseSettings && ( -

- Complete setup to continue: add your API URL and workflow JSON. -

- )} - -
-

ComfyUI API

-
- - 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. -

- - {showAdvancedApi && ( -
-
-
- - -
-
- - 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'} -

-
-
- - -
- {connectionStatus && ( -

- {connectionStatus.message} -

- )} - {canCloseSettings && isApiDirty && ( -

- You have unsaved API changes. -

- )} -
- -
-

Workflow

-

{hasConfiguredWorkflow ? `Selected: ${workflowName}` : 'No workflow selected yet'}

-
- - -
- - {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 - - - - ) : ( - No download URL provided - )} -
-
- ))} -
- )} -
- )} - - {renderTemplateBrowser()} -
- -
-
-

Server Dashboard

- -
- {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 && ( - - )} -
-
-
-

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.

-
- -
- -
-
-

Interrupt Running Execution

-

Stop currently running generation jobs on the server.

-
- -
- -
-
-

Clear Server History

-

Delete historical job records from the ComfyUI server.

-
- -
- -
-
-

Free VRAM / Unload Models

-

Release runtime memory and unload models to recover from memory pressure.

-
- -
-
- {opsActionStatus && ( -

- {opsActionStatus.message} -

- )} -
- -
-
-
+ navigate('/')} + /> ) } function renderOnboardingPage() { - const isApiDirty = normalizeBaseUrl(settingsUrl) !== normalizeBaseUrl(apiUrl) - const canGoToWorkflowStep = hasConfiguredApiUrl - return ( -
-
-
-
-
-

Getting Started

-

Step {onboardingStep} of 2

-
-
- - -
-
- - {onboardingStep === 1 ? ( -
-

Connect to ComfyUI

-
- - 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. -

- - {showAdvancedApi && ( -
-
-
- - -
-
- - 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'} -

-
-
- - -
- {connectionStatus && ( -

- {connectionStatus.message} -

- )} - {isApiDirty && ( -

You have unsaved API changes.

- )} -
- ) : ( -
-

Choose Workflow

-

{hasConfiguredWorkflow ? `Selected: ${workflowName}` : 'No workflow selected yet'}

-
- - -
- - {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 - - - - ) : ( - No download URL provided - )} -
-
- ))} -
- )} -
- )} - {renderTemplateBrowser()} -
- - -
-
- )} -
-
-
+ ) } 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

+
+
+ + +
+
+ + {onboardingStep === 1 ? ( +
+

Connect to ComfyUI

+
+ + 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. +

+ + {showAdvancedApi && ( +
+
+
+ + +
+
+ + 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'} +

+
+
+ + +
+ {connectionStatus && ( +

+ {connectionStatus.message} +

+ )} + {isApiDirty && ( +

You have unsaved API changes.

+ )} +
+ ) : ( +
+

Choose Workflow

+

{hasConfiguredWorkflow ? `Selected: ${workflowName}` : 'No workflow selected yet'}

+
+ + +
+ + {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 + + + + ) : ( + No download URL provided + )} +
+
+ ))} +
+ )} +
+ )} + {renderTemplateBrowser()} +
+ + +
+
+ )} +
+
+
+ ) +} 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.

+
+ +
+ +
+ {!canCloseSettings && ( +

+ Complete setup to continue: add your API URL and workflow JSON. +

+ )} + +
+

ComfyUI API

+
+ + 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. +

+ + {showAdvancedApi && ( +
+
+
+ + +
+
+ + 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'} +

+
+
+ + +
+ {connectionStatus && ( +

+ {connectionStatus.message} +

+ )} + {canCloseSettings && isApiDirty && ( +

+ You have unsaved API changes. +

+ )} +
+ +
+

Workflow

+

{hasConfiguredWorkflow ? `Selected: ${workflowName}` : 'No workflow selected yet'}

+
+ + +
+ + {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 + + + + ) : ( + No download URL provided + )} +
+
+ ))} +
+ )} +
+ )} + + {renderTemplateBrowser()} +
+ +
+
+

Server Dashboard

+ +
+ {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 && ( + + )} +
+
+
+

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.

+
+ +
+ +
+
+

Interrupt Running Execution

+

Stop currently running generation jobs on the server.

+
+ +
+ +
+
+

Clear Server History

+

Delete historical job records from the ComfyUI server.

+
+ +
+ +
+
+

Free VRAM / Unload Models

+

Release runtime memory and unload models to recover from memory pressure.

+
+ +
+
+ {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 ? ( -
-
-

{statusMessage}

-
- ) : error ? ( -
-
-

Error

-

{error}

-
-
- ) : ( - Generated - )} -
- )} - - {isLoading && !imageSrc && ( -
-
-

{statusMessage}

-
- )} - - {error && !imageSrc && ( -
-
-

Error

-

{error}

-
-
- )} - -
-
-
- - API: {hasConfiguredApiUrl ? 'Configured' : 'Missing'} - - - Workflow: {hasConfiguredWorkflow ? workflowName : 'Missing'} - - {typeof queueState.running === 'number' && typeof queueState.pending === 'number' && ( - - Queue: {queueState.running} running, {queueState.pending} pending - - )} -
- {!canCloseSettings && ( - - )} -
- - {promptInputMode === 'dual' && ( - - )} -
-
- -
- {inputImageName && ( -
- - {inputImageName} - - -
- )} -