mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 17:35:33 -03:00
Compare commits
102
Commits
v1.1.1
...
8b344ea39f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b344ea39f | ||
|
|
8348a0cef8 | ||
|
|
7cf785b72f | ||
|
|
e8913f4481 | ||
|
|
f9c3d8dc97 | ||
|
|
09ca91fc0e | ||
|
|
16f5222efd | ||
|
|
28e7c04b37 | ||
|
|
28f99c46d3 | ||
|
|
205194f4e6 | ||
|
|
402d8b07cf | ||
|
|
3e303ab316 | ||
|
|
e9e8c31ad1 | ||
|
|
703a6a4ea0 | ||
|
|
283730cf38 | ||
|
|
20417797e8 | ||
|
|
004c69b9ef | ||
|
|
47fe2d3783 | ||
|
|
36ef840a22 | ||
|
|
09c2445ac9 | ||
|
|
8a6d23f9c7 | ||
|
|
3d207b6744 | ||
|
|
b3edda62ad | ||
|
|
a429e6b1c3 | ||
|
|
c1bf9c6221 | ||
|
|
75fffc1e25 | ||
|
|
f264bab65c | ||
|
|
154fcd803b | ||
|
|
4ef32d3a96 | ||
|
|
d2d109a69c | ||
|
|
3a2941d751 | ||
|
|
0ac10dfd42 | ||
|
|
9c95856b2f | ||
|
|
5ce4667d32 | ||
|
|
be53fda6df | ||
|
|
f48de05102 | ||
|
|
93ad81ed87 | ||
|
|
ea14d211be | ||
|
|
8052cefd46 | ||
|
|
845815b9b7 | ||
|
|
609dc5d783 | ||
|
|
7a71b34b54 | ||
|
|
71a459422f | ||
|
|
cd2628a0ee | ||
|
|
85da7175bc | ||
|
|
d3bf0a164b | ||
|
|
afb6ca1b8d | ||
|
|
94f43426d7 | ||
|
|
2b361f4f5d | ||
|
|
7438072f8c | ||
|
|
26c54fd358 | ||
|
|
7cb6b04c63 | ||
|
|
fc29cde82a | ||
|
|
559ca946dc | ||
|
|
2b8e7c7504 | ||
|
|
6816d75933 | ||
|
|
b58abbad7c | ||
|
|
999814ca87 | ||
|
|
3c2760a803 | ||
|
|
0edbd7bcca | ||
|
|
21e89fa7de | ||
|
|
968d6d1d1f | ||
|
|
cf0fd0e0ad | ||
|
|
16e5dcf7b2 | ||
|
|
ab6bb25d46 | ||
|
|
07f49559be | ||
|
|
b24b1a7e57 | ||
|
|
faf64f8986 | ||
|
|
a617487a43 | ||
|
|
3012a7aef3 | ||
|
|
499e19de34 | ||
|
|
9161762ca9 | ||
|
|
9bbd26efe6 | ||
|
|
258b2622d5 | ||
|
|
80ec9085dd | ||
|
|
c5c7373e10 | ||
|
|
b7721866e5 | ||
|
|
8314b9bedb | ||
|
|
75298a402f | ||
|
|
92b5efd414 | ||
|
|
33ee392b7b | ||
|
|
5237f8b7dc | ||
|
|
5107313fd1 | ||
|
|
95bbc66919 | ||
|
|
e268e59419 | ||
|
|
547e1f9498 | ||
|
|
bf32d8b6fd | ||
|
|
8299881024 | ||
|
|
da02268196 | ||
|
|
8c4b9a1e70 | ||
|
|
0906c484e9 | ||
|
|
4199c30fec | ||
|
|
4a8084cdbc | ||
|
|
6263e6848c | ||
|
|
58c266ad07 | ||
|
|
2939813e1a | ||
|
|
a9e5ee7e79 | ||
|
|
a17b0e9901 | ||
|
|
8f23d966bf | ||
|
|
7a76fc72d0 | ||
|
|
518a4dd5ee | ||
|
|
2b6d4e5d8b |
@@ -1,153 +0,0 @@
|
|||||||
# 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
|
|
||||||
+11
-1
@@ -7,17 +7,24 @@ py/run_test.py
|
|||||||
.vscode/
|
.vscode/
|
||||||
cache/
|
cache/
|
||||||
civitai/
|
civitai/
|
||||||
|
stats/
|
||||||
|
wildcards/
|
||||||
|
backups/
|
||||||
|
logs/
|
||||||
node_modules/
|
node_modules/
|
||||||
coverage/
|
coverage/
|
||||||
.coverage
|
.coverage
|
||||||
model_cache/
|
model_cache/
|
||||||
|
|
||||||
# agent
|
# agent / dev tooling
|
||||||
.opencode/
|
.opencode/
|
||||||
.claude/
|
.claude/
|
||||||
.sisyphus/
|
.sisyphus/
|
||||||
.codex
|
.codex
|
||||||
.omo
|
.omo
|
||||||
|
reasonix.toml
|
||||||
|
.reasonix/
|
||||||
|
.codegraph/
|
||||||
|
|
||||||
# Vue widgets development cache (but keep build output)
|
# Vue widgets development cache (but keep build output)
|
||||||
vue-widgets/node_modules/
|
vue-widgets/node_modules/
|
||||||
@@ -26,3 +33,6 @@ vue-widgets/dist/
|
|||||||
|
|
||||||
# Hypothesis test cache
|
# Hypothesis test cache
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
|
|
||||||
|
# Working/research notes (not committed)
|
||||||
|
.docs/
|
||||||
|
|||||||
+370
-340
File diff suppressed because it is too large
Load Diff
+2139
-2019
File diff suppressed because it is too large
Load Diff
+154
-34
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
"cancelling": "Cancelling...",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
"removeFromFavorites": "Remove from favorites",
|
"removeFromFavorites": "Remove from favorites",
|
||||||
"viewOnCivitai": "View on Civitai",
|
"viewOnCivitai": "View on Civitai",
|
||||||
"notAvailableFromCivitai": "Not available from Civitai",
|
"notAvailableFromCivitai": "Not available from Civitai",
|
||||||
|
"viewOnHuggingFace": "View on Hugging Face",
|
||||||
"sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)",
|
"sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)",
|
||||||
"copyLoRASyntax": "Copy LoRA Syntax",
|
"copyLoRASyntax": "Copy LoRA Syntax",
|
||||||
"checkpointNameCopied": "Checkpoint name copied",
|
"checkpointNameCopied": "Checkpoint name copied",
|
||||||
@@ -144,6 +146,10 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"timesUsed": "Times used"
|
"timesUsed": "Times used"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"versionCount": "{count} versions",
|
||||||
|
"viewAllVersions": "View all local versions"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -182,6 +188,9 @@
|
|||||||
},
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "Manage Excluded Models"
|
"label": "Manage Excluded Models"
|
||||||
|
},
|
||||||
|
"groupByModel": {
|
||||||
|
"label": "Group by Model"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -194,13 +203,7 @@
|
|||||||
"statistics": "Stats"
|
"statistics": "Stats"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search...",
|
"placeholder": "Search",
|
||||||
"placeholders": {
|
|
||||||
"loras": "Search LoRAs...",
|
|
||||||
"recipes": "Search recipes...",
|
|
||||||
"checkpoints": "Search checkpoints...",
|
|
||||||
"embeddings": "Search embeddings..."
|
|
||||||
},
|
|
||||||
"options": "Search Options",
|
"options": "Search Options",
|
||||||
"searchIn": "Search In:",
|
"searchIn": "Search In:",
|
||||||
"notAvailable": "Search not available on statistics page",
|
"notAvailable": "Search not available on statistics page",
|
||||||
@@ -250,7 +253,18 @@
|
|||||||
"toggle": "Toggle theme",
|
"toggle": "Toggle theme",
|
||||||
"switchToLight": "Switch to light theme",
|
"switchToLight": "Switch to light theme",
|
||||||
"switchToDark": "Switch to dark theme",
|
"switchToDark": "Switch to dark theme",
|
||||||
"switchToAuto": "Switch to auto theme"
|
"switchToAuto": "Switch to auto theme",
|
||||||
|
"presets": "Theme Presets",
|
||||||
|
"default": "Default",
|
||||||
|
"nord": "Nord",
|
||||||
|
"midnight": "Midnight",
|
||||||
|
"monokai": "Monokai",
|
||||||
|
"dracula": "Dracula",
|
||||||
|
"solarized": "Solarized",
|
||||||
|
"mode": "Mode",
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
|
"auto": "Auto"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"checkUpdates": "Check Updates",
|
"checkUpdates": "Check Updates",
|
||||||
@@ -262,6 +276,9 @@
|
|||||||
"civitaiApiKey": "Civitai API Key",
|
"civitaiApiKey": "Civitai API Key",
|
||||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||||
|
"civitaiApiKeyConfigured": "Configured",
|
||||||
|
"civitaiApiKeyNotConfigured": "Not configured",
|
||||||
|
"civitaiApiKeySet": "Set up",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Civitai host",
|
"label": "Civitai host",
|
||||||
"help": "Choose which Civitai site opens when using View on Civitai links.",
|
"help": "Choose which Civitai site opens when using View on Civitai links.",
|
||||||
@@ -302,6 +319,7 @@
|
|||||||
"downloads": "Downloads",
|
"downloads": "Downloads",
|
||||||
"videoSettings": "Video Settings",
|
"videoSettings": "Video Settings",
|
||||||
"layoutSettings": "Layout Settings",
|
"layoutSettings": "Layout Settings",
|
||||||
|
"licenseIcons": "License Icons",
|
||||||
"misc": "Miscellaneous",
|
"misc": "Miscellaneous",
|
||||||
"backup": "Backups",
|
"backup": "Backups",
|
||||||
"folderSettings": "Default Roots",
|
"folderSettings": "Default Roots",
|
||||||
@@ -309,7 +327,7 @@
|
|||||||
"extraFolderPaths": "Extra Folder Paths",
|
"extraFolderPaths": "Extra Folder Paths",
|
||||||
"downloadPathTemplates": "Download Path Templates",
|
"downloadPathTemplates": "Download Path Templates",
|
||||||
"priorityTags": "Priority Tags",
|
"priorityTags": "Priority Tags",
|
||||||
"updateFlags": "Update Flags",
|
"versionScope": "Version Scope",
|
||||||
"exampleImages": "Example Images",
|
"exampleImages": "Example Images",
|
||||||
"autoOrganize": "Auto-organize",
|
"autoOrganize": "Auto-organize",
|
||||||
"metadata": "Metadata",
|
"metadata": "Metadata",
|
||||||
@@ -414,6 +432,8 @@
|
|||||||
"help": "When enabled, versions downloaded before will be skipped."
|
"help": "When enabled, versions downloaded before will be skipped."
|
||||||
},
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
|
"groupByModel": "Group by Model",
|
||||||
|
"groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.",
|
||||||
"displayDensity": "Display Density",
|
"displayDensity": "Display Density",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
@@ -570,18 +590,22 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"restartRequired": "Requires restart"
|
"restartRequired": "Requires restart"
|
||||||
},
|
},
|
||||||
"updateFlagStrategy": {
|
"versionGrouping": {
|
||||||
"label": "Update Flag Strategy",
|
"label": "Version Grouping",
|
||||||
"help": "Decide whether update badges should only appear when a new release shares the same base model as your local files or whenever any newer version exists for that model.",
|
"help": "Decide how versions are grouped for display: by base model or all together. Also controls update badge logic and the VLM version list filtering.",
|
||||||
"options": {
|
"options": {
|
||||||
"sameBase": "Match updates by base model",
|
"sameBase": "Group by base model (same_base)",
|
||||||
"any": "Flag any available update"
|
"any": "Show all versions (any)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hideEarlyAccessUpdates": {
|
"hideEarlyAccessUpdates": {
|
||||||
"label": "Hide Early Access Updates",
|
"label": "Hide Early Access Updates",
|
||||||
"help": "When enabled, models with only early access updates will not show 'Update available' badge"
|
"help": "When enabled, models with only early access updates will not show 'Update available' badge"
|
||||||
},
|
},
|
||||||
|
"licenseIcons": {
|
||||||
|
"useNewStyle": "Use updated license icons",
|
||||||
|
"useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design."
|
||||||
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
||||||
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
|
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
|
||||||
@@ -650,7 +674,11 @@
|
|||||||
"sizeAsc": "Smallest",
|
"sizeAsc": "Smallest",
|
||||||
"usage": "Use Count",
|
"usage": "Use Count",
|
||||||
"usageDesc": "Most",
|
"usageDesc": "Most",
|
||||||
"usageAsc": "Least"
|
"usageAsc": "Least",
|
||||||
|
"versionsCount": "Local Versions",
|
||||||
|
"versionsCountDesc": "Most versions first",
|
||||||
|
"versionsCountAsc": "Fewest versions first",
|
||||||
|
"versionIdDesc": "Newest version first"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh model list",
|
"title": "Refresh model list",
|
||||||
@@ -955,10 +983,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Root",
|
"modelRoot": "Root",
|
||||||
"moreOptions": "More options",
|
|
||||||
"collapseAll": "Collapse All Folders",
|
"collapseAll": "Collapse All Folders",
|
||||||
"pinSidebar": "Pin Sidebar",
|
|
||||||
"unpinSidebar": "Unpin Sidebar",
|
|
||||||
"hideOnThisPage": "Hide sidebar on this page",
|
"hideOnThisPage": "Hide sidebar on this page",
|
||||||
"showSidebar": "Show sidebar",
|
"showSidebar": "Show sidebar",
|
||||||
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
|
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
|
||||||
@@ -999,6 +1024,18 @@
|
|||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
"insights": "Insights"
|
"insights": "Insights"
|
||||||
},
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalModels": "Total Models",
|
||||||
|
"totalStorage": "Total Storage",
|
||||||
|
"totalGenerations": "Total Generations",
|
||||||
|
"usageRate": "Usage Rate",
|
||||||
|
"loras": "LoRAs",
|
||||||
|
"checkpoints": "Checkpoints",
|
||||||
|
"embeddings": "Embeddings",
|
||||||
|
"uniqueTags": "Unique Tags",
|
||||||
|
"unusedModels": "Unused Models",
|
||||||
|
"avgUsesPerModel": "Avg. Uses/Model"
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"mostUsedLoras": "Most Used LoRAs",
|
"mostUsedLoras": "Most Used LoRAs",
|
||||||
"mostUsedCheckpoints": "Most Used Checkpoints",
|
"mostUsedCheckpoints": "Most Used Checkpoints",
|
||||||
@@ -1016,13 +1053,77 @@
|
|||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
"smartInsights": "Smart Insights",
|
"smartInsights": "Smart Insights",
|
||||||
"recommendations": "Recommendations"
|
"recommendations": "Recommendations",
|
||||||
|
"noInsights": "No insights available",
|
||||||
|
"unusedLoras": {
|
||||||
|
"high": {
|
||||||
|
"title": "High Number of Unused LoRAs",
|
||||||
|
"description": "{percent}% of your LoRAs ({count}/{total}) have never been used.",
|
||||||
|
"suggestion": "Consider organizing or archiving unused models to free up storage space."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedCheckpoints": {
|
||||||
|
"detected": {
|
||||||
|
"title": "Unused Checkpoints Detected",
|
||||||
|
"description": "{percent}% of your checkpoints ({count}/{total}) have never been used.",
|
||||||
|
"suggestion": "Review and consider removing checkpoints you no longer need."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedEmbeddings": {
|
||||||
|
"high": {
|
||||||
|
"title": "High Number of Unused Embeddings",
|
||||||
|
"description": "{percent}% of your embeddings ({count}/{total}) have never been used.",
|
||||||
|
"suggestion": "Consider organizing or archiving unused embeddings to optimize your collection."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"large": {
|
||||||
|
"title": "Large Collection Detected",
|
||||||
|
"description": "Your model collection is using {size} of storage.",
|
||||||
|
"suggestion": "Consider using external storage or cloud solutions for better organization."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"active": {
|
||||||
|
"title": "Active User",
|
||||||
|
"description": "You've completed {count} generations so far!",
|
||||||
|
"suggestion": "Keep exploring and creating amazing content with your models."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"collectionOverview": "Collection Overview",
|
"collectionOverview": "Collection Overview",
|
||||||
"baseModelDistribution": "Base Model Distribution",
|
"baseModelDistribution": "Base Model Distribution",
|
||||||
"usageTrends": "Usage Trends (Last 30 Days)",
|
"usageTrends": "Usage Trends (Last 30 Days)",
|
||||||
"usageDistribution": "Usage Distribution"
|
"usageDistribution": "Usage Distribution",
|
||||||
|
"date": "Date",
|
||||||
|
"usageCount": "Usage Count",
|
||||||
|
"fileSizeBytes": "File Size (bytes)",
|
||||||
|
"models": "Models",
|
||||||
|
"loraUsage": "LoRA Usage",
|
||||||
|
"checkpointUsage": "Checkpoint Usage",
|
||||||
|
"embeddingUsage": "Embedding Usage"
|
||||||
|
},
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"locon": "LyCORIS",
|
||||||
|
"dora": "DoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusion Model",
|
||||||
|
"embedding": "Embeddings"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noModels": "No models found",
|
||||||
|
"errorLoading": "Error loading data",
|
||||||
|
"noStorageData": "No storage data available",
|
||||||
|
"rootFolder": "Root",
|
||||||
|
"chartLibraryMissing": "Chart requires Chart.js library"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"tagCount": "{tag}: {count} models",
|
||||||
|
"chartUsage": "{name}: {size}, {count} uses",
|
||||||
|
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
@@ -1034,7 +1135,10 @@
|
|||||||
"titleWithType": "Download {type} from URL",
|
"titleWithType": "Download {type} from URL",
|
||||||
"civitaiUrl": "Civitai URL(s):",
|
"civitaiUrl": "Civitai URL(s):",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "Enter one CivitAI or CivArchive URL per line. Supports multiple URLs for batch download.",
|
"urlHint": "Enter one CivitAI, CivArchive, or Hugging Face URL per line. Supports multiple URLs for batch download.",
|
||||||
|
"selectHfFiles": "Select file(s) to download from this repository:",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"fetchingRepoFiles": "Fetching repository files...",
|
||||||
"locationPreview": "Download Location Preview",
|
"locationPreview": "Download Location Preview",
|
||||||
"useDefaultPath": "Use Default Path",
|
"useDefaultPath": "Use Default Path",
|
||||||
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
|
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
|
||||||
@@ -1063,7 +1167,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Invalid Civitai URL format",
|
"invalidUrl": "Invalid Civitai URL format",
|
||||||
"noVersions": "No versions available for this model"
|
"noVersions": "No versions available for this model",
|
||||||
|
"mixedSources": "Cannot mix CivitAI and Hugging Face URLs in the same batch.",
|
||||||
|
"noModelFiles": "No model files found in this repository."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "Preparing download...",
|
"preparing": "Preparing download...",
|
||||||
@@ -1214,6 +1320,8 @@
|
|||||||
"editVersionName": "Edit version name",
|
"editVersionName": "Edit version name",
|
||||||
"viewOnCivitai": "View on Civitai",
|
"viewOnCivitai": "View on Civitai",
|
||||||
"viewOnCivitaiText": "View on Civitai",
|
"viewOnCivitaiText": "View on Civitai",
|
||||||
|
"viewOnHuggingFace": "View on Hugging Face",
|
||||||
|
"viewOnHuggingFaceText": "View on Hugging Face",
|
||||||
"viewCreatorProfile": "View Creator Profile",
|
"viewCreatorProfile": "View Creator Profile",
|
||||||
"openFileLocation": "Open File Location",
|
"openFileLocation": "Open File Location",
|
||||||
"sendToWorkflow": "Send to ComfyUI",
|
"sendToWorkflow": "Send to ComfyUI",
|
||||||
@@ -1370,7 +1478,7 @@
|
|||||||
"resumeModelUpdates": "Resume updates for this model",
|
"resumeModelUpdates": "Resume updates for this model",
|
||||||
"ignoreModelUpdates": "Ignore updates for this model",
|
"ignoreModelUpdates": "Ignore updates for this model",
|
||||||
"viewLocalVersions": "View all local versions",
|
"viewLocalVersions": "View all local versions",
|
||||||
"viewLocalTooltip": "Coming soon"
|
"viewLocalTooltip": "Show all local versions of this model on the main page"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"label": "Base filter",
|
"label": "Base filter",
|
||||||
@@ -1398,6 +1506,21 @@
|
|||||||
"versionDeleted": "Version deleted"
|
"versionDeleted": "Version deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "Metadata Fetch Summary",
|
||||||
|
"statSuccess": "Success",
|
||||||
|
"statFailed": "Failed",
|
||||||
|
"statSkipped": "Skipped",
|
||||||
|
"statTotal": "Total Scanned",
|
||||||
|
"statDuration": "Duration",
|
||||||
|
"successMessage": "All {count} {type}s updated successfully!",
|
||||||
|
"failedItems": "Failed Items ({count})",
|
||||||
|
"close": "Close",
|
||||||
|
"copyReport": "Copy Report",
|
||||||
|
"downloadCsv": "Download CSV",
|
||||||
|
"columnModelName": "Model Name",
|
||||||
|
"columnError": "Error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1411,15 +1534,6 @@
|
|||||||
"duplicate": "This tag already exists"
|
"duplicate": "This tag already exists"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keyboard": {
|
|
||||||
"navigation": "Keyboard Navigation:",
|
|
||||||
"shortcuts": {
|
|
||||||
"pageUp": "Scroll up one page",
|
|
||||||
"pageDown": "Scroll down one page",
|
|
||||||
"home": "Jump to top",
|
|
||||||
"end": "Jump to bottom"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"initialization": {
|
"initialization": {
|
||||||
"title": "Initializing",
|
"title": "Initializing",
|
||||||
"message": "Preparing your workspace...",
|
"message": "Preparing your workspace...",
|
||||||
@@ -1509,12 +1623,15 @@
|
|||||||
"modelUpdated": "Model updated in workflow",
|
"modelUpdated": "Model updated in workflow",
|
||||||
"modelFailed": "Failed to update model node",
|
"modelFailed": "Failed to update model node",
|
||||||
"embeddingAdded": "Embedding added to workflow",
|
"embeddingAdded": "Embedding added to workflow",
|
||||||
"embeddingFailed": "Failed to add embedding"
|
"embeddingFailed": "Failed to add embedding",
|
||||||
|
"promptSent": "Prompt sent to workflow",
|
||||||
|
"promptFailed": "Failed to send prompt"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Recipe",
|
"recipe": "Recipe",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"embedding": "Embedding",
|
"embedding": "Embedding",
|
||||||
|
"prompt": "Prompt",
|
||||||
"replace": "Replace",
|
"replace": "Replace",
|
||||||
"append": "Append",
|
"append": "Append",
|
||||||
"selectTargetNode": "Select target node",
|
"selectTargetNode": "Select target node",
|
||||||
@@ -1701,6 +1818,7 @@
|
|||||||
"enterLoraName": "Please enter a LoRA name or syntax",
|
"enterLoraName": "Please enter a LoRA name or syntax",
|
||||||
"reconnectedSuccessfully": "LoRA reconnected successfully",
|
"reconnectedSuccessfully": "LoRA reconnected successfully",
|
||||||
"reconnectFailed": "Error reconnecting LoRA: {message}",
|
"reconnectFailed": "Error reconnecting LoRA: {message}",
|
||||||
|
"noPromptToSend": "No prompt to send",
|
||||||
"cannotSend": "Cannot send recipe: Missing recipe ID",
|
"cannotSend": "Cannot send recipe: Missing recipe ID",
|
||||||
"sendFailed": "Failed to send recipe to workflow",
|
"sendFailed": "Failed to send recipe to workflow",
|
||||||
"sendError": "Error sending recipe to workflow",
|
"sendError": "Error sending recipe to workflow",
|
||||||
@@ -1957,7 +2075,9 @@
|
|||||||
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
|
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
|
||||||
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
|
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "Copied to clipboard",
|
||||||
|
"downloadStarted": "Download started"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
+2139
-2019
File diff suppressed because it is too large
Load Diff
+2139
-2019
File diff suppressed because it is too large
Load Diff
+2139
-2019
File diff suppressed because it is too large
Load Diff
+2139
-2019
File diff suppressed because it is too large
Load Diff
+2139
-2019
File diff suppressed because it is too large
Load Diff
+2139
-2019
File diff suppressed because it is too large
Load Diff
+2139
-2019
File diff suppressed because it is too large
Load Diff
+2139
-2019
File diff suppressed because it is too large
Load Diff
@@ -436,5 +436,14 @@ class LoraManager:
|
|||||||
try:
|
try:
|
||||||
logger.info("LoRA Manager: Cleaning up services")
|
logger.info("LoRA Manager: Cleaning up services")
|
||||||
|
|
||||||
|
# Cancel any in-flight scanner initialization tasks so thread-pool
|
||||||
|
# workers (e.g. _initialize_cache_sync) can break out of their loops
|
||||||
|
# when the server shuts down (e.g. Ctrl+C on WSL).
|
||||||
|
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
|
||||||
|
scanner = ServiceRegistry.get_service_sync(name)
|
||||||
|
if scanner is not None and hasattr(scanner, "cancel_task"):
|
||||||
|
scanner.cancel_task()
|
||||||
|
logger.debug("LoRA Manager: Cancelled %s", name)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -901,6 +901,55 @@ class LoraLoaderManagerExtractor(NodeMetadataExtractor):
|
|||||||
"node_id": node_id
|
"node_id": node_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class LoraTextLoaderManagerExtractor(NodeMetadataExtractor):
|
||||||
|
"""Extract LoRA metadata from LoraTextLoaderLM (LoRA Text Loader).
|
||||||
|
|
||||||
|
The node accepts a `lora_syntax` STRING containing <lora:name:strength> tags
|
||||||
|
(same format as the ComfyUI prompt), plus an optional `lora_stack`.
|
||||||
|
This extractor parses the syntax string using the same regex as the node.
|
||||||
|
"""
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
active_loras = []
|
||||||
|
|
||||||
|
# Process lora_stack if available (optional input)
|
||||||
|
if "lora_stack" in inputs:
|
||||||
|
lora_stack = inputs.get("lora_stack", [])
|
||||||
|
for item in lora_stack:
|
||||||
|
# lora_stack entries are (path, model_strength, clip_strength) tuples
|
||||||
|
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||||
|
lora_path = item[0]
|
||||||
|
model_strength = item[1]
|
||||||
|
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
|
||||||
|
active_loras.append({
|
||||||
|
"name": lora_name,
|
||||||
|
"strength": round(float(model_strength), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Process lora_syntax string input
|
||||||
|
if "lora_syntax" in inputs:
|
||||||
|
lora_syntax = inputs.get("lora_syntax", "")
|
||||||
|
if lora_syntax and isinstance(lora_syntax, str):
|
||||||
|
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
|
||||||
|
matches = re.findall(pattern, lora_syntax, re.IGNORECASE)
|
||||||
|
for match in matches:
|
||||||
|
lora_name = match[0]
|
||||||
|
model_strength = float(match[1])
|
||||||
|
active_loras.append({
|
||||||
|
"name": lora_name,
|
||||||
|
"strength": round(model_strength, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
if active_loras:
|
||||||
|
metadata[LORAS][node_id] = {
|
||||||
|
"lora_list": active_loras,
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FluxGuidanceExtractor(NodeMetadataExtractor):
|
class FluxGuidanceExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
@@ -1146,6 +1195,7 @@ NODE_EXTRACTORS = {
|
|||||||
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
|
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
|
||||||
"LoraLoader": LoraLoaderExtractor,
|
"LoraLoader": LoraLoaderExtractor,
|
||||||
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
||||||
|
"LoraTextLoaderLM": LoraTextLoaderManagerExtractor,
|
||||||
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
|
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
|
||||||
"TensorRTLoader": TensorRTLoaderExtractor,
|
"TensorRTLoader": TensorRTLoaderExtractor,
|
||||||
# Conditioning
|
# Conditioning
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ IMG_EXTENSIONS = (
|
|||||||
".tif",
|
".tif",
|
||||||
".tiff",
|
".tiff",
|
||||||
".webp",
|
".webp",
|
||||||
|
".avif",
|
||||||
|
".jxl",
|
||||||
".mp4"
|
".mp4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -298,7 +298,12 @@ class SaveImageLM:
|
|||||||
key = parts[0]
|
key = parts[0]
|
||||||
|
|
||||||
if key == "seed" and "seed" in metadata_dict:
|
if key == "seed" and "seed" in metadata_dict:
|
||||||
filename = filename.replace(segment, str(metadata_dict.get("seed", "")))
|
seed_value = metadata_dict.get("seed")
|
||||||
|
if seed_value is not None:
|
||||||
|
filename = filename.replace(segment, str(seed_value))
|
||||||
|
else:
|
||||||
|
# Fallback if seed was not captured by metadata collector
|
||||||
|
filename = filename.replace(segment, "0")
|
||||||
elif key == "width" and "size" in metadata_dict:
|
elif key == "width" and "size" in metadata_dict:
|
||||||
size = metadata_dict.get("size", "x")
|
size = metadata_dict.get("size", "x")
|
||||||
w = size.split("x")[0] if isinstance(size, str) else size[0]
|
w = size.split("x")[0] if isinstance(size, str) else size[0]
|
||||||
@@ -603,7 +608,7 @@ class SaveImageLM:
|
|||||||
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
||||||
|
|
||||||
# Generate filename with counter if needed
|
# Generate filename with counter if needed
|
||||||
base_filename = filename
|
base_filename = filename.replace("%batch_num%", str(i))
|
||||||
if add_counter_to_filename:
|
if add_counter_to_filename:
|
||||||
# Use counter + i to ensure unique filenames for all images in batch
|
# Use counter + i to ensure unique filenames for all images in batch
|
||||||
current_counter = counter + i
|
current_counter = counter + i
|
||||||
|
|||||||
@@ -123,23 +123,38 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
if model_hash_from_hashes:
|
if model_hash_from_hashes:
|
||||||
metadata["model_hash"] = model_hash_from_hashes
|
metadata["model_hash"] = model_hash_from_hashes
|
||||||
|
|
||||||
# Extract Lora hashes in alternative format
|
# Extract Lora hashes in alternative format.
|
||||||
|
# Run unconditionally (not just as fallback) so that
|
||||||
|
# non-empty hashes from Lora hashes fill in the gaps left
|
||||||
|
# by empty values in the Hashes JSON dict. Some WebUI
|
||||||
|
# builds write real hash values only to Lora hashes and
|
||||||
|
# leave the Hashes JSON values empty.
|
||||||
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
||||||
if not hashes_match and lora_hashes_match:
|
if lora_hashes_match:
|
||||||
try:
|
try:
|
||||||
lora_hashes_str = lora_hashes_match.group(1)
|
lora_hashes_str = lora_hashes_match.group(1)
|
||||||
lora_hash_entries = lora_hashes_str.split(', ')
|
lora_hash_entries = lora_hashes_str.split(', ')
|
||||||
|
|
||||||
# Initialize hashes dict if it doesn't exist
|
|
||||||
if "hashes" not in metadata:
|
|
||||||
metadata["hashes"] = {}
|
|
||||||
|
|
||||||
# Parse each lora hash entry (format: "name: hash")
|
# Parse each lora hash entry (format: "name: hash")
|
||||||
for entry in lora_hash_entries:
|
for entry in lora_hash_entries:
|
||||||
if ': ' in entry:
|
if ': ' in entry:
|
||||||
lora_name, lora_hash = entry.split(': ', 1)
|
lora_name, lora_hash = entry.split(': ', 1)
|
||||||
# Add as lora type in the same format as regular hashes
|
lora_hash = lora_hash.strip()
|
||||||
metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip()
|
if not lora_hash:
|
||||||
|
# Skip entries without a hash value
|
||||||
|
continue
|
||||||
|
# Initialize hashes dict if it doesn't exist
|
||||||
|
if "hashes" not in metadata:
|
||||||
|
metadata["hashes"] = {}
|
||||||
|
# Add as lora type in the same format as
|
||||||
|
# regular hashes. Only override an
|
||||||
|
# existing entry if its value is empty
|
||||||
|
# (Lora hashes is the more reliable
|
||||||
|
# source when Hashes JSON has blanks).
|
||||||
|
key = f"lora:{lora_name}"
|
||||||
|
existing = metadata["hashes"].get(key, "")
|
||||||
|
if not existing:
|
||||||
|
metadata["hashes"][key] = lora_hash
|
||||||
|
|
||||||
# Remove lora hashes from params section
|
# Remove lora hashes from params section
|
||||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||||
@@ -363,6 +378,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip entries without a hash value — they can't be
|
||||||
|
# resolved via CivitAI and would only produce a
|
||||||
|
# useless "Deleted" entry in the recipe.
|
||||||
|
if not lora_hash:
|
||||||
|
continue
|
||||||
|
|
||||||
lora_type, lora_name = hash_key.split(':', 1)
|
lora_type, lora_name = hash_key.split(':', 1)
|
||||||
|
|
||||||
# Get weight from extranet tags if available, else default to 1.0
|
# Get weight from extranet tags if available, else default to 1.0
|
||||||
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
# Try to get info from Civitai
|
# Try to get info from Civitai
|
||||||
if metadata_provider:
|
if metadata_provider:
|
||||||
try:
|
try:
|
||||||
if lora_hash:
|
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||||
# If we have hash, use it for lookup
|
|
||||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
|
||||||
else:
|
|
||||||
civitai_info = None
|
|
||||||
|
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
lora_entry,
|
lora_entry,
|
||||||
|
|||||||
@@ -514,11 +514,21 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
result["loras"].append(lora_entry)
|
result["loras"].append(lora_entry)
|
||||||
|
|
||||||
# Process modelVersionIds from Civitai image API
|
# Process modelVersionIds from Civitai image API.
|
||||||
# These are model version IDs returned at root level when meta doesn't contain resources
|
# These are version IDs returned at root level of the API response.
|
||||||
if "modelVersionIds" in metadata and isinstance(
|
# When resources or civitaiResources are already present in metadata
|
||||||
metadata["modelVersionIds"], list
|
# (which they are when ?withMeta=true is passed), those sections have
|
||||||
|
# complete hash/type information — modelVersionIds is a fallback for
|
||||||
|
# when meta is null and only the flat ID list is available. Skipping
|
||||||
|
# it here avoids duplicates: the same file hash often resolves to
|
||||||
|
# different version IDs via hash lookup (resources) vs the original
|
||||||
|
# version ID in modelVersionIds, and both paths would create entries.
|
||||||
|
if (
|
||||||
|
"modelVersionIds" in metadata
|
||||||
|
and isinstance(metadata["modelVersionIds"], list)
|
||||||
|
and not result.get("loras")
|
||||||
):
|
):
|
||||||
|
|
||||||
for version_id in metadata["modelVersionIds"]:
|
for version_id in metadata["modelVersionIds"]:
|
||||||
version_id_str = str(version_id)
|
version_id_str = str(version_id)
|
||||||
|
|
||||||
@@ -526,6 +536,13 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
if version_id_str in added_loras:
|
if version_id_str in added_loras:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip if this version ID is already the recipe's checkpoint
|
||||||
|
# (resolved earlier from embedded resources/Model hash,
|
||||||
|
# avoiding a duplicate CivitAI API call).
|
||||||
|
existing_model = result.get("model")
|
||||||
|
if existing_model and str(existing_model.get("id")) == version_id_str:
|
||||||
|
continue
|
||||||
|
|
||||||
# Initialize lora entry with version ID
|
# Initialize lora entry with version ID
|
||||||
lora_entry = {
|
lora_entry = {
|
||||||
"id": version_id,
|
"id": version_id,
|
||||||
@@ -559,9 +576,40 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if populated_entry is None:
|
if populated_entry is None:
|
||||||
continue # Skip invalid LoRA types
|
# Not a LoRA — try as checkpoint (only if we
|
||||||
|
# don't already have one). Reuses the same
|
||||||
|
# civitai_info from the API call above so no
|
||||||
|
# extra query is made.
|
||||||
|
if result["model"] is None:
|
||||||
|
checkpoint_entry = {
|
||||||
|
"id": version_id,
|
||||||
|
"modelId": 0,
|
||||||
|
"name": "Unknown Model",
|
||||||
|
"version": "",
|
||||||
|
"type": "checkpoint",
|
||||||
|
"existsLocally": False,
|
||||||
|
"localPath": None,
|
||||||
|
"file_name": "",
|
||||||
|
"hash": "",
|
||||||
|
"thumbnailUrl": (
|
||||||
|
"/loras_static/images/no-preview.png"
|
||||||
|
),
|
||||||
|
"baseModel": "",
|
||||||
|
"size": 0,
|
||||||
|
"downloadUrl": "",
|
||||||
|
"isDeleted": False,
|
||||||
|
}
|
||||||
|
cp_populated = await (
|
||||||
|
self.populate_checkpoint_from_civitai(
|
||||||
|
checkpoint_entry, civitai_info
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if cp_populated.get("modelId"):
|
||||||
|
result["model"] = cp_populated
|
||||||
|
continue # Not a LoRA, don't add to loras
|
||||||
|
|
||||||
lora_entry = populated_entry
|
lora_entry = populated_entry
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error fetching Civitai info for model version {version_id}: {e}"
|
f"Error fetching Civitai info for model version {version_id}: {e}"
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
"""Handlers for Hugging Face model listing and download.
|
||||||
|
|
||||||
|
Minimal MVP implementation — uses direct HTTP to the HF API for file
|
||||||
|
listing and the project's existing aiohttp-based Downloader for
|
||||||
|
downloading. No huggingface_hub dependency required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from ...config import config
|
||||||
|
from ...services.downloader import (
|
||||||
|
DownloadProgress,
|
||||||
|
get_downloader,
|
||||||
|
)
|
||||||
|
from ...services.aria2_downloader import Aria2Downloader
|
||||||
|
from ...services.settings_manager import get_settings_manager
|
||||||
|
from ...services.service_registry import ServiceRegistry
|
||||||
|
from ...services.websocket_manager import ws_manager
|
||||||
|
from ...utils.constants import MODEL_FILE_EXTENSIONS
|
||||||
|
from ...utils.metadata_manager import MetadataManager
|
||||||
|
from ...utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_MODEL_CLASS = LoraMetadata
|
||||||
|
_DEFAULT_SCANNER_GETTER = "get_lora_scanner"
|
||||||
|
|
||||||
|
# Shared aiohttp session for HF API calls (created on first use)
|
||||||
|
_hf_api_session: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_hf_api_session() -> aiohttp.ClientSession:
|
||||||
|
"""Get or create the shared aiohttp session for HF API calls."""
|
||||||
|
global _hf_api_session # needed because we reassign the module-level name
|
||||||
|
if _hf_api_session is None or _hf_api_session.closed:
|
||||||
|
_hf_api_session = aiohttp.ClientSession(
|
||||||
|
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
|
||||||
|
timeout=aiohttp.ClientTimeout(total=30),
|
||||||
|
)
|
||||||
|
return _hf_api_session
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_model_type(model_root: str) -> tuple[Any, str]:
|
||||||
|
"""Determine model class and scanner by matching ``model_root`` against the
|
||||||
|
configured root paths for each model type (from ``Config``).
|
||||||
|
|
||||||
|
The ``model_root`` value comes from the frontend's model-root dropdown,
|
||||||
|
which is populated from the current page's scanner roots. By checking
|
||||||
|
which scanner's root list it belongs to, we avoid fragile heuristics
|
||||||
|
like substring-matching path names.
|
||||||
|
"""
|
||||||
|
norm = os.path.normpath(model_root).replace(os.sep, "/")
|
||||||
|
|
||||||
|
# LoRA roots
|
||||||
|
for p in (config.loras_roots or []) + (config.extra_loras_roots or []):
|
||||||
|
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||||
|
return LoraMetadata, "get_lora_scanner"
|
||||||
|
|
||||||
|
# Checkpoint / UNet roots
|
||||||
|
for p in (
|
||||||
|
(config.checkpoints_roots or [])
|
||||||
|
+ (config.extra_checkpoints_roots or [])
|
||||||
|
+ (config.unet_roots or [])
|
||||||
|
+ (config.extra_unet_roots or [])
|
||||||
|
):
|
||||||
|
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||||
|
return CheckpointMetadata, "get_checkpoint_scanner"
|
||||||
|
|
||||||
|
# Embedding roots
|
||||||
|
for p in (config.embeddings_roots or []) + (config.extra_embeddings_roots or []):
|
||||||
|
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||||
|
return EmbeddingMetadata, "get_embedding_scanner"
|
||||||
|
|
||||||
|
# Fallback — should not happen in normal use
|
||||||
|
logger.warning(
|
||||||
|
"Could not determine model type for root '%s'; defaulting to LoRA",
|
||||||
|
model_root,
|
||||||
|
)
|
||||||
|
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
|
||||||
|
"""Create a proper .metadata.json and add the model to the scanner cache.
|
||||||
|
|
||||||
|
Uses ``MetadataManager.create_default_metadata()`` which computes the
|
||||||
|
SHA256 hash, extracts safetensors header metadata (base_model), and
|
||||||
|
produces a fully-populated ``LoraMetadata`` (or ``CheckpointMetadata`` /
|
||||||
|
``EmbeddingMetadata``) object. We then overlay HF-specific fields and
|
||||||
|
register the model in the in-memory scanner cache so it appears
|
||||||
|
immediately without a full filesystem walk.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hf_url = f"https://huggingface.co/{repo}"
|
||||||
|
model_class, scanner_getter_name = _infer_model_type(model_root)
|
||||||
|
|
||||||
|
# 1. Create proper metadata (computes SHA256, reads safetensors headers)
|
||||||
|
metadata = await MetadataManager.create_default_metadata(
|
||||||
|
dest_path, model_class=model_class
|
||||||
|
)
|
||||||
|
if metadata is None:
|
||||||
|
logger.warning("create_default_metadata returned None for %s", dest_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Overlay HF-specific fields
|
||||||
|
metadata._unknown_fields["hf_url"] = hf_url
|
||||||
|
metadata.from_civitai = False # HF models are not from CivitAI
|
||||||
|
|
||||||
|
# 3. Save metadata atomically
|
||||||
|
await MetadataManager.save_metadata(dest_path, metadata)
|
||||||
|
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
|
||||||
|
|
||||||
|
# 4. Determine relative folder path for cache
|
||||||
|
# model_root is an absolute path; dest_path is under it
|
||||||
|
folder = ""
|
||||||
|
if os.path.isabs(model_root) and dest_path.startswith(model_root):
|
||||||
|
rel = os.path.relpath(os.path.dirname(dest_path), model_root)
|
||||||
|
folder = rel.replace(os.sep, "/") if rel != "." else ""
|
||||||
|
|
||||||
|
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
|
||||||
|
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
|
||||||
|
if scanner_getter is not None:
|
||||||
|
scanner = await scanner_getter()
|
||||||
|
if scanner is not None:
|
||||||
|
metadata_dict = metadata.to_dict()
|
||||||
|
metadata_dict["hf_url"] = hf_url
|
||||||
|
await scanner.add_model_to_cache(metadata_dict, folder)
|
||||||
|
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to save HF metadata for %s: %s", dest_path, exc)
|
||||||
|
|
||||||
|
|
||||||
|
class HfHandler:
|
||||||
|
"""Handle Hugging Face model browsing and download."""
|
||||||
|
|
||||||
|
async def get_hf_repo_files(self, request: web.Request) -> web.Response:
|
||||||
|
"""List model-weight files from a HF repo with real file sizes.
|
||||||
|
|
||||||
|
Uses the HF tree API endpoint which returns accurate file sizes
|
||||||
|
(including LFS-tracked files), unlike the model info endpoint.
|
||||||
|
"""
|
||||||
|
repo = request.query.get("repo", "").strip()
|
||||||
|
if not repo or "/" not in repo:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Missing or invalid 'repo' parameter (expected user/repo)"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
url = f"https://huggingface.co/api/models/{repo}/tree/main"
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = await _get_hf_api_session()
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
if resp.status == 404:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"Repo '{repo}' not found"}, status=404
|
||||||
|
)
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"HF API error {resp.status}: {text[:200]}"},
|
||||||
|
status=resp.status,
|
||||||
|
)
|
||||||
|
tree: list[dict[str, Any]] = await resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to fetch HF repo files: %s", exc)
|
||||||
|
return web.json_response({"error": str(exc)}, status=502)
|
||||||
|
|
||||||
|
files: list[dict[str, Any]] = []
|
||||||
|
for entry in tree:
|
||||||
|
path: str = entry.get("path", "")
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
if ext not in MODEL_FILE_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
size = entry.get("size", 0) or 0
|
||||||
|
if size == 0 and "lfs" in entry:
|
||||||
|
size = entry["lfs"].get("size", 0) or 0
|
||||||
|
files.append({
|
||||||
|
"filename": path,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
|
||||||
|
files.sort(key=lambda f: f["size"], reverse=True)
|
||||||
|
return web.json_response(files)
|
||||||
|
|
||||||
|
async def download_hf_model(self, request: web.Request) -> web.Response:
|
||||||
|
"""Download a single file from Hugging Face into the model directory.
|
||||||
|
|
||||||
|
POST JSON body::
|
||||||
|
|
||||||
|
{
|
||||||
|
"repo": "dx8152/Flux2-Klein-9B-Consistency",
|
||||||
|
"filename": "Flux2-Klein-9B-consistency-V2.safetensors",
|
||||||
|
"revision": "main",
|
||||||
|
"model_root": "loras",
|
||||||
|
"relative_path": "",
|
||||||
|
"use_default_paths": false,
|
||||||
|
"download_id": "optional-batch-id"
|
||||||
|
}
|
||||||
|
|
||||||
|
If ``download_id`` is provided, real-time progress (bytes, speed,
|
||||||
|
percentage) is broadcast via the WebSocket progress system, matching
|
||||||
|
the CivitAI download experience.
|
||||||
|
|
||||||
|
Respects the ``download_backend`` setting (``aria2`` or ``default``).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload: dict[str, Any] = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
|
repo = (payload.get("repo") or "").strip()
|
||||||
|
filename = (payload.get("filename") or "").strip()
|
||||||
|
revision = (payload.get("revision") or "main").strip()
|
||||||
|
model_root = (payload.get("model_root") or "").strip()
|
||||||
|
relative_path = (payload.get("relative_path") or "").strip()
|
||||||
|
use_default_paths = bool(payload.get("use_default_paths", False))
|
||||||
|
download_id: str | None = payload.get("download_id")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"download_hf_model: repo=%s file=%s root=%s download_id=%s",
|
||||||
|
repo, filename, model_root, download_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not repo or not filename:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate repo format — must be user/repo_name
|
||||||
|
if repo.count("/") != 1 or not re.match(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", repo):
|
||||||
|
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
|
||||||
|
author, repo_name = repo.split("/", 1)
|
||||||
|
if ".." in (author, repo_name) or "." in (author, repo_name):
|
||||||
|
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
|
||||||
|
|
||||||
|
# Validate filename — must not contain path separators or ..
|
||||||
|
if "/" in filename or "\\" in filename or ".." in filename:
|
||||||
|
return web.json_response({"error": "Invalid filename"}, status=400)
|
||||||
|
|
||||||
|
# Validate relative_path — must not be absolute or escape base directory
|
||||||
|
if relative_path:
|
||||||
|
if os.path.isabs(relative_path):
|
||||||
|
return web.json_response({"error": "relative_path must not be absolute"}, status=400)
|
||||||
|
if ".." in relative_path.split("/") or "\\" in relative_path:
|
||||||
|
return web.json_response({"error": "Invalid relative_path"}, status=400)
|
||||||
|
|
||||||
|
# Validate model_root — must not contain path traversal
|
||||||
|
if not os.path.isabs(model_root):
|
||||||
|
# For relative model_root, check it doesn't escape
|
||||||
|
resolved_model_root = os.path.realpath(
|
||||||
|
os.path.join(os.getcwd(), "models", model_root)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
resolved_model_root = os.path.realpath(model_root)
|
||||||
|
|
||||||
|
# Verify model_root is within a configured scanner root
|
||||||
|
allowed_roots = set()
|
||||||
|
for root_list in (
|
||||||
|
config.loras_roots or [],
|
||||||
|
config.extra_loras_roots or [],
|
||||||
|
config.checkpoints_roots or [],
|
||||||
|
config.extra_checkpoints_roots or [],
|
||||||
|
config.unet_roots or [],
|
||||||
|
config.extra_unet_roots or [],
|
||||||
|
config.embeddings_roots or [],
|
||||||
|
config.extra_embeddings_roots or [],
|
||||||
|
):
|
||||||
|
for r in root_list:
|
||||||
|
allowed_roots.add(os.path.realpath(r))
|
||||||
|
|
||||||
|
if not any(resolved_model_root == root or resolved_model_root.startswith(root + os.sep) for root in allowed_roots):
|
||||||
|
logger.warning("Invalid model_root rejected: %s", model_root)
|
||||||
|
return web.json_response({"error": f"Invalid model_root: {model_root}"}, status=400)
|
||||||
|
|
||||||
|
base_dir = resolved_model_root
|
||||||
|
|
||||||
|
if use_default_paths:
|
||||||
|
target_dir = os.path.join(base_dir, "huggingface", author, repo_name)
|
||||||
|
elif relative_path:
|
||||||
|
target_dir = os.path.join(base_dir, relative_path)
|
||||||
|
else:
|
||||||
|
target_dir = base_dir
|
||||||
|
|
||||||
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
|
dest_path = os.path.join(target_dir, filename)
|
||||||
|
|
||||||
|
# Resolve symlinks and check for path traversal escape
|
||||||
|
real_dest = os.path.realpath(dest_path)
|
||||||
|
real_base = os.path.realpath(target_dir)
|
||||||
|
if not real_dest.startswith(real_base + os.sep):
|
||||||
|
logger.warning("Path traversal blocked: %s -> %s", dest_path, real_dest)
|
||||||
|
return web.json_response({"error": "Path traversal detected"}, status=400)
|
||||||
|
|
||||||
|
# Check if already exists (simple skip)
|
||||||
|
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
|
||||||
|
logger.info("download_hf_model: file already exists, skipping — %s", dest_path)
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"message": f"File already exists: {dest_path}",
|
||||||
|
"path": dest_path,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build HF resolve URL
|
||||||
|
resolve_url = (
|
||||||
|
f"https://huggingface.co/{repo}/resolve/{revision}/{filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set up progress callback if download_id is provided
|
||||||
|
progress_callback = None
|
||||||
|
if download_id:
|
||||||
|
|
||||||
|
async def _progress_callback(
|
||||||
|
progress: float | DownloadProgress,
|
||||||
|
snapshot: DownloadProgress | None = None,
|
||||||
|
) -> None:
|
||||||
|
percent = 0.0
|
||||||
|
metrics = snapshot if isinstance(snapshot, DownloadProgress) else None
|
||||||
|
|
||||||
|
if isinstance(progress, DownloadProgress):
|
||||||
|
percent = progress.percent_complete
|
||||||
|
metrics = progress
|
||||||
|
elif isinstance(snapshot, DownloadProgress):
|
||||||
|
percent = snapshot.percent_complete
|
||||||
|
else:
|
||||||
|
percent = float(progress)
|
||||||
|
|
||||||
|
broadcast: dict[str, Any] = {
|
||||||
|
"status": "progress",
|
||||||
|
"progress": round(percent),
|
||||||
|
}
|
||||||
|
if metrics:
|
||||||
|
broadcast["bytes_downloaded"] = metrics.bytes_downloaded
|
||||||
|
broadcast["total_bytes"] = metrics.total_bytes
|
||||||
|
broadcast["bytes_per_second"] = metrics.bytes_per_second
|
||||||
|
|
||||||
|
await ws_manager.broadcast_download_progress(download_id, broadcast)
|
||||||
|
|
||||||
|
progress_callback = _progress_callback
|
||||||
|
|
||||||
|
# Respect download backend setting (aria2 vs default)
|
||||||
|
download_backend = (
|
||||||
|
get_settings_manager().get("download_backend", "default")
|
||||||
|
)
|
||||||
|
|
||||||
|
if download_backend == "aria2":
|
||||||
|
aria2 = await Aria2Downloader.get_instance()
|
||||||
|
aid = download_id or f"hf_{repo}_{filename}"
|
||||||
|
try:
|
||||||
|
hf_success, hf_result = await aria2.download_file(
|
||||||
|
url=resolve_url,
|
||||||
|
save_path=dest_path,
|
||||||
|
download_id=aid,
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
)
|
||||||
|
if hf_success:
|
||||||
|
await _save_hf_metadata(dest_path, repo, model_root)
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Downloaded to {dest_path}",
|
||||||
|
"path": dest_path,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": hf_result or "aria2 download failed"},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("HF download (aria2) failed: %s", exc)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": str(exc)}, status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default: use built-in aiohttp Downloader
|
||||||
|
downloader = await get_downloader()
|
||||||
|
try:
|
||||||
|
success, result = await downloader.download_file(
|
||||||
|
url=resolve_url,
|
||||||
|
save_path=dest_path,
|
||||||
|
use_auth=False,
|
||||||
|
allow_resume=True,
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
await _save_hf_metadata(dest_path, repo, model_root)
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Downloaded to {result}",
|
||||||
|
"path": result,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": result or "Download failed"},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("HF download failed: %s", exc)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": str(exc)}, status=500
|
||||||
|
)
|
||||||
@@ -48,8 +48,12 @@ from ...utils.constants import (
|
|||||||
SUPPORTED_MEDIA_EXTENSIONS,
|
SUPPORTED_MEDIA_EXTENSIONS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
)
|
)
|
||||||
|
from .hf_handlers import HfHandler
|
||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
from ...utils.example_images_paths import is_valid_example_images_root
|
from ...utils.example_images_paths import (
|
||||||
|
find_non_compliant_items_in_example_images_root,
|
||||||
|
is_valid_example_images_root,
|
||||||
|
)
|
||||||
from ...utils.lora_metadata import extract_trained_words
|
from ...utils.lora_metadata import extract_trained_words
|
||||||
from ...utils.session_logging import get_standalone_session_log_snapshot
|
from ...utils.session_logging import get_standalone_session_log_snapshot
|
||||||
from ...utils.usage_stats import UsageStats
|
from ...utils.usage_stats import UsageStats
|
||||||
@@ -411,9 +415,10 @@ class PromptServerProtocol(Protocol):
|
|||||||
"""Subset of PromptServer used by the handlers."""
|
"""Subset of PromptServer used by the handlers."""
|
||||||
|
|
||||||
instance: "PromptServerProtocol"
|
instance: "PromptServerProtocol"
|
||||||
|
sockets: dict # maps clientId (sid) → WebSocketResponse
|
||||||
|
|
||||||
def send_sync(
|
def send_sync(
|
||||||
self, event: str, payload: dict
|
self, event: str, payload: dict | None = None, sid: str | None = None
|
||||||
) -> None: # pragma: no cover - protocol
|
) -> None: # pragma: no cover - protocol
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -468,89 +473,154 @@ class BackupServiceProtocol(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class NodeRegistry:
|
class NodeRegistry:
|
||||||
"""Thread-safe registry for tracking LoRA nodes in active workflows."""
|
"""Thread-safe registry for tracking LoRA nodes across ComfyUI tabs.
|
||||||
|
|
||||||
|
Each connected ComfyUI browser tab (identified by its ``sid`` / ``clientId``)
|
||||||
|
registers its own set of workflow nodes. Queries merge all known tabs into
|
||||||
|
a single result so that the calling LM panel always sees *every* available
|
||||||
|
target node, regardless of which tab responded fastest.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._nodes: Dict[str, dict] = {}
|
# sid → {unique_id → node_info}
|
||||||
self._registry_updated = asyncio.Event()
|
self._tab_nodes: Dict[str, Dict[str, dict]] = {}
|
||||||
|
self._ready = asyncio.Event()
|
||||||
|
self._waiting_clients: set[str] = set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending_client_count(self) -> int:
|
||||||
|
"""Number of clients that have not yet responded in the current refresh cycle."""
|
||||||
|
return len(self._waiting_clients)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers to build one node dict (extracted so it's reused for each tab)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _build_node_dict(node: dict) -> dict:
|
||||||
|
node_id = node["node_id"]
|
||||||
|
graph_id = str(node["graph_id"])
|
||||||
|
unique_id = f"{graph_id}:{node_id}"
|
||||||
|
node_type = node.get("type", "")
|
||||||
|
type_id = NODE_TYPES.get(node_type, 0)
|
||||||
|
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
|
||||||
|
|
||||||
|
raw_capabilities = node.get("capabilities")
|
||||||
|
capabilities: dict = {}
|
||||||
|
if isinstance(raw_capabilities, dict):
|
||||||
|
capabilities = dict(raw_capabilities)
|
||||||
|
|
||||||
|
raw_widget_names: list | None = node.get("widget_names")
|
||||||
|
if not isinstance(raw_widget_names, list):
|
||||||
|
capability_widget_names = capabilities.get("widget_names")
|
||||||
|
raw_widget_names = (
|
||||||
|
capability_widget_names
|
||||||
|
if isinstance(capability_widget_names, list)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
widget_names: list[str] = []
|
||||||
|
if isinstance(raw_widget_names, list):
|
||||||
|
widget_names = [
|
||||||
|
str(widget_name)
|
||||||
|
for widget_name in raw_widget_names
|
||||||
|
if isinstance(widget_name, str) and widget_name
|
||||||
|
]
|
||||||
|
|
||||||
|
if widget_names:
|
||||||
|
capabilities["widget_names"] = widget_names
|
||||||
|
else:
|
||||||
|
capabilities.pop("widget_names", None)
|
||||||
|
|
||||||
|
if "supports_lora" in capabilities:
|
||||||
|
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
|
||||||
|
|
||||||
|
comfy_class = node.get("comfy_class")
|
||||||
|
if not isinstance(comfy_class, str) or not comfy_class:
|
||||||
|
comfy_class = node_type if isinstance(node_type, str) else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": node_id,
|
||||||
|
"graph_id": graph_id,
|
||||||
|
"graph_name": node.get("graph_name"),
|
||||||
|
"unique_id": unique_id,
|
||||||
|
"bgcolor": bgcolor,
|
||||||
|
"title": node.get("title"),
|
||||||
|
"type": type_id,
|
||||||
|
"type_name": node_type,
|
||||||
|
"comfy_class": comfy_class,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"widget_names": widget_names,
|
||||||
|
"mode": node.get("mode"),
|
||||||
|
"marker_role": node.get("marker_role"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def register_nodes(self, sid: str, nodes: list[dict]) -> None:
|
||||||
|
"""Register/replace the node list for a single ComfyUI tab (identified by *sid*)."""
|
||||||
|
tab_nodes: dict[str, dict] = {}
|
||||||
|
for node in nodes:
|
||||||
|
nd = self._build_node_dict(node)
|
||||||
|
tab_nodes[nd["unique_id"]] = nd
|
||||||
|
|
||||||
async def register_nodes(self, nodes: list[dict]) -> None:
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._nodes.clear()
|
self._tab_nodes[sid] = tab_nodes
|
||||||
for node in nodes:
|
self._waiting_clients.discard(sid)
|
||||||
node_id = node["node_id"]
|
if not self._waiting_clients:
|
||||||
graph_id = str(node["graph_id"])
|
self._ready.set()
|
||||||
unique_id = f"{graph_id}:{node_id}"
|
|
||||||
node_type = node.get("type", "")
|
|
||||||
type_id = NODE_TYPES.get(node_type, 0)
|
|
||||||
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
|
|
||||||
raw_capabilities = node.get("capabilities")
|
|
||||||
capabilities: dict = {}
|
|
||||||
if isinstance(raw_capabilities, dict):
|
|
||||||
capabilities = dict(raw_capabilities)
|
|
||||||
|
|
||||||
raw_widget_names: list | None = node.get("widget_names")
|
logger.debug("Registered %s nodes from client %s", len(nodes), sid)
|
||||||
if not isinstance(raw_widget_names, list):
|
|
||||||
capability_widget_names = capabilities.get("widget_names")
|
|
||||||
raw_widget_names = (
|
|
||||||
capability_widget_names
|
|
||||||
if isinstance(capability_widget_names, list)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
widget_names: list[str] = []
|
def prepare_for_refresh(self, active_sids: list[str]) -> None:
|
||||||
if isinstance(raw_widget_names, list):
|
"""Set the list of client IDs we expect to hear from during the next refresh cycle."""
|
||||||
widget_names = [
|
self._ready.clear()
|
||||||
str(widget_name)
|
self._waiting_clients = set(active_sids)
|
||||||
for widget_name in raw_widget_names
|
|
||||||
if isinstance(widget_name, str) and widget_name
|
|
||||||
]
|
|
||||||
|
|
||||||
if widget_names:
|
async def wait_for_all(self, timeout: float = 2.0) -> bool:
|
||||||
capabilities["widget_names"] = widget_names
|
"""Block until every client in the current waiting set has responded
|
||||||
else:
|
(or *timeout* seconds elapse). Returns ``True`` if all responded."""
|
||||||
capabilities.pop("widget_names", None)
|
if not self._waiting_clients:
|
||||||
|
return True
|
||||||
if "supports_lora" in capabilities:
|
|
||||||
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
|
|
||||||
|
|
||||||
comfy_class = node.get("comfy_class")
|
|
||||||
if not isinstance(comfy_class, str) or not comfy_class:
|
|
||||||
comfy_class = node_type if isinstance(node_type, str) else None
|
|
||||||
|
|
||||||
self._nodes[unique_id] = {
|
|
||||||
"id": node_id,
|
|
||||||
"graph_id": graph_id,
|
|
||||||
"graph_name": node.get("graph_name"),
|
|
||||||
"unique_id": unique_id,
|
|
||||||
"bgcolor": bgcolor,
|
|
||||||
"title": node.get("title"),
|
|
||||||
"type": type_id,
|
|
||||||
"type_name": node_type,
|
|
||||||
"comfy_class": comfy_class,
|
|
||||||
"capabilities": capabilities,
|
|
||||||
"widget_names": widget_names,
|
|
||||||
"mode": node.get("mode"),
|
|
||||||
}
|
|
||||||
logger.debug("Registered %s nodes in registry", len(nodes))
|
|
||||||
self._registry_updated.set()
|
|
||||||
|
|
||||||
async def get_registry(self) -> dict:
|
|
||||||
async with self._lock:
|
|
||||||
return {
|
|
||||||
"nodes": dict(self._nodes),
|
|
||||||
"node_count": len(self._nodes),
|
|
||||||
}
|
|
||||||
|
|
||||||
async def wait_for_update(self, timeout: float = 1.0) -> bool:
|
|
||||||
self._registry_updated.clear()
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self._registry_updated.wait(), timeout=timeout)
|
await asyncio.wait_for(self._ready.wait(), timeout=timeout)
|
||||||
return True
|
return True
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def get_merged_registry(self, active_sids: set[str] | None = None) -> dict:
|
||||||
|
"""Return the union of all known tab nodes, pruning any tab that is no
|
||||||
|
longer connected."""
|
||||||
|
async with self._lock:
|
||||||
|
# Garbage-collect stale entries (disconnected tabs)
|
||||||
|
if active_sids is not None:
|
||||||
|
for sid in list(self._tab_nodes):
|
||||||
|
if sid not in active_sids:
|
||||||
|
del self._tab_nodes[sid]
|
||||||
|
|
||||||
|
merged: dict[str, dict] = {}
|
||||||
|
tab_info: dict[str, dict] = {}
|
||||||
|
for sid, nodes in self._tab_nodes.items():
|
||||||
|
tab_info[sid] = {
|
||||||
|
"node_count": len(nodes),
|
||||||
|
"graph_names": list(
|
||||||
|
{
|
||||||
|
n.get("graph_name")
|
||||||
|
for n in nodes.values()
|
||||||
|
if n.get("graph_name")
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
merged.update(nodes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"nodes": merged,
|
||||||
|
"node_count": len(merged),
|
||||||
|
"tab_count": len(self._tab_nodes),
|
||||||
|
"tabs": tab_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class HealthCheckHandler:
|
class HealthCheckHandler:
|
||||||
async def health_check(self, request: web.Request) -> web.Response:
|
async def health_check(self, request: web.Request) -> web.Response:
|
||||||
@@ -1328,6 +1398,9 @@ class SettingsHandler:
|
|||||||
"folder_paths",
|
"folder_paths",
|
||||||
"libraries",
|
"libraries",
|
||||||
"active_library",
|
"active_library",
|
||||||
|
# Sensitive — never expose the actual value to the frontend;
|
||||||
|
# frontend receives a boolean instead (civitai_api_key_set).
|
||||||
|
"civitai_api_key",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1382,6 +1455,9 @@ class SettingsHandler:
|
|||||||
value = self._settings.get(key)
|
value = self._settings.get(key)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
response_data[key] = value
|
response_data[key] = value
|
||||||
|
# Sensitive fields: only expose a boolean indicating whether set
|
||||||
|
raw_key = self._settings.get("civitai_api_key")
|
||||||
|
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||||
settings_file = getattr(self._settings, "settings_file", None)
|
settings_file = getattr(self._settings, "settings_file", None)
|
||||||
if settings_file:
|
if settings_file:
|
||||||
response_data["settings_file"] = settings_file
|
response_data["settings_file"] = settings_file
|
||||||
@@ -1492,6 +1568,16 @@ class SettingsHandler:
|
|||||||
if not os.path.isdir(folder_path):
|
if not os.path.isdir(folder_path):
|
||||||
return "Please set a dedicated folder for example images."
|
return "Please set a dedicated folder for example images."
|
||||||
if not self._is_dedicated_example_images_folder(folder_path):
|
if not self._is_dedicated_example_images_folder(folder_path):
|
||||||
|
offending = find_non_compliant_items_in_example_images_root(folder_path)
|
||||||
|
if offending:
|
||||||
|
items_str = ", ".join(repr(item) for item in offending[:5])
|
||||||
|
if len(offending) > 5:
|
||||||
|
items_str += f" … and {len(offending) - 5} more"
|
||||||
|
return (
|
||||||
|
f"The folder contains items that are not valid example image "
|
||||||
|
f"folders: {items_str}. Please use a dedicated, empty folder "
|
||||||
|
f"for example images to prevent accidental data loss."
|
||||||
|
)
|
||||||
return "Please set a dedicated folder for example images."
|
return "Please set a dedicated folder for example images."
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -2975,10 +3061,21 @@ class NodeRegistryHandler:
|
|||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
nodes = data.get("nodes", [])
|
nodes = data.get("nodes", [])
|
||||||
|
client_id = data.get("client_id")
|
||||||
if not isinstance(nodes, list):
|
if not isinstance(nodes, list):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "nodes must be a list"}, status=400
|
{"success": False, "error": "nodes must be a list"}, status=400
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not isinstance(client_id, str) or not client_id:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing client_id parameter",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
for index, node in enumerate(nodes):
|
for index, node in enumerate(nodes):
|
||||||
if not isinstance(node, dict):
|
if not isinstance(node, dict):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -3022,7 +3119,7 @@ class NodeRegistryHandler:
|
|||||||
else:
|
else:
|
||||||
node["graph_name"] = str(graph_name)
|
node["graph_name"] = str(graph_name)
|
||||||
|
|
||||||
await self._node_registry.register_nodes(nodes)
|
await self._node_registry.register_nodes(client_id, nodes)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -3046,9 +3143,15 @@ class NodeRegistryHandler:
|
|||||||
status=503,
|
status=503,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Snapshot of currently-connected ComfyUI tabs
|
||||||
|
active_sids = list(self._prompt_server.instance.sockets.keys())
|
||||||
|
self._node_registry.prepare_for_refresh(active_sids)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
|
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
|
||||||
logger.debug("Sent registry refresh request to frontend")
|
logger.debug(
|
||||||
|
"Sent registry refresh request (expecting %s clients)", len(active_sids)
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to send registry refresh message: %s", exc)
|
logger.error("Failed to send registry refresh message: %s", exc)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -3060,19 +3163,31 @@ class NodeRegistryHandler:
|
|||||||
status=500,
|
status=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
registry_updated = await self._node_registry.wait_for_update(timeout=1.0)
|
if not await self._node_registry.wait_for_all(timeout=2.0):
|
||||||
if not registry_updated:
|
logger.warning(
|
||||||
logger.warning("Registry refresh timeout after 1 second")
|
"Registry refresh timeout after 2s (%s/%s clients responded)",
|
||||||
|
len(active_sids) - self._node_registry.pending_client_count,
|
||||||
|
len(active_sids),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-read current sockets after the wait: a tab may have connected
|
||||||
|
# while we were waiting, and we don't want to garbage-collect it.
|
||||||
|
current_sids = set(self._prompt_server.instance.sockets.keys())
|
||||||
|
registry_info = await self._node_registry.get_merged_registry(
|
||||||
|
active_sids=current_sids
|
||||||
|
)
|
||||||
|
|
||||||
|
if registry_info["node_count"] == 0:
|
||||||
|
logger.warning("No nodes registered after refresh")
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "Timeout Error",
|
"error": "Empty Registry",
|
||||||
"message": "Registry refresh timeout - ComfyUI frontend may not be responsive",
|
"message": "No workflow nodes found — ensure ComfyUI is open and the extension is loaded.",
|
||||||
},
|
},
|
||||||
status=408,
|
status=408,
|
||||||
)
|
)
|
||||||
|
|
||||||
registry_info = await self._node_registry.get_registry()
|
|
||||||
return web.json_response({"success": True, "data": registry_info})
|
return web.json_response({"success": True, "data": registry_info})
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Failed to get registry: %s", exc, exc_info=True)
|
logger.error("Failed to get registry: %s", exc, exc_info=True)
|
||||||
@@ -3085,13 +3200,17 @@ class NodeRegistryHandler:
|
|||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
widget_name = data.get("widget_name")
|
widget_name = data.get("widget_name")
|
||||||
|
action = data.get("action")
|
||||||
value = data.get("value")
|
value = data.get("value")
|
||||||
mode = data.get("mode", "replace")
|
mode = data.get("mode", "replace")
|
||||||
node_ids = data.get("node_ids")
|
node_ids = data.get("node_ids")
|
||||||
|
|
||||||
if not isinstance(widget_name, str) or not widget_name:
|
if not action and (not isinstance(widget_name, str) or not widget_name):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Missing widget_name parameter"},
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing parameter: provide either 'action' or 'widget_name'",
|
||||||
|
},
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3130,12 +3249,15 @@ class NodeRegistryHandler:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
parsed_node_id = node_identifier
|
parsed_node_id = node_identifier
|
||||||
|
|
||||||
payload = {
|
payload: dict = {
|
||||||
"id": parsed_node_id,
|
"id": parsed_node_id,
|
||||||
"widget_name": widget_name,
|
|
||||||
"value": value,
|
"value": value,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
}
|
}
|
||||||
|
if action:
|
||||||
|
payload["action"] = action
|
||||||
|
if widget_name:
|
||||||
|
payload["widget_name"] = widget_name
|
||||||
|
|
||||||
if graph_identifier is not None:
|
if graph_identifier is not None:
|
||||||
payload["graph_id"] = str(graph_identifier)
|
payload["graph_id"] = str(graph_identifier)
|
||||||
@@ -3194,6 +3316,7 @@ class MiscHandlerSet:
|
|||||||
doctor: DoctorHandler,
|
doctor: DoctorHandler,
|
||||||
example_workflows: ExampleWorkflowsHandler,
|
example_workflows: ExampleWorkflowsHandler,
|
||||||
base_model: BaseModelHandlerSet,
|
base_model: BaseModelHandlerSet,
|
||||||
|
hf_handler: HfHandler | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.health = health
|
self.health = health
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -3212,6 +3335,7 @@ class MiscHandlerSet:
|
|||||||
self.doctor = doctor
|
self.doctor = doctor
|
||||||
self.example_workflows = example_workflows
|
self.example_workflows = example_workflows
|
||||||
self.base_model = base_model
|
self.base_model = base_model
|
||||||
|
self.hf_handler = hf_handler
|
||||||
|
|
||||||
def to_route_mapping(
|
def to_route_mapping(
|
||||||
self,
|
self,
|
||||||
@@ -3257,6 +3381,9 @@ class MiscHandlerSet:
|
|||||||
"get_supporters": self.supporters.get_supporters,
|
"get_supporters": self.supporters.get_supporters,
|
||||||
"get_example_workflows": self.example_workflows.get_example_workflows,
|
"get_example_workflows": self.example_workflows.get_example_workflows,
|
||||||
"get_example_workflow": self.example_workflows.get_example_workflow,
|
"get_example_workflow": self.example_workflows.get_example_workflow,
|
||||||
|
# Hugging Face handlers
|
||||||
|
"get_hf_repo_files": self.hf_handler.get_hf_repo_files,
|
||||||
|
"download_hf_model": self.hf_handler.download_hf_model,
|
||||||
# Base model handlers
|
# Base model handlers
|
||||||
"get_base_models": self.base_model.get_base_models,
|
"get_base_models": self.base_model.get_base_models,
|
||||||
"refresh_base_models": self.base_model.refresh_base_models,
|
"refresh_base_models": self.base_model.refresh_base_models,
|
||||||
|
|||||||
@@ -203,11 +203,17 @@ class ModelListingHandler:
|
|||||||
result = await self._service.get_paginated_data(**params)
|
result = await self._service.get_paginated_data(**params)
|
||||||
|
|
||||||
format_start = time.perf_counter()
|
format_start = time.perf_counter()
|
||||||
|
formatted_raw = [
|
||||||
|
await self._service.format_response(entry)
|
||||||
|
for entry in result["items"]
|
||||||
|
]
|
||||||
|
# Filter out None entries returned for corrupted cache rows (issue #730).
|
||||||
|
# Note: "total" intentionally remains the pre-filter count to reflect
|
||||||
|
# the true number of models in the cache; corrupted entries are rare
|
||||||
|
# and adjusting total would cause pagination drift on every page.
|
||||||
|
formatted_items = [item for item in formatted_raw if item is not None]
|
||||||
formatted_result = {
|
formatted_result = {
|
||||||
"items": [
|
"items": formatted_items,
|
||||||
await self._service.format_response(item)
|
|
||||||
for item in result["items"]
|
|
||||||
],
|
|
||||||
"total": result["total"],
|
"total": result["total"],
|
||||||
"page": result["page"],
|
"page": result["page"],
|
||||||
"page_size": result["page_size"],
|
"page_size": result["page_size"],
|
||||||
@@ -233,14 +239,20 @@ class ModelListingHandler:
|
|||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
params = self._parse_common_params(request)
|
params = self._parse_common_params(request)
|
||||||
|
# group_by_model is meaningless for excluded view; strip it
|
||||||
|
params.pop("group_by_model", None)
|
||||||
result = await self._service.get_excluded_paginated_data(**params)
|
result = await self._service.get_excluded_paginated_data(**params)
|
||||||
|
|
||||||
format_start = time.perf_counter()
|
format_start = time.perf_counter()
|
||||||
|
formatted_raw = [
|
||||||
|
await self._service.format_response(entry)
|
||||||
|
for entry in result["items"]
|
||||||
|
]
|
||||||
|
# Filter out None entries returned for corrupted cache rows (issue #730).
|
||||||
|
# "total" stays at the pre-filter count; see get_models for rationale.
|
||||||
|
formatted_items = [item for item in formatted_raw if item is not None]
|
||||||
formatted_result = {
|
formatted_result = {
|
||||||
"items": [
|
"items": formatted_items,
|
||||||
await self._service.format_response(item)
|
|
||||||
for item in result["items"]
|
|
||||||
],
|
|
||||||
"total": result["total"],
|
"total": result["total"],
|
||||||
"page": result["page"],
|
"page": result["page"],
|
||||||
"page_size": result["page_size"],
|
"page_size": result["page_size"],
|
||||||
@@ -366,6 +378,19 @@ class ModelListingHandler:
|
|||||||
request.query.get("name_pattern_use_regex", "false").lower() == "true"
|
request.query.get("name_pattern_use_regex", "false").lower() == "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Group-by-model flag: deduplicate versions sharing the same civitai modelId
|
||||||
|
group_by_model = (
|
||||||
|
request.query.get("group_by_model", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
# View-local-versions filter: show all local versions of a specific model
|
||||||
|
civitai_model_id = request.query.get("civitai_model_id")
|
||||||
|
if civitai_model_id is not None:
|
||||||
|
try:
|
||||||
|
civitai_model_id = int(civitai_model_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
civitai_model_id = None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
@@ -389,6 +414,8 @@ class ModelListingHandler:
|
|||||||
"name_pattern_include": name_pattern_include,
|
"name_pattern_include": name_pattern_include,
|
||||||
"name_pattern_exclude": name_pattern_exclude,
|
"name_pattern_exclude": name_pattern_exclude,
|
||||||
"name_pattern_use_regex": name_pattern_use_regex,
|
"name_pattern_use_regex": name_pattern_use_regex,
|
||||||
|
"group_by_model": group_by_model,
|
||||||
|
"civitai_model_id": civitai_model_id,
|
||||||
**self._parse_specific_params(request),
|
**self._parse_specific_params(request),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,8 +543,13 @@ class ModelManagementHandler:
|
|||||||
if not success:
|
if not success:
|
||||||
return web.json_response({"success": False, "error": error})
|
return web.json_response({"success": False, "error": error})
|
||||||
|
|
||||||
formatted_metadata = await self._service.format_response(model_data)
|
formatted = await self._service.format_response(model_data)
|
||||||
return web.json_response({"success": True, "metadata": formatted_metadata})
|
if formatted is None:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Model entry is corrupted (missing file_path)"},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
return web.json_response({"success": True, "metadata": formatted})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if is_expected_offline_error(str(exc)):
|
if is_expected_offline_error(str(exc)):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -1074,10 +1106,12 @@ class ModelQueryHandler:
|
|||||||
# Sort: originals first, copies last
|
# Sort: originals first, copies last
|
||||||
sorted_models = self._sort_duplicate_group(filtered)
|
sorted_models = self._sort_duplicate_group(filtered)
|
||||||
|
|
||||||
# Format response
|
# Format response, filtering out corrupted entries (issue #730)
|
||||||
group = {"hash": sha256, "models": []}
|
group = {"hash": sha256, "models": []}
|
||||||
for model in sorted_models:
|
for model in sorted_models:
|
||||||
group["models"].append(await self._service.format_response(model))
|
formatted = await self._service.format_response(model)
|
||||||
|
if formatted is not None:
|
||||||
|
group["models"].append(formatted)
|
||||||
|
|
||||||
# Only include groups with 2+ models after filtering
|
# Only include groups with 2+ models after filtering
|
||||||
if len(group["models"]) > 1:
|
if len(group["models"]) > 1:
|
||||||
@@ -1194,9 +1228,9 @@ class ModelQueryHandler:
|
|||||||
(m for m in cache.raw_data if m["file_path"] == path), None
|
(m for m in cache.raw_data if m["file_path"] == path), None
|
||||||
)
|
)
|
||||||
if model:
|
if model:
|
||||||
group["models"].append(
|
formatted = await self._service.format_response(model)
|
||||||
await self._service.format_response(model)
|
if formatted is not None:
|
||||||
)
|
group["models"].append(formatted)
|
||||||
hash_val = self._service.scanner.get_hash_by_filename(filename)
|
hash_val = self._service.scanner.get_hash_by_filename(filename)
|
||||||
if hash_val:
|
if hash_val:
|
||||||
main_path = self._service.get_path_by_hash(hash_val)
|
main_path = self._service.get_path_by_hash(hash_val)
|
||||||
@@ -1206,9 +1240,9 @@ class ModelQueryHandler:
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if main_model:
|
if main_model:
|
||||||
group["models"].insert(
|
formatted = await self._service.format_response(main_model)
|
||||||
0, await self._service.format_response(main_model)
|
if formatted is not None:
|
||||||
)
|
group["models"].insert(0, formatted)
|
||||||
if group["models"]:
|
if group["models"]:
|
||||||
result.append(group)
|
result.append(group)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -1272,6 +1306,14 @@ class ModelQueryHandler:
|
|||||||
license_flags = (model_data or {}).get("license_flags")
|
license_flags = (model_data or {}).get("license_flags")
|
||||||
if license_flags is not None:
|
if license_flags is not None:
|
||||||
response_payload["license_flags"] = int(license_flags)
|
response_payload["license_flags"] = int(license_flags)
|
||||||
|
# Include the user's license icon style preference so the
|
||||||
|
# ComfyUI tooltip can pick the right set without a separate
|
||||||
|
# API call.
|
||||||
|
try:
|
||||||
|
settings = get_settings_manager()
|
||||||
|
response_payload["use_new_license_icons"] = settings.get("use_new_license_icons", True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return web.json_response(response_payload)
|
return web.json_response(response_payload)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@@ -1820,6 +1862,39 @@ class ModelDownloadHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def update_download_queue_status(self, request: web.Request) -> web.Response:
|
||||||
|
"""Update the status of a queue item (non-terminal transitions).
|
||||||
|
|
||||||
|
Supported transitions include ``queued → downloading``,
|
||||||
|
``downloading → paused``, ``paused → downloading``, etc.
|
||||||
|
Terminal transitions (``completed``, ``failed``, ``canceled``)
|
||||||
|
should use ``complete_download_in_queue`` instead.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
download_id = request.query.get("download_id")
|
||||||
|
status = request.query.get("status")
|
||||||
|
if not download_id or not status:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "download_id and status are required",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
service = await DownloadQueueService.get_instance()
|
||||||
|
updated = await service.update_status(download_id, status)
|
||||||
|
if not updated:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Download not found in queue"},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
return web.json_response({"success": True})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error updating download queue status: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class ModelCivitaiHandler:
|
class ModelCivitaiHandler:
|
||||||
"""CivitAI integration endpoints."""
|
"""CivitAI integration endpoints."""
|
||||||
@@ -1861,7 +1936,9 @@ class ModelCivitaiHandler:
|
|||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
"Error in fetch_all_civitai for %ss: %s", self._service.model_type, exc
|
"Error in fetch_all_civitai for %ss: %s",
|
||||||
|
self._service.model_type, exc,
|
||||||
|
exc_info=True,
|
||||||
)
|
)
|
||||||
return web.Response(text=str(exc), status=500)
|
return web.Response(text=str(exc), status=500)
|
||||||
|
|
||||||
@@ -2862,6 +2939,7 @@ class ModelHandlerSet:
|
|||||||
"retry_all_failed_downloads": self.download.retry_all_failed_downloads,
|
"retry_all_failed_downloads": self.download.retry_all_failed_downloads,
|
||||||
"complete_download_in_queue": self.download.complete_download_in_queue,
|
"complete_download_in_queue": self.download.complete_download_in_queue,
|
||||||
"get_download_stats": self.download.get_download_stats,
|
"get_download_stats": self.download.get_download_stats,
|
||||||
|
"update_download_queue_status": self.download.update_download_queue_status,
|
||||||
"get_civitai_versions": self.civitai.get_civitai_versions,
|
"get_civitai_versions": self.civitai.get_civitai_versions,
|
||||||
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
|
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
|
||||||
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
|
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from ...utils.civitai_utils import (
|
|||||||
extract_civitai_image_id_from_cdn_url,
|
extract_civitai_image_id_from_cdn_url,
|
||||||
rewrite_preview_url,
|
rewrite_preview_url,
|
||||||
)
|
)
|
||||||
|
from ...utils.constants import NSFW_LEVELS
|
||||||
from ...utils.exif_utils import ExifUtils
|
from ...utils.exif_utils import ExifUtils
|
||||||
from ...recipes.merger import GenParamsMerger
|
from ...recipes.merger import GenParamsMerger
|
||||||
from ...recipes.enrichment import RecipeEnricher
|
from ...recipes.enrichment import RecipeEnricher
|
||||||
@@ -1120,6 +1121,13 @@ class RecipeManagementHandler:
|
|||||||
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
||||||
metadata["base_model"] = parsed_embedded["base_model"]
|
metadata["base_model"] = parsed_embedded["base_model"]
|
||||||
|
|
||||||
|
# Extract preview_nsfw_level from the CivitAI API response
|
||||||
|
# (injected into civitai_meta_raw by _download_remote_media).
|
||||||
|
if isinstance(civitai_meta_raw, dict):
|
||||||
|
bl = civitai_meta_raw.get("browsingLevel")
|
||||||
|
if isinstance(bl, int) and bl > 0:
|
||||||
|
metadata["preview_nsfw_level"] = bl
|
||||||
|
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
await RecipeEnricher.enrich_recipe(
|
await RecipeEnricher.enrich_recipe(
|
||||||
recipe=metadata,
|
recipe=metadata,
|
||||||
@@ -1515,8 +1523,31 @@ class RecipeManagementHandler:
|
|||||||
# CivitAI API returns modelVersionIds at the root level of
|
# CivitAI API returns modelVersionIds at the root level of
|
||||||
# the image response, NOT inside the meta object.
|
# the image response, NOT inside the meta object.
|
||||||
mvids = image_info.get("modelVersionIds")
|
mvids = image_info.get("modelVersionIds")
|
||||||
if mvids and isinstance(civitai_meta_raw, dict):
|
if mvids:
|
||||||
civitai_meta_raw["modelVersionIds"] = mvids
|
if isinstance(civitai_meta_raw, dict):
|
||||||
|
civitai_meta_raw["modelVersionIds"] = mvids
|
||||||
|
else:
|
||||||
|
# meta is null but modelVersionIds exists — create a
|
||||||
|
# minimal dict so downstream parsers can discover
|
||||||
|
# LoRAs and checkpoints from the API response.
|
||||||
|
civitai_meta_raw = {"modelVersionIds": mvids}
|
||||||
|
|
||||||
|
# Inject browsingLevel (canonical integer) so the recipe's
|
||||||
|
# preview_nsfw_level can be set, enabling proper NSFW blur
|
||||||
|
# of the preview image. Fall back to nsfwLevel (string)
|
||||||
|
# when browsingLevel is absent.
|
||||||
|
if isinstance(civitai_meta_raw, dict):
|
||||||
|
browsing_level = image_info.get("browsingLevel")
|
||||||
|
nsfw_level_str = image_info.get("nsfwLevel")
|
||||||
|
if isinstance(browsing_level, int) and browsing_level > 0:
|
||||||
|
civitai_meta_raw["browsingLevel"] = browsing_level
|
||||||
|
elif (
|
||||||
|
isinstance(nsfw_level_str, str)
|
||||||
|
and nsfw_level_str in NSFW_LEVELS
|
||||||
|
):
|
||||||
|
civitai_meta_raw["browsingLevel"] = NSFW_LEVELS[
|
||||||
|
nsfw_level_str
|
||||||
|
]
|
||||||
|
|
||||||
original_url = (
|
original_url = (
|
||||||
image_info.get("url") if civitai_image_id and image_info else None
|
image_info.get("url") if civitai_image_id and image_info else None
|
||||||
@@ -1796,6 +1827,13 @@ class RecipeManagementHandler:
|
|||||||
"source_path": image_url,
|
"source_path": image_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Extract preview_nsfw_level from the CivitAI API response
|
||||||
|
# (injected into civitai_meta_raw by _download_remote_media).
|
||||||
|
if isinstance(civitai_meta_raw, dict):
|
||||||
|
bl = civitai_meta_raw.get("browsingLevel")
|
||||||
|
if isinstance(bl, int) and bl > 0:
|
||||||
|
metadata["preview_nsfw_level"] = bl
|
||||||
|
|
||||||
if civitai_parsed:
|
if civitai_parsed:
|
||||||
civitai_loras = civitai_parsed.get("loras", [])
|
civitai_loras = civitai_parsed.get("loras", [])
|
||||||
if civitai_loras and not metadata.get("loras"):
|
if civitai_loras and not metadata.get("loras"):
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
||||||
),
|
),
|
||||||
|
# Hugging Face model endpoints
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
|
||||||
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"POST", "/api/lm/download-hf-model", "download_hf_model"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from .handlers.misc_handlers import (
|
|||||||
build_service_registry_adapter,
|
build_service_registry_adapter,
|
||||||
)
|
)
|
||||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||||
|
from .handlers.hf_handlers import HfHandler
|
||||||
from .misc_route_registrar import MiscRouteRegistrar
|
from .misc_route_registrar import MiscRouteRegistrar
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -136,6 +137,7 @@ class MiscRoutes:
|
|||||||
doctor = DoctorHandler(settings_service=self._settings)
|
doctor = DoctorHandler(settings_service=self._settings)
|
||||||
example_workflows = ExampleWorkflowsHandler()
|
example_workflows = ExampleWorkflowsHandler()
|
||||||
base_model = BaseModelHandlerSet()
|
base_model = BaseModelHandlerSet()
|
||||||
|
hf_handler = HfHandler()
|
||||||
|
|
||||||
return self._handler_set_factory(
|
return self._handler_set_factory(
|
||||||
health=health,
|
health=health,
|
||||||
@@ -155,6 +157,7 @@ class MiscRoutes:
|
|||||||
doctor=doctor,
|
doctor=doctor,
|
||||||
example_workflows=example_workflows,
|
example_workflows=example_workflows,
|
||||||
base_model=base_model,
|
base_model=base_model,
|
||||||
|
hf_handler=hf_handler,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue"
|
"GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue"
|
||||||
),
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/downloads/queue/status", "update_download_queue_status"
|
||||||
|
),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
|
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
|
||||||
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
|
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
|
||||||
)
|
)
|
||||||
|
|||||||
+45
-16
@@ -11,6 +11,8 @@ from ..config import config
|
|||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
from ..services.server_i18n import server_i18n
|
from ..services.server_i18n import server_i18n
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
from ..services.model_query import normalize_sub_type, resolve_sub_type
|
||||||
|
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
||||||
from ..utils.usage_stats import UsageStats
|
from ..utils.usage_stats import UsageStats
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -140,6 +142,21 @@ class StatsRoutes:
|
|||||||
# Get usage statistics
|
# Get usage statistics
|
||||||
usage_data = await self.usage_stats.get_stats()
|
usage_data = await self.usage_stats.get_stats()
|
||||||
|
|
||||||
|
# CivitAI model type distribution across all model types
|
||||||
|
# Use the same logic as the filter panel: normalize_sub_type(resolve_sub_type(entry))
|
||||||
|
# with sub-type validation per model type
|
||||||
|
model_types_counter: Counter[str] = Counter()
|
||||||
|
for entry in lora_cache.raw_data:
|
||||||
|
ntype = normalize_sub_type(resolve_sub_type(entry))
|
||||||
|
if ntype and ntype in VALID_LORA_SUB_TYPES:
|
||||||
|
model_types_counter[ntype] += 1
|
||||||
|
for entry in checkpoint_cache.raw_data:
|
||||||
|
ntype = normalize_sub_type(resolve_sub_type(entry))
|
||||||
|
if ntype and ntype in VALID_CHECKPOINT_SUB_TYPES:
|
||||||
|
model_types_counter[ntype] += 1
|
||||||
|
# Embeddings: always count as "embedding" regardless of CivitAI sub-type
|
||||||
|
model_types_counter['embedding'] = len(embedding_cache.raw_data)
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': {
|
'data': {
|
||||||
@@ -154,7 +171,8 @@ class StatsRoutes:
|
|||||||
'total_generations': usage_data.get('total_executions', 0),
|
'total_generations': usage_data.get('total_executions', 0),
|
||||||
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
|
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
|
||||||
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {})),
|
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {})),
|
||||||
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {}))
|
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {})),
|
||||||
|
'model_types_distribution': dict(model_types_counter.most_common())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -459,9 +477,12 @@ class StatsRoutes:
|
|||||||
if unused_lora_percent > 50:
|
if unused_lora_percent > 50:
|
||||||
insights.append({
|
insights.append({
|
||||||
'type': 'warning',
|
'type': 'warning',
|
||||||
'title': 'High Number of Unused LoRAs',
|
'key': 'insights.unusedLoras.high',
|
||||||
'description': f'{unused_lora_percent:.1f}% of your LoRAs ({unused_loras}/{total_loras}) have never been used.',
|
'params': {
|
||||||
'suggestion': 'Consider organizing or archiving unused models to free up storage space.'
|
'percent': f'{unused_lora_percent:.1f}',
|
||||||
|
'count': str(unused_loras),
|
||||||
|
'total': str(total_loras)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if total_checkpoints > 0:
|
if total_checkpoints > 0:
|
||||||
@@ -469,9 +490,12 @@ class StatsRoutes:
|
|||||||
if unused_checkpoint_percent > 30:
|
if unused_checkpoint_percent > 30:
|
||||||
insights.append({
|
insights.append({
|
||||||
'type': 'warning',
|
'type': 'warning',
|
||||||
'title': 'Unused Checkpoints Detected',
|
'key': 'insights.unusedCheckpoints.detected',
|
||||||
'description': f'{unused_checkpoint_percent:.1f}% of your checkpoints ({unused_checkpoints}/{total_checkpoints}) have never been used.',
|
'params': {
|
||||||
'suggestion': 'Review and consider removing checkpoints you no longer need.'
|
'percent': f'{unused_checkpoint_percent:.1f}',
|
||||||
|
'count': str(unused_checkpoints),
|
||||||
|
'total': str(total_checkpoints)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if total_embeddings > 0:
|
if total_embeddings > 0:
|
||||||
@@ -479,9 +503,12 @@ class StatsRoutes:
|
|||||||
if unused_embedding_percent > 50:
|
if unused_embedding_percent > 50:
|
||||||
insights.append({
|
insights.append({
|
||||||
'type': 'warning',
|
'type': 'warning',
|
||||||
'title': 'High Number of Unused Embeddings',
|
'key': 'insights.unusedEmbeddings.high',
|
||||||
'description': f'{unused_embedding_percent:.1f}% of your embeddings ({unused_embeddings}/{total_embeddings}) have never been used.',
|
'params': {
|
||||||
'suggestion': 'Consider organizing or archiving unused embeddings to optimize your collection.'
|
'percent': f'{unused_embedding_percent:.1f}',
|
||||||
|
'count': str(unused_embeddings),
|
||||||
|
'total': str(total_embeddings)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
# Storage insights
|
# Storage insights
|
||||||
@@ -492,18 +519,20 @@ class StatsRoutes:
|
|||||||
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
|
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
|
||||||
insights.append({
|
insights.append({
|
||||||
'type': 'info',
|
'type': 'info',
|
||||||
'title': 'Large Collection Detected',
|
'key': 'insights.collection.large',
|
||||||
'description': f'Your model collection is using {self._format_size(total_size)} of storage.',
|
'params': {
|
||||||
'suggestion': 'Consider using external storage or cloud solutions for better organization.'
|
'size': self._format_size(total_size)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
# Recent activity insight
|
# Recent activity insight
|
||||||
if usage_data.get('total_executions', 0) > 100:
|
if usage_data.get('total_executions', 0) > 100:
|
||||||
insights.append({
|
insights.append({
|
||||||
'type': 'success',
|
'type': 'success',
|
||||||
'title': 'Active User',
|
'key': 'insights.activity.active',
|
||||||
'description': f'You\'ve completed {usage_data["total_executions"]} generations so far!',
|
'params': {
|
||||||
'suggestion': 'Keep exploring and creating amazing content with your models.'
|
'count': str(usage_data['total_executions'])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
||||||
|
|
||||||
|
# User-managed directories that live inside the plugin folder (portable
|
||||||
|
# mode) and must survive a Git-based update. ``git clean -fd`` would
|
||||||
|
# otherwise delete them because they are untracked and, in released tags,
|
||||||
|
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
|
||||||
|
# regardless of whether it is ignored.
|
||||||
|
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache')
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_excludes() -> List[str]:
|
||||||
|
"""Build the ``-e`` arguments for ``git clean`` from :data:`_PRESERVE_DIRS`."""
|
||||||
|
excludes: List[str] = []
|
||||||
|
for name in _PRESERVE_DIRS:
|
||||||
|
excludes.append('-e')
|
||||||
|
excludes.append(name)
|
||||||
|
# For directories, also exclude nested matches explicitly
|
||||||
|
# (``-e dir`` alone matches the dir entry; ``-e dir/**`` guards
|
||||||
|
# contents under all git versions as defense-in-depth).
|
||||||
|
excludes.append('-e')
|
||||||
|
excludes.append(f'{name}/**')
|
||||||
|
return excludes
|
||||||
|
|
||||||
|
|
||||||
class UpdateRoutes:
|
class UpdateRoutes:
|
||||||
"""Routes for handling plugin update checks"""
|
"""Routes for handling plugin update checks"""
|
||||||
@@ -365,6 +386,8 @@ class UpdateRoutes:
|
|||||||
)
|
)
|
||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
|
clean_excludes = _clean_excludes()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Open the Git repository
|
# Open the Git repository
|
||||||
repo = git.Repo(plugin_root)
|
repo = git.Repo(plugin_root)
|
||||||
@@ -376,8 +399,9 @@ class UpdateRoutes:
|
|||||||
if nightly:
|
if nightly:
|
||||||
# Reset to discard any local changes
|
# Reset to discard any local changes
|
||||||
repo.git.reset('--hard')
|
repo.git.reset('--hard')
|
||||||
# Clean untracked files
|
# Clean untracked files, but preserve user-managed directories
|
||||||
repo.git.clean('-fd')
|
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||||
|
repo.git.clean('-fd', *clean_excludes)
|
||||||
|
|
||||||
# Switch to main branch and pull latest
|
# Switch to main branch and pull latest
|
||||||
main_branch = 'main'
|
main_branch = 'main'
|
||||||
@@ -394,8 +418,9 @@ class UpdateRoutes:
|
|||||||
else:
|
else:
|
||||||
# Reset to discard any local changes
|
# Reset to discard any local changes
|
||||||
repo.git.reset('--hard')
|
repo.git.reset('--hard')
|
||||||
# Clean untracked files
|
# Clean untracked files, but preserve user-managed directories
|
||||||
repo.git.clean('-fd')
|
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||||
|
repo.git.clean('-fd', *clean_excludes)
|
||||||
|
|
||||||
# Get latest release tag
|
# Get latest release tag
|
||||||
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class Aria2Downloader:
|
|||||||
self._transfers: Dict[str, Aria2Transfer] = {}
|
self._transfers: Dict[str, Aria2Transfer] = {}
|
||||||
self._poll_interval = 0.5
|
self._poll_interval = 0.5
|
||||||
self._state_store = Aria2TransferStateStore()
|
self._state_store = Aria2TransferStateStore()
|
||||||
|
self._stderr_reader_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
@@ -115,7 +116,7 @@ class Aria2Downloader:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
status = await self.get_status(download_id)
|
status = await self._get_status_with_retry(download_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
return False, "aria2 download not found"
|
return False, "aria2 download not found"
|
||||||
|
|
||||||
@@ -136,6 +137,35 @@ class Aria2Downloader:
|
|||||||
finally:
|
finally:
|
||||||
self._transfers.pop(download_id, None)
|
self._transfers.pop(download_id, None)
|
||||||
|
|
||||||
|
async def _get_status_with_retry(
|
||||||
|
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Call get_status with retry for transient RPC failures.
|
||||||
|
|
||||||
|
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
|
||||||
|
``None`` immediately when the download_id is not tracked (a missing
|
||||||
|
transfer is not a transient condition, so retrying is pointless).
|
||||||
|
|
||||||
|
A single failed RPC call should not immediately fail the download,
|
||||||
|
because aria2 may be temporarily busy (e.g. finalizing multiple
|
||||||
|
concurrent downloads) and a retry will often succeed.
|
||||||
|
"""
|
||||||
|
last_exc: Optional[Exception] = None
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
return await self.get_status(download_id)
|
||||||
|
except Aria2Error as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
logger.warning(
|
||||||
|
"aria2 get_status transient failure (attempt %d/%d) for %s: %s",
|
||||||
|
attempt + 1, max_retries, download_id, exc,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(retry_delay)
|
||||||
|
raise Aria2Error(
|
||||||
|
f"Failed to query aria2 download status after {max_retries} attempts: {last_exc}"
|
||||||
|
) from last_exc
|
||||||
|
|
||||||
async def _schedule_download(
|
async def _schedule_download(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
@@ -312,6 +342,16 @@ class Aria2Downloader:
|
|||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Shut down the RPC process and session."""
|
"""Shut down the RPC process and session."""
|
||||||
|
|
||||||
|
# Cancel the background stderr reader first so it stops reading
|
||||||
|
# from the pipe before the subprocess is terminated.
|
||||||
|
if self._stderr_reader_task is not None:
|
||||||
|
self._stderr_reader_task.cancel()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._stderr_reader_task, timeout=2.0)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
self._stderr_reader_task = None
|
||||||
|
|
||||||
if self._rpc_session is not None:
|
if self._rpc_session is not None:
|
||||||
await self._rpc_session.close()
|
await self._rpc_session.close()
|
||||||
self._rpc_session = None
|
self._rpc_session = None
|
||||||
@@ -331,6 +371,23 @@ class Aria2Downloader:
|
|||||||
process.kill()
|
process.kill()
|
||||||
await process.wait()
|
await process.wait()
|
||||||
|
|
||||||
|
async def _drain_stderr(self) -> None:
|
||||||
|
"""Continuously drain aria2's stderr pipe so it never blocks.
|
||||||
|
|
||||||
|
When the 64 KB pipe buffer fills up, aria2's ``write()`` to stderr
|
||||||
|
blocks, which freezes the entire ``aria2c`` process — including its
|
||||||
|
RPC handler. This background task reads lines from stderr as they
|
||||||
|
arrive and forwards them to Python's logger.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
assert self._process is not None and self._process.stderr is not None
|
||||||
|
async for line in self._process.stderr:
|
||||||
|
text = line.decode("utf-8", errors="replace").rstrip()
|
||||||
|
if text:
|
||||||
|
logger.debug("aria2 stderr: %s", text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
|
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
|
||||||
try:
|
try:
|
||||||
result = callback(snapshot, snapshot)
|
result = callback(snapshot, snapshot)
|
||||||
@@ -465,6 +522,17 @@ class Aria2Downloader:
|
|||||||
|
|
||||||
await self._wait_until_ready()
|
await self._wait_until_ready()
|
||||||
|
|
||||||
|
# Drain aria2's stderr in a background task so the pipe buffer
|
||||||
|
# never fills up. If the pipe blocks, aria2 itself freezes and
|
||||||
|
# cannot respond to RPC — this was the root cause of the
|
||||||
|
# "Failed to query aria2 download status" timeout bug.
|
||||||
|
# Must start AFTER _wait_until_ready to avoid a race where the
|
||||||
|
# drain task consumes aria2's early-exit error message before
|
||||||
|
# _wait_until_ready can read it.
|
||||||
|
self._stderr_reader_task = asyncio.create_task(
|
||||||
|
self._drain_stderr()
|
||||||
|
)
|
||||||
|
|
||||||
def _resolve_executable(self) -> str:
|
def _resolve_executable(self) -> str:
|
||||||
settings = get_settings_manager()
|
settings = get_settings_manager()
|
||||||
configured_path = (settings.get("aria2c_path") or "").strip()
|
configured_path = (settings.get("aria2c_path") or "").strip()
|
||||||
@@ -584,7 +652,9 @@ class Aria2Downloader:
|
|||||||
if self._rpc_session is None or self._rpc_session.closed:
|
if self._rpc_session is None or self._rpc_session.closed:
|
||||||
async with self._rpc_session_lock:
|
async with self._rpc_session_lock:
|
||||||
if self._rpc_session is None or self._rpc_session.closed:
|
if self._rpc_session is None or self._rpc_session.closed:
|
||||||
timeout = aiohttp.ClientTimeout(total=30)
|
timeout = aiohttp.ClientTimeout(
|
||||||
|
total=None, sock_connect=10, sock_read=60
|
||||||
|
)
|
||||||
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
|
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
|
||||||
return self._rpc_session
|
return self._rpc_session
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,100 @@ class BaseModelService(ABC):
|
|||||||
fetch_duration = time.perf_counter() - t0
|
fetch_duration = time.perf_counter() - t0
|
||||||
initial_count = len(sorted_data)
|
initial_count = len(sorted_data)
|
||||||
|
|
||||||
|
# Optionally filter by civitai model ID (shows all local versions of a specific model)
|
||||||
|
civitai_model_id = kwargs.get("civitai_model_id")
|
||||||
|
if civitai_model_id is not None:
|
||||||
|
sorted_data = [
|
||||||
|
item for item in sorted_data
|
||||||
|
if self._extract_model_id(item) == civitai_model_id
|
||||||
|
]
|
||||||
|
# VLM mode: always sort by version ID descending (newest version first),
|
||||||
|
# regardless of the current sort_by preference.
|
||||||
|
sorted_data.sort(
|
||||||
|
key=lambda x: self._extract_version_id(x) or 0,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optionally group by civitai modelId, showing only the latest version per model
|
||||||
|
dedup_lost = 0
|
||||||
|
if kwargs.get("group_by_model") and civitai_model_id is None:
|
||||||
|
# Determine whether to further sub-group by base model
|
||||||
|
# When version_grouping is "same_base", versions with different
|
||||||
|
# base models are effectively different groups — the dedup key
|
||||||
|
# needs to include base_model so the version count and VLM flow
|
||||||
|
# stay consistent (card shows correct count for its base model).
|
||||||
|
ufs = self.settings.get("version_grouping", "same_base")
|
||||||
|
group_by_base = ufs == "same_base"
|
||||||
|
|
||||||
|
dedup_map = {} # (modelId [,base_model]) -> (item, version_id)
|
||||||
|
version_counter = {} # same-key -> count
|
||||||
|
standalone = []
|
||||||
|
for item in sorted_data:
|
||||||
|
mid = self._extract_model_id(item)
|
||||||
|
if mid is None:
|
||||||
|
standalone.append(item)
|
||||||
|
continue
|
||||||
|
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||||
|
# Count all versions per key
|
||||||
|
version_counter[key] = version_counter.get(key, 0) + 1
|
||||||
|
vid = self._extract_version_id(item) or 0
|
||||||
|
if key not in dedup_map or vid > dedup_map[key][1]:
|
||||||
|
dedup_map[key] = (item, vid)
|
||||||
|
# Attach version_count to each surviving grouped item (shallow copy
|
||||||
|
# to avoid mutating cached dicts — the cache is shared across requests)
|
||||||
|
for key, (item, vid) in dedup_map.items():
|
||||||
|
item = dict(item)
|
||||||
|
item["version_count"] = version_counter[key]
|
||||||
|
dedup_map[key] = (item, vid)
|
||||||
|
dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone))
|
||||||
|
sorted_data = [entry[0] for entry in dedup_map.values()] + standalone
|
||||||
|
|
||||||
|
# Re-sort by version_count (grouped: after dedup; non-grouped: group internally, sort, expand)
|
||||||
|
if sort_params.key == "versions_count" and civitai_model_id is None:
|
||||||
|
reverse = sort_params.order == "desc"
|
||||||
|
if kwargs.get("group_by_model"):
|
||||||
|
# Grouped mode: items are already dedup'd with version_count attached
|
||||||
|
sorted_data.sort(
|
||||||
|
key=lambda x: (
|
||||||
|
x.get("version_count", 0),
|
||||||
|
(x.get("model_name") or x.get("file_name") or "").lower(),
|
||||||
|
x.get("file_path", "").lower(),
|
||||||
|
),
|
||||||
|
reverse=reverse,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Non-grouped mode: group internally, sort groups by count, expand
|
||||||
|
# Respect the version_grouping setting (same logic as grouped dedup)
|
||||||
|
ufs = self.settings.get("version_grouping", "same_base")
|
||||||
|
group_by_base = ufs == "same_base"
|
||||||
|
|
||||||
|
model_groups: Dict[Any, List[Dict]] = {}
|
||||||
|
ungrouped_standalone: List[Dict] = []
|
||||||
|
for item in sorted_data:
|
||||||
|
mid = self._extract_model_id(item)
|
||||||
|
if mid is None:
|
||||||
|
ungrouped_standalone.append(item)
|
||||||
|
continue
|
||||||
|
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||||
|
model_groups.setdefault(key, []).append(item)
|
||||||
|
# Sort versions within each group by version id descending
|
||||||
|
for items in model_groups.values():
|
||||||
|
items.sort(
|
||||||
|
key=lambda x: self._extract_version_id(x) or 0,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
# Sort groups by version count
|
||||||
|
sorted_groups = sorted(
|
||||||
|
model_groups.values(),
|
||||||
|
key=lambda items: len(items),
|
||||||
|
reverse=reverse,
|
||||||
|
)
|
||||||
|
# Flatten: grouped items first, standalone items last
|
||||||
|
sorted_data = []
|
||||||
|
for items in sorted_groups:
|
||||||
|
sorted_data.extend(items)
|
||||||
|
sorted_data.extend(ungrouped_standalone)
|
||||||
|
|
||||||
t1 = time.perf_counter()
|
t1 = time.perf_counter()
|
||||||
if hash_filters:
|
if hash_filters:
|
||||||
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
||||||
@@ -172,7 +266,7 @@ class BaseModelService(ABC):
|
|||||||
overall_duration = time.perf_counter() - overall_start
|
overall_duration = time.perf_counter() - overall_start
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
|
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
|
||||||
"Counts: initial=%d, post_filter=%d, final=%d",
|
"Counts: initial=%d, dedup=%d, post_filter=%d, final=%d",
|
||||||
self.__class__.__name__,
|
self.__class__.__name__,
|
||||||
overall_duration,
|
overall_duration,
|
||||||
fetch_duration,
|
fetch_duration,
|
||||||
@@ -181,6 +275,7 @@ class BaseModelService(ABC):
|
|||||||
pagination_duration,
|
pagination_duration,
|
||||||
annotate_duration,
|
annotate_duration,
|
||||||
initial_count,
|
initial_count,
|
||||||
|
dedup_lost,
|
||||||
post_filter_count,
|
post_filter_count,
|
||||||
final_count,
|
final_count,
|
||||||
)
|
)
|
||||||
@@ -495,7 +590,7 @@ class BaseModelService(ABC):
|
|||||||
if not ordered_ids:
|
if not ordered_ids:
|
||||||
return annotated
|
return annotated
|
||||||
|
|
||||||
strategy_value = self.settings.get("update_flag_strategy")
|
strategy_value = self.settings.get("version_grouping")
|
||||||
if isinstance(strategy_value, str) and strategy_value.strip():
|
if isinstance(strategy_value, str) and strategy_value.strip():
|
||||||
strategy = strategy_value.strip().lower()
|
strategy = strategy_value.strip().lower()
|
||||||
else:
|
else:
|
||||||
@@ -696,8 +791,12 @@ class BaseModelService(ABC):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def format_response(self, model_data: Dict) -> Dict:
|
async def format_response(self, model_data: Dict) -> Optional[Dict]:
|
||||||
"""Format model data for API response - must be implemented by subclasses"""
|
"""Format model data for API response - must be implemented by subclasses.
|
||||||
|
|
||||||
|
Subclasses should return None for corrupted entries so the handler
|
||||||
|
layer can filter them out. See issue #730.
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Common service methods that delegate to scanner
|
# Common service methods that delegate to scanner
|
||||||
|
|||||||
@@ -523,6 +523,10 @@ class BatchImportService:
|
|||||||
if payload.get("checkpoint"):
|
if payload.get("checkpoint"):
|
||||||
metadata["checkpoint"] = payload["checkpoint"]
|
metadata["checkpoint"] = payload["checkpoint"]
|
||||||
|
|
||||||
|
nsfw = payload.get("preview_nsfw_level")
|
||||||
|
if isinstance(nsfw, int) and nsfw > 0:
|
||||||
|
metadata["preview_nsfw_level"] = nsfw
|
||||||
|
|
||||||
image_bytes = None
|
image_bytes = None
|
||||||
image_base64 = payload.get("image_base64")
|
image_base64 = payload.get("image_base64")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from .auto_tag_service import extract_auto_tags
|
from .auto_tag_service import extract_auto_tags
|
||||||
@@ -21,20 +21,37 @@ class CheckpointService(BaseModelService):
|
|||||||
"""
|
"""
|
||||||
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, checkpoint_data: Dict) -> Dict:
|
async def format_response(self, checkpoint_data: Dict) -> Optional[Dict]:
|
||||||
"""Format Checkpoint data for API response"""
|
"""Format Checkpoint data for API response.
|
||||||
|
|
||||||
|
Returns None when the entry is missing critical fields (corrupted cache
|
||||||
|
row), so the handler layer can filter it out. See issue #730.
|
||||||
|
"""
|
||||||
|
# Guard against corrupted cache entries missing critical fields
|
||||||
|
file_path = checkpoint_data.get("file_path")
|
||||||
|
if not file_path or not isinstance(file_path, str):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping corrupted checkpoint entry (missing file_path): %s",
|
||||||
|
checkpoint_data.get("file_name", "<unknown>"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
# Get sub_type from cache entry (new canonical field)
|
# Get sub_type from cache entry (new canonical field)
|
||||||
sub_type = checkpoint_data.get("sub_type", "checkpoint")
|
sub_type = checkpoint_data.get("sub_type", "checkpoint")
|
||||||
|
|
||||||
|
file_name = checkpoint_data.get("file_name") or ""
|
||||||
|
model_name = checkpoint_data.get("model_name") or file_name
|
||||||
|
folder = checkpoint_data.get("folder") or ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"model_name": checkpoint_data["model_name"],
|
"model_name": model_name,
|
||||||
"file_name": checkpoint_data["file_name"],
|
"file_name": file_name,
|
||||||
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")),
|
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")),
|
||||||
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0),
|
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0),
|
||||||
"base_model": checkpoint_data.get("base_model", ""),
|
"base_model": checkpoint_data.get("base_model", ""),
|
||||||
"folder": checkpoint_data["folder"],
|
"folder": folder,
|
||||||
"sha256": checkpoint_data.get("sha256", ""),
|
"sha256": checkpoint_data.get("sha256", ""),
|
||||||
"file_path": checkpoint_data["file_path"].replace(os.sep, "/"),
|
"file_path": file_path.replace(os.sep, "/"),
|
||||||
"file_size": checkpoint_data.get("size", 0),
|
"file_size": checkpoint_data.get("size", 0),
|
||||||
"modified": checkpoint_data.get("modified", ""),
|
"modified": checkpoint_data.get("modified", ""),
|
||||||
"tags": checkpoint_data.get("tags", []),
|
"tags": checkpoint_data.get("tags", []),
|
||||||
@@ -48,6 +65,8 @@ class CheckpointService(BaseModelService):
|
|||||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||||
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||||
|
"version_count": checkpoint_data.get("version_count"),
|
||||||
|
"hf_url": checkpoint_data.get("hf_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ class CivArchiveClient:
|
|||||||
if resolved:
|
if resolved:
|
||||||
return resolved, None
|
return resolved, None
|
||||||
|
|
||||||
logger.error("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
logger.debug("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
||||||
return None, "No version data found"
|
return None, "No version data found"
|
||||||
|
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
@@ -417,7 +417,7 @@ class CivArchiveClient:
|
|||||||
|
|
||||||
if version_id is not None:
|
if version_id is not None:
|
||||||
raw_id = version_data.get("id")
|
raw_id = version_data.get("id")
|
||||||
if raw_id != version_id:
|
if raw_id is not None and str(raw_id) != str(version_id):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Requested version %s doesn't match default version %s for model %s",
|
"Requested version %s doesn't match default version %s for model %s",
|
||||||
version_id,
|
version_id,
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ class CivitaiBaseModelService:
|
|||||||
"ernie": "ERNI",
|
"ernie": "ERNI",
|
||||||
"ernie turbo": "ETRB",
|
"ernie turbo": "ETRB",
|
||||||
"nucleus": "NUCL",
|
"nucleus": "NUCL",
|
||||||
|
"krea 2": "KR2",
|
||||||
"svd": "SVD",
|
"svd": "SVD",
|
||||||
"ltxv": "LTXV",
|
"ltxv": "LTXV",
|
||||||
"ltxv2": "LTV2",
|
"ltxv2": "LTV2",
|
||||||
@@ -424,6 +425,7 @@ class CivitaiBaseModelService:
|
|||||||
"Ernie",
|
"Ernie",
|
||||||
"Ernie Turbo",
|
"Ernie Turbo",
|
||||||
"Nucleus",
|
"Nucleus",
|
||||||
|
"Krea 2",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class CivitaiClient:
|
|||||||
self._MAX_CACHE_ENTRIES = 500
|
self._MAX_CACHE_ENTRIES = 500
|
||||||
|
|
||||||
def _build_image_info_url(self, image_id: str) -> str:
|
def _build_image_info_url(self, image_id: str) -> str:
|
||||||
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
return f"{self.base_url}/images?imageId={image_id}&nsfw=X&withMeta=true"
|
||||||
|
|
||||||
async def _make_request(
|
async def _make_request(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from .metadata_service import get_default_metadata_provider, get_metadata_provid
|
|||||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||||
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
||||||
from .aria2_transfer_state import Aria2TransferStateStore
|
from .aria2_transfer_state import Aria2TransferStateStore
|
||||||
|
from .download_queue_service import DownloadQueueService
|
||||||
|
|
||||||
# Download to temporary file first
|
# Download to temporary file first
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -360,6 +361,15 @@ class DownloadManager:
|
|||||||
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
||||||
await self._persist_aria2_state(task_id)
|
await self._persist_aria2_state(task_id)
|
||||||
|
|
||||||
|
# Update SQLite queue status to 'downloading'
|
||||||
|
try:
|
||||||
|
queue_service = await DownloadQueueService.get_instance()
|
||||||
|
await queue_service.update_status(task_id, "downloading")
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to update queue status for %s", task_id, exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
# Use original download implementation
|
# Use original download implementation
|
||||||
try:
|
try:
|
||||||
# Check for cancellation before starting
|
# Check for cancellation before starting
|
||||||
@@ -396,6 +406,22 @@ class DownloadManager:
|
|||||||
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
||||||
await self._persist_aria2_state(task_id)
|
await self._persist_aria2_state(task_id)
|
||||||
|
|
||||||
|
# Move queue item to history on completion
|
||||||
|
try:
|
||||||
|
queue_service = await DownloadQueueService.get_instance()
|
||||||
|
await queue_service.complete_download(
|
||||||
|
download_id=task_id,
|
||||||
|
status=result.get("status", "completed") if result.get("success") else "failed",
|
||||||
|
error=result.get("error") if not result.get("success") else None,
|
||||||
|
file_path=result.get("file_path"),
|
||||||
|
bytes_downloaded=self._active_downloads.get(task_id, {}).get("bytes_downloaded", 0),
|
||||||
|
total_bytes=self._active_downloads.get(task_id, {}).get("total_bytes"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to complete queue item for %s", task_id, exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# Handle cancellation
|
# Handle cancellation
|
||||||
@@ -404,6 +430,19 @@ class DownloadManager:
|
|||||||
self._active_downloads[task_id]["bytes_per_second"] = 0.0
|
self._active_downloads[task_id]["bytes_per_second"] = 0.0
|
||||||
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
||||||
await self._persist_aria2_state(task_id)
|
await self._persist_aria2_state(task_id)
|
||||||
|
|
||||||
|
# Move queue item to history as canceled
|
||||||
|
try:
|
||||||
|
queue_service = await DownloadQueueService.get_instance()
|
||||||
|
await queue_service.complete_download(
|
||||||
|
download_id=task_id,
|
||||||
|
status="canceled",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to cancel queue item for %s", task_id, exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Download cancelled for task {task_id}")
|
logger.info(f"Download cancelled for task {task_id}")
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -417,6 +456,22 @@ class DownloadManager:
|
|||||||
self._active_downloads[task_id]["bytes_per_second"] = 0.0
|
self._active_downloads[task_id]["bytes_per_second"] = 0.0
|
||||||
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
||||||
await self._persist_aria2_state(task_id)
|
await self._persist_aria2_state(task_id)
|
||||||
|
|
||||||
|
# Move queue item to history as failed
|
||||||
|
try:
|
||||||
|
queue_service = await DownloadQueueService.get_instance()
|
||||||
|
await queue_service.complete_download(
|
||||||
|
download_id=task_id,
|
||||||
|
status="failed",
|
||||||
|
error=str(e),
|
||||||
|
bytes_downloaded=self._active_downloads.get(task_id, {}).get("bytes_downloaded", 0),
|
||||||
|
total_bytes=self._active_downloads.get(task_id, {}).get("total_bytes"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to complete queue item for %s", task_id, exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
finally:
|
finally:
|
||||||
# Schedule cleanup of download record after delay
|
# Schedule cleanup of download record after delay
|
||||||
@@ -1233,10 +1288,24 @@ class DownloadManager:
|
|||||||
"download_id": download_id,
|
"download_id": download_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if this checkpoint should be treated as a diffusion model based on baseModel
|
# Check if this checkpoint should be treated as a diffusion model
|
||||||
|
# Priority: (1) any file has type "UNet" or "Diffusion Model",
|
||||||
|
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
|
||||||
is_diffusion_model = False
|
is_diffusion_model = False
|
||||||
if model_type == "checkpoint":
|
if model_type == "checkpoint":
|
||||||
if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
|
# Check file types first (more direct signal from CivitAI)
|
||||||
|
version_files = version_info.get("files", [])
|
||||||
|
for f in version_files:
|
||||||
|
f_type = f.get("type", "")
|
||||||
|
if f_type in ("UNet", "Diffusion Model"):
|
||||||
|
is_diffusion_model = True
|
||||||
|
logger.info(
|
||||||
|
f"File type '{f_type}' detected, routing checkpoint to unet folder"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback to baseModel name check
|
||||||
|
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
|
||||||
is_diffusion_model = True
|
is_diffusion_model = True
|
||||||
logger.info(
|
logger.info(
|
||||||
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
||||||
@@ -1365,7 +1434,7 @@ class DownloadManager:
|
|||||||
f
|
f
|
||||||
for f in files
|
for f in files
|
||||||
if f.get("primary")
|
if f.get("primary")
|
||||||
and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1396,7 +1465,7 @@ class DownloadManager:
|
|||||||
(
|
(
|
||||||
f
|
f
|
||||||
for f in files
|
for f in files
|
||||||
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1974,7 +2043,21 @@ class DownloadManager:
|
|||||||
break
|
break
|
||||||
|
|
||||||
last_error = result
|
last_error = result
|
||||||
if os.path.exists(save_path):
|
# For aria2: if the .aria2 control file is missing, aria2 considers
|
||||||
|
# the download complete. A transient RPC failure may have made us
|
||||||
|
# think the download failed even though the file is fully on disk.
|
||||||
|
# Keep the file so a retry can find it already complete.
|
||||||
|
if (
|
||||||
|
transfer_backend == "aria2"
|
||||||
|
and os.path.exists(save_path)
|
||||||
|
and not os.path.exists(f"{save_path}.aria2")
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"aria2 download reported failure but .aria2 file is absent "
|
||||||
|
"for %s — the file is likely complete. Preserving it for retry.",
|
||||||
|
save_path,
|
||||||
|
)
|
||||||
|
elif os.path.exists(save_path):
|
||||||
try:
|
try:
|
||||||
os.remove(save_path)
|
os.remove(save_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from .auto_tag_service import extract_auto_tags
|
from .auto_tag_service import extract_auto_tags
|
||||||
@@ -21,20 +21,37 @@ class EmbeddingService(BaseModelService):
|
|||||||
"""
|
"""
|
||||||
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, embedding_data: Dict) -> Dict:
|
async def format_response(self, embedding_data: Dict) -> Optional[Dict]:
|
||||||
"""Format Embedding data for API response"""
|
"""Format Embedding data for API response.
|
||||||
|
|
||||||
|
Returns None when the entry is missing critical fields (corrupted cache
|
||||||
|
row), so the handler layer can filter it out. See issue #730.
|
||||||
|
"""
|
||||||
|
# Guard against corrupted cache entries missing critical fields
|
||||||
|
file_path = embedding_data.get("file_path")
|
||||||
|
if not file_path or not isinstance(file_path, str):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping corrupted embedding entry (missing file_path): %s",
|
||||||
|
embedding_data.get("file_name", "<unknown>"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
# Get sub_type from cache entry (new canonical field)
|
# Get sub_type from cache entry (new canonical field)
|
||||||
sub_type = embedding_data.get("sub_type", "embedding")
|
sub_type = embedding_data.get("sub_type", "embedding")
|
||||||
|
|
||||||
|
file_name = embedding_data.get("file_name") or ""
|
||||||
|
model_name = embedding_data.get("model_name") or file_name
|
||||||
|
folder = embedding_data.get("folder") or ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"model_name": embedding_data["model_name"],
|
"model_name": model_name,
|
||||||
"file_name": embedding_data["file_name"],
|
"file_name": file_name,
|
||||||
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")),
|
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")),
|
||||||
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0),
|
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0),
|
||||||
"base_model": embedding_data.get("base_model", ""),
|
"base_model": embedding_data.get("base_model", ""),
|
||||||
"folder": embedding_data["folder"],
|
"folder": folder,
|
||||||
"sha256": embedding_data.get("sha256", ""),
|
"sha256": embedding_data.get("sha256", ""),
|
||||||
"file_path": embedding_data["file_path"].replace(os.sep, "/"),
|
"file_path": file_path.replace(os.sep, "/"),
|
||||||
"file_size": embedding_data.get("size", 0),
|
"file_size": embedding_data.get("size", 0),
|
||||||
"modified": embedding_data.get("modified", ""),
|
"modified": embedding_data.get("modified", ""),
|
||||||
"tags": embedding_data.get("tags", []),
|
"tags": embedding_data.get("tags", []),
|
||||||
@@ -48,6 +65,8 @@ class EmbeddingService(BaseModelService):
|
|||||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||||
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||||
|
"version_count": embedding_data.get("version_count"),
|
||||||
|
"hf_url": embedding_data.get("hf_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -24,23 +24,41 @@ class LoraService(BaseModelService):
|
|||||||
"""
|
"""
|
||||||
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, lora_data: Dict) -> Dict:
|
async def format_response(self, lora_data: Dict) -> Optional[Dict]:
|
||||||
"""Format LoRA data for API response"""
|
"""Format LoRA data for API response.
|
||||||
|
|
||||||
|
Returns None when the entry is missing critical fields (corrupted cache
|
||||||
|
row), so the handler layer can filter it out instead of crashing the
|
||||||
|
whole listing request. See issue #730.
|
||||||
|
"""
|
||||||
|
# Guard against corrupted cache entries missing critical fields
|
||||||
|
file_path = lora_data.get("file_path")
|
||||||
|
if not file_path or not isinstance(file_path, str):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping corrupted LoRA entry (missing file_path): %s",
|
||||||
|
lora_data.get("file_name", "<unknown>"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
||||||
# Normalize to lowercase for consistent API responses
|
# Normalize to lowercase for consistent API responses
|
||||||
sub_type = resolve_sub_type(lora_data).lower()
|
sub_type = resolve_sub_type(lora_data).lower()
|
||||||
|
|
||||||
|
file_name = lora_data.get("file_name") or ""
|
||||||
|
model_name = lora_data.get("model_name") or file_name
|
||||||
|
folder = lora_data.get("folder") or ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"model_name": lora_data["model_name"],
|
"model_name": model_name,
|
||||||
"file_name": lora_data["file_name"],
|
"file_name": file_name,
|
||||||
"preview_url": config.get_preview_static_url(
|
"preview_url": config.get_preview_static_url(
|
||||||
lora_data.get("preview_url", "")
|
lora_data.get("preview_url", "")
|
||||||
),
|
),
|
||||||
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
|
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
|
||||||
"base_model": lora_data.get("base_model", ""),
|
"base_model": lora_data.get("base_model", ""),
|
||||||
"folder": lora_data["folder"],
|
"folder": folder,
|
||||||
"sha256": lora_data.get("sha256", ""),
|
"sha256": lora_data.get("sha256", ""),
|
||||||
"file_path": lora_data["file_path"].replace(os.sep, "/"),
|
"file_path": file_path.replace(os.sep, "/"),
|
||||||
"file_size": lora_data.get("size", 0),
|
"file_size": lora_data.get("size", 0),
|
||||||
"modified": lora_data.get("modified", ""),
|
"modified": lora_data.get("modified", ""),
|
||||||
"tags": lora_data.get("tags", []),
|
"tags": lora_data.get("tags", []),
|
||||||
@@ -59,6 +77,8 @@ class LoraService(BaseModelService):
|
|||||||
lora_data.get("civitai", {}), minimal=True
|
lora_data.get("civitai", {}), minimal=True
|
||||||
),
|
),
|
||||||
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||||
|
"version_count": lora_data.get("version_count"),
|
||||||
|
"hf_url": lora_data.get("hf_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
|
|||||||
@@ -216,13 +216,19 @@ class MetadataSyncService:
|
|||||||
provider_used: Optional[str] = None
|
provider_used: Optional[str] = None
|
||||||
last_error: Optional[str] = None
|
last_error: Optional[str] = None
|
||||||
civitai_api_not_found = False
|
civitai_api_not_found = False
|
||||||
|
any_rate_limited = False
|
||||||
|
|
||||||
for provider_name, provider in provider_attempts:
|
for provider_name, provider in provider_attempts:
|
||||||
try:
|
try:
|
||||||
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
|
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
exc.provider = exc.provider or (provider_name or provider.__class__.__name__)
|
logger.warning(
|
||||||
raise
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
|
provider_name or provider.__class__.__name__,
|
||||||
|
exc.retry_after or 0,
|
||||||
|
)
|
||||||
|
any_rate_limited = True
|
||||||
|
continue
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
|
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
|
||||||
civitai_metadata_candidate, error = None, str(exc)
|
civitai_metadata_candidate, error = None, str(exc)
|
||||||
@@ -258,6 +264,14 @@ class MetadataSyncService:
|
|||||||
model_data["last_checked_at"] = datetime.now().timestamp()
|
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||||
needs_save = True
|
needs_save = True
|
||||||
|
|
||||||
|
# When the model was already classified as "not on CivitAI" via
|
||||||
|
# .metadata.json (civitai_deleted=True) but the SQLite cache is
|
||||||
|
# stale (because the pre-fix code never persisted these flags),
|
||||||
|
# ensure the flags are written to the scanner cache + SQLite.
|
||||||
|
if not needs_save and model_data.get("civitai_deleted") is True:
|
||||||
|
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||||
|
needs_save = True
|
||||||
|
|
||||||
# Save metadata if any state was updated
|
# Save metadata if any state was updated
|
||||||
if needs_save:
|
if needs_save:
|
||||||
data_to_save = model_data.copy()
|
data_to_save = model_data.copy()
|
||||||
@@ -266,6 +280,7 @@ class MetadataSyncService:
|
|||||||
if "last_checked_at" not in data_to_save:
|
if "last_checked_at" not in data_to_save:
|
||||||
data_to_save["last_checked_at"] = datetime.now().timestamp()
|
data_to_save["last_checked_at"] = datetime.now().timestamp()
|
||||||
await self._metadata_manager.save_metadata(file_path, data_to_save)
|
await self._metadata_manager.save_metadata(file_path, data_to_save)
|
||||||
|
await update_cache_func(file_path, file_path, data_to_save)
|
||||||
|
|
||||||
default_error = (
|
default_error = (
|
||||||
"CivitAI model is deleted and metadata archive DB is not enabled"
|
"CivitAI model is deleted and metadata archive DB is not enabled"
|
||||||
@@ -276,17 +291,18 @@ class MetadataSyncService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
resolved_error = last_error or default_error
|
resolved_error = last_error or default_error
|
||||||
|
if any_rate_limited and "Rate limited" not in resolved_error:
|
||||||
|
resolved_error = "Rate limited"
|
||||||
if is_expected_offline_error(resolved_error):
|
if is_expected_offline_error(resolved_error):
|
||||||
resolved_error = OFFLINE_FRIENDLY_MESSAGE
|
resolved_error = OFFLINE_FRIENDLY_MESSAGE
|
||||||
|
|
||||||
error_msg = (
|
error_msg = (
|
||||||
f"Error fetching metadata: {resolved_error} "
|
f"Error fetching metadata: {resolved_error} "
|
||||||
f"(model_name={model_data.get('model_name', '')})"
|
f"(file={os.path.basename(file_path)}, sha256={sha256})"
|
||||||
)
|
)
|
||||||
if is_expected_offline_error(resolved_error):
|
# Use case layer (BulkMetadataRefreshUseCase) logs failed models at WARNING level,
|
||||||
logger.info(error_msg)
|
# so this level is demoted to DEBUG to avoid duplicate user-visible logging.
|
||||||
else:
|
logger.debug(error_msg)
|
||||||
logger.error(error_msg)
|
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
|
||||||
model_data["from_civitai"] = True
|
model_data["from_civitai"] = True
|
||||||
@@ -411,7 +427,18 @@ class MetadataSyncService:
|
|||||||
metadata = await metadata_loader(metadata_path)
|
metadata = await metadata_loader(metadata_path)
|
||||||
|
|
||||||
for key, value in updates.items():
|
for key, value in updates.items():
|
||||||
if isinstance(value, dict) and isinstance(metadata.get(key), dict):
|
if key == "tags" and isinstance(value, list):
|
||||||
|
# Normalize tags: trim, lowercase, deduplicate
|
||||||
|
normalized = []
|
||||||
|
seen = set()
|
||||||
|
for tag in value:
|
||||||
|
if isinstance(tag, str):
|
||||||
|
t = tag.strip().lower()
|
||||||
|
if t and t not in seen:
|
||||||
|
normalized.append(t)
|
||||||
|
seen.add(t)
|
||||||
|
metadata[key] = normalized
|
||||||
|
elif isinstance(value, dict) and isinstance(metadata.get(key), dict):
|
||||||
metadata[key].update(value)
|
metadata[key].update(value)
|
||||||
else:
|
else:
|
||||||
metadata[key] = value
|
metadata[key] = value
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ SUPPORTED_SORT_MODES = [
|
|||||||
('size', 'desc'),
|
('size', 'desc'),
|
||||||
('usage', 'asc'),
|
('usage', 'asc'),
|
||||||
('usage', 'desc'),
|
('usage', 'desc'),
|
||||||
|
('versions_count', 'asc'),
|
||||||
|
('versions_count', 'desc'),
|
||||||
]
|
]
|
||||||
# Is this in use?
|
# Is this in use?
|
||||||
|
|
||||||
@@ -263,6 +265,17 @@ class ModelCache:
|
|||||||
),
|
),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
|
elif sort_key == 'versions_count':
|
||||||
|
# Pre-dedup sort: fall back to name sort.
|
||||||
|
# Actual re-sort by version_count happens in get_paginated_data after dedup.
|
||||||
|
result = natsorted(
|
||||||
|
data,
|
||||||
|
key=lambda x: (
|
||||||
|
self._get_display_name(x).lower(),
|
||||||
|
x.get('file_path', '').lower()
|
||||||
|
),
|
||||||
|
reverse=reverse
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback: no sort
|
# Fallback: no sort
|
||||||
result = list(data)
|
result = list(data)
|
||||||
|
|||||||
@@ -65,7 +65,14 @@ class _RateLimitRetryHelper:
|
|||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
attempt += 1
|
attempt += 1
|
||||||
if attempt >= self._retry_limit:
|
|
||||||
|
# Determine effective retry limit based on rate-limit magnitude
|
||||||
|
effective_retry_limit = self._retry_limit # default: 3
|
||||||
|
if exc.retry_after is not None and exc.retry_after >= 120.0:
|
||||||
|
# Long rate-limit window (>=2 min) — retries are futile
|
||||||
|
effective_retry_limit = 1 # total 1 attempt = 0 retries
|
||||||
|
|
||||||
|
if attempt >= effective_retry_limit:
|
||||||
exc.provider = exc.provider or label
|
exc.provider = exc.provider or label
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -478,8 +485,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result:
|
if result:
|
||||||
return result, error
|
return result, error
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
exc.provider = exc.provider or label
|
logger.warning(
|
||||||
raise exc
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
|
label,
|
||||||
|
exc.retry_after or 0,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
|
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
|
||||||
continue
|
continue
|
||||||
@@ -497,16 +508,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
if not_found_confirmed:
|
logger.warning(
|
||||||
logger.debug(
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
"Suppressing rate limit from %s for model %s: "
|
label,
|
||||||
"already confirmed as not found by another provider",
|
exc.retry_after or 0,
|
||||||
label,
|
)
|
||||||
model_id,
|
continue
|
||||||
)
|
|
||||||
return None
|
|
||||||
exc.provider = exc.provider or label
|
|
||||||
raise exc
|
|
||||||
except ResourceNotFoundError:
|
except ResourceNotFoundError:
|
||||||
not_found_confirmed = True
|
not_found_confirmed = True
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -532,8 +539,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
exc.provider = exc.provider or label
|
logger.warning(
|
||||||
raise exc
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
|
label,
|
||||||
|
exc.retry_after or 0,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Provider %s failed for get_model_version: %s", label, e)
|
logger.debug("Provider %s failed for get_model_version: %s", label, e)
|
||||||
continue
|
continue
|
||||||
@@ -550,8 +561,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result:
|
if result:
|
||||||
return result, error
|
return result, error
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
exc.provider = exc.provider or label
|
logger.warning(
|
||||||
raise exc
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
|
label,
|
||||||
|
exc.retry_after or 0,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
|
logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
|
||||||
continue
|
continue
|
||||||
@@ -572,8 +587,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
continue
|
continue
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
exc.provider = exc.provider or label
|
logger.warning(
|
||||||
raise exc
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
|
label,
|
||||||
|
exc.retry_after or 0,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Provider %s failed for get_model_versions_by_hashes: %s",
|
"Provider %s failed for get_model_versions_by_hashes: %s",
|
||||||
@@ -594,8 +613,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
exc.provider = exc.provider or label
|
logger.warning(
|
||||||
raise exc
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
|
label,
|
||||||
|
exc.retry_after or 0,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Provider %s failed for get_user_models: %s", label, e)
|
logger.debug("Provider %s failed for get_user_models: %s", label, e)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -294,12 +294,14 @@ class ModelFilterSet:
|
|||||||
for tag, state in tag_filters.items():
|
for tag, state in tag_filters.items():
|
||||||
if not tag:
|
if not tag:
|
||||||
continue
|
continue
|
||||||
|
# Normalize to lowercase for case-insensitive matching
|
||||||
|
normalized = tag.strip().lower()
|
||||||
if state == "exclude":
|
if state == "exclude":
|
||||||
exclude_tags.add(tag)
|
exclude_tags.add(normalized)
|
||||||
else:
|
else:
|
||||||
include_tags.add(tag)
|
include_tags.add(normalized)
|
||||||
else:
|
else:
|
||||||
include_tags = {tag for tag in tag_filters if tag}
|
include_tags = {tag.strip().lower() for tag in tag_filters if tag}
|
||||||
|
|
||||||
if include_tags:
|
if include_tags:
|
||||||
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
|
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
|
||||||
@@ -318,13 +320,17 @@ class ModelFilterSet:
|
|||||||
return True
|
return True
|
||||||
# Otherwise, check if all non-special tags match
|
# Otherwise, check if all non-special tags match
|
||||||
if non_special_tags:
|
if non_special_tags:
|
||||||
return all(tag in (item_tags or []) for tag in non_special_tags)
|
# Case-insensitive: normalize item tags too
|
||||||
|
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||||
|
return all(tag in normalized_item_tags for tag in non_special_tags)
|
||||||
return True
|
return True
|
||||||
# Normal case: all tags must match
|
# Normal case: all tags must match (case-insensitive)
|
||||||
return all(tag in (item_tags or []) for tag in non_special_tags)
|
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||||
|
return all(tag in normalized_item_tags for tag in non_special_tags)
|
||||||
else:
|
else:
|
||||||
# OR logic (default): item must have ANY include tag
|
# OR logic (default): item must have ANY include tag (case-insensitive)
|
||||||
return any(tag in include_tags for tag in (item_tags or []))
|
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||||
|
return bool(normalized_item_tags & include_tags)
|
||||||
|
|
||||||
items = [item for item in items if matches_include(item.get("tags"))]
|
items = [item for item in items if matches_include(item.get("tags"))]
|
||||||
|
|
||||||
@@ -333,7 +339,9 @@ class ModelFilterSet:
|
|||||||
def matches_exclude(item_tags):
|
def matches_exclude(item_tags):
|
||||||
if not item_tags and "__no_tags__" in exclude_tags:
|
if not item_tags and "__no_tags__" in exclude_tags:
|
||||||
return True
|
return True
|
||||||
return any(tag in exclude_tags for tag in (item_tags or []))
|
# Case-insensitive: normalize item tags
|
||||||
|
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||||
|
return bool(normalized_item_tags & exclude_tags)
|
||||||
|
|
||||||
items = [
|
items = [
|
||||||
item for item in items if not matches_exclude(item.get("tags"))
|
item for item in items if not matches_exclude(item.get("tags"))
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ class ModelScanner:
|
|||||||
'civitai': civitai_slim,
|
'civitai': civitai_slim,
|
||||||
'civitai_deleted': bool(get_value('civitai_deleted', False)),
|
'civitai_deleted': bool(get_value('civitai_deleted', False)),
|
||||||
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
|
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
|
||||||
|
'hf_url': get_value('hf_url', '') or '',
|
||||||
}
|
}
|
||||||
|
|
||||||
license_source: Dict[str, Any] = {}
|
license_source: Dict[str, Any] = {}
|
||||||
@@ -476,11 +477,20 @@ class ModelScanner:
|
|||||||
for tag in adjusted_item.get('tags') or []:
|
for tag in adjusted_item.get('tags') or []:
|
||||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
# Validate cache entries and check health
|
# Validate cache entries and check health.
|
||||||
|
# Always use the validated/repaired entries — even when there are no
|
||||||
|
# invalid entries, auto_repair may have filled in missing optional
|
||||||
|
# fields (model_name, file_name, folder) with safe defaults on a copied
|
||||||
|
# working_entry. Without this unconditional replacement the repaired
|
||||||
|
# copies are discarded and None values propagate to format_response.
|
||||||
|
# See issue #730.
|
||||||
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
|
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
|
||||||
adjusted_raw_data, auto_repair=True
|
adjusted_raw_data, auto_repair=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Always use the validated entries (repaired copies)
|
||||||
|
adjusted_raw_data = valid_entries
|
||||||
|
|
||||||
if invalid_entries:
|
if invalid_entries:
|
||||||
monitor = CacheHealthMonitor()
|
monitor = CacheHealthMonitor()
|
||||||
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
|
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
|
||||||
@@ -532,6 +542,13 @@ class ModelScanner:
|
|||||||
if not scan_result or not getattr(self, '_persistent_cache', None):
|
if not scan_result or not getattr(self, '_persistent_cache', None):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self.is_cancelled():
|
||||||
|
logger.info(
|
||||||
|
f"{self.model_type.capitalize()} Scanner: Skipping _save_persistent_cache "
|
||||||
|
"after cancellation"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index)
|
hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
try:
|
try:
|
||||||
@@ -705,14 +722,20 @@ class ModelScanner:
|
|||||||
# Determine the page type based on model type
|
# Determine the page type based on model type
|
||||||
# Scan for new data
|
# Scan for new data
|
||||||
scan_result = await self._gather_model_data()
|
scan_result = await self._gather_model_data()
|
||||||
await self._apply_scan_result(scan_result)
|
if not self.is_cancelled():
|
||||||
await self._save_persistent_cache(scan_result)
|
await self._apply_scan_result(scan_result)
|
||||||
await self._sync_download_history(scan_result.raw_data, source='scan')
|
await self._save_persistent_cache(scan_result)
|
||||||
|
await self._sync_download_history(scan_result.raw_data, source='scan')
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
||||||
f"found {len(scan_result.raw_data)} models"
|
f"found {len(scan_result.raw_data)} models"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled "
|
||||||
|
f"after {time.time() - start_time:.2f} seconds"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
|
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
|
||||||
# Ensure cache is at least an empty structure on error
|
# Ensure cache is at least an empty structure on error
|
||||||
@@ -1067,8 +1090,11 @@ class ModelScanner:
|
|||||||
|
|
||||||
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
||||||
|
|
||||||
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes)
|
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes).
|
||||||
if not model_data.get('sha256') and file_path:
|
# Respect hash_status='pending' (set by CheckpointScanner for large models) to defer
|
||||||
|
# hash calculation until on-demand — avoids reading entire checkpoint files at startup.
|
||||||
|
hash_status = model_data.get('hash_status', '')
|
||||||
|
if not model_data.get('sha256') and hash_status != 'pending' and file_path:
|
||||||
try:
|
try:
|
||||||
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
|
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
|
||||||
sha256 = await calculate_sha256(file_path)
|
sha256 = await calculate_sha256(file_path)
|
||||||
@@ -1093,6 +1119,13 @@ class ModelScanner:
|
|||||||
if scan_result is None:
|
if scan_result is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self.is_cancelled():
|
||||||
|
logger.info(
|
||||||
|
f"{self.model_type.capitalize()} Scanner: Skipping _apply_scan_result "
|
||||||
|
"after cancellation"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
self._hash_index = scan_result.hash_index
|
self._hash_index = scan_result.hash_index
|
||||||
self._tags_count = dict(scan_result.tags_count)
|
self._tags_count = dict(scan_result.tags_count)
|
||||||
self._excluded_models = list(scan_result.excluded_models)
|
self._excluded_models = list(scan_result.excluded_models)
|
||||||
@@ -1762,6 +1795,13 @@ class ModelScanner:
|
|||||||
if not file_paths or self._cache is None:
|
if not file_paths or self._cache is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if self.is_cancelled():
|
||||||
|
logger.info(
|
||||||
|
f"{self.model_type.capitalize()} Scanner: Skipping cache update "
|
||||||
|
"after cancelled bulk delete"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get all models that need to be removed from cache
|
# Get all models that need to be removed from cache
|
||||||
models_to_remove = [item for item in self._cache.raw_data if item['file_path'] in file_paths]
|
models_to_remove = [item for item in self._cache.raw_data if item['file_path'] in file_paths]
|
||||||
|
|||||||
@@ -724,6 +724,16 @@ class ModelUpdateService:
|
|||||||
"Refreshing update metadata for %d %s models", total_models, model_type
|
"Refreshing update metadata for %d %s models", total_models, model_type
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# When filtering by folder, also collect the cross-folder version set
|
||||||
|
# so that versions already present in other folders are not reported
|
||||||
|
# as available updates. See issue #997.
|
||||||
|
all_local_versions: Optional[Dict[int, List[int]]] = None
|
||||||
|
if folder_path is not None:
|
||||||
|
all_local_versions = await self._collect_local_versions(
|
||||||
|
scanner,
|
||||||
|
target_model_ids=target_filter,
|
||||||
|
)
|
||||||
|
|
||||||
results: Dict[int, ModelUpdateRecord] = {}
|
results: Dict[int, ModelUpdateRecord] = {}
|
||||||
prefetched: Dict[int, Mapping] = {}
|
prefetched: Dict[int, Mapping] = {}
|
||||||
|
|
||||||
@@ -762,6 +772,12 @@ class ModelUpdateService:
|
|||||||
for index, (model_id, version_ids) in enumerate(
|
for index, (model_id, version_ids) in enumerate(
|
||||||
local_versions.items(), start=1
|
local_versions.items(), start=1
|
||||||
):
|
):
|
||||||
|
# Use cross-folder version IDs for is_in_library if available
|
||||||
|
all_vids: Sequence[int] = (
|
||||||
|
all_local_versions.get(model_id, [])
|
||||||
|
if all_local_versions is not None
|
||||||
|
else version_ids
|
||||||
|
)
|
||||||
record = await self._refresh_single_model(
|
record = await self._refresh_single_model(
|
||||||
model_type,
|
model_type,
|
||||||
model_id,
|
model_id,
|
||||||
@@ -769,6 +785,7 @@ class ModelUpdateService:
|
|||||||
metadata_provider,
|
metadata_provider,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
prefetched_response=prefetched.get(model_id),
|
prefetched_response=prefetched.get(model_id),
|
||||||
|
all_local_version_ids=all_vids,
|
||||||
)
|
)
|
||||||
if scanner.is_cancelled():
|
if scanner.is_cancelled():
|
||||||
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
||||||
@@ -964,8 +981,16 @@ class ModelUpdateService:
|
|||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
prefetched_response: Optional[Mapping] = None,
|
prefetched_response: Optional[Mapping] = None,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
) -> Optional[ModelUpdateRecord]:
|
) -> Optional[ModelUpdateRecord]:
|
||||||
normalized_local = self._normalize_sequence(local_versions)
|
normalized_local = self._normalize_sequence(local_versions)
|
||||||
|
# When folder-filtering, this carries the cross-folder version set
|
||||||
|
# for is_in_library; otherwise it falls back to normalized_local.
|
||||||
|
normalized_all = (
|
||||||
|
self._normalize_sequence(all_local_version_ids)
|
||||||
|
if all_local_version_ids is not None
|
||||||
|
else normalized_local
|
||||||
|
)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
existing = self._get_record(model_type, model_id)
|
existing = self._get_record(model_type, model_id)
|
||||||
@@ -973,6 +998,7 @@ class ModelUpdateService:
|
|||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
existing,
|
existing,
|
||||||
normalized_local,
|
normalized_local,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1048,6 +1074,7 @@ class ModelUpdateService:
|
|||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
existing,
|
existing,
|
||||||
normalized_local,
|
normalized_local,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1059,6 +1086,7 @@ class ModelUpdateService:
|
|||||||
model_type=model_type,
|
model_type=model_type,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
last_checked_at=now,
|
last_checked_at=now,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
record = replace(record, should_ignore_model=True)
|
record = replace(record, should_ignore_model=True)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
@@ -1077,6 +1105,7 @@ class ModelUpdateService:
|
|||||||
fetched_versions,
|
fetched_versions,
|
||||||
existing,
|
existing,
|
||||||
now,
|
now,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
@@ -1085,6 +1114,7 @@ class ModelUpdateService:
|
|||||||
model_type=model_type,
|
model_type=model_type,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
last_checked_at=existing.last_checked_at if existing else None,
|
last_checked_at=existing.last_checked_at if existing else None,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1322,12 +1352,20 @@ class ModelUpdateService:
|
|||||||
existing: Optional[ModelUpdateRecord],
|
existing: Optional[ModelUpdateRecord],
|
||||||
normalized_local: Sequence[int],
|
normalized_local: Sequence[int],
|
||||||
*,
|
*,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
model_type: Optional[str] = None,
|
model_type: Optional[str] = None,
|
||||||
model_id: Optional[int] = None,
|
model_id: Optional[int] = None,
|
||||||
last_checked_at: Optional[float] = None,
|
last_checked_at: Optional[float] = None,
|
||||||
version_info: Optional[Mapping] = None,
|
version_info: Optional[Mapping] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
local_set = set(normalized_local)
|
local_set = set(normalized_local)
|
||||||
|
# When folder-filtering, also consider versions in other folders
|
||||||
|
# as in-library so they are not reported as available updates.
|
||||||
|
effective_local_set: set[int] = (
|
||||||
|
local_set | set(all_local_version_ids)
|
||||||
|
if all_local_version_ids is not None
|
||||||
|
else local_set
|
||||||
|
)
|
||||||
versions: List[ModelVersionRecord] = []
|
versions: List[ModelVersionRecord] = []
|
||||||
ignore_map: Dict[int, bool] = {}
|
ignore_map: Dict[int, bool] = {}
|
||||||
if existing:
|
if existing:
|
||||||
@@ -1339,7 +1377,7 @@ class ModelUpdateService:
|
|||||||
versions.append(
|
versions.append(
|
||||||
replace(
|
replace(
|
||||||
version,
|
version,
|
||||||
is_in_library=version.version_id in local_set,
|
is_in_library=version.version_id in effective_local_set,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif model_type is None or model_id is None:
|
elif model_type is None or model_id is None:
|
||||||
@@ -1386,8 +1424,17 @@ class ModelUpdateService:
|
|||||||
remote_versions: Sequence[ModelVersionRecord],
|
remote_versions: Sequence[ModelVersionRecord],
|
||||||
existing: Optional[ModelUpdateRecord],
|
existing: Optional[ModelUpdateRecord],
|
||||||
timestamp: float,
|
timestamp: float,
|
||||||
|
*,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
local_set = set(local_versions)
|
local_set = set(local_versions)
|
||||||
|
# When folder-filtering, also consider versions in other folders
|
||||||
|
# as in-library so they are not reported as available updates.
|
||||||
|
effective_local_set: set[int] = (
|
||||||
|
local_set | set(all_local_version_ids)
|
||||||
|
if all_local_version_ids is not None
|
||||||
|
else local_set
|
||||||
|
)
|
||||||
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
|
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
|
||||||
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
|
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
|
||||||
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
|
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
|
||||||
@@ -1406,7 +1453,7 @@ class ModelUpdateService:
|
|||||||
released_at=remote_version.released_at,
|
released_at=remote_version.released_at,
|
||||||
size_bytes=remote_version.size_bytes,
|
size_bytes=remote_version.size_bytes,
|
||||||
preview_url=remote_version.preview_url or preview_map.get(version_id),
|
preview_url=remote_version.preview_url or preview_map.get(version_id),
|
||||||
is_in_library=version_id in local_set,
|
is_in_library=version_id in effective_local_set,
|
||||||
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
|
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
|
||||||
sort_index=sort_map.get(version_id, index),
|
sort_index=sort_map.get(version_id, index),
|
||||||
early_access_ends_at=remote_version.early_access_ends_at,
|
early_access_ends_at=remote_version.early_access_ends_at,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class PersistentModelCache:
|
|||||||
"db_checked",
|
"db_checked",
|
||||||
"last_checked_at",
|
"last_checked_at",
|
||||||
"hash_status",
|
"hash_status",
|
||||||
|
"hf_url",
|
||||||
)
|
)
|
||||||
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
||||||
_instances: Dict[str, "PersistentModelCache"] = {}
|
_instances: Dict[str, "PersistentModelCache"] = {}
|
||||||
@@ -165,8 +166,8 @@ class PersistentModelCache:
|
|||||||
|
|
||||||
item = {
|
item = {
|
||||||
"file_path": file_path,
|
"file_path": file_path,
|
||||||
"file_name": row["file_name"],
|
"file_name": row["file_name"] or "",
|
||||||
"model_name": row["model_name"],
|
"model_name": row["model_name"] or "",
|
||||||
"folder": row["folder"] or "",
|
"folder": row["folder"] or "",
|
||||||
"size": row["size"] or 0,
|
"size": row["size"] or 0,
|
||||||
"modified": row["modified"] or 0.0,
|
"modified": row["modified"] or 0.0,
|
||||||
@@ -188,6 +189,7 @@ class PersistentModelCache:
|
|||||||
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
||||||
"license_flags": int(license_value),
|
"license_flags": int(license_value),
|
||||||
"hash_status": row["hash_status"] or "completed",
|
"hash_status": row["hash_status"] or "completed",
|
||||||
|
"hf_url": row["hf_url"] or "",
|
||||||
}
|
}
|
||||||
raw_data.append(item)
|
raw_data.append(item)
|
||||||
|
|
||||||
@@ -452,6 +454,7 @@ class PersistentModelCache:
|
|||||||
db_checked INTEGER,
|
db_checked INTEGER,
|
||||||
last_checked_at REAL,
|
last_checked_at REAL,
|
||||||
hash_status TEXT,
|
hash_status TEXT,
|
||||||
|
hf_url TEXT DEFAULT '',
|
||||||
PRIMARY KEY (model_type, file_path)
|
PRIMARY KEY (model_type, file_path)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -500,6 +503,7 @@ class PersistentModelCache:
|
|||||||
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
||||||
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
||||||
"hash_status": "TEXT DEFAULT 'completed'",
|
"hash_status": "TEXT DEFAULT 'completed'",
|
||||||
|
"hf_url": "TEXT DEFAULT ''",
|
||||||
}
|
}
|
||||||
|
|
||||||
for column, definition in required_columns.items():
|
for column, definition in required_columns.items():
|
||||||
@@ -548,19 +552,19 @@ class PersistentModelCache:
|
|||||||
return (
|
return (
|
||||||
model_type,
|
model_type,
|
||||||
item.get("file_path"),
|
item.get("file_path"),
|
||||||
item.get("file_name"),
|
item.get("file_name") or "",
|
||||||
item.get("model_name"),
|
item.get("model_name") or "",
|
||||||
item.get("folder"),
|
item.get("folder") or "",
|
||||||
int(item.get("size") or 0),
|
int(item.get("size") or 0),
|
||||||
float(item.get("modified") or 0.0),
|
float(item.get("modified") or 0.0),
|
||||||
(item.get("sha256") or "").lower() or None,
|
(item.get("sha256") or "").lower() or None,
|
||||||
item.get("base_model"),
|
item.get("base_model") or "",
|
||||||
item.get("preview_url"),
|
item.get("preview_url") or "",
|
||||||
int(item.get("preview_nsfw_level") or 0),
|
int(item.get("preview_nsfw_level") or 0),
|
||||||
1 if item.get("from_civitai", True) else 0,
|
1 if item.get("from_civitai", True) else 0,
|
||||||
1 if item.get("favorite") else 0,
|
1 if item.get("favorite") else 0,
|
||||||
item.get("notes"),
|
item.get("notes") or "",
|
||||||
item.get("usage_tips"),
|
item.get("usage_tips") or "",
|
||||||
metadata_source,
|
metadata_source,
|
||||||
civitai.get("id"),
|
civitai.get("id"),
|
||||||
civitai.get("modelId"),
|
civitai.get("modelId"),
|
||||||
@@ -575,6 +579,7 @@ class PersistentModelCache:
|
|||||||
1 if item.get("db_checked") else 0,
|
1 if item.get("db_checked") else 0,
|
||||||
float(item.get("last_checked_at") or 0.0),
|
float(item.get("last_checked_at") or 0.0),
|
||||||
item.get("hash_status", "completed"),
|
item.get("hash_status", "completed"),
|
||||||
|
item.get("hf_url") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _insert_model_sql(self) -> str:
|
def _insert_model_sql(self) -> str:
|
||||||
|
|||||||
@@ -146,11 +146,38 @@ class RecipeAnalysisService:
|
|||||||
):
|
):
|
||||||
metadata = metadata["meta"]
|
metadata = metadata["meta"]
|
||||||
|
|
||||||
# Include modelVersionIds from root level if available
|
# Include modelVersionIds from root level if available.
|
||||||
# Civitai API returns modelVersionIds at root level, not in meta
|
# CivitAI API returns modelVersionIds at root level, not in meta.
|
||||||
|
# When meta is null (None), create a minimal dict so downstream
|
||||||
|
# parsers can still discover LoRAs and checkpoints.
|
||||||
model_version_ids = image_info.get("modelVersionIds")
|
model_version_ids = image_info.get("modelVersionIds")
|
||||||
if model_version_ids and isinstance(metadata, dict):
|
if model_version_ids:
|
||||||
metadata["modelVersionIds"] = model_version_ids
|
if isinstance(metadata, dict):
|
||||||
|
metadata["modelVersionIds"] = model_version_ids
|
||||||
|
else:
|
||||||
|
metadata = {"modelVersionIds": model_version_ids}
|
||||||
|
|
||||||
|
# Inject browsingLevel (canonical integer) so the recipe's
|
||||||
|
# preview_nsfw_level can be set, enabling proper NSFW blur
|
||||||
|
# of the preview image. Fall back to nsfwLevel (string)
|
||||||
|
# when browsingLevel is absent.
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
browsing_level = image_info.get("browsingLevel")
|
||||||
|
nsfw_level_str = image_info.get("nsfwLevel")
|
||||||
|
if isinstance(browsing_level, int) and browsing_level > 0:
|
||||||
|
metadata["browsingLevel"] = browsing_level
|
||||||
|
elif (
|
||||||
|
isinstance(nsfw_level_str, str)
|
||||||
|
and nsfw_level_str
|
||||||
|
in (
|
||||||
|
"PG", "PG13", "R", "X", "XXX", "Blocked",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
from ...utils.constants import NSFW_LEVELS
|
||||||
|
|
||||||
|
metadata["browsingLevel"] = NSFW_LEVELS.get(
|
||||||
|
nsfw_level_str, 0
|
||||||
|
)
|
||||||
|
|
||||||
# Validate that metadata contains meaningful recipe fields
|
# Validate that metadata contains meaningful recipe fields
|
||||||
# If not, treat as None to trigger EXIF extraction from downloaded image
|
# If not, treat as None to trigger EXIF extraction from downloaded image
|
||||||
@@ -171,12 +198,19 @@ class RecipeAnalysisService:
|
|||||||
temp_path = self._create_temp_path(suffix=extension)
|
temp_path = self._create_temp_path(suffix=extension)
|
||||||
await self._download_image(url, temp_path)
|
await self._download_image(url, temp_path)
|
||||||
|
|
||||||
if metadata is None and not is_video:
|
# Always extract EXIF from the downloaded image for generation
|
||||||
metadata = await asyncio.to_thread(
|
# params (prompt, negative prompt, sampler, steps, etc.).
|
||||||
|
# Previously this was gated on ``metadata is None``, but that
|
||||||
|
# skipped EXIF entirely when API metadata (modelVersionIds,
|
||||||
|
# browsingLevel) is present, losing all generation parameters.
|
||||||
|
exif_metadata = None
|
||||||
|
if not is_video:
|
||||||
|
exif_metadata = await asyncio.to_thread(
|
||||||
self._exif_utils.extract_image_metadata, temp_path
|
self._exif_utils.extract_image_metadata, temp_path
|
||||||
)
|
)
|
||||||
|
|
||||||
if not metadata and civitai_image_id and image_info:
|
# Fallback: try the original (non-optimized) image for EXIF data
|
||||||
|
if not exif_metadata and civitai_image_id and image_info:
|
||||||
original_url = image_info.get("url")
|
original_url = image_info.get("url")
|
||||||
if original_url:
|
if original_url:
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
@@ -187,15 +221,38 @@ class RecipeAnalysisService:
|
|||||||
orig_temp_path = self._create_temp_path(suffix=".png")
|
orig_temp_path = self._create_temp_path(suffix=".png")
|
||||||
try:
|
try:
|
||||||
await self._download_image(original_url, orig_temp_path)
|
await self._download_image(original_url, orig_temp_path)
|
||||||
metadata = await asyncio.to_thread(
|
exif_metadata = await asyncio.to_thread(
|
||||||
self._exif_utils.extract_image_metadata,
|
self._exif_utils.extract_image_metadata,
|
||||||
orig_temp_path,
|
orig_temp_path,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
self._safe_cleanup(orig_temp_path)
|
self._safe_cleanup(orig_temp_path)
|
||||||
|
|
||||||
|
# Parse EXIF data (typically a string like parameters/prompt/workflow)
|
||||||
|
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
|
||||||
|
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
|
||||||
|
# This mirrors the two-pass approach in _do_import_from_url.
|
||||||
|
exif_parsed_result = None
|
||||||
|
if isinstance(exif_metadata, str):
|
||||||
|
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
|
||||||
|
if exif_parser:
|
||||||
|
exif_data = await exif_parser.parse_metadata(
|
||||||
|
exif_metadata, recipe_scanner=recipe_scanner,
|
||||||
|
)
|
||||||
|
if exif_data and not exif_data.get("error"):
|
||||||
|
exif_parsed_result = exif_data
|
||||||
|
|
||||||
|
# Merge API metadata (dict) with EXIF data (if dict) for the
|
||||||
|
# CivitaiApiMetadataParser. If EXIF data is a string it was
|
||||||
|
# parsed above — don't try to merge a string into a dict.
|
||||||
|
merged = {}
|
||||||
|
if isinstance(exif_metadata, dict):
|
||||||
|
merged.update(exif_metadata)
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
merged.update(metadata)
|
||||||
|
|
||||||
result = await self._parse_metadata(
|
result = await self._parse_metadata(
|
||||||
metadata or {},
|
merged,
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
image_path=temp_path,
|
image_path=temp_path,
|
||||||
include_image_base64=True,
|
include_image_base64=True,
|
||||||
@@ -203,13 +260,23 @@ class RecipeAnalysisService:
|
|||||||
extension=extension,
|
extension=extension,
|
||||||
)
|
)
|
||||||
|
|
||||||
if civitai_image_id and image_info and not result.payload.get("error"):
|
# Merge EXIF string-parsed gen_params into the API result.
|
||||||
mvid = image_info.get("modelVersionId")
|
# API gen_params take priority (they come later via update).
|
||||||
if not mvid:
|
if exif_parsed_result and not result.payload.get("error"):
|
||||||
mvids = image_info.get("modelVersionIds")
|
exif_gp = exif_parsed_result.get("gen_params") or {}
|
||||||
if isinstance(mvids, list) and mvids:
|
result_gp = result.payload.get("gen_params") or {}
|
||||||
mvid = mvids[0]
|
merged_gp = {**exif_gp, **result_gp}
|
||||||
|
if merged_gp:
|
||||||
|
result.payload["gen_params"] = merged_gp
|
||||||
|
|
||||||
|
if civitai_image_id and image_info and not result.payload.get("error"):
|
||||||
|
# Use the metadata dict we built (may contain modelVersionIds
|
||||||
|
# and browsingLevel from the API root level). Do NOT pass
|
||||||
|
# image_info.get("meta") — it is null for images whose meta
|
||||||
|
# lives at the root level only. Also do NOT derive
|
||||||
|
# model_version_id from modelVersionIds[0] — that array mixes
|
||||||
|
# checkpoints, LoRAs, and other types without ordering
|
||||||
|
# guarantees; the parser already resolved them correctly.
|
||||||
recipe_for_enrich = {
|
recipe_for_enrich = {
|
||||||
"gen_params": result.payload.get("gen_params", {}),
|
"gen_params": result.payload.get("gen_params", {}),
|
||||||
"loras": result.payload.get("loras", []),
|
"loras": result.payload.get("loras", []),
|
||||||
@@ -222,8 +289,10 @@ class RecipeAnalysisService:
|
|||||||
recipe=recipe_for_enrich,
|
recipe=recipe_for_enrich,
|
||||||
civitai_client=civitai_client,
|
civitai_client=civitai_client,
|
||||||
request_params=None,
|
request_params=None,
|
||||||
prefetched_civitai_meta_raw=image_info.get("meta"),
|
prefetched_civitai_meta_raw=(
|
||||||
prefetched_model_version_id=mvid,
|
metadata if isinstance(metadata, dict) else None
|
||||||
|
),
|
||||||
|
prefetched_model_version_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
|
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
|
||||||
@@ -232,6 +301,12 @@ class RecipeAnalysisService:
|
|||||||
if recipe_for_enrich.get("base_model"):
|
if recipe_for_enrich.get("base_model"):
|
||||||
result.payload["base_model"] = recipe_for_enrich["base_model"]
|
result.payload["base_model"] = recipe_for_enrich["base_model"]
|
||||||
|
|
||||||
|
# Extract browsingLevel from our constructed metadata for NSFW blur
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
bl = metadata.get("browsingLevel")
|
||||||
|
if isinstance(bl, int) and bl > 0:
|
||||||
|
result.payload["preview_nsfw_level"] = bl
|
||||||
|
|
||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
if temp_path:
|
if temp_path:
|
||||||
@@ -314,6 +389,10 @@ class RecipeAnalysisService:
|
|||||||
"prompt_type",
|
"prompt_type",
|
||||||
"positive",
|
"positive",
|
||||||
"negative",
|
"negative",
|
||||||
|
# modelVersionIds is injected at the root level by CivitAI's image
|
||||||
|
# API when meta is null. It carries the version IDs of ALL models
|
||||||
|
# (checkpoint + LoRAs) used to generate the image.
|
||||||
|
"modelVersionIds",
|
||||||
}
|
}
|
||||||
return any(field in metadata for field in recipe_fields)
|
return any(field in metadata for field in recipe_fields)
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"autoplay_on_hover": False,
|
"autoplay_on_hover": False,
|
||||||
"display_density": "default",
|
"display_density": "default",
|
||||||
"card_info_display": "always",
|
"card_info_display": "always",
|
||||||
"show_folder_sidebar": True,
|
|
||||||
"include_trigger_words": False,
|
"include_trigger_words": False,
|
||||||
"compact_mode": False,
|
"compact_mode": False,
|
||||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||||
@@ -99,13 +98,15 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"lora_syntax_format": "legacy",
|
"lora_syntax_format": "legacy",
|
||||||
"model_card_footer_action": "replace_preview",
|
"model_card_footer_action": "replace_preview",
|
||||||
"show_version_on_card": True,
|
"show_version_on_card": True,
|
||||||
"update_flag_strategy": "same_base",
|
"version_grouping": "same_base",
|
||||||
"auto_organize_exclusions": [],
|
"auto_organize_exclusions": [],
|
||||||
"metadata_refresh_skip_paths": [],
|
"metadata_refresh_skip_paths": [],
|
||||||
"skip_previously_downloaded_model_versions": False,
|
"skip_previously_downloaded_model_versions": False,
|
||||||
"download_skip_base_models": [],
|
"download_skip_base_models": [],
|
||||||
"backup_auto_enabled": True,
|
"backup_auto_enabled": True,
|
||||||
"backup_retention_count": 5,
|
"backup_retention_count": 5,
|
||||||
|
"use_new_license_icons": True,
|
||||||
|
"group_by_model": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -134,6 +135,9 @@ class SettingsManager:
|
|||||||
self._template_path = (
|
self._template_path = (
|
||||||
Path(__file__).resolve().parents[2] / "settings.json.example"
|
Path(__file__).resolve().parents[2] / "settings.json.example"
|
||||||
)
|
)
|
||||||
|
# Known placeholder value in settings.json.example; any file containing
|
||||||
|
# this value should be treated as "not configured".
|
||||||
|
self._TEMPLATE_PLACEHOLDER_API_KEY = "your_civitai_api_key_here"
|
||||||
self.settings = self._load_settings()
|
self.settings = self._load_settings()
|
||||||
self._migrate_setting_keys()
|
self._migrate_setting_keys()
|
||||||
self._ensure_default_settings()
|
self._ensure_default_settings()
|
||||||
@@ -165,6 +169,12 @@ class SettingsManager:
|
|||||||
self._original_disk_payload = copy.deepcopy(data)
|
self._original_disk_payload = copy.deepcopy(data)
|
||||||
if self._matches_template_payload(data):
|
if self._matches_template_payload(data):
|
||||||
self._preserve_disk_template = True
|
self._preserve_disk_template = True
|
||||||
|
# Clean up the template placeholder so it is not treated
|
||||||
|
# as a real key (affects both the frontend boolean and
|
||||||
|
# the downloader's Authorization header).
|
||||||
|
placeholder = self._TEMPLATE_PLACEHOLDER_API_KEY
|
||||||
|
if data.get("civitai_api_key") == placeholder:
|
||||||
|
data["civitai_api_key"] = ""
|
||||||
return data
|
return data
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
logger.error("Failed to parse settings.json: %s", exc)
|
logger.error("Failed to parse settings.json: %s", exc)
|
||||||
@@ -735,6 +745,7 @@ class SettingsManager:
|
|||||||
"includeTriggerWords": "include_trigger_words",
|
"includeTriggerWords": "include_trigger_words",
|
||||||
"compactMode": "compact_mode",
|
"compactMode": "compact_mode",
|
||||||
"modelCardFooterAction": "model_card_footer_action",
|
"modelCardFooterAction": "model_card_footer_action",
|
||||||
|
"update_flag_strategy": "version_grouping",
|
||||||
}
|
}
|
||||||
|
|
||||||
updated = False
|
updated = False
|
||||||
@@ -1557,7 +1568,7 @@ class SettingsManager:
|
|||||||
previous_dir = os.path.dirname(previous_path) or target_dir
|
previous_dir = os.path.dirname(previous_path) or target_dir
|
||||||
|
|
||||||
if os.path.abspath(previous_path) != os.path.abspath(target_path):
|
if os.path.abspath(previous_path) != os.path.abspath(target_path):
|
||||||
self._copy_model_cache_directory(previous_dir, target_dir)
|
self._migrate_settings_directory_content(previous_dir, target_dir)
|
||||||
logger.info("Switching settings file to: %s", target_path)
|
logger.info("Switching settings file to: %s", target_path)
|
||||||
|
|
||||||
self._pending_portable_switch = {"other_path": other_path}
|
self._pending_portable_switch = {"other_path": other_path}
|
||||||
@@ -1592,46 +1603,52 @@ class SettingsManager:
|
|||||||
finally:
|
finally:
|
||||||
self._pending_portable_switch = None
|
self._pending_portable_switch = None
|
||||||
|
|
||||||
def _copy_model_cache_directory(self, source_dir: str, target_dir: str) -> None:
|
def _migrate_settings_directory_content(
|
||||||
"""Copy model_cache artifacts when switching storage locations."""
|
self, source_dir: str, target_dir: str
|
||||||
|
) -> None:
|
||||||
|
"""Migrate settings directory subdirectories when switching storage locations.
|
||||||
|
|
||||||
|
Copies the canonical subdirectories (cache, backups, logs, stats, wildcards)
|
||||||
|
from the old settings directory to the new one. Legacy cache artifacts
|
||||||
|
(model_cache, recipe_cache, etc.) are migrated lazily by
|
||||||
|
``resolve_cache_path_with_migration`` on first access.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_dir: The previous settings directory path.
|
||||||
|
target_dir: The new settings directory path.
|
||||||
|
"""
|
||||||
|
|
||||||
if not source_dir or not target_dir:
|
if not source_dir or not target_dir:
|
||||||
return
|
return
|
||||||
|
|
||||||
source_cache_dir = os.path.join(source_dir, "model_cache")
|
def _copy_dir(name: str) -> None:
|
||||||
target_cache_dir = os.path.join(target_dir, "model_cache")
|
source = os.path.join(source_dir, name)
|
||||||
if os.path.isdir(source_cache_dir) and os.path.abspath(
|
target = os.path.join(target_dir, name)
|
||||||
source_cache_dir
|
if os.path.isdir(source) and os.path.abspath(source) != os.path.abspath(
|
||||||
) != os.path.abspath(target_cache_dir):
|
target
|
||||||
try:
|
):
|
||||||
shutil.copytree(
|
try:
|
||||||
source_cache_dir,
|
shutil.copytree(
|
||||||
target_cache_dir,
|
source,
|
||||||
dirs_exist_ok=True,
|
target,
|
||||||
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
|
dirs_exist_ok=True,
|
||||||
)
|
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
|
||||||
except Exception as exc:
|
)
|
||||||
logger.warning(
|
except Exception as exc:
|
||||||
"Failed to copy model_cache directory from %s to %s: %s",
|
logger.warning(
|
||||||
source_cache_dir,
|
"Failed to copy directory %s from %s to %s: %s",
|
||||||
target_cache_dir,
|
name,
|
||||||
exc,
|
source,
|
||||||
)
|
target,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
source_cache_file = os.path.join(source_dir, "model_cache.sqlite")
|
# Managed subdirectories under settings_dir
|
||||||
target_cache_file = os.path.join(target_dir, "model_cache.sqlite")
|
_copy_dir("cache")
|
||||||
if os.path.isfile(source_cache_file) and os.path.abspath(
|
_copy_dir("backups")
|
||||||
source_cache_file
|
_copy_dir("logs")
|
||||||
) != os.path.abspath(target_cache_file):
|
_copy_dir("stats")
|
||||||
try:
|
_copy_dir("wildcards")
|
||||||
shutil.copy2(source_cache_file, target_cache_file)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to copy model_cache.sqlite from %s to %s: %s",
|
|
||||||
source_cache_file,
|
|
||||||
target_cache_file,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_user_config_directory(self) -> str:
|
def _get_user_config_directory(self) -> str:
|
||||||
"""Return the user configuration directory, falling back to ~/.config."""
|
"""Return the user configuration directory, falling back to ~/.config."""
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ class TagUpdateService:
|
|||||||
if isinstance(tag, str) and tag.strip():
|
if isinstance(tag, str) and tag.strip():
|
||||||
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
|
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
|
||||||
normalized = tag.strip().lower()
|
normalized = tag.strip().lower()
|
||||||
if normalized.lower() not in existing_lower:
|
if normalized not in existing_lower:
|
||||||
existing_tags.append(normalized)
|
existing_tags.append(normalized)
|
||||||
existing_lower.append(normalized.lower())
|
existing_lower.append(normalized)
|
||||||
tags_added.append(normalized)
|
tags_added.append(normalized)
|
||||||
|
|
||||||
metadata["tags"] = existing_tags
|
metadata["tags"] = existing_tags
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
||||||
|
|
||||||
from ..metadata_sync_service import MetadataSyncService
|
from ..metadata_sync_service import MetadataSyncService
|
||||||
@@ -62,26 +63,48 @@ class BulkMetadataRefreshUseCase:
|
|||||||
]
|
]
|
||||||
|
|
||||||
total_to_process = len(to_process)
|
total_to_process = len(to_process)
|
||||||
|
initial_skipped = total_models - total_to_process # models excluded from fetch queue
|
||||||
processed = 0
|
processed = 0
|
||||||
success = 0
|
success = 0
|
||||||
|
skipped_count = initial_skipped
|
||||||
|
handled_count = initial_skipped
|
||||||
needs_resort = False
|
needs_resort = False
|
||||||
|
start_time = time.monotonic()
|
||||||
|
failures: List[Dict[str, str]] = []
|
||||||
|
|
||||||
self._service.scanner.reset_cancellation()
|
self._service.scanner.reset_cancellation()
|
||||||
|
|
||||||
async def emit(status: str, **extra: Any) -> None:
|
async def emit(status: str, **extra: Any) -> None:
|
||||||
if progress_callback is None:
|
if progress_callback is None:
|
||||||
return
|
return
|
||||||
payload = {"status": status, "total": total_to_process, "processed": processed, "success": success}
|
payload = {
|
||||||
|
"status": status,
|
||||||
|
"total": total_models,
|
||||||
|
"processed": processed,
|
||||||
|
"success": success,
|
||||||
|
"failure_count": len(failures),
|
||||||
|
"skipped_count": skipped_count,
|
||||||
|
"handled": handled_count,
|
||||||
|
"elapsed_seconds": int(time.monotonic() - start_time),
|
||||||
|
}
|
||||||
|
# Only include full failure details in terminal emits (completed,
|
||||||
|
# cancelled, rate_limited) to avoid serializing the list on every
|
||||||
|
# per-model progress update.
|
||||||
|
if failures and status in ("completed", "cancelled", "rate_limited"):
|
||||||
|
payload["failures"] = failures
|
||||||
payload.update(extra)
|
payload.update(extra)
|
||||||
await progress_callback.on_progress(payload)
|
await progress_callback.on_progress(payload)
|
||||||
|
|
||||||
await emit("started")
|
await emit("started")
|
||||||
|
|
||||||
|
RATE_LIMIT_ABORT_THRESHOLD = 3
|
||||||
|
consecutive_rate_limits = 0
|
||||||
|
|
||||||
for model in to_process:
|
for model in to_process:
|
||||||
if self._service.scanner.is_cancelled():
|
if self._service.scanner.is_cancelled():
|
||||||
self._logger.info("Bulk metadata refresh cancelled by user")
|
self._logger.info("Bulk metadata refresh cancelled by user")
|
||||||
await emit("cancelled", processed=processed, success=success)
|
await emit("cancelled", processed=processed, success=success)
|
||||||
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
|
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
|
||||||
try:
|
try:
|
||||||
original_name = model.get("model_name")
|
original_name = model.get("model_name")
|
||||||
|
|
||||||
@@ -101,31 +124,76 @@ class BulkMetadataRefreshUseCase:
|
|||||||
model["hash_status"] = "completed"
|
model["hash_status"] = "completed"
|
||||||
else:
|
else:
|
||||||
self._logger.error(f"Failed to calculate hash for {file_path}")
|
self._logger.error(f"Failed to calculate hash for {file_path}")
|
||||||
|
failures.append({"name": model.get("model_name", file_path or "Unknown"), "error": "Failed to calculate hash"})
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
|
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
|
||||||
|
skipped_count += 1
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip models without valid hash
|
# Skip models without valid hash
|
||||||
if not model.get("sha256"):
|
if not model.get("sha256"):
|
||||||
self._logger.warning(f"Skipping model without hash: {file_path}")
|
self._logger.warning(f"Skipping model without hash: {file_path}")
|
||||||
|
skipped_count += 1
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await MetadataManager.hydrate_model_data(model)
|
await MetadataManager.hydrate_model_data(model)
|
||||||
result, _ = await self._metadata_sync.fetch_and_update_model(
|
result, error_msg = await self._metadata_sync.fetch_and_update_model(
|
||||||
sha256=model["sha256"],
|
sha256=model["sha256"],
|
||||||
file_path=model["file_path"],
|
file_path=model["file_path"],
|
||||||
model_data=model,
|
model_data=model,
|
||||||
update_cache_func=self._service.scanner.update_single_model_cache,
|
update_cache_func=self._service.scanner.update_single_model_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not result and error_msg and "Rate limited" in error_msg:
|
||||||
|
consecutive_rate_limits += 1
|
||||||
|
else:
|
||||||
|
consecutive_rate_limits = 0
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
current_name = model.get("model_name", file_path or "Unknown")
|
||||||
|
failures.append({"name": current_name, "error": error_msg or "Unknown error"})
|
||||||
|
self._logger.warning("Failed to fetch metadata for %s: %s", current_name, error_msg)
|
||||||
|
|
||||||
|
if consecutive_rate_limits >= RATE_LIMIT_ABORT_THRESHOLD:
|
||||||
|
# The current model was attempted and failed due to rate limiting;
|
||||||
|
# count it before aborting so the summary is consistent.
|
||||||
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
|
self._logger.warning(
|
||||||
|
"Bulk metadata refresh aborted: %d consecutive rate limits detected. "
|
||||||
|
"Processed %d/%d models.",
|
||||||
|
consecutive_rate_limits,
|
||||||
|
processed,
|
||||||
|
total_to_process,
|
||||||
|
)
|
||||||
|
await emit(
|
||||||
|
"rate_limited",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Rate limit detected; {total_to_process - processed} models skipped",
|
||||||
|
"processed": processed,
|
||||||
|
"updated": success,
|
||||||
|
"total": total_models,
|
||||||
|
"failures": failures,
|
||||||
|
"failure_count": len(failures),
|
||||||
|
"skipped_count": skipped_count,
|
||||||
|
"elapsed_seconds": int(time.monotonic() - start_time),
|
||||||
|
}
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
success += 1
|
success += 1
|
||||||
if original_name != model.get("model_name"):
|
if original_name != model.get("model_name"):
|
||||||
needs_resort = True
|
needs_resort = True
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
await emit(
|
await emit(
|
||||||
"processing",
|
"processing",
|
||||||
processed=processed,
|
processed=processed,
|
||||||
@@ -134,6 +202,9 @@ class BulkMetadataRefreshUseCase:
|
|||||||
)
|
)
|
||||||
except Exception as exc: # pragma: no cover - logging path
|
except Exception as exc: # pragma: no cover - logging path
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
|
current_name = model.get("model_name", model.get("file_path", "Unknown"))
|
||||||
|
failures.append({"name": current_name, "error": str(exc)})
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
"Error fetching CivitAI data for %s: %s",
|
"Error fetching CivitAI data for %s: %s",
|
||||||
model.get("file_path"),
|
model.get("file_path"),
|
||||||
@@ -150,7 +221,7 @@ class BulkMetadataRefreshUseCase:
|
|||||||
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
|
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models}
|
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
|
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
|
||||||
|
|||||||
+20
-1
@@ -31,6 +31,8 @@ PREVIEW_EXTENSIONS = [
|
|||||||
".mp4",
|
".mp4",
|
||||||
".gif",
|
".gif",
|
||||||
".webm",
|
".webm",
|
||||||
|
".avif",
|
||||||
|
".jxl",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Card preview image width
|
# Card preview image width
|
||||||
@@ -41,10 +43,24 @@ EXAMPLE_IMAGE_WIDTH = 832
|
|||||||
|
|
||||||
# Supported media extensions for example downloads
|
# Supported media extensions for example downloads
|
||||||
SUPPORTED_MEDIA_EXTENSIONS = {
|
SUPPORTED_MEDIA_EXTENSIONS = {
|
||||||
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif"],
|
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif", ".jxl"],
|
||||||
"videos": [".mp4", ".webm"],
|
"videos": [".mp4", ".webm"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Model weight file extensions recognised by scanners.
|
||||||
|
# This is the union of all scanner extensions (lora, checkpoint, embedding).
|
||||||
|
MODEL_FILE_EXTENSIONS = {
|
||||||
|
".safetensors",
|
||||||
|
".ckpt",
|
||||||
|
".pt",
|
||||||
|
".pt2",
|
||||||
|
".bin",
|
||||||
|
".pth",
|
||||||
|
".pkl",
|
||||||
|
".sft",
|
||||||
|
".gguf",
|
||||||
|
}
|
||||||
|
|
||||||
# Valid sub-types for each scanner type
|
# Valid sub-types for each scanner type
|
||||||
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
||||||
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
||||||
@@ -145,6 +161,8 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
|||||||
"Qwen",
|
"Qwen",
|
||||||
"ZImageBase",
|
"ZImageBase",
|
||||||
"ZImageTurbo",
|
"ZImageTurbo",
|
||||||
|
# Krea 2 — loaded via UNETLoader in ComfyUI
|
||||||
|
"Krea 2",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -211,5 +229,6 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
|||||||
"Ernie",
|
"Ernie",
|
||||||
"Ernie Turbo",
|
"Ernie Turbo",
|
||||||
"Nucleus",
|
"Nucleus",
|
||||||
|
"Krea 2",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ from ..services.settings_manager import get_settings_manager
|
|||||||
|
|
||||||
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
|
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
|
||||||
|
|
||||||
|
# Filesystem/metadata files that are never created by the example images system
|
||||||
|
# and are safe to ignore during validation. The cleanup service only operates on
|
||||||
|
# directories, so these files pose no data-loss risk.
|
||||||
|
_SAFE_FILENAMES: frozenset[str] = frozenset({
|
||||||
|
".DS_Store", # macOS folder metadata
|
||||||
|
"Thumbs.db", # Windows thumbnail cache
|
||||||
|
"desktop.ini", # Windows folder customization
|
||||||
|
".localized", # macOS folder name localization
|
||||||
|
".gitkeep", # Placeholder to keep empty dirs in git
|
||||||
|
".gitignore", # Git ignore rules
|
||||||
|
})
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -180,6 +192,22 @@ def is_hash_folder(name: str) -> bool:
|
|||||||
return bool(_HEX_PATTERN.fullmatch(name or ""))
|
return bool(_HEX_PATTERN.fullmatch(name or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_ignorable_entry(item: str, item_path: str) -> bool:
|
||||||
|
"""Return True if *item* is a harmless system/hidden file we can skip.
|
||||||
|
|
||||||
|
These files are never created by the example images system and are safe to
|
||||||
|
ignore because the cleanup/delete operations only act on **directories**,
|
||||||
|
never on individual files (other than ``.download_progress.json``).
|
||||||
|
"""
|
||||||
|
if item in _SAFE_FILENAMES:
|
||||||
|
return True
|
||||||
|
# Hide Unix hidden files (dotfiles) that are regular files,
|
||||||
|
# since the cleanup system never deletes or moves files.
|
||||||
|
if item.startswith(".") and os.path.isfile(item_path):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def is_valid_example_images_root(folder_path: str) -> bool:
|
def is_valid_example_images_root(folder_path: str) -> bool:
|
||||||
"""Check whether a folder looks like a dedicated example images root."""
|
"""Check whether a folder looks like a dedicated example images root."""
|
||||||
|
|
||||||
@@ -190,9 +218,16 @@ def is_valid_example_images_root(folder_path: str) -> bool:
|
|||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
item_path = os.path.join(folder_path, item)
|
item_path = os.path.join(folder_path, item)
|
||||||
|
|
||||||
|
# .download_progress.json is an expected metadata file — check before
|
||||||
|
# the generic dotfile rule so it stays explicitly documented.
|
||||||
if item == ".download_progress.json" and os.path.isfile(item_path):
|
if item == ".download_progress.json" and os.path.isfile(item_path):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip harmless system/hidden files — cleanup only touches directories
|
||||||
|
if _is_safe_ignorable_entry(item, item_path):
|
||||||
|
continue
|
||||||
|
|
||||||
if os.path.isdir(item_path):
|
if os.path.isdir(item_path):
|
||||||
if is_hash_folder(item):
|
if is_hash_folder(item):
|
||||||
continue
|
continue
|
||||||
@@ -211,6 +246,41 @@ def is_valid_example_images_root(folder_path: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def find_non_compliant_items_in_example_images_root(folder_path: str) -> list[str]:
|
||||||
|
"""Return the names of items that prevent *folder_path* from being a valid
|
||||||
|
example images root, or an empty list if the folder is valid.
|
||||||
|
|
||||||
|
This mirrors ``is_valid_example_images_root`` but **returns** the offending
|
||||||
|
names instead of a boolean, so callers can produce actionable error messages.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
items = os.listdir(folder_path)
|
||||||
|
except OSError as exc:
|
||||||
|
return [f"<cannot list directory: {exc}>"]
|
||||||
|
|
||||||
|
offending: list[str] = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item_path = os.path.join(folder_path, item)
|
||||||
|
|
||||||
|
# Same skip rules as is_valid_example_images_root
|
||||||
|
if item == ".download_progress.json" and os.path.isfile(item_path):
|
||||||
|
continue
|
||||||
|
if _is_safe_ignorable_entry(item, item_path):
|
||||||
|
continue
|
||||||
|
if os.path.isdir(item_path):
|
||||||
|
if is_hash_folder(item):
|
||||||
|
continue
|
||||||
|
if item == "_deleted":
|
||||||
|
continue
|
||||||
|
if _library_folder_has_only_hash_dirs(item_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
offending.append(item)
|
||||||
|
|
||||||
|
return offending
|
||||||
|
|
||||||
|
|
||||||
def _library_folder_has_only_hash_dirs(path: str) -> bool:
|
def _library_folder_has_only_hash_dirs(path: str) -> bool:
|
||||||
"""Return True when a library subfolder only contains hash folders or metadata files."""
|
"""Return True when a library subfolder only contains hash folders or metadata files."""
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ class ExampleImagesProcessor:
|
|||||||
return '.gif'
|
return '.gif'
|
||||||
elif content.startswith(b'RIFF') and b'WEBP' in content[:12]:
|
elif content.startswith(b'RIFF') and b'WEBP' in content[:12]:
|
||||||
return '.webp'
|
return '.webp'
|
||||||
|
elif len(content) >= 12 and content[4:8] == b'ftyp' and b'avif' in content[8:24]:
|
||||||
|
return '.avif'
|
||||||
|
elif content.startswith(b'\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a'):
|
||||||
|
return '.jxl'
|
||||||
elif content.startswith(b'\x00\x00\x00\x18ftypmp4') or content.startswith(b'\x00\x00\x00\x20ftypmp4'):
|
elif content.startswith(b'\x00\x00\x00\x18ftypmp4') or content.startswith(b'\x00\x00\x00\x20ftypmp4'):
|
||||||
return '.mp4'
|
return '.mp4'
|
||||||
elif content.startswith(b'\x1A\x45\xDF\xA3'):
|
elif content.startswith(b'\x1A\x45\xDF\xA3'):
|
||||||
@@ -75,6 +79,8 @@ class ExampleImagesProcessor:
|
|||||||
'image/png': '.png',
|
'image/png': '.png',
|
||||||
'image/gif': '.gif',
|
'image/gif': '.gif',
|
||||||
'image/webp': '.webp',
|
'image/webp': '.webp',
|
||||||
|
'image/avif': '.avif',
|
||||||
|
'image/jxl': '.jxl',
|
||||||
'video/mp4': '.mp4',
|
'video/mp4': '.mp4',
|
||||||
'video/webm': '.webm',
|
'video/webm': '.webm',
|
||||||
'video/quicktime': '.mov'
|
'video/quicktime': '.mov'
|
||||||
|
|||||||
+117
-7
@@ -1,17 +1,125 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import struct
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import piexif
|
import piexif
|
||||||
from PIL import Image, PngImagePlugin
|
from PIL import Image, PngImagePlugin
|
||||||
|
|
||||||
|
try:
|
||||||
|
import brotli
|
||||||
|
_BROTLI_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
brotli = None
|
||||||
|
_BROTLI_AVAILABLE = False
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class ExifUtils:
|
class ExifUtils:
|
||||||
"""Utility functions for working with EXIF data in images"""
|
"""Utility functions for working with EXIF data in images"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_isobmff_boxes(data: bytes, offset: int = 0) -> list[dict]:
|
||||||
|
boxes = []
|
||||||
|
while offset + 8 <= len(data):
|
||||||
|
size = struct.unpack('>I', data[offset:offset + 4])[0]
|
||||||
|
box_type = data[offset + 4:offset + 8]
|
||||||
|
if size == 0:
|
||||||
|
break
|
||||||
|
if size < 8 or offset + size > len(data):
|
||||||
|
break
|
||||||
|
box_data = data[offset + 8:offset + size]
|
||||||
|
boxes.append({'type': box_type, 'data': box_data, 'size': size})
|
||||||
|
offset += size
|
||||||
|
return boxes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_jxl_container(data: bytes) -> bool:
|
||||||
|
if len(data) < 32:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
struct.unpack('>I', data[:4])[0] == 12
|
||||||
|
and data[4:8] == b'JXL '
|
||||||
|
and data[8:12] == bytes([0x0d, 0x0a, 0x87, 0x0a])
|
||||||
|
and struct.unpack('>I', data[12:16])[0] >= 16
|
||||||
|
and data[16:20] == b'ftyp'
|
||||||
|
and data[20:24] == b'jxl '
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_avif_container(data: bytes) -> bool:
|
||||||
|
if len(data) < 16:
|
||||||
|
return False
|
||||||
|
for box in ExifUtils._parse_isobmff_boxes(data):
|
||||||
|
if box['type'] == b'ftyp' and b'avif' in box['data']:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Max decompressed size for brotli metadata (2 MB)
|
||||||
|
_BROTLI_MAX_DECOMPRESSED = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_isobmff_brotli(image_path: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
with open(image_path, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if ExifUtils._is_jxl_container(data):
|
||||||
|
boxes = ExifUtils._parse_isobmff_boxes(data, offset=12)
|
||||||
|
elif ExifUtils._is_avif_container(data):
|
||||||
|
boxes = ExifUtils._parse_isobmff_boxes(data)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
brob = None
|
||||||
|
for box in boxes:
|
||||||
|
if box['type'] == b'brob':
|
||||||
|
brob = box
|
||||||
|
break
|
||||||
|
if brob is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = brob['data']
|
||||||
|
if payload[:4] != b'comf':
|
||||||
|
return None
|
||||||
|
compressed = payload[4:]
|
||||||
|
|
||||||
|
if _BROTLI_AVAILABLE:
|
||||||
|
try:
|
||||||
|
decompressed = brotli.decompress(compressed)
|
||||||
|
if len(decompressed) > ExifUtils._BROTLI_MAX_DECOMPRESSED:
|
||||||
|
logger.warning(
|
||||||
|
"Brotli metadata too large (%d bytes, max %d), ignoring",
|
||||||
|
len(decompressed),
|
||||||
|
ExifUtils._BROTLI_MAX_DECOMPRESSED,
|
||||||
|
)
|
||||||
|
decompressed = None
|
||||||
|
except Exception:
|
||||||
|
decompressed = None
|
||||||
|
else:
|
||||||
|
decompressed = None
|
||||||
|
|
||||||
|
raw = decompressed if decompressed is not None else compressed
|
||||||
|
try:
|
||||||
|
meta = json.loads(raw.decode('utf-8'))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = {"parameters": None, "prompt": None, "workflow": None, "comment": None}
|
||||||
|
if isinstance(meta.get("prompt"), (dict, list)):
|
||||||
|
result["prompt"] = json.dumps(meta["prompt"])
|
||||||
|
elif isinstance(meta.get("prompt"), str):
|
||||||
|
result["prompt"] = meta["prompt"]
|
||||||
|
if isinstance(meta.get("workflow"), (dict, list)):
|
||||||
|
result["workflow"] = json.dumps(meta["workflow"])
|
||||||
|
elif isinstance(meta.get("workflow"), str):
|
||||||
|
result["workflow"] = meta["workflow"]
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _decode_user_comment(user_comment: Any) -> Optional[str]:
|
def _decode_user_comment(user_comment: Any) -> Optional[str]:
|
||||||
if user_comment is None:
|
if user_comment is None:
|
||||||
@@ -43,6 +151,12 @@ class ExifUtils:
|
|||||||
"comment": None,
|
"comment": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
|
if ext in ('.avif', '.jxl'):
|
||||||
|
brotli_meta = ExifUtils._extract_isobmff_brotli(image_path)
|
||||||
|
if brotli_meta:
|
||||||
|
return brotli_meta
|
||||||
|
|
||||||
with Image.open(image_path) as img:
|
with Image.open(image_path) as img:
|
||||||
info = getattr(img, "info", {}) or {}
|
info = getattr(img, "info", {}) or {}
|
||||||
|
|
||||||
@@ -149,7 +263,6 @@ class ExifUtils:
|
|||||||
Optional[str]: Extracted metadata or None if not found
|
Optional[str]: Extracted metadata or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Skip for video files
|
|
||||||
if image_path:
|
if image_path:
|
||||||
ext = os.path.splitext(image_path)[1].lower()
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
if ext in ['.mp4', '.webm']:
|
if ext in ['.mp4', '.webm']:
|
||||||
@@ -177,10 +290,9 @@ class ExifUtils:
|
|||||||
str: Path to the updated image
|
str: Path to the updated image
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Skip for video files
|
|
||||||
if image_path:
|
if image_path:
|
||||||
ext = os.path.splitext(image_path)[1].lower()
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
if ext in ['.mp4', '.webm']:
|
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
|
||||||
return image_path
|
return image_path
|
||||||
|
|
||||||
metadata_fields = ExifUtils._load_structured_metadata(image_path)
|
metadata_fields = ExifUtils._load_structured_metadata(image_path)
|
||||||
@@ -212,10 +324,9 @@ class ExifUtils:
|
|||||||
def append_recipe_metadata(image_path, recipe_data) -> str:
|
def append_recipe_metadata(image_path, recipe_data) -> str:
|
||||||
"""Append recipe metadata to an image's EXIF data"""
|
"""Append recipe metadata to an image's EXIF data"""
|
||||||
try:
|
try:
|
||||||
# Skip for video files
|
|
||||||
if image_path:
|
if image_path:
|
||||||
ext = os.path.splitext(image_path)[1].lower()
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
if ext in ['.mp4', '.webm']:
|
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
|
||||||
return image_path
|
return image_path
|
||||||
|
|
||||||
# First, extract existing metadata
|
# First, extract existing metadata
|
||||||
@@ -327,10 +438,9 @@ class ExifUtils:
|
|||||||
Tuple of (optimized_image_data, extension)
|
Tuple of (optimized_image_data, extension)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Skip for video files early if it's a file path
|
|
||||||
if isinstance(image_data, str) and os.path.exists(image_data):
|
if isinstance(image_data, str) and os.path.exists(image_data):
|
||||||
ext = os.path.splitext(image_data)[1].lower()
|
ext = os.path.splitext(image_data)[1].lower()
|
||||||
if ext in ['.mp4', '.webm']:
|
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
|
||||||
try:
|
try:
|
||||||
with open(image_data, 'rb') as f:
|
with open(image_data, 'rb') as f:
|
||||||
return f.read(), ext
|
return f.read(), ext
|
||||||
|
|||||||
+15
-1
@@ -34,12 +34,26 @@ def _get_hash_chunk_size_bytes() -> int:
|
|||||||
|
|
||||||
|
|
||||||
async def calculate_sha256(file_path: str) -> str:
|
async def calculate_sha256(file_path: str) -> str:
|
||||||
"""Calculate SHA256 hash of a file (full file content)."""
|
"""Calculate SHA256 hash of a file (full file content).
|
||||||
|
|
||||||
|
Uses ``posix_fadvise`` with ``POSIX_FADV_DONTNEED`` to avoid polluting the OS page
|
||||||
|
cache — critical on WSL where cached file pages live inside the VM and are not
|
||||||
|
accounted for in guest ``used`` memory, causing VmmemWSL to balloon.
|
||||||
|
|
||||||
|
On Windows/macOS where ``posix_fadvise`` is not available the hint is silently
|
||||||
|
skipped.
|
||||||
|
"""
|
||||||
sha256_hash = hashlib.sha256()
|
sha256_hash = hashlib.sha256()
|
||||||
chunk_size = _get_hash_chunk_size_bytes()
|
chunk_size = _get_hash_chunk_size_bytes()
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
|
fd = f.fileno()
|
||||||
for byte_block in iter(lambda: f.read(chunk_size), b""):
|
for byte_block in iter(lambda: f.read(chunk_size), b""):
|
||||||
sha256_hash.update(byte_block)
|
sha256_hash.update(byte_block)
|
||||||
|
# Evict pages after reading so the data doesn't linger in the kernel page
|
||||||
|
# cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth.
|
||||||
|
# Guard against platforms (Windows, macOS) that lack posix_fadvise.
|
||||||
|
if hasattr(os, "posix_fadvise") and hasattr(os, "POSIX_FADV_DONTNEED"):
|
||||||
|
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
|
||||||
return sha256_hash.hexdigest()
|
return sha256_hash.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.1.1"
|
version = "1.1.6"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
{
|
|
||||||
"id": 1746460,
|
|
||||||
"name": "Mixplin Style [Illustrious]",
|
|
||||||
"type": "LORA",
|
|
||||||
"description": "description",
|
|
||||||
"username": "Ty_Lee",
|
|
||||||
"downloadCount": 4207,
|
|
||||||
"favoriteCount": 0,
|
|
||||||
"commentCount": 8,
|
|
||||||
"ratingCount": 0,
|
|
||||||
"rating": 0,
|
|
||||||
"is_nsfw": true,
|
|
||||||
"nsfw_level": 31,
|
|
||||||
"createdAt": "2025-07-06T01:51:42.859Z",
|
|
||||||
"updatedAt": "2025-10-10T23:15:26.714Z",
|
|
||||||
"deletedAt": null,
|
|
||||||
"tags": [
|
|
||||||
"art",
|
|
||||||
"style",
|
|
||||||
"artist style",
|
|
||||||
"styles",
|
|
||||||
"mixplin",
|
|
||||||
"artiststyle"
|
|
||||||
],
|
|
||||||
"creator_id": "Ty_Lee",
|
|
||||||
"creator_username": "Ty_Lee",
|
|
||||||
"creator_name": "Ty_Lee",
|
|
||||||
"creator_url": "/users/Ty_Lee",
|
|
||||||
"versions": [
|
|
||||||
{
|
|
||||||
"id": 2042594,
|
|
||||||
"name": "v2.0",
|
|
||||||
"href": "/models/1746460?modelVersionId=2042594"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1976567,
|
|
||||||
"name": "v1.0",
|
|
||||||
"href": "/models/1746460?modelVersionId=1976567"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version": {
|
|
||||||
"id": 1976567,
|
|
||||||
"modelId": 1746460,
|
|
||||||
"name": "v1.0",
|
|
||||||
"baseModel": "Illustrious",
|
|
||||||
"baseModelType": "Standard",
|
|
||||||
"description": null,
|
|
||||||
"downloadCount": 437,
|
|
||||||
"ratingCount": 0,
|
|
||||||
"rating": 0,
|
|
||||||
"is_nsfw": true,
|
|
||||||
"nsfw_level": 31,
|
|
||||||
"createdAt": "2025-07-05T10:17:28.716Z",
|
|
||||||
"updatedAt": "2025-10-10T23:15:26.756Z",
|
|
||||||
"deletedAt": null,
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"id": 1874043,
|
|
||||||
"name": "mxpln-illustrious-ty_lee.safetensors",
|
|
||||||
"type": "Model",
|
|
||||||
"sizeKB": 223124.37109375,
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1976567",
|
|
||||||
"modelId": 1746460,
|
|
||||||
"modelName": "Mixplin Style [Illustrious]",
|
|
||||||
"modelVersionId": 1976567,
|
|
||||||
"is_nsfw": true,
|
|
||||||
"nsfw_level": 31,
|
|
||||||
"sha256": "e2b7a280d6539556f23f380b3f71e4e22bc4524445c4c96526e117c6005c6ad3",
|
|
||||||
"createdAt": "2025-07-05T10:17:28.716Z",
|
|
||||||
"updatedAt": "2025-10-10T23:15:26.766Z",
|
|
||||||
"is_primary": false,
|
|
||||||
"mirrors": [
|
|
||||||
{
|
|
||||||
"filename": "mxpln-illustrious-ty_lee.safetensors",
|
|
||||||
"url": "https://civitai.com/api/download/models/1976567",
|
|
||||||
"source": "civitai",
|
|
||||||
"model_id": 1746460,
|
|
||||||
"model_version_id": 1976567,
|
|
||||||
"deletedAt": null,
|
|
||||||
"is_gated": false,
|
|
||||||
"is_paid": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"id": 86403595,
|
|
||||||
"url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"width": 1560,
|
|
||||||
"height": 2280,
|
|
||||||
"hash": "U7G8Zp0w02%IA6%N00-;D]-W~VNG0nMw-.IV",
|
|
||||||
"type": "image",
|
|
||||||
"minor": false,
|
|
||||||
"poi": false,
|
|
||||||
"hasMeta": true,
|
|
||||||
"hasPositivePrompt": true,
|
|
||||||
"onSite": false,
|
|
||||||
"remixOfId": null,
|
|
||||||
"image_url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
|
|
||||||
"link": "https://genur.art/posts/86403595"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"trigger": [
|
|
||||||
"mxpln"
|
|
||||||
],
|
|
||||||
"allow_download": true,
|
|
||||||
"download_url": "/api/download/models/1976567",
|
|
||||||
"platform_url": "https://civitai.com/models/1746460?modelVersionId=1976567",
|
|
||||||
"civitai_model_id": 1746460,
|
|
||||||
"civitai_model_version_id": 1976567,
|
|
||||||
"href": "/models/1746460?modelVersionId=1976567",
|
|
||||||
"mirrors": [
|
|
||||||
{
|
|
||||||
"platform": "tensorart",
|
|
||||||
"href": "/tensorart/models/904473536033245448/versions/904473536033245448",
|
|
||||||
"platform_url": "https://tensor.art/models/904473536033245448",
|
|
||||||
"name": "Mixplin Style MXP",
|
|
||||||
"version_name": "Mixplin",
|
|
||||||
"id": "904473536033245448",
|
|
||||||
"version_id": "904473536033245448"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"platform": "civitai",
|
|
||||||
"platform_name": "CivitAI",
|
|
||||||
"meta": {
|
|
||||||
"title": "Mixplin Style [Illustrious] - v1.0 - CivitAI Archive",
|
|
||||||
"description": "Mixplin Style [Illustrious] v1.0 is a Illustrious LORA AI model created by Ty_Lee for generating images of art, style, artist style, styles, mixplin, artiststyle",
|
|
||||||
"image": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
|
|
||||||
"canonical": "https://civarchive.com/models/1746460?modelVersionId=1976567"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
CREATE TABLE models (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
username TEXT,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
) STRICT;
|
|
||||||
CREATE TABLE model_versions (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
model_id INTEGER NOT NULL,
|
|
||||||
position INTEGER NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
base_model TEXT NOT NULL,
|
|
||||||
published_at INTEGER,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
) STRICT;
|
|
||||||
CREATE INDEX model_versions_model_id_idx ON model_versions (model_id);
|
|
||||||
CREATE TABLE model_files (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
model_id INTEGER NOT NULL,
|
|
||||||
version_id INTEGER NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
sha256 TEXT,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
) STRICT;
|
|
||||||
CREATE INDEX model_files_model_id_idx ON model_files (model_id);
|
|
||||||
CREATE INDEX model_files_version_id_idx ON model_files (version_id);
|
|
||||||
CREATE TABLE archived_model_files (
|
|
||||||
file_id INTEGER PRIMARY KEY,
|
|
||||||
model_id INTEGER NOT NULL,
|
|
||||||
version_id INTEGER NOT NULL
|
|
||||||
) STRICT;
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
{
|
|
||||||
"id": 1231067,
|
|
||||||
"name": "Vivid Impressions Storybook Style",
|
|
||||||
"description": "<h3 id=\"if-you'd-like-to-support-me-feel-free-to-visit-my-ko-fi-page.-please-share-your-images-using-the-"+add-post"-button-below.-it-supports-the-creators.-thanks!-nnfwkvfly\">If you'd like to support me, feel free to visit my <a target=\"_blank\" rel=\"ugc\" href=\"https://ko-fi.com/pixelpawsai\">Ko-Fi</a> page. ❤️<br /><br />Please share your images using the \"<span style=\"color:rgb(250, 82, 82)\">+add post</span>\" button below. It supports the creators. Thanks! 💕</h3><h3 id=\"if-you-like-my-lora-please-like-comment-or-donate-some-buzz.-much-appreciated!-vyeqok3go\">If you like my LoRA, please<span style=\"color:rgb(230, 73, 128)\"> </span><span style=\"color:rgb(250, 82, 82)\">like</span>, <span style=\"color:rgb(250, 82, 82)\">comment</span>, or <span style=\"color:#fa5252\">donate some Buzz</span>. Much appreciated! ❤️</h3><h3 id=\"-lo912t8rj\"></h3><h3 id=\"trigger-word:-ppstorybook-wlggllim2\"><strong><span style=\"color:rgb(253, 126, 20)\">Trigger word: </span></strong>ppstorybook</h3><h3 id=\"strength:-0.8-experiment-as-you-like-luvhks6za\"><strong><span style=\"color:rgb(253, 126, 20)\">Strength: </span></strong>0.8, experiment as you like</h3>",
|
|
||||||
"allowNoCredit": true,
|
|
||||||
"allowCommercialUse": [
|
|
||||||
"Image",
|
|
||||||
"RentCivit",
|
|
||||||
"Rent",
|
|
||||||
"Sell"
|
|
||||||
],
|
|
||||||
"allowDerivatives": true,
|
|
||||||
"allowDifferentLicense": true,
|
|
||||||
"type": "LORA",
|
|
||||||
"minor": false,
|
|
||||||
"sfwOnly": false,
|
|
||||||
"poi": false,
|
|
||||||
"nsfw": false,
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"availability": "Public",
|
|
||||||
"cosmetic": null,
|
|
||||||
"supportsGeneration": true,
|
|
||||||
"stats": {
|
|
||||||
"downloadCount": 2183,
|
|
||||||
"favoriteCount": 0,
|
|
||||||
"thumbsUpCount": 416,
|
|
||||||
"thumbsDownCount": 0,
|
|
||||||
"commentCount": 12,
|
|
||||||
"ratingCount": 0,
|
|
||||||
"rating": 0,
|
|
||||||
"tippedAmountCount": 360
|
|
||||||
},
|
|
||||||
"creator": {
|
|
||||||
"username": "PixelPawsAI",
|
|
||||||
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"style",
|
|
||||||
"illustration",
|
|
||||||
"storybook"
|
|
||||||
],
|
|
||||||
"modelVersions": [
|
|
||||||
{
|
|
||||||
"id": 1387174,
|
|
||||||
"index": 0,
|
|
||||||
"name": "v1.0",
|
|
||||||
"baseModel": "Flux.1 D",
|
|
||||||
"baseModelType": "Standard",
|
|
||||||
"createdAt": "2025-02-08T11:15:47.197Z",
|
|
||||||
"publishedAt": "2025-02-08T11:29:04.487Z",
|
|
||||||
"status": "Published",
|
|
||||||
"availability": "Public",
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"trainedWords": [
|
|
||||||
"ppstorybook"
|
|
||||||
],
|
|
||||||
"covered": true,
|
|
||||||
"stats": {
|
|
||||||
"downloadCount": 2183,
|
|
||||||
"ratingCount": 0,
|
|
||||||
"rating": 0,
|
|
||||||
"thumbsUpCount": 416,
|
|
||||||
"thumbsDownCount": 0
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"id": 1289799,
|
|
||||||
"sizeKB": 18829.1484375,
|
|
||||||
"name": "pp-storybook_rank2_bf16.safetensors",
|
|
||||||
"type": "Model",
|
|
||||||
"pickleScanResult": "Success",
|
|
||||||
"pickleScanMessage": "No Pickle imports",
|
|
||||||
"virusScanResult": "Success",
|
|
||||||
"virusScanMessage": null,
|
|
||||||
"scannedAt": "2025-02-08T11:21:04.247Z",
|
|
||||||
"metadata": {
|
|
||||||
"format": "SafeTensor"
|
|
||||||
},
|
|
||||||
"hashes": {
|
|
||||||
"AutoV1": "F414C813",
|
|
||||||
"AutoV2": "9753338AB6",
|
|
||||||
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
|
|
||||||
"CRC32": "A65AE7B3",
|
|
||||||
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
|
|
||||||
"AutoV3": "34A22376739D"
|
|
||||||
},
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1387174",
|
|
||||||
"primary": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/original=true/56547310.jpeg",
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"width": 832,
|
|
||||||
"height": 1216,
|
|
||||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
|
||||||
"type": "image",
|
|
||||||
"minor": false,
|
|
||||||
"poi": false,
|
|
||||||
"hasMeta": true,
|
|
||||||
"hasPositivePrompt": true,
|
|
||||||
"onSite": false,
|
|
||||||
"remixOfId": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
{
|
|
||||||
"id": 1387174,
|
|
||||||
"modelId": 1231067,
|
|
||||||
"name": "v1.0",
|
|
||||||
"createdAt": "2025-02-08T11:15:47.197Z",
|
|
||||||
"updatedAt": "2025-02-08T11:29:04.526Z",
|
|
||||||
"status": "Published",
|
|
||||||
"publishedAt": "2025-02-08T11:29:04.487Z",
|
|
||||||
"trainedWords": [
|
|
||||||
"ppstorybook"
|
|
||||||
],
|
|
||||||
"trainingStatus": null,
|
|
||||||
"trainingDetails": null,
|
|
||||||
"baseModel": "Flux.1 D",
|
|
||||||
"baseModelType": null,
|
|
||||||
"earlyAccessEndsAt": null,
|
|
||||||
"earlyAccessConfig": null,
|
|
||||||
"description": null,
|
|
||||||
"uploadType": "Created",
|
|
||||||
"usageControl": "Download",
|
|
||||||
"air": "urn:air:flux1:lora:civitai:1231067@1387174",
|
|
||||||
"stats": {
|
|
||||||
"downloadCount": 1436,
|
|
||||||
"ratingCount": 0,
|
|
||||||
"rating": 0,
|
|
||||||
"thumbsUpCount": 316
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"name": "Vivid Impressions Storybook Style",
|
|
||||||
"type": "LORA",
|
|
||||||
"nsfw": false,
|
|
||||||
"poi": false
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"id": 1289799,
|
|
||||||
"sizeKB": 18829.1484375,
|
|
||||||
"name": "pp-storybook_rank2_bf16.safetensors",
|
|
||||||
"type": "Model",
|
|
||||||
"pickleScanResult": "Success",
|
|
||||||
"pickleScanMessage": "No Pickle imports",
|
|
||||||
"virusScanResult": "Success",
|
|
||||||
"virusScanMessage": null,
|
|
||||||
"scannedAt": "2025-02-08T11:21:04.247Z",
|
|
||||||
"metadata": {
|
|
||||||
"format": "SafeTensor",
|
|
||||||
"size": null,
|
|
||||||
"fp": null
|
|
||||||
},
|
|
||||||
"hashes": {
|
|
||||||
"AutoV1": "F414C813",
|
|
||||||
"AutoV2": "9753338AB6",
|
|
||||||
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
|
|
||||||
"CRC32": "A65AE7B3",
|
|
||||||
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
|
|
||||||
"AutoV3": "34A22376739D"
|
|
||||||
},
|
|
||||||
"primary": true,
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/width=832/56547310.jpeg",
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"width": 832,
|
|
||||||
"height": 1216,
|
|
||||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
|
||||||
"type": "image",
|
|
||||||
"metadata": {
|
|
||||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
|
||||||
"size": 1361590,
|
|
||||||
"width": 832,
|
|
||||||
"height": 1216
|
|
||||||
},
|
|
||||||
"meta": {
|
|
||||||
"Size": "832x1216",
|
|
||||||
"seed": 1116375220995209,
|
|
||||||
"Model": "flux_dev_fp8",
|
|
||||||
"steps": 23,
|
|
||||||
"hashes": {
|
|
||||||
"model": ""
|
|
||||||
},
|
|
||||||
"prompt": "ppstorybook,A dreamy bunny hopping across a rainbow bridge, with fluffy clouds surrounding it and tiny birds flying alongside, rendered in a magical, soft-focus style with pastel hues and glowing accents.",
|
|
||||||
"Version": "ComfyUI",
|
|
||||||
"sampler": "DPM++ 2M",
|
|
||||||
"cfgScale": 3.5,
|
|
||||||
"clipSkip": 1,
|
|
||||||
"resources": [],
|
|
||||||
"Model hash": ""
|
|
||||||
},
|
|
||||||
"availability": "Public",
|
|
||||||
"hasMeta": true,
|
|
||||||
"hasPositivePrompt": true,
|
|
||||||
"onSite": false,
|
|
||||||
"remixOfId": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
{
|
|
||||||
"resource-stack": {
|
|
||||||
"class_type": "CheckpointLoaderSimple",
|
|
||||||
"inputs": { "ckpt_name": "urn:air:sdxl:checkpoint:civitai:827184@1410435" }
|
|
||||||
},
|
|
||||||
"resource-stack-1": {
|
|
||||||
"class_type": "LoraLoader",
|
|
||||||
"inputs": {
|
|
||||||
"lora_name": "urn:air:sdxl:lora:civitai:1107767@1253442",
|
|
||||||
"strength_model": 1,
|
|
||||||
"strength_clip": 1,
|
|
||||||
"model": ["resource-stack", 0],
|
|
||||||
"clip": ["resource-stack", 1]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"resource-stack-2": {
|
|
||||||
"class_type": "LoraLoader",
|
|
||||||
"inputs": {
|
|
||||||
"lora_name": "urn:air:sdxl:lora:civitai:1342708@1516344",
|
|
||||||
"strength_model": 1,
|
|
||||||
"strength_clip": 1,
|
|
||||||
"model": ["resource-stack-1", 0],
|
|
||||||
"clip": ["resource-stack-1", 1]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"resource-stack-3": {
|
|
||||||
"class_type": "LoraLoader",
|
|
||||||
"inputs": {
|
|
||||||
"lora_name": "urn:air:sdxl:lora:civitai:122359@135867",
|
|
||||||
"strength_model": 1.55,
|
|
||||||
"strength_clip": 1,
|
|
||||||
"model": ["resource-stack-2", 0],
|
|
||||||
"clip": ["resource-stack-2", 1]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"6": {
|
|
||||||
"class_type": "smZ CLIPTextEncode",
|
|
||||||
"inputs": {
|
|
||||||
"text": "masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking",
|
|
||||||
"parser": "comfy",
|
|
||||||
"text_g": "",
|
|
||||||
"text_l": "",
|
|
||||||
"ascore": 2.5,
|
|
||||||
"width": 0,
|
|
||||||
"height": 0,
|
|
||||||
"crop_w": 0,
|
|
||||||
"crop_h": 0,
|
|
||||||
"target_width": 0,
|
|
||||||
"target_height": 0,
|
|
||||||
"smZ_steps": 1,
|
|
||||||
"mean_normalization": true,
|
|
||||||
"multi_conditioning": true,
|
|
||||||
"use_old_emphasis_implementation": false,
|
|
||||||
"with_SDXL": false,
|
|
||||||
"clip": ["resource-stack-3", 1]
|
|
||||||
},
|
|
||||||
"_meta": { "title": "Positive" }
|
|
||||||
},
|
|
||||||
"7": {
|
|
||||||
"class_type": "smZ CLIPTextEncode",
|
|
||||||
"inputs": {
|
|
||||||
"text": "bad quality,worst quality,worst detail,sketch,censor",
|
|
||||||
"parser": "comfy",
|
|
||||||
"text_g": "",
|
|
||||||
"text_l": "",
|
|
||||||
"ascore": 2.5,
|
|
||||||
"width": 0,
|
|
||||||
"height": 0,
|
|
||||||
"crop_w": 0,
|
|
||||||
"crop_h": 0,
|
|
||||||
"target_width": 0,
|
|
||||||
"target_height": 0,
|
|
||||||
"smZ_steps": 1,
|
|
||||||
"mean_normalization": true,
|
|
||||||
"multi_conditioning": true,
|
|
||||||
"use_old_emphasis_implementation": false,
|
|
||||||
"with_SDXL": false,
|
|
||||||
"clip": ["resource-stack-3", 1]
|
|
||||||
},
|
|
||||||
"_meta": { "title": "Negative" }
|
|
||||||
},
|
|
||||||
"20": {
|
|
||||||
"class_type": "UpscaleModelLoader",
|
|
||||||
"inputs": { "model_name": "urn:air:other:upscaler:civitai:147759@164821" },
|
|
||||||
"_meta": { "title": "Load Upscale Model" }
|
|
||||||
},
|
|
||||||
"17": {
|
|
||||||
"class_type": "LoadImage",
|
|
||||||
"inputs": {
|
|
||||||
"image": "https://orchestration.civitai.com/v2/consumer/blobs/5KZ6358TW8CNEGPZKD08NVDB30",
|
|
||||||
"upload": "image"
|
|
||||||
},
|
|
||||||
"_meta": { "title": "Image Load" }
|
|
||||||
},
|
|
||||||
"19": {
|
|
||||||
"class_type": "ImageUpscaleWithModel",
|
|
||||||
"inputs": { "upscale_model": ["20", 0], "image": ["17", 0] },
|
|
||||||
"_meta": { "title": "Upscale Image (using Model)" }
|
|
||||||
},
|
|
||||||
"23": {
|
|
||||||
"class_type": "ImageScale",
|
|
||||||
"inputs": {
|
|
||||||
"upscale_method": "nearest-exact",
|
|
||||||
"crop": "disabled",
|
|
||||||
"width": 1280,
|
|
||||||
"height": 1856,
|
|
||||||
"image": ["19", 0]
|
|
||||||
},
|
|
||||||
"_meta": { "title": "Upscale Image" }
|
|
||||||
},
|
|
||||||
"21": {
|
|
||||||
"class_type": "VAEEncode",
|
|
||||||
"inputs": { "pixels": ["23", 0], "vae": ["resource-stack", 2] },
|
|
||||||
"_meta": { "title": "VAE Encode" }
|
|
||||||
},
|
|
||||||
"11": {
|
|
||||||
"class_type": "KSampler",
|
|
||||||
"inputs": {
|
|
||||||
"sampler_name": "euler_ancestral",
|
|
||||||
"scheduler": "normal",
|
|
||||||
"seed": 2088370631,
|
|
||||||
"steps": 47,
|
|
||||||
"cfg": 6.5,
|
|
||||||
"denoise": 0.3,
|
|
||||||
"model": ["resource-stack-3", 0],
|
|
||||||
"positive": ["6", 0],
|
|
||||||
"negative": ["7", 0],
|
|
||||||
"latent_image": ["21", 0]
|
|
||||||
},
|
|
||||||
"_meta": { "title": "KSampler" }
|
|
||||||
},
|
|
||||||
"13": {
|
|
||||||
"class_type": "VAEDecode",
|
|
||||||
"inputs": { "samples": ["11", 0], "vae": ["resource-stack", 2] },
|
|
||||||
"_meta": { "title": "VAE Decode" }
|
|
||||||
},
|
|
||||||
"12": {
|
|
||||||
"class_type": "SaveImage",
|
|
||||||
"inputs": { "filename_prefix": "ComfyUI", "images": ["13", 0] },
|
|
||||||
"_meta": { "title": "Save Image" }
|
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"airs": [
|
|
||||||
"urn:air:other:upscaler:civitai:147759@164821",
|
|
||||||
"urn:air:sdxl:checkpoint:civitai:827184@1410435",
|
|
||||||
"urn:air:sdxl:lora:civitai:1107767@1253442",
|
|
||||||
"urn:air:sdxl:lora:civitai:1342708@1516344",
|
|
||||||
"urn:air:sdxl:lora:civitai:122359@135867"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"extraMetadata": "{\u0022prompt\u0022:\u0022masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking\u0022,\u0022negativePrompt\u0022:\u0022bad quality,worst quality,worst detail,sketch,censor\u0022,\u0022steps\u0022:47,\u0022cfgScale\u0022:6.5,\u0022sampler\u0022:\u0022euler_ancestral\u0022,\u0022workflowId\u0022:\u0022img2img-hires\u0022,\u0022resources\u0022:[{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1253442,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1516344,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:135867,\u0022strength\u0022:1.55}],\u0022remixOfId\u0022:32140259}"
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
a dynamic and dramatic digital artwork featuring a stylized anthropomorphic white tiger with striking yellow eyes. The tiger is depicted in a powerful stance, wielding a katana with one hand raised above its head. Its fur is detailed with black stripes, and its mane flows wildly, blending with the stormy background. The scene is set amidst swirling dark clouds and flashes of lightning, enhancing the sense of movement and energy. The composition is vertical, with the tiger positioned centrally, creating a sense of depth and intensity. The color palette is dominated by shades of blue, gray, and white, with bright highlights from the lightning. The overall style is reminiscent of fantasy or manga art, with a focus on dynamic action and dramatic lighting.
|
|
||||||
Negative prompt:
|
|
||||||
Steps: 30, Sampler: Undefined, CFG scale: 3.5, Seed: 90300501, Size: 832x1216, Clip skip: 2, Created Date: 2025-03-05T13:51:18.1770234Z, Civitai resources: [{"type":"checkpoint","modelVersionId":691639,"modelName":"FLUX","modelVersionName":"Dev"},{"type":"lora","weight":0.4,"modelVersionId":1202162,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Gothic Lines"},{"type":"lora","weight":0.8,"modelVersionId":1470588,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Retro"},{"type":"lora","weight":0.75,"modelVersionId":746484,"modelName":"Elden Ring - Yoshitaka Amano","modelVersionName":"V1"},{"type":"lora","weight":0.2,"modelVersionId":914935,"modelName":"Ink-style","modelVersionName":"ink-dynamic"},{"type":"lora","weight":0.2,"modelVersionId":1189379,"modelName":"Painterly Fantasy by ChronoKnight - [FLUX \u0026 IL]","modelVersionName":"FLUX"},{"type":"lora","weight":0.2,"modelVersionId":757030,"modelName":"Mezzotint Artstyle for Flux - by Ethanar","modelVersionName":"V1"}], Civitai metadata: {}
|
|
||||||
|
|
||||||
masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject,
|
|
||||||
dynamic angle, dutch angle, from below, epic half body portrait, gritty, wabi sabi, looking at viewer, woman is a geisha, parted lips,
|
|
||||||
holographic skin, holofoil glitter, faint, glowing, ethereal, neon hair, glowing hair, otherworldly glow, she is dangerous
|
|
||||||
<lora:ck-shadow-circuit-IL:0.78>, <lora:ck-nc-cyberpunk-IL-000011:0.4>, <lora:ck-neon-retrowave-IL:0.2>, <lora:ck-yoneyama-mai-IL-000014:0.4>
|
|
||||||
Negative prompt: score_6, score_5, score_4, bad quality, worst quality, worst detail, sketch, censorship, furry, window, headphones,
|
|
||||||
Steps: 30, Sampler: Euler a, Schedule type: Simple, CFG scale: 7, Seed: 1405717592, Size: 832x1216, Model hash: 1ad6ca7f70, Model: waiNSFWIllustrious_v100, Denoising strength: 0.35, Hires CFG Scale: 5, Hires upscale: 1.3, Hires steps: 20, Hires upscaler: 4x-AnimeSharp, Lora hashes: "ck-shadow-circuit-IL: 88e247aa8c3d, ck-nc-cyberpunk-IL-000011: 935e6755554c, ck-neon-retrowave-IL: edafb9df7da1, ck-yoneyama-mai-IL-000014: 1b9305692a2e", Version: f2.0.1v1.10.1-1.10.1, Diffusion in Low Bits: Automatic (fp16 LoRA)
|
|
||||||
|
|
||||||
Masterpiece, best quality, high quality, newest, highres, 8K, HDR, absurdres, 1girl, solo, futuristic warrior, sleek exosuit with glowing energy cores, long braided hair flowing behind, gripping a high-tech bow with an energy arrow drawn, standing on a floating platform overlooking a massive space station, planets and nebulae in the distance, soft glow from distant stars, cinematic depth, foreshortening, dynamic pose, dramatic sci-fi lighting.
|
|
||||||
Negative prompt: worst quality, normal quality, anatomical nonsense, bad anatomy,interlocked fingers, extra fingers,watermark,simple background, loli,
|
|
||||||
Steps: 20, Sampler: euler_ancestral_karras, CFG scale: 8.0, Seed: 691121152183439, Model: il\waiNSFWIllustrious_v110.safetensors, Model hash: c3688ee04c, Lora_0 Model name: iLLMythAn1m3Style.safetensors, Lora_0 Model hash: ba7a040786, Lora_0 Strength model: 1.0, Lora_0 Strength clip: 1.0, Hashes: {"model": "c3688ee04c", "lora:iLLMythAn1m3Style": "ba7a040786"}
|
|
||||||
|
|
||||||
Immerse yourself in the enchanting journey, where harmonious transmutation of Bauhaus art unites photographic precision and contemporary illustration, capturing an enthralling blend between vivid abstract nature and urban landscapes. Let your eyes be captivated by a kaleidoscope of rich, deep reds and yellows, entwined with intriguing shades that beckon a somber atmosphere. As your spirit ventures along this haunting path, witness the mysterious, high-angle perspective dominated by scattered clouds – granting you a mesmerizing glimpse into the ever-transforming realm of metamorphosing environments. ,<lora:flux/fav/ck-charcoal-drawing-000014.safetensors:1.0:1.0>
|
|
||||||
Negative prompt:
|
|
||||||
Steps: 20, Sampler: Euler, CFG scale: 3.5, Seed: 885491426361006, Size: 832x1216, Model hash: 4610115bb0, Model: flux_dev, Hashes: {"LORA:flux/fav/ck-charcoal-drawing-000014.safetensors": "34d36c17c1", "model": "4610115bb0"}, Version: ComfyUI
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
In this ethereal masterpiece, metallic sculptures juxtapose effortlessly against a subtle backdrop of misty neutral hues. Exquisite curvatures and geometric shapes converge harmoniously, creating an illuminating realm of polished metallic surfaces. Shimmering copper, gleaming silver, and lustrous gold hues dance in perfect balance, highlighting the intricate play of light and shadow cast upon these celestial forms. A halo of diffused radiance envelops each piece, enhancing their textured depths and metallic brilliance while allowing delicate details to emerge from obscurity. The composition conveys a serene yet mesmerizing atmosphere, as if suspended in a dreamlike limbo between reality and fantasy. The tantalizing interplay of colors within this transcendent realm creates a profound sense of depth and grandeur that invites the viewer into an enchanting voyage through abstract metallic beauty. This captivating artwork evokes emotions of boundless curiosity and reverence reminiscent of the timeless works by artists such as Giorgio de Chirico or Paul Klee, while asserting a unique, modern artistic sensibility. With every observation, a new nuance unfolds, as if a never-ending story waiting to be discovered through the lens of metallic artistry.
|
|
||||||
Negative prompt:
|
|
||||||
Steps: 25, Sampler: dpmpp_2m_sgm_uniform, Seed: 471889513588087, Model: Fluxmania V5P.safetensors, Model hash: 8ae0583b06, VAE: ae.sft, VAE hash: afc8e28272, Lora_0 Model name: ArtVador I.safetensors, Lora_0 Model hash: 08f7133a58, Lora_0 Strength model: 0.65, Lora_0 Strength clip: 0.65, Lora_1 Model name: Kaoru Yamada.safetensors, Lora_1 Model hash: d4893f7202, Lora_1 Strength model: 0.75, Lora_1 Strength clip: 0.75, Hashes: {"model": "8ae0583b06", "vae": "afc8e28272", "lora:ArtVador I": "08f7133a58", "lora:Kaoru Yamada": "d4893f7202"}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "42803a29-02dc-49e1-b798-27da70e8b408",
|
|
||||||
"file_path": "/home/miao/workspace/ComfyUI/models/loras/recipes/test/42803a29-02dc-49e1-b798-27da70e8b408.webp",
|
|
||||||
"title": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect",
|
|
||||||
"modified": 1754897325.0507245,
|
|
||||||
"created_date": 1754897325.0507245,
|
|
||||||
"base_model": "Illustrious",
|
|
||||||
"loras": [
|
|
||||||
{
|
|
||||||
"file_name": "",
|
|
||||||
"hash": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a",
|
|
||||||
"strength": 1.0,
|
|
||||||
"modelVersionId": 2007092,
|
|
||||||
"modelName": "Pony: People's Works +",
|
|
||||||
"modelVersionName": "v8_Illusv1.0",
|
|
||||||
"isDeleted": false,
|
|
||||||
"exclude": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"gen_params": {
|
|
||||||
"prompt": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect eyes, realistic eyes,\n(flat colors:1.5), (anime:1.5), (lineart:1.5),\nclose-up, solo, tongue, 1girl, food, (saliva:0.1), open mouth, candy, simple background, blue background, large lollipop, tongue out, fade background, lips, hand up, holding, looking at viewer, licking, seductive, half-closed eyes,",
|
|
||||||
"negative_prompt": "shiny skin,",
|
|
||||||
"steps": 19,
|
|
||||||
"sampler": "Euler a",
|
|
||||||
"cfg_scale": 5,
|
|
||||||
"seed": 1765271748,
|
|
||||||
"size": "832x1216",
|
|
||||||
"clip_skip": 2
|
|
||||||
},
|
|
||||||
"fingerprint": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a:1.0",
|
|
||||||
"source_path": "https://civitai.com/images/92427432",
|
|
||||||
"folder": "test"
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"id": 2269146,
|
|
||||||
"modelId": 2004760,
|
|
||||||
"name": "v1.0 Illustrious",
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"trainedWords": ["PencilSketchDaal"],
|
|
||||||
"baseModel": "Illustrious",
|
|
||||||
"description": "<p>Illustrious. Your pencil may vary with your checkpoint. </p>",
|
|
||||||
"model": {
|
|
||||||
"name": "Pencil Sketch Anime",
|
|
||||||
"type": "LORA",
|
|
||||||
"nsfw": false,
|
|
||||||
"description": "description",
|
|
||||||
"tags": ["style"],
|
|
||||||
"allowNoCredit": true,
|
|
||||||
"allowCommercialUse": ["Sell"],
|
|
||||||
"allowDerivatives": true,
|
|
||||||
"allowDifferentLicense": true
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"id": 2161260,
|
|
||||||
"sizeKB": 223106.37890625,
|
|
||||||
"name": "Pencil-Sketch-Illustrious.safetensors",
|
|
||||||
"type": "Model",
|
|
||||||
"hashes": {
|
|
||||||
"SHA256": "2C70479CD673B0FE056EAF4FD97C7F33A39F14853805431AC9AB84226ECE3B82"
|
|
||||||
},
|
|
||||||
"primary": true,
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/2269146",
|
|
||||||
"mirrors": {}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{},
|
|
||||||
{}
|
|
||||||
],
|
|
||||||
"creator": {
|
|
||||||
"username": "Daalis",
|
|
||||||
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/eb245b49-edc8-4ed6-ad7b-6d61eb8c51de/width=96/Daalis.jpeg"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
{
|
|
||||||
"id": 1255556,
|
|
||||||
"modelId": 1117241,
|
|
||||||
"name": "v1.0",
|
|
||||||
"createdAt": "2025-01-08T06:13:08.839Z",
|
|
||||||
"updatedAt": "2025-01-08T06:28:54.156Z",
|
|
||||||
"status": "Published",
|
|
||||||
"publishedAt": "2025-01-08T06:28:54.155Z",
|
|
||||||
"trainedWords": ["in the style of ppWhimsy"],
|
|
||||||
"trainingStatus": null,
|
|
||||||
"trainingDetails": null,
|
|
||||||
"baseModel": "Flux.1 D",
|
|
||||||
"baseModelType": "Standard",
|
|
||||||
"earlyAccessEndsAt": null,
|
|
||||||
"earlyAccessConfig": null,
|
|
||||||
"description": null,
|
|
||||||
"uploadType": "Created",
|
|
||||||
"usageControl": "Download",
|
|
||||||
"air": "urn:air:flux1:lora:civitai:1117241@1255556",
|
|
||||||
"stats": {
|
|
||||||
"downloadCount": 210,
|
|
||||||
"ratingCount": 0,
|
|
||||||
"rating": 0,
|
|
||||||
"thumbsUpCount": 26
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"name": "Enchanted Whimsy style (Flux)",
|
|
||||||
"type": "LORA",
|
|
||||||
"nsfw": false,
|
|
||||||
"poi": false
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"id": 1160774,
|
|
||||||
"sizeKB": 38828.8125,
|
|
||||||
"name": "pp-enchanted-whimsy.safetensors",
|
|
||||||
"type": "Model",
|
|
||||||
"pickleScanResult": "Success",
|
|
||||||
"pickleScanMessage": "No Pickle imports",
|
|
||||||
"virusScanResult": "Success",
|
|
||||||
"virusScanMessage": null,
|
|
||||||
"scannedAt": "2025-01-08T06:16:27.731Z",
|
|
||||||
"metadata": {
|
|
||||||
"format": "SafeTensor",
|
|
||||||
"size": null,
|
|
||||||
"fp": null
|
|
||||||
},
|
|
||||||
"hashes": {
|
|
||||||
"AutoV1": "40CAF049",
|
|
||||||
"AutoV2": "3202778C3E",
|
|
||||||
"SHA256": "3202778C3EBE5CF7EBE5FC51561DEAE8611F4362036EB7C02EFA033C705E6240",
|
|
||||||
"CRC32": "69DCD953",
|
|
||||||
"BLAKE3": "ED04580DDB1AD36D8B87F4B0800F5930C7E5D4A7269BDC2BE26ED77EA1A34697",
|
|
||||||
"AutoV3": "BF82986F8597"
|
|
||||||
},
|
|
||||||
"primary": true,
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1255556"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/707aef9b-36fb-46c2-ac41-adcab539d3a6/width=832/50270101.jpeg",
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"width": 832,
|
|
||||||
"height": 1216,
|
|
||||||
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
|
|
||||||
"type": "image",
|
|
||||||
"metadata": {
|
|
||||||
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
|
|
||||||
"size": 702313,
|
|
||||||
"width": 832,
|
|
||||||
"height": 1216
|
|
||||||
},
|
|
||||||
"minor": false,
|
|
||||||
"poi": false,
|
|
||||||
"meta": {
|
|
||||||
"prompt": "in the style of ppWhimsy, a close-up of a boy with a crown of ferns and tiny horns, his eyes wide with wonder as a family of glowing hedgehogs nestle in his hands, their spines shimmering with soft pastel colors"
|
|
||||||
},
|
|
||||||
"availability": "Public",
|
|
||||||
"hasMeta": true,
|
|
||||||
"hasPositivePrompt": true,
|
|
||||||
"onSite": false,
|
|
||||||
"remixOfId": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1255556",
|
|
||||||
"creator": {
|
|
||||||
"username": "PixelPawsAI",
|
|
||||||
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,3 +13,5 @@ aiosqlite
|
|||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
platformdirs
|
platformdirs
|
||||||
pyyaml
|
pyyaml
|
||||||
|
# brotli — ISOBMFF (AVIF/JXL) metadata decompression
|
||||||
|
brotli>=1.2.0
|
||||||
|
|||||||
@@ -349,8 +349,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-percentage {
|
.progress-percentage {
|
||||||
font-size: 1.2em;
|
font-size: var(--text-lg);
|
||||||
font-weight: 600;
|
font-weight: var(--weight-semibold);
|
||||||
color: var(--lora-accent);
|
color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,9 +365,9 @@
|
|||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, var(--lora-accent), oklch(from var(--lora-accent) calc(l + 0.1) c h));
|
background: var(--lora-accent);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius-xs);
|
||||||
transition: width 0.3s ease;
|
transition: width var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progress Stats */
|
/* Progress Stats */
|
||||||
@@ -389,27 +389,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-item.success {
|
.stat-item.success {
|
||||||
border-left: 3px solid #00B87A;
|
border-left: 4px solid var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item.failed {
|
.stat-item.failed {
|
||||||
border-left: 3px solid var(--lora-error);
|
border-left: 4px solid var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item.skipped {
|
.stat-item.skipped {
|
||||||
border-left: 3px solid var(--lora-warning);
|
border-left: 4px solid var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.8em;
|
font-size: var(--text-xs);
|
||||||
color: var(--text-color);
|
color: var(--text-secondary);
|
||||||
opacity: 0.7;
|
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 1.4em;
|
font-size: var(--text-lg);
|
||||||
font-weight: 600;
|
font-weight: var(--weight-semibold);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,8 +424,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.current-item-label {
|
.current-item-label {
|
||||||
color: var(--text-color);
|
color: var(--text-secondary);
|
||||||
opacity: 0.7;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,27 +447,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.results-header {
|
.results-header {
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-icon {
|
.results-icon {
|
||||||
font-size: 3em;
|
font-size: var(--text-xl);
|
||||||
color: #00B87A;
|
color: var(--color-success);
|
||||||
margin-bottom: var(--space-1);
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-icon.warning {
|
.results-icon.warning {
|
||||||
color: var(--lora-warning);
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-icon.error {
|
.results-icon.error {
|
||||||
color: var(--lora-error);
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-title {
|
.results-title {
|
||||||
font-size: 1.3em;
|
font-size: var(--text-lg);
|
||||||
font-weight: 600;
|
font-weight: var(--weight-semibold);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,27 +493,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.result-card.success {
|
.result-card.success {
|
||||||
border-left: 3px solid #00B87A;
|
border-left: 4px solid var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-card.failed {
|
.result-card.failed {
|
||||||
border-left: 3px solid var(--lora-error);
|
border-left: 4px solid var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-card.skipped {
|
.result-card.skipped {
|
||||||
border-left: 3px solid var(--lora-warning);
|
border-left: 4px solid var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-label {
|
.result-label {
|
||||||
font-size: 0.8em;
|
font-size: var(--text-xs);
|
||||||
color: var(--text-color);
|
color: var(--text-secondary);
|
||||||
opacity: 0.7;
|
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-value {
|
.result-value {
|
||||||
font-size: 1.4em;
|
font-size: var(--text-lg);
|
||||||
font-weight: 600;
|
font-weight: var(--weight-semibold);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,13 +526,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: var(--space-2);
|
||||||
padding: 10px;
|
padding: var(--space-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--lora-accent);
|
color: var(--lora-accent);
|
||||||
font-weight: 500;
|
font-weight: var(--weight-medium);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
transition: background 0.2s;
|
transition: background var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.details-toggle:hover {
|
.details-toggle:hover {
|
||||||
@@ -541,7 +540,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.details-toggle i {
|
.details-toggle i {
|
||||||
transition: transform 0.2s;
|
transition: transform var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.details-toggle.expanded i {
|
.details-toggle.expanded i {
|
||||||
@@ -561,10 +560,10 @@
|
|||||||
.result-item {
|
.result-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--space-2);
|
||||||
padding: 10px 12px;
|
padding: var(--space-2) var(--space-3);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
font-size: 0.9em;
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-item:last-child {
|
.result-item:last-child {
|
||||||
@@ -572,28 +571,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.result-item-status {
|
.result-item-status {
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.8em;
|
font-size: var(--text-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-item-status.success {
|
.result-item-status.success {
|
||||||
background: oklch(from #00B87A l c h / 0.2);
|
color: var(--color-success);
|
||||||
color: #00B87A;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-item-status.failed {
|
.result-item-status.failed {
|
||||||
background: oklch(from var(--lora-error) l c h / 0.2);
|
color: var(--color-error);
|
||||||
color: var(--lora-error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-item-status.skipped {
|
.result-item-status.skipped {
|
||||||
background: oklch(from var(--lora-warning) l c h / 0.2);
|
color: var(--color-warning);
|
||||||
color: var(--lora-warning);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-item-info {
|
.result-item-info {
|
||||||
@@ -610,8 +604,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.result-item-error {
|
.result-item-error {
|
||||||
font-size: 0.8em;
|
font-size: var(--text-xs);
|
||||||
color: var(--lora-error);
|
color: var(--color-error);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,11 +655,11 @@
|
|||||||
|
|
||||||
/* Completed State */
|
/* Completed State */
|
||||||
.batch-progress-container.completed .progress-bar {
|
.batch-progress-container.completed .progress-bar {
|
||||||
background: #00B87A;
|
background: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-progress-container.completed .status-icon {
|
.batch-progress-container.completed .status-icon {
|
||||||
color: #00B87A;
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-progress-container.completed .status-icon i {
|
.batch-progress-container.completed .status-icon i {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Style for selected cards */
|
/* Style for selected cards */
|
||||||
.model-card.selected {
|
.model-card.selected {
|
||||||
box-shadow: 0 0 0 2px var(--lora-accent);
|
outline: 2px solid var(--lora-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -509,6 +509,50 @@
|
|||||||
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Clickable version count link (shown in group-by-model mode) */
|
||||||
|
.version-count-link {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 2px;
|
||||||
|
border: 1px solid var(--color-accent-border);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--color-accent-subtle);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.version-count-link:hover {
|
||||||
|
background: var(--color-accent-border);
|
||||||
|
border-color: var(--color-accent-transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Medium density adjustments for version count link */
|
||||||
|
.medium-density .version-count-link {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium-density .badge-version-unit .version-count-link {
|
||||||
|
max-width: 90px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact density adjustments for version count link */
|
||||||
|
.compact-density .version-count-link {
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-density .badge-version-unit .version-count-link {
|
||||||
|
max-width: 70px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
/* Version row — flex container for badges + version names */
|
/* Version row — flex container for badges + version names */
|
||||||
.version-row {
|
.version-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -690,6 +734,21 @@ body.hide-card-version .hl-badge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Grid-scoped loading overlay (replaces full-page overlay for VirtualScroller refreshes) */
|
||||||
|
.grid-loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--lora-bg-transparent, oklch(0% 0 0 / 0.3));
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Add after the existing .model-card:hover styles */
|
/* Add after the existing .model-card:hover styles */
|
||||||
|
|
||||||
@keyframes update-pulse {
|
@keyframes update-pulse {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
position: sticky; /* Keep the sticky position */
|
position: sticky; /* Keep the sticky position */
|
||||||
top: var(--space-1);
|
top: var(--space-1);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); /* Use accent color with low opacity */
|
background-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.1); /* Use accent color with low opacity */
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border-top: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3); /* Add top border with accent color */
|
border-top: 1px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.3); /* Add top border with accent color */
|
||||||
border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */
|
border-bottom: 1px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.4); /* Make bottom border stronger */
|
||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
box-shadow: var(--shadow-lg); /* Stronger shadow */
|
box-shadow: var(--shadow-lg); /* Stronger shadow */
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
.duplicates-banner i.fa-exclamation-triangle {
|
.duplicates-banner i.fa-exclamation-triangle {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicates-banner .banner-actions {
|
.duplicates-banner .banner-actions {
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
|
|
||||||
.duplicates-banner button.btn-exit-mode:hover {
|
.duplicates-banner button.btn-exit-mode:hover {
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.duplicates-banner button:hover {
|
.duplicates-banner button:hover {
|
||||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
/* Duplicate groups */
|
/* Duplicate groups */
|
||||||
.duplicate-group {
|
.duplicate-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 2px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
border: 2px solid oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-left: 4px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); /* Add accent border on the left */
|
border-left: 4px solid oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h)); /* Add accent border on the left */
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicate-group-header span:last-child {
|
.duplicate-group-header span:last-child {
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.duplicate-group-header button:hover {
|
.duplicate-group-header button:hover {
|
||||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-toggle-btn:hover {
|
.group-toggle-btn:hover {
|
||||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h);
|
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
@@ -247,16 +247,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.model-card.duplicate:hover {
|
.model-card.duplicate:hover {
|
||||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-card.duplicate.latest {
|
.model-card.duplicate.latest {
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
border-color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-card.duplicate-selected {
|
.model-card.duplicate-selected {
|
||||||
border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
border: 2px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
@@ -328,7 +328,7 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px dashed var(--border-color);
|
border-top: 1px dashed var(--border-color);
|
||||||
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
word-break: break-all; /* Ensure long hashes wrap properly */
|
word-break: break-all; /* Ensure long hashes wrap properly */
|
||||||
}
|
}
|
||||||
@@ -351,12 +351,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.verification-badge.verified {
|
.verification-badge.verified {
|
||||||
background-color: oklch(70% 0.2 140); /* Green for verified */
|
background-color: var(--color-success); /* Green for verified */
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verification-badge.mismatch {
|
.verification-badge.mismatch {
|
||||||
background-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
background-color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
|
|
||||||
/* Hash Mismatch Styling */
|
/* Hash Mismatch Styling */
|
||||||
.model-card.duplicate.hash-mismatch {
|
.model-card.duplicate.hash-mismatch {
|
||||||
border: 2px dashed oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
border: 2px dashed oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -380,8 +380,8 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: repeating-linear-gradient(
|
background: repeating-linear-gradient(
|
||||||
45deg,
|
45deg,
|
||||||
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05),
|
oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h) / 0.05),
|
||||||
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05) 10px,
|
oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h) / 0.05) 10px,
|
||||||
transparent 10px,
|
transparent 10px,
|
||||||
transparent 20px
|
transparent 20px
|
||||||
);
|
);
|
||||||
@@ -398,7 +398,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
left: 10px; /* Changed from right:10px to left:10px */
|
left: 10px; /* Changed from right:10px to left:10px */
|
||||||
background: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
background: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
@@ -417,7 +417,7 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px dashed var(--border-color);
|
border-top: 1px dashed var(--border-color);
|
||||||
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +437,7 @@
|
|||||||
|
|
||||||
.btn-verify-hashes:hover {
|
.btn-verify-hashes:hover {
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +498,7 @@
|
|||||||
|
|
||||||
.help-icon:hover {
|
.help-icon:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Help tooltip */
|
/* Help tooltip */
|
||||||
@@ -573,7 +573,7 @@
|
|||||||
/* In dark mode, add additional distinction */
|
/* In dark mode, add additional distinction */
|
||||||
html[data-theme="dark"] .duplicates-banner {
|
html[data-theme="dark"] .duplicates-banner {
|
||||||
box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */
|
box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */
|
||||||
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */
|
background-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.15); /* Slightly stronger background in dark mode */
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .duplicate-group {
|
html[data-theme="dark"] .duplicate-group {
|
||||||
@@ -598,11 +598,11 @@ html[data-theme="dark"] .help-tooltip {
|
|||||||
background: var(--lora-accent);
|
background: var(--lora-accent);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
|
box-shadow: 0 0 0 2px oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.25);
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
#findDuplicatesBtn.active:hover {
|
#findDuplicatesBtn.active:hover {
|
||||||
background: oklch(calc(var(--lora-accent-l) - 5%) var(--lora-accent-c) var(--lora-accent-h));
|
background: oklch(calc(var(--color-accent-l) - 5%) var(--color-accent-c) var(--color-accent-h));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
padding-left: 2.25rem !important;
|
padding-left: 2.25rem !important;
|
||||||
padding-right: 5rem !important;
|
padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -190,6 +190,81 @@
|
|||||||
right: 2.25rem;
|
right: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Clear button: sit immediately left of the search-options toggle */
|
||||||
|
.header-search .search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 4.25rem; /* 2.25rem (options toggle) + 28px toggle width + 4px gap */
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background-color var(--transition-base), color var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-clear.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-clear:hover {
|
||||||
|
background: color-mix(in oklch, var(--text-muted) 15%, transparent);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard shortcut cue: shown when search is empty, hidden when typing */
|
||||||
|
.header-search .search-shortcut-cue {
|
||||||
|
position: absolute;
|
||||||
|
right: 4.25rem; /* same slot as clear button */
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-shortcut-cue kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
/* Subtle tint derived from text color so it adapts to both light & dark themes */
|
||||||
|
background: color-mix(in oklch, var(--text-muted) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in oklch, var(--text-muted) 25%, transparent);
|
||||||
|
border-radius: var(--border-radius-xs, 3px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-shortcut-cue.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search.disabled .search-shortcut-cue {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.header-search .search-options-toggle:hover,
|
.header-search .search-options-toggle:hover,
|
||||||
.header-search .search-filter-toggle:hover,
|
.header-search .search-filter-toggle:hover,
|
||||||
.header-search .search-filter-toggle:focus-visible {
|
.header-search .search-filter-toggle:focus-visible {
|
||||||
@@ -283,7 +358,6 @@
|
|||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
/* Ensure relative positioning for the container */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle .light-icon,
|
.theme-toggle .light-icon,
|
||||||
@@ -293,17 +367,14 @@
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
/* Center perfectly */
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Default state shows dark icon */
|
|
||||||
.theme-toggle .dark-icon {
|
.theme-toggle .dark-icon {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme shows light icon */
|
|
||||||
.theme-toggle.theme-light .light-icon {
|
.theme-toggle.theme-light .light-icon {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -313,7 +384,6 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme shows dark icon */
|
|
||||||
.theme-toggle.theme-dark .dark-icon {
|
.theme-toggle.theme-dark .dark-icon {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -323,7 +393,6 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Auto theme shows auto icon */
|
|
||||||
.theme-toggle.theme-auto .auto-icon {
|
.theme-toggle.theme-auto .auto-icon {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -333,6 +402,201 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-popover {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
background: var(--surface-base, #ffffff);
|
||||||
|
border: 1px solid var(--border-base, #e0e0e0);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
box-shadow: var(--shadow-xl, 0 4px 16px rgba(0, 0, 0, 0.15));
|
||||||
|
padding: 12px;
|
||||||
|
min-width: 220px;
|
||||||
|
z-index: calc(var(--z-overlay) + 1);
|
||||||
|
animation: theme-popover-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-popover.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes theme-popover-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-popover-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-popover-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary, #6c757d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-popover-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-base, #e0e0e0);
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-popover-modes {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border: 1px solid var(--border-base, #e0e0e0);
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
background: var(--surface-elevated, #ffffff);
|
||||||
|
color: var(--text-primary, #333333);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: background-color var(--transition-base, 200ms ease),
|
||||||
|
border-color var(--transition-base, 200ms ease),
|
||||||
|
color var(--transition-base, 200ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-mode-btn i {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-mode-btn:hover {
|
||||||
|
background: var(--surface-hover, oklch(95% 0.02 256));
|
||||||
|
border-color: var(--color-accent, oklch(68% 0.28 256));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-mode-btn.active {
|
||||||
|
background: var(--color-accent-subtle, oklch(68% 0.28 256 / 0.12));
|
||||||
|
border-color: var(--color-accent, oklch(68% 0.28 256));
|
||||||
|
color: var(--color-accent, oklch(68% 0.28 256));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-popover-presets {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preset-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border: 1px solid var(--border-base, #e0e0e0);
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
background: var(--surface-elevated, #ffffff);
|
||||||
|
color: var(--text-primary, #333333);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transition: background-color var(--transition-base, 200ms ease),
|
||||||
|
border-color var(--transition-base, 200ms ease),
|
||||||
|
color var(--transition-base, 200ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preset-btn:hover {
|
||||||
|
background: var(--surface-hover, oklch(95% 0.02 256));
|
||||||
|
border-color: var(--color-accent, oklch(68% 0.28 256));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preset-btn.active {
|
||||||
|
background: var(--color-accent-subtle, oklch(68% 0.28 256 / 0.12));
|
||||||
|
border-color: var(--color-accent, oklch(68% 0.28 256));
|
||||||
|
color: var(--color-accent, oklch(68% 0.28 256));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: var(--radius-xs, 4px);
|
||||||
|
border: 1px solid var(--border-subtle, oklch(72% 0.03 256 / 0.45));
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform var(--transition-base, 200ms ease),
|
||||||
|
box-shadow var(--transition-base, 200ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solid accent colors — each swatch shows the theme's accent color directly.
|
||||||
|
This matches the app's flat, token-driven design language instead of using
|
||||||
|
decorative gradients that clash with the matte aesthetic. */
|
||||||
|
|
||||||
|
.preset-swatch-default {
|
||||||
|
background: oklch(68% 0.28 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-swatch-nord {
|
||||||
|
background: oklch(62% 0.18 213);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-swatch-midnight {
|
||||||
|
background: oklch(52% 0.15 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-swatch-monokai {
|
||||||
|
background: oklch(72% 0.24 190);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-swatch-dracula {
|
||||||
|
background: oklch(68% 0.24 265);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-swatch-solarized {
|
||||||
|
background: oklch(55% 0.18 175);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preset-btn.active .preset-swatch {
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent, oklch(68% 0.28 256));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preset-btn:hover .preset-swatch {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode: use each preset's dark-mode accent lightness for visibility.
|
||||||
|
These match the --color-accent-l values from [data-theme="dark"][data-theme-preset="..."]
|
||||||
|
in tokens/colors.css so the swatch accurately previews what the theme looks like. */
|
||||||
|
|
||||||
|
[data-theme="dark"] .preset-swatch-default {
|
||||||
|
background: oklch(68% 0.28 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .preset-swatch-nord {
|
||||||
|
background: oklch(68% 0.18 213);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .preset-swatch-midnight {
|
||||||
|
background: oklch(68% 0.14 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .preset-swatch-monokai {
|
||||||
|
background: oklch(72% 0.24 190);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .preset-swatch-dracula {
|
||||||
|
background: oklch(72% 0.24 265);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .preset-swatch-solarized {
|
||||||
|
background: oklch(60% 0.18 175);
|
||||||
|
}
|
||||||
|
|
||||||
/* Badge styling */
|
/* Badge styling */
|
||||||
.update-badge {
|
.update-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -211,7 +211,7 @@
|
|||||||
|
|
||||||
.lora-item.is-early-access {
|
.lora-item.is-early-access {
|
||||||
background: rgba(0, 184, 122, 0.05);
|
background: rgba(0, 184, 122, 0.05);
|
||||||
border-left: 4px solid #00B87A;
|
border-left: 4px solid var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lora-item.missing-locally {
|
.lora-item.missing-locally {
|
||||||
@@ -310,7 +310,7 @@
|
|||||||
|
|
||||||
.missing-lora-item.is-early-access {
|
.missing-lora-item.is-early-access {
|
||||||
background: rgba(0, 184, 122, 0.05);
|
background: rgba(0, 184, 122, 0.05);
|
||||||
border-left: 3px solid #00B87A;
|
border-left: 3px solid var(--color-success);
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,7 +630,7 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: rgba(0, 184, 122, 0.1);
|
background: rgba(0, 184, 122, 0.1);
|
||||||
border: 1px solid #00B87A;
|
border: 1px solid var(--color-success);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
@@ -646,7 +646,7 @@
|
|||||||
|
|
||||||
/* Specific styling for the early access warning container in import modal */
|
/* Specific styling for the early access warning container in import modal */
|
||||||
.early-access-warning .warning-icon {
|
.early-access-warning .warning-icon {
|
||||||
color: #00B87A;
|
color: var(--color-success);
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
/* Keyboard navigation indicator and help */
|
|
||||||
.keyboard-nav-hint {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
cursor: help;
|
|
||||||
transition: var(--transition-base);
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-nav-hint:hover {
|
|
||||||
background: var(--lora-accent);
|
|
||||||
color: white;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-nav-hint i {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip styling */
|
|
||||||
.tooltip {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip .tooltiptext {
|
|
||||||
visibility: hidden;
|
|
||||||
width: 240px;
|
|
||||||
background-color: var(--lora-surface);
|
|
||||||
color: var(--text-color);
|
|
||||||
text-align: center;
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
padding: 8px;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 9999; /* Ensure tooltip appears above cards */
|
|
||||||
right: 120%; /* Position tooltip to the left of the icon */
|
|
||||||
top: 50%; /* Vertically center */
|
|
||||||
transform: translateY(-15%); /* Vertically center */
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
border: 1px solid var(--lora-border);
|
|
||||||
font-size: 0.85em;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip .tooltiptext::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 50%; /* Vertically center arrow */
|
|
||||||
left: 100%; /* Arrow on the right side */
|
|
||||||
margin-top: -5px;
|
|
||||||
border-width: 5px;
|
|
||||||
border-style: solid;
|
|
||||||
border-color: transparent transparent transparent var(--lora-border); /* Arrow points right */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip:hover .tooltiptext {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keyboard shortcuts table */
|
|
||||||
.keyboard-shortcuts {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-shortcuts td {
|
|
||||||
padding: 4px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-shortcuts td:first-child {
|
|
||||||
font-weight: bold;
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--bg-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
font-size: 0.8em;
|
|
||||||
box-shadow: var(--shadow-xs);
|
|
||||||
}
|
|
||||||
@@ -72,6 +72,10 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-header-actions .license-permissions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.license-restrictions {
|
.license-restrictions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -95,6 +99,41 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Set 2 — New style permission indicators */
|
||||||
|
.license-permissions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-icon-new {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--text-muted);
|
||||||
|
-webkit-mask: var(--license-icon-image) center/contain no-repeat;
|
||||||
|
mask: var(--license-icon-image) center/contain no-repeat;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
cursor: default;
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-icon-new.allowed {
|
||||||
|
background-color: var(--color-success, #40c057);
|
||||||
|
outline-color: color-mix(in oklch, var(--color-success, #40c057) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-icon-new.denied {
|
||||||
|
background-color: var(--color-error, #fa5252);
|
||||||
|
outline-color: color-mix(in oklch, var(--color-error, #fa5252) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-icon-new:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
/* Info Grid */
|
/* Info Grid */
|
||||||
.info-grid {
|
.info-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -229,6 +229,19 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Header row for params section */
|
||||||
|
.metadata-row.params-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-row.params-row .param-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Styling for parameters tags */
|
/* Styling for parameters tags */
|
||||||
.params-tags {
|
.params-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -272,13 +285,25 @@
|
|||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metadata-row.prompt-row .param-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-row.prompt-row .param-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.metadata-label {
|
.metadata-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-prompt-wrapper {
|
.metadata-prompt-wrapper {
|
||||||
@@ -286,7 +311,7 @@
|
|||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 6px 30px 6px 8px;
|
padding: 6px 8px;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
max-height: 80px; /* Reduced from 120px */
|
max-height: 80px; /* Reduced from 120px */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -302,22 +327,26 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-prompt-btn {
|
.copy-prompt-btn,
|
||||||
position: absolute;
|
.send-prompt-btn,
|
||||||
top: 6px;
|
.send-params-btn {
|
||||||
right: 6px;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 3px;
|
padding: 3px 6px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-prompt-btn:hover {
|
.copy-prompt-btn:hover,
|
||||||
|
.send-prompt-btn:hover,
|
||||||
|
.send-params-btn:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: var(--lora-accent);
|
color: var(--lora-accent);
|
||||||
|
background: var(--lora-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling for metadata panel */
|
/* Scrollbar styling for metadata panel */
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-tag-compact {
|
.model-tag-compact {
|
||||||
@@ -28,6 +30,9 @@
|
|||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
max-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for empty tags placeholder */
|
/* Style for empty tags placeholder */
|
||||||
@@ -118,8 +123,9 @@
|
|||||||
/* Model Tags Edit Mode */
|
/* Model Tags Edit Mode */
|
||||||
.model-tags-header {
|
.model-tags-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-tags-btn {
|
.edit-tags-btn {
|
||||||
@@ -132,6 +138,7 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
margin-left: var(--space-1);
|
margin-left: var(--space-1);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-tags-btn.visible,
|
.edit-tags-btn.visible,
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/* Metadata Refresh Result Modal — component styles only */
|
||||||
|
|
||||||
|
.metadata-refresh-result-modal {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-summary-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-value {
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--lora-text);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-success {
|
||||||
|
border-left-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-failure {
|
||||||
|
border-left-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-skipped {
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-total {
|
||||||
|
border-left-color: var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-time {
|
||||||
|
border-left-color: var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-failures-section {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-failures-section h4 {
|
||||||
|
margin: 0 0 var(--space-2) 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-error);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-failures-section h4 i {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table-wrapper {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
text-align: left;
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table td {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table tr:hover td {
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-index {
|
||||||
|
width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-name {
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-success-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border-left: 4px solid var(--color-success);
|
||||||
|
color: var(--lora-text);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-success-message i {
|
||||||
|
font-size: 1.2em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .failure-table th {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .failure-table td {
|
||||||
|
border-bottom-color: var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .failure-table tr:hover td {
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
@@ -822,3 +822,65 @@
|
|||||||
[data-theme="dark"] .batch-preview-item {
|
[data-theme="dark"] .batch-preview-item {
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hf-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: oklch(0.55 0.12 250 / 0.15);
|
||||||
|
color: oklch(0.7 0.12 250);
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Checkbox inside HF batch preview items */
|
||||||
|
.batch-preview-checkbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--lora-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select All toolbar in batch preview */
|
||||||
|
.batch-preview-select-all {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-select-all input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--lora-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-select-all label {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .batch-preview-select-all {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|||||||
@@ -335,7 +335,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* API key input specific styles */
|
/* API key input — CSS masking (prevents Chrome password manager triggers) */
|
||||||
|
.api-key-masked {
|
||||||
|
-webkit-text-security: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API key input specific styles (shared with proxy password) */
|
||||||
.api-key-input {
|
.api-key-input {
|
||||||
width: 100%; /* Take full width of parent */
|
width: 100%; /* Take full width of parent */
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -345,7 +350,7 @@
|
|||||||
|
|
||||||
.api-key-input input {
|
.api-key-input input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 40px 6px 10px; /* Add left padding */
|
padding: 6px 40px 6px 10px; /* Right padding for eye button */
|
||||||
height: 32px;
|
height: 32px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
@@ -353,6 +358,13 @@
|
|||||||
background-color: var(--lora-surface);
|
background-color: var(--lora-surface);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-input input:focus {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-key-input .toggle-visibility {
|
.api-key-input .toggle-visibility {
|
||||||
@@ -364,12 +376,98 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-key-input .toggle-visibility:hover {
|
.api-key-input .toggle-visibility:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* API key item — stack status/edit views vertically for smooth cross-fade */
|
||||||
|
.api-key-item .setting-control {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API key status display (shown when not editing) */
|
||||||
|
.api-key-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||||
|
max-height: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-status.is-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
pointer-events: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-status-text {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status color modifiers — replace inline styles */
|
||||||
|
.api-key-status--configured .fa-check-circle {
|
||||||
|
color: var(--lora-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-status--unconfigured .fa-times-circle {
|
||||||
|
color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes for status icon colors (used by JS) */
|
||||||
|
.text-success {
|
||||||
|
color: var(--lora-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API key inline edit container — flex row with input + buttons */
|
||||||
|
.api-key-edit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||||
|
max-height: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-edit.is-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
pointer-events: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-edit .api-key-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-edit .primary-btn,
|
||||||
|
.api-key-edit .secondary-btn {
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Text input wrapper styles for consistent input styling */
|
/* Text input wrapper styles for consistent input styling */
|
||||||
.text-input-wrapper {
|
.text-input-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -823,54 +921,107 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.range-control input[type="range"] {
|
.range-control input[type="range"] {
|
||||||
|
--range-fill: 40%;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 4px;
|
height: 6px;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: var(--border-color);
|
background: linear-gradient(
|
||||||
border-radius: 2px;
|
to right,
|
||||||
|
var(--lora-accent) 0%,
|
||||||
|
var(--lora-accent) var(--range-fill),
|
||||||
|
var(--border-color) var(--range-fill),
|
||||||
|
var(--border-color) 100%
|
||||||
|
);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-control input[type="range"]:focus-visible {
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-control input[type="range"]::-webkit-slider-thumb {
|
.range-control input[type="range"]::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 16px;
|
width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--lora-accent);
|
background: var(--lora-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 2px solid var(--lora-surface);
|
border: 2px solid var(--lora-surface);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
box-shadow: var(--shadow-md);
|
||||||
transition: transform 0.15s ease;
|
transition: transform var(--transition-bounce), box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-control input[type="range"]::-webkit-slider-thumb:hover {
|
.range-control input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
transform: scale(1.15);
|
transform: scale(1.2);
|
||||||
|
box-shadow: var(--shadow-md), 0 0 0 4px var(--color-accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-control input[type="range"]::-webkit-slider-thumb:active {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: var(--shadow-md), 0 0 0 6px var(--color-accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-control input[type="range"]:focus-visible::-webkit-slider-thumb {
|
||||||
|
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-accent-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-control input[type="range"]::-moz-range-thumb {
|
.range-control input[type="range"]::-moz-range-thumb {
|
||||||
width: 16px;
|
width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--lora-accent);
|
background: var(--lora-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 2px solid var(--lora-surface);
|
border: 2px solid var(--lora-surface);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
box-shadow: var(--shadow-md);
|
||||||
|
transition: transform var(--transition-bounce), box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-control input[type="range"]::-moz-range-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
box-shadow: var(--shadow-md), 0 0 0 4px var(--color-accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-control input[type="range"]::-moz-range-thumb:active {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: var(--shadow-md), 0 0 0 6px var(--color-accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-control input[type="range"]::-moz-range-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-control .range-value {
|
.range-control .range-value {
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.9em;
|
font-size: 0.85em;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--text-color);
|
color: var(--lora-accent);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .range-control input[type="range"] {
|
[data-theme="dark"] .range-control input[type="range"] {
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
var(--lora-accent) 0%,
|
||||||
|
var(--lora-accent) var(--range-fill),
|
||||||
|
rgba(255, 255, 255, 0.15) var(--range-fill),
|
||||||
|
rgba(255, 255, 255, 0.15) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .range-control input[type="range"]::-moz-range-track {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#recipeTagsContainer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.recipe-modal-header h2 {
|
.recipe-modal-header h2 {
|
||||||
margin: 0 0 var(--space-1);
|
margin: 0 0 var(--space-1);
|
||||||
padding: var(--space-1);
|
padding: var(--space-1);
|
||||||
@@ -95,127 +99,11 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-editor.tags-editor input {
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove obsolete button styles */
|
/* Remove obsolete button styles */
|
||||||
.editor-actions {
|
.editor-actions {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Special styling for tags content */
|
|
||||||
.tags-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-display {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-tags {
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: var(--text-color);
|
|
||||||
opacity: 0.6;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Recipe Tags styles */
|
|
||||||
.recipe-tags-container {
|
|
||||||
position: relative;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tags-compact {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tag-compact {
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 0.75em;
|
|
||||||
color: var(--text-color);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .recipe-tag-compact {
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
border: 1px solid var(--lora-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tag-more {
|
|
||||||
background: var(--lora-accent);
|
|
||||||
color: var(--lora-text);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 0.75em;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tags-tooltip {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 8px);
|
|
||||||
left: 0;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
box-shadow: var(--shadow-dropdown);
|
|
||||||
padding: 10px 14px;
|
|
||||||
max-width: 400px;
|
|
||||||
z-index: 10;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transform: translateY(-4px);
|
|
||||||
transition: var(--transition-base);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tags-tooltip.visible {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateY(0);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-content {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-tag {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
padding: 3px 8px;
|
|
||||||
font-size: 0.75em;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .tooltip-tag {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
border: 1px solid var(--lora-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
#recipeModal .modal-content {
|
#recipeModal .modal-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1153,7 +1041,7 @@
|
|||||||
max-height: 2.4em;
|
max-height: 2.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-tags-container {
|
#recipeTagsContainer {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,69 +8,28 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: var(--transition-slow);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
box-shadow: var(--shadow-header);
|
box-shadow: var(--shadow-header);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
/* Default state: hidden off-screen */
|
/* Default: hidden off-screen — prevents flash before JS runs */
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-sidebar.hidden-by-setting {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Visible state */
|
|
||||||
.folder-sidebar.visible {
|
.folder-sidebar.visible {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Auto-hide states */
|
.folder-sidebar.hidden-by-setting {
|
||||||
.folder-sidebar.auto-hide {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-sidebar.auto-hide.hover-active {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-sidebar.collapsed {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover detection area for auto-hide */
|
|
||||||
.sidebar-hover-area {
|
|
||||||
position: fixed;
|
|
||||||
top: 68px;
|
|
||||||
left: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: calc(100vh - 88px);
|
|
||||||
z-index: calc(var(--z-overlay) - 1);
|
|
||||||
background: transparent;
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-hover-area.hidden-by-setting {
|
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-hover-area.disabled {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -151,74 +110,14 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Sidebar More Options Dropdown ===== */
|
|
||||||
.sidebar-more-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
right: 8px;
|
|
||||||
min-width: 190px;
|
|
||||||
background: var(--bg-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
z-index: calc(var(--z-overlay) + 20);
|
|
||||||
display: none;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-more-dropdown.open {
|
|
||||||
display: block;
|
|
||||||
animation: dropdownFadeIn 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dropdownFadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(-4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-dropdown-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: var(--text-color);
|
|
||||||
transition: var(--transition-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-dropdown-item:hover {
|
|
||||||
background: var(--lora-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-dropdown-item i {
|
|
||||||
width: 16px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.9em;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-dropdown-item:hover i {
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-dropdown-item.disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Sidebar Hidden Indicator (left edge) ===== */
|
/* ===== Sidebar Hidden Indicator (left edge) ===== */
|
||||||
.sidebar-hidden-indicator {
|
.sidebar-hidden-indicator {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 50%;
|
top: 68px; /* Align with sidebar header */
|
||||||
transform: translateY(-50%);
|
|
||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 44px;
|
height: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -235,7 +134,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-hidden-indicator i {
|
.sidebar-hidden-indicator i {
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
transition: color 0.15s ease;
|
transition: color 0.15s ease;
|
||||||
}
|
}
|
||||||
@@ -244,6 +143,21 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtle breathing animation for first-time discovery */
|
||||||
|
@keyframes sidebarBreathing {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 0.65; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-hidden-indicator.breathing {
|
||||||
|
animation: sidebarBreathing 2.5s ease-in-out infinite;
|
||||||
|
animation-delay: 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-hidden-indicator.breathing:hover {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-hidden-indicator-tooltip {
|
.sidebar-hidden-indicator-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 100%;
|
left: 100%;
|
||||||
@@ -630,7 +544,7 @@
|
|||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design — Mobile: overlay when shown */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.folder-sidebar {
|
.folder-sidebar {
|
||||||
top: 68px;
|
top: 68px;
|
||||||
@@ -641,12 +555,8 @@
|
|||||||
z-index: calc(var(--z-overlay) + 10);
|
z-index: calc(var(--z-overlay) + 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-sidebar.collapsed {
|
/* Mobile overlay when sidebar is shown */
|
||||||
transform: translateX(-100%);
|
.folder-sidebar.visible::before {
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile overlay */
|
|
||||||
.folder-sidebar:not(.collapsed)::before {
|
|
||||||
content: '';
|
content: '';
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -264,6 +264,174 @@
|
|||||||
box-shadow: 0 0 0 2px oklch(var(--lora-accent) / 0.15);
|
box-shadow: 0 0 0 2px oklch(var(--lora-accent) / 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disabled sort dropdown — used when VLM custom filter is active */
|
||||||
|
.control-group select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group select:disabled:hover {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Sort dropdown — decoupled trigger width ===========================
|
||||||
|
The native <select> sizes its trigger to the widest <option>, wasting
|
||||||
|
horizontal space when a short option is selected. This custom trigger
|
||||||
|
sizes to the currently selected text only; the dropdown menu sizes to
|
||||||
|
its content independently. The native <select> is kept in the DOM
|
||||||
|
(visually hidden) so existing JS that reads/writes `.value` / `.disabled`
|
||||||
|
and dynamically adds/removes <option>s keeps working. */
|
||||||
|
|
||||||
|
.sort-dropdown-group {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 240px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger:hover,
|
||||||
|
.sort-trigger:focus-visible {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: var(--bg-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger__label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger__caret {
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: transform var(--transition-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group.active .sort-trigger__caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group.active .sort-trigger {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in oklch, var(--lora-accent) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled state — mirrors the native :disabled look (used when VLM is active) */
|
||||||
|
.sort-dropdown-group.is-disabled .sort-trigger {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown menu — sizes to its content, independent of trigger width.
|
||||||
|
Inherits base .dropdown-menu styling; capped for very long i18n text. */
|
||||||
|
.sort-dropdown-menu {
|
||||||
|
min-width: max-content;
|
||||||
|
max-width: 320px;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optgroup label rendered as a section header */
|
||||||
|
.sort-dropdown-group .sort-optgroup-label {
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-optgroup-label:first-child {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Option items */
|
||||||
|
.sort-dropdown-group .sort-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-option::before {
|
||||||
|
content: '';
|
||||||
|
width: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-option:hover {
|
||||||
|
background-color: color-mix(in oklch, var(--lora-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-option.is-selected {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-option.is-selected::before {
|
||||||
|
content: '\2713';
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visually hidden native <select> — kept in the DOM for programmatic access.
|
||||||
|
High-specificity selector overrides .control-group select { min-width: 100px }. */
|
||||||
|
.control-group .sort-select-native {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ensure hidden class works properly */
|
/* Ensure hidden class works properly */
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
transition: var(--transition-slow);
|
transition: var(--transition-slow);
|
||||||
/* Add glow effect */
|
/* Add glow effect */
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 2px rgba(24, 144, 255, 0.3),
|
0 0 0 2px color-mix(in oklch, var(--color-accent) 30%, transparent),
|
||||||
0 0 20px rgba(24, 144, 255, 0.2),
|
0 0 20px color-mix(in oklch, var(--color-accent) 20%, transparent),
|
||||||
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,14 +221,14 @@
|
|||||||
@keyframes onboarding-pulse {
|
@keyframes onboarding-pulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 2px rgba(24, 144, 255, 0.4),
|
0 0 0 2px color-mix(in oklch, var(--color-accent) 40%, transparent),
|
||||||
0 0 20px rgba(24, 144, 255, 0.3),
|
0 0 20px color-mix(in oklch, var(--color-accent) 30%, transparent),
|
||||||
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 4px rgba(24, 144, 255, 0.6),
|
0 0 0 4px color-mix(in oklch, var(--color-accent) 60%, transparent),
|
||||||
0 0 30px rgba(24, 144, 255, 0.4),
|
0 0 30px color-mix(in oklch, var(--color-accent) 40%, transparent),
|
||||||
inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,10 +36,11 @@
|
|||||||
@import 'components/initialization.css';
|
@import 'components/initialization.css';
|
||||||
@import 'components/progress-panel.css';
|
@import 'components/progress-panel.css';
|
||||||
@import 'components/duplicates.css'; /* Add duplicates component */
|
@import 'components/duplicates.css'; /* Add duplicates component */
|
||||||
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
|
||||||
@import 'components/statistics.css'; /* Add statistics component */
|
@import 'components/statistics.css'; /* Add statistics component */
|
||||||
@import 'components/sidebar.css'; /* Add sidebar component */
|
@import 'components/sidebar.css'; /* Add sidebar component */
|
||||||
@import 'components/media-viewer.css';
|
@import 'components/media-viewer.css';
|
||||||
|
@import 'components/metadata-refresh-result.css';
|
||||||
|
|
||||||
.initialization-notice {
|
.initialization-notice {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -58,3 +59,5 @@
|
|||||||
.initialization-notice .loading-spinner {
|
.initialization-notice .loading-spinner {
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- reused from shared styles ---------- */
|
||||||
|
|||||||
@@ -37,13 +37,13 @@
|
|||||||
--color-error-border: color-mix(in oklch, var(--color-error) 50%, transparent);
|
--color-error-border: color-mix(in oklch, var(--color-error) 50%, transparent);
|
||||||
|
|
||||||
--color-info: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h));
|
--color-info: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h));
|
||||||
--color-info-bg: oklch(72% 0.2 220);
|
--color-info-bg: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h));
|
||||||
--color-info-text: oklch(28% 0.03 220);
|
--color-info-text: oklch(28% 0.03 var(--color-info-h));
|
||||||
--color-info-glow: oklch(72% 0.2 220 / 0.28);
|
--color-info-glow: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h) / 0.28);
|
||||||
|
|
||||||
--color-skip-refresh-bg: oklch(82% 0.12 45);
|
--color-skip-refresh-bg: oklch(82% 0.12 var(--color-warning-h));
|
||||||
--color-skip-refresh-text: oklch(35% 0.02 45);
|
--color-skip-refresh-text: oklch(35% 0.02 var(--color-warning-h));
|
||||||
--color-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
|
--color-skip-refresh-glow: oklch(82% 0.12 var(--color-warning-h) / 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -106,12 +106,360 @@
|
|||||||
--status-info-bg: oklch(50% 0.10 190 / 0.25);
|
--status-info-bg: oklch(50% 0.10 190 / 0.25);
|
||||||
--status-info-border: oklch(55% 0.12 195 / 0.3);
|
--status-info-border: oklch(55% 0.12 195 / 0.3);
|
||||||
|
|
||||||
--color-info-bg: oklch(62% 0.18 220);
|
--color-info-bg: oklch(62% 0.18 var(--color-info-h));
|
||||||
--color-info-text: oklch(98% 0.02 240);
|
--color-info-text: oklch(98% 0.02 var(--color-info-h));
|
||||||
--color-info-glow: oklch(62% 0.18 220 / 0.4);
|
--color-info-glow: oklch(62% 0.18 var(--color-info-h) / 0.4);
|
||||||
|
|
||||||
--color-error-bg: color-mix(in oklch, var(--color-error) 15%, transparent);
|
--color-error-bg: color-mix(in oklch, var(--color-error) 15%, transparent);
|
||||||
--color-error-border: color-mix(in oklch, var(--color-error) 40%, transparent);
|
--color-error-border: color-mix(in oklch, var(--color-error) 40%, transparent);
|
||||||
|
|
||||||
--favorite-color: #ffc107;
|
--favorite-color: #ffc107;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Preset: Nord ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-theme-preset="nord"] {
|
||||||
|
--color-accent-h: 213;
|
||||||
|
--color-accent-c: 0.18;
|
||||||
|
--color-accent-l: 62%;
|
||||||
|
--color-warning-h: 35;
|
||||||
|
--color-warning-c: 0.18;
|
||||||
|
--color-success-h: 130;
|
||||||
|
--color-error-l: 62%;
|
||||||
|
--color-error-c: 0.22;
|
||||||
|
--color-error-h: 5;
|
||||||
|
--color-info-h: 195;
|
||||||
|
--color-info-c: 0.18;
|
||||||
|
|
||||||
|
--bg-base: oklch(96% 0.01 240);
|
||||||
|
--bg-elevated: oklch(98% 0.008 240 / 0.95);
|
||||||
|
--bg-hover: oklch(93% 0.02 240);
|
||||||
|
--bg-disabled: oklch(92% 0.01 240);
|
||||||
|
|
||||||
|
--text-primary: oklch(22% 0.03 260);
|
||||||
|
--text-secondary: oklch(48% 0.03 260);
|
||||||
|
--text-inverse: oklch(97% 0.01 240);
|
||||||
|
|
||||||
|
--surface-base: oklch(97% 0.01 240);
|
||||||
|
--surface-elevated: oklch(98% 0.008 240 / 0.95);
|
||||||
|
--surface-hover: oklch(93% 0.02 240);
|
||||||
|
--surface-subtle: oklch(0% 0 0 / 0.03);
|
||||||
|
|
||||||
|
--border-base: oklch(82% 0.03 240);
|
||||||
|
--border-subtle: oklch(82% 0.03 240 / 0.45);
|
||||||
|
|
||||||
|
--favorite-color: oklch(72% 0.14 85);
|
||||||
|
--favorite-glow: oklch(72% 0.14 85 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"][data-theme-preset="nord"] {
|
||||||
|
--color-accent-h: 213;
|
||||||
|
--color-accent-c: 0.18;
|
||||||
|
--color-accent-l: 68%;
|
||||||
|
--color-warning-h: 35;
|
||||||
|
--color-warning-c: 0.18;
|
||||||
|
--color-success-h: 130;
|
||||||
|
--color-error-l: 65%;
|
||||||
|
--color-error-c: 0.22;
|
||||||
|
--color-error-h: 5;
|
||||||
|
--color-info-h: 195;
|
||||||
|
--color-info-c: 0.18;
|
||||||
|
|
||||||
|
--bg-base: oklch(20% 0.03 260);
|
||||||
|
--bg-elevated: oklch(24% 0.03 260 / 0.98);
|
||||||
|
--bg-hover: oklch(30% 0.03 260);
|
||||||
|
--bg-disabled: oklch(30% 0.02 260);
|
||||||
|
|
||||||
|
--text-primary: oklch(87% 0.02 240);
|
||||||
|
--text-secondary: oklch(68% 0.02 240);
|
||||||
|
--text-inverse: oklch(20% 0.03 260);
|
||||||
|
|
||||||
|
--surface-base: oklch(26% 0.03 260);
|
||||||
|
--surface-elevated: oklch(24% 0.03 260 / 0.98);
|
||||||
|
--surface-hover: oklch(30% 0.03 260);
|
||||||
|
--surface-subtle: oklch(100% 0 0 / 0.03);
|
||||||
|
|
||||||
|
--border-base: oklch(38% 0.03 260);
|
||||||
|
--border-subtle: oklch(87% 0.02 240 / 0.15);
|
||||||
|
|
||||||
|
--favorite-color: oklch(78% 0.15 85);
|
||||||
|
--favorite-glow: oklch(78% 0.15 85 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preset: Midnight ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-theme-preset="midnight"] {
|
||||||
|
--color-accent-h: 300;
|
||||||
|
--color-accent-c: 0.15;
|
||||||
|
--color-accent-l: 52%;
|
||||||
|
--color-warning-h: 50;
|
||||||
|
--color-warning-c: 0.18;
|
||||||
|
--color-success-h: 135;
|
||||||
|
--color-error-h: 5;
|
||||||
|
--color-error-l: 62%;
|
||||||
|
--color-error-c: 0.22;
|
||||||
|
--color-info-h: 195;
|
||||||
|
--color-info-c: 0.12;
|
||||||
|
|
||||||
|
--bg-base: oklch(96% 0.01 255);
|
||||||
|
--bg-elevated: oklch(98% 0.008 255 / 0.95);
|
||||||
|
--bg-hover: oklch(93% 0.02 255);
|
||||||
|
--bg-disabled: oklch(92% 0.01 255);
|
||||||
|
|
||||||
|
--text-primary: oklch(22% 0.03 260);
|
||||||
|
--text-secondary: oklch(48% 0.03 260);
|
||||||
|
--text-inverse: oklch(97% 0.01 255);
|
||||||
|
|
||||||
|
--surface-base: oklch(97% 0.01 255);
|
||||||
|
--surface-elevated: oklch(98% 0.008 255 / 0.95);
|
||||||
|
--surface-hover: oklch(93% 0.02 255);
|
||||||
|
--surface-subtle: oklch(0% 0 0 / 0.03);
|
||||||
|
|
||||||
|
--border-base: oklch(80% 0.03 255);
|
||||||
|
--border-subtle: oklch(80% 0.03 255 / 0.45);
|
||||||
|
|
||||||
|
--favorite-color: oklch(72% 0.16 85);
|
||||||
|
--favorite-glow: oklch(72% 0.16 85 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"][data-theme-preset="midnight"] {
|
||||||
|
--color-accent-h: 300;
|
||||||
|
--color-accent-c: 0.14;
|
||||||
|
--color-accent-l: 68%;
|
||||||
|
--color-warning-h: 50;
|
||||||
|
--color-warning-c: 0.18;
|
||||||
|
--color-success-h: 135;
|
||||||
|
--color-error-h: 5;
|
||||||
|
--color-error-l: 65%;
|
||||||
|
--color-error-c: 0.22;
|
||||||
|
--color-info-h: 195;
|
||||||
|
--color-info-c: 0.12;
|
||||||
|
|
||||||
|
--bg-base: oklch(18% 0.03 260);
|
||||||
|
--bg-elevated: oklch(22% 0.03 260 / 0.98);
|
||||||
|
--bg-hover: oklch(28% 0.03 260);
|
||||||
|
--bg-disabled: oklch(28% 0.02 260);
|
||||||
|
|
||||||
|
--text-primary: oklch(88% 0.02 255);
|
||||||
|
--text-secondary: oklch(68% 0.02 255);
|
||||||
|
--text-inverse: oklch(18% 0.03 260);
|
||||||
|
|
||||||
|
--surface-base: oklch(24% 0.03 260);
|
||||||
|
--surface-elevated: oklch(22% 0.03 260 / 0.98);
|
||||||
|
--surface-hover: oklch(28% 0.03 260);
|
||||||
|
--surface-subtle: oklch(100% 0 0 / 0.03);
|
||||||
|
|
||||||
|
--border-base: oklch(36% 0.03 260);
|
||||||
|
--border-subtle: oklch(88% 0.02 255 / 0.15);
|
||||||
|
|
||||||
|
--favorite-color: oklch(78% 0.16 85);
|
||||||
|
--favorite-glow: oklch(78% 0.16 85 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preset: Monokai ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-theme-preset="monokai"] {
|
||||||
|
--color-accent-h: 190;
|
||||||
|
--color-accent-c: 0.24;
|
||||||
|
--color-accent-l: 72%;
|
||||||
|
--color-warning-h: 50;
|
||||||
|
--color-warning-c: 0.22;
|
||||||
|
--color-success-h: 140;
|
||||||
|
--color-error-l: 60%;
|
||||||
|
--color-error-c: 0.22;
|
||||||
|
--color-error-h: 340;
|
||||||
|
--color-info-h: 250;
|
||||||
|
|
||||||
|
--bg-base: oklch(96% 0.01 80);
|
||||||
|
--bg-elevated: oklch(98% 0.005 80 / 0.95);
|
||||||
|
--bg-hover: oklch(93% 0.015 80);
|
||||||
|
--bg-disabled: oklch(92% 0.01 80);
|
||||||
|
|
||||||
|
--text-primary: oklch(20% 0.02 100);
|
||||||
|
--text-secondary: oklch(45% 0.02 100);
|
||||||
|
--text-inverse: oklch(97% 0.01 80);
|
||||||
|
|
||||||
|
--surface-base: oklch(97% 0.008 80);
|
||||||
|
--surface-elevated: oklch(98% 0.005 80 / 0.95);
|
||||||
|
--surface-hover: oklch(93% 0.015 80);
|
||||||
|
--surface-subtle: oklch(0% 0 0 / 0.03);
|
||||||
|
|
||||||
|
--border-base: oklch(80% 0.02 80);
|
||||||
|
--border-subtle: oklch(80% 0.02 80 / 0.45);
|
||||||
|
|
||||||
|
--favorite-color: oklch(72% 0.16 85);
|
||||||
|
--favorite-glow: oklch(72% 0.16 85 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"][data-theme-preset="monokai"] {
|
||||||
|
--color-accent-h: 190;
|
||||||
|
--color-accent-c: 0.24;
|
||||||
|
--color-accent-l: 72%;
|
||||||
|
--color-warning-h: 50;
|
||||||
|
--color-warning-c: 0.22;
|
||||||
|
--color-success-h: 140;
|
||||||
|
--color-error-l: 65%;
|
||||||
|
--color-error-c: 0.22;
|
||||||
|
--color-error-h: 340;
|
||||||
|
--color-info-h: 250;
|
||||||
|
|
||||||
|
--bg-base: oklch(18% 0.02 100);
|
||||||
|
--bg-elevated: oklch(22% 0.02 100 / 0.98);
|
||||||
|
--bg-hover: oklch(28% 0.025 100);
|
||||||
|
--bg-disabled: oklch(28% 0.015 100);
|
||||||
|
|
||||||
|
--text-primary: oklch(90% 0.02 80);
|
||||||
|
--text-secondary: oklch(70% 0.02 80);
|
||||||
|
--text-inverse: oklch(18% 0.02 100);
|
||||||
|
|
||||||
|
--surface-base: oklch(24% 0.02 100);
|
||||||
|
--surface-elevated: oklch(22% 0.02 100 / 0.98);
|
||||||
|
--surface-hover: oklch(28% 0.025 100);
|
||||||
|
--surface-subtle: oklch(100% 0 0 / 0.03);
|
||||||
|
|
||||||
|
--border-base: oklch(36% 0.02 100);
|
||||||
|
--border-subtle: oklch(90% 0.02 80 / 0.15);
|
||||||
|
|
||||||
|
--favorite-color: oklch(78% 0.16 85);
|
||||||
|
--favorite-glow: oklch(78% 0.16 85 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preset: Dracula ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-theme-preset="dracula"] {
|
||||||
|
--color-accent-h: 265;
|
||||||
|
--color-accent-c: 0.24;
|
||||||
|
--color-accent-l: 68%;
|
||||||
|
--color-warning-h: 45;
|
||||||
|
--color-warning-c: 0.22;
|
||||||
|
--color-success-h: 135;
|
||||||
|
--color-error-l: 62%;
|
||||||
|
--color-error-c: 0.22;
|
||||||
|
--color-error-h: 350;
|
||||||
|
--color-info-h: 195;
|
||||||
|
|
||||||
|
--bg-base: oklch(96% 0.01 290);
|
||||||
|
--bg-elevated: oklch(98% 0.008 290 / 0.95);
|
||||||
|
--bg-hover: oklch(93% 0.02 290);
|
||||||
|
--bg-disabled: oklch(92% 0.01 290);
|
||||||
|
|
||||||
|
--text-primary: oklch(22% 0.04 290);
|
||||||
|
--text-secondary: oklch(48% 0.04 290);
|
||||||
|
--text-inverse: oklch(97% 0.01 290);
|
||||||
|
|
||||||
|
--surface-base: oklch(97% 0.01 290);
|
||||||
|
--surface-elevated: oklch(98% 0.008 290 / 0.95);
|
||||||
|
--surface-hover: oklch(93% 0.02 290);
|
||||||
|
--surface-subtle: oklch(0% 0 0 / 0.03);
|
||||||
|
|
||||||
|
--border-base: oklch(80% 0.04 290);
|
||||||
|
--border-subtle: oklch(80% 0.04 290 / 0.45);
|
||||||
|
|
||||||
|
--favorite-color: oklch(72% 0.16 85);
|
||||||
|
--favorite-glow: oklch(72% 0.16 85 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"][data-theme-preset="dracula"] {
|
||||||
|
--color-accent-h: 265;
|
||||||
|
--color-accent-c: 0.24;
|
||||||
|
--color-accent-l: 72%;
|
||||||
|
--color-warning-h: 45;
|
||||||
|
--color-warning-c: 0.22;
|
||||||
|
--color-success-h: 135;
|
||||||
|
--color-error-l: 65%;
|
||||||
|
--color-error-c: 0.22;
|
||||||
|
--color-error-h: 350;
|
||||||
|
--color-info-h: 195;
|
||||||
|
|
||||||
|
--bg-base: oklch(18% 0.04 290);
|
||||||
|
--bg-elevated: oklch(22% 0.04 290 / 0.98);
|
||||||
|
--bg-hover: oklch(28% 0.04 290);
|
||||||
|
--bg-disabled: oklch(28% 0.03 290);
|
||||||
|
|
||||||
|
--text-primary: oklch(90% 0.02 290);
|
||||||
|
--text-secondary: oklch(70% 0.03 290);
|
||||||
|
--text-inverse: oklch(18% 0.04 290);
|
||||||
|
|
||||||
|
--surface-base: oklch(24% 0.04 290);
|
||||||
|
--surface-elevated: oklch(22% 0.04 290 / 0.98);
|
||||||
|
--surface-hover: oklch(28% 0.04 290);
|
||||||
|
--surface-subtle: oklch(100% 0 0 / 0.03);
|
||||||
|
|
||||||
|
--border-base: oklch(36% 0.04 290);
|
||||||
|
--border-subtle: oklch(90% 0.02 290 / 0.15);
|
||||||
|
|
||||||
|
--favorite-color: oklch(78% 0.16 85);
|
||||||
|
--favorite-glow: oklch(78% 0.16 85 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preset: Solarized ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-theme-preset="solarized"] {
|
||||||
|
--color-accent-h: 175;
|
||||||
|
--color-accent-c: 0.18;
|
||||||
|
--color-accent-l: 55%;
|
||||||
|
--color-warning-h: 45;
|
||||||
|
--color-warning-c: 0.20;
|
||||||
|
--color-success-h: 68;
|
||||||
|
--color-error-l: 62%;
|
||||||
|
--color-error-c: 0.22;
|
||||||
|
--color-error-h: 5;
|
||||||
|
--color-info-h: 220;
|
||||||
|
--color-info-c: 0.16;
|
||||||
|
--color-info-l: 68%;
|
||||||
|
|
||||||
|
--bg-base: oklch(95% 0.03 85);
|
||||||
|
--bg-elevated: oklch(97% 0.025 85 / 0.95);
|
||||||
|
--bg-hover: oklch(91% 0.035 85);
|
||||||
|
--bg-disabled: oklch(90% 0.025 85);
|
||||||
|
|
||||||
|
--text-primary: oklch(30% 0.06 200);
|
||||||
|
--text-secondary: oklch(50% 0.04 200);
|
||||||
|
--text-inverse: oklch(95% 0.03 85);
|
||||||
|
|
||||||
|
--surface-base: oklch(96% 0.025 85);
|
||||||
|
--surface-elevated: oklch(97% 0.025 85 / 0.95);
|
||||||
|
--surface-hover: oklch(91% 0.035 85);
|
||||||
|
--surface-subtle: oklch(0% 0 0 / 0.03);
|
||||||
|
|
||||||
|
--border-base: oklch(78% 0.04 85);
|
||||||
|
--border-subtle: oklch(78% 0.04 85 / 0.45);
|
||||||
|
|
||||||
|
--favorite-color: oklch(68% 0.16 75);
|
||||||
|
--favorite-glow: oklch(68% 0.16 75 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"][data-theme-preset="solarized"] {
|
||||||
|
--color-accent-h: 175;
|
||||||
|
--color-accent-c: 0.18;
|
||||||
|
--color-accent-l: 60%;
|
||||||
|
--color-warning-h: 45;
|
||||||
|
--color-warning-c: 0.20;
|
||||||
|
--color-success-h: 68;
|
||||||
|
--color-error-l: 65%;
|
||||||
|
--color-error-c: 0.22;
|
||||||
|
--color-error-h: 5;
|
||||||
|
--color-info-h: 220;
|
||||||
|
--color-info-c: 0.16;
|
||||||
|
--color-info-l: 68%;
|
||||||
|
|
||||||
|
--bg-base: oklch(18% 0.05 200);
|
||||||
|
--bg-elevated: oklch(22% 0.05 200 / 0.98);
|
||||||
|
--bg-hover: oklch(28% 0.05 200);
|
||||||
|
--bg-disabled: oklch(28% 0.04 200);
|
||||||
|
|
||||||
|
--text-primary: oklch(72% 0.03 85);
|
||||||
|
--text-secondary: oklch(62% 0.03 85);
|
||||||
|
--text-inverse: oklch(18% 0.05 200);
|
||||||
|
|
||||||
|
--surface-base: oklch(24% 0.05 200);
|
||||||
|
--surface-elevated: oklch(22% 0.05 200 / 0.98);
|
||||||
|
--surface-hover: oklch(28% 0.05 200);
|
||||||
|
--surface-subtle: oklch(100% 0 0 / 0.03);
|
||||||
|
|
||||||
|
--border-base: oklch(36% 0.04 200);
|
||||||
|
--border-subtle: oklch(72% 0.03 85 / 0.15);
|
||||||
|
|
||||||
|
--favorite-color: oklch(72% 0.16 75);
|
||||||
|
--favorite-glow: oklch(72% 0.16 75 / 0.5);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-brush"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 21v-4a4 4 0 1 1 4 4h-4" /><path d="M21 3a16 16 0 0 0 -12.8 10.2" /><path d="M21 3a16 16 0 0 1 -10.2 12.8" /><path d="M10.6 9a9 9 0 0 1 4.4 4.4" /></svg>
|
||||||
|
After Width: | Height: | Size: 460 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-currency-dollar"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M16.7 8a3 3 0 0 0 -2.7 -2h-4a3 3 0 0 0 0 6h4a3 3 0 0 1 0 6h-4a3 3 0 0 1 -2.7 -2" /><path d="M12 3v3m0 12v3" /></svg>
|
||||||
|
After Width: | Height: | Size: 431 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-git-merge"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 18a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M5 6a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M15 12a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M7 8l0 8" /><path d="M7 8a4 4 0 0 0 4 4h4" /></svg>
|
||||||
|
After Width: | Height: | Size: 501 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-license"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 21h-9a3 3 0 0 1 -3 -3v-1h10v2a2 2 0 0 0 4 0v-14a2 2 0 1 1 2 2h-2m2 -4h-11a3 3 0 0 0 -3 3v11" /><path d="M9 7l4 0" /><path d="M9 11l4 0" /></svg>
|
||||||
|
After Width: | Height: | Size: 455 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-user"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>
|
||||||
|
After Width: | Height: | Size: 401 B |
@@ -190,6 +190,12 @@ export const DOWNLOAD_ENDPOINTS = {
|
|||||||
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images
|
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hugging Face API endpoints
|
||||||
|
export const HF_ENDPOINTS = {
|
||||||
|
repoFiles: '/api/lm/hf-repo-files',
|
||||||
|
download: '/api/lm/download-hf-model',
|
||||||
|
};
|
||||||
|
|
||||||
// WebSocket endpoints
|
// WebSocket endpoints
|
||||||
export const WS_ENDPOINTS = {
|
export const WS_ENDPOINTS = {
|
||||||
fetchProgress: '/ws/fetch-progress'
|
fetchProgress: '/ws/fetch-progress'
|
||||||
|
|||||||
+368
-28
@@ -1,12 +1,13 @@
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
import { getStorageItem, getSessionItem, removeSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
||||||
import {
|
import {
|
||||||
getCompleteApiConfig,
|
getCompleteApiConfig,
|
||||||
getCurrentModelType,
|
getCurrentModelType,
|
||||||
isValidModelType,
|
isValidModelType,
|
||||||
DOWNLOAD_ENDPOINTS,
|
DOWNLOAD_ENDPOINTS,
|
||||||
|
HF_ENDPOINTS,
|
||||||
WS_ENDPOINTS
|
WS_ENDPOINTS
|
||||||
} from './apiConfig.js';
|
} from './apiConfig.js';
|
||||||
import { resetAndReload } from './modelApiFactory.js';
|
import { resetAndReload } from './modelApiFactory.js';
|
||||||
@@ -115,7 +116,10 @@ export class BaseModelApiClient {
|
|||||||
const pageState = this.getPageState();
|
const pageState = this.getPageState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(`Loading more ${this.apiConfig.config.displayName}s...`);
|
// Use grid-scoped loading instead of full-page overlay
|
||||||
|
if (state.virtualScroller?.showGridLoading) {
|
||||||
|
state.virtualScroller.showGridLoading();
|
||||||
|
}
|
||||||
|
|
||||||
pageState.isLoading = true;
|
pageState.isLoading = true;
|
||||||
if (resetPage) {
|
if (resetPage) {
|
||||||
@@ -133,6 +137,16 @@ export class BaseModelApiClient {
|
|||||||
pageState.hasMore = result.hasMore;
|
pageState.hasMore = result.hasMore;
|
||||||
pageState.currentPage = pageState.currentPage + 1;
|
pageState.currentPage = pageState.currentPage + 1;
|
||||||
|
|
||||||
|
// When resetting to page 1, scroll back to the top
|
||||||
|
// This covers: folder selection, filter/sort/search changes,
|
||||||
|
// favorites/update/excluded view toggles, alphabet filter, etc.
|
||||||
|
if (resetPage) {
|
||||||
|
const scrollContainer = document.querySelector('.page-content');
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (updateFolders) {
|
if (updateFolders) {
|
||||||
sidebarManager.refresh();
|
sidebarManager.refresh();
|
||||||
}
|
}
|
||||||
@@ -144,7 +158,14 @@ export class BaseModelApiClient {
|
|||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
pageState.isLoading = false;
|
pageState.isLoading = false;
|
||||||
state.loadingManager.hide();
|
// Wait for the next rAF so refreshWithData's scheduleRender has
|
||||||
|
// completed rendering new cards before hiding the grid loading overlay.
|
||||||
|
// This eliminates the ~6.7ms blank-frame gap that caused the flicker.
|
||||||
|
if (state.virtualScroller?.hideGridLoading) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
state.virtualScroller.hideGridLoading();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,17 +489,21 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
|
const abortController = new AbortController();
|
||||||
try {
|
try {
|
||||||
state.loadingManager.show(
|
state.loadingManager.show(
|
||||||
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
|
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
state.loadingManager.showCancelButton(() => this.cancelTask());
|
state.loadingManager.showCancelButton(() => {
|
||||||
|
this.cancelTask();
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
|
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
|
||||||
url.searchParams.append('full_rebuild', fullRebuild);
|
url.searchParams.append('full_rebuild', fullRebuild);
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url, { signal: abortController.signal });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
|
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
|
||||||
@@ -494,6 +519,10 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
|
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Refresh failed:', error);
|
console.error('Refresh failed:', error);
|
||||||
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
|
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -547,6 +576,14 @@ export class BaseModelApiClient {
|
|||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
||||||
|
|
||||||
|
// Wait for WebSocket connection to establish
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
ws.onopen = resolve;
|
||||||
|
ws.onerror = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now that we're connected, set up the message/error handlers
|
||||||
|
// for the actual operation (separate from connection errors)
|
||||||
const operationComplete = new Promise((resolve, reject) => {
|
const operationComplete = new Promise((resolve, reject) => {
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
@@ -556,25 +593,39 @@ export class BaseModelApiClient {
|
|||||||
loading.setStatus('Starting metadata fetch...');
|
loading.setStatus('Starting metadata fetch...');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'processing':
|
case 'processing': {
|
||||||
const percent = ((data.processed / data.total) * 100).toFixed(1);
|
const handled = data.handled || data.processed;
|
||||||
|
const percent = ((handled / data.total) * 100).toFixed(1);
|
||||||
loading.setProgress(percent);
|
loading.setProgress(percent);
|
||||||
loading.setStatus(
|
let statusText = `Processing (${handled}/${data.total}) ${data.current_name || ''}`;
|
||||||
`Processing (${data.processed}/${data.total}) ${data.current_name}`
|
if (data.failure_count > 0) {
|
||||||
);
|
statusText += ` | ❌ ${data.failure_count} failed`;
|
||||||
|
}
|
||||||
|
if (data.skipped_count > 0) {
|
||||||
|
statusText += ` | ⏭️ ${data.skipped_count} skipped`;
|
||||||
|
}
|
||||||
|
loading.setStatus(statusText);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'completed':
|
case 'completed': {
|
||||||
loading.setProgress(100);
|
loading.setProgress(100);
|
||||||
loading.setStatus(
|
let summaryText = `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`;
|
||||||
`Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`
|
if (data.failure_count > 0) {
|
||||||
);
|
summaryText += ` | ❌ ${data.failure_count} failed`;
|
||||||
|
}
|
||||||
|
if (data.skipped_count > 0) {
|
||||||
|
summaryText += ` | ⏭️ ${data.skipped_count} skipped`;
|
||||||
|
}
|
||||||
|
summaryText += ` (⏱ ${data.elapsed_seconds || '?'}s)`;
|
||||||
|
loading.setStatus(summaryText);
|
||||||
resolve(data);
|
resolve(data);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
loading.setStatus('Operation cancelled by user');
|
loading.setStatus('Operation cancelled by user');
|
||||||
resolve(data); // Consider it complete but marked as cancelled
|
resolve(data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
@@ -588,12 +639,6 @@ export class BaseModelApiClient {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for WebSocket connection to establish
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
ws.onopen = resolve;
|
|
||||||
ws.onerror = reject;
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, {
|
const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -608,10 +653,10 @@ export class BaseModelApiClient {
|
|||||||
const finalData = await operationComplete;
|
const finalData = await operationComplete;
|
||||||
|
|
||||||
resetAndReload(false);
|
resetAndReload(false);
|
||||||
if (finalData && finalData.status === 'cancelled') {
|
|
||||||
showToast('toast.api.operationCancelledPartial', { success: finalData.success, total: finalData.total }, 'info');
|
// Show result summary with failure details
|
||||||
} else {
|
if (finalData) {
|
||||||
showToast('toast.api.metadataUpdateComplete', {}, 'success');
|
this._showMetadataRefreshResult(finalData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching metadata:', error);
|
console.error('Error fetching metadata:', error);
|
||||||
@@ -627,6 +672,205 @@ export class BaseModelApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_showMetadataRefreshResult(data) {
|
||||||
|
const { success, total } = data;
|
||||||
|
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelledPartial', { success, total }, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._showFailureDetailsModal(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
_showFailureDetailsModal(data) {
|
||||||
|
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
|
||||||
|
|
||||||
|
// Build failure list HTML
|
||||||
|
const failureRows = failures.map((f, i) =>
|
||||||
|
`<tr>
|
||||||
|
<td class="failure-index">${i + 1}</td>
|
||||||
|
<td class="failure-name" title="${this._escapeHtml(f.name)}">${this._escapeHtml(f.name)}</td>
|
||||||
|
<td class="failure-error">${this._escapeHtml(f.error || 'Unknown')}</td>
|
||||||
|
</tr>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const modalHtml = `
|
||||||
|
<div id="metadataRefreshResultModal" class="modal" style="display: block;">
|
||||||
|
<div class="modal-content metadata-refresh-result-modal">
|
||||||
|
<button class="close" data-action="close-modal">×</button>
|
||||||
|
|
||||||
|
<h2>${translate('modals.metadataFetchSummary.title', {}, 'Metadata Fetch Summary')}</h2>
|
||||||
|
|
||||||
|
<div class="refresh-summary-stats">
|
||||||
|
<div class="stat-card stat-card-success">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSuccess', {}, 'Success')}</span>
|
||||||
|
<span class="stat-card-value">${success}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-failure">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statFailed', {}, 'Failed')}</span>
|
||||||
|
<span class="stat-card-value">${failure_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-skipped">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSkipped', {}, 'Skipped')}</span>
|
||||||
|
<span class="stat-card-value">${skipped_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-total">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span>
|
||||||
|
<span class="stat-card-value">${total || processed}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-time">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statDuration', {}, 'Duration')}</span>
|
||||||
|
<span class="stat-card-value">${elapsed_seconds}s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${failure_count > 0 ? `
|
||||||
|
<div class="refresh-failures-section">
|
||||||
|
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.metadataFetchSummary.failedItems', { count: failure_count }, 'Failed Items (' + failure_count + ')')}</h4>
|
||||||
|
<div class="failure-table-wrapper">
|
||||||
|
<table class="failure-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>${translate('modals.metadataFetchSummary.columnModelName', {}, 'Model Name')}</th>
|
||||||
|
<th>${translate('modals.metadataFetchSummary.columnError', {}, 'Error')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${failureRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="refresh-success-message">
|
||||||
|
<i class="fas fa-check-circle"></i> ${translate('modals.metadataFetchSummary.successMessage', { count: success, type: this.apiConfig.config.displayName }, 'All ' + success + ' ' + this.apiConfig.config.displayName + 's updated successfully!')}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel-btn" data-action="close-modal">${translate('modals.metadataFetchSummary.close', {}, 'Close')}</button>
|
||||||
|
${failure_count > 0 ? `
|
||||||
|
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.metadataFetchSummary.copyReport', {}, 'Copy Report')}</button>
|
||||||
|
<button class="secondary-btn" data-action="download-csv"><i class="fas fa-download"></i> ${translate('modals.metadataFetchSummary.downloadCsv', {}, 'Download CSV')}</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const existing = document.getElementById('metadataRefreshResultModal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = modalHtml;
|
||||||
|
const modal = container.firstElementChild;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
const action = e.target.closest('[data-action]')?.dataset.action;
|
||||||
|
if (!action) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'close-modal':
|
||||||
|
modal.remove();
|
||||||
|
break;
|
||||||
|
case 'copy-report':
|
||||||
|
BaseModelApiClient._copyRefreshReport(e.target.closest('[data-action]'), data);
|
||||||
|
break;
|
||||||
|
case 'download-csv':
|
||||||
|
BaseModelApiClient._downloadRefreshReport(data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _copyRefreshReport(btn, data) {
|
||||||
|
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
|
||||||
|
const lines = [
|
||||||
|
'=== Metadata Refresh Report ===',
|
||||||
|
`Date: ${new Date().toLocaleString()}`,
|
||||||
|
`Duration: ${elapsed_seconds}s`,
|
||||||
|
`Total scanned: ${total || processed}`,
|
||||||
|
`Successfully updated: ${success}`,
|
||||||
|
`Failed: ${failure_count}`,
|
||||||
|
`Skipped: ${skipped_count}`,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
if (failure_count > 0) {
|
||||||
|
lines.push('--- Failed Items ---');
|
||||||
|
failures.forEach((f, i) => {
|
||||||
|
lines.push(`${i + 1}. ${f.name || 'Unknown'} — ${f.error || 'Unknown error'}`);
|
||||||
|
});
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
lines.push('====================');
|
||||||
|
|
||||||
|
const text = lines.join('\n');
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||||
|
if (btn) {
|
||||||
|
const origHTML = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||||
|
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// Fallback
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static _downloadRefreshReport(data) {
|
||||||
|
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
|
||||||
|
|
||||||
|
// CSV header
|
||||||
|
let csv = 'Model Name,Error\n';
|
||||||
|
failures.forEach(f => {
|
||||||
|
const name = (f.name || 'Unknown').replace(/"/g, '""');
|
||||||
|
const error = (f.error || 'Unknown').replace(/"/g, '""');
|
||||||
|
csv += `"${name}","${error}"\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add summary as trailing comments
|
||||||
|
csv += `\n# Summary: ${success} success, ${failure_count} failed, ${skipped_count} skipped, ${elapsed_seconds}s\n`;
|
||||||
|
csv += `# Total scanned: ${total || processed}\n`;
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `metadata-refresh-failures-${Date.now()}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
showToast('toast.api.downloadStarted', {}, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
async refreshBulkModelMetadata(filePaths) {
|
async refreshBulkModelMetadata(filePaths) {
|
||||||
if (!filePaths || filePaths.length === 0) {
|
if (!filePaths || filePaths.length === 0) {
|
||||||
throw new Error('No file paths provided');
|
throw new Error('No file paths provided');
|
||||||
@@ -728,13 +972,19 @@ export class BaseModelApiClient {
|
|||||||
throw new Error('No model IDs provided');
|
throw new Error('No model IDs provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.loadingManager.show('Checking for updates...', 0);
|
state.loadingManager.show('Checking for updates...', 0);
|
||||||
state.loadingManager.showCancelButton(() => this.cancelTask());
|
state.loadingManager.showCancelButton(() => {
|
||||||
|
this.cancelTask();
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: abortController.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model_ids: modelIds,
|
model_ids: modelIds,
|
||||||
force
|
force
|
||||||
@@ -759,6 +1009,10 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
console.error('Error refreshing updates for models:', error);
|
console.error('Error refreshing updates for models:', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -771,13 +1025,19 @@ export class BaseModelApiClient {
|
|||||||
throw new Error('No folder path provided');
|
throw new Error('No folder path provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.loadingManager.show('Checking for updates...', 0);
|
state.loadingManager.show('Checking for updates...', 0);
|
||||||
state.loadingManager.showCancelButton(() => this.cancelTask());
|
state.loadingManager.showCancelButton(() => {
|
||||||
|
this.cancelTask();
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: abortController.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
folder_path: folderPath,
|
folder_path: folderPath,
|
||||||
force
|
force
|
||||||
@@ -802,6 +1062,10 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
console.error('Error refreshing updates for folder:', error);
|
console.error('Error refreshing updates for folder:', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -980,6 +1244,48 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchHfRepoFiles(repo, revision = 'main') {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ repo, revision });
|
||||||
|
const response = await fetch(`${HF_ENDPOINTS.repoFiles}?${params}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Failed to fetch HF repo files');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching HF repo files:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadHfModel({ repo, filename, revision, modelRoot, relativePath, useDefaultPaths, download_id }) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(HF_ENDPOINTS.download, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
repo,
|
||||||
|
filename,
|
||||||
|
revision: revision || 'main',
|
||||||
|
model_root: modelRoot,
|
||||||
|
relative_path: relativePath || '',
|
||||||
|
use_default_paths: useDefaultPaths || false,
|
||||||
|
...(download_id ? { download_id } : {}),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading HF model:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_buildQueryParams(baseParams, pageState) {
|
_buildQueryParams(baseParams, pageState) {
|
||||||
const params = new URLSearchParams(baseParams);
|
const params = new URLSearchParams(baseParams);
|
||||||
const isExcludedView = pageState.viewMode === 'excluded';
|
const isExcludedView = pageState.viewMode === 'excluded';
|
||||||
@@ -1018,6 +1324,12 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
|
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
|
||||||
|
|
||||||
|
// Pass group-by-model mode to backend (skip when showing all versions of a specific model)
|
||||||
|
const vlmModelId = getSessionItem('vlm_model_id');
|
||||||
|
if (state.global.settings.group_by_model && !vlmModelId) {
|
||||||
|
params.append('group_by_model', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
if (!isExcludedView && pageState.filters) {
|
if (!isExcludedView && pageState.filters) {
|
||||||
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
|
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
|
||||||
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
||||||
@@ -1099,6 +1411,24 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_addModelSpecificParams(params, pageState) {
|
_addModelSpecificParams(params, pageState) {
|
||||||
|
// Check for View Local Versions filter (takes priority over recipe filters)
|
||||||
|
const vlmModelId = getSessionItem('vlm_model_id');
|
||||||
|
const vlmPageType = getSessionItem('vlm_page_type');
|
||||||
|
if (vlmModelId && vlmPageType === this.modelType) {
|
||||||
|
params.append('civitai_model_id', vlmModelId);
|
||||||
|
const vlmBaseModel = getSessionItem('vlm_base_model');
|
||||||
|
if (vlmBaseModel) {
|
||||||
|
params.append('base_model', vlmBaseModel);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (vlmModelId && vlmPageType !== this.modelType) {
|
||||||
|
// Stale VLM data from a different page type — clean up
|
||||||
|
removeSessionItem('vlm_model_id');
|
||||||
|
removeSessionItem('vlm_model_name');
|
||||||
|
removeSessionItem('vlm_base_model');
|
||||||
|
removeSessionItem('vlm_page_type');
|
||||||
|
}
|
||||||
|
|
||||||
if (this.modelType === 'loras') {
|
if (this.modelType === 'loras') {
|
||||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||||
@@ -1251,15 +1581,21 @@ export class BaseModelApiClient {
|
|||||||
throw new Error('No file paths provided');
|
throw new Error('No file paths provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
|
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
|
||||||
state.loadingManager.showCancelButton(() => this.cancelTask());
|
state.loadingManager.showCancelButton(() => {
|
||||||
|
this.cancelTask();
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
|
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
signal: abortController.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
file_paths: filePaths
|
file_paths: filePaths
|
||||||
})
|
})
|
||||||
@@ -1282,6 +1618,10 @@ export class BaseModelApiClient {
|
|||||||
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
console.log(`Bulk delete cancelled by user for ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
||||||
|
return { success: false, cancelled: true };
|
||||||
|
}
|
||||||
console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error);
|
console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ export class LoraApiClient extends BaseModelApiClient {
|
|||||||
* Add LoRA-specific parameters to query
|
* Add LoRA-specific parameters to query
|
||||||
*/
|
*/
|
||||||
_addModelSpecificParams(params, pageState) {
|
_addModelSpecificParams(params, pageState) {
|
||||||
|
// Let parent handle View Local Versions filter first
|
||||||
|
super._addModelSpecificParams(params, pageState);
|
||||||
|
// If VLM filter was applied, skip recipe-specific filters
|
||||||
|
if (params.has('civitai_model_id')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||||
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
||||||
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
||||||
|
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
|
||||||
|
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
|
||||||
|
|
||||||
|
// Update check indicator for group-by-model
|
||||||
|
if (groupByModelCheck) {
|
||||||
|
const isEnabled = !!state.global.settings.group_by_model;
|
||||||
|
groupByModelCheck.style.display = isEnabled ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (isRecipesPage) {
|
if (isRecipesPage) {
|
||||||
modelUpdateItem?.classList.add('hidden');
|
modelUpdateItem?.classList.add('hidden');
|
||||||
@@ -31,6 +39,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
downloadExamplesItem?.classList.add('hidden');
|
downloadExamplesItem?.classList.add('hidden');
|
||||||
cleanupExamplesItem?.classList.add('hidden');
|
cleanupExamplesItem?.classList.add('hidden');
|
||||||
excludedModelsItem?.classList.add('hidden');
|
excludedModelsItem?.classList.add('hidden');
|
||||||
|
groupByModelItem?.classList.add('hidden');
|
||||||
repairRecipesItem?.classList.remove('hidden');
|
repairRecipesItem?.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
modelUpdateItem?.classList.remove('hidden');
|
modelUpdateItem?.classList.remove('hidden');
|
||||||
@@ -38,6 +47,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
downloadExamplesItem?.classList.remove('hidden');
|
downloadExamplesItem?.classList.remove('hidden');
|
||||||
cleanupExamplesItem?.classList.remove('hidden');
|
cleanupExamplesItem?.classList.remove('hidden');
|
||||||
excludedModelsItem?.classList.remove('hidden');
|
excludedModelsItem?.classList.remove('hidden');
|
||||||
|
groupByModelItem?.classList.remove('hidden');
|
||||||
repairRecipesItem?.classList.add('hidden');
|
repairRecipesItem?.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +84,9 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
case 'manage-excluded-models':
|
case 'manage-excluded-models':
|
||||||
this.manageExcludedModels();
|
this.manageExcludedModels();
|
||||||
break;
|
break;
|
||||||
|
case 'toggle-group-by-model':
|
||||||
|
this.toggleGroupByModel();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`Unhandled global context menu action: ${action}`);
|
console.warn(`Unhandled global context menu action: ${action}`);
|
||||||
break;
|
break;
|
||||||
@@ -86,6 +99,30 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleGroupByModel() {
|
||||||
|
const sm = window.settingsManager;
|
||||||
|
if (!sm) {
|
||||||
|
console.error('settingsManager not available on window');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newValue = !state.global.settings.group_by_model;
|
||||||
|
state.global.settings.group_by_model = newValue;
|
||||||
|
|
||||||
|
// Save/restore sort preference when toggling group_by_model
|
||||||
|
if (window.pageControls?.onGroupByModelToggled) {
|
||||||
|
window.pageControls.onGroupByModelToggled(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.saveSetting('group_by_model', newValue).catch((error) => {
|
||||||
|
console.error('Failed to save group_by_model setting:', error);
|
||||||
|
// Revert state on failure
|
||||||
|
state.global.settings.group_by_model = !newValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
sm.applyFrontendSettings();
|
||||||
|
sm.reloadContent();
|
||||||
|
}
|
||||||
|
|
||||||
async downloadExampleImages(menuItem) {
|
async downloadExampleImages(menuItem) {
|
||||||
const downloadPath = state?.global?.settings?.example_images_path;
|
const downloadPath = state?.global?.settings?.example_images_path;
|
||||||
if (!downloadPath) {
|
if (!downloadPath) {
|
||||||
|
|||||||
+131
-49
@@ -1,9 +1,9 @@
|
|||||||
import { updateService } from '../managers/UpdateService.js';
|
import { updateService } from '../managers/UpdateService.js';
|
||||||
import { toggleTheme } from '../utils/uiHelpers.js';
|
import { toggleTheme, setPreset, CYCLE_ORDER, PRESET_NAMES } from '../utils/uiHelpers.js';
|
||||||
import { SearchManager } from '../managers/SearchManager.js';
|
import { SearchManager } from '../managers/SearchManager.js';
|
||||||
import { FilterManager } from '../managers/FilterManager.js';
|
import { FilterManager } from '../managers/FilterManager.js';
|
||||||
import { initPageState } from '../state/index.js';
|
import { initPageState } from '../state/index.js';
|
||||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { updateElementAttribute } from '../utils/i18nHelpers.js';
|
import { updateElementAttribute } from '../utils/i18nHelpers.js';
|
||||||
import { renderSupporters } from '../services/supportersService.js';
|
import { renderSupporters } from '../services/supportersService.js';
|
||||||
|
|
||||||
@@ -47,25 +47,8 @@ export class HeaderManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initializeCommonElements() {
|
initializeCommonElements() {
|
||||||
// Handle theme toggle
|
this.initializeThemePopover();
|
||||||
const themeToggle = document.querySelector('.theme-toggle');
|
|
||||||
if (themeToggle) {
|
|
||||||
const currentTheme = getStorageItem('theme') || 'auto';
|
|
||||||
themeToggle.classList.add(`theme-${currentTheme}`);
|
|
||||||
|
|
||||||
// Use i18nHelpers to update themeToggle's title
|
|
||||||
this.updateThemeTooltip(themeToggle, currentTheme);
|
|
||||||
|
|
||||||
themeToggle.addEventListener('click', async () => {
|
|
||||||
if (typeof toggleTheme === 'function') {
|
|
||||||
const newTheme = toggleTheme();
|
|
||||||
// Use i18nHelpers to update themeToggle's title
|
|
||||||
this.updateThemeTooltip(themeToggle, newTheme);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle settings toggle
|
|
||||||
const settingsToggle = document.querySelector('.settings-toggle');
|
const settingsToggle = document.querySelector('.settings-toggle');
|
||||||
if (settingsToggle) {
|
if (settingsToggle) {
|
||||||
settingsToggle.addEventListener('click', () => {
|
settingsToggle.addEventListener('click', () => {
|
||||||
@@ -75,7 +58,6 @@ export class HeaderManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle update toggle
|
|
||||||
const updateToggle = document.getElementById('updateToggleBtn');
|
const updateToggle = document.getElementById('updateToggleBtn');
|
||||||
if (updateToggle) {
|
if (updateToggle) {
|
||||||
updateToggle.addEventListener('click', () => {
|
updateToggle.addEventListener('click', () => {
|
||||||
@@ -83,13 +65,11 @@ export class HeaderManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle support toggle
|
|
||||||
const supportToggle = document.getElementById('supportToggleBtn');
|
const supportToggle = document.getElementById('supportToggleBtn');
|
||||||
if (supportToggle) {
|
if (supportToggle) {
|
||||||
supportToggle.addEventListener('click', async () => {
|
supportToggle.addEventListener('click', async () => {
|
||||||
if (window.modalManager) {
|
if (window.modalManager) {
|
||||||
window.modalManager.toggleModal('supportModal');
|
window.modalManager.toggleModal('supportModal');
|
||||||
// Load supporters data when modal opens
|
|
||||||
try {
|
try {
|
||||||
await renderSupporters();
|
await renderSupporters();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -99,41 +79,144 @@ export class HeaderManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle QR code toggle
|
|
||||||
const qrToggle = document.getElementById('toggleQRCode');
|
const qrToggle = document.getElementById('toggleQRCode');
|
||||||
const qrContainer = document.getElementById('qrCodeContainer');
|
const qrContainer = document.getElementById('qrCodeContainer');
|
||||||
|
|
||||||
if (qrToggle && qrContainer) {
|
if (qrToggle && qrContainer) {
|
||||||
qrToggle.addEventListener('click', function() {
|
qrToggle.addEventListener('click', function () {
|
||||||
qrContainer.classList.toggle('show');
|
qrContainer.classList.toggle('show');
|
||||||
qrToggle.classList.toggle('active');
|
qrToggle.classList.toggle('active');
|
||||||
|
|
||||||
const toggleText = qrToggle.querySelector('.toggle-text');
|
const toggleText = qrToggle.querySelector('.toggle-text');
|
||||||
if (qrContainer.classList.contains('show')) {
|
if (qrContainer.classList.contains('show')) {
|
||||||
toggleText.textContent = 'Hide WeChat QR Code';
|
toggleText.textContent = 'Hide WeChat QR Code';
|
||||||
// Add small delay to ensure DOM is updated before scrolling
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
const supportModal = document.querySelector('.support-modal');
|
||||||
const supportModal = document.querySelector('.support-modal');
|
if (supportModal) {
|
||||||
if (supportModal) {
|
supportModal.scrollTo({
|
||||||
supportModal.scrollTo({
|
top: supportModal.scrollHeight,
|
||||||
top: supportModal.scrollHeight,
|
behavior: 'smooth'
|
||||||
behavior: 'smooth'
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
} else {
|
|
||||||
toggleText.textContent = 'Show WeChat QR Code';
|
|
||||||
}
|
}
|
||||||
});
|
}, 250);
|
||||||
|
} else {
|
||||||
|
toggleText.textContent = 'Show WeChat QR Code';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide search functionality on Statistics page
|
|
||||||
this.updateHeaderForPage();
|
this.updateHeaderForPage();
|
||||||
|
|
||||||
// Initialize hamburger menu for mobile
|
|
||||||
this.initializeHamburgerMenu();
|
this.initializeHamburgerMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeThemePopover() {
|
||||||
|
const themeToggle = document.querySelector('.theme-toggle');
|
||||||
|
const themePopover = document.getElementById('themePopover');
|
||||||
|
if (!themeToggle || !themePopover) return;
|
||||||
|
|
||||||
|
const currentTheme = getStorageItem('theme') || 'auto';
|
||||||
|
const currentPreset = getStorageItem('theme_preset') || 'default';
|
||||||
|
themeToggle.classList.add(`theme-${currentTheme}`);
|
||||||
|
this.updateThemeTooltip(themeToggle, currentTheme);
|
||||||
|
this.updatePopoverActiveStates(currentTheme, currentPreset);
|
||||||
|
|
||||||
|
themeToggle.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const isOpen = themePopover.classList.contains('active');
|
||||||
|
this.closeAllPopovers();
|
||||||
|
if (!isOpen) {
|
||||||
|
this.positionThemePopover();
|
||||||
|
themePopover.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
themePopover.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const modeBtn = e.target.closest('.theme-mode-btn');
|
||||||
|
const presetBtn = e.target.closest('.theme-preset-btn');
|
||||||
|
|
||||||
|
if (modeBtn) {
|
||||||
|
const mode = modeBtn.dataset.mode;
|
||||||
|
this.setThemeMode(mode);
|
||||||
|
} else if (presetBtn) {
|
||||||
|
const preset = presetBtn.dataset.preset;
|
||||||
|
this.setThemePreset(preset);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!themeToggle.contains(e.target) && !themePopover.contains(e.target)) {
|
||||||
|
themePopover.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reposition on resize while popover is active
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (themePopover.classList.contains('active')) {
|
||||||
|
this.positionThemePopover();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAllPopovers() {
|
||||||
|
const themePopover = document.getElementById('themePopover');
|
||||||
|
if (themePopover) {
|
||||||
|
themePopover.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
positionThemePopover() {
|
||||||
|
const themeToggle = document.querySelector('.theme-toggle');
|
||||||
|
const themePopover = document.getElementById('themePopover');
|
||||||
|
if (!themeToggle || !themePopover) return;
|
||||||
|
const rect = themeToggle.getBoundingClientRect();
|
||||||
|
// Guard: toggle may be hidden on narrow viewports (≤950px CSS hides .header-controls)
|
||||||
|
if (rect.width === 0 || rect.height === 0) return;
|
||||||
|
themePopover.style.top = (rect.bottom + 8) + 'px';
|
||||||
|
themePopover.style.right = (window.innerWidth - rect.right - 8) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
setThemeMode(mode) {
|
||||||
|
setStorageItem('theme', mode);
|
||||||
|
const htmlElement = document.documentElement;
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
htmlElement.removeAttribute('data-theme');
|
||||||
|
if (mode === 'dark' || (mode === 'auto' && prefersDark)) {
|
||||||
|
htmlElement.setAttribute('data-theme', 'dark');
|
||||||
|
document.body.dataset.theme = 'dark';
|
||||||
|
} else {
|
||||||
|
htmlElement.setAttribute('data-theme', 'light');
|
||||||
|
document.body.dataset.theme = 'light';
|
||||||
|
}
|
||||||
|
const themeToggle = document.querySelector('.theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
|
||||||
|
themeToggle.classList.add(`theme-${mode}`);
|
||||||
|
this.updateThemeTooltip(themeToggle, mode);
|
||||||
|
}
|
||||||
|
this.updateHamburgerThemeIcon();
|
||||||
|
this.updatePopoverActiveStates(mode, getStorageItem('theme_preset') || 'default');
|
||||||
|
}
|
||||||
|
|
||||||
|
setThemePreset(preset) {
|
||||||
|
setPreset(preset);
|
||||||
|
this.updatePopoverActiveStates(getStorageItem('theme') || 'auto', preset);
|
||||||
|
this.updateHamburgerThemeIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePopoverActiveStates(theme, preset) {
|
||||||
|
const popover = document.getElementById('themePopover');
|
||||||
|
if (!popover) return;
|
||||||
|
|
||||||
|
popover.querySelectorAll('.theme-mode-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.mode === theme);
|
||||||
|
});
|
||||||
|
|
||||||
|
popover.querySelectorAll('.theme-preset-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.preset === preset);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
initializeHamburgerMenu() {
|
initializeHamburgerMenu() {
|
||||||
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
|
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
|
||||||
const hamburgerDropdown = document.getElementById('hamburgerDropdown');
|
const hamburgerDropdown = document.getElementById('hamburgerDropdown');
|
||||||
@@ -188,7 +271,6 @@ export class HeaderManager {
|
|||||||
case 'theme':
|
case 'theme':
|
||||||
if (typeof toggleTheme === 'function') {
|
if (typeof toggleTheme === 'function') {
|
||||||
const newTheme = toggleTheme();
|
const newTheme = toggleTheme();
|
||||||
// Update theme toggle in header if it exists
|
|
||||||
const themeToggle = document.querySelector('.theme-toggle');
|
const themeToggle = document.querySelector('.theme-toggle');
|
||||||
if (themeToggle) {
|
if (themeToggle) {
|
||||||
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
|
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
|
||||||
@@ -196,6 +278,7 @@ export class HeaderManager {
|
|||||||
this.updateThemeTooltip(themeToggle, newTheme);
|
this.updateThemeTooltip(themeToggle, newTheme);
|
||||||
}
|
}
|
||||||
this.updateHamburgerThemeIcon();
|
this.updateHamburgerThemeIcon();
|
||||||
|
this.updatePopoverActiveStates(newTheme, getStorageItem('theme_preset') || 'default');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
@@ -255,7 +338,6 @@ export class HeaderManager {
|
|||||||
const headerSearch = document.getElementById('headerSearch');
|
const headerSearch = document.getElementById('headerSearch');
|
||||||
const searchInput = headerSearch?.querySelector('#searchInput');
|
const searchInput = headerSearch?.querySelector('#searchInput');
|
||||||
const searchButtons = headerSearch?.querySelectorAll('button');
|
const searchButtons = headerSearch?.querySelectorAll('button');
|
||||||
const placeholderKey = 'header.search.placeholders.' + this.currentPage;
|
|
||||||
|
|
||||||
if (this.currentPage === 'statistics' && headerSearch) {
|
if (this.currentPage === 'statistics' && headerSearch) {
|
||||||
headerSearch.classList.add('disabled');
|
headerSearch.classList.add('disabled');
|
||||||
@@ -270,7 +352,7 @@ export class HeaderManager {
|
|||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.disabled = false;
|
searchInput.disabled = false;
|
||||||
// Use i18nHelpers to update placeholder
|
// Use i18nHelpers to update placeholder
|
||||||
updateElementAttribute(searchInput, 'placeholder', placeholderKey, {}, '');
|
updateElementAttribute(searchInput, 'placeholder', 'header.search.placeholder', {}, '');
|
||||||
}
|
}
|
||||||
searchButtons?.forEach(btn => btn.disabled = false);
|
searchButtons?.forEach(btn => btn.disabled = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Recipe Modal Component
|
// Recipe Modal Component
|
||||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
@@ -7,6 +7,8 @@ import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
|||||||
import { downloadManager } from '../managers/DownloadManager.js';
|
import { downloadManager } from '../managers/DownloadManager.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
import { openMediaViewer } from './shared/MediaViewer.js';
|
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||||
|
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
|
||||||
|
import { setupTagEditMode } from './shared/ModelTags.js';
|
||||||
|
|
||||||
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||||
'prompt',
|
'prompt',
|
||||||
@@ -38,6 +40,16 @@ const GEN_PARAM_NORMALIZATION = {
|
|||||||
'Denoising strength': 'denoising_strength',
|
'Denoising strength': 'denoising_strength',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PARAM_DISPLAY_NAMES = {
|
||||||
|
steps: 'Steps',
|
||||||
|
sampler: 'Sampler',
|
||||||
|
cfg_scale: 'CFG',
|
||||||
|
seed: 'Seed',
|
||||||
|
size: 'Size',
|
||||||
|
clip_skip: 'Clip Skip',
|
||||||
|
denoising_strength: 'Denoising Strength',
|
||||||
|
};
|
||||||
|
|
||||||
class RecipeModal {
|
class RecipeModal {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.promptEditorState = {};
|
this.promptEditorState = {};
|
||||||
@@ -139,14 +151,6 @@ class RecipeModal {
|
|||||||
this.saveTitleEdit();
|
this.saveTitleEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tags edit
|
|
||||||
const tagsEditor = document.getElementById('recipeTagsEditor');
|
|
||||||
if (tagsEditor && tagsEditor.classList.contains('active') &&
|
|
||||||
!tagsEditor.contains(event.target) &&
|
|
||||||
!event.target.closest('.edit-icon')) {
|
|
||||||
this.saveTagsEdit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle reconnect input
|
// Handle reconnect input
|
||||||
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
|
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
|
||||||
reconnectContainers.forEach(container => {
|
reconnectContainers.forEach(container => {
|
||||||
@@ -236,98 +240,10 @@ class RecipeModal {
|
|||||||
this.filePath = hydratedRecipe.file_path;
|
this.filePath = hydratedRecipe.file_path;
|
||||||
this.listFilePath = hydratedRecipe.file_path;
|
this.listFilePath = hydratedRecipe.file_path;
|
||||||
|
|
||||||
// Set recipe tags if they exist
|
// Render tags using shared utility
|
||||||
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
const tagsContainer = document.getElementById('recipeTagsContainer');
|
||||||
const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent');
|
if (tagsContainer) {
|
||||||
|
this.updateTagsDisplay(tagsContainer, hydratedRecipe.tags || []);
|
||||||
if (tagsCompactElement) {
|
|
||||||
// Add tags container with edit functionality
|
|
||||||
tagsCompactElement.innerHTML = `
|
|
||||||
<div class="editable-content tags-content">
|
|
||||||
<div class="tags-display"></div>
|
|
||||||
<button class="edit-icon" title="Edit tags"><i class="fas fa-pencil-alt"></i></button>
|
|
||||||
</div>
|
|
||||||
<div id="recipeTagsEditor" class="content-editor tags-editor">
|
|
||||||
<input type="text" class="tags-input" placeholder="Enter tags separated by commas">
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
|
|
||||||
|
|
||||||
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
|
||||||
// Limit displayed tags to 5, show a "+X more" button if needed
|
|
||||||
const maxVisibleTags = 5;
|
|
||||||
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
|
|
||||||
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
|
|
||||||
|
|
||||||
// Add visible tags
|
|
||||||
visibleTags.forEach(tag => {
|
|
||||||
const tagElement = document.createElement('div');
|
|
||||||
tagElement.className = 'recipe-tag-compact';
|
|
||||||
tagElement.textContent = tag;
|
|
||||||
tagsDisplay.appendChild(tagElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add "more" button if needed
|
|
||||||
if (remainingTags.length > 0) {
|
|
||||||
const moreButton = document.createElement('div');
|
|
||||||
moreButton.className = 'recipe-tag-more';
|
|
||||||
moreButton.textContent = `+${remainingTags.length} more`;
|
|
||||||
tagsDisplay.appendChild(moreButton);
|
|
||||||
|
|
||||||
// Add tooltip functionality
|
|
||||||
moreButton.addEventListener('mouseenter', () => {
|
|
||||||
document.getElementById('recipeTagsTooltip').classList.add('visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
moreButton.addEventListener('mouseleave', () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
|
|
||||||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('recipeTagsTooltip').addEventListener('mouseleave', () => {
|
|
||||||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add all tags to tooltip
|
|
||||||
if (tagsTooltipContent) {
|
|
||||||
tagsTooltipContent.innerHTML = '';
|
|
||||||
hydratedRecipe.tags.forEach(tag => {
|
|
||||||
const tooltipTag = document.createElement('div');
|
|
||||||
tooltipTag.className = 'tooltip-tag';
|
|
||||||
tooltipTag.textContent = tag;
|
|
||||||
tagsTooltipContent.appendChild(tooltipTag);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add event listeners for tags editing
|
|
||||||
const editTagsIcon = tagsCompactElement.querySelector('.edit-icon');
|
|
||||||
const tagsInput = tagsCompactElement.querySelector('.tags-input');
|
|
||||||
|
|
||||||
// Set current tags in the input
|
|
||||||
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
|
||||||
tagsInput.value = hydratedRecipe.tags.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
|
|
||||||
|
|
||||||
// Add key event listener for Enter key
|
|
||||||
tagsInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.saveTagsEdit();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.cancelTagsEdit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set recipe image
|
// Set recipe image
|
||||||
@@ -609,17 +525,35 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncTagsDisplay(tags) {
|
syncTagsDisplay(tags) {
|
||||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
const container = document.getElementById('recipeTagsContainer');
|
||||||
if (!tagsContainer) {
|
if (!container) return;
|
||||||
return;
|
this.updateTagsDisplay(container, tags || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateTagsDisplay(tagsContainer, tags || []);
|
// Re-render tags display using shared utility, wire edit mode with ModelTags
|
||||||
|
updateTagsDisplay(container, tags) {
|
||||||
|
const filePath = this.filePath || '';
|
||||||
|
|
||||||
const tagsInput = tagsContainer.querySelector('.tags-input');
|
container.innerHTML = renderCompactTags(tags, filePath);
|
||||||
if (tagsInput) {
|
|
||||||
tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : '';
|
// Setup tooltip for all tags
|
||||||
}
|
setupTagTooltip(container);
|
||||||
|
|
||||||
|
// Wire edit button using shared tag editing (no suggestions for recipes)
|
||||||
|
setupTagEditMode(null, {
|
||||||
|
container: container,
|
||||||
|
showSuggestions: false,
|
||||||
|
normalizeTag: false,
|
||||||
|
saveHandler: async (filePath, tags) => {
|
||||||
|
await updateRecipeMetadata(filePath, { tags }, this.getMetadataUpdateOptions());
|
||||||
|
},
|
||||||
|
onSaved: (tags) => {
|
||||||
|
this.currentRecipe.tags = tags;
|
||||||
|
this.commitField('tags');
|
||||||
|
const c = document.getElementById('recipeTagsContainer');
|
||||||
|
if (c) this.updateTagsDisplay(c, tags);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
syncPromptField(field, value, placeholder) {
|
syncPromptField(field, value, placeholder) {
|
||||||
@@ -664,10 +598,11 @@ class RecipeModal {
|
|||||||
|
|
||||||
for (const [key, value] of Object.entries(sanitizedGenParams)) {
|
for (const [key, value] of Object.entries(sanitizedGenParams)) {
|
||||||
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
||||||
|
const displayName = PARAM_DISPLAY_NAMES[key] || key;
|
||||||
const paramTag = document.createElement('div');
|
const paramTag = document.createElement('div');
|
||||||
paramTag.className = 'param-tag';
|
paramTag.className = 'param-tag';
|
||||||
paramTag.innerHTML = `
|
paramTag.innerHTML = `
|
||||||
<span class="param-name">${key}:</span>
|
<span class="param-name">${displayName}:</span>
|
||||||
<span class="param-value">${value}</span>
|
<span class="param-value">${value}</span>
|
||||||
`;
|
`;
|
||||||
otherParamsElement.appendChild(paramTag);
|
otherParamsElement.appendChild(paramTag);
|
||||||
@@ -976,139 +911,6 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags editing methods
|
|
||||||
showTagsEditor() {
|
|
||||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
|
||||||
if (tagsContainer) {
|
|
||||||
tagsContainer.querySelector('.editable-content').classList.add('hide');
|
|
||||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
|
||||||
editor.classList.add('active');
|
|
||||||
const input = editor.querySelector('input');
|
|
||||||
input.oninput = () => this.markFieldDirty('tags');
|
|
||||||
input.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveTagsEdit() {
|
|
||||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
|
||||||
if (tagsContainer) {
|
|
||||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
|
||||||
const input = editor.querySelector('input');
|
|
||||||
const tagsText = input.value.trim();
|
|
||||||
|
|
||||||
// Parse tags
|
|
||||||
let newTags = [];
|
|
||||||
if (tagsText) {
|
|
||||||
newTags = tagsText.split(',')
|
|
||||||
.map(tag => tag.trim())
|
|
||||||
.filter(tag => tag.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if tags changed
|
|
||||||
const oldTags = this.currentRecipe.tags || [];
|
|
||||||
const tagsChanged =
|
|
||||||
newTags.length !== oldTags.length ||
|
|
||||||
newTags.some((tag, index) => tag !== oldTags[index]);
|
|
||||||
|
|
||||||
if (tagsChanged) {
|
|
||||||
// Update the recipe on the server
|
|
||||||
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
|
|
||||||
.then(data => {
|
|
||||||
// Show success toast
|
|
||||||
showToast('toast.recipes.tagsUpdated', {}, 'success');
|
|
||||||
|
|
||||||
// Update the current recipe object
|
|
||||||
this.currentRecipe.tags = newTags;
|
|
||||||
this.commitField('tags');
|
|
||||||
|
|
||||||
// Update tags in the UI
|
|
||||||
this.updateTagsDisplay(tagsContainer, newTags);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
// Error is handled in the API function
|
|
||||||
this.clearFieldDirty('tags');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.clearFieldDirty('tags');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide editor
|
|
||||||
editor.classList.remove('active');
|
|
||||||
tagsContainer.querySelector('.editable-content').classList.remove('hide');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to update tags display
|
|
||||||
updateTagsDisplay(tagsContainer, tags) {
|
|
||||||
const tagsDisplay = tagsContainer.querySelector('.tags-display');
|
|
||||||
tagsDisplay.innerHTML = '';
|
|
||||||
|
|
||||||
if (tags.length > 0) {
|
|
||||||
// Limit displayed tags to 5, show a "+X more" button if needed
|
|
||||||
const maxVisibleTags = 5;
|
|
||||||
const visibleTags = tags.slice(0, maxVisibleTags);
|
|
||||||
const remainingTags = tags.length > maxVisibleTags ? tags.slice(maxVisibleTags) : [];
|
|
||||||
|
|
||||||
// Add visible tags
|
|
||||||
visibleTags.forEach(tag => {
|
|
||||||
const tagElement = document.createElement('div');
|
|
||||||
tagElement.className = 'recipe-tag-compact';
|
|
||||||
tagElement.textContent = tag;
|
|
||||||
tagsDisplay.appendChild(tagElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add "more" button if needed
|
|
||||||
if (remainingTags.length > 0) {
|
|
||||||
const moreButton = document.createElement('div');
|
|
||||||
moreButton.className = 'recipe-tag-more';
|
|
||||||
moreButton.textContent = `+${remainingTags.length} more`;
|
|
||||||
tagsDisplay.appendChild(moreButton);
|
|
||||||
|
|
||||||
// Update tooltip content
|
|
||||||
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
|
|
||||||
if (tooltipContent) {
|
|
||||||
tooltipContent.innerHTML = '';
|
|
||||||
tags.forEach(tag => {
|
|
||||||
const tooltipTag = document.createElement('div');
|
|
||||||
tooltipTag.className = 'tooltip-tag';
|
|
||||||
tooltipTag.textContent = tag;
|
|
||||||
tooltipContent.appendChild(tooltipTag);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-add tooltip functionality
|
|
||||||
moreButton.addEventListener('mouseenter', () => {
|
|
||||||
document.getElementById('recipeTagsTooltip').classList.add('visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
moreButton.addEventListener('mouseleave', () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
|
|
||||||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelTagsEdit() {
|
|
||||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
|
||||||
if (tagsContainer) {
|
|
||||||
// Reset input value
|
|
||||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
|
||||||
const input = editor.querySelector('input');
|
|
||||||
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
|
|
||||||
this.clearFieldDirty('tags');
|
|
||||||
|
|
||||||
// Hide editor
|
|
||||||
editor.classList.remove('active');
|
|
||||||
tagsContainer.querySelector('.editable-content').classList.remove('hide');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setupPromptEditors() {
|
setupPromptEditors() {
|
||||||
const promptConfigs = [
|
const promptConfigs = [
|
||||||
{
|
{
|
||||||
@@ -1409,6 +1211,53 @@ class RecipeModal {
|
|||||||
this.sendRecipeToWorkflow();
|
this.sendRecipeToWorkflow();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send prompt to workflow buttons
|
||||||
|
const sendPromptBtn = document.getElementById('sendPromptBtn');
|
||||||
|
const sendNegativePromptBtn = document.getElementById('sendNegativePromptBtn');
|
||||||
|
|
||||||
|
if (sendPromptBtn) {
|
||||||
|
sendPromptBtn.addEventListener('click', () => {
|
||||||
|
let promptText = this.currentRecipe?.gen_params?.prompt || '';
|
||||||
|
if (this.shouldStripLoraOnCopy()) {
|
||||||
|
promptText = RecipeModal.stripLoraTags(promptText);
|
||||||
|
}
|
||||||
|
if (!promptText.trim()) {
|
||||||
|
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendPromptToWorkflow(promptText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sendNegativePromptBtn) {
|
||||||
|
sendNegativePromptBtn.addEventListener('click', () => {
|
||||||
|
let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
||||||
|
if (this.shouldStripLoraOnCopy()) {
|
||||||
|
negativePromptText = RecipeModal.stripLoraTags(negativePromptText);
|
||||||
|
}
|
||||||
|
if (!negativePromptText.trim()) {
|
||||||
|
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendPromptToWorkflow(negativePromptText, {
|
||||||
|
actionTypeText: 'Negative Prompt',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send params to workflow button
|
||||||
|
const sendParamsBtn = document.getElementById('sendParamsBtn');
|
||||||
|
if (sendParamsBtn) {
|
||||||
|
sendParamsBtn.addEventListener('click', () => {
|
||||||
|
const genParams = this.currentRecipe?.gen_params || {};
|
||||||
|
if (!genParams || Object.keys(genParams).length === 0) {
|
||||||
|
showToast('No generation parameters available', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendGenParamsToWorkflow(genParams);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1417,14 +1266,7 @@ class RecipeModal {
|
|||||||
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
|
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
|
||||||
*/
|
*/
|
||||||
static stripLoraTags(text) {
|
static stripLoraTags(text) {
|
||||||
return text
|
return stripLoraTags(text);
|
||||||
.replace(/<lora:[^>]*>/gi, '')
|
|
||||||
.replace(/<lora:[^&]*>/gi, '')
|
|
||||||
.replace(/,(\s*,)+/g, ',')
|
|
||||||
.replace(/^,\s*/, '')
|
|
||||||
.replace(/,\s*$/, '')
|
|
||||||
.replace(/\s{2,}/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldStripLoraOnCopy() {
|
shouldStripLoraOnCopy() {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { bulkManager } from '../managers/BulkManager.js';
|
import { bulkManager } from '../managers/BulkManager.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
||||||
@@ -17,12 +17,8 @@ export class SidebarManager {
|
|||||||
this.treeData = {};
|
this.treeData = {};
|
||||||
this.selectedPath = '';
|
this.selectedPath = '';
|
||||||
this.expandedNodes = new Set();
|
this.expandedNodes = new Set();
|
||||||
this.isVisible = true;
|
|
||||||
this.isPinned = false;
|
|
||||||
this.apiClient = null;
|
this.apiClient = null;
|
||||||
this.openDropdown = null;
|
this.openDropdown = null;
|
||||||
this.hoverTimeout = null;
|
|
||||||
this.isHovering = false;
|
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
this.displayMode = 'tree'; // 'tree' or 'list'
|
this.displayMode = 'tree'; // 'tree' or 'list'
|
||||||
this.foldersList = [];
|
this.foldersList = [];
|
||||||
@@ -35,9 +31,7 @@ export class SidebarManager {
|
|||||||
this.folderTreeElement = null;
|
this.folderTreeElement = null;
|
||||||
this.currentDropTarget = null;
|
this.currentDropTarget = null;
|
||||||
this.lastPageControls = null;
|
this.lastPageControls = null;
|
||||||
this.isDisabledBySetting = false;
|
|
||||||
this.isDisabledByPage = false;
|
this.isDisabledByPage = false;
|
||||||
this.isMoreDropdownOpen = false;
|
|
||||||
this.initializationPromise = null;
|
this.initializationPromise = null;
|
||||||
this.isCreatingFolder = false;
|
this.isCreatingFolder = false;
|
||||||
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
|
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
|
||||||
@@ -48,12 +42,7 @@ export class SidebarManager {
|
|||||||
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
|
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
|
||||||
this.handleDocumentClick = this.handleDocumentClick.bind(this);
|
this.handleDocumentClick = this.handleDocumentClick.bind(this);
|
||||||
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
|
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
|
||||||
this.handlePinToggle = this.handlePinToggle.bind(this);
|
|
||||||
this.handleCollapseAll = this.handleCollapseAll.bind(this);
|
this.handleCollapseAll = this.handleCollapseAll.bind(this);
|
||||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
|
||||||
this.handleMouseLeave = this.handleMouseLeave.bind(this);
|
|
||||||
this.handleHoverAreaEnter = this.handleHoverAreaEnter.bind(this);
|
|
||||||
this.handleHoverAreaLeave = this.handleHoverAreaLeave.bind(this);
|
|
||||||
this.updateContainerMargin = this.updateContainerMargin.bind(this);
|
this.updateContainerMargin = this.updateContainerMargin.bind(this);
|
||||||
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
|
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
|
||||||
this.handleFolderListClick = this.handleFolderListClick.bind(this);
|
this.handleFolderListClick = this.handleFolderListClick.bind(this);
|
||||||
@@ -70,9 +59,7 @@ export class SidebarManager {
|
|||||||
this.handleSidebarDrop = this.handleSidebarDrop.bind(this);
|
this.handleSidebarDrop = this.handleSidebarDrop.bind(this);
|
||||||
this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this);
|
this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this);
|
||||||
this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this);
|
this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this);
|
||||||
this.handleMoreToggle = this.handleMoreToggle.bind(this);
|
this.handleHideToggle = this.handleHideToggle.bind(this);
|
||||||
this.handleMoreDropdownItemClick = this.handleMoreDropdownItemClick.bind(this);
|
|
||||||
this.handleDocumentClickForMore = this.handleDocumentClickForMore.bind(this);
|
|
||||||
this.getPageDisplayName = this.getPageDisplayName.bind(this);
|
this.getPageDisplayName = this.getPageDisplayName.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,12 +68,6 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize(pageControls, options = {}) {
|
async initialize(pageControls, options = {}) {
|
||||||
const { forceInitialize = false } = options;
|
|
||||||
|
|
||||||
if (this.isDisabledBySetting && !forceInitialize) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up previous initialization if exists
|
// Clean up previous initialization if exists
|
||||||
if (this.isInitialized) {
|
if (this.isInitialized) {
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
@@ -99,25 +80,15 @@ export class SidebarManager {
|
|||||||
|| pageControls?.sidebarApiClient
|
|| pageControls?.sidebarApiClient
|
||||||
|| getModelApiClient();
|
|| getModelApiClient();
|
||||||
|
|
||||||
// Set initial sidebar state immediately (hidden by default)
|
|
||||||
this.setInitialSidebarState();
|
|
||||||
|
|
||||||
this.setupEventHandlers();
|
this.setupEventHandlers();
|
||||||
this.initializeDragAndDrop();
|
this.initializeDragAndDrop();
|
||||||
this.updateSidebarTitle();
|
this.updateSidebarTitle();
|
||||||
this.restoreSidebarState();
|
this.restoreSidebarState();
|
||||||
// Re-apply DOM visibility now that per-page state is known
|
// Apply DOM visibility based on per-page state
|
||||||
this.updateDomVisibility(!this.isDisabledBySetting);
|
this.updateDomVisibility();
|
||||||
await this.loadFolderTree();
|
await this.loadFolderTree();
|
||||||
if (this.isDisabledBySetting && !forceInitialize) {
|
|
||||||
this.cleanup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.restoreSelectedFolder();
|
this.restoreSelectedFolder();
|
||||||
|
|
||||||
// Apply final state with animation after everything is loaded
|
|
||||||
this.applyFinalSidebarState();
|
|
||||||
|
|
||||||
// Update container margin based on initial sidebar state
|
// Update container margin based on initial sidebar state
|
||||||
this.updateContainerMargin();
|
this.updateContainerMargin();
|
||||||
|
|
||||||
@@ -128,12 +99,6 @@ export class SidebarManager {
|
|||||||
cleanup() {
|
cleanup() {
|
||||||
if (!this.isInitialized) return;
|
if (!this.isInitialized) return;
|
||||||
|
|
||||||
// Clear any pending timeouts
|
|
||||||
if (this.hoverTimeout) {
|
|
||||||
clearTimeout(this.hoverTimeout);
|
|
||||||
this.hoverTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up event handlers
|
// Clean up event handlers
|
||||||
this.removeEventHandlers();
|
this.removeEventHandlers();
|
||||||
|
|
||||||
@@ -151,11 +116,6 @@ export class SidebarManager {
|
|||||||
this.sidebarDragHandlersInitialized = false;
|
this.sidebarDragHandlersInitialized = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const moreDropdown = document.getElementById('sidebarMoreDropdown');
|
|
||||||
if (moreDropdown) {
|
|
||||||
moreDropdown.classList.remove('open');
|
|
||||||
}
|
|
||||||
this.isMoreDropdownOpen = false;
|
|
||||||
this.hideSidebarHiddenIndicator();
|
this.hideSidebarHiddenIndicator();
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
@@ -165,7 +125,6 @@ export class SidebarManager {
|
|||||||
this.selectedPath = '';
|
this.selectedPath = '';
|
||||||
this.expandedNodes = new Set();
|
this.expandedNodes = new Set();
|
||||||
this.openDropdown = null;
|
this.openDropdown = null;
|
||||||
this.isHovering = false;
|
|
||||||
this.isDisabledByPage = false;
|
this.isDisabledByPage = false;
|
||||||
this.apiClient = null;
|
this.apiClient = null;
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
@@ -185,19 +144,13 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeEventHandlers() {
|
removeEventHandlers() {
|
||||||
const pinToggleBtn = document.getElementById('sidebarPinToggle');
|
|
||||||
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||||
const folderTree = document.getElementById('sidebarFolderTree');
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
||||||
const sidebarHeader = document.getElementById('sidebarHeader');
|
const sidebarHeader = document.getElementById('sidebarHeader');
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
|
||||||
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
|
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
|
||||||
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
|
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
|
||||||
|
|
||||||
if (pinToggleBtn) {
|
|
||||||
pinToggleBtn.removeEventListener('click', this.handlePinToggle);
|
|
||||||
}
|
|
||||||
if (collapseAllBtn) {
|
if (collapseAllBtn) {
|
||||||
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
|
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
|
||||||
}
|
}
|
||||||
@@ -212,14 +165,6 @@ export class SidebarManager {
|
|||||||
if (sidebarHeader) {
|
if (sidebarHeader) {
|
||||||
sidebarHeader.removeEventListener('click', this.handleSidebarHeaderClick);
|
sidebarHeader.removeEventListener('click', this.handleSidebarHeaderClick);
|
||||||
}
|
}
|
||||||
if (sidebar) {
|
|
||||||
sidebar.removeEventListener('mouseenter', this.handleMouseEnter);
|
|
||||||
sidebar.removeEventListener('mouseleave', this.handleMouseLeave);
|
|
||||||
}
|
|
||||||
if (hoverArea) {
|
|
||||||
hoverArea.removeEventListener('mouseenter', this.handleHoverAreaEnter);
|
|
||||||
hoverArea.removeEventListener('mouseleave', this.handleHoverAreaLeave);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove document click handler
|
// Remove document click handler
|
||||||
document.removeEventListener('click', this.handleDocumentClick);
|
document.removeEventListener('click', this.handleDocumentClick);
|
||||||
@@ -234,17 +179,10 @@ export class SidebarManager {
|
|||||||
recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle);
|
recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
const moreToggle = document.getElementById('sidebarMoreToggle');
|
const hideToggle = document.getElementById('sidebarHideToggle');
|
||||||
if (moreToggle) {
|
if (hideToggle) {
|
||||||
moreToggle.removeEventListener('click', this.handleMoreToggle);
|
hideToggle.removeEventListener('click', this.handleHideToggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
const moreDropdown = document.getElementById('sidebarMoreDropdown');
|
|
||||||
if (moreDropdown) {
|
|
||||||
moreDropdown.removeEventListener('click', this.handleMoreDropdownItemClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.removeEventListener('click', this.handleDocumentClickForMore);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeDragAndDrop() {
|
initializeDragAndDrop() {
|
||||||
@@ -519,21 +457,69 @@ export class SidebarManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
||||||
|
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||||
|
|
||||||
if (useBulkMove) {
|
if (useBulkMove) {
|
||||||
await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
|
const results = await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
|
||||||
|
movedFiles = (results || [])
|
||||||
|
.filter(r => r.success)
|
||||||
|
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||||
} else {
|
} else {
|
||||||
await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
|
const result = await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
|
||||||
|
if (result) {
|
||||||
|
movedFiles.push({
|
||||||
|
original_file_path: result.original_file_path || this.draggedFilePaths[0],
|
||||||
|
new_file_path: result.new_file_path
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log('[SidebarManager] apiClient.move successful');
|
console.log('[SidebarManager] apiClient.move successful');
|
||||||
|
|
||||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
// Update VirtualScroller in-place instead of full reload
|
||||||
console.log('[SidebarManager] calling resetAndReload');
|
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||||
await this.pageControls.resetAndReload(true);
|
const pageState = getCurrentPageState();
|
||||||
} else {
|
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
console.log('[SidebarManager] calling refresh');
|
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||||
await this.refresh();
|
const isFolderFiltered = pageState.activeFolder !== null;
|
||||||
|
|
||||||
|
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
|
|
||||||
|
// Determine if items in the target folder are visible in the current view
|
||||||
|
let itemsRemainVisible = true;
|
||||||
|
if (isFolderFiltered) {
|
||||||
|
if (isRecursive) {
|
||||||
|
itemsRemainVisible = normalizedActive === '' ||
|
||||||
|
normalizedTarget === normalizedActive ||
|
||||||
|
normalizedTarget.startsWith(normalizedActive + '/');
|
||||||
|
} else {
|
||||||
|
itemsRemainVisible = normalizedTarget === normalizedActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemsRemainVisible) {
|
||||||
|
// Items stay visible — update each item's file_path to reflect new location
|
||||||
|
for (const moved of movedFiles) {
|
||||||
|
if (moved.original_file_path && moved.new_file_path) {
|
||||||
|
state.virtualScroller.updateSingleItem(moved.original_file_path, {
|
||||||
|
file_path: moved.new_file_path,
|
||||||
|
folder: normalizedTarget
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Items no longer visible in current folder — remove from VirtualScroller
|
||||||
|
const pathsToRemove = movedFiles
|
||||||
|
.map(m => m.original_file_path)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (pathsToRemove.length > 0) {
|
||||||
|
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh sidebar folder tree only (no model data reload)
|
||||||
|
await this.refresh();
|
||||||
|
|
||||||
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
||||||
bulkManager.toggleBulkMode();
|
bulkManager.toggleBulkMode();
|
||||||
}
|
}
|
||||||
@@ -592,21 +578,69 @@ export class SidebarManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
||||||
|
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||||
|
|
||||||
if (useBulkMove) {
|
if (useBulkMove) {
|
||||||
await this.apiClient.moveBulkModels(draggedFilePaths, destination);
|
const results = await this.apiClient.moveBulkModels(draggedFilePaths, destination);
|
||||||
|
movedFiles = (results || [])
|
||||||
|
.filter(r => r.success)
|
||||||
|
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||||
} else {
|
} else {
|
||||||
await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
|
const result = await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
|
||||||
|
if (result) {
|
||||||
|
movedFiles.push({
|
||||||
|
original_file_path: result.original_file_path || draggedFilePaths[0],
|
||||||
|
new_file_path: result.new_file_path
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log('[SidebarManager] apiClient.move successful');
|
console.log('[SidebarManager] apiClient.move successful');
|
||||||
|
|
||||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
// Update VirtualScroller in-place instead of full reload
|
||||||
console.log('[SidebarManager] calling resetAndReload');
|
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||||
await this.pageControls.resetAndReload(true);
|
const pageState = getCurrentPageState();
|
||||||
} else {
|
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
console.log('[SidebarManager] calling refresh');
|
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||||
await this.refresh();
|
const isFolderFiltered = pageState.activeFolder !== null;
|
||||||
|
|
||||||
|
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
|
|
||||||
|
// Determine if items in the target folder are visible in the current view
|
||||||
|
let itemsRemainVisible = true;
|
||||||
|
if (isFolderFiltered) {
|
||||||
|
if (isRecursive) {
|
||||||
|
itemsRemainVisible = normalizedActive === '' ||
|
||||||
|
normalizedTarget === normalizedActive ||
|
||||||
|
normalizedTarget.startsWith(normalizedActive + '/');
|
||||||
|
} else {
|
||||||
|
itemsRemainVisible = normalizedTarget === normalizedActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemsRemainVisible) {
|
||||||
|
// Items stay visible — update each item's file_path to reflect new location
|
||||||
|
for (const moved of movedFiles) {
|
||||||
|
if (moved.original_file_path && moved.new_file_path) {
|
||||||
|
state.virtualScroller.updateSingleItem(moved.original_file_path, {
|
||||||
|
file_path: moved.new_file_path,
|
||||||
|
folder: normalizedTarget
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Items no longer visible in current folder — remove from VirtualScroller
|
||||||
|
const pathsToRemove = movedFiles
|
||||||
|
.map(m => m.original_file_path)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (pathsToRemove.length > 0) {
|
||||||
|
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh sidebar folder tree only (no model data reload)
|
||||||
|
await this.refresh();
|
||||||
|
|
||||||
if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
||||||
bulkManager.toggleBulkMode();
|
bulkManager.toggleBulkMode();
|
||||||
}
|
}
|
||||||
@@ -919,60 +953,6 @@ export class SidebarManager {
|
|||||||
this.currentDropTarget = null;
|
this.currentDropTarget = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
|
||||||
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|
|
||||||
|| this.pageControls?.sidebarApiClient
|
|
||||||
|| getModelApiClient();
|
|
||||||
|
|
||||||
// Set initial sidebar state immediately (hidden by default)
|
|
||||||
this.setInitialSidebarState();
|
|
||||||
|
|
||||||
this.setupEventHandlers();
|
|
||||||
this.initializeDragAndDrop();
|
|
||||||
this.updateSidebarTitle();
|
|
||||||
this.restoreSidebarState();
|
|
||||||
await this.loadFolderTree();
|
|
||||||
this.restoreSelectedFolder();
|
|
||||||
|
|
||||||
// Apply final state with animation after everything is loaded
|
|
||||||
this.applyFinalSidebarState();
|
|
||||||
|
|
||||||
// Update container margin based on initial sidebar state
|
|
||||||
this.updateContainerMargin();
|
|
||||||
}
|
|
||||||
|
|
||||||
setInitialSidebarState() {
|
|
||||||
if (this.isDisabledBySetting) return;
|
|
||||||
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
|
||||||
|
|
||||||
if (!sidebar || !hoverArea) return;
|
|
||||||
|
|
||||||
// Get stored pin state
|
|
||||||
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
|
|
||||||
this.isPinned = isPinned;
|
|
||||||
|
|
||||||
// Sidebar starts hidden by default (CSS handles this)
|
|
||||||
// Just set up the hover area state
|
|
||||||
if (window.innerWidth <= 1024) {
|
|
||||||
hoverArea.classList.add('disabled');
|
|
||||||
} else if (this.isPinned) {
|
|
||||||
hoverArea.classList.add('disabled');
|
|
||||||
} else {
|
|
||||||
hoverArea.classList.remove('disabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyFinalSidebarState() {
|
|
||||||
if (this.isDisabledBySetting) return;
|
|
||||||
|
|
||||||
// Use requestAnimationFrame to ensure DOM is ready
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
this.updateAutoHideState();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSidebarTitle() {
|
updateSidebarTitle() {
|
||||||
const sidebarTitle = document.getElementById('sidebarTitle');
|
const sidebarTitle = document.getElementById('sidebarTitle');
|
||||||
if (sidebarTitle) {
|
if (sidebarTitle) {
|
||||||
@@ -987,12 +967,6 @@ export class SidebarManager {
|
|||||||
sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick);
|
sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pin toggle button
|
|
||||||
const pinToggleBtn = document.getElementById('sidebarPinToggle');
|
|
||||||
if (pinToggleBtn) {
|
|
||||||
pinToggleBtn.addEventListener('click', this.handlePinToggle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapse all button
|
// Collapse all button
|
||||||
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||||
if (collapseAllBtn) {
|
if (collapseAllBtn) {
|
||||||
@@ -1018,34 +992,18 @@ export class SidebarManager {
|
|||||||
sidebarBreadcrumbNav.addEventListener('click', this.handleBreadcrumbClick);
|
sidebarBreadcrumbNav.addEventListener('click', this.handleBreadcrumbClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hover detection for auto-hide
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
|
||||||
|
|
||||||
if (sidebar) {
|
|
||||||
sidebar.addEventListener('mouseenter', this.handleMouseEnter);
|
|
||||||
sidebar.addEventListener('mouseleave', this.handleMouseLeave);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hoverArea) {
|
|
||||||
hoverArea.addEventListener('mouseenter', this.handleHoverAreaEnter);
|
|
||||||
hoverArea.addEventListener('mouseleave', this.handleHoverAreaLeave);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close sidebar when clicking outside on mobile
|
// Close sidebar when clicking outside on mobile
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (window.innerWidth <= 1024 && this.isVisible) {
|
if (window.innerWidth <= 1024) {
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
if (sidebar && !sidebar.contains(e.target) && !this.isDisabledByPage) {
|
||||||
if (sidebar && !sidebar.contains(e.target)) {
|
sidebar.classList.remove('visible');
|
||||||
this.hideSidebar();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle window resize
|
// Handle window resize
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
this.updateAutoHideState();
|
|
||||||
this.updateContainerMargin();
|
this.updateContainerMargin();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1074,18 +1032,11 @@ export class SidebarManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// More options dropdown
|
// Dedicated hide sidebar button
|
||||||
const moreToggle = document.getElementById('sidebarMoreToggle');
|
const hideToggle = document.getElementById('sidebarHideToggle');
|
||||||
if (moreToggle) {
|
if (hideToggle) {
|
||||||
moreToggle.addEventListener('click', this.handleMoreToggle);
|
hideToggle.addEventListener('click', this.handleHideToggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
const moreDropdown = document.getElementById('sidebarMoreDropdown');
|
|
||||||
if (moreDropdown) {
|
|
||||||
moreDropdown.addEventListener('click', this.handleMoreDropdownItemClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', this.handleDocumentClickForMore);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDocumentClick(event) {
|
handleDocumentClick(event) {
|
||||||
@@ -1102,14 +1053,9 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePinToggle(event) {
|
handleHideToggle(event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.isPinned = !this.isPinned;
|
this.toggleHideOnThisPage();
|
||||||
this.updateAutoHideState();
|
|
||||||
this.updatePinButton();
|
|
||||||
this.updateMoreDropdownLabels();
|
|
||||||
this.saveSidebarState();
|
|
||||||
this.updateContainerMargin();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCollapseAll(event) {
|
handleCollapseAll(event) {
|
||||||
@@ -1119,102 +1065,13 @@ export class SidebarManager {
|
|||||||
this.saveExpandedState();
|
this.saveExpandedState();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseEnter() {
|
// ===== Sidebar visibility (per-page) and container margin =====
|
||||||
this.isHovering = true;
|
|
||||||
if (this.hoverTimeout) {
|
|
||||||
clearTimeout(this.hoverTimeout);
|
|
||||||
this.hoverTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isPinned) {
|
|
||||||
this.showSidebar();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMouseLeave() {
|
|
||||||
this.isHovering = false;
|
|
||||||
if (!this.isPinned) {
|
|
||||||
this.hoverTimeout = setTimeout(() => {
|
|
||||||
if (!this.isHovering) {
|
|
||||||
this.hideSidebar();
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHoverAreaEnter() {
|
|
||||||
if (!this.isPinned) {
|
|
||||||
this.showSidebar();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHoverAreaLeave() {
|
|
||||||
// Let the sidebar's mouse leave handler deal with hiding
|
|
||||||
}
|
|
||||||
|
|
||||||
showSidebar() {
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
if (sidebar && !this.isPinned) {
|
|
||||||
sidebar.classList.add('hover-active');
|
|
||||||
this.isVisible = true;
|
|
||||||
this.updateContainerMargin();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hideSidebar() {
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
if (sidebar && !this.isPinned) {
|
|
||||||
sidebar.classList.remove('hover-active');
|
|
||||||
this.isVisible = false;
|
|
||||||
this.updateContainerMargin();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAutoHideState() {
|
|
||||||
if (this.isDisabledBySetting || this.isDisabledByPage) return;
|
|
||||||
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
|
||||||
|
|
||||||
if (!sidebar || !hoverArea) return;
|
|
||||||
|
|
||||||
if (window.innerWidth <= 1024) {
|
|
||||||
// Mobile: always use collapsed state
|
|
||||||
sidebar.classList.remove('auto-hide', 'hover-active', 'visible');
|
|
||||||
sidebar.classList.add('collapsed');
|
|
||||||
hoverArea.classList.add('disabled');
|
|
||||||
this.isVisible = false;
|
|
||||||
} else if (this.isPinned) {
|
|
||||||
// Desktop pinned: always visible
|
|
||||||
sidebar.classList.remove('auto-hide', 'collapsed', 'hover-active');
|
|
||||||
sidebar.classList.add('visible');
|
|
||||||
hoverArea.classList.add('disabled');
|
|
||||||
this.isVisible = true;
|
|
||||||
} else {
|
|
||||||
// Desktop auto-hide: use hover detection
|
|
||||||
sidebar.classList.remove('collapsed', 'visible');
|
|
||||||
sidebar.classList.add('auto-hide');
|
|
||||||
hoverArea.classList.remove('disabled');
|
|
||||||
|
|
||||||
if (this.isHovering) {
|
|
||||||
sidebar.classList.add('hover-active');
|
|
||||||
this.isVisible = true;
|
|
||||||
} else {
|
|
||||||
sidebar.classList.remove('hover-active');
|
|
||||||
this.isVisible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update container margin when sidebar state changes
|
|
||||||
this.updateContainerMargin();
|
|
||||||
}
|
|
||||||
|
|
||||||
// New method to update container margin based on sidebar state
|
|
||||||
updateContainerMargin() {
|
updateContainerMargin() {
|
||||||
const container = document.querySelector('.container');
|
const container = document.querySelector('.container');
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
|
||||||
if (!container || !sidebar || this.isDisabledBySetting) return;
|
if (!container || !sidebar) return;
|
||||||
|
|
||||||
// Always reset margin first — needed when transitioning from visible to hidden
|
// Always reset margin first — needed when transitioning from visible to hidden
|
||||||
container.style.marginLeft = '';
|
container.style.marginLeft = '';
|
||||||
@@ -1222,194 +1079,40 @@ export class SidebarManager {
|
|||||||
// When per-page disabled, skip adjustment but margin is already reset
|
// When per-page disabled, skip adjustment but margin is already reset
|
||||||
if (this.isDisabledByPage) return;
|
if (this.isDisabledByPage) return;
|
||||||
|
|
||||||
// Only adjust margin if sidebar is visible and pinned
|
// Sidebar is visible — adjust margin if we need room
|
||||||
if ((this.isPinned || this.isHovering) && this.isVisible) {
|
const sidebarWidth = sidebar.offsetWidth;
|
||||||
const sidebarWidth = sidebar.offsetWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportWidth = window.innerWidth;
|
const containerWidth = container.offsetWidth;
|
||||||
const containerWidth = container.offsetWidth;
|
|
||||||
|
|
||||||
// Check if there's enough space for both sidebar and container
|
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
|
||||||
// We need: sidebar width + container width + some padding < viewport width
|
container.style.marginLeft = `${sidebarWidth + 10}px`;
|
||||||
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
|
|
||||||
// Not enough space, push container to the right
|
|
||||||
container.style.marginLeft = `${sidebarWidth + 10}px`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDomVisibility(enabled) {
|
updateDomVisibility() {
|
||||||
// Per-page disable adds on top of global setting
|
const isHidden = this.isDisabledByPage;
|
||||||
const isVisible = enabled && !this.isDisabledByPage;
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
|
||||||
|
|
||||||
if (sidebar) {
|
if (sidebar) {
|
||||||
sidebar.classList.toggle('hidden-by-setting', !isVisible);
|
sidebar.classList.toggle('visible', !isHidden);
|
||||||
sidebar.setAttribute('aria-hidden', (!isVisible).toString());
|
sidebar.classList.toggle('hidden-by-setting', isHidden);
|
||||||
|
sidebar.setAttribute('aria-hidden', isHidden.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hoverArea) {
|
// Show or hide the "sidebar hidden" edge indicator
|
||||||
hoverArea.classList.toggle('hidden-by-setting', !isVisible);
|
if (isHidden) {
|
||||||
if (!isVisible) {
|
|
||||||
hoverArea.classList.add('disabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show or hide the "sidebar hidden" notification
|
|
||||||
if (enabled && this.isDisabledByPage) {
|
|
||||||
this.showSidebarHiddenIndicator();
|
this.showSidebarHiddenIndicator();
|
||||||
} else {
|
} else {
|
||||||
this.hideSidebarHiddenIndicator();
|
this.hideSidebarHiddenIndicator();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSidebarEnabled(enabled) {
|
|
||||||
this.isDisabledBySetting = !enabled;
|
|
||||||
this.updateDomVisibility(enabled);
|
|
||||||
|
|
||||||
const shouldForceInitialization = !enabled && !this.isInitialized;
|
|
||||||
const needsInitialization = !this.isInitialized || shouldForceInitialization;
|
|
||||||
|
|
||||||
if (this.lastPageControls && needsInitialization) {
|
|
||||||
if (!this.initializationPromise) {
|
|
||||||
this.initializationPromise = this.initialize(this.lastPageControls, {
|
|
||||||
forceInitialize: shouldForceInitialization,
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Sidebar initialization failed:', error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.initializationPromise = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.initializationPromise;
|
|
||||||
} else if (this.initializationPromise) {
|
|
||||||
await this.initializationPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!enabled) {
|
|
||||||
this.isHovering = false;
|
|
||||||
this.isVisible = false;
|
|
||||||
|
|
||||||
const container = document.querySelector('.container');
|
|
||||||
if (container) {
|
|
||||||
container.style.marginLeft = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isInitialized) {
|
|
||||||
this.updateBreadcrumbs();
|
|
||||||
this.updateSidebarHeader();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isInitialized) {
|
|
||||||
this.updateAutoHideState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePinButton() {
|
|
||||||
const pinBtn = document.getElementById('sidebarPinToggle');
|
|
||||||
if (pinBtn) {
|
|
||||||
pinBtn.classList.toggle('active', this.isPinned);
|
|
||||||
pinBtn.title = this.isPinned
|
|
||||||
? translate('sidebar.unpinSidebar')
|
|
||||||
: translate('sidebar.pinSidebar');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== More Options Dropdown =====
|
|
||||||
|
|
||||||
handleMoreToggle(event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
const dropdown = document.getElementById('sidebarMoreDropdown');
|
|
||||||
if (!dropdown) return;
|
|
||||||
|
|
||||||
this.isMoreDropdownOpen = !dropdown.classList.contains('open');
|
|
||||||
dropdown.classList.toggle('open', this.isMoreDropdownOpen);
|
|
||||||
this.updateMoreDropdownLabels();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMoreDropdownItemClick(event) {
|
|
||||||
const item = event.target.closest('.sidebar-dropdown-item');
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
const action = item.dataset.action;
|
|
||||||
if (!action) return;
|
|
||||||
|
|
||||||
const dropdown = document.getElementById('sidebarMoreDropdown');
|
|
||||||
if (dropdown) {
|
|
||||||
dropdown.classList.remove('open');
|
|
||||||
this.isMoreDropdownOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'toggle-pin':
|
|
||||||
this.handlePinToggle(event);
|
|
||||||
break;
|
|
||||||
case 'toggle-hide':
|
|
||||||
this.toggleHideOnThisPage();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDocumentClickForMore(event) {
|
|
||||||
const dropdown = document.getElementById('sidebarMoreDropdown');
|
|
||||||
const toggle = document.getElementById('sidebarMoreToggle');
|
|
||||||
if (!dropdown || !toggle) return;
|
|
||||||
|
|
||||||
if (!dropdown.contains(event.target) && !toggle.contains(event.target)) {
|
|
||||||
dropdown.classList.remove('open');
|
|
||||||
this.isMoreDropdownOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMoreDropdownLabels() {
|
|
||||||
const pinLabel = document.getElementById('sidebarMorePinLabel');
|
|
||||||
if (pinLabel) {
|
|
||||||
pinLabel.textContent = this.isPinned
|
|
||||||
? translate('sidebar.unpinSidebar')
|
|
||||||
: translate('sidebar.pinSidebar');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideItem = document.querySelector('.sidebar-dropdown-item[data-action="toggle-hide"]');
|
|
||||||
if (hideItem) {
|
|
||||||
const hideIcon = hideItem.querySelector('i');
|
|
||||||
const hideLabel = hideItem.querySelector('span');
|
|
||||||
if (this.isDisabledByPage) {
|
|
||||||
hideLabel.textContent = translate('sidebar.showSidebar');
|
|
||||||
if (hideIcon) {
|
|
||||||
hideIcon.className = 'fas fa-eye';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hideLabel.textContent = translate('sidebar.hideOnThisPage');
|
|
||||||
if (hideIcon) {
|
|
||||||
hideIcon.className = 'fas fa-eye-slash';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleHideOnThisPage() {
|
toggleHideOnThisPage() {
|
||||||
this.isDisabledByPage = !this.isDisabledByPage;
|
this.isDisabledByPage = !this.isDisabledByPage;
|
||||||
setStorageItem(`${this.pageType}_sidebarDisabled`, this.isDisabledByPage);
|
setStorageItem(`${this.pageType}_sidebarDisabled`, this.isDisabledByPage);
|
||||||
this.updateDomVisibility(!this.isDisabledBySetting);
|
this.updateDomVisibility();
|
||||||
this.updateAutoHideState();
|
|
||||||
this.updateContainerMargin();
|
this.updateContainerMargin();
|
||||||
this.updateMoreDropdownLabels();
|
|
||||||
|
|
||||||
if (!this.isDisabledByPage) {
|
|
||||||
this.hideSidebarHiddenIndicator();
|
|
||||||
} else {
|
|
||||||
showToast(
|
|
||||||
'sidebar.sidebarHiddenNotification',
|
|
||||||
{ page: this.getPageDisplayName() },
|
|
||||||
'info',
|
|
||||||
`Sidebar hidden on ${this.getPageDisplayName()} page`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getPageDisplayName() {
|
getPageDisplayName() {
|
||||||
@@ -1433,7 +1136,15 @@ export class SidebarManager {
|
|||||||
<span class="sidebar-hidden-indicator-tooltip">${translate('sidebar.showSidebar')}</span>
|
<span class="sidebar-hidden-indicator-tooltip">${translate('sidebar.showSidebar')}</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Subtle breathing animation on first sight to aid discoverability;
|
||||||
|
// stops permanently after user clicks the restore button once
|
||||||
|
const restoreKey = `${this.pageType}_restoreButtonUsed`;
|
||||||
|
if (!getStorageItem(restoreKey, false)) {
|
||||||
|
indicator.classList.add('breathing');
|
||||||
|
}
|
||||||
|
|
||||||
indicator.addEventListener('click', () => {
|
indicator.addEventListener('click', () => {
|
||||||
|
setStorageItem(restoreKey, true);
|
||||||
this.toggleHideOnThisPage();
|
this.toggleHideOnThisPage();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1731,13 +1442,8 @@ export class SidebarManager {
|
|||||||
this.pageControls.pageState.activeFolder = normalizedPath;
|
this.pageControls.pageState.activeFolder = normalizedPath;
|
||||||
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
|
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
|
||||||
|
|
||||||
// Reload models with new filter
|
// Reload models with new filter (loadMoreWithVirtualScroll will scroll to top)
|
||||||
await this.pageControls.resetAndReload();
|
await this.pageControls.resetAndReload();
|
||||||
|
|
||||||
// Auto-hide sidebar on mobile after selection
|
|
||||||
if (window.innerWidth <= 1024) {
|
|
||||||
this.hideSidebar();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFolderListClick(event) {
|
handleFolderListClick(event) {
|
||||||
@@ -2047,65 +1753,55 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSidebar() {
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
|
|
||||||
|
|
||||||
if (!sidebar) return;
|
|
||||||
|
|
||||||
this.isVisible = !this.isVisible;
|
|
||||||
|
|
||||||
if (this.isVisible) {
|
|
||||||
sidebar.classList.remove('collapsed');
|
|
||||||
sidebar.classList.add('visible');
|
|
||||||
} else {
|
|
||||||
sidebar.classList.remove('visible');
|
|
||||||
sidebar.classList.add('collapsed');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.classList.toggle('active', this.isVisible);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.saveSidebarState();
|
|
||||||
}
|
|
||||||
|
|
||||||
closeSidebar() {
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
|
|
||||||
|
|
||||||
if (!sidebar) return;
|
|
||||||
|
|
||||||
this.isVisible = false;
|
|
||||||
sidebar.classList.remove('visible');
|
|
||||||
sidebar.classList.add('collapsed');
|
|
||||||
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.classList.remove('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.saveSidebarState();
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreSidebarState() {
|
restoreSidebarState() {
|
||||||
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
|
// Migration: old pin/unpin and global hide → per-page hide
|
||||||
|
this._migrateOldSettings();
|
||||||
|
|
||||||
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
||||||
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
||||||
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
||||||
this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false);
|
this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false);
|
||||||
|
|
||||||
this.isPinned = isPinned;
|
|
||||||
this.expandedNodes = new Set(expandedPaths);
|
this.expandedNodes = new Set(expandedPaths);
|
||||||
this.displayMode = displayMode;
|
this.displayMode = displayMode;
|
||||||
this.recursiveSearchEnabled = recursiveSearchEnabled;
|
this.recursiveSearchEnabled = recursiveSearchEnabled;
|
||||||
|
|
||||||
this.updatePinButton();
|
|
||||||
this.updateDisplayModeButton();
|
this.updateDisplayModeButton();
|
||||||
this.updateCollapseAllButton();
|
this.updateCollapseAllButton();
|
||||||
this.updateSearchRecursiveOption();
|
this.updateSearchRecursiveOption();
|
||||||
this.updateRecursiveToggleButton();
|
this.updateRecursiveToggleButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration: old pin/unpin and global show_folder_sidebar → per-page hide
|
||||||
|
* - sidebarPinned=false (was auto-hide) → sidebarDisabled=true for that page
|
||||||
|
* - show_folder_sidebar=false (global) → sidebarDisabled=true for ALL pages
|
||||||
|
*/
|
||||||
|
_migrateOldSettings() {
|
||||||
|
if (getStorageItem('_sidebar_migration_done')) return;
|
||||||
|
|
||||||
|
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings'];
|
||||||
|
|
||||||
|
// 1. Migrate global hide setting to per-page
|
||||||
|
if (state?.global?.settings?.show_folder_sidebar === false) {
|
||||||
|
PAGES.forEach(p => setStorageItem(`${p}_sidebarDisabled`, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Migrate unpinned (auto-hide) to per-page hide
|
||||||
|
PAGES.forEach(p => {
|
||||||
|
const wasPinned = getStorageItem(`${p}_sidebarPinned`, true);
|
||||||
|
const alreadyDisabled = getStorageItem(`${p}_sidebarDisabled`, false);
|
||||||
|
if (wasPinned === false && !alreadyDisabled) {
|
||||||
|
// Was auto-hide → user didn't want sidebar taking space
|
||||||
|
setStorageItem(`${p}_sidebarDisabled`, true);
|
||||||
|
}
|
||||||
|
// Clean up old keys
|
||||||
|
localStorage.removeItem(`${p}_sidebarPinned`);
|
||||||
|
});
|
||||||
|
|
||||||
|
setStorageItem('_sidebar_migration_done', true);
|
||||||
|
}
|
||||||
|
|
||||||
restoreSelectedFolder() {
|
restoreSelectedFolder() {
|
||||||
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
|
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
|
||||||
if (activeFolder && typeof activeFolder === 'string') {
|
if (activeFolder && typeof activeFolder === 'string') {
|
||||||
@@ -2118,11 +1814,6 @@ export class SidebarManager {
|
|||||||
this.updateSidebarHeader();
|
this.updateSidebarHeader();
|
||||||
this.updateBreadcrumbs(); // Always update breadcrumbs
|
this.updateBreadcrumbs(); // Always update breadcrumbs
|
||||||
}
|
}
|
||||||
// Removed hidden class toggle since breadcrumbs are always visible now
|
|
||||||
}
|
|
||||||
|
|
||||||
saveSidebarState() {
|
|
||||||
setStorageItem(`${this.pageType}_sidebarPinned`, this.isPinned);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveExpandedState() {
|
saveExpandedState() {
|
||||||
@@ -2134,7 +1825,7 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
async refresh() {
|
||||||
if (this.isDisabledBySetting || !this.isInitialized) {
|
if (!this.isInitialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,23 @@ export class CheckpointsControls extends PageControls {
|
|||||||
* Clear checkpoint custom filter and reload
|
* Clear checkpoint custom filter and reload
|
||||||
*/
|
*/
|
||||||
async clearCustomFilter() {
|
async clearCustomFilter() {
|
||||||
|
// Check for View Local Versions filter first
|
||||||
|
const vlmModelId = getSessionItem('vlm_model_id');
|
||||||
|
if (vlmModelId) {
|
||||||
|
removeSessionItem('vlm_model_id');
|
||||||
|
removeSessionItem('vlm_model_name');
|
||||||
|
removeSessionItem('vlm_base_model');
|
||||||
|
removeSessionItem('vlm_page_type');
|
||||||
|
this._restoreSortAfterVlm();
|
||||||
|
// Hide the indicator
|
||||||
|
const indicator = document.getElementById('customFilterIndicator');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.classList.add('hidden');
|
||||||
|
}
|
||||||
|
await resetAndReload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
removeSessionItem('recipe_to_checkpoint_filterHash');
|
removeSessionItem('recipe_to_checkpoint_filterHash');
|
||||||
removeSessionItem('recipe_to_checkpoint_filterHashes');
|
removeSessionItem('recipe_to_checkpoint_filterHashes');
|
||||||
removeSessionItem('filterCheckpointRecipeName');
|
removeSessionItem('filterCheckpointRecipeName');
|
||||||
@@ -106,14 +123,4 @@ export class CheckpointsControls extends PageControls {
|
|||||||
|
|
||||||
await resetAndReload();
|
await resetAndReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to truncate text with ellipsis
|
|
||||||
* @param {string} text
|
|
||||||
* @param {number} maxLength
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
_truncateText(text, maxLength) {
|
|
||||||
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,22 @@ export class LorasControls extends PageControls {
|
|||||||
* Clear the custom filter and reload the page
|
* Clear the custom filter and reload the page
|
||||||
*/
|
*/
|
||||||
async clearCustomFilter() {
|
async clearCustomFilter() {
|
||||||
|
// Check for View Local Versions filter first (handles VLM and reloads)
|
||||||
|
const vlmModelId = getSessionItem('vlm_model_id');
|
||||||
|
if (vlmModelId) {
|
||||||
|
removeSessionItem('vlm_model_id');
|
||||||
|
removeSessionItem('vlm_model_name');
|
||||||
|
removeSessionItem('vlm_base_model');
|
||||||
|
removeSessionItem('vlm_page_type');
|
||||||
|
this._restoreSortAfterVlm();
|
||||||
|
const indicator = document.getElementById('customFilterIndicator');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.classList.add('hidden');
|
||||||
|
}
|
||||||
|
await resetAndReload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Clearing custom filter...");
|
console.log("Clearing custom filter...");
|
||||||
// Remove filter parameters from session storage
|
// Remove filter parameters from session storage
|
||||||
removeSessionItem('recipe_to_lora_filterLoraHash');
|
removeSessionItem('recipe_to_lora_filterLoraHash');
|
||||||
@@ -134,16 +150,6 @@ export class LorasControls extends PageControls {
|
|||||||
await resetAndReload();
|
await resetAndReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to truncate text with ellipsis
|
|
||||||
* @param {string} text - Text to truncate
|
|
||||||
* @param {number} maxLength - Maximum length before truncating
|
|
||||||
* @returns {string} - Truncated text
|
|
||||||
*/
|
|
||||||
_truncateText(text, maxLength) {
|
|
||||||
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the alphabet bar component
|
* Initialize the alphabet bar component
|
||||||
*/
|
*/
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user