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/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..bea0ac2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,13 +1,22 @@ -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 useApiSettingsForm from './hooks/useApiSettingsForm' +import useOnboardingFlow from './hooks/useOnboardingFlow' +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' - -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']) +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 { getTemplateBrowserData } from './features/templates/templateBrowserData' +import { normalizeBaseUrl } from './lib/apiUrl' +import { analyzeWorkflowPromptInputs } from './lib/workflowPrompt' export default function App() { const navigate = useNavigate() @@ -18,46 +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 [serverFeatures, setServerFeatures] = useState(null) - const [serverSystemStats, setServerSystemStats] = useState(null) - 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 [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, @@ -69,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, @@ -91,25 +81,84 @@ 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, + recentJobsError, + selectedJobId, + setSelectedJobId, + jobDetail, + setJobDetail, + isLoadingJobDetail, + jobDetailError, + setJobDetailError, + 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 - - 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 @@ -136,138 +185,19 @@ 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) - 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) 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) + 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') } @@ -275,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) { @@ -338,463 +253,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) { - throw new Error(`Template index failed: ${res.status}`) - } - const indexRaw = await res.json() - return extractIndexedTemplates(indexRaw, templatesBaseUrl, source) - } - - async function fetchServerData() { - if (!apiUrl) return - - setIsLoadingServerData(true) - setServerDataError(null) - try { - const baseUrl = normalizeBaseUrl(apiUrl) - const [featuresRes, statsRes, extensionsRes, historyRes, templatesRes, 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`), - ]) - - 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) - } - } - 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') - } - } catch (err) { - setTemplateSource('none') - setServerDataError(String(err)) - } finally { - setIsLoadingServerData(false) - } - } - - 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) - 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({ @@ -855,286 +313,16 @@ 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) + async function copyModelUrl(url) { + if (!url) return 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) { - 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' }) - 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) }) + await navigator.clipboard.writeText(url) + setError('Model URL copied to clipboard') + } catch (_) { + setError('Failed to copy model URL') } } - 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 }) - } - function handleSaveSettings() { saveApiSettings() } @@ -1182,682 +370,97 @@ 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} - - -
- )} -