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.
This commit is contained in:
Will Miao
2026-03-08 21:03:14 +08:00
parent 2fbe6c8843
commit cda271890a
4 changed files with 296 additions and 11 deletions

View File

@@ -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,
}

View File

@@ -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()]

View File

@@ -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,
)

View File

@@ -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,
};