Compare commits

...

35 Commits

Author SHA1 Message Date
Will Miao
4000b7f7e7 feat: Add configurable LoRA strength adjustment step setting
Implements issue #808 - Allow users to customize the strength
variation range for LoRA widget arrow buttons.

Changes:
- Add 'Strength Adjustment Step' setting (0.01-0.1) in settings.js
- Replace hardcoded 0.05 increments with configurable step value
- Apply to both LoRA strength and CLIP strength controls

Fixes #808
2026-03-19 17:33:18 +08:00
Will Miao
76c15105e6 feat(lora-pool): add regex include/exclude name pattern filtering (#839)
Add name pattern filtering to LoRA Pool node allowing users to filter
LoRAs by filename or model name using either plain text or regex patterns.

Features:
- Include patterns: only show LoRAs matching at least one pattern
- Exclude patterns: exclude LoRAs matching any pattern
- Regex toggle: switch between substring and regex matching
- Case-insensitive matching for both modes
- Invalid regex automatically falls back to substring matching
- Filters apply to both file_name and model_name fields

Backend:
- Update LoraPoolLM._default_config() with namePatterns structure
- Add name pattern filtering to _apply_pool_filters() and _apply_specific_filters()
- Add API parameter parsing for name_pattern_include/exclude/use_regex
- Update LoraPoolConfig type with namePatterns field

Frontend:
- Add NamePatternsSection.vue component with pattern input UI
- Update useLoraPoolState to manage pattern state and API integration
- Update LoraPoolSummaryView to display NamePatternsSection
- Increase LORA_POOL_WIDGET_MIN_HEIGHT to accommodate new UI

Tests:
- Add 7 test cases covering text/regex include, exclude, combined
  filtering, model name fallback, and invalid regex handling

Closes #839
2026-03-19 17:15:05 +08:00
Will Miao
b11c90e19b feat: add type ignore comments and remove unused imports
- Add `# type: ignore` comments to comfy.sd and folder_paths imports
- Remove unused imports: os, random, and extract_lora_name
- Clean up import statements across checkpoint_loader, lora_randomizer, and unet_loader nodes
2026-03-19 15:54:49 +08:00
pixelpaws
9f5d2d0c18 Merge pull request #862 from EnragedAntelope/claude/add-webp-image-support-t8kG9
Improve webp image support
2026-03-19 15:35:16 +08:00
Will Miao
a0dc5229f4 feat(unet_loader): move torch import inside methods for lazy loading
- Delay torch import until needed in load_unet and load_unet_gguf methods
- This improves module loading performance by avoiding unnecessary imports
- Maintains functionality while reducing initial import overhead
2026-03-19 15:29:41 +08:00
Will Miao
61c31ecbd0 fix: exclude __init__.py from pytest collection to prevent CI import errors 2026-03-19 14:43:45 +08:00
Will Miao
1ae1b0d607 refactor: move No LoRA feature from LoRA Pool to Lora Cycler widget
Move the 'empty/no LoRA' cycling functionality from the LoRA Pool node
to the Lora Cycler widget for cleaner architecture:

Frontend changes:
- Add include_no_lora field to CyclerConfig interface
- Add includeNoLora state and logic to useLoraCyclerState composable
- Add toggle UI in LoraCyclerSettingsView with special styling
- Show 'No LoRA' entry in LoraListModal when enabled
- Update LoraCyclerWidget to integrate new logic

Backend changes:
- lora_cycler.py reads include_no_lora from config
- Calculate effective_total_count (actual count + 1 when enabled)
- Return empty lora_stack when on No LoRA position
- Return actual LoRA count in total_count (not effective count)

Reverted files to pre-PR state:
- lora_loader.py, lora_pool.py, lora_randomizer.py, lora_stacker.py
- lora_routes.py, lora_service.py
- LoraPoolWidget.vue and related files

Related to PR #861

Co-authored-by: dogatech <dogatech@dogatech.home>
2026-03-19 14:19:49 +08:00
dogatech
8dd849892d Allow for empty lora (no loras option) in Lora Pool 2026-03-19 09:23:03 +08:00
Will Miao
03e1fa75c5 feat: auto-focus URL input when batch import modal opens 2026-03-18 22:33:45 +08:00
Will Miao
fefcaa4a45 fix: improve Civitai recipe import by extracting EXIF when API metadata is empty
- Add validation to check if Civitai API metadata contains recipe fields
- Fall back to EXIF extraction when API returns empty metadata (meta.meta=null)
- Improve error messages to distinguish between missing metadata and unsupported format
- Add _has_recipe_fields() helper method to validate metadata content

This fixes import failures for Civitai images where the API returns
metadata wrapper but no actual generation parameters (e.g., images
edited in Photoshop that lost their original generation metadata)
2026-03-18 22:30:36 +08:00
Will Miao
701a6a6c44 refactor: remove GGUF loading logic from CheckpointLoaderLM
GGUF models are pure Unet models and should be handled by UNETLoaderLM.
2026-03-18 21:36:07 +08:00
Will Miao
0ef414d17e feat: standardize Checkpoint/Unet loader names and use OS-native path separators
- Rename nodes to 'Checkpoint Loader (LoraManager)' and 'Unet Loader (LoraManager)'\n- Use os.sep for relative path formatting in model COMBO inputs\n- Update path matching to be robust across OS separators\n- Update docstrings and comments
2026-03-18 21:33:19 +08:00
Will Miao
75dccaef87 test: fix cache validator tests to account for new hash_status field and side effects 2026-03-18 21:10:56 +08:00
Will Miao
7e87ec9521 fix: persist hash_status in model cache to support lazy hashing on restart 2026-03-18 21:07:40 +08:00
Will Miao
46522edb1b refactor: simplify GGUF import helper with dynamic path detection
- Add _get_gguf_path() to dynamically derive ComfyUI-GGUF path from current file location
- Remove Strategy 2 and 3, keeping only Strategy 1 (sys.modules path-based lookup)
- Remove hard-coded absolute paths
- Streamline logging output
- Code cleanup: reduced from 235 to 154 lines
2026-03-18 19:55:54 +08:00
Will Miao
2dae4c1291 fix: isolate extra unet paths from checkpoints to prevent type misclassification
Refactor _prepare_checkpoint_paths() to return a tuple instead of having
side effects on instance variables. This prevents extra unet paths from
being incorrectly classified as checkpoints when processing extra paths.

- Changed return type from List[str] to Tuple[List[str], List[str], List[str]]
  (all_paths, checkpoint_roots, unet_roots)
- Updated _init_checkpoint_paths() and _apply_library_paths() callers
- Fixed extra paths processing to properly isolate main and extra roots
- Updated test_checkpoint_path_overlap.py tests for new API

This ensures models in extra unet paths are correctly identified as
diffusion_model type and don't appear in checkpoints list.
2026-03-17 22:03:57 +08:00
EnragedAntelope
a32325402e Merge branch 'willmiao:main' into claude/add-webp-image-support-t8kG9 2026-03-17 08:37:46 -04:00
Will Miao
70c150bd80 fix(services): implement stable sorting for model and recipe caches
Add file_path as a tie-breaker for all sort modes in ModelCache, BaseModelService, LoraService, and RecipeCache to ensure deterministic ordering when primary keys are identical. Resolves issue #859.
2026-03-17 14:20:23 +08:00
Will Miao
9e81c33f8a fix(utils): make sanitize_folder_name idempotent by combining strip/rstrip calls 2026-03-17 11:24:59 +08:00
Will Miao
22c0dbd734 feat(recipes): persist 'Skip images without metadata' choice in batch import 2026-03-17 11:01:41 +08:00
Will Miao
d0c58472be fix(i18n): add missing common.actions.close translation key 2026-03-17 09:57:27 +08:00
Will Miao
b3c530bf36 fix(autocomplete): handle multi-word tag matching with normalized spaces
- Replace multiple consecutive spaces with single underscore for tag matching
  (e.g., 'looking  to   the side' → 'looking_to_the_side')
- Support prefix/suffix matching for flexible multi-word autocomplete
  (e.g., 'looking to the' → 'looking_to_the_side')
- Add comprehensive test coverage for multi-word scenarios

Test coverage:
- Multi-word exact match (Danbooru convention)
- Partial match with last token replacement
- Command mode with multi-word phrases
- Multiple consecutive spaces handling
- Backend LOG10 popularity weight validation

Fixes: 'looking to the side' input now correctly replaces with
'looking_to_the_side, ' (or 'looking to the side, ' with space replacement)
2026-03-17 09:34:01 +08:00
Claude
05ebd7493d chore: update package-lock.json after npm install
https://claude.ai/code/session_01SgT2pkisi27bEQELX5EeXZ
2026-03-17 01:33:34 +00:00
Claude
90986bd795 feat: add case-insensitive webp support for lora cover photos
Make preview file discovery case-insensitive so files with uppercase
extensions like .WEBP are found on case-sensitive filesystems. Also
explicitly list image/webp in the file picker accept attribute for
broader browser compatibility.

https://claude.ai/code/session_01SgT2pkisi27bEQELX5EeXZ
2026-03-17 01:32:48 +00:00
Will Miao
b5a0725d2c fix(autocomplete): improve tag search ranking with popularity-based sorting
- Add LOG10(post_count) weighting to BM25 score for better relevance ranking
- Prioritize tag_name prefix matches above alias matches using CASE statement
- Remove frontend re-scoring logic to trust backend排序 results
- Fix pagination consistency: page N+1 scores <= page N minimum score

Key improvements:
- '1girl' (6M posts) now ranks #1 instead of #149 for search '1'
- tag_name prefix matches always appear before alias matches
- Popular tags rank higher than obscure ones with same prefix
- Consistent ordering across pagination boundaries

Test coverage:
- Add test_search_tag_name_prefix_match_priority
- Add test_search_ranks_popular_tags_higher
- Add test_search_pagination_ordering_consistency
- Add test_search_rank_score_includes_popularity_weight
- Update test data with 15 tags starting with '1'

Fixes issues with autocomplete dropdown showing inconsistent results
when scrolling through paginated search results.
2026-03-16 19:09:07 +08:00
Will Miao
ef38bda04f docs: remove redundant example metadata files (#856)
- Delete examples/metadata/ directory and all example files
  - Real metadata.json files in model roots are better examples
  - Examples were artificial and could become outdated
  - Maintenance burden outweighs benefit

- Remove 'Complete Examples' section from docs/metadata-json-schema.md
- Remove reference to example files in 'See Also' section

Rationale:
Users have access to real-world metadata.json files in their actual
model directories, which contain complete Civitai API responses with
authentic data structures (images arrays with prompts, files with hashes,
creator information, etc.). These are more valuable than simplified
artificial examples.
2026-03-16 09:41:58 +08:00
Will Miao
58713ea6e0 fix(top-menu): use dynamic imports to eliminate deprecation warnings
- Replace static imports of deprecated ComfyButton and ComfyButtonGroup with dynamic imports
- Only loads legacy API files when frontend version < 1.33.9 (backward compatibility path)
- Frontend >= 1.33.9 users no longer see deprecation warnings since legacy code is never loaded
- Preserves full backward compatibility for older ComfyUI frontend versions
- All existing tests pass (159 JS + 65 Vue tests)
2026-03-16 09:41:58 +08:00
Will Miao
8b91920058 docs: add comprehensive metadata.json schema documentation (#856)
- Create docs/metadata-json-schema.md with complete field reference
  - All base fields for LoRA, Checkpoint, and Embedding models
  - Complete civitai object structure with Used vs Stored field classification
  - Model-level fields (allowCommercialUse, allowDerivatives, etc.)
  - Creator fields (username, image)
  - customImages structure with actual field names and types
  - Field behavior categories (Auto-Updated, Set Once, User-Editable)

- Add .specs/metadata.schema.json for programmatic validation
  - JSON Schema draft-07 format
  - oneOf schemas for each model type
  - Definitions for civitaiObject and usageTips

- Add example metadata files for each model type
  - lora-civitai.json: LoRA with full Civitai data
  - lora-custom.json: User-defined LoRA with trigger words
  - lora-no-triggerwords.json: LoRA without trigger words
  - checkpoint-civitai.json: Checkpoint from Civitai
  - embedding-custom.json: Custom embedding

Key clarifications:
  - modified: Import timestamp (Set Once, never changes after import)
  - size: File size at import time (Set Once)
  - base_model: Optional with actual values (SDXL 1.0, Flux.1 D, etc.)
  - model_type: Used in metadata.json (not sub_type which is internal)
  - allowCommercialUse: ["Image", "Video", "RentCivit", "Rent"]
  - civitai.files/images: Marked as Used by Lora Manager
  - User-editable fields clearly documented (model_name, tags, etc.)
2026-03-16 09:41:58 +08:00
Will Miao
ee466113d5 feat: implement batch import recipe functionality (frontend + backend fixes)
Backend fixes:
- Add missing API route for /api/lm/recipes/batch-import/progress (GET)
- Add missing API route for /api/lm/recipes/batch-import/directory (POST)
- Add missing API route for /api/lm/recipes/browse-directory (POST)
- Register WebSocket endpoint for batch import progress
- Fix skip_no_metadata default value (True -> False) to allow no-LoRA imports
- Add items array to BatchImportProgress.to_dict() for detailed results

Frontend implementation:
- Create BatchImportManager.js with complete batch import workflow
- Add directory browser UI for selecting folders
- Add batch import modal with URL list and directory input modes
- Implement real-time progress tracking (WebSocket + HTTP polling)
- Add results summary with success/failed/skipped statistics
- Add expandable details view showing individual item status
- Auto-refresh recipe list after import completion

UI improvements:
- Add spinner animation for importing status
- Simplify results summary UI to match progress stats styling
- Fix current item text alignment
- Fix dark theme styling for directory browser button
- Fix batch import button styling consistency

Translations:
- Add batch import related i18n keys to all locale files
- Run sync_translation_keys.py to sync all translations

Fixes:
- Batch import now allows images without LoRAs (matches single import behavior)
- Progress endpoint now returns complete items array with status details
- Results view correctly displays skipped items with error messages
2026-03-16 09:41:58 +08:00
Will Miao
f86651652c feat(batch-import): implement backend batch import service with adaptive concurrency
- Add BatchImportService with concurrent execution using asyncio.gather
- Implement AdaptiveConcurrencyController with dynamic adjustment
- Add input validation for URLs and local paths
- Support duplicate detection via skip_duplicates parameter
- Add WebSocket progress broadcasting for real-time updates
- Create comprehensive unit tests for batch import functionality
- Update API handlers and route registrations
- Add i18n translation keys for batch import UI
2026-03-16 09:41:58 +08:00
Will Miao
c89d4dae85 fix(extra-paths): support trigger words for LoRAs in extra folder paths, fixes #860
- Update get_lora_info() to check both loras_roots and extra_loras_roots
- Add fallback logic to return trigger words even if path not in recognized roots
- Ensure Trigger Word Toggle node displays trigger words for LoRAs from extra folder paths

Fixes issue where LoRAs added from extra folder paths would not show their trigger words in connected Trigger Word Toggle nodes.
2026-03-16 09:38:21 +08:00
pixelpaws
55a18d401b Merge pull request #858 from botchedchuckle/patch-1
Fix: Escape HTML in Prompt/NegativePrompt for MetadataPanel
2026-03-14 14:43:46 +08:00
botchedchuckle
7570936c75 Fix: Escape HTML in Prompt/NegativePrompt for MetadataPanel
* Fixed a bug where `prompt` and `negativePrompt` were both being
  added directly to HTML without escaping them. Given prompts are
  allowed to have HTML characters (e.g. `<lora:something:0.75>`), by
  forgetting to escape them some tags were missing in the metadata
  views for example images using those characters.
2026-03-13 01:29:04 -07:00
Will Miao
4fcf641d57 fix(bulk-context-menu): escape special characters in data-filepath selector to support double quotes in filenames (#845) 2026-03-12 08:49:10 +08:00
Will Miao
5c29e26c4e fix(top-menu): add backward compatibility for actionBarButtons API (#853)
- Implement version detection using __COMFYUI_FRONTEND_VERSION__ and /system_stats API
- Add version parsing and comparison utilities
- Dynamically register extension based on frontend version
- Use actionBarButtons API for frontend >= 1.33.9
- Fallback to legacy ComfyButton approach for older versions
- Add comprehensive version detection tests
2026-03-12 07:41:29 +08:00
87 changed files with 9684 additions and 1033 deletions

View File

@@ -0,0 +1,153 @@
# Recipe Batch Import Feature Design
## Overview
Enable users to import multiple images as recipes in a single operation, rather than processing them individually. This feature addresses the need for efficient bulk recipe creation from existing image collections.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend │
├─────────────────────────────────────────────────────────────────┤
│ BatchImportManager.js │
│ ├── InputCollector (收集URL列表/目录路径) │
│ ├── ConcurrencyController (自适应并发控制) │
│ ├── ProgressTracker (进度追踪) │
│ └── ResultAggregator (结果汇总) │
├─────────────────────────────────────────────────────────────────┤
│ batch_import_modal.html │
│ └── 批量导入UI组件 │
├─────────────────────────────────────────────────────────────────┤
│ batch_import_progress.css │
│ └── 进度显示样式 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend │
├─────────────────────────────────────────────────────────────────┤
│ py/routes/handlers/recipe_handlers.py │
│ ├── start_batch_import() - 启动批量导入 │
│ ├── get_batch_import_progress() - 查询进度 │
│ └── cancel_batch_import() - 取消导入 │
├─────────────────────────────────────────────────────────────────┤
│ py/services/batch_import_service.py │
│ ├── 自适应并发执行 │
│ ├── 结果汇总 │
│ └── WebSocket进度广播 │
└─────────────────────────────────────────────────────────────────┘
```
## API Endpoints
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/lm/recipes/batch-import/start` | POST | 启动批量导入,返回 operation_id |
| `/api/lm/recipes/batch-import/progress` | GET | 查询进度状态 |
| `/api/lm/recipes/batch-import/cancel` | POST | 取消导入 |
## Backend Implementation Details
### BatchImportService
Location: `py/services/batch_import_service.py`
Key classes:
- `BatchImportItem`: Dataclass for individual import item
- `BatchImportProgress`: Dataclass for tracking progress
- `BatchImportService`: Main service class
Features:
- Adaptive concurrency control (adjusts based on success/failure rate)
- WebSocket progress broadcasting
- Graceful error handling (individual failures don't stop the batch)
- Result aggregation
### WebSocket Message Format
```json
{
"type": "batch_import_progress",
"operation_id": "xxx",
"total": 50,
"completed": 23,
"success": 21,
"failed": 2,
"skipped": 0,
"current_item": "image_024.png",
"status": "running"
}
```
### Input Types
1. **URL List**: Array of URLs (http/https)
2. **Local Paths**: Array of local file paths
3. **Directory**: Path to directory with optional recursive flag
### Error Handling
- Invalid URLs/paths: Skip and record error
- Download failures: Record error, continue
- Metadata extraction failures: Mark as "no metadata"
- Duplicate detection: Option to skip duplicates
## Frontend Implementation Details (TODO)
### UI Components
1. **BatchImportModal**: Main modal with tabs for URLs/Directory input
2. **ProgressDisplay**: Real-time progress bar and status
3. **ResultsSummary**: Final results with success/failure breakdown
### Adaptive Concurrency Controller
```javascript
class AdaptiveConcurrencyController {
constructor(options = {}) {
this.minConcurrency = options.minConcurrency || 1;
this.maxConcurrency = options.maxConcurrency || 5;
this.currentConcurrency = options.initialConcurrency || 3;
}
adjustConcurrency(taskDuration, success) {
if (success && taskDuration < 1000 && this.currentConcurrency < this.maxConcurrency) {
this.currentConcurrency = Math.min(this.currentConcurrency + 1, this.maxConcurrency);
}
if (!success || taskDuration > 10000) {
this.currentConcurrency = Math.max(this.currentConcurrency - 1, this.minConcurrency);
}
return this.currentConcurrency;
}
}
```
## File Structure
```
Backend (implemented):
├── py/services/batch_import_service.py # 后端服务
├── py/routes/handlers/batch_import_handler.py # API处理器 (added to recipe_handlers.py)
├── tests/services/test_batch_import_service.py # 单元测试
└── tests/routes/test_batch_import_routes.py # API集成测试
Frontend (TODO):
├── static/js/managers/BatchImportManager.js # 主管理器
├── static/js/managers/batch/ # 子模块
│ ├── ConcurrencyController.js # 并发控制
│ ├── ProgressTracker.js # 进度追踪
│ └── ResultAggregator.js # 结果汇总
├── static/css/components/batch-import-modal.css # 样式
└── templates/components/batch_import_modal.html # Modal模板
```
## Implementation Status
- [x] Backend BatchImportService
- [x] Backend API handlers
- [x] WebSocket progress broadcasting
- [x] Unit tests
- [x] Integration tests
- [ ] Frontend BatchImportManager
- [ ] Frontend UI components
- [ ] E2E tests

464
.specs/metadata.schema.json Normal file
View File

@@ -0,0 +1,464 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/willmiao/ComfyUI-Lora-Manager/.specs/metadata.schema.json",
"title": "ComfyUI LoRa Manager Model Metadata",
"description": "Schema for .metadata.json sidecar files used by ComfyUI LoRa Manager",
"type": "object",
"oneOf": [
{
"title": "LoRA Model Metadata",
"properties": {
"file_name": {
"type": "string",
"description": "Filename without extension"
},
"model_name": {
"type": "string",
"description": "Display name of the model"
},
"file_path": {
"type": "string",
"description": "Full absolute path to the model file"
},
"size": {
"type": "integer",
"minimum": 0,
"description": "File size in bytes at time of import/download"
},
"modified": {
"type": "number",
"description": "Unix timestamp when model was imported/added (Date Added)"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$",
"description": "SHA256 hash of the model file (lowercase)"
},
"base_model": {
"type": "string",
"description": "Base model type (SD1.5, SD2.1, SDXL, SD3, Flux, Unknown, etc.)"
},
"preview_url": {
"type": "string",
"description": "Path to preview image file"
},
"preview_nsfw_level": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "NSFW level using bitmask values: 0 (none), 1 (PG), 2 (PG13), 4 (R), 8 (X), 16 (XXX), 32 (Blocked)"
},
"notes": {
"type": "string",
"default": "",
"description": "User-defined notes"
},
"from_civitai": {
"type": "boolean",
"default": true,
"description": "Whether the model originated from Civitai"
},
"civitai": {
"$ref": "#/definitions/civitaiObject"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Model tags"
},
"modelDescription": {
"type": "string",
"default": "",
"description": "Full model description"
},
"civitai_deleted": {
"type": "boolean",
"default": false,
"description": "Whether the model was deleted from Civitai"
},
"favorite": {
"type": "boolean",
"default": false,
"description": "Whether the model is marked as favorite"
},
"exclude": {
"type": "boolean",
"default": false,
"description": "Whether to exclude from cache/scanning"
},
"db_checked": {
"type": "boolean",
"default": false,
"description": "Whether checked against archive database"
},
"skip_metadata_refresh": {
"type": "boolean",
"default": false,
"description": "Skip this model during bulk metadata refresh"
},
"metadata_source": {
"type": ["string", "null"],
"enum": ["civitai_api", "civarchive", "archive_db", null],
"default": null,
"description": "Last provider that supplied metadata"
},
"last_checked_at": {
"type": "number",
"default": 0,
"description": "Unix timestamp of last metadata check"
},
"hash_status": {
"type": "string",
"enum": ["pending", "calculating", "completed", "failed"],
"default": "completed",
"description": "Hash calculation status"
},
"usage_tips": {
"type": "string",
"default": "{}",
"description": "JSON string containing recommended usage parameters (LoRA only)"
}
},
"required": [
"file_name",
"model_name",
"file_path",
"size",
"modified",
"sha256",
"base_model"
],
"additionalProperties": true
},
{
"title": "Checkpoint Model Metadata",
"properties": {
"file_name": {
"type": "string"
},
"model_name": {
"type": "string"
},
"file_path": {
"type": "string"
},
"size": {
"type": "integer",
"minimum": 0
},
"modified": {
"type": "number"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"base_model": {
"type": "string"
},
"preview_url": {
"type": "string"
},
"preview_nsfw_level": {
"type": "integer",
"minimum": 0,
"maximum": 3,
"default": 0
},
"notes": {
"type": "string",
"default": ""
},
"from_civitai": {
"type": "boolean",
"default": true
},
"civitai": {
"$ref": "#/definitions/civitaiObject"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"modelDescription": {
"type": "string",
"default": ""
},
"civitai_deleted": {
"type": "boolean",
"default": false
},
"favorite": {
"type": "boolean",
"default": false
},
"exclude": {
"type": "boolean",
"default": false
},
"db_checked": {
"type": "boolean",
"default": false
},
"skip_metadata_refresh": {
"type": "boolean",
"default": false
},
"metadata_source": {
"type": ["string", "null"],
"enum": ["civitai_api", "civarchive", "archive_db", null],
"default": null
},
"last_checked_at": {
"type": "number",
"default": 0
},
"hash_status": {
"type": "string",
"enum": ["pending", "calculating", "completed", "failed"],
"default": "completed"
},
"sub_type": {
"type": "string",
"default": "checkpoint",
"description": "Model sub-type (checkpoint, diffusion_model, etc.)"
}
},
"required": [
"file_name",
"model_name",
"file_path",
"size",
"modified",
"sha256",
"base_model"
],
"additionalProperties": true
},
{
"title": "Embedding Model Metadata",
"properties": {
"file_name": {
"type": "string"
},
"model_name": {
"type": "string"
},
"file_path": {
"type": "string"
},
"size": {
"type": "integer",
"minimum": 0
},
"modified": {
"type": "number"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"base_model": {
"type": "string"
},
"preview_url": {
"type": "string"
},
"preview_nsfw_level": {
"type": "integer",
"minimum": 0,
"maximum": 3,
"default": 0
},
"notes": {
"type": "string",
"default": ""
},
"from_civitai": {
"type": "boolean",
"default": true
},
"civitai": {
"$ref": "#/definitions/civitaiObject"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"modelDescription": {
"type": "string",
"default": ""
},
"civitai_deleted": {
"type": "boolean",
"default": false
},
"favorite": {
"type": "boolean",
"default": false
},
"exclude": {
"type": "boolean",
"default": false
},
"db_checked": {
"type": "boolean",
"default": false
},
"skip_metadata_refresh": {
"type": "boolean",
"default": false
},
"metadata_source": {
"type": ["string", "null"],
"enum": ["civitai_api", "civarchive", "archive_db", null],
"default": null
},
"last_checked_at": {
"type": "number",
"default": 0
},
"hash_status": {
"type": "string",
"enum": ["pending", "calculating", "completed", "failed"],
"default": "completed"
},
"sub_type": {
"type": "string",
"default": "embedding",
"description": "Model sub-type"
}
},
"required": [
"file_name",
"model_name",
"file_path",
"size",
"modified",
"sha256",
"base_model"
],
"additionalProperties": true
}
],
"definitions": {
"civitaiObject": {
"type": "object",
"default": {},
"description": "Civitai/CivArchive API data and user-defined fields",
"properties": {
"id": {
"type": "integer",
"description": "Version ID from Civitai"
},
"modelId": {
"type": "integer",
"description": "Model ID from Civitai"
},
"name": {
"type": "string",
"description": "Version name"
},
"description": {
"type": "string",
"description": "Version description"
},
"baseModel": {
"type": "string",
"description": "Base model type from Civitai"
},
"type": {
"type": "string",
"description": "Model type (checkpoint, embedding, etc.)"
},
"trainedWords": {
"type": "array",
"items": {
"type": "string"
},
"description": "Trigger words for the model (from API or user-defined)"
},
"customImages": {
"type": "array",
"items": {
"type": "object"
},
"description": "Custom example images added by user"
},
"model": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"files": {
"type": "array",
"items": {
"type": "object"
}
},
"images": {
"type": "array",
"items": {
"type": "object"
}
},
"creator": {
"type": "object"
}
},
"additionalProperties": true
},
"usageTips": {
"type": "object",
"description": "Structure for usage_tips JSON string (LoRA models)",
"properties": {
"strength_min": {
"type": "number",
"description": "Minimum recommended model strength"
},
"strength_max": {
"type": "number",
"description": "Maximum recommended model strength"
},
"strength_range": {
"type": "string",
"description": "Human-readable strength range"
},
"strength": {
"type": "number",
"description": "Single recommended strength value"
},
"clip_strength": {
"type": "number",
"description": "Recommended CLIP/embedding strength"
},
"clip_skip": {
"type": "integer",
"description": "Recommended CLIP skip value"
}
},
"additionalProperties": true
}
}
}

View File

@@ -321,6 +321,12 @@ npm run test:coverage
---
## Documentation
- **[metadata.json Schema Documentation](docs/metadata-json-schema.md)** — Complete reference for the `.metadata.json` sidecar file format, including all fields, types, and examples for LoRA, Checkpoint, and Embedding models.
---
## Contributing
Thank you for your interest in contributing to ComfyUI LoRA Manager! As this project is currently in its early stages and undergoing rapid development and refactoring, we are temporarily not accepting pull requests.

View File

@@ -1,6 +1,8 @@
try: # pragma: no cover - import fallback for pytest collection
from .py.lora_manager import LoraManager
from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
from .py.nodes.checkpoint_loader import CheckpointLoaderLM
from .py.nodes.unet_loader import UNETLoaderLM
from .py.nodes.trigger_word_toggle import TriggerWordToggleLM
from .py.nodes.prompt import PromptLM
from .py.nodes.text import TextLM
@@ -27,12 +29,12 @@ except (
PromptLM = importlib.import_module("py.nodes.prompt").PromptLM
TextLM = importlib.import_module("py.nodes.text").TextLM
LoraManager = importlib.import_module("py.lora_manager").LoraManager
LoraLoaderLM = importlib.import_module(
"py.nodes.lora_loader"
).LoraLoaderLM
LoraTextLoaderLM = importlib.import_module(
"py.nodes.lora_loader"
).LoraTextLoaderLM
LoraLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraLoaderLM
LoraTextLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraTextLoaderLM
CheckpointLoaderLM = importlib.import_module(
"py.nodes.checkpoint_loader"
).CheckpointLoaderLM
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM
TriggerWordToggleLM = importlib.import_module(
"py.nodes.trigger_word_toggle"
).TriggerWordToggleLM
@@ -49,9 +51,7 @@ except (
LoraRandomizerLM = importlib.import_module(
"py.nodes.lora_randomizer"
).LoraRandomizerLM
LoraCyclerLM = importlib.import_module(
"py.nodes.lora_cycler"
).LoraCyclerLM
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = {
@@ -59,6 +59,8 @@ NODE_CLASS_MAPPINGS = {
TextLM.NAME: TextLM,
LoraLoaderLM.NAME: LoraLoaderLM,
LoraTextLoaderLM.NAME: LoraTextLoaderLM,
CheckpointLoaderLM.NAME: CheckpointLoaderLM,
UNETLoaderLM.NAME: UNETLoaderLM,
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
LoraStackerLM.NAME: LoraStackerLM,
SaveImageLM.NAME: SaveImageLM,

View File

@@ -0,0 +1,363 @@
# metadata.json Schema Documentation
This document defines the complete schema for `.metadata.json` files used by Lora Manager. These sidecar files store model metadata alongside model files (LoRA, Checkpoint, Embedding).
## Overview
- **File naming**: `<model_name>.metadata.json` (e.g., `my_lora.safetensors``my_lora.metadata.json`)
- **Format**: JSON with UTF-8 encoding
- **Purpose**: Store model metadata, tags, descriptions, preview images, and Civitai/CivArchive integration data
- **Extensibility**: Unknown fields are preserved via `_unknown_fields` mechanism for forward compatibility
---
## Base Fields (All Model Types)
These fields are present in all model metadata files.
| Field | Type | Required | Auto-Updated | Description |
|-------|------|----------|--------------|-------------|
| `file_name` | string | ✅ Yes | ✅ Yes | Filename without extension (e.g., `"my_lora"`) |
| `model_name` | string | ✅ Yes | ❌ No | Display name of the model. **Default**: `file_name` if no other source |
| `file_path` | string | ✅ Yes | ✅ Yes | Full absolute path to the model file (normalized with `/` separators) |
| `size` | integer | ✅ Yes | ❌ No | File size in bytes. **Set at**: Initial scan or download completion. Does not change thereafter. |
| `modified` | float | ✅ Yes | ❌ No | **Import timestamp** — Unix timestamp when the model was first imported/added to the system. Used for "Date Added" sorting. Does not change after initial creation. |
| `sha256` | string | ⚠️ Conditional | ✅ Yes | SHA256 hash of the model file (lowercase). **LoRA**: Required. **Checkpoint**: May be empty when `hash_status="pending"` (lazy hash calculation) |
| `base_model` | string | ❌ No | ❌ No | Base model type. **Examples**: `"SD 1.5"`, `"SDXL 1.0"`, `"SDXL Lightning"`, `"Flux.1 D"`, `"Flux.1 S"`, `"Flux.1 Krea"`, `"Illustrious"`, `"Pony"`, `"AuraFlow"`, `"Kolors"`, `"ZImageTurbo"`, `"Wan Video"`, etc. **Default**: `"Unknown"` or `""` |
| `preview_url` | string | ❌ No | ✅ Yes | Path to preview image file |
| `preview_nsfw_level` | integer | ❌ No | ❌ No | NSFW level using **bitmask values** from Civitai: `1` (PG), `2` (PG13), `4` (R), `8` (X), `16` (XXX), `32` (Blocked). **Default**: `0` (none) |
| `notes` | string | ❌ No | ❌ No | User-defined notes |
| `from_civitai` | boolean | ❌ No (default: `true`) | ❌ No | Whether the model originated from Civitai |
| `civitai` | object | ❌ No | ⚠️ Partial | Civitai/CivArchive API data and user-defined fields |
| `tags` | array[string] | ❌ No | ⚠️ Partial | Model tags (merged from API and user input) |
| `modelDescription` | string | ❌ No | ⚠️ Partial | Full model description (from API or user) |
| `civitai_deleted` | boolean | ❌ No (default: `false`) | ❌ No | Whether the model was deleted from Civitai |
| `favorite` | boolean | ❌ No (default: `false`) | ❌ No | Whether the model is marked as favorite |
| `exclude` | boolean | ❌ No (default: `false`) | ❌ No | Whether to exclude from cache/scanning. User can set from `false` to `true` (currently no UI to revert) |
| `db_checked` | boolean | ❌ No (default: `false`) | ❌ No | Whether checked against archive database |
| `skip_metadata_refresh` | boolean | ❌ No (default: `false`) | ❌ No | Skip this model during bulk metadata refresh |
| `metadata_source` | string\|null | ❌ No | ✅ Yes | Last provider that supplied metadata (see below) |
| `last_checked_at` | float | ❌ No (default: `0`) | ✅ Yes | Unix timestamp of last metadata check |
| `hash_status` | string | ❌ No (default: `"completed"`) | ✅ Yes | Hash calculation status: `"pending"`, `"calculating"`, `"completed"`, `"failed"` |
---
## Model-Specific Fields
### LoRA Models
LoRA models do not have a `model_type` field in metadata.json. The type is inferred from context or `civitai.type` (e.g., `"LoRA"`, `"LoCon"`, `"DoRA"`).
| Field | Type | Required | Auto-Updated | Description |
|-------|------|----------|--------------|-------------|
| `usage_tips` | string (JSON) | ❌ No (default: `"{}"`) | ❌ No | JSON string containing recommended usage parameters |
**`usage_tips` JSON structure:**
```json
{
"strength_min": 0.3,
"strength_max": 0.8,
"strength_range": "0.3-0.8",
"strength": 0.6,
"clip_strength": 0.5,
"clip_skip": 2
}
```
| Key | Type | Description |
|-----|------|-------------|
| `strength_min` | number | Minimum recommended model strength |
| `strength_max` | number | Maximum recommended model strength |
| `strength_range` | string | Human-readable strength range |
| `strength` | number | Single recommended strength value |
| `clip_strength` | number | Recommended CLIP/embedding strength |
| `clip_skip` | integer | Recommended CLIP skip value |
---
### Checkpoint Models
| Field | Type | Required | Auto-Updated | Description |
|-------|------|----------|--------------|-------------|
| `model_type` | string | ❌ No (default: `"checkpoint"`) | ❌ No | Model type: `"checkpoint"`, `"diffusion_model"` |
---
### Embedding Models
| Field | Type | Required | Auto-Updated | Description |
|-------|------|----------|--------------|-------------|
| `model_type` | string | ❌ No (default: `"embedding"`) | ❌ No | Model type: `"embedding"` |
---
## The `civitai` Field Structure
The `civitai` object stores the complete Civitai/CivArchive API response. Lora Manager preserves all fields from the API for future compatibility and extracts specific fields for use in the application.
### Version-Level Fields (Civitai API)
**Fields Used by Lora Manager:**
| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Version ID |
| `modelId` | integer | Parent model ID |
| `name` | string | Version name (e.g., `"v1.0"`, `"v2.0-pruned"`) |
| `nsfwLevel` | integer | NSFW level (bitmask: 1=PG, 2=PG13, 4=R, 8=X, 16=XXX, 32=Blocked) |
| `baseModel` | string | Base model (e.g., `"SDXL 1.0"`, `"Flux.1 D"`, `"Illustrious"`, `"Pony"`) |
| `trainedWords` | array[string] | **Trigger words** for the model |
| `type` | string | Model type (`"LoRA"`, `"Checkpoint"`, `"TextualInversion"`) |
| `earlyAccessEndsAt` | string\|null | Early access end date (used for update notifications) |
| `description` | string | Version description (HTML) |
| `model` | object | Parent model object (see Model-Level Fields below) |
| `creator` | object | Creator information (see Creator Fields below) |
| `files` | array[object] | File list with hashes, sizes, download URLs (used for metadata extraction) |
| `images` | array[object] | Image list with metadata, prompts, NSFW levels (used for preview/examples) |
**Fields Stored but Not Currently Used:**
| Field | Type | Description |
|-------|------|-------------|
| `createdAt` | string (ISO 8601) | Creation timestamp |
| `updatedAt` | string (ISO 8601) | Last update timestamp |
| `status` | string | Version status (e.g., `"Published"`, `"Draft"`) |
| `publishedAt` | string (ISO 8601) | Publication timestamp |
| `baseModelType` | string | Base model type (e.g., `"Standard"`, `"Inpaint"`, `"Refiner"`) |
| `earlyAccessConfig` | object | Early access configuration |
| `uploadType` | string | Upload type (`"Created"`, `"FineTuned"`, etc.) |
| `usageControl` | string | Usage control setting |
| `air` | string | Artifact ID (URN format: `urn:air:sdxl:lora:civitai:122359@135867`) |
| `stats` | object | Download count, ratings, thumbs up count |
| `videos` | array[object] | Video list |
| `downloadUrl` | string | Direct download URL |
| `trainingStatus` | string\|null | Training status (for on-site training) |
| `trainingDetails` | object\|null | Training configuration |
### Model-Level Fields (`civitai.model.*`)
**Fields Used by Lora Manager:**
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Model name |
| `type` | string | Model type (`"LoRA"`, `"Checkpoint"`, `"TextualInversion"`) |
| `description` | string | Model description (HTML, used for `modelDescription`) |
| `tags` | array[string] | Model tags (used for `tags` field) |
| `allowNoCredit` | boolean | License: allow use without credit |
| `allowCommercialUse` | array[string] | License: allowed commercial uses. **Values**: `"Image"` (sell generated images), `"Video"` (sell generated videos), `"RentCivit"` (rent on Civitai), `"Rent"` (rent elsewhere) |
| `allowDerivatives` | boolean | License: allow derivatives |
| `allowDifferentLicense` | boolean | License: allow different license |
**Fields Stored but Not Currently Used:**
| Field | Type | Description |
|-------|------|-------------|
| `nsfw` | boolean | Model NSFW flag |
| `poi` | boolean | Person of Interest flag |
### Creator Fields (`civitai.creator.*`)
Both fields are used by Lora Manager:
| Field | Type | Description |
|-------|------|-------------|
| `username` | string | Creator username (used for author display and search) |
| `image` | string | Creator avatar URL (used for display) |
### Model Type Field (Top-Level, Outside `civitai`)
| Field | Type | Values | Description |
|-------|------|--------|-------------|
| `model_type` | string | `"checkpoint"`, `"diffusion_model"`, `"embedding"` | Stored in metadata.json for Checkpoint and Embedding models. **Note**: LoRA models do not have this field; type is inferred from `civitai.type` or context. |
### User-Defined Fields (Within `civitai`)
For models not from Civitai or user-added data:
| Field | Type | Description |
|-------|------|-------------|
| `trainedWords` | array[string] | **Trigger words** — manually added by user |
| `customImages` | array[object] | Custom example images added by user |
### customImages Structure
Each custom image entry has the following structure:
```json
{
"url": "",
"id": "short_id",
"nsfwLevel": 0,
"width": 832,
"height": 1216,
"type": "image",
"meta": {
"prompt": "...",
"negativePrompt": "...",
"steps": 20,
"cfgScale": 7,
"seed": 123456
},
"hasMeta": true,
"hasPositivePrompt": true
}
```
| Field | Type | Description |
|-------|------|-------------|
| `url` | string | Empty for local custom images |
| `id` | string | Short ID or filename |
| `nsfwLevel` | integer | NSFW level (bitmask) |
| `width` | integer | Image width in pixels |
| `height` | integer | Image height in pixels |
| `type` | string | `"image"` or `"video"` |
| `meta` | object\|null | Generation metadata (prompt, seed, etc.) extracted from image |
| `hasMeta` | boolean | Whether metadata is available |
| `hasPositivePrompt` | boolean | Whether a positive prompt is available |
### Minimal Non-Civitai Example
```json
{
"civitai": {
"trainedWords": ["my_trigger_word"]
}
}
```
### Non-Civitai Example Without Trigger Words
```json
{
"civitai": {}
}
```
### Example: User-Added Custom Images
```json
{
"civitai": {
"trainedWords": ["custom_style"],
"customImages": [
{
"url": "",
"id": "example_1",
"nsfwLevel": 0,
"width": 832,
"height": 1216,
"type": "image",
"meta": {
"prompt": "example prompt",
"seed": 12345
},
"hasMeta": true,
"hasPositivePrompt": true
}
]
}
}
```
---
## Metadata Source Values
The `metadata_source` field indicates which provider last updated the metadata:
| Value | Source |
|-------|--------|
| `"civitai_api"` | Civitai API |
| `"civarchive"` | CivArchive API |
| `"archive_db"` | Metadata Archive Database |
| `null` | No external source (user-defined only) |
---
## Auto-Update Behavior
### Fields Updated During Scanning
These fields are automatically synchronized with the filesystem:
- `file_name` — Updated if actual filename differs
- `file_path` — Normalized and updated if path changes
- `preview_url` — Updated if preview file is moved/removed
- `sha256` — Updated during hash calculation (when `hash_status="pending"`)
- `hash_status` — Updated during hash calculation
- `last_checked_at` — Timestamp of scan
- `metadata_source` — Set based on metadata provider
### Fields Set Once (Immutable After Import)
These fields are set when the model is first imported/scanned and **never change** thereafter:
- `modified` — Import timestamp (used for "Date Added" sorting)
- `size` — File size at time of import/download
### User-Editable Fields
These fields can be edited by users at any time through the Lora Manager UI or by manually editing the metadata.json file:
- `model_name` — Display name
- `tags` — Model tags
- `modelDescription` — Model description
- `notes` — User notes
- `favorite` — Favorite flag
- `exclude` — Exclude from scanning (user can set `false``true`, currently no UI to revert)
- `skip_metadata_refresh` — Skip during bulk refresh
- `civitai.trainedWords` — Trigger words
- `civitai.customImages` — Custom example images
- `usage_tips` — Usage recommendations (LoRA only)
---
## Field Reference by Behavior
### Required Fields (Must Always Exist)
- `file_name`
- `model_name` (defaults to `file_name` if not provided)
- `file_path`
- `size`
- `modified`
- `sha256` (LoRA: always required; Checkpoint: may be empty when `hash_status="pending"`)
### Optional Fields with Defaults
| Field | Default |
|-------|---------|
| `base_model` | `"Unknown"` or `""` |
| `preview_nsfw_level` | `0` |
| `from_civitai` | `true` |
| `civitai` | `{}` |
| `tags` | `[]` |
| `modelDescription` | `""` |
| `notes` | `""` |
| `civitai_deleted` | `false` |
| `favorite` | `false` |
| `exclude` | `false` |
| `db_checked` | `false` |
| `skip_metadata_refresh` | `false` |
| `metadata_source` | `null` |
| `last_checked_at` | `0` |
| `hash_status` | `"completed"` |
| `usage_tips` | `"{}"` (LoRA only) |
| `model_type` | `"checkpoint"` or `"embedding"` (not present in LoRA models) |
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2026-03 | Initial schema documentation |
---
## See Also
- [JSON Schema Definition](../.specs/metadata.schema.json) — Formal JSON Schema for validation

View File

@@ -14,7 +14,8 @@
"backToTop": "Nach oben",
"settings": "Einstellungen",
"help": "Hilfe",
"add": "Hinzufügen"
"add": "Hinzufügen",
"close": "Schließen"
},
"status": {
"loading": "Wird geladen...",
@@ -729,6 +730,64 @@
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1497,14 @@
"recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}",
"importFailed": "Import fehlgeschlagen: {message}",
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
"folderTreeError": "Fehler beim Laden des Ordnerbaums"
"folderTreeError": "Fehler beim Laden des Ordnerbaums",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "Keine Modelle ausgewählt",

View File

@@ -14,7 +14,8 @@
"backToTop": "Back to top",
"settings": "Settings",
"help": "Help",
"add": "Add"
"add": "Add",
"close": "Close"
},
"status": {
"loading": "Loading...",
@@ -729,6 +730,64 @@
"failed": "Failed to repair recipe: {message}",
"missingId": "Cannot repair recipe: Missing recipe ID"
}
},
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1497,14 @@
"recipeSaveFailed": "Failed to save recipe: {error}",
"importFailed": "Import failed: {message}",
"folderTreeFailed": "Failed to load folder tree",
"folderTreeError": "Error loading folder tree"
"folderTreeError": "Error loading folder tree",
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}"
},
"models": {
"noModelsSelected": "No models selected",

View File

@@ -14,7 +14,8 @@
"backToTop": "Volver arriba",
"settings": "Configuración",
"help": "Ayuda",
"add": "Añadir"
"add": "Añadir",
"close": "Cerrar"
},
"status": {
"loading": "Cargando...",
@@ -729,6 +730,64 @@
"failed": "Error al reparar la receta: {message}",
"missingId": "No se puede reparar la receta: falta el ID de la receta"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1497,14 @@
"recipeSaveFailed": "Error al guardar receta: {error}",
"importFailed": "Importación falló: {message}",
"folderTreeFailed": "Error al cargar árbol de carpetas",
"folderTreeError": "Error cargando árbol de carpetas"
"folderTreeError": "Error cargando árbol de carpetas",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "No hay modelos seleccionados",

View File

@@ -14,7 +14,8 @@
"backToTop": "Retour en haut",
"settings": "Paramètres",
"help": "Aide",
"add": "Ajouter"
"add": "Ajouter",
"close": "Fermer"
},
"status": {
"loading": "Chargement...",
@@ -729,6 +730,64 @@
"failed": "Échec de la réparation de la recette : {message}",
"missingId": "Impossible de réparer la recette : ID de recette manquant"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1497,14 @@
"recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}",
"importFailed": "Échec de l'importation : {message}",
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
"folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers"
"folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "Aucun modèle sélectionné",

View File

@@ -14,7 +14,8 @@
"backToTop": "חזרה למעלה",
"settings": "הגדרות",
"help": "עזרה",
"add": "הוספה"
"add": "הוספה",
"close": "סגור"
},
"status": {
"loading": "טוען...",
@@ -729,6 +730,64 @@
"failed": "תיקון המתכון נכשל: {message}",
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1497,14 @@
"recipeSaveFailed": "שמירת המתכון נכשלה: {error}",
"importFailed": "הייבוא נכשל: {message}",
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
"folderTreeError": "שגיאה בטעינת עץ התיקיות"
"folderTreeError": "שגיאה בטעינת עץ התיקיות",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "לא נבחרו מודלים",

View File

@@ -14,7 +14,8 @@
"backToTop": "トップへ戻る",
"settings": "設定",
"help": "ヘルプ",
"add": "追加"
"add": "追加",
"close": "閉じる"
},
"status": {
"loading": "読み込み中...",
@@ -729,6 +730,64 @@
"failed": "レシピの修復に失敗しました: {message}",
"missingId": "レシピを修復できません: レシピIDがありません"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1497,14 @@
"recipeSaveFailed": "レシピの保存に失敗しました:{error}",
"importFailed": "インポートに失敗しました:{message}",
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
"folderTreeError": "フォルダツリー読み込みエラー"
"folderTreeError": "フォルダツリー読み込みエラー",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "モデルが選択されていません",

View File

@@ -14,7 +14,8 @@
"backToTop": "맨 위로",
"settings": "설정",
"help": "도움말",
"add": "추가"
"add": "추가",
"close": "닫기"
},
"status": {
"loading": "로딩 중...",
@@ -729,6 +730,64 @@
"failed": "레시피 복구 실패: {message}",
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1497,14 @@
"recipeSaveFailed": "레시피 저장 실패: {error}",
"importFailed": "가져오기 실패: {message}",
"folderTreeFailed": "폴더 트리 로딩 실패",
"folderTreeError": "폴더 트리 로딩 오류"
"folderTreeError": "폴더 트리 로딩 오류",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "선택된 모델이 없습니다",

View File

@@ -14,7 +14,8 @@
"backToTop": "Наверх",
"settings": "Настройки",
"help": "Справка",
"add": "Добавить"
"add": "Добавить",
"close": "Закрыть"
},
"status": {
"loading": "Загрузка...",
@@ -729,6 +730,64 @@
"failed": "Не удалось восстановить рецепт: {message}",
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1497,14 @@
"recipeSaveFailed": "Не удалось сохранить рецепт: {error}",
"importFailed": "Импорт не удался: {message}",
"folderTreeFailed": "Не удалось загрузить дерево папок",
"folderTreeError": "Ошибка загрузки дерева папок"
"folderTreeError": "Ошибка загрузки дерева папок",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "Модели не выбраны",

View File

@@ -14,7 +14,8 @@
"backToTop": "返回顶部",
"settings": "设置",
"help": "帮助",
"add": "添加"
"add": "添加",
"close": "关闭"
},
"status": {
"loading": "加载中...",
@@ -729,6 +730,64 @@
"failed": "修复配方失败:{message}",
"missingId": "无法修复配方:缺少配方 ID"
}
},
"batchImport": {
"title": "批量导入配方",
"action": "批量导入",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "输入目录路径以导入该文件夹中的所有图片。",
"urlsLabel": "图片 URL 或本地路径",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "/图片/文件夹/路径",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "标签(可选,应用于所有配方)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "跳过无元数据的图片",
"skipNoMetadataHelp": "没有 LoRA 元数据的图片将自动跳过。",
"start": "[TODO: Translate] Start Import",
"startImport": "开始导入",
"importing": "正在导入配方...",
"progress": "进度",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "当前",
"preparing": "准备中...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "取消",
"cancelled": "批量导入已取消",
"completed": "导入完成",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "成功导入 {count} 个配方",
"successCount": "成功",
"failedCount": "失败",
"skippedCount": "跳过",
"totalProcessed": "总计处理",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "请至少输入一个 URL 或路径",
"enterDirectory": "请输入目录路径",
"startFailed": "启动导入失败:{message}"
}
}
},
"checkpoints": {
@@ -764,7 +823,7 @@
"emptyFolderName": "请输入文件夹名称",
"invalidFolderName": "文件夹名称包含无效字符",
"noDragState": "未找到待处理的拖放操作"
},
},
"empty": {
"noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹"
@@ -1438,7 +1497,14 @@
"recipeSaveFailed": "保存配方失败:{error}",
"importFailed": "导入失败:{message}",
"folderTreeFailed": "加载文件夹树失败",
"folderTreeError": "加载文件夹树出错"
"folderTreeError": "加载文件夹树出错",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "未选中模型",

View File

@@ -14,7 +14,8 @@
"backToTop": "回到頂部",
"settings": "設定",
"help": "說明",
"add": "新增"
"add": "新增",
"close": "關閉"
},
"status": {
"loading": "載入中...",
@@ -729,6 +730,64 @@
"failed": "修復配方失敗:{message}",
"missingId": "無法修復配方:缺少配方 ID"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1497,14 @@
"recipeSaveFailed": "儲存配方失敗:{error}",
"importFailed": "匯入失敗:{message}",
"folderTreeFailed": "載入資料夾樹狀結構失敗",
"folderTreeError": "載入資料夾樹狀結構錯誤"
"folderTreeError": "載入資料夾樹狀結構錯誤",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "未選擇模型",

3
package-lock.json generated
View File

@@ -114,7 +114,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -138,7 +137,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1613,7 +1611,6 @@
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.0.1",
"data-urls": "^5.0.0",

View File

@@ -707,7 +707,13 @@ class Config:
def _prepare_checkpoint_paths(
self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str]
) -> List[str]:
) -> Tuple[List[str], List[str], List[str]]:
"""Prepare checkpoint paths and return (all_roots, checkpoint_roots, unet_roots).
Returns:
Tuple of (all_unique_paths, checkpoint_only_paths, unet_only_paths)
This method does NOT modify instance variables - callers must set them.
"""
checkpoint_map = self._dedupe_existing_paths(checkpoint_paths)
unet_map = self._dedupe_existing_paths(unet_paths)
@@ -737,8 +743,8 @@ class Config:
checkpoint_values = set(checkpoint_map.values())
unet_values = set(unet_map.values())
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_values]
self.unet_roots = [p for p in unique_paths if p in unet_values]
checkpoint_roots = [p for p in unique_paths if p in checkpoint_values]
unet_roots = [p for p in unique_paths if p in unet_values]
for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(
@@ -747,7 +753,7 @@ class Config:
if real_path != original_path:
self.add_path_mapping(original_path, real_path)
return unique_paths
return unique_paths, checkpoint_roots, unet_roots
def _prepare_embedding_paths(self, raw_paths: Iterable[str]) -> List[str]:
path_map = self._dedupe_existing_paths(raw_paths)
@@ -776,9 +782,11 @@ class Config:
embedding_paths = folder_paths.get("embeddings", []) or []
self.loras_roots = self._prepare_lora_paths(lora_paths)
self.base_models_roots = self._prepare_checkpoint_paths(
checkpoint_paths, unet_paths
)
(
self.base_models_roots,
self.checkpoints_roots,
self.unet_roots,
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
@@ -789,18 +797,11 @@ class Config:
extra_embedding_paths = extra_paths.get("embeddings", []) or []
self.extra_loras_roots = self._prepare_lora_paths(extra_lora_paths)
# Save main paths before processing extra paths ( _prepare_checkpoint_paths overwrites them)
saved_checkpoints_roots = self.checkpoints_roots
saved_unet_roots = self.unet_roots
self.extra_checkpoints_roots = self._prepare_checkpoint_paths(
extra_checkpoint_paths, extra_unet_paths
)
self.extra_unet_roots = (
self.unet_roots if self.unet_roots is not None else []
) # unet_roots was set by _prepare_checkpoint_paths
# Restore main paths
self.checkpoints_roots = saved_checkpoints_roots
self.unet_roots = saved_unet_roots
(
_,
self.extra_checkpoints_roots,
self.extra_unet_roots,
) = self._prepare_checkpoint_paths(extra_checkpoint_paths, extra_unet_paths)
self.extra_embeddings_roots = self._prepare_embedding_paths(
extra_embedding_paths
)
@@ -857,9 +858,11 @@ class Config:
try:
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
raw_unet_paths = folder_paths.get_folder_paths("unet")
unique_paths = self._prepare_checkpoint_paths(
raw_checkpoint_paths, raw_unet_paths
)
(
unique_paths,
self.checkpoints_roots,
self.unet_roots,
) = self._prepare_checkpoint_paths(raw_checkpoint_paths, raw_unet_paths)
logger.info(
"Found checkpoint roots:"

View File

@@ -0,0 +1,118 @@
import logging
from typing import List, Tuple
import comfy.sd # type: ignore
import folder_paths # type: ignore
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
class CheckpointLoaderLM:
"""Checkpoint Loader with support for extra folder paths
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
extra folder paths, providing a unified interface for checkpoint loading.
"""
NAME = "Checkpoint Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(s):
# Get list of checkpoint names from scanner (includes extra folder paths)
checkpoint_names = s._get_checkpoint_names()
return {
"required": {
"ckpt_name": (
checkpoint_names,
{"tooltip": "The name of the checkpoint (model) to load."},
),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "VAE")
RETURN_NAMES = ("MODEL", "CLIP", "VAE")
OUTPUT_TOOLTIPS = (
"The model used for denoising latents.",
"The CLIP model used for encoding text prompts.",
"The VAE model used for encoding and decoding images to and from latent space.",
)
FUNCTION = "load_checkpoint"
@classmethod
def _get_checkpoint_names(cls) -> List[str]:
"""Get list of checkpoint names from scanner cache in ComfyUI format (relative path with extension)"""
try:
from ..services.service_registry import ServiceRegistry
import asyncio
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only checkpoint type (not diffusion_model) and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") == "checkpoint":
file_path = item.get("file_path", "")
if file_path:
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
try:
loop = asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(_get_names())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(_get_names())
except Exception as e:
logger.error(f"Error getting checkpoint names: {e}")
return []
def load_checkpoint(self, ckpt_name: str) -> Tuple:
"""Load a checkpoint by name, supporting extra folder paths
Args:
ckpt_name: The name of the checkpoint to load (relative path with extension)
Returns:
Tuple of (MODEL, CLIP, VAE)
"""
# Get absolute path from cache using ComfyUI-style name
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
if metadata is None:
raise FileNotFoundError(
f"Checkpoint '{ckpt_name}' not found in LoRA Manager cache. "
"Make sure the checkpoint is indexed and try again."
)
# Load regular checkpoint using ComfyUI's API
logger.info(f"Loading checkpoint from: {ckpt_path}")
out = comfy.sd.load_checkpoint_guess_config(
ckpt_path,
output_vae=True,
output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)
return out[:3]

View File

@@ -0,0 +1,161 @@
"""
Helper module to safely import ComfyUI-GGUF modules.
This module provides a robust way to import ComfyUI-GGUF functionality
regardless of how ComfyUI loaded it.
"""
import sys
import os
import importlib.util
import logging
from typing import Optional, Tuple, Any
logger = logging.getLogger(__name__)
def _get_gguf_path() -> str:
"""Get the path to ComfyUI-GGUF based on this file's location.
Since ComfyUI-Lora-Manager and ComfyUI-GGUF are both in custom_nodes/,
we can derive the GGUF path from our own location.
"""
# This file is at: custom_nodes/ComfyUI-Lora-Manager/py/nodes/gguf_import_helper.py
# ComfyUI-GGUF is at: custom_nodes/ComfyUI-GGUF
current_file = os.path.abspath(__file__)
# Go up 4 levels: nodes -> py -> ComfyUI-Lora-Manager -> custom_nodes
custom_nodes_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
)
return os.path.join(custom_nodes_dir, "ComfyUI-GGUF")
def _find_gguf_module() -> Optional[Any]:
"""Find ComfyUI-GGUF module in sys.modules.
ComfyUI registers modules using the full path with dots replaced by _x_.
"""
gguf_path = _get_gguf_path()
sys_module_name = gguf_path.replace(".", "_x_")
logger.debug(f"[GGUF Import] Looking for module '{sys_module_name}' in sys.modules")
if sys_module_name in sys.modules:
logger.info(f"[GGUF Import] Found module: '{sys_module_name}'")
return sys.modules[sys_module_name]
logger.debug(f"[GGUF Import] Module not found: '{sys_module_name}'")
return None
def _load_gguf_modules_directly() -> Optional[Any]:
"""Load ComfyUI-GGUF modules directly from file paths."""
gguf_path = _get_gguf_path()
logger.info(f"[GGUF Import] Direct Load: Attempting to load from '{gguf_path}'")
if not os.path.exists(gguf_path):
logger.warning(f"[GGUF Import] Path does not exist: {gguf_path}")
return None
try:
namespace = "ComfyUI_GGUF_Dynamic"
init_path = os.path.join(gguf_path, "__init__.py")
if not os.path.exists(init_path):
logger.warning(f"[GGUF Import] __init__.py not found at '{init_path}'")
return None
logger.debug(f"[GGUF Import] Loading from '{init_path}'")
spec = importlib.util.spec_from_file_location(namespace, init_path)
if not spec or not spec.loader:
logger.error(f"[GGUF Import] Failed to create spec for '{init_path}'")
return None
package = importlib.util.module_from_spec(spec)
package.__path__ = [gguf_path]
sys.modules[namespace] = package
spec.loader.exec_module(package)
logger.debug(f"[GGUF Import] Loaded main package '{namespace}'")
# Load submodules
loaded = []
for submod_name in ["loader", "ops", "nodes"]:
submod_path = os.path.join(gguf_path, f"{submod_name}.py")
if os.path.exists(submod_path):
submod_spec = importlib.util.spec_from_file_location(
f"{namespace}.{submod_name}", submod_path
)
if submod_spec and submod_spec.loader:
submod = importlib.util.module_from_spec(submod_spec)
submod.__package__ = namespace
sys.modules[f"{namespace}.{submod_name}"] = submod
submod_spec.loader.exec_module(submod)
setattr(package, submod_name, submod)
loaded.append(submod_name)
logger.debug(f"[GGUF Import] Loaded submodule '{submod_name}'")
logger.info(f"[GGUF Import] Direct Load success: {loaded}")
return package
except Exception as e:
logger.error(f"[GGUF Import] Direct Load failed: {e}", exc_info=True)
return None
def get_gguf_modules() -> Tuple[Any, Any, Any]:
"""Get ComfyUI-GGUF modules (loader, ops, nodes).
Returns:
Tuple of (loader_module, ops_module, nodes_module)
Raises:
RuntimeError: If ComfyUI-GGUF cannot be found or loaded.
"""
logger.debug("[GGUF Import] Starting module search...")
# Try to find already loaded module first
gguf_module = _find_gguf_module()
if gguf_module is None:
logger.info("[GGUF Import] Not found in sys.modules, trying direct load...")
gguf_module = _load_gguf_modules_directly()
if gguf_module is None:
raise RuntimeError(
"ComfyUI-GGUF is not installed. "
"Please install from https://github.com/city96/ComfyUI-GGUF"
)
# Extract submodules
loader = getattr(gguf_module, "loader", None)
ops = getattr(gguf_module, "ops", None)
nodes = getattr(gguf_module, "nodes", None)
if loader is None or ops is None or nodes is None:
missing = [
name
for name, mod in [("loader", loader), ("ops", ops), ("nodes", nodes)]
if mod is None
]
raise RuntimeError(f"ComfyUI-GGUF missing submodules: {missing}")
logger.debug("[GGUF Import] All modules loaded successfully")
return loader, ops, nodes
def get_gguf_sd_loader():
"""Get the gguf_sd_loader function from ComfyUI-GGUF."""
loader, _, _ = get_gguf_modules()
return getattr(loader, "gguf_sd_loader")
def get_ggml_ops():
"""Get the GGMLOps class from ComfyUI-GGUF."""
_, ops, _ = get_gguf_modules()
return getattr(ops, "GGMLOps")
def get_gguf_model_patcher():
"""Get the GGUFModelPatcher class from ComfyUI-GGUF."""
_, _, nodes = get_gguf_modules()
return getattr(nodes, "GGUFModelPatcher")

View File

@@ -56,6 +56,9 @@ class LoraCyclerLM:
clip_strength = float(cycler_config.get("clip_strength", 1.0))
sort_by = "filename"
# Include "no lora" option
include_no_lora = cycler_config.get("include_no_lora", False)
# Dual-index mechanism for batch queue synchronization
execution_index = cycler_config.get("execution_index") # Can be None
# next_index_from_config = cycler_config.get("next_index") # Not used on backend
@@ -71,7 +74,10 @@ class LoraCyclerLM:
total_count = len(lora_list)
if total_count == 0:
# Calculate effective total count (includes no lora option if enabled)
effective_total_count = total_count + 1 if include_no_lora else total_count
if total_count == 0 and not include_no_lora:
logger.warning("[LoraCyclerLM] No LoRAs available in pool")
return {
"result": ([],),
@@ -93,42 +99,66 @@ class LoraCyclerLM:
else:
actual_index = current_index
# Clamp index to valid range (1-based)
clamped_index = max(1, min(actual_index, total_count))
# Clamp index to valid range (1-based, includes no lora if enabled)
clamped_index = max(1, min(actual_index, effective_total_count))
# Get LoRA at current index (convert to 0-based for list access)
current_lora = lora_list[clamped_index - 1]
# Check if current index is the "no lora" option (last position when include_no_lora is True)
is_no_lora = include_no_lora and clamped_index == effective_total_count
# Build LORA_STACK with single LoRA
lora_path, _ = get_lora_info(current_lora["file_name"])
if not lora_path:
logger.warning(
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
)
if is_no_lora:
# "No LoRA" option - return empty stack
lora_stack = []
current_lora_name = "No LoRA"
current_lora_filename = "No LoRA"
else:
# Normalize path separators
lora_path = lora_path.replace("/", os.sep)
lora_stack = [(lora_path, model_strength, clip_strength)]
# Get LoRA at current index (convert to 0-based for list access)
current_lora = lora_list[clamped_index - 1]
current_lora_name = current_lora["file_name"]
current_lora_filename = current_lora["file_name"]
# Build LORA_STACK with single LoRA
if current_lora["file_name"] == "None":
lora_path = None
else:
lora_path, _ = get_lora_info(current_lora["file_name"])
if not lora_path:
if current_lora["file_name"] != "None":
logger.warning(
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
)
lora_stack = []
else:
# Normalize path separators
lora_path = lora_path.replace("/", os.sep)
lora_stack = [(lora_path, model_strength, clip_strength)]
# Calculate next index (wrap to 1 if at end)
next_index = clamped_index + 1
if next_index > total_count:
if next_index > effective_total_count:
next_index = 1
# Get next LoRA for UI display (what will be used next generation)
next_lora = lora_list[next_index - 1]
next_display_name = next_lora["file_name"]
is_next_no_lora = include_no_lora and next_index == effective_total_count
if is_next_no_lora:
next_display_name = "No LoRA"
next_lora_filename = "No LoRA"
else:
next_lora = lora_list[next_index - 1]
next_display_name = next_lora["file_name"]
next_lora_filename = next_lora["file_name"]
return {
"result": (lora_stack,),
"ui": {
"current_index": [clamped_index],
"next_index": [next_index],
"total_count": [total_count],
"current_lora_name": [current_lora["file_name"]],
"current_lora_filename": [current_lora["file_name"]],
"total_count": [
total_count
], # Return actual LoRA count, not effective_total_count
"current_lora_name": [current_lora_name],
"current_lora_filename": [current_lora_filename],
"next_lora_name": [next_display_name],
"next_lora_filename": [next_lora["file_name"]],
"next_lora_filename": [next_lora_filename],
},
}

View File

@@ -82,6 +82,7 @@ class LoraPoolLM:
"folders": {"include": [], "exclude": []},
"favoritesOnly": False,
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": [], "exclude": [], "useRegex": False},
},
"preview": {"matchCount": 0, "lastUpdated": 0},
}

View File

@@ -7,10 +7,8 @@ and tracks the last used combination for reuse.
"""
import logging
import random
import os
from ..utils.utils import get_lora_info
from .utils import extract_lora_name
logger = logging.getLogger(__name__)

205
py/nodes/unet_loader.py Normal file
View File

@@ -0,0 +1,205 @@
import logging
import os
from typing import List, Tuple
import comfy.sd # type: ignore
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
class UNETLoaderLM:
"""UNET Loader with support for extra folder paths
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
extra folder paths, providing a unified interface for UNET loading.
Supports both regular diffusion models and GGUF format models.
"""
NAME = "Unet Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(s):
# Get list of unet names from scanner (includes extra folder paths)
unet_names = s._get_unet_names()
return {
"required": {
"unet_name": (
unet_names,
{"tooltip": "The name of the diffusion model to load."},
),
"weight_dtype": (
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
{"tooltip": "The dtype to use for the model weights."},
),
}
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("MODEL",)
OUTPUT_TOOLTIPS = ("The model used for denoising latents.",)
FUNCTION = "load_unet"
@classmethod
def _get_unet_names(cls) -> List[str]:
"""Get list of diffusion model names from scanner cache in ComfyUI format (relative path with extension)"""
try:
from ..services.service_registry import ServiceRegistry
import asyncio
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only diffusion_model type and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") == "diffusion_model":
file_path = item.get("file_path", "")
if file_path:
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
try:
loop = asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(_get_names())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(_get_names())
except Exception as e:
logger.error(f"Error getting unet names: {e}")
return []
def load_unet(self, unet_name: str, weight_dtype: str) -> Tuple:
"""Load a diffusion model by name, supporting extra folder paths
Args:
unet_name: The name of the diffusion model to load (relative path with extension)
weight_dtype: The dtype to use for model weights
Returns:
Tuple of (MODEL,)
"""
import torch
# Get absolute path from cache using ComfyUI-style name
unet_path, metadata = get_checkpoint_info_absolute(unet_name)
if metadata is None:
raise FileNotFoundError(
f"Diffusion model '{unet_name}' not found in LoRA Manager cache. "
"Make sure the model is indexed and try again."
)
# Check if it's a GGUF model
if unet_path.endswith(".gguf"):
return self._load_gguf_unet(unet_path, unet_name, weight_dtype)
# Load regular diffusion model using ComfyUI's API
logger.info(f"Loading diffusion model from: {unet_path}")
# Build model options based on weight_dtype
model_options = {}
if weight_dtype == "fp8_e4m3fn":
model_options["dtype"] = torch.float8_e4m3fn
elif weight_dtype == "fp8_e4m3fn_fast":
model_options["dtype"] = torch.float8_e4m3fn
model_options["fp8_optimizations"] = True
elif weight_dtype == "fp8_e5m2":
model_options["dtype"] = torch.float8_e5m2
model = comfy.sd.load_diffusion_model(unet_path, model_options=model_options)
return (model,)
def _load_gguf_unet(
self, unet_path: str, unet_name: str, weight_dtype: str
) -> Tuple:
"""Load a GGUF format diffusion model
Args:
unet_path: Absolute path to the GGUF file
unet_name: Name of the model for error messages
weight_dtype: The dtype to use for model weights
Returns:
Tuple of (MODEL,)
"""
import torch
from .gguf_import_helper import get_gguf_modules
# Get ComfyUI-GGUF modules using helper (handles various import scenarios)
try:
loader_module, ops_module, nodes_module = get_gguf_modules()
gguf_sd_loader = getattr(loader_module, "gguf_sd_loader")
GGMLOps = getattr(ops_module, "GGMLOps")
GGUFModelPatcher = getattr(nodes_module, "GGUFModelPatcher")
except RuntimeError as e:
raise RuntimeError(f"Cannot load GGUF model '{unet_name}'. {str(e)}")
logger.info(f"Loading GGUF diffusion model from: {unet_path}")
try:
# Load GGUF state dict
sd, extra = gguf_sd_loader(unet_path)
# Prepare kwargs for metadata if supported
kwargs = {}
import inspect
valid_params = inspect.signature(
comfy.sd.load_diffusion_model_state_dict
).parameters
if "metadata" in valid_params:
kwargs["metadata"] = extra.get("metadata", {})
# Setup custom operations with GGUF support
ops = GGMLOps()
# Handle weight_dtype for GGUF models
if weight_dtype in ("default", None):
ops.Linear.dequant_dtype = None
elif weight_dtype in ["target"]:
ops.Linear.dequant_dtype = weight_dtype
else:
ops.Linear.dequant_dtype = getattr(torch, weight_dtype, None)
# Load the model
model = comfy.sd.load_diffusion_model_state_dict(
sd, model_options={"custom_operations": ops}, **kwargs
)
if model is None:
raise RuntimeError(
f"Could not detect model type for GGUF diffusion model: {unet_path}"
)
# Wrap with GGUFModelPatcher
model = GGUFModelPatcher.clone(model)
return (model,)
except Exception as e:
logger.error(f"Error loading GGUF diffusion model '{unet_name}': {e}")
raise RuntimeError(
f"Failed to load GGUF diffusion model '{unet_name}': {str(e)}"
)

View File

@@ -1,4 +1,5 @@
"""Base infrastructure shared across recipe routes."""
from __future__ import annotations
import logging
@@ -16,12 +17,14 @@ from ..services.recipes import (
RecipePersistenceService,
RecipeSharingService,
)
from ..services.batch_import_service import BatchImportService
from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry
from ..services.settings_manager import get_settings_manager
from ..utils.constants import CARD_PREVIEW_WIDTH
from ..utils.exif_utils import ExifUtils
from .handlers.recipe_handlers import (
BatchImportHandler,
RecipeAnalysisHandler,
RecipeHandlerSet,
RecipeListingHandler,
@@ -116,7 +119,10 @@ class BaseRecipeRoutes:
recipe_scanner_getter = lambda: self.recipe_scanner
civitai_client_getter = lambda: self.civitai_client
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"
)
if not standalone_mode:
from ..metadata_collector import get_metadata # type: ignore[import-not-found]
from ..metadata_collector.metadata_processor import ( # type: ignore[import-not-found]
@@ -190,6 +196,22 @@ class BaseRecipeRoutes:
sharing_service=sharing_service,
)
from ..services.websocket_manager import ws_manager
batch_import_service = BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
batch_import = BatchImportHandler(
ensure_dependencies_ready=self.ensure_dependencies_ready,
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
logger=logger,
batch_import_service=batch_import_service,
)
return RecipeHandlerSet(
page_view=page_view,
listing=listing,
@@ -197,4 +219,5 @@ class BaseRecipeRoutes:
management=management,
analysis=analysis,
sharing=sharing,
batch_import=batch_import,
)

View File

@@ -309,6 +309,13 @@ class ModelListingHandler:
else:
allow_selling_generated_content = None # None means no filter applied
# Name pattern filters for LoRA Pool
name_pattern_include = request.query.getall("name_pattern_include", [])
name_pattern_exclude = request.query.getall("name_pattern_exclude", [])
name_pattern_use_regex = (
request.query.get("name_pattern_use_regex", "false").lower() == "true"
)
return {
"page": page,
"page_size": page_size,
@@ -328,6 +335,9 @@ class ModelListingHandler:
"credit_required": credit_required,
"allow_selling_generated_content": allow_selling_generated_content,
"model_types": model_types,
"name_pattern_include": name_pattern_include,
"name_pattern_exclude": name_pattern_exclude,
"name_pattern_use_regex": name_pattern_use_regex,
**self._parse_specific_params(request),
}

View File

@@ -1,4 +1,5 @@
"""Dedicated handler objects for recipe-related routes."""
from __future__ import annotations
import json
@@ -8,6 +9,7 @@ import re
import asyncio
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
from aiohttp import web
@@ -29,6 +31,7 @@ from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher
from ...services.websocket_manager import ws_manager as default_ws_manager
from ...services.batch_import_service import BatchImportService
Logger = logging.Logger
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
@@ -46,8 +49,11 @@ class RecipeHandlerSet:
management: "RecipeManagementHandler"
analysis: "RecipeAnalysisHandler"
sharing: "RecipeSharingHandler"
batch_import: "BatchImportHandler"
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
def to_route_mapping(
self,
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
"""Expose handler coroutines keyed by registrar handler names."""
return {
@@ -81,6 +87,11 @@ class RecipeHandlerSet:
"cancel_repair": self.management.cancel_repair,
"repair_recipe": self.management.repair_recipe,
"get_repair_progress": self.management.get_repair_progress,
"start_batch_import": self.batch_import.start_batch_import,
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
"cancel_batch_import": self.batch_import.cancel_batch_import,
"start_directory_import": self.batch_import.start_directory_import,
"browse_directory": self.batch_import.browse_directory,
}
@@ -170,8 +181,10 @@ class RecipeListingHandler:
search_options = {
"title": request.query.get("search_title", "true").lower() == "true",
"tags": request.query.get("search_tags", "true").lower() == "true",
"lora_name": request.query.get("search_lora_name", "true").lower() == "true",
"lora_model": request.query.get("search_lora_model", "true").lower() == "true",
"lora_name": request.query.get("search_lora_name", "true").lower()
== "true",
"lora_model": request.query.get("search_lora_model", "true").lower()
== "true",
"prompt": request.query.get("search_prompt", "true").lower() == "true",
}
@@ -246,7 +259,9 @@ class RecipeListingHandler:
return web.json_response({"error": "Recipe not found"}, status=404)
return web.json_response(recipe)
except Exception as exc:
self._logger.error("Error retrieving recipe details: %s", exc, exc_info=True)
self._logger.error(
"Error retrieving recipe details: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
def format_recipe_file_url(self, file_path: str) -> str:
@@ -256,7 +271,9 @@ class RecipeListingHandler:
if static_url:
return static_url
except Exception as exc: # pragma: no cover - logging path
self._logger.error("Error formatting recipe file URL: %s", exc, exc_info=True)
self._logger.error(
"Error formatting recipe file URL: %s", exc, exc_info=True
)
return "/loras_static/images/no-preview.png"
return "/loras_static/images/no-preview.png"
@@ -293,7 +310,9 @@ class RecipeQueryHandler:
for tag in recipe.get("tags", []) or []:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
sorted_tags = [{"tag": tag, "count": count} for tag, count in tag_counts.items()]
sorted_tags = [
{"tag": tag, "count": count} for tag, count in tag_counts.items()
]
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response({"success": True, "tags": sorted_tags[:limit]})
except Exception as exc:
@@ -313,9 +332,14 @@ class RecipeQueryHandler:
for recipe in getattr(cache, "raw_data", []):
base_model = recipe.get("base_model")
if base_model:
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
base_model_counts[base_model] = (
base_model_counts.get(base_model, 0) + 1
)
sorted_models = [{"name": model, "count": count} for model, count in base_model_counts.items()]
sorted_models = [
{"name": model, "count": count}
for model, count in base_model_counts.items()
]
sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response({"success": True, "base_models": sorted_models})
except Exception as exc:
@@ -345,7 +369,9 @@ class RecipeQueryHandler:
folders = await recipe_scanner.get_folders()
return web.json_response({"success": True, "folders": folders})
except Exception as exc:
self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True)
self._logger.error(
"Error retrieving recipe folders: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_folder_tree(self, request: web.Request) -> web.Response:
@@ -358,7 +384,9 @@ class RecipeQueryHandler:
folder_tree = await recipe_scanner.get_folder_tree()
return web.json_response({"success": True, "tree": folder_tree})
except Exception as exc:
self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True)
self._logger.error(
"Error retrieving recipe folder tree: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
@@ -371,7 +399,9 @@ class RecipeQueryHandler:
folder_tree = await recipe_scanner.get_folder_tree()
return web.json_response({"success": True, "tree": folder_tree})
except Exception as exc:
self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True)
self._logger.error(
"Error retrieving unified recipe folder tree: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
@@ -383,7 +413,9 @@ class RecipeQueryHandler:
lora_hash = request.query.get("hash")
if not lora_hash:
return web.json_response({"success": False, "error": "Lora hash is required"}, status=400)
return web.json_response(
{"success": False, "error": "Lora hash is required"}, status=400
)
matching_recipes = await recipe_scanner.get_recipes_for_lora(lora_hash)
return web.json_response({"success": True, "recipes": matching_recipes})
@@ -400,7 +432,9 @@ class RecipeQueryHandler:
self._logger.info("Manually triggering recipe cache rebuild")
await recipe_scanner.get_cached_data(force_refresh=True)
return web.json_response({"success": True, "message": "Recipe cache refreshed successfully"})
return web.json_response(
{"success": True, "message": "Recipe cache refreshed successfully"}
)
except Exception as exc:
self._logger.error("Error refreshing recipe cache: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -429,7 +463,9 @@ class RecipeQueryHandler:
"id": recipe.get("id"),
"title": recipe.get("title"),
"file_url": recipe.get("file_url")
or self._format_recipe_file_url(recipe.get("file_path", "")),
or self._format_recipe_file_url(
recipe.get("file_path", "")
),
"modified": recipe.get("modified"),
"created_date": recipe.get("created_date"),
"lora_count": len(recipe.get("loras", [])),
@@ -437,7 +473,9 @@ class RecipeQueryHandler:
)
if len(recipes) >= 2:
recipes.sort(key=lambda entry: entry.get("modified", 0), reverse=True)
recipes.sort(
key=lambda entry: entry.get("modified", 0), reverse=True
)
response_data.append(
{
"type": "fingerprint",
@@ -460,7 +498,9 @@ class RecipeQueryHandler:
"id": recipe.get("id"),
"title": recipe.get("title"),
"file_url": recipe.get("file_url")
or self._format_recipe_file_url(recipe.get("file_path", "")),
or self._format_recipe_file_url(
recipe.get("file_path", "")
),
"modified": recipe.get("modified"),
"created_date": recipe.get("created_date"),
"lora_count": len(recipe.get("loras", [])),
@@ -468,7 +508,9 @@ class RecipeQueryHandler:
)
if len(recipes) >= 2:
recipes.sort(key=lambda entry: entry.get("modified", 0), reverse=True)
recipes.sort(
key=lambda entry: entry.get("modified", 0), reverse=True
)
response_data.append(
{
"type": "source_url",
@@ -479,9 +521,13 @@ class RecipeQueryHandler:
)
response_data.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response({"success": True, "duplicate_groups": response_data})
return web.json_response(
{"success": True, "duplicate_groups": response_data}
)
except Exception as exc:
self._logger.error("Error finding duplicate recipes: %s", exc, exc_info=True)
self._logger.error(
"Error finding duplicate recipes: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_recipe_syntax(self, request: web.Request) -> web.Response:
@@ -498,9 +544,13 @@ class RecipeQueryHandler:
return web.json_response({"error": "Recipe not found"}, status=404)
if not syntax_parts:
return web.json_response({"error": "No LoRAs found in this recipe"}, status=400)
return web.json_response(
{"error": "No LoRAs found in this recipe"}, status=400
)
return web.json_response({"success": True, "syntax": " ".join(syntax_parts)})
return web.json_response(
{"success": True, "syntax": " ".join(syntax_parts)}
)
except Exception as exc:
self._logger.error("Error generating recipe syntax: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
@@ -561,11 +611,17 @@ class RecipeManagementHandler:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
# Check if already running
if self._ws_manager.is_recipe_repair_running():
return web.json_response({"success": False, "error": "Recipe repair already in progress"}, status=409)
return web.json_response(
{"success": False, "error": "Recipe repair already in progress"},
status=409,
)
recipe_scanner.reset_cancellation()
@@ -579,11 +635,12 @@ class RecipeManagementHandler:
progress_callback=progress_callback
)
except Exception as e:
self._logger.error(f"Error in recipe repair task: {e}", exc_info=True)
await self._ws_manager.broadcast_recipe_repair_progress({
"status": "error",
"error": str(e)
})
self._logger.error(
f"Error in recipe repair task: {e}", exc_info=True
)
await self._ws_manager.broadcast_recipe_repair_progress(
{"status": "error", "error": str(e)}
)
finally:
# Keep the final status for a while so the UI can see it
await asyncio.sleep(5)
@@ -593,7 +650,9 @@ class RecipeManagementHandler:
asyncio.create_task(run_repair())
return web.json_response({"success": True, "message": "Recipe repair started"})
return web.json_response(
{"success": True, "message": "Recipe repair started"}
)
except Exception as exc:
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -603,10 +662,15 @@ class RecipeManagementHandler:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
recipe_scanner.cancel_task()
return web.json_response({"success": True, "message": "Cancellation requested"})
return web.json_response(
{"success": True, "message": "Cancellation requested"}
)
except Exception as exc:
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -616,7 +680,10 @@ class RecipeManagementHandler:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
@@ -632,25 +699,26 @@ class RecipeManagementHandler:
progress = self._ws_manager.get_recipe_repair_progress()
if progress:
return web.json_response({"success": True, "progress": progress})
return web.json_response({"success": False, "message": "No repair in progress"}, status=404)
return web.json_response(
{"success": False, "message": "No repair in progress"}, status=404
)
except Exception as exc:
self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def import_remote_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
# 1. Parse Parameters
params = request.rel_url.query
image_url = params.get("image_url")
name = params.get("name")
resources_raw = params.get("resources")
if not image_url:
raise RecipeValidationError("Missing required field: image_url")
if not name:
@@ -658,64 +726,80 @@ class RecipeManagementHandler:
if not resources_raw:
raise RecipeValidationError("Missing required field: resources")
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
checkpoint_entry, lora_entries = self._parse_resources_payload(
resources_raw
)
gen_params_request = self._parse_gen_params(params.get("gen_params"))
# 2. Initial Metadata Construction
metadata: Dict[str, Any] = {
"base_model": params.get("base_model", "") or "",
"loras": lora_entries,
"gen_params": gen_params_request or {},
"source_url": image_url
"source_url": image_url,
}
source_path = params.get("source_path")
if source_path:
metadata["source_path"] = source_path
# Checkpoint handling
if checkpoint_entry:
metadata["checkpoint"] = checkpoint_entry
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
# Actually enricher looks at metadata['checkpoint'], so this is fine.
# Try to resolve base model from checkpoint if not explicitly provided
if not metadata["base_model"]:
base_model_from_metadata = await self._resolve_base_model_from_checkpoint(checkpoint_entry)
base_model_from_metadata = (
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
)
if base_model_from_metadata:
metadata["base_model"] = base_model_from_metadata
tags = self._parse_tags(params.get("tags"))
# 3. Download Image
image_bytes, extension, civitai_meta_from_download = await self._download_remote_media(image_url)
(
image_bytes,
extension,
civitai_meta_from_download,
) = await self._download_remote_media(image_url)
# 4. Extract Embedded Metadata
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
# with embedded data if we want it to merge it.
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
# with embedded data if we want it to merge it.
# However, logic in Enricher merges: request > civitai > embedded.
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
# OR pass them to enricher to handle?
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
# Wait, `Enricher` fetches Civitai info internally based on URL.
# Wait, `Enricher` fetches Civitai info internally based on URL.
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
# Let's extract embedded metadata first
embedded_gen_params = {}
try:
with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_img:
with tempfile.NamedTemporaryFile(
suffix=extension, delete=False
) as temp_img:
temp_img.write(image_bytes)
temp_img_path = temp_img.name
try:
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
if raw_embedded:
parser = self._analysis_service._recipe_parser_factory.create_parser(raw_embedded)
parser = (
self._analysis_service._recipe_parser_factory.create_parser(
raw_embedded
)
)
if parser:
parsed_embedded = await parser.parse_metadata(raw_embedded, recipe_scanner=recipe_scanner)
parsed_embedded = await parser.parse_metadata(
raw_embedded, recipe_scanner=recipe_scanner
)
if parsed_embedded and "gen_params" in parsed_embedded:
embedded_gen_params = parsed_embedded["gen_params"]
else:
@@ -724,7 +808,9 @@ class RecipeManagementHandler:
if os.path.exists(temp_img_path):
os.unlink(temp_img_path)
except Exception as exc:
self._logger.warning("Failed to extract embedded metadata during import: %s", exc)
self._logger.warning(
"Failed to extract embedded metadata during import: %s", exc
)
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
if embedded_gen_params:
@@ -732,18 +818,18 @@ class RecipeManagementHandler:
# But wait, we want request params to override everything.
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
metadata["gen_params"] = embedded_gen_params
# 5. Enrich with unified logic
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe(
recipe=metadata,
recipe=metadata,
civitai_client=civitai_client,
request_params=gen_params_request # Pass explicit request params here to override
request_params=gen_params_request, # Pass explicit request params here to override
)
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
# we might want to manually merge it?
# we might want to manually merge it?
# But usually `import_remote_recipe` is used with Civitai URLs.
# For now, relying on Enricher's internal fetch is consistent with repair.
@@ -762,7 +848,9 @@ class RecipeManagementHandler:
except RecipeDownloadError as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
self._logger.error("Error importing recipe from remote source: %s", exc, exc_info=True)
self._logger.error(
"Error importing recipe from remote source: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def delete_recipe(self, request: web.Request) -> web.Response:
@@ -816,7 +904,11 @@ class RecipeManagementHandler:
target_path = data.get("target_path")
if not recipe_id or not target_path:
return web.json_response(
{"success": False, "error": "recipe_id and target_path are required"}, status=400
{
"success": False,
"error": "recipe_id and target_path are required",
},
status=400,
)
result = await self._persistence_service.move_recipe(
@@ -845,7 +937,11 @@ class RecipeManagementHandler:
target_path = data.get("target_path")
if not recipe_ids or not target_path:
return web.json_response(
{"success": False, "error": "recipe_ids and target_path are required"}, status=400
{
"success": False,
"error": "recipe_ids and target_path are required",
},
status=400,
)
result = await self._persistence_service.move_recipes_bulk(
@@ -934,7 +1030,9 @@ class RecipeManagementHandler:
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
self._logger.error("Error saving recipe from widget: %s", exc, exc_info=True)
self._logger.error(
"Error saving recipe from widget: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def _parse_save_payload(self, reader) -> dict[str, Any]:
@@ -1006,7 +1104,9 @@ class RecipeManagementHandler:
raise RecipeValidationError("gen_params payload must be an object")
return parsed
def _parse_resources_payload(self, payload_raw: str) -> tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
def _parse_resources_payload(
self, payload_raw: str
) -> tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
try:
payload = json.loads(payload_raw)
except json.JSONDecodeError as exc:
@@ -1066,15 +1166,19 @@ class RecipeManagementHandler:
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
if civitai_match:
if civitai_client is None:
raise RecipeDownloadError("Civitai client unavailable for image download")
raise RecipeDownloadError(
"Civitai client unavailable for image download"
)
image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info:
raise RecipeDownloadError("Failed to fetch image information from Civitai")
raise RecipeDownloadError(
"Failed to fetch image information from Civitai"
)
media_url = image_info.get("url")
if not media_url:
raise RecipeDownloadError("No image URL found in Civitai response")
# Use optimized preview URLs if possible
media_type = image_info.get("type")
rewritten_url, _ = rewrite_preview_url(media_url, media_type=media_type)
@@ -1083,18 +1187,24 @@ class RecipeManagementHandler:
else:
download_url = media_url
success, result = await downloader.download_file(download_url, temp_path, use_auth=False)
success, result = await downloader.download_file(
download_url, temp_path, use_auth=False
)
if not success:
raise RecipeDownloadError(f"Failed to download image: {result}")
# Extract extension from URL
url_path = download_url.split('?')[0].split('#')[0]
url_path = download_url.split("?")[0].split("#")[0]
extension = os.path.splitext(url_path)[1].lower()
if not extension:
extension = ".webp" # Default to webp if unknown
extension = ".webp" # Default to webp if unknown
with open(temp_path, "rb") as file_obj:
return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None
return (
file_obj.read(),
extension,
image_info.get("meta") if civitai_match and image_info else None,
)
except RecipeDownloadError:
raise
except RecipeValidationError:
@@ -1108,14 +1218,15 @@ class RecipeManagementHandler:
except FileNotFoundError:
pass
def _safe_int(self, value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
async def _resolve_base_model_from_checkpoint(self, checkpoint_entry: Dict[str, Any]) -> str:
async def _resolve_base_model_from_checkpoint(
self, checkpoint_entry: Dict[str, Any]
) -> str:
version_id = self._safe_int(checkpoint_entry.get("modelVersionId"))
if not version_id:
@@ -1134,7 +1245,9 @@ class RecipeManagementHandler:
base_model = version_info.get("baseModel") or ""
return str(base_model) if base_model is not None else ""
except Exception as exc: # pragma: no cover - defensive logging
self._logger.warning("Failed to resolve base model from checkpoint metadata: %s", exc)
self._logger.warning(
"Failed to resolve base model from checkpoint metadata: %s", exc
)
return ""
@@ -1279,5 +1392,311 @@ class RecipeSharingHandler:
except RecipeNotFoundError as exc:
return web.json_response({"error": str(exc)}, status=404)
except Exception as exc:
self._logger.error("Error downloading shared recipe: %s", exc, exc_info=True)
self._logger.error(
"Error downloading shared recipe: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
class BatchImportHandler:
"""Handle batch import operations for recipes."""
def __init__(
self,
*,
ensure_dependencies_ready: EnsureDependenciesCallable,
recipe_scanner_getter: RecipeScannerGetter,
civitai_client_getter: CivitaiClientGetter,
logger: Logger,
batch_import_service: BatchImportService,
) -> None:
self._ensure_dependencies_ready = ensure_dependencies_ready
self._recipe_scanner_getter = recipe_scanner_getter
self._civitai_client_getter = civitai_client_getter
self._logger = logger
self._batch_import_service = batch_import_service
async def start_batch_import(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
if self._batch_import_service.is_import_running():
return web.json_response(
{"success": False, "error": "Batch import already in progress"},
status=409,
)
data = await request.json()
items = data.get("items", [])
tags = data.get("tags", [])
skip_no_metadata = data.get("skip_no_metadata", False)
if not items:
return web.json_response(
{"success": False, "error": "No items provided"},
status=400,
)
for item in items:
if not item.get("source"):
return web.json_response(
{
"success": False,
"error": "Each item must have a 'source' field",
},
status=400,
)
operation_id = await self._batch_import_service.start_batch_import(
recipe_scanner_getter=self._recipe_scanner_getter,
civitai_client_getter=self._civitai_client_getter,
items=items,
tags=tags,
skip_no_metadata=skip_no_metadata,
)
return web.json_response(
{
"success": True,
"operation_id": operation_id,
}
)
except RecipeValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self._logger.error("Error starting batch import: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def start_directory_import(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
if self._batch_import_service.is_import_running():
return web.json_response(
{"success": False, "error": "Batch import already in progress"},
status=409,
)
data = await request.json()
directory = data.get("directory")
recursive = data.get("recursive", True)
tags = data.get("tags", [])
skip_no_metadata = data.get("skip_no_metadata", True)
if not directory:
return web.json_response(
{"success": False, "error": "Directory path is required"},
status=400,
)
operation_id = await self._batch_import_service.start_directory_import(
recipe_scanner_getter=self._recipe_scanner_getter,
civitai_client_getter=self._civitai_client_getter,
directory=directory,
recursive=recursive,
tags=tags,
skip_no_metadata=skip_no_metadata,
)
return web.json_response(
{
"success": True,
"operation_id": operation_id,
}
)
except RecipeValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self._logger.error(
"Error starting directory import: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_batch_import_progress(self, request: web.Request) -> web.Response:
try:
operation_id = request.query.get("operation_id")
if not operation_id:
return web.json_response(
{"success": False, "error": "operation_id is required"},
status=400,
)
progress = self._batch_import_service.get_progress(operation_id)
if not progress:
return web.json_response(
{"success": False, "error": "Operation not found"},
status=404,
)
return web.json_response(
{
"success": True,
"progress": progress.to_dict(),
}
)
except Exception as exc:
self._logger.error(
"Error getting batch import progress: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def cancel_batch_import(self, request: web.Request) -> web.Response:
try:
data = await request.json()
operation_id = data.get("operation_id")
if not operation_id:
return web.json_response(
{"success": False, "error": "operation_id is required"},
status=400,
)
cancelled = self._batch_import_service.cancel_import(operation_id)
if not cancelled:
return web.json_response(
{
"success": False,
"error": "Operation not found or already completed",
},
status=404,
)
return web.json_response(
{"success": True, "message": "Cancellation requested"}
)
except Exception as exc:
self._logger.error("Error cancelling batch import: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def browse_directory(self, request: web.Request) -> web.Response:
"""Browse a directory and return its contents (subdirectories and files)."""
try:
data = await request.json()
directory_path = data.get("path", "")
if not directory_path:
return web.json_response(
{"success": False, "error": "Directory path is required"},
status=400,
)
# Normalize the path
path = Path(directory_path).expanduser().resolve()
# Security check: ensure path is within allowed directories
# Allow common image/model directories
allowed_roots = [
Path.home(),
Path("/"), # Allow browsing from root for flexibility
]
# Check if path is within any allowed root
is_allowed = False
for root in allowed_roots:
try:
path.relative_to(root)
is_allowed = True
break
except ValueError:
continue
if not is_allowed:
return web.json_response(
{"success": False, "error": "Access denied to this directory"},
status=403,
)
if not path.exists():
return web.json_response(
{"success": False, "error": "Directory does not exist"},
status=404,
)
if not path.is_dir():
return web.json_response(
{"success": False, "error": "Path is not a directory"},
status=400,
)
# List directory contents
directories = []
image_files = []
image_extensions = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".tif",
}
try:
for item in path.iterdir():
try:
if item.is_dir():
# Skip hidden directories and common system folders
if not item.name.startswith(".") and item.name not in [
"__pycache__",
"node_modules",
]:
directories.append(
{
"name": item.name,
"path": str(item),
"is_parent": False,
}
)
elif item.is_file() and item.suffix.lower() in image_extensions:
image_files.append(
{
"name": item.name,
"path": str(item),
"size": item.stat().st_size,
}
)
except (PermissionError, OSError):
# Skip files/directories we can't access
continue
# Sort directories and files alphabetically
directories.sort(key=lambda x: x["name"].lower())
image_files.sort(key=lambda x: x["name"].lower())
# Add parent directory if not at root
parent_path = path.parent
show_parent = str(path) != str(path.root)
return web.json_response(
{
"success": True,
"current_path": str(path),
"parent_path": str(parent_path) if show_parent else None,
"directories": directories,
"image_files": image_files,
"image_count": len(image_files),
"directory_count": len(directories),
}
)
except PermissionError:
return web.json_response(
{"success": False, "error": "Permission denied"},
status=403,
)
except OSError as exc:
return web.json_response(
{"success": False, "error": f"Error reading directory: {str(exc)}"},
status=500,
)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"},
status=400,
)
except Exception as exc:
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)

View File

@@ -1,4 +1,5 @@
"""Route registrar for recipe endpoints."""
from __future__ import annotations
from dataclasses import dataclass
@@ -22,7 +23,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}", "get_recipe"),
RouteDefinition("GET", "/api/lm/recipes/import-remote", "import_remote_recipe"),
RouteDefinition("POST", "/api/lm/recipes/analyze-image", "analyze_uploaded_image"),
RouteDefinition("POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"),
RouteDefinition(
"POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"
),
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
@@ -30,9 +33,13 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"),
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
RouteDefinition(
"GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"
),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
RouteDefinition(
"GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"
),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
@@ -40,13 +47,26 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
RouteDefinition(
"POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"
),
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
RouteDefinition(
"GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress"
),
RouteDefinition(
"POST", "/api/lm/recipes/batch-import/cancel", "cancel_batch_import"
),
RouteDefinition(
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
),
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
)
@@ -63,7 +83,9 @@ class RecipeRouteRegistrar:
def __init__(self, app: web.Application) -> None:
self._app = app
def register_routes(self, handler_lookup: Mapping[str, Callable[[web.Request], object]]) -> None:
def register_routes(
self, handler_lookup: Mapping[str, Callable[[web.Request], object]]
) -> None:
for definition in ROUTE_DEFINITIONS:
handler = handler_lookup[definition.handler_name]
self._bind_route(definition.method, definition.path, handler)

View File

@@ -208,7 +208,11 @@ class BaseModelService(ABC):
reverse = sort_params.order == "desc"
annotated.sort(
key=lambda x: (x.get("usage_count", 0), x.get("model_name", "").lower()),
key=lambda x: (
x.get("usage_count", 0),
x.get("model_name", "").lower(),
x.get("file_path", "").lower()
),
reverse=reverse,
)
return annotated

View File

@@ -0,0 +1,593 @@
"""Batch import service for importing multiple images as recipes."""
from __future__ import annotations
import asyncio
import logging
import os
import time
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set
from aiohttp import web
from .recipes import (
RecipeAnalysisService,
RecipePersistenceService,
RecipeValidationError,
RecipeDownloadError,
RecipeNotFoundError,
)
class ImportItemType(Enum):
"""Type of import item."""
URL = "url"
LOCAL_PATH = "local_path"
class ImportStatus(Enum):
"""Status of an individual import item."""
PENDING = "pending"
PROCESSING = "processing"
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
@dataclass
class BatchImportItem:
"""Represents a single item to import."""
id: str
source: str
item_type: ImportItemType
status: ImportStatus = ImportStatus.PENDING
error_message: Optional[str] = None
recipe_name: Optional[str] = None
recipe_id: Optional[str] = None
duration: float = 0.0
@dataclass
class BatchImportProgress:
"""Tracks progress of a batch import operation."""
operation_id: str
total: int
completed: int = 0
success: int = 0
failed: int = 0
skipped: int = 0
current_item: str = ""
status: str = "pending"
started_at: float = field(default_factory=time.time)
finished_at: Optional[float] = None
items: List[BatchImportItem] = field(default_factory=list)
tags: List[str] = field(default_factory=list)
skip_no_metadata: bool = False
skip_duplicates: bool = False
def to_dict(self) -> Dict[str, Any]:
return {
"operation_id": self.operation_id,
"total": self.total,
"completed": self.completed,
"success": self.success,
"failed": self.failed,
"skipped": self.skipped,
"current_item": self.current_item,
"status": self.status,
"started_at": self.started_at,
"finished_at": self.finished_at,
"progress_percent": round((self.completed / self.total) * 100, 1)
if self.total > 0
else 0,
"items": [
{
"id": item.id,
"source": item.source,
"item_type": item.item_type.value,
"status": item.status.value,
"error_message": item.error_message,
"recipe_name": item.recipe_name,
"recipe_id": item.recipe_id,
"duration": item.duration,
}
for item in self.items
],
}
class AdaptiveConcurrencyController:
"""Adjusts concurrency based on task performance."""
def __init__(
self,
min_concurrency: int = 1,
max_concurrency: int = 5,
initial_concurrency: int = 3,
) -> None:
self.min_concurrency = min_concurrency
self.max_concurrency = max_concurrency
self.current_concurrency = initial_concurrency
self._task_durations: List[float] = []
self._recent_errors = 0
self._recent_successes = 0
def record_result(self, duration: float, success: bool) -> None:
self._task_durations.append(duration)
if len(self._task_durations) > 10:
self._task_durations.pop(0)
if success:
self._recent_successes += 1
if duration < 1.0 and self.current_concurrency < self.max_concurrency:
self.current_concurrency = min(
self.current_concurrency + 1, self.max_concurrency
)
elif duration > 10.0 and self.current_concurrency > self.min_concurrency:
self.current_concurrency = max(
self.current_concurrency - 1, self.min_concurrency
)
else:
self._recent_errors += 1
if self.current_concurrency > self.min_concurrency:
self.current_concurrency = max(
self.current_concurrency - 1, self.min_concurrency
)
def reset_counters(self) -> None:
self._recent_errors = 0
self._recent_successes = 0
def get_semaphore(self) -> asyncio.Semaphore:
return asyncio.Semaphore(self.current_concurrency)
class BatchImportService:
"""Service for batch importing images as recipes."""
SUPPORTED_EXTENSIONS: Set[str] = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
def __init__(
self,
*,
analysis_service: RecipeAnalysisService,
persistence_service: RecipePersistenceService,
ws_manager: Any,
logger: logging.Logger,
) -> None:
self._analysis_service = analysis_service
self._persistence_service = persistence_service
self._ws_manager = ws_manager
self._logger = logger
self._active_operations: Dict[str, BatchImportProgress] = {}
self._cancellation_flags: Dict[str, bool] = {}
self._concurrency_controller = AdaptiveConcurrencyController()
def is_import_running(self, operation_id: Optional[str] = None) -> bool:
if operation_id:
progress = self._active_operations.get(operation_id)
return progress is not None and progress.status in ("pending", "running")
return any(
p.status in ("pending", "running") for p in self._active_operations.values()
)
def get_progress(self, operation_id: str) -> Optional[BatchImportProgress]:
return self._active_operations.get(operation_id)
def cancel_import(self, operation_id: str) -> bool:
if operation_id in self._active_operations:
self._cancellation_flags[operation_id] = True
return True
return False
def _validate_url(self, url: str) -> bool:
import re
url_pattern = re.compile(
r"^https?://"
r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|"
r"localhost|"
r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
r"(?::\d+)?"
r"(?:/?|[/?]\S+)$",
re.IGNORECASE,
)
return url_pattern.match(url) is not None
def _validate_local_path(self, path: str) -> bool:
try:
normalized = os.path.normpath(path)
if not os.path.isabs(normalized):
return False
if ".." in normalized:
return False
return True
except Exception:
return False
def _is_duplicate_source(
self,
source: str,
item_type: ImportItemType,
recipe_scanner: Any,
) -> bool:
try:
cache = recipe_scanner.get_cached_data_sync()
if not cache:
return False
for recipe in getattr(cache, "raw_data", []):
source_path = recipe.get("source_path") or recipe.get("source_url")
if source_path and source_path == source:
return True
return False
except Exception:
self._logger.warning("Failed to check for duplicates", exc_info=True)
return False
async def start_batch_import(
self,
*,
recipe_scanner_getter: Callable[[], Any],
civitai_client_getter: Callable[[], Any],
items: List[Dict[str, str]],
tags: Optional[List[str]] = None,
skip_no_metadata: bool = False,
skip_duplicates: bool = False,
) -> str:
operation_id = str(uuid.uuid4())
import_items = []
for idx, item in enumerate(items):
source = item.get("source", "")
item_type_str = item.get("type", "url")
if item_type_str == "url" or source.startswith(("http://", "https://")):
item_type = ImportItemType.URL
else:
item_type = ImportItemType.LOCAL_PATH
batch_import_item = BatchImportItem(
id=f"{operation_id}_{idx}",
source=source,
item_type=item_type,
)
import_items.append(batch_import_item)
progress = BatchImportProgress(
operation_id=operation_id,
total=len(import_items),
items=import_items,
tags=tags or [],
skip_no_metadata=skip_no_metadata,
skip_duplicates=skip_duplicates,
)
self._active_operations[operation_id] = progress
self._cancellation_flags[operation_id] = False
asyncio.create_task(
self._run_batch_import(
operation_id=operation_id,
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
)
)
return operation_id
async def start_directory_import(
self,
*,
recipe_scanner_getter: Callable[[], Any],
civitai_client_getter: Callable[[], Any],
directory: str,
recursive: bool = True,
tags: Optional[List[str]] = None,
skip_no_metadata: bool = False,
skip_duplicates: bool = False,
) -> str:
image_paths = await self._discover_images(directory, recursive)
items = [{"source": path, "type": "local_path"} for path in image_paths]
return await self.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=items,
tags=tags,
skip_no_metadata=skip_no_metadata,
skip_duplicates=skip_duplicates,
)
async def _discover_images(
self,
directory: str,
recursive: bool = True,
) -> List[str]:
if not os.path.isdir(directory):
raise RecipeValidationError(f"Directory not found: {directory}")
image_paths: List[str] = []
if recursive:
for root, _, files in os.walk(directory):
for filename in files:
if self._is_supported_image(filename):
image_paths.append(os.path.join(root, filename))
else:
for filename in os.listdir(directory):
filepath = os.path.join(directory, filename)
if os.path.isfile(filepath) and self._is_supported_image(filename):
image_paths.append(filepath)
return sorted(image_paths)
def _is_supported_image(self, filename: str) -> bool:
ext = os.path.splitext(filename)[1].lower()
return ext in self.SUPPORTED_EXTENSIONS
async def _run_batch_import(
self,
*,
operation_id: str,
recipe_scanner_getter: Callable[[], Any],
civitai_client_getter: Callable[[], Any],
) -> None:
progress = self._active_operations.get(operation_id)
if not progress:
return
progress.status = "running"
await self._broadcast_progress(progress)
self._concurrency_controller = AdaptiveConcurrencyController()
async def process_item(item: BatchImportItem) -> None:
if self._cancellation_flags.get(operation_id, False):
return
progress.current_item = (
os.path.basename(item.source)
if item.item_type == ImportItemType.LOCAL_PATH
else item.source[:50]
)
item.status = ImportStatus.PROCESSING
await self._broadcast_progress(progress)
start_time = time.time()
try:
result = await self._import_single_item(
item=item,
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
tags=progress.tags,
skip_no_metadata=progress.skip_no_metadata,
skip_duplicates=progress.skip_duplicates,
semaphore=self._concurrency_controller.get_semaphore(),
)
duration = time.time() - start_time
item.duration = duration
self._concurrency_controller.record_result(
duration, result.get("success", False)
)
if result.get("success"):
item.status = ImportStatus.SUCCESS
item.recipe_name = result.get("recipe_name")
item.recipe_id = result.get("recipe_id")
progress.success += 1
elif result.get("skipped"):
item.status = ImportStatus.SKIPPED
item.error_message = result.get("error")
progress.skipped += 1
else:
item.status = ImportStatus.FAILED
item.error_message = result.get("error")
progress.failed += 1
except Exception as e:
self._logger.error(f"Error importing {item.source}: {e}")
item.status = ImportStatus.FAILED
item.error_message = str(e)
item.duration = time.time() - start_time
progress.failed += 1
self._concurrency_controller.record_result(item.duration, False)
progress.completed += 1
await self._broadcast_progress(progress)
tasks = [process_item(item) for item in progress.items]
await asyncio.gather(*tasks, return_exceptions=True)
if self._cancellation_flags.get(operation_id, False):
progress.status = "cancelled"
else:
progress.status = "completed"
progress.finished_at = time.time()
progress.current_item = ""
await self._broadcast_progress(progress)
await asyncio.sleep(5)
self._cleanup_operation(operation_id)
async def _import_single_item(
self,
*,
item: BatchImportItem,
recipe_scanner_getter: Callable[[], Any],
civitai_client_getter: Callable[[], Any],
tags: List[str],
skip_no_metadata: bool,
skip_duplicates: bool,
semaphore: asyncio.Semaphore,
) -> Dict[str, Any]:
async with semaphore:
recipe_scanner = recipe_scanner_getter()
if recipe_scanner is None:
return {"success": False, "error": "Recipe scanner unavailable"}
try:
if item.item_type == ImportItemType.URL:
if not self._validate_url(item.source):
return {
"success": False,
"error": f"Invalid URL format: {item.source}",
}
if skip_duplicates:
if self._is_duplicate_source(
item.source, item.item_type, recipe_scanner
):
return {
"success": False,
"skipped": True,
"error": "Duplicate source URL",
}
civitai_client = civitai_client_getter()
analysis_result = await self._analysis_service.analyze_remote_image(
url=item.source,
recipe_scanner=recipe_scanner,
civitai_client=civitai_client,
)
else:
if not self._validate_local_path(item.source):
return {
"success": False,
"error": f"Invalid or unsafe path: {item.source}",
}
if not os.path.exists(item.source):
return {
"success": False,
"error": f"File not found: {item.source}",
}
if skip_duplicates:
if self._is_duplicate_source(
item.source, item.item_type, recipe_scanner
):
return {
"success": False,
"skipped": True,
"error": "Duplicate source path",
}
analysis_result = await self._analysis_service.analyze_local_image(
file_path=item.source,
recipe_scanner=recipe_scanner,
)
payload = analysis_result.payload
if payload.get("error"):
if skip_no_metadata and "No metadata" in payload.get("error", ""):
return {
"success": False,
"skipped": True,
"error": payload["error"],
}
return {"success": False, "error": payload["error"]}
loras = payload.get("loras", [])
if not loras:
if skip_no_metadata:
return {
"success": False,
"skipped": True,
"error": "No LoRAs found in image",
}
# When skip_no_metadata is False, allow importing images without LoRAs
# Continue with empty loras list
recipe_name = self._generate_recipe_name(item, payload)
all_tags = list(set(tags + (payload.get("tags", []) or [])))
metadata = {
"base_model": payload.get("base_model", ""),
"loras": loras,
"gen_params": payload.get("gen_params", {}),
"source_path": item.source,
}
if payload.get("checkpoint"):
metadata["checkpoint"] = payload["checkpoint"]
image_bytes = None
image_base64 = payload.get("image_base64")
if item.item_type == ImportItemType.LOCAL_PATH:
with open(item.source, "rb") as f:
image_bytes = f.read()
image_base64 = None
save_result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner,
image_bytes=image_bytes,
image_base64=image_base64,
name=recipe_name,
tags=all_tags,
metadata=metadata,
extension=payload.get("extension"),
)
if save_result.status == 200:
return {
"success": True,
"recipe_name": recipe_name,
"recipe_id": save_result.payload.get("id"),
}
else:
return {
"success": False,
"error": save_result.payload.get(
"error", "Failed to save recipe"
),
}
except RecipeValidationError as e:
return {"success": False, "error": str(e)}
except RecipeDownloadError as e:
return {"success": False, "error": str(e)}
except RecipeNotFoundError as e:
return {"success": False, "skipped": True, "error": str(e)}
except Exception as e:
self._logger.error(
f"Unexpected error importing {item.source}: {e}", exc_info=True
)
return {"success": False, "error": str(e)}
def _generate_recipe_name(
self, item: BatchImportItem, payload: Dict[str, Any]
) -> str:
if item.item_type == ImportItemType.LOCAL_PATH:
base_name = os.path.splitext(os.path.basename(item.source))[0]
return base_name[:100]
else:
loras = payload.get("loras", [])
if loras:
first_lora = loras[0].get("name", "Recipe")
return f"Import - {first_lora}"[:100]
return f"Imported Recipe {item.id[:8]}"
async def _broadcast_progress(self, progress: BatchImportProgress) -> None:
await self._ws_manager.broadcast(
{
"type": "batch_import_progress",
**progress.to_dict(),
}
)
def _cleanup_operation(self, operation_id: str) -> None:
if operation_id in self._cancellation_flags:
del self._cancellation_flags[operation_id]

View File

@@ -58,6 +58,7 @@ class CacheEntryValidator:
'preview_nsfw_level': (0, False),
'notes': ('', False),
'usage_tips': ('', False),
'hash_status': ('completed', False),
}
@classmethod
@@ -90,13 +91,31 @@ class CacheEntryValidator:
errors: List[str] = []
repaired = False
# If auto_repair is on, we work on a copy. If not, we still need a safe way to check fields.
working_entry = dict(entry) if auto_repair else entry
# Determine effective hash_status for validation logic
hash_status = entry.get('hash_status')
if hash_status is None:
if auto_repair:
working_entry['hash_status'] = 'completed'
repaired = True
hash_status = 'completed'
for field_name, (default_value, is_required) in cls.CORE_FIELDS.items():
value = working_entry.get(field_name)
# Get current value from the original entry to avoid side effects during validation
value = entry.get(field_name)
# Check if field is missing or None
if value is None:
# Special case: sha256 can be None/empty if hash_status is pending
if field_name == 'sha256' and hash_status == 'pending':
if auto_repair:
working_entry[field_name] = ''
repaired = True
continue
if is_required:
errors.append(f"Required field '{field_name}' is missing or None")
if auto_repair:
@@ -107,6 +126,10 @@ class CacheEntryValidator:
# Validate field type and value
field_error = cls._validate_field(field_name, value, default_value)
if field_error:
# Special case: allow empty string for sha256 if pending
if field_name == 'sha256' and hash_status == 'pending' and value == '':
continue
errors.append(field_error)
if auto_repair:
working_entry[field_name] = cls._get_default_copy(default_value)
@@ -127,7 +150,7 @@ class CacheEntryValidator:
# Special validation: sha256 must not be empty for required field
# BUT allow empty sha256 when hash_status is pending (lazy hash calculation)
sha256 = working_entry.get('sha256', '')
hash_status = working_entry.get('hash_status', 'completed')
# Use the effective hash_status we determined earlier
if not sha256 or (isinstance(sha256, str) and not sha256.strip()):
# Allow empty sha256 for lazy hash calculation (checkpoints)
if hash_status != 'pending':
@@ -144,8 +167,13 @@ class CacheEntryValidator:
if isinstance(sha256, str):
normalized_sha = sha256.lower().strip()
if normalized_sha != sha256:
working_entry['sha256'] = normalized_sha
repaired = True
if auto_repair:
working_entry['sha256'] = normalized_sha
repaired = True
else:
# If not auto-repairing, we don't consider case difference as a "critical error"
# that invalidates the entry, but we also don't mark it repaired.
pass
# Determine if entry is valid
# Entry is valid if no critical required field errors remain after repair

View File

@@ -13,22 +13,35 @@ from .model_hash_index import ModelHashIndex
logger = logging.getLogger(__name__)
class CheckpointScanner(ModelScanner):
"""Service for scanning and managing checkpoint files"""
def __init__(self):
# Define supported file extensions
file_extensions = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft', '.gguf'}
file_extensions = {
".ckpt",
".pt",
".pt2",
".bin",
".pth",
".safetensors",
".pkl",
".sft",
".gguf",
}
super().__init__(
model_type="checkpoint",
model_class=CheckpointMetadata,
file_extensions=file_extensions,
hash_index=ModelHashIndex()
hash_index=ModelHashIndex(),
)
async def _create_default_metadata(self, file_path: str) -> Optional[CheckpointMetadata]:
async def _create_default_metadata(
self, file_path: str
) -> Optional[CheckpointMetadata]:
"""Create default metadata for checkpoint without calculating hash (lazy hash).
Checkpoints are typically large (10GB+), so we skip hash calculation during initial
scanning to improve startup performance. Hash will be calculated on-demand when
fetching metadata from Civitai.
@@ -38,13 +51,13 @@ class CheckpointScanner(ModelScanner):
if not os.path.exists(real_path):
logger.error(f"File not found: {file_path}")
return None
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
# Find preview image
preview_url = find_preview_file(base_name, dir_path)
# Create metadata WITHOUT calculating hash
metadata = CheckpointMetadata(
file_name=base_name,
@@ -59,70 +72,76 @@ class CheckpointScanner(ModelScanner):
modelDescription="",
sub_type="checkpoint",
from_civitai=False, # Mark as local model since no hash yet
hash_status="pending" # Mark hash as pending
hash_status="pending", # Mark hash as pending
)
# Save the created metadata
logger.info(f"Creating checkpoint metadata (hash pending) for {file_path}")
await MetadataManager.save_metadata(file_path, metadata)
return metadata
except Exception as e:
logger.error(f"Error creating default checkpoint metadata for {file_path}: {e}")
logger.error(
f"Error creating default checkpoint metadata for {file_path}: {e}"
)
return None
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
"""Calculate hash for a checkpoint on-demand.
Args:
file_path: Path to the model file
Returns:
SHA256 hash string, or None if calculation failed
"""
from ..utils.file_utils import calculate_sha256
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
logger.error(f"File not found for hash calculation: {file_path}")
return None
# Load current metadata
metadata, _ = await MetadataManager.load_metadata(file_path, self.model_class)
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if metadata is None:
logger.error(f"No metadata found for {file_path}")
return None
# Check if hash is already calculated
if metadata.hash_status == "completed" and metadata.sha256:
return metadata.sha256
# Update status to calculating
metadata.hash_status = "calculating"
await MetadataManager.save_metadata(file_path, metadata)
# Calculate hash
logger.info(f"Calculating hash for checkpoint: {file_path}")
sha256 = await calculate_sha256(real_path)
# Update metadata with hash
metadata.sha256 = sha256
metadata.hash_status = "completed"
await MetadataManager.save_metadata(file_path, metadata)
# Update hash index
self._hash_index.add_entry(sha256.lower(), file_path)
logger.info(f"Hash calculated for checkpoint: {file_path}")
return sha256
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
# Update status to failed
try:
metadata, _ = await MetadataManager.load_metadata(file_path, self.model_class)
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if metadata:
metadata.hash_status = "failed"
await MetadataManager.save_metadata(file_path, metadata)
@@ -130,43 +149,46 @@ class CheckpointScanner(ModelScanner):
pass
return None
async def calculate_all_pending_hashes(self, progress_callback=None) -> Dict[str, int]:
async def calculate_all_pending_hashes(
self, progress_callback=None
) -> Dict[str, int]:
"""Calculate hashes for all checkpoints with pending hash status.
If cache is not initialized, scans filesystem directly for metadata files
with hash_status != 'completed'.
Args:
progress_callback: Optional callback(progress, total, current_file)
Returns:
Dict with 'completed', 'failed', 'total' counts
"""
# Try to get from cache first
cache = await self.get_cached_data()
if cache and cache.raw_data:
# Use cache if available
pending_models = [
item for item in cache.raw_data
if item.get('hash_status') != 'completed' or not item.get('sha256')
item
for item in cache.raw_data
if item.get("hash_status") != "completed" or not item.get("sha256")
]
else:
# Cache not initialized, scan filesystem directly
pending_models = await self._find_pending_models_from_filesystem()
if not pending_models:
return {'completed': 0, 'failed': 0, 'total': 0}
return {"completed": 0, "failed": 0, "total": 0}
total = len(pending_models)
completed = 0
failed = 0
for i, model_data in enumerate(pending_models):
file_path = model_data.get('file_path')
file_path = model_data.get("file_path")
if not file_path:
continue
try:
sha256 = await self.calculate_hash_for_model(file_path)
if sha256:
@@ -176,77 +198,102 @@ class CheckpointScanner(ModelScanner):
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
failed += 1
if progress_callback:
try:
await progress_callback(i + 1, total, file_path)
except Exception:
pass
return {
'completed': completed,
'failed': failed,
'total': total
}
return {"completed": completed, "failed": failed, "total": total}
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
"""Scan filesystem for checkpoint metadata files with pending hash status."""
pending_models = []
for root_path in self.get_model_roots():
if not os.path.exists(root_path):
continue
for dirpath, _dirnames, filenames in os.walk(root_path):
for filename in filenames:
if not filename.endswith('.metadata.json'):
if not filename.endswith(".metadata.json"):
continue
metadata_path = os.path.join(dirpath, filename)
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
with open(metadata_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Check if hash is pending
hash_status = data.get('hash_status', 'completed')
sha256 = data.get('sha256', '')
if hash_status != 'completed' or not sha256:
hash_status = data.get("hash_status", "completed")
sha256 = data.get("sha256", "")
if hash_status != "completed" or not sha256:
# Find corresponding model file
model_name = filename.replace('.metadata.json', '')
model_name = filename.replace(".metadata.json", "")
model_path = None
# Look for model file with matching name
for ext in self.file_extensions:
potential_path = os.path.join(dirpath, model_name + ext)
if os.path.exists(potential_path):
model_path = potential_path
break
if model_path:
pending_models.append({
'file_path': model_path.replace(os.sep, '/'),
'hash_status': hash_status,
'sha256': sha256,
**{k: v for k, v in data.items() if k not in ['file_path', 'hash_status', 'sha256']}
})
pending_models.append(
{
"file_path": model_path.replace(os.sep, "/"),
"hash_status": hash_status,
"sha256": sha256,
**{
k: v
for k, v in data.items()
if k
not in [
"file_path",
"hash_status",
"sha256",
]
},
}
)
except (json.JSONDecodeError, Exception) as e:
logger.debug(f"Error reading metadata file {metadata_path}: {e}")
logger.debug(
f"Error reading metadata file {metadata_path}: {e}"
)
continue
return pending_models
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
"""Resolve the sub-type based on the root path."""
"""Resolve the sub-type based on the root path.
Checks both standard ComfyUI paths and LoRA Manager's extra folder paths.
"""
if not root_path:
return None
# Check standard ComfyUI checkpoint paths
if config.checkpoints_roots and root_path in config.checkpoints_roots:
return "checkpoint"
# Check extra checkpoint paths
if (
config.extra_checkpoints_roots
and root_path in config.extra_checkpoints_roots
):
return "checkpoint"
# Check standard ComfyUI unet paths
if config.unet_roots and root_path in config.unet_roots:
return "diffusion_model"
# Check extra unet paths
if config.extra_unet_roots and root_path in config.extra_unet_roots:
return "diffusion_model"
return None
def adjust_metadata(self, metadata, file_path, root_path):

View File

@@ -10,7 +10,11 @@ import uuid
from typing import Dict, List, Optional, Set, Tuple
from urllib.parse import urlparse
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.constants import CARD_PREVIEW_WIDTH, DIFFUSION_MODEL_BASE_MODELS, VALID_LORA_TYPES
from ..utils.constants import (
CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS,
VALID_LORA_TYPES,
)
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media
from ..utils.utils import sanitize_folder_name
@@ -352,10 +356,12 @@ class DownloadManager:
# Check if this checkpoint should be treated as a diffusion model based on baseModel
is_diffusion_model = False
if model_type == "checkpoint":
base_model_value = version_info.get('baseModel', '')
base_model_value = version_info.get("baseModel", "")
if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True
logger.info(f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder")
logger.info(
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
)
# Case 2: model_version_id was None, check after getting version_info
if model_version_id is None:
@@ -464,7 +470,7 @@ class DownloadManager:
# 2. Get file information
files = version_info.get("files", [])
file_info = None
# If file_params is provided, try to find matching file
if file_params and model_version_id:
target_type = file_params.get("type", "Model")
@@ -472,23 +478,28 @@ class DownloadManager:
target_size = file_params.get("size", "full")
target_fp = file_params.get("fp")
is_primary = file_params.get("isPrimary", False)
if is_primary:
# Find primary file
file_info = next(
(f for f in files if f.get("primary") and f.get("type") in ("Model", "Negative")),
None
(
f
for f in files
if f.get("primary")
and f.get("type") in ("Model", "Negative")
),
None,
)
else:
# Match by metadata
for f in files:
f_type = f.get("type", "")
f_meta = f.get("metadata", {})
# Check type match
if f_type != target_type:
continue
# Check metadata match
if f_meta.get("format") != target_format:
continue
@@ -496,10 +507,10 @@ class DownloadManager:
continue
if target_fp and f_meta.get("fp") != target_fp:
continue
file_info = f
break
# Fallback to primary file if no match found
if not file_info:
file_info = next(
@@ -510,7 +521,7 @@ class DownloadManager:
),
None,
)
if not file_info:
return {"success": False, "error": "No suitable file found in metadata"}
mirrors = file_info.get("mirrors") or []
@@ -1220,7 +1231,13 @@ class DownloadManager:
entries: List = []
for index, file_path in enumerate(file_paths):
entry = base_metadata if index == 0 else copy.deepcopy(base_metadata)
entry.update_file_info(file_path)
# Update file paths without modifying size and modified timestamps
# modified should remain as the download start time (import time)
# size will be updated below to reflect actual downloaded file size
entry.file_path = file_path.replace(os.sep, "/")
entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
# Update size to actual downloaded file size
entry.size = os.path.getsize(file_path)
entry.sha256 = await calculate_sha256(file_path)
entries.append(entry)

View File

@@ -27,7 +27,7 @@ class LoraService(BaseModelService):
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
# Normalize to lowercase for consistent API responses
sub_type = resolve_sub_type(lora_data).lower()
return {
"model_name": lora_data["model_name"],
"file_name": lora_data["file_name"],
@@ -48,7 +48,9 @@ class LoraService(BaseModelService):
"notes": lora_data.get("notes", ""),
"favorite": lora_data.get("favorite", False),
"update_available": bool(lora_data.get("update_available", False)),
"skip_metadata_refresh": bool(lora_data.get("skip_metadata_refresh", False)),
"skip_metadata_refresh": bool(
lora_data.get("skip_metadata_refresh", False)
),
"sub_type": sub_type,
"civitai": self.filter_civitai_data(
lora_data.get("civitai", {}), minimal=True
@@ -62,6 +64,68 @@ class LoraService(BaseModelService):
if first_letter:
data = self._filter_by_first_letter(data, first_letter)
# Handle name pattern filters
name_pattern_include = kwargs.get("name_pattern_include", [])
name_pattern_exclude = kwargs.get("name_pattern_exclude", [])
name_pattern_use_regex = kwargs.get("name_pattern_use_regex", False)
if name_pattern_include or name_pattern_exclude:
import re
def matches_pattern(name, pattern, use_regex):
"""Check if name matches pattern (regex or substring)"""
if not name:
return False
if use_regex:
try:
return bool(re.search(pattern, name, re.IGNORECASE))
except re.error:
# Invalid regex, fall back to substring match
return pattern.lower() in name.lower()
else:
return pattern.lower() in name.lower()
def matches_any_pattern(name, patterns, use_regex):
"""Check if name matches any of the patterns"""
if not patterns:
return True
return any(matches_pattern(name, p, use_regex) for p in patterns)
filtered = []
for lora in data:
model_name = lora.get("model_name", "")
file_name = lora.get("file_name", "")
names_to_check = [n for n in [model_name, file_name] if n]
# Check exclude patterns first
excluded = False
if name_pattern_exclude:
for name in names_to_check:
if matches_any_pattern(
name, name_pattern_exclude, name_pattern_use_regex
):
excluded = True
break
if excluded:
continue
# Check include patterns
if name_pattern_include:
included = False
for name in names_to_check:
if matches_any_pattern(
name, name_pattern_include, name_pattern_use_regex
):
included = True
break
if not included:
continue
filtered.append(lora)
data = filtered
return data
def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]:
@@ -368,9 +432,7 @@ class LoraService(BaseModelService):
rng.uniform(clip_strength_min, clip_strength_max), 2
)
else:
clip_str = round(
rng.uniform(clip_strength_min, clip_strength_max), 2
)
clip_str = round(rng.uniform(clip_strength_min, clip_strength_max), 2)
result_loras.append(
{
@@ -485,12 +547,69 @@ class LoraService(BaseModelService):
if bool(lora.get("license_flags", 127) & (1 << 1))
]
# Apply name pattern filters
name_patterns = filter_section.get("namePatterns", {})
include_patterns = name_patterns.get("include", [])
exclude_patterns = name_patterns.get("exclude", [])
use_regex = name_patterns.get("useRegex", False)
if include_patterns or exclude_patterns:
import re
def matches_pattern(name, pattern, use_regex):
"""Check if name matches pattern (regex or substring)"""
if not name:
return False
if use_regex:
try:
return bool(re.search(pattern, name, re.IGNORECASE))
except re.error:
# Invalid regex, fall back to substring match
return pattern.lower() in name.lower()
else:
return pattern.lower() in name.lower()
def matches_any_pattern(name, patterns, use_regex):
"""Check if name matches any of the patterns"""
if not patterns:
return True
return any(matches_pattern(name, p, use_regex) for p in patterns)
filtered = []
for lora in available_loras:
model_name = lora.get("model_name", "")
file_name = lora.get("file_name", "")
names_to_check = [n for n in [model_name, file_name] if n]
# Check exclude patterns first
excluded = False
if exclude_patterns:
for name in names_to_check:
if matches_any_pattern(name, exclude_patterns, use_regex):
excluded = True
break
if excluded:
continue
# Check include patterns
if include_patterns:
included = False
for name in names_to_check:
if matches_any_pattern(name, include_patterns, use_regex):
included = True
break
if not included:
continue
filtered.append(lora)
available_loras = filtered
return available_loras
async def get_cycler_list(
self,
pool_config: Optional[Dict] = None,
sort_by: str = "filename"
self, pool_config: Optional[Dict] = None, sort_by: str = "filename"
) -> List[Dict]:
"""
Get filtered and sorted LoRA list for cycling.
@@ -516,12 +635,18 @@ class LoraService(BaseModelService):
if sort_by == "model_name":
available_loras = sorted(
available_loras,
key=lambda x: (x.get("model_name") or x.get("file_name", "")).lower()
key=lambda x: (
(x.get("model_name") or x.get("file_name", "")).lower(),
x.get("file_path", "").lower(),
),
)
else: # Default to filename
available_loras = sorted(
available_loras,
key=lambda x: x.get("file_name", "").lower()
key=lambda x: (
x.get("file_name", "").lower(),
x.get("file_path", "").lower(),
),
)
# Return minimal data needed for cycling

View File

@@ -221,33 +221,45 @@ class ModelCache:
start_time = time.perf_counter()
reverse = (order == 'desc')
if sort_key == 'name':
# Natural sort by configured display name, case-insensitive
# Natural sort by configured display name, case-insensitive, with file_path as tie-breaker
result = natsorted(
data,
key=lambda x: self._get_display_name(x).lower(),
key=lambda x: (
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse
)
elif sort_key == 'date':
# Sort by modified timestamp (use .get() with default to handle missing fields)
# Sort by modified timestamp, fallback to name and path for stability
result = sorted(
data,
key=lambda x: x.get('modified', 0.0),
key=lambda x: (
x.get('modified', 0.0),
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse
)
elif sort_key == 'size':
# Sort by file size (use .get() with default to handle missing fields)
# Sort by file size, fallback to name and path for stability
result = sorted(
data,
key=lambda x: x.get('size', 0),
key=lambda x: (
x.get('size', 0),
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse
)
elif sort_key == 'usage':
# Sort by usage count, fallback to 0, then name for stability
# Sort by usage count, fallback to 0, then name and path for stability
return sorted(
data,
key=lambda x: (
x.get('usage_count', 0),
self._get_display_name(x).lower()
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse
)

View File

@@ -14,7 +14,6 @@ from ..utils.metadata_manager import MetadataManager
from ..utils.civitai_utils import resolve_license_info
from .model_cache import ModelCache
from .model_hash_index import ModelHashIndex
from ..utils.constants import PREVIEW_EXTENSIONS
from .model_lifecycle_service import delete_model_artifacts
from .service_registry import ServiceRegistry
from .websocket_manager import ws_manager
@@ -1442,14 +1441,13 @@ class ModelScanner:
file_path = self._hash_index.get_path(sha256.lower())
if not file_path:
return None
base_name = os.path.splitext(file_path)[0]
for ext in PREVIEW_EXTENSIONS:
preview_path = f"{base_name}{ext}"
if os.path.exists(preview_path):
return config.get_preview_static_url(preview_path)
dir_path = os.path.dirname(file_path)
base_name = os.path.splitext(os.path.basename(file_path))[0]
preview_path = find_preview_file(base_name, dir_path)
if preview_path:
return config.get_preview_static_url(preview_path)
return None
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:

View File

@@ -56,6 +56,7 @@ class PersistentModelCache:
"exclude",
"db_checked",
"last_checked_at",
"hash_status",
)
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
_instances: Dict[str, "PersistentModelCache"] = {}
@@ -186,6 +187,7 @@ class PersistentModelCache:
"civitai_deleted": bool(row["civitai_deleted"]),
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
"license_flags": int(license_value),
"hash_status": row["hash_status"] or "completed",
}
raw_data.append(item)
@@ -449,6 +451,7 @@ class PersistentModelCache:
exclude INTEGER,
db_checked INTEGER,
last_checked_at REAL,
hash_status TEXT,
PRIMARY KEY (model_type, file_path)
);
@@ -496,6 +499,7 @@ class PersistentModelCache:
"skip_metadata_refresh": "INTEGER DEFAULT 0",
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
"hash_status": "TEXT DEFAULT 'completed'",
}
for column, definition in required_columns.items():
@@ -570,6 +574,7 @@ class PersistentModelCache:
1 if item.get("exclude") else 0,
1 if item.get("db_checked") else 0,
float(item.get("last_checked_at") or 0.0),
item.get("hash_status", "completed"),
)
def _insert_model_sql(self) -> str:

View File

@@ -135,7 +135,8 @@ class RecipeCache:
"""Sort cached views. Caller must hold ``_lock``."""
self.sorted_by_name = natsorted(
self.raw_data, key=lambda x: x.get("title", "").lower()
self.raw_data,
key=lambda x: (x.get("title", "").lower(), x.get("file_path", "").lower()),
)
if not name_only:
self.sorted_by_date = sorted(

View File

@@ -1,4 +1,5 @@
"""Services responsible for recipe metadata analysis."""
from __future__ import annotations
import base64
@@ -69,7 +70,9 @@ class RecipeAnalysisService:
try:
metadata = self._exif_utils.extract_image_metadata(temp_path)
if not metadata:
return AnalysisResult({"error": "No metadata found in this image", "loras": []})
return AnalysisResult(
{"error": "No metadata found in this image", "loras": []}
)
return await self._parse_metadata(
metadata,
@@ -105,29 +108,33 @@ class RecipeAnalysisService:
if civitai_match:
image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info:
raise RecipeDownloadError("Failed to fetch image information from Civitai")
raise RecipeDownloadError(
"Failed to fetch image information from Civitai"
)
image_url = image_info.get("url")
if not image_url:
raise RecipeDownloadError("No image URL found in Civitai response")
is_video = image_info.get("type") == "video"
# Use optimized preview URLs if possible
rewritten_url, _ = rewrite_preview_url(image_url, media_type=image_info.get("type"))
rewritten_url, _ = rewrite_preview_url(
image_url, media_type=image_info.get("type")
)
if rewritten_url:
image_url = rewritten_url
if is_video:
# Extract extension from URL
url_path = image_url.split('?')[0].split('#')[0]
url_path = image_url.split("?")[0].split("#")[0]
extension = os.path.splitext(url_path)[1].lower() or ".mp4"
else:
extension = ".jpg"
temp_path = self._create_temp_path(suffix=extension)
await self._download_image(image_url, temp_path)
metadata = image_info.get("meta") if "meta" in image_info else None
if (
isinstance(metadata, dict)
@@ -135,15 +142,23 @@ class RecipeAnalysisService:
and isinstance(metadata["meta"], dict)
):
metadata = metadata["meta"]
# Validate that metadata contains meaningful recipe fields
# If not, treat as None to trigger EXIF extraction from downloaded image
if isinstance(metadata, dict) and not self._has_recipe_fields(metadata):
self._logger.debug(
"Civitai API metadata lacks recipe fields, will extract from EXIF"
)
metadata = None
else:
# Basic extension detection for non-Civitai URLs
url_path = url.split('?')[0].split('#')[0]
url_path = url.split("?")[0].split("#")[0]
extension = os.path.splitext(url_path)[1].lower()
if extension in [".mp4", ".webm"]:
is_video = True
else:
extension = ".jpg"
temp_path = self._create_temp_path(suffix=extension)
await self._download_image(url, temp_path)
@@ -211,7 +226,9 @@ class RecipeAnalysisService:
image_bytes = self._convert_tensor_to_png_bytes(latest_image)
if image_bytes is None:
raise RecipeValidationError("Cannot handle this data shape from metadata registry")
raise RecipeValidationError(
"Cannot handle this data shape from metadata registry"
)
return AnalysisResult(
{
@@ -222,6 +239,22 @@ class RecipeAnalysisService:
# Internal helpers -------------------------------------------------
def _has_recipe_fields(self, metadata: dict[str, Any]) -> bool:
"""Check if metadata contains meaningful recipe-related fields."""
recipe_fields = {
"prompt",
"negative_prompt",
"resources",
"hashes",
"params",
"generationData",
"Workflow",
"prompt_type",
"positive",
"negative",
}
return any(field in metadata for field in recipe_fields)
async def _parse_metadata(
self,
metadata: dict[str, Any],
@@ -234,7 +267,12 @@ class RecipeAnalysisService:
) -> AnalysisResult:
parser = self._recipe_parser_factory.create_parser(metadata)
if parser is None:
payload = {"error": "No parser found for this image", "loras": []}
# Provide more specific error message based on metadata source
if not metadata:
error_msg = "This image does not contain any generation metadata (prompt, models, or parameters)"
else:
error_msg = "No parser found for this image"
payload = {"error": error_msg, "loras": []}
if include_image_base64 and image_path:
payload["image_base64"] = self._encode_file(image_path)
payload["is_video"] = is_video
@@ -257,7 +295,9 @@ class RecipeAnalysisService:
matching_recipes: list[str] = []
if fingerprint:
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(fingerprint)
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(
fingerprint
)
result["matching_recipes"] = matching_recipes
return AnalysisResult(result)
@@ -269,7 +309,10 @@ class RecipeAnalysisService:
raise RecipeDownloadError(f"Failed to download image from URL: {result}")
def _metadata_not_found_response(self, path: str) -> AnalysisResult:
payload: dict[str, Any] = {"error": "No metadata found in this image", "loras": []}
payload: dict[str, Any] = {
"error": "No metadata found in this image",
"loras": [],
}
if os.path.exists(path):
payload["image_base64"] = self._encode_file(path)
return AnalysisResult(payload)
@@ -305,7 +348,9 @@ class RecipeAnalysisService:
if hasattr(tensor_image, "shape"):
self._logger.debug(
"Tensor shape: %s, dtype: %s", tensor_image.shape, getattr(tensor_image, "dtype", None)
"Tensor shape: %s, dtype: %s",
tensor_image.shape,
getattr(tensor_image, "dtype", None),
)
import torch # type: ignore[import-not-found]

View File

@@ -449,6 +449,11 @@ class TagFTSIndex:
Supports alias search: if the query matches an alias rather than
the tag_name, the result will include a "matched_alias" field.
Ranking is based on a combination of:
1. FTS5 bm25 relevance score (how well the text matches)
2. Post count (popularity)
3. Exact prefix match boost (tag_name starts with query)
Args:
query: The search query string.
categories: Optional list of category IDs to filter by.
@@ -457,7 +462,7 @@ class TagFTSIndex:
Returns:
List of dictionaries with tag_name, category, post_count,
and optionally matched_alias.
rank_score, and optionally matched_alias.
"""
# Ensure index is ready (lazy initialization)
if not self.ensure_ready():
@@ -473,35 +478,67 @@ class TagFTSIndex:
if not fts_query:
return []
query_lower = query.lower().strip()
try:
with self._lock:
conn = self._connect(readonly=True)
try:
# Build the SQL query - now also fetch aliases for matched_alias detection
# Use subquery for category filter to ensure FTS is evaluated first
# Build the SQL query with bm25 ranking
# FTS5 bm25() returns negative scores, lower is better
# We use -bm25() to get higher=better scores
# Weights: -100.0 for exact matches, 1.0 for others
# Add LOG10(post_count) weighting to boost popular tags
# Use CASE to boost tag_name prefix matches above alias matches
if categories:
placeholders = ",".join("?" * len(categories))
sql = f"""
SELECT t.tag_name, t.category, t.post_count, t.aliases
FROM tags t
WHERE t.rowid IN (
SELECT rowid FROM tag_fts WHERE searchable_text MATCH ?
)
SELECT t.tag_name, t.category, t.post_count, t.aliases,
CASE
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
ELSE 0
END AS is_tag_name_match,
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
FROM tag_fts
JOIN tags t ON tag_fts.rowid = t.rowid
WHERE tag_fts.searchable_text MATCH ?
AND t.category IN ({placeholders})
ORDER BY t.post_count DESC
ORDER BY is_tag_name_match DESC, rank_score DESC
LIMIT ? OFFSET ?
"""
params = [fts_query] + categories + [limit, offset]
# Escape special LIKE characters and add wildcard
query_escaped = (
query_lower.lstrip("/")
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
params = (
[query_escaped + "%", fts_query]
+ categories
+ [limit, offset]
)
else:
sql = """
SELECT t.tag_name, t.category, t.post_count, t.aliases
FROM tag_fts f
JOIN tags t ON f.rowid = t.rowid
WHERE f.searchable_text MATCH ?
ORDER BY t.post_count DESC
SELECT t.tag_name, t.category, t.post_count, t.aliases,
CASE
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
ELSE 0
END AS is_tag_name_match,
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
FROM tag_fts
JOIN tags t ON tag_fts.rowid = t.rowid
WHERE tag_fts.searchable_text MATCH ?
ORDER BY is_tag_name_match DESC, rank_score DESC
LIMIT ? OFFSET ?
"""
params = [fts_query, limit, offset]
query_escaped = (
query_lower.lstrip("/")
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
params = [query_escaped + "%", fts_query, limit, offset]
cursor = conn.execute(sql, params)
results = []
@@ -510,8 +547,17 @@ class TagFTSIndex:
"tag_name": row[0],
"category": row[1],
"post_count": row[2],
"is_tag_name_match": row[4] == 1,
"rank_score": row[5],
}
# Set is_exact_prefix based on tag_name match
tag_name = row[0]
if tag_name.lower().startswith(query_lower.lstrip("/")):
result["is_exact_prefix"] = True
else:
result["is_exact_prefix"] = result["is_tag_name_match"]
# Check if search matched an alias rather than the tag_name
matched_alias = self._find_matched_alias(query, row[0], row[3])
if matched_alias:

View File

@@ -40,49 +40,39 @@ async def calculate_sha256(file_path: str) -> str:
return sha256_hash.hexdigest()
def find_preview_file(base_name: str, dir_path: str) -> str:
"""Find preview file for given base name in directory"""
"""Find preview file for given base name in directory.
Performs an exact-case check first (fast path), then falls back to a
case-insensitive scan so that files like ``model.WEBP`` or ``model.Png``
are discovered on case-sensitive filesystems.
"""
temp_extensions = PREVIEW_EXTENSIONS.copy()
# Add example extension for compatibility
# https://github.com/willmiao/ComfyUI-Lora-Manager/issues/225
# The preview image will be optimized to lora-name.webp, so it won't affect other logic
temp_extensions.append(".example.0.jpeg")
# Fast path: exact-case match
for ext in temp_extensions:
full_pattern = os.path.join(dir_path, f"{base_name}{ext}")
if os.path.exists(full_pattern):
# Check if this is an image and not already webp
# TODO: disable the optimization for now, maybe add a config option later
# if ext.lower().endswith(('.jpg', '.jpeg', '.png')) and not ext.lower().endswith('.webp'):
# try:
# # Optimize the image to webp format
# webp_path = os.path.join(dir_path, f"{base_name}.webp")
# # Use ExifUtils to optimize the image
# with open(full_pattern, 'rb') as f:
# image_data = f.read()
# optimized_data, _ = ExifUtils.optimize_image(
# image_data=image_data,
# target_width=CARD_PREVIEW_WIDTH,
# format='webp',
# quality=85,
# preserve_metadata=False
# )
# # Save the optimized webp file
# with open(webp_path, 'wb') as f:
# f.write(optimized_data)
# logger.debug(f"Optimized preview image from {full_pattern} to {webp_path}")
# return webp_path.replace(os.sep, "/")
# except Exception as e:
# logger.error(f"Error optimizing preview image {full_pattern}: {e}")
# # Fall back to original file if optimization fails
# return full_pattern.replace(os.sep, "/")
# Return the original path for webp images or non-image files
return full_pattern.replace(os.sep, "/")
# Slow path: case-insensitive match for systems with mixed-case extensions
# (e.g. .WEBP, .Png, .JPG placed manually or by external tools)
try:
dir_entries = os.listdir(dir_path)
except OSError:
return ""
base_lower = base_name.lower()
for ext in temp_extensions:
target = f"{base_lower}{ext}" # ext is already lowercase
for entry in dir_entries:
if entry.lower() == target:
return os.path.join(dir_path, entry).replace(os.sep, "/")
return ""
def get_preview_extension(preview_path: str) -> str:

View File

@@ -4,32 +4,40 @@ from datetime import datetime
import os
from .model_utils import determine_base_model
@dataclass
class BaseModelMetadata:
"""Base class for all model metadata structures"""
file_name: str # The filename without extension
model_name: str # The model's name defined by the creator
file_path: str # Full path to the model file
size: int # File size in bytes
modified: float # Timestamp when the model was added to the management system
sha256: str # SHA256 hash of the file
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
preview_url: str # Preview image URL
preview_nsfw_level: int = 0 # NSFW level of the preview image
notes: str = "" # Additional notes
from_civitai: bool = True # Whether from Civitai
civitai: Dict[str, Any] = field(default_factory=dict) # Civitai API data if available
tags: List[str] = None # Model tags
file_name: str # The filename without extension
model_name: str # The model's name defined by the creator
file_path: str # Full path to the model file
size: int # File size in bytes
modified: float # Timestamp when the model was added to the management system
sha256: str # SHA256 hash of the file
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
preview_url: str # Preview image URL
preview_nsfw_level: int = 0 # NSFW level of the preview image
notes: str = "" # Additional notes
from_civitai: bool = True # Whether from Civitai
civitai: Dict[str, Any] = field(
default_factory=dict
) # Civitai API data if available
tags: List[str] = None # Model tags
modelDescription: str = "" # Full model description
civitai_deleted: bool = False # Whether deleted from Civitai
favorite: bool = False # Whether the model is a favorite
exclude: bool = False # Whether to exclude this model from the cache
db_checked: bool = False # Whether checked in archive DB
skip_metadata_refresh: bool = False # Whether to skip this model during bulk metadata refresh
favorite: bool = False # Whether the model is a favorite
exclude: bool = False # Whether to exclude this model from the cache
db_checked: bool = False # Whether checked in archive DB
skip_metadata_refresh: bool = (
False # Whether to skip this model during bulk metadata refresh
)
metadata_source: Optional[str] = None # Last provider that supplied metadata
last_checked_at: float = 0 # Last checked timestamp
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
_unknown_fields: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False) # Store unknown fields
_unknown_fields: Dict[str, Any] = field(
default_factory=dict, repr=False, compare=False
) # Store unknown fields
def __post_init__(self):
# Initialize empty lists to avoid mutable default parameter issue
@@ -40,211 +48,238 @@ class BaseModelMetadata:
self.tags = []
@classmethod
def from_dict(cls, data: Dict) -> 'BaseModelMetadata':
def from_dict(cls, data: Dict) -> "BaseModelMetadata":
"""Create instance from dictionary"""
data_copy = data.copy()
# Use cached fields if available, otherwise compute them
if not hasattr(cls, '_known_fields_cache'):
if not hasattr(cls, "_known_fields_cache"):
known_fields = set()
for c in cls.mro():
if hasattr(c, '__annotations__'):
if hasattr(c, "__annotations__"):
known_fields.update(c.__annotations__.keys())
cls._known_fields_cache = known_fields
known_fields = cls._known_fields_cache
# Extract fields that match our class attributes
fields_to_use = {k: v for k, v in data_copy.items() if k in known_fields}
# Store unknown fields separately
unknown_fields = {k: v for k, v in data_copy.items() if k not in known_fields and not k.startswith('_')}
unknown_fields = {
k: v
for k, v in data_copy.items()
if k not in known_fields and not k.startswith("_")
}
# Create instance with known fields
instance = cls(**fields_to_use)
# Add unknown fields as a separate attribute
instance._unknown_fields = unknown_fields
return instance
def to_dict(self) -> Dict:
"""Convert to dictionary for JSON serialization"""
result = asdict(self)
# Remove private fields
result = {k: v for k, v in result.items() if not k.startswith('_')}
result = {k: v for k, v in result.items() if not k.startswith("_")}
# Add back unknown fields if they exist
if hasattr(self, '_unknown_fields'):
if hasattr(self, "_unknown_fields"):
result.update(self._unknown_fields)
return result
def update_civitai_info(self, civitai_data: Dict) -> None:
"""Update Civitai information"""
self.civitai = civitai_data
def update_file_info(self, file_path: str) -> None:
"""Update metadata with actual file information"""
def update_file_info(self, file_path: str, update_timestamps: bool = False) -> None:
"""
Update metadata with actual file information.
Args:
file_path: Path to the model file
update_timestamps: If True, update size and modified from filesystem.
If False (default), only update file_path and file_name.
Set to True only when file has been moved/relocated.
"""
if os.path.exists(file_path):
self.size = os.path.getsize(file_path)
self.modified = os.path.getmtime(file_path)
self.file_path = file_path.replace(os.sep, '/')
# Update file_name when file_path changes
if update_timestamps:
# Only update size and modified when file has been relocated
self.size = os.path.getsize(file_path)
self.modified = os.path.getmtime(file_path)
# Always update paths when this method is called
self.file_path = file_path.replace(os.sep, "/")
self.file_name = os.path.splitext(os.path.basename(file_path))[0]
@staticmethod
def generate_unique_filename(target_dir: str, base_name: str, extension: str, hash_provider: callable = None) -> str:
def generate_unique_filename(
target_dir: str, base_name: str, extension: str, hash_provider: callable = None
) -> str:
"""Generate a unique filename to avoid conflicts
Args:
target_dir: Target directory path
base_name: Base filename without extension
extension: File extension including the dot
hash_provider: A callable that returns the SHA256 hash when needed
Returns:
str: Unique filename that doesn't conflict with existing files
"""
original_filename = f"{base_name}{extension}"
target_path = os.path.join(target_dir, original_filename)
# If no conflict, return original filename
if not os.path.exists(target_path):
return original_filename
# Only compute hash when needed
if hash_provider:
sha256_hash = hash_provider()
else:
sha256_hash = "0000"
# Generate short hash (first 4 characters of SHA256)
short_hash = sha256_hash[:4] if sha256_hash else "0000"
# Try with short hash suffix
unique_filename = f"{base_name}-{short_hash}{extension}"
unique_path = os.path.join(target_dir, unique_filename)
# If still conflicts, add incremental number
counter = 1
while os.path.exists(unique_path):
unique_filename = f"{base_name}-{short_hash}-{counter}{extension}"
unique_path = os.path.join(target_dir, unique_filename)
counter += 1
return unique_filename
@dataclass
class LoraMetadata(BaseModelMetadata):
"""Represents the metadata structure for a Lora model"""
usage_tips: str = "{}" # Usage tips for the model, json string
usage_tips: str = "{}" # Usage tips for the model, json string
@classmethod
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'LoraMetadata':
def from_civitai_info(
cls, version_info: Dict, file_info: Dict, save_path: str
) -> "LoraMetadata":
"""Create LoraMetadata instance from Civitai version info"""
file_name = file_info.get('name', '')
base_model = determine_base_model(version_info.get('baseModel', ''))
file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get("baseModel", ""))
# Extract tags and description if available
tags = []
description = ""
model_data = version_info.get('model') or {}
if 'tags' in model_data:
tags = model_data['tags']
if 'description' in model_data:
description = model_data['description']
model_data = version_info.get("model") or {}
if "tags" in model_data:
tags = model_data["tags"]
if "description" in model_data:
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get('name', os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, '/'),
size=file_info.get('sizeKB', 0) * 1024,
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(),
sha256=(file_info.get("hashes") or {}).get("SHA256", "").lower(),
base_model=base_model,
preview_url='', # Will be updated after preview download
preview_nsfw_level=0, # Will be updated after preview download
preview_url="", # Will be updated after preview download
preview_nsfw_level=0, # Will be updated after preview download
from_civitai=True,
civitai=version_info,
tags=tags,
modelDescription=description
modelDescription=description,
)
@dataclass
class CheckpointMetadata(BaseModelMetadata):
"""Represents the metadata structure for a Checkpoint model"""
sub_type: str = "checkpoint" # Model sub-type (checkpoint, diffusion_model, etc.)
@classmethod
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'CheckpointMetadata':
def from_civitai_info(
cls, version_info: Dict, file_info: Dict, save_path: str
) -> "CheckpointMetadata":
"""Create CheckpointMetadata instance from Civitai version info"""
file_name = file_info.get('name', '')
base_model = determine_base_model(version_info.get('baseModel', ''))
sub_type = version_info.get('type', 'checkpoint')
file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get("baseModel", ""))
sub_type = version_info.get("type", "checkpoint")
# Extract tags and description if available
tags = []
description = ""
model_data = version_info.get('model') or {}
if 'tags' in model_data:
tags = model_data['tags']
if 'description' in model_data:
description = model_data['description']
model_data = version_info.get("model") or {}
if "tags" in model_data:
tags = model_data["tags"]
if "description" in model_data:
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get('name', os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, '/'),
size=file_info.get('sizeKB', 0) * 1024,
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(),
sha256=(file_info.get("hashes") or {}).get("SHA256", "").lower(),
base_model=base_model,
preview_url='', # Will be updated after preview download
preview_url="", # Will be updated after preview download
preview_nsfw_level=0,
from_civitai=True,
civitai=version_info,
sub_type=sub_type,
tags=tags,
modelDescription=description
modelDescription=description,
)
@dataclass
class EmbeddingMetadata(BaseModelMetadata):
"""Represents the metadata structure for an Embedding model"""
sub_type: str = "embedding"
@classmethod
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'EmbeddingMetadata':
def from_civitai_info(
cls, version_info: Dict, file_info: Dict, save_path: str
) -> "EmbeddingMetadata":
"""Create EmbeddingMetadata instance from Civitai version info"""
file_name = file_info.get('name', '')
base_model = determine_base_model(version_info.get('baseModel', ''))
sub_type = version_info.get('type', 'embedding')
file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get("baseModel", ""))
sub_type = version_info.get("type", "embedding")
# Extract tags and description if available
tags = []
description = ""
model_data = version_info.get('model') or {}
if 'tags' in model_data:
tags = model_data['tags']
if 'description' in model_data:
description = model_data['description']
model_data = version_info.get("model") or {}
if "tags" in model_data:
tags = model_data["tags"]
if "description" in model_data:
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get('name', os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, '/'),
size=file_info.get('sizeKB', 0) * 1024,
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(),
sha256=(file_info.get("hashes") or {}).get("SHA256", "").lower(),
base_model=base_model,
preview_url='', # Will be updated after preview download
preview_url="", # Will be updated after preview download
preview_nsfw_level=0,
from_civitai=True,
civitai=version_info,
sub_type=sub_type,
tags=tags,
modelDescription=description
modelDescription=description,
)

View File

@@ -7,33 +7,47 @@ from ..config import config
from ..services.settings_manager import get_settings_manager
import asyncio
def get_lora_info(lora_name):
"""Get the lora path and trigger words from cache"""
async def _get_lora_info_async():
scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data()
for item in cache.raw_data:
if item.get('file_name') == lora_name:
file_path = item.get('file_path')
if item.get("file_name") == lora_name:
file_path = item.get("file_path")
if file_path:
for root in config.loras_roots:
root = root.replace(os.sep, '/')
# Check all lora roots including extra paths
all_roots = list(config.loras_roots or []) + list(
config.extra_loras_roots or []
)
for root in all_roots:
root = root.replace(os.sep, "/")
if file_path.startswith(root):
relative_path = os.path.relpath(file_path, root).replace(os.sep, '/')
relative_path = os.path.relpath(file_path, root).replace(
os.sep, "/"
)
# Get trigger words from civitai metadata
civitai = item.get('civitai', {})
trigger_words = civitai.get('trainedWords', []) if civitai else []
civitai = item.get("civitai", {})
trigger_words = (
civitai.get("trainedWords", []) if civitai else []
)
return relative_path, trigger_words
# If not found in any root, return path with trigger words from cache
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
return lora_name, []
try:
# Check if we're already in an event loop
loop = asyncio.get_running_loop()
# If we're in a running loop, we need to use a different approach
# Create a new thread to run the async code
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
@@ -41,11 +55,11 @@ def get_lora_info(lora_name):
return new_loop.run_until_complete(_get_lora_info_async())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
# No event loop is running, we can use asyncio.run()
return asyncio.run(_get_lora_info_async())
@@ -53,33 +67,34 @@ def get_lora_info(lora_name):
def get_lora_info_absolute(lora_name):
"""Get the absolute lora path and trigger words from cache
Returns:
tuple: (absolute_path, trigger_words) where absolute_path is the full
tuple: (absolute_path, trigger_words) where absolute_path is the full
file system path to the LoRA file, or original lora_name if not found
"""
async def _get_lora_info_absolute_async():
scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data()
for item in cache.raw_data:
if item.get('file_name') == lora_name:
file_path = item.get('file_path')
if item.get("file_name") == lora_name:
file_path = item.get("file_path")
if file_path:
# Return absolute path directly
# Get trigger words from civitai metadata
civitai = item.get('civitai', {})
trigger_words = civitai.get('trainedWords', []) if civitai else []
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
return lora_name, []
try:
# Check if we're already in an event loop
loop = asyncio.get_running_loop()
# If we're in a running loop, we need to use a different approach
# Create a new thread to run the async code
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
@@ -87,50 +102,161 @@ def get_lora_info_absolute(lora_name):
return new_loop.run_until_complete(_get_lora_info_absolute_async())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
# No event loop is running, we can use asyncio.run()
return asyncio.run(_get_lora_info_absolute_async())
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
"""
Check if text matches pattern using fuzzy matching.
Returns True if similarity ratio is above threshold.
"""
if not pattern or not text:
return False
# Convert both to lowercase for case-insensitive matching
text = text.lower()
pattern = pattern.lower()
# Split pattern into words
search_words = pattern.split()
# Check each word
for word in search_words:
# First check if word is a substring (faster)
if word in text:
def get_checkpoint_info_absolute(checkpoint_name):
"""Get the absolute checkpoint path and metadata from cache
Supports ComfyUI-style model names (e.g., "folder/model_name.ext")
Args:
checkpoint_name: The model name, can be:
- ComfyUI format: "folder/model_name.safetensors"
- Simple name: "model_name"
Returns:
tuple: (absolute_path, metadata) where absolute_path is the full
file system path to the checkpoint file, or original checkpoint_name if not found,
metadata is the full model metadata dict or None
"""
async def _get_checkpoint_info_absolute_async():
from ..services.service_registry import ServiceRegistry
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get model roots for matching
model_roots = scanner.get_model_roots()
# Normalize the checkpoint name
normalized_name = checkpoint_name.replace(os.sep, "/")
for item in cache.raw_data:
file_path = item.get("file_path", "")
if not file_path:
continue
# If not found as substring, try fuzzy matching
# Check if any part of the text matches this word
found_match = False
for text_part in text.split():
ratio = SequenceMatcher(None, text_part, word).ratio()
if ratio >= threshold:
found_match = True
break
if not found_match:
return False
# All words found either as substrings or fuzzy matches
return True
# Format the stored path as ComfyUI-style name
formatted_name = _format_model_name_for_comfyui(file_path, model_roots)
# Match by formatted name (normalize separators for robust comparison)
if formatted_name.replace(os.sep, "/") == normalized_name or formatted_name == checkpoint_name:
return file_path, item
# Also try matching by basename only (for backward compatibility)
file_name = item.get("file_name", "")
if (
file_name == checkpoint_name
or file_name == os.path.splitext(normalized_name)[0]
):
return file_path, item
return checkpoint_name, None
try:
# Check if we're already in an event loop
loop = asyncio.get_running_loop()
# If we're in a running loop, we need to use a different approach
# Create a new thread to run the async code
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(
_get_checkpoint_info_absolute_async()
)
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
# No event loop is running, we can use asyncio.run()
return asyncio.run(_get_checkpoint_info_absolute_async())
def _format_model_name_for_comfyui(file_path: str, model_roots: list) -> str:
"""Format file path to ComfyUI-style model name (relative path with extension)
Example: /path/to/checkpoints/Illustrious/model.safetensors -> Illustrious/model.safetensors
Args:
file_path: Absolute path to the model file
model_roots: List of model root directories
Returns:
ComfyUI-style model name with relative path and extension
"""
# Find the matching root and get relative path
for root in model_roots:
try:
# Normalize paths for comparison
norm_file = os.path.normcase(os.path.abspath(file_path))
norm_root = os.path.normcase(os.path.abspath(root))
# Add trailing separator for prefix check
if not norm_root.endswith(os.sep):
norm_root += os.sep
if norm_file.startswith(norm_root):
# Use os.path.relpath to get relative path with OS-native separator
return os.path.relpath(file_path, root)
except (ValueError, TypeError):
continue
# If no root matches, just return the basename with extension
return os.path.basename(file_path)
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
"""
Check if text matches pattern using fuzzy matching.
Returns True if similarity ratio is above threshold.
"""
if not pattern or not text:
return False
# Convert both to lowercase for case-insensitive matching
text = text.lower()
pattern = pattern.lower()
# Split pattern into words
search_words = pattern.split()
# Check each word
for word in search_words:
# First check if word is a substring (faster)
if word in text:
continue
# If not found as substring, try fuzzy matching
# Check if any part of the text matches this word
found_match = False
for text_part in text.split():
ratio = SequenceMatcher(None, text_part, word).ratio()
if ratio >= threshold:
found_match = True
break
if not found_match:
return False
# All words found either as substrings or fuzzy matches
return True
def sanitize_folder_name(name: str, replacement: str = "_") -> str:
"""Sanitize a folder name by removing or replacing invalid characters.
@@ -156,10 +282,13 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
# Collapse repeated replacement characters to a single instance
if replacement:
sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized)
sanitized = sanitized.strip(replacement)
# Remove trailing spaces or periods which are invalid on Windows
sanitized = sanitized.rstrip(" .")
# Combine stripping to be idempotent:
# Right side: strip replacement, space, and dot (Windows restriction)
# Left side: strip replacement and space (leading dots are allowed)
sanitized = sanitized.rstrip(" ." + replacement).lstrip(" " + replacement)
else:
# If no replacement, just strip spaces and dots from right, spaces from left
sanitized = sanitized.rstrip(" .").lstrip(" ")
if not sanitized:
return "unnamed"
@@ -170,25 +299,25 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
def calculate_recipe_fingerprint(loras):
"""
Calculate a unique fingerprint for a recipe based on its LoRAs.
The fingerprint is created by sorting LoRA hashes, filtering invalid entries,
normalizing strength values to 2 decimal places, and joining in format:
hash1:strength1|hash2:strength2|...
Args:
loras (list): List of LoRA dictionaries with hash and strength values
Returns:
str: The calculated fingerprint
"""
if not loras:
return ""
valid_loras = []
for lora in loras:
if lora.get("exclude", False):
continue
hash_value = lora.get("hash", "")
if isinstance(hash_value, str):
hash_value = hash_value.lower()
@@ -206,18 +335,23 @@ def calculate_recipe_fingerprint(loras):
strength = round(float(strength_val), 2)
except (ValueError, TypeError):
strength = 1.0
valid_loras.append((hash_value, strength))
# Sort by hash
valid_loras.sort()
# Join in format hash1:strength1|hash2:strength2|...
fingerprint = "|".join([f"{hash_value}:{strength}" for hash_value, strength in valid_loras])
fingerprint = "|".join(
[f"{hash_value}:{strength}" for hash_value, strength in valid_loras]
)
return fingerprint
def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora') -> str:
def calculate_relative_path_for_model(
model_data: Dict, model_type: str = "lora"
) -> str:
"""Calculate relative path for existing model using template from settings
Args:
@@ -233,77 +367,80 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora'
# If template is empty, return empty path (flat structure)
if not path_template:
return ''
return ""
# Get base model name from model metadata
civitai_data = model_data.get('civitai', {})
civitai_data = model_data.get("civitai", {})
# For CivitAI models, prefer civitai data only if 'id' exists; for non-CivitAI models, use model_data directly
if civitai_data and civitai_data.get('id') is not None:
base_model = model_data.get('base_model', '')
if civitai_data and civitai_data.get("id") is not None:
base_model = model_data.get("base_model", "")
# Get author from civitai creator data
creator_info = civitai_data.get('creator') or {}
author = creator_info.get('username') or 'Anonymous'
creator_info = civitai_data.get("creator") or {}
author = creator_info.get("username") or "Anonymous"
else:
# Fallback to model_data fields for non-CivitAI models
base_model = model_data.get('base_model', '')
author = 'Anonymous' # Default for non-CivitAI models
base_model = model_data.get("base_model", "")
author = "Anonymous" # Default for non-CivitAI models
model_tags = model_data.get('tags', [])
model_tags = model_data.get("tags", [])
# Apply mapping if available
base_model_mappings = settings_manager.get('base_model_path_mappings', {})
base_model_mappings = settings_manager.get("base_model_path_mappings", {})
mapped_base_model = base_model_mappings.get(base_model, base_model)
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
lowercase_tags = [tag.lower() for tag in model_tags if isinstance(tag, str)]
first_tag = settings_manager.resolve_priority_tag_for_model(lowercase_tags, model_type)
first_tag = settings_manager.resolve_priority_tag_for_model(
lowercase_tags, model_type
)
if not first_tag:
first_tag = 'no tags' # Default if no tags available
first_tag = "no tags" # Default if no tags available
# Format the template with available data
model_name = sanitize_folder_name(model_data.get('model_name', ''))
version_name = ''
model_name = sanitize_folder_name(model_data.get("model_name", ""))
version_name = ""
if isinstance(civitai_data, dict):
version_name = sanitize_folder_name(civitai_data.get('name') or '')
version_name = sanitize_folder_name(civitai_data.get("name") or "")
formatted_path = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
formatted_path = formatted_path.replace('{model_name}', model_name)
formatted_path = formatted_path.replace('{version_name}', version_name)
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
formatted_path = formatted_path.replace("{first_tag}", first_tag)
formatted_path = formatted_path.replace("{author}", author)
formatted_path = formatted_path.replace("{model_name}", model_name)
formatted_path = formatted_path.replace("{version_name}", version_name)
if model_type == 'embedding':
formatted_path = formatted_path.replace(' ', '_')
if model_type == "embedding":
formatted_path = formatted_path.replace(" ", "_")
return formatted_path
def remove_empty_dirs(path):
"""Recursively remove empty directories starting from the given path.
Args:
path (str): Root directory to start cleaning from
Returns:
int: Number of empty directories removed
"""
removed_count = 0
if not os.path.isdir(path):
return removed_count
# List all files in directory
files = os.listdir(path)
# Process all subdirectories first
for file in files:
full_path = os.path.join(path, file)
if os.path.isdir(full_path):
removed_count += remove_empty_dirs(full_path)
# Check if directory is now empty (after processing subdirectories)
if not os.listdir(path):
try:
@@ -311,5 +448,5 @@ def remove_empty_dirs(path):
removed_count += 1
except OSError:
pass
return removed_count

View File

@@ -1,5 +1,5 @@
[pytest]
addopts = -v --import-mode=importlib -m "not performance"
addopts = -v --import-mode=importlib -m "not performance" --ignore=__init__.py
testpaths = tests
python_files = test_*.py
python_classes = Test*

View File

@@ -345,6 +345,7 @@ class StandaloneLoraManager(LoraManager):
"/ws/download-progress", ws_manager.handle_download_connection
)
app.router.add_get("/ws/init-progress", ws_manager.handle_init_connection)
app.router.add_get("/ws/batch-import-progress", ws_manager.handle_connection)
# Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services())

View File

@@ -0,0 +1,677 @@
/* Batch Import Modal Styles */
/* Step Containers */
.batch-import-step {
margin: var(--space-2) 0;
}
/* Section Description */
.section-description {
color: var(--text-color);
opacity: 0.8;
margin-bottom: var(--space-2);
font-size: 0.95em;
}
/* Hint Text */
.input-hint {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-color);
opacity: 0.7;
font-size: 0.85em;
margin-top: 6px;
}
.input-hint i {
color: var(--lora-accent);
}
/* Textarea Styling */
#batchUrlInput {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-family: inherit;
font-size: 0.9em;
resize: vertical;
transition: border-color 0.2s, box-shadow 0.2s;
}
#batchUrlInput:focus {
outline: none;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
}
/* Checkbox Group */
.checkbox-group {
margin-top: var(--space-2);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
color: var(--text-color);
font-size: 0.95em;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
display: none;
}
.checkmark {
width: 18px;
height: 18px;
border: 2px solid var(--border-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
background: var(--bg-color);
}
.checkbox-label input[type="checkbox"]:checked + .checkmark {
background: var(--lora-accent);
border-color: var(--lora-accent);
}
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
color: var(--lora-text);
font-size: 12px;
}
/* Batch Options */
.batch-options {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border-color);
}
/* Input with Button */
.input-with-button {
display: flex;
gap: 8px;
}
.input-with-button input {
flex: 1;
min-width: 0;
}
.input-with-button button {
flex-shrink: 0;
white-space: nowrap;
padding: 8px 16px;
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: background-color 0.2s;
}
.input-with-button button:hover {
background: oklch(from var(--lora-accent) l c h / 0.9);
}
/* Dark theme adjustments for input-with-button */
[data-theme="dark"] .input-with-button button {
background: var(--lora-accent);
color: var(--lora-text);
}
[data-theme="dark"] .input-with-button button:hover {
background: oklch(from var(--lora-accent) calc(l - 0.1) c h);
}
/* Directory Browser */
.directory-browser {
margin-top: var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--lora-surface);
overflow: hidden;
}
.browser-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s;
}
.back-btn:hover {
border-color: var(--lora-accent);
background: var(--bg-color);
}
.back-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.current-path {
flex: 1;
padding: 6px 10px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.browser-content {
max-height: 300px;
overflow-y: auto;
padding: 12px;
}
.browser-section {
margin-bottom: 16px;
}
.browser-section:last-child {
margin-bottom: 0;
}
.section-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 0.85em;
color: var(--text-color);
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
}
.section-label i {
color: var(--lora-accent);
}
.folder-list,
.file-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.folder-item,
.file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.folder-item:hover,
.file-item:hover {
background: var(--lora-surface-hover, oklch(from var(--lora-accent) l c h / 0.1));
border-color: var(--lora-accent);
}
.folder-item.selected,
.file-item.selected {
background: oklch(from var(--lora-accent) l c h / 0.15);
border-color: var(--lora-accent);
}
.folder-item i {
color: #fbbf24;
font-size: 1.1em;
}
.file-item i {
color: var(--text-color);
opacity: 0.6;
font-size: 1em;
}
.item-name {
flex: 1;
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-size {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
}
.browser-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--bg-color);
border-top: 1px solid var(--border-color);
}
.stats {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.8;
}
.stats span {
font-weight: 600;
color: var(--lora-accent);
}
/* Dark theme adjustments */
[data-theme="dark"] .directory-browser {
background: var(--card-bg);
}
[data-theme="dark"] .browser-header,
[data-theme="dark"] .browser-footer {
background: var(--lora-surface);
}
[data-theme="dark"] .folder-item i {
color: #fcd34d;
}
/* Progress Container */
.batch-progress-container {
padding: var(--space-3);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
margin-bottom: var(--space-3);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
}
.progress-status {
display: flex;
align-items: center;
gap: 10px;
}
.status-icon {
color: var(--lora-accent);
font-size: 1.1em;
}
.status-icon i {
animation: fa-spin 2s infinite linear;
}
.status-text {
font-weight: 500;
color: var(--text-color);
}
.progress-percentage {
font-size: 1.2em;
font-weight: 600;
color: var(--lora-accent);
}
/* Progress Bar */
.progress-bar-container {
height: 8px;
background: var(--bg-color);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--space-3);
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--lora-accent), oklch(from var(--lora-accent) calc(l + 0.1) c h));
border-radius: 4px;
transition: width 0.3s ease;
}
/* Progress Stats */
.progress-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
}
.stat-item.success {
border-left: 3px solid #00B87A;
}
.stat-item.failed {
border-left: 3px solid var(--lora-error);
}
.stat-item.skipped {
border-left: 3px solid var(--lora-warning);
}
.stat-label {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 4px;
}
.stat-value {
font-size: 1.4em;
font-weight: 600;
color: var(--text-color);
}
/* Current Item */
.current-item {
display: flex;
align-items: baseline;
gap: 10px;
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
}
.current-item-label {
color: var(--text-color);
opacity: 0.7;
flex-shrink: 0;
}
.current-item-name {
color: var(--text-color);
font-weight: 500;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
/* Results Container */
.batch-results-container {
padding: var(--space-3);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
margin-bottom: var(--space-3);
}
.results-header {
text-align: center;
margin-bottom: var(--space-3);
}
.results-icon {
font-size: 3em;
color: #00B87A;
margin-bottom: var(--space-1);
}
.results-icon.warning {
color: var(--lora-warning);
}
.results-icon.error {
color: var(--lora-error);
}
.results-title {
font-size: 1.3em;
font-weight: 600;
color: var(--text-color);
}
/* Results Summary - Matches progress-stats styling */
.results-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.result-card {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
text-align: center;
}
.result-card.success {
border-left: 3px solid #00B87A;
}
.result-card.failed {
border-left: 3px solid var(--lora-error);
}
.result-card.skipped {
border-left: 3px solid var(--lora-warning);
}
.result-label {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 4px;
}
.result-value {
font-size: 1.4em;
font-weight: 600;
color: var(--text-color);
}
/* Results Details */
.results-details {
border-top: 1px solid var(--border-color);
padding-top: var(--space-2);
}
.details-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
cursor: pointer;
color: var(--lora-accent);
font-weight: 500;
border-radius: var(--border-radius-xs);
transition: background 0.2s;
}
.details-toggle:hover {
background: oklch(from var(--lora-accent) l c h / 0.1);
}
.details-toggle i {
transition: transform 0.2s;
}
.details-toggle.expanded i {
transform: rotate(180deg);
}
.details-list {
max-height: 250px;
overflow-y: auto;
margin-top: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
}
/* Result Item in Details */
.result-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
}
.result-item:last-child {
border-bottom: none;
}
.result-item-status {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
}
.result-item-status.success {
background: oklch(from #00B87A l c h / 0.2);
color: #00B87A;
}
.result-item-status.failed {
background: oklch(from var(--lora-error) l c h / 0.2);
color: var(--lora-error);
}
.result-item-status.skipped {
background: oklch(from var(--lora-warning) l c h / 0.2);
color: var(--lora-warning);
}
.result-item-info {
flex: 1;
min-width: 0;
}
.result-item-name {
font-weight: 500;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-item-error {
font-size: 0.8em;
color: var(--lora-error);
margin-top: 2px;
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.progress-stats,
.results-summary {
grid-template-columns: repeat(2, 1fr);
}
.batch-progress-container,
.batch-results-container {
padding: var(--space-2);
}
}
/* Dark Theme Adjustments */
[data-theme="dark"] .batch-progress-container,
[data-theme="dark"] .batch-results-container {
background: var(--card-bg);
}
[data-theme="dark"] .stat-item,
[data-theme="dark"] .result-card,
[data-theme="dark"] .current-item,
[data-theme="dark"] .details-list {
background: var(--lora-surface);
}
/* Cancelled State */
.batch-progress-container.cancelled .progress-bar {
background: var(--lora-warning);
}
.batch-progress-container.cancelled .status-icon {
color: var(--lora-warning);
}
/* Error State */
.batch-progress-container.error .progress-bar {
background: var(--lora-error);
}
.batch-progress-container.error .status-icon {
color: var(--lora-error);
}
/* Completed State */
.batch-progress-container.completed .progress-bar {
background: #00B87A;
}
.batch-progress-container.completed .status-icon {
color: #00B87A;
}
.batch-progress-container.completed .status-icon i {
animation: none;
}
.batch-progress-container.completed .status-icon i::before {
content: '\f00c';
}

View File

@@ -251,7 +251,7 @@ export class BaseModelApiClient {
replaceModelPreview(filePath) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*,video/mp4';
input.accept = 'image/*,image/webp,video/mp4';
input.onchange = async () => {
if (!input.files || !input.files[0]) return;

View File

@@ -117,7 +117,10 @@ export class BulkContextMenu extends BaseContextMenu {
countSkipStatus(skipState) {
let count = 0;
for (const filePath of state.selectedModels) {
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(filePath)
: filePath.replace(/["\\]/g, '\\$&');
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card) {
const isSkipped = card.dataset.skip_metadata_refresh === 'true';
if (isSkipped === skipState) {

View File

@@ -201,8 +201,9 @@ class RecipeCard {
this.recipe.favorite = isFavorite;
// Re-find star icon in case of re-render during fault
const filePathForXpath = this.recipe.file_path.replace(/"/g, '&quot;');
const currentCard = card.ownerDocument.evaluate(
`.//*[@data-filepath="${this.recipe.file_path}"]`,
`.//*[@data-filepath="${filePathForXpath}"]`,
card.ownerDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
).singleNodeValue || card;

View File

@@ -846,8 +846,14 @@ function setupLoraSpecificFields(filePath) {
const currentPath = resolveFilePath();
if (!currentPath) return;
const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) ||
document.querySelector(`.model-card[data-filepath="${filePath}"]`);
const escapedCurrentPath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(currentPath)
: currentPath.replace(/["\\]/g, '\\$&');
const escapedFilePath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(filePath)
: filePath.replace(/["\\]/g, '\\$&');
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedCurrentPath}"]`) ||
document.querySelector(`.model-card[data-filepath="${escapedFilePath}"]`);
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
if (key === 'strength_range') {

View File

@@ -49,7 +49,10 @@ function formatPresetKey(key) {
*/
window.removePreset = async function(key) {
const filePath = document.querySelector('#modelModal .modal-content .file-path').dataset.filepath;
const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(filePath)
: filePath.replace(/["\\]/g, '\\$&');
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
delete currentPresets[key];

View File

@@ -2,6 +2,7 @@
* MetadataPanel.js
* Generates metadata panels for showcase media items
*/
import { escapeHtml } from '../utils.js';
/**
* Generate metadata panel HTML
@@ -49,6 +50,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
}
if (prompt) {
prompt = escapeHtml(prompt);
content += `
<div class="metadata-row prompt-row">
<span class="metadata-label">Prompt:</span>
@@ -64,6 +66,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
}
if (negativePrompt) {
negativePrompt = escapeHtml(negativePrompt);
content += `
<div class="metadata-row prompt-row">
<span class="metadata-label">Negative Prompt:</span>
@@ -80,4 +83,4 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
content += '</div></div>';
return content;
}
}

View File

@@ -0,0 +1,815 @@
import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { WS_ENDPOINTS } from '../api/apiConfig.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
/**
* Manager for batch importing recipes from multiple images
*/
export class BatchImportManager {
constructor() {
this.initialized = false;
this.inputMode = 'urls'; // 'urls' or 'directory'
this.operationId = null;
this.wsConnection = null;
this.pollingInterval = null;
this.progress = null;
this.results = null;
this.isCancelled = false;
}
/**
* Show the batch import modal
*/
showModal() {
if (!this.initialized) {
this.initialize();
}
this.resetState();
modalManager.showModal('batchImportModal');
}
/**
* Initialize the manager
*/
initialize() {
this.initialized = true;
// Add event listener for persisting "Skip images without metadata" choice
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
if (skipNoMetadata) {
skipNoMetadata.addEventListener('change', (e) => {
setStorageItem('batch_import_skip_no_metadata', e.target.checked);
});
}
}
/**
* Reset all state to initial values
*/
resetState() {
this.inputMode = 'urls';
this.operationId = null;
this.progress = null;
this.results = null;
this.isCancelled = false;
// Reset UI
this.showStep('batchInputStep');
this.toggleInputMode('urls');
// Clear inputs
const urlInput = document.getElementById('batchUrlInput');
if (urlInput) urlInput.value = '';
const directoryInput = document.getElementById('batchDirectoryInput');
if (directoryInput) directoryInput.value = '';
const tagsInput = document.getElementById('batchTagsInput');
if (tagsInput) tagsInput.value = '';
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
if (skipNoMetadata) {
// Load preference from storage, defaulting to true
skipNoMetadata.checked = getStorageItem('batch_import_skip_no_metadata', true);
}
const recursiveCheck = document.getElementById('batchRecursiveCheck');
if (recursiveCheck) recursiveCheck.checked = true;
// Reset progress UI
this.updateProgressUI({
total: 0,
completed: 0,
success: 0,
failed: 0,
skipped: 0,
progress_percent: 0,
current_item: '',
status: 'pending'
});
// Reset results
const detailsList = document.getElementById('batchDetailsList');
if (detailsList) {
detailsList.innerHTML = '';
detailsList.style.display = 'none';
}
const toggleIcon = document.getElementById('resultsToggleIcon');
if (toggleIcon) {
toggleIcon.classList.remove('expanded');
}
// Clean up any existing connections
this.cleanupConnections();
// Focus on the URL input field for better UX
setTimeout(() => {
const urlInput = document.getElementById('batchUrlInput');
if (urlInput) {
urlInput.focus();
}
}, 100);
}
/**
* Show a specific step in the modal
*/
showStep(stepId) {
document.querySelectorAll('.batch-import-step').forEach(step => {
step.style.display = 'none';
});
const step = document.getElementById(stepId);
if (step) {
step.style.display = 'block';
}
}
/**
* Toggle between URL list and directory input modes
*/
toggleInputMode(mode) {
this.inputMode = mode;
// Update toggle buttons
document.querySelectorAll('.toggle-btn[data-mode]').forEach(btn => {
btn.classList.remove('active');
});
const activeBtn = document.querySelector(`.toggle-btn[data-mode="${mode}"]`);
if (activeBtn) {
activeBtn.classList.add('active');
}
// Show/hide appropriate sections
const urlSection = document.getElementById('urlListSection');
const directorySection = document.getElementById('directorySection');
if (urlSection && directorySection) {
if (mode === 'urls') {
urlSection.style.display = 'block';
directorySection.style.display = 'none';
} else {
urlSection.style.display = 'none';
directorySection.style.display = 'block';
}
}
}
/**
* Start the batch import process
*/
async startImport() {
const data = this.collectInputData();
if (!this.validateInput(data)) {
return;
}
try {
// Show progress step
this.showStep('batchProgressStep');
// Start the import
const response = await this.sendStartRequest(data);
if (response.success) {
this.operationId = response.operation_id;
this.isCancelled = false;
// Connect to WebSocket for real-time updates
this.connectWebSocket();
// Start polling as fallback
this.startPolling();
} else {
showToast('toast.recipes.batchImportFailed', { message: response.error }, 'error');
this.showStep('batchInputStep');
}
} catch (error) {
console.error('Error starting batch import:', error);
showToast('toast.recipes.batchImportFailed', { message: error.message }, 'error');
this.showStep('batchInputStep');
}
}
/**
* Collect input data from the form
*/
collectInputData() {
const data = {
mode: this.inputMode,
tags: [],
skip_no_metadata: false
};
// Collect tags
const tagsInput = document.getElementById('batchTagsInput');
if (tagsInput && tagsInput.value.trim()) {
data.tags = tagsInput.value.split(',').map(t => t.trim()).filter(t => t);
}
// Collect skip_no_metadata
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
if (skipNoMetadata) {
data.skip_no_metadata = skipNoMetadata.checked;
}
if (this.inputMode === 'urls') {
const urlInput = document.getElementById('batchUrlInput');
if (urlInput) {
const urls = urlInput.value.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
// Convert to items format
data.items = urls.map(url => ({
source: url,
type: this.detectUrlType(url)
}));
}
} else {
const directoryInput = document.getElementById('batchDirectoryInput');
if (directoryInput) {
data.directory = directoryInput.value.trim();
}
const recursiveCheck = document.getElementById('batchRecursiveCheck');
if (recursiveCheck) {
data.recursive = recursiveCheck.checked;
}
}
return data;
}
/**
* Detect if a URL is http or local path
*/
detectUrlType(url) {
if (url.startsWith('http://') || url.startsWith('https://')) {
return 'url';
}
return 'local_path';
}
/**
* Validate the input data
*/
validateInput(data) {
if (data.mode === 'urls') {
if (!data.items || data.items.length === 0) {
showToast('toast.recipes.batchImportNoUrls', {}, 'error');
return false;
}
} else {
if (!data.directory) {
showToast('toast.recipes.batchImportNoDirectory', {}, 'error');
return false;
}
}
return true;
}
/**
* Send the start batch import request
*/
async sendStartRequest(data) {
const endpoint = data.mode === 'urls'
? '/api/lm/recipes/batch-import/start'
: '/api/lm/recipes/batch-import/directory';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
return await response.json();
}
/**
* Connect to WebSocket for real-time progress updates
*/
connectWebSocket() {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/ws/batch-import-progress?id=${this.operationId}`;
this.wsConnection = new WebSocket(wsUrl);
this.wsConnection.onopen = () => {
console.log('Connected to batch import progress WebSocket');
};
this.wsConnection.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'batch_import_progress') {
this.handleProgressUpdate(data);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
this.wsConnection.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.wsConnection.onclose = () => {
console.log('WebSocket connection closed');
};
}
/**
* Start polling for progress updates (fallback)
*/
startPolling() {
this.pollingInterval = setInterval(async () => {
if (!this.operationId || this.isCancelled) {
return;
}
try {
const response = await fetch(`/api/lm/recipes/batch-import/progress?operation_id=${this.operationId}`);
const data = await response.json();
if (data.success && data.progress) {
this.handleProgressUpdate(data.progress);
}
} catch (error) {
console.error('Error polling progress:', error);
}
}, 1000);
}
/**
* Handle progress update from WebSocket or polling
*/
handleProgressUpdate(progress) {
this.progress = progress;
this.updateProgressUI(progress);
// Check if import is complete
if (progress.status === 'completed' || progress.status === 'cancelled' ||
(progress.total > 0 && progress.completed >= progress.total)) {
this.importComplete(progress);
}
}
/**
* Update the progress UI
*/
updateProgressUI(progress) {
// Update progress bar
const progressBar = document.getElementById('batchProgressBar');
if (progressBar) {
progressBar.style.width = `${progress.progress_percent || 0}%`;
}
// Update percentage
const progressPercent = document.getElementById('batchProgressPercent');
if (progressPercent) {
progressPercent.textContent = `${Math.round(progress.progress_percent || 0)}%`;
}
// Update stats
const totalCount = document.getElementById('batchTotalCount');
if (totalCount) totalCount.textContent = progress.total || 0;
const successCount = document.getElementById('batchSuccessCount');
if (successCount) successCount.textContent = progress.success || 0;
const failedCount = document.getElementById('batchFailedCount');
if (failedCount) failedCount.textContent = progress.failed || 0;
const skippedCount = document.getElementById('batchSkippedCount');
if (skippedCount) skippedCount.textContent = progress.skipped || 0;
// Update current item
const currentItem = document.getElementById('batchCurrentItem');
if (currentItem) {
currentItem.textContent = progress.current_item || '-';
}
// Update status text
const statusText = document.getElementById('batchStatusText');
if (statusText) {
if (progress.status === 'running') {
statusText.textContent = translate('recipes.batchImport.importing', {}, 'Importing...');
} else if (progress.status === 'completed') {
statusText.textContent = translate('recipes.batchImport.completed', {}, 'Import completed');
} else if (progress.status === 'cancelled') {
statusText.textContent = translate('recipes.batchImport.cancelled', {}, 'Import cancelled');
}
}
// Update container classes
const progressContainer = document.querySelector('.batch-progress-container');
if (progressContainer) {
progressContainer.classList.remove('completed', 'cancelled', 'error');
if (progress.status === 'completed') {
progressContainer.classList.add('completed');
} else if (progress.status === 'cancelled') {
progressContainer.classList.add('cancelled');
} else if (progress.failed > 0 && progress.failed === progress.total) {
progressContainer.classList.add('error');
}
}
}
/**
* Handle import completion
*/
importComplete(progress) {
this.cleanupConnections();
this.results = progress;
// Refresh recipes list to show newly imported recipes
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
window.recipeManager.loadRecipes();
}
// Show results step
this.showStep('batchResultsStep');
this.updateResultsUI(progress);
}
/**
* Update the results UI
*/
updateResultsUI(progress) {
// Update summary cards
const resultsTotal = document.getElementById('resultsTotal');
if (resultsTotal) resultsTotal.textContent = progress.total || 0;
const resultsSuccess = document.getElementById('resultsSuccess');
if (resultsSuccess) resultsSuccess.textContent = progress.success || 0;
const resultsFailed = document.getElementById('resultsFailed');
if (resultsFailed) resultsFailed.textContent = progress.failed || 0;
const resultsSkipped = document.getElementById('resultsSkipped');
if (resultsSkipped) resultsSkipped.textContent = progress.skipped || 0;
// Update header based on results
const resultsHeader = document.getElementById('batchResultsHeader');
if (resultsHeader) {
const icon = resultsHeader.querySelector('.results-icon i');
const title = resultsHeader.querySelector('.results-title');
if (this.isCancelled) {
if (icon) {
icon.className = 'fas fa-stop-circle';
icon.parentElement.classList.add('warning');
}
if (title) title.textContent = translate('recipes.batchImport.cancelled', {}, 'Import cancelled');
} else if (progress.failed === 0 && progress.success > 0) {
if (icon) {
icon.className = 'fas fa-check-circle';
icon.parentElement.classList.remove('warning', 'error');
}
if (title) title.textContent = translate('recipes.batchImport.completed', {}, 'Import completed');
} else if (progress.failed > 0 && progress.success === 0) {
if (icon) {
icon.className = 'fas fa-times-circle';
icon.parentElement.classList.add('error');
}
if (title) title.textContent = translate('recipes.batchImport.failed', {}, 'Import failed');
} else {
if (icon) {
icon.className = 'fas fa-exclamation-circle';
icon.parentElement.classList.add('warning');
}
if (title) title.textContent = translate('recipes.batchImport.completedWithErrors', {}, 'Completed with errors');
}
}
}
/**
* Toggle the results details visibility
*/
toggleResultsDetails() {
const detailsList = document.getElementById('batchDetailsList');
const toggleIcon = document.getElementById('resultsToggleIcon');
const toggle = document.querySelector('.details-toggle');
if (detailsList && toggleIcon) {
if (detailsList.style.display === 'none') {
detailsList.style.display = 'block';
toggleIcon.classList.add('expanded');
if (toggle) toggle.classList.add('expanded');
// Load details if not loaded
if (detailsList.children.length === 0 && this.results && this.results.items) {
this.loadResultsDetails(this.results.items);
}
} else {
detailsList.style.display = 'none';
toggleIcon.classList.remove('expanded');
if (toggle) toggle.classList.remove('expanded');
}
}
}
/**
* Load results details into the list
*/
loadResultsDetails(items) {
const detailsList = document.getElementById('batchDetailsList');
if (!detailsList) return;
detailsList.innerHTML = '';
items.forEach(item => {
const resultItem = document.createElement('div');
resultItem.className = 'result-item';
const statusClass = item.status === 'success' ? 'success' :
item.status === 'failed' ? 'failed' : 'skipped';
const statusIcon = item.status === 'success' ? 'check' :
item.status === 'failed' ? 'times' : 'forward';
resultItem.innerHTML = `
<div class="result-item-status ${statusClass}">
<i class="fas fa-${statusIcon}"></i>
</div>
<div class="result-item-info">
<div class="result-item-name">${this.escapeHtml(item.source || item.current_item || 'Unknown')}</div>
${item.error_message ? `<div class="result-item-error">${this.escapeHtml(item.error_message)}</div>` : ''}
</div>
`;
detailsList.appendChild(resultItem);
});
}
/**
* Cancel the current import
*/
async cancelImport() {
if (!this.operationId) return;
this.isCancelled = true;
try {
const response = await fetch('/api/lm/recipes/batch-import/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ operation_id: this.operationId })
});
const data = await response.json();
if (data.success) {
showToast('toast.recipes.batchImportCancelling', {}, 'info');
} else {
showToast('toast.recipes.batchImportCancelFailed', { message: data.error }, 'error');
}
} catch (error) {
console.error('Error cancelling import:', error);
showToast('toast.recipes.batchImportCancelFailed', { message: error.message }, 'error');
}
}
/**
* Close modal and reset state
*/
closeAndReset() {
this.cleanupConnections();
this.resetState();
modalManager.closeModal('batchImportModal');
}
/**
* Start a new import (from results step)
*/
startNewImport() {
this.resetState();
this.showStep('batchInputStep');
}
/**
* Toggle directory browser visibility
*/
toggleDirectoryBrowser() {
const browser = document.getElementById('batchDirectoryBrowser');
if (browser) {
const isVisible = browser.style.display !== 'none';
browser.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
// Load initial directory when opening
const currentPath = document.getElementById('batchDirectoryInput').value;
this.loadDirectory(currentPath || '/');
}
}
}
/**
* Load directory contents
*/
async loadDirectory(path) {
try {
const response = await fetch('/api/lm/recipes/browse-directory', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ path })
});
const data = await response.json();
if (data.success) {
this.renderDirectoryBrowser(data);
} else {
showToast('toast.recipes.batchImportBrowseFailed', { message: data.error }, 'error');
}
} catch (error) {
console.error('Error loading directory:', error);
showToast('toast.recipes.batchImportBrowseFailed', { message: error.message }, 'error');
}
}
/**
* Render directory browser UI
*/
renderDirectoryBrowser(data) {
const currentPathEl = document.getElementById('batchCurrentPath');
const folderList = document.getElementById('batchFolderList');
const fileList = document.getElementById('batchFileList');
const directoryCount = document.getElementById('batchDirectoryCount');
const imageCount = document.getElementById('batchImageCount');
if (currentPathEl) {
currentPathEl.textContent = data.current_path;
}
// Render folders
if (folderList) {
folderList.innerHTML = '';
// Add parent directory if available
if (data.parent_path) {
const parentItem = this.createFolderItem('..', data.parent_path, true);
folderList.appendChild(parentItem);
}
data.directories.forEach(dir => {
folderList.appendChild(this.createFolderItem(dir.name, dir.path));
});
}
// Render files
if (fileList) {
fileList.innerHTML = '';
data.image_files.forEach(file => {
fileList.appendChild(this.createFileItem(file.name, file.path, file.size));
});
}
// Update stats
if (directoryCount) {
directoryCount.textContent = data.directory_count;
}
if (imageCount) {
imageCount.textContent = data.image_count;
}
}
/**
* Create folder item element
*/
createFolderItem(name, path, isParent = false) {
const item = document.createElement('div');
item.className = 'folder-item';
item.dataset.path = path;
item.innerHTML = `
<i class="fas fa-folder${isParent ? '' : ''}"></i>
<span class="item-name">${this.escapeHtml(name)}</span>
`;
item.addEventListener('click', () => {
if (isParent) {
this.navigateToParentDirectory();
} else {
this.loadDirectory(path);
}
});
return item;
}
/**
* Create file item element
*/
createFileItem(name, path, size) {
const item = document.createElement('div');
item.className = 'file-item';
item.dataset.path = path;
item.innerHTML = `
<i class="fas fa-image"></i>
<span class="item-name">${this.escapeHtml(name)}</span>
<span class="item-size">${this.formatFileSize(size)}</span>
`;
return item;
}
/**
* Navigate to parent directory
*/
navigateToParentDirectory() {
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
if (currentPath) {
// Get parent path using path manipulation
const lastSeparator = currentPath.lastIndexOf('/');
const parentPath = lastSeparator > 0 ? currentPath.substring(0, lastSeparator) : currentPath;
this.loadDirectory(parentPath);
}
}
/**
* Select current directory
*/
selectCurrentDirectory() {
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
const directoryInput = document.getElementById('batchDirectoryInput');
if (currentPath && directoryInput) {
directoryInput.value = currentPath;
this.toggleDirectoryBrowser(); // Close browser
showToast('toast.recipes.batchImportDirectorySelected', { path: currentPath }, 'success');
}
}
/**
* Format file size for display
*/
formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Browse for directory using File System Access API (deprecated - kept for compatibility)
*/
async browseDirectory() {
// Now redirects to the new directory browser
this.toggleDirectoryBrowser();
}
/**
* Clean up WebSocket and polling connections
*/
cleanupConnections() {
if (this.wsConnection) {
if (this.wsConnection.readyState === WebSocket.OPEN ||
this.wsConnection.readyState === WebSocket.CONNECTING) {
this.wsConnection.close();
}
this.wsConnection = null;
}
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Create singleton instance
export const batchImportManager = new BatchImportManager();

View File

@@ -568,7 +568,8 @@ export class BulkManager {
}
deselectItem(filepath) {
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
const escapedPath = this.escapeAttributeValue(filepath);
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card) {
card.classList.remove('selected');
}
@@ -632,7 +633,8 @@ export class BulkManager {
for (const filepath of state.selectedModels) {
const metadata = metadataCache.get(filepath);
if (metadata) {
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
const escapedPath = this.escapeAttributeValue(filepath);
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card) {
this.updateMetadataCacheFromCard(filepath, card);
}

View File

@@ -134,6 +134,19 @@ export class ModalManager {
});
}
// Add batchImportModal registration
const batchImportModal = document.getElementById('batchImportModal');
if (batchImportModal) {
this.registerModal('batchImportModal', {
element: batchImportModal,
onClose: () => {
this.getModal('batchImportModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add recipeModal registration
const recipeModal = document.getElementById('recipeModal');
if (recipeModal) {

View File

@@ -1,6 +1,7 @@
// Recipe manager module
import { appCore } from './core.js';
import { ImportManager } from './managers/ImportManager.js';
import { BatchImportManager } from './managers/BatchImportManager.js';
import { RecipeModal } from './components/RecipeModal.js';
import { state, getCurrentPageState } from './state/index.js';
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
@@ -46,6 +47,10 @@ class RecipeManager {
// Initialize ImportManager
this.importManager = new ImportManager();
// Initialize BatchImportManager and make it globally accessible
this.batchImportManager = new BatchImportManager();
window.batchImportManager = this.batchImportManager;
// Initialize RecipeModal
this.recipeModal = new RecipeModal();

View File

@@ -7,7 +7,10 @@ let pendingExcludePath = null;
export function showDeleteModal(filePath) {
pendingDeletePath = filePath;
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(filePath)
: filePath.replace(/["\\]/g, '\\$&');
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
const modelName = card ? card.dataset.name : filePath.split('/').pop();
const modal = modalManager.getModal('deleteModal').element;
const modelInfo = modal.querySelector('.delete-model-info');
@@ -47,7 +50,10 @@ export function closeDeleteModal() {
export function showExcludeModal(filePath) {
pendingExcludePath = filePath;
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(filePath)
: filePath.replace(/["\\]/g, '\\$&');
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
const modelName = card ? card.dataset.name : filePath.split('/').pop();
const modal = modalManager.getModal('excludeModal').element;
const modelInfo = modal.querySelector('.exclude-model-info');

View File

@@ -197,7 +197,10 @@ export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
}
export function openCivitai(filePath) {
const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(filePath)
: filePath.replace(/["\\]/g, '\\$&');
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (!loraCard) return;
const metaData = JSON.parse(loraCard.dataset.meta);

View File

@@ -0,0 +1,206 @@
<div id="batchImportModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<button class="close" onclick="modalManager.closeModal('batchImportModal')">&times;</button>
<h2>{{ t('recipes.batchImport.title') }}</h2>
</div>
<!-- Step 1: Input Selection -->
<div class="batch-import-step" id="batchInputStep">
<div class="import-mode-toggle">
<button class="toggle-btn active" data-mode="urls" onclick="batchImportManager.toggleInputMode('urls')">
<i class="fas fa-link"></i> {{ t('recipes.batchImport.urlList') }}
</button>
<button class="toggle-btn" data-mode="directory" onclick="batchImportManager.toggleInputMode('directory')">
<i class="fas fa-folder"></i> {{ t('recipes.batchImport.directory') }}
</button>
</div>
<!-- URL List Section -->
<div class="import-section" id="urlListSection">
<p class="section-description">{{ t('recipes.batchImport.urlDescription') }}</p>
<div class="input-group">
<label for="batchUrlInput">{{ t('recipes.batchImport.urlsLabel') }}</label>
<textarea id="batchUrlInput" rows="8" placeholder="{{ t('recipes.batchImport.urlsPlaceholder') }}"></textarea>
<div class="input-hint">
<i class="fas fa-info-circle"></i>
{{ t('recipes.batchImport.urlsHint') }}
</div>
</div>
</div>
<!-- Directory Section -->
<div class="import-section" id="directorySection" style="display: none;">
<p class="section-description">{{ t('recipes.batchImport.directoryDescription') }}</p>
<div class="input-group">
<label for="batchDirectoryInput">{{ t('recipes.batchImport.directoryPath') }}</label>
<div class="input-with-button">
<input type="text" id="batchDirectoryInput" placeholder="{{ t('recipes.batchImport.directoryPlaceholder') }}" autocomplete="off">
<button class="secondary-btn" onclick="batchImportManager.toggleDirectoryBrowser()">
<i class="fas fa-folder-open"></i> {{ t('recipes.batchImport.browse') }}
</button>
</div>
</div>
<!-- Directory Browser -->
<div class="directory-browser" id="batchDirectoryBrowser" style="display: none;">
<div class="browser-header">
<button class="back-btn" onclick="batchImportManager.navigateToParentDirectory()" title="{{ t('recipes.batchImport.backToParent') }}">
<i class="fas fa-arrow-up"></i>
</button>
<div class="current-path" id="batchCurrentPath"></div>
</div>
<div class="browser-content">
<div class="browser-section">
<div class="section-label"><i class="fas fa-folder"></i> {{ t('recipes.batchImport.folders') }}</div>
<div class="folder-list" id="batchFolderList"></div>
</div>
<div class="browser-section">
<div class="section-label"><i class="fas fa-image"></i> {{ t('recipes.batchImport.imageFiles') }}</div>
<div class="file-list" id="batchFileList"></div>
</div>
</div>
<div class="browser-footer">
<div class="stats">
<span id="batchDirectoryCount">0</span> {{ t('recipes.batchImport.folders') }},
<span id="batchImageCount">0</span> {{ t('recipes.batchImport.images') }}
</div>
<button class="primary-btn" onclick="batchImportManager.selectCurrentDirectory()">
<i class="fas fa-check"></i> {{ t('recipes.batchImport.selectFolder') }}
</button>
</div>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="batchRecursiveCheck" checked>
<span class="checkmark"></span>
{{ t('recipes.batchImport.recursive') }}
</label>
</div>
</div>
<!-- Common Options -->
<div class="batch-options">
<div class="input-group">
<label for="batchTagsInput">{{ t('recipes.batchImport.tagsOptional') }}</label>
<input type="text" id="batchTagsInput" placeholder="{{ t('recipes.batchImport.tagsPlaceholder') }}">
<div class="input-hint">
<i class="fas fa-info-circle"></i>
{{ t('recipes.batchImport.tagsHint') }}
</div>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="batchSkipNoMetadata">
<span class="checkmark"></span>
{{ t('recipes.batchImport.skipNoMetadata') }}
</label>
</div>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="modalManager.closeModal('batchImportModal')">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" id="batchImportStartBtn" onclick="batchImportManager.startImport()">
<i class="fas fa-play"></i> {{ t('recipes.batchImport.start') }}
</button>
</div>
</div>
<!-- Step 2: Progress -->
<div class="batch-import-step" id="batchProgressStep" style="display: none;">
<div class="batch-progress-container">
<div class="progress-header">
<div class="progress-status">
<span class="status-icon"><i class="fas fa-spinner fa-spin"></i></span>
<span class="status-text" id="batchStatusText">{{ t('recipes.batchImport.importing') }}</span>
</div>
<div class="progress-percentage" id="batchProgressPercent">0%</div>
</div>
<div class="progress-bar-container">
<div class="progress-bar" id="batchProgressBar" style="width: 0%"></div>
</div>
<div class="progress-stats">
<div class="stat-item">
<span class="stat-label">{{ t('recipes.batchImport.total') }}</span>
<span class="stat-value" id="batchTotalCount">0</span>
</div>
<div class="stat-item success">
<span class="stat-label">{{ t('recipes.batchImport.success') }}</span>
<span class="stat-value" id="batchSuccessCount">0</span>
</div>
<div class="stat-item failed">
<span class="stat-label">{{ t('recipes.batchImport.failed') }}</span>
<span class="stat-value" id="batchFailedCount">0</span>
</div>
<div class="stat-item skipped">
<span class="stat-label">{{ t('recipes.batchImport.skipped') }}</span>
<span class="stat-value" id="batchSkippedCount">0</span>
</div>
</div>
<div class="current-item" id="batchCurrentItemContainer">
<span class="current-item-label">{{ t('recipes.batchImport.current') }}</span>
<span class="current-item-name" id="batchCurrentItem">-</span>
</div>
</div>
<div class="modal-actions">
<button class="secondary-btn" id="batchCancelBtn" onclick="batchImportManager.cancelImport()">
<i class="fas fa-stop"></i> {{ t('recipes.batchImport.cancel') }}
</button>
</div>
</div>
<!-- Step 3: Results -->
<div class="batch-import-step" id="batchResultsStep" style="display: none;">
<div class="batch-results-container">
<div class="results-header" id="batchResultsHeader">
<div class="results-icon">
<i class="fas fa-check-circle"></i>
</div>
<div class="results-title">{{ t('recipes.batchImport.completed') }}</div>
</div>
<div class="results-summary">
<div class="result-card total">
<span class="result-label">{{ t('recipes.batchImport.total') }}</span>
<span class="result-value" id="resultsTotal">0</span>
</div>
<div class="result-card success">
<span class="result-label">{{ t('recipes.batchImport.success') }}</span>
<span class="result-value" id="resultsSuccess">0</span>
</div>
<div class="result-card failed">
<span class="result-label">{{ t('recipes.batchImport.failed') }}</span>
<span class="result-value" id="resultsFailed">0</span>
</div>
<div class="result-card skipped">
<span class="result-label">{{ t('recipes.batchImport.skipped') }}</span>
<span class="result-value" id="resultsSkipped">0</span>
</div>
</div>
<div class="results-details" id="batchResultsDetails">
<div class="details-toggle" onclick="batchImportManager.toggleResultsDetails()">
<i class="fas fa-chevron-down" id="resultsToggleIcon"></i>
<span>{{ t('recipes.batchImport.viewDetails') }}</span>
</div>
<div class="details-list" id="batchDetailsList" style="display: none;">
<!-- Details will be populated dynamically -->
</div>
</div>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="batchImportManager.closeAndReset()">{{ t('common.actions.close') }}</button>
<button class="primary-btn" onclick="batchImportManager.startNewImport()">
<i class="fas fa-plus"></i> {{ t('recipes.batchImport.newImport') }}
</button>
</div>
</div>
</div>
</div>

View File

@@ -7,10 +7,12 @@
<link rel="stylesheet" href="/loras_static/css/components/card.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/components/recipe-modal.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/components/batch-import-modal.css?v={{ version }}">
{% endblock %}
{% block additional_components %}
{% include 'components/import_modal.html' %}
{% include 'components/batch_import_modal.html' %}
{% include 'components/recipe_modal.html' %}
<div id="recipeContextMenu" class="context-menu" style="display: none;">
@@ -85,6 +87,10 @@
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
t('recipes.controls.import.action') }}</button>
</div>
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{
t('recipes.batchImport.action') }}</button>
</div>
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>

View File

@@ -36,8 +36,8 @@ class TestCheckpointPathOverlap:
config._preview_root_paths = set()
config._cached_fingerprint = None
# Call the method under test
result = config._prepare_checkpoint_paths(
# Call the method under test - now returns a tuple
all_paths, checkpoint_roots, unet_roots = config._prepare_checkpoint_paths(
[str(checkpoints_link)], [str(unet_link)]
)
@@ -50,21 +50,27 @@ class TestCheckpointPathOverlap:
]
assert len(warning_messages) == 1
assert "checkpoints" in warning_messages[0].lower()
assert "diffusion_models" in warning_messages[0].lower() or "unet" in warning_messages[0].lower()
assert (
"diffusion_models" in warning_messages[0].lower()
or "unet" in warning_messages[0].lower()
)
# Verify warning mentions backward compatibility fallback
assert "falling back" in warning_messages[0].lower() or "backward compatibility" in warning_messages[0].lower()
assert (
"falling back" in warning_messages[0].lower()
or "backward compatibility" in warning_messages[0].lower()
)
# Verify only one path is returned (deduplication still works)
assert len(result) == 1
assert len(all_paths) == 1
# Prioritizes checkpoints path for backward compatibility
assert _normalize(result[0]) == _normalize(str(checkpoints_link))
assert _normalize(all_paths[0]) == _normalize(str(checkpoints_link))
# Verify checkpoints_roots has the path (prioritized)
assert len(config.checkpoints_roots) == 1
assert _normalize(config.checkpoints_roots[0]) == _normalize(str(checkpoints_link))
# Verify checkpoint_roots has the path (prioritized)
assert len(checkpoint_roots) == 1
assert _normalize(checkpoint_roots[0]) == _normalize(str(checkpoints_link))
# Verify unet_roots is empty (overlapping paths removed)
assert config.unet_roots == []
assert unet_roots == []
def test_non_overlapping_paths_no_warning(
self, monkeypatch: pytest.MonkeyPatch, tmp_path, caplog
@@ -83,7 +89,7 @@ class TestCheckpointPathOverlap:
config._preview_root_paths = set()
config._cached_fingerprint = None
result = config._prepare_checkpoint_paths(
all_paths, checkpoint_roots, unet_roots = config._prepare_checkpoint_paths(
[str(checkpoints_dir)], [str(unet_dir)]
)
@@ -97,14 +103,14 @@ class TestCheckpointPathOverlap:
assert len(warning_messages) == 0
# Verify both paths are returned
assert len(result) == 2
normalized_result = [_normalize(p) for p in result]
assert len(all_paths) == 2
normalized_result = [_normalize(p) for p in all_paths]
assert _normalize(str(checkpoints_dir)) in normalized_result
assert _normalize(str(unet_dir)) in normalized_result
# Verify both roots are properly set
assert len(config.checkpoints_roots) == 1
assert len(config.unet_roots) == 1
assert len(checkpoint_roots) == 1
assert len(unet_roots) == 1
def test_partial_overlap_prioritizes_checkpoints(
self, monkeypatch: pytest.MonkeyPatch, tmp_path, caplog
@@ -129,9 +135,9 @@ class TestCheckpointPathOverlap:
config._cached_fingerprint = None
# One checkpoint path overlaps with one unet path
result = config._prepare_checkpoint_paths(
all_paths, checkpoint_roots, unet_roots = config._prepare_checkpoint_paths(
[str(shared_link), str(separate_checkpoint)],
[str(shared_link), str(separate_unet)]
[str(shared_link), str(separate_unet)],
)
# Verify warning was logged for the overlapping path
@@ -144,17 +150,20 @@ class TestCheckpointPathOverlap:
assert len(warning_messages) == 1
# Verify 3 unique paths (shared counted once as checkpoint, plus separate ones)
assert len(result) == 3
assert len(all_paths) == 3
# Verify the overlapping path appears in warning message
assert str(shared_link.name) in warning_messages[0] or str(shared_dir.name) in warning_messages[0]
assert (
str(shared_link.name) in warning_messages[0]
or str(shared_dir.name) in warning_messages[0]
)
# Verify checkpoints_roots includes both checkpoint paths (including the shared one)
assert len(config.checkpoints_roots) == 2
checkpoint_normalized = [_normalize(p) for p in config.checkpoints_roots]
# Verify checkpoint_roots includes both checkpoint paths (including the shared one)
assert len(checkpoint_roots) == 2
checkpoint_normalized = [_normalize(p) for p in checkpoint_roots]
assert _normalize(str(shared_link)) in checkpoint_normalized
assert _normalize(str(separate_checkpoint)) in checkpoint_normalized
# Verify unet_roots only includes the non-overlapping unet path
assert len(config.unet_roots) == 1
assert _normalize(config.unet_roots[0]) == _normalize(str(separate_unet))
assert len(unet_roots) == 1
assert _normalize(unet_roots[0]) == _normalize(str(separate_unet))

View File

@@ -156,4 +156,542 @@ describe('AutoComplete widget interactions', () => {
expect(highlighted).toContain('detail');
expect(highlighted).not.toMatch(/beta<\/span>/i);
});
it('handles arrow key navigation with virtual scrolling', async () => {
vi.useFakeTimers();
const mockItems = Array.from({ length: 50 }, (_, i) => `model_${i.toString().padStart(2, '0')}.safetensors`);
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('model');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
enableVirtualScroll: true,
itemHeight: 40,
visibleItems: 15,
pageSize: 20,
});
input.value = 'model';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(autoComplete.items.length).toBeGreaterThan(0);
expect(autoComplete.selectedIndex).toBe(0);
const initialSelectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
expect(initialSelectedEl).toBeDefined();
const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
input.dispatchEvent(arrowDownEvent);
expect(autoComplete.selectedIndex).toBe(1);
const secondSelectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
expect(secondSelectedEl).toBeDefined();
expect(secondSelectedEl?.dataset.index).toBe('1');
const arrowUpEvent = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
input.dispatchEvent(arrowUpEvent);
expect(autoComplete.selectedIndex).toBe(0);
const firstSelectedElAgain = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
expect(firstSelectedElAgain).toBeDefined();
expect(firstSelectedElAgain?.dataset.index).toBe('0');
});
it('maintains selection when scrolling to invisible items', async () => {
vi.useFakeTimers();
const mockItems = Array.from({ length: 100 }, (_, i) => `item_${i.toString().padStart(3, '0')}.safetensors`);
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('item');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.style.width = '400px';
input.style.height = '200px';
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
enableVirtualScroll: true,
itemHeight: 40,
visibleItems: 15,
pageSize: 20,
});
input.value = 'item';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(autoComplete.items.length).toBeGreaterThan(0);
autoComplete.selectedIndex = 14;
const scrollTopBefore = autoComplete.scrollContainer?.scrollTop || 0;
const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
input.dispatchEvent(arrowDownEvent);
await vi.runAllTimersAsync();
await Promise.resolve();
expect(autoComplete.selectedIndex).toBe(15);
const selectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
expect(selectedEl).toBeDefined();
expect(selectedEl?.dataset.index).toBe('15');
const scrollTopAfter = autoComplete.scrollContainer?.scrollTop || 0;
expect(scrollTopAfter).toBeGreaterThanOrEqual(scrollTopBefore);
});
it('replaces entire multi-word phrase when it matches selected tag (Danbooru convention)', async () => {
const mockTags = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
{ tag_name: 'looking_away', category: 0, post_count: 5678 },
];
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: mockTags }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = 'looking to the side';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
await autoComplete.insertSelection('looking_to_the_side');
expect(input.value).toBe('looking_to_the_side, ');
expect(autoComplete.dropdown.style.display).toBe('none');
expect(input.focus).toHaveBeenCalled();
});
it('replaces only last token when typing partial match (e.g., "hello 1gi" -> "1girl")', async () => {
const mockTags = [
{ tag_name: '1girl', category: 4, post_count: 500000 },
{ tag_name: '1boy', category: 4, post_count: 300000 },
];
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: mockTags }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('hello 1gi');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = 'hello 1gi';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = 'hello 1gi';
await autoComplete.insertSelection('1girl');
expect(input.value).toBe('hello 1girl, ');
});
it('replaces entire phrase for underscore tag match (e.g., "blue hair" -> "blue_hair")', async () => {
const mockTags = [
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
{ tag_name: 'blue_eyes', category: 0, post_count: 80000 },
];
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: mockTags }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = 'blue hair';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = 'blue hair';
await autoComplete.insertSelection('blue_hair');
expect(input.value).toBe('blue_hair, ');
});
it('handles multi-word phrase with preceding text correctly', async () => {
const mockTags = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
];
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: mockTags }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl, looking to the side');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '1girl, looking to the side';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = 'looking to the side';
await autoComplete.insertSelection('looking_to_the_side');
expect(input.value).toBe('1girl, looking_to_the_side, ');
});
it('replaces entire command and search term when using command mode with multi-word phrase', async () => {
const mockTags = [
{ tag_name: 'looking_to_the_side', category: 4, post_count: 1234 },
{ tag_name: 'looking_away', category: 4, post_count: 5678 },
];
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: mockTags }),
});
// Simulate "/char looking to the side" input
caretHelperInstance.getBeforeCursor.mockReturnValue('/char looking to the side');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '/char looking to the side';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
// Set up command mode state
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = '/char looking to the side';
await autoComplete.insertSelection('looking_to_the_side');
// Command part should be replaced along with search term
expect(input.value).toBe('looking_to_the_side, ');
});
it('replaces only last token when multi-word query does not exactly match selected tag', async () => {
const mockTags = [
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
{ tag_name: 'blue_eyes', category: 0, post_count: 80000 },
];
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: mockTags }),
});
// User types "looking to the blue" but selects "blue_hair" (doesn't match entire phrase)
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the blue');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = 'looking to the blue';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = 'looking to the blue';
await autoComplete.insertSelection('blue_hair');
// Only "blue" should be replaced, not the entire phrase
expect(input.value).toBe('looking to the blue_hair, ');
});
it('handles multiple consecutive spaces in multi-word phrase correctly', async () => {
const mockTags = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
];
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: mockTags }),
});
// Input with multiple spaces between words
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = 'looking to the side';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = 'looking to the side';
await autoComplete.insertSelection('looking_to_the_side');
// Multiple spaces should be normalized to single underscores for matching
expect(input.value).toBe('looking_to_the_side, ');
});
it('handles command mode with partial match replacing only last token', async () => {
const mockTags = [
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
];
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: mockTags }),
});
// Command mode but selected tag doesn't match entire search phrase
caretHelperInstance.getBeforeCursor.mockReturnValue('/general looking to the blue');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '/general looking to the blue';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
// Command mode with activeCommand
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = { categories: [0, 7], label: 'General' };
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = '/general looking to the blue';
await autoComplete.insertSelection('blue_hair');
// In command mode, the entire command + search term should be replaced
expect(input.value).toBe('blue_hair, ');
});
it('replaces entire phrase when selected tag starts with underscore version of search term (prefix match)', async () => {
const mockTags = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
];
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: mockTags }),
});
// User types partial phrase "looking to the" and selects "looking_to_the_side"
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = 'looking to the';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = 'looking to the';
await autoComplete.insertSelection('looking_to_the_side');
// Entire phrase should be replaced with selected tag (with underscores)
expect(input.value).toBe('looking_to_the_side, ');
});
it('inserts tag with underscores regardless of space replacement setting', async () => {
const mockTags = [
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
];
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: mockTags }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('blue');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = 'blue';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
await autoComplete.insertSelection('blue_hair');
// Tag should be inserted with underscores, not spaces
expect(input.value).toBe('blue_hair, ');
});
it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => {
const mockTags = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
];
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: mockTags }),
});
// User types suffix "to the side" and selects "looking_to_the_side"
caretHelperInstance.getBeforeCursor.mockReturnValue('to the side');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = 'to the side';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = 'to the side';
await autoComplete.insertSelection('looking_to_the_side');
// Entire phrase should be replaced with selected tag
expect(input.value).toBe('looking_to_the_side, ');
});
});

View File

@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
describe('Version Detection Logic', () => {
const parseVersion = (versionStr) => {
if (!versionStr || typeof versionStr !== 'string') {
return [0, 0, 0];
}
const cleanVersion = versionStr.replace(/^[vV]/, '').split('-')[0];
const parts = cleanVersion.split('.').map(part => parseInt(part, 10) || 0);
while (parts.length < 3) {
parts.push(0);
}
return parts;
};
const compareVersions = (version1, version2) => {
const v1 = typeof version1 === 'string' ? parseVersion(version1) : version1;
const v2 = typeof version2 === 'string' ? parseVersion(version2) : version2;
for (let i = 0; i < 3; i++) {
if (v1[i] > v2[i]) return 1;
if (v1[i] < v2[i]) return -1;
}
return 0;
};
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
const supportsActionBarButtons = (version) => {
return compareVersions(version, MIN_VERSION_FOR_ACTION_BAR) >= 0;
};
it('should parse version strings correctly', () => {
expect(parseVersion('1.33.9')).toEqual([1, 33, 9]);
expect(parseVersion('v1.33.9')).toEqual([1, 33, 9]);
expect(parseVersion('1.33.9-beta')).toEqual([1, 33, 9]);
expect(parseVersion('1.33')).toEqual([1, 33, 0]);
expect(parseVersion('1')).toEqual([1, 0, 0]);
expect(parseVersion('')).toEqual([0, 0, 0]);
expect(parseVersion(null)).toEqual([0, 0, 0]);
});
it('should compare versions correctly', () => {
expect(compareVersions('1.33.9', '1.33.9')).toBe(0);
expect(compareVersions('1.33.10', '1.33.9')).toBe(1);
expect(compareVersions('1.34.0', '1.33.9')).toBe(1);
expect(compareVersions('2.0.0', '1.33.9')).toBe(1);
expect(compareVersions('1.33.8', '1.33.9')).toBe(-1);
expect(compareVersions('1.32.0', '1.33.9')).toBe(-1);
expect(compareVersions('0.9.9', '1.33.9')).toBe(-1);
});
it('should return false for versions below 1.33.9', () => {
expect(supportsActionBarButtons('1.33.8')).toBe(false);
expect(supportsActionBarButtons('1.32.0')).toBe(false);
expect(supportsActionBarButtons('0.9.9')).toBe(false);
});
it('should return true for versions 1.33.9 and above', () => {
expect(supportsActionBarButtons('1.33.9')).toBe(true);
expect(supportsActionBarButtons('1.33.10')).toBe(true);
expect(supportsActionBarButtons('1.34.0')).toBe(true);
expect(supportsActionBarButtons('2.0.0')).toBe(true);
});
it('should handle edge cases in version parsing', () => {
expect(supportsActionBarButtons('v1.33.9')).toBe(true);
expect(supportsActionBarButtons('1.33.9-rc.1')).toBe(true);
expect(supportsActionBarButtons('1.33.9-beta')).toBe(true);
});
});

View File

@@ -1,4 +1,5 @@
"""Integration smoke tests for the recipe route stack."""
from __future__ import annotations
import json
@@ -94,19 +95,25 @@ class StubAnalysisService:
self._recipe_parser_factory = None
StubAnalysisService.instances.append(self)
async def analyze_uploaded_image(self, *, image_bytes: bytes | None, recipe_scanner) -> SimpleNamespace: # noqa: D401 - mirrors real signature
async def analyze_uploaded_image(
self, *, image_bytes: bytes | None, recipe_scanner
) -> SimpleNamespace: # noqa: D401 - mirrors real signature
if self.raise_for_uploaded:
raise self.raise_for_uploaded
self.upload_calls.append(image_bytes or b"")
return self.result
async def analyze_remote_image(self, *, url: Optional[str], recipe_scanner, civitai_client) -> SimpleNamespace: # noqa: D401
async def analyze_remote_image(
self, *, url: Optional[str], recipe_scanner, civitai_client
) -> SimpleNamespace: # noqa: D401
if self.raise_for_remote:
raise self.raise_for_remote
self.remote_calls.append(url)
return self.result
async def analyze_local_image(self, *, file_path: Optional[str], recipe_scanner) -> SimpleNamespace: # noqa: D401
async def analyze_local_image(
self, *, file_path: Optional[str], recipe_scanner
) -> SimpleNamespace: # noqa: D401
if self.raise_for_local:
raise self.raise_for_local
self.local_calls.append(file_path)
@@ -125,11 +132,23 @@ class StubPersistenceService:
self.save_calls: List[Dict[str, Any]] = []
self.delete_calls: List[str] = []
self.move_calls: List[Dict[str, str]] = []
self.save_result = SimpleNamespace(payload={"success": True, "recipe_id": "stub-id"}, status=200)
self.save_result = SimpleNamespace(
payload={"success": True, "recipe_id": "stub-id"}, status=200
)
self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
StubPersistenceService.instances.append(self)
async def save_recipe(self, *, recipe_scanner, image_bytes, image_base64, name, tags, metadata, extension=None) -> SimpleNamespace: # noqa: D401
async def save_recipe(
self,
*,
recipe_scanner,
image_bytes,
image_base64,
name,
tags,
metadata,
extension=None,
) -> SimpleNamespace: # noqa: D401
self.save_calls.append(
{
"recipe_scanner": recipe_scanner,
@@ -148,22 +167,42 @@ class StubPersistenceService:
await recipe_scanner.remove_recipe(recipe_id)
return self.delete_result
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> SimpleNamespace: # noqa: D401
async def move_recipe(
self, *, recipe_scanner, recipe_id: str, target_path: str
) -> SimpleNamespace: # noqa: D401
self.move_calls.append({"recipe_id": recipe_id, "target_path": target_path})
return SimpleNamespace(
payload={"success": True, "recipe_id": recipe_id, "new_file_path": target_path}, status=200
payload={
"success": True,
"recipe_id": recipe_id,
"new_file_path": target_path,
},
status=200,
)
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
return SimpleNamespace(payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200)
async def update_recipe(
self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]
) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
return SimpleNamespace(
payload={"success": True, "recipe_id": recipe_id, "updates": updates},
status=200,
)
async def reconnect_lora(self, *, recipe_scanner, recipe_id: str, lora_index: int, target_name: str) -> SimpleNamespace: # pragma: no cover
async def reconnect_lora(
self, *, recipe_scanner, recipe_id: str, lora_index: int, target_name: str
) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(payload={"success": True}, status=200)
async def bulk_delete(self, *, recipe_scanner, recipe_ids: List[str]) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(payload={"success": True, "deleted": recipe_ids}, status=200)
async def bulk_delete(
self, *, recipe_scanner, recipe_ids: List[str]
) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(
payload={"success": True, "deleted": recipe_ids}, status=200
)
async def save_recipe_from_widget(self, *, recipe_scanner, metadata: Dict[str, Any], image_bytes: bytes) -> SimpleNamespace: # pragma: no cover
async def save_recipe_from_widget(
self, *, recipe_scanner, metadata: Dict[str, Any], image_bytes: bytes
) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(payload={"success": True}, status=200)
@@ -176,7 +215,11 @@ class StubSharingService:
self.share_calls: List[str] = []
self.download_calls: List[str] = []
self.share_result = SimpleNamespace(
payload={"success": True, "download_url": "/share/stub", "filename": "recipe.png"},
payload={
"success": True,
"download_url": "/share/stub",
"filename": "recipe.png",
},
status=200,
)
self.download_info = SimpleNamespace(file_path="", download_filename="")
@@ -186,7 +229,9 @@ class StubSharingService:
self.share_calls.append(recipe_id)
return self.share_result
async def prepare_download(self, *, recipe_scanner, recipe_id: str) -> SimpleNamespace:
async def prepare_download(
self, *, recipe_scanner, recipe_id: str
) -> SimpleNamespace:
self.download_calls.append(recipe_id)
return self.download_info
@@ -214,7 +259,9 @@ class StubCivitaiClient:
@asynccontextmanager
async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRouteHarness]:
async def recipe_harness(
monkeypatch, tmp_path: Path
) -> AsyncIterator[RecipeRouteHarness]:
"""Context manager that yields a fully wired recipe route harness."""
StubAnalysisService.instances.clear()
@@ -237,8 +284,12 @@ async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRou
monkeypatch.setattr(ServiceRegistry, "get_recipe_scanner", fake_get_recipe_scanner)
monkeypatch.setattr(ServiceRegistry, "get_civitai_client", fake_get_civitai_client)
monkeypatch.setattr(base_recipe_routes, "RecipeAnalysisService", StubAnalysisService)
monkeypatch.setattr(base_recipe_routes, "RecipePersistenceService", StubPersistenceService)
monkeypatch.setattr(
base_recipe_routes, "RecipeAnalysisService", StubAnalysisService
)
monkeypatch.setattr(
base_recipe_routes, "RecipePersistenceService", StubPersistenceService
)
monkeypatch.setattr(base_recipe_routes, "RecipeSharingService", StubSharingService)
monkeypatch.setattr(base_recipe_routes, "get_downloader", fake_get_downloader)
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)], raising=False)
@@ -294,7 +345,9 @@ async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> N
async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
form = FormData()
form.add_field("image", b"stub", filename="sample.png", content_type="image/png")
form.add_field(
"image", b"stub", filename="sample.png", content_type="image/png"
)
form.add_field("name", "Test Recipe")
form.add_field("tags", json.dumps(["tag-a"]))
form.add_field("metadata", json.dumps({"loras": []}))
@@ -312,7 +365,9 @@ async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) ->
assert save_payload["recipe_id"] == "saved-id"
assert harness.persistence.save_calls[-1]["name"] == "Test Recipe"
harness.persistence.delete_result = SimpleNamespace(payload={"success": True}, status=200)
harness.persistence.delete_result = SimpleNamespace(
payload={"success": True}, status=200
)
delete_response = await harness.client.delete("/api/lm/recipe/saved-id")
delete_payload = await delete_response.json()
@@ -326,14 +381,20 @@ async def test_move_recipe_invokes_persistence(monkeypatch, tmp_path: Path) -> N
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipe/move",
json={"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")},
json={
"recipe_id": "move-me",
"target_path": str(tmp_path / "recipes" / "subdir"),
},
)
payload = await response.json()
assert response.status == 200
assert payload["recipe_id"] == "move-me"
assert harness.persistence.move_calls == [
{"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")}
{
"recipe_id": "move-me",
"target_path": str(tmp_path / "recipes" / "subdir"),
}
]
@@ -348,7 +409,10 @@ async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
async def fake_get_default_metadata_provider():
return Provider()
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", fake_get_default_metadata_provider)
monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness:
resources = [
@@ -397,7 +461,9 @@ async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
assert harness.downloader.urls == ["https://example.com/images/1"]
async def test_import_remote_recipe_falls_back_to_request_base_model(monkeypatch, tmp_path: Path) -> None:
async def test_import_remote_recipe_falls_back_to_request_base_model(
monkeypatch, tmp_path: Path
) -> None:
provider_calls: list[str | int] = []
class Provider:
@@ -408,7 +474,10 @@ async def test_import_remote_recipe_falls_back_to_request_base_model(monkeypatch
async def fake_get_default_metadata_provider():
return Provider()
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", fake_get_default_metadata_provider)
monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness:
resources = [
@@ -444,13 +513,16 @@ async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
async def fake_get_default_metadata_provider():
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", fake_get_default_metadata_provider)
monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.civitai.image_info["12345"] = {
"id": 12345,
"url": "https://image.civitai.com/x/y/original=true/video.mp4",
"type": "video"
"type": "video",
}
response = await harness.client.get(
@@ -469,7 +541,7 @@ async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
# Verify downloader was called with rewritten URL
assert "transcode=true" in harness.downloader.urls[0]
# Verify persistence was called with correct extension
call = harness.persistence.save_calls[-1]
assert call["extension"] == ".mp4"
@@ -477,7 +549,9 @@ async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis.raise_for_uploaded = RecipeValidationError("No image data provided")
harness.analysis.raise_for_uploaded = RecipeValidationError(
"No image data provided"
)
form = FormData()
form.add_field("image", b"", filename="empty.png", content_type="image/png")
@@ -504,7 +578,11 @@ async def test_share_and_download_recipe(monkeypatch, tmp_path: Path) -> None:
}
harness.sharing.share_result = SimpleNamespace(
payload={"success": True, "download_url": "/api/share", "filename": "share.png"},
payload={
"success": True,
"download_url": "/api/share",
"filename": "share.png",
},
status=200,
)
harness.sharing.download_info = SimpleNamespace(
@@ -519,15 +597,24 @@ async def test_share_and_download_recipe(monkeypatch, tmp_path: Path) -> None:
assert share_payload["filename"] == "share.png"
assert harness.sharing.share_calls == [recipe_id]
download_response = await harness.client.get(f"/api/lm/recipe/{recipe_id}/share/download")
download_response = await harness.client.get(
f"/api/lm/recipe/{recipe_id}/share/download"
)
body = await download_response.read()
assert download_response.status == 200
assert download_response.headers["Content-Disposition"] == 'attachment; filename="share.png"'
assert (
download_response.headers["Content-Disposition"]
== 'attachment; filename="share.png"'
)
assert body == b"stub"
download_path.unlink(missing_ok=True)
async def test_import_remote_recipe_merges_metadata(monkeypatch, tmp_path: Path) -> None:
async def test_import_remote_recipe_merges_metadata(
monkeypatch, tmp_path: Path
) -> None:
# 1. Mock Metadata Provider
class Provider:
async def get_model_version_info(self, model_version_id):
@@ -536,22 +623,25 @@ async def test_import_remote_recipe_merges_metadata(monkeypatch, tmp_path: Path)
async def fake_get_default_metadata_provider():
return Provider()
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", fake_get_default_metadata_provider)
monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
# 2. Mock ExifUtils to return some embedded metadata
class MockExifUtils:
@staticmethod
def extract_image_metadata(path):
return "Recipe metadata: " + json.dumps({
"gen_params": {"prompt": "from embedded", "seed": 123}
})
return "Recipe metadata: " + json.dumps(
{"gen_params": {"prompt": "from embedded", "seed": 123}}
)
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
# 3. Mock Parser Factory for StubAnalysisService
class MockParser:
async def parse_metadata(self, raw, recipe_scanner=None):
return json.loads(raw[len("Recipe metadata: "):])
return json.loads(raw[len("Recipe metadata: ") :])
class MockFactory:
def create_parser(self, raw):
@@ -562,12 +652,12 @@ async def test_import_remote_recipe_merges_metadata(monkeypatch, tmp_path: Path)
# 4. Setup Harness and run test
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis._recipe_parser_factory = MockFactory()
# Civitai meta via image_info
harness.civitai.image_info["1"] = {
"id": 1,
"url": "https://example.com/images/1.jpg",
"meta": {"prompt": "from civitai", "cfg": 7.0}
"meta": {"prompt": "from civitai", "cfg": 7.0},
}
resources = []
@@ -583,11 +673,11 @@ async def test_import_remote_recipe_merges_metadata(monkeypatch, tmp_path: Path)
payload = await response.json()
assert response.status == 200
call = harness.persistence.save_calls[-1]
metadata = call["metadata"]
gen_params = metadata["gen_params"]
assert gen_params["seed"] == 123
@@ -619,3 +709,142 @@ async def test_get_recipe_syntax(monkeypatch, tmp_path: Path) -> None:
response_404 = await harness.client.get("/api/lm/recipe/non-existent/syntax")
assert response_404.status == 404
async def test_batch_import_start_success(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/batch-import/start",
json={
"items": [
{"source": "https://example.com/image1.png"},
{"source": "https://example.com/image2.png"},
],
"tags": ["batch", "import"],
"skip_no_metadata": True,
},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert "operation_id" in payload
async def test_batch_import_start_empty_items(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/batch-import/start",
json={"items": [], "tags": []},
)
payload = await response.json()
assert response.status == 400
assert payload["success"] is False
assert "No items provided" in payload["error"]
async def test_batch_import_start_missing_source(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/batch-import/start",
json={"items": [{"source": ""}]},
)
payload = await response.json()
assert response.status == 400
assert payload["success"] is False
assert "source" in payload["error"].lower()
async def test_batch_import_start_already_running(monkeypatch, tmp_path: Path) -> None:
import asyncio
async with recipe_harness(monkeypatch, tmp_path) as harness:
original_analyze = harness.analysis.analyze_remote_image
async def slow_analyze(*, url, recipe_scanner, civitai_client):
await asyncio.sleep(0.5)
return await original_analyze(
url=url, recipe_scanner=recipe_scanner, civitai_client=civitai_client
)
harness.analysis.analyze_remote_image = slow_analyze
items = [{"source": f"https://example.com/image{i}.png"} for i in range(10)]
response1 = await harness.client.post(
"/api/lm/recipes/batch-import/start",
json={"items": items},
)
assert response1.status == 200
payload1 = await response1.json()
assert payload1["success"] is True
await asyncio.sleep(0.1)
response2 = await harness.client.post(
"/api/lm/recipes/batch-import/start",
json={"items": [{"source": "https://example.com/other.png"}]},
)
payload2 = await response2.json()
assert response2.status == 409
assert "already in progress" in payload2["error"].lower()
async def test_batch_import_get_progress_not_found(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.get(
"/api/lm/recipes/batch-import/progress",
params={"operation_id": "nonexistent-id"},
)
payload = await response.json()
assert response.status == 404
assert payload["success"] is False
async def test_batch_import_get_progress_missing_id(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.get("/api/lm/recipes/batch-import/progress")
payload = await response.json()
assert response.status == 400
assert payload["success"] is False
async def test_batch_import_cancel_success(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
start_response = await harness.client.post(
"/api/lm/recipes/batch-import/start",
json={"items": [{"source": "https://example.com/image.png"}]},
)
start_payload = await start_response.json()
operation_id = start_payload["operation_id"]
cancel_response = await harness.client.post(
"/api/lm/recipes/batch-import/cancel",
json={"operation_id": operation_id},
)
cancel_payload = await cancel_response.json()
assert cancel_response.status == 200
assert cancel_payload["success"] is True
async def test_batch_import_cancel_not_found(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/batch-import/cancel",
json={"operation_id": "nonexistent-id"},
)
payload = await response.json()
assert response.status == 404
assert payload["success"] is False
async def test_batch_import_cancel_missing_id(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/batch-import/cancel",
json={},
)
payload = await response.json()
assert response.status == 400
assert payload["success"] is False

View File

@@ -0,0 +1,597 @@
"""Unit tests for BatchImportService."""
from __future__ import annotations
import asyncio
import logging
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from py.services.batch_import_service import (
AdaptiveConcurrencyController,
BatchImportItem,
BatchImportProgress,
BatchImportService,
ImportItemType,
ImportStatus,
)
class MockWebSocketManager:
def __init__(self):
self.broadcasts: List[Dict[str, Any]] = []
async def broadcast(self, data: Dict[str, Any]):
self.broadcasts.append(data)
@dataclass
class MockAnalysisResult:
payload: Dict[str, Any]
status: int = 200
class MockAnalysisService:
def __init__(self, results: Optional[Dict[str, MockAnalysisResult]] = None):
self.results = results or {}
self.call_count = 0
self.last_url = None
self.last_path = None
async def analyze_remote_image(self, *, url: str, recipe_scanner, civitai_client):
self.call_count += 1
self.last_url = url
if url in self.results:
return self.results[url]
return MockAnalysisResult({"error": "No metadata found", "loras": []})
async def analyze_local_image(self, *, file_path: str, recipe_scanner):
self.call_count += 1
self.last_path = file_path
if file_path in self.results:
return self.results[file_path]
return MockAnalysisResult({"error": "No metadata found", "loras": []})
@dataclass
class MockSaveResult:
payload: Dict[str, Any]
status: int = 200
class MockPersistenceService:
def __init__(self, should_succeed: bool = True):
self.should_succeed = should_succeed
self.saved_recipes: List[Dict[str, Any]] = []
self.call_count = 0
async def save_recipe(
self,
*,
recipe_scanner,
image_bytes: Optional[bytes] = None,
image_base64: Optional[str] = None,
name: str,
tags: List[str],
metadata: Dict[str, Any],
extension: Optional[str] = None,
):
self.call_count += 1
self.saved_recipes.append(
{
"name": name,
"tags": tags,
"metadata": metadata,
}
)
if self.should_succeed:
return MockSaveResult({"success": True, "id": f"recipe_{self.call_count}"})
return MockSaveResult({"success": False, "error": "Save failed"}, status=400)
class TestAdaptiveConcurrencyController:
def test_initial_values(self):
controller = AdaptiveConcurrencyController()
assert controller.current_concurrency == 3
assert controller.min_concurrency == 1
assert controller.max_concurrency == 5
def test_custom_initial_values(self):
controller = AdaptiveConcurrencyController(
min_concurrency=2,
max_concurrency=10,
initial_concurrency=5,
)
assert controller.current_concurrency == 5
assert controller.min_concurrency == 2
assert controller.max_concurrency == 10
def test_increase_concurrency_on_success(self):
controller = AdaptiveConcurrencyController(initial_concurrency=3)
controller.record_result(duration=0.5, success=True)
assert controller.current_concurrency == 4
def test_do_not_exceed_max(self):
controller = AdaptiveConcurrencyController(
max_concurrency=5,
initial_concurrency=5,
)
controller.record_result(duration=0.5, success=True)
assert controller.current_concurrency == 5
def test_decrease_concurrency_on_failure(self):
controller = AdaptiveConcurrencyController(initial_concurrency=3)
controller.record_result(duration=1.0, success=False)
assert controller.current_concurrency == 2
def test_do_not_go_below_min(self):
controller = AdaptiveConcurrencyController(
min_concurrency=1,
initial_concurrency=1,
)
controller.record_result(duration=1.0, success=False)
assert controller.current_concurrency == 1
def test_slow_task_decreases_concurrency(self):
controller = AdaptiveConcurrencyController(initial_concurrency=3)
controller.record_result(duration=11.0, success=True)
assert controller.current_concurrency == 2
def test_fast_task_increases_concurrency(self):
controller = AdaptiveConcurrencyController(initial_concurrency=3)
controller.record_result(duration=0.5, success=True)
assert controller.current_concurrency == 4
def test_moderate_task_no_change(self):
controller = AdaptiveConcurrencyController(initial_concurrency=3)
controller.record_result(duration=5.0, success=True)
assert controller.current_concurrency == 3
class TestBatchImportProgress:
def test_to_dict(self):
progress = BatchImportProgress(
operation_id="test-123",
total=10,
completed=5,
success=3,
failed=2,
skipped=0,
current_item="image.png",
status="running",
)
result = progress.to_dict()
assert result["operation_id"] == "test-123"
assert result["total"] == 10
assert result["completed"] == 5
assert result["success"] == 3
assert result["failed"] == 2
assert result["progress_percent"] == 50.0
def test_progress_percent_zero_total(self):
progress = BatchImportProgress(
operation_id="test-123",
total=0,
)
assert progress.to_dict()["progress_percent"] == 0
class TestBatchImportItem:
def test_defaults(self):
item = BatchImportItem(
id="item-1",
source="https://example.com/image.png",
item_type=ImportItemType.URL,
)
assert item.status == ImportStatus.PENDING
assert item.error_message is None
assert item.recipe_name is None
class TestBatchImportService:
@pytest.fixture
def mock_services(self):
ws_manager = MockWebSocketManager()
analysis_service = MockAnalysisService()
persistence_service = MockPersistenceService()
logger = logging.getLogger("test")
return ws_manager, analysis_service, persistence_service, logger
@pytest.fixture
def service(self, mock_services):
ws_manager, analysis_service, persistence_service, logger = mock_services
return BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
def test_is_import_running_no_operations(self, service):
assert not service.is_import_running()
@pytest.mark.asyncio
async def test_start_batch_import_creates_operation(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": "https://example.com/image.png"}],
)
assert operation_id is not None
assert service.is_import_running(operation_id)
@pytest.mark.asyncio
async def test_get_progress(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[
{"source": "https://example.com/1.png"},
{"source": "https://example.com/2.png"},
],
)
progress = service.get_progress(operation_id)
assert progress is not None
assert progress.total == 2
assert progress.status in ("pending", "running")
@pytest.mark.asyncio
async def test_cancel_import(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": "https://example.com/image.png"}],
)
assert service.cancel_import(operation_id) is True
assert service.cancel_import("nonexistent") is False
@pytest.mark.asyncio
async def test_discover_images_non_recursive(self, service, tmp_path):
for i in range(3):
(tmp_path / f"image{i}.png").write_bytes(b"fake-image")
(tmp_path / "subdir").mkdir()
(tmp_path / "subdir" / "hidden.png").write_bytes(b"fake-image")
images = await service._discover_images(str(tmp_path), recursive=False)
assert len(images) == 3
@pytest.mark.asyncio
async def test_discover_images_recursive(self, service, tmp_path):
for i in range(2):
(tmp_path / f"image{i}.png").write_bytes(b"fake-image")
subdir = tmp_path / "subdir"
subdir.mkdir()
for i in range(2):
(subdir / f"nested{i}.jpg").write_bytes(b"fake-image")
images = await service._discover_images(str(tmp_path), recursive=True)
assert len(images) == 4
@pytest.mark.asyncio
async def test_discover_images_filters_by_extension(self, service, tmp_path):
(tmp_path / "image.png").write_bytes(b"fake-image")
(tmp_path / "image.jpg").write_bytes(b"fake-image")
(tmp_path / "image.webp").write_bytes(b"fake-image")
(tmp_path / "document.pdf").write_bytes(b"fake-doc")
(tmp_path / "script.py").write_bytes(b"print('hello')")
images = await service._discover_images(str(tmp_path), recursive=False)
assert len(images) == 3
@pytest.mark.asyncio
async def test_discover_images_invalid_directory(self, service):
from py.services.recipes.errors import RecipeValidationError
with pytest.raises(RecipeValidationError):
await service._discover_images("/nonexistent/path", recursive=False)
def test_is_supported_image(self, service):
assert service._is_supported_image("test.png") is True
assert service._is_supported_image("test.jpg") is True
assert service._is_supported_image("test.jpeg") is True
assert service._is_supported_image("test.webp") is True
assert service._is_supported_image("test.gif") is True
assert service._is_supported_image("test.bmp") is True
assert service._is_supported_image("test.pdf") is False
assert service._is_supported_image("test.txt") is False
@pytest.mark.asyncio
async def test_batch_import_processes_items(self, mock_services, tmp_path):
ws_manager, _, persistence_service, logger = mock_services
analysis_service = MockAnalysisService(
{
"https://example.com/valid.png": MockAnalysisResult(
{
"loras": [{"name": "test-lora", "weight": 1.0}],
"base_model": "SD1.5",
"gen_params": {"steps": 20},
}
),
}
)
service = BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
recipe_scanner_getter = lambda: SimpleNamespace(
find_recipes_by_fingerprint=lambda x: [],
add_recipe=lambda x: None,
)
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[
{"source": "https://example.com/valid.png"},
{"source": "https://example.com/no-meta.png"},
],
skip_no_metadata=True,
)
await asyncio.sleep(0.5)
progress = service.get_progress(operation_id)
assert progress is not None or persistence_service.call_count == 1
@pytest.mark.asyncio
async def test_start_directory_import(self, service, tmp_path):
for i in range(5):
(tmp_path / f"image{i}.png").write_bytes(b"fake-image")
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_directory_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
directory=str(tmp_path),
recursive=False,
)
progress = service.get_progress(operation_id)
assert progress is not None
assert progress.total == 5
@pytest.mark.asyncio
async def test_websocket_broadcasts_progress(self, mock_services):
ws_manager, analysis_service, persistence_service, logger = mock_services
service = BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": "https://example.com/test.png"}],
)
await asyncio.sleep(0.3)
assert len(ws_manager.broadcasts) > 0
assert any(
b.get("type") == "batch_import_progress" for b in ws_manager.broadcasts
)
@pytest.mark.asyncio
async def test_cancellation_stops_processing(self, mock_services):
ws_manager, analysis_service, persistence_service, logger = mock_services
service = BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
items = [{"source": f"https://example.com/{i}.png"} for i in range(10)]
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=items,
)
service.cancel_import(operation_id)
await asyncio.sleep(0.3)
progress = service.get_progress(operation_id)
if progress:
assert progress.status == "cancelled"
class TestBatchImportServiceEdgeCases:
@pytest.fixture
def service(self):
ws_manager = MockWebSocketManager()
analysis_service = MockAnalysisService()
persistence_service = MockPersistenceService()
logger = logging.getLogger("test")
return BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
@pytest.mark.asyncio
async def test_empty_items_list(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[],
)
progress = service.get_progress(operation_id)
assert progress is not None
assert progress.total == 0
@pytest.mark.asyncio
async def test_mixed_url_and_path_items(self, service, tmp_path):
(tmp_path / "local.png").write_bytes(b"fake-image")
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[
{"source": "https://example.com/remote.png", "type": "url"},
{"source": str(tmp_path / "local.png"), "type": "local_path"},
],
)
progress = service.get_progress(operation_id)
assert progress is not None
assert progress.total == 2
assert progress.items[0].item_type == ImportItemType.URL
assert progress.items[1].item_type == ImportItemType.LOCAL_PATH
@pytest.mark.asyncio
async def test_tags_are_passed_to_persistence(self, tmp_path):
ws_manager = MockWebSocketManager()
analysis_service = MockAnalysisService(
{
str(tmp_path / "test.png"): MockAnalysisResult(
{
"loras": [{"name": "test-lora"}],
}
),
}
)
persistence_service = MockPersistenceService()
logger = logging.getLogger("test")
(tmp_path / "test.png").write_bytes(b"fake-image")
service = BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
recipe_scanner_getter = lambda: SimpleNamespace(
find_recipes_by_fingerprint=lambda x: [],
)
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": str(tmp_path / "test.png")}],
tags=["batch-import", "test"],
)
await asyncio.sleep(0.3)
if persistence_service.saved_recipes:
assert "batch-import" in persistence_service.saved_recipes[0]["tags"]
assert "test" in persistence_service.saved_recipes[0]["tags"]
@pytest.mark.asyncio
async def test_skip_duplicates_parameter(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": "https://example.com/test.png"}],
skip_duplicates=True,
)
progress = service.get_progress(operation_id)
assert progress is not None
assert progress.skip_duplicates is True
@pytest.mark.asyncio
async def test_skip_duplicates_false_by_default(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": "https://example.com/test.png"}],
)
progress = service.get_progress(operation_id)
assert progress is not None
assert progress.skip_duplicates is False
class TestInputValidation:
@pytest.fixture
def service(self):
ws_manager = MockWebSocketManager()
analysis_service = MockAnalysisService()
persistence_service = MockPersistenceService()
logger = logging.getLogger("test")
return BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
def test_validate_valid_url(self, service):
assert service._validate_url("https://example.com/image.png") is True
assert service._validate_url("http://example.com/image.png") is True
assert service._validate_url("https://civitai.com/images/123") is True
def test_validate_invalid_url(self, service):
assert service._validate_url("not-a-url") is False
assert service._validate_url("ftp://example.com/file") is False
assert service._validate_url("") is False
def test_validate_valid_local_path(self, service, tmp_path):
valid_path = str(tmp_path / "image.png")
assert service._validate_local_path(valid_path) is True
def test_validate_invalid_local_path(self, service):
assert service._validate_local_path("../etc/passwd") is False
assert service._validate_local_path("relative/path.png") is False
assert service._validate_local_path("") is False

View File

@@ -194,6 +194,7 @@ class TestCacheHealthMonitor:
'preview_nsfw_level': 0,
'notes': '',
'usage_tips': '',
'hash_status': 'completed',
}
incomplete_entry = {
'file_path': '/models/test2.safetensors',

View File

@@ -369,3 +369,289 @@ async def test_pool_filter_combined_all_filters(lora_service):
# - tags: tag1 ✓
assert len(filtered) == 1
assert filtered[0]["file_name"] == "match_all.safetensors"
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_include_text(lora_service):
"""Test filtering by name patterns with text matching (useRegex=False)."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test include patterns with text matching
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": ["character"], "exclude": [], "useRegex": False},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 2
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"character_anime_v1.safetensors",
"character_realistic_v1.safetensors",
}
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_exclude_text(lora_service):
"""Test excluding by name patterns with text matching (useRegex=False)."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test exclude patterns with text matching
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": [], "exclude": ["anime"], "useRegex": False},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 2
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"character_realistic_v1.safetensors",
"style_watercolor_v1.safetensors",
}
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_include_regex(lora_service):
"""Test filtering by name patterns with regex matching (useRegex=True)."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test include patterns with regex matching - match files starting with "character_"
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": ["^character_"], "exclude": [], "useRegex": True},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 2
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"character_anime_v1.safetensors",
"character_realistic_v1.safetensors",
}
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_exclude_regex(lora_service):
"""Test excluding by name patterns with regex matching (useRegex=True)."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test exclude patterns with regex matching - exclude files ending with "_v1.safetensors"
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {
"include": [],
"exclude": ["_v1\\.safetensors$"],
"useRegex": True,
},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 0 # All files match the exclude pattern
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_combined(lora_service):
"""Test combining include and exclude name patterns."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test include "character" but exclude "anime"
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {
"include": ["character"],
"exclude": ["anime"],
"useRegex": False,
},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 1
assert filtered[0]["file_name"] == "character_realistic_v1.safetensors"
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_model_name_fallback(lora_service):
"""Test that name pattern filtering falls back to model_name when file_name doesn't match."""
sample_loras = [
{
"file_name": "abc123.safetensors",
"model_name": "Super Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "def456.safetensors",
"model_name": "Realistic Portrait",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Should match model_name even if file_name doesn't contain the pattern
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": ["anime"], "exclude": [], "useRegex": False},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 1
assert filtered[0]["file_name"] == "abc123.safetensors"
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_invalid_regex(lora_service):
"""Test that invalid regex falls back to substring matching."""
sample_loras = [
{
"file_name": "character_anime[test]_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Invalid regex pattern (unclosed character class) should fall back to substring matching
# The pattern "anime[" is invalid regex but valid substring - it exists in the filename
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": ["anime["], "exclude": [], "useRegex": True},
}
# Should not crash and should match using substring fallback
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 1 # Substring match works even with invalid regex

View File

@@ -0,0 +1,158 @@
"""Tests for checkpoint and unet loaders with extra folder paths support"""
import pytest
import os
# Get project root directory (ComfyUI-Lora-Manager folder)
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
class TestCheckpointLoaderLM:
"""Test CheckpointLoaderLM node"""
def test_class_attributes(self):
"""Test that CheckpointLoaderLM has required class attributes"""
# Import in a way that doesn't require ComfyUI
import ast
filepath = os.path.join(PROJECT_ROOT, "py", "nodes", "checkpoint_loader.py")
with open(filepath, "r") as f:
tree = ast.parse(f.read())
# Find CheckpointLoaderLM class
classes = {
node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
}
assert "CheckpointLoaderLM" in classes
cls = classes["CheckpointLoaderLM"]
# Check for NAME attribute
name_attr = [
n
for n in cls.body
if isinstance(n, ast.Assign)
and any(t.id == "NAME" for t in n.targets if isinstance(t, ast.Name))
]
assert len(name_attr) > 0, "CheckpointLoaderLM should have NAME attribute"
# Check for CATEGORY attribute
cat_attr = [
n
for n in cls.body
if isinstance(n, ast.Assign)
and any(t.id == "CATEGORY" for t in n.targets if isinstance(t, ast.Name))
]
assert len(cat_attr) > 0, "CheckpointLoaderLM should have CATEGORY attribute"
# Check for INPUT_TYPES method
input_types = [
n
for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == "INPUT_TYPES"
]
assert len(input_types) > 0, "CheckpointLoaderLM should have INPUT_TYPES method"
# Check for load_checkpoint method
load_method = [
n
for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == "load_checkpoint"
]
assert len(load_method) > 0, (
"CheckpointLoaderLM should have load_checkpoint method"
)
class TestUNETLoaderLM:
"""Test UNETLoaderLM node"""
def test_class_attributes(self):
"""Test that UNETLoaderLM has required class attributes"""
# Import in a way that doesn't require ComfyUI
import ast
filepath = os.path.join(PROJECT_ROOT, "py", "nodes", "unet_loader.py")
with open(filepath, "r") as f:
tree = ast.parse(f.read())
# Find UNETLoaderLM class
classes = {
node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
}
assert "UNETLoaderLM" in classes
cls = classes["UNETLoaderLM"]
# Check for NAME attribute
name_attr = [
n
for n in cls.body
if isinstance(n, ast.Assign)
and any(t.id == "NAME" for t in n.targets if isinstance(t, ast.Name))
]
assert len(name_attr) > 0, "UNETLoaderLM should have NAME attribute"
# Check for CATEGORY attribute
cat_attr = [
n
for n in cls.body
if isinstance(n, ast.Assign)
and any(t.id == "CATEGORY" for t in n.targets if isinstance(t, ast.Name))
]
assert len(cat_attr) > 0, "UNETLoaderLM should have CATEGORY attribute"
# Check for INPUT_TYPES method
input_types = [
n
for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == "INPUT_TYPES"
]
assert len(input_types) > 0, "UNETLoaderLM should have INPUT_TYPES method"
# Check for load_unet method
load_method = [
n
for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == "load_unet"
]
assert len(load_method) > 0, "UNETLoaderLM should have load_unet method"
class TestUtils:
"""Test utility functions"""
def test_get_checkpoint_info_absolute_exists(self):
"""Test that get_checkpoint_info_absolute function exists in utils"""
import ast
filepath = os.path.join(PROJECT_ROOT, "py", "utils", "utils.py")
with open(filepath, "r") as f:
tree = ast.parse(f.read())
functions = [
node.name for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)
]
assert "get_checkpoint_info_absolute" in functions, (
"get_checkpoint_info_absolute should exist"
)
def test_format_model_name_for_comfyui_exists(self):
"""Test that _format_model_name_for_comfyui function exists in utils"""
import ast
filepath = os.path.join(PROJECT_ROOT, "py", "utils", "utils.py")
with open(filepath, "r") as f:
tree = ast.parse(f.read())
functions = [
node.name for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)
]
assert "_format_model_name_for_comfyui" in functions, (
"_format_model_name_for_comfyui should exist"
)

View File

@@ -31,10 +31,27 @@ def temp_db_path():
@pytest.fixture
def temp_csv_path():
"""Create a temporary CSV file with test data."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") as f:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8"
) as f:
# Write test data in the same format as danbooru_e621_merged.csv
# Format: tag_name,category,post_count,aliases
# Include multiple tags starting with "1" to test popularity-based ranking
f.write('1girl,0,6008644,"1girls,sole_female"\n')
f.write('1boy,0,1405457,"1boys,sole_male"\n')
f.write('1:1,14,377032,""\n')
f.write('16:9,14,152866,""\n')
f.write('1other,0,70962,""\n')
f.write('16:10,14,14739,""\n')
f.write('1990s_(style),0,9369,""\n')
f.write('1_eye,0,7179,""\n')
f.write('1:2,14,5865,""\n')
f.write('1980s_(style),0,5665,""\n')
f.write('1koma,0,4384,""\n')
f.write('1_horn,0,2122,""\n')
f.write('101_dalmatian_street,3,1933,""\n')
f.write('1upgobbo,3,1731,""\n')
f.write('14:9,14,1038,""\n')
f.write('highres,5,5256195,"high_res,high_resolution,hires"\n')
f.write('solo,0,5000954,"alone,female_solo,single"\n')
f.write('hatsune_miku,4,500000,"miku"\n')
@@ -86,7 +103,7 @@ class TestTagFTSIndexBuild:
fts.build_index()
assert fts.is_ready() is True
assert fts.get_indexed_count() == 10
assert fts.get_indexed_count() == 24
def test_build_index_nonexistent_csv(self, temp_db_path):
"""Test that build_index handles missing CSV gracefully."""
@@ -187,6 +204,110 @@ class TestTagFTSIndexSearch:
results = populated_fts.search("girl", limit=1)
assert len(results) <= 1
def test_search_tag_name_prefix_match_priority(self, populated_fts):
"""Test that tag_name prefix matches rank higher than alias matches."""
results = populated_fts.search("1", limit=20)
assert len(results) > 0, "Should return results for '1'"
# Find first alias match (if any)
first_alias_idx = None
for i, result in enumerate(results):
if result.get("matched_alias"):
first_alias_idx = i
break
# All tag_name prefix matches should come before alias matches
if first_alias_idx is not None:
for i in range(first_alias_idx):
assert results[i]["tag_name"].lower().startswith("1"), (
f"Tag at index {i} should start with '1' before alias matches"
)
def test_search_ranks_popular_tags_higher(self, populated_fts):
"""Test that tags with higher post_count rank higher among prefix matches."""
results = populated_fts.search("1", limit=20)
# Filter to only tag_name prefix matches
prefix_matches = [r for r in results if r["tag_name"].lower().startswith("1")]
assert len(prefix_matches) > 1, "Should have multiple prefix matches"
# Verify descending post_count order among prefix matches
for i in range(len(prefix_matches) - 1):
assert (
prefix_matches[i]["post_count"] >= prefix_matches[i + 1]["post_count"]
), (
f"Tags should be sorted by post_count: {prefix_matches[i]['tag_name']} ({prefix_matches[i]['post_count']}) >= {prefix_matches[i + 1]['tag_name']} ({prefix_matches[i + 1]['post_count']})"
)
def test_search_pagination_ordering_consistency(self, populated_fts):
"""Test that pagination maintains consistent ordering by post_count."""
page1 = populated_fts.search("1", limit=10, offset=0)
page2 = populated_fts.search("1", limit=10, offset=10)
assert len(page1) > 0, "Page 1 should have results"
assert len(page2) > 0, "Page 2 should have results"
# Page 2 max post_count should be <= Page 1 min post_count
page1_min_posts = min(r["post_count"] for r in page1)
page2_max_posts = max(r["post_count"] for r in page2)
assert page2_max_posts <= page1_min_posts, (
f"Page 2 max post_count ({page2_max_posts}) should be <= Page 1 min post_count ({page1_min_posts})"
)
def test_search_returns_popular_tags_higher(self, populated_fts):
"""Test that search returns popular tags (higher post_count) first."""
results = populated_fts.search("1", limit=5)
assert len(results) >= 2, "Need at least 2 results to compare"
# 1girl has 6M posts, should be ranked first
girl_result = next((r for r in results if r["tag_name"] == "1girl"), None)
assert girl_result is not None, "1girl should be in results"
assert results[0]["tag_name"] == "1girl", (
"1girl should be first due to highest post_count"
)
# Find a tag with significantly fewer posts
low_post_result = next((r for r in results if r["post_count"] < 10000), None)
if low_post_result:
assert girl_result["post_count"] > low_post_result["post_count"], (
f"1girl (6M posts) should have higher post_count than {low_post_result['tag_name']} ({low_post_result['post_count']} posts)"
)
def test_search_popularity_ordering(self, populated_fts):
"""Test that results are ordered by post_count (popularity)."""
results = populated_fts.search("1", limit=20)
# Get 1girl and 1boy results for comparison
girl_result = next((r for r in results if r["tag_name"] == "1girl"), None)
boy_result = next((r for r in results if r["tag_name"] == "1boy"), None)
assert girl_result is not None, "1girl should be in results"
assert boy_result is not None, "1boy should be in results"
# 1girl: 6M posts, 1boy: 1.4M posts
assert girl_result["post_count"] == 6008644, "1girl should have 6M posts"
assert boy_result["post_count"] == 1405457, "1boy should have 1.4M posts"
# 1girl should rank higher due to higher post_count
girl_rank = results.index(girl_result)
boy_rank = results.index(boy_result)
assert girl_rank < boy_rank, (
f"1girl should rank higher than 1boy due to higher post_count "
f"(girl rank: {girl_rank}, boy rank: {boy_rank})"
)
# Verify results are sorted by post_count descending
for i in range(len(results) - 1):
assert results[i]["post_count"] >= results[i + 1]["post_count"], (
f"Results should be sorted by post_count descending: "
f"{results[i]['tag_name']} ({results[i]['post_count']}) >= "
f"{results[i + 1]['tag_name']} ({results[i + 1]['post_count']})"
)
class TestAliasSearch:
"""Tests for alias search functionality."""
@@ -204,7 +325,9 @@ class TestAliasSearch:
results = populated_fts.search("miku")
assert len(results) >= 1
hatsune_result = next((r for r in results if r["tag_name"] == "hatsune_miku"), None)
hatsune_result = next(
(r for r in results if r["tag_name"] == "hatsune_miku"), None
)
assert hatsune_result is not None
assert hatsune_result["matched_alias"] == "miku"
@@ -214,7 +337,9 @@ class TestAliasSearch:
results = populated_fts.search("hatsune")
assert len(results) >= 1
hatsune_result = next((r for r in results if r["tag_name"] == "hatsune_miku"), None)
hatsune_result = next(
(r for r in results if r["tag_name"] == "hatsune_miku"), None
)
assert hatsune_result is not None
assert "matched_alias" not in hatsune_result
@@ -301,7 +426,9 @@ class TestSlashPrefixAliases:
@pytest.fixture
def fts_with_slash_aliases(self, temp_db_path):
"""Create an FTS index with slash-prefixed aliases."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") as f:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8"
) as f:
# Format: tag_name,category,post_count,aliases
f.write('long_hair,0,4350743,"/lh,longhair,very_long_hair"\n')
f.write('breasts,0,3439214,"/b,boobs,oppai"\n')
@@ -380,7 +507,15 @@ class TestCategoryMappings:
def test_category_name_to_ids_complete(self):
"""Test that CATEGORY_NAME_TO_IDS includes all expected names."""
expected_names = ["general", "artist", "copyright", "character", "meta", "species", "lore"]
expected_names = [
"general",
"artist",
"copyright",
"character",
"meta",
"species",
"lore",
]
for name in expected_names:
assert name in CATEGORY_NAME_TO_IDS
assert isinstance(CATEGORY_NAME_TO_IDS[name], list)

View File

@@ -2,8 +2,8 @@
<div class="lora-cycler-widget">
<LoraCyclerSettingsView
:current-index="state.currentIndex.value"
:total-count="state.totalCount.value"
:current-lora-name="state.currentLoraName.value"
:total-count="displayTotalCount"
:current-lora-name="displayLoraName"
:current-lora-filename="state.currentLoraFilename.value"
:model-strength="state.modelStrength.value"
:clip-strength="state.clipStrength.value"
@@ -16,11 +16,14 @@
:is-pause-disabled="hasQueuedPrompts"
:is-workflow-executing="state.isWorkflowExecuting.value"
:executing-repeat-step="state.executingRepeatStep.value"
:include-no-lora="state.includeNoLora.value"
:is-no-lora="isNoLora"
@update:current-index="handleIndexUpdate"
@update:model-strength="state.modelStrength.value = $event"
@update:clip-strength="state.clipStrength.value = $event"
@update:use-custom-clip-range="handleUseCustomClipRangeChange"
@update:repeat-count="handleRepeatCountChange"
@update:include-no-lora="handleIncludeNoLoraChange"
@toggle-pause="handleTogglePause"
@reset-index="handleResetIndex"
@open-lora-selector="isModalOpen = true"
@@ -30,6 +33,7 @@
:visible="isModalOpen"
:lora-list="cachedLoraList"
:current-index="state.currentIndex.value"
:include-no-lora="state.includeNoLora.value"
@close="isModalOpen = false"
@select="handleModalSelect"
/>
@@ -37,7 +41,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, computed } from 'vue'
import LoraCyclerSettingsView from './lora-cycler/LoraCyclerSettingsView.vue'
import LoraListModal from './lora-cycler/LoraListModal.vue'
import { useLoraCyclerState } from '../composables/useLoraCyclerState'
@@ -102,6 +106,31 @@ const isModalOpen = ref(false)
// Cache for LoRA list (used by modal)
const cachedLoraList = ref<LoraItem[]>([])
// Computed: display total count (includes no lora option if enabled)
const displayTotalCount = computed(() => {
const baseCount = state.totalCount.value
return state.includeNoLora.value ? baseCount + 1 : baseCount
})
// Computed: display LoRA name (shows "No LoRA" if on the last index and includeNoLora is enabled)
const displayLoraName = computed(() => {
const currentIndex = state.currentIndex.value
const totalCount = state.totalCount.value
// If includeNoLora is enabled and we're on the last position (no lora slot)
if (state.includeNoLora.value && currentIndex === totalCount + 1) {
return 'No LoRA'
}
// Otherwise show the normal LoRA name
return state.currentLoraName.value
})
// Computed: check if currently on "No LoRA" option
const isNoLora = computed(() => {
return state.includeNoLora.value && state.currentIndex.value === state.totalCount.value + 1
})
// Get pool config from connected node
const getPoolConfig = (): LoraPoolConfig | null => {
// Check if getPoolConfig method exists on node (added by main.ts)
@@ -113,7 +142,17 @@ const getPoolConfig = (): LoraPoolConfig | null => {
// Update display from LoRA list and index
const updateDisplayFromLoraList = (loraList: LoraItem[], index: number) => {
if (loraList.length > 0 && index > 0 && index <= loraList.length) {
const actualLoraCount = loraList.length
// If index is beyond actual LoRA count, it means we're on the "no lora" option
if (state.includeNoLora.value && index === actualLoraCount + 1) {
state.currentLoraName.value = 'No LoRA'
state.currentLoraFilename.value = 'No LoRA'
return
}
// Otherwise, show normal LoRA info
if (actualLoraCount > 0 && index > 0 && index <= actualLoraCount) {
const currentLora = loraList[index - 1]
if (currentLora) {
state.currentLoraName.value = currentLora.file_name
@@ -124,6 +163,14 @@ const updateDisplayFromLoraList = (loraList: LoraItem[], index: number) => {
// Handle index update from user
const handleIndexUpdate = async (newIndex: number) => {
// Calculate max valid index (includes no lora slot if enabled)
const maxIndex = state.includeNoLora.value
? state.totalCount.value + 1
: state.totalCount.value
// Clamp index to valid range
const clampedIndex = Math.max(1, Math.min(newIndex, maxIndex || 1))
// Reset execution state when user manually changes index
// This ensures the next execution starts from the user-set index
;(props.widget as any)[HAS_EXECUTED] = false
@@ -134,14 +181,14 @@ const handleIndexUpdate = async (newIndex: number) => {
executionQueue.length = 0
hasQueuedPrompts.value = false
state.setIndex(newIndex)
state.setIndex(clampedIndex)
// Refresh list to update current LoRA display
try {
const poolConfig = getPoolConfig()
const loraList = await state.fetchCyclerList(poolConfig)
cachedLoraList.value = loraList
updateDisplayFromLoraList(loraList, newIndex)
updateDisplayFromLoraList(loraList, clampedIndex)
} catch (error) {
console.error('[LoraCyclerWidget] Error updating index:', error)
}
@@ -169,6 +216,17 @@ const handleRepeatCountChange = (newValue: number) => {
state.displayRepeatUsed.value = 0
}
// Handle include no lora toggle
const handleIncludeNoLoraChange = (newValue: boolean) => {
state.includeNoLora.value = newValue
// If turning off and current index is beyond the actual LoRA count,
// clamp it to the last valid LoRA index
if (!newValue && state.currentIndex.value > state.totalCount.value) {
state.currentIndex.value = Math.max(1, state.totalCount.value)
}
}
// Handle pause toggle
const handleTogglePause = () => {
state.togglePause()

View File

@@ -8,6 +8,9 @@
:exclude-tags="state.excludeTags.value"
:include-folders="state.includeFolders.value"
:exclude-folders="state.excludeFolders.value"
:include-patterns="state.includePatterns.value"
:exclude-patterns="state.excludePatterns.value"
:use-regex="state.useRegex.value"
:no-credit-required="state.noCreditRequired.value"
:allow-selling="state.allowSelling.value"
:preview-items="state.previewItems.value"
@@ -16,6 +19,9 @@
@open-modal="openModal"
@update:include-folders="state.includeFolders.value = $event"
@update:exclude-folders="state.excludeFolders.value = $event"
@update:include-patterns="state.includePatterns.value = $event"
@update:exclude-patterns="state.excludePatterns.value = $event"
@update:use-regex="state.useRegex.value = $event"
@update:no-credit-required="state.noCreditRequired.value = $event"
@update:allow-selling="state.allowSelling.value = $event"
@refresh="state.refreshPreview"

View File

@@ -13,7 +13,9 @@
@click="handleOpenSelector"
>
<span class="progress-label">{{ isWorkflowExecuting ? 'Using LoRA:' : 'Next LoRA:' }}</span>
<span class="progress-name clickable" :class="{ disabled: isPauseDisabled }" :title="currentLoraFilename">
<span class="progress-name clickable"
:class="{ disabled: isPauseDisabled, 'no-lora': isNoLora }"
:title="currentLoraFilename">
{{ currentLoraName || 'None' }}
<svg class="selector-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 10l5 5 5-5z"/>
@@ -160,6 +162,27 @@
/>
</div>
</div>
<!-- Include No LoRA Toggle -->
<div class="setting-section">
<div class="section-header-with-toggle">
<label class="setting-label">
Add "No LoRA" step
</label>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': includeNoLora }"
@click="$emit('update:includeNoLora', !includeNoLora)"
role="switch"
:aria-checked="includeNoLora"
title="Add an iteration without LoRA for comparison"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</div>
</div>
</div>
</template>
@@ -182,6 +205,8 @@ const props = defineProps<{
isPauseDisabled: boolean
isWorkflowExecuting: boolean
executingRepeatStep: number
includeNoLora: boolean
isNoLora?: boolean
}>()
const emit = defineEmits<{
@@ -190,6 +215,7 @@ const emit = defineEmits<{
'update:clipStrength': [value: number]
'update:useCustomClipRange': [value: boolean]
'update:repeatCount': [value: number]
'update:includeNoLora': [value: boolean]
'toggle-pause': []
'reset-index': []
'open-lora-selector': []
@@ -346,6 +372,16 @@ const onRepeatBlur = (event: Event) => {
color: rgba(191, 219, 254, 1);
}
.progress-name.no-lora {
font-style: italic;
color: rgba(226, 232, 240, 0.6);
}
.progress-name.clickable.no-lora:hover:not(.disabled) {
background: rgba(160, 174, 192, 0.2);
color: rgba(226, 232, 240, 0.8);
}
.progress-name.clickable.disabled {
cursor: not-allowed;
opacity: 0.5;

View File

@@ -35,7 +35,10 @@
v-for="item in filteredList"
:key="item.index"
class="lora-item"
:class="{ active: currentIndex === item.index }"
:class="{
active: currentIndex === item.index,
'no-lora-item': item.lora.file_name === 'No LoRA'
}"
@mouseenter="showPreview(item.lora.file_name, $event)"
@mouseleave="hidePreview"
@click="selectLora(item.index)"
@@ -65,6 +68,7 @@ const props = defineProps<{
visible: boolean
loraList: LoraItem[]
currentIndex: number
includeNoLora?: boolean
}>()
const emit = defineEmits<{
@@ -79,7 +83,8 @@ const searchInputRef = ref<HTMLInputElement | null>(null)
let previewTooltip: any = null
const subtitleText = computed(() => {
const total = props.loraList.length
const baseTotal = props.loraList.length
const total = props.includeNoLora ? baseTotal + 1 : baseTotal
const filtered = filteredList.value.length
if (filtered === total) {
return `Total: ${total} LoRA${total !== 1 ? 's' : ''}`
@@ -88,11 +93,19 @@ const subtitleText = computed(() => {
})
const filteredList = computed<LoraListItem[]>(() => {
const list = props.loraList.map((lora, idx) => ({
const list: LoraListItem[] = props.loraList.map((lora, idx) => ({
index: idx + 1,
lora
}))
// Add "No LoRA" option at the end if includeNoLora is enabled
if (props.includeNoLora) {
list.push({
index: list.length + 1,
lora: { file_name: 'No LoRA' } as LoraItem
})
}
if (!searchQuery.value.trim()) {
return list
}
@@ -303,6 +316,15 @@ onUnmounted(() => {
font-weight: 500;
}
.lora-item.no-lora-item .lora-name {
font-style: italic;
color: rgba(226, 232, 240, 0.6);
}
.lora-item.no-lora-item:hover .lora-name {
color: rgba(226, 232, 240, 0.8);
}
.no-results {
padding: 32px 20px;
text-align: center;

View File

@@ -24,6 +24,15 @@
@edit-exclude="$emit('open-modal', 'excludeFolders')"
/>
<NamePatternsSection
:include-patterns="includePatterns"
:exclude-patterns="excludePatterns"
:use-regex="useRegex"
@update:include-patterns="$emit('update:includePatterns', $event)"
@update:exclude-patterns="$emit('update:excludePatterns', $event)"
@update:use-regex="$emit('update:useRegex', $event)"
/>
<LicenseSection
:no-credit-required="noCreditRequired"
:allow-selling="allowSelling"
@@ -46,6 +55,7 @@
import BaseModelSection from './sections/BaseModelSection.vue'
import TagsSection from './sections/TagsSection.vue'
import FoldersSection from './sections/FoldersSection.vue'
import NamePatternsSection from './sections/NamePatternsSection.vue'
import LicenseSection from './sections/LicenseSection.vue'
import LoraPoolPreview from './LoraPoolPreview.vue'
import type { BaseModelOption, LoraItem } from '../../composables/types'
@@ -61,6 +71,10 @@ defineProps<{
// Folders
includeFolders: string[]
excludeFolders: string[]
// Name patterns
includePatterns: string[]
excludePatterns: string[]
useRegex: boolean
// License
noCreditRequired: boolean
allowSelling: boolean
@@ -74,6 +88,9 @@ defineEmits<{
'open-modal': [modal: ModalType]
'update:includeFolders': [value: string[]]
'update:excludeFolders': [value: string[]]
'update:includePatterns': [value: string[]]
'update:excludePatterns': [value: string[]]
'update:useRegex': [value: boolean]
'update:noCreditRequired': [value: boolean]
'update:allowSelling': [value: boolean]
refresh: []

View File

@@ -0,0 +1,255 @@
<template>
<div class="section">
<div class="section__header">
<span class="section__title">NAME PATTERNS</span>
<label class="section__toggle">
<input
type="checkbox"
:checked="useRegex"
@change="$emit('update:useRegex', ($event.target as HTMLInputElement).checked)"
/>
<span class="section__toggle-label">Use Regex</span>
</label>
</div>
<div class="section__columns">
<!-- Include column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--include">INCLUDE</span>
</div>
<div class="section__input-wrapper">
<input
type="text"
v-model="includeInput"
:placeholder="useRegex ? 'Add regex pattern...' : 'Add text pattern...'"
class="section__input"
@keydown.enter="addInclude"
/>
<button type="button" class="section__add-btn" @click="addInclude">+</button>
</div>
<div class="section__patterns">
<FilterChip
v-for="pattern in includePatterns"
:key="pattern"
:label="pattern"
variant="include"
removable
@remove="removeInclude(pattern)"
/>
<div v-if="includePatterns.length === 0" class="section__empty">
{{ useRegex ? 'No regex patterns' : 'No text patterns' }}
</div>
</div>
</div>
<!-- Exclude column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--exclude">EXCLUDE</span>
</div>
<div class="section__input-wrapper">
<input
type="text"
v-model="excludeInput"
:placeholder="useRegex ? 'Add regex pattern...' : 'Add text pattern...'"
class="section__input"
@keydown.enter="addExclude"
/>
<button type="button" class="section__add-btn" @click="addExclude">+</button>
</div>
<div class="section__patterns">
<FilterChip
v-for="pattern in excludePatterns"
:key="pattern"
:label="pattern"
variant="exclude"
removable
@remove="removeExclude(pattern)"
/>
<div v-if="excludePatterns.length === 0" class="section__empty">
{{ useRegex ? 'No regex patterns' : 'No text patterns' }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import FilterChip from '../shared/FilterChip.vue'
const props = defineProps<{
includePatterns: string[]
excludePatterns: string[]
useRegex: boolean
}>()
const emit = defineEmits<{
'update:includePatterns': [value: string[]]
'update:excludePatterns': [value: string[]]
'update:useRegex': [value: boolean]
}>()
const includeInput = ref('')
const excludeInput = ref('')
const addInclude = () => {
const pattern = includeInput.value.trim()
if (pattern && !props.includePatterns.includes(pattern)) {
emit('update:includePatterns', [...props.includePatterns, pattern])
includeInput.value = ''
}
}
const addExclude = () => {
const pattern = excludeInput.value.trim()
if (pattern && !props.excludePatterns.includes(pattern)) {
emit('update:excludePatterns', [...props.excludePatterns, pattern])
excludeInput.value = ''
}
}
const removeInclude = (pattern: string) => {
emit('update:includePatterns', props.includePatterns.filter(p => p !== pattern))
}
const removeExclude = (pattern: string) => {
emit('update:excludePatterns', props.excludePatterns.filter(p => p !== pattern))
}
</script>
<style scoped>
.section {
margin-bottom: 16px;
}
.section__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section__title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.7;
}
.section__toggle input[type="checkbox"] {
margin: 0;
width: 14px;
height: 14px;
cursor: pointer;
}
.section__toggle-label {
font-weight: 500;
}
.section__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.section__column {
min-width: 0;
}
.section__column-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.section__column-title {
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.section__column-title--include {
color: #4299e1;
}
.section__column-title--exclude {
color: #ef4444;
}
.section__input-wrapper {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.section__input {
flex: 1;
min-width: 0;
padding: 6px 8px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--comfy-input-border, #444);
border-radius: 4px;
color: var(--fg-color, #fff);
font-size: 12px;
outline: none;
}
.section__input:focus {
border-color: #4299e1;
}
.section__add-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--comfy-input-border, #444);
border-radius: 4px;
color: var(--fg-color, #fff);
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.section__add-btn:hover {
background: var(--comfy-input-bg-hover, #444);
border-color: #4299e1;
}
.section__patterns {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 22px;
}
.section__empty {
font-size: 10px;
color: var(--fg-color, #fff);
opacity: 0.3;
font-style: italic;
min-height: 22px;
display: flex;
align-items: center;
}
</style>

View File

@@ -10,6 +10,12 @@ export interface LoraPoolConfig {
noCreditRequired: boolean
allowSelling: boolean
}
namePatterns: {
include: string[]
exclude: string[]
useRegex: boolean
}
includeEmptyLora?: boolean // Optional, deprecated (moved to Cycler)
}
preview: { matchCount: number; lastUpdated: number }
}
@@ -84,6 +90,8 @@ export interface CyclerConfig {
repeat_count: number // How many times each LoRA should repeat (default: 1)
repeat_used: number // How many times current index has been used
is_paused: boolean // Whether iteration is paused
// Include "no LoRA" option in cycle
include_no_lora: boolean // Whether to include empty LoRA option
}
// Widget config union type

View File

@@ -4,6 +4,7 @@ import type { ComponentWidget, CyclerConfig, LoraPoolConfig } from './types'
export interface CyclerLoraItem {
file_name: string
model_name: string
file_path: string
}
export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
@@ -34,6 +35,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
const repeatUsed = ref(0) // How many times current index has been used (internal tracking)
const displayRepeatUsed = ref(0) // For UI display, deferred updates like currentIndex
const isPaused = ref(false) // Whether iteration is paused
const includeNoLora = ref(false) // Whether to include empty LoRA option in cycle
// Execution progress tracking (visual feedback)
const isWorkflowExecuting = ref(false) // Workflow is currently running
@@ -58,6 +60,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
repeat_count: repeatCount.value,
repeat_used: repeatUsed.value,
is_paused: isPaused.value,
include_no_lora: includeNoLora.value,
}
}
return {
@@ -75,6 +78,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
repeat_count: repeatCount.value,
repeat_used: repeatUsed.value,
is_paused: isPaused.value,
include_no_lora: includeNoLora.value,
}
}
@@ -93,12 +97,13 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
sortBy.value = config.sort_by || 'filename'
currentLoraName.value = config.current_lora_name || ''
currentLoraFilename.value = config.current_lora_filename || ''
// Advanced index control features
repeatCount.value = config.repeat_count ?? 1
repeatUsed.value = config.repeat_used ?? 0
isPaused.value = config.is_paused ?? false
// Note: execution_index and next_index are not restored from config
// as they are transient values used only during batch execution
// Advanced index control features
repeatCount.value = config.repeat_count ?? 1
repeatUsed.value = config.repeat_used ?? 0
isPaused.value = config.is_paused ?? false
includeNoLora.value = config.include_no_lora ?? false
// Note: execution_index and next_index are not restored from config
// as they are transient values used only during batch execution
} finally {
isRestoring = false
}
@@ -111,7 +116,9 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
// Calculate the next index (wrap to 1 if at end)
const current = executionIndex.value ?? currentIndex.value
let next = current + 1
if (totalCount.value > 0 && next > totalCount.value) {
// Total count includes no lora option if enabled
const effectiveTotalCount = includeNoLora.value ? totalCount.value + 1 : totalCount.value
if (effectiveTotalCount > 0 && next > effectiveTotalCount) {
next = 1
}
nextIndex.value = next
@@ -122,7 +129,9 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
if (nextIndex.value === null) {
// First execution uses current_index, so next is current + 1
let next = currentIndex.value + 1
if (totalCount.value > 0 && next > totalCount.value) {
// Total count includes no lora option if enabled
const effectiveTotalCount = includeNoLora.value ? totalCount.value + 1 : totalCount.value
if (effectiveTotalCount > 0 && next > effectiveTotalCount) {
next = 1
}
nextIndex.value = next
@@ -230,7 +239,9 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
// Set index manually
const setIndex = (index: number) => {
if (index >= 1 && index <= totalCount.value) {
// Total count includes no lora option if enabled
const effectiveTotalCount = includeNoLora.value ? totalCount.value + 1 : totalCount.value
if (index >= 1 && index <= effectiveTotalCount) {
currentIndex.value = index
}
}
@@ -272,6 +283,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
repeatCount,
repeatUsed,
isPaused,
includeNoLora,
], () => {
widget.value = buildConfig()
}, { deep: true })
@@ -294,6 +306,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
repeatUsed,
displayRepeatUsed,
isPaused,
includeNoLora,
isWorkflowExecuting,
executingRepeatStep,

View File

@@ -62,6 +62,9 @@ export function useLoraPoolApi() {
foldersExclude?: string[]
noCreditRequired?: boolean
allowSelling?: boolean
namePatternsInclude?: string[]
namePatternsExclude?: string[]
namePatternsUseRegex?: boolean
page?: number
pageSize?: number
}
@@ -92,6 +95,13 @@ export function useLoraPoolApi() {
urlParams.set('allow_selling_generated_content', String(params.allowSelling))
}
// Name pattern filters
params.namePatternsInclude?.forEach(pattern => urlParams.append('name_pattern_include', pattern))
params.namePatternsExclude?.forEach(pattern => urlParams.append('name_pattern_exclude', pattern))
if (params.namePatternsUseRegex !== undefined) {
urlParams.set('name_pattern_use_regex', String(params.namePatternsUseRegex))
}
const response = await fetch(`/api/lm/loras/list?${urlParams}`)
const data = await response.json()

View File

@@ -24,6 +24,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
const excludeFolders = ref<string[]>([])
const noCreditRequired = ref(false)
const allowSelling = ref(false)
const includePatterns = ref<string[]>([])
const excludePatterns = ref<string[]>([])
const useRegex = ref(false)
// Available options from API
const availableBaseModels = ref<BaseModelOption[]>([])
@@ -52,6 +55,11 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
license: {
noCreditRequired: noCreditRequired.value,
allowSelling: allowSelling.value
},
namePatterns: {
include: includePatterns.value,
exclude: excludePatterns.value,
useRegex: useRegex.value
}
},
preview: {
@@ -94,6 +102,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
updateIfChanged(excludeFolders, filters.folders?.exclude || [])
updateIfChanged(noCreditRequired, filters.license?.noCreditRequired ?? false)
updateIfChanged(allowSelling, filters.license?.allowSelling ?? false)
updateIfChanged(includePatterns, filters.namePatterns?.include || [])
updateIfChanged(excludePatterns, filters.namePatterns?.exclude || [])
updateIfChanged(useRegex, filters.namePatterns?.useRegex ?? false)
// matchCount doesn't trigger watchers, so direct assignment is fine
matchCount.value = preview?.matchCount || 0
@@ -125,6 +136,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
foldersExclude: excludeFolders.value,
noCreditRequired: noCreditRequired.value || undefined,
allowSelling: allowSelling.value || undefined,
namePatternsInclude: includePatterns.value,
namePatternsExclude: excludePatterns.value,
namePatternsUseRegex: useRegex.value,
pageSize: 6
})
@@ -150,7 +164,10 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
includeFolders,
excludeFolders,
noCreditRequired,
allowSelling
allowSelling,
includePatterns,
excludePatterns,
useRegex
], onFilterChange, { deep: true })
return {
@@ -162,6 +179,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
excludeFolders,
noCreditRequired,
allowSelling,
includePatterns,
excludePatterns,
useRegex,
// Available options
availableBaseModels,

View File

@@ -13,12 +13,12 @@ import {
} from './mode-change-handler'
const LORA_POOL_WIDGET_MIN_WIDTH = 500
const LORA_POOL_WIDGET_MIN_HEIGHT = 400
const LORA_POOL_WIDGET_MIN_HEIGHT = 520
const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
const LORA_CYCLER_WIDGET_MIN_WIDTH = 380
const LORA_CYCLER_WIDGET_MIN_HEIGHT = 314
const LORA_CYCLER_WIDGET_MIN_HEIGHT = 344
const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200

View File

@@ -84,7 +84,8 @@ describe('useLoraCyclerState', () => {
current_lora_filename: '',
repeat_count: 1,
repeat_used: 0,
is_paused: false
is_paused: false,
include_no_lora: false
})
expect(state.currentIndex.value).toBe(5)

View File

@@ -24,6 +24,7 @@ export function createMockCyclerConfig(overrides: Partial<CyclerConfig> = {}): C
repeat_count: 1,
repeat_used: 0,
is_paused: false,
include_no_lora: false,
...overrides
}
}
@@ -54,7 +55,8 @@ export function createMockPoolConfig(overrides: Partial<LoraPoolConfig> = {}): L
export function createMockLoraList(count: number = 5): CyclerLoraItem[] {
return Array.from({ length: count }, (_, i) => ({
file_name: `lora${i + 1}.safetensors`,
model_name: `LoRA Model ${i + 1}`
model_name: `LoRA Model ${i + 1}`,
file_path: `/models/loras/lora${i + 1}.safetensors`
}))
}

View File

@@ -742,6 +742,14 @@ class AutoComplete {
try {
this.currentSearchTerm = term;
// Save current search type to detect mode changes during async search
const searchTypeAtStart = this.searchType;
// Clear items before starting new search to avoid stale data
// This is critical for preventing command suggestions from persisting
// when switching from command mode to regular tag search
this.items = [];
if (!endpoint) {
endpoint = `/lm/${this.modelType}/relative-paths`;
}
@@ -776,7 +784,15 @@ class AutoComplete {
const resultsArrays = await Promise.all(searchPromises);
// Merge and deduplicate results
// Check if search type changed during async operation
// If so, skip updating items to prevent stale data from showing
if (this.searchType !== searchTypeAtStart) {
console.log('[Lora Manager] Search type changed during search, skipping update');
return;
}
// Merge and deduplicate results while preserving order from backend
// Backend returns results sorted by relevance, so we maintain that order
const seen = new Set();
const mergedItems = [];
@@ -793,39 +809,10 @@ class AutoComplete {
}
}
// Score and sort results: exact matches first, then by match quality
const scoredItems = mergedItems.map(item => {
let bestScore = -1;
let isExact = false;
for (const query of queriesToExecute) {
const match = this._matchItem(item, query);
if (match.matched) {
// Higher score for exact matches
const score = match.isExactMatch ? 1000 : 100;
if (score > bestScore) {
bestScore = score;
isExact = match.isExactMatch;
}
}
}
return { item, score: bestScore, isExact };
});
// Sort by score (descending), exact matches first
scoredItems.sort((a, b) => {
if (b.isExact !== a.isExact) {
return b.isExact ? 1 : -1;
}
return b.score - a.score;
});
// Extract just the items
const sortedItems = scoredItems.map(s => s.item);
if (sortedItems.length > 0) {
this.items = sortedItems;
// Use backend-sorted results directly without re-scoring
// Backend already ranks by: FTS5 bm25 score + post count + exact prefix boost
if (mergedItems.length > 0) {
this.items = mergedItems;
this.render();
this.show();
} else {
@@ -908,6 +895,12 @@ class AutoComplete {
* @param {string} filter - Optional filter for commands
*/
_showCommandList(filter = '') {
// Only show command list if we're in command mode
// This prevents stale command suggestions from appearing after switching to tag search
if (this.searchType !== 'commands' && this.showingCommands !== true) {
return;
}
const filterLower = filter.toLowerCase();
// Get unique commands (avoid duplicates like /char and /character)
@@ -942,12 +935,20 @@ class AutoComplete {
* Render the command list dropdown
*/
_renderCommandList() {
this.dropdown.innerHTML = '';
// Clear command list items properly based on rendering mode
if (this.contentContainer) {
// Virtual scrolling mode - clear content container
this.contentContainer.innerHTML = '';
} else {
// Non-virtual scrolling mode - clear dropdown direct children
this.dropdown.innerHTML = '';
}
this.selectedIndex = -1;
this.items.forEach((item, index) => {
const itemEl = document.createElement('div');
itemEl.className = 'comfy-autocomplete-item comfy-autocomplete-command';
itemEl.dataset.index = index.toString();
const cmdSpan = document.createElement('span');
cmdSpan.className = 'lm-autocomplete-command-name';
@@ -973,6 +974,8 @@ class AutoComplete {
justify-content: space-between;
align-items: center;
gap: 12px;
height: ${this.options.itemHeight}px;
box-sizing: border-box;
`;
itemEl.addEventListener('mouseenter', () => {
@@ -983,18 +986,29 @@ class AutoComplete {
this._insertCommand(item.command);
});
this.dropdown.appendChild(itemEl);
// Append to correct container based on rendering mode
if (this.contentContainer) {
this.contentContainer.appendChild(itemEl);
} else {
this.dropdown.appendChild(itemEl);
}
});
// Remove border from last item
if (this.dropdown.lastChild) {
this.dropdown.lastChild.style.borderBottom = 'none';
const lastChild = this.contentContainer ? this.contentContainer.lastChild : this.dropdown.lastChild;
if (lastChild) {
lastChild.style.borderBottom = 'none';
}
// Auto-select first item
if (this.items.length > 0) {
setTimeout(() => this.selectItem(0), 100);
}
// Update virtual scroll height for virtual scrolling mode
if (this.contentContainer) {
this.updateVirtualScrollHeight();
}
}
/**
@@ -1057,28 +1071,49 @@ class AutoComplete {
}
if (this.options.enableVirtualScroll && this.contentContainer) {
// Use virtual scrolling - only update visible items if dropdown is already visible
// If not visible, updateVisibleItems() will be called from show() after display:block
// Use virtual scrolling - always update visible items to ensure content is fresh
// The dropdown visibility is controlled by show()/hide()
this.updateVirtualScrollHeight();
if (this.isVisible && this.dropdown.style.display !== 'none') {
this.updateVisibleItems();
}
this.updateVisibleItems();
} else {
// Traditional rendering (fallback)
this.dropdown.innerHTML = '';
// Check if items are enriched (have tag_name, category, post_count)
// Check if items are enriched (have tag_name, category, post_count) or command objects
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0];
this.items.forEach((itemData, index) => {
const item = document.createElement('div');
item.className = 'comfy-autocomplete-item';
// Get the display text and path for insertion
const displayText = isEnriched ? itemData.tag_name : itemData;
const insertPath = isEnriched ? itemData.tag_name : itemData;
if (isCommand) {
// Render command item
const cmdSpan = document.createElement('span');
cmdSpan.className = 'lm-autocomplete-command-name';
cmdSpan.textContent = itemData.command;
if (isEnriched) {
const labelSpan = document.createElement('span');
labelSpan.className = 'lm-autocomplete-command-label';
labelSpan.textContent = itemData.label;
item.appendChild(cmdSpan);
item.appendChild(labelSpan);
item.style.cssText = `
padding: 8px 12px;
cursor: pointer;
color: rgba(226, 232, 240, 0.8);
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
transition: all 0.2s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
`;
} else if (isEnriched) {
// Render enriched item with category badge and post count
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
} else {
@@ -1087,7 +1122,7 @@ class AutoComplete {
const nameSpan = document.createElement('span');
nameSpan.className = 'lm-autocomplete-name';
// Use display text without extension for cleaner UI
const displayTextWithoutExt = this._getDisplayText(displayText);
const displayTextWithoutExt = this._getDisplayText(itemData);
nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm);
nameSpan.style.cssText = `
flex: 1;
@@ -1096,25 +1131,25 @@ class AutoComplete {
text-overflow: ellipsis;
`;
item.appendChild(nameSpan);
// Apply item styles with new color scheme
item.style.cssText = `
padding: 8px 12px;
cursor: pointer;
color: rgba(226, 232, 240, 0.8);
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
transition: all 0.2s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
`;
}
// Apply item styles with new color scheme
item.style.cssText = `
padding: 8px 12px;
cursor: pointer;
color: rgba(226, 232, 240, 0.8);
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
transition: all 0.2s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
`;
// Hover and selection handlers
item.addEventListener('mouseenter', () => {
this.selectItem(index);
@@ -1126,7 +1161,12 @@ class AutoComplete {
// Click handler
item.addEventListener('click', () => {
this.insertSelection(insertPath);
if (isCommand) {
this._insertCommand(itemData.command);
} else {
const insertPath = isEnriched ? itemData.tag_name : itemData;
this.insertSelection(insertPath);
}
});
this.dropdown.appendChild(item);
@@ -1369,39 +1409,11 @@ class AutoComplete {
this.hasMoreItems = false;
}
// If we got new items, add them and re-render
// If we got new items, append them and re-render
// IMPORTANT: Do NOT re-sort! Backend already returns results sorted by relevance
if (newItems.length > 0) {
const currentLength = this.items.length;
this.items.push(...newItems);
// Re-score and sort all items
const scoredItems = this.items.map(item => {
let bestScore = -1;
let isExact = false;
for (const query of queriesToExecute) {
const match = this._matchItem(item, query);
if (match.matched) {
const score = match.isExactMatch ? 1000 : 100;
if (score > bestScore) {
bestScore = score;
isExact = match.isExactMatch;
}
}
}
return { item, score: bestScore, isExact };
});
scoredItems.sort((a, b) => {
if (b.isExact !== a.isExact) {
return b.isExact ? 1 : -1;
}
return b.score - a.score;
});
this.items = scoredItems.map(s => s.item);
// Update render
if (this.options.enableVirtualScroll) {
this.updateVirtualScrollHeight();
@@ -1458,10 +1470,18 @@ class AutoComplete {
* Update the total height of the virtual scroll container
*/
updateVirtualScrollHeight() {
if (!this.contentContainer) return;
if (!this.contentContainer || !this.scrollContainer) return;
this.totalHeight = this.items.length * this.options.itemHeight;
this.contentContainer.style.height = `${this.totalHeight}px`;
// Adjust scroll container max-height based on actual content
// Only show scrollbar when content exceeds visibleItems limit
const maxHeight = this.options.visibleItems * this.options.itemHeight;
const shouldShowScrollbar = this.totalHeight > maxHeight;
this.scrollContainer.style.maxHeight = shouldShowScrollbar ? `${maxHeight}px` : `${this.totalHeight}px`;
this.scrollContainer.style.overflowY = shouldShowScrollbar ? 'auto' : 'hidden';
}
/**
@@ -1473,11 +1493,12 @@ class AutoComplete {
const scrollTop = this.scrollContainer.scrollTop;
const containerHeight = this.scrollContainer.clientHeight;
// Calculate which items should be visible
const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - 2);
// Calculate which items should be visible with a larger buffer for smoother rendering
// Use a fixed buffer of 5 items to ensure selected item is always rendered
const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - 5);
const endIndex = Math.min(
this.items.length - 1,
Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + 2
Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + 5
);
// Clear current content
@@ -1492,10 +1513,11 @@ class AutoComplete {
// Render visible items
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0];
for (let i = startIndex; i <= endIndex; i++) {
const itemData = this.items[i];
const itemEl = this.createItemElement(itemData, i, isEnriched);
const itemEl = this.createItemElement(itemData, i, isEnriched, isCommand);
this.contentContainer.appendChild(itemEl);
}
@@ -1505,12 +1527,22 @@ class AutoComplete {
bottomSpacer.style.height = `${(this.items.length - 1 - endIndex) * this.options.itemHeight}px`;
this.contentContainer.appendChild(bottomSpacer);
}
// Re-apply selection styling after re-rendering
// This ensures the selected item remains highlighted even after DOM updates
if (this.selectedIndex >= startIndex && this.selectedIndex <= endIndex) {
const selectedEl = this.contentContainer.querySelector(`.comfy-autocomplete-item[data-index="${this.selectedIndex}"]`);
if (selectedEl) {
selectedEl.classList.add('comfy-autocomplete-item-selected');
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
}
}
}
/**
* Create a single item element
*/
createItemElement(itemData, index, isEnriched) {
createItemElement(itemData, index, isEnriched, isCommand = false) {
const item = document.createElement('div');
item.className = 'comfy-autocomplete-item';
item.dataset.index = index.toString();
@@ -1532,16 +1564,31 @@ class AutoComplete {
box-sizing: border-box;
`;
const displayText = isEnriched ? itemData.tag_name : itemData;
const insertPath = isEnriched ? itemData.tag_name : itemData;
// Check if this is a command object (override parameter if needed)
if (!isCommand && itemData && typeof itemData === 'object' && 'command' in itemData) {
isCommand = true;
}
if (isEnriched) {
if (isCommand) {
// Render command item
const cmdSpan = document.createElement('span');
cmdSpan.className = 'lm-autocomplete-command-name';
cmdSpan.textContent = itemData.command;
const labelSpan = document.createElement('span');
labelSpan.className = 'lm-autocomplete-command-label';
labelSpan.textContent = itemData.label;
item.appendChild(cmdSpan);
item.appendChild(labelSpan);
item.style.gap = '12px';
} else if (isEnriched) {
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
} else {
const nameSpan = document.createElement('span');
nameSpan.className = 'lm-autocomplete-name';
// Use display text without extension for cleaner UI
const displayTextWithoutExt = this._getDisplayText(displayText);
const displayTextWithoutExt = this._getDisplayText(itemData);
nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm);
nameSpan.style.cssText = `
flex: 1;
@@ -1561,8 +1608,14 @@ class AutoComplete {
this.hidePreview();
});
// Click handler
item.addEventListener('click', () => {
this.insertSelection(insertPath);
if (isCommand) {
this._insertCommand(itemData.command);
} else {
const insertPath = isEnriched ? itemData.tag_name : itemData;
this.insertSelection(insertPath);
}
});
return item;
@@ -1578,7 +1631,10 @@ class AutoComplete {
if (this.options.enableVirtualScroll && this.contentContainer) {
this.dropdown.style.display = 'block';
this.isVisible = true;
this.updateVisibleItems();
// Skip updateVisibleItems if showing commands (already rendered by _renderCommandList)
if (!this.showingCommands) {
this.updateVisibleItems();
}
this.positionAtCursor();
} else {
// Position dropdown at cursor position using TextAreaCaretHelper
@@ -1638,6 +1694,19 @@ class AutoComplete {
this.isVisible = false;
this.selectedIndex = -1;
this.showingCommands = false;
// Clear items to prevent stale data from being displayed
// when autocomplete is shown again
this.items = [];
// Clear content container to prevent stale items from showing
if (this.contentContainer) {
// Virtual scrolling mode - clear content container
this.contentContainer.innerHTML = '';
} else {
// Non-virtual scrolling mode - clear dropdown direct children
this.dropdown.innerHTML = '';
}
// Reset virtual scrolling state
this.virtualScrollOffset = 0;
@@ -1688,26 +1757,22 @@ class AutoComplete {
// If item is not visible, scroll to make it visible
if (itemTop < scrollTop || itemBottom > scrollBottom) {
this.scrollContainer.scrollTop = itemTop - containerHeight / 2;
// Scroll to position the item in the visible area
// Position item at 1/3 from top for better visibility
const targetScrollTop = Math.max(0, itemTop - containerHeight / 3);
this.scrollContainer.scrollTop = targetScrollTop;
// Re-render visible items after scroll
this.updateVisibleItems();
}
// Find the item element using data-index attribute
const selectedEl = container.querySelector(`.comfy-autocomplete-item[data-index="${index}"]`);
if (selectedEl) {
selectedEl.classList.add('comfy-autocomplete-item-selected');
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
// Show preview for selected item
if (this.options.showPreview) {
if (typeof this.behavior.showPreview === 'function') {
this.behavior.showPreview(this, this.items[index], selectedEl);
} else if (this.previewTooltip) {
this.showPreviewForItem(this.items[index], selectedEl);
}
}
// Apply selection after DOM is updated
// Use setTimeout to ensure DOM has been re-rendered
setTimeout(() => {
this._applyItemSelection(index);
}, 0);
} else {
// Item is already visible, apply selection immediately
this._applyItemSelection(index);
}
} else {
// Traditional rendering
@@ -1731,6 +1796,31 @@ class AutoComplete {
}
}
}
/**
* Apply selection styling to an item (used after virtual scroll re-render)
* @param {number} index - Index of item to select
*/
_applyItemSelection(index) {
if (!this.contentContainer) return;
// Find the item element using data-index attribute
const selectedEl = this.contentContainer.querySelector(`.comfy-autocomplete-item[data-index="${index}"]`);
if (selectedEl) {
selectedEl.classList.add('comfy-autocomplete-item-selected');
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
// Show preview for selected item
if (this.options.showPreview) {
if (typeof this.behavior.showPreview === 'function') {
this.behavior.showPreview(this, this.items[index], selectedEl);
} else if (this.previewTooltip) {
this.showPreviewForItem(this.items[index], selectedEl);
}
}
}
}
handleKeyDown(e) {
if (!this.isVisible) {
@@ -1740,12 +1830,39 @@ class AutoComplete {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1));
if (this.options.enableVirtualScroll && this.scrollContainer) {
// For virtual scrolling, handle boundary cases
if (this.selectedIndex >= this.items.length - 1) {
// Already at last item, try to load more
if (this.hasMoreItems && !this.isLoadingMore) {
this.loadMoreItems().then(() => {
// After loading more, select the next item
if (this.selectedIndex < this.items.length - 1) {
this.selectItem(this.selectedIndex + 1);
}
});
}
} else {
this.selectItem(this.selectedIndex + 1);
}
} else {
this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1));
}
break;
case 'ArrowUp':
e.preventDefault();
this.selectItem(Math.max(this.selectedIndex - 1, 0));
if (this.options.enableVirtualScroll && this.scrollContainer) {
// For virtual scrolling, handle top boundary
if (this.selectedIndex <= 0) {
// Already at first item, ensure it's selected
this.selectItem(0);
} else {
this.selectItem(this.selectedIndex - 1);
}
} else {
this.selectItem(Math.max(this.selectedIndex - 1, 0));
}
break;
case 'Enter':
@@ -1788,10 +1905,38 @@ class AutoComplete {
// For regular tag autocomplete (no command), only replace the last space-separated token
// This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, "
// However, if the user typed a multi-word phrase that matches a tag (e.g., "looking to the side"
// matching "looking_to_the_side"), replace the entire phrase instead of just the last word.
// Command mode (e.g., "/char miku") should replace the entire command+search
let searchTerm = fullSearchTerm;
if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) {
searchTerm = this._getLastSpaceToken(fullSearchTerm);
// Check if the selectedItem exists and its tag_name matches the full search term
// when converted to underscore format (Danbooru convention)
const selectedItem = this.selectedIndex >= 0 ? this.items[this.selectedIndex] : null;
const selectedTagName = selectedItem && typeof selectedItem === 'object' && 'tag_name'
? selectedItem.tag_name
: null;
// Convert full search term to underscore format and check if it matches selected tag
// Normalize multiple spaces to single underscore for matching (e.g., "looking to the side" -> "looking_to_the_side")
const underscoreVersion = fullSearchTerm.replace(/ +/g, '_').toLowerCase();
const selectedTagLower = selectedTagName?.toLowerCase() ?? '';
// If multi-word search term is a prefix or suffix of the selected tag,
// replace the entire phrase. This handles cases where user types partial tag name.
// Examples:
// - "looking to the" -> "looking_to_the_side" (prefix match)
// - "to the side" -> "looking_to_the_side" (suffix match)
// - "looking to the side" -> "looking_to_the_side" (exact match)
if (fullSearchTerm.includes(' ') && (
selectedTagLower.startsWith(underscoreVersion) ||
selectedTagLower.endsWith(underscoreVersion) ||
underscoreVersion === selectedTagLower
)) {
searchTerm = fullSearchTerm;
} else {
searchTerm = this._getLastSpaceToken(fullSearchTerm);
}
}
const searchStartPos = caretPos - searchTerm.length;

View File

@@ -14,6 +14,7 @@ import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKey
import { forwardMiddleMouseToCanvas } from "./utils.js";
import { PreviewTooltip } from "./preview_tooltip.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
import { getStrengthStepPreference } from "./settings.js";
export function addLorasWidget(node, name, opts, callback) {
ensureLmStyles();
@@ -416,7 +417,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - 0.05).toFixed(2);
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - getStrengthStepPreference()).toFixed(2);
// Sync clipStrength if collapsed
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
@@ -488,7 +489,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + 0.05).toFixed(2);
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + getStrengthStepPreference()).toFixed(2);
// Sync clipStrength if collapsed
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
@@ -541,7 +542,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) - 0.05).toFixed(2);
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) - getStrengthStepPreference()).toFixed(2);
const newValue = formatLoraValue(lorasData);
updateWidgetValue(newValue);
@@ -611,7 +612,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) + 0.05).toFixed(2);
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) + getStrengthStepPreference()).toFixed(2);
const newValue = formatLoraValue(lorasData);
updateWidgetValue(newValue);

View File

@@ -24,6 +24,9 @@ const NEW_TAB_TEMPLATE_DEFAULT = "Default";
const NEW_TAB_ZOOM_LEVEL = 0.8;
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
const STRENGTH_STEP_DEFAULT = 0.05;
// ============================================================================
// Helper Functions
// ============================================================================
@@ -232,6 +235,32 @@ const getNewTabTemplatePreference = (() => {
};
})();
const getStrengthStepPreference = (() => {
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 strength step.");
settingsUnavailableLogged = true;
}
return STRENGTH_STEP_DEFAULT;
}
try {
const value = settingManager.get(STRENGTH_STEP_SETTING_ID);
return value ?? STRENGTH_STEP_DEFAULT;
} catch (error) {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: unable to read strength step setting, using default.", error);
settingsUnavailableLogged = true;
}
return STRENGTH_STEP_DEFAULT;
}
};
})();
// ============================================================================
// Register Extension with All Settings
// ============================================================================
@@ -293,6 +322,19 @@ app.registerExtension({
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"],
},
{
id: STRENGTH_STEP_SETTING_ID,
name: "Strength Adjustment Step",
type: "slider",
attrs: {
min: 0.01,
max: 0.1,
step: 0.01,
},
defaultValue: STRENGTH_STEP_DEFAULT,
tooltip: "Step size for adjusting LoRA strength via arrow buttons or keyboard (default: 0.05)",
category: ["LoRA Manager", "LoRA Widget", "Strength Step"],
},
],
async setup() {
await loadWorkflowOptions();
@@ -375,4 +417,5 @@ export {
getTagSpaceReplacementPreference,
getUsageStatisticsPreference,
getNewTabTemplatePreference,
getStrengthStepPreference,
};

View File

@@ -3,6 +3,10 @@ import { app } from "../../scripts/app.js";
const BUTTON_TOOLTIP = "Launch LoRA Manager (Shift+Click opens in new window)";
const LORA_MANAGER_PATH = "/loras";
const NEW_WINDOW_FEATURES = "width=1200,height=800,resizable=yes,scrollbars=yes,status=yes";
const MAX_ATTACH_ATTEMPTS = 120;
const BUTTON_GROUP_CLASS = "lora-manager-top-menu-group";
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
const openLoraManager = (event) => {
const url = `${window.location.origin}${LORA_MANAGER_PATH}`;
@@ -15,6 +19,65 @@ const openLoraManager = (event) => {
window.open(url, "_blank");
};
const getComfyUIFrontendVersion = async () => {
try {
if (window['__COMFYUI_FRONTEND_VERSION__']) {
return window['__COMFYUI_FRONTEND_VERSION__'];
}
} catch (error) {
console.warn("LoRA Manager: unable to read __COMFYUI_FRONTEND_VERSION__:", error);
}
try {
const response = await fetch("/system_stats");
const data = await response.json();
if (data?.system?.comfyui_frontend_version) {
return data.system.comfyui_frontend_version;
}
if (data?.system?.required_frontend_version) {
return data.system.required_frontend_version;
}
} catch (error) {
console.warn("LoRA Manager: unable to fetch system_stats:", error);
}
return "0.0.0";
};
const parseVersion = (versionStr) => {
if (!versionStr || typeof versionStr !== 'string') {
return [0, 0, 0];
}
const cleanVersion = versionStr.replace(/^[vV]/, '').split('-')[0];
const parts = cleanVersion.split('.').map(part => parseInt(part, 10) || 0);
while (parts.length < 3) {
parts.push(0);
}
return parts;
};
const compareVersions = (version1, version2) => {
const v1 = typeof version1 === 'string' ? parseVersion(version1) : version1;
const v2 = typeof version2 === 'string' ? parseVersion(version2) : version2;
for (let i = 0; i < 3; i++) {
if (v1[i] > v2[i]) return 1;
if (v1[i] < v2[i]) return -1;
}
return 0;
};
const supportsActionBarButtons = async () => {
const version = await getComfyUIFrontendVersion();
return compareVersions(version, MIN_VERSION_FOR_ACTION_BAR) >= 0;
};
const fetchVersionInfo = async () => {
try {
const response = await fetch("/api/lm/version-info");
@@ -30,6 +93,55 @@ const fetchVersionInfo = async () => {
return "";
};
const createTopMenuButton = async () => {
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
const button = new ComfyButton({
icon: "loramanager",
tooltip: BUTTON_TOOLTIP,
app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
});
button.element.setAttribute("aria-label", BUTTON_TOOLTIP);
button.element.title = BUTTON_TOOLTIP;
if (button.iconElement) {
button.iconElement.innerHTML = getLoraManagerIcon();
button.iconElement.style.width = "1.2rem";
button.iconElement.style.height = "1.2rem";
}
button.element.addEventListener("click", openLoraManager);
return button;
};
const attachTopMenuButton = async (attempt = 0) => {
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
return;
}
const settingsGroup = app.menu?.settingsGroup;
if (!settingsGroup?.element?.parentElement) {
if (attempt >= MAX_ATTACH_ATTEMPTS) {
console.warn("LoRA Manager: unable to locate the ComfyUI settings button group.");
return;
}
requestAnimationFrame(() => attachTopMenuButton(attempt + 1));
return;
}
const loraManagerButton = await createTopMenuButton();
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
const buttonGroup = new ComfyButtonGroup(loraManagerButton);
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
settingsGroup.element.before(buttonGroup.element);
};
const createAboutBadge = (version) => {
const label = version ? `LoRA Manager v${version}` : "LoRA Manager";
@@ -40,60 +152,80 @@ const createAboutBadge = (version) => {
};
};
app.registerExtension({
name: "LoraManager.TopMenu",
actionBarButtons: [
{
icon: "icon-[mdi--alpha-l-box] size-4",
tooltip: BUTTON_TOOLTIP,
onClick: openLoraManager
}
],
aboutPageBadges: [createAboutBadge()],
async setup() {
const version = await fetchVersionInfo();
this.aboutPageBadges = [createAboutBadge(version)];
const injectStyles = () => {
const styleId = 'lm-top-menu-button-styles';
if (document.getElementById(styleId)) return;
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"].lm-top-menu-button {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"].lm-top-menu-button:hover {
background-color: var(--primary-hover-bg) !important;
}
`;
document.head.appendChild(style);
};
injectStyles();
const replaceButtonIcon = () => {
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
buttons.forEach(button => {
button.classList.add('lm-top-menu-button');
button.innerHTML = getLoraManagerIcon();
button.style.borderRadius = '4px';
button.style.padding = '6px';
button.style.backgroundColor = 'var(--primary-bg)';
const svg = button.querySelector('svg');
if (svg) {
svg.style.width = '20px';
svg.style.height = '20px';
}
});
if (buttons.length === 0) {
requestAnimationFrame(replaceButtonIcon);
const createExtensionObject = (useActionBar) => {
const extensionObj = {
name: "LoraManager.TopMenu",
async setup() {
const version = await fetchVersionInfo();
if (!useActionBar) {
console.log("LoRA Manager: using legacy button attachment (frontend version < 1.33.9)");
await attachTopMenuButton();
} else {
console.log("LoRA Manager: using actionBarButtons API (frontend version >= 1.33.9)");
}
};
requestAnimationFrame(replaceButtonIcon);
},
});
this.aboutPageBadges = [createAboutBadge(version)];
const injectStyles = () => {
const styleId = 'lm-top-menu-button-styles';
if (document.getElementById(styleId)) return;
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"].lm-top-menu-button {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"].lm-top-menu-button:hover {
background-color: var(--primary-hover-bg) !important;
}
`;
document.head.appendChild(style);
};
injectStyles();
const replaceButtonIcon = () => {
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
buttons.forEach(button => {
button.classList.add('lm-top-menu-button');
button.innerHTML = getLoraManagerIcon();
button.style.borderRadius = '4px';
button.style.padding = '6px';
button.style.backgroundColor = 'var(--primary-bg)';
const svg = button.querySelector('svg');
if (svg) {
svg.style.width = '20px';
svg.style.height = '20px';
}
});
if (buttons.length === 0) {
requestAnimationFrame(replaceButtonIcon);
}
};
requestAnimationFrame(replaceButtonIcon);
},
};
if (useActionBar) {
extensionObj.actionBarButtons = [
{
icon: "icon-[mdi--alpha-l-box] size-4",
tooltip: BUTTON_TOOLTIP,
onClick: openLoraManager
}
];
}
return extensionObj;
};
(async () => {
const useActionBar = await supportsActionBarButtons();
const extensionObj = createExtensionObject(useActionBar);
app.registerExtension(extensionObj);
})();
const getLoraManagerIcon = () => {
return `

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long