mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 06:32:12 -03:00
Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9150718edb | ||
|
|
50abd85fae | ||
|
|
7b4607bed7 | ||
|
|
6f74186498 | ||
|
|
eb8b95176b | ||
|
|
091d8aba39 | ||
|
|
379e3ce2f6 | ||
|
|
1b7b598f7a | ||
|
|
fd06086a05 | ||
|
|
50c012ae33 | ||
|
|
796acba764 | ||
|
|
3aab0cc916 | ||
|
|
4c2c8c2bc8 | ||
|
|
e44180b832 | ||
|
|
4ff397e9c1 | ||
|
|
633ad2d386 | ||
|
|
1dee7f5cf9 | ||
|
|
b0f0158f98 | ||
|
|
7f2e8a0afb | ||
|
|
7a7517cfb6 | ||
|
|
f0c852ef23 | ||
|
|
839bcbd37f | ||
|
|
ab6a4844f0 | ||
|
|
dad549f65f | ||
|
|
aab1797269 | ||
|
|
cb460fcdb0 | ||
|
|
88e7f671d2 | ||
|
|
07d599810d | ||
|
|
4f3c91b307 | ||
|
|
ad7d372887 | ||
|
|
4e909f3008 | ||
|
|
bd0dfd4ef5 | ||
|
|
c5b597dc89 | ||
|
|
bd4958edc3 | ||
|
|
428a2ce420 | ||
|
|
5636437df2 | ||
|
|
10c0668b02 | ||
|
|
0c67ff85ee | ||
|
|
cde6151c71 | ||
|
|
9ed5319ad2 | ||
|
|
40756b7dd3 | ||
|
|
2a9ceb9e85 | ||
|
|
30077099ec | ||
|
|
fc8240e99e | ||
|
|
4951ff358e | ||
|
|
73f2a34d08 | ||
|
|
394eebe070 | ||
|
|
bc08a45214 | ||
|
|
0c96e8d328 | ||
|
|
859277a7eb | ||
|
|
9e510d64ec | ||
|
|
430ba84cf7 | ||
|
|
0ae2d084f4 | ||
|
|
514846cd4a | ||
|
|
1ebd2c93a0 | ||
|
|
688baef2f0 | ||
|
|
6a17e75782 | ||
|
|
bce6b0e610 | ||
|
|
177b20263d | ||
|
|
65cede7335 | ||
|
|
9719dd4d07 | ||
|
|
7a5f4514f3 | ||
|
|
b44ef9ceaa | ||
|
|
647728b2e1 | ||
|
|
3d348900ac | ||
|
|
32249d1886 | ||
|
|
f842ea990e | ||
|
|
f2e12c0fd3 | ||
|
|
f62b3f62be | ||
|
|
b57a317c82 | ||
|
|
fa063ba1ce | ||
|
|
eb30595d23 | ||
|
|
fd7cb3300d | ||
|
|
f199c9b591 | ||
|
|
255ca4fc93 | ||
|
|
09c1bd78cd | ||
|
|
edbcca9bbd | ||
|
|
8c68298202 | ||
|
|
a80380d1f0 | ||
|
|
f13f22c949 | ||
|
|
07aeeb6c70 | ||
|
|
4317b06049 | ||
|
|
ab85ba54a9 | ||
|
|
837c32c42f | ||
|
|
953117efa1 | ||
|
|
afa5533145 | ||
|
|
102defe29c | ||
|
|
8120716cd8 | ||
|
|
2b239c3747 | ||
|
|
a59c31bc06 | ||
|
|
d30c8e13df | ||
|
|
5d5a2a998a | ||
|
|
e5b557504e | ||
|
|
e43aa5cae4 | ||
|
|
f5d5bffa61 | ||
|
|
7d6b717385 | ||
|
|
d9ce2c56c0 | ||
|
|
914d24b8bf | ||
|
|
1329294981 | ||
|
|
475906a25e | ||
|
|
84b68cff90 | ||
|
|
41759f5e67 | ||
|
|
91cd88f1df | ||
|
|
e5869648fb | ||
|
|
7b139b9b1d | ||
|
|
a552f07448 | ||
|
|
6486107ca2 | ||
|
|
6330c65d41 | ||
|
|
00e6904664 | ||
|
|
39195aa529 | ||
|
|
fc0a834beb | ||
|
|
b044b329fc | ||
|
|
502c29c6bd | ||
|
|
bc9dd317f7 | ||
|
|
61816cf75d | ||
|
|
db7f09797b | ||
|
|
6e64f97e2b | ||
|
|
3f646aa0c9 | ||
|
|
67fb205b43 | ||
|
|
dd89aa49c1 | ||
|
|
3ba5c4c2ab | ||
|
|
7caca0163e | ||
|
|
30fd0470de | ||
|
|
63b087fc80 | ||
|
|
154ae82519 | ||
|
|
c8a179488a | ||
|
|
ca6bb43406 | ||
|
|
a07720a3bf | ||
|
|
bdb4422cbc | ||
|
|
099a71b2cc | ||
|
|
3382d83aee | ||
|
|
7e133e4b9d | ||
|
|
2494fa19a6 | ||
|
|
5359129fad | ||
|
|
4743b3c406 | ||
|
|
32d94be08a | ||
|
|
56143eb170 | ||
|
|
817de3a0ae | ||
|
|
675d49e4ce | ||
|
|
fbb95bc623 | ||
|
|
6b3a11e01a | ||
|
|
40f7f14c1b | ||
|
|
a6e23a7630 | ||
|
|
3fc72d6bc1 | ||
|
|
a3a00bbeed | ||
|
|
74bfd397aa | ||
|
|
5000478991 | ||
|
|
40cd2e23ac | ||
|
|
6efe59bd9e | ||
|
|
83f379df33 | ||
|
|
4d6f4fcf69 | ||
|
|
22ee37b817 | ||
|
|
f09224152a | ||
|
|
df93670598 | ||
|
|
073fb3a94a | ||
|
|
53c4165d82 | ||
|
|
8cd4550189 | ||
|
|
2b2e4fefab | ||
|
|
5f93648297 | ||
|
|
8a628f0bd0 | ||
|
|
b67c8598d6 | ||
|
|
0254c9d0e9 | ||
|
|
ecb512995c | ||
|
|
f8b9fa9b20 |
24
.github/workflows/backend-tests.yml
vendored
24
.github/workflows/backend-tests.yml
vendored
@@ -47,6 +47,30 @@ jobs:
|
|||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Verify symlink support
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
root = pathlib.Path(tempfile.mkdtemp(prefix="lm-symlink-check-"))
|
||||||
|
target = root / "target"
|
||||||
|
target.mkdir()
|
||||||
|
link = root / "link"
|
||||||
|
try:
|
||||||
|
link.symlink_to(target, target_is_directory=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise SystemExit(f"Failed to create directory symlink in CI: {exc}")
|
||||||
|
|
||||||
|
is_link = os.path.islink(link)
|
||||||
|
is_dir = os.path.isdir(link)
|
||||||
|
realpath = os.path.realpath(link)
|
||||||
|
print(f"islink={is_link} isdir={is_dir} realpath={realpath}")
|
||||||
|
if not (is_link and is_dir and realpath == str(target)):
|
||||||
|
raise SystemExit("Directory symlink is not functioning correctly in CI; aborting.")
|
||||||
|
PY
|
||||||
|
|
||||||
- name: Run pytest with coverage
|
- name: Run pytest with coverage
|
||||||
env:
|
env:
|
||||||
COVERAGE_FILE: coverage/backend/.coverage
|
COVERAGE_FILE: coverage/backend/.coverage
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
settings.json
|
settings.json
|
||||||
path_mappings.yaml
|
path_mappings.yaml
|
||||||
output/*
|
output/*
|
||||||
@@ -10,3 +11,11 @@ node_modules/
|
|||||||
coverage/
|
coverage/
|
||||||
.coverage
|
.coverage
|
||||||
model_cache/
|
model_cache/
|
||||||
|
|
||||||
|
# agent
|
||||||
|
.opencode/
|
||||||
|
|
||||||
|
# Vue widgets development cache (but keep build output)
|
||||||
|
vue-widgets/node_modules/
|
||||||
|
vue-widgets/.vite/
|
||||||
|
vue-widgets/dist/
|
||||||
|
|||||||
202
AGENTS.md
202
AGENTS.md
@@ -1,22 +1,192 @@
|
|||||||
# Repository Guidelines
|
# AGENTS.md
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
This file provides guidance for agentic coding assistants working in this repository.
|
||||||
ComfyUI LoRA Manager pairs a Python backend with browser-side widgets. Backend modules live in <code>py/</code> with HTTP entry points in <code>py/routes/</code>, feature logic in <code>py/services/</code>, shared helpers in <code>py/utils/</code>, and custom nodes in <code>py/nodes/</code>. UI scripts extend ComfyUI from <code>web/comfyui/</code>, while deploy-ready assets remain in <code>static/</code> and <code>templates/</code>. Localization files live in <code>locales/</code>, example workflows in <code>example_workflows/</code>, and interim tests such as <code>test_i18n.py</code> sit beside their source until a dedicated <code>tests/</code> tree lands.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Development Commands
|
||||||
- <code>pip install -r requirements.txt</code> installs backend dependencies.
|
|
||||||
- <code>python standalone.py --port 8188</code> launches the standalone server for iterative development.
|
|
||||||
- <code>python -m pytest test_i18n.py</code> runs the current regression suite; target new files explicitly, e.g. <code>python -m pytest tests/test_recipes.py</code>.
|
|
||||||
- <code>python scripts/sync_translation_keys.py</code> synchronizes locale keys after UI string updates.
|
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
### Backend Development
|
||||||
Follow PEP 8 with four-space indentation and descriptive snake_case file and function names such as <code>settings_manager.py</code>. Classes stay PascalCase, constants in UPPER_SNAKE_CASE, and loggers retrieved via <code>logging.getLogger(__name__)</code>. Prefer explicit type hints and docstrings on public APIs. JavaScript under <code>web/comfyui/</code> uses ES modules with camelCase helpers and the <code>_widget.js</code> suffix for UI components.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
```bash
|
||||||
Pytest powers backend tests. Name modules <code>test_<feature>.py</code> and keep them near the code or in a future <code>tests/</code> package. Mock ComfyUI dependencies through helpers in <code>standalone.py</code>, keep filesystem fixtures deterministic, and ensure translations are covered. Run <code>python -m pytest</code> before submitting changes.
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
# Run standalone server (port 8188 by default)
|
||||||
Commits follow the conventional format, e.g. <code>feat(settings): add default model path</code>, and should stay focused on a single concern. Pull requests must outline the problem, summarize the solution, list manual verification steps (server run, targeted pytest), and link related issues. Include screenshots or GIFs for UI or locale updates and call out migration steps such as <code>settings.json</code> adjustments.
|
python standalone.py --port 8188
|
||||||
|
|
||||||
|
# Run all backend tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pytest tests/test_recipes.py
|
||||||
|
|
||||||
|
# Run specific test function
|
||||||
|
pytest tests/test_recipes.py::test_function_name
|
||||||
|
|
||||||
|
# Run backend tests with coverage
|
||||||
|
COVERAGE_FILE=coverage/backend/.coverage pytest \
|
||||||
|
--cov=py \
|
||||||
|
--cov=standalone \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
--cov-report=html:coverage/backend/html \
|
||||||
|
--cov-report=xml:coverage/backend/coverage.xml \
|
||||||
|
--cov-report=json:coverage/backend/coverage.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install frontend dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run frontend tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run frontend tests in watch mode
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# Run frontend tests with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Code Style
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
- Use `from __future__ import annotations` for forward references in type hints
|
||||||
|
- Group imports: standard library, third-party, local (separated by blank lines)
|
||||||
|
- Use absolute imports within `py/` package: `from ..services import X`
|
||||||
|
- Mock ComfyUI dependencies in tests using `tests/conftest.py` patterns
|
||||||
|
|
||||||
|
### Formatting & Types
|
||||||
|
|
||||||
|
- PEP 8 with 4-space indentation
|
||||||
|
- Type hints required for function signatures and class attributes
|
||||||
|
- Use `TYPE_CHECKING` guard for type-checking-only imports
|
||||||
|
- Prefer dataclasses for simple data containers
|
||||||
|
- Use `Optional[T]` for nullable types, `Union[T, None]` only when necessary
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- Files: `snake_case.py` (e.g., `model_scanner.py`, `lora_service.py`)
|
||||||
|
- Classes: `PascalCase` (e.g., `ModelScanner`, `LoraService`)
|
||||||
|
- Functions/variables: `snake_case` (e.g., `get_instance`, `model_type`)
|
||||||
|
- Constants: `UPPER_SNAKE_CASE` (e.g., `VALID_LORA_TYPES`)
|
||||||
|
- Private members: `_single_underscore` (protected), `__double_underscore` (name-mangled)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Use `logging.getLogger(__name__)` for module-level loggers
|
||||||
|
- Define custom exceptions in `py/services/errors.py`
|
||||||
|
- Use `asyncio.Lock` for thread-safe singleton patterns
|
||||||
|
- Raise specific exceptions with descriptive messages
|
||||||
|
- Log errors at appropriate levels (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||||
|
|
||||||
|
### Async Patterns
|
||||||
|
|
||||||
|
- Use `async def` for I/O-bound operations
|
||||||
|
- Mark async tests with `@pytest.mark.asyncio`
|
||||||
|
- Use `async with` for context managers
|
||||||
|
- Singleton pattern with class-level locks: see `ModelScanner.get_instance()`
|
||||||
|
- Use `aiohttp.web.Response` for HTTP responses
|
||||||
|
|
||||||
|
### Testing Patterns
|
||||||
|
|
||||||
|
- Use `pytest` with `--import-mode=importlib`
|
||||||
|
- Fixtures in `tests/conftest.py` handle ComfyUI mocking
|
||||||
|
- Use `@pytest.mark.no_settings_dir_isolation` for tests needing real paths
|
||||||
|
- Test files: `tests/test_*.py`
|
||||||
|
- Use `tmp_path_factory` for temporary directory isolation
|
||||||
|
|
||||||
|
## JavaScript Code Style
|
||||||
|
|
||||||
|
### Imports & Modules
|
||||||
|
|
||||||
|
- ES modules with `import`/`export`
|
||||||
|
- Use `import { app } from "../../scripts/app.js"` for ComfyUI integration
|
||||||
|
- Export named functions/classes: `export function foo() {}`
|
||||||
|
- Widget files use `*_widget.js` suffix
|
||||||
|
|
||||||
|
### Naming & Formatting
|
||||||
|
|
||||||
|
- camelCase for functions, variables, object properties
|
||||||
|
- PascalCase for classes/constructors
|
||||||
|
- Constants: `UPPER_SNAKE_CASE` (e.g., `CONVERTED_TYPE`)
|
||||||
|
- Files: `snake_case.js` or `kebab-case.js`
|
||||||
|
- 2-space indentation preferred (follow existing file conventions)
|
||||||
|
|
||||||
|
### Widget Development
|
||||||
|
|
||||||
|
- Use `app.registerExtension()` to register ComfyUI extensions
|
||||||
|
- Use `node.addDOMWidget(name, type, element, options)` for custom widgets
|
||||||
|
- Event handlers attached via `addEventListener` or widget callbacks
|
||||||
|
- See `web/comfyui/utils.js` for shared utilities
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Service Layer
|
||||||
|
|
||||||
|
- Use `ServiceRegistry` singleton for dependency injection
|
||||||
|
- Services follow singleton pattern via `get_instance()` class method
|
||||||
|
- Separate scanners (discovery) from services (business logic)
|
||||||
|
- Handlers in `py/routes/handlers/` implement route logic
|
||||||
|
|
||||||
|
### Model Types
|
||||||
|
|
||||||
|
- BaseModelService is abstract base for LoRA, Checkpoint, Embedding services
|
||||||
|
- ModelScanner provides file discovery and hash-based deduplication
|
||||||
|
- Persistent cache in SQLite via `PersistentModelCache`
|
||||||
|
- Metadata sync from CivitAI/CivArchive via `MetadataSyncService`
|
||||||
|
|
||||||
|
### Routes & Handlers
|
||||||
|
|
||||||
|
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, etc.
|
||||||
|
- Handlers are pure functions taking dependencies as parameters
|
||||||
|
- Use `WebSocketManager` for real-time progress updates
|
||||||
|
- Return `aiohttp.web.json_response` or `web.Response`
|
||||||
|
|
||||||
|
### Recipe System
|
||||||
|
|
||||||
|
- Base metadata in `py/recipes/base.py`
|
||||||
|
- Enrichment adds model metadata: `RecipeEnrichmentService`
|
||||||
|
- Parsers for different formats in `py/recipes/parsers/`
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- Always use English for comments (per copilot-instructions.md)
|
||||||
|
- Dual mode: ComfyUI plugin (uses folder_paths) vs standalone (reads settings.json)
|
||||||
|
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
||||||
|
- Settings auto-saved in user directory or portable mode
|
||||||
|
- WebSocket broadcasts for real-time updates (downloads, scans)
|
||||||
|
- Symlink handling requires normalized paths
|
||||||
|
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
|
||||||
|
- Run `python scripts/sync_translation_keys.py` after UI string updates
|
||||||
|
|
||||||
|
## Frontend UI Architecture
|
||||||
|
|
||||||
|
This project has two distinct UI systems:
|
||||||
|
|
||||||
|
### 1. Standalone Lora Manager Web UI
|
||||||
|
- Location: `./static/` and `./templates/`
|
||||||
|
- Purpose: Full-featured web application for managing LoRA models
|
||||||
|
- Tech stack: Vanilla JS + CSS, served by the standalone server
|
||||||
|
- Development: Uses npm for frontend testing (`npm test`, `npm run test:watch`, etc.)
|
||||||
|
|
||||||
|
### 2. ComfyUI Custom Node Widgets
|
||||||
|
- Location: `./web/comfyui/`
|
||||||
|
- Purpose: Widgets and UI logic that ComfyUI loads as custom node extensions
|
||||||
|
- Tech stack: Vanilla JS + Vue.js widgets (in `./vue-widgets/` and built to `./web/comfyui/vue-widgets/`)
|
||||||
|
- Widget styling: Primary styles in `./web/comfyui/lm_styles.css` (NOT `./static/css/`)
|
||||||
|
- Development: No npm build step for these widgets (Vue widgets use build system)
|
||||||
|
|
||||||
|
### Widget Development Guidelines
|
||||||
|
- Use `app.registerExtension()` to register ComfyUI extensions (ComfyUI integration layer)
|
||||||
|
- Use `node.addDOMWidget()` for custom DOM widgets
|
||||||
|
- Widget styles should follow the patterns in `./web/comfyui/lm_styles.css`
|
||||||
|
- Selected state: `rgba(66, 153, 225, 0.3)` background, `rgba(66, 153, 225, 0.6)` border
|
||||||
|
- Hover state: `rgba(66, 153, 225, 0.2)` background
|
||||||
|
- Color palette matches the Lora Manager accent color (blue #4299e1)
|
||||||
|
- Use oklch() for color values when possible (defined in `./static/css/base.css`)
|
||||||
|
- Vue widget components are in `./vue-widgets/src/components/` and built to `./web/comfyui/vue-widgets/`
|
||||||
|
- When modifying widget styles, check `./web/comfyui/lm_styles.css` for consistency with other ComfyUI widgets
|
||||||
|
|
||||||
## Configuration & Localization Tips
|
|
||||||
Copy <code>settings.json.example</code> to <code>settings.json</code> and adapt model directories before running the standalone server. Store reference assets in <code>civitai/</code> or <code>docs/</code> to keep runtime directories deploy-ready. Whenever UI text changes, update every <code>locales/<lang>.json</code> file and rerun the translation sync script so ComfyUI surfaces localized strings.
|
|
||||||
|
|||||||
211
CLAUDE.md
Normal file
211
CLAUDE.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
ComfyUI LoRA Manager is a comprehensive LoRA management system for ComfyUI that combines a Python backend with browser-based widgets. It provides model organization, downloading from CivitAI/CivArchive, recipe management, and one-click workflow integration.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Backend Development
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Install development dependencies (for testing)
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
# Run standalone server (port 8188 by default)
|
||||||
|
python standalone.py --port 8188
|
||||||
|
|
||||||
|
# Run backend tests with coverage
|
||||||
|
COVERAGE_FILE=coverage/backend/.coverage pytest \
|
||||||
|
--cov=py \
|
||||||
|
--cov=standalone \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
--cov-report=html:coverage/backend/html \
|
||||||
|
--cov-report=xml:coverage/backend/coverage.xml \
|
||||||
|
--cov-report=json:coverage/backend/coverage.json
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pytest tests/test_recipes.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Development
|
||||||
|
```bash
|
||||||
|
# Install frontend dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run frontend tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run frontend tests in watch mode
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# Run frontend tests with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Localization
|
||||||
|
```bash
|
||||||
|
# Sync translation keys after UI string updates
|
||||||
|
python scripts/sync_translation_keys.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend Structure (Python)
|
||||||
|
|
||||||
|
**Core Entry Points:**
|
||||||
|
- `__init__.py` - ComfyUI plugin entry point, registers nodes and routes
|
||||||
|
- `standalone.py` - Standalone server that mocks ComfyUI dependencies
|
||||||
|
- `py/lora_manager.py` - Main LoraManager class that registers HTTP routes
|
||||||
|
|
||||||
|
**Service Layer** (`py/services/`):
|
||||||
|
- `ServiceRegistry` - Singleton service registry for dependency management
|
||||||
|
- `ModelServiceFactory` - Factory for creating model services (LoRA, Checkpoint, Embedding)
|
||||||
|
- Scanner services (`lora_scanner.py`, `checkpoint_scanner.py`, `embedding_scanner.py`) - Model file discovery and indexing
|
||||||
|
- `model_scanner.py` - Base scanner with hash-based deduplication and metadata extraction
|
||||||
|
- `persistent_model_cache.py` - SQLite-based cache for model metadata
|
||||||
|
- `metadata_sync_service.py` - Syncs metadata from CivitAI/CivArchive APIs
|
||||||
|
- `civitai_client.py` / `civarchive_client.py` - API clients for external services
|
||||||
|
- `downloader.py` / `download_manager.py` - Model download orchestration
|
||||||
|
- `recipe_scanner.py` - Recipe file management and image association
|
||||||
|
- `settings_manager.py` - Application settings with migration support
|
||||||
|
- `websocket_manager.py` - WebSocket broadcasting for real-time updates
|
||||||
|
- `use_cases/` - Business logic orchestration (auto-organize, bulk refresh, downloads)
|
||||||
|
|
||||||
|
**Routes Layer** (`py/routes/`):
|
||||||
|
- Route registrars organize endpoints by domain (models, recipes, previews, example images, updates)
|
||||||
|
- `handlers/` - Request handlers implementing business logic
|
||||||
|
- Routes use aiohttp and integrate with ComfyUI's PromptServer
|
||||||
|
|
||||||
|
**Recipe System** (`py/recipes/`):
|
||||||
|
- `base.py` - Base recipe metadata structure
|
||||||
|
- `enrichment.py` - Enriches recipes with model metadata
|
||||||
|
- `merger.py` - Merges recipe data from multiple sources
|
||||||
|
- `parsers/` - Parsers for different recipe formats (PNG, JSON, workflow)
|
||||||
|
|
||||||
|
**Custom Nodes** (`py/nodes/`):
|
||||||
|
- `lora_loader.py` - LoRA loader nodes with preset support
|
||||||
|
- `save_image.py` - Enhanced save image with pattern-based filenames
|
||||||
|
- `trigger_word_toggle.py` - Toggle trigger words in prompts
|
||||||
|
- `lora_stacker.py` - Stack multiple LoRAs
|
||||||
|
- `prompt.py` - Prompt node with autocomplete
|
||||||
|
- `wanvideo_lora_select.py` - WanVideo-specific LoRA selection
|
||||||
|
|
||||||
|
**Configuration** (`py/config.py`):
|
||||||
|
- Manages folder paths for models, checkpoints, embeddings
|
||||||
|
- Handles symlink mappings for complex directory structures
|
||||||
|
- Auto-saves paths to settings.json in ComfyUI mode
|
||||||
|
|
||||||
|
### Frontend Structure (JavaScript)
|
||||||
|
|
||||||
|
**ComfyUI Widgets** (`web/comfyui/`):
|
||||||
|
- Vanilla JavaScript ES modules extending ComfyUI's LiteGraph-based UI
|
||||||
|
- `loras_widget.js` - Main LoRA selection widget with preview
|
||||||
|
- `loras_widget_events.js` - Event handling for widget interactions
|
||||||
|
- `autocomplete.js` - Autocomplete for trigger words and embeddings
|
||||||
|
- `preview_tooltip.js` - Preview tooltip for model cards
|
||||||
|
- `top_menu_extension.js` - Adds "Launch LoRA Manager" menu item
|
||||||
|
- `trigger_word_highlight.js` - Syntax highlighting for trigger words
|
||||||
|
- `utils.js` - Shared utilities and API helpers
|
||||||
|
|
||||||
|
**Widget Development:**
|
||||||
|
- Widgets use `app.registerExtension` and `getCustomWidgets` hooks
|
||||||
|
- `node.addDOMWidget(name, type, element, options)` embeds HTML in nodes
|
||||||
|
- See `docs/dom_widget_dev_guide.md` for complete DOMWidget development guide
|
||||||
|
|
||||||
|
**Web Source** (`web-src/`):
|
||||||
|
- Modern frontend components (if migrating from static)
|
||||||
|
- `components/` - Reusable UI components
|
||||||
|
- `styles/` - CSS styling
|
||||||
|
|
||||||
|
### Key Patterns
|
||||||
|
|
||||||
|
**Dual Mode Operation:**
|
||||||
|
- ComfyUI plugin mode: Integrates with ComfyUI's PromptServer, uses folder_paths
|
||||||
|
- Standalone mode: Mocks ComfyUI dependencies via `standalone.py`, reads paths from settings.json
|
||||||
|
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
||||||
|
|
||||||
|
**Settings Management:**
|
||||||
|
- Settings stored in user directory (via `platformdirs`) or portable mode (in repo)
|
||||||
|
- Migration system tracks settings schema version
|
||||||
|
- Template in `settings.json.example` defines defaults
|
||||||
|
|
||||||
|
**Model Scanning Flow:**
|
||||||
|
1. Scanner walks folder paths, computes file hashes
|
||||||
|
2. Hash-based deduplication prevents duplicate processing
|
||||||
|
3. Metadata extracted from safetensors headers
|
||||||
|
4. Persistent cache stores results in SQLite
|
||||||
|
5. Background sync fetches CivitAI/CivArchive metadata
|
||||||
|
6. WebSocket broadcasts updates to connected clients
|
||||||
|
|
||||||
|
**Recipe System:**
|
||||||
|
- Recipes store LoRA combinations with parameters
|
||||||
|
- Supports import from workflow JSON, PNG metadata
|
||||||
|
- Images associated with recipes via sibling file detection
|
||||||
|
- Enrichment adds model metadata for display
|
||||||
|
|
||||||
|
**Frontend-Backend Communication:**
|
||||||
|
- REST API for CRUD operations
|
||||||
|
- WebSocket for real-time progress updates (downloads, scans)
|
||||||
|
- API endpoints follow `/loras/*` pattern
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
- PEP 8 with 4-space indentation
|
||||||
|
- snake_case for files, functions, variables
|
||||||
|
- PascalCase for classes
|
||||||
|
- Type hints preferred
|
||||||
|
- English comments only (per copilot-instructions.md)
|
||||||
|
- Loggers via `logging.getLogger(__name__)`
|
||||||
|
|
||||||
|
**JavaScript:**
|
||||||
|
- ES modules with camelCase
|
||||||
|
- Files use `*_widget.js` suffix for ComfyUI widgets
|
||||||
|
- Prefer vanilla JS, avoid framework dependencies
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Backend Tests:**
|
||||||
|
- pytest with `--import-mode=importlib`
|
||||||
|
- Test files: `tests/test_*.py`
|
||||||
|
- Fixtures in `tests/conftest.py`
|
||||||
|
- Mock ComfyUI dependencies using standalone.py patterns
|
||||||
|
- Markers: `@pytest.mark.asyncio` for async tests, `@pytest.mark.no_settings_dir_isolation` for real paths
|
||||||
|
|
||||||
|
**Frontend Tests:**
|
||||||
|
- Vitest with jsdom environment
|
||||||
|
- Test files: `tests/frontend/**/*.test.js`
|
||||||
|
- Setup in `tests/frontend/setup.js`
|
||||||
|
- Coverage via `npm run test:coverage`
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
**Settings Location:**
|
||||||
|
- ComfyUI mode: Auto-saves folder paths to user settings directory
|
||||||
|
- Standalone mode: Use `settings.json` (copy from `settings.json.example`)
|
||||||
|
- Portable mode: Set `"use_portable_settings": true` in settings.json
|
||||||
|
|
||||||
|
**API Integration:**
|
||||||
|
- CivitAI API key required for downloads (add to settings)
|
||||||
|
- CivArchive API used as fallback for deleted models
|
||||||
|
- Metadata archive database available for offline metadata
|
||||||
|
|
||||||
|
**Symlink Handling:**
|
||||||
|
- Config scans symlinks to map virtual paths to physical locations
|
||||||
|
- Preview validation uses normalized preview root paths
|
||||||
|
- Fingerprinting prevents redundant symlink rescans
|
||||||
|
|
||||||
|
**ComfyUI Node Development:**
|
||||||
|
- Nodes defined in `py/nodes/`, registered in `__init__.py`
|
||||||
|
- Frontend widgets in `web/comfyui/`, matched by node type
|
||||||
|
- Use `WEB_DIRECTORY = "./web/comfyui"` convention
|
||||||
|
|
||||||
|
**Recipe Image Association:**
|
||||||
|
- Recipes scan for sibling images in same directory
|
||||||
|
- Supports repair/migration of recipe image paths
|
||||||
|
- See `py/services/recipe_scanner.py` for implementation details
|
||||||
103
IFLOW.md
103
IFLOW.md
@@ -1,103 +0,0 @@
|
|||||||
# ComfyUI LoRA Manager - iFlow 上下文
|
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
ComfyUI LoRA Manager 是一个全面的工具集,用于简化 ComfyUI 中 LoRA 模型的组织、下载和应用。它提供了强大的功能,如配方管理、检查点组织和一键工作流集成,使模型操作更快、更流畅、更简单。
|
|
||||||
|
|
||||||
该项目是一个 Python 后端与 JavaScript 前端结合的 Web 应用程序,既可以作为 ComfyUI 的自定义节点运行,也可以作为独立应用程序运行。
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
D:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\
|
|
||||||
├── py/ # Python 后端代码
|
|
||||||
│ ├── config.py # 全局配置
|
|
||||||
│ ├── lora_manager.py # 主入口点
|
|
||||||
│ ├── controllers/ # 控制器
|
|
||||||
│ ├── metadata_collector/ # 元数据收集器
|
|
||||||
│ ├── middleware/ # 中间件
|
|
||||||
│ ├── nodes/ # ComfyUI 节点
|
|
||||||
│ ├── recipes/ # 配方相关
|
|
||||||
│ ├── routes/ # API 路由
|
|
||||||
│ ├── services/ # 业务逻辑服务
|
|
||||||
│ ├── utils/ # 工具函数
|
|
||||||
│ └── validators/ # 验证器
|
|
||||||
├── static/ # 静态资源 (CSS, JS, 图片)
|
|
||||||
├── templates/ # HTML 模板
|
|
||||||
├── locales/ # 国际化文件
|
|
||||||
├── tests/ # 测试代码
|
|
||||||
├── standalone.py # 独立模式入口
|
|
||||||
├── requirements.txt # Python 依赖
|
|
||||||
├── package.json # Node.js 依赖和脚本
|
|
||||||
└── README.md # 项目说明
|
|
||||||
```
|
|
||||||
|
|
||||||
## 核心组件
|
|
||||||
|
|
||||||
### 后端 (Python)
|
|
||||||
|
|
||||||
- **主入口**: `py/lora_manager.py` 和 `standalone.py`
|
|
||||||
- **配置**: `py/config.py` 管理全局配置和路径
|
|
||||||
- **路由**: `py/routes/` 目录下包含各种 API 路由
|
|
||||||
- **服务**: `py/services/` 目录下包含业务逻辑,如模型扫描、下载管理等
|
|
||||||
- **模型管理**: 使用 `ModelServiceFactory` 来管理不同类型的模型 (LoRA, Checkpoint, Embedding)
|
|
||||||
|
|
||||||
### 前端 (JavaScript)
|
|
||||||
|
|
||||||
- **构建工具**: 使用 Node.js 和 npm 进行依赖管理和测试
|
|
||||||
- **测试**: 使用 Vitest 进行前端测试
|
|
||||||
|
|
||||||
## 构建和运行
|
|
||||||
|
|
||||||
### 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Python 依赖
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Node.js 依赖 (用于测试)
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 运行 (ComfyUI 模式)
|
|
||||||
|
|
||||||
作为 ComfyUI 的自定义节点安装后,在 ComfyUI 中启动即可。
|
|
||||||
|
|
||||||
### 运行 (独立模式)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用默认配置运行
|
|
||||||
python standalone.py
|
|
||||||
|
|
||||||
# 指定主机和端口
|
|
||||||
python standalone.py --host 127.0.0.1 --port 9000
|
|
||||||
```
|
|
||||||
|
|
||||||
### 测试
|
|
||||||
|
|
||||||
#### 后端测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装开发依赖
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
# 运行测试
|
|
||||||
pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 前端测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 运行测试
|
|
||||||
npm run test
|
|
||||||
|
|
||||||
# 运行测试并生成覆盖率报告
|
|
||||||
npm run test:coverage
|
|
||||||
```
|
|
||||||
|
|
||||||
## 开发约定
|
|
||||||
|
|
||||||
- **代码风格**: Python 代码应遵循 PEP 8 规范
|
|
||||||
- **测试**: 新功能应包含相应的单元测试
|
|
||||||
- **配置**: 使用 `settings.json` 文件进行用户配置
|
|
||||||
- **日志**: 使用 Python 标准库 `logging` 模块进行日志记录
|
|
||||||
11
README.md
11
README.md
@@ -34,6 +34,17 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
|||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
### v0.9.12
|
||||||
|
* **LoRA Randomizer System** - Introduced a comprehensive LoRA randomization system featuring LoRA Pool and LoRA Randomizer nodes for flexible and dynamic generation workflows.
|
||||||
|
* **LoRA Randomizer Template** - Refer to the new "LoRA Randomizer" template workflow for detailed examples of flexible randomization modes, lock & reuse options, and other features.
|
||||||
|
* **Recipe Folders** - Introduced a folder system for the Recipes page, allowing users to freely organize recipes just like they do with models.
|
||||||
|
* **Recipe Bulk Operations** - Added bulk mode support for batch moving, deleting, and setting base models for selected recipes with intuitive controls like click-and-drag selection, drag-to-folder, and Ctrl+A (Select All).
|
||||||
|
* **Prompt Search & Sorting** - Search recipes by prompt content and sort by Recipe Name, Imported Date, or LoRA Count for better browsing.
|
||||||
|
* **Recipe Favorites** - Mark specific recipes as favorites for quick access.
|
||||||
|
* **Video Recipe Support** - Enabled support for video recipes (import via LM extension or URL; video file import not supported).
|
||||||
|
* **Performance Improvements** - Fixed performance issues for dramatically improved startup and loading speed. After first scan, subsequent loads are instant regardless of collection size.
|
||||||
|
* **ComfyUI Nodes 2.0 Support** - Basic support for ComfyUI Nodes 2.0.
|
||||||
|
|
||||||
### v0.9.10
|
### v0.9.10
|
||||||
* **Smarter Update Matching** - Users can now choose to check and group updates by matching base model only or with no base-model constraint; version lists also support toggling between same-base versions or all versions.
|
* **Smarter Update Matching** - Users can now choose to check and group updates by matching base model only or with no base-model constraint; version lists also support toggling between same-base versions or all versions.
|
||||||
* **Flexible Tag Filtering** - The filter panel now supports tag exclusion: click a tag to include, click again to exclude, and click a third time to clear, enabling stronger and more flexible tag filters.
|
* **Flexible Tag Filtering** - The filter panel now supports tag exclusion: click a tag to include, click again to exclude, and click a third time to clear, enabling stronger and more flexible tag filters.
|
||||||
|
|||||||
65
__init__.py
65
__init__.py
@@ -4,12 +4,16 @@ try: # pragma: no cover - import fallback for pytest collection
|
|||||||
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
||||||
from .py.nodes.prompt import PromptLoraManager
|
from .py.nodes.prompt import PromptLoraManager
|
||||||
from .py.nodes.lora_stacker import LoraStacker
|
from .py.nodes.lora_stacker import LoraStacker
|
||||||
from .py.nodes.save_image import SaveImage
|
from .py.nodes.save_image import SaveImageLM
|
||||||
from .py.nodes.debug_metadata import DebugMetadata
|
from .py.nodes.debug_metadata import DebugMetadata
|
||||||
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelect
|
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelectLM
|
||||||
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
|
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
|
||||||
|
from .py.nodes.lora_pool import LoraPoolNode
|
||||||
|
from .py.nodes.lora_randomizer import LoraRandomizerNode
|
||||||
from .py.metadata_collector import init as init_metadata_collector
|
from .py.metadata_collector import init as init_metadata_collector
|
||||||
except ImportError: # pragma: no cover - allows running under pytest without package install
|
except (
|
||||||
|
ImportError
|
||||||
|
): # pragma: no cover - allows running under pytest without package install
|
||||||
import importlib
|
import importlib
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
@@ -20,14 +24,28 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
|
|||||||
|
|
||||||
PromptLoraManager = importlib.import_module("py.nodes.prompt").PromptLoraManager
|
PromptLoraManager = importlib.import_module("py.nodes.prompt").PromptLoraManager
|
||||||
LoraManager = importlib.import_module("py.lora_manager").LoraManager
|
LoraManager = importlib.import_module("py.lora_manager").LoraManager
|
||||||
LoraManagerLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerLoader
|
LoraManagerLoader = importlib.import_module(
|
||||||
LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader
|
"py.nodes.lora_loader"
|
||||||
TriggerWordToggle = importlib.import_module("py.nodes.trigger_word_toggle").TriggerWordToggle
|
).LoraManagerLoader
|
||||||
|
LoraManagerTextLoader = importlib.import_module(
|
||||||
|
"py.nodes.lora_loader"
|
||||||
|
).LoraManagerTextLoader
|
||||||
|
TriggerWordToggle = importlib.import_module(
|
||||||
|
"py.nodes.trigger_word_toggle"
|
||||||
|
).TriggerWordToggle
|
||||||
LoraStacker = importlib.import_module("py.nodes.lora_stacker").LoraStacker
|
LoraStacker = importlib.import_module("py.nodes.lora_stacker").LoraStacker
|
||||||
SaveImage = importlib.import_module("py.nodes.save_image").SaveImage
|
SaveImageLM = importlib.import_module("py.nodes.save_image").SaveImageLM
|
||||||
DebugMetadata = importlib.import_module("py.nodes.debug_metadata").DebugMetadata
|
DebugMetadata = importlib.import_module("py.nodes.debug_metadata").DebugMetadata
|
||||||
WanVideoLoraSelect = importlib.import_module("py.nodes.wanvideo_lora_select").WanVideoLoraSelect
|
WanVideoLoraSelectLM = importlib.import_module(
|
||||||
WanVideoLoraSelectFromText = importlib.import_module("py.nodes.wanvideo_lora_select_from_text").WanVideoLoraSelectFromText
|
"py.nodes.wanvideo_lora_select"
|
||||||
|
).WanVideoLoraSelectLM
|
||||||
|
WanVideoLoraSelectFromText = importlib.import_module(
|
||||||
|
"py.nodes.wanvideo_lora_select_from_text"
|
||||||
|
).WanVideoLoraSelectFromText
|
||||||
|
LoraPoolNode = importlib.import_module("py.nodes.lora_pool").LoraPoolNode
|
||||||
|
LoraRandomizerNode = importlib.import_module(
|
||||||
|
"py.nodes.lora_randomizer"
|
||||||
|
).LoraRandomizerNode
|
||||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
@@ -36,17 +54,38 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
|
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
|
||||||
TriggerWordToggle.NAME: TriggerWordToggle,
|
TriggerWordToggle.NAME: TriggerWordToggle,
|
||||||
LoraStacker.NAME: LoraStacker,
|
LoraStacker.NAME: LoraStacker,
|
||||||
SaveImage.NAME: SaveImage,
|
SaveImageLM.NAME: SaveImageLM,
|
||||||
DebugMetadata.NAME: DebugMetadata,
|
DebugMetadata.NAME: DebugMetadata,
|
||||||
WanVideoLoraSelect.NAME: WanVideoLoraSelect,
|
WanVideoLoraSelectLM.NAME: WanVideoLoraSelectLM,
|
||||||
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText
|
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText,
|
||||||
|
LoraPoolNode.NAME: LoraPoolNode,
|
||||||
|
LoraRandomizerNode.NAME: LoraRandomizerNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web/comfyui"
|
WEB_DIRECTORY = "./web/comfyui"
|
||||||
|
|
||||||
|
# Check and build Vue widgets if needed (development mode)
|
||||||
|
try:
|
||||||
|
from .py.vue_widget_builder import check_and_build_vue_widgets
|
||||||
|
|
||||||
|
# Auto-build in development, warn only if fails
|
||||||
|
check_and_build_vue_widgets(auto_build=True, warn_only=True)
|
||||||
|
except ImportError:
|
||||||
|
# Fallback for pytest
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
check_and_build_vue_widgets = importlib.import_module(
|
||||||
|
"py.vue_widget_builder"
|
||||||
|
).check_and_build_vue_widgets
|
||||||
|
check_and_build_vue_widgets(auto_build=True, warn_only=True)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.warning(f"[LoRA Manager] Vue widget build check skipped: {e}")
|
||||||
|
|
||||||
# Initialize metadata collector
|
# Initialize metadata collector
|
||||||
init_metadata_collector()
|
init_metadata_collector()
|
||||||
|
|
||||||
# Register routes on import
|
# Register routes on import
|
||||||
LoraManager.add_routes()
|
LoraManager.add_routes()
|
||||||
__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY']
|
__all__ = ["NODE_CLASS_MAPPINGS", "WEB_DIRECTORY"]
|
||||||
|
|||||||
544
docs/dom_widget_dev_guide.md
Normal file
544
docs/dom_widget_dev_guide.md
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
# DOMWidget Development Guide
|
||||||
|
|
||||||
|
This document provides a comprehensive guide for developing custom DOMWidgets in ComfyUI using Vanilla JavaScript. DOMWidgets allow you to embed standard HTML elements (div, video, canvas, input, etc.) into ComfyUI nodes while benefitting from the frontend's automatic layout and zoom management.
|
||||||
|
|
||||||
|
## 1. Core Concepts
|
||||||
|
|
||||||
|
In ComfyUI, a `DOMWidget` extends the default LiteGraph Canvas rendering logic. It maintains an HTML layer on top of the Canvas, making complex interactions and media displays significantly easier to implement than pure Canvas drawing.
|
||||||
|
|
||||||
|
### Key APIs
|
||||||
|
* **`app.registerExtension`**: The entry point for registering extensions.
|
||||||
|
* **`getCustomWidgets`**: A hook for defining new widget types associated with specific input types.
|
||||||
|
* **`node.addDOMWidget`**: The core method to add HTML elements to a node.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Basic Structure
|
||||||
|
|
||||||
|
A standard custom DOMWidget extension typically follows this structure:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "My.Custom.Extension",
|
||||||
|
async getCustomWidgets() {
|
||||||
|
return {
|
||||||
|
// Define a new widget type named "MY_WIDGET_TYPE"
|
||||||
|
MY_WIDGET_TYPE(node, inputName, inputData, app) {
|
||||||
|
// 1. Create the HTML element
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.innerHTML = "Hello <b>DOMWidget</b>!";
|
||||||
|
|
||||||
|
// 2. Setup styles (Optional but recommended)
|
||||||
|
container.style.color = "white";
|
||||||
|
container.style.backgroundColor = "#222";
|
||||||
|
container.style.padding = "5px";
|
||||||
|
|
||||||
|
// 3. Add the DOMWidget and return the result
|
||||||
|
const widget = node.addDOMWidget(inputName, "MY_WIDGET_TYPE", container, {
|
||||||
|
// Configuration options
|
||||||
|
getValue() {
|
||||||
|
return container.innerText;
|
||||||
|
},
|
||||||
|
setValue(v) {
|
||||||
|
container.innerText = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Return in the standard format
|
||||||
|
return { widget };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ComfyUI Dual Rendering Modes
|
||||||
|
|
||||||
|
ComfyUI frontend supports two rendering modes:
|
||||||
|
|
||||||
|
| Mode | Description | DOM Structure |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Canvas Mode** | Traditional rendering where widgets are rendered on top of canvas using absolute positioning | Uses `.dom-widget` class on containers |
|
||||||
|
| **Vue DOM Mode** | New rendering mode where nodes and widgets are rendered as Vue components | Uses `.lg-node-widget` class on containers with dynamic IDs (e.g., `v-1-0`) |
|
||||||
|
|
||||||
|
### Mode Switching
|
||||||
|
|
||||||
|
The frontend switches between modes via `LiteGraph.vueNodesMode` boolean:
|
||||||
|
- `LiteGraph.vueNodesMode = true` → Vue DOM Mode
|
||||||
|
- `LiteGraph.vueNodesMode = false` → Canvas Mode
|
||||||
|
|
||||||
|
**Key Behavior**: Mode switching triggers DOM re-rendering WITHOUT page reload. Widget elements are destroyed and recreated, so any event listeners or references to old DOM elements become invalid.
|
||||||
|
|
||||||
|
### Testing Mode Switches via Chrome DevTools MCP
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Trigger render mode change
|
||||||
|
LiteGraph.vueNodesMode = !LiteGraph.vueNodesMode;
|
||||||
|
|
||||||
|
// Force canvas redraw (optional but helps trigger re-render)
|
||||||
|
if (app.canvas) {
|
||||||
|
app.canvas.draw(true, true);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Notes
|
||||||
|
|
||||||
|
When implementing widgets that attach event listeners or maintain external references:
|
||||||
|
1. **Use `node.onRemoved`** to clean up when node is deleted
|
||||||
|
2. **Detect DOM changes** by checking if widget input element is still in document: `document.body.contains(inputElement)`
|
||||||
|
3. **Poll for mode changes** by watching `LiteGraph.vueNodesMode` and re-initializing when it changes
|
||||||
|
4. **Use `loadedGraphNode` hook** for initial setup (guarantees DOM is fully rendered)
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The `addDOMWidget` API
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
node.addDOMWidget(name, type, element, options)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
1. **`name`**: The internal name of the widget (usually matches the input name).
|
||||||
|
2. **`type`**: The type identifier for the widget.
|
||||||
|
3. **`element`**: The actual HTMLElement to embed.
|
||||||
|
4. **`options`**: (Object) Configuration for lifecycle, sizing, and persistence.
|
||||||
|
|
||||||
|
### Common `options` Fields
|
||||||
|
| Field | Type | Description |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `getValue` | `Function` | Defines how to retrieve the widget's value for serialization. |
|
||||||
|
| `setValue` | `Function` | Defines how to restore the widget's state from workflow data. |
|
||||||
|
| `getMinHeight` | `Function` | Returns the minimum height in pixels. |
|
||||||
|
| `getHeight` | `Function` | Returns the preferred height (supports numbers or percentage strings like `"50%"`). |
|
||||||
|
| `onResize` | `Function` | Callback triggered when the widget is resized. |
|
||||||
|
| `hideOnZoom`| `Boolean` | Whether to hide the DOM element when zoomed out to improve performance (default: `true`). |
|
||||||
|
| `selectOn` | `string[]` | Events on the element that should trigger node selection (default: `['focus', 'click']`). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Size Control
|
||||||
|
|
||||||
|
Custom DOMWidgets must actively inform the parent Node of their size requirements to ensure the Node layout is calculated correctly and connection wires remain aligned.
|
||||||
|
|
||||||
|
### 4.1 Core Mechanism
|
||||||
|
|
||||||
|
Whether in Canvas Mode or Vue Mode, the underlying logic model (`LGraphNode`) calls the widget's `computeLayoutSize` method to determine dimensions. This logic is used to calculate the Node's total size and the position of input/output slots.
|
||||||
|
|
||||||
|
### 4.2 Controlling Height
|
||||||
|
|
||||||
|
It is recommended to use the `options` parameter to define height behavior.
|
||||||
|
|
||||||
|
**Performance Note:** providing `getMinHeight` and `getHeight` via `options` allows the system to skip expensive DOM measurements (`getComputedStyle`) during rendering loop. This significantly improves performance and prevents FPS drops during node resizing.
|
||||||
|
|
||||||
|
**Method 1: Using `options` (Recommended)**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const widget = node.addDOMWidget("MyWidget", "custom", element, {
|
||||||
|
// Specify minimum height in pixels
|
||||||
|
getMinHeight: () => 150,
|
||||||
|
|
||||||
|
// Or specify preferred height (pixels or percentage string)
|
||||||
|
// getHeight: () => "50%",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Method 2: Using CSS Variables**
|
||||||
|
|
||||||
|
You can also set specific CSS variables on the root element:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
element.style.setProperty("--comfy-widget-min-height", "150px");
|
||||||
|
// or --comfy-widget-height
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Controlling Width
|
||||||
|
|
||||||
|
By default, a DOMWidget's width automatically stretches to fit the Node's width (which is determined by the Title or other Input Slots).
|
||||||
|
|
||||||
|
If you must **force the Node to be wider** to accommodate your widget, you need to override the widget instance's `computeLayoutSize` method:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const widget = node.addDOMWidget("WideWidget", "custom", element);
|
||||||
|
|
||||||
|
// Override the default layout calculation
|
||||||
|
widget.computeLayoutSize = (targetNode) => {
|
||||||
|
return {
|
||||||
|
minHeight: 150, // Must return height
|
||||||
|
minWidth: 300 // Force the Node to be at least 300px wide
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Dynamic Resizing
|
||||||
|
|
||||||
|
If your widget's content changes dynamically (e.g., expanding sections, loading images, or CSS changes), the DOM element will resize, but the Canvas-rendered Node background and Slots will not automatically follow. You must manually trigger a synchronization.
|
||||||
|
|
||||||
|
**The Update Sequence:**
|
||||||
|
Whenever the **actual rendering height** of your DOM element changes, execute the following "three-step combo":
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 1. Calculate the new optimal size for the node based on current widget requirements
|
||||||
|
const newSize = node.computeSize();
|
||||||
|
|
||||||
|
// 2. Apply the new size to the node model (updates bounding box and slot positions)
|
||||||
|
node.setSize(newSize);
|
||||||
|
|
||||||
|
// 3. Mark the canvas as dirty to trigger a redraw in the next animation frame
|
||||||
|
node.setDirtyCanvas(true, true);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Scenarios:**
|
||||||
|
|
||||||
|
| Scenario | Actual Height Change? | Update Required? |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Expand/Collapse content** | **Yes** | ✅ **Yes**. Prevents widget from overflowing node boundaries. |
|
||||||
|
| **Image/Video finished loading** | **Yes** | ✅ **Yes**. Initial height might be 0 until the media loads. |
|
||||||
|
| **Changing `minHeight`** | **Maybe** | ❓ **Only if** the change causes the element's actual height to shift. |
|
||||||
|
| **Changing font size/styles** | **Yes** | ✅ **Yes**. Text reflow often changes the total height. |
|
||||||
|
| **User dragging node corner** | **Yes** | ❌ **No**. LiteGraph handles this internally. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. State Persistence (Serialization)
|
||||||
|
|
||||||
|
### 5.1 Default Behavior
|
||||||
|
|
||||||
|
DOMWidgets have **serialization enabled** by default (`serialize` property is `true`).
|
||||||
|
* **Saving**: ComfyUI attempts to read the widget's value to save into the Workflow file.
|
||||||
|
* **Loading**: ComfyUI reads the value from the Workflow file and assigns it to the widget.
|
||||||
|
|
||||||
|
### 5.2 Custom Serialization
|
||||||
|
|
||||||
|
To make persistence work effectively (saving internal DOM state and restoring it), you must implement `getValue` and `setValue` in the `options`:
|
||||||
|
|
||||||
|
* **`getValue`**: Returns the state to be saved (Number, String, or Object).
|
||||||
|
* **`setValue`**: Receives the restored value and updates the DOM element.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const inputEl = document.createElement("input");
|
||||||
|
const widget = node.addDOMWidget("MyInput", "custom", inputEl, {
|
||||||
|
// 1. Called during Save
|
||||||
|
getValue: () => {
|
||||||
|
return inputEl.value;
|
||||||
|
},
|
||||||
|
// 2. Called during Load or Copy/Paste
|
||||||
|
setValue: (value) => {
|
||||||
|
inputEl.value = value || "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional: Listen for changes to update widget.value immediately
|
||||||
|
inputEl.addEventListener("change", () => {
|
||||||
|
widget.value = inputEl.value; // Triggers callbacks
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 The Restoration Mechanism (`configure`)
|
||||||
|
|
||||||
|
* **`configure(data)`**: When a Workflow is loaded, `LGraphNode` calls its `configure(data)` method.
|
||||||
|
* **`setValue` Chain**: During `configure`, the Node iterates over the saved `widgets_values` array and assigns each value (`widget.value = savedValue`). For DOMWidgets, this assignment triggers the `setValue` callback defined in your options.
|
||||||
|
|
||||||
|
Therefore, `options.setValue` is the critical hook for restoring widget state.
|
||||||
|
|
||||||
|
### 5.4 Disabling Serialization
|
||||||
|
|
||||||
|
If your widget is purely for display (e.g., a real-time monitor or generated chart) and doesn't need to save state, disable serialization to reduce workflow file size.
|
||||||
|
|
||||||
|
**Note**: You cannot set this via `options`. You must modify the widget instance directly.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const widget = node.addDOMWidget("DisplayOnly", "custom", element);
|
||||||
|
widget.serialize = false; // Explicitly disable
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Lifecycle & Events
|
||||||
|
|
||||||
|
### 6.1 `onResize`
|
||||||
|
|
||||||
|
When the Node size changes (e.g., user drags the corner), the widget can receive a notification via `options`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const widget = node.addDOMWidget("ResizingWidget", "custom", element, {
|
||||||
|
onResize: (w) => {
|
||||||
|
// 'w' is the widget instance
|
||||||
|
// Adjust internal DOM layout here if necessary
|
||||||
|
console.log("Widget resized");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Construction & Mounting
|
||||||
|
|
||||||
|
* **Construction**: Occurs immediately when `addDOMWidget` is called.
|
||||||
|
* **Mounting**:
|
||||||
|
* **Canvas Mode**: Appended to `.dom-widget-container` via `DomWidget.vue`.
|
||||||
|
* **Vue Mode**: Appended inside the Node component via `WidgetDOM.vue`.
|
||||||
|
* **Caution**: When `addDOMWidget` returns, the element may not be in the `document.body` yet. If you need to access layout properties like `getBoundingClientRect`, use `setTimeout` or wait for the first `onResize`.
|
||||||
|
|
||||||
|
### 6.3 Cleanup
|
||||||
|
|
||||||
|
If you create external references (like `setInterval` or global event listeners), ensure you clean them up using `node.onRemoved`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
node.onRemoved = function() {
|
||||||
|
clearInterval(myInterval);
|
||||||
|
// Call original onRemoved if it existed
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Styling & Best Practices
|
||||||
|
|
||||||
|
### 7.1 Styling
|
||||||
|
Since DOMWidgets are placed in absolute positioned containers or managed by Vue, ensure your container handles sizing gracefully:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
container.style.width = "100%";
|
||||||
|
container.style.boxSizing = "border-box";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Path References
|
||||||
|
When importing `app`, adjust the path based on your extension's folder depth. Typically:
|
||||||
|
`import { app } from "../../scripts/app.js";`
|
||||||
|
|
||||||
|
### 7.3 Security
|
||||||
|
If setting `innerHTML` dynamically, ensure the content is sanitized or trusted to prevent XSS attacks.
|
||||||
|
|
||||||
|
### 7.4 UI Constraints for ComfyUI Custom Node Widgets
|
||||||
|
|
||||||
|
When developing DOMWidgets as internal UI widgets for ComfyUI custom nodes, keep the following constraints in mind:
|
||||||
|
|
||||||
|
#### 7.4.1 Minimize Vertical Space
|
||||||
|
|
||||||
|
ComfyUI nodes are often displayed in a compact graph view with many nodes visible simultaneously. Avoid excessive vertical spacing that could clutter the workspace.
|
||||||
|
|
||||||
|
- Keep layouts compact and efficient
|
||||||
|
- Use appropriate padding and margins (4-8px typically)
|
||||||
|
- Stack related controls vertically but avoid unnecessary spacing
|
||||||
|
|
||||||
|
#### 7.4.2 Avoid Dynamic Height Changes
|
||||||
|
|
||||||
|
Dynamic height changes (expand/collapse sections, showing/hiding content) can cause node layout recalculations and affect connection wire positioning.
|
||||||
|
|
||||||
|
- Prefer static layouts over expandable/collapsible sections
|
||||||
|
- Use tooltips or overlays for additional information instead
|
||||||
|
- If dynamic height is unavoidable, manually trigger layout updates (see Section 4.4)
|
||||||
|
|
||||||
|
#### 7.4.3 Keep UI Simple and Intuitive
|
||||||
|
|
||||||
|
As internal widgets for ComfyUI custom nodes, the UI should be accessible to users without technical implementation details.
|
||||||
|
|
||||||
|
- Use clear, user-friendly terminology (avoid "frontend/backend roll" in favor of "fixed/always randomize")
|
||||||
|
- Focus on user intent rather than implementation details
|
||||||
|
- Avoid complex interactions that may confuse users
|
||||||
|
|
||||||
|
#### 7.4.4 Forward Middle Mouse Events to Canvas
|
||||||
|
|
||||||
|
By default, when a DOM widget receives pointer events (e.g., mouse clicks, drags), these events are captured by the widget and not forwarded to the ComfyUI canvas. This prevents users from panning the workflow using the middle mouse button when the cursor is over a DOM widget.
|
||||||
|
|
||||||
|
To enable workflow panning over your widget, you should forward middle mouse events (button 1) to the canvas using the `forwardMiddleMouseToCanvas` utility function:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { forwardMiddleMouseToCanvas } from "./utils.js";
|
||||||
|
|
||||||
|
// In your widget creation function
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.style.width = "100%";
|
||||||
|
container.style.height = "100%";
|
||||||
|
// ... other styles ...
|
||||||
|
|
||||||
|
// Forward middle mouse events to canvas for panning
|
||||||
|
forwardMiddleMouseToCanvas(container);
|
||||||
|
|
||||||
|
const widget = node.addDOMWidget(name, type, container, { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
The `forwardMiddleMouseToCanvas` function:
|
||||||
|
- Forwards `pointerdown` events with button 1 (middle mouse button) to `app.canvas.processMouseDown`
|
||||||
|
- Forwards `pointermove` events while middle mouse button is pressed to `app.canvas.processMouseMove`
|
||||||
|
- Forwards `pointerup` events with button 1 to `app.canvas.processMouseUp`
|
||||||
|
|
||||||
|
This allows users to pan the workflow canvas even when their mouse cursor is hovering over your DOM widget.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Event Handling in Vue DOM Render Mode
|
||||||
|
|
||||||
|
ComfyUI frontend supports two rendering modes for nodes:
|
||||||
|
- **Legacy Canvas Mode**: Traditional rendering where widgets are rendered on top of the canvas using absolute positioning
|
||||||
|
- **Vue DOM Render Mode**: New rendering mode where nodes and widgets are rendered as Vue components
|
||||||
|
|
||||||
|
In Vue DOM render mode, event handling works differently. The frontend uses `useCanvasInteractions` composable to manage event forwarding to the canvas. This can cause custom event handlers in your widgets (e.g., mouse wheel for sliders, custom drag operations) to be intercepted by the canvas.
|
||||||
|
|
||||||
|
### 8.1 Wheel Event Handling
|
||||||
|
|
||||||
|
By default in Vue DOM render mode, wheel events on widgets may be forwarded to the canvas for workflow zoom, overriding your custom wheel handlers (e.g., adjusting slider values with mouse wheel).
|
||||||
|
|
||||||
|
To fix this, use the `data-capture-wheel="true"` attribute on elements that should capture wheel events:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Vue component template -->
|
||||||
|
<div class="my-slider" data-capture-wheel="true" @wheel="onWheel">
|
||||||
|
<!-- Slider content -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const onWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
// Custom wheel handling logic here
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- ComfyUI's `useCanvasInteractions.ts` checks `target?.closest('[data-capture-wheel="true"]')` before forwarding wheel events
|
||||||
|
- If an element (or its ancestor) has this attribute, wheel events are not forwarded to canvas
|
||||||
|
- Your custom `@wheel` handler will work as expected
|
||||||
|
|
||||||
|
**Granular control:**
|
||||||
|
- Apply `data-capture-wheel="true"` to specific interactive elements (e.g., sliders, scrollable areas)
|
||||||
|
- Widget container without this attribute will allow workflow zoom when wheel is used elsewhere
|
||||||
|
- This allows users to both: adjust widget values with wheel, and zoom workflow with wheel in widget's non-interactive areas
|
||||||
|
|
||||||
|
**Example from DualRangeSlider.vue:**
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="dual-range-slider"
|
||||||
|
:class="{ disabled, 'is-dragging': dragging !== null }"
|
||||||
|
data-capture-wheel="true"
|
||||||
|
@wheel="onWheel"
|
||||||
|
>
|
||||||
|
<!-- Slider tracks and handles -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Pointer Event Handling
|
||||||
|
|
||||||
|
In Vue DOM render mode, pointer events (click, drag, etc.) may also be captured by the canvas system. For custom drag operations:
|
||||||
|
|
||||||
|
1. **Use event modifiers to stop propagation:**
|
||||||
|
```vue
|
||||||
|
<div
|
||||||
|
@pointerdown.stop="startDrag"
|
||||||
|
@pointermove.stop="onDrag"
|
||||||
|
@pointerup.stop="stopDrag"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use pointer capture for reliable drag tracking:**
|
||||||
|
```javascript
|
||||||
|
const startDrag = (event: PointerEvent) => {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
target.setPointerCapture(event.pointerId)
|
||||||
|
// ... drag initialization
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopDrag = (event: PointerEvent) => {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
target.releasePointerCapture(event.pointerId)
|
||||||
|
// ... drag cleanup
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use `touch-action: none` CSS for touch devices:**
|
||||||
|
```css
|
||||||
|
.my-draggable {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Compatibility Checklist
|
||||||
|
|
||||||
|
Ensure your widget works in both rendering modes:
|
||||||
|
|
||||||
|
| Feature | Canvas Mode | Vue DOM Mode | Solution |
|
||||||
|
|---------|-------------|--------------|----------|
|
||||||
|
| Mouse wheel on sliders | Works by default | Needs `data-capture-wheel` | Add `data-capture-wheel="true"` to slider elements |
|
||||||
|
| Custom drag operations | Works with `stopPropagation()` | Needs `stopPropagation()` | Use `.stop` modifier and pointer capture |
|
||||||
|
| Middle mouse panning | Manual forwarding required | Manual forwarding required | Use `forwardMiddleMouseToCanvas()` |
|
||||||
|
| Workflow zoom on widget edges | Works by default | Works by default | No action needed (works by default) |
|
||||||
|
|
||||||
|
### 8.4 Testing Recommendations
|
||||||
|
|
||||||
|
Test your widget in both rendering modes:
|
||||||
|
1. Toggle between Canvas Mode and Vue DOM Mode in ComfyUI settings
|
||||||
|
2. Verify custom interactions (wheel, drag, etc.) work in both modes
|
||||||
|
3. Verify canvas interactions (zoom, pan) still work when cursor is over non-interactive widget areas
|
||||||
|
4. Test with touch devices if applicable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Complete Example: Text Counter
|
||||||
|
|
||||||
|
This example implements a simple widget that displays the character count of another text widget in the same node.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "Comfy.TextCounter",
|
||||||
|
getCustomWidgets() {
|
||||||
|
return {
|
||||||
|
TEXT_COUNTER(node, inputName) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
Object.assign(el.style, {
|
||||||
|
background: "#222",
|
||||||
|
border: "1px solid #444",
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#eee"
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.innerText = "Characters: 0";
|
||||||
|
el.appendChild(label);
|
||||||
|
|
||||||
|
const widget = node.addDOMWidget(inputName, "TEXT_COUNTER", el, {
|
||||||
|
getValue() { return ""; }, // Nothing to save
|
||||||
|
setValue(v) { }, // Nothing to restore
|
||||||
|
getMinHeight() { return 40; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable serialization for this display-only widget
|
||||||
|
widget.serialize = false;
|
||||||
|
|
||||||
|
// Custom method to update UI
|
||||||
|
widget.updateCount = (text) => {
|
||||||
|
label.innerText = `Characters: ${text.length}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { widget };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
nodeCreated(node) {
|
||||||
|
// Logic to link widgets after the node is initialized
|
||||||
|
if (node.comfyClass === "MyTextNode") {
|
||||||
|
const counterWidget = node.widgets.find(w => w.type === "TEXT_COUNTER");
|
||||||
|
const textWidget = node.widgets.find(w => w.name === "text");
|
||||||
|
|
||||||
|
if (counterWidget && textWidget) {
|
||||||
|
// Hook into the text widget's callback
|
||||||
|
const oldCallback = textWidget.callback;
|
||||||
|
textWidget.callback = function(v) {
|
||||||
|
if (oldCallback) oldCallback.apply(this, arguments);
|
||||||
|
counterWidget.updateCount(v);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Before Width: | Height: | Size: 668 KiB After Width: | Height: | Size: 668 KiB |
BIN
example_workflows/Lora_Randomizer.jpg
Normal file
BIN
example_workflows/Lora_Randomizer.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 739 KiB |
1
example_workflows/Lora_Randomizer.json
Normal file
1
example_workflows/Lora_Randomizer.json
Normal file
File diff suppressed because one or more lines are too long
@@ -131,6 +131,9 @@
|
|||||||
"badges": {
|
"badges": {
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"updateAvailable": "Update verfügbar"
|
"updateAvailable": "Update verfügbar"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"timesUsed": "Verwendungsanzahl"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -159,6 +162,13 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Recipe-Daten reparieren",
|
||||||
|
"loading": "Recipe-Daten werden repariert...",
|
||||||
|
"success": "{count} Rezepte erfolgreich repariert.",
|
||||||
|
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
||||||
|
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +198,8 @@
|
|||||||
"creator": "Ersteller",
|
"creator": "Ersteller",
|
||||||
"title": "Rezept-Titel",
|
"title": "Rezept-Titel",
|
||||||
"loraName": "LoRA-Dateiname",
|
"loraName": "LoRA-Dateiname",
|
||||||
"loraModel": "LoRA-Modellname"
|
"loraModel": "LoRA-Modellname",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +210,7 @@
|
|||||||
"license": "Lizenz",
|
"license": "Lizenz",
|
||||||
"noCreditRequired": "Kein Credit erforderlich",
|
"noCreditRequired": "Kein Credit erforderlich",
|
||||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||||
|
"noTags": "Keine Tags",
|
||||||
"clearAll": "Alle Filter löschen"
|
"clearAll": "Alle Filter löschen"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -221,7 +233,9 @@
|
|||||||
"label": "Einstellungsordner öffnen",
|
"label": "Einstellungsordner öffnen",
|
||||||
"tooltip": "Den Ordner mit der settings.json öffnen",
|
"tooltip": "Den Ordner mit der settings.json öffnen",
|
||||||
"success": "Einstellungsordner geöffnet",
|
"success": "Einstellungsordner geöffnet",
|
||||||
"failed": "Einstellungsordner konnte nicht geöffnet werden"
|
"failed": "Einstellungsordner konnte nicht geöffnet werden",
|
||||||
|
"copied": "Einstellungspfad in die Zwischenablage kopiert: {{path}}",
|
||||||
|
"clipboardFallback": "Einstellungspfad: {{path}}"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "Inhaltsfilterung",
|
"contentFiltering": "Inhaltsfilterung",
|
||||||
@@ -305,6 +319,8 @@
|
|||||||
"defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
"defaultCheckpointRoot": "Standard-Checkpoint-Stammordner",
|
"defaultCheckpointRoot": "Standard-Checkpoint-Stammordner",
|
||||||
"defaultCheckpointRootHelp": "Legen Sie den Standard-Checkpoint-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultCheckpointRootHelp": "Legen Sie den Standard-Checkpoint-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
|
"defaultUnetRoot": "Standard-Diffusion-Modell-Stammordner",
|
||||||
|
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
"defaultEmbeddingRoot": "Standard-Embedding-Stammordner",
|
"defaultEmbeddingRoot": "Standard-Embedding-Stammordner",
|
||||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
"noDefault": "Kein Standard"
|
"noDefault": "Kein Standard"
|
||||||
@@ -443,7 +459,10 @@
|
|||||||
"dateAsc": "Älteste",
|
"dateAsc": "Älteste",
|
||||||
"size": "Dateigröße",
|
"size": "Dateigröße",
|
||||||
"sizeDesc": "Größte",
|
"sizeDesc": "Größte",
|
||||||
"sizeAsc": "Kleinste"
|
"sizeAsc": "Kleinste",
|
||||||
|
"usage": "Anzahl Nutzung",
|
||||||
|
"usageDesc": "Meiste",
|
||||||
|
"usageAsc": "Wenigste"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Modelliste aktualisieren",
|
"title": "Modelliste aktualisieren",
|
||||||
@@ -518,6 +537,7 @@
|
|||||||
"replacePreview": "Vorschau ersetzen",
|
"replacePreview": "Vorschau ersetzen",
|
||||||
"setContentRating": "Inhaltsbewertung festlegen",
|
"setContentRating": "Inhaltsbewertung festlegen",
|
||||||
"moveToFolder": "In Ordner verschieben",
|
"moveToFolder": "In Ordner verschieben",
|
||||||
|
"repairMetadata": "Metadaten reparieren",
|
||||||
"excludeModel": "Modell ausschließen",
|
"excludeModel": "Modell ausschließen",
|
||||||
"deleteModel": "Modell löschen",
|
"deleteModel": "Modell löschen",
|
||||||
"shareRecipe": "Rezept teilen",
|
"shareRecipe": "Rezept teilen",
|
||||||
@@ -588,10 +608,26 @@
|
|||||||
"selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
|
"selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Rezepte sortieren nach...",
|
||||||
|
"name": "Name",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "Datum",
|
||||||
|
"dateDesc": "Neueste",
|
||||||
|
"dateAsc": "Älteste",
|
||||||
|
"lorasCount": "LoRA-Anzahl",
|
||||||
|
"lorasCountDesc": "Meiste",
|
||||||
|
"lorasCountAsc": "Wenigste"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Rezeptliste aktualisieren"
|
"title": "Rezeptliste aktualisieren"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Gefiltert nach LoRA"
|
"filteredByLora": "Gefiltert nach LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Nur Favoriten anzeigen",
|
||||||
|
"action": "Favoriten"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count} Duplikat-Gruppen gefunden",
|
"found": "{count} Duplikat-Gruppen gefunden",
|
||||||
@@ -617,11 +653,25 @@
|
|||||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
||||||
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||||
"prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}"
|
"prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "Rezept-Metadaten werden repariert...",
|
||||||
|
"success": "Rezept-Metadaten erfolgreich repariert",
|
||||||
|
"skipped": "Rezept bereits in der neuesten Version, keine Reparatur erforderlich",
|
||||||
|
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
|
||||||
|
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"title": "Checkpoint-Modelle"
|
"title": "Checkpoint-Modelle",
|
||||||
|
"modelTypes": {
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusion Model"
|
||||||
|
},
|
||||||
|
"contextMenu": {
|
||||||
|
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding-Modelle"
|
"title": "Embedding-Modelle"
|
||||||
@@ -638,7 +688,8 @@
|
|||||||
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
||||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden."
|
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -848,7 +899,9 @@
|
|||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
"success": "Dateispeicherort erfolgreich geöffnet",
|
"success": "Dateispeicherort erfolgreich geöffnet",
|
||||||
"failed": "Fehler beim Öffnen des Dateispeicherorts"
|
"failed": "Fehler beim Öffnen des Dateispeicherorts",
|
||||||
|
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
|
||||||
|
"clipboardFallback": "Pfad: {{path}}"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@@ -871,11 +924,13 @@
|
|||||||
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",
|
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",
|
||||||
"strengthMin": "Stärke Min",
|
"strengthMin": "Stärke Min",
|
||||||
"strengthMax": "Stärke Max",
|
"strengthMax": "Stärke Max",
|
||||||
|
"strengthRange": "Stärkenbereich",
|
||||||
"strength": "Stärke",
|
"strength": "Stärke",
|
||||||
"clipStrength": "Clip-Stärke",
|
"clipStrength": "Clip-Stärke",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Wert",
|
"valuePlaceholder": "Wert",
|
||||||
"add": "Hinzufügen"
|
"add": "Hinzufügen",
|
||||||
|
"invalidRange": "Ungültiges Bereichsformat. Verwenden Sie x.x-y.y"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "Trigger Words",
|
"label": "Trigger Words",
|
||||||
@@ -914,6 +969,13 @@
|
|||||||
"recipes": "Rezepte",
|
"recipes": "Rezepte",
|
||||||
"versions": "Versionen"
|
"versions": "Versionen"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"label": "Modellnavigation",
|
||||||
|
"previousWithShortcut": "Vorheriges Modell (←)",
|
||||||
|
"nextWithShortcut": "Nächstes Modell (→)",
|
||||||
|
"noPrevious": "Kein vorheriges Modell verfügbar",
|
||||||
|
"noNext": "Kein weiteres Modell verfügbar"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"noImageSell": "No selling generated content",
|
"noImageSell": "No selling generated content",
|
||||||
"noRentCivit": "No Civitai generation",
|
"noRentCivit": "No Civitai generation",
|
||||||
@@ -1317,6 +1379,7 @@
|
|||||||
"verificationCompleteSuccess": "Verifikation abgeschlossen. Alle Dateien sind bestätigte Duplikate.",
|
"verificationCompleteSuccess": "Verifikation abgeschlossen. Alle Dateien sind bestätigte Duplikate.",
|
||||||
"verificationFailed": "Fehler beim Verifizieren der Hashes: {message}",
|
"verificationFailed": "Fehler beim Verifizieren der Hashes: {message}",
|
||||||
"noTagsToAdd": "Keine Tags zum Hinzufügen",
|
"noTagsToAdd": "Keine Tags zum Hinzufügen",
|
||||||
|
"bulkTagsUpdating": "Tags für {count} Modell(e) werden aktualisiert...",
|
||||||
"tagsAddedSuccessfully": "Erfolgreich {tagCount} Tag(s) zu {count} {type}(s) hinzugefügt",
|
"tagsAddedSuccessfully": "Erfolgreich {tagCount} Tag(s) zu {count} {type}(s) hinzugefügt",
|
||||||
"tagsReplacedSuccessfully": "Tags für {count} {type}(s) erfolgreich durch {tagCount} Tag(s) ersetzt",
|
"tagsReplacedSuccessfully": "Tags für {count} {type}(s) erfolgreich durch {tagCount} Tag(s) ersetzt",
|
||||||
"tagsAddFailed": "Fehler beim Hinzufügen von Tags zu {count} Modell(en)",
|
"tagsAddFailed": "Fehler beim Hinzufügen von Tags zu {count} Modell(en)",
|
||||||
@@ -1330,6 +1393,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"loraRootsFailed": "Fehler beim Laden der LoRA-Stammverzeichnisse: {message}",
|
"loraRootsFailed": "Fehler beim Laden der LoRA-Stammverzeichnisse: {message}",
|
||||||
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
|
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
|
||||||
|
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
|
||||||
"embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}",
|
"embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}",
|
||||||
"mappingsUpdated": "Basis-Modell-Pfad-Zuordnungen aktualisiert ({count} Zuordnung{plural})",
|
"mappingsUpdated": "Basis-Modell-Pfad-Zuordnungen aktualisiert ({count} Zuordnung{plural})",
|
||||||
"mappingsCleared": "Basis-Modell-Pfad-Zuordnungen gelöscht",
|
"mappingsCleared": "Basis-Modell-Pfad-Zuordnungen gelöscht",
|
||||||
@@ -1437,6 +1501,8 @@
|
|||||||
"metadataRefreshed": "Metadaten erfolgreich aktualisiert",
|
"metadataRefreshed": "Metadaten erfolgreich aktualisiert",
|
||||||
"metadataRefreshFailed": "Fehler beim Aktualisieren der Metadaten: {message}",
|
"metadataRefreshFailed": "Fehler beim Aktualisieren der Metadaten: {message}",
|
||||||
"metadataUpdateComplete": "Metadaten-Update abgeschlossen",
|
"metadataUpdateComplete": "Metadaten-Update abgeschlossen",
|
||||||
|
"operationCancelled": "Vorgang vom Benutzer abgebrochen",
|
||||||
|
"operationCancelledPartial": "Vorgang abgebrochen. {success} Elemente verarbeitet.",
|
||||||
"metadataFetchFailed": "Fehler beim Abrufen der Metadaten: {message}",
|
"metadataFetchFailed": "Fehler beim Abrufen der Metadaten: {message}",
|
||||||
"bulkMetadataCompleteAll": "Alle {count} {type}s erfolgreich aktualisiert",
|
"bulkMetadataCompleteAll": "Alle {count} {type}s erfolgreich aktualisiert",
|
||||||
"bulkMetadataCompletePartial": "{success} von {total} {type}s aktualisiert",
|
"bulkMetadataCompletePartial": "{success} von {total} {type}s aktualisiert",
|
||||||
@@ -1453,7 +1519,8 @@
|
|||||||
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
|
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
||||||
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
||||||
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}"
|
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"korean": "한국어",
|
"korean": "한국어",
|
||||||
"french": "Français",
|
"french": "Français",
|
||||||
"spanish": "Español",
|
"spanish": "Español",
|
||||||
"Hebrew": "עברית"
|
"Hebrew": "עברית"
|
||||||
},
|
},
|
||||||
"fileSize": {
|
"fileSize": {
|
||||||
"zero": "0 Bytes",
|
"zero": "0 Bytes",
|
||||||
@@ -131,6 +131,9 @@
|
|||||||
"badges": {
|
"badges": {
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"updateAvailable": "Update available"
|
"updateAvailable": "Update available"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"timesUsed": "Times used"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -159,6 +162,13 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Repair recipes data",
|
||||||
|
"loading": "Repairing recipe data...",
|
||||||
|
"success": "Successfully repaired {count} recipes.",
|
||||||
|
"cancelled": "Repair cancelled. {count} recipes were repaired.",
|
||||||
|
"error": "Recipe repair failed: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +198,8 @@
|
|||||||
"creator": "Creator",
|
"creator": "Creator",
|
||||||
"title": "Recipe Title",
|
"title": "Recipe Title",
|
||||||
"loraName": "LoRA Filename",
|
"loraName": "LoRA Filename",
|
||||||
"loraModel": "LoRA Model Name"
|
"loraModel": "LoRA Model Name",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +210,7 @@
|
|||||||
"license": "License",
|
"license": "License",
|
||||||
"noCreditRequired": "No Credit Required",
|
"noCreditRequired": "No Credit Required",
|
||||||
"allowSellingGeneratedContent": "Allow Selling",
|
"allowSellingGeneratedContent": "Allow Selling",
|
||||||
|
"noTags": "No tags",
|
||||||
"clearAll": "Clear All Filters"
|
"clearAll": "Clear All Filters"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -219,9 +231,11 @@
|
|||||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||||
"openSettingsFileLocation": {
|
"openSettingsFileLocation": {
|
||||||
"label": "Open settings folder",
|
"label": "Open settings folder",
|
||||||
"tooltip": "Open the folder containing settings.json",
|
"tooltip": "Open folder containing settings.json",
|
||||||
"success": "Opened settings.json folder",
|
"success": "Opened settings.json folder",
|
||||||
"failed": "Failed to open settings.json folder"
|
"failed": "Failed to open settings.json folder",
|
||||||
|
"copied": "Settings path copied to clipboard: {{path}}",
|
||||||
|
"clipboardFallback": "Settings path: {{path}}"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "Content Filtering",
|
"contentFiltering": "Content Filtering",
|
||||||
@@ -302,11 +316,13 @@
|
|||||||
"loadingLibraries": "Loading libraries...",
|
"loadingLibraries": "Loading libraries...",
|
||||||
"noLibraries": "No libraries configured",
|
"noLibraries": "No libraries configured",
|
||||||
"defaultLoraRoot": "Default LoRA Root",
|
"defaultLoraRoot": "Default LoRA Root",
|
||||||
"defaultLoraRootHelp": "Set the default LoRA root directory for downloads, imports and moves",
|
"defaultLoraRootHelp": "Set default LoRA root directory for downloads, imports and moves",
|
||||||
"defaultCheckpointRoot": "Default Checkpoint Root",
|
"defaultCheckpointRoot": "Default Checkpoint Root",
|
||||||
"defaultCheckpointRootHelp": "Set the default checkpoint root directory for downloads, imports and moves",
|
"defaultCheckpointRootHelp": "Set default checkpoint root directory for downloads, imports and moves",
|
||||||
|
"defaultUnetRoot": "Default Diffusion Model Root",
|
||||||
|
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
||||||
"defaultEmbeddingRoot": "Default Embedding Root",
|
"defaultEmbeddingRoot": "Default Embedding Root",
|
||||||
"defaultEmbeddingRootHelp": "Set the default embedding root directory for downloads, imports and moves",
|
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
||||||
"noDefault": "No Default"
|
"noDefault": "No Default"
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -443,7 +459,10 @@
|
|||||||
"dateAsc": "Oldest",
|
"dateAsc": "Oldest",
|
||||||
"size": "File Size",
|
"size": "File Size",
|
||||||
"sizeDesc": "Largest",
|
"sizeDesc": "Largest",
|
||||||
"sizeAsc": "Smallest"
|
"sizeAsc": "Smallest",
|
||||||
|
"usage": "Use Count",
|
||||||
|
"usageDesc": "Most",
|
||||||
|
"usageAsc": "Least"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh model list",
|
"title": "Refresh model list",
|
||||||
@@ -518,6 +537,7 @@
|
|||||||
"replacePreview": "Replace Preview",
|
"replacePreview": "Replace Preview",
|
||||||
"setContentRating": "Set Content Rating",
|
"setContentRating": "Set Content Rating",
|
||||||
"moveToFolder": "Move to Folder",
|
"moveToFolder": "Move to Folder",
|
||||||
|
"repairMetadata": "Repair metadata",
|
||||||
"excludeModel": "Exclude Model",
|
"excludeModel": "Exclude Model",
|
||||||
"deleteModel": "Delete Model",
|
"deleteModel": "Delete Model",
|
||||||
"shareRecipe": "Share Recipe",
|
"shareRecipe": "Share Recipe",
|
||||||
@@ -588,10 +608,26 @@
|
|||||||
"selectLoraRoot": "Please select a LoRA root directory"
|
"selectLoraRoot": "Please select a LoRA root directory"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Sort recipes by...",
|
||||||
|
"name": "Name",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "Date",
|
||||||
|
"dateDesc": "Newest",
|
||||||
|
"dateAsc": "Oldest",
|
||||||
|
"lorasCount": "LoRA Count",
|
||||||
|
"lorasCountDesc": "Most",
|
||||||
|
"lorasCountAsc": "Least"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh recipe list"
|
"title": "Refresh recipe list"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Filtered by LoRA"
|
"filteredByLora": "Filtered by LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Show Favorites Only",
|
||||||
|
"action": "Favorites"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Found {count} duplicate groups",
|
"found": "Found {count} duplicate groups",
|
||||||
@@ -617,11 +653,25 @@
|
|||||||
"noMissingLoras": "No missing LoRAs to download",
|
"noMissingLoras": "No missing LoRAs to download",
|
||||||
"getInfoFailed": "Failed to get information for missing LoRAs",
|
"getInfoFailed": "Failed to get information for missing LoRAs",
|
||||||
"prepareError": "Error preparing LoRAs for download: {message}"
|
"prepareError": "Error preparing LoRAs for download: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "Repairing recipe metadata...",
|
||||||
|
"success": "Recipe metadata repaired successfully",
|
||||||
|
"skipped": "Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "Failed to repair recipe: {message}",
|
||||||
|
"missingId": "Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"title": "Checkpoint Models"
|
"title": "Checkpoint Models",
|
||||||
|
"modelTypes": {
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusion Model"
|
||||||
|
},
|
||||||
|
"contextMenu": {
|
||||||
|
"moveToOtherTypeFolder": "Move to {otherType} Folder"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding Models"
|
"title": "Embedding Models"
|
||||||
@@ -638,7 +688,8 @@
|
|||||||
"recursiveUnavailable": "Recursive search is available in tree view only",
|
"recursiveUnavailable": "Recursive search is available in tree view only",
|
||||||
"collapseAllDisabled": "Not available in list view",
|
"collapseAllDisabled": "Not available in list view",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Unable to determine destination path for move."
|
"unableToResolveRoot": "Unable to determine destination path for move.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -848,7 +899,9 @@
|
|||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
"success": "File location opened successfully",
|
"success": "File location opened successfully",
|
||||||
"failed": "Failed to open file location"
|
"failed": "Failed to open file location",
|
||||||
|
"copied": "Path copied to clipboard: {{path}}",
|
||||||
|
"clipboardFallback": "Path: {{path}}"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@@ -871,11 +924,13 @@
|
|||||||
"addPresetParameter": "Add preset parameter...",
|
"addPresetParameter": "Add preset parameter...",
|
||||||
"strengthMin": "Strength Min",
|
"strengthMin": "Strength Min",
|
||||||
"strengthMax": "Strength Max",
|
"strengthMax": "Strength Max",
|
||||||
|
"strengthRange": "Strength Range",
|
||||||
"strength": "Strength",
|
"strength": "Strength",
|
||||||
"clipStrength": "Clip Strength",
|
"clipStrength": "Clip Strength",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Value",
|
"valuePlaceholder": "Value",
|
||||||
"add": "Add"
|
"add": "Add",
|
||||||
|
"invalidRange": "Invalid range format. Use x.x-y.y"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "Trigger Words",
|
"label": "Trigger Words",
|
||||||
@@ -914,6 +969,13 @@
|
|||||||
"recipes": "Recipes",
|
"recipes": "Recipes",
|
||||||
"versions": "Versions"
|
"versions": "Versions"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"label": "Model navigation",
|
||||||
|
"previousWithShortcut": "Previous model (←)",
|
||||||
|
"nextWithShortcut": "Next model (→)",
|
||||||
|
"noPrevious": "No previous model available",
|
||||||
|
"noNext": "No next model available"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"noImageSell": "No selling generated content",
|
"noImageSell": "No selling generated content",
|
||||||
"noRentCivit": "No Civitai generation",
|
"noRentCivit": "No Civitai generation",
|
||||||
@@ -1317,6 +1379,7 @@
|
|||||||
"verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.",
|
"verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.",
|
||||||
"verificationFailed": "Failed to verify hashes: {message}",
|
"verificationFailed": "Failed to verify hashes: {message}",
|
||||||
"noTagsToAdd": "No tags to add",
|
"noTagsToAdd": "No tags to add",
|
||||||
|
"bulkTagsUpdating": "Updating tags for {count} model(s)...",
|
||||||
"tagsAddedSuccessfully": "Successfully added {tagCount} tag(s) to {count} {type}(s)",
|
"tagsAddedSuccessfully": "Successfully added {tagCount} tag(s) to {count} {type}(s)",
|
||||||
"tagsReplacedSuccessfully": "Successfully replaced tags for {count} {type}(s) with {tagCount} tag(s)",
|
"tagsReplacedSuccessfully": "Successfully replaced tags for {count} {type}(s) with {tagCount} tag(s)",
|
||||||
"tagsAddFailed": "Failed to add tags to {count} model(s)",
|
"tagsAddFailed": "Failed to add tags to {count} model(s)",
|
||||||
@@ -1330,6 +1393,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"loraRootsFailed": "Failed to load LoRA roots: {message}",
|
"loraRootsFailed": "Failed to load LoRA roots: {message}",
|
||||||
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
|
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
|
||||||
|
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
|
||||||
"embeddingRootsFailed": "Failed to load embedding roots: {message}",
|
"embeddingRootsFailed": "Failed to load embedding roots: {message}",
|
||||||
"mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})",
|
"mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})",
|
||||||
"mappingsCleared": "Base model path mappings cleared",
|
"mappingsCleared": "Base model path mappings cleared",
|
||||||
@@ -1437,6 +1501,8 @@
|
|||||||
"metadataRefreshed": "Metadata refreshed successfully",
|
"metadataRefreshed": "Metadata refreshed successfully",
|
||||||
"metadataRefreshFailed": "Failed to refresh metadata: {message}",
|
"metadataRefreshFailed": "Failed to refresh metadata: {message}",
|
||||||
"metadataUpdateComplete": "Metadata update complete",
|
"metadataUpdateComplete": "Metadata update complete",
|
||||||
|
"operationCancelled": "Operation cancelled by user",
|
||||||
|
"operationCancelledPartial": "Operation cancelled. {success} items processed.",
|
||||||
"metadataFetchFailed": "Failed to fetch metadata: {message}",
|
"metadataFetchFailed": "Failed to fetch metadata: {message}",
|
||||||
"bulkMetadataCompleteAll": "Successfully refreshed all {count} {type}s",
|
"bulkMetadataCompleteAll": "Successfully refreshed all {count} {type}s",
|
||||||
"bulkMetadataCompletePartial": "Refreshed {success} of {total} {type}s",
|
"bulkMetadataCompletePartial": "Refreshed {success} of {total} {type}s",
|
||||||
@@ -1453,7 +1519,8 @@
|
|||||||
"bulkMoveFailures": "Failed moves:\n{failures}",
|
"bulkMoveFailures": "Failed moves:\n{failures}",
|
||||||
"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}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -131,6 +131,9 @@
|
|||||||
"badges": {
|
"badges": {
|
||||||
"update": "Actualización",
|
"update": "Actualización",
|
||||||
"updateAvailable": "Actualización disponible"
|
"updateAvailable": "Actualización disponible"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"timesUsed": "Veces usado"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -159,6 +162,13 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Reparar datos de recetas",
|
||||||
|
"loading": "Reparando datos de recetas...",
|
||||||
|
"success": "Se repararon con éxito {count} recetas.",
|
||||||
|
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
||||||
|
"error": "Error al reparar recetas: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +198,8 @@
|
|||||||
"creator": "Creador",
|
"creator": "Creador",
|
||||||
"title": "Título de la receta",
|
"title": "Título de la receta",
|
||||||
"loraName": "Nombre de archivo LoRA",
|
"loraName": "Nombre de archivo LoRA",
|
||||||
"loraModel": "Nombre del modelo LoRA"
|
"loraModel": "Nombre del modelo LoRA",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +210,7 @@
|
|||||||
"license": "Licencia",
|
"license": "Licencia",
|
||||||
"noCreditRequired": "Sin crédito requerido",
|
"noCreditRequired": "Sin crédito requerido",
|
||||||
"allowSellingGeneratedContent": "Venta permitida",
|
"allowSellingGeneratedContent": "Venta permitida",
|
||||||
|
"noTags": "Sin etiquetas",
|
||||||
"clearAll": "Limpiar todos los filtros"
|
"clearAll": "Limpiar todos los filtros"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -221,7 +233,9 @@
|
|||||||
"label": "Abrir carpeta de ajustes",
|
"label": "Abrir carpeta de ajustes",
|
||||||
"tooltip": "Abrir la carpeta que contiene settings.json",
|
"tooltip": "Abrir la carpeta que contiene settings.json",
|
||||||
"success": "Carpeta de settings.json abierta",
|
"success": "Carpeta de settings.json abierta",
|
||||||
"failed": "No se pudo abrir la carpeta de settings.json"
|
"failed": "No se pudo abrir la carpeta de settings.json",
|
||||||
|
"copied": "Ruta de configuración copiada al portapapeles: {{path}}",
|
||||||
|
"clipboardFallback": "Ruta de configuración: {{path}}"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "Filtrado de contenido",
|
"contentFiltering": "Filtrado de contenido",
|
||||||
@@ -305,6 +319,8 @@
|
|||||||
"defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos",
|
"defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos",
|
||||||
"defaultCheckpointRoot": "Raíz predeterminada de checkpoint",
|
"defaultCheckpointRoot": "Raíz predeterminada de checkpoint",
|
||||||
"defaultCheckpointRootHelp": "Establecer el directorio raíz predeterminado de checkpoint para descargas, importaciones y movimientos",
|
"defaultCheckpointRootHelp": "Establecer el directorio raíz predeterminado de checkpoint para descargas, importaciones y movimientos",
|
||||||
|
"defaultUnetRoot": "Raíz predeterminada de Diffusion Model",
|
||||||
|
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
||||||
"defaultEmbeddingRoot": "Raíz predeterminada de embedding",
|
"defaultEmbeddingRoot": "Raíz predeterminada de embedding",
|
||||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
||||||
"noDefault": "Sin predeterminado"
|
"noDefault": "Sin predeterminado"
|
||||||
@@ -443,7 +459,10 @@
|
|||||||
"dateAsc": "Más antiguo",
|
"dateAsc": "Más antiguo",
|
||||||
"size": "Tamaño de archivo",
|
"size": "Tamaño de archivo",
|
||||||
"sizeDesc": "Mayor",
|
"sizeDesc": "Mayor",
|
||||||
"sizeAsc": "Menor"
|
"sizeAsc": "Menor",
|
||||||
|
"usage": "Número de usos",
|
||||||
|
"usageDesc": "Más",
|
||||||
|
"usageAsc": "Menos"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de modelos",
|
"title": "Actualizar lista de modelos",
|
||||||
@@ -518,6 +537,7 @@
|
|||||||
"replacePreview": "Reemplazar vista previa",
|
"replacePreview": "Reemplazar vista previa",
|
||||||
"setContentRating": "Establecer clasificación de contenido",
|
"setContentRating": "Establecer clasificación de contenido",
|
||||||
"moveToFolder": "Mover a carpeta",
|
"moveToFolder": "Mover a carpeta",
|
||||||
|
"repairMetadata": "Reparar metadatos",
|
||||||
"excludeModel": "Excluir modelo",
|
"excludeModel": "Excluir modelo",
|
||||||
"deleteModel": "Eliminar modelo",
|
"deleteModel": "Eliminar modelo",
|
||||||
"shareRecipe": "Compartir receta",
|
"shareRecipe": "Compartir receta",
|
||||||
@@ -588,10 +608,26 @@
|
|||||||
"selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA"
|
"selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Ordenar recetas por...",
|
||||||
|
"name": "Nombre",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "Fecha",
|
||||||
|
"dateDesc": "Más reciente",
|
||||||
|
"dateAsc": "Más antiguo",
|
||||||
|
"lorasCount": "Cant. de LoRAs",
|
||||||
|
"lorasCountDesc": "Más",
|
||||||
|
"lorasCountAsc": "Menos"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de recetas"
|
"title": "Actualizar lista de recetas"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Filtrado por LoRA"
|
"filteredByLora": "Filtrado por LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Mostrar solo favoritos",
|
||||||
|
"action": "Favoritos"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Se encontraron {count} grupos de duplicados",
|
"found": "Se encontraron {count} grupos de duplicados",
|
||||||
@@ -617,11 +653,25 @@
|
|||||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
||||||
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
|
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||||
"prepareError": "Error preparando LoRAs para descarga: {message}"
|
"prepareError": "Error preparando LoRAs para descarga: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "Reparando metadatos de la receta...",
|
||||||
|
"success": "Metadatos de la receta reparados con éxito",
|
||||||
|
"skipped": "La receta ya está en la última versión, no se necesita reparación",
|
||||||
|
"failed": "Error al reparar la receta: {message}",
|
||||||
|
"missingId": "No se puede reparar la receta: falta el ID de la receta"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"title": "Modelos checkpoint"
|
"title": "Modelos checkpoint",
|
||||||
|
"modelTypes": {
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusion Model"
|
||||||
|
},
|
||||||
|
"contextMenu": {
|
||||||
|
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Modelos embedding"
|
"title": "Modelos embedding"
|
||||||
@@ -638,7 +688,8 @@
|
|||||||
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
||||||
"collapseAllDisabled": "No disponible en vista de lista",
|
"collapseAllDisabled": "No disponible en vista de lista",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento."
|
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -848,7 +899,9 @@
|
|||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
"success": "Ubicación del archivo abierta exitosamente",
|
"success": "Ubicación del archivo abierta exitosamente",
|
||||||
"failed": "Error al abrir la ubicación del archivo"
|
"failed": "Error al abrir la ubicación del archivo",
|
||||||
|
"copied": "Ruta copiada al portapapeles: {{path}}",
|
||||||
|
"clipboardFallback": "Ruta: {{path}}"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "Versión",
|
"version": "Versión",
|
||||||
@@ -871,11 +924,13 @@
|
|||||||
"addPresetParameter": "Añadir parámetro preestablecido...",
|
"addPresetParameter": "Añadir parámetro preestablecido...",
|
||||||
"strengthMin": "Fuerza mínima",
|
"strengthMin": "Fuerza mínima",
|
||||||
"strengthMax": "Fuerza máxima",
|
"strengthMax": "Fuerza máxima",
|
||||||
|
"strengthRange": "Rango de fuerza",
|
||||||
"strength": "Fuerza",
|
"strength": "Fuerza",
|
||||||
"clipStrength": "Fuerza de Clip",
|
"clipStrength": "Fuerza de Clip",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Valor",
|
"valuePlaceholder": "Valor",
|
||||||
"add": "Añadir"
|
"add": "Añadir",
|
||||||
|
"invalidRange": "Formato de rango inválido. Use x.x-y.y"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "Palabras clave",
|
"label": "Palabras clave",
|
||||||
@@ -914,6 +969,13 @@
|
|||||||
"recipes": "Recetas",
|
"recipes": "Recetas",
|
||||||
"versions": "Versiones"
|
"versions": "Versiones"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"label": "Navegación de modelos",
|
||||||
|
"previousWithShortcut": "Modelo anterior (←)",
|
||||||
|
"nextWithShortcut": "Siguiente modelo (→)",
|
||||||
|
"noPrevious": "No hay modelo anterior disponible",
|
||||||
|
"noNext": "No hay siguiente modelo disponible"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"noImageSell": "No selling generated content",
|
"noImageSell": "No selling generated content",
|
||||||
"noRentCivit": "No Civitai generation",
|
"noRentCivit": "No Civitai generation",
|
||||||
@@ -1317,6 +1379,7 @@
|
|||||||
"verificationCompleteSuccess": "Verificación completa. Todos los archivos son confirmados duplicados.",
|
"verificationCompleteSuccess": "Verificación completa. Todos los archivos son confirmados duplicados.",
|
||||||
"verificationFailed": "Error al verificar hashes: {message}",
|
"verificationFailed": "Error al verificar hashes: {message}",
|
||||||
"noTagsToAdd": "No hay etiquetas para añadir",
|
"noTagsToAdd": "No hay etiquetas para añadir",
|
||||||
|
"bulkTagsUpdating": "Actualizando etiquetas para {count} modelo(s)...",
|
||||||
"tagsAddedSuccessfully": "Se añadieron exitosamente {tagCount} etiqueta(s) a {count} {type}(s)",
|
"tagsAddedSuccessfully": "Se añadieron exitosamente {tagCount} etiqueta(s) a {count} {type}(s)",
|
||||||
"tagsReplacedSuccessfully": "Se reemplazaron exitosamente las etiquetas de {count} {type}(s) con {tagCount} etiqueta(s)",
|
"tagsReplacedSuccessfully": "Se reemplazaron exitosamente las etiquetas de {count} {type}(s) con {tagCount} etiqueta(s)",
|
||||||
"tagsAddFailed": "Error al añadir etiquetas a {count} modelo(s)",
|
"tagsAddFailed": "Error al añadir etiquetas a {count} modelo(s)",
|
||||||
@@ -1330,6 +1393,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"loraRootsFailed": "Error al cargar raíces de LoRA: {message}",
|
"loraRootsFailed": "Error al cargar raíces de LoRA: {message}",
|
||||||
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
|
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
|
||||||
|
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
|
||||||
"embeddingRootsFailed": "Error al cargar raíces de embedding: {message}",
|
"embeddingRootsFailed": "Error al cargar raíces de embedding: {message}",
|
||||||
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
|
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
|
||||||
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
|
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
|
||||||
@@ -1437,6 +1501,8 @@
|
|||||||
"metadataRefreshed": "Metadatos actualizados exitosamente",
|
"metadataRefreshed": "Metadatos actualizados exitosamente",
|
||||||
"metadataRefreshFailed": "Error al actualizar metadatos: {message}",
|
"metadataRefreshFailed": "Error al actualizar metadatos: {message}",
|
||||||
"metadataUpdateComplete": "Actualización de metadatos completada",
|
"metadataUpdateComplete": "Actualización de metadatos completada",
|
||||||
|
"operationCancelled": "Operación cancelada por el usuario",
|
||||||
|
"operationCancelledPartial": "Operación cancelada. {success} elementos procesados.",
|
||||||
"metadataFetchFailed": "Error al obtener metadatos: {message}",
|
"metadataFetchFailed": "Error al obtener metadatos: {message}",
|
||||||
"bulkMetadataCompleteAll": "Actualizados exitosamente todos los {count} {type}s",
|
"bulkMetadataCompleteAll": "Actualizados exitosamente todos los {count} {type}s",
|
||||||
"bulkMetadataCompletePartial": "Actualizados {success} de {total} {type}s",
|
"bulkMetadataCompletePartial": "Actualizados {success} de {total} {type}s",
|
||||||
@@ -1453,7 +1519,8 @@
|
|||||||
"bulkMoveFailures": "Movimientos fallidos:\n{failures}",
|
"bulkMoveFailures": "Movimientos fallidos:\n{failures}",
|
||||||
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
||||||
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}"
|
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -131,6 +131,9 @@
|
|||||||
"badges": {
|
"badges": {
|
||||||
"update": "Mise à jour",
|
"update": "Mise à jour",
|
||||||
"updateAvailable": "Mise à jour disponible"
|
"updateAvailable": "Mise à jour disponible"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"timesUsed": "Nombre d'utilisations"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -159,6 +162,13 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Réparer les données de recettes",
|
||||||
|
"loading": "Réparation des données de recettes...",
|
||||||
|
"success": "{count} recettes réparées avec succès.",
|
||||||
|
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
|
||||||
|
"error": "Échec de la réparation des recettes : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +198,8 @@
|
|||||||
"creator": "Créateur",
|
"creator": "Créateur",
|
||||||
"title": "Titre de la recipe",
|
"title": "Titre de la recipe",
|
||||||
"loraName": "Nom de fichier LoRA",
|
"loraName": "Nom de fichier LoRA",
|
||||||
"loraModel": "Nom du modèle LoRA"
|
"loraModel": "Nom du modèle LoRA",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +210,7 @@
|
|||||||
"license": "Licence",
|
"license": "Licence",
|
||||||
"noCreditRequired": "Crédit non requis",
|
"noCreditRequired": "Crédit non requis",
|
||||||
"allowSellingGeneratedContent": "Vente autorisée",
|
"allowSellingGeneratedContent": "Vente autorisée",
|
||||||
|
"noTags": "Aucun tag",
|
||||||
"clearAll": "Effacer tous les filtres"
|
"clearAll": "Effacer tous les filtres"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -221,7 +233,9 @@
|
|||||||
"label": "Ouvrir le dossier des paramètres",
|
"label": "Ouvrir le dossier des paramètres",
|
||||||
"tooltip": "Ouvrir le dossier contenant settings.json",
|
"tooltip": "Ouvrir le dossier contenant settings.json",
|
||||||
"success": "Dossier settings.json ouvert",
|
"success": "Dossier settings.json ouvert",
|
||||||
"failed": "Impossible d'ouvrir le dossier settings.json"
|
"failed": "Impossible d'ouvrir le dossier settings.json",
|
||||||
|
"copied": "Chemin des paramètres copié dans le presse-papiers: {{path}}",
|
||||||
|
"clipboardFallback": "Chemin des paramètres: {{path}}"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "Filtrage du contenu",
|
"contentFiltering": "Filtrage du contenu",
|
||||||
@@ -305,6 +319,8 @@
|
|||||||
"defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements",
|
"defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements",
|
||||||
"defaultCheckpointRoot": "Racine Checkpoint par défaut",
|
"defaultCheckpointRoot": "Racine Checkpoint par défaut",
|
||||||
"defaultCheckpointRootHelp": "Définir le répertoire racine checkpoint par défaut pour les téléchargements, imports et déplacements",
|
"defaultCheckpointRootHelp": "Définir le répertoire racine checkpoint par défaut pour les téléchargements, imports et déplacements",
|
||||||
|
"defaultUnetRoot": "Racine Diffusion Model par défaut",
|
||||||
|
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
|
||||||
"defaultEmbeddingRoot": "Racine Embedding par défaut",
|
"defaultEmbeddingRoot": "Racine Embedding par défaut",
|
||||||
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
||||||
"noDefault": "Aucun par défaut"
|
"noDefault": "Aucun par défaut"
|
||||||
@@ -443,7 +459,10 @@
|
|||||||
"dateAsc": "Plus ancien",
|
"dateAsc": "Plus ancien",
|
||||||
"size": "Taille du fichier",
|
"size": "Taille du fichier",
|
||||||
"sizeDesc": "Plus grand",
|
"sizeDesc": "Plus grand",
|
||||||
"sizeAsc": "Plus petit"
|
"sizeAsc": "Plus petit",
|
||||||
|
"usage": "Nombre d'utilisations",
|
||||||
|
"usageDesc": "Plus",
|
||||||
|
"usageAsc": "Moins"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des modèles",
|
"title": "Actualiser la liste des modèles",
|
||||||
@@ -518,6 +537,7 @@
|
|||||||
"replacePreview": "Remplacer l'aperçu",
|
"replacePreview": "Remplacer l'aperçu",
|
||||||
"setContentRating": "Définir la classification du contenu",
|
"setContentRating": "Définir la classification du contenu",
|
||||||
"moveToFolder": "Déplacer vers un dossier",
|
"moveToFolder": "Déplacer vers un dossier",
|
||||||
|
"repairMetadata": "Réparer les métadonnées",
|
||||||
"excludeModel": "Exclure le modèle",
|
"excludeModel": "Exclure le modèle",
|
||||||
"deleteModel": "Supprimer le modèle",
|
"deleteModel": "Supprimer le modèle",
|
||||||
"shareRecipe": "Partager la recipe",
|
"shareRecipe": "Partager la recipe",
|
||||||
@@ -588,10 +608,26 @@
|
|||||||
"selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA"
|
"selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Trier les recettes par...",
|
||||||
|
"name": "Nom",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "Date",
|
||||||
|
"dateDesc": "Plus récent",
|
||||||
|
"dateAsc": "Plus ancien",
|
||||||
|
"lorasCount": "Nombre de LoRAs",
|
||||||
|
"lorasCountDesc": "Plus",
|
||||||
|
"lorasCountAsc": "Moins"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des recipes"
|
"title": "Actualiser la liste des recipes"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Filtré par LoRA"
|
"filteredByLora": "Filtré par LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Afficher uniquement les favoris",
|
||||||
|
"action": "Favoris"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Trouvé {count} groupes de doublons",
|
"found": "Trouvé {count} groupes de doublons",
|
||||||
@@ -617,11 +653,25 @@
|
|||||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
||||||
"getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
"getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||||
"prepareError": "Erreur lors de la préparation des LoRAs pour le téléchargement : {message}"
|
"prepareError": "Erreur lors de la préparation des LoRAs pour le téléchargement : {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "Réparation des métadonnées de la recette...",
|
||||||
|
"success": "Métadonnées de la recette réparées avec succès",
|
||||||
|
"skipped": "Recette déjà à la version la plus récente, aucune réparation nécessaire",
|
||||||
|
"failed": "Échec de la réparation de la recette : {message}",
|
||||||
|
"missingId": "Impossible de réparer la recette : ID de recette manquant"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"title": "Modèles Checkpoint"
|
"title": "Modèles Checkpoint",
|
||||||
|
"modelTypes": {
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusion Model"
|
||||||
|
},
|
||||||
|
"contextMenu": {
|
||||||
|
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Modèles Embedding"
|
"title": "Modèles Embedding"
|
||||||
@@ -638,7 +688,8 @@
|
|||||||
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
||||||
"collapseAllDisabled": "Non disponible en vue liste",
|
"collapseAllDisabled": "Non disponible en vue liste",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement."
|
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -848,7 +899,9 @@
|
|||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
"success": "Emplacement du fichier ouvert avec succès",
|
"success": "Emplacement du fichier ouvert avec succès",
|
||||||
"failed": "Échec de l'ouverture de l'emplacement du fichier"
|
"failed": "Échec de l'ouverture de l'emplacement du fichier",
|
||||||
|
"copied": "Chemin copié dans le presse-papiers: {{path}}",
|
||||||
|
"clipboardFallback": "Chemin: {{path}}"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@@ -871,11 +924,13 @@
|
|||||||
"addPresetParameter": "Ajouter un paramètre prédéfini...",
|
"addPresetParameter": "Ajouter un paramètre prédéfini...",
|
||||||
"strengthMin": "Force Min",
|
"strengthMin": "Force Min",
|
||||||
"strengthMax": "Force Max",
|
"strengthMax": "Force Max",
|
||||||
|
"strengthRange": "Gamme de force",
|
||||||
"strength": "Force",
|
"strength": "Force",
|
||||||
"clipStrength": "Force Clip",
|
"clipStrength": "Force Clip",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Valeur",
|
"valuePlaceholder": "Valeur",
|
||||||
"add": "Ajouter"
|
"add": "Ajouter",
|
||||||
|
"invalidRange": "Format de plage invalide. Utilisez x.x-y.y"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "Mots-clés",
|
"label": "Mots-clés",
|
||||||
@@ -914,6 +969,13 @@
|
|||||||
"recipes": "Recipes",
|
"recipes": "Recipes",
|
||||||
"versions": "Versions"
|
"versions": "Versions"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"label": "Navigation des modèles",
|
||||||
|
"previousWithShortcut": "Modèle précédent (←)",
|
||||||
|
"nextWithShortcut": "Modèle suivant (→)",
|
||||||
|
"noPrevious": "Aucun modèle précédent",
|
||||||
|
"noNext": "Aucun modèle suivant"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"noImageSell": "No selling generated content",
|
"noImageSell": "No selling generated content",
|
||||||
"noRentCivit": "No Civitai generation",
|
"noRentCivit": "No Civitai generation",
|
||||||
@@ -1317,6 +1379,7 @@
|
|||||||
"verificationCompleteSuccess": "Vérification terminée. Tous les fichiers sont confirmés comme doublons.",
|
"verificationCompleteSuccess": "Vérification terminée. Tous les fichiers sont confirmés comme doublons.",
|
||||||
"verificationFailed": "Échec de la vérification des hash : {message}",
|
"verificationFailed": "Échec de la vérification des hash : {message}",
|
||||||
"noTagsToAdd": "Aucun tag à ajouter",
|
"noTagsToAdd": "Aucun tag à ajouter",
|
||||||
|
"bulkTagsUpdating": "Mise à jour des tags pour {count} modèle(s)...",
|
||||||
"tagsAddedSuccessfully": "{tagCount} tag(s) ajouté(s) avec succès à {count} {type}(s)",
|
"tagsAddedSuccessfully": "{tagCount} tag(s) ajouté(s) avec succès à {count} {type}(s)",
|
||||||
"tagsReplacedSuccessfully": "Tags remplacés avec succès pour {count} {type}(s) avec {tagCount} tag(s)",
|
"tagsReplacedSuccessfully": "Tags remplacés avec succès pour {count} {type}(s) avec {tagCount} tag(s)",
|
||||||
"tagsAddFailed": "Échec de l'ajout des tags à {count} modèle(s)",
|
"tagsAddFailed": "Échec de l'ajout des tags à {count} modèle(s)",
|
||||||
@@ -1330,6 +1393,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"loraRootsFailed": "Échec du chargement des racines LoRA : {message}",
|
"loraRootsFailed": "Échec du chargement des racines LoRA : {message}",
|
||||||
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
|
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
|
||||||
|
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
|
||||||
"embeddingRootsFailed": "Échec du chargement des racines embedding : {message}",
|
"embeddingRootsFailed": "Échec du chargement des racines embedding : {message}",
|
||||||
"mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})",
|
"mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})",
|
||||||
"mappingsCleared": "Mappages de chemin de modèle de base effacés",
|
"mappingsCleared": "Mappages de chemin de modèle de base effacés",
|
||||||
@@ -1437,6 +1501,8 @@
|
|||||||
"metadataRefreshed": "Métadonnées actualisées avec succès",
|
"metadataRefreshed": "Métadonnées actualisées avec succès",
|
||||||
"metadataRefreshFailed": "Échec de l'actualisation des métadonnées : {message}",
|
"metadataRefreshFailed": "Échec de l'actualisation des métadonnées : {message}",
|
||||||
"metadataUpdateComplete": "Mise à jour des métadonnées terminée",
|
"metadataUpdateComplete": "Mise à jour des métadonnées terminée",
|
||||||
|
"operationCancelled": "Opération annulée par l'utilisateur",
|
||||||
|
"operationCancelledPartial": "Opération annulée. {success} éléments traités.",
|
||||||
"metadataFetchFailed": "Échec de la récupération des métadonnées : {message}",
|
"metadataFetchFailed": "Échec de la récupération des métadonnées : {message}",
|
||||||
"bulkMetadataCompleteAll": "Actualisation réussie de tous les {count} {type}s",
|
"bulkMetadataCompleteAll": "Actualisation réussie de tous les {count} {type}s",
|
||||||
"bulkMetadataCompletePartial": "{success} sur {total} {type}s actualisés",
|
"bulkMetadataCompletePartial": "{success} sur {total} {type}s actualisés",
|
||||||
@@ -1453,7 +1519,8 @@
|
|||||||
"bulkMoveFailures": "Échecs de déplacement :\n{failures}",
|
"bulkMoveFailures": "Échecs de déplacement :\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
||||||
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
|
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
|
||||||
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}"
|
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
133
locales/he.json
133
locales/he.json
@@ -131,6 +131,9 @@
|
|||||||
"badges": {
|
"badges": {
|
||||||
"update": "עדכון",
|
"update": "עדכון",
|
||||||
"updateAvailable": "עדכון זמין"
|
"updateAvailable": "עדכון זמין"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"timesUsed": "מספר שימושים"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -159,6 +162,13 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "תיקון נתוני מתכונים",
|
||||||
|
"loading": "מתקן נתוני מתכונים...",
|
||||||
|
"success": "תוקנו בהצלחה {count} מתכונים.",
|
||||||
|
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
||||||
|
"error": "תיקון המתכונים נכשל: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +198,8 @@
|
|||||||
"creator": "יוצר",
|
"creator": "יוצר",
|
||||||
"title": "כותרת מתכון",
|
"title": "כותרת מתכון",
|
||||||
"loraName": "שם קובץ LoRA",
|
"loraName": "שם קובץ LoRA",
|
||||||
"loraModel": "שם מודל LoRA"
|
"loraModel": "שם מודל LoRA",
|
||||||
|
"prompt": "הנחיה"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +210,7 @@
|
|||||||
"license": "רישיון",
|
"license": "רישיון",
|
||||||
"noCreditRequired": "ללא קרדיט נדרש",
|
"noCreditRequired": "ללא קרדיט נדרש",
|
||||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||||
|
"noTags": "ללא תגיות",
|
||||||
"clearAll": "נקה את כל המסננים"
|
"clearAll": "נקה את כל המסננים"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -221,13 +233,16 @@
|
|||||||
"label": "פתח תיקיית הגדרות",
|
"label": "פתח תיקיית הגדרות",
|
||||||
"tooltip": "פתח את התיקייה שמכילה את settings.json",
|
"tooltip": "פתח את התיקייה שמכילה את settings.json",
|
||||||
"success": "תיקיית settings.json נפתחה",
|
"success": "תיקיית settings.json נפתחה",
|
||||||
"failed": "לא ניתן לפתוח את תיקיית settings.json"
|
"failed": "לא ניתן לפתוח את תיקיית settings.json",
|
||||||
|
"copied": "נתיב ההגדרות הועתק ללוח העריכה: {{path}}",
|
||||||
|
"clipboardFallback": "נתיב ההגדרות: {{path}}"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "סינון תוכן",
|
"contentFiltering": "סינון תוכן",
|
||||||
"videoSettings": "הגדרות וידאו",
|
"videoSettings": "הגדרות וידאו",
|
||||||
"layoutSettings": "הגדרות פריסה",
|
"layoutSettings": "הגדרות פריסה",
|
||||||
"folderSettings": "הגדרות תיקייה",
|
"folderSettings": "הגדרות תיקייה",
|
||||||
|
"priorityTags": "תגיות עדיפות",
|
||||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||||
"exampleImages": "תמונות דוגמה",
|
"exampleImages": "תמונות דוגמה",
|
||||||
"updateFlags": "תגי עדכון",
|
"updateFlags": "תגי עדכון",
|
||||||
@@ -235,8 +250,7 @@
|
|||||||
"misc": "שונות",
|
"misc": "שונות",
|
||||||
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
|
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
|
||||||
"storageLocation": "מיקום ההגדרות",
|
"storageLocation": "מיקום ההגדרות",
|
||||||
"proxySettings": "הגדרות פרוקסי",
|
"proxySettings": "הגדרות פרוקסי"
|
||||||
"priorityTags": "תגיות עדיפות"
|
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"locationLabel": "מצב נייד",
|
"locationLabel": "מצב נייד",
|
||||||
@@ -298,17 +312,39 @@
|
|||||||
},
|
},
|
||||||
"folderSettings": {
|
"folderSettings": {
|
||||||
"activeLibrary": "ספרייה פעילה",
|
"activeLibrary": "ספרייה פעילה",
|
||||||
"activeLibraryHelp": "החלפה בין הספריות המוגדרות תעדכן את תיקיות ברירת המחדל. שינוי הבחירה ירענן את הדף.",
|
"activeLibraryHelp": "החלפה בין הספריות המוגדרות לעדכן את תיקיות ברירת המחדל. שינוי הבחירה ירענן את הדף.",
|
||||||
"loadingLibraries": "טוען ספריות...",
|
"loadingLibraries": "טוען ספריות...",
|
||||||
"noLibraries": "לא הוגדרו ספריות",
|
"noLibraries": "לא הוגדרו ספריות",
|
||||||
"defaultLoraRoot": "תיקיית שורש ברירת מחדל של LoRA",
|
"defaultLoraRoot": "תיקיית שורש ברירת מחדל של LoRA",
|
||||||
"defaultLoraRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של LoRA להורדות, ייבוא והעברות",
|
"defaultLoraRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של LoRA להורדות, ייבוא והעברות",
|
||||||
"defaultCheckpointRoot": "תיקיית שורש ברירת מחדל של Checkpoint",
|
"defaultCheckpointRoot": "תיקיית שורש ברירת מחדל של Checkpoint",
|
||||||
"defaultCheckpointRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של checkpoint להורדות, ייבוא והעברות",
|
"defaultCheckpointRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של checkpoint להורדות, ייבוא והעברות",
|
||||||
|
"defaultUnetRoot": "תיקיית שורש ברירת מחדל של Diffusion Model",
|
||||||
|
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
||||||
"defaultEmbeddingRoot": "תיקיית שורש ברירת מחדל של Embedding",
|
"defaultEmbeddingRoot": "תיקיית שורש ברירת מחדל של Embedding",
|
||||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||||
"noDefault": "אין ברירת מחדל"
|
"noDefault": "אין ברירת מחדל"
|
||||||
},
|
},
|
||||||
|
"priorityTags": {
|
||||||
|
"title": "תגיות עדיפות",
|
||||||
|
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
|
||||||
|
"placeholder": "character, concept, style(toon|toon_style)",
|
||||||
|
"helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות",
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"embedding": "Embedding"
|
||||||
|
},
|
||||||
|
"saveSuccess": "תגיות העדיפות עודכנו.",
|
||||||
|
"saveError": "עדכון תגיות העדיפות נכשל.",
|
||||||
|
"loadingSuggestions": "טוען הצעות...",
|
||||||
|
"validation": {
|
||||||
|
"missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.",
|
||||||
|
"missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.",
|
||||||
|
"duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.",
|
||||||
|
"unknown": "תצורת תגיות העדיפות שגויה."
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadPathTemplates": {
|
"downloadPathTemplates": {
|
||||||
"title": "תבניות נתיב הורדה",
|
"title": "תבניות נתיב הורדה",
|
||||||
"help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.",
|
"help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.",
|
||||||
@@ -320,8 +356,8 @@
|
|||||||
"byFirstTag": "לפי תגית ראשונה",
|
"byFirstTag": "לפי תגית ראשונה",
|
||||||
"baseModelFirstTag": "מודל בסיס + תגית ראשונה",
|
"baseModelFirstTag": "מודל בסיס + תגית ראשונה",
|
||||||
"baseModelAuthor": "מודל בסיס + יוצר",
|
"baseModelAuthor": "מודל בסיס + יוצר",
|
||||||
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
|
|
||||||
"authorFirstTag": "יוצר + תגית ראשונה",
|
"authorFirstTag": "יוצר + תגית ראשונה",
|
||||||
|
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
|
||||||
"customTemplate": "תבנית מותאמת אישית"
|
"customTemplate": "תבנית מותאמת אישית"
|
||||||
},
|
},
|
||||||
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
|
||||||
@@ -409,26 +445,6 @@
|
|||||||
"proxyPassword": "סיסמה (אופציונלי)",
|
"proxyPassword": "סיסמה (אופציונלי)",
|
||||||
"proxyPasswordPlaceholder": "password",
|
"proxyPasswordPlaceholder": "password",
|
||||||
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
|
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
|
||||||
},
|
|
||||||
"priorityTags": {
|
|
||||||
"title": "תגיות עדיפות",
|
|
||||||
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
|
|
||||||
"placeholder": "character, concept, style(toon|toon_style)",
|
|
||||||
"helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות",
|
|
||||||
"modelTypes": {
|
|
||||||
"lora": "LoRA",
|
|
||||||
"checkpoint": "Checkpoint",
|
|
||||||
"embedding": "Embedding"
|
|
||||||
},
|
|
||||||
"saveSuccess": "תגיות העדיפות עודכנו.",
|
|
||||||
"saveError": "עדכון תגיות העדיפות נכשל.",
|
|
||||||
"loadingSuggestions": "טוען הצעות...",
|
|
||||||
"validation": {
|
|
||||||
"missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.",
|
|
||||||
"missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.",
|
|
||||||
"duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.",
|
|
||||||
"unknown": "תצורת תגיות העדיפות שגויה."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"loras": {
|
"loras": {
|
||||||
@@ -443,7 +459,10 @@
|
|||||||
"dateAsc": "הישן ביותר",
|
"dateAsc": "הישן ביותר",
|
||||||
"size": "גודל קובץ",
|
"size": "גודל קובץ",
|
||||||
"sizeDesc": "הגדול ביותר",
|
"sizeDesc": "הגדול ביותר",
|
||||||
"sizeAsc": "הקטן ביותר"
|
"sizeAsc": "הקטן ביותר",
|
||||||
|
"usage": "מספר שימושים",
|
||||||
|
"usageDesc": "הכי הרבה",
|
||||||
|
"usageAsc": "הכי פחות"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מודלים",
|
"title": "רענן רשימת מודלים",
|
||||||
@@ -518,6 +537,7 @@
|
|||||||
"replacePreview": "החלף תצוגה מקדימה",
|
"replacePreview": "החלף תצוגה מקדימה",
|
||||||
"setContentRating": "הגדר דירוג תוכן",
|
"setContentRating": "הגדר דירוג תוכן",
|
||||||
"moveToFolder": "העבר לתיקייה",
|
"moveToFolder": "העבר לתיקייה",
|
||||||
|
"repairMetadata": "תיקון מטא-דאטה",
|
||||||
"excludeModel": "החרג מודל",
|
"excludeModel": "החרג מודל",
|
||||||
"deleteModel": "מחק מודל",
|
"deleteModel": "מחק מודל",
|
||||||
"shareRecipe": "שתף מתכון",
|
"shareRecipe": "שתף מתכון",
|
||||||
@@ -588,10 +608,26 @@
|
|||||||
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
|
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "מיון מתכונים לפי...",
|
||||||
|
"name": "שם",
|
||||||
|
"nameAsc": "א - ת",
|
||||||
|
"nameDesc": "ת - א",
|
||||||
|
"date": "תאריך",
|
||||||
|
"dateDesc": "הכי חדש",
|
||||||
|
"dateAsc": "הכי ישן",
|
||||||
|
"lorasCount": "מספר LoRAs",
|
||||||
|
"lorasCountDesc": "הכי הרבה",
|
||||||
|
"lorasCountAsc": "הכי פחות"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מתכונים"
|
"title": "רענן רשימת מתכונים"
|
||||||
},
|
},
|
||||||
"filteredByLora": "מסונן לפי LoRA"
|
"filteredByLora": "מסונן לפי LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "הצג מועדפים בלבד",
|
||||||
|
"action": "מועדפים"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "נמצאו {count} קבוצות כפולות",
|
"found": "נמצאו {count} קבוצות כפולות",
|
||||||
@@ -617,11 +653,25 @@
|
|||||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||||
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||||
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "מתקן מטא-דאטה של מתכון...",
|
||||||
|
"success": "מטא-דאטה של מתכון תוקן בהצלחה",
|
||||||
|
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
|
||||||
|
"failed": "תיקון המתכון נכשל: {message}",
|
||||||
|
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"title": "מודלי Checkpoint"
|
"title": "מודלי Checkpoint",
|
||||||
|
"modelTypes": {
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusion Model"
|
||||||
|
},
|
||||||
|
"contextMenu": {
|
||||||
|
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "מודלי Embedding"
|
"title": "מודלי Embedding"
|
||||||
@@ -638,7 +688,8 @@
|
|||||||
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
||||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה."
|
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -848,7 +899,9 @@
|
|||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
"success": "מיקום הקובץ נפתח בהצלחה",
|
"success": "מיקום הקובץ נפתח בהצלחה",
|
||||||
"failed": "פתיחת מיקום הקובץ נכשלה"
|
"failed": "פתיחת מיקום הקובץ נכשלה",
|
||||||
|
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
||||||
|
"clipboardFallback": "נתיב: {{path}}"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "גרסה",
|
"version": "גרסה",
|
||||||
@@ -871,11 +924,13 @@
|
|||||||
"addPresetParameter": "הוסף פרמטר קבוע מראש...",
|
"addPresetParameter": "הוסף פרמטר קבוע מראש...",
|
||||||
"strengthMin": "חוזק מינימלי",
|
"strengthMin": "חוזק מינימלי",
|
||||||
"strengthMax": "חוזק מקסימלי",
|
"strengthMax": "חוזק מקסימלי",
|
||||||
|
"strengthRange": "טווח עוצמה",
|
||||||
"strength": "חוזק",
|
"strength": "חוזק",
|
||||||
"clipStrength": "עוצמת CLIP",
|
"clipStrength": "עוצמת CLIP",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "ערך",
|
"valuePlaceholder": "ערך",
|
||||||
"add": "הוסף"
|
"add": "הוסף",
|
||||||
|
"invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "מילות טריגר",
|
"label": "מילות טריגר",
|
||||||
@@ -914,6 +969,13 @@
|
|||||||
"recipes": "מתכונים",
|
"recipes": "מתכונים",
|
||||||
"versions": "גרסאות"
|
"versions": "גרסאות"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"label": "ניווט מודלים",
|
||||||
|
"previousWithShortcut": "המודל הקודם (←)",
|
||||||
|
"nextWithShortcut": "המודל הבא (→)",
|
||||||
|
"noPrevious": "אין מודל קודם זמין",
|
||||||
|
"noNext": "אין מודל נוסף זמין"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"noImageSell": "No selling generated content",
|
"noImageSell": "No selling generated content",
|
||||||
"noRentCivit": "No Civitai generation",
|
"noRentCivit": "No Civitai generation",
|
||||||
@@ -1317,6 +1379,7 @@
|
|||||||
"verificationCompleteSuccess": "האימות הושלם. כל הקבצים אושרו ככפולים.",
|
"verificationCompleteSuccess": "האימות הושלם. כל הקבצים אושרו ככפולים.",
|
||||||
"verificationFailed": "אימות ה-hashes נכשל: {message}",
|
"verificationFailed": "אימות ה-hashes נכשל: {message}",
|
||||||
"noTagsToAdd": "אין תגיות להוספה",
|
"noTagsToAdd": "אין תגיות להוספה",
|
||||||
|
"bulkTagsUpdating": "מעדכן תגיות עבור {count} מודלים...",
|
||||||
"tagsAddedSuccessfully": "נוספו בהצלחה {tagCount} תגית(ות) ל-{count} {type}(ים)",
|
"tagsAddedSuccessfully": "נוספו בהצלחה {tagCount} תגית(ות) ל-{count} {type}(ים)",
|
||||||
"tagsReplacedSuccessfully": "הוחלפו בהצלחה תגיות עבור {count} {type}(ים) ב-{tagCount} תגית(ות)",
|
"tagsReplacedSuccessfully": "הוחלפו בהצלחה תגיות עבור {count} {type}(ים) ב-{tagCount} תגית(ות)",
|
||||||
"tagsAddFailed": "הוספת תגיות ל-{count} מודל(ים) נכשלה",
|
"tagsAddFailed": "הוספת תגיות ל-{count} מודל(ים) נכשלה",
|
||||||
@@ -1330,6 +1393,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"loraRootsFailed": "טעינת שורשי LoRA נכשלה: {message}",
|
"loraRootsFailed": "טעינת שורשי LoRA נכשלה: {message}",
|
||||||
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
|
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
|
||||||
|
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
|
||||||
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
|
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
|
||||||
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count} מיפוי{plural})",
|
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count} מיפוי{plural})",
|
||||||
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
|
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
|
||||||
@@ -1437,6 +1501,8 @@
|
|||||||
"metadataRefreshed": "המטא-דאטה רועננה בהצלחה",
|
"metadataRefreshed": "המטא-דאטה רועננה בהצלחה",
|
||||||
"metadataRefreshFailed": "רענון המטא-דאטה נכשל: {message}",
|
"metadataRefreshFailed": "רענון המטא-דאטה נכשל: {message}",
|
||||||
"metadataUpdateComplete": "עדכון המטא-דאטה הושלם",
|
"metadataUpdateComplete": "עדכון המטא-דאטה הושלם",
|
||||||
|
"operationCancelled": "הפעולה בוטלה על ידי המשתמש",
|
||||||
|
"operationCancelledPartial": "הפעולה בוטלה. {success} פריטים עובדו.",
|
||||||
"metadataFetchFailed": "אחזור המטא-דאטה נכשל: {message}",
|
"metadataFetchFailed": "אחזור המטא-דאטה נכשל: {message}",
|
||||||
"bulkMetadataCompleteAll": "רועננו בהצלחה כל {count} ה-{type}s",
|
"bulkMetadataCompleteAll": "רועננו בהצלחה כל {count} ה-{type}s",
|
||||||
"bulkMetadataCompletePartial": "רועננו {success} מתוך {total} {type}s",
|
"bulkMetadataCompletePartial": "רועננו {success} מתוך {total} {type}s",
|
||||||
@@ -1453,7 +1519,8 @@
|
|||||||
"bulkMoveFailures": "העברות שנכשלו:\n{failures}",
|
"bulkMoveFailures": "העברות שנכשלו:\n{failures}",
|
||||||
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
||||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}"
|
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -131,6 +131,9 @@
|
|||||||
"badges": {
|
"badges": {
|
||||||
"update": "アップデート",
|
"update": "アップデート",
|
||||||
"updateAvailable": "アップデートがあります"
|
"updateAvailable": "アップデートがあります"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"timesUsed": "使用回数"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -159,6 +162,13 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "レシピデータの修復",
|
||||||
|
"loading": "レシピデータを修復中...",
|
||||||
|
"success": "{count} 件のレシピを正常に修復しました。",
|
||||||
|
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
||||||
|
"error": "レシピの修復に失敗しました: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +198,8 @@
|
|||||||
"creator": "作成者",
|
"creator": "作成者",
|
||||||
"title": "レシピタイトル",
|
"title": "レシピタイトル",
|
||||||
"loraName": "LoRAファイル名",
|
"loraName": "LoRAファイル名",
|
||||||
"loraModel": "LoRAモデル名"
|
"loraModel": "LoRAモデル名",
|
||||||
|
"prompt": "プロンプト"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +210,7 @@
|
|||||||
"license": "ライセンス",
|
"license": "ライセンス",
|
||||||
"noCreditRequired": "クレジット不要",
|
"noCreditRequired": "クレジット不要",
|
||||||
"allowSellingGeneratedContent": "販売許可",
|
"allowSellingGeneratedContent": "販売許可",
|
||||||
|
"noTags": "タグなし",
|
||||||
"clearAll": "すべてのフィルタをクリア"
|
"clearAll": "すべてのフィルタをクリア"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -221,7 +233,9 @@
|
|||||||
"label": "設定フォルダーを開く",
|
"label": "設定フォルダーを開く",
|
||||||
"tooltip": "settings.json を含むフォルダーを開きます",
|
"tooltip": "settings.json を含むフォルダーを開きます",
|
||||||
"success": "settings.json フォルダーを開きました",
|
"success": "settings.json フォルダーを開きました",
|
||||||
"failed": "settings.json フォルダーを開けませんでした"
|
"failed": "settings.json フォルダーを開けませんでした",
|
||||||
|
"copied": "設定パスをクリップボードにコピーしました: {{path}}",
|
||||||
|
"clipboardFallback": "設定パス: {{path}}"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "コンテンツフィルタリング",
|
"contentFiltering": "コンテンツフィルタリング",
|
||||||
@@ -305,6 +319,8 @@
|
|||||||
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
|
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
|
||||||
"defaultCheckpointRoot": "デフォルトCheckpointルート",
|
"defaultCheckpointRoot": "デフォルトCheckpointルート",
|
||||||
"defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトcheckpointルートディレクトリを設定",
|
"defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトcheckpointルートディレクトリを設定",
|
||||||
|
"defaultUnetRoot": "デフォルトDiffusion Modelルート",
|
||||||
|
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
||||||
"defaultEmbeddingRoot": "デフォルトEmbeddingルート",
|
"defaultEmbeddingRoot": "デフォルトEmbeddingルート",
|
||||||
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
||||||
"noDefault": "デフォルトなし"
|
"noDefault": "デフォルトなし"
|
||||||
@@ -443,7 +459,10 @@
|
|||||||
"dateAsc": "古い順",
|
"dateAsc": "古い順",
|
||||||
"size": "ファイルサイズ",
|
"size": "ファイルサイズ",
|
||||||
"sizeDesc": "大きい順",
|
"sizeDesc": "大きい順",
|
||||||
"sizeAsc": "小さい順"
|
"sizeAsc": "小さい順",
|
||||||
|
"usage": "使用回数",
|
||||||
|
"usageDesc": "多い",
|
||||||
|
"usageAsc": "少ない"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "モデルリストを更新",
|
"title": "モデルリストを更新",
|
||||||
@@ -518,6 +537,7 @@
|
|||||||
"replacePreview": "プレビューを置換",
|
"replacePreview": "プレビューを置換",
|
||||||
"setContentRating": "コンテンツレーティングを設定",
|
"setContentRating": "コンテンツレーティングを設定",
|
||||||
"moveToFolder": "フォルダに移動",
|
"moveToFolder": "フォルダに移動",
|
||||||
|
"repairMetadata": "メタデータを修復",
|
||||||
"excludeModel": "モデルを除外",
|
"excludeModel": "モデルを除外",
|
||||||
"deleteModel": "モデルを削除",
|
"deleteModel": "モデルを削除",
|
||||||
"shareRecipe": "レシピを共有",
|
"shareRecipe": "レシピを共有",
|
||||||
@@ -588,10 +608,26 @@
|
|||||||
"selectLoraRoot": "LoRAルートディレクトリを選択してください"
|
"selectLoraRoot": "LoRAルートディレクトリを選択してください"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "レシピの並び替え...",
|
||||||
|
"name": "名前",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "日付",
|
||||||
|
"dateDesc": "新しい順",
|
||||||
|
"dateAsc": "古い順",
|
||||||
|
"lorasCount": "LoRA数",
|
||||||
|
"lorasCountDesc": "多い順",
|
||||||
|
"lorasCountAsc": "少ない順"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "レシピリストを更新"
|
"title": "レシピリストを更新"
|
||||||
},
|
},
|
||||||
"filteredByLora": "LoRAでフィルタ済み"
|
"filteredByLora": "LoRAでフィルタ済み",
|
||||||
|
"favorites": {
|
||||||
|
"title": "お気に入りのみ表示",
|
||||||
|
"action": "お気に入り"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count} 個の重複グループが見つかりました",
|
"found": "{count} 個の重複グループが見つかりました",
|
||||||
@@ -617,11 +653,25 @@
|
|||||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||||
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
|
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||||
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
|
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "レシピのメタデータを修復中...",
|
||||||
|
"success": "レシピのメタデータが正常に修復されました",
|
||||||
|
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
|
||||||
|
"failed": "レシピの修復に失敗しました: {message}",
|
||||||
|
"missingId": "レシピを修復できません: レシピIDがありません"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"title": "Checkpointモデル"
|
"title": "Checkpointモデル",
|
||||||
|
"modelTypes": {
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusion Model"
|
||||||
|
},
|
||||||
|
"contextMenu": {
|
||||||
|
"moveToOtherTypeFolder": "{otherType} フォルダに移動"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embeddingモデル"
|
"title": "Embeddingモデル"
|
||||||
@@ -638,7 +688,8 @@
|
|||||||
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
||||||
"collapseAllDisabled": "リストビューでは利用できません",
|
"collapseAllDisabled": "リストビューでは利用できません",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "移動先のパスを特定できません。"
|
"unableToResolveRoot": "移動先のパスを特定できません。",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -848,7 +899,9 @@
|
|||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
"success": "ファイルの場所を正常に開きました",
|
"success": "ファイルの場所を正常に開きました",
|
||||||
"failed": "ファイルの場所を開くのに失敗しました"
|
"failed": "ファイルの場所を開くのに失敗しました",
|
||||||
|
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||||
|
"clipboardFallback": "パス: {{path}}"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "バージョン",
|
"version": "バージョン",
|
||||||
@@ -871,11 +924,13 @@
|
|||||||
"addPresetParameter": "プリセットパラメータを追加...",
|
"addPresetParameter": "プリセットパラメータを追加...",
|
||||||
"strengthMin": "強度最小",
|
"strengthMin": "強度最小",
|
||||||
"strengthMax": "強度最大",
|
"strengthMax": "強度最大",
|
||||||
|
"strengthRange": "強度範囲",
|
||||||
"strength": "強度",
|
"strength": "強度",
|
||||||
"clipStrength": "クリップ強度",
|
"clipStrength": "クリップ強度",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "値",
|
"valuePlaceholder": "値",
|
||||||
"add": "追加"
|
"add": "追加",
|
||||||
|
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "トリガーワード",
|
"label": "トリガーワード",
|
||||||
@@ -914,6 +969,13 @@
|
|||||||
"recipes": "レシピ",
|
"recipes": "レシピ",
|
||||||
"versions": "バージョン"
|
"versions": "バージョン"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"label": "モデルナビゲーション",
|
||||||
|
"previousWithShortcut": "前のモデル(←)",
|
||||||
|
"nextWithShortcut": "次のモデル(→)",
|
||||||
|
"noPrevious": "前のモデルがありません",
|
||||||
|
"noNext": "次のモデルがありません"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"noImageSell": "No selling generated content",
|
"noImageSell": "No selling generated content",
|
||||||
"noRentCivit": "No Civitai generation",
|
"noRentCivit": "No Civitai generation",
|
||||||
@@ -1317,6 +1379,7 @@
|
|||||||
"verificationCompleteSuccess": "検証完了。すべてのファイルが重複であることが確認されました。",
|
"verificationCompleteSuccess": "検証完了。すべてのファイルが重複であることが確認されました。",
|
||||||
"verificationFailed": "ハッシュの検証に失敗しました:{message}",
|
"verificationFailed": "ハッシュの検証に失敗しました:{message}",
|
||||||
"noTagsToAdd": "追加するタグがありません",
|
"noTagsToAdd": "追加するタグがありません",
|
||||||
|
"bulkTagsUpdating": "{count} 個のモデルのタグを更新しています...",
|
||||||
"tagsAddedSuccessfully": "{count} {type} に {tagCount} 個のタグを追加しました",
|
"tagsAddedSuccessfully": "{count} {type} に {tagCount} 個のタグを追加しました",
|
||||||
"tagsReplacedSuccessfully": "{count} {type} のタグを {tagCount} 個に置換しました",
|
"tagsReplacedSuccessfully": "{count} {type} のタグを {tagCount} 個に置換しました",
|
||||||
"tagsAddFailed": "{count} モデルへのタグ追加に失敗しました",
|
"tagsAddFailed": "{count} モデルへのタグ追加に失敗しました",
|
||||||
@@ -1330,6 +1393,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"loraRootsFailed": "LoRAルートの読み込みに失敗しました:{message}",
|
"loraRootsFailed": "LoRAルートの読み込みに失敗しました:{message}",
|
||||||
"checkpointRootsFailed": "checkpointルートの読み込みに失敗しました:{message}",
|
"checkpointRootsFailed": "checkpointルートの読み込みに失敗しました:{message}",
|
||||||
|
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
|
||||||
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
|
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
|
||||||
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング{plural})",
|
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング{plural})",
|
||||||
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
|
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
|
||||||
@@ -1437,6 +1501,8 @@
|
|||||||
"metadataRefreshed": "メタデータが正常に更新されました",
|
"metadataRefreshed": "メタデータが正常に更新されました",
|
||||||
"metadataRefreshFailed": "メタデータの更新に失敗しました:{message}",
|
"metadataRefreshFailed": "メタデータの更新に失敗しました:{message}",
|
||||||
"metadataUpdateComplete": "メタデータ更新完了",
|
"metadataUpdateComplete": "メタデータ更新完了",
|
||||||
|
"operationCancelled": "ユーザーによって操作がキャンセルされました",
|
||||||
|
"operationCancelledPartial": "操作がキャンセルされました。{success} 個の項目が処理されました。",
|
||||||
"metadataFetchFailed": "メタデータの取得に失敗しました:{message}",
|
"metadataFetchFailed": "メタデータの取得に失敗しました:{message}",
|
||||||
"bulkMetadataCompleteAll": "{count} {type}すべてが正常に更新されました",
|
"bulkMetadataCompleteAll": "{count} {type}すべてが正常に更新されました",
|
||||||
"bulkMetadataCompletePartial": "{total} {type}のうち {success} が更新されました",
|
"bulkMetadataCompletePartial": "{total} {type}のうち {success} が更新されました",
|
||||||
@@ -1453,7 +1519,8 @@
|
|||||||
"bulkMoveFailures": "失敗した移動:\n{failures}",
|
"bulkMoveFailures": "失敗した移動:\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
||||||
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
||||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}"
|
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -131,6 +131,9 @@
|
|||||||
"badges": {
|
"badges": {
|
||||||
"update": "업데이트",
|
"update": "업데이트",
|
||||||
"updateAvailable": "업데이트 가능"
|
"updateAvailable": "업데이트 가능"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"timesUsed": "사용 횟수"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -159,6 +162,13 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "레시피 데이터 복구",
|
||||||
|
"loading": "레시피 데이터 복구 중...",
|
||||||
|
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
||||||
|
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
||||||
|
"error": "레시피 복구 실패: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +198,8 @@
|
|||||||
"creator": "제작자",
|
"creator": "제작자",
|
||||||
"title": "레시피 제목",
|
"title": "레시피 제목",
|
||||||
"loraName": "LoRA 파일명",
|
"loraName": "LoRA 파일명",
|
||||||
"loraModel": "LoRA 모델명"
|
"loraModel": "LoRA 모델명",
|
||||||
|
"prompt": "프롬프트"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +210,7 @@
|
|||||||
"license": "라이선스",
|
"license": "라이선스",
|
||||||
"noCreditRequired": "크레딧 표기 없음",
|
"noCreditRequired": "크레딧 표기 없음",
|
||||||
"allowSellingGeneratedContent": "판매 허용",
|
"allowSellingGeneratedContent": "판매 허용",
|
||||||
|
"noTags": "태그 없음",
|
||||||
"clearAll": "모든 필터 지우기"
|
"clearAll": "모든 필터 지우기"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -221,7 +233,9 @@
|
|||||||
"label": "설정 폴더 열기",
|
"label": "설정 폴더 열기",
|
||||||
"tooltip": "settings.json이 있는 폴더를 엽니다",
|
"tooltip": "settings.json이 있는 폴더를 엽니다",
|
||||||
"success": "settings.json 폴더를 열었습니다",
|
"success": "settings.json 폴더를 열었습니다",
|
||||||
"failed": "settings.json 폴더를 열지 못했습니다"
|
"failed": "settings.json 폴더를 열지 못했습니다",
|
||||||
|
"copied": "설정 경로가 클립보드에 복사되었습니다: {{path}}",
|
||||||
|
"clipboardFallback": "설정 경로: {{path}}"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "콘텐츠 필터링",
|
"contentFiltering": "콘텐츠 필터링",
|
||||||
@@ -305,6 +319,8 @@
|
|||||||
"defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다",
|
"defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다",
|
||||||
"defaultCheckpointRoot": "기본 Checkpoint 루트",
|
"defaultCheckpointRoot": "기본 Checkpoint 루트",
|
||||||
"defaultCheckpointRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Checkpoint 루트 디렉토리를 설정합니다",
|
"defaultCheckpointRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Checkpoint 루트 디렉토리를 설정합니다",
|
||||||
|
"defaultUnetRoot": "기본 Diffusion Model 루트",
|
||||||
|
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
||||||
"defaultEmbeddingRoot": "기본 Embedding 루트",
|
"defaultEmbeddingRoot": "기본 Embedding 루트",
|
||||||
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
||||||
"noDefault": "기본값 없음"
|
"noDefault": "기본값 없음"
|
||||||
@@ -443,7 +459,10 @@
|
|||||||
"dateAsc": "오래된순",
|
"dateAsc": "오래된순",
|
||||||
"size": "파일 크기",
|
"size": "파일 크기",
|
||||||
"sizeDesc": "큰 순서",
|
"sizeDesc": "큰 순서",
|
||||||
"sizeAsc": "작은 순서"
|
"sizeAsc": "작은 순서",
|
||||||
|
"usage": "사용 횟수",
|
||||||
|
"usageDesc": "많은 순",
|
||||||
|
"usageAsc": "적은 순"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "모델 목록 새로고침",
|
"title": "모델 목록 새로고침",
|
||||||
@@ -518,6 +537,7 @@
|
|||||||
"replacePreview": "미리보기 교체",
|
"replacePreview": "미리보기 교체",
|
||||||
"setContentRating": "콘텐츠 등급 설정",
|
"setContentRating": "콘텐츠 등급 설정",
|
||||||
"moveToFolder": "폴더로 이동",
|
"moveToFolder": "폴더로 이동",
|
||||||
|
"repairMetadata": "메타데이터 복구",
|
||||||
"excludeModel": "모델 제외",
|
"excludeModel": "모델 제외",
|
||||||
"deleteModel": "모델 삭제",
|
"deleteModel": "모델 삭제",
|
||||||
"shareRecipe": "레시피 공유",
|
"shareRecipe": "레시피 공유",
|
||||||
@@ -588,10 +608,26 @@
|
|||||||
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
|
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "레시피 정렬...",
|
||||||
|
"name": "이름",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "날짜",
|
||||||
|
"dateDesc": "최신순",
|
||||||
|
"dateAsc": "오래된순",
|
||||||
|
"lorasCount": "LoRA 수",
|
||||||
|
"lorasCountDesc": "많은순",
|
||||||
|
"lorasCountAsc": "적은순"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "레시피 목록 새로고침"
|
"title": "레시피 목록 새로고침"
|
||||||
},
|
},
|
||||||
"filteredByLora": "LoRA로 필터링됨"
|
"filteredByLora": "LoRA로 필터링됨",
|
||||||
|
"favorites": {
|
||||||
|
"title": "즐겨찾기만 표시",
|
||||||
|
"action": "즐겨찾기"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count}개의 중복 그룹 발견",
|
"found": "{count}개의 중복 그룹 발견",
|
||||||
@@ -617,11 +653,25 @@
|
|||||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||||
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||||
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
|
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "레시피 메타데이터 복구 중...",
|
||||||
|
"success": "레시피 메타데이터가 성공적으로 복구되었습니다",
|
||||||
|
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
|
||||||
|
"failed": "레시피 복구 실패: {message}",
|
||||||
|
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"title": "Checkpoint 모델"
|
"title": "Checkpoint 모델",
|
||||||
|
"modelTypes": {
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusion Model"
|
||||||
|
},
|
||||||
|
"contextMenu": {
|
||||||
|
"moveToOtherTypeFolder": "{otherType} 폴더로 이동"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 모델"
|
"title": "Embedding 모델"
|
||||||
@@ -638,7 +688,8 @@
|
|||||||
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
||||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다."
|
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -848,7 +899,9 @@
|
|||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
"success": "파일 위치가 성공적으로 열렸습니다",
|
"success": "파일 위치가 성공적으로 열렸습니다",
|
||||||
"failed": "파일 위치 열기에 실패했습니다"
|
"failed": "파일 위치 열기에 실패했습니다",
|
||||||
|
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||||
|
"clipboardFallback": "경로: {{path}}"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "버전",
|
"version": "버전",
|
||||||
@@ -871,11 +924,13 @@
|
|||||||
"addPresetParameter": "프리셋 매개변수 추가...",
|
"addPresetParameter": "프리셋 매개변수 추가...",
|
||||||
"strengthMin": "최소 강도",
|
"strengthMin": "최소 강도",
|
||||||
"strengthMax": "최대 강도",
|
"strengthMax": "최대 강도",
|
||||||
|
"strengthRange": "강도 범위",
|
||||||
"strength": "강도",
|
"strength": "강도",
|
||||||
"clipStrength": "클립 강도",
|
"clipStrength": "클립 강도",
|
||||||
"clipSkip": "클립 스킵",
|
"clipSkip": "클립 스킵",
|
||||||
"valuePlaceholder": "값",
|
"valuePlaceholder": "값",
|
||||||
"add": "추가"
|
"add": "추가",
|
||||||
|
"invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "트리거 단어",
|
"label": "트리거 단어",
|
||||||
@@ -914,6 +969,13 @@
|
|||||||
"recipes": "레시피",
|
"recipes": "레시피",
|
||||||
"versions": "버전"
|
"versions": "버전"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"label": "모델 탐색",
|
||||||
|
"previousWithShortcut": "이전 모델(←)",
|
||||||
|
"nextWithShortcut": "다음 모델(→)",
|
||||||
|
"noPrevious": "이전 모델이 없습니다",
|
||||||
|
"noNext": "다음 모델이 없습니다"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"noImageSell": "No selling generated content",
|
"noImageSell": "No selling generated content",
|
||||||
"noRentCivit": "No Civitai generation",
|
"noRentCivit": "No Civitai generation",
|
||||||
@@ -1317,6 +1379,7 @@
|
|||||||
"verificationCompleteSuccess": "검증 완료. 모든 파일이 중복임을 확인했습니다.",
|
"verificationCompleteSuccess": "검증 완료. 모든 파일이 중복임을 확인했습니다.",
|
||||||
"verificationFailed": "해시 검증 실패: {message}",
|
"verificationFailed": "해시 검증 실패: {message}",
|
||||||
"noTagsToAdd": "추가할 태그가 없습니다",
|
"noTagsToAdd": "추가할 태그가 없습니다",
|
||||||
|
"bulkTagsUpdating": "{count}개 모델의 태그를 업데이트 중입니다...",
|
||||||
"tagsAddedSuccessfully": "{count}개의 {type}에 {tagCount}개의 태그가 성공적으로 추가되었습니다",
|
"tagsAddedSuccessfully": "{count}개의 {type}에 {tagCount}개의 태그가 성공적으로 추가되었습니다",
|
||||||
"tagsReplacedSuccessfully": "{count}개의 {type}의 태그가 {tagCount}개의 태그로 성공적으로 교체되었습니다",
|
"tagsReplacedSuccessfully": "{count}개의 {type}의 태그가 {tagCount}개의 태그로 성공적으로 교체되었습니다",
|
||||||
"tagsAddFailed": "{count}개의 모델에 태그 추가에 실패했습니다",
|
"tagsAddFailed": "{count}개의 모델에 태그 추가에 실패했습니다",
|
||||||
@@ -1330,6 +1393,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"loraRootsFailed": "LoRA 루트 로딩 실패: {message}",
|
"loraRootsFailed": "LoRA 루트 로딩 실패: {message}",
|
||||||
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
|
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
|
||||||
|
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
|
||||||
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
|
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
|
||||||
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
|
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
|
||||||
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
|
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
|
||||||
@@ -1437,6 +1501,8 @@
|
|||||||
"metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다",
|
"metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다",
|
||||||
"metadataRefreshFailed": "메타데이터 새로고침 실패: {message}",
|
"metadataRefreshFailed": "메타데이터 새로고침 실패: {message}",
|
||||||
"metadataUpdateComplete": "메타데이터 업데이트 완료",
|
"metadataUpdateComplete": "메타데이터 업데이트 완료",
|
||||||
|
"operationCancelled": "사용자에 의해 작업이 취소되었습니다",
|
||||||
|
"operationCancelledPartial": "작업이 취소되었습니다. {success}개 항목이 처리되었습니다.",
|
||||||
"metadataFetchFailed": "메타데이터 가져오기 실패: {message}",
|
"metadataFetchFailed": "메타데이터 가져오기 실패: {message}",
|
||||||
"bulkMetadataCompleteAll": "모든 {count}개 {type}이(가) 성공적으로 새로고침되었습니다",
|
"bulkMetadataCompleteAll": "모든 {count}개 {type}이(가) 성공적으로 새로고침되었습니다",
|
||||||
"bulkMetadataCompletePartial": "{total}개 중 {success}개 {type}이(가) 새로고침되었습니다",
|
"bulkMetadataCompletePartial": "{total}개 중 {success}개 {type}이(가) 새로고침되었습니다",
|
||||||
@@ -1453,7 +1519,8 @@
|
|||||||
"bulkMoveFailures": "실패한 이동:\n{failures}",
|
"bulkMoveFailures": "실패한 이동:\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
||||||
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
||||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}"
|
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -131,6 +131,9 @@
|
|||||||
"badges": {
|
"badges": {
|
||||||
"update": "Обновление",
|
"update": "Обновление",
|
||||||
"updateAvailable": "Доступно обновление"
|
"updateAvailable": "Доступно обновление"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"timesUsed": "Количество использований"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -159,6 +162,13 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Восстановить данные рецептов",
|
||||||
|
"loading": "Восстановление данных рецептов...",
|
||||||
|
"success": "Успешно восстановлено {count} рецептов.",
|
||||||
|
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
||||||
|
"error": "Ошибка восстановления рецептов: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +198,8 @@
|
|||||||
"creator": "Автор",
|
"creator": "Автор",
|
||||||
"title": "Название рецепта",
|
"title": "Название рецепта",
|
||||||
"loraName": "Имя файла LoRA",
|
"loraName": "Имя файла LoRA",
|
||||||
"loraModel": "Название модели LoRA"
|
"loraModel": "Название модели LoRA",
|
||||||
|
"prompt": "Запрос"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +210,7 @@
|
|||||||
"license": "Лицензия",
|
"license": "Лицензия",
|
||||||
"noCreditRequired": "Без указания авторства",
|
"noCreditRequired": "Без указания авторства",
|
||||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||||
|
"noTags": "Без тегов",
|
||||||
"clearAll": "Очистить все фильтры"
|
"clearAll": "Очистить все фильтры"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -221,7 +233,9 @@
|
|||||||
"label": "Открыть папку настроек",
|
"label": "Открыть папку настроек",
|
||||||
"tooltip": "Открыть папку, содержащую settings.json",
|
"tooltip": "Открыть папку, содержащую settings.json",
|
||||||
"success": "Папка settings.json открыта",
|
"success": "Папка settings.json открыта",
|
||||||
"failed": "Не удалось открыть папку settings.json"
|
"failed": "Не удалось открыть папку settings.json",
|
||||||
|
"copied": "Путь настроек скопирован в буфер обмена: {{path}}",
|
||||||
|
"clipboardFallback": "Путь настроек: {{path}}"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "Фильтрация контента",
|
"contentFiltering": "Фильтрация контента",
|
||||||
@@ -305,6 +319,8 @@
|
|||||||
"defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений",
|
"defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений",
|
||||||
"defaultCheckpointRoot": "Корневая папка Checkpoint по умолчанию",
|
"defaultCheckpointRoot": "Корневая папка Checkpoint по умолчанию",
|
||||||
"defaultCheckpointRootHelp": "Установить корневую папку checkpoint по умолчанию для загрузок, импорта и перемещений",
|
"defaultCheckpointRootHelp": "Установить корневую папку checkpoint по умолчанию для загрузок, импорта и перемещений",
|
||||||
|
"defaultUnetRoot": "Корневая папка Diffusion Model по умолчанию",
|
||||||
|
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
||||||
"defaultEmbeddingRoot": "Корневая папка Embedding по умолчанию",
|
"defaultEmbeddingRoot": "Корневая папка Embedding по умолчанию",
|
||||||
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
||||||
"noDefault": "Не задано"
|
"noDefault": "Не задано"
|
||||||
@@ -443,7 +459,10 @@
|
|||||||
"dateAsc": "Старейшим",
|
"dateAsc": "Старейшим",
|
||||||
"size": "Размеру файла",
|
"size": "Размеру файла",
|
||||||
"sizeDesc": "Наибольшим",
|
"sizeDesc": "Наибольшим",
|
||||||
"sizeAsc": "Наименьшим"
|
"sizeAsc": "Наименьшим",
|
||||||
|
"usage": "Число использований",
|
||||||
|
"usageDesc": "Больше",
|
||||||
|
"usageAsc": "Меньше"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список моделей",
|
"title": "Обновить список моделей",
|
||||||
@@ -518,6 +537,7 @@
|
|||||||
"replacePreview": "Заменить превью",
|
"replacePreview": "Заменить превью",
|
||||||
"setContentRating": "Установить рейтинг контента",
|
"setContentRating": "Установить рейтинг контента",
|
||||||
"moveToFolder": "Переместить в папку",
|
"moveToFolder": "Переместить в папку",
|
||||||
|
"repairMetadata": "Восстановить метаданные",
|
||||||
"excludeModel": "Исключить модель",
|
"excludeModel": "Исключить модель",
|
||||||
"deleteModel": "Удалить модель",
|
"deleteModel": "Удалить модель",
|
||||||
"shareRecipe": "Поделиться рецептом",
|
"shareRecipe": "Поделиться рецептом",
|
||||||
@@ -588,10 +608,26 @@
|
|||||||
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
|
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Сортировка рецептов...",
|
||||||
|
"name": "Имя",
|
||||||
|
"nameAsc": "А - Я",
|
||||||
|
"nameDesc": "Я - А",
|
||||||
|
"date": "Дата",
|
||||||
|
"dateDesc": "Сначала новые",
|
||||||
|
"dateAsc": "Сначала старые",
|
||||||
|
"lorasCount": "Кол-во LoRA",
|
||||||
|
"lorasCountDesc": "Больше всего",
|
||||||
|
"lorasCountAsc": "Меньше всего"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список рецептов"
|
"title": "Обновить список рецептов"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Фильтр по LoRA"
|
"filteredByLora": "Фильтр по LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Только избранные",
|
||||||
|
"action": "Избранное"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Найдено {count} групп дубликатов",
|
"found": "Найдено {count} групп дубликатов",
|
||||||
@@ -617,11 +653,25 @@
|
|||||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||||
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||||
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
|
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "Восстановление метаданных рецепта...",
|
||||||
|
"success": "Метаданные рецепта успешно восстановлены",
|
||||||
|
"skipped": "Рецепт уже последней версии, восстановление не требуется",
|
||||||
|
"failed": "Не удалось восстановить рецепт: {message}",
|
||||||
|
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"title": "Модели Checkpoint"
|
"title": "Модели Checkpoint",
|
||||||
|
"modelTypes": {
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusion Model"
|
||||||
|
},
|
||||||
|
"contextMenu": {
|
||||||
|
"moveToOtherTypeFolder": "Переместить в папку {otherType}"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Модели Embedding"
|
"title": "Модели Embedding"
|
||||||
@@ -638,7 +688,8 @@
|
|||||||
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
||||||
"collapseAllDisabled": "Недоступно в виде списка",
|
"collapseAllDisabled": "Недоступно в виде списка",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения."
|
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -848,7 +899,9 @@
|
|||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
"success": "Расположение файла успешно открыто",
|
"success": "Расположение файла успешно открыто",
|
||||||
"failed": "Не удалось открыть расположение файла"
|
"failed": "Не удалось открыть расположение файла",
|
||||||
|
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
||||||
|
"clipboardFallback": "Путь: {{path}}"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "Версия",
|
"version": "Версия",
|
||||||
@@ -871,11 +924,13 @@
|
|||||||
"addPresetParameter": "Добавить предустановленный параметр...",
|
"addPresetParameter": "Добавить предустановленный параметр...",
|
||||||
"strengthMin": "Мин. сила",
|
"strengthMin": "Мин. сила",
|
||||||
"strengthMax": "Макс. сила",
|
"strengthMax": "Макс. сила",
|
||||||
|
"strengthRange": "Диапазон силы",
|
||||||
"strength": "Сила",
|
"strength": "Сила",
|
||||||
"clipStrength": "Сила клипа",
|
"clipStrength": "Сила клипа",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Значение",
|
"valuePlaceholder": "Значение",
|
||||||
"add": "Добавить"
|
"add": "Добавить",
|
||||||
|
"invalidRange": "Неверный формат диапазона. Используйте x.x-y.y"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "Триггерные слова",
|
"label": "Триггерные слова",
|
||||||
@@ -914,6 +969,13 @@
|
|||||||
"recipes": "Рецепты",
|
"recipes": "Рецепты",
|
||||||
"versions": "Версии"
|
"versions": "Версии"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"label": "Навигация по моделям",
|
||||||
|
"previousWithShortcut": "Предыдущая модель (←)",
|
||||||
|
"nextWithShortcut": "Следующая модель (→)",
|
||||||
|
"noPrevious": "Предыдущая модель отсутствует",
|
||||||
|
"noNext": "Следующая модель отсутствует"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"noImageSell": "No selling generated content",
|
"noImageSell": "No selling generated content",
|
||||||
"noRentCivit": "No Civitai generation",
|
"noRentCivit": "No Civitai generation",
|
||||||
@@ -1317,6 +1379,7 @@
|
|||||||
"verificationCompleteSuccess": "Проверка завершена. Все файлы подтверждены как дубликаты.",
|
"verificationCompleteSuccess": "Проверка завершена. Все файлы подтверждены как дубликаты.",
|
||||||
"verificationFailed": "Не удалось проверить хеши: {message}",
|
"verificationFailed": "Не удалось проверить хеши: {message}",
|
||||||
"noTagsToAdd": "Нет тегов для добавления",
|
"noTagsToAdd": "Нет тегов для добавления",
|
||||||
|
"bulkTagsUpdating": "Обновление тегов для {count} модел(ей)...",
|
||||||
"tagsAddedSuccessfully": "Успешно добавлено {tagCount} тег(ов) к {count} {type}(ам)",
|
"tagsAddedSuccessfully": "Успешно добавлено {tagCount} тег(ов) к {count} {type}(ам)",
|
||||||
"tagsReplacedSuccessfully": "Успешно заменены теги для {count} {type}(ов) на {tagCount} тег(ов)",
|
"tagsReplacedSuccessfully": "Успешно заменены теги для {count} {type}(ов) на {tagCount} тег(ов)",
|
||||||
"tagsAddFailed": "Не удалось добавить теги к {count} модель(ям)",
|
"tagsAddFailed": "Не удалось добавить теги к {count} модель(ям)",
|
||||||
@@ -1330,6 +1393,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"loraRootsFailed": "Не удалось загрузить корни LoRA: {message}",
|
"loraRootsFailed": "Не удалось загрузить корни LoRA: {message}",
|
||||||
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
|
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
|
||||||
|
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
|
||||||
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
|
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
|
||||||
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count} сопоставлени{plural})",
|
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count} сопоставлени{plural})",
|
||||||
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
|
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
|
||||||
@@ -1437,6 +1501,8 @@
|
|||||||
"metadataRefreshed": "Метаданные успешно обновлены",
|
"metadataRefreshed": "Метаданные успешно обновлены",
|
||||||
"metadataRefreshFailed": "Не удалось обновить метаданные: {message}",
|
"metadataRefreshFailed": "Не удалось обновить метаданные: {message}",
|
||||||
"metadataUpdateComplete": "Обновление метаданных завершено",
|
"metadataUpdateComplete": "Обновление метаданных завершено",
|
||||||
|
"operationCancelled": "Операция отменена пользователем",
|
||||||
|
"operationCancelledPartial": "Операция отменена. Обработано {success} элементов.",
|
||||||
"metadataFetchFailed": "Не удалось получить метаданные: {message}",
|
"metadataFetchFailed": "Не удалось получить метаданные: {message}",
|
||||||
"bulkMetadataCompleteAll": "Успешно обновлены все {count} {type}s",
|
"bulkMetadataCompleteAll": "Успешно обновлены все {count} {type}s",
|
||||||
"bulkMetadataCompletePartial": "Обновлено {success} из {total} {type}s",
|
"bulkMetadataCompletePartial": "Обновлено {success} из {total} {type}s",
|
||||||
@@ -1453,7 +1519,8 @@
|
|||||||
"bulkMoveFailures": "Неудачные перемещения:\n{failures}",
|
"bulkMoveFailures": "Неудачные перемещения:\n{failures}",
|
||||||
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
||||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}"
|
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -131,6 +131,9 @@
|
|||||||
"badges": {
|
"badges": {
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"updateAvailable": "有可用更新"
|
"updateAvailable": "有可用更新"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"timesUsed": "使用次数"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -159,6 +162,13 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "修复配方数据",
|
||||||
|
"loading": "正在修复配方数据...",
|
||||||
|
"success": "成功修复了 {count} 个配方。",
|
||||||
|
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
||||||
|
"error": "配方修复失败:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +198,8 @@
|
|||||||
"creator": "创作者",
|
"creator": "创作者",
|
||||||
"title": "配方标题",
|
"title": "配方标题",
|
||||||
"loraName": "LoRA 文件名",
|
"loraName": "LoRA 文件名",
|
||||||
"loraModel": "LoRA 模型名称"
|
"loraModel": "LoRA 模型名称",
|
||||||
|
"prompt": "提示词"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +210,7 @@
|
|||||||
"license": "许可证",
|
"license": "许可证",
|
||||||
"noCreditRequired": "无需署名",
|
"noCreditRequired": "无需署名",
|
||||||
"allowSellingGeneratedContent": "允许销售",
|
"allowSellingGeneratedContent": "允许销售",
|
||||||
|
"noTags": "无标签",
|
||||||
"clearAll": "清除所有筛选"
|
"clearAll": "清除所有筛选"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -221,7 +233,9 @@
|
|||||||
"label": "打开设置文件夹",
|
"label": "打开设置文件夹",
|
||||||
"tooltip": "打开包含 settings.json 的文件夹",
|
"tooltip": "打开包含 settings.json 的文件夹",
|
||||||
"success": "已打开 settings.json 文件夹",
|
"success": "已打开 settings.json 文件夹",
|
||||||
"failed": "无法打开 settings.json 文件夹"
|
"failed": "无法打开 settings.json 文件夹",
|
||||||
|
"copied": "设置路径已复制到剪贴板:{{path}}",
|
||||||
|
"clipboardFallback": "设置路径:{{path}}"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "内容过滤",
|
"contentFiltering": "内容过滤",
|
||||||
@@ -305,6 +319,8 @@
|
|||||||
"defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录",
|
"defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录",
|
||||||
"defaultCheckpointRoot": "默认 Checkpoint 根目录",
|
"defaultCheckpointRoot": "默认 Checkpoint 根目录",
|
||||||
"defaultCheckpointRootHelp": "设置下载、导入和移动时的默认 Checkpoint 根目录",
|
"defaultCheckpointRootHelp": "设置下载、导入和移动时的默认 Checkpoint 根目录",
|
||||||
|
"defaultUnetRoot": "默认 Diffusion Model 根目录",
|
||||||
|
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
||||||
"defaultEmbeddingRoot": "默认 Embedding 根目录",
|
"defaultEmbeddingRoot": "默认 Embedding 根目录",
|
||||||
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
||||||
"noDefault": "无默认"
|
"noDefault": "无默认"
|
||||||
@@ -443,7 +459,10 @@
|
|||||||
"dateAsc": "最旧",
|
"dateAsc": "最旧",
|
||||||
"size": "文件大小",
|
"size": "文件大小",
|
||||||
"sizeDesc": "最大",
|
"sizeDesc": "最大",
|
||||||
"sizeAsc": "最小"
|
"sizeAsc": "最小",
|
||||||
|
"usage": "使用次数",
|
||||||
|
"usageDesc": "最多",
|
||||||
|
"usageAsc": "最少"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新模型列表",
|
"title": "刷新模型列表",
|
||||||
@@ -518,6 +537,7 @@
|
|||||||
"replacePreview": "替换预览",
|
"replacePreview": "替换预览",
|
||||||
"setContentRating": "设置内容评级",
|
"setContentRating": "设置内容评级",
|
||||||
"moveToFolder": "移动到文件夹",
|
"moveToFolder": "移动到文件夹",
|
||||||
|
"repairMetadata": "修复元数据",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
"deleteModel": "删除模型",
|
"deleteModel": "删除模型",
|
||||||
"shareRecipe": "分享配方",
|
"shareRecipe": "分享配方",
|
||||||
@@ -588,10 +608,26 @@
|
|||||||
"selectLoraRoot": "请选择 LoRA 根目录"
|
"selectLoraRoot": "请选择 LoRA 根目录"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "配方排序...",
|
||||||
|
"name": "名称",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "时间",
|
||||||
|
"dateDesc": "最新",
|
||||||
|
"dateAsc": "最早",
|
||||||
|
"lorasCount": "LoRA 数量",
|
||||||
|
"lorasCountDesc": "最多",
|
||||||
|
"lorasCountAsc": "最少"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新配方列表"
|
"title": "刷新配方列表"
|
||||||
},
|
},
|
||||||
"filteredByLora": "按 LoRA 筛选"
|
"filteredByLora": "按 LoRA 筛选",
|
||||||
|
"favorites": {
|
||||||
|
"title": "仅显示收藏",
|
||||||
|
"action": "收藏"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "发现 {count} 个重复组",
|
"found": "发现 {count} 个重复组",
|
||||||
@@ -617,11 +653,25 @@
|
|||||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||||
"getInfoFailed": "获取缺失 LoRA 信息失败",
|
"getInfoFailed": "获取缺失 LoRA 信息失败",
|
||||||
"prepareError": "准备下载 LoRA 时出错:{message}"
|
"prepareError": "准备下载 LoRA 时出错:{message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "正在修复配方元数据...",
|
||||||
|
"success": "配方元数据修复成功",
|
||||||
|
"skipped": "配方已是最新版本,无需修复",
|
||||||
|
"failed": "修复配方失败:{message}",
|
||||||
|
"missingId": "无法修复配方:缺少配方 ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"title": "Checkpoint 模型"
|
"title": "Checkpoint 模型",
|
||||||
|
"modelTypes": {
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusion Model"
|
||||||
|
},
|
||||||
|
"contextMenu": {
|
||||||
|
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 模型"
|
"title": "Embedding 模型"
|
||||||
@@ -638,7 +688,8 @@
|
|||||||
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
||||||
"collapseAllDisabled": "列表视图下不可用",
|
"collapseAllDisabled": "列表视图下不可用",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "无法确定移动的目标路径。"
|
"unableToResolveRoot": "无法确定移动的目标路径。",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -848,7 +899,9 @@
|
|||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
"success": "文件位置已成功打开",
|
"success": "文件位置已成功打开",
|
||||||
"failed": "打开文件位置失败"
|
"failed": "打开文件位置失败",
|
||||||
|
"copied": "路径已复制到剪贴板:{{path}}",
|
||||||
|
"clipboardFallback": "路径:{{path}}"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
@@ -871,11 +924,13 @@
|
|||||||
"addPresetParameter": "添加预设参数...",
|
"addPresetParameter": "添加预设参数...",
|
||||||
"strengthMin": "最小强度",
|
"strengthMin": "最小强度",
|
||||||
"strengthMax": "最大强度",
|
"strengthMax": "最大强度",
|
||||||
|
"strengthRange": "强度范围",
|
||||||
"strength": "强度",
|
"strength": "强度",
|
||||||
"clipStrength": "Clip 强度",
|
"clipStrength": "Clip 强度",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "数值",
|
"valuePlaceholder": "数值",
|
||||||
"add": "添加"
|
"add": "添加",
|
||||||
|
"invalidRange": "无效的范围格式。请使用 x.x-y.y"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "触发词",
|
"label": "触发词",
|
||||||
@@ -914,6 +969,13 @@
|
|||||||
"recipes": "配方",
|
"recipes": "配方",
|
||||||
"versions": "版本"
|
"versions": "版本"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"label": "模型导航",
|
||||||
|
"previousWithShortcut": "上一个模型(←)",
|
||||||
|
"nextWithShortcut": "下一个模型(→)",
|
||||||
|
"noPrevious": "没有上一个模型",
|
||||||
|
"noNext": "没有下一个模型"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"noImageSell": "No selling generated content",
|
"noImageSell": "No selling generated content",
|
||||||
"noRentCivit": "No Civitai generation",
|
"noRentCivit": "No Civitai generation",
|
||||||
@@ -1317,6 +1379,7 @@
|
|||||||
"verificationCompleteSuccess": "验证完成。所有文件均为重复项。",
|
"verificationCompleteSuccess": "验证完成。所有文件均为重复项。",
|
||||||
"verificationFailed": "验证哈希失败:{message}",
|
"verificationFailed": "验证哈希失败:{message}",
|
||||||
"noTagsToAdd": "没有可添加的标签",
|
"noTagsToAdd": "没有可添加的标签",
|
||||||
|
"bulkTagsUpdating": "正在更新 {count} 个模型的标签...",
|
||||||
"tagsAddedSuccessfully": "已成功为 {count} 个 {type} 添加 {tagCount} 个标签",
|
"tagsAddedSuccessfully": "已成功为 {count} 个 {type} 添加 {tagCount} 个标签",
|
||||||
"tagsReplacedSuccessfully": "已成功为 {count} 个 {type} 替换为 {tagCount} 个标签",
|
"tagsReplacedSuccessfully": "已成功为 {count} 个 {type} 替换为 {tagCount} 个标签",
|
||||||
"tagsAddFailed": "为 {count} 个模型添加标签失败",
|
"tagsAddFailed": "为 {count} 个模型添加标签失败",
|
||||||
@@ -1330,6 +1393,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"loraRootsFailed": "加载 LoRA 根目录失败:{message}",
|
"loraRootsFailed": "加载 LoRA 根目录失败:{message}",
|
||||||
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
|
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
|
||||||
|
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
|
||||||
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
|
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
|
||||||
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射{plural})",
|
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射{plural})",
|
||||||
"mappingsCleared": "基础模型路径映射已清除",
|
"mappingsCleared": "基础模型路径映射已清除",
|
||||||
@@ -1437,6 +1501,8 @@
|
|||||||
"metadataRefreshed": "元数据刷新成功",
|
"metadataRefreshed": "元数据刷新成功",
|
||||||
"metadataRefreshFailed": "刷新元数据失败:{message}",
|
"metadataRefreshFailed": "刷新元数据失败:{message}",
|
||||||
"metadataUpdateComplete": "元数据更新完成",
|
"metadataUpdateComplete": "元数据更新完成",
|
||||||
|
"operationCancelled": "操作已由用户取消",
|
||||||
|
"operationCancelledPartial": "操作已取消。已处理 {success} 个项目。",
|
||||||
"metadataFetchFailed": "获取元数据失败:{message}",
|
"metadataFetchFailed": "获取元数据失败:{message}",
|
||||||
"bulkMetadataCompleteAll": "全部 {count} 个 {type} 元数据刷新成功",
|
"bulkMetadataCompleteAll": "全部 {count} 个 {type} 元数据刷新成功",
|
||||||
"bulkMetadataCompletePartial": "已刷新 {success}/{total} 个 {type} 元数据",
|
"bulkMetadataCompletePartial": "已刷新 {success}/{total} 个 {type} 元数据",
|
||||||
@@ -1453,7 +1519,8 @@
|
|||||||
"bulkMoveFailures": "移动失败:\n{failures}",
|
"bulkMoveFailures": "移动失败:\n{failures}",
|
||||||
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
||||||
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
||||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}"
|
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -131,6 +131,9 @@
|
|||||||
"badges": {
|
"badges": {
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"updateAvailable": "有可用更新"
|
"updateAvailable": "有可用更新"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"timesUsed": "使用次數"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -154,11 +157,18 @@
|
|||||||
"error": "清理範例圖片資料夾失敗:{message}"
|
"error": "清理範例圖片資料夾失敗:{message}"
|
||||||
},
|
},
|
||||||
"fetchMissingLicenses": {
|
"fetchMissingLicenses": {
|
||||||
"label": "Refresh license metadata",
|
"label": "重新整理授權中繼資料",
|
||||||
"loading": "Refreshing license metadata for {typePlural}...",
|
"loading": "正在重新整理 {typePlural} 的授權中繼資料...",
|
||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "已更新 {count} 個 {typePlural} 的授權中繼資料",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "所有 {typePlural} 已具備授權中繼資料",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "重新整理 {typePlural} 授權中繼資料失敗:{message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "修復配方資料",
|
||||||
|
"loading": "正在修復配方資料...",
|
||||||
|
"success": "成功修復 {count} 個配方。",
|
||||||
|
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
||||||
|
"error": "配方修復失敗:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +198,8 @@
|
|||||||
"creator": "創作者",
|
"creator": "創作者",
|
||||||
"title": "配方標題",
|
"title": "配方標題",
|
||||||
"loraName": "LoRA 檔案名稱",
|
"loraName": "LoRA 檔案名稱",
|
||||||
"loraModel": "LoRA 模型名稱"
|
"loraModel": "LoRA 模型名稱",
|
||||||
|
"prompt": "提示詞"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +210,7 @@
|
|||||||
"license": "授權",
|
"license": "授權",
|
||||||
"noCreditRequired": "無需署名",
|
"noCreditRequired": "無需署名",
|
||||||
"allowSellingGeneratedContent": "允許銷售",
|
"allowSellingGeneratedContent": "允許銷售",
|
||||||
|
"noTags": "無標籤",
|
||||||
"clearAll": "清除所有篩選"
|
"clearAll": "清除所有篩選"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -221,7 +233,9 @@
|
|||||||
"label": "開啟設定資料夾",
|
"label": "開啟設定資料夾",
|
||||||
"tooltip": "開啟包含 settings.json 的資料夾",
|
"tooltip": "開啟包含 settings.json 的資料夾",
|
||||||
"success": "已開啟 settings.json 資料夾",
|
"success": "已開啟 settings.json 資料夾",
|
||||||
"failed": "無法開啟 settings.json 資料夾"
|
"failed": "無法開啟 settings.json 資料夾",
|
||||||
|
"copied": "設定路徑已複製到剪貼簿:{{path}}",
|
||||||
|
"clipboardFallback": "設定路徑:{{path}}"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "內容過濾",
|
"contentFiltering": "內容過濾",
|
||||||
@@ -305,6 +319,8 @@
|
|||||||
"defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄",
|
"defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄",
|
||||||
"defaultCheckpointRoot": "預設 Checkpoint 根目錄",
|
"defaultCheckpointRoot": "預設 Checkpoint 根目錄",
|
||||||
"defaultCheckpointRootHelp": "設定下載、匯入和移動時的預設 Checkpoint 根目錄",
|
"defaultCheckpointRootHelp": "設定下載、匯入和移動時的預設 Checkpoint 根目錄",
|
||||||
|
"defaultUnetRoot": "預設 Diffusion Model 根目錄",
|
||||||
|
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
||||||
"defaultEmbeddingRoot": "預設 Embedding 根目錄",
|
"defaultEmbeddingRoot": "預設 Embedding 根目錄",
|
||||||
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
||||||
"noDefault": "未設定預設"
|
"noDefault": "未設定預設"
|
||||||
@@ -443,7 +459,10 @@
|
|||||||
"dateAsc": "最舊",
|
"dateAsc": "最舊",
|
||||||
"size": "檔案大小",
|
"size": "檔案大小",
|
||||||
"sizeDesc": "最大",
|
"sizeDesc": "最大",
|
||||||
"sizeAsc": "最小"
|
"sizeAsc": "最小",
|
||||||
|
"usage": "使用次數",
|
||||||
|
"usageDesc": "最多",
|
||||||
|
"usageAsc": "最少"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理模型列表",
|
"title": "重新整理模型列表",
|
||||||
@@ -518,6 +537,7 @@
|
|||||||
"replacePreview": "更換預覽圖",
|
"replacePreview": "更換預覽圖",
|
||||||
"setContentRating": "設定內容分級",
|
"setContentRating": "設定內容分級",
|
||||||
"moveToFolder": "移動到資料夾",
|
"moveToFolder": "移動到資料夾",
|
||||||
|
"repairMetadata": "修復元數據",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
"deleteModel": "刪除模型",
|
"deleteModel": "刪除模型",
|
||||||
"shareRecipe": "分享配方",
|
"shareRecipe": "分享配方",
|
||||||
@@ -588,10 +608,26 @@
|
|||||||
"selectLoraRoot": "請選擇 LoRA 根目錄"
|
"selectLoraRoot": "請選擇 LoRA 根目錄"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "配方排序...",
|
||||||
|
"name": "名稱",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "時間",
|
||||||
|
"dateDesc": "最新",
|
||||||
|
"dateAsc": "最舊",
|
||||||
|
"lorasCount": "LoRA 數量",
|
||||||
|
"lorasCountDesc": "最多",
|
||||||
|
"lorasCountAsc": "最少"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理配方列表"
|
"title": "重新整理配方列表"
|
||||||
},
|
},
|
||||||
"filteredByLora": "已依 LoRA 篩選"
|
"filteredByLora": "已依 LoRA 篩選",
|
||||||
|
"favorites": {
|
||||||
|
"title": "僅顯示收藏",
|
||||||
|
"action": "收藏"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "發現 {count} 組重複項",
|
"found": "發現 {count} 組重複項",
|
||||||
@@ -617,11 +653,25 @@
|
|||||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||||
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
|
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||||
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
|
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "正在修復配方元數據...",
|
||||||
|
"success": "配方元數據修復成功",
|
||||||
|
"skipped": "配方已是最新版本,無需修復",
|
||||||
|
"failed": "修復配方失敗:{message}",
|
||||||
|
"missingId": "無法修復配方:缺少配方 ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"title": "Checkpoint 模型"
|
"title": "Checkpoint 模型",
|
||||||
|
"modelTypes": {
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusion Model"
|
||||||
|
},
|
||||||
|
"contextMenu": {
|
||||||
|
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 模型"
|
"title": "Embedding 模型"
|
||||||
@@ -638,7 +688,8 @@
|
|||||||
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
||||||
"collapseAllDisabled": "列表檢視下不可用",
|
"collapseAllDisabled": "列表檢視下不可用",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "無法確定移動的目標路徑。"
|
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -848,7 +899,9 @@
|
|||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
"success": "檔案位置已成功開啟",
|
"success": "檔案位置已成功開啟",
|
||||||
"failed": "開啟檔案位置失敗"
|
"failed": "開啟檔案位置失敗",
|
||||||
|
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||||
|
"clipboardFallback": "路徑:{{path}}"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
@@ -871,11 +924,13 @@
|
|||||||
"addPresetParameter": "新增預設參數...",
|
"addPresetParameter": "新增預設參數...",
|
||||||
"strengthMin": "最小強度",
|
"strengthMin": "最小強度",
|
||||||
"strengthMax": "最大強度",
|
"strengthMax": "最大強度",
|
||||||
|
"strengthRange": "強度範圍",
|
||||||
"strength": "強度",
|
"strength": "強度",
|
||||||
"clipStrength": "Clip 強度",
|
"clipStrength": "Clip 強度",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "數值",
|
"valuePlaceholder": "數值",
|
||||||
"add": "新增"
|
"add": "新增",
|
||||||
|
"invalidRange": "無效的範圍格式。請使用 x.x-y.y"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "觸發詞",
|
"label": "觸發詞",
|
||||||
@@ -914,6 +969,13 @@
|
|||||||
"recipes": "配方",
|
"recipes": "配方",
|
||||||
"versions": "版本"
|
"versions": "版本"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"label": "模型導覽",
|
||||||
|
"previousWithShortcut": "上一個模型(←)",
|
||||||
|
"nextWithShortcut": "下一個模型(→)",
|
||||||
|
"noPrevious": "沒有上一個模型",
|
||||||
|
"noNext": "沒有下一個模型"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"noImageSell": "No selling generated content",
|
"noImageSell": "No selling generated content",
|
||||||
"noRentCivit": "No Civitai generation",
|
"noRentCivit": "No Civitai generation",
|
||||||
@@ -1317,6 +1379,7 @@
|
|||||||
"verificationCompleteSuccess": "驗證完成。所有檔案均確認為重複項。",
|
"verificationCompleteSuccess": "驗證完成。所有檔案均確認為重複項。",
|
||||||
"verificationFailed": "驗證雜湊失敗:{message}",
|
"verificationFailed": "驗證雜湊失敗:{message}",
|
||||||
"noTagsToAdd": "沒有可新增的標籤",
|
"noTagsToAdd": "沒有可新增的標籤",
|
||||||
|
"bulkTagsUpdating": "正在更新 {count} 個模型的標籤...",
|
||||||
"tagsAddedSuccessfully": "已成功將 {tagCount} 個標籤新增到 {count} 個 {type}",
|
"tagsAddedSuccessfully": "已成功將 {tagCount} 個標籤新增到 {count} 個 {type}",
|
||||||
"tagsReplacedSuccessfully": "已成功以 {tagCount} 個標籤取代 {count} 個 {type} 的標籤",
|
"tagsReplacedSuccessfully": "已成功以 {tagCount} 個標籤取代 {count} 個 {type} 的標籤",
|
||||||
"tagsAddFailed": "新增標籤到 {count} 個模型失敗",
|
"tagsAddFailed": "新增標籤到 {count} 個模型失敗",
|
||||||
@@ -1330,6 +1393,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"loraRootsFailed": "載入 LoRA 根目錄失敗:{message}",
|
"loraRootsFailed": "載入 LoRA 根目錄失敗:{message}",
|
||||||
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
|
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
|
||||||
|
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
|
||||||
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
|
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
|
||||||
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
|
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
|
||||||
"mappingsCleared": "基礎模型路徑對應已清除",
|
"mappingsCleared": "基礎模型路徑對應已清除",
|
||||||
@@ -1437,6 +1501,8 @@
|
|||||||
"metadataRefreshed": "metadata 已成功刷新",
|
"metadataRefreshed": "metadata 已成功刷新",
|
||||||
"metadataRefreshFailed": "刷新 metadata 失敗:{message}",
|
"metadataRefreshFailed": "刷新 metadata 失敗:{message}",
|
||||||
"metadataUpdateComplete": "metadata 更新完成",
|
"metadataUpdateComplete": "metadata 更新完成",
|
||||||
|
"operationCancelled": "操作已由用戶取消",
|
||||||
|
"operationCancelledPartial": "操作已取消。已處理 {success} 個項目。",
|
||||||
"metadataFetchFailed": "取得 metadata 失敗:{message}",
|
"metadataFetchFailed": "取得 metadata 失敗:{message}",
|
||||||
"bulkMetadataCompleteAll": "已成功刷新全部 {count} 個 {type}",
|
"bulkMetadataCompleteAll": "已成功刷新全部 {count} 個 {type}",
|
||||||
"bulkMetadataCompletePartial": "已刷新 {success} / {total} 個 {type}",
|
"bulkMetadataCompletePartial": "已刷新 {success} / {total} 個 {type}",
|
||||||
@@ -1453,7 +1519,8 @@
|
|||||||
"bulkMoveFailures": "移動失敗:\n{failures}",
|
"bulkMoveFailures": "移動失敗:\n{failures}",
|
||||||
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
||||||
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
||||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}"
|
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
285
py/config.py
285
py/config.py
@@ -1,13 +1,15 @@
|
|||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import folder_paths # type: ignore
|
import folder_paths # type: ignore
|
||||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import time
|
||||||
|
|
||||||
from .utils.settings_paths import ensure_settings_file, load_settings_template
|
from .utils.settings_paths import ensure_settings_file, get_settings_dir, load_settings_template
|
||||||
|
|
||||||
# Use an environment variable to control standalone mode
|
# Use an environment variable to control standalone mode
|
||||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||||
@@ -80,6 +82,8 @@ class Config:
|
|||||||
self._path_mappings: Dict[str, str] = {}
|
self._path_mappings: Dict[str, str] = {}
|
||||||
# Normalized preview root directories used to validate preview access
|
# Normalized preview root directories used to validate preview access
|
||||||
self._preview_root_paths: Set[Path] = set()
|
self._preview_root_paths: Set[Path] = set()
|
||||||
|
# Fingerprint of the symlink layout from the last successful scan
|
||||||
|
self._cached_fingerprint: Optional[Dict[str, object]] = None
|
||||||
self.loras_roots = self._init_lora_paths()
|
self.loras_roots = self._init_lora_paths()
|
||||||
self.checkpoints_roots = None
|
self.checkpoints_roots = None
|
||||||
self.unet_roots = None
|
self.unet_roots = None
|
||||||
@@ -87,8 +91,7 @@ class Config:
|
|||||||
self.base_models_roots = self._init_checkpoint_paths()
|
self.base_models_roots = self._init_checkpoint_paths()
|
||||||
self.embeddings_roots = self._init_embedding_paths()
|
self.embeddings_roots = self._init_embedding_paths()
|
||||||
# Scan symbolic links during initialization
|
# Scan symbolic links during initialization
|
||||||
self._scan_symbolic_links()
|
self._initialize_symlink_mappings()
|
||||||
self._rebuild_preview_roots()
|
|
||||||
|
|
||||||
if not standalone_mode:
|
if not standalone_mode:
|
||||||
# Save the paths to settings.json when running in ComfyUI mode
|
# Save the paths to settings.json when running in ComfyUI mode
|
||||||
@@ -220,45 +223,217 @@ class Config:
|
|||||||
logger.error(f"Error checking link status for {path}: {e}")
|
logger.error(f"Error checking link status for {path}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _normalize_path(self, path: str) -> str:
|
||||||
|
return os.path.normpath(path).replace(os.sep, '/')
|
||||||
|
|
||||||
|
def _get_symlink_cache_path(self) -> Path:
|
||||||
|
cache_dir = Path(get_settings_dir(create=True)) / "cache"
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return cache_dir / "symlink_map.json"
|
||||||
|
|
||||||
|
def _symlink_roots(self) -> List[str]:
|
||||||
|
roots: List[str] = []
|
||||||
|
roots.extend(self.loras_roots or [])
|
||||||
|
roots.extend(self.base_models_roots or [])
|
||||||
|
roots.extend(self.embeddings_roots or [])
|
||||||
|
return roots
|
||||||
|
|
||||||
|
def _build_symlink_fingerprint(self) -> Dict[str, object]:
|
||||||
|
roots = [self._normalize_path(path) for path in self._symlink_roots() if path]
|
||||||
|
unique_roots = sorted(set(roots))
|
||||||
|
# Fingerprint now only contains the root paths to avoid sensitivity to folder content changes.
|
||||||
|
return {"roots": unique_roots}
|
||||||
|
|
||||||
|
def _initialize_symlink_mappings(self) -> None:
|
||||||
|
start = time.perf_counter()
|
||||||
|
cache_loaded = self._load_persisted_cache_into_mappings()
|
||||||
|
|
||||||
|
if cache_loaded:
|
||||||
|
logger.info(
|
||||||
|
"Symlink mappings restored from cache in %.2f ms",
|
||||||
|
(time.perf_counter() - start) * 1000,
|
||||||
|
)
|
||||||
|
self._rebuild_preview_roots()
|
||||||
|
|
||||||
|
# Only rescan if target roots have changed.
|
||||||
|
# This is stable across file additions/deletions.
|
||||||
|
current_fingerprint = self._build_symlink_fingerprint()
|
||||||
|
cached_fingerprint = self._cached_fingerprint
|
||||||
|
|
||||||
|
if cached_fingerprint and current_fingerprint == cached_fingerprint:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Symlink root paths changed; rescanning symbolic links")
|
||||||
|
|
||||||
|
self.rebuild_symlink_cache()
|
||||||
|
logger.info(
|
||||||
|
"Symlink mappings rebuilt and cached in %.2f ms",
|
||||||
|
(time.perf_counter() - start) * 1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
def rebuild_symlink_cache(self) -> None:
|
||||||
|
"""Force a fresh scan of all symbolic links and update the persistent cache."""
|
||||||
|
self._scan_symbolic_links()
|
||||||
|
self._save_symlink_cache()
|
||||||
|
self._rebuild_preview_roots()
|
||||||
|
|
||||||
|
def _load_persisted_cache_into_mappings(self) -> bool:
|
||||||
|
"""Load the symlink cache and store its fingerprint for comparison."""
|
||||||
|
cache_path = self._get_symlink_cache_path()
|
||||||
|
if not cache_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with cache_path.open("r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info("Failed to load symlink cache %s: %s", cache_path, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
cached_mappings = payload.get("path_mappings")
|
||||||
|
if not isinstance(cached_mappings, Mapping):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Store the cached fingerprint for comparison during initialization
|
||||||
|
self._cached_fingerprint = payload.get("fingerprint")
|
||||||
|
|
||||||
|
normalized_mappings: Dict[str, str] = {}
|
||||||
|
for target, link in cached_mappings.items():
|
||||||
|
if not isinstance(target, str) or not isinstance(link, str):
|
||||||
|
continue
|
||||||
|
normalized_mappings[self._normalize_path(target)] = self._normalize_path(link)
|
||||||
|
|
||||||
|
self._path_mappings = normalized_mappings
|
||||||
|
logger.info("Symlink cache loaded with %d mappings", len(self._path_mappings))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _save_symlink_cache(self) -> None:
|
||||||
|
cache_path = self._get_symlink_cache_path()
|
||||||
|
payload = {
|
||||||
|
"fingerprint": self._build_symlink_fingerprint(),
|
||||||
|
"path_mappings": self._path_mappings,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with cache_path.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||||
|
logger.debug("Symlink cache saved to %s with %d mappings", cache_path, len(self._path_mappings))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info("Failed to write symlink cache %s: %s", cache_path, exc)
|
||||||
|
|
||||||
def _scan_symbolic_links(self):
|
def _scan_symbolic_links(self):
|
||||||
"""Scan all symbolic links in LoRA, Checkpoint, and Embedding root directories"""
|
"""Scan all symbolic links in LoRA, Checkpoint, and Embedding root directories"""
|
||||||
for root in self.loras_roots:
|
start = time.perf_counter()
|
||||||
self._scan_directory_links(root)
|
|
||||||
|
|
||||||
for root in self.base_models_roots:
|
# Reset mappings before rescanning to avoid stale entries
|
||||||
self._scan_directory_links(root)
|
self._path_mappings.clear()
|
||||||
|
self._seed_root_symlink_mappings()
|
||||||
|
visited_dirs: Set[str] = set()
|
||||||
|
for root in self._symlink_roots():
|
||||||
|
self._scan_directory_links(root, visited_dirs)
|
||||||
|
logger.debug(
|
||||||
|
"Symlink scan finished in %.2f ms with %d mappings",
|
||||||
|
(time.perf_counter() - start) * 1000,
|
||||||
|
len(self._path_mappings),
|
||||||
|
)
|
||||||
|
|
||||||
for root in self.embeddings_roots:
|
def _scan_directory_links(self, root: str, visited_dirs: Set[str]):
|
||||||
self._scan_directory_links(root)
|
"""Iteratively scan directory symlinks to avoid deep recursion."""
|
||||||
|
|
||||||
def _scan_directory_links(self, root: str):
|
|
||||||
"""Recursively scan symbolic links in a directory"""
|
|
||||||
try:
|
try:
|
||||||
with os.scandir(root) as it:
|
# Note: We only use realpath for the initial root if it's not already resolved
|
||||||
for entry in it:
|
# to ensure we have a valid entry point.
|
||||||
if self._is_link(entry.path):
|
root_real = self._normalize_path(os.path.realpath(root))
|
||||||
target_path = os.path.realpath(entry.path)
|
except OSError:
|
||||||
if os.path.isdir(target_path):
|
root_real = self._normalize_path(root)
|
||||||
self.add_path_mapping(entry.path, target_path)
|
|
||||||
self._scan_directory_links(target_path)
|
if root_real in visited_dirs:
|
||||||
elif entry.is_dir(follow_symlinks=False):
|
return
|
||||||
self._scan_directory_links(entry.path)
|
|
||||||
except Exception as e:
|
visited_dirs.add(root_real)
|
||||||
logger.error(f"Error scanning links in {root}: {e}")
|
# Stack entries: (display_path, real_resolved_path)
|
||||||
|
stack: List[Tuple[str, str]] = [(root, root_real)]
|
||||||
|
|
||||||
|
while stack:
|
||||||
|
current_display, current_real = stack.pop()
|
||||||
|
try:
|
||||||
|
with os.scandir(current_display) as it:
|
||||||
|
for entry in it:
|
||||||
|
try:
|
||||||
|
# 1. High speed detection using dirent data (is_symlink)
|
||||||
|
is_link = entry.is_symlink()
|
||||||
|
|
||||||
|
# On Windows, is_symlink handles reparse points
|
||||||
|
if is_link:
|
||||||
|
# Only resolve realpath when we actually find a link
|
||||||
|
target_path = os.path.realpath(entry.path)
|
||||||
|
if not os.path.isdir(target_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_target = self._normalize_path(target_path)
|
||||||
|
self.add_path_mapping(entry.path, target_path)
|
||||||
|
|
||||||
|
if normalized_target in visited_dirs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
visited_dirs.add(normalized_target)
|
||||||
|
stack.append((target_path, normalized_target))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Process normal directories
|
||||||
|
if not entry.is_dir(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# For normal directories, we avoid realpath() call by
|
||||||
|
# incrementally building the real path relative to current_real.
|
||||||
|
# This is safe because 'entry' is NOT a symlink.
|
||||||
|
entry_real = self._normalize_path(os.path.join(current_real, entry.name))
|
||||||
|
|
||||||
|
if entry_real in visited_dirs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
visited_dirs.add(entry_real)
|
||||||
|
stack.append((entry.path, entry_real))
|
||||||
|
except Exception as inner_exc:
|
||||||
|
logger.debug(
|
||||||
|
"Error processing directory entry %s: %s", entry.path, inner_exc
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error scanning links in {current_display}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def add_path_mapping(self, link_path: str, target_path: str):
|
def add_path_mapping(self, link_path: str, target_path: str):
|
||||||
"""Add a symbolic link path mapping
|
"""Add a symbolic link path mapping
|
||||||
target_path: actual target path
|
target_path: actual target path
|
||||||
link_path: symbolic link path
|
link_path: symbolic link path
|
||||||
"""
|
"""
|
||||||
normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
|
normalized_link = self._normalize_path(link_path)
|
||||||
normalized_target = os.path.normpath(target_path).replace(os.sep, '/')
|
normalized_target = self._normalize_path(target_path)
|
||||||
# Keep the original mapping: target path -> link path
|
# Keep the original mapping: target path -> link path
|
||||||
self._path_mappings[normalized_target] = normalized_link
|
self._path_mappings[normalized_target] = normalized_link
|
||||||
logger.info(f"Added path mapping: {normalized_target} -> {normalized_link}")
|
logger.info(f"Added path mapping: {normalized_target} -> {normalized_link}")
|
||||||
self._preview_root_paths.update(self._expand_preview_root(normalized_target))
|
self._preview_root_paths.update(self._expand_preview_root(normalized_target))
|
||||||
self._preview_root_paths.update(self._expand_preview_root(normalized_link))
|
self._preview_root_paths.update(self._expand_preview_root(normalized_link))
|
||||||
|
|
||||||
|
def _seed_root_symlink_mappings(self) -> None:
|
||||||
|
"""Ensure symlinked root folders are recorded before deep scanning."""
|
||||||
|
|
||||||
|
for root in self._symlink_roots():
|
||||||
|
if not root:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if not self._is_link(root):
|
||||||
|
continue
|
||||||
|
target_path = os.path.realpath(root)
|
||||||
|
if not os.path.isdir(target_path):
|
||||||
|
continue
|
||||||
|
self.add_path_mapping(root, target_path)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Skipping root symlink %s: %s", root, exc)
|
||||||
|
|
||||||
def _expand_preview_root(self, path: str) -> Set[Path]:
|
def _expand_preview_root(self, path: str) -> Set[Path]:
|
||||||
"""Return normalized ``Path`` objects representing a preview root."""
|
"""Return normalized ``Path`` objects representing a preview root."""
|
||||||
|
|
||||||
@@ -315,28 +490,44 @@ class Config:
|
|||||||
preview_roots.update(self._expand_preview_root(link))
|
preview_roots.update(self._expand_preview_root(link))
|
||||||
|
|
||||||
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()}
|
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()}
|
||||||
|
logger.debug(
|
||||||
|
"Preview roots rebuilt: %d paths from %d lora roots, %d checkpoint roots, %d embedding roots, %d symlink mappings",
|
||||||
|
len(self._preview_root_paths),
|
||||||
|
len(self.loras_roots or []),
|
||||||
|
len(self.base_models_roots or []),
|
||||||
|
len(self.embeddings_roots or []),
|
||||||
|
len(self._path_mappings),
|
||||||
|
)
|
||||||
|
|
||||||
def map_path_to_link(self, path: str) -> str:
|
def map_path_to_link(self, path: str) -> str:
|
||||||
"""Map a target path back to its symbolic link path"""
|
"""Map a target path back to its symbolic link path"""
|
||||||
normalized_path = os.path.normpath(path).replace(os.sep, '/')
|
normalized_path = os.path.normpath(path).replace(os.sep, '/')
|
||||||
# Check if the path is contained in any mapped target path
|
# Check if the path is contained in any mapped target path
|
||||||
for target_path, link_path in self._path_mappings.items():
|
for target_path, link_path in self._path_mappings.items():
|
||||||
if normalized_path.startswith(target_path):
|
# Match whole path components to avoid prefix collisions (e.g., /a/b vs /a/bc)
|
||||||
|
if normalized_path == target_path:
|
||||||
|
return link_path
|
||||||
|
|
||||||
|
if normalized_path.startswith(target_path + '/'):
|
||||||
# If the path starts with the target path, replace with link path
|
# If the path starts with the target path, replace with link path
|
||||||
mapped_path = normalized_path.replace(target_path, link_path, 1)
|
mapped_path = normalized_path.replace(target_path, link_path, 1)
|
||||||
return mapped_path
|
return mapped_path
|
||||||
return path
|
return normalized_path
|
||||||
|
|
||||||
def map_link_to_path(self, link_path: str) -> str:
|
def map_link_to_path(self, link_path: str) -> str:
|
||||||
"""Map a symbolic link path back to the actual path"""
|
"""Map a symbolic link path back to the actual path"""
|
||||||
normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
|
normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
|
||||||
# Check if the path is contained in any mapped target path
|
# Check if the path is contained in any mapped target path
|
||||||
for target_path, link_path in self._path_mappings.items():
|
for target_path, link_path_mapped in self._path_mappings.items():
|
||||||
if normalized_link.startswith(target_path):
|
# Match whole path components
|
||||||
# If the path starts with the target path, replace with actual path
|
if normalized_link == link_path_mapped:
|
||||||
mapped_path = normalized_link.replace(target_path, link_path, 1)
|
return target_path
|
||||||
|
|
||||||
|
if normalized_link.startswith(link_path_mapped + '/'):
|
||||||
|
# If the path starts with the link path, replace with actual path
|
||||||
|
mapped_path = normalized_link.replace(link_path_mapped, target_path, 1)
|
||||||
return mapped_path
|
return mapped_path
|
||||||
return link_path
|
return normalized_link
|
||||||
|
|
||||||
def _dedupe_existing_paths(self, raw_paths: Iterable[str]) -> Dict[str, str]:
|
def _dedupe_existing_paths(self, raw_paths: Iterable[str]) -> Dict[str, str]:
|
||||||
dedup: Dict[str, str] = {}
|
dedup: Dict[str, str] = {}
|
||||||
@@ -411,8 +602,7 @@ class Config:
|
|||||||
self.base_models_roots = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
self.base_models_roots = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
||||||
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
|
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
|
||||||
|
|
||||||
self._scan_symbolic_links()
|
self._initialize_symlink_mappings()
|
||||||
self._rebuild_preview_roots()
|
|
||||||
|
|
||||||
def _init_lora_paths(self) -> List[str]:
|
def _init_lora_paths(self) -> List[str]:
|
||||||
"""Initialize and validate LoRA paths from ComfyUI settings"""
|
"""Initialize and validate LoRA paths from ComfyUI settings"""
|
||||||
@@ -483,12 +673,29 @@ class Config:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Use os.path.normcase for case-insensitive comparison on Windows.
|
||||||
|
# On Windows, Path.relative_to() is case-sensitive for drive letters,
|
||||||
|
# causing paths like 'a:/folder' to not match 'A:/folder'.
|
||||||
|
candidate_str = os.path.normcase(str(candidate))
|
||||||
for root in self._preview_root_paths:
|
for root in self._preview_root_paths:
|
||||||
try:
|
root_str = os.path.normcase(str(root))
|
||||||
candidate.relative_to(root)
|
# Check if candidate is equal to or under the root directory
|
||||||
|
if candidate_str == root_str or candidate_str.startswith(root_str + os.sep):
|
||||||
return True
|
return True
|
||||||
except ValueError:
|
|
||||||
continue
|
if self._preview_root_paths:
|
||||||
|
logger.debug(
|
||||||
|
"Preview path rejected: %s (candidate=%s, num_roots=%d, first_root=%s)",
|
||||||
|
preview_path,
|
||||||
|
candidate_str,
|
||||||
|
len(self._preview_root_paths),
|
||||||
|
os.path.normcase(str(next(iter(self._preview_root_paths)))),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Preview path rejected (no roots configured): %s",
|
||||||
|
preview_path,
|
||||||
|
)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import asyncio
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
from .utils.logging_config import setup_logging
|
||||||
|
|
||||||
|
# Check if we're in standalone mode
|
||||||
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||||
|
|
||||||
|
# Only setup logging prefix if not in standalone mode
|
||||||
|
if not standalone_mode:
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
from server import PromptServer # type: ignore
|
from server import PromptServer # type: ignore
|
||||||
|
|
||||||
from .config import config
|
from .config import config
|
||||||
@@ -17,12 +26,10 @@ from .services.settings_manager import get_settings_manager
|
|||||||
from .utils.example_images_migration import ExampleImagesMigration
|
from .utils.example_images_migration import ExampleImagesMigration
|
||||||
from .services.websocket_manager import ws_manager
|
from .services.websocket_manager import ws_manager
|
||||||
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
||||||
|
from .middleware.csp_middleware import relax_csp_for_remote_media
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Check if we're in standalone mode
|
|
||||||
STANDALONE_MODE = 'nodes' not in sys.modules
|
|
||||||
|
|
||||||
HEADER_SIZE_LIMIT = 16384
|
HEADER_SIZE_LIMIT = 16384
|
||||||
|
|
||||||
|
|
||||||
@@ -62,6 +69,23 @@ class LoraManager:
|
|||||||
"""Initialize and register all routes using the new refactored architecture"""
|
"""Initialize and register all routes using the new refactored architecture"""
|
||||||
app = PromptServer.instance.app
|
app = PromptServer.instance.app
|
||||||
|
|
||||||
|
if relax_csp_for_remote_media not in app.middlewares:
|
||||||
|
# Ensure CSP relaxer executes after ComfyUI's block_external_middleware so it can
|
||||||
|
# see and extend the restrictive header instead of being overwritten by it.
|
||||||
|
block_middleware_index = next(
|
||||||
|
(
|
||||||
|
idx
|
||||||
|
for idx, middleware in enumerate(app.middlewares)
|
||||||
|
if getattr(middleware, "__name__", "") == "block_external_middleware"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if block_middleware_index is None:
|
||||||
|
app.middlewares.append(relax_csp_for_remote_media)
|
||||||
|
else:
|
||||||
|
app.middlewares.insert(block_middleware_index, relax_csp_for_remote_media)
|
||||||
|
|
||||||
# Increase allowed header sizes so browsers with large localhost cookie
|
# Increase allowed header sizes so browsers with large localhost cookie
|
||||||
# jars (multiple UIs on 127.0.0.1) don't trip aiohttp's 8KB default
|
# jars (multiple UIs on 127.0.0.1) don't trip aiohttp's 8KB default
|
||||||
# limits. Cookies for unrelated apps are still sent to the plugin and
|
# limits. Cookies for unrelated apps are still sent to the plugin and
|
||||||
@@ -140,8 +164,6 @@ class LoraManager:
|
|||||||
# Add cleanup
|
# Add cleanup
|
||||||
app.on_shutdown.append(cls._cleanup)
|
app.on_shutdown.append(cls._cleanup)
|
||||||
|
|
||||||
logger.info(f"LoRA Manager: Set up routes for {len(ModelServiceFactory.get_registered_types())} model types: {', '.join(ModelServiceFactory.get_registered_types())}")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _initialize_services(cls):
|
async def _initialize_services(cls):
|
||||||
"""Initialize all services using the ServiceRegistry"""
|
"""Initialize all services using the ServiceRegistry"""
|
||||||
|
|||||||
@@ -39,8 +39,39 @@ class MetadataProcessor:
|
|||||||
if node_id in metadata.get(SAMPLING, {}) and metadata[SAMPLING][node_id].get(IS_SAMPLER, False):
|
if node_id in metadata.get(SAMPLING, {}) and metadata[SAMPLING][node_id].get(IS_SAMPLER, False):
|
||||||
candidate_samplers[node_id] = metadata[SAMPLING][node_id]
|
candidate_samplers[node_id] = metadata[SAMPLING][node_id]
|
||||||
|
|
||||||
# If we found candidate samplers, apply primary sampler logic to these candidates only
|
# If we found candidate samplers, apply primary sampler logic to these candidates only
|
||||||
if candidate_samplers:
|
|
||||||
|
# PRE-PROCESS: Ensure all candidate samplers have their parameters populated
|
||||||
|
# This is especially important for SamplerCustomAdvanced which needs tracing
|
||||||
|
prompt = metadata.get("current_prompt")
|
||||||
|
for node_id in candidate_samplers:
|
||||||
|
# If a sampler is missing common parameters like steps or denoise,
|
||||||
|
# try to populate them using tracing before ranking
|
||||||
|
sampler_info = candidate_samplers[node_id]
|
||||||
|
params = sampler_info.get("parameters", {})
|
||||||
|
|
||||||
|
if prompt and (params.get("steps") is None or params.get("denoise") is None):
|
||||||
|
# Create a temporary params dict to use the handler
|
||||||
|
temp_params = {
|
||||||
|
"steps": params.get("steps"),
|
||||||
|
"denoise": params.get("denoise"),
|
||||||
|
"sampler": params.get("sampler_name"),
|
||||||
|
"scheduler": params.get("scheduler")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if it's SamplerCustomAdvanced
|
||||||
|
if prompt.original_prompt and node_id in prompt.original_prompt:
|
||||||
|
if prompt.original_prompt[node_id].get("class_type") == "SamplerCustomAdvanced":
|
||||||
|
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, node_id, temp_params)
|
||||||
|
|
||||||
|
# Update the actual parameters with found values
|
||||||
|
params["steps"] = temp_params.get("steps")
|
||||||
|
params["denoise"] = temp_params.get("denoise")
|
||||||
|
if temp_params.get("sampler"):
|
||||||
|
params["sampler_name"] = temp_params.get("sampler")
|
||||||
|
if temp_params.get("scheduler"):
|
||||||
|
params["scheduler"] = temp_params.get("scheduler")
|
||||||
|
|
||||||
# Collect potential primary samplers based on different criteria
|
# Collect potential primary samplers based on different criteria
|
||||||
custom_advanced_samplers = []
|
custom_advanced_samplers = []
|
||||||
advanced_add_noise_samplers = []
|
advanced_add_noise_samplers = []
|
||||||
@@ -49,7 +80,6 @@ class MetadataProcessor:
|
|||||||
high_denoise_id = None
|
high_denoise_id = None
|
||||||
|
|
||||||
# First, check for SamplerCustomAdvanced among candidates
|
# First, check for SamplerCustomAdvanced among candidates
|
||||||
prompt = metadata.get("current_prompt")
|
|
||||||
if prompt and prompt.original_prompt:
|
if prompt and prompt.original_prompt:
|
||||||
for node_id in candidate_samplers:
|
for node_id in candidate_samplers:
|
||||||
node_info = prompt.original_prompt.get(node_id, {})
|
node_info = prompt.original_prompt.get(node_id, {})
|
||||||
@@ -77,15 +107,16 @@ class MetadataProcessor:
|
|||||||
# Combine all potential primary samplers
|
# Combine all potential primary samplers
|
||||||
potential_samplers = custom_advanced_samplers + advanced_add_noise_samplers + high_denoise_samplers
|
potential_samplers = custom_advanced_samplers + advanced_add_noise_samplers + high_denoise_samplers
|
||||||
|
|
||||||
# Find the most recent potential primary sampler (closest to downstream node)
|
# Find the first potential primary sampler (prefer base sampler over refine)
|
||||||
for i in range(downstream_index - 1, -1, -1):
|
# Use forward search to prioritize the first one in execution order
|
||||||
|
for i in range(downstream_index):
|
||||||
node_id = execution_order[i]
|
node_id = execution_order[i]
|
||||||
if node_id in potential_samplers:
|
if node_id in potential_samplers:
|
||||||
return node_id, candidate_samplers[node_id]
|
return node_id, candidate_samplers[node_id]
|
||||||
|
|
||||||
# If no potential sampler found from our criteria, return the most recent sampler
|
# If no potential sampler found from our criteria, return the first sampler
|
||||||
if candidate_samplers:
|
if candidate_samplers:
|
||||||
for i in range(downstream_index - 1, -1, -1):
|
for i in range(downstream_index):
|
||||||
node_id = execution_order[i]
|
node_id = execution_order[i]
|
||||||
if node_id in candidate_samplers:
|
if node_id in candidate_samplers:
|
||||||
return node_id, candidate_samplers[node_id]
|
return node_id, candidate_samplers[node_id]
|
||||||
@@ -176,8 +207,11 @@ class MetadataProcessor:
|
|||||||
found_node_id = input_value[0] # Connected node_id
|
found_node_id = input_value[0] # Connected node_id
|
||||||
|
|
||||||
# If we're looking for a specific node class
|
# If we're looking for a specific node class
|
||||||
if target_class and prompt.original_prompt[found_node_id].get("class_type") == target_class:
|
if target_class:
|
||||||
return found_node_id
|
if found_node_id not in prompt.original_prompt:
|
||||||
|
return None
|
||||||
|
if prompt.original_prompt[found_node_id].get("class_type") == target_class:
|
||||||
|
return found_node_id
|
||||||
|
|
||||||
# If we're not looking for a specific class, update the last valid node
|
# If we're not looking for a specific class, update the last valid node
|
||||||
if not target_class:
|
if not target_class:
|
||||||
@@ -185,11 +219,19 @@ class MetadataProcessor:
|
|||||||
|
|
||||||
# Continue tracing through intermediate nodes
|
# Continue tracing through intermediate nodes
|
||||||
current_node_id = found_node_id
|
current_node_id = found_node_id
|
||||||
# For most conditioning nodes, the input we want to follow is named "conditioning"
|
|
||||||
if "conditioning" in prompt.original_prompt[current_node_id].get("inputs", {}):
|
# Check if current source node exists
|
||||||
|
if current_node_id not in prompt.original_prompt:
|
||||||
|
return found_node_id if not target_class else None
|
||||||
|
|
||||||
|
# Determine which input to follow next on the source node
|
||||||
|
source_node_inputs = prompt.original_prompt[current_node_id].get("inputs", {})
|
||||||
|
if input_name in source_node_inputs:
|
||||||
|
current_input = input_name
|
||||||
|
elif "conditioning" in source_node_inputs:
|
||||||
current_input = "conditioning"
|
current_input = "conditioning"
|
||||||
else:
|
else:
|
||||||
# If there's no "conditioning" input, return the current node
|
# If there's no suitable input to follow, return the current node
|
||||||
# if we're not looking for a specific target_class
|
# if we're not looking for a specific target_class
|
||||||
return found_node_id if not target_class else None
|
return found_node_id if not target_class else None
|
||||||
else:
|
else:
|
||||||
@@ -202,12 +244,89 @@ class MetadataProcessor:
|
|||||||
return last_valid_node if not target_class else None
|
return last_valid_node if not target_class else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_primary_checkpoint(metadata):
|
def trace_model_path(metadata, prompt, start_node_id):
|
||||||
"""Find the primary checkpoint model in the workflow"""
|
"""
|
||||||
|
Trace the model connection path upstream to find the checkpoint
|
||||||
|
"""
|
||||||
|
if not prompt or not prompt.original_prompt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_node_id = start_node_id
|
||||||
|
depth = 0
|
||||||
|
max_depth = 50
|
||||||
|
|
||||||
|
while depth < max_depth:
|
||||||
|
# Check if current node is a registered checkpoint in our metadata
|
||||||
|
# This handles cached nodes correctly because metadata contains info for all nodes in the graph
|
||||||
|
if current_node_id in metadata.get(MODELS, {}):
|
||||||
|
if metadata[MODELS][current_node_id].get("type") == "checkpoint":
|
||||||
|
return current_node_id
|
||||||
|
|
||||||
|
if current_node_id not in prompt.original_prompt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
node = prompt.original_prompt[current_node_id]
|
||||||
|
inputs = node.get("inputs", {})
|
||||||
|
class_type = node.get("class_type", "")
|
||||||
|
|
||||||
|
# Determine which input to follow next
|
||||||
|
next_input_name = "model"
|
||||||
|
|
||||||
|
# Special handling for initial node
|
||||||
|
if depth == 0:
|
||||||
|
if class_type == "SamplerCustomAdvanced":
|
||||||
|
next_input_name = "guider"
|
||||||
|
|
||||||
|
# If the specific input doesn't exist, try generic 'model'
|
||||||
|
if next_input_name not in inputs:
|
||||||
|
if "model" in inputs:
|
||||||
|
next_input_name = "model"
|
||||||
|
elif "basic_pipe" in inputs:
|
||||||
|
# Handle pipe nodes like FromBasicPipe by following the pipeline
|
||||||
|
next_input_name = "basic_pipe"
|
||||||
|
else:
|
||||||
|
# Dead end - no model input to follow
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get connected node
|
||||||
|
input_val = inputs[next_input_name]
|
||||||
|
if isinstance(input_val, list) and len(input_val) > 0:
|
||||||
|
current_node_id = input_val[0]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
depth += 1
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def find_primary_checkpoint(metadata, downstream_id=None, primary_sampler_id=None):
|
||||||
|
"""
|
||||||
|
Find the primary checkpoint model in the workflow
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- metadata: The workflow metadata
|
||||||
|
- downstream_id: Optional ID of a downstream node to help identify the specific primary sampler
|
||||||
|
- primary_sampler_id: Optional ID of the primary sampler if already known
|
||||||
|
"""
|
||||||
if not metadata.get(MODELS):
|
if not metadata.get(MODELS):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# In most workflows, there's only one checkpoint, so we can just take the first one
|
# Method 1: Topology-based tracing (More accurate for complex workflows)
|
||||||
|
# First, find the primary sampler if not provided
|
||||||
|
if not primary_sampler_id:
|
||||||
|
primary_sampler_id, _ = MetadataProcessor.find_primary_sampler(metadata, downstream_id)
|
||||||
|
|
||||||
|
if primary_sampler_id:
|
||||||
|
prompt = metadata.get("current_prompt")
|
||||||
|
if prompt:
|
||||||
|
# Trace back from the sampler to find the checkpoint
|
||||||
|
checkpoint_id = MetadataProcessor.trace_model_path(metadata, prompt, primary_sampler_id)
|
||||||
|
if checkpoint_id and checkpoint_id in metadata.get(MODELS, {}):
|
||||||
|
return metadata[MODELS][checkpoint_id].get("name")
|
||||||
|
|
||||||
|
# Method 2: Fallback to the first available checkpoint (Original behavior)
|
||||||
|
# In most simple workflows, there's only one checkpoint, so we can just take the first one
|
||||||
for node_id, model_info in metadata.get(MODELS, {}).items():
|
for node_id, model_info in metadata.get(MODELS, {}).items():
|
||||||
if model_info.get("type") == "checkpoint":
|
if model_info.get("type") == "checkpoint":
|
||||||
return model_info.get("name")
|
return model_info.get("name")
|
||||||
@@ -311,7 +430,8 @@ class MetadataProcessor:
|
|||||||
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
|
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
|
||||||
|
|
||||||
# Directly get checkpoint from metadata instead of tracing
|
# Directly get checkpoint from metadata instead of tracing
|
||||||
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata)
|
# Pass primary_sampler_id to avoid redundant calculation
|
||||||
|
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata, id, primary_sampler_id)
|
||||||
if checkpoint:
|
if checkpoint:
|
||||||
params["checkpoint"] = checkpoint
|
params["checkpoint"] = checkpoint
|
||||||
|
|
||||||
@@ -445,6 +565,7 @@ class MetadataProcessor:
|
|||||||
scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {})
|
scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {})
|
||||||
params["steps"] = scheduler_params.get("steps")
|
params["steps"] = scheduler_params.get("steps")
|
||||||
params["scheduler"] = scheduler_params.get("scheduler")
|
params["scheduler"] = scheduler_params.get("scheduler")
|
||||||
|
params["denoise"] = scheduler_params.get("denoise")
|
||||||
|
|
||||||
# 2. Trace sampler input to find KSamplerSelect (only if sampler input exists)
|
# 2. Trace sampler input to find KSamplerSelect (only if sampler input exists)
|
||||||
if "sampler" in sampler_inputs:
|
if "sampler" in sampler_inputs:
|
||||||
|
|||||||
@@ -196,9 +196,11 @@ class MetadataRegistry:
|
|||||||
node_metadata[category] = {}
|
node_metadata[category] = {}
|
||||||
node_metadata[category][node_id] = current_metadata[category][node_id]
|
node_metadata[category][node_id] = current_metadata[category][node_id]
|
||||||
|
|
||||||
# Save to cache if we have any metadata for this node
|
# Save new metadata or clear stale cache entries when metadata is empty
|
||||||
if any(node_metadata.values()):
|
if any(node_metadata.values()):
|
||||||
self.node_cache[cache_key] = node_metadata
|
self.node_cache[cache_key] = node_metadata
|
||||||
|
else:
|
||||||
|
self.node_cache.pop(cache_key, None)
|
||||||
|
|
||||||
def clear_unused_cache(self):
|
def clear_unused_cache(self):
|
||||||
"""Clean up node_cache entries that are no longer in use"""
|
"""Clean up node_cache entries that are no longer in use"""
|
||||||
|
|||||||
65
py/middleware/csp_middleware.py
Normal file
65
py/middleware/csp_middleware.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Middleware helpers for adjusting Content Security Policy headers."""
|
||||||
|
|
||||||
|
from typing import Awaitable, Callable, Dict, List
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
REMOTE_MEDIA_SOURCES = (
|
||||||
|
"https://image.civitai.com",
|
||||||
|
"https://img.genur.art",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def relax_csp_for_remote_media(
|
||||||
|
request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]]
|
||||||
|
) -> web.StreamResponse:
|
||||||
|
"""Allow LoRA Manager media previews to load from trusted remote domains.
|
||||||
|
|
||||||
|
When ComfyUI is started with ``--disable-api-nodes`` it injects a restrictive
|
||||||
|
``Content-Security-Policy`` header that blocks remote images and videos. The
|
||||||
|
LoRA Manager UI legitimately needs to fetch previews from Civitai and Genur,
|
||||||
|
so this middleware augments the existing CSP to whitelist those hosts while
|
||||||
|
preserving all other directives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
response: web.StreamResponse = await handler(request)
|
||||||
|
header_value = response.headers.get("Content-Security-Policy")
|
||||||
|
|
||||||
|
if not header_value:
|
||||||
|
return response
|
||||||
|
|
||||||
|
directive_order: List[str] = []
|
||||||
|
directives: Dict[str, List[str]] = {}
|
||||||
|
|
||||||
|
for raw_directive in header_value.split(";"):
|
||||||
|
directive = raw_directive.strip()
|
||||||
|
if not directive:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = directive.split()
|
||||||
|
name, values = parts[0], parts[1:]
|
||||||
|
if name not in directive_order:
|
||||||
|
directive_order.append(name)
|
||||||
|
directives[name] = values
|
||||||
|
|
||||||
|
def merge_sources(name: str, sources: List[str], defaults: List[str] | None = None) -> None:
|
||||||
|
existing = directives.get(name, list(defaults or []))
|
||||||
|
|
||||||
|
for source in sources:
|
||||||
|
if source not in existing:
|
||||||
|
existing.append(source)
|
||||||
|
|
||||||
|
directives[name] = existing
|
||||||
|
if name not in directive_order:
|
||||||
|
directive_order.append(name)
|
||||||
|
|
||||||
|
merge_sources("img-src", list(REMOTE_MEDIA_SOURCES))
|
||||||
|
merge_sources("media-src", ["'self'", *REMOTE_MEDIA_SOURCES], defaults=["'self'"])
|
||||||
|
|
||||||
|
updated_header = "; ".join(
|
||||||
|
f"{name} {' '.join(directives[name])}".rstrip() for name in directive_order
|
||||||
|
)
|
||||||
|
|
||||||
|
response.headers["Content-Security-Policy"] = f"{updated_header};"
|
||||||
|
return response
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
from server import PromptServer # type: ignore
|
|
||||||
from ..metadata_collector.metadata_processor import MetadataProcessor
|
from ..metadata_collector.metadata_processor import MetadataProcessor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class DebugMetadata:
|
class DebugMetadata:
|
||||||
NAME = "Debug Metadata (LoraManager)"
|
NAME = "Debug Metadata (LoraManager)"
|
||||||
CATEGORY = "Lora Manager/utils"
|
CATEGORY = "Lora Manager/utils"
|
||||||
@@ -25,21 +25,37 @@ class DebugMetadata:
|
|||||||
FUNCTION = "process_metadata"
|
FUNCTION = "process_metadata"
|
||||||
|
|
||||||
def process_metadata(self, images, id):
|
def process_metadata(self, images, id):
|
||||||
|
"""
|
||||||
|
Process metadata from the execution context and return it for UI display.
|
||||||
|
|
||||||
|
The metadata is returned via the 'ui' key in the return dict, which triggers
|
||||||
|
node.onExecuted on the frontend to update the JsonDisplayWidget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
images: Input images (required for execution flow)
|
||||||
|
id: Node's unique ID (hidden)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'result' (empty tuple) and 'ui' (metadata dict for widget display)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get the current execution context's metadata
|
# Get the current execution context's metadata
|
||||||
from ..metadata_collector import get_metadata
|
from ..metadata_collector import get_metadata
|
||||||
|
|
||||||
metadata = get_metadata()
|
metadata = get_metadata()
|
||||||
|
|
||||||
# Use the MetadataProcessor to convert it to JSON string
|
# Use the MetadataProcessor to convert it to dict
|
||||||
metadata_json = MetadataProcessor.to_json(metadata, id)
|
metadata_dict = MetadataProcessor.to_dict(metadata, id)
|
||||||
|
|
||||||
# Send metadata to frontend for display
|
return {
|
||||||
PromptServer.instance.send_sync("metadata_update", {
|
"result": (),
|
||||||
"id": id,
|
# ComfyUI expects ui values to be lists, wrap the dict in a list
|
||||||
"metadata": metadata_json
|
"ui": {"metadata": [metadata_dict]},
|
||||||
})
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing metadata: {e}")
|
logger.error(f"Error processing metadata: {e}")
|
||||||
|
return {
|
||||||
return ()
|
"result": (),
|
||||||
|
"ui": {"metadata": [{"error": str(e)}]},
|
||||||
|
}
|
||||||
|
|||||||
87
py/nodes/lora_pool.py
Normal file
87
py/nodes/lora_pool.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""
|
||||||
|
LoRA Pool Node - Defines filter configuration for LoRA selection.
|
||||||
|
|
||||||
|
This node provides a visual filter editor that generates a LORA_POOL_CONFIG
|
||||||
|
object for use by downstream nodes (like LoRA Randomizer).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoraPoolNode:
|
||||||
|
"""
|
||||||
|
A node that defines LoRA filter criteria through a Vue-based widget.
|
||||||
|
|
||||||
|
Outputs a LORA_POOL_CONFIG that can be consumed by:
|
||||||
|
- Frontend: LoRA Randomizer widget reads connected pool's widget value
|
||||||
|
- Backend: LoRA Randomizer receives config during workflow execution
|
||||||
|
"""
|
||||||
|
|
||||||
|
NAME = "Lora Pool (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/randomizer"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"pool_config": ("LORA_POOL_CONFIG", {}),
|
||||||
|
},
|
||||||
|
"hidden": {
|
||||||
|
# Hidden input to pass through unique node ID for frontend
|
||||||
|
"unique_id": "UNIQUE_ID",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("POOL_CONFIG",)
|
||||||
|
RETURN_NAMES = ("POOL_CONFIG",)
|
||||||
|
|
||||||
|
FUNCTION = "process"
|
||||||
|
OUTPUT_NODE = False
|
||||||
|
|
||||||
|
def process(self, pool_config, unique_id=None):
|
||||||
|
"""
|
||||||
|
Pass through the pool configuration filters.
|
||||||
|
|
||||||
|
The config is generated entirely by the frontend widget.
|
||||||
|
This function validates and returns only the filters field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pool_config: Dict containing filter criteria from widget
|
||||||
|
unique_id: Node's unique ID (hidden)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple containing the filters dict from pool_config
|
||||||
|
"""
|
||||||
|
# Validate required structure
|
||||||
|
if not isinstance(pool_config, dict):
|
||||||
|
logger.warning("Invalid pool_config type, using empty config")
|
||||||
|
pool_config = self._default_config()
|
||||||
|
|
||||||
|
# Ensure version field exists
|
||||||
|
if "version" not in pool_config:
|
||||||
|
pool_config["version"] = 1
|
||||||
|
|
||||||
|
# Extract filters field
|
||||||
|
filters = pool_config.get("filters", self._default_config()["filters"])
|
||||||
|
|
||||||
|
# Log for debugging
|
||||||
|
logger.debug(f"[LoraPoolNode] Processing filters: {filters}")
|
||||||
|
|
||||||
|
return (filters,)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_config():
|
||||||
|
"""Return default empty configuration."""
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"filters": {
|
||||||
|
"baseModels": [],
|
||||||
|
"tags": {"include": [], "exclude": []},
|
||||||
|
"folders": {"include": [], "exclude": []},
|
||||||
|
"favoritesOnly": False,
|
||||||
|
"license": {"noCreditRequired": False, "allowSelling": False},
|
||||||
|
},
|
||||||
|
"preview": {"matchCount": 0, "lastUpdated": 0},
|
||||||
|
}
|
||||||
206
py/nodes/lora_randomizer.py
Normal file
206
py/nodes/lora_randomizer.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""
|
||||||
|
Lora Randomizer Node - Randomly selects LoRAs from a pool with configurable settings.
|
||||||
|
|
||||||
|
This node accepts optional pool_config input to filter available LoRAs, and outputs
|
||||||
|
a LORA_STACK with randomly selected LoRAs. Returns UI updates with new random LoRAs
|
||||||
|
and tracks the last used combination for reuse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import os
|
||||||
|
from ..utils.utils import get_lora_info
|
||||||
|
from .utils import extract_lora_name
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoraRandomizerNode:
|
||||||
|
"""Node that randomly selects LoRAs from a pool"""
|
||||||
|
|
||||||
|
NAME = "Lora Randomizer (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/randomizer"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"randomizer_config": ("RANDOMIZER_CONFIG", {}),
|
||||||
|
"loras": ("LORAS", {}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"pool_config": ("POOL_CONFIG", {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("LORA_STACK",)
|
||||||
|
RETURN_NAMES = ("LORA_STACK",)
|
||||||
|
|
||||||
|
FUNCTION = "randomize"
|
||||||
|
OUTPUT_NODE = False
|
||||||
|
|
||||||
|
def _preprocess_loras_input(self, loras):
|
||||||
|
"""
|
||||||
|
Preprocess loras input to handle different widget formats.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
loras: Input from widget, either:
|
||||||
|
- List of LoRA dicts (expected format)
|
||||||
|
- Dict with '__value__' key containing the list
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of LoRA dicts
|
||||||
|
"""
|
||||||
|
if isinstance(loras, dict) and "__value__" in loras:
|
||||||
|
return loras["__value__"]
|
||||||
|
return loras
|
||||||
|
|
||||||
|
async def randomize(self, randomizer_config, loras, pool_config=None):
|
||||||
|
"""
|
||||||
|
Randomize LoRAs based on configuration and pool filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
randomizer_config: Dict with randomizer settings (count, strength ranges, roll_mode)
|
||||||
|
loras: List of LoRA dicts from LORAS widget (includes locked state)
|
||||||
|
pool_config: Optional config from LoRA Pool node for filtering
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with 'result' (LORA_STACK tuple) and 'ui' (for widget display)
|
||||||
|
"""
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
loras = self._preprocess_loras_input(loras)
|
||||||
|
|
||||||
|
roll_mode = randomizer_config.get("roll_mode", "always")
|
||||||
|
logger.debug(f"[LoraRandomizerNode] roll_mode: {roll_mode}")
|
||||||
|
|
||||||
|
# Dual seed mechanism for batch queue synchronization
|
||||||
|
# execution_seed: seed for generating execution_stack (= previous next_seed)
|
||||||
|
# next_seed: seed for generating ui_loras (= what will be displayed after execution)
|
||||||
|
execution_seed = randomizer_config.get("execution_seed", None)
|
||||||
|
next_seed = randomizer_config.get("next_seed", None)
|
||||||
|
|
||||||
|
if roll_mode == "fixed":
|
||||||
|
ui_loras = loras
|
||||||
|
execution_loras = loras
|
||||||
|
else:
|
||||||
|
scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
|
||||||
|
# Generate execution_loras from execution_seed (if available)
|
||||||
|
if execution_seed is not None:
|
||||||
|
# Use execution_seed to regenerate the same loras that were shown to user
|
||||||
|
execution_loras = await self._generate_random_loras_for_ui(
|
||||||
|
scanner, randomizer_config, loras, pool_config, seed=execution_seed
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# First execution: use loras input (what user sees in the widget)
|
||||||
|
execution_loras = loras
|
||||||
|
|
||||||
|
# Generate ui_loras from next_seed (for display after execution)
|
||||||
|
ui_loras = await self._generate_random_loras_for_ui(
|
||||||
|
scanner, randomizer_config, loras, pool_config, seed=next_seed
|
||||||
|
)
|
||||||
|
|
||||||
|
execution_stack = self._build_execution_stack_from_input(execution_loras)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"result": (execution_stack,),
|
||||||
|
"ui": {"loras": ui_loras, "last_used": execution_loras},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_execution_stack_from_input(self, loras):
|
||||||
|
"""
|
||||||
|
Build LORA_STACK tuple from input loras list for execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
loras: List of LoRA dicts with name, strength, clipStrength, active
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (lora_path, model_strength, clip_strength)
|
||||||
|
"""
|
||||||
|
lora_stack = []
|
||||||
|
for lora in loras:
|
||||||
|
if not lora.get("active", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get file path
|
||||||
|
lora_path, trigger_words = get_lora_info(lora["name"])
|
||||||
|
if not lora_path:
|
||||||
|
logger.warning(
|
||||||
|
f"[LoraRandomizerNode] Could not find path for LoRA: {lora['name']}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Normalize path separators
|
||||||
|
lora_path = lora_path.replace("/", os.sep)
|
||||||
|
|
||||||
|
# Extract strengths (convert to float to prevent string subtraction errors)
|
||||||
|
model_strength = float(lora.get("strength", 1.0))
|
||||||
|
clip_strength = float(lora.get("clipStrength", model_strength))
|
||||||
|
|
||||||
|
lora_stack.append((lora_path, model_strength, clip_strength))
|
||||||
|
|
||||||
|
return lora_stack
|
||||||
|
|
||||||
|
async def _generate_random_loras_for_ui(
|
||||||
|
self, scanner, randomizer_config, input_loras, pool_config=None, seed=None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate new random loras for UI display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scanner: LoraScanner instance
|
||||||
|
randomizer_config: Dict with randomizer settings
|
||||||
|
input_loras: Current input loras (for extracting locked loras)
|
||||||
|
pool_config: Optional pool filters
|
||||||
|
seed: Optional seed for deterministic randomization
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of LoRA dicts for UI display
|
||||||
|
"""
|
||||||
|
from ..services.lora_service import LoraService
|
||||||
|
|
||||||
|
# Parse randomizer settings (convert numeric values to float to prevent type errors)
|
||||||
|
count_mode = randomizer_config.get("count_mode", "range")
|
||||||
|
count_fixed = int(randomizer_config.get("count_fixed", 5))
|
||||||
|
count_min = int(randomizer_config.get("count_min", 3))
|
||||||
|
count_max = int(randomizer_config.get("count_max", 7))
|
||||||
|
model_strength_min = float(randomizer_config.get("model_strength_min", 0.0))
|
||||||
|
model_strength_max = float(randomizer_config.get("model_strength_max", 1.0))
|
||||||
|
use_same_clip_strength = randomizer_config.get("use_same_clip_strength", True)
|
||||||
|
clip_strength_min = float(randomizer_config.get("clip_strength_min", 0.0))
|
||||||
|
clip_strength_max = float(randomizer_config.get("clip_strength_max", 1.0))
|
||||||
|
use_recommended_strength = randomizer_config.get(
|
||||||
|
"use_recommended_strength", False
|
||||||
|
)
|
||||||
|
recommended_strength_scale_min = float(
|
||||||
|
randomizer_config.get("recommended_strength_scale_min", 0.5)
|
||||||
|
)
|
||||||
|
recommended_strength_scale_max = float(
|
||||||
|
randomizer_config.get("recommended_strength_scale_max", 1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract locked LoRAs from input
|
||||||
|
locked_loras = [lora for lora in input_loras if lora.get("locked", False)]
|
||||||
|
|
||||||
|
# Use LoraService to generate random LoRAs
|
||||||
|
lora_service = LoraService(scanner)
|
||||||
|
result_loras = await lora_service.get_random_loras(
|
||||||
|
count=count_fixed,
|
||||||
|
model_strength_min=model_strength_min,
|
||||||
|
model_strength_max=model_strength_max,
|
||||||
|
use_same_clip_strength=use_same_clip_strength,
|
||||||
|
clip_strength_min=clip_strength_min,
|
||||||
|
clip_strength_max=clip_strength_max,
|
||||||
|
locked_loras=locked_loras,
|
||||||
|
pool_config=pool_config,
|
||||||
|
count_mode=count_mode,
|
||||||
|
count_min=count_min,
|
||||||
|
count_max=count_max,
|
||||||
|
use_recommended_strength=use_recommended_strength,
|
||||||
|
recommended_strength_scale_min=recommended_strength_scale_min,
|
||||||
|
recommended_strength_scale_max=recommended_strength_scale_max,
|
||||||
|
seed=seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result_loras
|
||||||
@@ -9,7 +9,7 @@ from ..metadata_collector import get_metadata
|
|||||||
from PIL import Image, PngImagePlugin
|
from PIL import Image, PngImagePlugin
|
||||||
import piexif
|
import piexif
|
||||||
|
|
||||||
class SaveImage:
|
class SaveImageLM:
|
||||||
NAME = "Save Image (LoraManager)"
|
NAME = "Save Image (LoraManager)"
|
||||||
CATEGORY = "Lora Manager/utils"
|
CATEGORY = "Lora Manager/utils"
|
||||||
DESCRIPTION = "Save images with embedded generation metadata in compatible format"
|
DESCRIPTION = "Save images with embedded generation metadata in compatible format"
|
||||||
|
|||||||
@@ -73,102 +73,80 @@ class TriggerWordToggle:
|
|||||||
if isinstance(trigger_data, str):
|
if isinstance(trigger_data, str):
|
||||||
trigger_data = json.loads(trigger_data)
|
trigger_data = json.loads(trigger_data)
|
||||||
|
|
||||||
# Create dictionaries to track active state of words or groups
|
if isinstance(trigger_data, list):
|
||||||
# Also track strength values for each trigger word
|
if group_mode:
|
||||||
active_state = {}
|
|
||||||
strength_map = {}
|
|
||||||
|
|
||||||
for item in trigger_data:
|
|
||||||
text = item['text']
|
|
||||||
active = item.get('active', False)
|
|
||||||
# Extract strength if it's in the format "(word:strength)"
|
|
||||||
strength_match = re.match(r'\((.+):([\d.]+)\)', text)
|
|
||||||
if strength_match:
|
|
||||||
original_word = strength_match.group(1).strip()
|
|
||||||
strength = float(strength_match.group(2))
|
|
||||||
active_state[original_word] = active
|
|
||||||
if allow_strength_adjustment:
|
if allow_strength_adjustment:
|
||||||
strength_map[original_word] = strength
|
parsed_items = [
|
||||||
else:
|
self._parse_trigger_item(item, allow_strength_adjustment)
|
||||||
active_state[text.strip()] = active
|
for item in trigger_data
|
||||||
|
]
|
||||||
if group_mode:
|
filtered_groups = [
|
||||||
if isinstance(trigger_data, list):
|
|
||||||
filtered_groups = []
|
|
||||||
for item in trigger_data:
|
|
||||||
text = (item.get('text') or "").strip()
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
if item.get('active', False):
|
|
||||||
filtered_groups.append(text)
|
|
||||||
|
|
||||||
if filtered_groups:
|
|
||||||
filtered_triggers = ', '.join(filtered_groups)
|
|
||||||
else:
|
|
||||||
filtered_triggers = ""
|
|
||||||
else:
|
|
||||||
# Split by two or more consecutive commas to get groups
|
|
||||||
groups = re.split(r',{2,}', trigger_words)
|
|
||||||
# Remove leading/trailing whitespace from each group
|
|
||||||
groups = [group.strip() for group in groups]
|
|
||||||
|
|
||||||
# Process groups: keep those not in toggle_trigger_words or those that are active
|
|
||||||
filtered_groups = []
|
|
||||||
for group in groups:
|
|
||||||
# Check if this group contains any words that are in the active_state
|
|
||||||
group_words = [word.strip() for word in group.split(',')]
|
|
||||||
active_group_words = []
|
|
||||||
|
|
||||||
for word in group_words:
|
|
||||||
word_comparison = re.sub(r'\((.+):([\d.]+)\)', r'\1', word).strip()
|
|
||||||
|
|
||||||
if word_comparison not in active_state or active_state[word_comparison]:
|
|
||||||
active_group_words.append(
|
|
||||||
self._format_word_output(
|
|
||||||
word_comparison,
|
|
||||||
strength_map,
|
|
||||||
allow_strength_adjustment,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if active_group_words:
|
|
||||||
filtered_groups.append(', '.join(active_group_words))
|
|
||||||
|
|
||||||
if filtered_groups:
|
|
||||||
filtered_triggers = ', '.join(filtered_groups)
|
|
||||||
else:
|
|
||||||
filtered_triggers = ""
|
|
||||||
else:
|
|
||||||
# Normal mode: split by commas and treat each word as a separate tag
|
|
||||||
original_words = [word.strip() for word in trigger_words.split(',')]
|
|
||||||
# Filter out empty strings
|
|
||||||
original_words = [word for word in original_words if word]
|
|
||||||
|
|
||||||
filtered_words = []
|
|
||||||
for word in original_words:
|
|
||||||
# Remove any existing strength formatting for comparison
|
|
||||||
word_comparison = re.sub(r'\((.+):([\d.]+)\)', r'\1', word).strip()
|
|
||||||
|
|
||||||
if word_comparison not in active_state or active_state[word_comparison]:
|
|
||||||
filtered_words.append(
|
|
||||||
self._format_word_output(
|
self._format_word_output(
|
||||||
word_comparison,
|
item["text"],
|
||||||
strength_map,
|
item["strength"],
|
||||||
allow_strength_adjustment,
|
allow_strength_adjustment,
|
||||||
)
|
)
|
||||||
)
|
for item in parsed_items
|
||||||
|
if item["text"] and item["active"]
|
||||||
if filtered_words:
|
]
|
||||||
filtered_triggers = ', '.join(filtered_words)
|
else:
|
||||||
|
filtered_groups = [
|
||||||
|
(item.get('text') or "").strip()
|
||||||
|
for item in trigger_data
|
||||||
|
if (item.get('text') or "").strip() and item.get('active', False)
|
||||||
|
]
|
||||||
|
filtered_triggers = ', '.join(filtered_groups) if filtered_groups else ""
|
||||||
else:
|
else:
|
||||||
filtered_triggers = ""
|
parsed_items = [
|
||||||
|
self._parse_trigger_item(item, allow_strength_adjustment)
|
||||||
|
for item in trigger_data
|
||||||
|
]
|
||||||
|
filtered_words = [
|
||||||
|
self._format_word_output(
|
||||||
|
item["text"],
|
||||||
|
item["strength"],
|
||||||
|
allow_strength_adjustment,
|
||||||
|
)
|
||||||
|
for item in parsed_items
|
||||||
|
if item["text"] and item["active"]
|
||||||
|
]
|
||||||
|
filtered_triggers = ', '.join(filtered_words) if filtered_words else ""
|
||||||
|
else:
|
||||||
|
# Fallback to original message parsing if data is not in the expected list format
|
||||||
|
if group_mode:
|
||||||
|
groups = re.split(r',{2,}', trigger_words)
|
||||||
|
groups = [group.strip() for group in groups if group.strip()]
|
||||||
|
filtered_triggers = ', '.join(groups)
|
||||||
|
else:
|
||||||
|
words = [word.strip() for word in trigger_words.split(',') if word.strip()]
|
||||||
|
filtered_triggers = ', '.join(words)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing trigger words: {e}")
|
logger.error(f"Error processing trigger words: {e}")
|
||||||
|
|
||||||
return (filtered_triggers,)
|
return (filtered_triggers,)
|
||||||
|
|
||||||
def _format_word_output(self, base_word, strength_map, allow_strength_adjustment):
|
def _parse_trigger_item(self, item, allow_strength_adjustment):
|
||||||
if allow_strength_adjustment and base_word in strength_map:
|
text = (item.get('text') or "").strip()
|
||||||
return f"({base_word}:{strength_map[base_word]:.2f})"
|
active = bool(item.get('active', False))
|
||||||
|
strength = item.get('strength')
|
||||||
|
|
||||||
|
strength_match = re.match(r'^\((.+):([\d.]+)\)$', text)
|
||||||
|
if strength_match:
|
||||||
|
text = strength_match.group(1).strip()
|
||||||
|
if strength is None:
|
||||||
|
try:
|
||||||
|
strength = float(strength_match.group(2))
|
||||||
|
except ValueError:
|
||||||
|
strength = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": text,
|
||||||
|
"active": active,
|
||||||
|
"strength": strength if allow_strength_adjustment else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _format_word_output(self, base_word, strength, allow_strength_adjustment):
|
||||||
|
if allow_strength_adjustment and strength is not None:
|
||||||
|
return f"({base_word}:{strength:.2f})"
|
||||||
return base_word
|
return base_word
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ any_type = AnyType("*")
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import copy
|
import copy
|
||||||
|
import sys
|
||||||
import folder_paths
|
import folder_paths
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -98,25 +99,37 @@ def to_diffusers(input_lora):
|
|||||||
|
|
||||||
def nunchaku_load_lora(model, lora_name, lora_strength):
|
def nunchaku_load_lora(model, lora_name, lora_strength):
|
||||||
"""Load a Flux LoRA for Nunchaku model"""
|
"""Load a Flux LoRA for Nunchaku model"""
|
||||||
model_wrapper = model.model.diffusion_model
|
|
||||||
transformer = model_wrapper.model
|
|
||||||
|
|
||||||
# Save the transformer temporarily
|
|
||||||
model_wrapper.model = None
|
|
||||||
ret_model = copy.deepcopy(model) # copy everything except the model
|
|
||||||
ret_model_wrapper = ret_model.model.diffusion_model
|
|
||||||
|
|
||||||
# Restore the model and set it for the copy
|
|
||||||
model_wrapper.model = transformer
|
|
||||||
ret_model_wrapper.model = transformer
|
|
||||||
|
|
||||||
# Get full path to the LoRA file. Allow both direct paths and registered LoRA names.
|
# Get full path to the LoRA file. Allow both direct paths and registered LoRA names.
|
||||||
lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name)
|
lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name)
|
||||||
if not lora_path or not os.path.isfile(lora_path):
|
if not lora_path or not os.path.isfile(lora_path):
|
||||||
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
|
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
model_wrapper = model.model.diffusion_model
|
||||||
|
|
||||||
|
# Try to find copy_with_ctx in the same module as ComfyFluxWrapper
|
||||||
|
module_name = model_wrapper.__class__.__module__
|
||||||
|
module = sys.modules.get(module_name)
|
||||||
|
copy_with_ctx = getattr(module, "copy_with_ctx", None)
|
||||||
|
|
||||||
|
if copy_with_ctx is not None:
|
||||||
|
# New logic using copy_with_ctx from ComfyUI-nunchaku 1.1.0+
|
||||||
|
ret_model_wrapper, ret_model = copy_with_ctx(model_wrapper)
|
||||||
|
ret_model_wrapper.loras = [*model_wrapper.loras, (lora_path, lora_strength)]
|
||||||
|
else:
|
||||||
|
# Fallback to legacy logic
|
||||||
|
logger.warning("Please upgrade ComfyUI-nunchaku to 1.1.0 or above for better LoRA support. Falling back to legacy loading logic.")
|
||||||
|
transformer = model_wrapper.model
|
||||||
|
|
||||||
|
# Save the transformer temporarily
|
||||||
|
model_wrapper.model = None
|
||||||
|
ret_model = copy.deepcopy(model) # copy everything except the model
|
||||||
|
ret_model_wrapper = ret_model.model.diffusion_model
|
||||||
|
|
||||||
|
# Restore the model and set it for the copy
|
||||||
|
model_wrapper.model = transformer
|
||||||
|
ret_model_wrapper.model = transformer
|
||||||
|
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||||
|
|
||||||
# Convert the LoRA to diffusers format
|
# Convert the LoRA to diffusers format
|
||||||
sd = to_diffusers(lora_path)
|
sd = to_diffusers(lora_path)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class WanVideoLoraSelect:
|
class WanVideoLoraSelectLM:
|
||||||
NAME = "WanVideo Lora Select (LoraManager)"
|
NAME = "WanVideo Lora Select (LoraManager)"
|
||||||
CATEGORY = "Lora Manager/stackers"
|
CATEGORY = "Lora Manager/stackers"
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ class RecipeMetadataParser(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def populate_lora_from_civitai(self, lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any], Optional[str]],
|
@staticmethod
|
||||||
|
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any], Optional[str]],
|
||||||
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
|
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Populate a lora entry with information from Civitai API response
|
Populate a lora entry with information from Civitai API response
|
||||||
@@ -149,7 +150,8 @@ class RecipeMetadataParser(ABC):
|
|||||||
|
|
||||||
return lora_entry
|
return lora_entry
|
||||||
|
|
||||||
async def populate_checkpoint_from_civitai(self, checkpoint: Dict[str, Any], civitai_info: Dict[str, Any]) -> Dict[str, Any]:
|
@staticmethod
|
||||||
|
async def populate_checkpoint_from_civitai(checkpoint: Dict[str, Any], civitai_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Populate checkpoint information from Civitai API response
|
Populate checkpoint information from Civitai API response
|
||||||
|
|
||||||
@@ -187,6 +189,7 @@ class RecipeMetadataParser(ABC):
|
|||||||
checkpoint['downloadUrl'] = civitai_data.get('downloadUrl', '')
|
checkpoint['downloadUrl'] = civitai_data.get('downloadUrl', '')
|
||||||
|
|
||||||
checkpoint['modelId'] = civitai_data.get('modelId', checkpoint.get('modelId', 0))
|
checkpoint['modelId'] = civitai_data.get('modelId', checkpoint.get('modelId', 0))
|
||||||
|
checkpoint['id'] = civitai_data.get('id', 0)
|
||||||
|
|
||||||
if 'files' in civitai_data:
|
if 'files' in civitai_data:
|
||||||
model_file = next(
|
model_file = next(
|
||||||
|
|||||||
216
py/recipes/enrichment.py
Normal file
216
py/recipes/enrichment.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from .merger import GenParamsMerger
|
||||||
|
from .base import RecipeMetadataParser
|
||||||
|
from ..services.metadata_service import get_default_metadata_provider
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RecipeEnricher:
|
||||||
|
"""Service to enrich recipe metadata from multiple sources (Civitai, Embedded, User)."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def enrich_recipe(
|
||||||
|
recipe: Dict[str, Any],
|
||||||
|
civitai_client: Any,
|
||||||
|
request_params: Optional[Dict[str, Any]] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
||||||
|
civitai_client: Authenticated Civitai client instance.
|
||||||
|
request_params: (Optional) Parameters from a user request (e.g. import).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the recipe was modified, False otherwise.
|
||||||
|
"""
|
||||||
|
updated = False
|
||||||
|
gen_params = recipe.get("gen_params", {})
|
||||||
|
|
||||||
|
# 1. Fetch Civitai Info if available
|
||||||
|
civitai_meta = None
|
||||||
|
model_version_id = None
|
||||||
|
|
||||||
|
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
||||||
|
|
||||||
|
# Check if it's a Civitai image URL
|
||||||
|
image_id_match = re.search(r'civitai\.com/images/(\d+)', str(source_url))
|
||||||
|
if image_id_match:
|
||||||
|
image_id = image_id_match.group(1)
|
||||||
|
try:
|
||||||
|
image_info = await civitai_client.get_image_info(image_id)
|
||||||
|
if image_info:
|
||||||
|
# Handle nested meta often found in Civitai API responses
|
||||||
|
raw_meta = image_info.get("meta")
|
||||||
|
if isinstance(raw_meta, dict):
|
||||||
|
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||||
|
civitai_meta = raw_meta["meta"]
|
||||||
|
else:
|
||||||
|
civitai_meta = raw_meta
|
||||||
|
|
||||||
|
model_version_id = image_info.get("modelVersionId")
|
||||||
|
|
||||||
|
# If not at top level, check resources in meta
|
||||||
|
if not model_version_id and civitai_meta:
|
||||||
|
resources = civitai_meta.get("civitaiResources", [])
|
||||||
|
for res in resources:
|
||||||
|
if res.get("type") == "checkpoint":
|
||||||
|
model_version_id = res.get("modelVersionId")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
||||||
|
|
||||||
|
# 2. Merge Parameters
|
||||||
|
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
||||||
|
new_gen_params = GenParamsMerger.merge(
|
||||||
|
request_params=request_params,
|
||||||
|
civitai_meta=civitai_meta,
|
||||||
|
embedded_metadata=gen_params
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_gen_params != gen_params:
|
||||||
|
recipe["gen_params"] = new_gen_params
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# 3. Checkpoint Enrichment
|
||||||
|
# If we have a checkpoint entry, or we can find one
|
||||||
|
# Use 'id' (from Civitai version) as a marker that it's been enriched
|
||||||
|
checkpoint_entry = recipe.get("checkpoint")
|
||||||
|
has_full_checkpoint = checkpoint_entry and checkpoint_entry.get("name") and checkpoint_entry.get("id")
|
||||||
|
|
||||||
|
if not has_full_checkpoint:
|
||||||
|
# Helper to look up values in priority order
|
||||||
|
def start_lookup(keys):
|
||||||
|
for source in [request_params, civitai_meta, gen_params]:
|
||||||
|
if source:
|
||||||
|
if isinstance(keys, list):
|
||||||
|
for k in keys:
|
||||||
|
if k in source: return source[k]
|
||||||
|
else:
|
||||||
|
if keys in source: return source[keys]
|
||||||
|
return None
|
||||||
|
|
||||||
|
target_version_id = model_version_id or start_lookup("modelVersionId")
|
||||||
|
|
||||||
|
# Also check existing checkpoint entry
|
||||||
|
if not target_version_id and checkpoint_entry:
|
||||||
|
target_version_id = checkpoint_entry.get("modelVersionId") or checkpoint_entry.get("id")
|
||||||
|
|
||||||
|
# Check for version ID in resources (which might be a string in gen_params)
|
||||||
|
if not target_version_id:
|
||||||
|
# Look in all sources for "Civitai resources"
|
||||||
|
resources_val = start_lookup(["Civitai resources", "civitai_resources", "resources"])
|
||||||
|
if resources_val:
|
||||||
|
target_version_id = RecipeEnricher._extract_version_id_from_resources({"Civitai resources": resources_val})
|
||||||
|
|
||||||
|
target_hash = start_lookup(["Model hash", "checkpoint_hash", "hashes"])
|
||||||
|
if not target_hash and checkpoint_entry:
|
||||||
|
target_hash = checkpoint_entry.get("hash") or checkpoint_entry.get("model_hash")
|
||||||
|
|
||||||
|
# Look for 'Model' which sometimes is the hash or name
|
||||||
|
model_val = start_lookup("Model")
|
||||||
|
|
||||||
|
# Look for Checkpoint name fallback
|
||||||
|
checkpoint_val = checkpoint_entry.get("name") if checkpoint_entry else None
|
||||||
|
if not checkpoint_val:
|
||||||
|
checkpoint_val = start_lookup(["Checkpoint", "checkpoint"])
|
||||||
|
|
||||||
|
checkpoint_updated = await RecipeEnricher._resolve_and_populate_checkpoint(
|
||||||
|
recipe, target_version_id, target_hash, model_val, checkpoint_val
|
||||||
|
)
|
||||||
|
if checkpoint_updated:
|
||||||
|
updated = True
|
||||||
|
else:
|
||||||
|
# Checkpoint exists, no need to sync to gen_params anymore.
|
||||||
|
pass
|
||||||
|
# base_model resolution moved to _resolve_and_populate_checkpoint to support strict formatting
|
||||||
|
return updated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_version_id_from_resources(gen_params: Dict[str, Any]) -> Optional[Any]:
|
||||||
|
"""Try to find modelVersionId in Civitai resources parameter."""
|
||||||
|
civitai_resources_raw = gen_params.get("Civitai resources")
|
||||||
|
if not civitai_resources_raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
resources_list = None
|
||||||
|
if isinstance(civitai_resources_raw, str):
|
||||||
|
try:
|
||||||
|
resources_list = json.loads(civitai_resources_raw)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif isinstance(civitai_resources_raw, list):
|
||||||
|
resources_list = civitai_resources_raw
|
||||||
|
|
||||||
|
if isinstance(resources_list, list):
|
||||||
|
for res in resources_list:
|
||||||
|
if res.get("type") == "checkpoint":
|
||||||
|
return res.get("modelVersionId")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _resolve_and_populate_checkpoint(
|
||||||
|
recipe: Dict[str, Any],
|
||||||
|
target_version_id: Optional[Any],
|
||||||
|
target_hash: Optional[str],
|
||||||
|
model_val: Optional[str],
|
||||||
|
checkpoint_val: Optional[str]
|
||||||
|
) -> bool:
|
||||||
|
"""Find checkpoint metadata and populate it in the recipe."""
|
||||||
|
metadata_provider = await get_default_metadata_provider()
|
||||||
|
civitai_info = None
|
||||||
|
|
||||||
|
if target_version_id:
|
||||||
|
civitai_info = await metadata_provider.get_model_version_info(str(target_version_id))
|
||||||
|
elif target_hash:
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(target_hash)
|
||||||
|
else:
|
||||||
|
# Look for 'Model' which sometimes is the hash or name
|
||||||
|
if model_val and len(model_val) == 10: # Likely a short hash
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(model_val)
|
||||||
|
|
||||||
|
if civitai_info and not (isinstance(civitai_info, tuple) and civitai_info[1] == "Model not found"):
|
||||||
|
# If we already have a partial checkpoint, use it as base
|
||||||
|
existing_cp = recipe.get("checkpoint")
|
||||||
|
if existing_cp is None:
|
||||||
|
existing_cp = {}
|
||||||
|
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
|
||||||
|
# 1. First, resolve base_model using full data before we format it away
|
||||||
|
current_base_model = recipe.get("base_model")
|
||||||
|
resolved_base_model = checkpoint_data.get("baseModel")
|
||||||
|
if resolved_base_model:
|
||||||
|
# Update if empty OR if it matches our generic prefix but is less specific
|
||||||
|
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
|
||||||
|
if is_generic and resolved_base_model != current_base_model:
|
||||||
|
recipe["base_model"] = resolved_base_model
|
||||||
|
|
||||||
|
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName
|
||||||
|
formatted_checkpoint = {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"modelId": checkpoint_data.get("modelId"),
|
||||||
|
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||||
|
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name']
|
||||||
|
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name']
|
||||||
|
}
|
||||||
|
# Remove None values
|
||||||
|
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# Fallback to name extraction if we don't already have one
|
||||||
|
existing_cp = recipe.get("checkpoint")
|
||||||
|
if not existing_cp or not existing_cp.get("modelName"):
|
||||||
|
cp_name = checkpoint_val
|
||||||
|
if cp_name:
|
||||||
|
recipe["checkpoint"] = {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"modelName": cp_name
|
||||||
|
}
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
98
py/recipes/merger.py
Normal file
98
py/recipes/merger.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
from typing import Any, Dict, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class GenParamsMerger:
|
||||||
|
"""Utility to merge generation parameters from multiple sources with priority."""
|
||||||
|
|
||||||
|
BLACKLISTED_KEYS = {
|
||||||
|
"id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta",
|
||||||
|
"draft", "extra", "width", "height", "process", "quantity", "workflow",
|
||||||
|
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
|
||||||
|
"experimental", "civitaiResources", "civitai_resources", "Civitai resources",
|
||||||
|
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
|
||||||
|
"checkpoint", "checksum", "model_checksum"
|
||||||
|
}
|
||||||
|
|
||||||
|
NORMALIZATION_MAPPING = {
|
||||||
|
# Civitai specific
|
||||||
|
"cfgScale": "cfg_scale",
|
||||||
|
"clipSkip": "clip_skip",
|
||||||
|
"negativePrompt": "negative_prompt",
|
||||||
|
# Case variations
|
||||||
|
"Sampler": "sampler",
|
||||||
|
"Steps": "steps",
|
||||||
|
"Seed": "seed",
|
||||||
|
"Size": "size",
|
||||||
|
"Prompt": "prompt",
|
||||||
|
"Negative prompt": "negative_prompt",
|
||||||
|
"Cfg scale": "cfg_scale",
|
||||||
|
"Clip skip": "clip_skip",
|
||||||
|
"Denoising strength": "denoising_strength",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def merge(
|
||||||
|
request_params: Optional[Dict[str, Any]] = None,
|
||||||
|
civitai_meta: Optional[Dict[str, Any]] = None,
|
||||||
|
embedded_metadata: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Merge generation parameters from three sources.
|
||||||
|
|
||||||
|
Priority: request_params > civitai_meta > embedded_metadata
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_params: Params provided directly in the import request
|
||||||
|
civitai_meta: Params from Civitai Image API 'meta' field
|
||||||
|
embedded_metadata: Params extracted from image EXIF/embedded metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Merged parameters dictionary
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# 1. Start with embedded metadata (lowest priority)
|
||||||
|
if embedded_metadata:
|
||||||
|
# If it's a full recipe metadata, we use its gen_params
|
||||||
|
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
|
||||||
|
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
|
||||||
|
else:
|
||||||
|
# Otherwise assume the dict itself contains gen_params
|
||||||
|
GenParamsMerger._update_normalized(result, embedded_metadata)
|
||||||
|
|
||||||
|
# 2. Layer Civitai meta (medium priority)
|
||||||
|
if civitai_meta:
|
||||||
|
GenParamsMerger._update_normalized(result, civitai_meta)
|
||||||
|
|
||||||
|
# 3. Layer request params (highest priority)
|
||||||
|
if request_params:
|
||||||
|
GenParamsMerger._update_normalized(result, request_params)
|
||||||
|
|
||||||
|
# Filter out blacklisted keys and also the original camelCase keys if they were normalized
|
||||||
|
final_result = {}
|
||||||
|
for k, v in result.items():
|
||||||
|
if k in GenParamsMerger.BLACKLISTED_KEYS:
|
||||||
|
continue
|
||||||
|
if k in GenParamsMerger.NORMALIZATION_MAPPING:
|
||||||
|
continue
|
||||||
|
final_result[k] = v
|
||||||
|
|
||||||
|
return final_result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||||
|
"""Update target dict with normalized keys from source."""
|
||||||
|
for k, v in source.items():
|
||||||
|
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(k, k)
|
||||||
|
target[normalized_key] = v
|
||||||
|
# Also keep the original key for now if it's not the same,
|
||||||
|
# so we can filter at the end or avoid losing it if it wasn't supposed to be renamed?
|
||||||
|
# Actually, if we rename it, we should probably NOT keep both in 'target'
|
||||||
|
# because we want to filter them out at the end anyway.
|
||||||
|
if normalized_key != k:
|
||||||
|
# If we are overwriting an existing snake_case key with a camelCase one's value,
|
||||||
|
# that's fine because of the priority order of calls to _update_normalized.
|
||||||
|
pass
|
||||||
|
target[k] = v
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Parser for Automatic1111 metadata format."""
|
"""Parser for Automatic1111 metadata format."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
@@ -22,6 +23,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
CIVITAI_METADATA_REGEX = r', Civitai metadata:\s*(\{.*?\})'
|
CIVITAI_METADATA_REGEX = r', Civitai metadata:\s*(\{.*?\})'
|
||||||
EXTRANETS_REGEX = r'<(lora|hypernet):([^:]+):(-?[0-9.]+)>'
|
EXTRANETS_REGEX = r'<(lora|hypernet):([^:]+):(-?[0-9.]+)>'
|
||||||
MODEL_HASH_PATTERN = r'Model hash: ([a-zA-Z0-9]+)'
|
MODEL_HASH_PATTERN = r'Model hash: ([a-zA-Z0-9]+)'
|
||||||
|
MODEL_NAME_PATTERN = r'Model: ([^,]+)'
|
||||||
VAE_HASH_PATTERN = r'VAE hash: ([a-zA-Z0-9]+)'
|
VAE_HASH_PATTERN = r'VAE hash: ([a-zA-Z0-9]+)'
|
||||||
|
|
||||||
def is_metadata_matching(self, user_comment: str) -> bool:
|
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||||
@@ -115,6 +117,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.error("Error parsing hashes JSON")
|
logger.error("Error parsing hashes JSON")
|
||||||
|
|
||||||
|
# Pick up model hash from parsed hashes if available
|
||||||
|
if "hashes" in metadata and not metadata.get("model_hash"):
|
||||||
|
model_hash_from_hashes = metadata["hashes"].get("model")
|
||||||
|
if model_hash_from_hashes:
|
||||||
|
metadata["model_hash"] = model_hash_from_hashes
|
||||||
|
|
||||||
# Extract Lora hashes in alternative format
|
# Extract Lora hashes in alternative format
|
||||||
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 not hashes_match and lora_hashes_match:
|
||||||
@@ -138,6 +146,17 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing Lora hashes: {e}")
|
logger.error(f"Error parsing Lora hashes: {e}")
|
||||||
|
|
||||||
|
# Extract checkpoint model hash/name when provided outside Civitai resources
|
||||||
|
model_hash_match = re.search(self.MODEL_HASH_PATTERN, params_section)
|
||||||
|
if model_hash_match:
|
||||||
|
metadata["model_hash"] = model_hash_match.group(1).strip()
|
||||||
|
params_section = params_section.replace(model_hash_match.group(0), '')
|
||||||
|
|
||||||
|
model_name_match = re.search(self.MODEL_NAME_PATTERN, params_section)
|
||||||
|
if model_name_match:
|
||||||
|
metadata["model_name"] = model_name_match.group(1).strip()
|
||||||
|
params_section = params_section.replace(model_name_match.group(0), '')
|
||||||
|
|
||||||
# Extract basic parameters
|
# Extract basic parameters
|
||||||
param_pattern = r'([A-Za-z\s]+): ([^,]+)'
|
param_pattern = r'([A-Za-z\s]+): ([^,]+)'
|
||||||
params = re.findall(param_pattern, params_section)
|
params = re.findall(param_pattern, params_section)
|
||||||
@@ -178,9 +197,10 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
metadata["gen_params"] = gen_params
|
metadata["gen_params"] = gen_params
|
||||||
|
|
||||||
# Extract LoRA information
|
# Extract LoRA and checkpoint information
|
||||||
loras = []
|
loras = []
|
||||||
base_model_counts = {}
|
base_model_counts = {}
|
||||||
|
checkpoint = None
|
||||||
|
|
||||||
# First use Civitai resources if available (more reliable source)
|
# First use Civitai resources if available (more reliable source)
|
||||||
if metadata.get("civitai_resources"):
|
if metadata.get("civitai_resources"):
|
||||||
@@ -202,6 +222,50 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
resource["modelVersionId"] = air_modelVersionId
|
resource["modelVersionId"] = air_modelVersionId
|
||||||
# --- End added ---
|
# --- End added ---
|
||||||
|
|
||||||
|
if resource.get("type") == "checkpoint" and resource.get("modelVersionId"):
|
||||||
|
version_id = resource.get("modelVersionId")
|
||||||
|
version_id_str = str(version_id)
|
||||||
|
checkpoint_entry = {
|
||||||
|
'id': version_id,
|
||||||
|
'modelId': resource.get("modelId", 0),
|
||||||
|
'name': resource.get("modelName", "Unknown Checkpoint"),
|
||||||
|
'version': resource.get("modelVersionName", resource.get("versionName", "")),
|
||||||
|
'type': resource.get("type", "checkpoint"),
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': resource.get("modelName", ""),
|
||||||
|
'hash': resource.get("hash", "") or "",
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata_provider:
|
||||||
|
try:
|
||||||
|
civitai_info = await metadata_provider.get_model_version_info(version_id_str)
|
||||||
|
checkpoint_entry = await self.populate_checkpoint_from_civitai(
|
||||||
|
checkpoint_entry,
|
||||||
|
civitai_info
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Error fetching Civitai info for checkpoint version %s: %s",
|
||||||
|
version_id,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prefer the first checkpoint found
|
||||||
|
if checkpoint_entry.get("baseModel"):
|
||||||
|
base_model_value = checkpoint_entry["baseModel"]
|
||||||
|
base_model_counts[base_model_value] = base_model_counts.get(base_model_value, 0) + 1
|
||||||
|
|
||||||
|
if checkpoint is None:
|
||||||
|
checkpoint = checkpoint_entry
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"):
|
if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"):
|
||||||
# Initialize lora entry
|
# Initialize lora entry
|
||||||
lora_entry = {
|
lora_entry = {
|
||||||
@@ -237,6 +301,52 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
loras.append(lora_entry)
|
loras.append(lora_entry)
|
||||||
|
|
||||||
|
# Fallback checkpoint parsing from generic "Model" and "Model hash" fields
|
||||||
|
if checkpoint is None:
|
||||||
|
model_hash = metadata.get("model_hash")
|
||||||
|
if not model_hash and metadata.get("hashes"):
|
||||||
|
model_hash = metadata["hashes"].get("model")
|
||||||
|
|
||||||
|
model_name = metadata.get("model_name")
|
||||||
|
file_name = ""
|
||||||
|
if model_name:
|
||||||
|
cleaned_name = re.split(r"[\\\\/]", model_name)[-1]
|
||||||
|
file_name = os.path.splitext(cleaned_name)[0]
|
||||||
|
|
||||||
|
if model_hash or model_name:
|
||||||
|
checkpoint_entry = {
|
||||||
|
'id': 0,
|
||||||
|
'modelId': 0,
|
||||||
|
'name': model_name or "Unknown Checkpoint",
|
||||||
|
'version': '',
|
||||||
|
'type': 'checkpoint',
|
||||||
|
'hash': model_hash or "",
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': file_name,
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata_provider and model_hash:
|
||||||
|
try:
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(model_hash)
|
||||||
|
checkpoint_entry = await self.populate_checkpoint_from_civitai(
|
||||||
|
checkpoint_entry,
|
||||||
|
civitai_info
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for checkpoint hash {model_hash}: {e}")
|
||||||
|
|
||||||
|
if checkpoint_entry.get("baseModel"):
|
||||||
|
base_model_value = checkpoint_entry["baseModel"]
|
||||||
|
base_model_counts[base_model_value] = base_model_counts.get(base_model_value, 0) + 1
|
||||||
|
|
||||||
|
checkpoint = checkpoint_entry
|
||||||
|
|
||||||
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"]
|
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"]
|
||||||
if not loras or len(loras) == 0:
|
if not loras or len(loras) == 0:
|
||||||
# Extract lora weights from extranet tags in prompt (for later use)
|
# Extract lora weights from extranet tags in prompt (for later use)
|
||||||
@@ -300,7 +410,9 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
# Try to get base model from resources or make educated guess
|
# Try to get base model from resources or make educated guess
|
||||||
base_model = None
|
base_model = None
|
||||||
if base_model_counts:
|
if checkpoint and checkpoint.get("baseModel"):
|
||||||
|
base_model = checkpoint.get("baseModel")
|
||||||
|
elif base_model_counts:
|
||||||
# Use the most common base model from the loras
|
# Use the most common base model from the loras
|
||||||
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||||
|
|
||||||
@@ -318,6 +430,10 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
'from_automatic_metadata': True
|
'from_automatic_metadata': True
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if checkpoint:
|
||||||
|
result['checkpoint'] = checkpoint
|
||||||
|
result['model'] = checkpoint
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -24,12 +24,47 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
if not metadata or not isinstance(metadata, dict):
|
if not metadata or not isinstance(metadata, dict):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check for key markers specific to Civitai image metadata
|
def has_markers(payload: Dict[str, Any]) -> bool:
|
||||||
return any([
|
# Check for common CivitAI image metadata fields
|
||||||
"resources" in metadata,
|
civitai_image_fields = (
|
||||||
"civitaiResources" in metadata,
|
"resources",
|
||||||
"additionalResources" in metadata
|
"civitaiResources",
|
||||||
])
|
"additionalResources",
|
||||||
|
"hashes",
|
||||||
|
"prompt",
|
||||||
|
"negativePrompt",
|
||||||
|
"steps",
|
||||||
|
"sampler",
|
||||||
|
"cfgScale",
|
||||||
|
"seed",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"Model",
|
||||||
|
"Model hash"
|
||||||
|
)
|
||||||
|
return any(key in payload for key in civitai_image_fields)
|
||||||
|
|
||||||
|
# Check the main metadata object
|
||||||
|
if has_markers(metadata):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check for LoRA hash patterns
|
||||||
|
hashes = metadata.get("hashes")
|
||||||
|
if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check nested meta object (common in CivitAI image responses)
|
||||||
|
nested_meta = metadata.get("meta")
|
||||||
|
if isinstance(nested_meta, dict):
|
||||||
|
if has_markers(nested_meta):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Also check for LoRA hash patterns in nested meta
|
||||||
|
hashes = nested_meta.get("hashes")
|
||||||
|
if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
async def parse_metadata(self, metadata, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
async def parse_metadata(self, metadata, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||||
"""Parse metadata from Civitai image format
|
"""Parse metadata from Civitai image format
|
||||||
@@ -46,6 +81,26 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
# Get metadata provider instead of using civitai_client directly
|
# Get metadata provider instead of using civitai_client directly
|
||||||
metadata_provider = await get_default_metadata_provider()
|
metadata_provider = await get_default_metadata_provider()
|
||||||
|
|
||||||
|
# Civitai image responses may wrap the actual metadata inside a "meta" key
|
||||||
|
if (
|
||||||
|
isinstance(metadata, dict)
|
||||||
|
and "meta" in metadata
|
||||||
|
and isinstance(metadata["meta"], dict)
|
||||||
|
):
|
||||||
|
inner_meta = metadata["meta"]
|
||||||
|
if any(
|
||||||
|
key in inner_meta
|
||||||
|
for key in (
|
||||||
|
"resources",
|
||||||
|
"civitaiResources",
|
||||||
|
"additionalResources",
|
||||||
|
"hashes",
|
||||||
|
"prompt",
|
||||||
|
"negativePrompt",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
metadata = inner_meta
|
||||||
|
|
||||||
# Initialize result structure
|
# Initialize result structure
|
||||||
result = {
|
result = {
|
||||||
'base_model': None,
|
'base_model': None,
|
||||||
@@ -62,8 +117,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
lora_hashes = {}
|
lora_hashes = {}
|
||||||
if "hashes" in metadata and isinstance(metadata["hashes"], dict):
|
if "hashes" in metadata and isinstance(metadata["hashes"], dict):
|
||||||
for key, hash_value in metadata["hashes"].items():
|
for key, hash_value in metadata["hashes"].items():
|
||||||
if key.startswith("LORA:"):
|
key_str = str(key)
|
||||||
lora_name = key.replace("LORA:", "")
|
if key_str.lower().startswith("lora:"):
|
||||||
|
lora_name = key_str.split(":", 1)[1]
|
||||||
lora_hashes[lora_name] = hash_value
|
lora_hashes[lora_name] = hash_value
|
||||||
|
|
||||||
# Extract prompt and negative prompt
|
# Extract prompt and negative prompt
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
|||||||
# Find all LoraLoader nodes
|
# Find all LoraLoader nodes
|
||||||
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
|
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
|
||||||
|
|
||||||
if not lora_nodes:
|
|
||||||
return {"error": "No LoRA information found in this ComfyUI workflow", "loras": []}
|
|
||||||
|
|
||||||
# Process each LoraLoader node
|
# Process each LoraLoader node
|
||||||
for node_id, node in lora_nodes.items():
|
for node_id, node in lora_nodes.items():
|
||||||
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Parser for meta format (Lora_N Model hash) metadata."""
|
"""Parser for meta format (Lora_N Model hash) metadata."""
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
@@ -145,14 +146,53 @@ class MetaFormatParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
loras.append(lora_entry)
|
loras.append(lora_entry)
|
||||||
|
|
||||||
# Extract model information
|
# Extract checkpoint information from generic Model/Model hash fields
|
||||||
model = None
|
checkpoint = None
|
||||||
if 'model' in metadata:
|
model_hash = metadata.get("model_hash")
|
||||||
model = metadata['model']
|
model_name = metadata.get("model")
|
||||||
|
|
||||||
# Set base_model to the most common one from civitai_info
|
if model_hash or model_name:
|
||||||
base_model = None
|
cleaned_name = None
|
||||||
if base_model_counts:
|
if model_name:
|
||||||
|
cleaned_name = re.split(r"[\\\\/]", model_name)[-1]
|
||||||
|
cleaned_name = os.path.splitext(cleaned_name)[0]
|
||||||
|
|
||||||
|
checkpoint_entry = {
|
||||||
|
'id': 0,
|
||||||
|
'modelId': 0,
|
||||||
|
'name': model_name or "Unknown Checkpoint",
|
||||||
|
'version': '',
|
||||||
|
'type': 'checkpoint',
|
||||||
|
'hash': model_hash or "",
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': cleaned_name or (model_name or ""),
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata_provider and model_hash:
|
||||||
|
try:
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(model_hash)
|
||||||
|
checkpoint_entry = await self.populate_checkpoint_from_civitai(
|
||||||
|
checkpoint_entry,
|
||||||
|
civitai_info
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for checkpoint hash {model_hash}: {e}")
|
||||||
|
|
||||||
|
if checkpoint_entry.get("baseModel"):
|
||||||
|
base_model_value = checkpoint_entry["baseModel"]
|
||||||
|
base_model_counts[base_model_value] = base_model_counts.get(base_model_value, 0) + 1
|
||||||
|
|
||||||
|
checkpoint = checkpoint_entry
|
||||||
|
|
||||||
|
# Set base_model to the most common one from civitai_info or checkpoint
|
||||||
|
base_model = checkpoint["baseModel"] if checkpoint and checkpoint.get("baseModel") else None
|
||||||
|
if not base_model and base_model_counts:
|
||||||
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||||
|
|
||||||
# Extract generation parameters for recipe metadata
|
# Extract generation parameters for recipe metadata
|
||||||
@@ -170,7 +210,8 @@ class MetaFormatParser(RecipeMetadataParser):
|
|||||||
'loras': loras,
|
'loras': loras,
|
||||||
'gen_params': gen_params,
|
'gen_params': gen_params,
|
||||||
'raw_metadata': metadata,
|
'raw_metadata': metadata,
|
||||||
'from_meta_format': True
|
'from_meta_format': True,
|
||||||
|
**({'checkpoint': checkpoint, 'model': checkpoint} if checkpoint else {})
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, Optional
|
||||||
from ...config import config
|
from ...config import config
|
||||||
from ..base import RecipeMetadataParser
|
from ..base import RecipeMetadataParser
|
||||||
from ..constants import GEN_PARAM_KEYS
|
from ..constants import GEN_PARAM_KEYS
|
||||||
@@ -17,6 +17,28 @@ class RecipeFormatParser(RecipeMetadataParser):
|
|||||||
# Regular expression pattern for extracting recipe metadata
|
# Regular expression pattern for extracting recipe metadata
|
||||||
METADATA_MARKER = r'Recipe metadata: (\{.*\})'
|
METADATA_MARKER = r'Recipe metadata: (\{.*\})'
|
||||||
|
|
||||||
|
async def _get_lora_from_version_index(self, recipe_scanner, model_version_id: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return a cached LoRA entry by modelVersionId if available."""
|
||||||
|
|
||||||
|
if not recipe_scanner or not getattr(recipe_scanner, "_lora_scanner", None):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
normalized_id = int(model_version_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache = await recipe_scanner._lora_scanner.get_cached_data()
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.debug("Unable to load lora cache for version lookup: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not cache or not getattr(cache, "version_index", None):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cache.version_index.get(normalized_id)
|
||||||
|
|
||||||
def is_metadata_matching(self, user_comment: str) -> bool:
|
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||||
"""Check if the user comment matches the metadata format"""
|
"""Check if the user comment matches the metadata format"""
|
||||||
return re.search(self.METADATA_MARKER, user_comment, re.IGNORECASE | re.DOTALL) is not None
|
return re.search(self.METADATA_MARKER, user_comment, re.IGNORECASE | re.DOTALL) is not None
|
||||||
@@ -53,50 +75,111 @@ class RecipeFormatParser(RecipeMetadataParser):
|
|||||||
'type': 'lora',
|
'type': 'lora',
|
||||||
'weight': lora.get('strength', 1.0),
|
'weight': lora.get('strength', 1.0),
|
||||||
'file_name': lora.get('file_name', ''),
|
'file_name': lora.get('file_name', ''),
|
||||||
'hash': lora.get('hash', '')
|
'hash': lora.get('hash', ''),
|
||||||
|
'existsLocally': False,
|
||||||
|
'inLibrary': False,
|
||||||
|
'localPath': None,
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'size': 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if this LoRA exists locally by SHA256 hash
|
# Check if this LoRA exists locally by SHA256 hash
|
||||||
if lora.get('hash') and recipe_scanner:
|
if recipe_scanner:
|
||||||
lora_scanner = recipe_scanner._lora_scanner
|
lora_scanner = recipe_scanner._lora_scanner
|
||||||
exists_locally = lora_scanner.has_hash(lora['hash'])
|
|
||||||
if exists_locally:
|
if lora.get('hash'):
|
||||||
lora_cache = await lora_scanner.get_cached_data()
|
exists_locally = lora_scanner.has_hash(lora['hash'])
|
||||||
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
|
if exists_locally:
|
||||||
if lora_item:
|
lora_cache = await lora_scanner.get_cached_data()
|
||||||
|
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
|
||||||
|
if lora_item:
|
||||||
|
lora_entry['existsLocally'] = True
|
||||||
|
lora_entry['inLibrary'] = True
|
||||||
|
lora_entry['localPath'] = lora_item['file_path']
|
||||||
|
lora_entry['file_name'] = lora_item['file_name']
|
||||||
|
lora_entry['size'] = lora_item['size']
|
||||||
|
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
|
||||||
|
|
||||||
|
else:
|
||||||
|
lora_entry['existsLocally'] = False
|
||||||
|
lora_entry['inLibrary'] = False
|
||||||
|
lora_entry['localPath'] = None
|
||||||
|
|
||||||
|
# If we still don't have a local match, try matching by modelVersionId
|
||||||
|
if not lora_entry['existsLocally'] and lora.get('modelVersionId') is not None:
|
||||||
|
cached_lora = await self._get_lora_from_version_index(recipe_scanner, lora.get('modelVersionId'))
|
||||||
|
if cached_lora:
|
||||||
lora_entry['existsLocally'] = True
|
lora_entry['existsLocally'] = True
|
||||||
lora_entry['localPath'] = lora_item['file_path']
|
lora_entry['inLibrary'] = True
|
||||||
lora_entry['file_name'] = lora_item['file_name']
|
lora_entry['localPath'] = cached_lora.get('file_path')
|
||||||
lora_entry['size'] = lora_item['size']
|
lora_entry['file_name'] = cached_lora.get('file_name') or lora_entry['file_name']
|
||||||
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
|
lora_entry['size'] = cached_lora.get('size', lora_entry['size'])
|
||||||
|
if cached_lora.get('sha256'):
|
||||||
|
lora_entry['hash'] = cached_lora['sha256']
|
||||||
|
preview_url = cached_lora.get('preview_url')
|
||||||
|
if preview_url:
|
||||||
|
lora_entry['thumbnailUrl'] = config.get_preview_static_url(preview_url)
|
||||||
|
|
||||||
else:
|
# Try to get additional info from Civitai if we have a model version ID and still missing locally
|
||||||
lora_entry['existsLocally'] = False
|
if not lora_entry['existsLocally'] and lora.get('modelVersionId') and metadata_provider:
|
||||||
lora_entry['localPath'] = None
|
try:
|
||||||
|
civitai_info_tuple = await metadata_provider.get_model_version_info(lora['modelVersionId'])
|
||||||
# Try to get additional info from Civitai if we have a model version ID
|
# Populate lora entry with Civitai info
|
||||||
if lora.get('modelVersionId') and metadata_provider:
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
try:
|
lora_entry,
|
||||||
civitai_info_tuple = await metadata_provider.get_model_version_info(lora['modelVersionId'])
|
civitai_info_tuple,
|
||||||
# Populate lora entry with Civitai info
|
recipe_scanner,
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
None, # No need to track base model counts
|
||||||
lora_entry,
|
lora_entry.get('hash', '')
|
||||||
civitai_info_tuple,
|
)
|
||||||
recipe_scanner,
|
if populated_entry is None:
|
||||||
None, # No need to track base model counts
|
continue # Skip invalid LoRA types
|
||||||
lora['hash']
|
lora_entry = populated_entry
|
||||||
)
|
except Exception as e:
|
||||||
if populated_entry is None:
|
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
||||||
continue # Skip invalid LoRA types
|
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||||
lora_entry = populated_entry
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
|
||||||
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
|
||||||
|
|
||||||
loras.append(lora_entry)
|
loras.append(lora_entry)
|
||||||
|
|
||||||
logger.info(f"Found {len(loras)} loras in recipe metadata")
|
logger.info(f"Found {len(loras)} loras in recipe metadata")
|
||||||
|
|
||||||
|
# Process checkpoint information if present
|
||||||
|
checkpoint = None
|
||||||
|
checkpoint_data = recipe_metadata.get('checkpoint') or {}
|
||||||
|
if isinstance(checkpoint_data, dict) and checkpoint_data:
|
||||||
|
version_id = checkpoint_data.get('modelVersionId') or checkpoint_data.get('id')
|
||||||
|
checkpoint_entry = {
|
||||||
|
'id': version_id or 0,
|
||||||
|
'modelId': checkpoint_data.get('modelId', 0),
|
||||||
|
'name': checkpoint_data.get('name', 'Unknown Checkpoint'),
|
||||||
|
'version': checkpoint_data.get('version', ''),
|
||||||
|
'type': checkpoint_data.get('type', 'checkpoint'),
|
||||||
|
'hash': checkpoint_data.get('hash', ''),
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': checkpoint_data.get('file_name', ''),
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata_provider:
|
||||||
|
try:
|
||||||
|
civitai_info = None
|
||||||
|
if version_id:
|
||||||
|
civitai_info = await metadata_provider.get_model_version_info(str(version_id))
|
||||||
|
elif checkpoint_entry.get('hash'):
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(checkpoint_entry['hash'])
|
||||||
|
|
||||||
|
if civitai_info:
|
||||||
|
checkpoint_entry = await self.populate_checkpoint_from_civitai(checkpoint_entry, civitai_info)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for checkpoint in recipe metadata: {e}")
|
||||||
|
|
||||||
|
checkpoint = checkpoint_entry
|
||||||
|
|
||||||
# Filter gen_params to only include recognized keys
|
# Filter gen_params to only include recognized keys
|
||||||
filtered_gen_params = {}
|
filtered_gen_params = {}
|
||||||
if 'gen_params' in recipe_metadata:
|
if 'gen_params' in recipe_metadata:
|
||||||
@@ -105,12 +188,13 @@ class RecipeFormatParser(RecipeMetadataParser):
|
|||||||
filtered_gen_params[key] = value
|
filtered_gen_params[key] = value
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'base_model': recipe_metadata.get('base_model', ''),
|
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else recipe_metadata.get('base_model', ''),
|
||||||
'loras': loras,
|
'loras': loras,
|
||||||
'gen_params': filtered_gen_params,
|
'gen_params': filtered_gen_params,
|
||||||
'tags': recipe_metadata.get('tags', []),
|
'tags': recipe_metadata.get('tags', []),
|
||||||
'title': recipe_metadata.get('title', ''),
|
'title': recipe_metadata.get('title', ''),
|
||||||
'from_recipe_metadata': True
|
'from_recipe_metadata': True,
|
||||||
|
**({'checkpoint': checkpoint, 'model': checkpoint} if checkpoint else {})
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ class BaseModelRoutes(ABC):
|
|||||||
self.service = service
|
self.service = service
|
||||||
self.model_type = service.model_type
|
self.model_type = service.model_type
|
||||||
self.model_file_service = ModelFileService(service.scanner, service.model_type)
|
self.model_file_service = ModelFileService(service.scanner, service.model_type)
|
||||||
self.model_move_service = ModelMoveService(service.scanner)
|
self.model_move_service = ModelMoveService(service.scanner, service.model_type)
|
||||||
self.model_lifecycle_service = ModelLifecycleService(
|
self.model_lifecycle_service = ModelLifecycleService(
|
||||||
scanner=service.scanner,
|
scanner=service.scanner,
|
||||||
metadata_manager=MetadataManager,
|
metadata_manager=MetadataManager,
|
||||||
@@ -270,7 +270,7 @@ class BaseModelRoutes(ABC):
|
|||||||
def _ensure_move_service(self) -> ModelMoveService:
|
def _ensure_move_service(self) -> ModelMoveService:
|
||||||
if self.model_move_service is None:
|
if self.model_move_service is None:
|
||||||
service = self._ensure_service()
|
service = self._ensure_service()
|
||||||
self.model_move_service = ModelMoveService(service.scanner)
|
self.model_move_service = ModelMoveService(service.scanner, service.model_type)
|
||||||
return self.model_move_service
|
return self.model_move_service
|
||||||
|
|
||||||
def _ensure_lifecycle_service(self) -> ModelLifecycleService:
|
def _ensure_lifecycle_service(self) -> ModelLifecycleService:
|
||||||
|
|||||||
@@ -79,26 +79,8 @@ class BaseRecipeRoutes:
|
|||||||
return
|
return
|
||||||
|
|
||||||
app.on_startup.append(self.attach_dependencies)
|
app.on_startup.append(self.attach_dependencies)
|
||||||
app.on_startup.append(self.prewarm_cache)
|
|
||||||
self._startup_hooks_registered = True
|
self._startup_hooks_registered = True
|
||||||
|
|
||||||
async def prewarm_cache(self, app: web.Application | None = None) -> None:
|
|
||||||
"""Pre-load recipe and LoRA caches on startup."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.attach_dependencies(app)
|
|
||||||
|
|
||||||
if self.lora_scanner is not None:
|
|
||||||
await self.lora_scanner.get_cached_data()
|
|
||||||
hash_index = getattr(self.lora_scanner, "_hash_index", None)
|
|
||||||
if hash_index is not None and hasattr(hash_index, "_hash_to_path"):
|
|
||||||
_ = len(hash_index._hash_to_path)
|
|
||||||
|
|
||||||
if self.recipe_scanner is not None:
|
|
||||||
await self.recipe_scanner.get_cached_data(force_refresh=True)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Error pre-warming recipe cache: %s", exc, exc_info=True)
|
|
||||||
|
|
||||||
def to_route_mapping(self) -> Mapping[str, Callable]:
|
def to_route_mapping(self) -> Mapping[str, Callable]:
|
||||||
"""Return a mapping of handler name to coroutine for registrar binding."""
|
"""Return a mapping of handler name to coroutine for registrar binding."""
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/delete-example-image", "delete_example_image"),
|
RouteDefinition("POST", "/api/lm/delete-example-image", "delete_example_image"),
|
||||||
RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"),
|
RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"),
|
||||||
RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"),
|
RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"),
|
||||||
|
RouteDefinition("POST", "/api/lm/example-images/set-nsfw-level", "set_example_image_nsfw_level"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ class ExampleImagesManagementHandler:
|
|||||||
async def delete_example_image(self, request: web.Request) -> web.StreamResponse:
|
async def delete_example_image(self, request: web.Request) -> web.StreamResponse:
|
||||||
return await self._processor.delete_custom_image(request)
|
return await self._processor.delete_custom_image(request)
|
||||||
|
|
||||||
|
async def set_example_image_nsfw_level(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
return await self._processor.set_example_image_nsfw_level(request)
|
||||||
|
|
||||||
async def cleanup_example_image_folders(self, request: web.Request) -> web.StreamResponse:
|
async def cleanup_example_image_folders(self, request: web.Request) -> web.StreamResponse:
|
||||||
result = await self._cleanup_service.cleanup_example_image_folders()
|
result = await self._cleanup_service.cleanup_example_image_folders()
|
||||||
|
|
||||||
@@ -160,6 +163,7 @@ class ExampleImagesHandlerSet:
|
|||||||
"force_download_example_images": self.download.force_download_example_images,
|
"force_download_example_images": self.download.force_download_example_images,
|
||||||
"import_example_images": self.management.import_example_images,
|
"import_example_images": self.management.import_example_images,
|
||||||
"delete_example_image": self.management.delete_example_image,
|
"delete_example_image": self.management.delete_example_image,
|
||||||
|
"set_example_image_nsfw_level": self.management.set_example_image_nsfw_level,
|
||||||
"cleanup_example_image_folders": self.management.cleanup_example_image_folders,
|
"cleanup_example_image_folders": self.management.cleanup_example_image_folders,
|
||||||
"open_example_images_folder": self.files.open_example_images_folder,
|
"open_example_images_folder": self.files.open_example_images_folder,
|
||||||
"get_example_image_files": self.files.get_example_image_files,
|
"get_example_image_files": self.files.get_example_image_files,
|
||||||
|
|||||||
@@ -43,12 +43,55 @@ from ...utils.usage_stats import UsageStats
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_wsl() -> bool:
|
||||||
|
"""Check if running in WSL environment."""
|
||||||
|
try:
|
||||||
|
with open("/proc/version", "r") as f:
|
||||||
|
version_info = f.read().lower()
|
||||||
|
return "microsoft" in version_info or "wsl" in version_info
|
||||||
|
except (OSError, IOError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_docker() -> bool:
|
||||||
|
"""Check if running in Docker container."""
|
||||||
|
dockerenv_exists = os.path.exists("/.dockerenv")
|
||||||
|
if dockerenv_exists:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open("/proc/1/cgroup", "r") as f:
|
||||||
|
cgroup_content = f.read()
|
||||||
|
return (
|
||||||
|
"docker" in cgroup_content.lower()
|
||||||
|
or "kubepods" in cgroup_content.lower()
|
||||||
|
)
|
||||||
|
except (OSError, IOError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _wsl_to_windows_path(wsl_path: str) -> str | None:
|
||||||
|
"""Convert WSL path to Windows path using wslpath."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["wslpath", "-w", wsl_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class PromptServerProtocol(Protocol):
|
class PromptServerProtocol(Protocol):
|
||||||
"""Subset of PromptServer used by the handlers."""
|
"""Subset of PromptServer used by the handlers."""
|
||||||
|
|
||||||
instance: "PromptServerProtocol"
|
instance: "PromptServerProtocol"
|
||||||
|
|
||||||
def send_sync(self, event: str, payload: dict) -> None: # pragma: no cover - protocol
|
def send_sync(
|
||||||
|
self, event: str, payload: dict
|
||||||
|
) -> None: # pragma: no cover - protocol
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +106,9 @@ class UsageStatsFactory(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class MetadataProviderProtocol(Protocol):
|
class MetadataProviderProtocol(Protocol):
|
||||||
async def get_model_versions(self, model_id: int) -> dict | None: # pragma: no cover - protocol
|
async def get_model_versions(
|
||||||
|
self, model_id: int
|
||||||
|
) -> dict | None: # pragma: no cover - protocol
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@@ -109,7 +154,11 @@ class NodeRegistry:
|
|||||||
raw_widget_names: list | None = node.get("widget_names")
|
raw_widget_names: list | None = node.get("widget_names")
|
||||||
if not isinstance(raw_widget_names, list):
|
if not isinstance(raw_widget_names, list):
|
||||||
capability_widget_names = capabilities.get("widget_names")
|
capability_widget_names = capabilities.get("widget_names")
|
||||||
raw_widget_names = capability_widget_names if isinstance(capability_widget_names, list) else None
|
raw_widget_names = (
|
||||||
|
capability_widget_names
|
||||||
|
if isinstance(capability_widget_names, list)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
widget_names: list[str] = []
|
widget_names: list[str] = []
|
||||||
if isinstance(raw_widget_names, list):
|
if isinstance(raw_widget_names, list):
|
||||||
@@ -175,6 +224,7 @@ class SettingsHandler:
|
|||||||
"civitai_api_key",
|
"civitai_api_key",
|
||||||
"default_lora_root",
|
"default_lora_root",
|
||||||
"default_checkpoint_root",
|
"default_checkpoint_root",
|
||||||
|
"default_unet_root",
|
||||||
"default_embedding_root",
|
"default_embedding_root",
|
||||||
"base_model_path_mappings",
|
"base_model_path_mappings",
|
||||||
"download_path_templates",
|
"download_path_templates",
|
||||||
@@ -205,14 +255,25 @@ class SettingsHandler:
|
|||||||
"auto_organize_exclusions",
|
"auto_organize_exclusions",
|
||||||
)
|
)
|
||||||
|
|
||||||
_PROXY_KEYS = {"proxy_enabled", "proxy_host", "proxy_port", "proxy_username", "proxy_password", "proxy_type"}
|
_PROXY_KEYS = {
|
||||||
|
"proxy_enabled",
|
||||||
|
"proxy_host",
|
||||||
|
"proxy_port",
|
||||||
|
"proxy_username",
|
||||||
|
"proxy_password",
|
||||||
|
"proxy_type",
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
settings_service=None,
|
settings_service=None,
|
||||||
metadata_provider_updater: Callable[[], Awaitable[None]] = update_metadata_providers,
|
metadata_provider_updater: Callable[
|
||||||
downloader_factory: Callable[[], Awaitable[DownloaderProtocol]] = get_downloader,
|
[], Awaitable[None]
|
||||||
|
] = update_metadata_providers,
|
||||||
|
downloader_factory: Callable[
|
||||||
|
[], Awaitable[DownloaderProtocol]
|
||||||
|
] = get_downloader,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._settings = settings_service or get_settings_manager()
|
self._settings = settings_service or get_settings_manager()
|
||||||
self._metadata_provider_updater = metadata_provider_updater
|
self._metadata_provider_updater = metadata_provider_updater
|
||||||
@@ -248,11 +309,13 @@ class SettingsHandler:
|
|||||||
response_data["settings_file"] = settings_file
|
response_data["settings_file"] = settings_file
|
||||||
messages_getter = getattr(self._settings, "get_startup_messages", None)
|
messages_getter = getattr(self._settings, "get_startup_messages", None)
|
||||||
messages = list(messages_getter()) if callable(messages_getter) else []
|
messages = list(messages_getter()) if callable(messages_getter) else []
|
||||||
return web.json_response({
|
return web.json_response(
|
||||||
"success": True,
|
{
|
||||||
"settings": response_data,
|
"success": True,
|
||||||
"messages": messages,
|
"settings": response_data,
|
||||||
})
|
"messages": messages,
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Error getting settings: %s", exc, exc_info=True)
|
logger.error("Error getting settings: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
@@ -271,8 +334,12 @@ class SettingsHandler:
|
|||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Error parsing activate library request: %s", exc, exc_info=True)
|
logger.error(
|
||||||
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
|
"Error parsing activate library request: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON payload"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
library_name = data.get("library") or data.get("library_name")
|
library_name = data.get("library") or data.get("library_name")
|
||||||
if not isinstance(library_name, str) or not library_name.strip():
|
if not isinstance(library_name, str) or not library_name.strip():
|
||||||
@@ -297,7 +364,9 @@ class SettingsHandler:
|
|||||||
logger.debug("Attempted to activate unknown library '%s'", library_name)
|
logger.debug("Attempted to activate unknown library '%s'", library_name)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Error activating library '%s': %s", library_name, exc, exc_info=True)
|
logger.error(
|
||||||
|
"Error activating library '%s': %s", library_name, exc, exc_info=True
|
||||||
|
)
|
||||||
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_settings(self, request: web.Request) -> web.Response:
|
async def update_settings(self, request: web.Request) -> web.Response:
|
||||||
@@ -312,9 +381,14 @@ class SettingsHandler:
|
|||||||
if key == "example_images_path" and value:
|
if key == "example_images_path" and value:
|
||||||
validation_error = self._validate_example_images_path(value)
|
validation_error = self._validate_example_images_path(value)
|
||||||
if validation_error:
|
if validation_error:
|
||||||
return web.json_response({"success": False, "error": validation_error})
|
return web.json_response(
|
||||||
|
{"success": False, "error": validation_error}
|
||||||
|
)
|
||||||
|
|
||||||
if value == "__DELETE__" and key in ("proxy_username", "proxy_password"):
|
if value == "__DELETE__" and key in (
|
||||||
|
"proxy_username",
|
||||||
|
"proxy_password",
|
||||||
|
):
|
||||||
self._settings.delete(key)
|
self._settings.delete(key)
|
||||||
else:
|
else:
|
||||||
self._settings.set(key, value)
|
self._settings.set(key, value)
|
||||||
@@ -356,7 +430,9 @@ class UsageStatsHandler:
|
|||||||
data = await request.json()
|
data = await request.json()
|
||||||
prompt_id = data.get("prompt_id")
|
prompt_id = data.get("prompt_id")
|
||||||
if not prompt_id:
|
if not prompt_id:
|
||||||
return web.json_response({"success": False, "error": "Missing prompt_id"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing prompt_id"}, status=400
|
||||||
|
)
|
||||||
usage_stats = self._usage_stats_factory()
|
usage_stats = self._usage_stats_factory()
|
||||||
await usage_stats.process_execution(prompt_id)
|
await usage_stats.process_execution(prompt_id)
|
||||||
return web.json_response({"success": True})
|
return web.json_response({"success": True})
|
||||||
@@ -387,18 +463,24 @@ class LoraCodeHandler:
|
|||||||
mode = data.get("mode", "append")
|
mode = data.get("mode", "append")
|
||||||
|
|
||||||
if not lora_code:
|
if not lora_code:
|
||||||
return web.json_response({"success": False, "error": "Missing lora_code parameter"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing lora_code parameter"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
if node_ids is None:
|
if node_ids is None:
|
||||||
try:
|
try:
|
||||||
self._prompt_server.instance.send_sync(
|
self._prompt_server.instance.send_sync(
|
||||||
"lora_code_update", {"id": -1, "lora_code": lora_code, "mode": mode}
|
"lora_code_update",
|
||||||
|
{"id": -1, "lora_code": lora_code, "mode": mode},
|
||||||
)
|
)
|
||||||
results.append({"node_id": "broadcast", "success": True})
|
results.append({"node_id": "broadcast", "success": True})
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Error broadcasting lora code: %s", exc)
|
logger.error("Error broadcasting lora code: %s", exc)
|
||||||
results.append({"node_id": "broadcast", "success": False, "error": str(exc)})
|
results.append(
|
||||||
|
{"node_id": "broadcast", "success": False, "error": str(exc)}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
for entry in node_ids:
|
for entry in node_ids:
|
||||||
node_identifier = entry
|
node_identifier = entry
|
||||||
@@ -471,11 +553,19 @@ class TrainedWordsHandler:
|
|||||||
try:
|
try:
|
||||||
file_path = request.query.get("file_path")
|
file_path = request.query.get("file_path")
|
||||||
if not file_path:
|
if not file_path:
|
||||||
return web.json_response({"success": False, "error": "Missing file_path parameter"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing file_path parameter"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
return web.json_response({"success": False, "error": "File not found"}, status=404)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "File not found"}, status=404
|
||||||
|
)
|
||||||
if not file_path.endswith(".safetensors"):
|
if not file_path.endswith(".safetensors"):
|
||||||
return web.json_response({"success": False, "error": "File must be a safetensors file"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "File must be a safetensors file"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
trained_words, class_tokens = await extract_trained_words(file_path)
|
trained_words, class_tokens = await extract_trained_words(file_path)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -495,10 +585,15 @@ class ModelExampleFilesHandler:
|
|||||||
try:
|
try:
|
||||||
model_path = request.query.get("model_path")
|
model_path = request.query.get("model_path")
|
||||||
if not model_path:
|
if not model_path:
|
||||||
return web.json_response({"success": False, "error": "Missing model_path parameter"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing model_path parameter"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
model_dir = os.path.dirname(model_path)
|
model_dir = os.path.dirname(model_path)
|
||||||
if not os.path.exists(model_dir):
|
if not os.path.exists(model_dir):
|
||||||
return web.json_response({"success": False, "error": "Model directory not found"}, status=404)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Model directory not found"}, status=404
|
||||||
|
)
|
||||||
|
|
||||||
base_name = os.path.splitext(os.path.basename(model_path))[0]
|
base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
files = []
|
files = []
|
||||||
@@ -510,7 +605,10 @@ class ModelExampleFilesHandler:
|
|||||||
if not os.path.isfile(file_full_path):
|
if not os.path.isfile(file_full_path):
|
||||||
continue
|
continue
|
||||||
file_ext = os.path.splitext(file)[1].lower()
|
file_ext = os.path.splitext(file)[1].lower()
|
||||||
if file_ext not in SUPPORTED_MEDIA_EXTENSIONS["images"] and file_ext not in SUPPORTED_MEDIA_EXTENSIONS["videos"]:
|
if (
|
||||||
|
file_ext not in SUPPORTED_MEDIA_EXTENSIONS["images"]
|
||||||
|
and file_ext not in SUPPORTED_MEDIA_EXTENSIONS["videos"]
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
index = int(file[len(pattern) :].split(".")[0])
|
index = int(file[len(pattern) :].split(".")[0])
|
||||||
@@ -545,7 +643,13 @@ class ServiceRegistryAdapter:
|
|||||||
|
|
||||||
|
|
||||||
class ModelLibraryHandler:
|
class ModelLibraryHandler:
|
||||||
def __init__(self, service_registry: ServiceRegistryAdapter, metadata_provider_factory: Callable[[], Awaitable[MetadataProviderProtocol | None]]) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
service_registry: ServiceRegistryAdapter,
|
||||||
|
metadata_provider_factory: Callable[
|
||||||
|
[], Awaitable[MetadataProviderProtocol | None]
|
||||||
|
],
|
||||||
|
) -> None:
|
||||||
self._service_registry = service_registry
|
self._service_registry = service_registry
|
||||||
self._metadata_provider_factory = metadata_provider_factory
|
self._metadata_provider_factory = metadata_provider_factory
|
||||||
|
|
||||||
@@ -554,11 +658,17 @@ class ModelLibraryHandler:
|
|||||||
model_id_str = request.query.get("modelId")
|
model_id_str = request.query.get("modelId")
|
||||||
model_version_id_str = request.query.get("modelVersionId")
|
model_version_id_str = request.query.get("modelVersionId")
|
||||||
if not model_id_str:
|
if not model_id_str:
|
||||||
return web.json_response({"success": False, "error": "Missing required parameter: modelId"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing required parameter: modelId"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
model_id = int(model_id_str)
|
model_id = int(model_id_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return web.json_response({"success": False, "error": "Parameter modelId must be an integer"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Parameter modelId must be an integer"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
lora_scanner = await self._service_registry.get_lora_scanner()
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
@@ -568,29 +678,55 @@ class ModelLibraryHandler:
|
|||||||
try:
|
try:
|
||||||
model_version_id = int(model_version_id_str)
|
model_version_id = int(model_version_id_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return web.json_response({"success": False, "error": "Parameter modelVersionId must be an integer"}, status=400)
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Parameter modelVersionId must be an integer",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
exists = False
|
exists = False
|
||||||
model_type = None
|
model_type = None
|
||||||
if await lora_scanner.check_model_version_exists(model_version_id):
|
if await lora_scanner.check_model_version_exists(model_version_id):
|
||||||
exists = True
|
exists = True
|
||||||
model_type = "lora"
|
model_type = "lora"
|
||||||
elif checkpoint_scanner and await checkpoint_scanner.check_model_version_exists(model_version_id):
|
elif (
|
||||||
|
checkpoint_scanner
|
||||||
|
and await checkpoint_scanner.check_model_version_exists(
|
||||||
|
model_version_id
|
||||||
|
)
|
||||||
|
):
|
||||||
exists = True
|
exists = True
|
||||||
model_type = "checkpoint"
|
model_type = "checkpoint"
|
||||||
elif embedding_scanner and await embedding_scanner.check_model_version_exists(model_version_id):
|
elif (
|
||||||
|
embedding_scanner
|
||||||
|
and await embedding_scanner.check_model_version_exists(
|
||||||
|
model_version_id
|
||||||
|
)
|
||||||
|
):
|
||||||
exists = True
|
exists = True
|
||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
|
|
||||||
return web.json_response({"success": True, "exists": exists, "modelType": model_type if exists else None})
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"exists": exists,
|
||||||
|
"modelType": model_type if exists else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
||||||
checkpoint_versions = []
|
checkpoint_versions = []
|
||||||
embedding_versions = []
|
embedding_versions = []
|
||||||
if not lora_versions and checkpoint_scanner:
|
if not lora_versions and checkpoint_scanner:
|
||||||
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id)
|
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(
|
||||||
|
model_id
|
||||||
|
)
|
||||||
if not lora_versions and not checkpoint_versions and embedding_scanner:
|
if not lora_versions and not checkpoint_versions and embedding_scanner:
|
||||||
embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id)
|
embedding_versions = await embedding_scanner.get_model_versions_by_id(
|
||||||
|
model_id
|
||||||
|
)
|
||||||
|
|
||||||
model_type = None
|
model_type = None
|
||||||
versions = []
|
versions = []
|
||||||
@@ -604,7 +740,9 @@ class ModelLibraryHandler:
|
|||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
versions = embedding_versions
|
versions = embedding_versions
|
||||||
|
|
||||||
return web.json_response({"success": True, "modelType": model_type, "versions": versions})
|
return web.json_response(
|
||||||
|
{"success": True, "modelType": model_type, "versions": versions}
|
||||||
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
@@ -613,22 +751,35 @@ class ModelLibraryHandler:
|
|||||||
try:
|
try:
|
||||||
model_id_str = request.query.get("modelId")
|
model_id_str = request.query.get("modelId")
|
||||||
if not model_id_str:
|
if not model_id_str:
|
||||||
return web.json_response({"success": False, "error": "Missing required parameter: modelId"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing required parameter: modelId"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
model_id = int(model_id_str)
|
model_id = int(model_id_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return web.json_response({"success": False, "error": "Parameter modelId must be an integer"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Parameter modelId must be an integer"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
metadata_provider = await self._metadata_provider_factory()
|
metadata_provider = await self._metadata_provider_factory()
|
||||||
if not metadata_provider:
|
if not metadata_provider:
|
||||||
return web.json_response({"success": False, "error": "Metadata provider not available"}, status=503)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Metadata provider not available"},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await metadata_provider.get_model_versions(model_id)
|
response = await metadata_provider.get_model_versions(model_id)
|
||||||
except ResourceNotFoundError:
|
except ResourceNotFoundError:
|
||||||
return web.json_response({"success": False, "error": "Model not found"}, status=404)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Model not found"}, status=404
|
||||||
|
)
|
||||||
if not response or not response.get("modelVersions"):
|
if not response or not response.get("modelVersions"):
|
||||||
return web.json_response({"success": False, "error": "Model not found"}, status=404)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Model not found"}, status=404
|
||||||
|
)
|
||||||
|
|
||||||
versions = response.get("modelVersions", [])
|
versions = response.get("modelVersions", [])
|
||||||
model_name = response.get("name", "")
|
model_name = response.get("name", "")
|
||||||
@@ -646,10 +797,22 @@ class ModelLibraryHandler:
|
|||||||
scanner = await self._service_registry.get_embedding_scanner()
|
scanner = await self._service_registry.get_embedding_scanner()
|
||||||
normalized_type = "embedding"
|
normalized_type = "embedding"
|
||||||
else:
|
else:
|
||||||
return web.json_response({"success": False, "error": f'Model type "{model_type}" is not supported'}, status=400)
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f'Model type "{model_type}" is not supported',
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
if not scanner:
|
if not scanner:
|
||||||
return web.json_response({"success": False, "error": f'Scanner for type "{normalized_type}" is not available'}, status=503)
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f'Scanner for type "{normalized_type}" is not available',
|
||||||
|
},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
local_versions = await scanner.get_model_versions_by_id(model_id)
|
local_versions = await scanner.get_model_versions_by_id(model_id)
|
||||||
local_version_ids = {version["versionId"] for version in local_versions}
|
local_version_ids = {version["versionId"] for version in local_versions}
|
||||||
@@ -661,7 +824,9 @@ class ModelLibraryHandler:
|
|||||||
{
|
{
|
||||||
"id": version_id,
|
"id": version_id,
|
||||||
"name": version.get("name", ""),
|
"name": version.get("name", ""),
|
||||||
"thumbnailUrl": version.get("images")[0]["url"] if version.get("images") else None,
|
"thumbnailUrl": version.get("images")[0]["url"]
|
||||||
|
if version.get("images")
|
||||||
|
else None,
|
||||||
"inLibrary": version_id in local_version_ids,
|
"inLibrary": version_id in local_version_ids,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -683,19 +848,34 @@ class ModelLibraryHandler:
|
|||||||
try:
|
try:
|
||||||
username = request.query.get("username")
|
username = request.query.get("username")
|
||||||
if not username:
|
if not username:
|
||||||
return web.json_response({"success": False, "error": "Missing required parameter: username"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing required parameter: username"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
metadata_provider = await self._metadata_provider_factory()
|
metadata_provider = await self._metadata_provider_factory()
|
||||||
if not metadata_provider:
|
if not metadata_provider:
|
||||||
return web.json_response({"success": False, "error": "Metadata provider not available"}, status=503)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Metadata provider not available"},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
models = await metadata_provider.get_user_models(username)
|
models = await metadata_provider.get_user_models(username)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
return web.json_response({"success": False, "error": "Metadata provider does not support user model queries"}, status=501)
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Metadata provider does not support user model queries",
|
||||||
|
},
|
||||||
|
status=501,
|
||||||
|
)
|
||||||
|
|
||||||
if models is None:
|
if models is None:
|
||||||
return web.json_response({"success": False, "error": "Failed to fetch user models"}, status=502)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Failed to fetch user models"},
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
|
|
||||||
if not isinstance(models, list):
|
if not isinstance(models, list):
|
||||||
models = []
|
models = []
|
||||||
@@ -704,7 +884,9 @@ class ModelLibraryHandler:
|
|||||||
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
|
||||||
normalized_allowed_types = {model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES}
|
normalized_allowed_types = {
|
||||||
|
model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES
|
||||||
|
}
|
||||||
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
|
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
|
||||||
|
|
||||||
type_scanner_map: Dict[str, object | None] = {
|
type_scanner_map: Dict[str, object | None] = {
|
||||||
@@ -724,7 +906,13 @@ class ModelLibraryHandler:
|
|||||||
|
|
||||||
scanner = type_scanner_map.get(model_type)
|
scanner = type_scanner_map.get(model_type)
|
||||||
if scanner is None:
|
if scanner is None:
|
||||||
return web.json_response({"success": False, "error": f'Scanner for type "{model_type}" is not available'}, status=503)
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f'Scanner for type "{model_type}" is not available',
|
||||||
|
},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
tags_value = model.get("tags")
|
tags_value = model.get("tags")
|
||||||
tags = tags_value if isinstance(tags_value, list) else []
|
tags = tags_value if isinstance(tags_value, list) else []
|
||||||
@@ -759,7 +947,9 @@ class ModelLibraryHandler:
|
|||||||
rewritten_url, _ = rewrite_preview_url(raw_url, media_type)
|
rewritten_url, _ = rewrite_preview_url(raw_url, media_type)
|
||||||
thumbnail_url = rewritten_url
|
thumbnail_url = rewritten_url
|
||||||
|
|
||||||
in_library = await scanner.check_model_version_exists(version_id_int)
|
in_library = await scanner.check_model_version_exists(
|
||||||
|
version_id_int
|
||||||
|
)
|
||||||
|
|
||||||
versions.append(
|
versions.append(
|
||||||
{
|
{
|
||||||
@@ -775,7 +965,9 @@ class ModelLibraryHandler:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return web.json_response({"success": True, "username": username, "versions": versions})
|
return web.json_response(
|
||||||
|
{"success": True, "username": username, "versions": versions}
|
||||||
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Failed to get Civitai user models: %s", exc, exc_info=True)
|
logger.error("Failed to get Civitai user models: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
@@ -785,9 +977,13 @@ class MetadataArchiveHandler:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
metadata_archive_manager_factory: Callable[[], Awaitable[MetadataArchiveManagerProtocol]] = get_metadata_archive_manager,
|
metadata_archive_manager_factory: Callable[
|
||||||
|
[], Awaitable[MetadataArchiveManagerProtocol]
|
||||||
|
] = get_metadata_archive_manager,
|
||||||
settings_service=None,
|
settings_service=None,
|
||||||
metadata_provider_updater: Callable[[], Awaitable[None]] = update_metadata_providers,
|
metadata_provider_updater: Callable[
|
||||||
|
[], Awaitable[None]
|
||||||
|
] = update_metadata_providers,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._metadata_archive_manager_factory = metadata_archive_manager_factory
|
self._metadata_archive_manager_factory = metadata_archive_manager_factory
|
||||||
self._settings = settings_service or get_settings_manager()
|
self._settings = settings_service or get_settings_manager()
|
||||||
@@ -799,18 +995,37 @@ class MetadataArchiveHandler:
|
|||||||
download_id = request.query.get("download_id")
|
download_id = request.query.get("download_id")
|
||||||
|
|
||||||
def progress_callback(stage: str, message: str) -> None:
|
def progress_callback(stage: str, message: str) -> None:
|
||||||
data = {"stage": stage, "message": message, "type": "metadata_archive_download"}
|
data = {
|
||||||
|
"stage": stage,
|
||||||
|
"message": message,
|
||||||
|
"type": "metadata_archive_download",
|
||||||
|
}
|
||||||
if download_id:
|
if download_id:
|
||||||
asyncio.create_task(ws_manager.broadcast_download_progress(download_id, data))
|
asyncio.create_task(
|
||||||
|
ws_manager.broadcast_download_progress(download_id, data)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
asyncio.create_task(ws_manager.broadcast(data))
|
asyncio.create_task(ws_manager.broadcast(data))
|
||||||
|
|
||||||
success = await archive_manager.download_and_extract_database(progress_callback)
|
success = await archive_manager.download_and_extract_database(
|
||||||
|
progress_callback
|
||||||
|
)
|
||||||
if success:
|
if success:
|
||||||
self._settings.set("enable_metadata_archive_db", True)
|
self._settings.set("enable_metadata_archive_db", True)
|
||||||
await self._metadata_provider_updater()
|
await self._metadata_provider_updater()
|
||||||
return web.json_response({"success": True, "message": "Metadata archive database downloaded and extracted successfully"})
|
return web.json_response(
|
||||||
return web.json_response({"success": False, "error": "Failed to download and extract metadata archive database"}, status=500)
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "Metadata archive database downloaded and extracted successfully",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Failed to download and extract metadata archive database",
|
||||||
|
},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Error downloading metadata archive: %s", exc, exc_info=True)
|
logger.error("Error downloading metadata archive: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
@@ -822,8 +1037,19 @@ class MetadataArchiveHandler:
|
|||||||
if success:
|
if success:
|
||||||
self._settings.set("enable_metadata_archive_db", False)
|
self._settings.set("enable_metadata_archive_db", False)
|
||||||
await self._metadata_provider_updater()
|
await self._metadata_provider_updater()
|
||||||
return web.json_response({"success": True, "message": "Metadata archive database removed successfully"})
|
return web.json_response(
|
||||||
return web.json_response({"success": False, "error": "Failed to remove metadata archive database"}, status=500)
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "Metadata archive database removed successfully",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Failed to remove metadata archive database",
|
||||||
|
},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Error removing metadata archive: %s", exc, exc_info=True)
|
logger.error("Error removing metadata archive: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
@@ -844,39 +1070,136 @@ class MetadataArchiveHandler:
|
|||||||
"isAvailable": is_available,
|
"isAvailable": is_available,
|
||||||
"isEnabled": is_enabled,
|
"isEnabled": is_enabled,
|
||||||
"databaseSize": db_size,
|
"databaseSize": db_size,
|
||||||
"databasePath": archive_manager.get_database_path() if is_available else None,
|
"databasePath": archive_manager.get_database_path()
|
||||||
|
if is_available
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Error getting metadata archive status: %s", exc, exc_info=True)
|
logger.error(
|
||||||
|
"Error getting metadata archive status: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class FileSystemHandler:
|
class FileSystemHandler:
|
||||||
|
def __init__(self, settings_service=None) -> None:
|
||||||
|
self._settings = settings_service or get_settings_manager()
|
||||||
|
|
||||||
async def open_file_location(self, request: web.Request) -> web.Response:
|
async def open_file_location(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
file_path = data.get("file_path")
|
file_path = data.get("file_path")
|
||||||
if not file_path:
|
if not file_path:
|
||||||
return web.json_response({"success": False, "error": "Missing file_path parameter"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing file_path parameter"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
file_path = os.path.abspath(file_path)
|
file_path = os.path.abspath(file_path)
|
||||||
if not os.path.isfile(file_path):
|
if not os.path.isfile(file_path):
|
||||||
return web.json_response({"success": False, "error": "File does not exist"}, status=404)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "File does not exist"}, status=404
|
||||||
|
)
|
||||||
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
subprocess.Popen(["explorer", "/select,", file_path])
|
subprocess.Popen(["explorer", "/select,", file_path])
|
||||||
elif os.name == "posix":
|
elif os.name == "posix":
|
||||||
if sys.platform == "darwin":
|
if _is_docker():
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "Running in Docker: Path available for copying",
|
||||||
|
"path": file_path,
|
||||||
|
"mode": "clipboard",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif _is_wsl():
|
||||||
|
windows_path = _wsl_to_windows_path(file_path)
|
||||||
|
if windows_path:
|
||||||
|
subprocess.Popen(["explorer.exe", "/select,", windows_path])
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Failed to convert WSL path to Windows path: %s", file_path
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Failed to open file location: path conversion error",
|
||||||
|
},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
elif sys.platform == "darwin":
|
||||||
subprocess.Popen(["open", "-R", file_path])
|
subprocess.Popen(["open", "-R", file_path])
|
||||||
else:
|
else:
|
||||||
folder = os.path.dirname(file_path)
|
folder = os.path.dirname(file_path)
|
||||||
subprocess.Popen(["xdg-open", folder])
|
subprocess.Popen(["xdg-open", folder])
|
||||||
|
|
||||||
return web.json_response({"success": True, "message": f"Opened folder and selected file: {file_path}"})
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"Opened folder and selected file: {file_path}",
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Failed to open file location: %s", exc, exc_info=True)
|
logger.error("Failed to open file location: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def open_settings_location(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
settings_file = getattr(self._settings, "settings_file", None)
|
||||||
|
if not settings_file:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Settings file not found"}, status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
settings_file = os.path.abspath(settings_file)
|
||||||
|
if not os.path.isfile(settings_file):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Settings file does not exist"},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
subprocess.Popen(["explorer", "/select,", settings_file])
|
||||||
|
elif os.name == "posix":
|
||||||
|
if _is_docker():
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "Running in Docker: Path available for copying",
|
||||||
|
"path": settings_file,
|
||||||
|
"mode": "clipboard",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif _is_wsl():
|
||||||
|
windows_path = _wsl_to_windows_path(settings_file)
|
||||||
|
if windows_path:
|
||||||
|
subprocess.Popen(["explorer.exe", "/select,", windows_path])
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Failed to convert WSL path to Windows path: %s",
|
||||||
|
settings_file,
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Failed to open settings location: path conversion error",
|
||||||
|
},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
subprocess.Popen(["open", "-R", settings_file])
|
||||||
|
else:
|
||||||
|
folder = os.path.dirname(settings_file)
|
||||||
|
subprocess.Popen(["xdg-open", folder])
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "message": f"Opened settings folder: {settings_file}"}
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to open settings location: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class NodeRegistryHandler:
|
class NodeRegistryHandler:
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -895,21 +1218,44 @@ class NodeRegistryHandler:
|
|||||||
data = await request.json()
|
data = await request.json()
|
||||||
nodes = data.get("nodes", [])
|
nodes = data.get("nodes", [])
|
||||||
if not isinstance(nodes, list):
|
if not isinstance(nodes, list):
|
||||||
return web.json_response({"success": False, "error": "nodes must be a list"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "nodes must be a list"}, 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({"success": False, "error": f"Node {index} must be an object"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": f"Node {index} must be an object"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
node_id = node.get("node_id")
|
node_id = node.get("node_id")
|
||||||
if node_id is None:
|
if node_id is None:
|
||||||
return web.json_response({"success": False, "error": f"Node {index} missing node_id parameter"}, status=400)
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Node {index} missing node_id parameter",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
graph_id = node.get("graph_id")
|
graph_id = node.get("graph_id")
|
||||||
if graph_id is None:
|
if graph_id is None:
|
||||||
return web.json_response({"success": False, "error": f"Node {index} missing graph_id parameter"}, status=400)
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Node {index} missing graph_id parameter",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
graph_name = node.get("graph_name")
|
graph_name = node.get("graph_name")
|
||||||
try:
|
try:
|
||||||
node["node_id"] = int(node_id)
|
node["node_id"] = int(node_id)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return web.json_response({"success": False, "error": f"Node {index} node_id must be an integer"}, status=400)
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Node {index} node_id must be an integer",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
node["graph_id"] = str(graph_id)
|
node["graph_id"] = str(graph_id)
|
||||||
if graph_name is None:
|
if graph_name is None:
|
||||||
node["graph_name"] = None
|
node["graph_name"] = None
|
||||||
@@ -919,7 +1265,12 @@ class NodeRegistryHandler:
|
|||||||
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(nodes)
|
||||||
return web.json_response({"success": True, "message": f"{len(nodes)} nodes registered successfully"})
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"{len(nodes)} nodes registered successfully",
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Failed to register nodes: %s", exc, exc_info=True)
|
logger.error("Failed to register nodes: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
@@ -967,7 +1318,10 @@ class NodeRegistryHandler:
|
|||||||
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)
|
||||||
return web.json_response({"success": False, "error": "Internal Error", "message": str(exc)}, status=500)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Internal Error", "message": str(exc)},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
|
||||||
async def update_node_widget(self, request: web.Request) -> web.Response:
|
async def update_node_widget(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
@@ -977,10 +1331,15 @@ class NodeRegistryHandler:
|
|||||||
node_ids = data.get("node_ids")
|
node_ids = data.get("node_ids")
|
||||||
|
|
||||||
if not isinstance(widget_name, str) or not widget_name:
|
if not isinstance(widget_name, str) or not widget_name:
|
||||||
return web.json_response({"success": False, "error": "Missing widget_name parameter"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing widget_name parameter"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
if not isinstance(value, str) or not value:
|
if not isinstance(value, str) or not value:
|
||||||
return web.json_response({"success": False, "error": "Missing value parameter"}, status=400)
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing value parameter"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
if not isinstance(node_ids, list) or not node_ids:
|
if not isinstance(node_ids, list) or not node_ids:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -1080,7 +1439,9 @@ class MiscHandlerSet:
|
|||||||
self.metadata_archive = metadata_archive
|
self.metadata_archive = metadata_archive
|
||||||
self.filesystem = filesystem
|
self.filesystem = filesystem
|
||||||
|
|
||||||
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
def to_route_mapping(
|
||||||
|
self,
|
||||||
|
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||||
return {
|
return {
|
||||||
"health_check": self.health.health_check,
|
"health_check": self.health.health_check,
|
||||||
"get_settings": self.settings.get_settings,
|
"get_settings": self.settings.get_settings,
|
||||||
@@ -1103,6 +1464,7 @@ class MiscHandlerSet:
|
|||||||
"get_metadata_archive_status": self.metadata_archive.get_metadata_archive_status,
|
"get_metadata_archive_status": self.metadata_archive.get_metadata_archive_status,
|
||||||
"get_model_versions_status": self.model_library.get_model_versions_status,
|
"get_model_versions_status": self.model_library.get_model_versions_status,
|
||||||
"open_file_location": self.filesystem.open_file_location,
|
"open_file_location": self.filesystem.open_file_location,
|
||||||
|
"open_settings_location": self.filesystem.open_settings_location,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -41,9 +41,10 @@ class PreviewHandler:
|
|||||||
raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc
|
raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc
|
||||||
|
|
||||||
resolved_str = str(resolved)
|
resolved_str = str(resolved)
|
||||||
if not self._config.is_preview_path_allowed(resolved_str):
|
# TODO: Temporarily disabled path validation due to issues #772 and #774
|
||||||
logger.debug("Rejected preview outside allowed roots: %s", resolved_str)
|
# Re-enable after fixing preview root path handling
|
||||||
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
|
# if not self._config.is_preview_path_allowed(resolved_str):
|
||||||
|
# raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
|
||||||
|
|
||||||
if not resolved.is_file():
|
if not resolved.is_file():
|
||||||
logger.debug("Preview file not found at %s", resolved_str)
|
logger.debug("Preview file not found at %s", resolved_str)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import asyncio
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
|
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
|
||||||
@@ -23,6 +24,11 @@ from ...services.recipes import (
|
|||||||
RecipeValidationError,
|
RecipeValidationError,
|
||||||
)
|
)
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
|
from ...utils.exif_utils import ExifUtils
|
||||||
|
from ...recipes.merger import GenParamsMerger
|
||||||
|
from ...recipes.enrichment import RecipeEnricher
|
||||||
|
from ...services.websocket_manager import ws_manager as default_ws_manager
|
||||||
|
|
||||||
Logger = logging.Logger
|
Logger = logging.Logger
|
||||||
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||||
@@ -55,16 +61,26 @@ class RecipeHandlerSet:
|
|||||||
"delete_recipe": self.management.delete_recipe,
|
"delete_recipe": self.management.delete_recipe,
|
||||||
"get_top_tags": self.query.get_top_tags,
|
"get_top_tags": self.query.get_top_tags,
|
||||||
"get_base_models": self.query.get_base_models,
|
"get_base_models": self.query.get_base_models,
|
||||||
|
"get_roots": self.query.get_roots,
|
||||||
|
"get_folders": self.query.get_folders,
|
||||||
|
"get_folder_tree": self.query.get_folder_tree,
|
||||||
|
"get_unified_folder_tree": self.query.get_unified_folder_tree,
|
||||||
"share_recipe": self.sharing.share_recipe,
|
"share_recipe": self.sharing.share_recipe,
|
||||||
"download_shared_recipe": self.sharing.download_shared_recipe,
|
"download_shared_recipe": self.sharing.download_shared_recipe,
|
||||||
"get_recipe_syntax": self.query.get_recipe_syntax,
|
"get_recipe_syntax": self.query.get_recipe_syntax,
|
||||||
"update_recipe": self.management.update_recipe,
|
"update_recipe": self.management.update_recipe,
|
||||||
"reconnect_lora": self.management.reconnect_lora,
|
"reconnect_lora": self.management.reconnect_lora,
|
||||||
"find_duplicates": self.query.find_duplicates,
|
"find_duplicates": self.query.find_duplicates,
|
||||||
|
"move_recipes_bulk": self.management.move_recipes_bulk,
|
||||||
"bulk_delete": self.management.bulk_delete,
|
"bulk_delete": self.management.bulk_delete,
|
||||||
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
||||||
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
||||||
"scan_recipes": self.query.scan_recipes,
|
"scan_recipes": self.query.scan_recipes,
|
||||||
|
"move_recipe": self.management.move_recipe,
|
||||||
|
"repair_recipes": self.management.repair_recipes,
|
||||||
|
"cancel_repair": self.management.cancel_repair,
|
||||||
|
"repair_recipe": self.management.repair_recipe,
|
||||||
|
"get_repair_progress": self.management.get_repair_progress,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -148,12 +164,15 @@ class RecipeListingHandler:
|
|||||||
page_size = int(request.query.get("page_size", "20"))
|
page_size = int(request.query.get("page_size", "20"))
|
||||||
sort_by = request.query.get("sort_by", "date")
|
sort_by = request.query.get("sort_by", "date")
|
||||||
search = request.query.get("search")
|
search = request.query.get("search")
|
||||||
|
folder = request.query.get("folder")
|
||||||
|
recursive = request.query.get("recursive", "true").lower() == "true"
|
||||||
|
|
||||||
search_options = {
|
search_options = {
|
||||||
"title": request.query.get("search_title", "true").lower() == "true",
|
"title": request.query.get("search_title", "true").lower() == "true",
|
||||||
"tags": request.query.get("search_tags", "true").lower() == "true",
|
"tags": request.query.get("search_tags", "true").lower() == "true",
|
||||||
"lora_name": request.query.get("search_lora_name", "true").lower() == "true",
|
"lora_name": request.query.get("search_lora_name", "true").lower() == "true",
|
||||||
"lora_model": request.query.get("search_lora_model", "true").lower() == "true",
|
"lora_model": request.query.get("search_lora_model", "true").lower() == "true",
|
||||||
|
"prompt": request.query.get("search_prompt", "true").lower() == "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
filters: Dict[str, Any] = {}
|
filters: Dict[str, Any] = {}
|
||||||
@@ -161,6 +180,9 @@ class RecipeListingHandler:
|
|||||||
if base_models:
|
if base_models:
|
||||||
filters["base_model"] = base_models.split(",")
|
filters["base_model"] = base_models.split(",")
|
||||||
|
|
||||||
|
if request.query.get("favorite", "false").lower() == "true":
|
||||||
|
filters["favorite"] = True
|
||||||
|
|
||||||
tag_filters: Dict[str, str] = {}
|
tag_filters: Dict[str, str] = {}
|
||||||
legacy_tags = request.query.get("tags")
|
legacy_tags = request.query.get("tags")
|
||||||
if legacy_tags:
|
if legacy_tags:
|
||||||
@@ -192,6 +214,8 @@ class RecipeListingHandler:
|
|||||||
filters=filters,
|
filters=filters,
|
||||||
search_options=search_options,
|
search_options=search_options,
|
||||||
lora_hash=lora_hash,
|
lora_hash=lora_hash,
|
||||||
|
folder=folder,
|
||||||
|
recursive=recursive,
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in result.get("items", []):
|
for item in result.get("items", []):
|
||||||
@@ -298,6 +322,58 @@ class RecipeQueryHandler:
|
|||||||
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_roots(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
roots = [recipe_scanner.recipes_dir] if recipe_scanner.recipes_dir else []
|
||||||
|
return web.json_response({"success": True, "roots": roots})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipe roots: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_folders(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
folders = await recipe_scanner.get_folders()
|
||||||
|
return web.json_response({"success": True, "folders": folders})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_folder_tree(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
folder_tree = await recipe_scanner.get_folder_tree()
|
||||||
|
return web.json_response({"success": True, "tree": folder_tree})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
folder_tree = await recipe_scanner.get_folder_tree()
|
||||||
|
return web.json_response({"success": True, "tree": folder_tree})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -410,6 +486,7 @@ class RecipeManagementHandler:
|
|||||||
analysis_service: RecipeAnalysisService,
|
analysis_service: RecipeAnalysisService,
|
||||||
downloader_factory,
|
downloader_factory,
|
||||||
civitai_client_getter: CivitaiClientGetter,
|
civitai_client_getter: CivitaiClientGetter,
|
||||||
|
ws_manager=default_ws_manager,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._ensure_dependencies_ready = ensure_dependencies_ready
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
||||||
self._recipe_scanner_getter = recipe_scanner_getter
|
self._recipe_scanner_getter = recipe_scanner_getter
|
||||||
@@ -418,6 +495,7 @@ class RecipeManagementHandler:
|
|||||||
self._analysis_service = analysis_service
|
self._analysis_service = analysis_service
|
||||||
self._downloader_factory = downloader_factory
|
self._downloader_factory = downloader_factory
|
||||||
self._civitai_client_getter = civitai_client_getter
|
self._civitai_client_getter = civitai_client_getter
|
||||||
|
self._ws_manager = ws_manager
|
||||||
|
|
||||||
async def save_recipe(self, request: web.Request) -> web.Response:
|
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
@@ -436,6 +514,7 @@ class RecipeManagementHandler:
|
|||||||
name=payload["name"],
|
name=payload["name"],
|
||||||
tags=payload["tags"],
|
tags=payload["tags"],
|
||||||
metadata=payload["metadata"],
|
metadata=payload["metadata"],
|
||||||
|
extension=payload.get("extension"),
|
||||||
)
|
)
|
||||||
return web.json_response(result.payload, status=result.status)
|
return web.json_response(result.payload, status=result.status)
|
||||||
except RecipeValidationError as exc:
|
except RecipeValidationError as exc:
|
||||||
@@ -444,6 +523,88 @@ class RecipeManagementHandler:
|
|||||||
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
|
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def repair_recipes(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||||
|
|
||||||
|
# Check if already running
|
||||||
|
if self._ws_manager.is_recipe_repair_running():
|
||||||
|
return web.json_response({"success": False, "error": "Recipe repair already in progress"}, status=409)
|
||||||
|
|
||||||
|
recipe_scanner.reset_cancellation()
|
||||||
|
|
||||||
|
async def progress_callback(data):
|
||||||
|
await self._ws_manager.broadcast_recipe_repair_progress(data)
|
||||||
|
|
||||||
|
# Run in background to avoid timeout
|
||||||
|
async def run_repair():
|
||||||
|
try:
|
||||||
|
await recipe_scanner.repair_all_recipes(
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(f"Error in recipe repair task: {e}", exc_info=True)
|
||||||
|
await self._ws_manager.broadcast_recipe_repair_progress({
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
# Keep the final status for a while so the UI can see it
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
# Don't cleanup if it was cancelled, let the UI see the cancelled state for a bit?
|
||||||
|
# Actually cleanup_recipe_repair_progress is fine as long as we waited enough.
|
||||||
|
self._ws_manager.cleanup_recipe_repair_progress()
|
||||||
|
|
||||||
|
asyncio.create_task(run_repair())
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "message": "Recipe repair started"})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def cancel_repair(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||||
|
|
||||||
|
recipe_scanner.cancel_task()
|
||||||
|
return web.json_response({"success": True, "message": "Cancellation requested"})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def repair_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||||
|
|
||||||
|
recipe_id = request.match_info["recipe_id"]
|
||||||
|
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
|
||||||
|
return web.json_response(result)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_repair_progress(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
progress = self._ws_manager.get_recipe_repair_progress()
|
||||||
|
if progress:
|
||||||
|
return web.json_response({"success": True, "progress": progress})
|
||||||
|
return web.json_response({"success": False, "message": "No repair in progress"}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
async def import_remote_recipe(self, request: web.Request) -> web.Response:
|
async def import_remote_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -451,10 +612,12 @@ class RecipeManagementHandler:
|
|||||||
if recipe_scanner is None:
|
if recipe_scanner is None:
|
||||||
raise RuntimeError("Recipe scanner unavailable")
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
# 1. Parse Parameters
|
||||||
params = request.rel_url.query
|
params = request.rel_url.query
|
||||||
image_url = params.get("image_url")
|
image_url = params.get("image_url")
|
||||||
name = params.get("name")
|
name = params.get("name")
|
||||||
resources_raw = params.get("resources")
|
resources_raw = params.get("resources")
|
||||||
|
|
||||||
if not image_url:
|
if not image_url:
|
||||||
raise RecipeValidationError("Missing required field: image_url")
|
raise RecipeValidationError("Missing required field: image_url")
|
||||||
if not name:
|
if not name:
|
||||||
@@ -463,27 +626,93 @@ class RecipeManagementHandler:
|
|||||||
raise RecipeValidationError("Missing required field: resources")
|
raise RecipeValidationError("Missing required field: resources")
|
||||||
|
|
||||||
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
|
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
|
||||||
gen_params = self._parse_gen_params(params.get("gen_params"))
|
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||||
|
|
||||||
|
# 2. Initial Metadata Construction
|
||||||
metadata: Dict[str, Any] = {
|
metadata: Dict[str, Any] = {
|
||||||
"base_model": params.get("base_model", "") or "",
|
"base_model": params.get("base_model", "") or "",
|
||||||
"loras": lora_entries,
|
"loras": lora_entries,
|
||||||
|
"gen_params": gen_params_request or {},
|
||||||
|
"source_url": image_url
|
||||||
}
|
}
|
||||||
|
|
||||||
source_path = params.get("source_path")
|
source_path = params.get("source_path")
|
||||||
if source_path:
|
if source_path:
|
||||||
metadata["source_path"] = source_path
|
metadata["source_path"] = source_path
|
||||||
if gen_params is not None:
|
|
||||||
metadata["gen_params"] = gen_params
|
# Checkpoint handling
|
||||||
if checkpoint_entry:
|
if checkpoint_entry:
|
||||||
metadata["checkpoint"] = checkpoint_entry
|
metadata["checkpoint"] = checkpoint_entry
|
||||||
gen_params_ref = metadata.setdefault("gen_params", {})
|
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
|
||||||
if "checkpoint" not in gen_params_ref:
|
# Actually enricher looks at metadata['checkpoint'], so this is fine.
|
||||||
gen_params_ref["checkpoint"] = checkpoint_entry
|
|
||||||
base_model_from_metadata = await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
# Try to resolve base model from checkpoint if not explicitly provided
|
||||||
if base_model_from_metadata:
|
if not metadata["base_model"]:
|
||||||
metadata["base_model"] = base_model_from_metadata
|
base_model_from_metadata = await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
||||||
|
if base_model_from_metadata:
|
||||||
|
metadata["base_model"] = base_model_from_metadata
|
||||||
|
|
||||||
tags = self._parse_tags(params.get("tags"))
|
tags = self._parse_tags(params.get("tags"))
|
||||||
image_bytes = await self._download_image_bytes(image_url)
|
|
||||||
|
# 3. Download Image
|
||||||
|
image_bytes, extension, civitai_meta_from_download = await self._download_remote_media(image_url)
|
||||||
|
|
||||||
|
# 4. Extract Embedded Metadata
|
||||||
|
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
|
||||||
|
# with embedded data if we want it to merge it.
|
||||||
|
# However, logic in Enricher merges: request > civitai > embedded.
|
||||||
|
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
|
||||||
|
# OR pass them to enricher to handle?
|
||||||
|
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
|
||||||
|
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
|
||||||
|
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
|
||||||
|
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
|
||||||
|
# Wait, `Enricher` fetches Civitai info internally based on URL.
|
||||||
|
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
|
||||||
|
|
||||||
|
# Let's extract embedded metadata first
|
||||||
|
embedded_gen_params = {}
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_img:
|
||||||
|
temp_img.write(image_bytes)
|
||||||
|
temp_img_path = temp_img.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
|
||||||
|
if raw_embedded:
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(raw_embedded)
|
||||||
|
if parser:
|
||||||
|
parsed_embedded = await parser.parse_metadata(raw_embedded, recipe_scanner=recipe_scanner)
|
||||||
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
|
else:
|
||||||
|
embedded_gen_params = {"raw_metadata": raw_embedded}
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_img_path):
|
||||||
|
os.unlink(temp_img_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.warning("Failed to extract embedded metadata during import: %s", exc)
|
||||||
|
|
||||||
|
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
|
||||||
|
if embedded_gen_params:
|
||||||
|
# Merge embedded into existing gen_params (which currently only has request params if any)
|
||||||
|
# But wait, we want request params to override everything.
|
||||||
|
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
|
||||||
|
metadata["gen_params"] = embedded_gen_params
|
||||||
|
|
||||||
|
# 5. Enrich with unified logic
|
||||||
|
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
|
||||||
|
civitai_client = self._civitai_client_getter()
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=metadata,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params=gen_params_request # Pass explicit request params here to override
|
||||||
|
)
|
||||||
|
|
||||||
|
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
|
||||||
|
# we might want to manually merge it?
|
||||||
|
# But usually `import_remote_recipe` is used with Civitai URLs.
|
||||||
|
# For now, relying on Enricher's internal fetch is consistent with repair.
|
||||||
|
|
||||||
result = await self._persistence_service.save_recipe(
|
result = await self._persistence_service.save_recipe(
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
@@ -492,6 +721,7 @@ class RecipeManagementHandler:
|
|||||||
name=name,
|
name=name,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
|
extension=extension,
|
||||||
)
|
)
|
||||||
return web.json_response(result.payload, status=result.status)
|
return web.json_response(result.payload, status=result.status)
|
||||||
except RecipeValidationError as exc:
|
except RecipeValidationError as exc:
|
||||||
@@ -541,6 +771,64 @@ class RecipeManagementHandler:
|
|||||||
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def move_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
recipe_id = data.get("recipe_id")
|
||||||
|
target_path = data.get("target_path")
|
||||||
|
if not recipe_id or not target_path:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "recipe_id and target_path are required"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self._persistence_service.move_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_id=str(recipe_id),
|
||||||
|
target_path=str(target_path),
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error moving recipe: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def move_recipes_bulk(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
recipe_ids = data.get("recipe_ids") or []
|
||||||
|
target_path = data.get("target_path")
|
||||||
|
if not recipe_ids or not target_path:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "recipe_ids and target_path are required"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self._persistence_service.move_recipes_bulk(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_ids=recipe_ids,
|
||||||
|
target_path=str(target_path),
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error moving recipes in bulk: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -622,6 +910,7 @@ class RecipeManagementHandler:
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
tags: list[str] = []
|
tags: list[str] = []
|
||||||
metadata: Optional[Dict[str, Any]] = None
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
|
extension: Optional[str] = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
field = await reader.next()
|
field = await reader.next()
|
||||||
@@ -652,6 +941,8 @@ class RecipeManagementHandler:
|
|||||||
metadata = json.loads(metadata_text)
|
metadata = json.loads(metadata_text)
|
||||||
except Exception:
|
except Exception:
|
||||||
metadata = {}
|
metadata = {}
|
||||||
|
elif field.name == "extension":
|
||||||
|
extension = await field.text()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"image_bytes": image_bytes,
|
"image_bytes": image_bytes,
|
||||||
@@ -659,6 +950,7 @@ class RecipeManagementHandler:
|
|||||||
"name": name,
|
"name": name,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
|
"extension": extension,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _parse_tags(self, tag_text: Optional[str]) -> list[str]:
|
def _parse_tags(self, tag_text: Optional[str]) -> list[str]:
|
||||||
@@ -729,7 +1021,7 @@ class RecipeManagementHandler:
|
|||||||
"exclude": False,
|
"exclude": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _download_image_bytes(self, image_url: str) -> bytes:
|
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str]:
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
downloader = await self._downloader_factory()
|
downloader = await self._downloader_factory()
|
||||||
temp_path = None
|
temp_path = None
|
||||||
@@ -744,15 +1036,31 @@ class RecipeManagementHandler:
|
|||||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||||
if not image_info:
|
if not image_info:
|
||||||
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
||||||
download_url = image_info.get("url")
|
|
||||||
if not download_url:
|
media_url = image_info.get("url")
|
||||||
|
if not media_url:
|
||||||
raise RecipeDownloadError("No image URL found in Civitai response")
|
raise RecipeDownloadError("No image URL found in Civitai response")
|
||||||
|
|
||||||
|
# Use optimized preview URLs if possible
|
||||||
|
media_type = image_info.get("type")
|
||||||
|
rewritten_url, _ = rewrite_preview_url(media_url, media_type=media_type)
|
||||||
|
if rewritten_url:
|
||||||
|
download_url = rewritten_url
|
||||||
|
else:
|
||||||
|
download_url = media_url
|
||||||
|
|
||||||
success, result = await downloader.download_file(download_url, temp_path, use_auth=False)
|
success, result = await downloader.download_file(download_url, temp_path, use_auth=False)
|
||||||
if not success:
|
if not success:
|
||||||
raise RecipeDownloadError(f"Failed to download image: {result}")
|
raise RecipeDownloadError(f"Failed to download image: {result}")
|
||||||
|
|
||||||
|
# Extract extension from URL
|
||||||
|
url_path = download_url.split('?')[0].split('#')[0]
|
||||||
|
extension = os.path.splitext(url_path)[1].lower()
|
||||||
|
if not extension:
|
||||||
|
extension = ".webp" # Default to webp if unknown
|
||||||
|
|
||||||
with open(temp_path, "rb") as file_obj:
|
with open(temp_path, "rb") as file_obj:
|
||||||
return file_obj.read()
|
return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None
|
||||||
except RecipeDownloadError:
|
except RecipeDownloadError:
|
||||||
raise
|
raise
|
||||||
except RecipeValidationError:
|
except RecipeValidationError:
|
||||||
@@ -766,6 +1074,7 @@ class RecipeManagementHandler:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _safe_int(self, value: Any) -> int:
|
def _safe_int(self, value: Any) -> int:
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from ..utils.utils import get_lora_info
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LoraRoutes(BaseModelRoutes):
|
class LoraRoutes(BaseModelRoutes):
|
||||||
"""LoRA-specific route controller"""
|
"""LoRA-specific route controller"""
|
||||||
|
|
||||||
@@ -36,45 +37,67 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
app.on_startup.append(lambda _: self.initialize_services())
|
app.on_startup.append(lambda _: self.initialize_services())
|
||||||
|
|
||||||
# Setup common routes with 'loras' prefix (includes page route)
|
# Setup common routes with 'loras' prefix (includes page route)
|
||||||
super().setup_routes(app, 'loras')
|
super().setup_routes(app, "loras")
|
||||||
|
|
||||||
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
||||||
"""Setup LoRA-specific routes"""
|
"""Setup LoRA-specific routes"""
|
||||||
# LoRA-specific query routes
|
# LoRA-specific query routes
|
||||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/letter-counts', prefix, self.get_letter_counts)
|
registrar.add_prefixed_route(
|
||||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/get-trigger-words', prefix, self.get_lora_trigger_words)
|
"GET", "/api/lm/{prefix}/letter-counts", prefix, self.get_letter_counts
|
||||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/usage-tips-by-path', prefix, self.get_lora_usage_tips_by_path)
|
)
|
||||||
|
registrar.add_prefixed_route(
|
||||||
|
"GET",
|
||||||
|
"/api/lm/{prefix}/get-trigger-words",
|
||||||
|
prefix,
|
||||||
|
self.get_lora_trigger_words,
|
||||||
|
)
|
||||||
|
registrar.add_prefixed_route(
|
||||||
|
"GET",
|
||||||
|
"/api/lm/{prefix}/usage-tips-by-path",
|
||||||
|
prefix,
|
||||||
|
self.get_lora_usage_tips_by_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Randomizer routes
|
||||||
|
registrar.add_prefixed_route(
|
||||||
|
"POST", "/api/lm/{prefix}/random-sample", prefix, self.get_random_loras
|
||||||
|
)
|
||||||
|
|
||||||
# ComfyUI integration
|
# ComfyUI integration
|
||||||
registrar.add_prefixed_route('POST', '/api/lm/{prefix}/get_trigger_words', prefix, self.get_trigger_words)
|
registrar.add_prefixed_route(
|
||||||
|
"POST", "/api/lm/{prefix}/get_trigger_words", prefix, self.get_trigger_words
|
||||||
|
)
|
||||||
|
|
||||||
def _parse_specific_params(self, request: web.Request) -> Dict:
|
def _parse_specific_params(self, request: web.Request) -> Dict:
|
||||||
"""Parse LoRA-specific parameters"""
|
"""Parse LoRA-specific parameters"""
|
||||||
params = {}
|
params = {}
|
||||||
|
|
||||||
# LoRA-specific parameters
|
# LoRA-specific parameters
|
||||||
if 'first_letter' in request.query:
|
if "first_letter" in request.query:
|
||||||
params['first_letter'] = request.query.get('first_letter')
|
params["first_letter"] = request.query.get("first_letter")
|
||||||
|
|
||||||
# Handle fuzzy search parameter name variation
|
# Handle fuzzy search parameter name variation
|
||||||
if request.query.get('fuzzy') == 'true':
|
if request.query.get("fuzzy") == "true":
|
||||||
params['fuzzy_search'] = True
|
params["fuzzy_search"] = True
|
||||||
|
|
||||||
# Handle additional filter parameters for LoRAs
|
# Handle additional filter parameters for LoRAs
|
||||||
if 'lora_hash' in request.query:
|
if "lora_hash" in request.query:
|
||||||
if not params.get('hash_filters'):
|
if not params.get("hash_filters"):
|
||||||
params['hash_filters'] = {}
|
params["hash_filters"] = {}
|
||||||
params['hash_filters']['single_hash'] = request.query['lora_hash'].lower()
|
params["hash_filters"]["single_hash"] = request.query["lora_hash"].lower()
|
||||||
elif 'lora_hashes' in request.query:
|
elif "lora_hashes" in request.query:
|
||||||
if not params.get('hash_filters'):
|
if not params.get("hash_filters"):
|
||||||
params['hash_filters'] = {}
|
params["hash_filters"] = {}
|
||||||
params['hash_filters']['multiple_hashes'] = [h.lower() for h in request.query['lora_hashes'].split(',')]
|
params["hash_filters"]["multiple_hashes"] = [
|
||||||
|
h.lower() for h in request.query["lora_hashes"].split(",")
|
||||||
|
]
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||||
"""Validate CivitAI model type for LoRA"""
|
"""Validate CivitAI model type for LoRA"""
|
||||||
from ..utils.constants import VALID_LORA_TYPES
|
from ..utils.constants import VALID_LORA_TYPES
|
||||||
|
|
||||||
return model_type.lower() in VALID_LORA_TYPES
|
return model_type.lower() in VALID_LORA_TYPES
|
||||||
|
|
||||||
def _get_expected_model_types(self) -> str:
|
def _get_expected_model_types(self) -> str:
|
||||||
@@ -86,134 +109,179 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
"""Get count of LoRAs for each letter of the alphabet"""
|
"""Get count of LoRAs for each letter of the alphabet"""
|
||||||
try:
|
try:
|
||||||
letter_counts = await self.service.get_letter_counts()
|
letter_counts = await self.service.get_letter_counts()
|
||||||
return web.json_response({
|
return web.json_response({"success": True, "letter_counts": letter_counts})
|
||||||
'success': True,
|
|
||||||
'letter_counts': letter_counts
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting letter counts: {e}")
|
logger.error(f"Error getting letter counts: {e}")
|
||||||
return web.json_response({
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
async def get_lora_notes(self, request: web.Request) -> web.Response:
|
async def get_lora_notes(self, request: web.Request) -> web.Response:
|
||||||
"""Get notes for a specific LoRA file"""
|
"""Get notes for a specific LoRA file"""
|
||||||
try:
|
try:
|
||||||
lora_name = request.query.get('name')
|
lora_name = request.query.get("name")
|
||||||
if not lora_name:
|
if not lora_name:
|
||||||
return web.Response(text='Lora file name is required', status=400)
|
return web.Response(text="Lora file name is required", status=400)
|
||||||
|
|
||||||
notes = await self.service.get_lora_notes(lora_name)
|
notes = await self.service.get_lora_notes(lora_name)
|
||||||
if notes is not None:
|
if notes is not None:
|
||||||
return web.json_response({
|
return web.json_response({"success": True, "notes": notes})
|
||||||
'success': True,
|
|
||||||
'notes': notes
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
return web.json_response({
|
return web.json_response(
|
||||||
'success': False,
|
{"success": False, "error": "LoRA not found in cache"}, status=404
|
||||||
'error': 'LoRA not found in cache'
|
)
|
||||||
}, status=404)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting lora notes: {e}", exc_info=True)
|
logger.error(f"Error getting lora notes: {e}", exc_info=True)
|
||||||
return web.json_response({
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
async def get_lora_trigger_words(self, request: web.Request) -> web.Response:
|
async def get_lora_trigger_words(self, request: web.Request) -> web.Response:
|
||||||
"""Get trigger words for a specific LoRA file"""
|
"""Get trigger words for a specific LoRA file"""
|
||||||
try:
|
try:
|
||||||
lora_name = request.query.get('name')
|
lora_name = request.query.get("name")
|
||||||
if not lora_name:
|
if not lora_name:
|
||||||
return web.Response(text='Lora file name is required', status=400)
|
return web.Response(text="Lora file name is required", status=400)
|
||||||
|
|
||||||
trigger_words = await self.service.get_lora_trigger_words(lora_name)
|
trigger_words = await self.service.get_lora_trigger_words(lora_name)
|
||||||
return web.json_response({
|
return web.json_response({"success": True, "trigger_words": trigger_words})
|
||||||
'success': True,
|
|
||||||
'trigger_words': trigger_words
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting lora trigger words: {e}", exc_info=True)
|
logger.error(f"Error getting lora trigger words: {e}", exc_info=True)
|
||||||
return web.json_response({
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
async def get_lora_usage_tips_by_path(self, request: web.Request) -> web.Response:
|
async def get_lora_usage_tips_by_path(self, request: web.Request) -> web.Response:
|
||||||
"""Get usage tips for a LoRA by its relative path"""
|
"""Get usage tips for a LoRA by its relative path"""
|
||||||
try:
|
try:
|
||||||
relative_path = request.query.get('relative_path')
|
relative_path = request.query.get("relative_path")
|
||||||
if not relative_path:
|
if not relative_path:
|
||||||
return web.Response(text='Relative path is required', status=400)
|
return web.Response(text="Relative path is required", status=400)
|
||||||
|
|
||||||
usage_tips = await self.service.get_lora_usage_tips_by_relative_path(relative_path)
|
usage_tips = await self.service.get_lora_usage_tips_by_relative_path(
|
||||||
return web.json_response({
|
relative_path
|
||||||
'success': True,
|
)
|
||||||
'usage_tips': usage_tips or ''
|
return web.json_response({"success": True, "usage_tips": usage_tips or ""})
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting lora usage tips by path: {e}", exc_info=True)
|
logger.error(f"Error getting lora usage tips by path: {e}", exc_info=True)
|
||||||
return web.json_response({
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
async def get_lora_preview_url(self, request: web.Request) -> web.Response:
|
async def get_lora_preview_url(self, request: web.Request) -> web.Response:
|
||||||
"""Get the static preview URL for a LoRA file"""
|
"""Get the static preview URL for a LoRA file"""
|
||||||
try:
|
try:
|
||||||
lora_name = request.query.get('name')
|
lora_name = request.query.get("name")
|
||||||
if not lora_name:
|
if not lora_name:
|
||||||
return web.Response(text='Lora file name is required', status=400)
|
return web.Response(text="Lora file name is required", status=400)
|
||||||
|
|
||||||
preview_url = await self.service.get_lora_preview_url(lora_name)
|
preview_url = await self.service.get_lora_preview_url(lora_name)
|
||||||
if preview_url:
|
if preview_url:
|
||||||
return web.json_response({
|
return web.json_response({"success": True, "preview_url": preview_url})
|
||||||
'success': True,
|
|
||||||
'preview_url': preview_url
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
return web.json_response({
|
return web.json_response(
|
||||||
'success': False,
|
{
|
||||||
'error': 'No preview URL found for the specified lora'
|
"success": False,
|
||||||
}, status=404)
|
"error": "No preview URL found for the specified lora",
|
||||||
|
},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
|
logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
|
||||||
return web.json_response({
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
async def get_lora_civitai_url(self, request: web.Request) -> web.Response:
|
async def get_lora_civitai_url(self, request: web.Request) -> web.Response:
|
||||||
"""Get the Civitai URL for a LoRA file"""
|
"""Get the Civitai URL for a LoRA file"""
|
||||||
try:
|
try:
|
||||||
lora_name = request.query.get('name')
|
lora_name = request.query.get("name")
|
||||||
if not lora_name:
|
if not lora_name:
|
||||||
return web.Response(text='Lora file name is required', status=400)
|
return web.Response(text="Lora file name is required", status=400)
|
||||||
|
|
||||||
result = await self.service.get_lora_civitai_url(lora_name)
|
result = await self.service.get_lora_civitai_url(lora_name)
|
||||||
if result['civitai_url']:
|
if result["civitai_url"]:
|
||||||
return web.json_response({
|
return web.json_response({"success": True, **result})
|
||||||
'success': True,
|
|
||||||
**result
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
return web.json_response({
|
return web.json_response(
|
||||||
'success': False,
|
{
|
||||||
'error': 'No Civitai data found for the specified lora'
|
"success": False,
|
||||||
}, status=404)
|
"error": "No Civitai data found for the specified lora",
|
||||||
|
},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting lora Civitai URL: {e}", exc_info=True)
|
logger.error(f"Error getting lora Civitai URL: {e}", exc_info=True)
|
||||||
return web.json_response({
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
async def get_random_loras(self, request: web.Request) -> web.Response:
|
||||||
}, status=500)
|
"""Get random LoRAs based on filters and strength ranges"""
|
||||||
|
try:
|
||||||
|
json_data = await request.json()
|
||||||
|
|
||||||
|
# Parse parameters
|
||||||
|
count = json_data.get("count", 5)
|
||||||
|
count_min = json_data.get("count_min")
|
||||||
|
count_max = json_data.get("count_max")
|
||||||
|
model_strength_min = float(json_data.get("model_strength_min", 0.0))
|
||||||
|
model_strength_max = float(json_data.get("model_strength_max", 1.0))
|
||||||
|
use_same_clip_strength = json_data.get("use_same_clip_strength", True)
|
||||||
|
clip_strength_min = float(json_data.get("clip_strength_min", 0.0))
|
||||||
|
clip_strength_max = float(json_data.get("clip_strength_max", 1.0))
|
||||||
|
locked_loras = json_data.get("locked_loras", [])
|
||||||
|
pool_config = json_data.get("pool_config")
|
||||||
|
use_recommended_strength = json_data.get("use_recommended_strength", False)
|
||||||
|
recommended_strength_scale_min = float(
|
||||||
|
json_data.get("recommended_strength_scale_min", 0.5)
|
||||||
|
)
|
||||||
|
recommended_strength_scale_max = float(
|
||||||
|
json_data.get("recommended_strength_scale_max", 1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine target count
|
||||||
|
if count_min is not None and count_max is not None:
|
||||||
|
import random
|
||||||
|
|
||||||
|
target_count = random.randint(count_min, count_max)
|
||||||
|
else:
|
||||||
|
target_count = count
|
||||||
|
|
||||||
|
# Validate parameters
|
||||||
|
if target_count < 1 or target_count > 100:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Count must be between 1 and 100"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if model_strength_min < -10 or model_strength_max > 10:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Model strength must be between -10 and 10",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get random LoRAs from service
|
||||||
|
result_loras = await self.service.get_random_loras(
|
||||||
|
count=target_count,
|
||||||
|
model_strength_min=model_strength_min,
|
||||||
|
model_strength_max=model_strength_max,
|
||||||
|
use_same_clip_strength=use_same_clip_strength,
|
||||||
|
clip_strength_min=clip_strength_min,
|
||||||
|
clip_strength_max=clip_strength_max,
|
||||||
|
locked_loras=locked_loras,
|
||||||
|
pool_config=pool_config,
|
||||||
|
use_recommended_strength=use_recommended_strength,
|
||||||
|
recommended_strength_scale_min=recommended_strength_scale_min,
|
||||||
|
recommended_strength_scale_max=recommended_strength_scale_max,
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "loras": result_loras, "count": len(result_loras)}
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Invalid parameter for random LoRAs: {e}")
|
||||||
|
return web.json_response({"success": False, "error": str(e)}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting random LoRAs: {e}", exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
|
|
||||||
async def get_trigger_words(self, request: web.Request) -> web.Response:
|
async def get_trigger_words(self, request: web.Request) -> web.Response:
|
||||||
"""Get trigger words for specified LoRA models"""
|
"""Get trigger words for specified LoRA models"""
|
||||||
@@ -228,7 +296,9 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
all_trigger_words.extend(trigger_words)
|
all_trigger_words.extend(trigger_words)
|
||||||
|
|
||||||
# Format the trigger words
|
# Format the trigger words
|
||||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
trigger_words_text = (
|
||||||
|
",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||||
|
)
|
||||||
|
|
||||||
# Send update to all connected trigger word toggle nodes
|
# Send update to all connected trigger word toggle nodes
|
||||||
for entry in node_ids:
|
for entry in node_ids:
|
||||||
@@ -243,10 +313,7 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
parsed_node_id = node_identifier
|
parsed_node_id = node_identifier
|
||||||
|
|
||||||
payload = {
|
payload = {"id": parsed_node_id, "message": trigger_words_text}
|
||||||
"id": parsed_node_id,
|
|
||||||
"message": trigger_words_text
|
|
||||||
}
|
|
||||||
|
|
||||||
if graph_identifier is not None:
|
if graph_identifier is not None:
|
||||||
payload["graph_id"] = str(graph_identifier)
|
payload["graph_id"] = str(graph_identifier)
|
||||||
@@ -257,7 +324,4 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting trigger words: {e}")
|
logger.error(f"Error getting trigger words: {e}")
|
||||||
return web.json_response({
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
"success": False,
|
|
||||||
"error": str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"),
|
RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"),
|
||||||
RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"),
|
RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"),
|
||||||
RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"),
|
RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"),
|
||||||
|
RouteDefinition("POST", "/api/lm/settings/open-location", "open_settings_location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class MiscRoutes:
|
|||||||
settings_service=self._settings,
|
settings_service=self._settings,
|
||||||
metadata_provider_updater=self._metadata_provider_updater,
|
metadata_provider_updater=self._metadata_provider_updater,
|
||||||
)
|
)
|
||||||
filesystem = FileSystemHandler()
|
filesystem = FileSystemHandler(settings_service=self._settings)
|
||||||
node_registry_handler = NodeRegistryHandler(
|
node_registry_handler = NodeRegistryHandler(
|
||||||
node_registry=self._node_registry,
|
node_registry=self._node_registry,
|
||||||
prompt_server=self._prompt_server,
|
prompt_server=self._prompt_server,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
|
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
|
||||||
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
|
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
|
||||||
RouteDefinition("GET", "/api/lm/download-progress/{download_id}", "get_download_progress"),
|
RouteDefinition("GET", "/api/lm/download-progress/{download_id}", "get_download_progress"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
|
||||||
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
|
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -27,16 +27,26 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||||
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
|
||||||
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
|
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
|
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import asyncio
|
|||||||
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
from ..utils.constants import VALID_LORA_TYPES
|
from ..utils.constants import VALID_LORA_TYPES
|
||||||
from ..utils.models import BaseModelMetadata
|
from ..utils.models import BaseModelMetadata
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
|
from ..utils.usage_stats import UsageStats
|
||||||
from .model_query import (
|
from .model_query import (
|
||||||
FilterCriteria,
|
FilterCriteria,
|
||||||
ModelCacheRepository,
|
ModelCacheRepository,
|
||||||
@@ -23,6 +25,7 @@ logger = logging.getLogger(__name__)
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .model_update_service import ModelUpdateService
|
from .model_update_service import ModelUpdateService
|
||||||
|
|
||||||
|
|
||||||
class BaseModelService(ABC):
|
class BaseModelService(ABC):
|
||||||
"""Base service class for all model types"""
|
"""Base service class for all model types"""
|
||||||
|
|
||||||
@@ -63,8 +66,10 @@ class BaseModelService(ABC):
|
|||||||
self,
|
self,
|
||||||
page: int,
|
page: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
sort_by: str = 'name',
|
sort_by: str = "name",
|
||||||
folder: str = None,
|
folder: str = None,
|
||||||
|
folder_include: list = None,
|
||||||
|
folder_exclude: list = None,
|
||||||
search: str = None,
|
search: str = None,
|
||||||
fuzzy_search: bool = False,
|
fuzzy_search: bool = False,
|
||||||
base_models: list = None,
|
base_models: list = None,
|
||||||
@@ -79,16 +84,26 @@ class BaseModelService(ABC):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Get paginated and filtered model data"""
|
"""Get paginated and filtered model data"""
|
||||||
|
overall_start = time.perf_counter()
|
||||||
|
|
||||||
sort_params = self.cache_repository.parse_sort(sort_by)
|
sort_params = self.cache_repository.parse_sort(sort_by)
|
||||||
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
t0 = time.perf_counter()
|
||||||
|
if sort_params.key == "usage":
|
||||||
|
sorted_data = await self._fetch_with_usage_sort(sort_params)
|
||||||
|
else:
|
||||||
|
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
||||||
|
fetch_duration = time.perf_counter() - t0
|
||||||
|
initial_count = len(sorted_data)
|
||||||
|
|
||||||
|
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)
|
||||||
else:
|
else:
|
||||||
filtered_data = await self._apply_common_filters(
|
filtered_data = await self._apply_common_filters(
|
||||||
sorted_data,
|
sorted_data,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
|
folder_include=folder_include,
|
||||||
|
folder_exclude=folder_exclude,
|
||||||
base_models=base_models,
|
base_models=base_models,
|
||||||
model_types=model_types,
|
model_types=model_types,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
@@ -108,50 +123,110 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
# Apply license-based filters
|
# Apply license-based filters
|
||||||
if credit_required is not None:
|
if credit_required is not None:
|
||||||
filtered_data = await self._apply_credit_required_filter(filtered_data, credit_required)
|
filtered_data = await self._apply_credit_required_filter(
|
||||||
|
filtered_data, credit_required
|
||||||
|
)
|
||||||
|
|
||||||
if allow_selling_generated_content is not None:
|
if allow_selling_generated_content is not None:
|
||||||
filtered_data = await self._apply_allow_selling_filter(filtered_data, allow_selling_generated_content)
|
filtered_data = await self._apply_allow_selling_filter(
|
||||||
|
filtered_data, allow_selling_generated_content
|
||||||
|
)
|
||||||
|
filter_duration = time.perf_counter() - t1
|
||||||
|
post_filter_count = len(filtered_data)
|
||||||
|
|
||||||
annotated_for_filter: Optional[List[Dict]] = None
|
annotated_for_filter: Optional[List[Dict]] = None
|
||||||
|
t2 = time.perf_counter()
|
||||||
if update_available_only:
|
if update_available_only:
|
||||||
annotated_for_filter = await self._annotate_update_flags(filtered_data)
|
annotated_for_filter = await self._annotate_update_flags(filtered_data)
|
||||||
filtered_data = [
|
filtered_data = [
|
||||||
item for item in annotated_for_filter
|
item for item in annotated_for_filter if item.get("update_available")
|
||||||
if item.get('update_available')
|
|
||||||
]
|
]
|
||||||
|
update_filter_duration = time.perf_counter() - t2
|
||||||
|
final_count = len(filtered_data)
|
||||||
|
|
||||||
|
t3 = time.perf_counter()
|
||||||
paginated = self._paginate(filtered_data, page, page_size)
|
paginated = self._paginate(filtered_data, page, page_size)
|
||||||
|
pagination_duration = time.perf_counter() - t3
|
||||||
|
|
||||||
|
t4 = time.perf_counter()
|
||||||
if update_available_only:
|
if update_available_only:
|
||||||
# Items already include update flags thanks to the pre-filter annotation.
|
# Items already include update flags thanks to the pre-filter annotation.
|
||||||
paginated['items'] = list(paginated['items'])
|
paginated["items"] = list(paginated["items"])
|
||||||
else:
|
else:
|
||||||
paginated['items'] = await self._annotate_update_flags(
|
paginated["items"] = await self._annotate_update_flags(
|
||||||
paginated['items'],
|
paginated["items"],
|
||||||
)
|
)
|
||||||
|
annotate_duration = time.perf_counter() - t4
|
||||||
|
|
||||||
|
overall_duration = time.perf_counter() - overall_start
|
||||||
|
logger.debug(
|
||||||
|
"%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",
|
||||||
|
self.__class__.__name__,
|
||||||
|
overall_duration,
|
||||||
|
fetch_duration,
|
||||||
|
filter_duration,
|
||||||
|
update_filter_duration,
|
||||||
|
pagination_duration,
|
||||||
|
annotate_duration,
|
||||||
|
initial_count,
|
||||||
|
post_filter_count,
|
||||||
|
final_count,
|
||||||
|
)
|
||||||
return paginated
|
return paginated
|
||||||
|
|
||||||
|
async def _fetch_with_usage_sort(self, sort_params):
|
||||||
|
"""Fetch data sorted by usage count (desc/asc)."""
|
||||||
|
cache = await self.cache_repository.get_cache()
|
||||||
|
raw_items = cache.raw_data or []
|
||||||
|
|
||||||
async def _apply_hash_filters(self, data: List[Dict], hash_filters: Dict) -> List[Dict]:
|
# Map model type to usage stats bucket
|
||||||
|
bucket_map = {
|
||||||
|
"lora": "loras",
|
||||||
|
"checkpoint": "checkpoints",
|
||||||
|
# 'embedding': 'embeddings', # TODO: Enable when embedding usage tracking is implemented
|
||||||
|
}
|
||||||
|
bucket_key = bucket_map.get(self.model_type, "")
|
||||||
|
|
||||||
|
usage_stats = UsageStats()
|
||||||
|
stats = await usage_stats.get_stats()
|
||||||
|
usage_bucket = stats.get(bucket_key, {}) if bucket_key else {}
|
||||||
|
|
||||||
|
annotated = []
|
||||||
|
for item in raw_items:
|
||||||
|
sha = (item.get("sha256") or "").lower()
|
||||||
|
usage_info = (
|
||||||
|
usage_bucket.get(sha, {}) if isinstance(usage_bucket, dict) else {}
|
||||||
|
)
|
||||||
|
usage_count = (
|
||||||
|
usage_info.get("total", 0) if isinstance(usage_info, dict) else 0
|
||||||
|
)
|
||||||
|
annotated.append({**item, "usage_count": usage_count})
|
||||||
|
|
||||||
|
reverse = sort_params.order == "desc"
|
||||||
|
annotated.sort(
|
||||||
|
key=lambda x: (x.get("usage_count", 0), x.get("model_name", "").lower()),
|
||||||
|
reverse=reverse,
|
||||||
|
)
|
||||||
|
return annotated
|
||||||
|
|
||||||
|
async def _apply_hash_filters(
|
||||||
|
self, data: List[Dict], hash_filters: Dict
|
||||||
|
) -> List[Dict]:
|
||||||
"""Apply hash-based filtering"""
|
"""Apply hash-based filtering"""
|
||||||
single_hash = hash_filters.get('single_hash')
|
single_hash = hash_filters.get("single_hash")
|
||||||
multiple_hashes = hash_filters.get('multiple_hashes')
|
multiple_hashes = hash_filters.get("multiple_hashes")
|
||||||
|
|
||||||
if single_hash:
|
if single_hash:
|
||||||
# Filter by single hash
|
# Filter by single hash
|
||||||
single_hash = single_hash.lower()
|
single_hash = single_hash.lower()
|
||||||
return [
|
return [
|
||||||
item for item in data
|
item for item in data if item.get("sha256", "").lower() == single_hash
|
||||||
if item.get('sha256', '').lower() == single_hash
|
|
||||||
]
|
]
|
||||||
elif multiple_hashes:
|
elif multiple_hashes:
|
||||||
# Filter by multiple hashes
|
# Filter by multiple hashes
|
||||||
hash_set = set(hash.lower() for hash in multiple_hashes)
|
hash_set = set(hash.lower() for hash in multiple_hashes)
|
||||||
return [
|
return [item for item in data if item.get("sha256", "").lower() in hash_set]
|
||||||
item for item in data
|
|
||||||
if item.get('sha256', '').lower() in hash_set
|
|
||||||
]
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -159,6 +234,8 @@ class BaseModelService(ABC):
|
|||||||
self,
|
self,
|
||||||
data: List[Dict],
|
data: List[Dict],
|
||||||
folder: str = None,
|
folder: str = None,
|
||||||
|
folder_include: list = None,
|
||||||
|
folder_exclude: list = None,
|
||||||
base_models: list = None,
|
base_models: list = None,
|
||||||
model_types: list = None,
|
model_types: list = None,
|
||||||
tags: Optional[Dict[str, str]] = None,
|
tags: Optional[Dict[str, str]] = None,
|
||||||
@@ -169,6 +246,8 @@ class BaseModelService(ABC):
|
|||||||
normalized_options = self.search_strategy.normalize_options(search_options)
|
normalized_options = self.search_strategy.normalize_options(search_options)
|
||||||
criteria = FilterCriteria(
|
criteria = FilterCriteria(
|
||||||
folder=folder,
|
folder=folder,
|
||||||
|
folder_include=folder_include,
|
||||||
|
folder_exclude=folder_exclude,
|
||||||
base_models=base_models,
|
base_models=base_models,
|
||||||
model_types=model_types,
|
model_types=model_types,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
@@ -186,13 +265,17 @@ class BaseModelService(ABC):
|
|||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""Apply search filtering"""
|
"""Apply search filtering"""
|
||||||
normalized_options = self.search_strategy.normalize_options(search_options)
|
normalized_options = self.search_strategy.normalize_options(search_options)
|
||||||
return self.search_strategy.apply(data, search, normalized_options, fuzzy_search)
|
return self.search_strategy.apply(
|
||||||
|
data, search, normalized_options, fuzzy_search
|
||||||
|
)
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
"""Apply model-specific filters - to be overridden by subclasses if needed"""
|
"""Apply model-specific filters - to be overridden by subclasses if needed"""
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def _apply_credit_required_filter(self, data: List[Dict], credit_required: bool) -> List[Dict]:
|
async def _apply_credit_required_filter(
|
||||||
|
self, data: List[Dict], credit_required: bool
|
||||||
|
) -> List[Dict]:
|
||||||
"""Apply credit required filtering based on license_flags.
|
"""Apply credit required filtering based on license_flags.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -203,7 +286,9 @@ class BaseModelService(ABC):
|
|||||||
"""
|
"""
|
||||||
filtered_data = []
|
filtered_data = []
|
||||||
for item in data:
|
for item in data:
|
||||||
license_flags = item.get("license_flags", 127) # Default to all permissions enabled
|
license_flags = item.get(
|
||||||
|
"license_flags", 127
|
||||||
|
) # Default to all permissions enabled
|
||||||
|
|
||||||
# Bit 0 represents allowNoCredit (1 = no credit required, 0 = credit required)
|
# Bit 0 represents allowNoCredit (1 = no credit required, 0 = credit required)
|
||||||
allow_no_credit = bool(license_flags & (1 << 0))
|
allow_no_credit = bool(license_flags & (1 << 0))
|
||||||
@@ -219,7 +304,9 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
return filtered_data
|
return filtered_data
|
||||||
|
|
||||||
async def _apply_allow_selling_filter(self, data: List[Dict], allow_selling: bool) -> List[Dict]:
|
async def _apply_allow_selling_filter(
|
||||||
|
self, data: List[Dict], allow_selling: bool
|
||||||
|
) -> List[Dict]:
|
||||||
"""Apply allow selling generated content filtering based on license_flags.
|
"""Apply allow selling generated content filtering based on license_flags.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -230,7 +317,9 @@ class BaseModelService(ABC):
|
|||||||
"""
|
"""
|
||||||
filtered_data = []
|
filtered_data = []
|
||||||
for item in data:
|
for item in data:
|
||||||
license_flags = item.get("license_flags", 127) # Default to all permissions enabled
|
license_flags = item.get(
|
||||||
|
"license_flags", 127
|
||||||
|
) # Default to all permissions enabled
|
||||||
|
|
||||||
# Bits 1-4 represent commercial use permissions
|
# Bits 1-4 represent commercial use permissions
|
||||||
# Bit 1 specifically represents Image permission (allowCommercialUse contains Image)
|
# Bit 1 specifically represents Image permission (allowCommercialUse contains Image)
|
||||||
@@ -262,7 +351,7 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
if self.update_service is None:
|
if self.update_service is None:
|
||||||
for item in annotated:
|
for item in annotated:
|
||||||
item['update_available'] = False
|
item["update_available"] = False
|
||||||
return annotated
|
return annotated
|
||||||
|
|
||||||
id_to_items: Dict[int, List[Dict]] = {}
|
id_to_items: Dict[int, List[Dict]] = {}
|
||||||
@@ -270,7 +359,7 @@ class BaseModelService(ABC):
|
|||||||
for item in annotated:
|
for item in annotated:
|
||||||
model_id = self._extract_model_id(item)
|
model_id = self._extract_model_id(item)
|
||||||
if model_id is None:
|
if model_id is None:
|
||||||
item['update_available'] = False
|
item["update_available"] = False
|
||||||
continue
|
continue
|
||||||
if model_id not in id_to_items:
|
if model_id not in id_to_items:
|
||||||
id_to_items[model_id] = []
|
id_to_items[model_id] = []
|
||||||
@@ -346,13 +435,19 @@ class BaseModelService(ABC):
|
|||||||
default_flag = bool(resolved.get(model_id, False)) if resolved else False
|
default_flag = bool(resolved.get(model_id, False)) if resolved else False
|
||||||
record = records.get(model_id) if records else None
|
record = records.get(model_id) if records else None
|
||||||
base_highest_versions = (
|
base_highest_versions = (
|
||||||
self._build_highest_local_versions_by_base(record) if same_base_mode and record else {}
|
self._build_highest_local_versions_by_base(record)
|
||||||
|
if same_base_mode and record
|
||||||
|
else {}
|
||||||
)
|
)
|
||||||
for item in items_for_id:
|
for item in items_for_id:
|
||||||
if same_base_mode and record is not None:
|
if same_base_mode and record is not None:
|
||||||
base_model = self._extract_base_model(item)
|
base_model = self._extract_base_model(item)
|
||||||
normalized_base = self._normalize_base_model_name(base_model)
|
normalized_base = self._normalize_base_model_name(base_model)
|
||||||
threshold_version = base_highest_versions.get(normalized_base) if normalized_base else None
|
threshold_version = (
|
||||||
|
base_highest_versions.get(normalized_base)
|
||||||
|
if normalized_base
|
||||||
|
else None
|
||||||
|
)
|
||||||
if threshold_version is None:
|
if threshold_version is None:
|
||||||
threshold_version = self._extract_version_id(item)
|
threshold_version = self._extract_version_id(item)
|
||||||
flag = record.has_update_for_base(
|
flag = record.has_update_for_base(
|
||||||
@@ -361,17 +456,17 @@ class BaseModelService(ABC):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
flag = default_flag
|
flag = default_flag
|
||||||
item['update_available'] = flag
|
item["update_available"] = flag
|
||||||
|
|
||||||
return annotated
|
return annotated
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_model_id(item: Dict) -> Optional[int]:
|
def _extract_model_id(item: Dict) -> Optional[int]:
|
||||||
civitai = item.get('civitai') if isinstance(item, dict) else None
|
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||||
if not isinstance(civitai, dict):
|
if not isinstance(civitai, dict):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
value = civitai.get('modelId')
|
value = civitai.get("modelId")
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
return int(value)
|
return int(value)
|
||||||
@@ -380,10 +475,10 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_version_id(item: Dict) -> Optional[int]:
|
def _extract_version_id(item: Dict) -> Optional[int]:
|
||||||
civitai = item.get('civitai') if isinstance(item, dict) else None
|
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||||
if not isinstance(civitai, dict):
|
if not isinstance(civitai, dict):
|
||||||
return None
|
return None
|
||||||
value = civitai.get('id')
|
value = civitai.get("id")
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -393,7 +488,7 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_base_model(item: Dict) -> Optional[str]:
|
def _extract_base_model(item: Dict) -> Optional[str]:
|
||||||
value = item.get('base_model')
|
value = item.get("base_model")
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
@@ -430,7 +525,9 @@ class BaseModelService(ABC):
|
|||||||
for version in getattr(record, "versions", []):
|
for version in getattr(record, "versions", []):
|
||||||
if not getattr(version, "is_in_library", False):
|
if not getattr(version, "is_in_library", False):
|
||||||
continue
|
continue
|
||||||
normalized_base = self._normalize_base_model_name(getattr(version, "base_model", None))
|
normalized_base = self._normalize_base_model_name(
|
||||||
|
getattr(version, "base_model", None)
|
||||||
|
)
|
||||||
if normalized_base is None:
|
if normalized_base is None:
|
||||||
continue
|
continue
|
||||||
version_id = getattr(version, "version_id", None)
|
version_id = getattr(version, "version_id", None)
|
||||||
@@ -449,11 +546,11 @@ class BaseModelService(ABC):
|
|||||||
end_idx = min(start_idx + page_size, total_items)
|
end_idx = min(start_idx + page_size, total_items)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'items': data[start_idx:end_idx],
|
"items": data[start_idx:end_idx],
|
||||||
'total': total_items,
|
"total": total_items,
|
||||||
'page': page,
|
"page": page,
|
||||||
'page_size': page_size,
|
"page_size": page_size,
|
||||||
'total_pages': (total_items + page_size - 1) // page_size
|
"total_pages": (total_items + page_size - 1) // page_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -476,13 +573,18 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
type_counts: Dict[str, int] = {}
|
type_counts: Dict[str, int] = {}
|
||||||
for entry in cache.raw_data:
|
for entry in cache.raw_data:
|
||||||
normalized_type = normalize_civitai_model_type(resolve_civitai_model_type(entry))
|
normalized_type = normalize_civitai_model_type(
|
||||||
|
resolve_civitai_model_type(entry)
|
||||||
|
)
|
||||||
if not normalized_type or normalized_type not in VALID_LORA_TYPES:
|
if not normalized_type or normalized_type not in VALID_LORA_TYPES:
|
||||||
continue
|
continue
|
||||||
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||||
|
|
||||||
sorted_types = sorted(
|
sorted_types = sorted(
|
||||||
[{"type": model_type, "count": count} for model_type, count in type_counts.items()],
|
[
|
||||||
|
{"type": model_type, "count": count}
|
||||||
|
for model_type, count in type_counts.items()
|
||||||
|
],
|
||||||
key=lambda value: value["count"],
|
key=lambda value: value["count"],
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
@@ -501,9 +603,13 @@ class BaseModelService(ABC):
|
|||||||
"""Get hash for a model by its file path"""
|
"""Get hash for a model by its file path"""
|
||||||
return self.scanner.get_hash_by_path(file_path)
|
return self.scanner.get_hash_by_path(file_path)
|
||||||
|
|
||||||
async def scan_models(self, force_refresh: bool = False, rebuild_cache: bool = False):
|
async def scan_models(
|
||||||
|
self, force_refresh: bool = False, rebuild_cache: bool = False
|
||||||
|
):
|
||||||
"""Trigger model scanning"""
|
"""Trigger model scanning"""
|
||||||
return await self.scanner.get_cached_data(force_refresh=force_refresh, rebuild_cache=rebuild_cache)
|
return await self.scanner.get_cached_data(
|
||||||
|
force_refresh=force_refresh, rebuild_cache=rebuild_cache
|
||||||
|
)
|
||||||
|
|
||||||
async def get_model_info_by_name(self, name: str):
|
async def get_model_info_by_name(self, name: str):
|
||||||
"""Get model information by name"""
|
"""Get model information by name"""
|
||||||
@@ -518,11 +624,25 @@ class BaseModelService(ABC):
|
|||||||
if not data:
|
if not data:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
fields = ["id", "modelId", "name", "trainedWords"] if minimal else [
|
fields = (
|
||||||
"id", "modelId", "name", "createdAt", "updatedAt",
|
["id", "modelId", "name", "trainedWords"]
|
||||||
"publishedAt", "trainedWords", "baseModel", "description",
|
if minimal
|
||||||
"model", "images", "customImages", "creator"
|
else [
|
||||||
]
|
"id",
|
||||||
|
"modelId",
|
||||||
|
"name",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
"publishedAt",
|
||||||
|
"trainedWords",
|
||||||
|
"baseModel",
|
||||||
|
"description",
|
||||||
|
"model",
|
||||||
|
"images",
|
||||||
|
"customImages",
|
||||||
|
"creator",
|
||||||
|
]
|
||||||
|
)
|
||||||
return {k: data[k] for k in fields if k in data}
|
return {k: data[k] for k in fields if k in data}
|
||||||
|
|
||||||
async def get_folder_tree(self, model_root: str) -> Dict:
|
async def get_folder_tree(self, model_root: str) -> Dict:
|
||||||
@@ -544,7 +664,7 @@ class BaseModelService(ABC):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Split folder path into components
|
# Split folder path into components
|
||||||
parts = folder.split('/') if folder else []
|
parts = folder.split("/") if folder else []
|
||||||
current_level = tree
|
current_level = tree
|
||||||
|
|
||||||
for part in parts:
|
for part in parts:
|
||||||
@@ -573,7 +693,7 @@ class BaseModelService(ABC):
|
|||||||
relative_path = folder
|
relative_path = folder
|
||||||
|
|
||||||
# Split folder path into components
|
# Split folder path into components
|
||||||
parts = relative_path.split('/')
|
parts = relative_path.split("/")
|
||||||
current_level = unified_tree
|
current_level = unified_tree
|
||||||
|
|
||||||
for part in parts:
|
for part in parts:
|
||||||
@@ -588,8 +708,8 @@ class BaseModelService(ABC):
|
|||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
if model['file_name'] == model_name:
|
if model["file_name"] == model_name:
|
||||||
return model.get('notes', '')
|
return model.get("notes", "")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -598,23 +718,24 @@ class BaseModelService(ABC):
|
|||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
if model['file_name'] == model_name:
|
if model["file_name"] == model_name:
|
||||||
preview_url = model.get('preview_url')
|
preview_url = model.get("preview_url")
|
||||||
if preview_url:
|
if preview_url:
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
return config.get_preview_static_url(preview_url)
|
return config.get_preview_static_url(preview_url)
|
||||||
|
|
||||||
return '/loras_static/images/no-preview.png'
|
return "/loras_static/images/no-preview.png"
|
||||||
|
|
||||||
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
|
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
|
||||||
"""Get the Civitai URL for a model file"""
|
"""Get the Civitai URL for a model file"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
if model['file_name'] == model_name:
|
if model["file_name"] == model_name:
|
||||||
civitai_data = model.get('civitai', {})
|
civitai_data = model.get("civitai", {})
|
||||||
model_id = civitai_data.get('modelId')
|
model_id = civitai_data.get("modelId")
|
||||||
version_id = civitai_data.get('id')
|
version_id = civitai_data.get("id")
|
||||||
|
|
||||||
if model_id:
|
if model_id:
|
||||||
civitai_url = f"https://civitai.com/models/{model_id}"
|
civitai_url = f"https://civitai.com/models/{model_id}"
|
||||||
@@ -622,12 +743,12 @@ class BaseModelService(ABC):
|
|||||||
civitai_url += f"?modelVersionId={version_id}"
|
civitai_url += f"?modelVersionId={version_id}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'civitai_url': civitai_url,
|
"civitai_url": civitai_url,
|
||||||
'model_id': str(model_id),
|
"model_id": str(model_id),
|
||||||
'version_id': str(version_id) if version_id else None
|
"version_id": str(version_id) if version_id else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {'civitai_url': None, 'model_id': None, 'version_id': None}
|
return {"civitai_url": None, "model_id": None, "version_id": None}
|
||||||
|
|
||||||
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
||||||
"""Load full metadata for a single model.
|
"""Load full metadata for a single model.
|
||||||
@@ -635,18 +756,21 @@ class BaseModelService(ABC):
|
|||||||
Listing/search endpoints return lightweight cache entries; this method performs
|
Listing/search endpoints return lightweight cache entries; this method performs
|
||||||
a lazy read of the on-disk metadata snapshot when callers need full detail.
|
a lazy read of the on-disk metadata snapshot when callers need full detail.
|
||||||
"""
|
"""
|
||||||
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.metadata_class)
|
metadata, should_skip = await MetadataManager.load_metadata(
|
||||||
|
file_path, self.metadata_class
|
||||||
|
)
|
||||||
if should_skip or metadata is None:
|
if should_skip or metadata is None:
|
||||||
return None
|
return None
|
||||||
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||||
|
|
||||||
|
|
||||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||||
"""Return the stored modelDescription field for a model."""
|
"""Return the stored modelDescription field for a model."""
|
||||||
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.metadata_class)
|
metadata, should_skip = await MetadataManager.load_metadata(
|
||||||
|
file_path, self.metadata_class
|
||||||
|
)
|
||||||
if should_skip or metadata is None:
|
if should_skip or metadata is None:
|
||||||
return None
|
return None
|
||||||
return metadata.modelDescription or ''
|
return metadata.modelDescription or ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_search_tokens(search_term: str) -> tuple[List[str], List[str]]:
|
def _parse_search_tokens(search_term: str) -> tuple[List[str], List[str]]:
|
||||||
@@ -684,14 +808,21 @@ class BaseModelService(ABC):
|
|||||||
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple:
|
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple:
|
||||||
"""Sort paths by how well they satisfy the include tokens."""
|
"""Sort paths by how well they satisfy the include tokens."""
|
||||||
path_lower = relative_path.lower()
|
path_lower = relative_path.lower()
|
||||||
prefix_hits = sum(1 for term in include_terms if term and path_lower.startswith(term))
|
prefix_hits = sum(
|
||||||
match_positions = [path_lower.find(term) for term in include_terms if term and term in path_lower]
|
1 for term in include_terms if term and path_lower.startswith(term)
|
||||||
|
)
|
||||||
|
match_positions = [
|
||||||
|
path_lower.find(term)
|
||||||
|
for term in include_terms
|
||||||
|
if term and term in path_lower
|
||||||
|
]
|
||||||
first_match_index = min(match_positions) if match_positions else 0
|
first_match_index = min(match_positions) if match_positions else 0
|
||||||
|
|
||||||
return (-prefix_hits, first_match_index, len(relative_path), path_lower)
|
return (-prefix_hits, first_match_index, len(relative_path), path_lower)
|
||||||
|
|
||||||
|
async def search_relative_paths(
|
||||||
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
|
self, search_term: str, limit: int = 15
|
||||||
|
) -> List[str]:
|
||||||
"""Search model relative file paths for autocomplete functionality"""
|
"""Search model relative file paths for autocomplete functionality"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
include_terms, exclude_terms = self._parse_search_tokens(search_term)
|
include_terms, exclude_terms = self._parse_search_tokens(search_term)
|
||||||
@@ -702,7 +833,7 @@ class BaseModelService(ABC):
|
|||||||
model_roots = self.scanner.get_model_roots()
|
model_roots = self.scanner.get_model_roots()
|
||||||
|
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
file_path = model.get('file_path', '')
|
file_path = model.get("file_path", "")
|
||||||
if not file_path:
|
if not file_path:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -715,14 +846,18 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
if normalized_file.startswith(normalized_root):
|
if normalized_file.startswith(normalized_root):
|
||||||
# Remove root and leading separator to get relative path
|
# Remove root and leading separator to get relative path
|
||||||
relative_path = normalized_file[len(normalized_root):].lstrip(os.sep)
|
relative_path = normalized_file[len(normalized_root) :].lstrip(
|
||||||
|
os.sep
|
||||||
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
if not relative_path:
|
if not relative_path:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
relative_lower = relative_path.lower()
|
relative_lower = relative_path.lower()
|
||||||
if self._relative_path_matches_tokens(relative_lower, include_terms, exclude_terms):
|
if self._relative_path_matches_tokens(
|
||||||
|
relative_lower, include_terms, exclude_terms
|
||||||
|
):
|
||||||
matching_paths.append(relative_path)
|
matching_paths.append(relative_path)
|
||||||
|
|
||||||
if len(matching_paths) >= limit * 2: # Get more for better sorting
|
if len(matching_paths) >= limit * 2: # Get more for better sorting
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class CheckpointService(BaseModelService):
|
|||||||
"modified": checkpoint_data.get("modified", ""),
|
"modified": checkpoint_data.get("modified", ""),
|
||||||
"tags": checkpoint_data.get("tags", []),
|
"tags": checkpoint_data.get("tags", []),
|
||||||
"from_civitai": checkpoint_data.get("from_civitai", True),
|
"from_civitai": checkpoint_data.get("from_civitai", True),
|
||||||
|
"usage_count": checkpoint_data.get("usage_count", 0),
|
||||||
"notes": checkpoint_data.get("notes", ""),
|
"notes": checkpoint_data.get("notes", ""),
|
||||||
"model_type": checkpoint_data.get("model_type", "checkpoint"),
|
"model_type": checkpoint_data.get("model_type", "checkpoint"),
|
||||||
"favorite": checkpoint_data.get("favorite", False),
|
"favorite": checkpoint_data.get("favorite", False),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -128,6 +128,7 @@ class Downloader:
|
|||||||
self._session = None
|
self._session = None
|
||||||
self._session_created_at = None
|
self._session_created_at = None
|
||||||
self._proxy_url = None # Store proxy URL for current session
|
self._proxy_url = None # Store proxy URL for current session
|
||||||
|
self._session_lock = asyncio.Lock()
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better throughput
|
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better throughput
|
||||||
@@ -148,7 +149,10 @@ class Downloader:
|
|||||||
async def session(self) -> aiohttp.ClientSession:
|
async def session(self) -> aiohttp.ClientSession:
|
||||||
"""Get or create the global aiohttp session with optimized settings"""
|
"""Get or create the global aiohttp session with optimized settings"""
|
||||||
if self._session is None or self._should_refresh_session():
|
if self._session is None or self._should_refresh_session():
|
||||||
await self._create_session()
|
async with self._session_lock:
|
||||||
|
# Double check after acquiring lock
|
||||||
|
if self._session is None or self._should_refresh_session():
|
||||||
|
await self._create_session()
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -197,10 +201,18 @@ class Downloader:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def _create_session(self):
|
async def _create_session(self):
|
||||||
"""Create a new aiohttp session with optimized settings"""
|
"""Create a new aiohttp session with optimized settings.
|
||||||
|
|
||||||
|
Note: This is private and caller MUST hold self._session_lock.
|
||||||
|
"""
|
||||||
# Close existing session if any
|
# Close existing session if any
|
||||||
if self._session is not None:
|
if self._session is not None:
|
||||||
await self._session.close()
|
try:
|
||||||
|
await self._session.close()
|
||||||
|
except Exception as e: # pragma: no cover
|
||||||
|
logger.warning(f"Error closing previous session: {e}")
|
||||||
|
finally:
|
||||||
|
self._session = None
|
||||||
|
|
||||||
# Check for app-level proxy settings
|
# Check for app-level proxy settings
|
||||||
proxy_url = None
|
proxy_url = None
|
||||||
@@ -808,7 +820,8 @@ class Downloader:
|
|||||||
|
|
||||||
async def refresh_session(self):
|
async def refresh_session(self):
|
||||||
"""Force refresh the HTTP session (useful when proxy settings change)"""
|
"""Force refresh the HTTP session (useful when proxy settings change)"""
|
||||||
await self._create_session()
|
async with self._session_lock:
|
||||||
|
await self._create_session()
|
||||||
logger.info("HTTP session refreshed due to settings change")
|
logger.info("HTTP session refreshed due to settings change")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class EmbeddingService(BaseModelService):
|
|||||||
"modified": embedding_data.get("modified", ""),
|
"modified": embedding_data.get("modified", ""),
|
||||||
"tags": embedding_data.get("tags", []),
|
"tags": embedding_data.get("tags", []),
|
||||||
"from_civitai": embedding_data.get("from_civitai", True),
|
"from_civitai": embedding_data.get("from_civitai", True),
|
||||||
|
# "usage_count": embedding_data.get("usage_count", 0), # TODO: Enable when embedding usage tracking is implemented
|
||||||
"notes": embedding_data.get("notes", ""),
|
"notes": embedding_data.get("notes", ""),
|
||||||
"model_type": embedding_data.get("model_type", "embedding"),
|
"model_type": embedding_data.get("model_type", "embedding"),
|
||||||
"favorite": embedding_data.get("favorite", False),
|
"favorite": embedding_data.get("favorite", False),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from ..config import config
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LoraService(BaseModelService):
|
class LoraService(BaseModelService):
|
||||||
"""LoRA-specific service implementation"""
|
"""LoRA-specific service implementation"""
|
||||||
|
|
||||||
@@ -25,7 +26,9 @@ class LoraService(BaseModelService):
|
|||||||
return {
|
return {
|
||||||
"model_name": lora_data["model_name"],
|
"model_name": lora_data["model_name"],
|
||||||
"file_name": lora_data["file_name"],
|
"file_name": lora_data["file_name"],
|
||||||
"preview_url": config.get_preview_static_url(lora_data.get("preview_url", "")),
|
"preview_url": config.get_preview_static_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": lora_data["folder"],
|
||||||
@@ -35,17 +38,20 @@ class LoraService(BaseModelService):
|
|||||||
"modified": lora_data.get("modified", ""),
|
"modified": lora_data.get("modified", ""),
|
||||||
"tags": lora_data.get("tags", []),
|
"tags": lora_data.get("tags", []),
|
||||||
"from_civitai": lora_data.get("from_civitai", True),
|
"from_civitai": lora_data.get("from_civitai", True),
|
||||||
|
"usage_count": lora_data.get("usage_count", 0),
|
||||||
"usage_tips": lora_data.get("usage_tips", ""),
|
"usage_tips": lora_data.get("usage_tips", ""),
|
||||||
"notes": lora_data.get("notes", ""),
|
"notes": lora_data.get("notes", ""),
|
||||||
"favorite": lora_data.get("favorite", False),
|
"favorite": lora_data.get("favorite", False),
|
||||||
"update_available": bool(lora_data.get("update_available", False)),
|
"update_available": bool(lora_data.get("update_available", False)),
|
||||||
"civitai": self.filter_civitai_data(lora_data.get("civitai", {}), minimal=True)
|
"civitai": self.filter_civitai_data(
|
||||||
|
lora_data.get("civitai", {}), minimal=True
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
"""Apply LoRA-specific filters"""
|
"""Apply LoRA-specific filters"""
|
||||||
# Handle first_letter filter for LoRAs
|
# Handle first_letter filter for LoRAs
|
||||||
first_letter = kwargs.get('first_letter')
|
first_letter = kwargs.get("first_letter")
|
||||||
if first_letter:
|
if first_letter:
|
||||||
data = self._filter_by_first_letter(data, first_letter)
|
data = self._filter_by_first_letter(data, first_letter)
|
||||||
|
|
||||||
@@ -62,18 +68,18 @@ class LoraService(BaseModelService):
|
|||||||
filtered_data = []
|
filtered_data = []
|
||||||
|
|
||||||
for lora in data:
|
for lora in data:
|
||||||
model_name = lora.get('model_name', '')
|
model_name = lora.get("model_name", "")
|
||||||
if not model_name:
|
if not model_name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
first_char = model_name[0].upper()
|
first_char = model_name[0].upper()
|
||||||
|
|
||||||
if letter == '#' and first_char.isdigit():
|
if letter == "#" and first_char.isdigit():
|
||||||
filtered_data.append(lora)
|
filtered_data.append(lora)
|
||||||
elif letter == '@' and not first_char.isalnum():
|
elif letter == "@" and not first_char.isalnum():
|
||||||
# Special characters (not alphanumeric)
|
# Special characters (not alphanumeric)
|
||||||
filtered_data.append(lora)
|
filtered_data.append(lora)
|
||||||
elif letter == '漢' and self._is_cjk_character(first_char):
|
elif letter == "漢" and self._is_cjk_character(first_char):
|
||||||
# CJK characters
|
# CJK characters
|
||||||
filtered_data.append(lora)
|
filtered_data.append(lora)
|
||||||
elif letter.upper() == first_char:
|
elif letter.upper() == first_char:
|
||||||
@@ -86,26 +92,26 @@ class LoraService(BaseModelService):
|
|||||||
"""Check if character is a CJK character"""
|
"""Check if character is a CJK character"""
|
||||||
# Define Unicode ranges for CJK characters
|
# Define Unicode ranges for CJK characters
|
||||||
cjk_ranges = [
|
cjk_ranges = [
|
||||||
(0x4E00, 0x9FFF), # CJK Unified Ideographs
|
(0x4E00, 0x9FFF), # CJK Unified Ideographs
|
||||||
(0x3400, 0x4DBF), # CJK Unified Ideographs Extension A
|
(0x3400, 0x4DBF), # CJK Unified Ideographs Extension A
|
||||||
(0x20000, 0x2A6DF), # CJK Unified Ideographs Extension B
|
(0x20000, 0x2A6DF), # CJK Unified Ideographs Extension B
|
||||||
(0x2A700, 0x2B73F), # CJK Unified Ideographs Extension C
|
(0x2A700, 0x2B73F), # CJK Unified Ideographs Extension C
|
||||||
(0x2B740, 0x2B81F), # CJK Unified Ideographs Extension D
|
(0x2B740, 0x2B81F), # CJK Unified Ideographs Extension D
|
||||||
(0x2B820, 0x2CEAF), # CJK Unified Ideographs Extension E
|
(0x2B820, 0x2CEAF), # CJK Unified Ideographs Extension E
|
||||||
(0x2CEB0, 0x2EBEF), # CJK Unified Ideographs Extension F
|
(0x2CEB0, 0x2EBEF), # CJK Unified Ideographs Extension F
|
||||||
(0x30000, 0x3134F), # CJK Unified Ideographs Extension G
|
(0x30000, 0x3134F), # CJK Unified Ideographs Extension G
|
||||||
(0xF900, 0xFAFF), # CJK Compatibility Ideographs
|
(0xF900, 0xFAFF), # CJK Compatibility Ideographs
|
||||||
(0x3300, 0x33FF), # CJK Compatibility
|
(0x3300, 0x33FF), # CJK Compatibility
|
||||||
(0x3200, 0x32FF), # Enclosed CJK Letters and Months
|
(0x3200, 0x32FF), # Enclosed CJK Letters and Months
|
||||||
(0x3100, 0x312F), # Bopomofo
|
(0x3100, 0x312F), # Bopomofo
|
||||||
(0x31A0, 0x31BF), # Bopomofo Extended
|
(0x31A0, 0x31BF), # Bopomofo Extended
|
||||||
(0x3040, 0x309F), # Hiragana
|
(0x3040, 0x309F), # Hiragana
|
||||||
(0x30A0, 0x30FF), # Katakana
|
(0x30A0, 0x30FF), # Katakana
|
||||||
(0x31F0, 0x31FF), # Katakana Phonetic Extensions
|
(0x31F0, 0x31FF), # Katakana Phonetic Extensions
|
||||||
(0xAC00, 0xD7AF), # Hangul Syllables
|
(0xAC00, 0xD7AF), # Hangul Syllables
|
||||||
(0x1100, 0x11FF), # Hangul Jamo
|
(0x1100, 0x11FF), # Hangul Jamo
|
||||||
(0xA960, 0xA97F), # Hangul Jamo Extended-A
|
(0xA960, 0xA97F), # Hangul Jamo Extended-A
|
||||||
(0xD7B0, 0xD7FF), # Hangul Jamo Extended-B
|
(0xD7B0, 0xD7FF), # Hangul Jamo Extended-B
|
||||||
]
|
]
|
||||||
|
|
||||||
code_point = ord(char)
|
code_point = ord(char)
|
||||||
@@ -119,31 +125,53 @@ class LoraService(BaseModelService):
|
|||||||
|
|
||||||
# Define letter categories
|
# Define letter categories
|
||||||
letters = {
|
letters = {
|
||||||
'#': 0, # Numbers
|
"#": 0, # Numbers
|
||||||
'A': 0, 'B': 0, 'C': 0, 'D': 0, 'E': 0, 'F': 0, 'G': 0, 'H': 0,
|
"A": 0,
|
||||||
'I': 0, 'J': 0, 'K': 0, 'L': 0, 'M': 0, 'N': 0, 'O': 0, 'P': 0,
|
"B": 0,
|
||||||
'Q': 0, 'R': 0, 'S': 0, 'T': 0, 'U': 0, 'V': 0, 'W': 0, 'X': 0,
|
"C": 0,
|
||||||
'Y': 0, 'Z': 0,
|
"D": 0,
|
||||||
'@': 0, # Special characters
|
"E": 0,
|
||||||
'漢': 0 # CJK characters
|
"F": 0,
|
||||||
|
"G": 0,
|
||||||
|
"H": 0,
|
||||||
|
"I": 0,
|
||||||
|
"J": 0,
|
||||||
|
"K": 0,
|
||||||
|
"L": 0,
|
||||||
|
"M": 0,
|
||||||
|
"N": 0,
|
||||||
|
"O": 0,
|
||||||
|
"P": 0,
|
||||||
|
"Q": 0,
|
||||||
|
"R": 0,
|
||||||
|
"S": 0,
|
||||||
|
"T": 0,
|
||||||
|
"U": 0,
|
||||||
|
"V": 0,
|
||||||
|
"W": 0,
|
||||||
|
"X": 0,
|
||||||
|
"Y": 0,
|
||||||
|
"Z": 0,
|
||||||
|
"@": 0, # Special characters
|
||||||
|
"漢": 0, # CJK characters
|
||||||
}
|
}
|
||||||
|
|
||||||
# Count models for each letter
|
# Count models for each letter
|
||||||
for lora in data:
|
for lora in data:
|
||||||
model_name = lora.get('model_name', '')
|
model_name = lora.get("model_name", "")
|
||||||
if not model_name:
|
if not model_name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
first_char = model_name[0].upper()
|
first_char = model_name[0].upper()
|
||||||
|
|
||||||
if first_char.isdigit():
|
if first_char.isdigit():
|
||||||
letters['#'] += 1
|
letters["#"] += 1
|
||||||
elif first_char in letters:
|
elif first_char in letters:
|
||||||
letters[first_char] += 1
|
letters[first_char] += 1
|
||||||
elif self._is_cjk_character(first_char):
|
elif self._is_cjk_character(first_char):
|
||||||
letters['漢'] += 1
|
letters["漢"] += 1
|
||||||
elif not first_char.isalnum():
|
elif not first_char.isalnum():
|
||||||
letters['@'] += 1
|
letters["@"] += 1
|
||||||
|
|
||||||
return letters
|
return letters
|
||||||
|
|
||||||
@@ -152,25 +180,30 @@ class LoraService(BaseModelService):
|
|||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
for lora in cache.raw_data:
|
for lora in cache.raw_data:
|
||||||
if lora['file_name'] == lora_name:
|
if lora["file_name"] == lora_name:
|
||||||
civitai_data = lora.get('civitai', {})
|
civitai_data = lora.get("civitai", {})
|
||||||
return civitai_data.get('trainedWords', [])
|
return civitai_data.get("trainedWords", [])
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def get_lora_usage_tips_by_relative_path(self, relative_path: str) -> Optional[str]:
|
async def get_lora_usage_tips_by_relative_path(
|
||||||
|
self, relative_path: str
|
||||||
|
) -> Optional[str]:
|
||||||
"""Get usage tips for a LoRA by its relative path"""
|
"""Get usage tips for a LoRA by its relative path"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
for lora in cache.raw_data:
|
for lora in cache.raw_data:
|
||||||
file_path = lora.get('file_path', '')
|
file_path = lora.get("file_path", "")
|
||||||
if file_path:
|
if file_path:
|
||||||
# Convert to forward slashes and extract relative path
|
# Convert to forward slashes and extract relative path
|
||||||
file_path_normalized = file_path.replace('\\', '/')
|
file_path_normalized = file_path.replace("\\", "/")
|
||||||
relative_path = relative_path.replace('\\', '/')
|
relative_path = relative_path.replace("\\", "/")
|
||||||
# Find the relative path part by looking for the relative_path in the full path
|
# Find the relative path part by looking for the relative_path in the full path
|
||||||
if file_path_normalized.endswith(relative_path) or relative_path in file_path_normalized:
|
if (
|
||||||
return lora.get('usage_tips', '')
|
file_path_normalized.endswith(relative_path)
|
||||||
|
or relative_path in file_path_normalized
|
||||||
|
):
|
||||||
|
return lora.get("usage_tips", "")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -181,3 +214,268 @@ class LoraService(BaseModelService):
|
|||||||
def find_duplicate_filenames(self) -> Dict:
|
def find_duplicate_filenames(self) -> Dict:
|
||||||
"""Find LoRAs with conflicting filenames"""
|
"""Find LoRAs with conflicting filenames"""
|
||||||
return self.scanner._hash_index.get_duplicate_filenames()
|
return self.scanner._hash_index.get_duplicate_filenames()
|
||||||
|
|
||||||
|
async def get_random_loras(
|
||||||
|
self,
|
||||||
|
count: int,
|
||||||
|
model_strength_min: float = 0.0,
|
||||||
|
model_strength_max: float = 1.0,
|
||||||
|
use_same_clip_strength: bool = True,
|
||||||
|
clip_strength_min: float = 0.0,
|
||||||
|
clip_strength_max: float = 1.0,
|
||||||
|
locked_loras: Optional[List[Dict]] = None,
|
||||||
|
pool_config: Optional[Dict] = None,
|
||||||
|
count_mode: str = "fixed",
|
||||||
|
count_min: int = 3,
|
||||||
|
count_max: int = 7,
|
||||||
|
use_recommended_strength: bool = False,
|
||||||
|
recommended_strength_scale_min: float = 0.5,
|
||||||
|
recommended_strength_scale_max: float = 1.0,
|
||||||
|
seed: Optional[int] = None,
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get random LoRAs with specified strength ranges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count: Number of LoRAs to select (if count_mode='fixed')
|
||||||
|
model_strength_min: Minimum model strength
|
||||||
|
model_strength_max: Maximum model strength
|
||||||
|
use_same_clip_strength: Whether to use same strength for clip
|
||||||
|
clip_strength_min: Minimum clip strength
|
||||||
|
clip_strength_max: Maximum clip strength
|
||||||
|
locked_loras: List of locked LoRA dicts to preserve
|
||||||
|
pool_config: Optional pool config for filtering
|
||||||
|
count_mode: How to determine count ('fixed' or 'range')
|
||||||
|
count_min: Minimum count for range mode
|
||||||
|
count_max: Maximum count for range mode
|
||||||
|
use_recommended_strength: Whether to use recommended strength from usage_tips
|
||||||
|
recommended_strength_scale_min: Minimum scale factor for recommended strength
|
||||||
|
recommended_strength_scale_max: Maximum scale factor for recommended strength
|
||||||
|
seed: Optional random seed for reproducible/unique randomization per execution
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of LoRA dicts with randomized strengths
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Use a local Random instance to avoid affecting global random state
|
||||||
|
# This ensures each execution with a different seed produces different results
|
||||||
|
rng = random.Random(seed)
|
||||||
|
|
||||||
|
def get_recommended_strength(lora_data: Dict) -> Optional[float]:
|
||||||
|
"""Parse usage_tips JSON and extract recommended strength"""
|
||||||
|
try:
|
||||||
|
usage_tips = lora_data.get("usage_tips", "")
|
||||||
|
if not usage_tips:
|
||||||
|
return None
|
||||||
|
tips_data = json.loads(usage_tips)
|
||||||
|
return tips_data.get("strength")
|
||||||
|
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_recommended_clip_strength(lora_data: Dict) -> Optional[float]:
|
||||||
|
"""Parse usage_tips JSON and extract recommended clip strength"""
|
||||||
|
try:
|
||||||
|
usage_tips = lora_data.get("usage_tips", "")
|
||||||
|
if not usage_tips:
|
||||||
|
return None
|
||||||
|
tips_data = json.loads(usage_tips)
|
||||||
|
return tips_data.get("clipStrength")
|
||||||
|
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if locked_loras is None:
|
||||||
|
locked_loras = []
|
||||||
|
|
||||||
|
# Determine target count based on count_mode
|
||||||
|
if count_mode == "fixed":
|
||||||
|
target_count = count
|
||||||
|
else:
|
||||||
|
target_count = rng.randint(count_min, count_max)
|
||||||
|
|
||||||
|
# Get available loras from cache
|
||||||
|
cache = await self.scanner.get_cached_data(force_refresh=False)
|
||||||
|
available_loras = cache.raw_data if cache else []
|
||||||
|
|
||||||
|
# Apply pool filters if provided
|
||||||
|
if pool_config:
|
||||||
|
available_loras = await self._apply_pool_filters(
|
||||||
|
available_loras, pool_config
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate slots needed (total - locked)
|
||||||
|
locked_count = len(locked_loras)
|
||||||
|
slots_needed = target_count - locked_count
|
||||||
|
|
||||||
|
if slots_needed < 0:
|
||||||
|
slots_needed = 0
|
||||||
|
# Too many locked, trim to target
|
||||||
|
locked_loras = locked_loras[:target_count]
|
||||||
|
|
||||||
|
# Filter out locked LoRAs from available pool
|
||||||
|
locked_names = {lora["name"] for lora in locked_loras}
|
||||||
|
available_pool = [
|
||||||
|
l for l in available_loras if l["file_name"] not in locked_names
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ensure we don't try to select more than available
|
||||||
|
if slots_needed > len(available_pool):
|
||||||
|
slots_needed = len(available_pool)
|
||||||
|
|
||||||
|
# Random sample
|
||||||
|
selected = []
|
||||||
|
if slots_needed > 0:
|
||||||
|
selected = rng.sample(available_pool, slots_needed)
|
||||||
|
|
||||||
|
# Generate random strengths for selected LoRAs
|
||||||
|
result_loras = []
|
||||||
|
for lora in selected:
|
||||||
|
if use_recommended_strength:
|
||||||
|
recommended_strength = get_recommended_strength(lora)
|
||||||
|
if recommended_strength is not None:
|
||||||
|
scale = rng.uniform(
|
||||||
|
recommended_strength_scale_min, recommended_strength_scale_max
|
||||||
|
)
|
||||||
|
model_str = round(recommended_strength * scale, 2)
|
||||||
|
else:
|
||||||
|
model_str = round(
|
||||||
|
rng.uniform(model_strength_min, model_strength_max), 2
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
model_str = round(
|
||||||
|
rng.uniform(model_strength_min, model_strength_max), 2
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_same_clip_strength:
|
||||||
|
clip_str = model_str
|
||||||
|
elif use_recommended_strength:
|
||||||
|
recommended_clip_strength = get_recommended_clip_strength(lora)
|
||||||
|
if recommended_clip_strength is not None:
|
||||||
|
scale = rng.uniform(
|
||||||
|
recommended_strength_scale_min, recommended_strength_scale_max
|
||||||
|
)
|
||||||
|
clip_str = round(recommended_clip_strength * scale, 2)
|
||||||
|
else:
|
||||||
|
clip_str = round(
|
||||||
|
rng.uniform(clip_strength_min, clip_strength_max), 2
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
clip_str = round(
|
||||||
|
rng.uniform(clip_strength_min, clip_strength_max), 2
|
||||||
|
)
|
||||||
|
|
||||||
|
result_loras.append(
|
||||||
|
{
|
||||||
|
"name": lora["file_name"],
|
||||||
|
"strength": model_str,
|
||||||
|
"clipStrength": clip_str,
|
||||||
|
"active": True,
|
||||||
|
"expanded": abs(model_str - clip_str) > 0.001,
|
||||||
|
"locked": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge with locked LoRAs
|
||||||
|
result_loras.extend(locked_loras)
|
||||||
|
|
||||||
|
return result_loras
|
||||||
|
|
||||||
|
async def _apply_pool_filters(
|
||||||
|
self, available_loras: List[Dict], pool_config: Dict
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Apply pool_config filters to available LoRAs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
available_loras: List of all LoRA dicts
|
||||||
|
pool_config: Dict with filter settings from LoRA Pool node
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list of LoRA dicts
|
||||||
|
"""
|
||||||
|
from .model_query import FilterCriteria
|
||||||
|
|
||||||
|
filter_section = pool_config
|
||||||
|
|
||||||
|
# Extract filter parameters
|
||||||
|
selected_base_models = filter_section.get("baseModels", [])
|
||||||
|
tags_dict = filter_section.get("tags", {})
|
||||||
|
include_tags = tags_dict.get("include", [])
|
||||||
|
exclude_tags = tags_dict.get("exclude", [])
|
||||||
|
folders_dict = filter_section.get("folders", {})
|
||||||
|
include_folders = folders_dict.get("include", [])
|
||||||
|
exclude_folders = folders_dict.get("exclude", [])
|
||||||
|
license_dict = filter_section.get("license", {})
|
||||||
|
no_credit_required = license_dict.get("noCreditRequired", False)
|
||||||
|
allow_selling = license_dict.get("allowSelling", False)
|
||||||
|
|
||||||
|
# Build tag filters dict
|
||||||
|
tag_filters = {}
|
||||||
|
for tag in include_tags:
|
||||||
|
tag_filters[tag] = "include"
|
||||||
|
for tag in exclude_tags:
|
||||||
|
tag_filters[tag] = "exclude"
|
||||||
|
|
||||||
|
# Build folder filter
|
||||||
|
if include_folders or exclude_folders:
|
||||||
|
filtered = []
|
||||||
|
for lora in available_loras:
|
||||||
|
folder = lora.get("folder", "")
|
||||||
|
|
||||||
|
# Check exclude folders first
|
||||||
|
excluded = False
|
||||||
|
for exclude_folder in exclude_folders:
|
||||||
|
if folder.startswith(exclude_folder):
|
||||||
|
excluded = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if excluded:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check include folders
|
||||||
|
if include_folders:
|
||||||
|
included = False
|
||||||
|
for include_folder in include_folders:
|
||||||
|
if folder.startswith(include_folder):
|
||||||
|
included = True
|
||||||
|
break
|
||||||
|
if not included:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered.append(lora)
|
||||||
|
|
||||||
|
available_loras = filtered
|
||||||
|
|
||||||
|
# Apply base model filter
|
||||||
|
if selected_base_models:
|
||||||
|
available_loras = [
|
||||||
|
lora
|
||||||
|
for lora in available_loras
|
||||||
|
if lora.get("base_model") in selected_base_models
|
||||||
|
]
|
||||||
|
|
||||||
|
# Apply tag filters
|
||||||
|
if tag_filters:
|
||||||
|
criteria = FilterCriteria(tags=tag_filters)
|
||||||
|
available_loras = self.filter_set.apply(available_loras, criteria)
|
||||||
|
|
||||||
|
# Apply license filters
|
||||||
|
# no_credit_required=True means keep only models where credit is NOT required
|
||||||
|
# (i.e., allowNoCredit=True, which is bit 0 = 1 in license_flags)
|
||||||
|
if no_credit_required:
|
||||||
|
available_loras = [
|
||||||
|
lora
|
||||||
|
for lora in available_loras
|
||||||
|
if bool(lora.get("license_flags", 127) & (1 << 0))
|
||||||
|
]
|
||||||
|
|
||||||
|
# allow_selling=True means keep only models where selling generated content is allowed
|
||||||
|
if allow_selling:
|
||||||
|
available_loras = [
|
||||||
|
lora
|
||||||
|
for lora in available_loras
|
||||||
|
if bool(lora.get("license_flags", 127) & (1 << 1))
|
||||||
|
]
|
||||||
|
|
||||||
|
return available_loras
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class MetadataSyncService:
|
|||||||
files = meta.get("files")
|
files = meta.get("files")
|
||||||
images = meta.get("images")
|
images = meta.get("images")
|
||||||
source = meta.get("source")
|
source = meta.get("source")
|
||||||
return bool(files) and bool(images) and source != "archive_db"
|
return bool(files) and bool(images) and source not in ("archive_db", "civarchive")
|
||||||
|
|
||||||
async def update_model_metadata(
|
async def update_model_metadata(
|
||||||
self,
|
self,
|
||||||
@@ -90,11 +90,11 @@ class MetadataSyncService:
|
|||||||
existing_civitai = local_metadata.get("civitai") or {}
|
existing_civitai = local_metadata.get("civitai") or {}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
civitai_metadata.get("source") == "archive_db"
|
not self.is_civitai_api_metadata(civitai_metadata)
|
||||||
and self.is_civitai_api_metadata(existing_civitai)
|
and self.is_civitai_api_metadata(existing_civitai)
|
||||||
):
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
"Skip civitai update for %s (%s)",
|
"Skip civitai update for %s (%s) - existing metadata is higher quality",
|
||||||
local_metadata.get("model_name", ""),
|
local_metadata.get("model_name", ""),
|
||||||
existing_civitai.get("name", ""),
|
existing_civitai.get("name", ""),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
@@ -13,7 +17,10 @@ SUPPORTED_SORT_MODES = [
|
|||||||
('date', 'desc'),
|
('date', 'desc'),
|
||||||
('size', 'asc'),
|
('size', 'asc'),
|
||||||
('size', 'desc'),
|
('size', 'desc'),
|
||||||
|
('usage', 'asc'),
|
||||||
|
('usage', 'desc'),
|
||||||
]
|
]
|
||||||
|
# Is this in use?
|
||||||
|
|
||||||
DISPLAY_NAME_MODES = {"model_name", "file_name"}
|
DISPLAY_NAME_MODES = {"model_name", "file_name"}
|
||||||
|
|
||||||
@@ -212,40 +219,63 @@ class ModelCache:
|
|||||||
|
|
||||||
def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]:
|
def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]:
|
||||||
"""Sort data by sort_key and order"""
|
"""Sort data by sort_key and order"""
|
||||||
|
start_time = time.perf_counter()
|
||||||
reverse = (order == 'desc')
|
reverse = (order == 'desc')
|
||||||
if sort_key == 'name':
|
if sort_key == 'name':
|
||||||
# Natural sort by configured display name, case-insensitive
|
# Natural sort by configured display name, case-insensitive
|
||||||
return natsorted(
|
result = natsorted(
|
||||||
data,
|
data,
|
||||||
key=lambda x: self._get_display_name(x).lower(),
|
key=lambda x: self._get_display_name(x).lower(),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
elif sort_key == 'date':
|
elif sort_key == 'date':
|
||||||
# Sort by modified timestamp
|
# Sort by modified timestamp
|
||||||
return sorted(
|
result = sorted(
|
||||||
data,
|
data,
|
||||||
key=itemgetter('modified'),
|
key=itemgetter('modified'),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
elif sort_key == 'size':
|
elif sort_key == 'size':
|
||||||
# Sort by file size
|
# Sort by file size
|
||||||
return sorted(
|
result = sorted(
|
||||||
data,
|
data,
|
||||||
key=itemgetter('size'),
|
key=itemgetter('size'),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
|
elif sort_key == 'usage':
|
||||||
|
# Sort by usage count, fallback to 0, then name for stability
|
||||||
|
return sorted(
|
||||||
|
data,
|
||||||
|
key=lambda x: (
|
||||||
|
x.get('usage_count', 0),
|
||||||
|
self._get_display_name(x).lower()
|
||||||
|
),
|
||||||
|
reverse=reverse
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback: no sort
|
# Fallback: no sort
|
||||||
return list(data)
|
result = list(data)
|
||||||
|
|
||||||
|
duration = time.perf_counter() - start_time
|
||||||
|
if duration > 0.05:
|
||||||
|
logger.debug("ModelCache._sort_data(%s, %s) for %d items took %.3fs", sort_key, order, len(data), duration)
|
||||||
|
return result
|
||||||
|
|
||||||
async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc') -> List[Dict]:
|
async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc') -> List[Dict]:
|
||||||
"""Get sorted data by sort_key and order, using cache if possible"""
|
"""Get sorted data by sort_key and order, using cache if possible"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if (sort_key, order) == self._last_sort:
|
if (sort_key, order) == self._last_sort:
|
||||||
return self._last_sorted_data
|
return self._last_sorted_data
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
||||||
self._last_sort = (sort_key, order)
|
self._last_sort = (sort_key, order)
|
||||||
self._last_sorted_data = sorted_data
|
self._last_sorted_data = sorted_data
|
||||||
|
|
||||||
|
duration = time.perf_counter() - start_time
|
||||||
|
if duration > 0.1:
|
||||||
|
logger.debug("ModelCache.get_sorted_data(%s, %s) took %.3fs", sort_key, order, duration)
|
||||||
|
|
||||||
return sorted_data
|
return sorted_data
|
||||||
|
|
||||||
async def update_name_display_mode(self, display_mode: str) -> None:
|
async def update_name_display_mode(self, display_mode: str) -> None:
|
||||||
|
|||||||
@@ -36,11 +36,13 @@ class AutoOrganizeResult:
|
|||||||
self.results_truncated: bool = False
|
self.results_truncated: bool = False
|
||||||
self.sample_results: List[Dict[str, Any]] = []
|
self.sample_results: List[Dict[str, Any]] = []
|
||||||
self.is_flat_structure: bool = False
|
self.is_flat_structure: bool = False
|
||||||
|
self.status: str = 'success'
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert result to dictionary"""
|
"""Convert result to dictionary"""
|
||||||
result = {
|
result = {
|
||||||
'success': True,
|
'success': self.status != 'error',
|
||||||
|
'status': self.status,
|
||||||
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
|
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
|
||||||
'summary': {
|
'summary': {
|
||||||
'total': self.total,
|
'total': self.total,
|
||||||
@@ -98,6 +100,8 @@ class ModelFileService:
|
|||||||
result = AutoOrganizeResult()
|
result = AutoOrganizeResult()
|
||||||
source_directories: Set[str] = set()
|
source_directories: Set[str] = set()
|
||||||
|
|
||||||
|
self.scanner.reset_cancellation()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get all models from cache
|
# Get all models from cache
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
@@ -187,6 +191,21 @@ class ModelFileService:
|
|||||||
source_directories # Pass the set to track source directories
|
source_directories # Pass the set to track source directories
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.scanner.is_cancelled():
|
||||||
|
result.status = 'cancelled'
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback.on_progress({
|
||||||
|
'type': 'auto_organize_progress',
|
||||||
|
'status': 'cancelled',
|
||||||
|
'total': result.total,
|
||||||
|
'processed': result.processed,
|
||||||
|
'success': result.success_count,
|
||||||
|
'failures': result.failure_count,
|
||||||
|
'skipped': result.skipped_count,
|
||||||
|
'operation_type': result.operation_type
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
# Send cleanup progress
|
# Send cleanup progress
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
await progress_callback.on_progress({
|
await progress_callback.on_progress({
|
||||||
@@ -246,9 +265,15 @@ class ModelFileService:
|
|||||||
"""Process models in batches to avoid overwhelming the system"""
|
"""Process models in batches to avoid overwhelming the system"""
|
||||||
|
|
||||||
for i in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
|
for i in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
|
||||||
|
if self.scanner.is_cancelled():
|
||||||
|
logger.info(f"{self.model_type.capitalize()} File Service: Auto-organize cancelled by user")
|
||||||
|
break
|
||||||
|
|
||||||
batch = all_models[i:i + AUTO_ORGANIZE_BATCH_SIZE]
|
batch = all_models[i:i + AUTO_ORGANIZE_BATCH_SIZE]
|
||||||
|
|
||||||
for model in batch:
|
for model in batch:
|
||||||
|
if self.scanner.is_cancelled():
|
||||||
|
break
|
||||||
await self._process_single_model(model, model_roots, result, source_directories)
|
await self._process_single_model(model, model_roots, result, source_directories)
|
||||||
result.processed += 1
|
result.processed += 1
|
||||||
|
|
||||||
@@ -446,25 +471,46 @@ class ModelFileService:
|
|||||||
class ModelMoveService:
|
class ModelMoveService:
|
||||||
"""Service for handling individual model moves"""
|
"""Service for handling individual model moves"""
|
||||||
|
|
||||||
def __init__(self, scanner):
|
def __init__(self, scanner, model_type: str):
|
||||||
"""Initialize the service
|
"""Initialize the service
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
scanner: Model scanner instance
|
scanner: Model scanner instance
|
||||||
|
model_type: Type of model (e.g., 'lora', 'checkpoint')
|
||||||
"""
|
"""
|
||||||
self.scanner = scanner
|
self.scanner = scanner
|
||||||
|
self.model_type = model_type
|
||||||
|
|
||||||
async def move_model(self, file_path: str, target_path: str) -> Dict[str, Any]:
|
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
|
||||||
"""Move a single model file
|
"""Move a single model file
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Source file path
|
file_path: Source file path
|
||||||
target_path: Target directory path
|
target_path: Target directory path (used as root if use_default_paths is True)
|
||||||
|
use_default_paths: Whether to use default path template for organization
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with move result
|
Dictionary with move result
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
if use_default_paths:
|
||||||
|
# Find the model in cache to get metadata
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
model_data = next((m for m in cache.raw_data if m.get('file_path') == file_path), None)
|
||||||
|
|
||||||
|
if model_data:
|
||||||
|
from ..utils.utils import calculate_relative_path_for_model
|
||||||
|
relative_path = calculate_relative_path_for_model(model_data, self.model_type)
|
||||||
|
if relative_path:
|
||||||
|
target_path = os.path.join(target_path, relative_path).replace(os.sep, '/')
|
||||||
|
elif not get_settings_manager().get_download_path_template(self.model_type):
|
||||||
|
# Flat structure, target_path remains the root
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Could not calculate relative path (e.g. missing metadata)
|
||||||
|
# Fallback to manual target_path or skip?
|
||||||
|
pass
|
||||||
|
|
||||||
source_dir = os.path.dirname(file_path)
|
source_dir = os.path.dirname(file_path)
|
||||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||||
logger.info(f"Source and target directories are the same: {source_dir}")
|
logger.info(f"Source and target directories are the same: {source_dir}")
|
||||||
@@ -475,12 +521,15 @@ class ModelMoveService:
|
|||||||
'new_file_path': file_path
|
'new_file_path': file_path
|
||||||
}
|
}
|
||||||
|
|
||||||
new_file_path = await self.scanner.move_model(file_path, target_path)
|
move_result = await self.scanner.move_model(file_path, target_path)
|
||||||
if new_file_path:
|
if move_result:
|
||||||
|
new_file_path = move_result.get("new_path")
|
||||||
|
cache_entry = move_result.get("cache_entry")
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'original_file_path': file_path,
|
'original_file_path': file_path,
|
||||||
'new_file_path': new_file_path
|
'new_file_path': new_file_path,
|
||||||
|
'cache_entry': cache_entry
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
@@ -498,26 +547,32 @@ class ModelMoveService:
|
|||||||
'new_file_path': None
|
'new_file_path': None
|
||||||
}
|
}
|
||||||
|
|
||||||
async def move_models_bulk(self, file_paths: List[str], target_path: str) -> Dict[str, Any]:
|
async def move_models_bulk(self, file_paths: List[str], target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
|
||||||
"""Move multiple model files
|
"""Move multiple model files
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_paths: List of source file paths
|
file_paths: List of source file paths
|
||||||
target_path: Target directory path
|
target_path: Target directory path (used as root if use_default_paths is True)
|
||||||
|
use_default_paths: Whether to use default path template for organization
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with bulk move results
|
Dictionary with bulk move results
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
results = []
|
results = []
|
||||||
|
self.scanner.reset_cancellation()
|
||||||
|
|
||||||
for file_path in file_paths:
|
for file_path in file_paths:
|
||||||
result = await self.move_model(file_path, target_path)
|
if self.scanner.is_cancelled():
|
||||||
|
logger.info(f"{self.model_type.capitalize()} Move Service: Bulk move cancelled by user")
|
||||||
|
break
|
||||||
|
result = await self.move_model(file_path, target_path, use_default_paths=use_default_paths)
|
||||||
results.append({
|
results.append({
|
||||||
"original_file_path": file_path,
|
"original_file_path": file_path,
|
||||||
"new_file_path": result.get('new_file_path'),
|
"new_file_path": result.get('new_file_path'),
|
||||||
"success": result['success'],
|
"success": result['success'],
|
||||||
"message": result.get('message', result.get('error', 'Unknown'))
|
"message": result.get('message', result.get('error', 'Unknown')),
|
||||||
|
"cache_entry": result.get('cache_entry')
|
||||||
})
|
})
|
||||||
|
|
||||||
success_count = sum(1 for r in results if r["success"])
|
success_count = sum(1 for r in results if r["success"])
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Protocol, Callable
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Dict,
|
||||||
|
Iterable,
|
||||||
|
List,
|
||||||
|
Mapping,
|
||||||
|
Optional,
|
||||||
|
Sequence,
|
||||||
|
Tuple,
|
||||||
|
Protocol,
|
||||||
|
Callable,
|
||||||
|
)
|
||||||
|
|
||||||
from ..utils.constants import NSFW_LEVELS
|
from ..utils.constants import NSFW_LEVELS
|
||||||
from ..utils.utils import fuzzy_match as default_fuzzy_match
|
from ..utils.utils import fuzzy_match as default_fuzzy_match
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CIVITAI_MODEL_TYPE = "LORA"
|
DEFAULT_CIVITAI_MODEL_TYPE = "LORA"
|
||||||
@@ -47,8 +62,7 @@ def resolve_civitai_model_type(entry: Mapping[str, Any]) -> str:
|
|||||||
class SettingsProvider(Protocol):
|
class SettingsProvider(Protocol):
|
||||||
"""Protocol describing the SettingsManager contract used by query helpers."""
|
"""Protocol describing the SettingsManager contract used by query helpers."""
|
||||||
|
|
||||||
def get(self, key: str, default: Any = None) -> Any:
|
def get(self, key: str, default: Any = None) -> Any: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -64,6 +78,8 @@ class FilterCriteria:
|
|||||||
"""Container for model list filtering options."""
|
"""Container for model list filtering options."""
|
||||||
|
|
||||||
folder: Optional[str] = None
|
folder: Optional[str] = None
|
||||||
|
folder_include: Optional[Sequence[str]] = None
|
||||||
|
folder_exclude: Optional[Sequence[str]] = None
|
||||||
base_models: Optional[Sequence[str]] = None
|
base_models: Optional[Sequence[str]] = None
|
||||||
tags: Optional[Dict[str, str]] = None
|
tags: Optional[Dict[str, str]] = None
|
||||||
favorites_only: bool = False
|
favorites_only: bool = False
|
||||||
@@ -109,82 +125,222 @@ class ModelCacheRepository:
|
|||||||
class ModelFilterSet:
|
class ModelFilterSet:
|
||||||
"""Applies common filtering rules to the model collection."""
|
"""Applies common filtering rules to the model collection."""
|
||||||
|
|
||||||
def __init__(self, settings: SettingsProvider, nsfw_levels: Optional[Dict[str, int]] = None) -> None:
|
def __init__(
|
||||||
|
self, settings: SettingsProvider, nsfw_levels: Optional[Dict[str, int]] = None
|
||||||
|
) -> None:
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
self._nsfw_levels = nsfw_levels or NSFW_LEVELS
|
self._nsfw_levels = nsfw_levels or NSFW_LEVELS
|
||||||
|
|
||||||
def apply(self, data: Iterable[Dict[str, Any]], criteria: FilterCriteria) -> List[Dict[str, Any]]:
|
def apply(
|
||||||
|
self, data: Iterable[Dict[str, Any]], criteria: FilterCriteria
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
"""Return items that satisfy the provided criteria."""
|
"""Return items that satisfy the provided criteria."""
|
||||||
|
overall_start = time.perf_counter()
|
||||||
items = list(data)
|
items = list(data)
|
||||||
|
initial_count = len(items)
|
||||||
|
|
||||||
if self._settings.get("show_only_sfw", False):
|
if self._settings.get("show_only_sfw", False):
|
||||||
|
t0 = time.perf_counter()
|
||||||
threshold = self._nsfw_levels.get("R", 0)
|
threshold = self._nsfw_levels.get("R", 0)
|
||||||
items = [
|
items = [
|
||||||
item for item in items
|
item
|
||||||
if not item.get("preview_nsfw_level") or item.get("preview_nsfw_level") < threshold
|
for item in items
|
||||||
|
if not item.get("preview_nsfw_level")
|
||||||
|
or item.get("preview_nsfw_level") < threshold
|
||||||
]
|
]
|
||||||
|
sfw_duration = time.perf_counter() - t0
|
||||||
|
else:
|
||||||
|
sfw_duration = 0
|
||||||
|
|
||||||
|
favorites_duration = 0
|
||||||
if criteria.favorites_only:
|
if criteria.favorites_only:
|
||||||
|
t0 = time.perf_counter()
|
||||||
items = [item for item in items if item.get("favorite", False)]
|
items = [item for item in items if item.get("favorite", False)]
|
||||||
|
favorites_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
folder_duration = 0
|
||||||
folder = criteria.folder
|
folder = criteria.folder
|
||||||
|
folder_include = criteria.folder_include or []
|
||||||
|
folder_exclude = criteria.folder_exclude or []
|
||||||
options = criteria.search_options or {}
|
options = criteria.search_options or {}
|
||||||
recursive = bool(options.get("recursive", True))
|
recursive = bool(options.get("recursive", True))
|
||||||
|
|
||||||
|
# Apply folder exclude filters first
|
||||||
|
if folder_exclude:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
for exclude_folder in folder_exclude:
|
||||||
|
if exclude_folder:
|
||||||
|
# Check exact match OR prefix match (for subfolders)
|
||||||
|
# Normalize exclude_folder for prefix matching
|
||||||
|
if not exclude_folder.endswith("/"):
|
||||||
|
exclude_prefix = f"{exclude_folder}/"
|
||||||
|
else:
|
||||||
|
exclude_prefix = exclude_folder
|
||||||
|
items = [
|
||||||
|
item
|
||||||
|
for item in items
|
||||||
|
if item.get("folder") != exclude_folder
|
||||||
|
and not item.get("folder", "").startswith(exclude_prefix)
|
||||||
|
]
|
||||||
|
folder_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
# Apply folder include filters
|
||||||
if folder is not None:
|
if folder is not None:
|
||||||
|
t0 = time.perf_counter()
|
||||||
if recursive:
|
if recursive:
|
||||||
if folder:
|
if folder:
|
||||||
folder_with_sep = f"{folder}/"
|
folder_with_sep = f"{folder}/"
|
||||||
items = [
|
items = [
|
||||||
item for item in items
|
item
|
||||||
if item.get("folder") == folder or item.get("folder", "").startswith(folder_with_sep)
|
for item in items
|
||||||
|
if item.get("folder") == folder
|
||||||
|
or item.get("folder", "").startswith(folder_with_sep)
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
items = [item for item in items if item.get("folder") == folder]
|
items = [item for item in items if item.get("folder") == folder]
|
||||||
|
folder_duration = time.perf_counter() - t0 + folder_duration
|
||||||
|
|
||||||
|
# Apply folder include filters
|
||||||
|
if folder_include:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
matched_items = []
|
||||||
|
for include_folder in folder_include:
|
||||||
|
if include_folder:
|
||||||
|
if recursive:
|
||||||
|
# Normalize folder for prefix matching (similar to exclude logic)
|
||||||
|
if not include_folder.endswith("/"):
|
||||||
|
folder_prefix = f"{include_folder}/"
|
||||||
|
else:
|
||||||
|
folder_prefix = include_folder
|
||||||
|
folder_items = [
|
||||||
|
item
|
||||||
|
for item in items
|
||||||
|
if item.get("folder") == include_folder
|
||||||
|
or item.get("folder", "").startswith(folder_prefix)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
folder_items = [
|
||||||
|
item
|
||||||
|
for item in items
|
||||||
|
if item.get("folder") == include_folder
|
||||||
|
]
|
||||||
|
matched_items.extend(folder_items)
|
||||||
|
# Remove duplicates while preserving order
|
||||||
|
seen = set()
|
||||||
|
items = []
|
||||||
|
for item in matched_items:
|
||||||
|
# Use sha256 or id as unique identifier if available, otherwise use tuple representation
|
||||||
|
item_id = item.get("sha256") or item.get("id")
|
||||||
|
if item_id is not None:
|
||||||
|
identifier = item_id
|
||||||
|
else:
|
||||||
|
# For items without explicit id, use a tuple of key values
|
||||||
|
identifier = tuple(sorted((k, str(v)) for k, v in item.items()))
|
||||||
|
if identifier not in seen:
|
||||||
|
seen.add(identifier)
|
||||||
|
items.append(item)
|
||||||
|
folder_duration = time.perf_counter() - t0 + folder_duration
|
||||||
|
# Apply folder include filters (legacy single folder)
|
||||||
|
elif folder is not None:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
if recursive:
|
||||||
|
if folder:
|
||||||
|
# Normalize folder for prefix matching
|
||||||
|
if not folder.endswith("/"):
|
||||||
|
folder_prefix = f"{folder}/"
|
||||||
|
else:
|
||||||
|
folder_prefix = folder
|
||||||
|
items = [
|
||||||
|
item
|
||||||
|
for item in items
|
||||||
|
if item.get("folder") == folder
|
||||||
|
or item.get("folder", "").startswith(folder_prefix)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
items = [item for item in items if item.get("folder") == folder]
|
||||||
|
folder_duration = time.perf_counter() - t0 + folder_duration
|
||||||
|
|
||||||
|
base_models_duration = 0
|
||||||
base_models = criteria.base_models or []
|
base_models = criteria.base_models or []
|
||||||
if base_models:
|
if base_models:
|
||||||
|
t0 = time.perf_counter()
|
||||||
base_model_set = set(base_models)
|
base_model_set = set(base_models)
|
||||||
items = [item for item in items if item.get("base_model") in base_model_set]
|
items = [item for item in items if item.get("base_model") in base_model_set]
|
||||||
|
base_models_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
tags_duration = 0
|
||||||
tag_filters = criteria.tags or {}
|
tag_filters = criteria.tags or {}
|
||||||
include_tags = set()
|
if tag_filters:
|
||||||
exclude_tags = set()
|
t0 = time.perf_counter()
|
||||||
if isinstance(tag_filters, dict):
|
include_tags = set()
|
||||||
for tag, state in tag_filters.items():
|
exclude_tags = set()
|
||||||
if not tag:
|
if isinstance(tag_filters, dict):
|
||||||
continue
|
for tag, state in tag_filters.items():
|
||||||
if state == "exclude":
|
if not tag:
|
||||||
exclude_tags.add(tag)
|
continue
|
||||||
else:
|
if state == "exclude":
|
||||||
include_tags.add(tag)
|
exclude_tags.add(tag)
|
||||||
else:
|
else:
|
||||||
include_tags = {tag for tag in tag_filters if tag}
|
include_tags.add(tag)
|
||||||
|
else:
|
||||||
|
include_tags = {tag for tag in tag_filters if tag}
|
||||||
|
|
||||||
if include_tags:
|
if include_tags:
|
||||||
items = [
|
|
||||||
item for item in items
|
|
||||||
if any(tag in include_tags for tag in (item.get("tags", []) or []))
|
|
||||||
]
|
|
||||||
|
|
||||||
if exclude_tags:
|
def matches_include(item_tags):
|
||||||
items = [
|
if not item_tags and "__no_tags__" in include_tags:
|
||||||
item for item in items
|
return True
|
||||||
if not any(tag in exclude_tags for tag in (item.get("tags", []) or []))
|
return any(tag in include_tags for tag in (item_tags or []))
|
||||||
]
|
|
||||||
|
|
||||||
|
items = [item for item in items if matches_include(item.get("tags"))]
|
||||||
|
|
||||||
|
if exclude_tags:
|
||||||
|
|
||||||
|
def matches_exclude(item_tags):
|
||||||
|
if not item_tags and "__no_tags__" in exclude_tags:
|
||||||
|
return True
|
||||||
|
return any(tag in exclude_tags for tag in (item_tags or []))
|
||||||
|
|
||||||
|
items = [
|
||||||
|
item for item in items if not matches_exclude(item.get("tags"))
|
||||||
|
]
|
||||||
|
tags_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
model_types_duration = 0
|
||||||
model_types = criteria.model_types or []
|
model_types = criteria.model_types or []
|
||||||
normalized_model_types = {
|
if model_types:
|
||||||
model_type for model_type in (
|
t0 = time.perf_counter()
|
||||||
normalize_civitai_model_type(value) for value in model_types
|
normalized_model_types = {
|
||||||
)
|
model_type
|
||||||
if model_type
|
for model_type in (
|
||||||
}
|
normalize_civitai_model_type(value) for value in model_types
|
||||||
if normalized_model_types:
|
)
|
||||||
items = [
|
if model_type
|
||||||
item for item in items
|
}
|
||||||
if normalize_civitai_model_type(resolve_civitai_model_type(item)) in normalized_model_types
|
if normalized_model_types:
|
||||||
]
|
items = [
|
||||||
|
item
|
||||||
|
for item in items
|
||||||
|
if normalize_civitai_model_type(resolve_civitai_model_type(item))
|
||||||
|
in normalized_model_types
|
||||||
|
]
|
||||||
|
model_types_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
duration = time.perf_counter() - overall_start
|
||||||
|
if duration > 0.1: # Only log if it's potentially slow
|
||||||
|
logger.debug(
|
||||||
|
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs). "
|
||||||
|
"Count: %d -> %d",
|
||||||
|
duration,
|
||||||
|
sfw_duration,
|
||||||
|
favorites_duration,
|
||||||
|
folder_duration,
|
||||||
|
base_models_duration,
|
||||||
|
tags_duration,
|
||||||
|
model_types_duration,
|
||||||
|
initial_count,
|
||||||
|
len(items),
|
||||||
|
)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@@ -199,7 +355,9 @@ class SearchStrategy:
|
|||||||
"creator": False,
|
"creator": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, fuzzy_matcher: Optional[Callable[[str, str], bool]] = None) -> None:
|
def __init__(
|
||||||
|
self, fuzzy_matcher: Optional[Callable[[str, str], bool]] = None
|
||||||
|
) -> None:
|
||||||
self._fuzzy_match = fuzzy_matcher or default_fuzzy_match
|
self._fuzzy_match = fuzzy_matcher or default_fuzzy_match
|
||||||
|
|
||||||
def normalize_options(self, options: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
def normalize_options(self, options: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
@@ -238,7 +396,9 @@ class SearchStrategy:
|
|||||||
|
|
||||||
if options.get("tags", False):
|
if options.get("tags", False):
|
||||||
tags = item.get("tags", []) or []
|
tags = item.get("tags", []) or []
|
||||||
if any(self._matches(tag, search_term, search_lower, fuzzy) for tag in tags):
|
if any(
|
||||||
|
self._matches(tag, search_term, search_lower, fuzzy) for tag in tags
|
||||||
|
):
|
||||||
results.append(item)
|
results.append(item)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -249,13 +409,17 @@ class SearchStrategy:
|
|||||||
creator = civitai.get("creator")
|
creator = civitai.get("creator")
|
||||||
if isinstance(creator, dict):
|
if isinstance(creator, dict):
|
||||||
creator_username = creator.get("username", "")
|
creator_username = creator.get("username", "")
|
||||||
if creator_username and self._matches(creator_username, search_term, search_lower, fuzzy):
|
if creator_username and self._matches(
|
||||||
|
creator_username, search_term, search_lower, fuzzy
|
||||||
|
):
|
||||||
results.append(item)
|
results.append(item)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _matches(self, candidate: str, search_term: str, search_lower: str, fuzzy: bool) -> bool:
|
def _matches(
|
||||||
|
self, candidate: str, search_term: str, search_lower: str, fuzzy: bool
|
||||||
|
) -> bool:
|
||||||
if not isinstance(candidate, str):
|
if not isinstance(candidate, str):
|
||||||
candidate = "" if candidate is None else str(candidate)
|
candidate = "" if candidate is None else str(candidate)
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class ModelScanner:
|
|||||||
self._excluded_models = [] # List to track excluded models
|
self._excluded_models = [] # List to track excluded models
|
||||||
self._persistent_cache = get_persistent_cache()
|
self._persistent_cache = get_persistent_cache()
|
||||||
self._name_display_mode = self._resolve_name_display_mode()
|
self._name_display_mode = self._resolve_name_display_mode()
|
||||||
|
self._cancel_requested = False # Flag for cancellation
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
@@ -653,6 +654,11 @@ class ModelScanner:
|
|||||||
self._is_initializing = True # Set flag
|
self._is_initializing = True # Set flag
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Manually trigger a symlink rescan during a full rebuild.
|
||||||
|
# This ensures that any new symlink mappings are correctly picked up.
|
||||||
|
config.rebuild_symlink_cache()
|
||||||
|
|
||||||
# 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()
|
||||||
@@ -678,6 +684,7 @@ class ModelScanner:
|
|||||||
|
|
||||||
async def _reconcile_cache(self) -> None:
|
async def _reconcile_cache(self) -> None:
|
||||||
"""Fast cache reconciliation - only process differences between cache and filesystem"""
|
"""Fast cache reconciliation - only process differences between cache and filesystem"""
|
||||||
|
self.reset_cancellation()
|
||||||
self._is_initializing = True # Set flag for reconciliation duration
|
self._is_initializing = True # Set flag for reconciliation duration
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -737,6 +744,9 @@ class ModelScanner:
|
|||||||
|
|
||||||
# Yield control periodically
|
# Yield control periodically
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
|
if self.is_cancelled():
|
||||||
|
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled")
|
||||||
|
return
|
||||||
|
|
||||||
# Process new files in batches
|
# Process new files in batches
|
||||||
total_added = 0
|
total_added = 0
|
||||||
@@ -785,6 +795,10 @@ class ModelScanner:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding {path} to cache: {e}")
|
logger.error(f"Error adding {path} to cache: {e}")
|
||||||
|
|
||||||
|
if self.is_cancelled():
|
||||||
|
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile processing cancelled")
|
||||||
|
return
|
||||||
|
|
||||||
# Find missing files (in cache but not in filesystem)
|
# Find missing files (in cache but not in filesystem)
|
||||||
missing_files = cached_paths - found_paths
|
missing_files = cached_paths - found_paths
|
||||||
total_removed = 0
|
total_removed = 0
|
||||||
@@ -838,6 +852,19 @@ class ModelScanner:
|
|||||||
"""Check if the scanner is currently initializing"""
|
"""Check if the scanner is currently initializing"""
|
||||||
return self._is_initializing
|
return self._is_initializing
|
||||||
|
|
||||||
|
def cancel_task(self) -> None:
|
||||||
|
"""Request cancellation of the current long-running task."""
|
||||||
|
self._cancel_requested = True
|
||||||
|
logger.info(f"{self.model_type.capitalize()} Scanner: Cancellation requested")
|
||||||
|
|
||||||
|
def reset_cancellation(self) -> None:
|
||||||
|
"""Reset the cancellation flag."""
|
||||||
|
self._cancel_requested = False
|
||||||
|
|
||||||
|
def is_cancelled(self) -> bool:
|
||||||
|
"""Check if cancellation has been requested."""
|
||||||
|
return self._cancel_requested
|
||||||
|
|
||||||
def get_model_roots(self) -> List[str]:
|
def get_model_roots(self) -> List[str]:
|
||||||
"""Get model root directories"""
|
"""Get model root directories"""
|
||||||
raise NotImplementedError("Subclasses must implement get_model_roots")
|
raise NotImplementedError("Subclasses must implement get_model_roots")
|
||||||
@@ -927,7 +954,7 @@ class ModelScanner:
|
|||||||
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
|
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
|
||||||
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
||||||
await MetadataManager.save_metadata(file_path, metadata)
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
logger.debug(f"Created metadata from .civitai.info for {file_path}")
|
logger.info(f"Created metadata from .civitai.info for {file_path} (Reason: .civitai.info was found but .metadata.json was missing)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
|
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
|
||||||
else:
|
else:
|
||||||
@@ -1030,6 +1057,8 @@ class ModelScanner:
|
|||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error(f"Error reporting progress for {self.model_type}: {exc}")
|
logger.error(f"Error reporting progress for {self.model_type}: {exc}")
|
||||||
|
|
||||||
|
self.reset_cancellation()
|
||||||
|
|
||||||
async def scan_recursive(current_path: str, root_path: str, visited_paths: Set[str]) -> None:
|
async def scan_recursive(current_path: str, root_path: str, visited_paths: Set[str]) -> None:
|
||||||
nonlocal processed_files
|
nonlocal processed_files
|
||||||
|
|
||||||
@@ -1073,6 +1102,8 @@ class ModelScanner:
|
|||||||
|
|
||||||
await handle_progress()
|
await handle_progress()
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
|
if self.is_cancelled():
|
||||||
|
return
|
||||||
elif entry.is_dir(follow_symlinks=True):
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
await scan_recursive(entry.path, root_path, visited_paths)
|
await scan_recursive(entry.path, root_path, visited_paths)
|
||||||
except Exception as entry_error:
|
except Exception as entry_error:
|
||||||
@@ -1080,6 +1111,9 @@ class ModelScanner:
|
|||||||
except Exception as scan_error:
|
except Exception as scan_error:
|
||||||
logger.error(f"Error scanning {current_path}: {scan_error}")
|
logger.error(f"Error scanning {current_path}: {scan_error}")
|
||||||
|
|
||||||
|
if self.is_cancelled():
|
||||||
|
return
|
||||||
|
|
||||||
for model_root in self.get_model_roots():
|
for model_root in self.get_model_roots():
|
||||||
if not os.path.exists(model_root):
|
if not os.path.exists(model_root):
|
||||||
continue
|
continue
|
||||||
@@ -1216,9 +1250,12 @@ class ModelScanner:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving metadata file: {e}")
|
logger.error(f"Error moving metadata file: {e}")
|
||||||
|
|
||||||
await self.update_single_model_cache(source_path, target_file, metadata)
|
update_result = await self.update_single_model_cache(source_path, target_file, metadata, recalculate_type=True)
|
||||||
|
|
||||||
return target_file
|
return {
|
||||||
|
"new_path": target_file,
|
||||||
|
"cache_entry": update_result if isinstance(update_result, dict) else None
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||||
@@ -1250,7 +1287,7 @@ class ModelScanner:
|
|||||||
logger.error(f"Error updating metadata paths: {e}", exc_info=True)
|
logger.error(f"Error updating metadata paths: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def update_single_model_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool:
|
async def update_single_model_cache(self, original_path: str, new_path: str, metadata: Dict, recalculate_type: bool = False) -> Union[bool, Dict]:
|
||||||
"""Update cache after a model has been moved or modified"""
|
"""Update cache after a model has been moved or modified"""
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
@@ -1287,6 +1324,9 @@ class ModelScanner:
|
|||||||
file_path_override=normalized_new_path,
|
file_path_override=normalized_new_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if recalculate_type:
|
||||||
|
cache_entry = self.adjust_cached_entry(cache_entry)
|
||||||
|
|
||||||
cache.raw_data.append(cache_entry)
|
cache.raw_data.append(cache_entry)
|
||||||
cache.add_to_version_index(cache_entry)
|
cache.add_to_version_index(cache_entry)
|
||||||
|
|
||||||
@@ -1307,7 +1347,7 @@ class ModelScanner:
|
|||||||
if cache_modified:
|
if cache_modified:
|
||||||
await self._persist_current_cache()
|
await self._persist_current_cache()
|
||||||
|
|
||||||
return True
|
return cache_entry if metadata else True
|
||||||
|
|
||||||
def has_hash(self, sha256: str) -> bool:
|
def has_hash(self, sha256: str) -> bool:
|
||||||
"""Check if a model with given hash exists"""
|
"""Check if a model with given hash exists"""
|
||||||
@@ -1442,6 +1482,10 @@ class ModelScanner:
|
|||||||
deleted_models = []
|
deleted_models = []
|
||||||
|
|
||||||
for file_path in file_paths:
|
for file_path in file_paths:
|
||||||
|
if self.is_cancelled():
|
||||||
|
logger.info(f"{self.model_type.capitalize()} Scanner: Bulk delete cancelled by user")
|
||||||
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target_dir = os.path.dirname(file_path)
|
target_dir = os.path.dirname(file_path)
|
||||||
base_name = os.path.basename(file_path)
|
base_name = os.path.basename(file_path)
|
||||||
@@ -1482,6 +1526,7 @@ class ModelScanner:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
|
'status': 'cancelled' if self.is_cancelled() else 'success',
|
||||||
'total_deleted': total_deleted,
|
'total_deleted': total_deleted,
|
||||||
'total_attempted': len(file_paths),
|
'total_attempted': len(file_paths),
|
||||||
'cache_updated': cache_updated,
|
'cache_updated': cache_updated,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ class ModelServiceFactory:
|
|||||||
"""
|
"""
|
||||||
cls._services[model_type] = service_class
|
cls._services[model_type] = service_class
|
||||||
cls._routes[model_type] = route_class
|
cls._routes[model_type] = route_class
|
||||||
logger.info(f"Registered model type '{model_type}' with service {service_class.__name__} and routes {route_class.__name__}")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_service_class(cls, model_type: str) -> Type:
|
def get_service_class(cls, model_type: str) -> Type:
|
||||||
@@ -80,13 +79,10 @@ class ModelServiceFactory:
|
|||||||
Args:
|
Args:
|
||||||
app: The aiohttp application instance
|
app: The aiohttp application instance
|
||||||
"""
|
"""
|
||||||
logger.info(f"Setting up routes for {len(cls._services)} registered model types")
|
|
||||||
|
|
||||||
for model_type in cls._services.keys():
|
for model_type in cls._services.keys():
|
||||||
try:
|
try:
|
||||||
routes_instance = cls.get_route_instance(model_type)
|
routes_instance = cls.get_route_instance(model_type)
|
||||||
routes_instance.setup_routes(app)
|
routes_instance.setup_routes(app)
|
||||||
logger.info(f"Successfully set up routes for {model_type}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to setup routes for {model_type}: {e}", exc_info=True)
|
logger.error(f"Failed to setup routes for {model_type}: {e}", exc_info=True)
|
||||||
|
|
||||||
@@ -138,5 +134,3 @@ def register_default_model_types():
|
|||||||
|
|
||||||
# Register Embedding model type
|
# Register Embedding model type
|
||||||
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
|
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
|
||||||
|
|
||||||
logger.info("Registered default model types: lora, checkpoint, embedding")
|
|
||||||
@@ -466,6 +466,7 @@ class ModelUpdateService:
|
|||||||
target_model_ids: Optional[Sequence[int]] = None,
|
target_model_ids: Optional[Sequence[int]] = None,
|
||||||
) -> Dict[int, ModelUpdateRecord]:
|
) -> Dict[int, ModelUpdateRecord]:
|
||||||
"""Refresh update information for every model present in the cache."""
|
"""Refresh update information for every model present in the cache."""
|
||||||
|
scanner.reset_cancellation()
|
||||||
|
|
||||||
normalized_targets = (
|
normalized_targets = (
|
||||||
self._normalize_sequence(target_model_ids)
|
self._normalize_sequence(target_model_ids)
|
||||||
@@ -542,6 +543,9 @@ class ModelUpdateService:
|
|||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
prefetched_response=prefetched.get(model_id),
|
prefetched_response=prefetched.get(model_id),
|
||||||
)
|
)
|
||||||
|
if scanner.is_cancelled():
|
||||||
|
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
||||||
|
return results
|
||||||
if record:
|
if record:
|
||||||
results[model_id] = record
|
results[model_id] = record
|
||||||
if index % progress_interval == 0 or index == total_models:
|
if index % progress_interval == 0 or index == total_models:
|
||||||
@@ -585,6 +589,8 @@ class ModelUpdateService:
|
|||||||
model_type: str,
|
model_type: str,
|
||||||
model_id: int,
|
model_id: int,
|
||||||
version_ids: Sequence[int],
|
version_ids: Sequence[int],
|
||||||
|
*,
|
||||||
|
version_info: Optional[Mapping] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
"""Persist a new set of in-library version identifiers."""
|
"""Persist a new set of in-library version identifiers."""
|
||||||
|
|
||||||
@@ -596,6 +602,7 @@ class ModelUpdateService:
|
|||||||
normalized_versions,
|
normalized_versions,
|
||||||
model_type=model_type,
|
model_type=model_type,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
|
version_info=version_info,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -940,6 +947,7 @@ class ModelUpdateService:
|
|||||||
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,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
local_set = set(normalized_local)
|
local_set = set(normalized_local)
|
||||||
versions: List[ModelVersionRecord] = []
|
versions: List[ModelVersionRecord] = []
|
||||||
@@ -961,19 +969,26 @@ class ModelUpdateService:
|
|||||||
|
|
||||||
seen_ids = {version.version_id for version in versions}
|
seen_ids = {version.version_id for version in versions}
|
||||||
for missing_id in sorted(local_set - seen_ids):
|
for missing_id in sorted(local_set - seen_ids):
|
||||||
versions.append(
|
new_version: Optional[ModelVersionRecord] = None
|
||||||
ModelVersionRecord(
|
if version_info and _normalize_int(version_info.get("id")) == missing_id:
|
||||||
version_id=missing_id,
|
new_version = self._extract_single_version(version_info, index=len(versions))
|
||||||
name=None,
|
|
||||||
base_model=None,
|
if new_version:
|
||||||
released_at=None,
|
versions.append(replace(new_version, is_in_library=True))
|
||||||
size_bytes=None,
|
else:
|
||||||
preview_url=None,
|
versions.append(
|
||||||
is_in_library=True,
|
ModelVersionRecord(
|
||||||
should_ignore=ignore_map.get(missing_id, False),
|
version_id=missing_id,
|
||||||
sort_index=len(versions),
|
name=None,
|
||||||
|
base_model=None,
|
||||||
|
released_at=None,
|
||||||
|
size_bytes=None,
|
||||||
|
preview_url=None,
|
||||||
|
is_in_library=True,
|
||||||
|
should_ignore=ignore_map.get(missing_id, False),
|
||||||
|
sort_index=len(versions),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return ModelUpdateRecord(
|
return ModelUpdateRecord(
|
||||||
model_type=model_type,
|
model_type=model_type,
|
||||||
@@ -1079,33 +1094,45 @@ class ModelUpdateService:
|
|||||||
return []
|
return []
|
||||||
if not isinstance(versions, Iterable):
|
if not isinstance(versions, Iterable):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
extracted: List[ModelVersionRecord] = []
|
extracted: List[ModelVersionRecord] = []
|
||||||
for index, entry in enumerate(versions):
|
for index, entry in enumerate(versions):
|
||||||
if not isinstance(entry, Mapping):
|
version_record = self._extract_single_version(entry, index)
|
||||||
continue
|
if version_record:
|
||||||
version_id = _normalize_int(entry.get("id"))
|
extracted.append(version_record)
|
||||||
if version_id is None:
|
|
||||||
continue
|
|
||||||
name = _normalize_string(entry.get("name"))
|
|
||||||
base_model = _normalize_string(entry.get("baseModel"))
|
|
||||||
released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt"))
|
|
||||||
size_bytes = self._extract_size_bytes(entry.get("files"))
|
|
||||||
preview_url = self._extract_preview_url(entry.get("images"))
|
|
||||||
extracted.append(
|
|
||||||
ModelVersionRecord(
|
|
||||||
version_id=version_id,
|
|
||||||
name=name,
|
|
||||||
base_model=base_model,
|
|
||||||
released_at=released_at,
|
|
||||||
size_bytes=size_bytes,
|
|
||||||
preview_url=preview_url,
|
|
||||||
is_in_library=False,
|
|
||||||
should_ignore=False,
|
|
||||||
sort_index=index,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return extracted
|
return extracted
|
||||||
|
|
||||||
|
def _extract_single_version(
|
||||||
|
self, entry: Any, index: int = 0
|
||||||
|
) -> Optional[ModelVersionRecord]:
|
||||||
|
"""Convert a raw metadata entry into a structured record."""
|
||||||
|
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
return None
|
||||||
|
|
||||||
|
version_id = _normalize_int(entry.get("id"))
|
||||||
|
if version_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = _normalize_string(entry.get("name"))
|
||||||
|
base_model = _normalize_string(entry.get("baseModel"))
|
||||||
|
released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt"))
|
||||||
|
size_bytes = self._extract_size_bytes(entry.get("files"))
|
||||||
|
preview_url = self._extract_preview_url(entry.get("images"))
|
||||||
|
|
||||||
|
return ModelVersionRecord(
|
||||||
|
version_id=version_id,
|
||||||
|
name=name,
|
||||||
|
base_model=base_model,
|
||||||
|
released_at=released_at,
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
preview_url=preview_url,
|
||||||
|
is_in_library=False,
|
||||||
|
should_ignore=False,
|
||||||
|
sort_index=index,
|
||||||
|
)
|
||||||
|
|
||||||
def _extract_size_bytes(self, files) -> Optional[int]:
|
def _extract_size_bytes(self, files) -> Optional[int]:
|
||||||
if not isinstance(files, Iterable):
|
if not isinstance(files, Iterable):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ from natsort import natsorted
|
|||||||
@dataclass
|
@dataclass
|
||||||
class RecipeCache:
|
class RecipeCache:
|
||||||
"""Cache structure for Recipe data"""
|
"""Cache structure for Recipe data"""
|
||||||
|
|
||||||
raw_data: List[Dict]
|
raw_data: List[Dict]
|
||||||
sorted_by_name: List[Dict]
|
sorted_by_name: List[Dict]
|
||||||
sorted_by_date: List[Dict]
|
sorted_by_date: List[Dict]
|
||||||
|
folders: List[str] | None = None
|
||||||
|
folder_tree: Dict | None = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
# Normalize optional metadata containers
|
||||||
|
self.folders = self.folders or []
|
||||||
|
self.folder_tree = self.folder_tree or {}
|
||||||
|
|
||||||
async def resort(self, name_only: bool = False):
|
async def resort(self, name_only: bool = False):
|
||||||
"""Resort all cached data views"""
|
"""Resort all cached data views"""
|
||||||
|
|||||||
547
py/services/recipe_fts_index.py
Normal file
547
py/services/recipe_fts_index.py
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
"""SQLite FTS5-based full-text search index for recipes.
|
||||||
|
|
||||||
|
This module provides fast recipe search using SQLite's FTS5 extension,
|
||||||
|
enabling sub-100ms search times even with 20k+ recipes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
from ..utils.settings_paths import get_settings_dir
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeFTSIndex:
|
||||||
|
"""SQLite FTS5-based full-text search index for recipes.
|
||||||
|
|
||||||
|
Provides fast prefix-based search across multiple recipe fields:
|
||||||
|
- title
|
||||||
|
- tags
|
||||||
|
- lora_names (file names)
|
||||||
|
- lora_models (model names)
|
||||||
|
- prompt
|
||||||
|
- negative_prompt
|
||||||
|
"""
|
||||||
|
|
||||||
|
_DEFAULT_FILENAME = "recipe_fts.sqlite"
|
||||||
|
|
||||||
|
# Map of search option keys to FTS column names
|
||||||
|
FIELD_MAP = {
|
||||||
|
'title': ['title'],
|
||||||
|
'tags': ['tags'],
|
||||||
|
'lora_name': ['lora_names'],
|
||||||
|
'lora_model': ['lora_models'],
|
||||||
|
'prompt': ['prompt', 'negative_prompt'],
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, db_path: Optional[str] = None) -> None:
|
||||||
|
"""Initialize the FTS index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Optional path to the SQLite database file.
|
||||||
|
If not provided, uses the default location in settings directory.
|
||||||
|
"""
|
||||||
|
self._db_path = db_path or self._resolve_default_path()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._ready = threading.Event()
|
||||||
|
self._indexing_in_progress = False
|
||||||
|
self._schema_initialized = False
|
||||||
|
self._warned_not_ready = False
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
try:
|
||||||
|
directory = os.path.dirname(self._db_path)
|
||||||
|
if directory:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not create FTS index directory %s: %s", directory, exc)
|
||||||
|
|
||||||
|
def _resolve_default_path(self) -> str:
|
||||||
|
"""Resolve the default database path."""
|
||||||
|
override = os.environ.get("LORA_MANAGER_RECIPE_FTS_DB")
|
||||||
|
if override:
|
||||||
|
return override
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings_dir = get_settings_dir(create=True)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Falling back to current directory for FTS index: %s", exc)
|
||||||
|
settings_dir = "."
|
||||||
|
|
||||||
|
return os.path.join(settings_dir, self._DEFAULT_FILENAME)
|
||||||
|
|
||||||
|
def get_database_path(self) -> str:
|
||||||
|
"""Return the resolved database path."""
|
||||||
|
return self._db_path
|
||||||
|
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
"""Check if the FTS index is ready for queries."""
|
||||||
|
return self._ready.is_set()
|
||||||
|
|
||||||
|
def is_indexing(self) -> bool:
|
||||||
|
"""Check if indexing is currently in progress."""
|
||||||
|
return self._indexing_in_progress
|
||||||
|
|
||||||
|
def initialize(self) -> None:
|
||||||
|
"""Initialize the database schema."""
|
||||||
|
if self._schema_initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if self._schema_initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.executescript("""
|
||||||
|
-- FTS5 virtual table for full-text search
|
||||||
|
-- Note: We use a regular FTS5 table (not contentless) so we can retrieve recipe_id
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS recipe_fts USING fts5(
|
||||||
|
recipe_id,
|
||||||
|
title,
|
||||||
|
tags,
|
||||||
|
lora_names,
|
||||||
|
lora_models,
|
||||||
|
prompt,
|
||||||
|
negative_prompt,
|
||||||
|
tokenize='unicode61 remove_diacritics 2'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Recipe ID to rowid mapping for fast lookups and deletions
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_rowid (
|
||||||
|
recipe_id TEXT PRIMARY KEY,
|
||||||
|
fts_rowid INTEGER UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index version tracking
|
||||||
|
CREATE TABLE IF NOT EXISTS fts_metadata (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
self._schema_initialized = True
|
||||||
|
logger.debug("FTS index schema initialized at %s", self._db_path)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to initialize FTS schema: %s", exc)
|
||||||
|
|
||||||
|
def build_index(self, recipes: List[Dict[str, Any]]) -> None:
|
||||||
|
"""Build or rebuild the entire FTS index from recipe data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipes: List of recipe dictionaries to index.
|
||||||
|
"""
|
||||||
|
if self._indexing_in_progress:
|
||||||
|
logger.warning("FTS indexing already in progress, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._indexing_in_progress = True
|
||||||
|
self._ready.clear()
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.initialize()
|
||||||
|
if not self._schema_initialized:
|
||||||
|
logger.error("Cannot build FTS index: schema not initialized")
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
|
||||||
|
# Clear existing data
|
||||||
|
conn.execute("DELETE FROM recipe_fts")
|
||||||
|
conn.execute("DELETE FROM recipe_rowid")
|
||||||
|
|
||||||
|
# Batch insert for performance
|
||||||
|
batch_size = 500
|
||||||
|
total = len(recipes)
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
for i in range(0, total, batch_size):
|
||||||
|
batch = recipes[i:i + batch_size]
|
||||||
|
rows = []
|
||||||
|
rowid_mappings = []
|
||||||
|
|
||||||
|
for recipe in batch:
|
||||||
|
recipe_id = str(recipe.get('id', ''))
|
||||||
|
if not recipe_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
row = self._prepare_fts_row(recipe)
|
||||||
|
rows.append(row)
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
# Insert into FTS table
|
||||||
|
conn.executemany(
|
||||||
|
"""INSERT INTO recipe_fts (recipe_id, title, tags, lora_names,
|
||||||
|
lora_models, prompt, negative_prompt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
rows
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build rowid mappings
|
||||||
|
for row in rows:
|
||||||
|
recipe_id = row[0]
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT rowid FROM recipe_fts WHERE recipe_id = ?",
|
||||||
|
(recipe_id,)
|
||||||
|
)
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if result:
|
||||||
|
rowid_mappings.append((recipe_id, result[0]))
|
||||||
|
|
||||||
|
if rowid_mappings:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR REPLACE INTO recipe_rowid (recipe_id, fts_rowid) VALUES (?, ?)",
|
||||||
|
rowid_mappings
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update metadata
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||||
|
('last_build_time', str(time.time()))
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||||
|
('recipe_count', str(inserted))
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
logger.info("FTS index built: %d recipes indexed in %.2fs", inserted, elapsed)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
self._ready.set()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to build FTS index: %s", exc, exc_info=True)
|
||||||
|
finally:
|
||||||
|
self._indexing_in_progress = False
|
||||||
|
|
||||||
|
def search(self, query: str, fields: Optional[Set[str]] = None) -> Set[str]:
|
||||||
|
"""Search recipes using FTS5 with prefix matching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The search query string.
|
||||||
|
fields: Optional set of field names to search. If None, searches all fields.
|
||||||
|
Valid fields: 'title', 'tags', 'lora_name', 'lora_model', 'prompt'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of matching recipe IDs.
|
||||||
|
"""
|
||||||
|
if not self.is_ready():
|
||||||
|
if not self._warned_not_ready:
|
||||||
|
logger.debug("FTS index not ready, returning empty results")
|
||||||
|
self._warned_not_ready = True
|
||||||
|
return set()
|
||||||
|
|
||||||
|
if not query or not query.strip():
|
||||||
|
return set()
|
||||||
|
|
||||||
|
fts_query = self._build_fts_query(query, fields)
|
||||||
|
if not fts_query:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect(readonly=True)
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT recipe_id FROM recipe_fts WHERE recipe_fts MATCH ?",
|
||||||
|
(fts_query,)
|
||||||
|
)
|
||||||
|
return {row[0] for row in cursor.fetchall()}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("FTS search error for query '%s': %s", query, exc)
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def add_recipe(self, recipe: Dict[str, Any]) -> bool:
|
||||||
|
"""Add a single recipe to the FTS index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe: The recipe dictionary to add.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise.
|
||||||
|
"""
|
||||||
|
if not self.is_ready():
|
||||||
|
return False
|
||||||
|
|
||||||
|
recipe_id = str(recipe.get('id', ''))
|
||||||
|
if not recipe_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
# Remove existing entry if present
|
||||||
|
self._remove_recipe_locked(conn, recipe_id)
|
||||||
|
|
||||||
|
# Insert new entry
|
||||||
|
row = self._prepare_fts_row(recipe)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO recipe_fts (recipe_id, title, tags, lora_names,
|
||||||
|
lora_models, prompt, negative_prompt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
row
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update rowid mapping
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT rowid FROM recipe_fts WHERE recipe_id = ?",
|
||||||
|
(recipe_id,)
|
||||||
|
)
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if result:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO recipe_rowid (recipe_id, fts_rowid) VALUES (?, ?)",
|
||||||
|
(recipe_id, result[0])
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Failed to add recipe %s to FTS index: %s", recipe_id, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_recipe(self, recipe_id: str) -> bool:
|
||||||
|
"""Remove a recipe from the FTS index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_id: The ID of the recipe to remove.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise.
|
||||||
|
"""
|
||||||
|
if not self.is_ready():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not recipe_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
self._remove_recipe_locked(conn, recipe_id)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Failed to remove recipe %s from FTS index: %s", recipe_id, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_recipe(self, recipe: Dict[str, Any]) -> bool:
|
||||||
|
"""Update a recipe in the FTS index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe: The updated recipe dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise.
|
||||||
|
"""
|
||||||
|
return self.add_recipe(recipe) # add_recipe handles removal and re-insertion
|
||||||
|
|
||||||
|
def clear(self) -> bool:
|
||||||
|
"""Clear all data from the FTS index.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
conn.execute("DELETE FROM recipe_fts")
|
||||||
|
conn.execute("DELETE FROM recipe_rowid")
|
||||||
|
conn.commit()
|
||||||
|
self._ready.clear()
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to clear FTS index: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_indexed_count(self) -> int:
|
||||||
|
"""Return the number of recipes currently indexed."""
|
||||||
|
if not self._schema_initialized:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect(readonly=True)
|
||||||
|
try:
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM recipe_fts")
|
||||||
|
result = cursor.fetchone()
|
||||||
|
return result[0] if result else 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Internal helpers
|
||||||
|
|
||||||
|
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||||
|
"""Create a database connection."""
|
||||||
|
uri = False
|
||||||
|
path = self._db_path
|
||||||
|
if readonly:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise FileNotFoundError(path)
|
||||||
|
path = f"file:{path}?mode=ro"
|
||||||
|
uri = True
|
||||||
|
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _remove_recipe_locked(self, conn: sqlite3.Connection, recipe_id: str) -> None:
|
||||||
|
"""Remove a recipe entry. Caller must hold the lock."""
|
||||||
|
# Get the rowid for deletion
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT fts_rowid FROM recipe_rowid WHERE recipe_id = ?",
|
||||||
|
(recipe_id,)
|
||||||
|
)
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if result:
|
||||||
|
fts_rowid = result[0]
|
||||||
|
# Delete from FTS using rowid
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM recipe_fts WHERE rowid = ?",
|
||||||
|
(fts_rowid,)
|
||||||
|
)
|
||||||
|
# Also try direct delete by recipe_id (handles edge cases)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM recipe_fts WHERE recipe_id = ?",
|
||||||
|
(recipe_id,)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM recipe_rowid WHERE recipe_id = ?",
|
||||||
|
(recipe_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _prepare_fts_row(self, recipe: Dict[str, Any]) -> tuple:
|
||||||
|
"""Prepare a row tuple for FTS insertion."""
|
||||||
|
recipe_id = str(recipe.get('id', ''))
|
||||||
|
title = str(recipe.get('title', ''))
|
||||||
|
|
||||||
|
# Extract tags as space-separated string
|
||||||
|
tags_list = recipe.get('tags', [])
|
||||||
|
tags = ' '.join(str(t) for t in tags_list if t) if tags_list else ''
|
||||||
|
|
||||||
|
# Extract LoRA file names and model names
|
||||||
|
loras = recipe.get('loras', [])
|
||||||
|
lora_names = []
|
||||||
|
lora_models = []
|
||||||
|
for lora in loras:
|
||||||
|
if isinstance(lora, dict):
|
||||||
|
file_name = lora.get('file_name', '')
|
||||||
|
if file_name:
|
||||||
|
lora_names.append(str(file_name))
|
||||||
|
model_name = lora.get('modelName', '')
|
||||||
|
if model_name:
|
||||||
|
lora_models.append(str(model_name))
|
||||||
|
|
||||||
|
lora_names_str = ' '.join(lora_names)
|
||||||
|
lora_models_str = ' '.join(lora_models)
|
||||||
|
|
||||||
|
# Extract prompts from gen_params
|
||||||
|
gen_params = recipe.get('gen_params', {})
|
||||||
|
prompt = str(gen_params.get('prompt', '')) if gen_params else ''
|
||||||
|
negative_prompt = str(gen_params.get('negative_prompt', '')) if gen_params else ''
|
||||||
|
|
||||||
|
return (recipe_id, title, tags, lora_names_str, lora_models_str, prompt, negative_prompt)
|
||||||
|
|
||||||
|
def _build_fts_query(self, query: str, fields: Optional[Set[str]] = None) -> str:
|
||||||
|
"""Build an FTS5 query string with prefix matching and field restrictions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The user's search query.
|
||||||
|
fields: Optional set of field names to restrict search to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FTS5 query string.
|
||||||
|
"""
|
||||||
|
# Split query into words and clean them
|
||||||
|
words = query.lower().split()
|
||||||
|
if not words:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# Escape and add prefix wildcard to each word
|
||||||
|
prefix_terms = []
|
||||||
|
for word in words:
|
||||||
|
escaped = self._escape_fts_query(word)
|
||||||
|
if escaped:
|
||||||
|
# Add prefix wildcard for substring-like matching
|
||||||
|
# FTS5 prefix queries: word* matches words starting with "word"
|
||||||
|
prefix_terms.append(f'{escaped}*')
|
||||||
|
|
||||||
|
if not prefix_terms:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# Combine terms with implicit AND (all words must match)
|
||||||
|
term_expr = ' '.join(prefix_terms)
|
||||||
|
|
||||||
|
# If no field restriction, search all indexed fields (not recipe_id)
|
||||||
|
if not fields:
|
||||||
|
return term_expr
|
||||||
|
|
||||||
|
# Build field-restricted query with OR between fields
|
||||||
|
field_clauses = []
|
||||||
|
for field in fields:
|
||||||
|
if field in self.FIELD_MAP:
|
||||||
|
cols = self.FIELD_MAP[field]
|
||||||
|
for col in cols:
|
||||||
|
# FTS5 column filter syntax: column:term
|
||||||
|
# Need to handle multiple terms properly
|
||||||
|
for term in prefix_terms:
|
||||||
|
field_clauses.append(f'{col}:{term}')
|
||||||
|
|
||||||
|
if not field_clauses:
|
||||||
|
return term_expr
|
||||||
|
|
||||||
|
# Combine field clauses with OR
|
||||||
|
return ' OR '.join(field_clauses)
|
||||||
|
|
||||||
|
def _escape_fts_query(self, text: str) -> str:
|
||||||
|
"""Escape special FTS5 characters.
|
||||||
|
|
||||||
|
FTS5 special characters: " ( ) * : ^ -
|
||||||
|
We keep * for prefix matching but escape others.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# Replace FTS5 special characters with space
|
||||||
|
# Keep alphanumeric, CJK characters, and common punctuation
|
||||||
|
special = ['"', '(', ')', '*', ':', '^', '-', '{', '}', '[', ']']
|
||||||
|
result = text
|
||||||
|
for char in special:
|
||||||
|
result = result.replace(char, ' ')
|
||||||
|
|
||||||
|
# Collapse multiple spaces and strip
|
||||||
|
result = re.sub(r'\s+', ' ', result).strip()
|
||||||
|
return result
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import numpy as np
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from ...utils.utils import calculate_recipe_fingerprint
|
from ...utils.utils import calculate_recipe_fingerprint
|
||||||
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
from .errors import (
|
from .errors import (
|
||||||
RecipeDownloadError,
|
RecipeDownloadError,
|
||||||
RecipeNotFoundError,
|
RecipeNotFoundError,
|
||||||
@@ -94,36 +95,72 @@ class RecipeAnalysisService:
|
|||||||
if civitai_client is None:
|
if civitai_client is None:
|
||||||
raise RecipeServiceError("Civitai client unavailable")
|
raise RecipeServiceError("Civitai client unavailable")
|
||||||
|
|
||||||
temp_path = self._create_temp_path()
|
temp_path = None
|
||||||
metadata: Optional[dict[str, Any]] = None
|
metadata: Optional[dict[str, Any]] = None
|
||||||
|
is_video = False
|
||||||
|
extension = ".jpg" # Default
|
||||||
|
|
||||||
try:
|
try:
|
||||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
|
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
|
||||||
if civitai_match:
|
if civitai_match:
|
||||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||||
if not image_info:
|
if not image_info:
|
||||||
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
||||||
|
|
||||||
image_url = image_info.get("url")
|
image_url = image_info.get("url")
|
||||||
if not image_url:
|
if not image_url:
|
||||||
raise RecipeDownloadError("No image URL found in Civitai response")
|
raise RecipeDownloadError("No image URL found in Civitai response")
|
||||||
|
|
||||||
|
is_video = image_info.get("type") == "video"
|
||||||
|
|
||||||
|
# Use optimized preview URLs if possible
|
||||||
|
rewritten_url, _ = rewrite_preview_url(image_url, media_type=image_info.get("type"))
|
||||||
|
if rewritten_url:
|
||||||
|
image_url = rewritten_url
|
||||||
|
|
||||||
|
if is_video:
|
||||||
|
# Extract extension from URL
|
||||||
|
url_path = image_url.split('?')[0].split('#')[0]
|
||||||
|
extension = os.path.splitext(url_path)[1].lower() or ".mp4"
|
||||||
|
else:
|
||||||
|
extension = ".jpg"
|
||||||
|
|
||||||
|
temp_path = self._create_temp_path(suffix=extension)
|
||||||
await self._download_image(image_url, temp_path)
|
await self._download_image(image_url, temp_path)
|
||||||
|
|
||||||
metadata = image_info.get("meta") if "meta" in image_info else None
|
metadata = image_info.get("meta") if "meta" in image_info else None
|
||||||
|
if (
|
||||||
|
isinstance(metadata, dict)
|
||||||
|
and "meta" in metadata
|
||||||
|
and isinstance(metadata["meta"], dict)
|
||||||
|
):
|
||||||
|
metadata = metadata["meta"]
|
||||||
else:
|
else:
|
||||||
|
# Basic extension detection for non-Civitai URLs
|
||||||
|
url_path = url.split('?')[0].split('#')[0]
|
||||||
|
extension = os.path.splitext(url_path)[1].lower()
|
||||||
|
if extension in [".mp4", ".webm"]:
|
||||||
|
is_video = True
|
||||||
|
else:
|
||||||
|
extension = ".jpg"
|
||||||
|
|
||||||
|
temp_path = self._create_temp_path(suffix=extension)
|
||||||
await self._download_image(url, temp_path)
|
await self._download_image(url, temp_path)
|
||||||
|
|
||||||
if metadata is None:
|
if metadata is None and not is_video:
|
||||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
||||||
|
|
||||||
if not metadata:
|
|
||||||
return self._metadata_not_found_response(temp_path)
|
|
||||||
|
|
||||||
return await self._parse_metadata(
|
return await self._parse_metadata(
|
||||||
metadata,
|
metadata or {},
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
image_path=temp_path,
|
image_path=temp_path,
|
||||||
include_image_base64=True,
|
include_image_base64=True,
|
||||||
|
is_video=is_video,
|
||||||
|
extension=extension,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
self._safe_cleanup(temp_path)
|
if temp_path:
|
||||||
|
self._safe_cleanup(temp_path)
|
||||||
|
|
||||||
async def analyze_local_image(
|
async def analyze_local_image(
|
||||||
self,
|
self,
|
||||||
@@ -192,12 +229,16 @@ class RecipeAnalysisService:
|
|||||||
recipe_scanner,
|
recipe_scanner,
|
||||||
image_path: Optional[str],
|
image_path: Optional[str],
|
||||||
include_image_base64: bool,
|
include_image_base64: bool,
|
||||||
|
is_video: bool = False,
|
||||||
|
extension: str = ".jpg",
|
||||||
) -> AnalysisResult:
|
) -> AnalysisResult:
|
||||||
parser = self._recipe_parser_factory.create_parser(metadata)
|
parser = self._recipe_parser_factory.create_parser(metadata)
|
||||||
if parser is None:
|
if parser is None:
|
||||||
payload = {"error": "No parser found for this image", "loras": []}
|
payload = {"error": "No parser found for this image", "loras": []}
|
||||||
if include_image_base64 and image_path:
|
if include_image_base64 and image_path:
|
||||||
payload["image_base64"] = self._encode_file(image_path)
|
payload["image_base64"] = self._encode_file(image_path)
|
||||||
|
payload["is_video"] = is_video
|
||||||
|
payload["extension"] = extension
|
||||||
return AnalysisResult(payload)
|
return AnalysisResult(payload)
|
||||||
|
|
||||||
result = await parser.parse_metadata(metadata, recipe_scanner=recipe_scanner)
|
result = await parser.parse_metadata(metadata, recipe_scanner=recipe_scanner)
|
||||||
@@ -205,6 +246,9 @@ class RecipeAnalysisService:
|
|||||||
if include_image_base64 and image_path:
|
if include_image_base64 and image_path:
|
||||||
result["image_base64"] = self._encode_file(image_path)
|
result["image_base64"] = self._encode_file(image_path)
|
||||||
|
|
||||||
|
result["is_video"] = is_video
|
||||||
|
result["extension"] = extension
|
||||||
|
|
||||||
if "error" in result and not result.get("loras"):
|
if "error" in result and not result.get("loras"):
|
||||||
return AnalysisResult(result)
|
return AnalysisResult(result)
|
||||||
|
|
||||||
@@ -235,8 +279,8 @@ class RecipeAnalysisService:
|
|||||||
temp_file.write(data)
|
temp_file.write(data)
|
||||||
return temp_file.name
|
return temp_file.name
|
||||||
|
|
||||||
def _create_temp_path(self) -> str:
|
def _create_temp_path(self, suffix: str = ".jpg") -> str:
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as temp_file:
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
|
||||||
return temp_file.name
|
return temp_file.name
|
||||||
|
|
||||||
def _safe_cleanup(self, path: Optional[str]) -> None:
|
def _safe_cleanup(self, path: Optional[str]) -> None:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -46,6 +47,7 @@ class RecipePersistenceService:
|
|||||||
name: str | None,
|
name: str | None,
|
||||||
tags: Iterable[str],
|
tags: Iterable[str],
|
||||||
metadata: Optional[dict[str, Any]],
|
metadata: Optional[dict[str, Any]],
|
||||||
|
extension: str | None = None,
|
||||||
) -> PersistenceResult:
|
) -> PersistenceResult:
|
||||||
"""Persist a user uploaded recipe."""
|
"""Persist a user uploaded recipe."""
|
||||||
|
|
||||||
@@ -64,13 +66,21 @@ class RecipePersistenceService:
|
|||||||
os.makedirs(recipes_dir, exist_ok=True)
|
os.makedirs(recipes_dir, exist_ok=True)
|
||||||
|
|
||||||
recipe_id = str(uuid.uuid4())
|
recipe_id = str(uuid.uuid4())
|
||||||
optimized_image, extension = self._exif_utils.optimize_image(
|
|
||||||
image_data=resolved_image_bytes,
|
# Handle video formats by bypassing optimization and metadata embedding
|
||||||
target_width=self._card_preview_width,
|
is_video = extension in [".mp4", ".webm"]
|
||||||
format="webp",
|
if is_video:
|
||||||
quality=85,
|
optimized_image = resolved_image_bytes
|
||||||
preserve_metadata=True,
|
# extension is already set
|
||||||
)
|
else:
|
||||||
|
optimized_image, extension = self._exif_utils.optimize_image(
|
||||||
|
image_data=resolved_image_bytes,
|
||||||
|
target_width=self._card_preview_width,
|
||||||
|
format="webp",
|
||||||
|
quality=85,
|
||||||
|
preserve_metadata=True,
|
||||||
|
)
|
||||||
|
|
||||||
image_filename = f"{recipe_id}{extension}"
|
image_filename = f"{recipe_id}{extension}"
|
||||||
image_path = os.path.join(recipes_dir, image_filename)
|
image_path = os.path.join(recipes_dir, image_filename)
|
||||||
normalized_image_path = os.path.normpath(image_path)
|
normalized_image_path = os.path.normpath(image_path)
|
||||||
@@ -126,7 +136,8 @@ class RecipePersistenceService:
|
|||||||
with open(json_path, "w", encoding="utf-8") as file_obj:
|
with open(json_path, "w", encoding="utf-8") as file_obj:
|
||||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
|
if not is_video:
|
||||||
|
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
|
||||||
|
|
||||||
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
|
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
|
||||||
await recipe_scanner.add_recipe(recipe_data)
|
await recipe_scanner.add_recipe(recipe_data)
|
||||||
@@ -144,12 +155,8 @@ class RecipePersistenceService:
|
|||||||
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
||||||
"""Delete an existing recipe."""
|
"""Delete an existing recipe."""
|
||||||
|
|
||||||
recipes_dir = recipe_scanner.recipes_dir
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
raise RecipeNotFoundError("Recipes directory not found")
|
|
||||||
|
|
||||||
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
|
||||||
if not os.path.exists(recipe_json_path):
|
|
||||||
raise RecipeNotFoundError("Recipe not found")
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||||
@@ -166,9 +173,9 @@ class RecipePersistenceService:
|
|||||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
||||||
"""Update persisted metadata for a recipe."""
|
"""Update persisted metadata for a recipe."""
|
||||||
|
|
||||||
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level")):
|
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")):
|
||||||
raise RecipeValidationError(
|
raise RecipeValidationError(
|
||||||
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level)"
|
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite)"
|
||||||
)
|
)
|
||||||
|
|
||||||
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
||||||
@@ -177,6 +184,163 @@ class RecipePersistenceService:
|
|||||||
|
|
||||||
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates})
|
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates})
|
||||||
|
|
||||||
|
def _normalize_target_path(self, recipe_scanner, target_path: str) -> tuple[str, str]:
|
||||||
|
"""Normalize and validate the target path for recipe moves."""
|
||||||
|
|
||||||
|
if not target_path:
|
||||||
|
raise RecipeValidationError("Target path is required")
|
||||||
|
|
||||||
|
recipes_root = recipe_scanner.recipes_dir
|
||||||
|
if not recipes_root:
|
||||||
|
raise RecipeNotFoundError("Recipes directory not found")
|
||||||
|
|
||||||
|
normalized_target = os.path.normpath(target_path)
|
||||||
|
recipes_root = os.path.normpath(recipes_root)
|
||||||
|
if not os.path.isabs(normalized_target):
|
||||||
|
normalized_target = os.path.normpath(os.path.join(recipes_root, normalized_target))
|
||||||
|
|
||||||
|
try:
|
||||||
|
common_root = os.path.commonpath([normalized_target, recipes_root])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RecipeValidationError("Invalid target path") from exc
|
||||||
|
|
||||||
|
if common_root != recipes_root:
|
||||||
|
raise RecipeValidationError("Target path must be inside the recipes directory")
|
||||||
|
|
||||||
|
return normalized_target, recipes_root
|
||||||
|
|
||||||
|
async def _move_recipe_files(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
recipe_scanner,
|
||||||
|
recipe_id: str,
|
||||||
|
normalized_target: str,
|
||||||
|
recipes_root: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Move the recipe's JSON and preview image into the normalized target."""
|
||||||
|
|
||||||
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
|
recipe_data = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||||
|
if not recipe_data:
|
||||||
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
|
current_json_dir = os.path.dirname(recipe_json_path)
|
||||||
|
normalized_image_path = os.path.normpath(recipe_data.get("file_path") or "") if recipe_data.get("file_path") else None
|
||||||
|
|
||||||
|
os.makedirs(normalized_target, exist_ok=True)
|
||||||
|
|
||||||
|
if os.path.normpath(current_json_dir) == normalized_target:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Recipe is already in the target folder",
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"original_file_path": recipe_data.get("file_path"),
|
||||||
|
"new_file_path": recipe_data.get("file_path"),
|
||||||
|
}
|
||||||
|
|
||||||
|
new_json_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(recipe_json_path)))
|
||||||
|
shutil.move(recipe_json_path, new_json_path)
|
||||||
|
|
||||||
|
new_image_path = normalized_image_path
|
||||||
|
if normalized_image_path:
|
||||||
|
target_image_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(normalized_image_path)))
|
||||||
|
if os.path.exists(normalized_image_path) and normalized_image_path != target_image_path:
|
||||||
|
shutil.move(normalized_image_path, target_image_path)
|
||||||
|
new_image_path = target_image_path
|
||||||
|
|
||||||
|
relative_folder = os.path.relpath(normalized_target, recipes_root)
|
||||||
|
if relative_folder in (".", ""):
|
||||||
|
relative_folder = ""
|
||||||
|
updates = {"file_path": new_image_path or recipe_data.get("file_path"), "folder": relative_folder.replace(os.path.sep, "/")}
|
||||||
|
|
||||||
|
updated = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
||||||
|
if not updated:
|
||||||
|
raise RecipeNotFoundError("Recipe not found after move")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"original_file_path": recipe_data.get("file_path"),
|
||||||
|
"new_file_path": updates["file_path"],
|
||||||
|
"json_path": new_json_path,
|
||||||
|
"folder": updates["folder"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> PersistenceResult:
|
||||||
|
"""Move a recipe's assets into a new folder under the recipes root."""
|
||||||
|
|
||||||
|
normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path)
|
||||||
|
result = await self._move_recipe_files(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_id=recipe_id,
|
||||||
|
normalized_target=normalized_target,
|
||||||
|
recipes_root=recipes_root,
|
||||||
|
)
|
||||||
|
return PersistenceResult(result)
|
||||||
|
|
||||||
|
async def move_recipes_bulk(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
recipe_scanner,
|
||||||
|
recipe_ids: Iterable[str],
|
||||||
|
target_path: str,
|
||||||
|
) -> PersistenceResult:
|
||||||
|
"""Move multiple recipes to a new folder."""
|
||||||
|
|
||||||
|
recipe_ids = list(recipe_ids)
|
||||||
|
if not recipe_ids:
|
||||||
|
raise RecipeValidationError("No recipe IDs provided")
|
||||||
|
|
||||||
|
normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path)
|
||||||
|
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
success_count = 0
|
||||||
|
failure_count = 0
|
||||||
|
|
||||||
|
for recipe_id in recipe_ids:
|
||||||
|
try:
|
||||||
|
move_result = await self._move_recipe_files(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_id=str(recipe_id),
|
||||||
|
normalized_target=normalized_target,
|
||||||
|
recipes_root=recipes_root,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"original_file_path": move_result.get("original_file_path"),
|
||||||
|
"new_file_path": move_result.get("new_file_path"),
|
||||||
|
"success": True,
|
||||||
|
"message": move_result.get("message", ""),
|
||||||
|
"folder": move_result.get("folder", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
success_count += 1
|
||||||
|
except Exception as exc: # pragma: no cover - per-item error handling
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"original_file_path": None,
|
||||||
|
"new_file_path": None,
|
||||||
|
"success": False,
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
failure_count += 1
|
||||||
|
|
||||||
|
return PersistenceResult(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"Moved {success_count} of {len(recipe_ids)} recipes",
|
||||||
|
"results": results,
|
||||||
|
"success_count": success_count,
|
||||||
|
"failure_count": failure_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def reconnect_lora(
|
async def reconnect_lora(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -187,8 +351,8 @@ class RecipePersistenceService:
|
|||||||
) -> PersistenceResult:
|
) -> PersistenceResult:
|
||||||
"""Reconnect a LoRA entry within an existing recipe."""
|
"""Reconnect a LoRA entry within an existing recipe."""
|
||||||
|
|
||||||
recipe_path = os.path.join(recipe_scanner.recipes_dir, f"{recipe_id}.recipe.json")
|
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
if not os.path.exists(recipe_path):
|
if not recipe_path or not os.path.exists(recipe_path):
|
||||||
raise RecipeNotFoundError("Recipe not found")
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
target_lora = await recipe_scanner.get_local_lora(target_name)
|
target_lora = await recipe_scanner.get_local_lora(target_name)
|
||||||
@@ -233,16 +397,12 @@ class RecipePersistenceService:
|
|||||||
if not recipe_ids:
|
if not recipe_ids:
|
||||||
raise RecipeValidationError("No recipe IDs provided")
|
raise RecipeValidationError("No recipe IDs provided")
|
||||||
|
|
||||||
recipes_dir = recipe_scanner.recipes_dir
|
|
||||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
|
||||||
raise RecipeNotFoundError("Recipes directory not found")
|
|
||||||
|
|
||||||
deleted_recipes: list[str] = []
|
deleted_recipes: list[str] = []
|
||||||
failed_recipes: list[dict[str, Any]] = []
|
failed_recipes: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for recipe_id in recipe_ids:
|
for recipe_id in recipe_ids:
|
||||||
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
if not os.path.exists(recipe_json_path):
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
|
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"proxy_type": "http",
|
"proxy_type": "http",
|
||||||
"default_lora_root": "",
|
"default_lora_root": "",
|
||||||
"default_checkpoint_root": "",
|
"default_checkpoint_root": "",
|
||||||
|
"default_unet_root": "",
|
||||||
"default_embedding_root": "",
|
"default_embedding_root": "",
|
||||||
"base_model_path_mappings": {},
|
"base_model_path_mappings": {},
|
||||||
"download_path_templates": {},
|
"download_path_templates": {},
|
||||||
@@ -215,6 +216,7 @@ class SettingsManager:
|
|||||||
folder_paths=merged.get("folder_paths", {}),
|
folder_paths=merged.get("folder_paths", {}),
|
||||||
default_lora_root=merged.get("default_lora_root"),
|
default_lora_root=merged.get("default_lora_root"),
|
||||||
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
||||||
|
default_unet_root=merged.get("default_unet_root"),
|
||||||
default_embedding_root=merged.get("default_embedding_root"),
|
default_embedding_root=merged.get("default_embedding_root"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -300,6 +302,7 @@ class SettingsManager:
|
|||||||
folder_paths=normalized_top_level_paths,
|
folder_paths=normalized_top_level_paths,
|
||||||
default_lora_root=self.settings.get("default_lora_root", ""),
|
default_lora_root=self.settings.get("default_lora_root", ""),
|
||||||
default_checkpoint_root=self.settings.get("default_checkpoint_root", ""),
|
default_checkpoint_root=self.settings.get("default_checkpoint_root", ""),
|
||||||
|
default_unet_root=self.settings.get("default_unet_root", ""),
|
||||||
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
||||||
)
|
)
|
||||||
libraries = {library_name: library_payload}
|
libraries = {library_name: library_payload}
|
||||||
@@ -342,6 +345,7 @@ class SettingsManager:
|
|||||||
folder_paths=candidate_folder_paths,
|
folder_paths=candidate_folder_paths,
|
||||||
default_lora_root=data.get("default_lora_root"),
|
default_lora_root=data.get("default_lora_root"),
|
||||||
default_checkpoint_root=data.get("default_checkpoint_root"),
|
default_checkpoint_root=data.get("default_checkpoint_root"),
|
||||||
|
default_unet_root=data.get("default_unet_root"),
|
||||||
default_embedding_root=data.get("default_embedding_root"),
|
default_embedding_root=data.get("default_embedding_root"),
|
||||||
metadata=data.get("metadata"),
|
metadata=data.get("metadata"),
|
||||||
base=data,
|
base=data,
|
||||||
@@ -380,6 +384,7 @@ class SettingsManager:
|
|||||||
self.settings["folder_paths"] = folder_paths
|
self.settings["folder_paths"] = folder_paths
|
||||||
self.settings["default_lora_root"] = active_library.get("default_lora_root", "")
|
self.settings["default_lora_root"] = active_library.get("default_lora_root", "")
|
||||||
self.settings["default_checkpoint_root"] = active_library.get("default_checkpoint_root", "")
|
self.settings["default_checkpoint_root"] = active_library.get("default_checkpoint_root", "")
|
||||||
|
self.settings["default_unet_root"] = active_library.get("default_unet_root", "")
|
||||||
self.settings["default_embedding_root"] = active_library.get("default_embedding_root", "")
|
self.settings["default_embedding_root"] = active_library.get("default_embedding_root", "")
|
||||||
|
|
||||||
if save:
|
if save:
|
||||||
@@ -394,6 +399,7 @@ class SettingsManager:
|
|||||||
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
||||||
default_lora_root: Optional[str] = None,
|
default_lora_root: Optional[str] = None,
|
||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
base: Optional[Mapping[str, Any]] = None,
|
base: Optional[Mapping[str, Any]] = None,
|
||||||
@@ -416,6 +422,11 @@ class SettingsManager:
|
|||||||
else:
|
else:
|
||||||
payload.setdefault("default_checkpoint_root", "")
|
payload.setdefault("default_checkpoint_root", "")
|
||||||
|
|
||||||
|
if default_unet_root is not None:
|
||||||
|
payload["default_unet_root"] = default_unet_root
|
||||||
|
else:
|
||||||
|
payload.setdefault("default_unet_root", "")
|
||||||
|
|
||||||
if default_embedding_root is not None:
|
if default_embedding_root is not None:
|
||||||
payload["default_embedding_root"] = default_embedding_root
|
payload["default_embedding_root"] = default_embedding_root
|
||||||
else:
|
else:
|
||||||
@@ -517,6 +528,7 @@ class SettingsManager:
|
|||||||
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
||||||
default_lora_root: Optional[str] = None,
|
default_lora_root: Optional[str] = None,
|
||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
libraries = self.settings.get("libraries", {})
|
libraries = self.settings.get("libraries", {})
|
||||||
@@ -541,6 +553,10 @@ class SettingsManager:
|
|||||||
library["default_checkpoint_root"] = default_checkpoint_root
|
library["default_checkpoint_root"] = default_checkpoint_root
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
|
if default_unet_root is not None and library.get("default_unet_root") != default_unet_root:
|
||||||
|
library["default_unet_root"] = default_unet_root
|
||||||
|
changed = True
|
||||||
|
|
||||||
if default_embedding_root is not None and library.get("default_embedding_root") != default_embedding_root:
|
if default_embedding_root is not None and library.get("default_embedding_root") != default_embedding_root:
|
||||||
library["default_embedding_root"] = default_embedding_root
|
library["default_embedding_root"] = default_embedding_root
|
||||||
changed = True
|
changed = True
|
||||||
@@ -596,7 +612,11 @@ class SettingsManager:
|
|||||||
logger.info("Migration completed")
|
logger.info("Migration completed")
|
||||||
|
|
||||||
def _auto_set_default_roots(self):
|
def _auto_set_default_roots(self):
|
||||||
"""Auto set default root paths when only one folder is present and the current default is unset or not among the options."""
|
"""Auto set default root paths when the current default is unset or not among the options.
|
||||||
|
|
||||||
|
For single-path cases, always use that path.
|
||||||
|
For multi-path cases, only set if current default is empty or invalid.
|
||||||
|
"""
|
||||||
folder_paths = self.settings.get('folder_paths', {})
|
folder_paths = self.settings.get('folder_paths', {})
|
||||||
updated = False
|
updated = False
|
||||||
# loras
|
# loras
|
||||||
@@ -613,6 +633,14 @@ class SettingsManager:
|
|||||||
if current_checkpoint_root not in checkpoints:
|
if current_checkpoint_root not in checkpoints:
|
||||||
self.settings['default_checkpoint_root'] = checkpoints[0]
|
self.settings['default_checkpoint_root'] = checkpoints[0]
|
||||||
updated = True
|
updated = True
|
||||||
|
# unet (diffusion models) - auto-set if empty or invalid
|
||||||
|
unet_paths = folder_paths.get('unet', [])
|
||||||
|
if isinstance(unet_paths, list) and len(unet_paths) >= 1:
|
||||||
|
current_unet_root = self.settings.get('default_unet_root')
|
||||||
|
# Set to first path if current is empty or not in the valid paths
|
||||||
|
if not current_unet_root or current_unet_root not in unet_paths:
|
||||||
|
self.settings['default_unet_root'] = unet_paths[0]
|
||||||
|
updated = True
|
||||||
# embeddings
|
# embeddings
|
||||||
embeddings = folder_paths.get('embeddings', [])
|
embeddings = folder_paths.get('embeddings', [])
|
||||||
if isinstance(embeddings, list) and len(embeddings) == 1:
|
if isinstance(embeddings, list) and len(embeddings) == 1:
|
||||||
@@ -624,6 +652,7 @@ class SettingsManager:
|
|||||||
self._update_active_library_entry(
|
self._update_active_library_entry(
|
||||||
default_lora_root=self.settings.get('default_lora_root'),
|
default_lora_root=self.settings.get('default_lora_root'),
|
||||||
default_checkpoint_root=self.settings.get('default_checkpoint_root'),
|
default_checkpoint_root=self.settings.get('default_checkpoint_root'),
|
||||||
|
default_unet_root=self.settings.get('default_unet_root'),
|
||||||
default_embedding_root=self.settings.get('default_embedding_root'),
|
default_embedding_root=self.settings.get('default_embedding_root'),
|
||||||
)
|
)
|
||||||
if self._bootstrap_reason == "missing":
|
if self._bootstrap_reason == "missing":
|
||||||
@@ -851,6 +880,8 @@ class SettingsManager:
|
|||||||
self._update_active_library_entry(default_lora_root=str(value))
|
self._update_active_library_entry(default_lora_root=str(value))
|
||||||
elif key == 'default_checkpoint_root':
|
elif key == 'default_checkpoint_root':
|
||||||
self._update_active_library_entry(default_checkpoint_root=str(value))
|
self._update_active_library_entry(default_checkpoint_root=str(value))
|
||||||
|
elif key == 'default_unet_root':
|
||||||
|
self._update_active_library_entry(default_unet_root=str(value))
|
||||||
elif key == 'default_embedding_root':
|
elif key == 'default_embedding_root':
|
||||||
self._update_active_library_entry(default_embedding_root=str(value))
|
self._update_active_library_entry(default_embedding_root=str(value))
|
||||||
elif key == 'model_name_display':
|
elif key == 'model_name_display':
|
||||||
@@ -883,6 +914,7 @@ class SettingsManager:
|
|||||||
|
|
||||||
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._copy_model_cache_directory(previous_dir, target_dir)
|
||||||
|
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}
|
||||||
self.settings_file = target_path
|
self.settings_file = target_path
|
||||||
@@ -929,7 +961,12 @@ class SettingsManager:
|
|||||||
and os.path.abspath(source_cache_dir) != os.path.abspath(target_cache_dir)
|
and os.path.abspath(source_cache_dir) != os.path.abspath(target_cache_dir)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
shutil.copytree(source_cache_dir, target_cache_dir, dirs_exist_ok=True)
|
shutil.copytree(
|
||||||
|
source_cache_dir,
|
||||||
|
target_cache_dir,
|
||||||
|
dirs_exist_ok=True,
|
||||||
|
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to copy model_cache directory from %s to %s: %s",
|
"Failed to copy model_cache directory from %s to %s: %s",
|
||||||
@@ -1125,6 +1162,7 @@ class SettingsManager:
|
|||||||
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
||||||
default_lora_root: Optional[str] = None,
|
default_lora_root: Optional[str] = None,
|
||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
activate: bool = False,
|
activate: bool = False,
|
||||||
@@ -1149,6 +1187,11 @@ class SettingsManager:
|
|||||||
if default_checkpoint_root is not None
|
if default_checkpoint_root is not None
|
||||||
else existing.get("default_checkpoint_root")
|
else existing.get("default_checkpoint_root")
|
||||||
),
|
),
|
||||||
|
default_unet_root=(
|
||||||
|
default_unet_root
|
||||||
|
if default_unet_root is not None
|
||||||
|
else existing.get("default_unet_root")
|
||||||
|
),
|
||||||
default_embedding_root=(
|
default_embedding_root=(
|
||||||
default_embedding_root
|
default_embedding_root
|
||||||
if default_embedding_root is not None
|
if default_embedding_root is not None
|
||||||
@@ -1178,6 +1221,7 @@ class SettingsManager:
|
|||||||
folder_paths: Mapping[str, Iterable[str]],
|
folder_paths: Mapping[str, Iterable[str]],
|
||||||
default_lora_root: str = "",
|
default_lora_root: str = "",
|
||||||
default_checkpoint_root: str = "",
|
default_checkpoint_root: str = "",
|
||||||
|
default_unet_root: str = "",
|
||||||
default_embedding_root: str = "",
|
default_embedding_root: str = "",
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
activate: bool = False,
|
activate: bool = False,
|
||||||
@@ -1193,6 +1237,7 @@ class SettingsManager:
|
|||||||
folder_paths=folder_paths,
|
folder_paths=folder_paths,
|
||||||
default_lora_root=default_lora_root,
|
default_lora_root=default_lora_root,
|
||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
|
default_unet_root=default_unet_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
activate=activate,
|
activate=activate,
|
||||||
@@ -1250,6 +1295,7 @@ class SettingsManager:
|
|||||||
*,
|
*,
|
||||||
default_lora_root: Optional[str] = None,
|
default_lora_root: Optional[str] = None,
|
||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update folder paths for the active library."""
|
"""Update folder paths for the active library."""
|
||||||
@@ -1260,6 +1306,7 @@ class SettingsManager:
|
|||||||
folder_paths=folder_paths,
|
folder_paths=folder_paths,
|
||||||
default_lora_root=default_lora_root,
|
default_lora_root=default_lora_root,
|
||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
|
default_unet_root=default_unet_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
activate=True,
|
activate=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ class BulkMetadataRefreshUseCase:
|
|||||||
success = 0
|
success = 0
|
||||||
needs_resort = False
|
needs_resort = False
|
||||||
|
|
||||||
|
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
|
||||||
@@ -69,6 +71,10 @@ class BulkMetadataRefreshUseCase:
|
|||||||
await emit("started")
|
await emit("started")
|
||||||
|
|
||||||
for model in to_process:
|
for model in to_process:
|
||||||
|
if self._service.scanner.is_cancelled():
|
||||||
|
self._logger.info("Bulk metadata refresh cancelled by user")
|
||||||
|
await emit("cancelled", processed=processed, success=success)
|
||||||
|
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
|
||||||
try:
|
try:
|
||||||
original_name = model.get("model_name")
|
original_name = model.get("model_name")
|
||||||
await MetadataManager.hydrate_model_data(model)
|
await MetadataManager.hydrate_model_data(model)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class WebSocketManager:
|
|||||||
self._last_init_progress: Dict[str, Dict] = {}
|
self._last_init_progress: Dict[str, Dict] = {}
|
||||||
# Add auto-organize progress tracking
|
# Add auto-organize progress tracking
|
||||||
self._auto_organize_progress: Optional[Dict] = None
|
self._auto_organize_progress: Optional[Dict] = None
|
||||||
|
# Add recipe repair progress tracking
|
||||||
|
self._recipe_repair_progress: Optional[Dict] = None
|
||||||
self._auto_organize_lock = asyncio.Lock()
|
self._auto_organize_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
|
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
|
||||||
@@ -189,6 +191,14 @@ class WebSocketManager:
|
|||||||
# Broadcast via WebSocket
|
# Broadcast via WebSocket
|
||||||
await self.broadcast(data)
|
await self.broadcast(data)
|
||||||
|
|
||||||
|
async def broadcast_recipe_repair_progress(self, data: Dict):
|
||||||
|
"""Broadcast recipe repair progress to connected clients"""
|
||||||
|
# Store progress data in memory
|
||||||
|
self._recipe_repair_progress = data
|
||||||
|
|
||||||
|
# Broadcast via WebSocket
|
||||||
|
await self.broadcast(data)
|
||||||
|
|
||||||
def get_auto_organize_progress(self) -> Optional[Dict]:
|
def get_auto_organize_progress(self) -> Optional[Dict]:
|
||||||
"""Get current auto-organize progress"""
|
"""Get current auto-organize progress"""
|
||||||
return self._auto_organize_progress
|
return self._auto_organize_progress
|
||||||
@@ -197,6 +207,22 @@ class WebSocketManager:
|
|||||||
"""Clear auto-organize progress data"""
|
"""Clear auto-organize progress data"""
|
||||||
self._auto_organize_progress = None
|
self._auto_organize_progress = None
|
||||||
|
|
||||||
|
def get_recipe_repair_progress(self) -> Optional[Dict]:
|
||||||
|
"""Get current recipe repair progress"""
|
||||||
|
return self._recipe_repair_progress
|
||||||
|
|
||||||
|
def cleanup_recipe_repair_progress(self):
|
||||||
|
"""Clear recipe repair progress data if it is in a finished state"""
|
||||||
|
if self._recipe_repair_progress and self._recipe_repair_progress.get('status') in ['completed', 'cancelled', 'error']:
|
||||||
|
self._recipe_repair_progress = None
|
||||||
|
|
||||||
|
def is_recipe_repair_running(self) -> bool:
|
||||||
|
"""Check if recipe repair is currently running"""
|
||||||
|
if not self._recipe_repair_progress:
|
||||||
|
return False
|
||||||
|
status = self._recipe_repair_progress.get('status')
|
||||||
|
return status in ['started', 'processing']
|
||||||
|
|
||||||
def is_auto_organize_running(self) -> bool:
|
def is_auto_organize_running(self) -> bool:
|
||||||
"""Check if auto-organize is currently running"""
|
"""Check if auto-organize is currently running"""
|
||||||
if not self._auto_organize_progress:
|
if not self._auto_organize_progress:
|
||||||
|
|||||||
@@ -20,11 +20,25 @@ _COMMERCIAL_SHIFT = 1
|
|||||||
def _normalize_commercial_values(value: Any) -> Sequence[str]:
|
def _normalize_commercial_values(value: Any) -> Sequence[str]:
|
||||||
"""Return a normalized list of commercial permissions preserving source values."""
|
"""Return a normalized list of commercial permissions preserving source values."""
|
||||||
|
|
||||||
|
def _split_aggregate(value_str: str) -> list[str]:
|
||||||
|
stripped = value_str.strip()
|
||||||
|
looks_aggregate = "," in stripped or (stripped.startswith("{") and stripped.endswith("}"))
|
||||||
|
if not looks_aggregate:
|
||||||
|
return [value_str]
|
||||||
|
|
||||||
|
trimmed = stripped
|
||||||
|
if trimmed.startswith("{") and trimmed.endswith("}"):
|
||||||
|
trimmed = trimmed[1:-1]
|
||||||
|
|
||||||
|
parts = [part.strip() for part in trimmed.split(",")]
|
||||||
|
result = [part for part in parts if part]
|
||||||
|
return result or [value_str]
|
||||||
|
|
||||||
if value is None:
|
if value is None:
|
||||||
return list(_DEFAULT_ALLOW_COMMERCIAL_USE)
|
return list(_DEFAULT_ALLOW_COMMERCIAL_USE)
|
||||||
|
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return [value]
|
return _split_aggregate(value)
|
||||||
|
|
||||||
if isinstance(value, Iterable):
|
if isinstance(value, Iterable):
|
||||||
result = []
|
result = []
|
||||||
@@ -32,7 +46,7 @@ def _normalize_commercial_values(value: Any) -> Sequence[str]:
|
|||||||
if item is None:
|
if item is None:
|
||||||
continue
|
continue
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
result.append(item)
|
result.extend(_split_aggregate(item))
|
||||||
continue
|
continue
|
||||||
result.append(str(item))
|
result.append(str(item))
|
||||||
if result:
|
if result:
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ NSFW_LEVELS = {
|
|||||||
"R": 4,
|
"R": 4,
|
||||||
"X": 8,
|
"X": 8,
|
||||||
"XXX": 16,
|
"XXX": 16,
|
||||||
"Blocked": 32, # Probably not actually visible through the API without being logged in on model owner account?
|
"Blocked": 32, # Probably not actually visible through the API without being logged in on model owner account?
|
||||||
}
|
}
|
||||||
|
|
||||||
# Node type constants
|
# Node type constants
|
||||||
NODE_TYPES = {
|
NODE_TYPES = {
|
||||||
"Lora Loader (LoraManager)": 1,
|
"Lora Loader (LoraManager)": 1,
|
||||||
"Lora Stacker (LoraManager)": 2,
|
"Lora Stacker (LoraManager)": 2,
|
||||||
"WanVideo Lora Select (LoraManager)": 3
|
"WanVideo Lora Select (LoraManager)": 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Default ComfyUI node color when bgcolor is null
|
# Default ComfyUI node color when bgcolor is null
|
||||||
@@ -19,18 +19,18 @@ DEFAULT_NODE_COLOR = "#353535"
|
|||||||
|
|
||||||
# preview extensions
|
# preview extensions
|
||||||
PREVIEW_EXTENSIONS = [
|
PREVIEW_EXTENSIONS = [
|
||||||
'.webp',
|
".webp",
|
||||||
'.preview.webp',
|
".preview.webp",
|
||||||
'.preview.png',
|
".preview.png",
|
||||||
'.preview.jpeg',
|
".preview.jpeg",
|
||||||
'.preview.jpg',
|
".preview.jpg",
|
||||||
'.preview.mp4',
|
".preview.mp4",
|
||||||
'.png',
|
".png",
|
||||||
'.jpeg',
|
".jpeg",
|
||||||
'.jpg',
|
".jpg",
|
||||||
'.mp4',
|
".mp4",
|
||||||
'.gif',
|
".gif",
|
||||||
'.webm'
|
".webm",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Card preview image width
|
# Card preview image width
|
||||||
@@ -41,37 +41,70 @@ 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"],
|
||||||
'videos': ['.mp4', '.webm']
|
"videos": [".mp4", ".webm"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Valid Lora types
|
# Valid Lora types
|
||||||
VALID_LORA_TYPES = ['lora', 'locon', 'dora']
|
VALID_LORA_TYPES = ["lora", "locon", "dora"]
|
||||||
|
|
||||||
# Supported Civitai model types for user model queries (case-insensitive)
|
# Supported Civitai model types for user model queries (case-insensitive)
|
||||||
CIVITAI_USER_MODEL_TYPES = [
|
CIVITAI_USER_MODEL_TYPES = [
|
||||||
*VALID_LORA_TYPES,
|
*VALID_LORA_TYPES,
|
||||||
'textualinversion',
|
"textualinversion",
|
||||||
'checkpoint',
|
"checkpoint",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Default chunk size in megabytes used for hashing large files.
|
# Default chunk size in megabytes used for hashing large files.
|
||||||
DEFAULT_HASH_CHUNK_SIZE_MB = 4
|
DEFAULT_HASH_CHUNK_SIZE_MB = 4
|
||||||
|
|
||||||
# Auto-organize settings
|
# Auto-organize settings
|
||||||
AUTO_ORGANIZE_BATCH_SIZE = 50 # Process models in batches to avoid overwhelming the system
|
AUTO_ORGANIZE_BATCH_SIZE = (
|
||||||
|
50 # Process models in batches to avoid overwhelming the system
|
||||||
|
)
|
||||||
|
|
||||||
# Civitai model tags in priority order for subfolder organization
|
# Civitai model tags in priority order for subfolder organization
|
||||||
CIVITAI_MODEL_TAGS = [
|
CIVITAI_MODEL_TAGS = [
|
||||||
'character', 'concept', 'clothing',
|
"character",
|
||||||
'realistic', 'anime', 'toon', 'furry', 'style',
|
"concept",
|
||||||
'poses', 'background', 'tool', 'vehicle', 'buildings',
|
"clothing",
|
||||||
'objects', 'assets', 'animal', 'action'
|
"realistic",
|
||||||
|
"anime",
|
||||||
|
"toon",
|
||||||
|
"furry",
|
||||||
|
"style",
|
||||||
|
"poses",
|
||||||
|
"background",
|
||||||
|
"tool",
|
||||||
|
"vehicle",
|
||||||
|
"buildings",
|
||||||
|
"objects",
|
||||||
|
"assets",
|
||||||
|
"animal",
|
||||||
|
"action",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Default priority tag configuration strings for each model type
|
# Default priority tag configuration strings for each model type
|
||||||
DEFAULT_PRIORITY_TAG_CONFIG = {
|
DEFAULT_PRIORITY_TAG_CONFIG = {
|
||||||
'lora': ', '.join(CIVITAI_MODEL_TAGS),
|
"lora": ", ".join(CIVITAI_MODEL_TAGS),
|
||||||
'checkpoint': ', '.join(CIVITAI_MODEL_TAGS),
|
"checkpoint": ", ".join(CIVITAI_MODEL_TAGS),
|
||||||
'embedding': ', '.join(CIVITAI_MODEL_TAGS),
|
"embedding": ", ".join(CIVITAI_MODEL_TAGS),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# baseModel values from CivitAI that should be treated as diffusion models (unet)
|
||||||
|
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
|
||||||
|
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||||
|
[
|
||||||
|
"ZImageTurbo",
|
||||||
|
"Wan Video 1.3B t2v",
|
||||||
|
"Wan Video 14B t2v",
|
||||||
|
"Wan Video 14B i2v 480p",
|
||||||
|
"Wan Video 14B i2v 720p",
|
||||||
|
"Wan Video 2.2 TI2V-5B",
|
||||||
|
"Wan Video 2.2 I2V-A14B",
|
||||||
|
"Wan Video 2.2 T2V-A14B",
|
||||||
|
"Wan Video 2.5 T2V",
|
||||||
|
"Wan Video 2.5 I2V",
|
||||||
|
"Qwen",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -593,5 +593,114 @@ class ExampleImagesProcessor:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def set_example_image_nsfw_level(request: web.Request) -> web.StreamResponse:
|
||||||
|
"""
|
||||||
|
Update the NSFW level for a single example image (regular or custom).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response({'success': False, 'error': 'Invalid JSON body'}, status=400)
|
||||||
|
|
||||||
|
model_hash = data.get('model_hash')
|
||||||
|
raw_level = data.get('nsfw_level')
|
||||||
|
source = (data.get('source') or 'civitai').lower()
|
||||||
|
index = data.get('index')
|
||||||
|
image_id = data.get('id')
|
||||||
|
|
||||||
|
if model_hash is None or raw_level is None:
|
||||||
|
return web.json_response(
|
||||||
|
{'success': False, 'error': 'Missing required parameters: model_hash and nsfw_level'},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
nsfw_level = int(raw_level)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return web.json_response(
|
||||||
|
{'success': False, 'error': 'nsfw_level must be an integer'}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
if source == 'custom':
|
||||||
|
if not image_id:
|
||||||
|
return web.json_response(
|
||||||
|
{'success': False, 'error': 'Custom images require an id field'}, status=400
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
index = int(index)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return web.json_response(
|
||||||
|
{'success': False, 'error': 'Regular images require a numeric index'}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
|
|
||||||
|
model_data = None
|
||||||
|
scanner = None
|
||||||
|
|
||||||
|
for scan_obj in [lora_scanner, checkpoint_scanner, embedding_scanner]:
|
||||||
|
if scan_obj.has_hash(model_hash):
|
||||||
|
cache = await scan_obj.get_cached_data()
|
||||||
|
for item in cache.raw_data:
|
||||||
|
if item.get('sha256') == model_hash:
|
||||||
|
model_data = item
|
||||||
|
scanner = scan_obj
|
||||||
|
break
|
||||||
|
if model_data:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not model_data:
|
||||||
|
return web.json_response(
|
||||||
|
{'success': False, 'error': f"Model with hash {model_hash} not found in cache"},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
await MetadataManager.hydrate_model_data(model_data)
|
||||||
|
civitai_data = model_data.setdefault('civitai', {})
|
||||||
|
regular_images = civitai_data.get('images') or []
|
||||||
|
custom_images = civitai_data.get('customImages') or []
|
||||||
|
|
||||||
|
target_image = None
|
||||||
|
if source == 'custom':
|
||||||
|
for image in custom_images:
|
||||||
|
if image.get('id') == image_id:
|
||||||
|
target_image = image
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if 0 <= index < len(regular_images):
|
||||||
|
target_image = regular_images[index]
|
||||||
|
|
||||||
|
if target_image is None:
|
||||||
|
return web.json_response(
|
||||||
|
{'success': False, 'error': 'Target image not found'}, status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
target_image['nsfwLevel'] = nsfw_level
|
||||||
|
civitai_data['images'] = regular_images
|
||||||
|
civitai_data['customImages'] = custom_images
|
||||||
|
|
||||||
|
file_path = model_data.get('file_path')
|
||||||
|
if file_path:
|
||||||
|
model_copy = model_data.copy()
|
||||||
|
model_copy.pop('folder', None)
|
||||||
|
await MetadataManager.save_metadata(file_path, model_copy)
|
||||||
|
await scanner.update_single_model_cache(file_path, file_path, model_data)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'regular_images': regular_images,
|
||||||
|
'custom_images': custom_images,
|
||||||
|
'model_file_path': model_data.get('file_path', ''),
|
||||||
|
'nsfw_level': nsfw_level
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to update example image NSFW level: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({'success': False, 'error': str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ 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:
|
||||||
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
|
if ext in ['.mp4', '.webm']:
|
||||||
|
return None
|
||||||
|
|
||||||
# First try to open the image
|
# First try to open the image
|
||||||
with Image.open(image_path) as img:
|
with Image.open(image_path) as img:
|
||||||
# Method 1: Check for parameters in image info
|
# Method 1: Check for parameters in image info
|
||||||
@@ -80,6 +86,12 @@ class ExifUtils:
|
|||||||
str: Path to the updated image
|
str: Path to the updated image
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Skip for video files
|
||||||
|
if image_path:
|
||||||
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
|
if ext in ['.mp4', '.webm']:
|
||||||
|
return image_path
|
||||||
|
|
||||||
# Load the image and check its format
|
# Load the image and check its format
|
||||||
with Image.open(image_path) as img:
|
with Image.open(image_path) as img:
|
||||||
img_format = img.format
|
img_format = img.format
|
||||||
@@ -133,6 +145,12 @@ 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:
|
||||||
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
|
if ext in ['.mp4', '.webm']:
|
||||||
|
return image_path
|
||||||
|
|
||||||
# First, extract existing metadata
|
# First, extract existing metadata
|
||||||
metadata = ExifUtils.extract_image_metadata(image_path)
|
metadata = ExifUtils.extract_image_metadata(image_path)
|
||||||
|
|
||||||
@@ -141,6 +159,28 @@ class ExifUtils:
|
|||||||
# Remove any existing recipe metadata
|
# Remove any existing recipe metadata
|
||||||
metadata = ExifUtils.remove_recipe_metadata(metadata)
|
metadata = ExifUtils.remove_recipe_metadata(metadata)
|
||||||
|
|
||||||
|
# Prepare checkpoint data
|
||||||
|
checkpoint_data = recipe_data.get("checkpoint") or {}
|
||||||
|
simplified_checkpoint = None
|
||||||
|
if isinstance(checkpoint_data, dict) and checkpoint_data:
|
||||||
|
simplified_checkpoint = {
|
||||||
|
"type": checkpoint_data.get("type", "checkpoint"),
|
||||||
|
"modelId": checkpoint_data.get("modelId", 0),
|
||||||
|
"modelVersionId": checkpoint_data.get("modelVersionId")
|
||||||
|
or checkpoint_data.get("id", 0),
|
||||||
|
"modelName": checkpoint_data.get(
|
||||||
|
"modelName", checkpoint_data.get("name", "")
|
||||||
|
),
|
||||||
|
"modelVersionName": checkpoint_data.get(
|
||||||
|
"modelVersionName", checkpoint_data.get("version", "")
|
||||||
|
),
|
||||||
|
"hash": checkpoint_data.get("hash", "").lower()
|
||||||
|
if checkpoint_data.get("hash")
|
||||||
|
else "",
|
||||||
|
"file_name": checkpoint_data.get("file_name", ""),
|
||||||
|
"baseModel": checkpoint_data.get("baseModel", ""),
|
||||||
|
}
|
||||||
|
|
||||||
# Prepare simplified loras data
|
# Prepare simplified loras data
|
||||||
simplified_loras = []
|
simplified_loras = []
|
||||||
for lora in recipe_data.get("loras", []):
|
for lora in recipe_data.get("loras", []):
|
||||||
@@ -160,7 +200,8 @@ class ExifUtils:
|
|||||||
'base_model': recipe_data.get('base_model', ''),
|
'base_model': recipe_data.get('base_model', ''),
|
||||||
'loras': simplified_loras,
|
'loras': simplified_loras,
|
||||||
'gen_params': recipe_data.get('gen_params', {}),
|
'gen_params': recipe_data.get('gen_params', {}),
|
||||||
'tags': recipe_data.get('tags', [])
|
'tags': recipe_data.get('tags', []),
|
||||||
|
**({'checkpoint': simplified_checkpoint} if simplified_checkpoint else {})
|
||||||
}
|
}
|
||||||
|
|
||||||
# Convert to JSON string
|
# Convert to JSON string
|
||||||
@@ -219,6 +260,16 @@ 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):
|
||||||
|
ext = os.path.splitext(image_data)[1].lower()
|
||||||
|
if ext in ['.mp4', '.webm']:
|
||||||
|
try:
|
||||||
|
with open(image_data, 'rb') as f:
|
||||||
|
return f.read(), ext
|
||||||
|
except Exception:
|
||||||
|
return image_data, ext
|
||||||
|
|
||||||
# First validate the image data is usable
|
# First validate the image data is usable
|
||||||
img = None
|
img = None
|
||||||
if isinstance(image_data, str) and os.path.exists(image_data):
|
if isinstance(image_data, str) and os.path.exists(image_data):
|
||||||
|
|||||||
26
py/utils/logging_config.py
Normal file
26
py/utils/logging_config.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
"""
|
||||||
|
Sets up a global log record factory that prepends '[LoRA-Manager]' to all logs
|
||||||
|
generated by this extension.
|
||||||
|
"""
|
||||||
|
# project_root should be the parent of the directory containing this file (py/utils/logging_config.py)
|
||||||
|
# So project_root is ComfyUI-Lora-Manager/
|
||||||
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
prefix = "[LoRA-Manager] "
|
||||||
|
|
||||||
|
old_factory = logging.getLogRecordFactory()
|
||||||
|
def factory(*args, **kwargs):
|
||||||
|
record = old_factory(*args, **kwargs)
|
||||||
|
|
||||||
|
# Check if the log is coming from our extension
|
||||||
|
# We use pathname to verify if it's within our project directory
|
||||||
|
if record.pathname and os.path.abspath(record.pathname).startswith(project_root):
|
||||||
|
if isinstance(record.msg, str) and not record.msg.startswith(prefix):
|
||||||
|
record.msg = f"{prefix}{record.msg}"
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
logging.setLogRecordFactory(factory)
|
||||||
@@ -2,6 +2,7 @@ from datetime import datetime
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import Any, Dict, Optional, Type, Union
|
from typing import Any, Dict, Optional, Type, Union
|
||||||
|
|
||||||
from .models import BaseModelMetadata, LoraMetadata
|
from .models import BaseModelMetadata, LoraMetadata
|
||||||
@@ -203,7 +204,11 @@ class MetadataManager:
|
|||||||
preview_url = find_preview_file(base_name, dir_path)
|
preview_url = find_preview_file(base_name, dir_path)
|
||||||
|
|
||||||
# Calculate file hash
|
# Calculate file hash
|
||||||
|
start_hash_time = time.perf_counter()
|
||||||
|
logger.debug(f"Calculating SHA256 hash for {real_path}...")
|
||||||
sha256 = await calculate_sha256(real_path)
|
sha256 = await calculate_sha256(real_path)
|
||||||
|
hash_duration = time.perf_counter() - start_hash_time
|
||||||
|
logger.info(f"SHA256 hash calculated for {real_path} in {hash_duration:.3f}s")
|
||||||
|
|
||||||
# Create instance based on model type
|
# Create instance based on model type
|
||||||
if model_class.__name__ == "CheckpointMetadata":
|
if model_class.__name__ == "CheckpointMetadata":
|
||||||
@@ -255,6 +260,7 @@ class MetadataManager:
|
|||||||
# await MetadataManager._enrich_metadata(metadata, real_path)
|
# await MetadataManager._enrich_metadata(metadata, real_path)
|
||||||
|
|
||||||
# Save the created metadata
|
# Save the created metadata
|
||||||
|
logger.info(f"Creating new .metadata.json for {file_path} (Reason: No existing metadata found)")
|
||||||
await MetadataManager.save_metadata(file_path, metadata)
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
|
|||||||
241
py/vue_widget_builder.py
Normal file
241
py/vue_widget_builder.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""
|
||||||
|
Vue Widget Build Checker and Auto-builder
|
||||||
|
|
||||||
|
This module checks if Vue widgets are built and attempts to build them if needed.
|
||||||
|
Useful for development mode where source code might be newer than build output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VueWidgetBuilder:
|
||||||
|
"""Manages Vue widget build checking and auto-building."""
|
||||||
|
|
||||||
|
def __init__(self, project_root: Optional[Path] = None):
|
||||||
|
"""
|
||||||
|
Initialize the builder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_root: Project root directory. If None, auto-detects.
|
||||||
|
"""
|
||||||
|
if project_root is None:
|
||||||
|
# Auto-detect project root (where __init__.py is)
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
self.project_root = Path(project_root)
|
||||||
|
self.vue_widgets_dir = self.project_root / "vue-widgets"
|
||||||
|
self.build_output_dir = self.project_root / "web" / "comfyui" / "vue-widgets"
|
||||||
|
self.src_dir = self.vue_widgets_dir / "src"
|
||||||
|
|
||||||
|
def check_build_exists(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if build output exists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if at least one built .js file exists
|
||||||
|
"""
|
||||||
|
if not self.build_output_dir.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
js_files = list(self.build_output_dir.glob("*.js"))
|
||||||
|
return len(js_files) > 0
|
||||||
|
|
||||||
|
def check_build_outdated(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if source code is newer than build output.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if source is newer, False otherwise or if can't determine
|
||||||
|
"""
|
||||||
|
if not self.src_dir.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.check_build_exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get newest file in source directory
|
||||||
|
src_files = [f for f in self.src_dir.rglob("*") if f.is_file()]
|
||||||
|
if not src_files:
|
||||||
|
return False
|
||||||
|
|
||||||
|
newest_src_time = max(f.stat().st_mtime for f in src_files)
|
||||||
|
|
||||||
|
# Get oldest file in build directory
|
||||||
|
build_files = [f for f in self.build_output_dir.rglob("*.js") if f.is_file()]
|
||||||
|
if not build_files:
|
||||||
|
return True
|
||||||
|
|
||||||
|
oldest_build_time = min(f.stat().st_mtime for f in build_files)
|
||||||
|
|
||||||
|
return newest_src_time > oldest_build_time
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error checking build timestamps: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_node_available(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if Node.js is available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if node/npm are available
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["npm", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def build_widgets(self, force: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
Build Vue widgets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force: If True, build even if not needed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if build succeeded or not needed, False if failed
|
||||||
|
"""
|
||||||
|
if not force and self.check_build_exists() and not self.check_build_outdated():
|
||||||
|
logger.debug("Vue widgets build is up to date")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not self.vue_widgets_dir.exists():
|
||||||
|
logger.warning(f"Vue widgets directory not found: {self.vue_widgets_dir}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.check_node_available():
|
||||||
|
logger.warning(
|
||||||
|
"Node.js/npm not found. Cannot build Vue widgets. "
|
||||||
|
"Please install Node.js or build manually: cd vue-widgets && npm run build"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info("Building Vue widgets...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if node_modules exists, if not run npm install first
|
||||||
|
node_modules = self.vue_widgets_dir / "node_modules"
|
||||||
|
if not node_modules.exists():
|
||||||
|
logger.info("Installing npm dependencies...")
|
||||||
|
install_result = subprocess.run(
|
||||||
|
["npm", "install"],
|
||||||
|
cwd=self.vue_widgets_dir,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=300, # 5 minutes for install
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if install_result.returncode != 0:
|
||||||
|
logger.error(f"npm install failed: {install_result.stderr.decode()}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Run build
|
||||||
|
build_result = subprocess.run(
|
||||||
|
["npm", "run", "build"],
|
||||||
|
cwd=self.vue_widgets_dir,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=120, # 2 minutes for build
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if build_result.returncode == 0:
|
||||||
|
logger.info("✓ Vue widgets built successfully")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Build failed: {build_result.stderr.decode()}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Build timed out")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Build error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def ensure_built(self, auto_build: bool = True, warn_only: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
Ensure Vue widgets are built, optionally auto-building if needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auto_build: If True, attempt to build if needed
|
||||||
|
warn_only: If True, only warn on failure instead of raising
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if widgets are available (built or successfully auto-built)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If warn_only=False and build is missing/failed
|
||||||
|
"""
|
||||||
|
if self.check_build_exists():
|
||||||
|
# Build exists, check if outdated
|
||||||
|
if self.check_build_outdated():
|
||||||
|
logger.info("Vue widget source code is newer than build")
|
||||||
|
if auto_build:
|
||||||
|
return self.build_widgets()
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Vue widget build is outdated. "
|
||||||
|
"Please rebuild: cd vue-widgets && npm run build"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# No build exists
|
||||||
|
logger.warning("Vue widget build not found")
|
||||||
|
|
||||||
|
if auto_build:
|
||||||
|
if self.build_widgets():
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
msg = (
|
||||||
|
"Failed to build Vue widgets. "
|
||||||
|
"Please build manually: cd vue-widgets && npm install && npm run build"
|
||||||
|
)
|
||||||
|
if warn_only:
|
||||||
|
logger.warning(msg)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
else:
|
||||||
|
msg = "Vue widgets not built. Please run: cd vue-widgets && npm install && npm run build"
|
||||||
|
if warn_only:
|
||||||
|
logger.warning(msg)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_build_vue_widgets(
|
||||||
|
auto_build: bool = True,
|
||||||
|
warn_only: bool = True,
|
||||||
|
force: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Convenience function to check and build Vue widgets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auto_build: If True, attempt to build if needed
|
||||||
|
warn_only: If True, only warn on failure instead of raising
|
||||||
|
force: If True, force rebuild even if up to date
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if widgets are available
|
||||||
|
"""
|
||||||
|
builder = VueWidgetBuilder()
|
||||||
|
|
||||||
|
if force:
|
||||||
|
return builder.build_widgets(force=True)
|
||||||
|
|
||||||
|
return builder.ensure_built(auto_build=auto_build, warn_only=warn_only)
|
||||||
@@ -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 = "0.9.10"
|
version = "0.9.13"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -1,82 +1,33 @@
|
|||||||
{
|
{
|
||||||
"id": "0448c06d-de1b-46ab-975c-c5aa60d90dbc",
|
"id": "42803a29-02dc-49e1-b798-27da70e8b408",
|
||||||
"file_path": "D:/Workspace/ComfyUI/models/loras/recipes/0448c06d-de1b-46ab-975c-c5aa60d90dbc.jpg",
|
"file_path": "/home/miao/workspace/ComfyUI/models/loras/recipes/test/42803a29-02dc-49e1-b798-27da70e8b408.webp",
|
||||||
"title": "a mysterious, steampunk-inspired character standing in a dramatic pose",
|
"title": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect",
|
||||||
"modified": 1741837612.3931093,
|
"modified": 1754897325.0507245,
|
||||||
"created_date": 1741492786.5581934,
|
"created_date": 1754897325.0507245,
|
||||||
"base_model": "Flux.1 D",
|
"base_model": "Illustrious",
|
||||||
"loras": [
|
"loras": [
|
||||||
{
|
{
|
||||||
"file_name": "ChronoDivinitiesFlux_r1",
|
"file_name": "",
|
||||||
"hash": "ddbc5abd00db46ad464f5e3ca85f8f7121bc14b594d6785f441d9b002fffe66a",
|
"hash": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a",
|
||||||
"strength": 0.8,
|
"strength": 1.0,
|
||||||
"modelVersionId": 1438879,
|
"modelVersionId": 2007092,
|
||||||
"modelName": "Chrono Divinities - By HailoKnight",
|
"modelName": "Pony: People's Works +",
|
||||||
"modelVersionName": "Flux"
|
"modelVersionName": "v8_Illusv1.0",
|
||||||
},
|
"isDeleted": false,
|
||||||
{
|
"exclude": false
|
||||||
"file_name": "flux.1_lora_flyway_ink-dynamic",
|
|
||||||
"hash": "4b4f3b469a0d5d3a04a46886abfa33daa37a905db070ccfbd10b345c6fb00eff",
|
|
||||||
"strength": 0.2,
|
|
||||||
"modelVersionId": 914935,
|
|
||||||
"modelName": "Ink-style",
|
|
||||||
"modelVersionName": "ink-dynamic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "ck-painterly-fantasy-000017",
|
|
||||||
"hash": "48c67064e2936aec342580a2a729d91d75eb818e45ecf993b9650cc66c94c420",
|
|
||||||
"strength": 0.2,
|
|
||||||
"modelVersionId": 1189379,
|
|
||||||
"modelName": "Painterly Fantasy by ChronoKnight - [FLUX & IL]",
|
|
||||||
"modelVersionName": "FLUX"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "RetroAnimeFluxV1",
|
|
||||||
"hash": "8f43c31b6c3238ac44195c970d511d759c5893bddd00f59f42b8fe51e8e76fa0",
|
|
||||||
"strength": 0.8,
|
|
||||||
"modelVersionId": 806265,
|
|
||||||
"modelName": "Retro Anime Flux - Style",
|
|
||||||
"modelVersionName": "v1.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "Mezzotint_Artstyle_for_Flux_-_by_Ethanar",
|
|
||||||
"hash": "e6961502769123bf23a66c5c5298d76264fd6b9610f018319a0ccb091bfc308e",
|
|
||||||
"strength": 0.2,
|
|
||||||
"modelVersionId": 757030,
|
|
||||||
"modelName": "Mezzotint Artstyle for Flux - by Ethanar",
|
|
||||||
"modelVersionName": "V1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "FluxMythG0thicL1nes",
|
|
||||||
"hash": "ecb03595de62bd6183a0dd2b38bea35669fd4d509f4bbae5aa0572cfb7ef4279",
|
|
||||||
"strength": 0.4,
|
|
||||||
"modelVersionId": 1202162,
|
|
||||||
"modelName": "Velvet's Mythic Fantasy Styles | Flux + Pony + illustrious",
|
|
||||||
"modelVersionName": "Flux Gothic Lines"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "Elden_Ring_-_Yoshitaka_Amano",
|
|
||||||
"hash": "c660c4c55320be7206cb6a917c59d8da3953cc07169fe10bda833a54ec0024f9",
|
|
||||||
"strength": 0.75,
|
|
||||||
"modelVersionId": 746484,
|
|
||||||
"modelName": "Elden Ring - Yoshitaka Amano",
|
|
||||||
"modelVersionName": "V1"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"gen_params": {
|
"gen_params": {
|
||||||
"prompt": "a mysterious, steampunk-inspired character standing in a dramatic pose. The character is dressed in a long, intricately detailed dark coat with ornate patterns, a wide-brimmed hat, and leather boots. The face is partially obscured by the hat's shadow, adding to the enigmatic aura. The background showcases a large, antique clock with Roman numerals, surrounded by dynamic lightning and ethereal white birds, enhancing the fantastical atmosphere. The color palette is dominated by dark tones with striking contrasts of white and blue lightning, creating a sense of tension and energy. The overall composition is vertical, with the character centrally positioned, exuding a sense of power and mystery. hkchrono",
|
"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": "",
|
"negative_prompt": "shiny skin,",
|
||||||
"checkpoint": {
|
"steps": 19,
|
||||||
"type": "checkpoint",
|
"sampler": "Euler a",
|
||||||
"modelVersionId": 691639,
|
"cfg_scale": 5,
|
||||||
"modelName": "FLUX",
|
"seed": 1765271748,
|
||||||
"modelVersionName": "Dev"
|
|
||||||
},
|
|
||||||
"steps": "30",
|
|
||||||
"sampler": "Undefined",
|
|
||||||
"cfg_scale": "3.5",
|
|
||||||
"seed": "1472903449",
|
|
||||||
"size": "832x1216",
|
"size": "832x1216",
|
||||||
"clip_skip": "2"
|
"clip_skip": 2
|
||||||
}
|
},
|
||||||
|
"fingerprint": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a:1.0",
|
||||||
|
"source_path": "https://civitai.com/images/92427432",
|
||||||
|
"folder": "test"
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ class TranslationKeySynchronizer:
|
|||||||
self.locales_dir = locales_dir
|
self.locales_dir = locales_dir
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
self.reference_locale = 'en'
|
self.reference_locale = 'en'
|
||||||
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko']
|
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko', 'he']
|
||||||
|
|
||||||
def log(self, message: str, level: str = 'INFO'):
|
def log(self, message: str, level: str = 'INFO'):
|
||||||
"""Log a message if verbose mode is enabled."""
|
"""Log a message if verbose mode is enabled."""
|
||||||
|
|||||||
140
standalone.py
140
standalone.py
@@ -7,26 +7,31 @@ from py.utils.settings_paths import ensure_settings_file
|
|||||||
# Set environment variable to indicate standalone mode
|
# Set environment variable to indicate standalone mode
|
||||||
os.environ["LORA_MANAGER_STANDALONE"] = "1"
|
os.environ["LORA_MANAGER_STANDALONE"] = "1"
|
||||||
|
|
||||||
|
|
||||||
# Create mock modules for py/nodes directory - add this before any other imports
|
# Create mock modules for py/nodes directory - add this before any other imports
|
||||||
def mock_nodes_directory():
|
def mock_nodes_directory():
|
||||||
"""Create mock modules for all Python files in the py/nodes directory"""
|
"""Create mock modules for all Python files in the py/nodes directory"""
|
||||||
nodes_dir = os.path.join(os.path.dirname(__file__), 'py', 'nodes')
|
nodes_dir = os.path.join(os.path.dirname(__file__), "py", "nodes")
|
||||||
if os.path.exists(nodes_dir):
|
if os.path.exists(nodes_dir):
|
||||||
# Create a mock module for the nodes package itself
|
# Create a mock module for the nodes package itself
|
||||||
sys.modules['py.nodes'] = type('MockNodesModule', (), {})
|
sys.modules["py.nodes"] = type("MockNodesModule", (), {})
|
||||||
|
|
||||||
# Create mock modules for all Python files in the nodes directory
|
# Create mock modules for all Python files in the nodes directory
|
||||||
for file in os.listdir(nodes_dir):
|
for file in os.listdir(nodes_dir):
|
||||||
if file.endswith('.py') and file != '__init__.py':
|
if file.endswith(".py") and file != "__init__.py":
|
||||||
module_name = file[:-3] # Remove .py extension
|
module_name = file[:-3] # Remove .py extension
|
||||||
full_module_name = f'py.nodes.{module_name}'
|
full_module_name = f"py.nodes.{module_name}"
|
||||||
# Create empty module object
|
# Create empty module object
|
||||||
sys.modules[full_module_name] = type(f'Mock{module_name.capitalize()}Module', (), {})
|
sys.modules[full_module_name] = type(
|
||||||
|
f"Mock{module_name.capitalize()}Module", (), {}
|
||||||
|
)
|
||||||
print(f"Created mock module for: {full_module_name}")
|
print(f"Created mock module for: {full_module_name}")
|
||||||
|
|
||||||
|
|
||||||
# Run the mocking function before any other imports
|
# Run the mocking function before any other imports
|
||||||
mock_nodes_directory()
|
mock_nodes_directory()
|
||||||
|
|
||||||
|
|
||||||
# Create mock folder_paths module BEFORE any other imports
|
# Create mock folder_paths module BEFORE any other imports
|
||||||
class MockFolderPaths:
|
class MockFolderPaths:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -35,17 +40,17 @@ class MockFolderPaths:
|
|||||||
settings_path = ensure_settings_file()
|
settings_path = ensure_settings_file()
|
||||||
try:
|
try:
|
||||||
if os.path.exists(settings_path):
|
if os.path.exists(settings_path):
|
||||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
with open(settings_path, "r", encoding="utf-8") as f:
|
||||||
settings = json.load(f)
|
settings = json.load(f)
|
||||||
|
|
||||||
# For diffusion_models, combine unet and diffusers paths
|
# For diffusion_models, combine unet and diffusers paths
|
||||||
if folder_name == "diffusion_models":
|
if folder_name == "diffusion_models":
|
||||||
paths = []
|
paths = []
|
||||||
if 'folder_paths' in settings:
|
if "folder_paths" in settings:
|
||||||
if 'unet' in settings['folder_paths']:
|
if "unet" in settings["folder_paths"]:
|
||||||
paths.extend(settings['folder_paths']['unet'])
|
paths.extend(settings["folder_paths"]["unet"])
|
||||||
if 'diffusers' in settings['folder_paths']:
|
if "diffusers" in settings["folder_paths"]:
|
||||||
paths.extend(settings['folder_paths']['diffusers'])
|
paths.extend(settings["folder_paths"]["diffusers"])
|
||||||
# Filter out paths that don't exist
|
# Filter out paths that don't exist
|
||||||
valid_paths = [p for p in paths if os.path.exists(p)]
|
valid_paths = [p for p in paths if os.path.exists(p)]
|
||||||
if valid_paths:
|
if valid_paths:
|
||||||
@@ -53,8 +58,11 @@ class MockFolderPaths:
|
|||||||
else:
|
else:
|
||||||
print(f"Warning: No valid paths found for {folder_name}")
|
print(f"Warning: No valid paths found for {folder_name}")
|
||||||
# For other folder names, return their paths directly
|
# For other folder names, return their paths directly
|
||||||
elif 'folder_paths' in settings and folder_name in settings['folder_paths']:
|
elif (
|
||||||
paths = settings['folder_paths'][folder_name]
|
"folder_paths" in settings
|
||||||
|
and folder_name in settings["folder_paths"]
|
||||||
|
):
|
||||||
|
paths = settings["folder_paths"][folder_name]
|
||||||
valid_paths = [p for p in paths if os.path.exists(p)]
|
valid_paths = [p for p in paths if os.path.exists(p)]
|
||||||
if valid_paths:
|
if valid_paths:
|
||||||
return valid_paths
|
return valid_paths
|
||||||
@@ -68,13 +76,14 @@ class MockFolderPaths:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_temp_directory():
|
def get_temp_directory():
|
||||||
return os.path.join(os.path.dirname(__file__), 'temp')
|
return os.path.join(os.path.dirname(__file__), "temp")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set_temp_directory(path):
|
def set_temp_directory(path):
|
||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
# Create mock server module with PromptServer
|
# Create mock server module with PromptServer
|
||||||
class MockPromptServer:
|
class MockPromptServer:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -83,6 +92,7 @@ class MockPromptServer:
|
|||||||
def send_sync(self, *args, **kwargs):
|
def send_sync(self, *args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Create mock metadata_collector module
|
# Create mock metadata_collector module
|
||||||
class MockMetadataCollector:
|
class MockMetadataCollector:
|
||||||
def init(self):
|
def init(self):
|
||||||
@@ -91,10 +101,11 @@ class MockMetadataCollector:
|
|||||||
def get_metadata(self, prompt_id=None):
|
def get_metadata(self, prompt_id=None):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
# Initialize basic mocks before any imports
|
# Initialize basic mocks before any imports
|
||||||
sys.modules['folder_paths'] = MockFolderPaths()
|
sys.modules["folder_paths"] = MockFolderPaths()
|
||||||
sys.modules['server'] = type('server', (), {'PromptServer': MockPromptServer()})
|
sys.modules["server"] = type("server", (), {"PromptServer": MockPromptServer()})
|
||||||
sys.modules['py.metadata_collector'] = MockMetadataCollector()
|
sys.modules["py.metadata_collector"] = MockMetadataCollector()
|
||||||
|
|
||||||
# Now we can safely import modules that depend on folder_paths and server
|
# Now we can safely import modules that depend on folder_paths and server
|
||||||
import argparse
|
import argparse
|
||||||
@@ -106,12 +117,14 @@ from aiohttp import web
|
|||||||
HEADER_SIZE_LIMIT = 16384
|
HEADER_SIZE_LIMIT = 16384
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
logging.basicConfig(level=logging.INFO,
|
logging.basicConfig(
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
logger = logging.getLogger("lora-manager-standalone")
|
logger = logging.getLogger("lora-manager-standalone")
|
||||||
|
|
||||||
# Configure aiohttp access logger to be less verbose
|
# Configure aiohttp access logger to be less verbose
|
||||||
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
|
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
# Add specific suppression for connection reset errors
|
# Add specific suppression for connection reset errors
|
||||||
class ConnectionResetFilter(logging.Filter):
|
class ConnectionResetFilter(logging.Filter):
|
||||||
@@ -125,6 +138,7 @@ class ConnectionResetFilter(logging.Filter):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# Apply the filter to asyncio logger
|
# Apply the filter to asyncio logger
|
||||||
asyncio_logger = logging.getLogger("asyncio")
|
asyncio_logger = logging.getLogger("asyncio")
|
||||||
asyncio_logger.addFilter(ConnectionResetFilter())
|
asyncio_logger.addFilter(ConnectionResetFilter())
|
||||||
@@ -132,6 +146,7 @@ asyncio_logger.addFilter(ConnectionResetFilter())
|
|||||||
# Now we can import the global config from our local modules
|
# Now we can import the global config from our local modules
|
||||||
from py.config import config
|
from py.config import config
|
||||||
|
|
||||||
|
|
||||||
class StandaloneServer:
|
class StandaloneServer:
|
||||||
"""Server implementation for standalone mode"""
|
"""Server implementation for standalone mode"""
|
||||||
|
|
||||||
@@ -166,23 +181,27 @@ class StandaloneServer:
|
|||||||
def setup_routes(self):
|
def setup_routes(self):
|
||||||
"""Set up basic routes"""
|
"""Set up basic routes"""
|
||||||
# Add a simple status endpoint
|
# Add a simple status endpoint
|
||||||
self.app.router.add_get('/', self.handle_status)
|
self.app.router.add_get("/", self.handle_status)
|
||||||
|
|
||||||
# Add static route for example images if the path exists in settings
|
# Add static route for example images if the path exists in settings
|
||||||
settings_path = ensure_settings_file(logger)
|
settings_path = ensure_settings_file(logger)
|
||||||
if os.path.exists(settings_path):
|
if os.path.exists(settings_path):
|
||||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
with open(settings_path, "r", encoding="utf-8") as f:
|
||||||
settings = json.load(f)
|
settings = json.load(f)
|
||||||
example_images_path = settings.get('example_images_path')
|
example_images_path = settings.get("example_images_path")
|
||||||
logger.info(f"Example images path: {example_images_path}")
|
logger.info(f"Example images path: {example_images_path}")
|
||||||
if example_images_path and os.path.exists(example_images_path):
|
if example_images_path and os.path.exists(example_images_path):
|
||||||
self.app.router.add_static('/example_images_static', example_images_path)
|
self.app.router.add_static(
|
||||||
logger.info(f"Added static route for example images: /example_images_static -> {example_images_path}")
|
"/example_images_static", example_images_path
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Added static route for example images: /example_images_static -> {example_images_path}"
|
||||||
|
)
|
||||||
|
|
||||||
async def handle_status(self, request):
|
async def handle_status(self, request):
|
||||||
"""Handle status request by redirecting to loras page"""
|
"""Handle status request by redirecting to loras page"""
|
||||||
# Redirect to loras page instead of showing status
|
# Redirect to loras page instead of showing status
|
||||||
raise web.HTTPFound('/loras')
|
raise web.HTTPFound("/loras")
|
||||||
|
|
||||||
# Original JSON response (commented out)
|
# Original JSON response (commented out)
|
||||||
# return web.json_response({
|
# return web.json_response({
|
||||||
@@ -205,7 +224,7 @@ class StandaloneServer:
|
|||||||
# In standalone mode, we don't have the same websocket system
|
# In standalone mode, we don't have the same websocket system
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def start(self, host='127.0.0.1', port=8188):
|
async def start(self, host="127.0.0.1", port=8188):
|
||||||
"""Start the server"""
|
"""Start the server"""
|
||||||
runner = web.AppRunner(self.app)
|
runner = web.AppRunner(self.app)
|
||||||
await runner.setup()
|
await runner.setup()
|
||||||
@@ -224,9 +243,11 @@ class StandaloneServer:
|
|||||||
# This method exists in ComfyUI's server but we don't need it
|
# This method exists in ComfyUI's server but we don't need it
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# After all mocks are in place, import LoraManager
|
# After all mocks are in place, import LoraManager
|
||||||
from py.lora_manager import LoraManager
|
from py.lora_manager import LoraManager
|
||||||
|
|
||||||
|
|
||||||
def validate_settings():
|
def validate_settings():
|
||||||
"""Initialize settings and log any startup warnings."""
|
"""Initialize settings and log any startup warnings."""
|
||||||
try:
|
try:
|
||||||
@@ -267,6 +288,7 @@ def validate_settings():
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class StandaloneLoraManager(LoraManager):
|
class StandaloneLoraManager(LoraManager):
|
||||||
"""Extended LoraManager for standalone mode"""
|
"""Extended LoraManager for standalone mode"""
|
||||||
|
|
||||||
@@ -276,19 +298,23 @@ class StandaloneLoraManager(LoraManager):
|
|||||||
app = server_instance.app
|
app = server_instance.app
|
||||||
|
|
||||||
# Store app in a global-like location for compatibility
|
# Store app in a global-like location for compatibility
|
||||||
sys.modules['server'].PromptServer.instance = server_instance
|
sys.modules["server"].PromptServer.instance = server_instance
|
||||||
|
|
||||||
|
|
||||||
# Add static route for locales JSON files
|
# Add static route for locales JSON files
|
||||||
if os.path.exists(config.i18n_path):
|
if os.path.exists(config.i18n_path):
|
||||||
app.router.add_static('/locales', config.i18n_path)
|
app.router.add_static("/locales", config.i18n_path)
|
||||||
logger.info(f"Added static route for locales: /locales -> {config.i18n_path}")
|
logger.info(
|
||||||
|
f"Added static route for locales: /locales -> {config.i18n_path}"
|
||||||
|
)
|
||||||
|
|
||||||
# Add static route for plugin assets
|
# Add static route for plugin assets
|
||||||
app.router.add_static('/loras_static', config.static_path)
|
app.router.add_static("/loras_static", config.static_path)
|
||||||
|
|
||||||
# Setup feature routes
|
# Setup feature routes
|
||||||
from py.services.model_service_factory import ModelServiceFactory, register_default_model_types
|
from py.services.model_service_factory import (
|
||||||
|
ModelServiceFactory,
|
||||||
|
register_default_model_types,
|
||||||
|
)
|
||||||
from py.routes.recipe_routes import RecipeRoutes
|
from py.routes.recipe_routes import RecipeRoutes
|
||||||
from py.routes.update_routes import UpdateRoutes
|
from py.routes.update_routes import UpdateRoutes
|
||||||
from py.routes.misc_routes import MiscRoutes
|
from py.routes.misc_routes import MiscRoutes
|
||||||
@@ -297,7 +323,6 @@ class StandaloneLoraManager(LoraManager):
|
|||||||
from py.routes.stats_routes import StatsRoutes
|
from py.routes.stats_routes import StatsRoutes
|
||||||
from py.services.websocket_manager import ws_manager
|
from py.services.websocket_manager import ws_manager
|
||||||
|
|
||||||
|
|
||||||
register_default_model_types()
|
register_default_model_types()
|
||||||
|
|
||||||
# Setup all model routes using the factory
|
# Setup all model routes using the factory
|
||||||
@@ -314,9 +339,11 @@ class StandaloneLoraManager(LoraManager):
|
|||||||
PreviewRoutes.setup_routes(app)
|
PreviewRoutes.setup_routes(app)
|
||||||
|
|
||||||
# Setup WebSocket routes that are shared across all model types
|
# Setup WebSocket routes that are shared across all model types
|
||||||
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)
|
app.router.add_get("/ws/fetch-progress", ws_manager.handle_connection)
|
||||||
app.router.add_get('/ws/download-progress', ws_manager.handle_download_connection)
|
app.router.add_get(
|
||||||
app.router.add_get('/ws/init-progress', ws_manager.handle_init_connection)
|
"/ws/download-progress", ws_manager.handle_download_connection
|
||||||
|
)
|
||||||
|
app.router.add_get("/ws/init-progress", ws_manager.handle_init_connection)
|
||||||
|
|
||||||
# Schedule service initialization
|
# Schedule service initialization
|
||||||
app.on_startup.append(lambda app: cls._initialize_services())
|
app.on_startup.append(lambda app: cls._initialize_services())
|
||||||
@@ -324,28 +351,48 @@ class StandaloneLoraManager(LoraManager):
|
|||||||
# Add cleanup
|
# Add cleanup
|
||||||
app.on_shutdown.append(cls._cleanup)
|
app.on_shutdown.append(cls._cleanup)
|
||||||
|
|
||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
"""Parse command line arguments"""
|
"""Parse command line arguments"""
|
||||||
parser = argparse.ArgumentParser(description="LoRA Manager Standalone Server")
|
parser = argparse.ArgumentParser(description="LoRA Manager Standalone Server")
|
||||||
parser.add_argument("--host", type=str, default="0.0.0.0",
|
parser.add_argument(
|
||||||
help="Host address to bind the server to (default: 0.0.0.0)")
|
"--host",
|
||||||
parser.add_argument("--port", type=int, default=8188,
|
type=str,
|
||||||
help="Port to bind the server to (default: 8188, access via http://localhost:8188/loras)")
|
default="0.0.0.0",
|
||||||
|
help="Host address to bind the server to (default: 0.0.0.0)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=8188,
|
||||||
|
help="Port to bind the server to (default: 8188, access via http://localhost:8188/loras)",
|
||||||
|
)
|
||||||
# parser.add_argument("--loras", type=str, nargs="+",
|
# parser.add_argument("--loras", type=str, nargs="+",
|
||||||
# help="Additional paths to LoRA model directories (optional if settings.json has paths)")
|
# help="Additional paths to LoRA model directories (optional if settings.json has paths)")
|
||||||
# parser.add_argument("--checkpoints", type=str, nargs="+",
|
# parser.add_argument("--checkpoints", type=str, nargs="+",
|
||||||
# help="Additional paths to checkpoint model directories (optional if settings.json has paths)")
|
# help="Additional paths to checkpoint model directories (optional if settings.json has paths)")
|
||||||
parser.add_argument("--log-level", type=str, default="INFO",
|
parser.add_argument(
|
||||||
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
"--log-level",
|
||||||
help="Logging level")
|
type=str,
|
||||||
|
default="INFO",
|
||||||
|
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||||
|
help="Logging level",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable verbose logging (equivalent to --log-level DEBUG)",
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
"""Main entry point for standalone mode"""
|
"""Main entry point for standalone mode"""
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
# Set log level
|
# Set log level (verbose flag overrides to DEBUG)
|
||||||
logging.getLogger().setLevel(getattr(logging, args.log_level))
|
log_level = "DEBUG" if args.verbose else args.log_level
|
||||||
|
logging.getLogger().setLevel(getattr(logging, log_level))
|
||||||
|
|
||||||
# Validate settings before proceeding
|
# Validate settings before proceeding
|
||||||
if not validate_settings():
|
if not validate_settings():
|
||||||
@@ -363,6 +410,7 @@ async def main():
|
|||||||
await server.setup()
|
await server.setup()
|
||||||
await server.start(host=args.host, port=args.port)
|
await server.start(host=args.host, port=args.port)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
# Run the main function
|
# Run the main function
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
html, body {
|
html,
|
||||||
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden; /* Disable default scrolling */
|
overflow: hidden;
|
||||||
|
/* Disable default scrolling */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 针对Firefox */
|
/* 针对Firefox */
|
||||||
@@ -75,7 +77,8 @@ html, body {
|
|||||||
--border-radius-sm: 8px;
|
--border-radius-sm: 8px;
|
||||||
--border-radius-xs: 4px;
|
--border-radius-xs: 4px;
|
||||||
|
|
||||||
--scrollbar-width: 8px; /* 添加滚动条宽度变量 */
|
--scrollbar-width: 8px;
|
||||||
|
/* 添加滚动条宽度变量 */
|
||||||
|
|
||||||
/* Shortcut styles */
|
/* Shortcut styles */
|
||||||
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12);
|
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12);
|
||||||
@@ -104,7 +107,8 @@ html[data-theme="light"] {
|
|||||||
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
||||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||||
--lora-text: oklch(98% 0.02 256);
|
--lora-text: oklch(98% 0.02 256);
|
||||||
--lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */
|
--lora-warning: oklch(75% 0.25 80);
|
||||||
|
/* Modified to be used with oklch() */
|
||||||
--lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent);
|
--lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent);
|
||||||
--lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
|
--lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
|
||||||
--badge-update-bg: oklch(62% 0.18 220);
|
--badge-update-bg: oklch(62% 0.18 220);
|
||||||
@@ -118,5 +122,10 @@ body {
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-top: 0; /* Remove the padding-top */
|
padding-top: 0;
|
||||||
|
/* Remove the padding-top */
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,11 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: var(--z-header);
|
z-index: var(--z-header);
|
||||||
height: 48px; /* Reduced height */
|
height: 48px;
|
||||||
|
/* Reduced height */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1); /* Slightly stronger shadow */
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
/* Slightly stronger shadow */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-container {
|
.header-container {
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
max-width: 1800px;
|
max-width: 1800px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 3000px) {
|
@media (min-width: 3000px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
max-width: 2400px;
|
max-width: 2400px;
|
||||||
@@ -43,7 +46,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
gap: 2px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-logo {
|
.app-logo {
|
||||||
@@ -153,7 +156,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls > div {
|
.header-controls>div {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -168,14 +171,15 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls > div:hover {
|
.header-controls>div:hover {
|
||||||
background: var(--lora-accent);
|
background: var(--lora-accent);
|
||||||
color: white;
|
color: white;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
position: relative; /* Ensure relative positioning for the container */
|
position: relative;
|
||||||
|
/* Ensure relative positioning for the container */
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle .light-icon,
|
.theme-toggle .light-icon,
|
||||||
@@ -184,7 +188,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%); /* Center perfectly */
|
transform: translate(-50%, -50%);
|
||||||
|
/* Center perfectly */
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
@@ -246,14 +251,15 @@
|
|||||||
/* Mobile adjustments */
|
/* Mobile adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-title {
|
.app-title {
|
||||||
display: none; /* Hide text title on mobile */
|
display: none;
|
||||||
|
/* Hide text title on mobile */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls > div {
|
.header-controls>div {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
@@ -275,7 +281,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main-nav {
|
.main-nav {
|
||||||
display: none; /* Hide navigation on very small screens */
|
display: none;
|
||||||
|
/* Hide navigation on very small screens */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search {
|
.header-search {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/* Import Modal Styles */
|
/* Import Modal Styles */
|
||||||
.import-step {
|
.import-step {
|
||||||
margin: var(--space-2) 0;
|
margin: var(--space-2) 0;
|
||||||
transition: none !important; /* Disable any transitions that might affect display */
|
transition: none !important;
|
||||||
|
/* Disable any transitions that might affect display */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Import Mode Toggle */
|
/* Import Mode Toggle */
|
||||||
@@ -107,7 +108,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-image img {
|
.recipe-image img,
|
||||||
|
.recipe-preview-video {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
@@ -512,14 +514,17 @@
|
|||||||
|
|
||||||
/* Prevent layout shift with scrollbar */
|
/* Prevent layout shift with scrollbar */
|
||||||
.modal-content {
|
.modal-content {
|
||||||
overflow-y: scroll; /* Always show scrollbar */
|
overflow-y: scroll;
|
||||||
scrollbar-gutter: stable; /* Reserve space for scrollbar */
|
/* Always show scrollbar */
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
/* Reserve space for scrollbar */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For browsers that don't support scrollbar-gutter */
|
/* For browsers that don't support scrollbar-gutter */
|
||||||
@supports not (scrollbar-gutter: stable) {
|
@supports not (scrollbar-gutter: stable) {
|
||||||
.modal-content {
|
.modal-content {
|
||||||
padding-right: calc(var(--space-2) + var(--scrollbar-width)); /* Add extra padding for scrollbar */
|
padding-right: calc(var(--space-2) + var(--scrollbar-width));
|
||||||
|
/* Add extra padding for scrollbar */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,7 +591,8 @@
|
|||||||
|
|
||||||
/* Remove the old warning-message styles that were causing layout issues */
|
/* Remove the old warning-message styles that were causing layout issues */
|
||||||
.warning-message {
|
.warning-message {
|
||||||
display: none; /* Hide the old style */
|
display: none;
|
||||||
|
/* Hide the old style */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update deleted badge to be more prominent */
|
/* Update deleted badge to be more prominent */
|
||||||
@@ -613,7 +619,8 @@
|
|||||||
color: var(--lora-error);
|
color: var(--lora-error);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
min-height: 20px; /* Ensure there's always space for the error message */
|
min-height: 20px;
|
||||||
|
/* Ensure there's always space for the error message */
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,8 +669,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(-10px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicate-warning {
|
.duplicate-warning {
|
||||||
@@ -779,6 +793,7 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 9999; /* Ensure tooltip appears above cards */
|
z-index: 9999; /* Ensure tooltip appears above cards */
|
||||||
left: 120%; /* Position tooltip to the right of the icon */
|
right: 120%; /* Position tooltip to the left of the icon */
|
||||||
top: 50%; /* Vertically center */
|
top: 50%; /* Vertically center */
|
||||||
transform: translateY(-15%); /* Vertically center */
|
transform: translateY(-15%); /* Vertically center */
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -56,11 +56,11 @@
|
|||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%; /* Vertically center arrow */
|
top: 50%; /* Vertically center arrow */
|
||||||
right: 100%; /* Arrow on the left side */
|
left: 100%; /* Arrow on the right side */
|
||||||
margin-top: -5px;
|
margin-top: -5px;
|
||||||
border-width: 5px;
|
border-width: 5px;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: transparent var(--lora-border) transparent transparent; /* Arrow points left */
|
border-color: transparent transparent transparent var(--lora-border); /* Arrow points right */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip:hover .tooltiptext {
|
.tooltip:hover .tooltiptext {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
padding-bottom: var(--space-2);
|
padding-bottom: var(--space-2);
|
||||||
border-bottom: 1px solid var(--lora-border);
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header-actions {
|
.modal-header-actions {
|
||||||
@@ -18,6 +19,55 @@
|
|||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-header-row {
|
||||||
|
width: 84%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
position: relative;
|
||||||
|
padding-right: 96px; /* Reserve space for nav buttons to prevent wrapping overlap */
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-controls {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-btn {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover, var(--card-bg));
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-btn i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-header-actions .license-restrictions {
|
.modal-header-actions .license-restrictions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,12 @@
|
|||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-control-btn.set-nsfw-btn:hover {
|
||||||
|
background: var(--warning-color, #f0ad4e);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--warning-color, #f0ad4e);
|
||||||
|
}
|
||||||
|
|
||||||
.media-control-btn.example-delete-btn:hover:not(.disabled) {
|
.media-control-btn.example-delete-btn:hover:not(.disabled) {
|
||||||
background: var(--lora-error);
|
background: var(--lora-error);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ body.modal-open {
|
|||||||
0 10px 15px -3px rgba(0, 0, 0, 0.05);
|
0 10px 15px -3px rgba(0, 0, 0, 0.05);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden; /* 防止水平滚动条 */
|
overflow-x: hidden; /* 防止水平滚动条 */
|
||||||
|
scrollbar-gutter: stable both-edges; /* Reserve space to prevent layout shift when scrollbar toggles */
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content-large {
|
.modal-content-large {
|
||||||
@@ -121,6 +122,7 @@ body.modal-open {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close:hover {
|
.close:hover {
|
||||||
|
|||||||
@@ -328,11 +328,11 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-node.has-children > .tree-node-content .tree-expand-icon {
|
.tree-node.has-children>.tree-node-content .tree-expand-icon {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-node:not(.has-children) > .tree-node-content .tree-expand-icon {
|
.tree-node:not(.has-children)>.tree-node-content .tree-expand-icon {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -470,11 +470,11 @@
|
|||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-toggle-container .toggle-switch input:checked + .toggle-slider {
|
.inline-toggle-container .toggle-switch input:checked+.toggle-slider {
|
||||||
background-color: var(--lora-accent);
|
background-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-toggle-container .toggle-switch input:checked + .toggle-slider:before {
|
.inline-toggle-container .toggle-switch input:checked+.toggle-slider:before {
|
||||||
transform: translateX(18px);
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-modal {
|
.settings-modal {
|
||||||
max-width: 650px; /* Further increased from 600px for more space */
|
max-width: 700px; /* Further increased from 600px for more space */
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header {
|
.settings-header {
|
||||||
|
|||||||
@@ -242,6 +242,20 @@
|
|||||||
border-color: var(--lora-error-border);
|
border-color: var(--lora-error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtle styling for special system tags like "No tags" */
|
||||||
|
.filter-tag.special-tag {
|
||||||
|
border-style: dashed;
|
||||||
|
opacity: 0.8;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure solid border and full opacity when active or excluded */
|
||||||
|
.filter-tag.special-tag.active,
|
||||||
|
.filter-tag.special-tag.exclude {
|
||||||
|
border-style: solid;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tag filter styles */
|
/* Tag filter styles */
|
||||||
.tag-filter {
|
.tag-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export function getApiEndpoints(modelType) {
|
|||||||
exclude: `/api/lm/${modelType}/exclude`,
|
exclude: `/api/lm/${modelType}/exclude`,
|
||||||
rename: `/api/lm/${modelType}/rename`,
|
rename: `/api/lm/${modelType}/rename`,
|
||||||
save: `/api/lm/${modelType}/save-metadata`,
|
save: `/api/lm/${modelType}/save-metadata`,
|
||||||
|
cancelTask: `/api/lm/${modelType}/cancel-task`,
|
||||||
|
|
||||||
// Bulk operations
|
// Bulk operations
|
||||||
bulkDelete: `/api/lm/${modelType}/bulk-delete`,
|
bulkDelete: `/api/lm/${modelType}/bulk-delete`,
|
||||||
|
|||||||
@@ -82,6 +82,19 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelTask() {
|
||||||
|
try {
|
||||||
|
const endpoint = this.apiConfig.endpoints.cancelTask;
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error cancelling task for ${this.modelType}:`, error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
|
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
|
||||||
const pageState = this.getPageState();
|
const pageState = this.getPageState();
|
||||||
|
|
||||||
@@ -308,6 +321,7 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
async addTags(filePath, data) {
|
async addTags(filePath, data) {
|
||||||
try {
|
try {
|
||||||
|
state.loadingManager.showSimpleLoading('Adding tags...');
|
||||||
const response = await fetch(this.apiConfig.endpoints.addTags, {
|
const response = await fetch(this.apiConfig.endpoints.addTags, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -331,14 +345,18 @@ export class BaseModelApiClient {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding tags:', error);
|
console.error('Error adding tags:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
state.loadingManager.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(
|
state.loadingManager.show(
|
||||||
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`
|
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
|
||||||
|
0
|
||||||
);
|
);
|
||||||
|
state.loadingManager.showCancelButton(() => this.cancelTask());
|
||||||
|
|
||||||
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);
|
||||||
@@ -349,6 +367,12 @@ export class BaseModelApiClient {
|
|||||||
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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
resetAndReload(true);
|
resetAndReload(true);
|
||||||
|
|
||||||
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
|
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
|
||||||
@@ -402,6 +426,7 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
await state.loadingManager.showWithProgress(async (loading) => {
|
await state.loadingManager.showWithProgress(async (loading) => {
|
||||||
try {
|
try {
|
||||||
|
loading.showCancelButton(() => this.cancelTask());
|
||||||
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}`);
|
||||||
|
|
||||||
@@ -409,7 +434,7 @@ export class BaseModelApiClient {
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
switch(data.status) {
|
switch (data.status) {
|
||||||
case 'started':
|
case 'started':
|
||||||
loading.setStatus('Starting metadata fetch...');
|
loading.setStatus('Starting metadata fetch...');
|
||||||
break;
|
break;
|
||||||
@@ -427,7 +452,12 @@ export class BaseModelApiClient {
|
|||||||
loading.setStatus(
|
loading.setStatus(
|
||||||
`Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`
|
`Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`
|
||||||
);
|
);
|
||||||
resolve();
|
resolve(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cancelled':
|
||||||
|
loading.setStatus('Operation cancelled by user');
|
||||||
|
resolve(data); // Consider it complete but marked as cancelled
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
@@ -458,10 +488,14 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the operation to complete via WebSocket
|
// Wait for the operation to complete via WebSocket
|
||||||
await operationComplete;
|
const finalData = await operationComplete;
|
||||||
|
|
||||||
resetAndReload(false);
|
resetAndReload(false);
|
||||||
showToast('toast.api.metadataUpdateComplete', {}, 'success');
|
if (finalData && finalData.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelledPartial', { success: finalData.success, total: finalData.total }, 'info');
|
||||||
|
} else {
|
||||||
|
showToast('toast.api.metadataUpdateComplete', {}, 'success');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching metadata:', error);
|
console.error('Error fetching metadata:', error);
|
||||||
showToast('toast.api.metadataFetchFailed', { message: error.message }, 'error');
|
showToast('toast.api.metadataFetchFailed', { message: error.message }, 'error');
|
||||||
@@ -487,9 +521,17 @@ export class BaseModelApiClient {
|
|||||||
let failedItems = [];
|
let failedItems = [];
|
||||||
|
|
||||||
const progressController = state.loadingManager.showEnhancedProgress('Starting metadata refresh...');
|
const progressController = state.loadingManager.showEnhancedProgress('Starting metadata refresh...');
|
||||||
|
let cancelled = false;
|
||||||
|
progressController.showCancelButton(() => {
|
||||||
|
cancelled = true;
|
||||||
|
this.cancelTask();
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < filePaths.length; i++) {
|
for (let i = 0; i < filePaths.length; i++) {
|
||||||
|
if (cancelled) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
const filePath = filePaths[i];
|
const filePath = filePaths[i];
|
||||||
const fileName = filePath.split('/').pop();
|
const fileName = filePath.split('/').pop();
|
||||||
|
|
||||||
@@ -531,20 +573,15 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let completionMessage;
|
let completionMessage;
|
||||||
if (successCount === totalItems) {
|
if (cancelled) {
|
||||||
|
completionMessage = translate('toast.api.operationCancelledPartial', { success: successCount, total: totalItems }, `Operation cancelled. ${successCount} items processed.`);
|
||||||
|
showToast('toast.api.operationCancelledPartial', { success: successCount, total: totalItems }, 'info');
|
||||||
|
} else if (successCount === totalItems) {
|
||||||
completionMessage = translate('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`);
|
completionMessage = translate('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`);
|
||||||
showToast('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, 'success');
|
showToast('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, 'success');
|
||||||
} else if (successCount > 0) {
|
} else if (successCount > 0) {
|
||||||
completionMessage = translate('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`);
|
completionMessage = translate('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`);
|
||||||
showToast('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, 'warning');
|
showToast('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, 'warning');
|
||||||
|
|
||||||
// if (failedItems.length > 0) {
|
|
||||||
// const failureMessage = failedItems.length <= 3
|
|
||||||
// ? failedItems.map(item => `${item.fileName}: ${item.error}`).join('\n')
|
|
||||||
// : failedItems.slice(0, 3).map(item => `${item.fileName}: ${item.error}`).join('\n') +
|
|
||||||
// `\n(and ${failedItems.length - 3} more)`;
|
|
||||||
// showToast('toast.api.bulkMetadataFailureDetails', { failures: failureMessage }, 'warning', 6000);
|
|
||||||
// }
|
|
||||||
} else {
|
} else {
|
||||||
completionMessage = translate('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`);
|
completionMessage = translate('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`);
|
||||||
showToast('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, 'error');
|
showToast('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, 'error');
|
||||||
@@ -574,28 +611,42 @@ export class BaseModelApiClient {
|
|||||||
throw new Error('No model IDs provided');
|
throw new Error('No model IDs provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model_ids: modelIds,
|
|
||||||
force
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let payload = {};
|
|
||||||
try {
|
try {
|
||||||
payload = await response.json();
|
state.loadingManager.show('Checking for updates...', 0);
|
||||||
|
state.loadingManager.showCancelButton(() => this.cancelTask());
|
||||||
|
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model_ids: modelIds,
|
||||||
|
force
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to parse refresh updates response as JSON', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || payload?.success !== true) {
|
||||||
|
if (payload?.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const message = payload?.error || response.statusText || 'Failed to refresh updates';
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Unable to parse refresh updates response as JSON', error);
|
console.error('Error refreshing updates for models:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
state.loadingManager.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok || payload?.success !== true) {
|
|
||||||
const message = payload?.error || response.statusText || 'Failed to refresh updates';
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchCivitaiVersions(modelId, source = null) {
|
async fetchCivitaiVersions(modelId, source = null) {
|
||||||
@@ -895,13 +946,13 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveSingleModel(filePath, targetPath) {
|
async moveSingleModel(filePath, targetPath, useDefaultPaths = false) {
|
||||||
// Only allow move if supported
|
// Only allow move if supported
|
||||||
if (!this.apiConfig.config.supportsMove) {
|
if (!this.apiConfig.config.supportsMove) {
|
||||||
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
|
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath && !useDefaultPaths) {
|
||||||
showToast('toast.api.alreadyInFolder', { type: this.apiConfig.config.displayName }, 'info');
|
showToast('toast.api.alreadyInFolder', { type: this.apiConfig.config.displayName }, 'info');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -913,7 +964,8 @@ export class BaseModelApiClient {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
file_path: filePath,
|
file_path: filePath,
|
||||||
target_path: targetPath
|
target_path: targetPath,
|
||||||
|
use_default_paths: useDefaultPaths
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -935,18 +987,19 @@ export class BaseModelApiClient {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
return {
|
return {
|
||||||
original_file_path: result.original_file_path || filePath,
|
original_file_path: result.original_file_path || filePath,
|
||||||
new_file_path: result.new_file_path
|
new_file_path: result.new_file_path,
|
||||||
|
cache_entry: result.cache_entry
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveBulkModels(filePaths, targetPath) {
|
async moveBulkModels(filePaths, targetPath, useDefaultPaths = false) {
|
||||||
if (!this.apiConfig.config.supportsMove) {
|
if (!this.apiConfig.config.supportsMove) {
|
||||||
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const movedPaths = filePaths.filter(path => {
|
const movedPaths = useDefaultPaths ? filePaths : filePaths.filter(path => {
|
||||||
return path.substring(0, path.lastIndexOf('/')) !== targetPath;
|
return path.substring(0, path.lastIndexOf('/')) !== targetPath;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -962,7 +1015,8 @@ export class BaseModelApiClient {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
file_paths: movedPaths,
|
file_paths: movedPaths,
|
||||||
target_path: targetPath
|
target_path: targetPath,
|
||||||
|
use_default_paths: useDefaultPaths
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1013,6 +1067,7 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
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());
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
|
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1052,6 +1107,7 @@ export class BaseModelApiClient {
|
|||||||
let ws = null;
|
let ws = null;
|
||||||
|
|
||||||
await state.loadingManager.showWithProgress(async (loading) => {
|
await state.loadingManager.showWithProgress(async (loading) => {
|
||||||
|
loading.showCancelButton(() => this.stopExampleImages());
|
||||||
try {
|
try {
|
||||||
// Connect to WebSocket for progress updates
|
// Connect to WebSocket for progress updates
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
@@ -1063,7 +1119,7 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
if (data.type !== 'example_images_progress') return;
|
if (data.type !== 'example_images_progress') return;
|
||||||
|
|
||||||
switch(data.status) {
|
switch (data.status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
const percent = ((data.processed / data.total) * 100).toFixed(1);
|
const percent = ((data.processed / data.total) * 100).toFixed(1);
|
||||||
loading.setProgress(percent);
|
loading.setProgress(percent);
|
||||||
@@ -1199,6 +1255,7 @@ export class BaseModelApiClient {
|
|||||||
let ws = null;
|
let ws = null;
|
||||||
|
|
||||||
await state.loadingManager.showWithProgress(async (loading) => {
|
await state.loadingManager.showWithProgress(async (loading) => {
|
||||||
|
loading.showCancelButton(() => this.cancelTask());
|
||||||
try {
|
try {
|
||||||
// Connect to WebSocket for progress updates
|
// Connect to WebSocket for progress updates
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
@@ -1210,7 +1267,7 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
if (data.type !== 'auto_organize_progress') return;
|
if (data.type !== 'auto_organize_progress') return;
|
||||||
|
|
||||||
switch(data.status) {
|
switch (data.status) {
|
||||||
case 'started':
|
case 'started':
|
||||||
loading.setProgress(0);
|
loading.setProgress(0);
|
||||||
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
|
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
|
||||||
@@ -1252,6 +1309,11 @@ export class BaseModelApiClient {
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'cancelled':
|
||||||
|
loading.setStatus(translate('toast.api.operationCancelled', {}, 'Operation cancelled by user'));
|
||||||
|
resolve(data);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.error', { error: data.error }, `Error: ${data.error}`));
|
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.error', { error: data.error }, `Error: ${data.error}`));
|
||||||
reject(new Error(data.error));
|
reject(new Error(data.error));
|
||||||
@@ -1296,7 +1358,9 @@ export class BaseModelApiClient {
|
|||||||
const result = await operationComplete;
|
const result = await operationComplete;
|
||||||
|
|
||||||
// Show appropriate success message based on results
|
// Show appropriate success message based on results
|
||||||
if (result.failures === 0) {
|
if (result.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelledPartial', { success: result.success, total: result.total }, 'info');
|
||||||
|
} else if (result.failures === 0) {
|
||||||
showToast('toast.loras.autoOrganizeSuccess', {
|
showToast('toast.loras.autoOrganizeSuccess', {
|
||||||
count: result.success,
|
count: result.success,
|
||||||
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
|
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
|
||||||
@@ -1323,4 +1387,17 @@ export class BaseModelApiClient {
|
|||||||
completionMessage: translate('loras.bulkOperations.autoOrganizeProgress.complete', {}, 'Auto-organize complete')
|
completionMessage: translate('loras.bulkOperations.autoOrganizeProgress.complete', {}, 'Auto-organize complete')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async stopExampleImages() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/stop-example-images', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error stopping example images:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user