Two things agents kept getting wrong while editing UI copy, both learned the expensive way: * `locales/*.json` come in two layouts: `en.json` (the reference, hand-maintained) is 2-space indented, the nine translated locales are 4-space (what `sync_translation_keys.py` writes). Loading one with `json.load` and dumping it back with the other indent reformats the whole file: changing two strings in the translated locales produced a 26k-line diff that had to be reverted and redone in place. The section now names both layouts, says to edit values in place, and adds the cheap guard that catches the whole class of mistake: after writing to an existing structured file, `git diff --stat` must match the change you meant to make. * Changing an existing English string makes every other locale's value stale. Those values have to go back to `[TODO: Translate] <new English>` rather than keep describing the old behaviour until someone notices.
14 KiB
AGENTS.md
This file provides guidance for agentic coding assistants working in this repository.
Overview
ComfyUI LoRA Manager is a comprehensive LoRA management system for ComfyUI that combines a Python backend with browser-based widgets. It provides model organization, downloading from CivitAI/CivArchive, recipe management, and one-click workflow integration.
Development Commands
Backend Development
# Install dependencies
pip install -r requirements.txt
pip install -r requirements-dev.txt
# Run standalone server (port 8188 by default)
python standalone.py --port 8188
# Run all backend tests
pytest
# Run specific test file
pytest tests/test_recipes.py
# Run specific test function
pytest tests/test_recipes.py::test_function_name
# Run backend tests with coverage
COVERAGE_FILE=coverage/backend/.coverage pytest \
--cov=py --cov=standalone \
--cov-report=term-missing \
--cov-report=html:coverage/backend/html \
--cov-report=xml:coverage/backend/coverage.xml \
--cov-report=json:coverage/backend/coverage.json
Frontend Development (LoRA Manager Web UI)
# Install dependencies (root and Vue widgets)
npm install
cd vue-widgets && npm install && cd ..
npm test # Run all tests (JS + Vue)
npm run test:js # Run JS tests only
npm run test:vue # Run Vue widget tests only
npm run test:watch # Watch mode (JS tests only)
npm run test:coverage # Generate coverage report
Vue Widget Development
cd vue-widgets
npm install
npm run dev # Build in watch mode
npm run build # Build production bundle
npm run typecheck # Run TypeScript type checking
npm test # Run Vue widget tests
npm run test:watch # Watch mode
npm run test:coverage # Generate coverage report
Localization
# Sync translation keys after UI string updates
python scripts/sync_translation_keys.py
Locale files are in locales/ (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
locales/*.json keep a hand-maintained layout and the two groups differ: en.json (the
reference) uses 2-space indent, the nine translated locales use 4-space indent (what
sync_translation_keys.py writes). Both keep ensure_ascii=False and exactly one trailing newline.
Edit values in place (line/anchored text edit) and add keys with the sync script. Never
json.load + json.dumps a locale file just to change a string: the serializer rewrites the whole
layout, so a two-value edit lands as a several-thousand-line diff that has to be reverted and redone
(tests/i18n/test_i18n.py::test_locale_files_keep_their_maintained_layout now fails on that).
After writing to any existing structured file, check git diff --stat: the changed-line count must
match the change you meant to make.
After adding keys to en.json and syncing, stop: the [TODO: Translate] placeholders in
the other locales are the expected end state during feature development. Do NOT translate
proactively — translate only when the feature owner explicitly asks (see
docs/i18n-translation-guidelines.md §7).
Changing an existing English string is a translation event too: the other locales' value for
that key is now stale, so put it back in the queue ([TODO: Translate] <new English>) instead of
leaving a translation that describes the old behaviour.
Before translating anything, read docs/i18n-translation-guidelines.md — it defines the
term conventions (e.g. "Recipe" stays untranslated in French, 配方 in Chinese; model-type and
brand names are never translated), per-locale preferred renderings, placeholder rules, and
the known confusion hot-spots.
Code Style
Python
Imports & Formatting
- Use
from __future__ import annotationsfor forward references - Group imports: standard library, third-party, local (blank line separated)
- Use
TYPE_CHECKINGguard for type-checking-only imports - Absolute imports within
py/:from ..services import X - PEP 8 with 4-space indentation, type hints required
Naming Conventions
- Files:
snake_case.py, Classes:PascalCase, Functions/vars:snake_case - Constants:
UPPER_SNAKE_CASE, Private:_protected,__mangled
Error Handling & Async
- Use
logging.getLogger(__name__), define custom exceptions inpy/services/errors.py async deffor I/O,@pytest.mark.asynciofor async tests- Singleton with
asyncio.Lock: seeModelScanner.get_instance() - Return
aiohttp.web.json_responseorweb.Response
JavaScript/TypeScript
Imports & Modules
- ES modules:
import { app } from "../../scripts/app.js"for ComfyUI - Vue:
import { ref, computed } from 'vue', type imports:import type { Foo } - Export named functions:
export function foo() {}
Naming & Formatting
- camelCase for functions/vars/props, PascalCase for classes
- Constants:
UPPER_SNAKE_CASE, Files:snake_case.jsorkebab-case.js - 2-space indentation preferred (follow existing file conventions)
- Vue Single File Components:
<script setup lang="ts">preferred
Widget Development
- Prefer vanilla JS for
web/comfyui/widgets; avoid framework dependencies (except the Vue widgets invue-widgets/) - ComfyUI:
app.registerExtension(),node.addDOMWidget(name, type, element, options) - Event handlers via
addEventListeneror widget callbacks - Shared utilities:
web/comfyui/utils.js - Dual-mode rendering patterns (canvas vs Vue): see
docs/comfyui-dual-mode-widgets.md
Vue Composables Pattern
- Use composition API:
useXxxState(widget), return reactive refs and methods - Guard restoration loops with flag:
let isRestoring = false - Build config from state:
const buildConfig = (): Config => { ... }
Architecture
Dual Mode Operation
The system runs in two modes:
- ComfyUI plugin mode: Integrates with ComfyUI's PromptServer, uses
folder_pathsfor model discovery - Standalone mode:
standalone.pymocks ComfyUI dependencies, reads paths fromsettings.json - Detection:
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
Backend Entry Points
__init__.py— ComfyUI plugin entry: registers nodes viaNODE_CLASS_MAPPINGS, setsWEB_DIRECTORY, callsLoraManager.add_routes()standalone.py— Standalone server: mocksfolder_pathsand node modules, starts aiohttp serverpy/lora_manager.py— MainLoraManagerclass that registers all HTTP routes
Service Layer
ServiceRegistrysingleton for DI, services useget_instance()classmethodBaseModelServiceabstract base →LoraService,CheckpointService,EmbeddingServiceModelScannerbase →LoraScanner,CheckpointScanner,EmbeddingScannerfor file discovery with hash-based deduplicationPersistentModelCache(SQLite) for metadata persistenceMetadataSyncService— background sync from CivitAI/CivArchive APIsSettingsManager— settings with schema migration supportWebSocketManager— real-time progress broadcastingModelServiceFactory— creates the right service for each model type- Use cases in
py/services/use_cases/orchestrate complex business logic (auto-organize, bulk refresh, downloads) - Separate scanners (discovery) from services (business logic)
- Handlers in
py/routes/handlers/are pure functions with deps as params
Model Types & Routes
- API endpoints follow
/loras/*,/checkpoints/*,/embeddings/*,/other/*patterns - Route registrars organize endpoints by domain:
ModelRouteRegistrar,RecipeRouteRegistrar, etc. - Request handlers in
py/routes/handlers/implement route logic - All routes use aiohttp, return
web.json_responseorweb.Response - Endpoints consumed by the companion browser extension (lm-civitai-extension)
MUST also accept
GETwith query-string params: the extension is GET-only by convention (see its AGENTS.md), even for state-changing operations such asGET /api/lm/recipe/{recipe_id}/reimport
Recipe System
- Base:
py/recipes/base.py, Enrichment:RecipeEnrichmentServiceinpy/recipes/enrichment.py - Parsers:
py/recipes/parsers/for PNG metadata, JSON, and workflow formats
Custom Nodes
- Location:
py/nodes/, all nodes registered in__init__.py - Each node class has a
NAMEclass attribute used as key inNODE_CLASS_MAPPINGS - Standard ComfyUI node pattern:
INPUT_TYPES()classmethod,RETURN_TYPES,FUNCTION
Configuration
py/config.pymanages folder paths for models and handles symlink mappings- Auto-saves paths to
settings.jsonin ComfyUI mode settings.json.exampleis intentionally minimal (see Important Notes); all other defaults live inDEFAULT_SETTINGS(py/services/settings_manager.py)folder_pathsvsextra_folder_paths— different purposes, do not conflate:folder_paths(primary model roots): in ComfyUI plugin mode these come from the ComfyUI host; in standalone mode they are the ONLY source of model library paths and are currently edited by hand insettings.json.extra_folder_pathsis a ComfyUI-plugin-mode feature: paths visible ONLY to LoRA Manager, not to ComfyUI. Its motivation is that a very large model library slows ComfyUI itself down, while LoRA Manager handles large libraries without performance issues — so users keep ComfyUI's library small and add the bulk viaextra_folder_paths.
Frontend UI Architecture
1. LoRA Manager Web UI
- Location:
./static/(JS/CSS) and./templates/(HTML) - Tech: Vanilla JS + CSS, served by the hosting server (ComfyUI app in plugin mode,
standalone.pyin standalone mode) - Tests:
tests/frontend/**/*.test.js(vitest + jsdom)
2. ComfyUI Custom Node Widgets
- Location:
./web/comfyui/(Vanilla JS) +./vue-widgets/(Vue) - Primary styles:
./web/comfyui/lm_styles.css(NOT./static/css/) - Vue widgets: Vue 3 + TypeScript + PrimeVue + vue-i18n, e.g.
LoraPoolWidget,LoraRandomizerWidget,LoraCyclerWidget,AutocompleteTextWidget - Vue builds to
./web/comfyui/vue-widgets/; auto-built on ComfyUI startup viapy/vue_widget_builder.py, typecheck viavue-tsc - Widget registration:
app.registerExtension()andgetCustomWidgetshooks;node.addDOMWidget(...)embeds HTML in LiteGraph nodes - See
docs/dom_widget_dev_guide.mdfor the DOMWidget development guide
Testing
Backend (pytest)
- Config in
pytest.ini:--import-mode=importlib, testpaths=tests - Fixtures in
tests/conftest.pymock ComfyUI dependencies; usetmp_path_factoryfor isolation - Markers:
@pytest.mark.asyncio,@pytest.mark.no_settings_dir_isolation(tests needing real settings paths)
Frontend (vitest)
- Vanilla JS tests:
tests/frontend/**/*.test.jswith jsdom; setup intests/frontend/setup.js - Vue widget tests:
vue-widgets/tests/**/*.test.tswith jsdom +@vue/test-utils
UI Verification (manual default)
UI/layout changes are verified by the user by eye — do NOT spin up a sandbox, standalone server, or browser automation to "prove" a visual fix. Ask the user to look instead. The full browser E2E ceremony (server + Chrome DevTools MCP + screenshots) is slow, token-heavy, and fragile; reserve it for genuine server+browser integration bugs, and only when the user explicitly agrees.
If a cross-layer issue ever needs a live server, the sandboxed helpers live in
scripts/e2e/ (start_server.py, wait_for_server.py). Non-negotiable rules:
- Always launch with
--settings-path <sandbox>/settingsand sandboxedfolder_pathsunder/tmp— the repo folder is the real plugin folder and asettings.jsonthere is read by the live instance. Never touch real config or real model libraries. - Never kill a process you did not start;
start_server.pytracks its own PIDs via pidfile and refuses to touch unrelated processes on the port. - Abort after ~30 minutes or 3 consecutive tool failures; report
BLOCKEDwith observed state instead of retrying blindly. Clean up sandbox and server after.
Key Integration Points
- Settings: Stored in the user config directory (via
platformdirs) or portable mode ("use_portable_settings": true) - CivitAI/CivArchive: API clients for metadata sync and model downloads; CivitAI API key stored in settings
- Symlinks: Config scans symlinks to map virtual→physical paths; fingerprinting prevents redundant rescans
- WebSocket: Broadcasts real-time progress for downloads, scans, and metadata sync
- Model scanning flow: Walk folders → compute hashes → deduplicate → extract safetensors metadata → cache in SQLite → background CivitAI sync → WebSocket broadcast
Important Notes
- ALWAYS use English for comments (per copilot-instructions.md)
.civitai.infofiles are NOT LoRA Manager sidecars. They are written by third-party apps; LoRA Manager treats them as read-only and only consumes them during migration/import. Never write, modify, or delete them, and never propose doing so as a fix — LoRA Manager's own metadata lives in the.metadata.jsonsidecar it owns.- Sidecar/preview path derivation must go through
py/utils/sidecar_paths.pyhelpers (never inlinesplitext + ".metadata.json"): the centralized storage mode (sidecar_storage_mode/sidecar_storage_pathsettings) relocates.metadata.jsonfiles and preview images under a mirror tree, so any hand-built path is wrong in that mode..civitai.infostays co-located with the model file in both modes. The new settings keys live only inDEFAULT_SETTINGS—settings.json.examplestays minimal (see below). settings.json.examplemust stay minimal: onlyuse_portable_settings,civitai_api_key, and the four corefolder_pathskeys (loras,checkpoints,unet,embeddings). Do NOT add optional/default keys (model-category folders,default_*_root,auto_organize_exclusions, etc.) to this file unless the user explicitly asks for it. Defaults belong inDEFAULT_SETTINGSinpy/services/settings_manager.py.- Run
python scripts/sync_translation_keys.pyafter adding UI strings tolocales/en.json - Symlinks require normalized paths.
Business paths vs real paths: All stored paths and operation routing use the
original paths as they appear under configured model roots — symlinks are NOT
resolved.
os.path.realpathis only for scanner dedup and the symlink cache. Any path passed toos.remove/os.rename/shutil.moveor validated by a containment check MUST use the business path (i.e.os.path.abspath, notrealpath).