From cda271890ad043095d11adf4ec286c04d6d49776 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 8 Mar 2026 21:03:14 +0800 Subject: [PATCH] feat(workflow-template): add new tab template workflow with auto-zoom - Add GET /api/lm/example-workflows endpoint to list available templates - Add GET /api/lm/example-workflows/{filename} to retrieve specific workflow - Add 'New Tab Template Workflow' setting in LoRA Manager settings - Automatically apply 80% zoom level when loading template workflows - Override workflow's saved view settings to prevent visual zoom flicker The feature allows users to select a template workflow from example_workflows/ directory to load when creating new workflow tabs, with a hardcoded 0.8 zoom level for better initial view experience. --- py/routes/handlers/misc_handlers.py | 98 +++++++++++++++++ py/routes/misc_route_registrar.py | 26 ++++- py/routes/misc_routes.py | 18 ++- web/comfyui/settings.js | 165 +++++++++++++++++++++++++++- 4 files changed, 296 insertions(+), 11 deletions(-) diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 0d536527..79658b7f 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -252,6 +252,100 @@ class SupportersHandler: return web.json_response({"success": False, "error": str(exc)}, status=500) +class ExampleWorkflowsHandler: + """Handler for example workflow templates.""" + + def __init__(self, logger: logging.Logger | None = None) -> None: + self._logger = logger or logging.getLogger(__name__) + + def _get_workflows_dir(self) -> str: + """Get the example workflows directory path.""" + current_file = os.path.abspath(__file__) + root_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(current_file))) + ) + return os.path.join(root_dir, "example_workflows") + + def _format_workflow_name(self, filename: str) -> str: + """Convert filename to human-readable name.""" + name = os.path.splitext(filename)[0] + name = name.replace("_", " ") + return name + + async def get_example_workflows(self, request: web.Request) -> web.Response: + """Return list of available example workflows.""" + try: + workflows_dir = self._get_workflows_dir() + workflows = [ + { + "value": "Default", + "label": "Default (Blank)", + "path": None, + } + ] + + if os.path.exists(workflows_dir): + for filename in sorted(os.listdir(workflows_dir)): + if filename.endswith(".json"): + workflows.append( + { + "value": filename, + "label": self._format_workflow_name(filename), + "path": f"example_workflows/{filename}", + } + ) + + return web.json_response({"success": True, "workflows": workflows}) + except Exception as exc: + self._logger.error( + "Error listing example workflows: %s", exc, exc_info=True + ) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + async def get_example_workflow(self, request: web.Request) -> web.Response: + """Return a specific example workflow JSON content.""" + try: + filename = request.match_info.get("filename") + if not filename: + return web.json_response( + {"success": False, "error": "Filename not provided"}, status=400 + ) + + if filename == "Default": + return web.json_response( + { + "success": True, + "workflow": { + "last_node_id": 0, + "last_link_id": 0, + "nodes": [], + "links": [], + "groups": [], + "config": {}, + "extra": {}, + "version": 0.4, + }, + } + ) + + workflows_dir = self._get_workflows_dir() + filepath = os.path.join(workflows_dir, filename) + + if not os.path.exists(filepath): + return web.json_response( + {"success": False, "error": f"Workflow not found: {filename}"}, + status=404, + ) + + with open(filepath, "r", encoding="utf-8") as f: + workflow = json.load(f) + + return web.json_response({"success": True, "workflow": workflow}) + except Exception as exc: + self._logger.error("Error loading example workflow: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + class SettingsHandler: """Sync settings between backend and frontend.""" @@ -1523,6 +1617,7 @@ class MiscHandlerSet: filesystem: FileSystemHandler, custom_words: CustomWordsHandler, supporters: SupportersHandler, + example_workflows: ExampleWorkflowsHandler, ) -> None: self.health = health self.settings = settings @@ -1536,6 +1631,7 @@ class MiscHandlerSet: self.filesystem = filesystem self.custom_words = custom_words self.supporters = supporters + self.example_workflows = example_workflows def to_route_mapping( self, @@ -1565,6 +1661,8 @@ class MiscHandlerSet: "open_settings_location": self.filesystem.open_settings_location, "search_custom_words": self.custom_words.search_custom_words, "get_supporters": self.supporters.get_supporters, + "get_example_workflows": self.example_workflows.get_example_workflows, + "get_example_workflow": self.example_workflows.get_example_workflow, } diff --git a/py/routes/misc_route_registrar.py b/py/routes/misc_route_registrar.py index e2443644..5ab34b3b 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -38,12 +38,24 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("GET", "/api/lm/get-registry", "get_registry"), RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"), RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"), - RouteDefinition("POST", "/api/lm/download-metadata-archive", "download_metadata_archive"), - RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"), - RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"), - RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"), + RouteDefinition( + "POST", "/api/lm/download-metadata-archive", "download_metadata_archive" + ), + RouteDefinition( + "POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive" + ), + RouteDefinition( + "GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status" + ), + RouteDefinition( + "GET", "/api/lm/model-versions-status", "get_model_versions_status" + ), RouteDefinition("POST", "/api/lm/settings/open-location", "open_settings_location"), RouteDefinition("GET", "/api/lm/custom-words/search", "search_custom_words"), + RouteDefinition("GET", "/api/lm/example-workflows", "get_example_workflows"), + RouteDefinition( + "GET", "/api/lm/example-workflows/{filename}", "get_example_workflow" + ), ) @@ -67,7 +79,11 @@ class MiscRouteRegistrar: definitions: Iterable[RouteDefinition] = MISC_ROUTE_DEFINITIONS, ) -> None: for definition in definitions: - self._bind(definition.method, definition.path, handler_lookup[definition.handler_name]) + self._bind( + definition.method, + definition.path, + handler_lookup[definition.handler_name], + ) def _bind(self, method: str, path: str, handler: Callable) -> None: add_method_name = self._METHOD_MAP[method.upper()] diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py index acdb4e8a..73494b42 100644 --- a/py/routes/misc_routes.py +++ b/py/routes/misc_routes.py @@ -19,6 +19,7 @@ from ..services.downloader import get_downloader from ..utils.usage_stats import UsageStats from .handlers.misc_handlers import ( CustomWordsHandler, + ExampleWorkflowsHandler, FileSystemHandler, HealthCheckHandler, LoraCodeHandler, @@ -38,9 +39,10 @@ from .misc_route_registrar import MiscRouteRegistrar logger = logging.getLogger(__name__) -standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get( - "HF_HUB_DISABLE_TELEMETRY", "0" -) == "0" +standalone_mode = ( + os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" + or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" +) class MiscRoutes: @@ -75,7 +77,9 @@ class MiscRoutes: self._node_registry = node_registry or NodeRegistry() self._standalone_mode = standalone_mode_flag - self._handler_mapping: Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]] | None = None + self._handler_mapping: ( + Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]] | None + ) = None @staticmethod def setup_routes(app: web.Application) -> None: @@ -87,7 +91,9 @@ class MiscRoutes: registrar = self._registrar_factory(app) registrar.register_routes(self._ensure_handler_mapping()) - def _ensure_handler_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]: + def _ensure_handler_mapping( + self, + ) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]: if self._handler_mapping is None: handler_set = self._create_handler_set() self._handler_mapping = handler_set.to_route_mapping() @@ -121,6 +127,7 @@ class MiscRoutes: ) custom_words = CustomWordsHandler() supporters = SupportersHandler() + example_workflows = ExampleWorkflowsHandler() return self._handler_set_factory( health=health, @@ -135,6 +142,7 @@ class MiscRoutes: filesystem=filesystem, custom_words=custom_words, supporters=supporters, + example_workflows=example_workflows, ) diff --git a/web/comfyui/settings.js b/web/comfyui/settings.js index 35b64486..4a1c3f9b 100644 --- a/web/comfyui/settings.js +++ b/web/comfyui/settings.js @@ -19,10 +19,63 @@ const TAG_SPACE_REPLACEMENT_DEFAULT = false; const USAGE_STATISTICS_SETTING_ID = "loramanager.usage_statistics"; const USAGE_STATISTICS_DEFAULT = true; +const NEW_TAB_TEMPLATE_ID = "loramanager.new_tab_template"; +const NEW_TAB_TEMPLATE_DEFAULT = "Default"; + +const NEW_TAB_ZOOM_LEVEL = 0.8; + // ============================================================================ // Helper Functions // ============================================================================ +let workflowOptions = [NEW_TAB_TEMPLATE_DEFAULT]; +let workflowOptionsFull = [{ value: "Default", label: "Default (Blank)", path: null }]; +let workflowOptionsLoaded = false; + +const loadWorkflowOptions = async () => { + if (workflowOptionsLoaded) { + return; + } + try { + const response = await fetch("/api/lm/example-workflows"); + const data = await response.json(); + if (data.success && data.workflows) { + workflowOptionsFull = data.workflows; + workflowOptions = data.workflows.map((w) => w.label); + workflowOptionsLoaded = true; + } + } catch (error) { + console.warn("LoRA Manager: Failed to fetch workflow options", error); + } +}; + +const getWorkflowOptions = () => { + // Function may be called with or without parameters + // Return the current workflow options array + return workflowOptions; +}; + +const loadTemplateWorkflow = async (templateName) => { + if (!templateName || templateName === NEW_TAB_TEMPLATE_DEFAULT) { + return null; + } + try { + const workflow = workflowOptionsFull.find((w) => w.label === templateName); + if (workflow && workflow.value) { + const workflowResponse = await fetch( + `/api/lm/example-workflows/${encodeURIComponent(workflow.value)}` + ); + const workflowData = await workflowResponse.json(); + if (workflowData.success && workflowData.workflow) { + return workflowData.workflow; + } + } + } catch (error) { + console.error("LoRA Manager: Failed to load template workflow", error); + } + return null; +}; + const getWheelSensitivity = (() => { let settingsUnavailableLogged = false; @@ -153,6 +206,32 @@ const getUsageStatisticsPreference = (() => { }; })(); +const getNewTabTemplatePreference = (() => { + let settingsUnavailableLogged = false; + + return () => { + const settingManager = app?.extensionManager?.setting; + if (!settingManager || typeof settingManager.get !== "function") { + if (!settingsUnavailableLogged) { + console.warn("LoRA Manager: settings API unavailable, using default new tab template."); + settingsUnavailableLogged = true; + } + return NEW_TAB_TEMPLATE_DEFAULT; + } + + try { + const value = settingManager.get(NEW_TAB_TEMPLATE_ID); + return value ?? NEW_TAB_TEMPLATE_DEFAULT; + } catch (error) { + if (!settingsUnavailableLogged) { + console.warn("LoRA Manager: unable to read new tab template setting, using default.", error); + settingsUnavailableLogged = true; + } + return NEW_TAB_TEMPLATE_DEFAULT; + } + }; +})(); + // ============================================================================ // Register Extension with All Settings // ============================================================================ @@ -205,11 +284,95 @@ app.registerExtension({ tooltip: "When enabled, LoRA Manager will track model usage statistics during workflow execution. Disabling this will prevent unnecessary disk writes.", category: ["LoRA Manager", "Statistics", "Usage Tracking"], }, + { + id: NEW_TAB_TEMPLATE_ID, + name: "New Tab Template Workflow", + type: "combo", + options: getWorkflowOptions, + defaultValue: NEW_TAB_TEMPLATE_DEFAULT, + tooltip: "Choose a template workflow to load when creating a new workflow tab. 'Default (Blank)' keeps ComfyUI's original blank workflow behavior.", + category: ["LoRA Manager", "Workflow", "New Tab Template"], + }, ], + async setup() { + await loadWorkflowOptions(); + + const originalNewBlankWorkflow = async () => { + const blankGraph = { + last_node_id: 0, + last_link_id: 0, + nodes: [], + links: [], + groups: [], + config: {}, + extra: {}, + version: 0.4, + }; + await app.loadGraphData(blankGraph); + }; + + const waitForCommandStore = async (maxWaitMs = 5000) => { + const startTime = Date.now(); + while (Date.now() - startTime < maxWaitMs) { + if (app.extensionManager?.command?.commands) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; + }; + + const patchCommand = async () => { + const storeReady = await waitForCommandStore(); + if (!storeReady) { + console.warn("LoRA Manager: Could not access command store to patch NewBlankWorkflow"); + return; + } + + const commands = app.extensionManager.command.commands; + for (const cmd of commands) { + if (cmd.id === "Comfy.NewBlankWorkflow") { + const originalFunc = cmd.function; + cmd.function = async (metadata) => { + const templateName = getNewTabTemplatePreference(); + + if (templateName && templateName !== NEW_TAB_TEMPLATE_DEFAULT) { + const workflowData = await loadTemplateWorkflow(templateName); + if (workflowData) { + // Override the workflow's saved view settings with our custom zoom + if (!workflowData.extra) { + workflowData.extra = {}; + } + if (!workflowData.extra.ds) { + workflowData.extra.ds = { offset: [0, 0], scale: 1 }; + } + workflowData.extra.ds.scale = NEW_TAB_ZOOM_LEVEL; + + await app.loadGraphData(workflowData); + return; + } + } + + await originalNewBlankWorkflow(); + }; + break; + } + } + }; + + patchCommand(); + }, }); // ============================================================================ // Exports // ============================================================================ -export { getWheelSensitivity, getAutoPathCorrectionPreference, getPromptTagAutocompletePreference, getTagSpaceReplacementPreference, getUsageStatisticsPreference }; +export { + getWheelSensitivity, + getAutoPathCorrectionPreference, + getPromptTagAutocompletePreference, + getTagSpaceReplacementPreference, + getUsageStatisticsPreference, + getNewTabTemplatePreference, +};