mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-17 16:09:25 -03:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8314b9bedb | ||
|
|
75298a402f | ||
|
|
92b5efd414 | ||
|
|
33ee392b7b | ||
|
|
5237f8b7dc | ||
|
|
5107313fd1 | ||
|
|
95bbc66919 | ||
|
|
e268e59419 | ||
|
|
547e1f9498 |
@@ -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
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,3 +28,6 @@ vue-widgets/dist/
|
|||||||
|
|
||||||
# Hypothesis test cache
|
# Hypothesis test cache
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
|
|
||||||
|
# Working/research notes (not committed)
|
||||||
|
.docs/
|
||||||
|
|||||||
@@ -1424,15 +1424,6 @@
|
|||||||
"duplicate": "Dieser Tag existiert bereits"
|
"duplicate": "Dieser Tag existiert bereits"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keyboard": {
|
|
||||||
"navigation": "Tastatur-Navigation:",
|
|
||||||
"shortcuts": {
|
|
||||||
"pageUp": "Eine Seite nach oben scrollen",
|
|
||||||
"pageDown": "Eine Seite nach unten scrollen",
|
|
||||||
"home": "Zum Anfang springen",
|
|
||||||
"end": "Zum Ende springen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"initialization": {
|
"initialization": {
|
||||||
"title": "Initialisierung",
|
"title": "Initialisierung",
|
||||||
"message": "Ihr Arbeitsbereich wird vorbereitet...",
|
"message": "Ihr Arbeitsbereich wird vorbereitet...",
|
||||||
|
|||||||
@@ -1424,15 +1424,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...",
|
||||||
|
|||||||
@@ -1424,15 +1424,6 @@
|
|||||||
"duplicate": "Esta etiqueta ya existe"
|
"duplicate": "Esta etiqueta ya existe"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keyboard": {
|
|
||||||
"navigation": "Navegación por teclado:",
|
|
||||||
"shortcuts": {
|
|
||||||
"pageUp": "Desplazar hacia arriba una página",
|
|
||||||
"pageDown": "Desplazar hacia abajo una página",
|
|
||||||
"home": "Saltar al inicio",
|
|
||||||
"end": "Saltar al final"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"initialization": {
|
"initialization": {
|
||||||
"title": "Inicializando",
|
"title": "Inicializando",
|
||||||
"message": "Preparando tu espacio de trabajo...",
|
"message": "Preparando tu espacio de trabajo...",
|
||||||
|
|||||||
@@ -1424,15 +1424,6 @@
|
|||||||
"duplicate": "Ce tag existe déjà"
|
"duplicate": "Ce tag existe déjà"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keyboard": {
|
|
||||||
"navigation": "Navigation au clavier :",
|
|
||||||
"shortcuts": {
|
|
||||||
"pageUp": "Défiler d'une page vers le haut",
|
|
||||||
"pageDown": "Défiler d'une page vers le bas",
|
|
||||||
"home": "Aller en haut",
|
|
||||||
"end": "Aller en bas"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"initialization": {
|
"initialization": {
|
||||||
"title": "Initialisation",
|
"title": "Initialisation",
|
||||||
"message": "Préparation de votre espace de travail...",
|
"message": "Préparation de votre espace de travail...",
|
||||||
|
|||||||
@@ -1424,15 +1424,6 @@
|
|||||||
"duplicate": "תגית זו כבר קיימת"
|
"duplicate": "תגית זו כבר קיימת"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keyboard": {
|
|
||||||
"navigation": "ניווט במקלדת:",
|
|
||||||
"shortcuts": {
|
|
||||||
"pageUp": "גלול עמוד אחד למעלה",
|
|
||||||
"pageDown": "גלול עמוד אחד למטה",
|
|
||||||
"home": "קפוץ להתחלה",
|
|
||||||
"end": "קפוץ לסוף"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"initialization": {
|
"initialization": {
|
||||||
"title": "מאתחל",
|
"title": "מאתחל",
|
||||||
"message": "מכין את סביבת העבודה שלך...",
|
"message": "מכין את סביבת העבודה שלך...",
|
||||||
|
|||||||
@@ -1424,15 +1424,6 @@
|
|||||||
"duplicate": "このタグは既に存在します"
|
"duplicate": "このタグは既に存在します"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keyboard": {
|
|
||||||
"navigation": "キーボードナビゲーション:",
|
|
||||||
"shortcuts": {
|
|
||||||
"pageUp": "1ページ上にスクロール",
|
|
||||||
"pageDown": "1ページ下にスクロール",
|
|
||||||
"home": "トップにジャンプ",
|
|
||||||
"end": "ボトムにジャンプ"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"initialization": {
|
"initialization": {
|
||||||
"title": "初期化中",
|
"title": "初期化中",
|
||||||
"message": "ワークスペースを準備中...",
|
"message": "ワークスペースを準備中...",
|
||||||
|
|||||||
@@ -1424,15 +1424,6 @@
|
|||||||
"duplicate": "이 태그는 이미 존재합니다"
|
"duplicate": "이 태그는 이미 존재합니다"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keyboard": {
|
|
||||||
"navigation": "키보드 내비게이션:",
|
|
||||||
"shortcuts": {
|
|
||||||
"pageUp": "한 페이지 위로 스크롤",
|
|
||||||
"pageDown": "한 페이지 아래로 스크롤",
|
|
||||||
"home": "맨 위로 이동",
|
|
||||||
"end": "맨 아래로 이동"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"initialization": {
|
"initialization": {
|
||||||
"title": "초기화 중",
|
"title": "초기화 중",
|
||||||
"message": "작업공간을 준비하고 있습니다...",
|
"message": "작업공간을 준비하고 있습니다...",
|
||||||
|
|||||||
@@ -1424,15 +1424,6 @@
|
|||||||
"duplicate": "Этот тег уже существует"
|
"duplicate": "Этот тег уже существует"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keyboard": {
|
|
||||||
"navigation": "Навигация с клавиатуры:",
|
|
||||||
"shortcuts": {
|
|
||||||
"pageUp": "Прокрутить на страницу вверх",
|
|
||||||
"pageDown": "Прокрутить на страницу вниз",
|
|
||||||
"home": "Перейти к началу",
|
|
||||||
"end": "Перейти к концу"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"initialization": {
|
"initialization": {
|
||||||
"title": "Инициализация",
|
"title": "Инициализация",
|
||||||
"message": "Подготовка вашего рабочего пространства...",
|
"message": "Подготовка вашего рабочего пространства...",
|
||||||
|
|||||||
@@ -1424,15 +1424,6 @@
|
|||||||
"duplicate": "该标签已存在"
|
"duplicate": "该标签已存在"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keyboard": {
|
|
||||||
"navigation": "键盘导航:",
|
|
||||||
"shortcuts": {
|
|
||||||
"pageUp": "向上一页滚动",
|
|
||||||
"pageDown": "向下一页滚动",
|
|
||||||
"home": "跳到顶部",
|
|
||||||
"end": "跳到底部"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"initialization": {
|
"initialization": {
|
||||||
"title": "初始化",
|
"title": "初始化",
|
||||||
"message": "正在准备你的工作空间...",
|
"message": "正在准备你的工作空间...",
|
||||||
|
|||||||
@@ -1424,15 +1424,6 @@
|
|||||||
"duplicate": "此標籤已存在"
|
"duplicate": "此標籤已存在"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keyboard": {
|
|
||||||
"navigation": "鍵盤導覽:",
|
|
||||||
"shortcuts": {
|
|
||||||
"pageUp": "向上捲動一頁",
|
|
||||||
"pageDown": "向下捲動一頁",
|
|
||||||
"home": "跳至頂部",
|
|
||||||
"end": "跳至底部"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"initialization": {
|
"initialization": {
|
||||||
"title": "初始化",
|
"title": "初始化",
|
||||||
"message": "正在準備您的工作區...",
|
"message": "正在準備您的工作區...",
|
||||||
|
|||||||
@@ -1820,6 +1820,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."""
|
||||||
@@ -2864,6 +2897,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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ async def calculate_sha256(file_path: str) -> str:
|
|||||||
Uses ``posix_fadvise`` with ``POSIX_FADV_DONTNEED`` to avoid polluting the OS page
|
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
|
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.
|
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()
|
||||||
@@ -48,7 +51,9 @@ async def calculate_sha256(file_path: str) -> str:
|
|||||||
sha256_hash.update(byte_block)
|
sha256_hash.update(byte_block)
|
||||||
# Evict pages after reading so the data doesn't linger in the kernel page
|
# Evict pages after reading so the data doesn't linger in the kernel page
|
||||||
# cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth.
|
# cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth.
|
||||||
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
|
# 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,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.2"
|
version = "1.1.3"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -823,54 +823,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
@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';
|
||||||
|
|||||||
@@ -805,12 +805,14 @@ export class SettingsManager {
|
|||||||
|
|
||||||
// Set card blur amount slider
|
// Set card blur amount slider
|
||||||
const cardBlurAmountInput = document.getElementById('cardBlurAmount');
|
const cardBlurAmountInput = document.getElementById('cardBlurAmount');
|
||||||
|
const cardBlurValue = state.global.settings.card_blur_amount ?? 8;
|
||||||
if (cardBlurAmountInput) {
|
if (cardBlurAmountInput) {
|
||||||
cardBlurAmountInput.value = state.global.settings.card_blur_amount ?? 8;
|
cardBlurAmountInput.value = cardBlurValue;
|
||||||
|
cardBlurAmountInput.style.setProperty('--range-fill', (cardBlurValue / 20 * 100) + '%');
|
||||||
}
|
}
|
||||||
const cardBlurAmountValue = document.getElementById('cardBlurAmountValue');
|
const cardBlurAmountValue = document.getElementById('cardBlurAmountValue');
|
||||||
if (cardBlurAmountValue) {
|
if (cardBlurAmountValue) {
|
||||||
cardBlurAmountValue.textContent = `${state.global.settings.card_blur_amount ?? 8}px`;
|
cardBlurAmountValue.textContent = `${cardBlurValue}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usePortableCheckbox = document.getElementById('usePortableSettings');
|
const usePortableCheckbox = document.getElementById('usePortableSettings');
|
||||||
@@ -2070,6 +2072,9 @@ export class SettingsManager {
|
|||||||
displayEl.textContent = `${value}px`;
|
displayEl.textContent = `${value}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const max = parseInt(element.max, 10) || 20;
|
||||||
|
element.style.setProperty('--range-fill', (value / max * 100) + '%');
|
||||||
|
|
||||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||||
|
|||||||
@@ -100,30 +100,6 @@
|
|||||||
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="keyboard-nav-hint tooltip">
|
|
||||||
<i class="fas fa-keyboard"></i>
|
|
||||||
<span class="tooltiptext">
|
|
||||||
<span>{{ t('keyboard.navigation') }}</span>
|
|
||||||
<table class="keyboard-shortcuts">
|
|
||||||
<tr>
|
|
||||||
<td><span class="key">Page Up</span></td>
|
|
||||||
<td>{{ t('keyboard.shortcuts.pageUp') }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="key">Page Down</span></td>
|
|
||||||
<td>{{ t('keyboard.shortcuts.pageDown') }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="key">Home</span></td>
|
|
||||||
<td>{{ t('keyboard.shortcuts.home') }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="key">End</span></td>
|
|
||||||
<td>{{ t('keyboard.shortcuts.end') }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -549,7 +549,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-control range-control">
|
<div class="setting-control range-control">
|
||||||
<input type="range" id="cardBlurAmount" min="0" max="20" value="8" step="1"
|
<input type="range" id="cardBlurAmount" min="0" max="20" value="8" step="1"
|
||||||
oninput="document.getElementById('cardBlurAmountValue').textContent = this.value + 'px'"
|
oninput="var pct = (this.value / 20) * 100; this.style.setProperty('--range-fill', pct + '%'); document.getElementById('cardBlurAmountValue').textContent = this.value + 'px'"
|
||||||
onchange="settingsManager.saveRangeSetting('cardBlurAmount', 'cardBlurAmountValue', 'card_blur_amount')">
|
onchange="settingsManager.saveRangeSetting('cardBlurAmount', 'cardBlurAmountValue', 'card_blur_amount')">
|
||||||
<span id="cardBlurAmountValue" class="range-value">8px</span>
|
<span id="cardBlurAmountValue" class="range-value">8px</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -137,30 +137,6 @@
|
|||||||
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="keyboard-nav-hint tooltip">
|
|
||||||
<i class="fas fa-keyboard"></i>
|
|
||||||
<span class="tooltiptext">
|
|
||||||
<span>{{ t('keyboard.navigation') }}</span>
|
|
||||||
<table class="keyboard-shortcuts">
|
|
||||||
<tr>
|
|
||||||
<td><span class="key">Page Up</span></td>
|
|
||||||
<td>{{ t('keyboard.shortcuts.pageUp') }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="key">Page Down</span></td>
|
|
||||||
<td>{{ t('keyboard.shortcuts.pageDown') }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="key">Home</span></td>
|
|
||||||
<td>{{ t('keyboard.shortcuts.home') }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="key">End</span></td>
|
|
||||||
<td>{{ t('keyboard.shortcuts.end') }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user