mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-26 15:38:52 -03:00
Compare commits
7 Commits
v0.9.16
...
modal-rewo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26884630d3 | ||
|
|
66e9d77c67 | ||
|
|
5ffca15172 | ||
|
|
4d9115339b | ||
|
|
469f7a1829 | ||
|
|
d27e3c8126 | ||
|
|
7bc63d7631 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,6 +19,3 @@ model_cache/
|
||||
vue-widgets/node_modules/
|
||||
vue-widgets/.vite/
|
||||
vue-widgets/dist/
|
||||
|
||||
# Hypothesis test cache
|
||||
.hypothesis/
|
||||
|
||||
181
AGENTS.md
181
AGENTS.md
@@ -25,127 +25,168 @@ pytest tests/test_recipes.py::test_function_name
|
||||
|
||||
# Run backend tests with coverage
|
||||
COVERAGE_FILE=coverage/backend/.coverage pytest \
|
||||
--cov=py --cov=standalone \
|
||||
--cov=py \
|
||||
--cov=standalone \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=html:coverage/backend/html \
|
||||
--cov-report=xml:coverage/backend/coverage.xml
|
||||
--cov-report=xml:coverage/backend/coverage.xml \
|
||||
--cov-report=json:coverage/backend/coverage.json
|
||||
```
|
||||
|
||||
### Frontend Development (Standalone Web UI)
|
||||
### Frontend Development
|
||||
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
npm install
|
||||
npm test # Run all tests (JS + Vue)
|
||||
npm run test:js # Run JS tests only
|
||||
npm run test:watch # Watch mode
|
||||
npm run test:coverage # Generate coverage report
|
||||
```
|
||||
|
||||
### Vue Widget Development
|
||||
# Run frontend tests
|
||||
npm test
|
||||
|
||||
```bash
|
||||
cd vue-widgets
|
||||
npm install
|
||||
npm run dev # Build in watch mode
|
||||
npm run build # Build production bundle
|
||||
npm run typecheck # Run TypeScript type checking
|
||||
npm test # Run Vue widget tests
|
||||
npm run test:watch # Watch mode
|
||||
npm run test:coverage # Generate coverage report
|
||||
# Run frontend tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Run frontend tests with coverage
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## Python Code Style
|
||||
|
||||
### Imports & Formatting
|
||||
### Imports
|
||||
|
||||
- Use `from __future__ import annotations` for forward references
|
||||
- Group imports: standard library, third-party, local (blank line separated)
|
||||
- Absolute imports within `py/`: `from ..services import X`
|
||||
- PEP 8 with 4-space indentation, type hints required
|
||||
- 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`, Classes: `PascalCase`, Functions/vars: `snake_case`
|
||||
- Constants: `UPPER_SNAKE_CASE`, Private: `_protected`, `__mangled`
|
||||
- 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 & Async
|
||||
### Error Handling
|
||||
|
||||
- Use `logging.getLogger(__name__)`, define custom exceptions in `py/services/errors.py`
|
||||
- `async def` for I/O, `@pytest.mark.asyncio` for async tests
|
||||
- Singleton with `asyncio.Lock`: see `ModelScanner.get_instance()`
|
||||
- Return `aiohttp.web.json_response` or `web.Response`
|
||||
- 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)
|
||||
|
||||
### Testing
|
||||
### Async Patterns
|
||||
|
||||
- `pytest` with `--import-mode=importlib`
|
||||
- Fixtures in `tests/conftest.py`, use `tmp_path_factory` for isolation
|
||||
- Mark tests needing real paths: `@pytest.mark.no_settings_dir_isolation`
|
||||
- Mock ComfyUI dependencies via conftest 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
|
||||
|
||||
## JavaScript/TypeScript Code Style
|
||||
### 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: `import { app } from "../../scripts/app.js"` for ComfyUI
|
||||
- Vue: `import { ref, computed } from 'vue'`, type imports: `import type { Foo }`
|
||||
- Export named functions: `export function foo() {}`
|
||||
- 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/vars/props, PascalCase for classes
|
||||
- Constants: `UPPER_SNAKE_CASE`, Files: `snake_case.js` or `kebab-case.js`
|
||||
- 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)
|
||||
- Vue Single File Components: `<script setup lang="ts">` preferred
|
||||
|
||||
### Widget Development
|
||||
|
||||
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
|
||||
- Event handlers via `addEventListener` or widget callbacks
|
||||
- Shared utilities: `web/comfyui/utils.js`
|
||||
|
||||
### Vue Composables Pattern
|
||||
|
||||
- Use composition API: `useXxxState(widget)`, return reactive refs and methods
|
||||
- Guard restoration loops with flag: `let isRestoring = false`
|
||||
- Build config from state: `const buildConfig = (): Config => { ... }`
|
||||
- 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
|
||||
|
||||
- `ServiceRegistry` singleton for DI, services use `get_instance()` classmethod
|
||||
- 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/` are pure functions with deps as params
|
||||
- Handlers in `py/routes/handlers/` implement route logic
|
||||
|
||||
### Model Types & Routes
|
||||
### Model Types
|
||||
|
||||
- `BaseModelService` base for LoRA, Checkpoint, Embedding
|
||||
- `ModelScanner` for file discovery, hash deduplication
|
||||
- `PersistentModelCache` (SQLite) for persistence
|
||||
- Route registrars: `ModelRouteRegistrar`, endpoints: `/loras/*`, `/checkpoints/*`, `/embeddings/*`
|
||||
- WebSocket via `WebSocketManager` for real-time updates
|
||||
- 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: `py/recipes/base.py`, Enrichment: `RecipeEnrichmentService`
|
||||
- Parsers: `py/recipes/parsers/`
|
||||
- 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 (folder_paths) vs standalone (settings.json)
|
||||
- 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
|
||||
- Symlinks require normalized paths
|
||||
|
||||
## Frontend UI Architecture
|
||||
|
||||
### 1. Standalone Web UI
|
||||
This project has two distinct UI systems:
|
||||
|
||||
### 1. Standalone Lora Manager Web UI
|
||||
- Location: `./static/` and `./templates/`
|
||||
- Tech: Vanilla JS + CSS, served by standalone server
|
||||
- Tests via npm in root directory
|
||||
- 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/` (Vanilla JS) + `./vue-widgets/` (Vue)
|
||||
- Primary styles: `./web/comfyui/lm_styles.css` (NOT `./static/css/`)
|
||||
- Vue builds to `./web/comfyui/vue-widgets/`, typecheck via `vue-tsc`
|
||||
- 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
|
||||
|
||||
|
||||
258
CLAUDE.md
258
CLAUDE.md
@@ -8,22 +8,17 @@ ComfyUI LoRA Manager is a comprehensive LoRA management system for ComfyUI that
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend
|
||||
|
||||
### 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 all backend tests
|
||||
pytest
|
||||
|
||||
# Run specific test file or function
|
||||
pytest tests/test_recipes.py
|
||||
pytest tests/test_recipes.py::test_function_name
|
||||
|
||||
# Run backend tests with coverage
|
||||
COVERAGE_FILE=coverage/backend/.coverage pytest \
|
||||
--cov=py \
|
||||
@@ -32,158 +27,185 @@ COVERAGE_FILE=coverage/backend/.coverage pytest \
|
||||
--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
|
||||
|
||||
There are three test suites run by `npm test`: vanilla JS tests (vitest at root) and Vue widget tests (`vue-widgets/` vitest).
|
||||
|
||||
### Frontend Development
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
npm install
|
||||
cd vue-widgets && npm install && cd ..
|
||||
|
||||
# Run all frontend tests (JS + Vue)
|
||||
# Run frontend tests
|
||||
npm test
|
||||
|
||||
# Run only vanilla JS tests
|
||||
npm run test:js
|
||||
|
||||
# Run only Vue widget tests
|
||||
npm run test:vue
|
||||
|
||||
# Watch mode (JS tests only)
|
||||
# Run frontend tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Frontend coverage
|
||||
# Run frontend tests with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Build Vue widgets (output to web/comfyui/vue-widgets/)
|
||||
cd vue-widgets && npm run build
|
||||
|
||||
# Vue widget dev mode (watch + rebuild)
|
||||
cd vue-widgets && npm run dev
|
||||
|
||||
# Typecheck Vue widgets
|
||||
cd vue-widgets && npm run typecheck
|
||||
```
|
||||
|
||||
### Localization
|
||||
|
||||
```bash
|
||||
# Sync translation keys after UI string updates
|
||||
python scripts/sync_translation_keys.py
|
||||
```
|
||||
|
||||
Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Dual Mode Operation
|
||||
### Backend Structure (Python)
|
||||
|
||||
The system runs in two modes:
|
||||
- **ComfyUI plugin mode**: Integrates with ComfyUI's PromptServer, uses `folder_paths` for model discovery
|
||||
- **Standalone mode**: `standalone.py` mocks ComfyUI dependencies, reads paths from `settings.json`
|
||||
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
||||
**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
|
||||
|
||||
### Backend (Python)
|
||||
**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)
|
||||
|
||||
**Entry points:**
|
||||
- `__init__.py` — ComfyUI plugin entry: registers nodes via `NODE_CLASS_MAPPINGS`, sets `WEB_DIRECTORY`, calls `LoraManager.add_routes()`
|
||||
- `standalone.py` — Standalone server: mocks `folder_paths` and node modules, starts aiohttp server
|
||||
- `py/lora_manager.py` — Main `LoraManager` class that registers all HTTP routes
|
||||
**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
|
||||
|
||||
**Service layer** (`py/services/`):
|
||||
- `ServiceRegistry` singleton for dependency injection; services follow `get_instance()` singleton pattern
|
||||
- `BaseModelService` abstract base → `LoraService`, `CheckpointService`, `EmbeddingService`
|
||||
- `ModelScanner` base → `LoraScanner`, `CheckpointScanner`, `EmbeddingScanner` for file discovery with hash-based deduplication
|
||||
- `PersistentModelCache` — SQLite-based metadata cache
|
||||
- `MetadataSyncService` — Background sync from CivitAI/CivArchive APIs
|
||||
- `SettingsManager` — Settings with schema migration support
|
||||
- `WebSocketManager` — Real-time progress broadcasting
|
||||
- `ModelServiceFactory` — Creates the right service for each model type
|
||||
- Use cases in `py/services/use_cases/` orchestrate complex business logic (auto-organize, bulk refresh, downloads)
|
||||
**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)
|
||||
|
||||
**Routes** (`py/routes/`):
|
||||
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
|
||||
- Request handlers in `py/routes/handlers/` implement route logic
|
||||
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
|
||||
- All routes use aiohttp, return `web.json_response` or `web.Response`
|
||||
|
||||
**Recipe system** (`py/recipes/`):
|
||||
- `base.py` — Recipe metadata structure
|
||||
- `enrichment.py` — Enriches recipes with model metadata
|
||||
- `parsers/` — Parsers for PNG metadata, JSON, and workflow formats
|
||||
|
||||
**Custom nodes** (`py/nodes/`):
|
||||
- Each node class has a `NAME` class attribute used as key in `NODE_CLASS_MAPPINGS`
|
||||
- Standard ComfyUI node pattern: `INPUT_TYPES()` classmethod, `RETURN_TYPES`, `FUNCTION`
|
||||
- All nodes registered in `__init__.py`
|
||||
**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, handles symlink mappings
|
||||
- Manages folder paths for models, checkpoints, embeddings
|
||||
- Handles symlink mappings for complex directory structures
|
||||
- Auto-saves paths to settings.json in ComfyUI mode
|
||||
|
||||
### Frontend — Two Distinct UI Systems
|
||||
### Frontend Structure (JavaScript)
|
||||
|
||||
#### 1. Standalone Manager Web UI
|
||||
- **Location:** `static/` (JS/CSS) and `templates/` (HTML)
|
||||
- **Tech:** Vanilla JS + CSS, served by standalone server
|
||||
- **Structure:** `static/js/core.js` (shared), `loras.js`, `checkpoints.js`, `embeddings.js`, `recipes.js`, `statistics.js`
|
||||
- **Tests:** `tests/frontend/**/*.test.js` (vitest + jsdom)
|
||||
**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
|
||||
|
||||
#### 2. ComfyUI Custom Node Widgets
|
||||
- **Vanilla JS widgets:** `web/comfyui/*.js` — ES modules extending ComfyUI's LiteGraph UI
|
||||
- `loras_widget.js` / `loras_widget_events.js` — Main LoRA selection widget
|
||||
- `autocomplete.js` — Trigger word and embedding autocomplete
|
||||
- `preview_tooltip.js` — Model card preview tooltips
|
||||
- `top_menu_extension.js` — "Launch LoRA Manager" menu item
|
||||
- `utils.js` — Shared utilities and API helpers
|
||||
- Widget styling in `web/comfyui/lm_styles.css` (NOT `static/css/`)
|
||||
- **Vue widgets:** `vue-widgets/src/` → built to `web/comfyui/vue-widgets/`
|
||||
- Vue 3 + TypeScript + PrimeVue + vue-i18n
|
||||
- Vite build with CSS-injected-by-JS plugin
|
||||
- Components: `LoraPoolWidget`, `LoraRandomizerWidget`, `LoraCyclerWidget`, `AutocompleteTextWidget`
|
||||
- Auto-built on ComfyUI startup via `py/vue_widget_builder.py`
|
||||
- Tests: `vue-widgets/tests/**/*.test.ts` (vitest)
|
||||
**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
|
||||
|
||||
**Widget registration pattern:**
|
||||
- Widgets use `app.registerExtension()` and `getCustomWidgets` hooks
|
||||
- `node.addDOMWidget(name, type, element, options)` embeds HTML in LiteGraph nodes
|
||||
- See `docs/dom_widget_dev_guide.md` for 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, 4-space indentation, English comments only
|
||||
- Use `from __future__ import annotations` for forward references
|
||||
- Use `TYPE_CHECKING` guard for type-checking-only imports
|
||||
- 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__)`
|
||||
- Custom exceptions in `py/services/errors.py`
|
||||
- Async patterns: `async def` for I/O, `@pytest.mark.asyncio` for async tests
|
||||
- Singleton pattern with class-level `asyncio.Lock` (see `ModelScanner.get_instance()`)
|
||||
|
||||
**JavaScript:**
|
||||
- ES modules, camelCase functions/variables, PascalCase classes
|
||||
- Widget files use `*_widget.js` suffix
|
||||
- Prefer vanilla JS for `web/comfyui/` widgets, avoid framework dependencies (except Vue widgets)
|
||||
- ES modules with camelCase
|
||||
- Files use `*_widget.js` suffix for ComfyUI widgets
|
||||
- Prefer vanilla JS, avoid framework dependencies
|
||||
|
||||
## Testing
|
||||
|
||||
**Backend (pytest):**
|
||||
- Config in `pytest.ini`: `--import-mode=importlib`, testpaths=`tests`
|
||||
- Fixtures in `tests/conftest.py` handle ComfyUI dependency mocking
|
||||
- Markers: `@pytest.mark.asyncio`, `@pytest.mark.no_settings_dir_isolation`
|
||||
- Uses `tmp_path_factory` for directory isolation
|
||||
**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 (vitest):**
|
||||
- Vanilla JS tests: `tests/frontend/**/*.test.js` with jsdom
|
||||
- Vue widget tests: `vue-widgets/tests/**/*.test.ts` with jsdom + @vue/test-utils
|
||||
**Frontend Tests:**
|
||||
- Vitest with jsdom environment
|
||||
- Test files: `tests/frontend/**/*.test.js`
|
||||
- Setup in `tests/frontend/setup.js`
|
||||
- Coverage via `npm run test:coverage`
|
||||
|
||||
## Key Integration Points
|
||||
## Important Notes
|
||||
|
||||
- **Settings:** Stored in user directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`)
|
||||
- **CivitAI/CivArchive:** API clients for metadata sync and model downloads; CivitAI API key in settings
|
||||
- **Symlink handling:** Config scans symlinks to map virtual→physical paths; fingerprinting prevents redundant rescans
|
||||
- **WebSocket:** Broadcasts real-time progress for downloads, scans, and metadata sync
|
||||
- **Model scanning flow:** Walk folders → compute hashes → deduplicate → extract safetensors metadata → cache in SQLite → background CivitAI sync → WebSocket broadcast
|
||||
**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
|
||||
|
||||
@@ -34,14 +34,6 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v0.9.16
|
||||
* **Duplicate Detection Enhancement** - The model duplicates mode now respects filter configurations, making it easier to find duplicate groups within specific filtered results.
|
||||
* **Tag Logic Toggle** - Added OR/AND toggle for include tags filtering in the filters panel, providing more flexible tag-based model searches.
|
||||
* **Metadata Refresh Skip Paths** - New setting to exclude specific paths from metadata refresh operations. Models under these paths will be skipped when fetching metadata from remote sources.
|
||||
* **Dynamic Trigger Words in Prompt Node** - Prompt node now supports dynamic numbers of trigger word inputs for greater flexibility.
|
||||
* **Early Access Updates** - Model updates now display Early Access information, with a new setting to ignore Early Access updates if desired.
|
||||
* **LM Civitai Extension Integration** - Added integration with the LM Civitai Extension. Clicking the download button in model updates now sends downloads to the extension's download queue for seamless one-click downloads.
|
||||
|
||||
### v0.9.15
|
||||
* **Filter Presets** - Save filter combinations as presets for quick switching and reapplication.
|
||||
* **Bug Fixes** - Fixed various bugs for improved stability.
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
## Overview
|
||||
|
||||
The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com). With this extension, you can:
|
||||
The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com).
|
||||
It also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
|
||||
|
||||
With this extension, you can:
|
||||
|
||||
✅ Instantly see which models are already present in your local library
|
||||
✅ Download new models with a single click
|
||||
✅ Manage downloads efficiently with queue and parallel download support
|
||||
✅ Keep your downloaded models automatically organized according to your custom settings
|
||||
|
||||

|
||||
|
||||
**Update:** It now also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## Why Supporter Access?
|
||||
## Why Are All Features for Supporters Only?
|
||||
|
||||
LoRA Manager is built with love for the Stable Diffusion and ComfyUI communities. Your support makes it possible for me to keep improving and maintaining the tool full-time.
|
||||
I love building tools for the Stable Diffusion and ComfyUI communities, and LoRA Manager is a passion project that I've poured countless hours into. When I created this companion extension, my hope was to offer its core features for free, as a thank-you to all of you.
|
||||
|
||||
Supporter-exclusive features help ensure the long-term sustainability of LoRA Manager, allowing continuous updates, new features, and better performance for everyone.
|
||||
Unfortunately, I've reached a point where I need to be realistic. The level of support from the free model has been far lower than what's needed to justify the continuous development and maintenance for both projects. It was a difficult decision, but I've chosen to make the extension's features exclusive to supporters.
|
||||
|
||||
Every contribution directly fuels development and keeps the core LoRA Manager free and open-source. In addition to monthly supporters, one-time donation supporters will also receive a license key, with the duration scaling according to the contribution amount. Thank you for helping keep this project alive and growing. ❤️
|
||||
This change is crucial for me to be able to continue dedicating my time to improving the free and open-source LoRA Manager, which I'm committed to keeping available for everyone.
|
||||
|
||||
Your support does more than just unlock a few features—it allows me to keep innovating and ensures the core LoRA Manager project thrives. I'm incredibly grateful for your understanding and any support you can offer. ❤️
|
||||
|
||||
(_For those who previously supported me on Ko-fi with a one-time donation, I'll be sending out license keys individually as a thank-you._)
|
||||
|
||||
|
||||
---
|
||||
@@ -86,27 +90,20 @@ Clicking the download button adds the corresponding model version to the downloa
|
||||
|
||||
On a specific model page, visual indicators also appear on version buttons, showing which versions are already in your local library.
|
||||
|
||||
**Starting from v0.4.8**, model pages use a dedicated download button for better compatibility. When switching to a specific version by clicking a version button:
|
||||
When switching to a specific version by clicking a version button:
|
||||
|
||||
- The new **dedicated download button** directly triggers download via **LoRA Manager**
|
||||
- The **original download button** remains unchanged for standard browser downloads
|
||||
- Clicking the download button will open a dropdown:
|
||||
- Download via **LoRA Manager**
|
||||
- Download via **Original Download** (browser download)
|
||||
|
||||
You can check **Remember my choice** to set your preferred default. You can change this setting anytime in the extension's settings.
|
||||
|
||||

|
||||
|
||||
### Hide Models Already in Library (Beta)
|
||||
|
||||
**New in v0.4.8**: A new **Hide models already in library (Beta)** option makes it easier to focus on models you haven't added yet. It can be enabled from Settings, or toggled quickly using **Ctrl + Shift + H** (macOS: **Command + Shift + H**).
|
||||
|
||||
### Resources on Image Pages — now shows in-library indicators for image resources plus one-click recipe import
|
||||
|
||||
- **One-Click Import Civitai Image as Recipe** — Import any Civitai image as a recipe with a single click in the Resources Used panel.
|
||||
- **Auto-Queue Missing Assets** — In Settings you can decide if LoRAs or checkpoints referenced by that image should automatically be added to your download queue.
|
||||
- **More Accurate Metadata** — Importing directly from the page is faster than copying inside LM and keeps on-site tags and other metadata perfectly aligned.
|
||||
### Resources on Image Pages (2025-08-05) — now shows in-library indicators for image resources. ‘Import image as recipe’ coming soon!
|
||||
|
||||

|
||||
|
||||
[](https://github.com/user-attachments/assets/41fd4240-c949-4f83-bde7-8f3124c09494)
|
||||
|
||||
---
|
||||
|
||||
## Model Download Location & LoRA Manager Settings
|
||||
@@ -173,11 +170,11 @@ _Thanks to user **Temikus** for sharing this solution!_
|
||||
The extension will evolve alongside **LoRA Manager** improvements. Planned features include:
|
||||
|
||||
- [x] Support for **additional model types** (e.g., embeddings)
|
||||
- [x] One-click **Recipe Import**
|
||||
- [x] Display of in-library status for all resources in the **Resources Used** section of the image page
|
||||
- [ ] One-click **Recipe Import**
|
||||
- [x] Display of in-library status for all resources in the **Resources Used** section of the image page
|
||||
- [x] One-click **Auto-organize Models**
|
||||
- [x] **Hide models already in library (Beta)** - Focus on models you haven't added yet
|
||||
|
||||
**Stay tuned — and thank you for your support!**
|
||||
|
||||
---
|
||||
|
||||
|
||||
449
docs/plan/model-modal-redesign.md
Normal file
449
docs/plan/model-modal-redesign.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# Model Modal UI/UX 重构计划
|
||||
|
||||
> **Status**: Phase 1 Complete ✓
|
||||
> **Created**: 2026-02-06
|
||||
> **Target**: v2.x Release
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
### 1.1 背景与问题
|
||||
|
||||
当前 Model Modal 存在以下 UX 问题:
|
||||
|
||||
1. **空间利用率低** - 固定 800px 宽度,大屏环境下大量留白
|
||||
2. **Tab 切换繁琐** - 4 个 Tab(Examples/Description/Versions/Recipes)隐藏了重要信息
|
||||
3. **Examples 浏览不便** - 需持续向下滚动,无快速导航
|
||||
4. **添加自定义示例困难** - 需滚动到底部,操作路径长
|
||||
|
||||
### 1.2 设计目标
|
||||
|
||||
- **空间效率**: 利用 header 以下、sidebar 右侧的全部可用空间
|
||||
- **浏览体验**: 类似 Midjourney 的沉浸式图片浏览
|
||||
- **信息架构**: 关键元数据固定可见,次要信息可折叠
|
||||
- **操作效率**: 直觉化的键盘导航,减少点击次数
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计方案
|
||||
|
||||
### 2.1 布局架构: Split-View Overlay
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ HEADER (保持现有) │
|
||||
├──────────┬───────────────────────────────────────────────────────────┤
|
||||
│ │ ┌───────────────────────────┬────────────────────────┐ │
|
||||
│ FOLDER │ │ │ MODEL HEADER │ │
|
||||
│ SIDEBAR │ │ EXAMPLES SHOWCASE │ ├─ Name │ │
|
||||
│ (可折叠) │ │ │ ├─ Creator + Actions │ │
|
||||
│ │ │ ┌─────────────────┐ │ ├─ Tags │ │
|
||||
│ │ │ │ │ ├────────────────────────┤ │
|
||||
│ │ │ │ MAIN IMAGE │ │ COMPACT METADATA │ │
|
||||
│ │ │ │ (自适应高度) │ │ ├─ Ver | Base | Size │ │
|
||||
│ │ │ │ │ │ ├─ Location │ │
|
||||
│ │ │ └─────────────────┘ │ ├─ Usage Tips │ │
|
||||
│ │ │ │ ├─ Trigger Words │ │
|
||||
│ │ │ [PARAMS PREVIEW] │ ├─ Notes │ │
|
||||
│ │ │ (Prompt + Copy) ├────────────────────────┤ │
|
||||
│ │ │ │ CONTENT TABS │ │
|
||||
│ │ │ ┌─────────────────┐ │ [Desc][Versions][Rec] │ │
|
||||
│ │ │ │ THUMBNAIL RAIL │ │ │ │
|
||||
│ │ │ │ [1][2][3][4][+]│ │ TAB CONTENT AREA │ │
|
||||
│ │ │ └─────────────────┘ │ (Accordion / List) │ │
|
||||
│ │ └───────────────────────────┴────────────────────────┘ │
|
||||
└──────────┴───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**尺寸规格**:
|
||||
- Sidebar 展开: Left 60% | Right 40%
|
||||
- Sidebar 折叠: Left 65% | Right 35%
|
||||
- 最小宽度: 1200px (低于此值触发移动端适配)
|
||||
|
||||
### 2.2 左侧: Examples Showcase
|
||||
|
||||
#### 2.2.1 组件结构
|
||||
|
||||
| 组件 | 描述 | 优先级 |
|
||||
|------|------|--------|
|
||||
| Main Image | 自适应容器,保持原始比例,最大高度 70vh | P0 |
|
||||
| Params Panel | 底部滑出面板,显示 Prompt/Negative/Params | P0 |
|
||||
| Thumbnail Rail | 底部横向滚动条,支持点击跳转 | P0 |
|
||||
| Add Button | Rail 最右侧 "+" 按钮,打开上传区 | P0 |
|
||||
| Nav Arrows | 图片左右两侧悬停显示 | P1 |
|
||||
|
||||
#### 2.2.2 图片悬停操作
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ [👁] [📌] [🗑] │ ← 查看参数 | 设为预览 | 删除
|
||||
│ │
|
||||
│ IMAGE │
|
||||
│ │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
#### 2.2.3 键盘导航
|
||||
|
||||
| 按键 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ← | 上一个 Example | 循环(首张时到最后一张) |
|
||||
| → | 下一个 Example | 循环(末张时到第一张) |
|
||||
| I | Toggle Params Panel | 显示/隐藏图片参数 |
|
||||
| C | Copy Prompt | 复制当前 Prompt 到剪贴板 |
|
||||
|
||||
### 2.3 右侧: Metadata + Content
|
||||
|
||||
#### 2.3.1 固定头部 (不可折叠)
|
||||
|
||||
```
|
||||
┌────────────────────────┐
|
||||
│ MODEL NAME [×] │
|
||||
│ [👤 Creator] [🌐 Civ] │
|
||||
│ [tag1] [tag2] [tag3] │
|
||||
├────────────────────────┤
|
||||
│ Ver: v1.0 Size: 96MB │
|
||||
│ Base: SDXL │
|
||||
│ 📁 /path/to/file │
|
||||
├────────────────────────┤
|
||||
│ USAGE TIPS [✏️] │
|
||||
│ [strength: 0.8] [+] │
|
||||
├────────────────────────┤
|
||||
│ TRIGGER WORDS [✏️] │
|
||||
│ [word1] [word2] [📋] │
|
||||
├────────────────────────┤
|
||||
│ NOTES [✏️] │
|
||||
│ "Add your notes..." │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
#### 2.3.2 Tabs 设计
|
||||
|
||||
保留横向 Tab 切换,但优化内容展示:
|
||||
|
||||
| Tab | 内容 | 交互方式 |
|
||||
|-----|------|----------|
|
||||
| Description | About this version + Model Description | Accordion 折叠 |
|
||||
| Versions | 版本列表卡片 | 完整列表视图 |
|
||||
| Recipes | Recipe 卡片网格 | 网格布局 |
|
||||
|
||||
**Accordion 行为**:
|
||||
- 手风琴模式:同时只能展开一个 section
|
||||
- 默认:About this version 展开,Description 折叠
|
||||
- 动画:300ms ease-out
|
||||
|
||||
### 2.4 全局导航
|
||||
|
||||
#### 2.4.1 Model 切换
|
||||
|
||||
| 按键 | 功能 |
|
||||
|------|------|
|
||||
| ↑ | 上一个 Model |
|
||||
| ↓ | 下一个 Model |
|
||||
|
||||
**切换动画**:
|
||||
1. 当前 Modal 淡出 (150ms)
|
||||
2. 加载新 Model 数据
|
||||
3. 新 Modal 淡入 (150ms)
|
||||
4. 保持当前 Tab 状态(不重置到默认)
|
||||
|
||||
#### 2.4.2 首次使用提示
|
||||
|
||||
Modal 首次打开时,顶部显示提示条:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 💡 Tip: ↑↓ 切换模型 | ←→ 浏览示例 | I 查看参数 | ESC 关闭 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
- 3 秒后自动淡出
|
||||
- 提供 "不再显示" 选项
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术实现
|
||||
|
||||
### 3.1 文件结构变更
|
||||
|
||||
```
|
||||
static/
|
||||
├── js/
|
||||
│ └── components/
|
||||
│ └── model-modal/ # 新目录
|
||||
│ ├── index.js # 主入口
|
||||
│ ├── ModelModal.js # Modal 容器
|
||||
│ ├── ExampleShowcase.js # 左侧展示
|
||||
│ ├── ThumbnailRail.js # 缩略图导航
|
||||
│ ├── MetadataPanel.js # 右侧元数据
|
||||
│ ├── ContentTabs.js # Tabs 容器
|
||||
│ └── accordions/ # Accordion 组件
|
||||
│ ├── DescriptionAccordion.js
|
||||
│ └── VersionsList.js
|
||||
├── css/
|
||||
│ └── components/
|
||||
│ └── model-modal/ # 新目录
|
||||
│ ├── modal-overlay.css
|
||||
│ ├── showcase.css
|
||||
│ ├── thumbnail-rail.css
|
||||
│ ├── metadata.css
|
||||
│ └── tabs.css
|
||||
```
|
||||
|
||||
### 3.2 核心 CSS 架构
|
||||
|
||||
```css
|
||||
/* modal-overlay.css */
|
||||
.model-overlay {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: var(--sidebar-width, 250px);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--z-modal);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 0;
|
||||
|
||||
background: var(--bg-color);
|
||||
animation: modalSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.model-overlay.sidebar-collapsed {
|
||||
left: var(--sidebar-collapsed-width, 60px);
|
||||
grid-template-columns: 1.3fr 0.7fr;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.model-overlay {
|
||||
left: 0;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 响应式断点
|
||||
|
||||
| 断点 | 布局 | 说明 |
|
||||
|------|------|------|
|
||||
| > 1400px | Split 60/40 | 大屏优化 |
|
||||
| 1200-1400px | Split 50/50 | 标准桌面 |
|
||||
| 768-1200px | Split 50/50 | 小屏桌面/平板 |
|
||||
| < 768px | Stack | 移动端:Examples 在上,Metadata 在下 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 实施阶段
|
||||
|
||||
### Phase 1: 核心重构 (预计 2-3 周)
|
||||
|
||||
**目标**: MVP 可用,基础功能完整
|
||||
|
||||
**任务清单**:
|
||||
|
||||
- [ ] 创建新的文件结构和基础组件
|
||||
- [ ] 实现 Split-View Overlay 布局
|
||||
- [ ] CSS Grid 布局系统
|
||||
- [ ] Sidebar 状态联动
|
||||
- [ ] 响应式断点处理
|
||||
- [ ] 迁移左侧 Examples 区域
|
||||
- [ ] Main Image 自适应容器
|
||||
- [ ] Thumbnail Rail 组件
|
||||
- [ ] Params Panel 滑出动画
|
||||
- [ ] 实现新的快捷键系统
|
||||
- [ ] ↑↓ 切换 Model
|
||||
- [ ] ←→ 切换 Example
|
||||
- [ ] I/C/ESC 功能键
|
||||
- [ ] 移除旧 Modal 的 max-width 限制
|
||||
- [ ] 基础动画过渡
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 新布局在各种屏幕尺寸下正常显示
|
||||
- [ ] 键盘导航正常工作
|
||||
- [ ] 无阻塞性 Bug
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 体验优化 (预计 1-2 周)
|
||||
|
||||
**目标**: 信息架构优化,交互细节完善
|
||||
|
||||
**任务清单**:
|
||||
|
||||
- [ ] Accordion 组件实现
|
||||
- [ ] Description Tab 的折叠面板
|
||||
- [ ] 手风琴交互逻辑
|
||||
- [ ] 动画优化
|
||||
- [ ] 右侧 Metadata 区域固定化
|
||||
- [ ] 滚动行为优化
|
||||
- [ ] 编辑功能迁移
|
||||
- [ ] Example 添加流程优化
|
||||
- [ ] Rail 上的 "+" 按钮
|
||||
- [ ] Inline Upload Area
|
||||
- [ ] 拖拽上传支持
|
||||
- [ ] Model 切换动画优化
|
||||
- [ ] 淡入淡出效果
|
||||
- [ ] 加载状态指示
|
||||
- [ ] 首次使用提示
|
||||
|
||||
**验收标准**:
|
||||
- [ ] Accordion 交互流畅
|
||||
- [ ] 添加 Example 操作路径 < 2 步
|
||||
- [ ] Model 切换视觉反馈清晰
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 功能完整化 (预计 1-2 周)
|
||||
|
||||
**目标**: 所有现有功能迁移完成
|
||||
|
||||
**任务清单**:
|
||||
|
||||
- [ ] Versions Tab 完整实现
|
||||
- [ ] 版本列表卡片
|
||||
- [ ] 下载/忽略/删除操作
|
||||
- [ ] 更新状态 Badge
|
||||
- [ ] Recipes Tab 完整实现
|
||||
- [ ] Recipe 卡片网格
|
||||
- [ ] 复制/应用操作
|
||||
- [ ] Tab 状态保持
|
||||
- [ ] 切换 Model 时保持当前 Tab
|
||||
- [ ] Tab 内容滚动位置记忆
|
||||
- [ ] 所有编辑功能迁移
|
||||
- [ ] Model Name 编辑
|
||||
- [ ] Base Model 编辑
|
||||
- [ ] File Name 编辑
|
||||
- [ ] Tags 编辑
|
||||
- [ ] Usage Tips 编辑
|
||||
- [ ] Notes 编辑
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 所有现有功能可用
|
||||
- [ ] 单元测试覆盖率 > 80%
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 打磨与优化 (预计 1 周)
|
||||
|
||||
**目标**: 性能优化,边缘 case 处理
|
||||
|
||||
**任务清单**:
|
||||
|
||||
- [ ] 移动端适配完善
|
||||
- [ ] Stack 布局优化
|
||||
- [ ] 触摸手势支持(滑动切换)
|
||||
- [ ] 性能优化
|
||||
- [ ] 图片懒加载优化
|
||||
- [ ] 虚拟滚动(大量 Examples 时)
|
||||
- [ ] 减少重渲染
|
||||
- [ ] 无障碍支持
|
||||
- [ ] ARIA 标签
|
||||
- [ ] 键盘导航焦点管理
|
||||
- [ ] 屏幕阅读器测试
|
||||
- [ ] 动画性能优化
|
||||
- [ ] will-change 优化
|
||||
- [ ] 减少 layout thrashing
|
||||
|
||||
**验收标准**:
|
||||
- [ ] Lighthouse Performance > 90
|
||||
- [ ] 无障碍检查无严重问题
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 发布准备 (预计 3-5 天)
|
||||
|
||||
**目标**: 稳定版本,文档完整
|
||||
|
||||
**任务清单**:
|
||||
|
||||
- [ ] Bug 修复
|
||||
- [ ] 用户测试
|
||||
- [ ] 更新文档
|
||||
- [ ] README 更新
|
||||
- [ ] 快捷键说明
|
||||
- [ ] 截图/GIF 演示
|
||||
- [ ] 发布说明
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险与应对
|
||||
|
||||
| 风险 | 影响 | 应对策略 |
|
||||
|------|------|----------|
|
||||
| 用户不适应新布局 | 中 | 提供设置选项,允许切换回旧版(临时) |
|
||||
| 性能问题(大量 Examples) | 高 | Phase 4 重点优化,必要时虚拟滚动 |
|
||||
| 移动端体验不佳 | 中 | 单独设计移动端布局,非简单缩放 |
|
||||
| 与现有扩展冲突 | 低 | 充分的回归测试 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 关联文件
|
||||
|
||||
### 6.1 需修改的现有文件
|
||||
|
||||
```
|
||||
static/js/components/shared/ModelModal.js # 完全重构
|
||||
static/js/components/shared/showcase/ # 迁移至新目录
|
||||
static/css/components/lora-modal/ # 样式重写
|
||||
static/css/components/modal/_base.css # Overlay 样式调整
|
||||
```
|
||||
|
||||
### 6.2 参考资源
|
||||
|
||||
- [Midjourney Explore](https://www.midjourney.com/explore) - 交互参考
|
||||
- [Pinterest Pin View](https://www.pinterest.com) - 布局参考
|
||||
- [AGENTS.md](/AGENTS.md) - 项目代码规范
|
||||
|
||||
---
|
||||
|
||||
## 7. Checklist
|
||||
|
||||
### 7.1 启动前
|
||||
|
||||
- [ ] 创建 feature branch: `feature/model-modal-redesign`
|
||||
- [ ] 设置开发环境
|
||||
- [ ] 准备测试数据集(多种 Model 类型)
|
||||
|
||||
### 7.2 每个 Phase 完成时
|
||||
|
||||
- [ ] 代码审查
|
||||
- [ ] 功能测试
|
||||
- [ ] 更新本文档状态
|
||||
|
||||
### 7.3 发布前
|
||||
|
||||
- [ ] 完整回归测试
|
||||
- [ ] 更新 CHANGELOG
|
||||
- [ ] 更新版本号
|
||||
|
||||
---
|
||||
|
||||
## 8. 附录
|
||||
|
||||
### 8.1 命名规范
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
|------|------|------|
|
||||
| 文件 | kebab-case | `thumbnail-rail.js` |
|
||||
| 组件 | PascalCase | `ThumbnailRail` |
|
||||
| CSS 类 | BEM | `.thumbnail-rail__item--active` |
|
||||
| 变量 | camelCase | `currentExampleIndex` |
|
||||
|
||||
### 8.2 颜色规范
|
||||
|
||||
使用现有 CSS 变量,不引入新颜色:
|
||||
|
||||
```css
|
||||
--lora-accent: #4299e1;
|
||||
--lora-accent-l: 60%;
|
||||
--lora-accent-c: 0.2;
|
||||
--lora-accent-h: 250;
|
||||
--lora-surface: var(--card-bg);
|
||||
--lora-border: var(--border-color);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2026-02-06*
|
||||
@@ -1,678 +0,0 @@
|
||||
# Backend Testing Improvement Plan
|
||||
|
||||
**Status:** Phase 4 Complete ✅
|
||||
**Created:** 2026-02-11
|
||||
**Updated:** 2026-02-11
|
||||
**Priority:** P0 - Critical
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines a comprehensive plan to improve the quality, coverage, and maintainability of the LoRa Manager backend test suite. Recent critical bugs (_handle_download_task_done and get_status methods missing) were not caught by existing tests, highlighting significant gaps in the testing strategy.
|
||||
|
||||
## Current State Assessment
|
||||
|
||||
### Test Statistics
|
||||
- **Total Python Test Files:** 80+
|
||||
- **Total JavaScript Test Files:** 29
|
||||
- **Test Lines of Code:** ~15,000
|
||||
- **Current Pass Rate:** 100% (but missing critical edge cases)
|
||||
|
||||
### Key Findings
|
||||
1. **Coverage Gaps:** Critical modules have no direct tests
|
||||
2. **Mocking Issues:** Over-mocking hides real bugs
|
||||
3. **Integration Deficit:** Missing end-to-end tests
|
||||
4. **Async Inconsistency:** Multiple patterns for async tests
|
||||
5. **Maintenance Burden:** Large, complex test files with duplication
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 Completion Summary (2026-02-11)
|
||||
|
||||
### Completed Items
|
||||
|
||||
1. **Integration Test Framework** ✅
|
||||
- Created `tests/integration/` directory structure
|
||||
- Added `tests/integration/conftest.py` with shared fixtures
|
||||
- Added `tests/integration/__init__.py` for package organization
|
||||
|
||||
2. **Download Flow Integration Tests** ✅
|
||||
- Created `tests/integration/test_download_flow.py` with 7 tests
|
||||
- Tests cover:
|
||||
- Download with mocked network (2 tests)
|
||||
- Progress broadcast verification (1 test)
|
||||
- Error handling (1 test)
|
||||
- Cancellation flow (1 test)
|
||||
- Concurrent download management (1 test)
|
||||
- Route endpoint validation (1 test)
|
||||
|
||||
3. **Recipe Flow Integration Tests** ✅
|
||||
- Created `tests/integration/test_recipe_flow.py` with 9 tests
|
||||
- Tests cover:
|
||||
- Recipe save and retrieve flow (1 test)
|
||||
- Recipe update flow (1 test)
|
||||
- Recipe delete flow (1 test)
|
||||
- Recipe model extraction (1 test)
|
||||
- Generation parameters handling (1 test)
|
||||
- Concurrent recipe reads (1 test)
|
||||
- Concurrent read/write operations (1 test)
|
||||
- Recipe list endpoint (1 test)
|
||||
- Recipe metadata parsing (1 test)
|
||||
|
||||
4. **ModelLifecycleService Coverage** ✅
|
||||
- Added 12 new tests to `tests/services/test_model_lifecycle_service.py`
|
||||
- Tests cover:
|
||||
- `exclude_model` functionality (3 tests)
|
||||
- `bulk_delete_models` functionality (2 tests)
|
||||
- Error path tests (5 tests)
|
||||
- `_extract_model_id_from_payload` utility (3 tests)
|
||||
- Total: 18 tests (up from 6)
|
||||
|
||||
5. **PersistentRecipeCache Concurrent Access** ✅
|
||||
- Added 5 new concurrent access tests to `tests/test_persistent_recipe_cache.py`
|
||||
- Tests cover:
|
||||
- Concurrent reads without corruption (1 test)
|
||||
- Concurrent write and read operations (1 test)
|
||||
- Concurrent updates to same recipe (1 test)
|
||||
- Schema initialization thread safety (1 test)
|
||||
- Concurrent save and remove operations (1 test)
|
||||
- Total: 17 tests (up from 12)
|
||||
|
||||
### Test Results
|
||||
- **Integration Tests:** 16/16 passing
|
||||
- **ModelLifecycleService Tests:** 18/18 passing
|
||||
- **PersistentRecipeCache Tests:** 17/17 passing
|
||||
- **Total New Tests Added:** 28 tests
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 Completion Summary (2026-02-11)
|
||||
|
||||
### Completed Items
|
||||
|
||||
1. **pytest-asyncio Integration** ✅
|
||||
- Added `pytest-asyncio>=0.21.0` to `requirements-dev.txt`
|
||||
- Updated `pytest.ini` with `asyncio_mode = auto` and `asyncio_default_fixture_loop_scope = function`
|
||||
- Removed custom `pytest_pyfunc_call` handler from `tests/conftest.py`
|
||||
- Added `@pytest.mark.asyncio` decorator to 21 async test functions in `tests/services/test_download_manager.py`
|
||||
|
||||
2. **Error Path Tests** ✅
|
||||
- Created `tests/services/test_downloader_error_paths.py` with 19 new tests
|
||||
- Tests cover:
|
||||
- DownloadStreamControl state management (6 tests)
|
||||
- Downloader configuration and initialization (4 tests)
|
||||
- DownloadProgress dataclass (1 test)
|
||||
- Custom exceptions (2 tests)
|
||||
- Authentication headers (3 tests)
|
||||
- Session management (3 tests)
|
||||
|
||||
3. **Test Results**
|
||||
- All 45 tests pass (26 in test_download_manager.py + 19 in test_downloader_error_paths.py)
|
||||
- No regressions introduced
|
||||
|
||||
### Notes
|
||||
- Over-mocking fix in `test_download_manager.py` deferred to Phase 2 as it requires significant refactoring
|
||||
- Error path tests focus on unit-level testing of downloader components rather than complex integration scenarios
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Critical Fixes (P0) - Week 1-2
|
||||
|
||||
### 1.1 Fix Over-Mocking Issues
|
||||
|
||||
**Problem:** Tests mock the methods they purport to test, hiding real bugs.
|
||||
|
||||
**Affected Files:**
|
||||
- `tests/services/test_download_manager.py` - Mocks `_execute_download`
|
||||
- `tests/utils/test_example_images_download_manager_unit.py` - Mocks callbacks
|
||||
- `tests/routes/test_base_model_routes_smoke.py` - Uses fake service stubs
|
||||
|
||||
**Actions:**
|
||||
1. Refactor `test_download_manager.py` to test actual download logic
|
||||
2. Replace method-level mocks with dependency injection
|
||||
3. Add integration tests that verify real behavior
|
||||
|
||||
**Example Fix:**
|
||||
```python
|
||||
# BEFORE (Bad - mocks method under test)
|
||||
async def fake_execute_download(self, **kwargs):
|
||||
return {"success": True}
|
||||
monkeypatch.setattr(DownloadManager, "_execute_download", fake_execute_download)
|
||||
|
||||
# AFTER (Good - tests actual logic with injected dependencies)
|
||||
async def test_download_executes_with_real_logic(
|
||||
tmp_path, mock_downloader, mock_websocket
|
||||
):
|
||||
manager = DownloadManager(
|
||||
downloader=mock_downloader,
|
||||
ws_manager=mock_websocket
|
||||
)
|
||||
result = await manager._execute_download(urls=["http://test.com/file.safetensors"])
|
||||
assert result.success is True
|
||||
assert mock_downloader.download_calls == 1
|
||||
```
|
||||
|
||||
### 1.2 Add Missing Error Path Tests
|
||||
|
||||
**Problem:** Error handling code is not tested, leading to production failures.
|
||||
|
||||
**Required Tests:**
|
||||
|
||||
| Error Type | Module | Priority |
|
||||
|------------|--------|----------|
|
||||
| Network timeout | `downloader.py` | P0 |
|
||||
| Disk full | `download_manager.py` | P0 |
|
||||
| Permission denied | `example_images_download_manager.py` | P0 |
|
||||
| Session refresh failure | `downloader.py` | P1 |
|
||||
| Partial file cleanup | `download_manager.py` | P1 |
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_handles_network_timeout():
|
||||
"""Verify download retries on timeout and eventually fails gracefully."""
|
||||
# Arrange
|
||||
downloader = Downloader()
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get.side_effect = asyncio.TimeoutError()
|
||||
|
||||
# Act
|
||||
success, message = await downloader.download_file(
|
||||
url="http://test.com/file.safetensors",
|
||||
target_path=tmp_path / "test.safetensors",
|
||||
session=mock_session
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert success is False
|
||||
assert "timeout" in message.lower()
|
||||
assert mock_session.get.call_count == MAX_RETRIES
|
||||
```
|
||||
|
||||
### 1.3 Standardize Async Test Patterns
|
||||
|
||||
**Problem:** Inconsistent async test patterns across codebase.
|
||||
|
||||
**Current State:**
|
||||
- Some use `@pytest.mark.asyncio`
|
||||
- Some rely on custom `pytest_pyfunc_call` in conftest.py
|
||||
- Some use bare async functions
|
||||
|
||||
**Solution:**
|
||||
1. Add `pytest-asyncio` to requirements-dev.txt
|
||||
2. Update `pytest.ini`:
|
||||
```ini
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
```
|
||||
3. Remove custom `pytest_pyfunc_call` handler from conftest.py
|
||||
4. Bulk update all async tests to use `@pytest.mark.asyncio`
|
||||
|
||||
**Migration Script:**
|
||||
```bash
|
||||
# Find all async test functions missing decorator
|
||||
rg "^async def test_" tests/ --type py -A1 | grep -B1 "@pytest.mark" | grep "async def"
|
||||
|
||||
# Add decorator (manual review required)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Integration & Coverage (P1) - Week 3-4
|
||||
|
||||
### 2.1 Add Critical Module Tests
|
||||
|
||||
**Priority 1: `py/services/model_lifecycle_service.py`**
|
||||
```python
|
||||
# tests/services/test_model_lifecycle_service.py
|
||||
class TestModelLifecycleService:
|
||||
async def test_create_model_registers_in_cache(self):
|
||||
"""Verify new model is registered in both cache and database."""
|
||||
|
||||
async def test_delete_model_cleans_up_files_and_cache(self):
|
||||
"""Verify deletion removes files and updates all indexes."""
|
||||
|
||||
async def test_update_model_metadata_propagates_changes(self):
|
||||
"""Verify metadata updates reach all subscribers."""
|
||||
```
|
||||
|
||||
**Priority 2: `py/services/persistent_recipe_cache.py`**
|
||||
```python
|
||||
# tests/services/test_persistent_recipe_cache.py
|
||||
class TestPersistentRecipeCache:
|
||||
def test_initialization_creates_schema(self):
|
||||
"""Verify SQLite schema is created on first use."""
|
||||
|
||||
async def test_save_recipe_persists_to_sqlite(self):
|
||||
"""Verify recipe data is saved correctly."""
|
||||
|
||||
async def test_concurrent_access_does_not_corrupt_database(self):
|
||||
"""Verify thread safety under concurrent writes."""
|
||||
```
|
||||
|
||||
**Priority 3: Route Handler Tests**
|
||||
- `py/routes/handlers/preview_handlers.py`
|
||||
- `py/routes/handlers/misc_handlers.py`
|
||||
- `py/routes/handlers/model_handlers.py`
|
||||
|
||||
### 2.2 Add End-to-End Integration Tests
|
||||
|
||||
**Download Flow Integration Test:**
|
||||
```python
|
||||
# tests/integration/test_download_flow.py
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_download_flow(tmp_path, test_server):
|
||||
"""
|
||||
Integration test covering:
|
||||
1. Route receives download request
|
||||
2. DownloadCoordinator schedules it
|
||||
3. DownloadManager executes actual download
|
||||
4. Downloader makes HTTP request (to test server)
|
||||
5. Progress is broadcast via WebSocket
|
||||
6. File is saved and cache updated
|
||||
"""
|
||||
# Setup test server with known file
|
||||
test_file = tmp_path / "test_model.safetensors"
|
||||
test_file.write_bytes(b"fake model data")
|
||||
|
||||
# Start download
|
||||
async with aiohttp.ClientSession() as session:
|
||||
response = await session.post(
|
||||
"http://localhost:8188/api/lm/download",
|
||||
json={"urls": [f"http://localhost:{test_server.port}/test_model.safetensors"]}
|
||||
)
|
||||
assert response.status == 200
|
||||
|
||||
# Verify file downloaded
|
||||
downloaded = tmp_path / "downloads" / "test_model.safetensors"
|
||||
assert downloaded.exists()
|
||||
assert downloaded.read_bytes() == b"fake model data"
|
||||
|
||||
# Verify WebSocket progress updates
|
||||
assert len(ws_manager.broadcasts) > 0
|
||||
assert any(b["status"] == "completed" for b in ws_manager.broadcasts)
|
||||
```
|
||||
|
||||
**Recipe Flow Integration Test:**
|
||||
```python
|
||||
# tests/integration/test_recipe_flow.py
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_recipe_analysis_and_save_flow(tmp_path):
|
||||
"""
|
||||
Integration test covering:
|
||||
1. Import recipe from image
|
||||
2. Parse metadata and extract models
|
||||
3. Save to cache and database
|
||||
4. Retrieve and display
|
||||
"""
|
||||
```
|
||||
|
||||
### 2.3 Strengthen Assertions
|
||||
|
||||
**Replace loose assertions:**
|
||||
```python
|
||||
# BEFORE
|
||||
assert "mismatch" in message.lower()
|
||||
|
||||
# AFTER
|
||||
assert message == "File size mismatch. Expected: 1000 bytes, Got: 500 bytes"
|
||||
assert not target_path.exists()
|
||||
assert not Path(str(target_path) + ".part").exists()
|
||||
assert len(downloader.retry_history) == 3
|
||||
```
|
||||
|
||||
**Add state verification:**
|
||||
```python
|
||||
# BEFORE
|
||||
assert result is True
|
||||
|
||||
# AFTER
|
||||
assert result is True
|
||||
assert model["status"] == "downloaded"
|
||||
assert model["file_path"].exists()
|
||||
assert cache.get_by_hash(model["sha256"]) is not None
|
||||
assert len(ws_manager.payloads) >= 2 # Started + completed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 Completion Summary (2026-02-11)
|
||||
|
||||
### Completed Items
|
||||
|
||||
1. **Property-Based Tests (Hypothesis)** ✅
|
||||
- Created `tests/utils/test_utils_hypothesis.py` with 19 property-based tests
|
||||
- Tests cover:
|
||||
- `sanitize_folder_name` idempotency and invalid character handling (4 tests)
|
||||
- `_sanitize_library_name` idempotency and safe character filtering (2 tests)
|
||||
- `normalize_path` idempotency and forward slash usage (2 tests)
|
||||
- `fuzzy_match` edge cases and threshold behavior (3 tests)
|
||||
- `determine_base_model` return type guarantees (2 tests)
|
||||
- `get_preview_extension` return type validation (2 tests)
|
||||
- `calculate_recipe_fingerprint` determinism and ordering (4 tests)
|
||||
- Fixed Hypothesis plugin compatibility issue by creating a `MockModule` class in `conftest.py` that is hashable (unlike `types.SimpleNamespace`)
|
||||
|
||||
2. **Snapshot Tests (Syrupy)** ✅
|
||||
- Created `tests/routes/test_api_snapshots.py` with 7 snapshot tests
|
||||
- Tests cover:
|
||||
- SettingsHandler response formats (2 tests)
|
||||
- NodeRegistryHandler response formats (2 tests)
|
||||
- Utility function output verification (2 tests)
|
||||
- ModelLibraryHandler empty response format (1 test)
|
||||
- All snapshots generated and tests passing (7/7)
|
||||
|
||||
3. **Performance Benchmarks** ✅
|
||||
- Created `tests/performance/test_cache_performance.py` with 11 benchmark tests
|
||||
- Tests cover:
|
||||
- Hash index lookup performance (100, 1K, 10K models) - 3 tests
|
||||
- Hash index add entry performance (100, 10K existing) - 2 tests
|
||||
- Fuzzy matching performance (short text, long text, many words) - 3 tests
|
||||
- Recipe fingerprint calculation (5, 50, 200 LoRAs) - 3 tests
|
||||
- All benchmarks passing with performance metrics (11/11)
|
||||
|
||||
4. **Package Dependencies** ✅
|
||||
- Added `hypothesis>=6.0` to `requirements-dev.txt`
|
||||
- Added `syrupy>=5.0` to `requirements-dev.txt`
|
||||
- Added `pytest-benchmark>=5.0` to `requirements-dev.txt`
|
||||
|
||||
### Test Results
|
||||
- **Property-Based Tests:** 19/19 passing
|
||||
- **Snapshot Tests:** 7/7 passing
|
||||
- **Performance Benchmarks:** 11/11 passing
|
||||
- **Total New Tests Added:** 37 tests
|
||||
- **Full Test Suite:** 947/947 passing
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 Completion Summary (2026-02-11)
|
||||
|
||||
### Completed Items
|
||||
|
||||
1. **Centralized Test Fixtures** ✅
|
||||
- Added `mock_downloader` fixture to `tests/conftest.py`
|
||||
- Configurable mock with `should_fail` and `return_value` attributes
|
||||
- Records all download calls for verification
|
||||
- Added `mock_websocket_manager` fixture to `tests/conftest.py`
|
||||
- Recording WebSocket manager that captures all broadcast payloads
|
||||
- Includes helper method `get_payloads_by_type()` for filtering
|
||||
- Added `reset_singletons` autouse fixture to `tests/conftest.py`
|
||||
- Resets DownloadManager, ServiceRegistry, ModelScanner, and SettingsManager
|
||||
- Ensures test isolation and prevents singleton pollution
|
||||
|
||||
2. **Split Large Test Files** ✅
|
||||
- Split `tests/services/test_download_manager.py` (1422 lines) into:
|
||||
- `test_download_manager_basic.py` - Core functionality (12 tests)
|
||||
- `test_download_manager_error.py` - Error handling and execution (15 tests)
|
||||
- `test_download_manager_concurrent.py` - Advanced scenarios (6 tests)
|
||||
- Split `tests/utils/test_cache_paths.py` (530 lines) into:
|
||||
- `test_cache_paths_resolution.py` - Path resolution and CacheType tests (11 tests)
|
||||
- `test_cache_paths_validation.py` - Legacy path validation and cleanup (9 tests)
|
||||
- `test_cache_paths_migration.py` - Migration scenarios and auto-cleanup (9 tests)
|
||||
|
||||
3. **Complex Test Refactoring** ✅
|
||||
- Reviewed `test_example_images_download_manager_unit.py`
|
||||
- Existing async event-based patterns are appropriate for testing concurrent behavior
|
||||
- No refactoring needed - tests follow consistent patterns and are maintainable
|
||||
|
||||
### Test Results
|
||||
- **Download Manager Tests:** 33/33 passing across 3 files
|
||||
- **Cache Paths Tests:** 29/29 passing across 3 files
|
||||
- **Total Tests Maintained:** All existing tests preserved and organized
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Architecture & Maintainability (P2) - Week 5-6
|
||||
|
||||
### 3.1 Centralize Test Fixtures
|
||||
|
||||
**Create `tests/conftest.py` improvements:**
|
||||
|
||||
```python
|
||||
# tests/conftest.py additions
|
||||
|
||||
@pytest.fixture
|
||||
def mock_downloader():
|
||||
"""Provide a configurable mock downloader."""
|
||||
class MockDownloader:
|
||||
def __init__(self):
|
||||
self.download_calls = []
|
||||
self.should_fail = False
|
||||
|
||||
async def download_file(self, url, target_path, **kwargs):
|
||||
self.download_calls.append({"url": url, "target_path": target_path})
|
||||
if self.should_fail:
|
||||
return False, "Download failed"
|
||||
return True, str(target_path)
|
||||
|
||||
return MockDownloader()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_websocket_manager():
|
||||
"""Provide a recording WebSocket manager."""
|
||||
class RecordingWebSocketManager:
|
||||
def __init__(self):
|
||||
self.payloads = []
|
||||
|
||||
async def broadcast(self, payload):
|
||||
self.payloads.append(payload)
|
||||
|
||||
return RecordingWebSocketManager()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_scanner():
|
||||
"""Provide a mock model scanner with configurable cache."""
|
||||
# ... existing MockScanner but improved ...
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singletons():
|
||||
"""Reset all singletons before each test."""
|
||||
# Centralized singleton reset
|
||||
DownloadManager._instance = None
|
||||
ServiceRegistry.clear_services()
|
||||
ModelScanner._instances.clear()
|
||||
yield
|
||||
# Cleanup
|
||||
DownloadManager._instance = None
|
||||
ServiceRegistry.clear_services()
|
||||
ModelScanner._instances.clear()
|
||||
```
|
||||
|
||||
### 3.2 Split Large Test Files
|
||||
|
||||
**Target Files:**
|
||||
- `tests/services/test_download_manager.py` (1000+ lines) → Split into:
|
||||
- `test_download_manager_basic.py` - Core functionality
|
||||
- `test_download_manager_error.py` - Error handling
|
||||
- `test_download_manager_concurrent.py` - Concurrent operations
|
||||
|
||||
- `tests/utils/test_cache_paths.py` (529 lines) → Split into:
|
||||
- `test_cache_paths_resolution.py`
|
||||
- `test_cache_paths_validation.py`
|
||||
- `test_cache_paths_migration.py`
|
||||
|
||||
### 3.3 Refactor Complex Tests
|
||||
|
||||
**Example: Simplify test setup in `test_example_images_download_manager_unit.py`**
|
||||
|
||||
**Current (Complex):**
|
||||
```python
|
||||
async def test_start_download_bootstraps_progress_and_task(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
# 40+ lines of setup
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def fake_download(self, ...):
|
||||
started.set()
|
||||
await release.wait()
|
||||
# ... more logic ...
|
||||
```
|
||||
|
||||
**Improved (Using fixtures):**
|
||||
```python
|
||||
async def test_start_download_bootstraps_progress_and_task(
|
||||
download_manager_with_fake_backend, release_event
|
||||
):
|
||||
# Setup in fixtures, test is clean
|
||||
manager = download_manager_with_fake_backend
|
||||
result = await manager.start_download({"model_types": ["lora"]})
|
||||
assert result["success"] is True
|
||||
assert manager._is_downloading is True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Advanced Testing (P3) - Week 7-8
|
||||
|
||||
### 4.1 Add Property-Based Tests (Hypothesis)
|
||||
|
||||
**Install:** `pip install hypothesis`
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# tests/utils/test_hash_utils_hypothesis.py
|
||||
from hypothesis import given, strategies as st
|
||||
|
||||
@given(st.text(min_size=1, max_size=100))
|
||||
def test_hash_normalization_idempotent(name):
|
||||
"""Hash normalization should be idempotent."""
|
||||
normalized = normalize_hash(name)
|
||||
assert normalize_hash(normalized) == normalized
|
||||
|
||||
@given(st.lists(st.dictionaries(st.text(), st.text()), min_size=0, max_size=1000))
|
||||
def test_model_cache_handles_any_model_list(models):
|
||||
"""Cache should handle any list of models without crashing."""
|
||||
cache = ModelCache()
|
||||
cache.raw_data = models
|
||||
# Should not raise
|
||||
list(cache.iter_models())
|
||||
```
|
||||
|
||||
### 4.2 Add Snapshot Tests (Syrupy)
|
||||
|
||||
**Install:** `pip install syrupy`
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# tests/routes/test_api_snapshots.py
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lora_list_response_format(snapshot, client):
|
||||
"""Verify API response format matches snapshot."""
|
||||
response = await client.get("/api/lm/loras")
|
||||
data = await response.json()
|
||||
assert data == snapshot # Syrupy handles this
|
||||
```
|
||||
|
||||
### 4.3 Add Performance Benchmarks
|
||||
|
||||
**Install:** `pip install pytest-benchmark`
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# tests/performance/test_cache_performance.py
|
||||
import pytest
|
||||
|
||||
def test_cache_lookup_performance(benchmark):
|
||||
"""Benchmark cache lookup with 10,000 models."""
|
||||
cache = create_cache_with_n_models(10000)
|
||||
|
||||
result = benchmark(lambda: cache.get_by_hash("abc123"))
|
||||
# Benchmark automatically collects timing stats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Week 1-2: Critical Fixes
|
||||
- [x] Fix over-mocking in `test_download_manager.py` (Skipped - requires major refactoring, see Phase 2)
|
||||
- [x] Add network timeout tests (Added `test_downloader_error_paths.py` with 19 error path tests)
|
||||
- [x] Add disk full error tests (Covered in error path tests)
|
||||
- [x] Add permission denied tests (Covered in error path tests)
|
||||
- [x] Install and configure pytest-asyncio (Added to requirements-dev.txt and pytest.ini)
|
||||
- [x] Remove custom pytest_pyfunc_call handler (Removed from conftest.py)
|
||||
- [x] Add `@pytest.mark.asyncio` to all async tests (Added to 21 async test functions in test_download_manager.py)
|
||||
|
||||
### Week 3-4: Integration & Coverage
|
||||
- [x] Create `test_model_lifecycle_service.py` tests (12 new tests added)
|
||||
- [x] Create `test_persistent_recipe_cache.py` tests (5 new concurrent access tests added)
|
||||
- [x] Create `tests/integration/` directory (created with conftest.py)
|
||||
- [x] Add download flow integration test (7 tests added)
|
||||
- [x] Add recipe flow integration test (9 tests added)
|
||||
- [x] Add route handler tests for preview_handlers.py (already exists in test_preview_routes.py)
|
||||
- [x] Strengthen assertions across integration tests (comprehensive assertions added)
|
||||
|
||||
### Week 5-6: Architecture
|
||||
- [x] Add centralized fixtures to conftest.py
|
||||
- [x] Split `test_download_manager.py` into 3 files
|
||||
- [x] Split `test_cache_paths.py` into 3 files
|
||||
- [x] Refactor complex test setups (reviewed - no changes needed)
|
||||
- [x] Remove duplicate singleton reset fixtures (consolidated in conftest.py)
|
||||
|
||||
### Week 7-8: Advanced Testing
|
||||
- [x] Install hypothesis (Added to requirements-dev.txt)
|
||||
- [x] Add 10 property-based tests (Created 19 tests in test_utils_hypothesis.py)
|
||||
- [x] Install syrupy (Added to requirements-dev.txt)
|
||||
- [x] Add 5 snapshot tests (Created 7 tests in test_api_snapshots.py)
|
||||
- [x] Install pytest-benchmark (Added to requirements-dev.txt)
|
||||
- [x] Add 3 performance benchmarks (Created 11 tests in test_cache_performance.py)
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Quantitative
|
||||
- **Code Coverage:** Increase from ~70% to >90%
|
||||
- **Test Count:** Increase from 400+ to 600+
|
||||
- **Assertion Strength:** Replace 50+ weak assertions
|
||||
- **Integration Test Ratio:** Increase from 5% to 20%
|
||||
|
||||
### Qualitative
|
||||
- **Bug Escape Rate:** Reduce by 80%
|
||||
- **Test Maintenance Time:** Reduce by 50%
|
||||
- **Time to Write New Tests:** Reduce by 30%
|
||||
- **CI Pipeline Speed:** Maintain <5 minutes
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Breaking existing tests | Run full test suite after each change |
|
||||
| Increased CI time | Optimize tests, parallelize execution |
|
||||
| Developer resistance | Provide training, pair programming |
|
||||
| Maintenance burden | Document patterns, provide templates |
|
||||
| Coverage gaps | Use coverage.py in CI, fail on <90% |
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- `docs/testing/frontend-testing-roadmap.md` - Frontend testing plan
|
||||
- `docs/AGENTS.md` - Development guidelines
|
||||
- `pytest.ini` - Test configuration
|
||||
- `tests/conftest.py` - Shared fixtures
|
||||
|
||||
---
|
||||
|
||||
## Approval
|
||||
|
||||
| Role | Name | Date | Signature |
|
||||
|------|------|------|-----------|
|
||||
| Tech Lead | | | |
|
||||
| QA Lead | | | |
|
||||
| Product Owner | | | |
|
||||
|
||||
---
|
||||
|
||||
**Next Review Date:** 2026-02-25
|
||||
|
||||
**Document Owner:** Backend Team
|
||||
@@ -131,8 +131,7 @@
|
||||
},
|
||||
"badges": {
|
||||
"update": "Update",
|
||||
"updateAvailable": "Update verfügbar",
|
||||
"skipRefresh": "Metadaten-Aktualisierung übersprungen"
|
||||
"updateAvailable": "Update verfügbar"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Verwendungsanzahl"
|
||||
@@ -292,15 +291,6 @@
|
||||
"saveFailed": "Fehler beim Speichern der Ausschlüsse: {message}"
|
||||
}
|
||||
},
|
||||
"metadataRefreshSkipPaths": {
|
||||
"label": "Metadaten-Aktualisierung: Übersprungene Pfade",
|
||||
"placeholder": "Beispiel: temp, archived/old, test_models",
|
||||
"help": "Modelle in diesen Verzeichnispfaden bei der Massenaktualisierung der Metadaten (\"Alle Metadaten abrufen\") überspringen. Geben Sie Ordnerpfade relativ zum Modell-Stammverzeichnis ein, getrennt durch Kommas.",
|
||||
"validation": {
|
||||
"noPaths": "Geben Sie mindestens einen durch Kommas getrennten Pfad ein.",
|
||||
"saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Anzeige-Dichte",
|
||||
"displayDensityOptions": {
|
||||
@@ -426,10 +416,6 @@
|
||||
"any": "Jede verfügbare Aktualisierung markieren"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "Früher Zugriff Updates ausblenden",
|
||||
"help": "Nur Early-Access-Updates"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
|
||||
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen"
|
||||
@@ -541,12 +527,8 @@
|
||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||
"moveAll": "Alle in Ordner verschieben",
|
||||
"autoOrganize": "Automatisch organisieren",
|
||||
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
||||
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
||||
"deleteAll": "Alle Modelle löschen",
|
||||
"clear": "Auswahl löschen",
|
||||
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
||||
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Automatische Organisation wird initialisiert...",
|
||||
"starting": "Automatische Organisation für {type} wird gestartet...",
|
||||
@@ -1035,19 +1017,12 @@
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Unbenannte Version",
|
||||
"noDetails": "Keine zusätzlichen Details",
|
||||
"earlyAccess": "EA"
|
||||
},
|
||||
"eaTime": {
|
||||
"endingSoon": "bald endend",
|
||||
"hours": "in {count}h",
|
||||
"days": "in {count}d"
|
||||
"noDetails": "Keine zusätzlichen Details"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Aktuelle Version",
|
||||
"inLibrary": "In der Bibliothek",
|
||||
"newer": "Neuere Version",
|
||||
"earlyAccess": "Früher Zugriff",
|
||||
"ignored": "Ignoriert"
|
||||
},
|
||||
"actions": {
|
||||
@@ -1055,7 +1030,6 @@
|
||||
"delete": "Löschen",
|
||||
"ignore": "Ignorieren",
|
||||
"unignore": "Ignorierung aufheben",
|
||||
"earlyAccessTooltip": "Erfordert Early-Access-Kauf",
|
||||
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
|
||||
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
|
||||
"viewLocalVersions": "Alle lokalen Versionen anzeigen",
|
||||
@@ -1405,11 +1379,6 @@
|
||||
"bulkBaseModelUpdateSuccess": "Basis-Modell erfolgreich für {count} Modell(e) aktualisiert",
|
||||
"bulkBaseModelUpdatePartial": "{success} Modelle aktualisiert, {failed} fehlgeschlagen",
|
||||
"bulkBaseModelUpdateFailed": "Aktualisierung des Basis-Modells für ausgewählte Modelle fehlgeschlagen",
|
||||
"skipMetadataRefreshUpdating": "Aktualisiere Metadaten-Aktualisierungs-Flag für {count} Modell(e)...",
|
||||
"skipMetadataRefreshSet": "Metadaten-Aktualisierung für {count} Modell(e) übersprungen",
|
||||
"skipMetadataRefreshCleared": "Metadaten-Aktualisierung für {count} Modell(e) fortgesetzt",
|
||||
"skipMetadataRefreshPartial": "{success} Modell(e) aktualisiert, {failed} fehlgeschlagen",
|
||||
"skipMetadataRefreshFailed": "Fehler beim Aktualisieren des Metadaten-Aktualisierungs-Flags für ausgewählte Modelle",
|
||||
"bulkContentRatingUpdating": "Inhaltsbewertung wird für {count} Modell(e) aktualisiert...",
|
||||
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
||||
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
||||
@@ -1497,7 +1466,6 @@
|
||||
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
|
||||
"folderTreeError": "Fehler beim Laden des Ordnerbaums",
|
||||
"imagesImported": "Beispielbilder erfolgreich importiert",
|
||||
"imagesPartial": "{success} Bild(er) importiert, {failed} fehlgeschlagen",
|
||||
"importFailed": "Fehler beim Importieren der Beispielbilder: {message}"
|
||||
},
|
||||
"triggerWords": {
|
||||
|
||||
@@ -131,8 +131,7 @@
|
||||
},
|
||||
"badges": {
|
||||
"update": "Update",
|
||||
"updateAvailable": "Update available",
|
||||
"skipRefresh": "Metadata refresh skipped"
|
||||
"updateAvailable": "Update available"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Times used"
|
||||
@@ -292,15 +291,6 @@
|
||||
"saveFailed": "Unable to save exclusions: {message}"
|
||||
}
|
||||
},
|
||||
"metadataRefreshSkipPaths": {
|
||||
"label": "Metadata refresh skip paths",
|
||||
"placeholder": "Example: temp, archived/old, test_models",
|
||||
"help": "Skip models in these directory paths during bulk metadata refresh (\"Fetch All Metadata\"). Enter folder paths relative to your model root directory, separated by commas.",
|
||||
"validation": {
|
||||
"noPaths": "Enter at least one path separated by commas.",
|
||||
"saveFailed": "Unable to save skip paths: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Display Density",
|
||||
"displayDensityOptions": {
|
||||
@@ -426,10 +416,6 @@
|
||||
"any": "Flag any available update"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "Hide Early Access Updates",
|
||||
"help": "When enabled, models with only early access updates will not show 'Update available' badge"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
||||
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard"
|
||||
@@ -541,12 +527,8 @@
|
||||
"checkUpdates": "Check Updates for Selected",
|
||||
"moveAll": "Move Selected to Folder",
|
||||
"autoOrganize": "Auto-Organize Selected",
|
||||
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
|
||||
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
|
||||
"deleteAll": "Delete Selected Models",
|
||||
"clear": "Clear Selection",
|
||||
"skipMetadataRefreshCount": "Skip ({count} models)",
|
||||
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Initializing auto-organize...",
|
||||
"starting": "Starting auto-organize for {type}...",
|
||||
@@ -929,7 +911,12 @@
|
||||
"viewOnCivitai": "View on Civitai",
|
||||
"viewOnCivitaiText": "View on Civitai",
|
||||
"viewCreatorProfile": "View Creator Profile",
|
||||
"openFileLocation": "Open File Location"
|
||||
"openFileLocation": "Open File Location",
|
||||
"viewParams": "View parameters",
|
||||
"setPreview": "Set as preview",
|
||||
"previewSet": "Preview updated successfully",
|
||||
"previewFailed": "Failed to update preview",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "File location opened successfully",
|
||||
@@ -948,13 +935,15 @@
|
||||
"additionalNotes": "Additional Notes",
|
||||
"notesHint": "Press Enter to save, Shift+Enter for new line",
|
||||
"addNotesPlaceholder": "Add your notes here...",
|
||||
"aboutThisVersion": "About this version"
|
||||
"aboutThisVersion": "About this version",
|
||||
"triggerWords": "Trigger Words"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Notes saved successfully",
|
||||
"saveFailed": "Failed to save notes"
|
||||
},
|
||||
"usageTips": {
|
||||
"add": "Add",
|
||||
"addPresetParameter": "Add preset parameter...",
|
||||
"strengthMin": "Strength Min",
|
||||
"strengthMax": "Strength Max",
|
||||
@@ -963,17 +952,24 @@
|
||||
"clipStrength": "Clip Strength",
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "Value",
|
||||
"add": "Add",
|
||||
"invalidRange": "Invalid range format. Use x.x-y.y"
|
||||
},
|
||||
"params": {
|
||||
"title": "Generation Parameters",
|
||||
"prompt": "Prompt",
|
||||
"negativePrompt": "Negative Prompt",
|
||||
"noData": "No generation data available",
|
||||
"promptCopied": "Prompt copied to clipboard"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "Trigger Words",
|
||||
"noTriggerWordsNeeded": "No trigger word needed",
|
||||
"noTriggerWordsNeeded": "No trigger words needed",
|
||||
"edit": "Edit trigger words",
|
||||
"cancel": "Cancel editing",
|
||||
"save": "Save changes",
|
||||
"addPlaceholder": "Type to add or click suggestions below",
|
||||
"addPlaceholder": "Type to add trigger word...",
|
||||
"copyWord": "Copy trigger word",
|
||||
"copyAll": "Copy all trigger words",
|
||||
"deleteWord": "Delete trigger word",
|
||||
"suggestions": {
|
||||
"noSuggestions": "No suggestions available",
|
||||
@@ -983,6 +979,9 @@
|
||||
"wordSuggestions": "Word Suggestions",
|
||||
"wordsFound": "{count} words found",
|
||||
"loading": "Loading suggestions..."
|
||||
},
|
||||
"validation": {
|
||||
"duplicate": "This trigger word already exists"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
@@ -1008,7 +1007,11 @@
|
||||
"previousWithShortcut": "Previous model (←)",
|
||||
"nextWithShortcut": "Next model (→)",
|
||||
"noPrevious": "No previous model available",
|
||||
"noNext": "No next model available"
|
||||
"noNext": "No next model available",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"switchModel": "Switch model",
|
||||
"browseExamples": "Browse examples"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
@@ -1020,6 +1023,23 @@
|
||||
"noReLicense": "Same permissions required",
|
||||
"restrictionsLabel": "License restrictions"
|
||||
},
|
||||
"examples": {
|
||||
"add": "Add",
|
||||
"addFirst": "Add your first example",
|
||||
"dropFiles": "Drop files here or click to browse",
|
||||
"supportedFormats": "Supports: JPG, PNG, WEBP, MP4, WEBM",
|
||||
"uploading": "Uploading...",
|
||||
"uploadSuccess": "Example uploaded successfully",
|
||||
"uploadFailed": "Failed to upload example",
|
||||
"confirmDelete": "Delete this example image?",
|
||||
"deleted": "Example deleted successfully",
|
||||
"deleteFailed": "Failed to delete example",
|
||||
"title": "Example",
|
||||
"empty": "No example images available"
|
||||
},
|
||||
"accordion": {
|
||||
"modelDescription": "Model Description"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Loading example images...",
|
||||
"description": "Loading model description...",
|
||||
@@ -1035,19 +1055,12 @@
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Untitled Version",
|
||||
"noDetails": "No additional details",
|
||||
"earlyAccess": "EA"
|
||||
},
|
||||
"eaTime": {
|
||||
"endingSoon": "ending soon",
|
||||
"hours": "in {count}h",
|
||||
"days": "in {count}d"
|
||||
"noDetails": "No additional details"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Current Version",
|
||||
"inLibrary": "In Library",
|
||||
"newer": "Newer Version",
|
||||
"earlyAccess": "Early Access",
|
||||
"ignored": "Ignored"
|
||||
},
|
||||
"actions": {
|
||||
@@ -1055,7 +1068,6 @@
|
||||
"delete": "Delete",
|
||||
"ignore": "Ignore",
|
||||
"unignore": "Unignore",
|
||||
"earlyAccessTooltip": "Requires early access purchase",
|
||||
"resumeModelUpdates": "Resume updates for this model",
|
||||
"ignoreModelUpdates": "Ignore updates for this model",
|
||||
"viewLocalVersions": "View all local versions",
|
||||
@@ -1405,11 +1417,6 @@
|
||||
"bulkBaseModelUpdateSuccess": "Successfully updated base model for {count} model(s)",
|
||||
"bulkBaseModelUpdatePartial": "Updated {success} model(s), failed {failed} model(s)",
|
||||
"bulkBaseModelUpdateFailed": "Failed to update base model for selected models",
|
||||
"skipMetadataRefreshUpdating": "Updating metadata refresh flag for {count} model(s)...",
|
||||
"skipMetadataRefreshSet": "Metadata refresh skipped for {count} model(s)",
|
||||
"skipMetadataRefreshCleared": "Metadata refresh resumed for {count} model(s)",
|
||||
"skipMetadataRefreshPartial": "Updated {success} model(s), {failed} failed",
|
||||
"skipMetadataRefreshFailed": "Failed to update metadata refresh flag for selected models",
|
||||
"bulkContentRatingUpdating": "Updating content rating for {count} model(s)...",
|
||||
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
|
||||
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
|
||||
@@ -1497,7 +1504,6 @@
|
||||
"folderTreeFailed": "Failed to load folder tree",
|
||||
"folderTreeError": "Error loading folder tree",
|
||||
"imagesImported": "Example images imported successfully",
|
||||
"imagesPartial": "{success} image(s) imported, {failed} failed",
|
||||
"importFailed": "Failed to import example images: {message}"
|
||||
},
|
||||
"triggerWords": {
|
||||
|
||||
@@ -131,8 +131,7 @@
|
||||
},
|
||||
"badges": {
|
||||
"update": "Actualización",
|
||||
"updateAvailable": "Actualización disponible",
|
||||
"skipRefresh": "Actualización de metadatos omitida"
|
||||
"updateAvailable": "Actualización disponible"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Veces usado"
|
||||
@@ -292,15 +291,6 @@
|
||||
"saveFailed": "No se pudieron guardar las exclusiones: {message}"
|
||||
}
|
||||
},
|
||||
"metadataRefreshSkipPaths": {
|
||||
"label": "Rutas a omitir en la actualización de metadatos",
|
||||
"placeholder": "Ejemplo: temp, archived/old, test_models",
|
||||
"help": "Omitir modelos en estas rutas de directorio durante la actualización masiva de metadatos (\"Obtener todos los metadatos\"). Ingrese rutas de carpetas relativas al directorio raíz de modelos, separadas por comas.",
|
||||
"validation": {
|
||||
"noPaths": "Ingrese al menos una ruta separada por comas.",
|
||||
"saveFailed": "No se pudieron guardar las rutas a omitir: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Densidad de visualización",
|
||||
"displayDensityOptions": {
|
||||
@@ -426,10 +416,6 @@
|
||||
"any": "Marcar cualquier actualización disponible"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "Ocultar actualizaciones de acceso temprano",
|
||||
"help": "Solo actualizaciones de acceso temprano"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
|
||||
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles"
|
||||
@@ -541,12 +527,8 @@
|
||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||
"moveAll": "Mover todos a carpeta",
|
||||
"autoOrganize": "Auto-organizar seleccionados",
|
||||
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
||||
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
||||
"deleteAll": "Eliminar todos los modelos",
|
||||
"clear": "Limpiar selección",
|
||||
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
||||
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Inicializando auto-organización...",
|
||||
"starting": "Iniciando auto-organización para {type}...",
|
||||
@@ -1035,19 +1017,12 @@
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Versión sin nombre",
|
||||
"noDetails": "Sin detalles adicionales",
|
||||
"earlyAccess": "EA"
|
||||
},
|
||||
"eaTime": {
|
||||
"endingSoon": "terminando pronto",
|
||||
"hours": "en {count}h",
|
||||
"days": "en {count}d"
|
||||
"noDetails": "Sin detalles adicionales"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Versión actual",
|
||||
"inLibrary": "En la biblioteca",
|
||||
"newer": "Versión más reciente",
|
||||
"earlyAccess": "Acceso temprano",
|
||||
"ignored": "Ignorada"
|
||||
},
|
||||
"actions": {
|
||||
@@ -1055,7 +1030,6 @@
|
||||
"delete": "Eliminar",
|
||||
"ignore": "Ignorar",
|
||||
"unignore": "Dejar de ignorar",
|
||||
"earlyAccessTooltip": "Requiere compra de acceso temprano",
|
||||
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
|
||||
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
|
||||
"viewLocalVersions": "Ver todas las versiones locales",
|
||||
@@ -1405,11 +1379,6 @@
|
||||
"bulkBaseModelUpdateSuccess": "Modelo base actualizado exitosamente para {count} modelo(s)",
|
||||
"bulkBaseModelUpdatePartial": "Actualizados {success} modelo(s), fallaron {failed} modelo(s)",
|
||||
"bulkBaseModelUpdateFailed": "Error al actualizar el modelo base para los modelos seleccionados",
|
||||
"skipMetadataRefreshUpdating": "Actualizando flag de actualización de metadatos para {count} modelo(s)...",
|
||||
"skipMetadataRefreshSet": "Actualización de metadatos omitida para {count} modelo(s)",
|
||||
"skipMetadataRefreshCleared": "Actualización de metadatos reanudada para {count} modelo(s)",
|
||||
"skipMetadataRefreshPartial": "{success} modelo(s) actualizados, {failed} fallaron",
|
||||
"skipMetadataRefreshFailed": "Error al actualizar flag de actualización de metadatos para los modelos seleccionados",
|
||||
"bulkContentRatingUpdating": "Actualizando la clasificación de contenido para {count} modelo(s)...",
|
||||
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
|
||||
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
|
||||
@@ -1497,7 +1466,6 @@
|
||||
"folderTreeFailed": "Error al cargar árbol de carpetas",
|
||||
"folderTreeError": "Error al cargar árbol de carpetas",
|
||||
"imagesImported": "Imágenes de ejemplo importadas exitosamente",
|
||||
"imagesPartial": "{success} imagen(es) importada(s), {failed} fallida(s)",
|
||||
"importFailed": "Error al importar imágenes de ejemplo: {message}"
|
||||
},
|
||||
"triggerWords": {
|
||||
|
||||
@@ -131,8 +131,7 @@
|
||||
},
|
||||
"badges": {
|
||||
"update": "Mise à jour",
|
||||
"updateAvailable": "Mise à jour disponible",
|
||||
"skipRefresh": "Actualisation des métadonnées ignorée"
|
||||
"updateAvailable": "Mise à jour disponible"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Nombre d'utilisations"
|
||||
@@ -292,15 +291,6 @@
|
||||
"saveFailed": "Impossible d'enregistrer les exclusions : {message}"
|
||||
}
|
||||
},
|
||||
"metadataRefreshSkipPaths": {
|
||||
"label": "Chemins à ignorer pour l'actualisation des métadonnées",
|
||||
"placeholder": "Exemple : temp, archived/old, test_models",
|
||||
"help": "Ignorer les modèles dans ces chemins de répertoires lors de l'actualisation groupée des métadonnées (\"Récupérer toutes les métadonnées\"). Entrez les chemins de dossiers relatifs au répertoire racine des modèles, séparés par des virgules.",
|
||||
"validation": {
|
||||
"noPaths": "Entrez au moins un chemin séparé par des virgules.",
|
||||
"saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Densité d'affichage",
|
||||
"displayDensityOptions": {
|
||||
@@ -426,10 +416,6 @@
|
||||
"any": "Signaler n’importe quelle mise à jour disponible"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "Masquer les mises à jour en accès anticipé",
|
||||
"help": "Seulement les mises à jour en accès anticipé"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
|
||||
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers"
|
||||
@@ -541,12 +527,8 @@
|
||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||
"moveAll": "Déplacer tout vers un dossier",
|
||||
"autoOrganize": "Auto-organiser la sélection",
|
||||
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
|
||||
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
|
||||
"deleteAll": "Supprimer tous les modèles",
|
||||
"clear": "Effacer la sélection",
|
||||
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
||||
"resumeMetadataRefreshCount": "Reprendre({count} modèles)",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Initialisation de l'auto-organisation...",
|
||||
"starting": "Démarrage de l'auto-organisation pour {type}...",
|
||||
@@ -1035,19 +1017,12 @@
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Version sans nom",
|
||||
"noDetails": "Aucun détail supplémentaire",
|
||||
"earlyAccess": "EA"
|
||||
},
|
||||
"eaTime": {
|
||||
"endingSoon": "se termine bientôt",
|
||||
"hours": "dans {count}h",
|
||||
"days": "dans {count}j"
|
||||
"noDetails": "Aucun détail supplémentaire"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Version actuelle",
|
||||
"inLibrary": "Dans la bibliothèque",
|
||||
"newer": "Version plus récente",
|
||||
"earlyAccess": "Accès anticipé",
|
||||
"ignored": "Ignorée"
|
||||
},
|
||||
"actions": {
|
||||
@@ -1055,7 +1030,6 @@
|
||||
"delete": "Supprimer",
|
||||
"ignore": "Ignorer",
|
||||
"unignore": "Ne plus ignorer",
|
||||
"earlyAccessTooltip": "Nécessite l'achat de l'accès anticipé",
|
||||
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
|
||||
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
|
||||
"viewLocalVersions": "Voir toutes les versions locales",
|
||||
@@ -1405,11 +1379,6 @@
|
||||
"bulkBaseModelUpdateSuccess": "Modèle de base mis à jour avec succès pour {count} modèle(s)",
|
||||
"bulkBaseModelUpdatePartial": "{success} modèle(s) mis à jour, {failed} modèle(s) en échec",
|
||||
"bulkBaseModelUpdateFailed": "Échec de la mise à jour du modèle de base pour les modèles sélectionnés",
|
||||
"skipMetadataRefreshUpdating": "Mise à jour du flag d'actualisation des métadonnées pour {count} modèle(s)...",
|
||||
"skipMetadataRefreshSet": "Actualisation des métadonnées ignorée pour {count} modèle(s)",
|
||||
"skipMetadataRefreshCleared": "Actualisation des métadonnées reprise pour {count} modèle(s)",
|
||||
"skipMetadataRefreshPartial": "{success} modèle(s) mis à jour, {failed} échoué(s)",
|
||||
"skipMetadataRefreshFailed": "Échec de la mise à jour du flag d'actualisation des métadonnées pour les modèles sélectionnés",
|
||||
"bulkContentRatingUpdating": "Mise à jour de la classification du contenu pour {count} modèle(s)...",
|
||||
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
|
||||
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
|
||||
@@ -1497,7 +1466,6 @@
|
||||
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
|
||||
"folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers",
|
||||
"imagesImported": "Images d'exemple importées avec succès",
|
||||
"imagesPartial": "{success} image(s) importée(s), {failed} échouée(s)",
|
||||
"importFailed": "Échec de l'importation des images d'exemple : {message}"
|
||||
},
|
||||
"triggerWords": {
|
||||
|
||||
@@ -131,8 +131,7 @@
|
||||
},
|
||||
"badges": {
|
||||
"update": "עדכון",
|
||||
"updateAvailable": "עדכון זמין",
|
||||
"skipRefresh": "רענון המטא-נתונים דולג"
|
||||
"updateAvailable": "עדכון זמין"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "מספר שימושים"
|
||||
@@ -292,15 +291,6 @@
|
||||
"saveFailed": "לא ניתן לשמור את ההוצאות: {message}"
|
||||
}
|
||||
},
|
||||
"metadataRefreshSkipPaths": {
|
||||
"label": "נתיבים לדילוג ברענון מטא-נתונים",
|
||||
"placeholder": "דוגמה: temp, archived/old, test_models",
|
||||
"help": "דלג על מודלים בנתיבי תיקיות אלה בעת רענון מטא-נתונים המוני (\"אחזר את כל המטא-נתונים\"). הזן נתיבי תיקיות יחסית לספריית השורש של המודל, מופרדים בפסיקים.",
|
||||
"validation": {
|
||||
"noPaths": "הזן לפחות נתיב אחד מופרד בפסיקים.",
|
||||
"saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "צפיפות תצוגה",
|
||||
"displayDensityOptions": {
|
||||
@@ -426,10 +416,6 @@
|
||||
"any": "תוויות לכל עדכון זמין"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "הסתר עדכוני גישה מוקדמת",
|
||||
"help": "רק עדכוני גישה מוקדמת"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
|
||||
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח"
|
||||
@@ -541,12 +527,8 @@
|
||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||
"moveAll": "העבר הכל לתיקייה",
|
||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
||||
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
||||
"deleteAll": "מחק את כל המודלים",
|
||||
"clear": "נקה בחירה",
|
||||
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
||||
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "מאתחל ארגון אוטומטי...",
|
||||
"starting": "מתחיל ארגון אוטומטי עבור {type}...",
|
||||
@@ -1035,19 +1017,12 @@
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "גרסה ללא שם",
|
||||
"noDetails": "אין פרטים נוספים",
|
||||
"earlyAccess": "EA"
|
||||
},
|
||||
"eaTime": {
|
||||
"endingSoon": "מסתיים בקרוב",
|
||||
"hours": "בעוד {count} שעות",
|
||||
"days": "בעוד {count} ימים"
|
||||
"noDetails": "אין פרטים נוספים"
|
||||
},
|
||||
"badges": {
|
||||
"current": "גרסה נוכחית",
|
||||
"inLibrary": "בספרייה",
|
||||
"newer": "גרסה חדשה יותר",
|
||||
"earlyAccess": "גישה מוקדמת",
|
||||
"ignored": "התעלם"
|
||||
},
|
||||
"actions": {
|
||||
@@ -1055,7 +1030,6 @@
|
||||
"delete": "מחיקה",
|
||||
"ignore": "התעלם",
|
||||
"unignore": "בטל התעלמות",
|
||||
"earlyAccessTooltip": "נדרש רכישת גישה מוקדמת",
|
||||
"resumeModelUpdates": "המשך עדכונים עבור מודל זה",
|
||||
"ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה",
|
||||
"viewLocalVersions": "הצג את כל הגרסאות המקומיות",
|
||||
@@ -1405,11 +1379,6 @@
|
||||
"bulkBaseModelUpdateSuccess": "עודכן בהצלחה מודל הבסיס עבור {count} מודל(ים)",
|
||||
"bulkBaseModelUpdatePartial": "עודכנו {success} מודל(ים), נכשלו {failed} מודל(ים)",
|
||||
"bulkBaseModelUpdateFailed": "עדכון מודל הבסיס עבור המודלים שנבחרו נכשל",
|
||||
"skipMetadataRefreshUpdating": "מעדכן דגל רענון מטא-נתונים עבור {count} מודל(ים)...",
|
||||
"skipMetadataRefreshSet": "רענון מטא-נתונים דולג עבור {count} מודל(ים)",
|
||||
"skipMetadataRefreshCleared": "רענון מטא-נתונים התחדש עבור {count} מודל(ים)",
|
||||
"skipMetadataRefreshPartial": "{success} מודל(ים) עודכנו, {failed} נכשלו",
|
||||
"skipMetadataRefreshFailed": "נכשל בעדכון דגל רענון מטא-נתונים עבור המודלים הנבחרים",
|
||||
"bulkContentRatingUpdating": "מעדכן דירוג תוכן עבור {count} מודלים...",
|
||||
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
|
||||
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
|
||||
@@ -1497,7 +1466,6 @@
|
||||
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
|
||||
"folderTreeError": "שגיאה בטעינת עץ התיקיות",
|
||||
"imagesImported": "תמונות הדוגמה יובאו בהצלחה",
|
||||
"imagesPartial": "{success} תמונה/ות יובאו, {failed} נכשלו",
|
||||
"importFailed": "ייבוא תמונות הדוגמה נכשל: {message}"
|
||||
},
|
||||
"triggerWords": {
|
||||
|
||||
@@ -131,8 +131,7 @@
|
||||
},
|
||||
"badges": {
|
||||
"update": "アップデート",
|
||||
"updateAvailable": "アップデートがあります",
|
||||
"skipRefresh": "メタデータの更新がスキップされました"
|
||||
"updateAvailable": "アップデートがあります"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用回数"
|
||||
@@ -292,15 +291,6 @@
|
||||
"saveFailed": "除外設定を保存できませんでした: {message}"
|
||||
}
|
||||
},
|
||||
"metadataRefreshSkipPaths": {
|
||||
"label": "メタデータ更新スキップパス",
|
||||
"placeholder": "例:temp, archived/old, test_models",
|
||||
"help": "一括メタデータ更新(「すべてのメタデータを取得」)時にこれらのディレクトリパス内のモデルをスキップします。モデルルートディレクトリからの相対フォルダパスをカンマ区切りで入力してください。",
|
||||
"validation": {
|
||||
"noPaths": "カンマで区切って少なくとも1つのパスを入力してください。",
|
||||
"saveFailed": "スキップパスの保存に失敗しました:{message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "表示密度",
|
||||
"displayDensityOptions": {
|
||||
@@ -426,10 +416,6 @@
|
||||
"any": "利用可能な更新すべてを表示"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "早期アクセス更新を非表示",
|
||||
"help": "早期アクセスのみの更新"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
||||
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます"
|
||||
@@ -541,12 +527,8 @@
|
||||
"checkUpdates": "選択項目の更新を確認",
|
||||
"moveAll": "すべてをフォルダに移動",
|
||||
"autoOrganize": "自動整理を実行",
|
||||
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
||||
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
||||
"deleteAll": "すべてのモデルを削除",
|
||||
"clear": "選択をクリア",
|
||||
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
||||
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "自動整理を初期化中...",
|
||||
"starting": "{type}の自動整理を開始中...",
|
||||
@@ -1035,19 +1017,12 @@
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "名前のないバージョン",
|
||||
"noDetails": "追加情報なし",
|
||||
"earlyAccess": "EA"
|
||||
},
|
||||
"eaTime": {
|
||||
"endingSoon": "まもなく終了",
|
||||
"hours": "{count}時間後",
|
||||
"days": "{count}日後"
|
||||
"noDetails": "追加情報なし"
|
||||
},
|
||||
"badges": {
|
||||
"current": "現在のバージョン",
|
||||
"inLibrary": "ライブラリにあります",
|
||||
"newer": "新しいバージョン",
|
||||
"earlyAccess": "早期アクセス",
|
||||
"ignored": "無視中"
|
||||
},
|
||||
"actions": {
|
||||
@@ -1055,7 +1030,6 @@
|
||||
"delete": "削除",
|
||||
"ignore": "無視",
|
||||
"unignore": "無視を解除",
|
||||
"earlyAccessTooltip": "早期アクセス購入が必要",
|
||||
"resumeModelUpdates": "このモデルの更新を再開",
|
||||
"ignoreModelUpdates": "このモデルの更新を無視",
|
||||
"viewLocalVersions": "ローカルの全バージョンを表示",
|
||||
@@ -1405,11 +1379,6 @@
|
||||
"bulkBaseModelUpdateSuccess": "{count} モデルのベースモデルが正常に更新されました",
|
||||
"bulkBaseModelUpdatePartial": "{success} モデルを更新、{failed} モデルは失敗しました",
|
||||
"bulkBaseModelUpdateFailed": "選択したモデルのベースモデルの更新に失敗しました",
|
||||
"skipMetadataRefreshUpdating": "{count}モデルのメタデータ更新フラグを更新中...",
|
||||
"skipMetadataRefreshSet": "{count}モデルのメタデータ更新をスキップしました",
|
||||
"skipMetadataRefreshCleared": "{count}モデルのメタデータ更新を再開しました",
|
||||
"skipMetadataRefreshPartial": "{success}モデルを更新しました。{failed}モデルで失敗しました",
|
||||
"skipMetadataRefreshFailed": "選択したモデルのメタデータ更新フラグの更新に失敗しました",
|
||||
"bulkContentRatingUpdating": "{count} 件のモデルのコンテンツレーティングを更新中...",
|
||||
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
||||
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
||||
@@ -1497,7 +1466,6 @@
|
||||
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
|
||||
"folderTreeError": "フォルダツリー読み込みエラー",
|
||||
"imagesImported": "例画像が正常にインポートされました",
|
||||
"imagesPartial": "{success} 件の画像をインポート、{failed} 件失敗",
|
||||
"importFailed": "例画像のインポートに失敗しました:{message}"
|
||||
},
|
||||
"triggerWords": {
|
||||
|
||||
@@ -131,8 +131,7 @@
|
||||
},
|
||||
"badges": {
|
||||
"update": "업데이트",
|
||||
"updateAvailable": "업데이트 가능",
|
||||
"skipRefresh": "메타데이터 새로고침 건너뜀"
|
||||
"updateAvailable": "업데이트 가능"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "사용 횟수"
|
||||
@@ -292,15 +291,6 @@
|
||||
"saveFailed": "제외 항목을 저장할 수 없습니다: {message}"
|
||||
}
|
||||
},
|
||||
"metadataRefreshSkipPaths": {
|
||||
"label": "메타데이터 새로고침 건너뛰기 경로",
|
||||
"placeholder": "예: temp, archived/old, test_models",
|
||||
"help": "일괄 메타데이터 새로고침(\"모든 메타데이터 가져오기\") 시 이 디렉터리 경로의 모델을 건너뜁니다. 모델 루트 디렉터리를 기준으로 한 폴 더 경로를 쉼표로 구분하여 입력하세요.",
|
||||
"validation": {
|
||||
"noPaths": "쉼표로 구분하여 하나 이상의 경로를 입력하세요.",
|
||||
"saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "표시 밀도",
|
||||
"displayDensityOptions": {
|
||||
@@ -426,10 +416,6 @@
|
||||
"any": "사용 가능한 모든 업데이트 표시"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "얼리 액세스 업데이트 숨기기",
|
||||
"help": "얼리 액세스 업데이트만"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
||||
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다"
|
||||
@@ -541,12 +527,8 @@
|
||||
"checkUpdates": "선택 항목 업데이트 확인",
|
||||
"moveAll": "모두 폴더로 이동",
|
||||
"autoOrganize": "자동 정리 선택",
|
||||
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
||||
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
||||
"deleteAll": "모든 모델 삭제",
|
||||
"clear": "선택 지우기",
|
||||
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
||||
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "자동 정리 초기화 중...",
|
||||
"starting": "{type}에 대한 자동 정리 시작...",
|
||||
@@ -1035,19 +1017,12 @@
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "이름 없는 버전",
|
||||
"noDetails": "추가 정보 없음",
|
||||
"earlyAccess": "EA"
|
||||
},
|
||||
"eaTime": {
|
||||
"endingSoon": "곧 종료",
|
||||
"hours": "{count}시간 후",
|
||||
"days": "{count}일 후"
|
||||
"noDetails": "추가 정보 없음"
|
||||
},
|
||||
"badges": {
|
||||
"current": "현재 버전",
|
||||
"inLibrary": "라이브러리에 있음",
|
||||
"newer": "최신 버전",
|
||||
"earlyAccess": "얼리 액세스",
|
||||
"ignored": "무시됨"
|
||||
},
|
||||
"actions": {
|
||||
@@ -1055,7 +1030,6 @@
|
||||
"delete": "삭제",
|
||||
"ignore": "무시",
|
||||
"unignore": "무시 해제",
|
||||
"earlyAccessTooltip": "얼리 액세스 구매 필요",
|
||||
"resumeModelUpdates": "이 모델 업데이트 재개",
|
||||
"ignoreModelUpdates": "이 모델 업데이트 무시",
|
||||
"viewLocalVersions": "로컬 버전 모두 보기",
|
||||
@@ -1405,11 +1379,6 @@
|
||||
"bulkBaseModelUpdateSuccess": "{count}개의 모델에 베이스 모델이 성공적으로 업데이트되었습니다",
|
||||
"bulkBaseModelUpdatePartial": "{success}개의 모델이 업데이트되었고, {failed}개의 모델이 실패했습니다",
|
||||
"bulkBaseModelUpdateFailed": "선택한 모델의 베이스 모델 업데이트에 실패했습니다",
|
||||
"skipMetadataRefreshUpdating": "{count}개 모델의 메타데이터 새로고침 플래그를 업데이트하는 중...",
|
||||
"skipMetadataRefreshSet": "{count}개 모델의 메타데이터 새로고침을 건너뛰었습니다",
|
||||
"skipMetadataRefreshCleared": "{count}개 모델의 메타데이터 새로고침을 재개했습니다",
|
||||
"skipMetadataRefreshPartial": "{success}개 모델을 업데이트했습니다. {failed}개 실패",
|
||||
"skipMetadataRefreshFailed": "선택한 모델의 메타데이터 새로고침 플래그 업데이트 실패",
|
||||
"bulkContentRatingUpdating": "{count}개 모델의 콘텐츠 등급을 업데이트하는 중...",
|
||||
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
||||
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
||||
@@ -1497,7 +1466,6 @@
|
||||
"folderTreeFailed": "폴더 트리 로딩 실패",
|
||||
"folderTreeError": "폴더 트리 로딩 오류",
|
||||
"imagesImported": "예시 이미지가 성공적으로 가져와졌습니다",
|
||||
"imagesPartial": "{success}개 이미지 가져오기 성공, {failed}개 실패",
|
||||
"importFailed": "예시 이미지 가져오기 실패: {message}"
|
||||
},
|
||||
"triggerWords": {
|
||||
@@ -1624,4 +1592,4 @@
|
||||
"retry": "다시 시도"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,8 +131,7 @@
|
||||
},
|
||||
"badges": {
|
||||
"update": "Обновление",
|
||||
"updateAvailable": "Доступно обновление",
|
||||
"skipRefresh": "Обновление метаданных пропущено"
|
||||
"updateAvailable": "Доступно обновление"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Количество использований"
|
||||
@@ -292,15 +291,6 @@
|
||||
"saveFailed": "Не удалось сохранить исключения: {message}"
|
||||
}
|
||||
},
|
||||
"metadataRefreshSkipPaths": {
|
||||
"label": "Пути для пропуска обновления метаданных",
|
||||
"placeholder": "Пример: temp, archived/old, test_models",
|
||||
"help": "Пропускать модели в этих каталогах при массовом обновлении метаданных («Получить все метаданные»). Введите пути к папкам относительно корневого каталога моделей, разделённые запятой.",
|
||||
"validation": {
|
||||
"noPaths": "Введите хотя бы один путь, разделённый запятыми.",
|
||||
"saveFailed": "Не удалось сохранить пути для пропуска: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Плотность отображения",
|
||||
"displayDensityOptions": {
|
||||
@@ -426,10 +416,6 @@
|
||||
"any": "Отмечать любые доступные обновления"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "Скрыть обновления раннего доступа",
|
||||
"help": "Только обновления раннего доступа"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
||||
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена"
|
||||
@@ -541,12 +527,8 @@
|
||||
"checkUpdates": "Проверить обновления для выбранных",
|
||||
"moveAll": "Переместить все в папку",
|
||||
"autoOrganize": "Автоматически организовать выбранные",
|
||||
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
||||
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
||||
"deleteAll": "Удалить все модели",
|
||||
"clear": "Очистить выбор",
|
||||
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
||||
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Инициализация автоматической организации...",
|
||||
"starting": "Запуск автоматической организации для {type}...",
|
||||
@@ -1035,19 +1017,12 @@
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Версия без названия",
|
||||
"noDetails": "Дополнительная информация отсутствует",
|
||||
"earlyAccess": "EA"
|
||||
},
|
||||
"eaTime": {
|
||||
"endingSoon": "скоро заканчивается",
|
||||
"hours": "через {count}ч",
|
||||
"days": "через {count}д"
|
||||
"noDetails": "Дополнительная информация отсутствует"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Текущая версия",
|
||||
"inLibrary": "В библиотеке",
|
||||
"newer": "Более новая версия",
|
||||
"earlyAccess": "Ранний доступ",
|
||||
"ignored": "Игнорируется"
|
||||
},
|
||||
"actions": {
|
||||
@@ -1055,7 +1030,6 @@
|
||||
"delete": "Удалить",
|
||||
"ignore": "Игнорировать",
|
||||
"unignore": "Перестать игнорировать",
|
||||
"earlyAccessTooltip": "Требуется покупка раннего доступа",
|
||||
"resumeModelUpdates": "Возобновить обновления для этой модели",
|
||||
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
|
||||
"viewLocalVersions": "Показать все локальные версии",
|
||||
@@ -1405,11 +1379,6 @@
|
||||
"bulkBaseModelUpdateSuccess": "Базовая модель успешно обновлена для {count} моделей",
|
||||
"bulkBaseModelUpdatePartial": "Обновлено {success} моделей, не удалось обновить {failed} моделей",
|
||||
"bulkBaseModelUpdateFailed": "Не удалось обновить базовую модель для выбранных моделей",
|
||||
"skipMetadataRefreshUpdating": "Обновление флага обновления метаданных для {count} модели(ей)...",
|
||||
"skipMetadataRefreshSet": "Обновление метаданных пропущено для {count} модели(ей)",
|
||||
"skipMetadataRefreshCleared": "Обновление метаданных возобновлено для {count} модели(ей)",
|
||||
"skipMetadataRefreshPartial": "{success} модели(ей) обновлено, {failed} не удалось",
|
||||
"skipMetadataRefreshFailed": "Не удалось обновить флаг обновления метаданных для выбранных моделей",
|
||||
"bulkContentRatingUpdating": "Обновление рейтинга контента для {count} модель(ей)...",
|
||||
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
||||
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
||||
@@ -1497,7 +1466,6 @@
|
||||
"folderTreeFailed": "Не удалось загрузить дерево папок",
|
||||
"folderTreeError": "Ошибка загрузки дерева папок",
|
||||
"imagesImported": "Примеры изображений успешно импортированы",
|
||||
"imagesPartial": "{success} изображ. импортировано, {failed} не удалось",
|
||||
"importFailed": "Не удалось импортировать примеры изображений: {message}"
|
||||
},
|
||||
"triggerWords": {
|
||||
|
||||
@@ -131,8 +131,7 @@
|
||||
},
|
||||
"badges": {
|
||||
"update": "更新",
|
||||
"updateAvailable": "有可用更新",
|
||||
"skipRefresh": "元数据刷新已跳过"
|
||||
"updateAvailable": "有可用更新"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用次数"
|
||||
@@ -292,15 +291,6 @@
|
||||
"saveFailed": "无法保存排除项:{message}"
|
||||
}
|
||||
},
|
||||
"metadataRefreshSkipPaths": {
|
||||
"label": "元数据刷新跳过路径",
|
||||
"placeholder": "示例:temp, archived/old, test_models",
|
||||
"help": "批量刷新元数据(\"获取全部元数据\")时跳过这些目录路径中的模型。输入相对于模型根目录的文件夹路径,以逗号分隔。",
|
||||
"validation": {
|
||||
"noPaths": "请输入至少一个路径,以逗号分隔。",
|
||||
"saveFailed": "无法保存跳过路径:{message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "显示密度",
|
||||
"displayDensityOptions": {
|
||||
@@ -426,10 +416,6 @@
|
||||
"any": "显示任何可用更新"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "隐藏抢先体验更新",
|
||||
"help": "抢先体验更新"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
||||
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词"
|
||||
@@ -541,12 +527,8 @@
|
||||
"checkUpdates": "检查所选更新",
|
||||
"moveAll": "移动所选中到文件夹",
|
||||
"autoOrganize": "自动整理所选模型",
|
||||
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
||||
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
||||
"deleteAll": "删除选中模型",
|
||||
"clear": "清除选择",
|
||||
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
||||
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "正在初始化自动整理...",
|
||||
"starting": "正在为 {type} 启动自动整理...",
|
||||
@@ -1035,19 +1017,12 @@
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "未命名版本",
|
||||
"noDetails": "暂无更多信息",
|
||||
"earlyAccess": "EA"
|
||||
},
|
||||
"eaTime": {
|
||||
"endingSoon": "即将结束",
|
||||
"hours": "{count}小时后",
|
||||
"days": "{count}天后"
|
||||
"noDetails": "暂无更多信息"
|
||||
},
|
||||
"badges": {
|
||||
"current": "当前版本",
|
||||
"inLibrary": "已在库中",
|
||||
"newer": "较新的版本",
|
||||
"earlyAccess": "抢先体验",
|
||||
"ignored": "已忽略"
|
||||
},
|
||||
"actions": {
|
||||
@@ -1055,7 +1030,6 @@
|
||||
"delete": "删除",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"earlyAccessTooltip": "需要购买抢先体验",
|
||||
"resumeModelUpdates": "继续跟踪该模型的更新",
|
||||
"ignoreModelUpdates": "忽略该模型的更新",
|
||||
"viewLocalVersions": "查看所有本地版本",
|
||||
@@ -1405,11 +1379,6 @@
|
||||
"bulkBaseModelUpdateSuccess": "成功为 {count} 个模型更新基础模型",
|
||||
"bulkBaseModelUpdatePartial": "更新了 {success} 个模型,{failed} 个失败",
|
||||
"bulkBaseModelUpdateFailed": "为选中模型更新基础模型失败",
|
||||
"skipMetadataRefreshUpdating": "正在更新 {count} 个模型的元数据刷新标志...",
|
||||
"skipMetadataRefreshSet": "已为 {count} 个模型跳过元数据刷新",
|
||||
"skipMetadataRefreshCleared": "已为 {count} 个模型恢复元数据刷新",
|
||||
"skipMetadataRefreshPartial": "已更新 {success} 个模型,{failed} 个失败",
|
||||
"skipMetadataRefreshFailed": "未能更新所选模型的元数据刷新标志",
|
||||
"bulkContentRatingUpdating": "正在为 {count} 个模型更新内容评级...",
|
||||
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
||||
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
||||
@@ -1497,7 +1466,6 @@
|
||||
"folderTreeFailed": "加载文件夹树失败",
|
||||
"folderTreeError": "加载文件夹树出错",
|
||||
"imagesImported": "示例图片导入成功",
|
||||
"imagesPartial": "成功导入 {success} 张图片,{failed} 张失败",
|
||||
"importFailed": "导入示例图片失败:{message}"
|
||||
},
|
||||
"triggerWords": {
|
||||
|
||||
@@ -131,8 +131,7 @@
|
||||
},
|
||||
"badges": {
|
||||
"update": "更新",
|
||||
"updateAvailable": "有可用更新",
|
||||
"skipRefresh": "元數據更新已跳過"
|
||||
"updateAvailable": "有可用更新"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用次數"
|
||||
@@ -292,15 +291,6 @@
|
||||
"saveFailed": "無法儲存排除項目:{message}"
|
||||
}
|
||||
},
|
||||
"metadataRefreshSkipPaths": {
|
||||
"label": "中繼資料重新整理跳過路徑",
|
||||
"placeholder": "範例:temp, archived/old, test_models",
|
||||
"help": "批次重新整理中繼資料(「擷取所有中繼資料」)時跳過這些目錄路徑中的模型。輸入相對於模型根目錄的資料夾路徑,以逗號分隔。",
|
||||
"validation": {
|
||||
"noPaths": "請輸入至少一個路徑,以逗號分隔。",
|
||||
"saveFailed": "無法儲存跳過路徑:{message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "顯示密度",
|
||||
"displayDensityOptions": {
|
||||
@@ -426,10 +416,6 @@
|
||||
"any": "顯示任何可用更新"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "隱藏搶先體驗更新",
|
||||
"help": "搶先體驗更新"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
||||
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞"
|
||||
@@ -541,12 +527,8 @@
|
||||
"checkUpdates": "檢查所選更新",
|
||||
"moveAll": "全部移動到資料夾",
|
||||
"autoOrganize": "自動整理所選模型",
|
||||
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
||||
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
||||
"deleteAll": "刪除全部模型",
|
||||
"clear": "清除選取",
|
||||
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
||||
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "正在初始化自動整理...",
|
||||
"starting": "正在開始自動整理 {type}...",
|
||||
@@ -1035,19 +1017,12 @@
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "未命名版本",
|
||||
"noDetails": "沒有其他資訊",
|
||||
"earlyAccess": "EA"
|
||||
},
|
||||
"eaTime": {
|
||||
"endingSoon": "即將結束",
|
||||
"hours": "{count}小時後",
|
||||
"days": "{count}天後"
|
||||
"noDetails": "沒有其他資訊"
|
||||
},
|
||||
"badges": {
|
||||
"current": "目前版本",
|
||||
"inLibrary": "已在庫中",
|
||||
"newer": "較新版本",
|
||||
"earlyAccess": "搶先體驗",
|
||||
"ignored": "已忽略"
|
||||
},
|
||||
"actions": {
|
||||
@@ -1055,7 +1030,6 @@
|
||||
"delete": "刪除",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"earlyAccessTooltip": "需要購買搶先體驗",
|
||||
"resumeModelUpdates": "恢復追蹤此模型的更新",
|
||||
"ignoreModelUpdates": "忽略此模型的更新",
|
||||
"viewLocalVersions": "檢視所有本地版本",
|
||||
@@ -1405,11 +1379,6 @@
|
||||
"bulkBaseModelUpdateSuccess": "已成功為 {count} 個模型更新基礎模型",
|
||||
"bulkBaseModelUpdatePartial": "已更新 {success} 個模型,{failed} 個模型失敗",
|
||||
"bulkBaseModelUpdateFailed": "更新所選模型的基礎模型失敗",
|
||||
"skipMetadataRefreshUpdating": "正在更新 {count} 個模型的元數據更新標記...",
|
||||
"skipMetadataRefreshSet": "已為 {count} 個模型跳過元數據更新",
|
||||
"skipMetadataRefreshCleared": "已為 {count} 個模型恢復元數據更新",
|
||||
"skipMetadataRefreshPartial": "已更新 {success} 個模型,{failed} 個失敗",
|
||||
"skipMetadataRefreshFailed": "無法更新所選模型的元數據更新標記",
|
||||
"bulkContentRatingUpdating": "正在為 {count} 個模型更新內容分級...",
|
||||
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
||||
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
||||
@@ -1497,7 +1466,6 @@
|
||||
"folderTreeFailed": "載入資料夾樹狀結構失敗",
|
||||
"folderTreeError": "載入資料夾樹狀結構錯誤",
|
||||
"imagesImported": "範例圖片匯入成功",
|
||||
"imagesPartial": "成功匯入 {success} 張圖片,{failed} 張失敗",
|
||||
"importFailed": "匯入範例圖片失敗:{message}"
|
||||
},
|
||||
"triggerWords": {
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
from typing import Any
|
||||
import inspect
|
||||
|
||||
|
||||
class _AllContainer:
|
||||
"""Container that accepts any key for dynamic input validation."""
|
||||
|
||||
def __contains__(self, item):
|
||||
return True
|
||||
|
||||
def __getitem__(self, key):
|
||||
return ("STRING", {"forceInput": True})
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
class PromptLM:
|
||||
"""Encodes text (and optional trigger words) into CLIP conditioning."""
|
||||
@@ -19,27 +7,11 @@ class PromptLM:
|
||||
CATEGORY = "Lora Manager/conditioning"
|
||||
DESCRIPTION = (
|
||||
"Encodes a text prompt using a CLIP model into an embedding that can be used "
|
||||
"to guide the diffusion model towards generating specific images. "
|
||||
"Supports dynamic trigger words inputs."
|
||||
"to guide the diffusion model towards generating specific images."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
dyn_inputs = {
|
||||
"trigger_words1": (
|
||||
"STRING",
|
||||
{
|
||||
"forceInput": True,
|
||||
"tooltip": "Trigger words to prepend. Connect to add more inputs.",
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
# Bypass validation for dynamic inputs during graph execution
|
||||
stack = inspect.stack()
|
||||
if len(stack) > 2 and stack[2].function == "get_input_info":
|
||||
dyn_inputs = _AllContainer()
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"text": (
|
||||
@@ -51,34 +23,36 @@ class PromptLM:
|
||||
},
|
||||
),
|
||||
"clip": (
|
||||
"CLIP",
|
||||
'CLIP',
|
||||
{"tooltip": "The CLIP model used for encoding the text."},
|
||||
),
|
||||
},
|
||||
"optional": dyn_inputs,
|
||||
"optional": {
|
||||
"trigger_words": (
|
||||
'STRING',
|
||||
{
|
||||
"forceInput": True,
|
||||
"tooltip": (
|
||||
"Optional trigger words to prepend to the text before "
|
||||
"encoding."
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING", "STRING")
|
||||
RETURN_NAMES = ("CONDITIONING", "PROMPT")
|
||||
RETURN_TYPES = ('CONDITIONING', 'STRING',)
|
||||
RETURN_NAMES = ('CONDITIONING', 'PROMPT',)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"A conditioning containing the embedded text used to guide the diffusion model.",
|
||||
)
|
||||
FUNCTION = "encode"
|
||||
|
||||
def encode(self, text: str, clip: Any, **kwargs):
|
||||
# Collect all trigger words from dynamic inputs
|
||||
trigger_words = []
|
||||
for key, value in kwargs.items():
|
||||
if key.startswith("trigger_words") and value:
|
||||
trigger_words.append(value)
|
||||
|
||||
# Build final prompt
|
||||
def encode(self, text: str, clip: Any, trigger_words: Optional[str] = None):
|
||||
prompt = text
|
||||
if trigger_words:
|
||||
prompt = ", ".join(trigger_words + [text])
|
||||
else:
|
||||
prompt = text
|
||||
prompt = ", ".join([trigger_words, text])
|
||||
|
||||
from nodes import CLIPTextEncode # type: ignore
|
||||
|
||||
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
|
||||
return (conditioning, prompt)
|
||||
return (conditioning, prompt,)
|
||||
@@ -204,7 +204,6 @@ class BaseModelRoutes(ABC):
|
||||
service=service,
|
||||
update_service=update_service,
|
||||
metadata_provider_selector=get_metadata_provider,
|
||||
settings_service=self._settings,
|
||||
logger=logger,
|
||||
)
|
||||
return ModelHandlerSet(
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"""Handler set for example image routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Mapping
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from ...services.use_cases.example_images import (
|
||||
DownloadExampleImagesConfigurationError,
|
||||
DownloadExampleImagesInProgressError,
|
||||
@@ -125,9 +122,6 @@ class ExampleImagesManagementHandler:
|
||||
return web.json_response({'success': False, 'error': str(exc)}, status=400)
|
||||
except ExampleImagesImportError as exc:
|
||||
return web.json_response({'success': False, 'error': str(exc)}, status=500)
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error importing example images")
|
||||
return web.json_response({'success': False, 'error': str(exc)}, status=500)
|
||||
|
||||
async def delete_example_image(self, request: web.Request) -> web.StreamResponse:
|
||||
return await self._processor.delete_custom_image(request)
|
||||
|
||||
@@ -220,17 +220,43 @@ class HealthCheckHandler:
|
||||
class SettingsHandler:
|
||||
"""Sync settings between backend and frontend."""
|
||||
|
||||
# Settings keys that should NOT be synced to frontend.
|
||||
# All other settings are synced by default.
|
||||
_NO_SYNC_KEYS = frozenset({
|
||||
# Internal/performance settings (not used by frontend)
|
||||
"hash_chunk_size_mb",
|
||||
"download_stall_timeout_seconds",
|
||||
# Complex internal structures retrieved via separate endpoints
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
})
|
||||
_SYNC_KEYS = (
|
||||
"civitai_api_key",
|
||||
"default_lora_root",
|
||||
"default_checkpoint_root",
|
||||
"default_unet_root",
|
||||
"default_embedding_root",
|
||||
"base_model_path_mappings",
|
||||
"download_path_templates",
|
||||
"enable_metadata_archive_db",
|
||||
"language",
|
||||
"use_portable_settings",
|
||||
"onboarding_completed",
|
||||
"dismissed_banners",
|
||||
"proxy_enabled",
|
||||
"proxy_type",
|
||||
"proxy_host",
|
||||
"proxy_port",
|
||||
"proxy_username",
|
||||
"proxy_password",
|
||||
"example_images_path",
|
||||
"optimize_example_images",
|
||||
"auto_download_example_images",
|
||||
"blur_mature_content",
|
||||
"autoplay_on_hover",
|
||||
"display_density",
|
||||
"card_info_display",
|
||||
"show_folder_sidebar",
|
||||
"include_trigger_words",
|
||||
"show_only_sfw",
|
||||
"compact_mode",
|
||||
"priority_tags",
|
||||
"model_card_footer_action",
|
||||
"model_name_display",
|
||||
"update_flag_strategy",
|
||||
"auto_organize_exclusions",
|
||||
"filter_presets",
|
||||
)
|
||||
|
||||
_PROXY_KEYS = {
|
||||
"proxy_enabled",
|
||||
@@ -277,12 +303,10 @@ class SettingsHandler:
|
||||
async def get_settings(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
response_data = {}
|
||||
# Sync all settings except those in _NO_SYNC_KEYS
|
||||
for key in self._settings.keys():
|
||||
if key not in self._NO_SYNC_KEYS:
|
||||
value = self._settings.get(key)
|
||||
if value is not None:
|
||||
response_data[key] = value
|
||||
for key in self._SYNC_KEYS:
|
||||
value = self._settings.get(key)
|
||||
if value is not None:
|
||||
response_data[key] = value
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
|
||||
@@ -648,7 +648,7 @@ class ModelQueryHandler:
|
||||
async def get_top_tags(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
if limit < 0:
|
||||
if limit < 1 or limit > 100:
|
||||
limit = 20
|
||||
top_tags = await self._service.get_top_tags(limit)
|
||||
return web.json_response({"success": True, "tags": top_tags})
|
||||
@@ -1142,7 +1142,6 @@ class ModelDownloadHandler:
|
||||
request.query.get("use_default_paths", "false").lower() == "true"
|
||||
)
|
||||
source = request.query.get("source")
|
||||
file_params_json = request.query.get("file_params")
|
||||
|
||||
data = {"model_id": model_id, "use_default_paths": use_default_paths}
|
||||
if model_version_id:
|
||||
@@ -1151,12 +1150,6 @@ class ModelDownloadHandler:
|
||||
data["download_id"] = download_id
|
||||
if source:
|
||||
data["source"] = source
|
||||
if file_params_json:
|
||||
import json
|
||||
try:
|
||||
data["file_params"] = json.loads(file_params_json)
|
||||
except json.JSONDecodeError:
|
||||
self._logger.warning("Invalid file_params JSON: %s", file_params_json)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
future = loop.create_future()
|
||||
@@ -1540,13 +1533,11 @@ class ModelUpdateHandler:
|
||||
service,
|
||||
update_service,
|
||||
metadata_provider_selector,
|
||||
settings_service,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
self._service = service
|
||||
self._update_service = update_service
|
||||
self._metadata_provider_selector = metadata_provider_selector
|
||||
self._settings = settings_service
|
||||
self._logger = logger
|
||||
|
||||
async def fetch_missing_civitai_license_data(
|
||||
@@ -1783,9 +1774,6 @@ class ModelUpdateHandler:
|
||||
{"success": False, "error": "Model not tracked"}, status=404
|
||||
)
|
||||
|
||||
# Enrich EA versions with detailed info if needed
|
||||
record = await self._enrich_early_access_details(record)
|
||||
|
||||
overrides = await self._build_version_context(record)
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -1824,78 +1812,6 @@ class ModelUpdateHandler:
|
||||
)
|
||||
return None
|
||||
|
||||
async def _enrich_early_access_details(self, record):
|
||||
"""Fetch detailed EA info for versions missing exact end time.
|
||||
|
||||
Identifies versions with is_early_access=True but no early_access_ends_at,
|
||||
then fetches detailed info from CivitAI to get the exact end time.
|
||||
"""
|
||||
if not record or not record.versions:
|
||||
return record
|
||||
|
||||
# Find versions that need enrichment
|
||||
versions_needing_update = []
|
||||
for version in record.versions:
|
||||
if version.is_early_access and not version.early_access_ends_at:
|
||||
versions_needing_update.append(version)
|
||||
|
||||
if not versions_needing_update:
|
||||
return record
|
||||
|
||||
provider = await self._get_civitai_provider()
|
||||
if not provider:
|
||||
return record
|
||||
|
||||
# Fetch detailed info for each version needing update
|
||||
updated_versions = []
|
||||
for version in versions_needing_update:
|
||||
try:
|
||||
version_info, error = await provider.get_model_version_info(
|
||||
str(version.version_id)
|
||||
)
|
||||
if version_info and not error:
|
||||
ea_ends_at = version_info.get("earlyAccessEndsAt")
|
||||
if ea_ends_at:
|
||||
# Create updated version with EA end time
|
||||
from dataclasses import replace
|
||||
|
||||
updated_version = replace(
|
||||
version, early_access_ends_at=ea_ends_at
|
||||
)
|
||||
updated_versions.append(updated_version)
|
||||
self._logger.debug(
|
||||
"Enriched EA info for version %s: %s",
|
||||
version.version_id,
|
||||
ea_ends_at,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.debug(
|
||||
"Failed to fetch EA details for version %s: %s",
|
||||
version.version_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
if not updated_versions:
|
||||
return record
|
||||
|
||||
# Update record with enriched versions
|
||||
version_map = {v.version_id: v for v in record.versions}
|
||||
for updated in updated_versions:
|
||||
version_map[updated.version_id] = updated
|
||||
|
||||
# Create new record with updated versions
|
||||
from dataclasses import replace
|
||||
|
||||
new_record = replace(
|
||||
record, versions=list(version_map.values()),
|
||||
)
|
||||
|
||||
# Optionally persist to database for caching
|
||||
# Note: We don't persist here to avoid side effects; the data will be
|
||||
# refreshed on next bulk update if still needed
|
||||
|
||||
return new_record
|
||||
|
||||
async def _collect_models_missing_license(
|
||||
self,
|
||||
cache,
|
||||
@@ -2062,15 +1978,6 @@ class ModelUpdateHandler:
|
||||
version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None,
|
||||
) -> Dict:
|
||||
context = version_context or {}
|
||||
# Check user setting for hiding early access versions
|
||||
hide_early_access = False
|
||||
if self._settings is not None:
|
||||
try:
|
||||
hide_early_access = bool(
|
||||
self._settings.get("hide_early_access_updates", False)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"modelType": record.model_type,
|
||||
"modelId": record.model_id,
|
||||
@@ -2079,7 +1986,7 @@ class ModelUpdateHandler:
|
||||
"inLibraryVersionIds": record.in_library_version_ids,
|
||||
"lastCheckedAt": record.last_checked_at,
|
||||
"shouldIgnore": record.should_ignore_model,
|
||||
"hasUpdate": record.has_update(hide_early_access=hide_early_access),
|
||||
"hasUpdate": record.has_update(),
|
||||
"versions": [
|
||||
self._serialize_version(version, context.get(version.version_id))
|
||||
for version in record.versions
|
||||
@@ -2095,24 +2002,6 @@ class ModelUpdateHandler:
|
||||
preview_url = (
|
||||
preview_override if preview_override is not None else version.preview_url
|
||||
)
|
||||
|
||||
# Determine if version is currently in early access
|
||||
# Two-phase detection: use exact end time if available, otherwise fallback to basic flag
|
||||
is_early_access = False
|
||||
if version.early_access_ends_at:
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
ea_date = datetime.fromisoformat(
|
||||
version.early_access_ends_at.replace("Z", "+00:00")
|
||||
)
|
||||
is_early_access = ea_date > datetime.now(timezone.utc)
|
||||
except (ValueError, AttributeError):
|
||||
# If date parsing fails, treat as active EA (conservative)
|
||||
is_early_access = True
|
||||
elif getattr(version, 'is_early_access', False):
|
||||
# Fallback to basic EA flag from bulk API
|
||||
is_early_access = True
|
||||
|
||||
return {
|
||||
"versionId": version.version_id,
|
||||
"name": version.name,
|
||||
@@ -2122,8 +2011,6 @@ class ModelUpdateHandler:
|
||||
"previewUrl": preview_url,
|
||||
"isInLibrary": version.is_in_library,
|
||||
"shouldIgnore": version.should_ignore,
|
||||
"earlyAccessEndsAt": version.early_access_ends_at,
|
||||
"isEarlyAccess": is_early_access,
|
||||
"filePath": context.get("file_path"),
|
||||
"fileName": context.get("file_name"),
|
||||
}
|
||||
|
||||
@@ -380,13 +380,6 @@ class BaseModelService(ABC):
|
||||
strategy = "same_base"
|
||||
same_base_mode = strategy == "same_base"
|
||||
|
||||
# Check user setting for hiding early access updates
|
||||
hide_early_access = False
|
||||
try:
|
||||
hide_early_access = bool(self.settings.get("hide_early_access_updates", False))
|
||||
except Exception:
|
||||
hide_early_access = False
|
||||
|
||||
records = None
|
||||
resolved: Optional[Dict[int, bool]] = None
|
||||
if same_base_mode:
|
||||
@@ -395,7 +388,7 @@ class BaseModelService(ABC):
|
||||
try:
|
||||
records = await record_method(self.model_type, ordered_ids)
|
||||
resolved = {
|
||||
model_id: record.has_update(hide_early_access=hide_early_access)
|
||||
model_id: record.has_update()
|
||||
for model_id, record in records.items()
|
||||
}
|
||||
except Exception as exc:
|
||||
@@ -413,7 +406,7 @@ class BaseModelService(ABC):
|
||||
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
||||
if callable(bulk_method):
|
||||
try:
|
||||
resolved = await bulk_method(self.model_type, ordered_ids, hide_early_access=hide_early_access)
|
||||
resolved = await bulk_method(self.model_type, ordered_ids)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to resolve update status in bulk for %s models (%s): %s",
|
||||
@@ -426,7 +419,7 @@ class BaseModelService(ABC):
|
||||
|
||||
if resolved is None:
|
||||
tasks = [
|
||||
self.update_service.has_update(self.model_type, model_id, hide_early_access=hide_early_access)
|
||||
self.update_service.has_update(self.model_type, model_id)
|
||||
for model_id in ordered_ids
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -464,7 +457,6 @@ class BaseModelService(ABC):
|
||||
flag = record.has_update_for_base(
|
||||
threshold_version,
|
||||
base_model,
|
||||
hide_early_access=hide_early_access,
|
||||
)
|
||||
else:
|
||||
flag = default_flag
|
||||
|
||||
@@ -43,7 +43,6 @@ class CheckpointService(BaseModelService):
|
||||
"sub_type": sub_type,
|
||||
"favorite": checkpoint_data.get("favorite", False),
|
||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ class DownloadCoordinator:
|
||||
progress_callback=progress_callback,
|
||||
download_id=download_id,
|
||||
source=payload.get("source"),
|
||||
file_params=payload.get("file_params"),
|
||||
)
|
||||
|
||||
result["download_id"] = download_id
|
||||
|
||||
@@ -70,7 +70,6 @@ class DownloadManager:
|
||||
use_default_paths: bool = False,
|
||||
download_id: str = None,
|
||||
source: str = None,
|
||||
file_params: Dict = None,
|
||||
) -> Dict:
|
||||
"""Download model from Civitai with task tracking and concurrency control
|
||||
|
||||
@@ -83,7 +82,6 @@ class DownloadManager:
|
||||
use_default_paths: Flag to use default paths
|
||||
download_id: Unique identifier for this download task
|
||||
source: Optional source parameter to specify metadata provider
|
||||
file_params: Optional dict with file selection params (type, format, size, fp, isPrimary)
|
||||
|
||||
Returns:
|
||||
Dict with download result
|
||||
@@ -124,7 +122,6 @@ class DownloadManager:
|
||||
progress_callback,
|
||||
use_default_paths,
|
||||
source,
|
||||
file_params,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -158,7 +155,6 @@ class DownloadManager:
|
||||
progress_callback=None,
|
||||
use_default_paths: bool = False,
|
||||
source: str = None,
|
||||
file_params: Dict = None,
|
||||
):
|
||||
"""Execute download with semaphore to limit concurrency"""
|
||||
# Update status to waiting
|
||||
@@ -219,7 +215,6 @@ class DownloadManager:
|
||||
use_default_paths,
|
||||
task_id,
|
||||
source,
|
||||
file_params,
|
||||
)
|
||||
|
||||
# Update status based on result
|
||||
@@ -271,7 +266,6 @@ class DownloadManager:
|
||||
use_default_paths,
|
||||
download_id=None,
|
||||
source=None,
|
||||
file_params=None,
|
||||
):
|
||||
"""Wrapper for original download_from_civitai implementation"""
|
||||
try:
|
||||
@@ -462,57 +456,16 @@ class DownloadManager:
|
||||
await progress_callback(0)
|
||||
|
||||
# 2. Get file information
|
||||
files = version_info.get("files", [])
|
||||
file_info = None
|
||||
|
||||
# If file_params is provided, try to find matching file
|
||||
if file_params and model_version_id:
|
||||
target_type = file_params.get("type", "Model")
|
||||
target_format = file_params.get("format", "SafeTensor")
|
||||
target_size = file_params.get("size", "full")
|
||||
target_fp = file_params.get("fp")
|
||||
is_primary = file_params.get("isPrimary", False)
|
||||
|
||||
if is_primary:
|
||||
# Find primary file
|
||||
file_info = next(
|
||||
(f for f in files if f.get("primary") and f.get("type") in ("Model", "Negative")),
|
||||
None
|
||||
)
|
||||
else:
|
||||
# Match by metadata
|
||||
for f in files:
|
||||
f_type = f.get("type", "")
|
||||
f_meta = f.get("metadata", {})
|
||||
|
||||
# Check type match
|
||||
if f_type != target_type:
|
||||
continue
|
||||
|
||||
# Check metadata match
|
||||
if f_meta.get("format") != target_format:
|
||||
continue
|
||||
if f_meta.get("size") != target_size:
|
||||
continue
|
||||
if target_fp and f_meta.get("fp") != target_fp:
|
||||
continue
|
||||
|
||||
file_info = f
|
||||
break
|
||||
|
||||
# Fallback to primary file if no match found
|
||||
file_info = next(
|
||||
(
|
||||
f
|
||||
for f in version_info.get("files", [])
|
||||
if f.get("primary") and f.get("type") in ("Model", "Negative")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not file_info:
|
||||
file_info = next(
|
||||
(
|
||||
f
|
||||
for f in files
|
||||
if f.get("primary") and f.get("type") in ("Model", "Negative")
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not file_info:
|
||||
return {"success": False, "error": "No suitable file found in metadata"}
|
||||
return {"success": False, "error": "No primary file found in metadata"}
|
||||
mirrors = file_info.get("mirrors") or []
|
||||
download_urls = []
|
||||
if mirrors:
|
||||
@@ -543,9 +496,7 @@ class DownloadManager:
|
||||
return {"success": False, "error": "No mirror URL found"}
|
||||
|
||||
# 3. Prepare download
|
||||
file_name = file_info.get("name", "")
|
||||
if not file_name:
|
||||
return {"success": False, "error": "No filename found in file info"}
|
||||
file_name = file_info["name"]
|
||||
save_path = os.path.join(save_dir, file_name)
|
||||
|
||||
# 5. Prepare metadata based on model type
|
||||
|
||||
@@ -43,7 +43,6 @@ class EmbeddingService(BaseModelService):
|
||||
"sub_type": sub_type,
|
||||
"favorite": embedding_data.get("favorite", False),
|
||||
"update_available": bool(embedding_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ class LoraService(BaseModelService):
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", False),
|
||||
"update_available": bool(lora_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(lora_data.get("skip_metadata_refresh", False)),
|
||||
"sub_type": sub_type,
|
||||
"civitai": self.filter_civitai_data(
|
||||
lora_data.get("civitai", {}), minimal=True
|
||||
|
||||
@@ -248,7 +248,6 @@ class ModelScanner:
|
||||
'tags': tags_list,
|
||||
'civitai': civitai_slim,
|
||||
'civitai_deleted': bool(get_value('civitai_deleted', False)),
|
||||
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
|
||||
}
|
||||
|
||||
license_source: Dict[str, Any] = {}
|
||||
@@ -1448,7 +1447,7 @@ class ModelScanner:
|
||||
return None
|
||||
|
||||
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
"""Get top tags sorted by count. If limit is 0, return all tags."""
|
||||
"""Get top tags sorted by count"""
|
||||
await self.get_cached_data()
|
||||
|
||||
sorted_tags = sorted(
|
||||
@@ -1457,8 +1456,6 @@ class ModelScanner:
|
||||
reverse=True
|
||||
)
|
||||
|
||||
if limit == 0:
|
||||
return sorted_tags
|
||||
return sorted_tags[:limit]
|
||||
|
||||
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
|
||||
@@ -7,8 +7,7 @@ import os
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
||||
from typing import Dict, Iterable, List, Mapping, Optional, Sequence
|
||||
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
from .settings_manager import get_settings_manager
|
||||
@@ -65,9 +64,7 @@ class ModelVersionRecord:
|
||||
preview_url: Optional[str]
|
||||
is_in_library: bool
|
||||
should_ignore: bool
|
||||
early_access_ends_at: Optional[str] = None
|
||||
sort_index: int = 0
|
||||
is_early_access: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -100,12 +97,8 @@ class ModelUpdateRecord:
|
||||
|
||||
return [version.version_id for version in self.versions if version.is_in_library]
|
||||
|
||||
def has_update(self, hide_early_access: bool = False) -> bool:
|
||||
"""Return True when a non-ignored remote version newer than the newest local copy is available.
|
||||
|
||||
Args:
|
||||
hide_early_access: If True, exclude early access versions from update check.
|
||||
"""
|
||||
def has_update(self) -> bool:
|
||||
"""Return True when a non-ignored remote version newer than the newest local copy is available."""
|
||||
|
||||
if self.should_ignore_model:
|
||||
return False
|
||||
@@ -117,56 +110,22 @@ class ModelUpdateRecord:
|
||||
|
||||
if max_in_library is None:
|
||||
return any(
|
||||
not version.is_in_library
|
||||
and not version.should_ignore
|
||||
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
|
||||
for version in self.versions
|
||||
not version.is_in_library and not version.should_ignore for version in self.versions
|
||||
)
|
||||
|
||||
for version in self.versions:
|
||||
if version.is_in_library or version.should_ignore:
|
||||
continue
|
||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||
continue
|
||||
if version.version_id > max_in_library:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_early_access_active(version: ModelVersionRecord) -> bool:
|
||||
"""Check if a version is currently in early access period.
|
||||
|
||||
Uses two-phase detection:
|
||||
1. If exact EA end time available (from single version API), use it for precise check
|
||||
2. Otherwise fallback to basic EA flag (from bulk API)
|
||||
"""
|
||||
# Phase 2: Precise check with exact end time
|
||||
if version.early_access_ends_at:
|
||||
try:
|
||||
ea_date = datetime.fromisoformat(
|
||||
version.early_access_ends_at.replace("Z", "+00:00")
|
||||
)
|
||||
return ea_date > datetime.now(timezone.utc)
|
||||
except (ValueError, AttributeError):
|
||||
# If date parsing fails, treat as active EA (conservative)
|
||||
return True
|
||||
|
||||
# Phase 1: Basic EA flag from bulk API
|
||||
return version.is_early_access
|
||||
|
||||
def has_update_for_base(
|
||||
self,
|
||||
local_version_id: Optional[int],
|
||||
local_base_model: Optional[str],
|
||||
hide_early_access: bool = False,
|
||||
) -> bool:
|
||||
"""Return True when a newer remote version with the same base model exists.
|
||||
|
||||
Args:
|
||||
local_version_id: The current local version id.
|
||||
local_base_model: The base model to filter by.
|
||||
hide_early_access: If True, exclude early access versions from update check.
|
||||
"""
|
||||
"""Return True when a newer remote version with the same base model exists."""
|
||||
|
||||
if self.should_ignore_model:
|
||||
return False
|
||||
@@ -194,8 +153,6 @@ class ModelUpdateRecord:
|
||||
for version in self.versions:
|
||||
if version.is_in_library or version.should_ignore:
|
||||
continue
|
||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||
continue
|
||||
version_base = _normalize_base_model(version.base_model)
|
||||
if version_base != normalized_base:
|
||||
continue
|
||||
@@ -311,14 +268,6 @@ class ModelUpdateService:
|
||||
"ALTER TABLE model_update_versions "
|
||||
"ADD COLUMN should_ignore INTEGER NOT NULL DEFAULT 0"
|
||||
),
|
||||
"early_access_ends_at": (
|
||||
"ALTER TABLE model_update_versions "
|
||||
"ADD COLUMN early_access_ends_at TEXT"
|
||||
),
|
||||
"is_early_access": (
|
||||
"ALTER TABLE model_update_versions "
|
||||
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
|
||||
),
|
||||
}
|
||||
|
||||
for column, statement in migrations.items():
|
||||
@@ -418,8 +367,6 @@ class ModelUpdateService:
|
||||
preview_url TEXT,
|
||||
is_in_library INTEGER NOT NULL DEFAULT 0,
|
||||
should_ignore INTEGER NOT NULL DEFAULT 0,
|
||||
early_access_ends_at TEXT,
|
||||
is_early_access INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (model_id, version_id),
|
||||
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
||||
)
|
||||
@@ -437,8 +384,6 @@ class ModelUpdateService:
|
||||
"preview_url",
|
||||
"is_in_library",
|
||||
"should_ignore",
|
||||
"early_access_ends_at",
|
||||
"is_early_access",
|
||||
]
|
||||
defaults = {
|
||||
"sort_index": "0",
|
||||
@@ -449,8 +394,6 @@ class ModelUpdateService:
|
||||
"preview_url": "NULL",
|
||||
"is_in_library": "0",
|
||||
"should_ignore": "0",
|
||||
"early_access_ends_at": "NULL",
|
||||
"is_early_access": "0",
|
||||
}
|
||||
|
||||
select_parts = []
|
||||
@@ -724,8 +667,6 @@ class ModelUpdateService:
|
||||
is_in_library=False,
|
||||
should_ignore=should_ignore,
|
||||
sort_index=len(versions),
|
||||
early_access_ends_at=None,
|
||||
is_early_access=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -745,17 +686,16 @@ class ModelUpdateService:
|
||||
async with self._lock:
|
||||
return self._get_record(model_type, model_id)
|
||||
|
||||
async def has_update(self, model_type: str, model_id: int, hide_early_access: bool = False) -> bool:
|
||||
async def has_update(self, model_type: str, model_id: int) -> bool:
|
||||
"""Determine if a model has updates pending."""
|
||||
|
||||
record = await self.get_record(model_type, model_id)
|
||||
return record.has_update(hide_early_access=hide_early_access) if record else False
|
||||
return record.has_update() if record else False
|
||||
|
||||
async def has_updates_bulk(
|
||||
self,
|
||||
model_type: str,
|
||||
model_ids: Sequence[int],
|
||||
hide_early_access: bool = False,
|
||||
) -> Dict[int, bool]:
|
||||
"""Return update availability for each model id in a single database pass."""
|
||||
|
||||
@@ -767,7 +707,7 @@ class ModelUpdateService:
|
||||
records = self._get_records_bulk(model_type, normalized_ids)
|
||||
|
||||
return {
|
||||
model_id: records.get(model_id).has_update(hide_early_access=hide_early_access) if records.get(model_id) else False
|
||||
model_id: records.get(model_id).has_update() if records.get(model_id) else False
|
||||
for model_id in normalized_ids
|
||||
}
|
||||
|
||||
@@ -1047,8 +987,6 @@ class ModelUpdateService:
|
||||
is_in_library=True,
|
||||
should_ignore=ignore_map.get(missing_id, False),
|
||||
sort_index=len(versions),
|
||||
early_access_ends_at=None,
|
||||
is_early_access=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1091,8 +1029,6 @@ class ModelUpdateService:
|
||||
is_in_library=version_id in local_set,
|
||||
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
|
||||
sort_index=sort_map.get(version_id, index),
|
||||
early_access_ends_at=remote_version.early_access_ends_at,
|
||||
is_early_access=remote_version.is_early_access,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1119,8 +1055,6 @@ class ModelUpdateService:
|
||||
is_in_library=True,
|
||||
should_ignore=ignore_map.get(version_id, False),
|
||||
sort_index=len(versions),
|
||||
early_access_ends_at=None,
|
||||
is_early_access=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1186,11 +1120,6 @@ class ModelUpdateService:
|
||||
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"))
|
||||
early_access_ends_at = _normalize_string(entry.get("earlyAccessEndsAt"))
|
||||
|
||||
# Check availability field from bulk API for basic EA detection
|
||||
availability = _normalize_string(entry.get("availability"))
|
||||
is_early_access = availability == "EarlyAccess"
|
||||
|
||||
return ModelVersionRecord(
|
||||
version_id=version_id,
|
||||
@@ -1201,9 +1130,7 @@ class ModelUpdateService:
|
||||
preview_url=preview_url,
|
||||
is_in_library=False,
|
||||
should_ignore=False,
|
||||
early_access_ends_at=early_access_ends_at,
|
||||
sort_index=index,
|
||||
is_early_access=is_early_access,
|
||||
)
|
||||
|
||||
def _extract_size_bytes(self, files) -> Optional[int]:
|
||||
@@ -1304,8 +1231,7 @@ class ModelUpdateService:
|
||||
version_rows = conn.execute(
|
||||
f"""
|
||||
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||
is_early_access
|
||||
size_bytes, preview_url, is_in_library, should_ignore
|
||||
FROM model_update_versions
|
||||
WHERE model_id IN ({placeholders})
|
||||
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
||||
@@ -1326,9 +1252,7 @@ class ModelUpdateService:
|
||||
preview_url=row["preview_url"],
|
||||
is_in_library=bool(row["is_in_library"]),
|
||||
should_ignore=bool(row["should_ignore"]),
|
||||
early_access_ends_at=row["early_access_ends_at"],
|
||||
sort_index=_normalize_int(row["sort_index"]) or 0,
|
||||
is_early_access=bool(row["is_early_access"]),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1384,9 +1308,8 @@ class ModelUpdateService:
|
||||
"""
|
||||
INSERT INTO model_update_versions (
|
||||
version_id, model_id, sort_index, name, base_model, released_at,
|
||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||
is_early_access
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
size_bytes, preview_url, is_in_library, should_ignore
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
version.version_id,
|
||||
@@ -1399,8 +1322,6 @@ class ModelUpdateService:
|
||||
version.preview_url,
|
||||
1 if version.is_in_library else 0,
|
||||
1 if version.should_ignore else 0,
|
||||
version.early_access_ends_at,
|
||||
1 if version.is_early_access else 0,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -52,7 +52,6 @@ class PersistentModelCache:
|
||||
"trained_words",
|
||||
"license_flags",
|
||||
"civitai_deleted",
|
||||
"skip_metadata_refresh",
|
||||
"exclude",
|
||||
"db_checked",
|
||||
"last_checked_at",
|
||||
@@ -184,7 +183,6 @@ class PersistentModelCache:
|
||||
"tags": tags.get(file_path, []),
|
||||
"civitai": civitai,
|
||||
"civitai_deleted": bool(row["civitai_deleted"]),
|
||||
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
||||
"license_flags": int(license_value),
|
||||
}
|
||||
raw_data.append(item)
|
||||
@@ -493,7 +491,6 @@ class PersistentModelCache:
|
||||
"civitai_creator_username": "TEXT",
|
||||
"civitai_model_type": "TEXT",
|
||||
"civitai_deleted": "INTEGER DEFAULT 0",
|
||||
"skip_metadata_refresh": "INTEGER DEFAULT 0",
|
||||
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
||||
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
||||
}
|
||||
@@ -566,7 +563,6 @@ class PersistentModelCache:
|
||||
trained_words_json,
|
||||
int(license_flags),
|
||||
1 if item.get("civitai_deleted") else 0,
|
||||
1 if item.get("skip_metadata_refresh") else 0,
|
||||
1 if item.get("exclude") else 0,
|
||||
1 if item.get("db_checked") else 0,
|
||||
float(item.get("last_checked_at") or 0.0),
|
||||
|
||||
@@ -1351,9 +1351,8 @@ class RecipeScanner:
|
||||
|
||||
# Get hash from the first file
|
||||
for file_info in version_info.get('files', []):
|
||||
sha256_hash = (file_info.get('hashes') or {}).get('SHA256')
|
||||
if sha256_hash:
|
||||
return sha256_hash, False # Return hash with False for isDeleted flag
|
||||
if file_info.get('hashes', {}).get('SHA256'):
|
||||
return file_info['hashes']['SHA256'], False # Return hash with False for isDeleted flag
|
||||
|
||||
logger.debug(f"No SHA256 hash found in version info for ID: {model_version_id}")
|
||||
return None, False
|
||||
|
||||
@@ -69,7 +69,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"model_card_footer_action": "replace_preview",
|
||||
"update_flag_strategy": "same_base",
|
||||
"auto_organize_exclusions": [],
|
||||
"metadata_refresh_skip_paths": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -262,17 +261,6 @@ class SettingsManager:
|
||||
self.settings["auto_organize_exclusions"] = []
|
||||
inserted_defaults = True
|
||||
|
||||
if "metadata_refresh_skip_paths" in self.settings:
|
||||
normalized_skip_paths = self.normalize_metadata_refresh_skip_paths(
|
||||
self.settings.get("metadata_refresh_skip_paths")
|
||||
)
|
||||
if normalized_skip_paths != self.settings.get("metadata_refresh_skip_paths"):
|
||||
self.settings["metadata_refresh_skip_paths"] = normalized_skip_paths
|
||||
updated_existing = True
|
||||
else:
|
||||
self.settings["metadata_refresh_skip_paths"] = []
|
||||
inserted_defaults = True
|
||||
|
||||
for key, value in defaults.items():
|
||||
if key == "priority_tags":
|
||||
continue
|
||||
@@ -817,7 +805,6 @@ class SettingsManager:
|
||||
defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
|
||||
defaults.setdefault('folder_paths', {})
|
||||
defaults['auto_organize_exclusions'] = []
|
||||
defaults['metadata_refresh_skip_paths'] = []
|
||||
|
||||
library_name = defaults.get("active_library") or "default"
|
||||
default_library = self._build_library_payload(
|
||||
@@ -889,44 +876,6 @@ class SettingsManager:
|
||||
self._save_settings()
|
||||
return exclusions
|
||||
|
||||
def normalize_metadata_refresh_skip_paths(self, value: Any) -> List[str]:
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
if isinstance(value, str):
|
||||
candidates: Iterable[str] = (
|
||||
value.replace("\n", ",").replace(";", ",").split(",")
|
||||
)
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)):
|
||||
candidates = value
|
||||
else:
|
||||
return []
|
||||
|
||||
paths: List[str] = []
|
||||
for raw in candidates:
|
||||
if isinstance(raw, str):
|
||||
token = raw.replace("\\", "/").strip().strip("/")
|
||||
if token:
|
||||
paths.append(token)
|
||||
|
||||
unique_paths: List[str] = []
|
||||
seen = set()
|
||||
for path in paths:
|
||||
if path not in seen:
|
||||
seen.add(path)
|
||||
unique_paths.append(path)
|
||||
|
||||
return unique_paths
|
||||
|
||||
def get_metadata_refresh_skip_paths(self) -> List[str]:
|
||||
skip_paths = self.normalize_metadata_refresh_skip_paths(
|
||||
self.settings.get("metadata_refresh_skip_paths")
|
||||
)
|
||||
if skip_paths != self.settings.get("metadata_refresh_skip_paths"):
|
||||
self.settings["metadata_refresh_skip_paths"] = skip_paths
|
||||
self._save_settings()
|
||||
return skip_paths
|
||||
|
||||
def get_startup_messages(self) -> List[Dict[str, Any]]:
|
||||
return [message.copy() for message in self._startup_messages]
|
||||
|
||||
@@ -964,8 +913,6 @@ class SettingsManager:
|
||||
"""Set setting value and save"""
|
||||
if key == "auto_organize_exclusions":
|
||||
value = self.normalize_auto_organize_exclusions(value)
|
||||
elif key == "metadata_refresh_skip_paths":
|
||||
value = self.normalize_metadata_refresh_skip_paths(value)
|
||||
self.settings[key] = value
|
||||
portable_switch_pending = False
|
||||
if key == "use_portable_settings" and isinstance(value, bool):
|
||||
@@ -994,10 +941,6 @@ class SettingsManager:
|
||||
self._save_settings()
|
||||
logger.info(f"Deleted setting: {key}")
|
||||
|
||||
def keys(self) -> Iterable[str]:
|
||||
"""Return all setting keys."""
|
||||
return self.settings.keys()
|
||||
|
||||
def _prepare_portable_switch(self, use_portable: bool) -> None:
|
||||
"""Prepare switching the settings storage location."""
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
||||
from typing import Any, Dict, Optional, Protocol, Sequence
|
||||
|
||||
from ..metadata_sync_service import MetadataSyncService
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
@@ -43,13 +43,10 @@ class BulkMetadataRefreshUseCase:
|
||||
total_models = len(cache.raw_data)
|
||||
|
||||
enable_metadata_archive_db = self._settings.get("enable_metadata_archive_db", False)
|
||||
skip_paths = self._settings.get("metadata_refresh_skip_paths", [])
|
||||
to_process: Sequence[Dict[str, Any]] = [
|
||||
model
|
||||
for model in cache.raw_data
|
||||
if model.get("sha256")
|
||||
and not model.get("skip_metadata_refresh", False)
|
||||
and not self._is_in_skip_path(model.get("folder", ""), skip_paths)
|
||||
and (not model.get("civitai") or not model["civitai"].get("id"))
|
||||
and not (
|
||||
# Skip models confirmed not on CivitAI when no need to retry
|
||||
@@ -123,21 +120,6 @@ class BulkMetadataRefreshUseCase:
|
||||
|
||||
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models}
|
||||
|
||||
@staticmethod
|
||||
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
|
||||
if not skip_paths or not folder:
|
||||
return False
|
||||
normalized = folder.replace("\\", "/").strip("/")
|
||||
if not normalized:
|
||||
return False
|
||||
for sp in skip_paths:
|
||||
nsp = sp.replace("\\", "/").strip("/")
|
||||
if not nsp:
|
||||
continue
|
||||
if normalized == nsp or normalized.startswith(nsp + "/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def execute_with_error_handling(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -121,65 +121,100 @@ class DownloadManager:
|
||||
async def start_download(self, options: dict):
|
||||
"""Start downloading example images for models."""
|
||||
|
||||
# Step 1: Parse options (fast, non-blocking)
|
||||
data = options or {}
|
||||
auto_mode = data.get("auto_mode", False)
|
||||
optimize = data.get("optimize", True)
|
||||
model_types = data.get("model_types", ["lora", "checkpoint"])
|
||||
delay = float(data.get("delay", 0.2))
|
||||
force = data.get("force", False)
|
||||
|
||||
# Step 2: Validate configuration (fast lookup)
|
||||
settings_manager = get_settings_manager()
|
||||
base_path = settings_manager.get("example_images_path")
|
||||
|
||||
if not base_path:
|
||||
error_msg = "Example images path not configured in settings"
|
||||
if auto_mode:
|
||||
logger.debug(error_msg)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Example images path not configured, skipping auto download",
|
||||
}
|
||||
raise DownloadConfigurationError(error_msg)
|
||||
|
||||
active_library = settings_manager.get_active_library_name()
|
||||
output_dir = self._resolve_output_dir(active_library)
|
||||
if not output_dir:
|
||||
raise DownloadConfigurationError(
|
||||
"Example images path not configured in settings"
|
||||
)
|
||||
|
||||
# Step 3: Load progress file (I/O operation, done outside lock)
|
||||
processed_models = set()
|
||||
failed_models = set()
|
||||
|
||||
try:
|
||||
progress_file, processed_models, failed_models = await self._load_progress_file(output_dir)
|
||||
logger.debug(
|
||||
"Loaded previous progress, %s models already processed, %s models marked as failed",
|
||||
len(processed_models),
|
||||
len(failed_models),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load progress file: {e}")
|
||||
# Continue with empty sets
|
||||
|
||||
# Step 4: Quick state check and update (minimal lock time)
|
||||
async with self._state_lock:
|
||||
if self._is_downloading:
|
||||
raise DownloadInProgressError(self._progress.snapshot())
|
||||
|
||||
try:
|
||||
# Reset progress with loaded data
|
||||
data = options or {}
|
||||
auto_mode = data.get("auto_mode", False)
|
||||
optimize = data.get("optimize", True)
|
||||
model_types = data.get("model_types", ["lora", "checkpoint"])
|
||||
delay = float(data.get("delay", 0.2))
|
||||
force = data.get("force", False)
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
base_path = settings_manager.get("example_images_path")
|
||||
|
||||
if not base_path:
|
||||
error_msg = "Example images path not configured in settings"
|
||||
if auto_mode:
|
||||
logger.debug(error_msg)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Example images path not configured, skipping auto download",
|
||||
}
|
||||
raise DownloadConfigurationError(error_msg)
|
||||
|
||||
active_library = get_settings_manager().get_active_library_name()
|
||||
output_dir = self._resolve_output_dir(active_library)
|
||||
if not output_dir:
|
||||
raise DownloadConfigurationError(
|
||||
"Example images path not configured in settings"
|
||||
)
|
||||
|
||||
self._progress.reset()
|
||||
self._progress["processed_models"] = processed_models
|
||||
self._progress["failed_models"] = failed_models
|
||||
self._stop_requested = False
|
||||
self._progress["status"] = "running"
|
||||
self._progress["start_time"] = time.time()
|
||||
self._progress["end_time"] = None
|
||||
|
||||
progress_file = os.path.join(output_dir, ".download_progress.json")
|
||||
progress_source = progress_file
|
||||
if uses_library_scoped_folders():
|
||||
legacy_root = (
|
||||
get_settings_manager().get("example_images_path") or ""
|
||||
)
|
||||
legacy_progress = (
|
||||
os.path.join(legacy_root, ".download_progress.json")
|
||||
if legacy_root
|
||||
else ""
|
||||
)
|
||||
if (
|
||||
legacy_progress
|
||||
and os.path.exists(legacy_progress)
|
||||
and not os.path.exists(progress_file)
|
||||
):
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
shutil.move(legacy_progress, progress_file)
|
||||
logger.info(
|
||||
"Migrated legacy download progress file '%s' to '%s'",
|
||||
legacy_progress,
|
||||
progress_file,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to migrate download progress file from '%s' to '%s': %s",
|
||||
legacy_progress,
|
||||
progress_file,
|
||||
exc,
|
||||
)
|
||||
progress_source = legacy_progress
|
||||
|
||||
if os.path.exists(progress_source):
|
||||
try:
|
||||
with open(progress_source, "r", encoding="utf-8") as f:
|
||||
saved_progress = json.load(f)
|
||||
self._progress["processed_models"] = set(
|
||||
saved_progress.get("processed_models", [])
|
||||
)
|
||||
self._progress["failed_models"] = set(
|
||||
saved_progress.get("failed_models", [])
|
||||
)
|
||||
logger.debug(
|
||||
"Loaded previous progress, %s models already processed, %s models marked as failed",
|
||||
len(self._progress["processed_models"]),
|
||||
len(self._progress["failed_models"]),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load progress file: {e}")
|
||||
self._progress["processed_models"] = set()
|
||||
self._progress["failed_models"] = set()
|
||||
else:
|
||||
self._progress["processed_models"] = set()
|
||||
self._progress["failed_models"] = set()
|
||||
|
||||
self._is_downloading = True
|
||||
snapshot = self._progress.snapshot()
|
||||
|
||||
@@ -233,7 +268,7 @@ class DownloadManager:
|
||||
except Exception as save_error:
|
||||
logger.error(f"Failed to save progress after task failure: {save_error}")
|
||||
|
||||
async def get_status(self, request) -> dict:
|
||||
async def get_status(self, request):
|
||||
"""Get the current status of example images download."""
|
||||
|
||||
return {
|
||||
@@ -242,87 +277,6 @@ class DownloadManager:
|
||||
"status": self._progress.snapshot(),
|
||||
}
|
||||
|
||||
async def _load_progress_file(self, output_dir: str) -> tuple[str, set, set]:
|
||||
"""Load progress file from disk. Returns (progress_file_path, processed_models, failed_models).
|
||||
|
||||
This is a separate async method to allow running in executor to avoid blocking event loop.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, self._load_progress_file_sync, output_dir
|
||||
)
|
||||
|
||||
def _load_progress_file_sync(self, output_dir: str) -> tuple[str, set, set]:
|
||||
"""Synchronous implementation of progress file loading."""
|
||||
progress_file = os.path.join(output_dir, ".download_progress.json")
|
||||
progress_source = progress_file
|
||||
|
||||
# Handle legacy migration if needed
|
||||
if uses_library_scoped_folders():
|
||||
legacy_root = get_settings_manager().get("example_images_path") or ""
|
||||
legacy_progress = (
|
||||
os.path.join(legacy_root, ".download_progress.json")
|
||||
if legacy_root
|
||||
else ""
|
||||
)
|
||||
if (
|
||||
legacy_progress
|
||||
and os.path.exists(legacy_progress)
|
||||
and not os.path.exists(progress_file)
|
||||
):
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
shutil.move(legacy_progress, progress_file)
|
||||
logger.info(
|
||||
"Migrated legacy download progress file '%s' to '%s'",
|
||||
legacy_progress,
|
||||
progress_file,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to migrate download progress file from '%s' to '%s': %s",
|
||||
legacy_progress,
|
||||
progress_file,
|
||||
exc,
|
||||
)
|
||||
progress_source = legacy_progress
|
||||
|
||||
processed_models = set()
|
||||
failed_models = set()
|
||||
|
||||
if os.path.exists(progress_source):
|
||||
try:
|
||||
with open(progress_source, "r", encoding="utf-8") as f:
|
||||
saved_progress = json.load(f)
|
||||
processed_models = set(saved_progress.get("processed_models", []))
|
||||
failed_models = set(saved_progress.get("failed_models", []))
|
||||
except Exception:
|
||||
# Return empty sets on error
|
||||
pass
|
||||
|
||||
return progress_file, processed_models, failed_models
|
||||
|
||||
def _load_progress_sets_sync(self, progress_file: str) -> tuple[set, set]:
|
||||
"""Load only the processed and failed model sets from progress file.
|
||||
|
||||
This is a lighter version for quick checks without legacy migration.
|
||||
Returns (processed_models, failed_models).
|
||||
"""
|
||||
processed_models = set()
|
||||
failed_models = set()
|
||||
|
||||
if os.path.exists(progress_file):
|
||||
try:
|
||||
with open(progress_file, "r", encoding="utf-8") as f:
|
||||
saved_progress = json.load(f)
|
||||
processed_models = set(saved_progress.get("processed_models", []))
|
||||
failed_models = set(saved_progress.get("failed_models", []))
|
||||
except Exception:
|
||||
# Return empty sets on error
|
||||
pass
|
||||
|
||||
return processed_models, failed_models
|
||||
|
||||
async def check_pending_models(self, model_types: list[str]) -> dict:
|
||||
"""Quickly check how many models need example images downloaded.
|
||||
|
||||
@@ -366,49 +320,62 @@ class DownloadManager:
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
scanners.append(("embedding", embedding_scanner))
|
||||
|
||||
# Load progress file to check processed models (async to avoid blocking)
|
||||
# Load progress file to check processed models
|
||||
settings_manager = get_settings_manager()
|
||||
active_library = settings_manager.get_active_library_name()
|
||||
output_dir = self._resolve_output_dir(active_library)
|
||||
|
||||
|
||||
processed_models: set[str] = set()
|
||||
failed_models: set[str] = set()
|
||||
|
||||
|
||||
if output_dir:
|
||||
progress_file = os.path.join(output_dir, ".download_progress.json")
|
||||
loop = asyncio.get_event_loop()
|
||||
processed_models, failed_models = await loop.run_in_executor(
|
||||
None, self._load_progress_sets_sync, progress_file
|
||||
)
|
||||
if os.path.exists(progress_file):
|
||||
try:
|
||||
with open(progress_file, "r", encoding="utf-8") as f:
|
||||
saved_progress = json.load(f)
|
||||
processed_models = set(saved_progress.get("processed_models", []))
|
||||
failed_models = set(saved_progress.get("failed_models", []))
|
||||
except Exception:
|
||||
pass # Ignore progress file errors for quick check
|
||||
|
||||
# Collect all models and count in a single pass per scanner
|
||||
# Count models
|
||||
total_models = 0
|
||||
all_models_with_hash: list[tuple[str, str]] = [] # (hash, name) pairs
|
||||
|
||||
models_with_hash = 0
|
||||
|
||||
for scanner_type, scanner in scanners:
|
||||
cache = await scanner.get_cached_data()
|
||||
if cache and cache.raw_data:
|
||||
for model in cache.raw_data:
|
||||
total_models += 1
|
||||
raw_hash = model.get("sha256")
|
||||
if raw_hash:
|
||||
model_hash = raw_hash.lower()
|
||||
all_models_with_hash.append((model_hash, model.get("model_name", "Unknown")))
|
||||
if model.get("sha256"):
|
||||
models_with_hash += 1
|
||||
|
||||
models_with_hash = len(all_models_with_hash)
|
||||
|
||||
# Calculate pending count: check which models actually need processing
|
||||
# A model is pending if it has a hash, is not in processed_models,
|
||||
# and its folder doesn't exist or is empty
|
||||
# Calculate pending count
|
||||
# A model is pending if it has a hash and is not in processed_models
|
||||
# We also exclude failed_models unless force mode would be used
|
||||
pending_count = models_with_hash - len(processed_models.intersection(
|
||||
{m.get("sha256", "").lower() for scanner_type, scanner in scanners
|
||||
for m in (await scanner.get_cached_data()).raw_data if m.get("sha256")}
|
||||
))
|
||||
|
||||
# More accurate pending count: check which models actually need processing
|
||||
pending_hashes = set()
|
||||
for model_hash, model_name in all_models_with_hash:
|
||||
if model_hash not in processed_models:
|
||||
# Check if model folder exists with files
|
||||
model_dir = ExampleImagePathResolver.get_model_folder(
|
||||
model_hash, active_library
|
||||
)
|
||||
if not _model_directory_has_files(model_dir):
|
||||
pending_hashes.add(model_hash)
|
||||
for scanner_type, scanner in scanners:
|
||||
cache = await scanner.get_cached_data()
|
||||
if cache and cache.raw_data:
|
||||
for model in cache.raw_data:
|
||||
raw_hash = model.get("sha256")
|
||||
if not raw_hash:
|
||||
continue
|
||||
model_hash = raw_hash.lower()
|
||||
if model_hash not in processed_models:
|
||||
# Check if model folder exists with files
|
||||
model_dir = ExampleImagePathResolver.get_model_folder(
|
||||
model_hash, active_library
|
||||
)
|
||||
if not _model_directory_has_files(model_dir):
|
||||
pending_hashes.add(model_hash)
|
||||
|
||||
pending_count = len(pending_hashes)
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ class BaseModelMetadata:
|
||||
favorite: bool = False # Whether the model is a favorite
|
||||
exclude: bool = False # Whether to exclude this model from the cache
|
||||
db_checked: bool = False # Whether checked in archive DB
|
||||
skip_metadata_refresh: bool = False # Whether to skip this model during bulk metadata refresh
|
||||
metadata_source: Optional[str] = None # Last provider that supplied metadata
|
||||
last_checked_at: float = 0 # Last checked timestamp
|
||||
_unknown_fields: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False) # Store unknown fields
|
||||
@@ -143,27 +142,27 @@ class LoraMetadata(BaseModelMetadata):
|
||||
@classmethod
|
||||
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'LoraMetadata':
|
||||
"""Create LoraMetadata instance from Civitai version info"""
|
||||
file_name = file_info.get('name', '')
|
||||
file_name = file_info['name']
|
||||
base_model = determine_base_model(version_info.get('baseModel', ''))
|
||||
|
||||
|
||||
# Extract tags and description if available
|
||||
tags = []
|
||||
description = ""
|
||||
model_data = version_info.get('model') or {}
|
||||
if 'tags' in model_data:
|
||||
tags = model_data['tags']
|
||||
if 'description' in model_data:
|
||||
description = model_data['description']
|
||||
|
||||
if 'model' in version_info:
|
||||
if 'tags' in version_info['model']:
|
||||
tags = version_info['model']['tags']
|
||||
if 'description' in version_info['model']:
|
||||
description = version_info['model']['description']
|
||||
|
||||
return cls(
|
||||
file_name=os.path.splitext(file_name)[0],
|
||||
model_name=model_data.get('name', os.path.splitext(file_name)[0]),
|
||||
model_name=version_info.get('model').get('name', os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, '/'),
|
||||
size=file_info.get('sizeKB', 0) * 1024,
|
||||
modified=datetime.now().timestamp(),
|
||||
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(),
|
||||
sha256=file_info['hashes'].get('SHA256', '').lower(),
|
||||
base_model=base_model,
|
||||
preview_url='', # Will be updated after preview download
|
||||
preview_url=None, # Will be updated after preview download
|
||||
preview_nsfw_level=0, # Will be updated after preview download
|
||||
from_civitai=True,
|
||||
civitai=version_info,
|
||||
@@ -179,28 +178,28 @@ class CheckpointMetadata(BaseModelMetadata):
|
||||
@classmethod
|
||||
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'CheckpointMetadata':
|
||||
"""Create CheckpointMetadata instance from Civitai version info"""
|
||||
file_name = file_info.get('name', '')
|
||||
file_name = file_info['name']
|
||||
base_model = determine_base_model(version_info.get('baseModel', ''))
|
||||
sub_type = version_info.get('type', 'checkpoint')
|
||||
|
||||
|
||||
# Extract tags and description if available
|
||||
tags = []
|
||||
description = ""
|
||||
model_data = version_info.get('model') or {}
|
||||
if 'tags' in model_data:
|
||||
tags = model_data['tags']
|
||||
if 'description' in model_data:
|
||||
description = model_data['description']
|
||||
|
||||
if 'model' in version_info:
|
||||
if 'tags' in version_info['model']:
|
||||
tags = version_info['model']['tags']
|
||||
if 'description' in version_info['model']:
|
||||
description = version_info['model']['description']
|
||||
|
||||
return cls(
|
||||
file_name=os.path.splitext(file_name)[0],
|
||||
model_name=model_data.get('name', os.path.splitext(file_name)[0]),
|
||||
model_name=version_info.get('model').get('name', os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, '/'),
|
||||
size=file_info.get('sizeKB', 0) * 1024,
|
||||
modified=datetime.now().timestamp(),
|
||||
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(),
|
||||
sha256=file_info['hashes'].get('SHA256', '').lower(),
|
||||
base_model=base_model,
|
||||
preview_url='', # Will be updated after preview download
|
||||
preview_url=None, # Will be updated after preview download
|
||||
preview_nsfw_level=0,
|
||||
from_civitai=True,
|
||||
civitai=version_info,
|
||||
@@ -217,28 +216,28 @@ class EmbeddingMetadata(BaseModelMetadata):
|
||||
@classmethod
|
||||
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'EmbeddingMetadata':
|
||||
"""Create EmbeddingMetadata instance from Civitai version info"""
|
||||
file_name = file_info.get('name', '')
|
||||
file_name = file_info['name']
|
||||
base_model = determine_base_model(version_info.get('baseModel', ''))
|
||||
sub_type = version_info.get('type', 'embedding')
|
||||
|
||||
|
||||
# Extract tags and description if available
|
||||
tags = []
|
||||
description = ""
|
||||
model_data = version_info.get('model') or {}
|
||||
if 'tags' in model_data:
|
||||
tags = model_data['tags']
|
||||
if 'description' in model_data:
|
||||
description = model_data['description']
|
||||
|
||||
if 'model' in version_info:
|
||||
if 'tags' in version_info['model']:
|
||||
tags = version_info['model']['tags']
|
||||
if 'description' in version_info['model']:
|
||||
description = version_info['model']['description']
|
||||
|
||||
return cls(
|
||||
file_name=os.path.splitext(file_name)[0],
|
||||
model_name=model_data.get('name', os.path.splitext(file_name)[0]),
|
||||
model_name=version_info.get('model').get('name', os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, '/'),
|
||||
size=file_info.get('sizeKB', 0) * 1024,
|
||||
modified=datetime.now().timestamp(),
|
||||
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(),
|
||||
sha256=file_info['hashes'].get('SHA256', '').lower(),
|
||||
base_model=base_model,
|
||||
preview_url='', # Will be updated after preview download
|
||||
preview_url=None, # Will be updated after preview download
|
||||
preview_nsfw_level=0,
|
||||
from_civitai=True,
|
||||
civitai=version_info,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "0.9.16"
|
||||
version = "0.9.15"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -4,13 +4,9 @@ testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
# Asyncio configuration
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
# Register markers
|
||||
# Register async marker for coroutine-style tests
|
||||
markers =
|
||||
asyncio: execute test within asyncio event loop
|
||||
no_settings_dir_isolation: allow tests to use real settings paths
|
||||
integration: integration tests requiring external resources
|
||||
# Skip problematic directories to avoid import conflicts
|
||||
norecursedirs = .git .tox dist build *.egg __pycache__ py .hypothesis
|
||||
norecursedirs = .git .tox dist build *.egg __pycache__ py
|
||||
@@ -1,7 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=7.4
|
||||
pytest-cov>=4.1
|
||||
pytest-asyncio>=0.21.0
|
||||
hypothesis>=6.0
|
||||
syrupy>=5.0
|
||||
pytest-benchmark>=5.0
|
||||
|
||||
@@ -154,7 +154,6 @@ class StandaloneServer:
|
||||
self.app = web.Application(
|
||||
logger=logger,
|
||||
middlewares=[cache_control],
|
||||
client_max_size=256 * 1024 * 1024,
|
||||
handler_args={
|
||||
"max_field_size": HEADER_SIZE_LIMIT,
|
||||
"max_line_size": HEADER_SIZE_LIMIT,
|
||||
|
||||
@@ -60,14 +60,13 @@ body {
|
||||
--badge-update-bg: oklch(72% 0.2 220);
|
||||
--badge-update-text: oklch(28% 0.03 220);
|
||||
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
|
||||
--badge-skip-refresh-bg: oklch(82% 0.12 45);
|
||||
--badge-skip-refresh-text: oklch(35% 0.02 45);
|
||||
--badge-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
|
||||
|
||||
/* Spacing Scale */
|
||||
--space-1: calc(8px * 1);
|
||||
--space-2: calc(8px * 2);
|
||||
--space-3: calc(8px * 3);
|
||||
--space-4: calc(8px * 4);
|
||||
--space-5: calc(8px * 5);
|
||||
|
||||
/* Z-index Scale */
|
||||
--z-base: 10;
|
||||
@@ -117,9 +116,6 @@ html[data-theme="light"] {
|
||||
--badge-update-bg: oklch(62% 0.18 220);
|
||||
--badge-update-text: oklch(98% 0.02 240);
|
||||
--badge-update-glow: oklch(62% 0.18 220 / 0.4);
|
||||
--badge-skip-refresh-bg: oklch(82% 0.12 45);
|
||||
--badge-skip-refresh-text: oklch(98% 0.02 45);
|
||||
--badge-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -658,25 +658,3 @@
|
||||
margin-left: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.model-skip-refresh-badge {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border-radius: 3px;
|
||||
background: var(--badge-skip-refresh-bg);
|
||||
color: var(--badge-skip-refresh-text);
|
||||
font-size: 0.65rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 3px var(--badge-skip-refresh-glow);
|
||||
border: 1px solid color-mix(in oklab, var(--badge-skip-refresh-bg) 70%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.model-skip-refresh-badge i {
|
||||
margin-left: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -387,51 +387,3 @@
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Early Access styles - Buzz theme color (#F59F00) */
|
||||
.version-badge-early-access {
|
||||
background: color-mix(in oklch, #F59F00 25%, transparent);
|
||||
color: #E67700;
|
||||
border-color: color-mix(in oklch, #F59F00 55%, transparent);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .version-badge-early-access {
|
||||
background: color-mix(in oklch, #F59F00 20%, transparent);
|
||||
color: #F59F00;
|
||||
border-color: color-mix(in oklch, #F59F00 45%, transparent);
|
||||
}
|
||||
|
||||
.version-meta-ea {
|
||||
color: #E67700;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .version-meta-ea {
|
||||
color: #F59F00;
|
||||
}
|
||||
|
||||
/* Early Access row - gray out effect */
|
||||
.model-version-row.is-early-access {
|
||||
opacity: 0.85;
|
||||
filter: grayscale(40%);
|
||||
transition: opacity 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
|
||||
.model-version-row.is-early-access:hover {
|
||||
opacity: 0.95;
|
||||
filter: grayscale(25%);
|
||||
}
|
||||
|
||||
/* Early Access download button - Buzz theme color (#F59F00) */
|
||||
.version-action-early-access {
|
||||
background: color-mix(in oklch, #F59F00 15%, transparent);
|
||||
color: #E67700;
|
||||
border-color: color-mix(in oklch, #F59F00 50%, transparent);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .version-action-early-access {
|
||||
background: color-mix(in oklch, #F59F00 12%, transparent);
|
||||
color: #F59F00;
|
||||
border-color: color-mix(in oklch, #F59F00 40%, transparent);
|
||||
}
|
||||
|
||||
354
static/css/components/model-modal/metadata.css
Normal file
354
static/css/components/model-modal/metadata.css
Normal file
@@ -0,0 +1,354 @@
|
||||
/* Metadata Panel - Right Panel */
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--card-bg);
|
||||
border-left: 1px solid var(--lora-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header section */
|
||||
.metadata__header {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.metadata__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.metadata__name {
|
||||
font-size: 1.4em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.metadata__edit-btn {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: var(--text-color);
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.metadata__header:hover .metadata__edit-btn {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.metadata__edit-btn:hover {
|
||||
opacity: 1 !important;
|
||||
background: var(--lora-border);
|
||||
}
|
||||
|
||||
/* Creator and actions */
|
||||
.metadata__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.metadata__creator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .metadata__creator {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.metadata__creator:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
}
|
||||
|
||||
.metadata__creator-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--lora-accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metadata__creator-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.metadata__creator-avatar i {
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.metadata__creator-name {
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metadata__civitai-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.metadata__civitai-link:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
}
|
||||
|
||||
/* License icons */
|
||||
.metadata__licenses {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.metadata__license-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-block;
|
||||
background-color: var(--text-muted);
|
||||
-webkit-mask: var(--license-icon-image) center/contain no-repeat;
|
||||
mask: var(--license-icon-image) center/contain no-repeat;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.metadata__license-icon:hover {
|
||||
background-color: var(--text-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.metadata__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.metadata__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
border: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8em;
|
||||
color: var(--lora-accent);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.metadata__tag:hover {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Info grid */
|
||||
.metadata__info {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.metadata__info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.metadata__info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.metadata__info-item--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.metadata__info-label {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.metadata__info-value {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.metadata__info-value--mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.metadata__info-value--path {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.metadata__info-value--path:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Editable sections */
|
||||
.metadata__section {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.metadata__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.metadata__section-title {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metadata__section-edit {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: opacity 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.metadata__section:hover .metadata__section-edit {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.metadata__section-edit:hover {
|
||||
opacity: 1 !important;
|
||||
background: var(--lora-border);
|
||||
}
|
||||
|
||||
/* Usage tips / Trigger words */
|
||||
.metadata__tags--editable {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.metadata__tag--editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.metadata__tag--editable:hover {
|
||||
background: var(--lora-error);
|
||||
border-color: var(--lora-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.metadata__tag--add {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.metadata__tag--add:hover {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* Notes textarea */
|
||||
.metadata__notes {
|
||||
min-height: 60px;
|
||||
max-height: 120px;
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
color: var(--text-color);
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metadata__notes:focus {
|
||||
outline: none;
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.metadata__notes::placeholder {
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Content area (tabs + scrollable content) */
|
||||
.metadata__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.metadata__header {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.metadata__name {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.metadata__info {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.metadata__section {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
}
|
||||
167
static/css/components/model-modal/overlay.css
Normal file
167
static/css/components/model-modal/overlay.css
Normal file
@@ -0,0 +1,167 @@
|
||||
/* Model Modal Overlay - Split View Layout */
|
||||
|
||||
.model-overlay {
|
||||
position: fixed;
|
||||
top: var(--header-height, 48px);
|
||||
left: var(--sidebar-width, 250px);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--z-modal, 1000);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 0;
|
||||
|
||||
background: var(--bg-color) !important;
|
||||
opacity: 0;
|
||||
animation: modalOverlayFadeIn 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
.model-overlay.sidebar-collapsed {
|
||||
left: var(--sidebar-collapsed-width, 60px);
|
||||
grid-template-columns: 1.3fr 0.7fr;
|
||||
}
|
||||
|
||||
@keyframes modalOverlayFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.model-overlay.closing {
|
||||
opacity: 1 !important;
|
||||
animation: modalOverlayFadeOut 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes modalOverlayFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
.model-overlay__close {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.model-overlay__close:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Keyboard shortcut hint */
|
||||
.model-overlay__hint {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
font-size: 0.85em;
|
||||
border-radius: var(--border-radius-sm);
|
||||
opacity: 0;
|
||||
animation: hintFadeIn 0.3s ease-out 0.5s forwards, hintFadeOut 0.3s ease-out 3.5s forwards;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes hintFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hintFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.model-overlay__hint.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive breakpoints */
|
||||
@media (max-width: 1400px) {
|
||||
.model-overlay {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.model-overlay.sidebar-collapsed {
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.model-overlay {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.model-overlay.sidebar-collapsed {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: stack layout */
|
||||
@media (max-width: 768px) {
|
||||
.model-overlay {
|
||||
left: 0;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.model-overlay.sidebar-collapsed {
|
||||
left: 0;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Body scroll lock when modal is open */
|
||||
body.modal-open {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* Transition effect for content when switching models */
|
||||
.showcase,
|
||||
.metadata {
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.showcase.transitioning,
|
||||
.metadata.transitioning {
|
||||
opacity: 0;
|
||||
}
|
||||
272
static/css/components/model-modal/recipes.css
Normal file
272
static/css/components/model-modal/recipes.css
Normal file
@@ -0,0 +1,272 @@
|
||||
/* Recipes Tab Styles */
|
||||
|
||||
.recipes-loading,
|
||||
.recipes-error,
|
||||
.recipes-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.recipes-loading i,
|
||||
.recipes-error i,
|
||||
.recipes-empty i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: var(--space-3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.recipes-error i {
|
||||
color: var(--lora-error);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.recipes-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
margin: calc(-1 * var(--space-2)) calc(-1 * var(--space-2)) var(--space-2);
|
||||
}
|
||||
|
||||
.recipes-header__text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recipes-header__eyebrow {
|
||||
display: block;
|
||||
font-size: 0.75em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.6;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.recipes-header h3 {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recipes-header__description {
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.recipes-header__view-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: transparent;
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
font-size: 0.8em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipes-header__view-all:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
}
|
||||
|
||||
/* Recipe Cards Grid */
|
||||
.recipes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
/* Recipe Card */
|
||||
.recipe-card {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.recipe-card:focus {
|
||||
outline: none;
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
|
||||
}
|
||||
|
||||
/* Recipe Card Media */
|
||||
.recipe-card__media {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.recipe-card__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.recipe-card:hover .recipe-card__media img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.recipe-card__media-top {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.recipe-card:hover .recipe-card__media-top {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.recipe-card__copy {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.recipe-card__copy:hover {
|
||||
background: var(--lora-accent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Recipe Card Body */
|
||||
.recipe-card__body {
|
||||
padding: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recipe-card__title {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recipe-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.recipe-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.7em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recipe-card__badge--base {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.recipe-card__badge--empty {
|
||||
background: var(--lora-border);
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.recipe-card__badge--ready {
|
||||
background: oklch(60% 0.15 145);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.recipe-card__badge--missing {
|
||||
background: oklch(60% 0.15 30);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.recipe-card__cta {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--lora-border);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
color: var(--lora-accent);
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.recipe-card:hover .recipe-card__cta {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.recipe-card__cta i {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.recipe-card:hover .recipe-card__cta i {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Mobile Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.recipes-header {
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.recipes-header__view-all {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.recipes-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.recipe-card__media-top {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
566
static/css/components/model-modal/showcase.css
Normal file
566
static/css/components/model-modal/showcase.css
Normal file
@@ -0,0 +1,566 @@
|
||||
/* Examples Showcase - Left Panel */
|
||||
|
||||
.showcase {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--lora-surface);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Main image container */
|
||||
.showcase__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.showcase__image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.showcase__image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.showcase__image.loading {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Media container for images and videos */
|
||||
.showcase__media-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.showcase-media-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.showcase__media-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.showcase__media {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: filter 0.2s ease, opacity 0.3s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.showcase__media.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.showcase__media.blurred {
|
||||
filter: blur(25px);
|
||||
}
|
||||
|
||||
/* NSFW notice for main media - redesigned to avoid conflicts with card.css */
|
||||
.showcase__nsfw-notice {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-radius: var(--border-radius-base);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 5;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.showcase__nsfw-notice-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.showcase__nsfw-notice-text {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Show content button in NSFW notice - styled like card.css show-content-btn */
|
||||
.showcase__nsfw-show-btn {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 6px var(--space-3);
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.showcase__nsfw-show-btn:hover {
|
||||
background: oklch(58% 0.28 256);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.showcase__nsfw-show-btn i {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* Control button active state for blur toggle */
|
||||
.showcase__control-btn.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Video indicator for thumbnails */
|
||||
.thumbnail-rail__video-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* NSFW blur for thumbnails */
|
||||
.thumbnail-rail__item.nsfw-blur img {
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Navigation arrows */
|
||||
.showcase__nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.showcase:hover .showcase__nav {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.showcase__nav:hover {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
}
|
||||
|
||||
.showcase__nav--prev {
|
||||
left: var(--space-3);
|
||||
}
|
||||
|
||||
.showcase__nav--next {
|
||||
right: var(--space-3);
|
||||
}
|
||||
|
||||
.showcase__nav:disabled {
|
||||
opacity: 0.3 !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Image controls overlay */
|
||||
.showcase__controls {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Image counter */
|
||||
.showcase__counter {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: var(--space-2);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.showcase__image-wrapper:hover .showcase__counter {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.showcase__counter-current {
|
||||
font-weight: 600;
|
||||
min-width: 2ch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.showcase__counter-separator {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.showcase__counter-total {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.showcase__image-wrapper:hover .showcase__controls {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.showcase__control-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.showcase__control-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.showcase__control-btn--primary:hover {
|
||||
background: var(--lora-accent);
|
||||
}
|
||||
|
||||
.showcase__control-btn--danger:hover {
|
||||
background: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Active state for toggle buttons */
|
||||
.showcase__control-btn.active {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.showcase__control-btn.active:hover {
|
||||
background: var(--lora-accent-hover, #3182ce);
|
||||
}
|
||||
|
||||
/* Params panel (slide up) */
|
||||
.showcase__params {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-color);
|
||||
border-top: 1px solid var(--lora-border);
|
||||
padding: var(--space-3);
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
z-index: 6;
|
||||
max-height: 50%;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.showcase__params.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.showcase__params-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.showcase__params-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.showcase__params-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.showcase__params-close:hover {
|
||||
background: var(--lora-border);
|
||||
}
|
||||
|
||||
/* Prompt display */
|
||||
.showcase__prompt {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.showcase__prompt-label {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
margin-bottom: var(--space-1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.showcase__prompt-text {
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.5;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.showcase__prompt-copy {
|
||||
position: absolute;
|
||||
top: var(--space-1);
|
||||
right: var(--space-1);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: opacity 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.showcase__prompt-copy:hover {
|
||||
opacity: 1;
|
||||
background: var(--lora-border);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.showcase__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.showcase__loading i {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
/* Skeleton loading state */
|
||||
.showcase__skeleton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.skeleton-animation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.skeleton-spinner {
|
||||
font-size: 2.5rem;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.showcase__error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--lora-error);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.showcase__error i {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.showcase__error p {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.showcase__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.showcase__empty i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--space-2);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.showcase__main {
|
||||
padding: var(--space-2);
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.showcase__image {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.showcase__nav {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.showcase__nav--prev {
|
||||
left: var(--space-1);
|
||||
}
|
||||
|
||||
.showcase__nav--next {
|
||||
right: var(--space-1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Lazy Loading Styles
|
||||
============================================ */
|
||||
|
||||
/* Thumbnail lazy loading placeholder */
|
||||
.thumbnail-rail__item img {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
/* Loaded state */
|
||||
.thumbnail-rail__item img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Loading state with skeleton animation */
|
||||
.thumbnail-rail__item img.lazy-load {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--lora-surface) 25%,
|
||||
var(--lora-border) 50%,
|
||||
var(--lora-surface) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: lazy-loading-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes lazy-loading-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Error state for failed loads */
|
||||
.thumbnail-rail__item img.load-error {
|
||||
opacity: 0.3;
|
||||
background: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Cached image - subtle highlight */
|
||||
.thumbnail-rail__item img[data-cached="true"] {
|
||||
border: 1px solid var(--lora-accent);
|
||||
}
|
||||
153
static/css/components/model-modal/tabs.css
Normal file
153
static/css/components/model-modal/tabs.css
Normal file
@@ -0,0 +1,153 @@
|
||||
/* Tabs - Content Area */
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-1);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
opacity: 1;
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
border-bottom-color: var(--lora-accent);
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--badge-update-bg);
|
||||
color: var(--badge-update-text);
|
||||
font-size: 0.65em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tab__badge--pulse {
|
||||
animation: tabBadgePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes tabBadgePulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in oklch, var(--badge-update-bg) 50%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 4px color-mix(in oklch, var(--badge-update-bg) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tab content */
|
||||
.tab-panels {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-panel.active {
|
||||
display: block;
|
||||
animation: tabPanelFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tabPanelFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Accordion within tab panels */
|
||||
.accordion {
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.accordion__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.accordion__header:hover {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
|
||||
}
|
||||
|
||||
.accordion__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.accordion__icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.accordion.expanded .accordion__icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.accordion__content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
|
||||
.accordion.expanded .accordion__content {
|
||||
max-height: 500px; /* Adjust based on content */
|
||||
}
|
||||
|
||||
.accordion__body {
|
||||
padding: var(--space-3);
|
||||
border-top: 1px solid var(--lora-border);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.tab {
|
||||
font-size: 0.8em;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
}
|
||||
|
||||
.tab__badge {
|
||||
display: none; /* Hide badges on small screens */
|
||||
}
|
||||
}
|
||||
151
static/css/components/model-modal/thumbnail-rail.css
Normal file
151
static/css/components/model-modal/thumbnail-rail.css
Normal file
@@ -0,0 +1,151 @@
|
||||
/* Thumbnail Rail - Bottom of Showcase */
|
||||
|
||||
.thumbnail-rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border-top: 1px solid var(--lora-border);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--lora-border) transparent;
|
||||
}
|
||||
|
||||
.thumbnail-rail::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.thumbnail-rail::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.thumbnail-rail::-webkit-scrollbar-thumb {
|
||||
background-color: var(--lora-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Thumbnail item */
|
||||
.thumbnail-rail__item {
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.thumbnail-rail__item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.thumbnail-rail__item img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.thumbnail-rail__item:hover {
|
||||
border-color: var(--lora-border);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.thumbnail-rail__item.active {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
|
||||
}
|
||||
|
||||
/* NSFW blur for thumbnails - BEM naming to avoid conflicts with global .nsfw-blur */
|
||||
.thumbnail-rail__item--nsfw-blurred img {
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Legacy support for old class names (deprecated) */
|
||||
.thumbnail-rail__item.nsfw img {
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.thumbnail-rail__nsfw-badge {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
font-size: 0.65em;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Add button */
|
||||
.thumbnail-rail__add {
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: var(--bg-color);
|
||||
border: 2px dashed var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.thumbnail-rail__add:hover {
|
||||
border-color: var(--lora-accent);
|
||||
color: var(--lora-accent);
|
||||
opacity: 1;
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
|
||||
}
|
||||
|
||||
.thumbnail-rail__add i {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Upload area (inline expansion) */
|
||||
.thumbnail-rail__upload {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border-top: 1px solid var(--lora-border);
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.thumbnail-rail__upload.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.thumbnail-rail {
|
||||
padding: var(--space-2);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.thumbnail-rail__item,
|
||||
.thumbnail-rail__add {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
163
static/css/components/model-modal/upload.css
Normal file
163
static/css/components/model-modal/upload.css
Normal file
@@ -0,0 +1,163 @@
|
||||
/* Upload Area Styles */
|
||||
|
||||
.upload-area {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
border-top: 1px solid var(--lora-border);
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 10;
|
||||
max-height: 50%;
|
||||
}
|
||||
|
||||
.upload-area.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.upload-area__content {
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Dropzone */
|
||||
.upload-area__dropzone {
|
||||
border: 2px dashed var(--lora-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.upload-area__dropzone:hover {
|
||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02);
|
||||
}
|
||||
|
||||
.upload-area__dropzone.dragover {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08);
|
||||
}
|
||||
|
||||
.upload-area__input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.upload-area__placeholder {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.upload-area__placeholder i {
|
||||
font-size: 2.5rem;
|
||||
color: var(--lora-accent);
|
||||
opacity: 0.6;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.upload-area__title {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.upload-area__hint {
|
||||
margin: 0;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Uploading State */
|
||||
.upload-area__uploading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.upload-area__uploading i {
|
||||
font-size: 2rem;
|
||||
color: var(--lora-accent);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.upload-area__uploading p {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.upload-area__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-area__cancel {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: transparent;
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.upload-area__cancel:hover {
|
||||
border-color: var(--lora-error);
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Add Button in Empty State */
|
||||
.showcase__add-btn {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--lora-accent);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: white;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.showcase__add-btn:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Mobile Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.upload-area {
|
||||
max-height: 60%;
|
||||
}
|
||||
|
||||
.upload-area__content {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.upload-area__dropzone {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.upload-area__placeholder i {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
378
static/css/components/model-modal/versions.css
Normal file
378
static/css/components/model-modal/versions.css
Normal file
@@ -0,0 +1,378 @@
|
||||
/* Versions Tab Styles */
|
||||
|
||||
.versions-loading,
|
||||
.versions-error,
|
||||
.versions-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.versions-loading i,
|
||||
.versions-error i,
|
||||
.versions-empty i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: var(--space-3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.versions-error i {
|
||||
color: var(--lora-error);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.versions-empty-filter {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.versions-toolbar {
|
||||
padding: var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
margin: calc(-1 * var(--space-2)) calc(-1 * var(--space-2)) var(--space-2);
|
||||
}
|
||||
|
||||
.versions-toolbar-info-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.versions-toolbar-info-heading h3 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.versions-toolbar-info p {
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.versions-toolbar-actions {
|
||||
margin-top: var(--space-2);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.versions-filter-toggle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.versions-filter-toggle:hover {
|
||||
opacity: 1;
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
}
|
||||
|
||||
.versions-filter-toggle.active {
|
||||
opacity: 1;
|
||||
background: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.versions-toolbar-btn {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.versions-toolbar-btn-primary {
|
||||
background: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.versions-toolbar-btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Version Cards List */
|
||||
.versions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Version Card */
|
||||
.version-card {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr auto;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.version-card:hover {
|
||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.version-card.is-current {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
|
||||
}
|
||||
|
||||
.version-card.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-card.is-clickable:hover {
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Version Media */
|
||||
.version-media {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.version-media img,
|
||||
.version-media video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.version-media-placeholder {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
text-align: center;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
/* Version Details */
|
||||
.version-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
margin-bottom: var(--space-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.version-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.version-badge-current {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.version-badge-success {
|
||||
background: var(--lora-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.version-badge-info {
|
||||
background: var(--badge-update-bg);
|
||||
color: var(--badge-update-text);
|
||||
}
|
||||
|
||||
.version-badge-muted {
|
||||
background: var(--lora-border);
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.version-meta {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.version-meta-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.version-meta-primary {
|
||||
color: var(--lora-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Version Actions */
|
||||
.version-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.version-action {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-action-primary {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.version-action-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.version-action-danger {
|
||||
background: transparent;
|
||||
border-color: var(--lora-error);
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.version-action-danger:hover {
|
||||
background: var(--lora-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.version-action-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--lora-border);
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.version-action-ghost:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--text-color);
|
||||
}
|
||||
|
||||
.version-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Delete Modal for Version */
|
||||
.version-delete-modal .delete-model-info {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-3) 0;
|
||||
padding: var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.version-delete-modal .delete-preview {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.version-delete-modal .delete-preview img,
|
||||
.version-delete-modal .delete-preview video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.version-delete-modal .delete-info h3 {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.version-delete-modal .version-base-model {
|
||||
margin: 0;
|
||||
opacity: 0.7;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Mobile Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.version-card {
|
||||
grid-template-columns: 60px 1fr auto;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.version-media {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.version-name {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.version-action {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.versions-toolbar-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.versions-toolbar-btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,18 @@
|
||||
@import 'components/lora-modal/showcase.css';
|
||||
@import 'components/lora-modal/triggerwords.css';
|
||||
@import 'components/lora-modal/versions.css';
|
||||
|
||||
/* New Model Modal Split-View Design (Phase 1) */
|
||||
@import 'components/model-modal/overlay.css';
|
||||
@import 'components/model-modal/showcase.css';
|
||||
@import 'components/model-modal/thumbnail-rail.css';
|
||||
@import 'components/model-modal/metadata.css';
|
||||
@import 'components/model-modal/tabs.css';
|
||||
|
||||
/* Model Modal Phase 2 - Tabs and Upload */
|
||||
@import 'components/model-modal/versions.css';
|
||||
@import 'components/model-modal/recipes.css';
|
||||
@import 'components/model-modal/upload.css';
|
||||
@import 'components/shared/edit-metadata.css';
|
||||
@import 'components/search-filter.css';
|
||||
@import 'components/bulk.css';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { updateElementText, translate } from '../../utils/i18nHelpers.js';
|
||||
import { updateElementText } from '../../utils/i18nHelpers.js';
|
||||
|
||||
export class BulkContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -71,40 +71,6 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
if (setContentRatingItem) {
|
||||
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
||||
|
||||
if (skipMetadataRefreshItem && resumeMetadataRefreshItem) {
|
||||
const skipCount = this.countSkipStatus(true);
|
||||
const resumeCount = this.countSkipStatus(false);
|
||||
const totalCount = skipCount + resumeCount;
|
||||
|
||||
if (skipCount === totalCount) {
|
||||
skipMetadataRefreshItem.style.display = 'none';
|
||||
resumeMetadataRefreshItem.style.display = 'flex';
|
||||
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||
'loras.bulkOperations.resumeMetadataRefresh'
|
||||
);
|
||||
} else if (resumeCount === totalCount) {
|
||||
skipMetadataRefreshItem.style.display = 'flex';
|
||||
resumeMetadataRefreshItem.style.display = 'none';
|
||||
skipMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||
'loras.bulkOperations.skipMetadataRefresh'
|
||||
);
|
||||
} else {
|
||||
skipMetadataRefreshItem.style.display = 'flex';
|
||||
resumeMetadataRefreshItem.style.display = 'flex';
|
||||
skipMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||
'loras.bulkOperations.skipMetadataRefreshCount',
|
||||
{ count: resumeCount }
|
||||
);
|
||||
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||
'loras.bulkOperations.resumeMetadataRefreshCount',
|
||||
{ count: skipCount }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSelectedCountHeader() {
|
||||
@@ -114,20 +80,6 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
countSkipStatus(skipState) {
|
||||
let count = 0;
|
||||
for (const filePath of state.selectedModels) {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
if (card) {
|
||||
const isSkipped = card.dataset.skip_metadata_refresh === 'true';
|
||||
if (isSkipped === skipState) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
this.updateMenuItemsForModelType();
|
||||
this.updateSelectedCountHeader();
|
||||
@@ -166,12 +118,6 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
case 'auto-organize':
|
||||
bulkManager.autoOrganizeSelectedModels();
|
||||
break;
|
||||
case 'skip-metadata-refresh':
|
||||
bulkManager.setSkipMetadataRefresh(true);
|
||||
break;
|
||||
case 'resume-metadata-refresh':
|
||||
bulkManager.setSkipMetadataRefresh(false);
|
||||
break;
|
||||
case 'delete-all':
|
||||
bulkManager.showBulkDeleteModal();
|
||||
break;
|
||||
|
||||
@@ -217,18 +217,8 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
// Set recipe image
|
||||
const mediaContainer = document.getElementById('recipePreviewContainer');
|
||||
if (mediaContainer) {
|
||||
// Stop any playing video before replacing content
|
||||
const existingVideo = mediaContainer.querySelector('video');
|
||||
if (existingVideo) {
|
||||
existingVideo.pause();
|
||||
existingVideo.currentTime = 0;
|
||||
}
|
||||
|
||||
// Clear the container
|
||||
mediaContainer.innerHTML = '';
|
||||
|
||||
const modalImage = document.getElementById('recipeModalImage');
|
||||
if (modalImage) {
|
||||
// Ensure file_url exists, fallback to file_path if needed
|
||||
const imageUrl = recipe.file_url ||
|
||||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
||||
@@ -237,6 +227,10 @@ class RecipeModal {
|
||||
// Check if the file is a video (mp4)
|
||||
const isVideo = imageUrl.toLowerCase().endsWith('.mp4');
|
||||
|
||||
// Replace the image element with appropriate media element
|
||||
const mediaContainer = modalImage.parentElement;
|
||||
mediaContainer.innerHTML = '';
|
||||
|
||||
if (isVideo) {
|
||||
const videoElement = document.createElement('video');
|
||||
videoElement.id = 'recipeModalVideo';
|
||||
|
||||
871
static/js/components/model-modal/MetadataPanel.js
Normal file
871
static/js/components/model-modal/MetadataPanel.js
Normal file
@@ -0,0 +1,871 @@
|
||||
/**
|
||||
* MetadataPanel - Right panel for model metadata and tabs
|
||||
* Features:
|
||||
* - Fixed header with model info
|
||||
* - Compact metadata grid
|
||||
* - Editable fields (usage tips, trigger words, notes)
|
||||
* - Tabs with accordion content (Description, Versions, Recipes)
|
||||
*/
|
||||
|
||||
import { escapeHtml, formatFileSize } from '../shared/utils.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { VersionsTab } from './VersionsTab.js';
|
||||
import { RecipesTab } from './RecipesTab.js';
|
||||
|
||||
export class MetadataPanel {
|
||||
constructor(container) {
|
||||
this.element = container;
|
||||
this.model = null;
|
||||
this.modelType = null;
|
||||
this.activeTab = 'description';
|
||||
this.versionsTab = null;
|
||||
this.recipesTab = null;
|
||||
this.notesDebounceTimer = null;
|
||||
this.isEditingUsageTips = false;
|
||||
this.isEditingTriggerWords = false;
|
||||
this.editingTriggerWords = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the metadata panel
|
||||
*/
|
||||
render({ model, modelType }) {
|
||||
this.model = model;
|
||||
this.modelType = modelType;
|
||||
|
||||
this.element.innerHTML = this.getTemplate();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTML template
|
||||
*/
|
||||
getTemplate() {
|
||||
const m = this.model;
|
||||
const civitai = m.civitai || {};
|
||||
const creator = civitai.creator || {};
|
||||
|
||||
return `
|
||||
<div class="metadata__header">
|
||||
<div class="metadata__title-row">
|
||||
<h2 class="metadata__name">${escapeHtml(m.model_name || 'Unknown')}</h2>
|
||||
<button class="metadata__edit-btn" data-action="edit-name" title="${translate('modals.model.actions.editModelName', {}, 'Edit model name')}">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="metadata__actions">
|
||||
${creator.username ? `
|
||||
<div class="metadata__creator" data-action="view-creator" data-username="${escapeHtml(creator.username)}">
|
||||
${creator.image ? `
|
||||
<div class="metadata__creator-avatar">
|
||||
<img src="${creator.image}" alt="${escapeHtml(creator.username)}" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
<i class="fas fa-user" style="display: none;"></i>
|
||||
</div>
|
||||
` : `
|
||||
<div class="metadata__creator-avatar">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>
|
||||
`}
|
||||
<span class="metadata__creator-name">${escapeHtml(creator.username)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${m.from_civitai ? `
|
||||
<a class="metadata__civitai-link" href="https://civitai.com/models/${civitai.modelId}" target="_blank" rel="noopener">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span>${translate('modals.model.actions.viewOnCivitai', {}, 'Civitai')}</span>
|
||||
</a>
|
||||
` : ''}
|
||||
|
||||
${this.renderLicenseIcons()}
|
||||
</div>
|
||||
|
||||
${this.renderTags(m.tags)}
|
||||
</div>
|
||||
|
||||
<div class="metadata__info">
|
||||
<div class="metadata__info-grid">
|
||||
<div class="metadata__info-item">
|
||||
<span class="metadata__info-label">${translate('modals.model.metadata.version', {}, 'Version')}</span>
|
||||
<span class="metadata__info-value">${escapeHtml(civitai.name || 'N/A')}</span>
|
||||
</div>
|
||||
|
||||
<div class="metadata__info-item">
|
||||
<span class="metadata__info-label">${translate('modals.model.metadata.size', {}, 'Size')}</span>
|
||||
<span class="metadata__info-value metadata__info-value--mono">${formatFileSize(m.file_size)}</span>
|
||||
</div>
|
||||
|
||||
<div class="metadata__info-item">
|
||||
<span class="metadata__info-label">${translate('modals.model.metadata.baseModel', {}, 'Base Model')}</span>
|
||||
<span class="metadata__info-value">${escapeHtml(m.base_model || translate('modals.model.metadata.unknown', {}, 'Unknown'))}</span>
|
||||
</div>
|
||||
|
||||
<div class="metadata__info-item">
|
||||
<span class="metadata__info-label">${translate('modals.model.metadata.fileName', {}, 'File Name')}</span>
|
||||
<span class="metadata__info-value metadata__info-value--mono">${escapeHtml(m.file_name || 'N/A')}</span>
|
||||
</div>
|
||||
|
||||
<div class="metadata__info-item metadata__info-item--full">
|
||||
<span class="metadata__info-label">${translate('modals.model.metadata.location', {}, 'Location')}</span>
|
||||
<span class="metadata__info-value metadata__info-value--path" data-action="open-location" title="${translate('modals.model.actions.openFileLocation', {}, 'Open file location')}">
|
||||
${escapeHtml((m.file_path || '').replace(/[^/]+$/, '') || 'N/A')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.modelType === 'loras' ? this.renderLoraSpecific() : ''}
|
||||
|
||||
${this.renderNotes(m.notes)}
|
||||
|
||||
<div class="metadata__content">
|
||||
${this.renderTabs()}
|
||||
${this.renderTabPanels()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render license icons
|
||||
*/
|
||||
renderLicenseIcons() {
|
||||
const license = this.model.civitai?.model;
|
||||
if (!license) return '';
|
||||
|
||||
const icons = [];
|
||||
|
||||
if (license.allowNoCredit === false) {
|
||||
icons.push({ icon: 'user-check', title: translate('modals.model.license.creditRequired', {}, 'Creator credit required') });
|
||||
}
|
||||
|
||||
if (license.allowCommercialUse) {
|
||||
const restrictions = this.resolveCommercialRestrictions(license.allowCommercialUse);
|
||||
restrictions.forEach(r => {
|
||||
icons.push({ icon: r.icon, title: r.title });
|
||||
});
|
||||
}
|
||||
|
||||
if (license.allowDerivatives === false) {
|
||||
icons.push({ icon: 'exchange-off', title: translate('modals.model.license.noDerivatives', {}, 'No sharing merges') });
|
||||
}
|
||||
|
||||
if (license.allowDifferentLicense === false) {
|
||||
icons.push({ icon: 'rotate-2', title: translate('modals.model.license.noReLicense', {}, 'Same permissions required') });
|
||||
}
|
||||
|
||||
if (icons.length === 0) return '';
|
||||
|
||||
return `
|
||||
<div class="metadata__licenses">
|
||||
${icons.map(icon => `
|
||||
<span class="metadata__license-icon"
|
||||
style="--license-icon-image: url('/loras_static/images/tabler/${icon.icon}.svg')"
|
||||
title="${escapeHtml(icon.title)}"
|
||||
role="img"
|
||||
aria-label="${escapeHtml(icon.title)}">
|
||||
</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve commercial restrictions
|
||||
*/
|
||||
resolveCommercialRestrictions(value) {
|
||||
const COMMERCIAL_CONFIG = [
|
||||
{ key: 'image', icon: 'photo-off', title: translate('modals.model.license.noImageSell', {}, 'No selling generated content') },
|
||||
{ key: 'rentcivit', icon: 'brush-off', title: translate('modals.model.license.noRentCivit', {}, 'No Civitai generation') },
|
||||
{ key: 'rent', icon: 'world-off', title: translate('modals.model.license.noRent', {}, 'No generation services') },
|
||||
{ key: 'sell', icon: 'shopping-cart-off', title: translate('modals.model.license.noSell', {}, 'No selling models') },
|
||||
];
|
||||
|
||||
let allowed = new Set();
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
|
||||
values.forEach(v => {
|
||||
if (!v && v !== '') return;
|
||||
const cleaned = String(v).trim().toLowerCase().replace(/[\s_-]+/g, '').replace(/[^a-z]/g, '');
|
||||
if (cleaned) allowed.add(cleaned);
|
||||
});
|
||||
|
||||
if (allowed.has('sell')) {
|
||||
allowed.add('rent');
|
||||
allowed.add('rentcivit');
|
||||
allowed.add('image');
|
||||
}
|
||||
if (allowed.has('rent')) {
|
||||
allowed.add('rentcivit');
|
||||
}
|
||||
|
||||
return COMMERCIAL_CONFIG.filter(config => !allowed.has(config.key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tags
|
||||
*/
|
||||
renderTags(tags) {
|
||||
if (!tags || tags.length === 0) return '';
|
||||
|
||||
const visibleTags = tags.slice(0, 8);
|
||||
const remaining = tags.length - visibleTags.length;
|
||||
|
||||
return `
|
||||
<div class="metadata__tags">
|
||||
${visibleTags.map(tag => `
|
||||
<span class="metadata__tag">${escapeHtml(tag)}</span>
|
||||
`).join('')}
|
||||
${remaining > 0 ? `<span class="metadata__tag">+${remaining}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render LoRA specific sections with editing
|
||||
*/
|
||||
renderLoraSpecific() {
|
||||
const m = this.model;
|
||||
const usageTips = m.usage_tips ? JSON.parse(m.usage_tips) : {};
|
||||
const triggerWords = this.isEditingTriggerWords
|
||||
? this.editingTriggerWords
|
||||
: (m.civitai?.trainedWords || []);
|
||||
|
||||
return `
|
||||
<div class="metadata__section">
|
||||
<div class="metadata__section-header">
|
||||
<span class="metadata__section-title">${translate('modals.model.metadata.usageTips', {}, 'Usage Tips')}</span>
|
||||
${!this.isEditingUsageTips ? `
|
||||
<button class="metadata__section-edit" data-action="edit-usage-tips" title="${translate('modals.model.usageTips.add', {}, 'Add usage tip')}">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="metadata__tags--editable">
|
||||
${Object.entries(usageTips).map(([key, value]) => `
|
||||
<span class="metadata__tag metadata__tag--editable" data-key="${escapeHtml(key)}" data-action="remove-usage-tip" title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||
${escapeHtml(key)}: ${escapeHtml(String(value))}
|
||||
</span>
|
||||
`).join('')}
|
||||
${this.isEditingUsageTips ? this.renderUsageTipEditor() : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metadata__section">
|
||||
<div class="metadata__section-header">
|
||||
<span class="metadata__section-title">${translate('modals.model.triggerWords.label', {}, 'Trigger Words')}</span>
|
||||
<div class="metadata__section-actions">
|
||||
${!this.isEditingTriggerWords ? `
|
||||
<button class="metadata__section-edit" data-action="copy-trigger-words" title="${translate('modals.model.triggerWords.copyWord', {}, 'Copy all trigger words')}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<button class="metadata__section-edit" data-action="edit-trigger-words" title="${translate('modals.model.triggerWords.edit', {}, 'Edit trigger words')}">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
` : `
|
||||
<button class="metadata__section-edit" data-action="cancel-trigger-words" title="${translate('common.actions.cancel', {}, 'Cancel')}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<button class="metadata__section-edit metadata__section-edit--primary" data-action="save-trigger-words" title="${translate('common.actions.save', {}, 'Save')}">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata__tags--editable">
|
||||
${triggerWords.map(word => `
|
||||
<span class="metadata__tag ${this.isEditingTriggerWords ? 'metadata__tag--removable' : 'metadata__tag--editable'}"
|
||||
data-word="${escapeHtml(word)}"
|
||||
${this.isEditingTriggerWords ? 'data-action="remove-trigger-word"' : 'data-action="copy-trigger-word"'}
|
||||
title="${this.isEditingTriggerWords ? translate('common.actions.delete', {}, 'Delete') : translate('modals.model.triggerWords.copyWord', {}, 'Copy trigger word')}">
|
||||
${escapeHtml(word)}
|
||||
${this.isEditingTriggerWords ? '<i class="fas fa-times"></i>' : ''}
|
||||
</span>
|
||||
`).join('')}
|
||||
${this.isEditingTriggerWords ? `
|
||||
<input type="text"
|
||||
class="metadata__tag-input"
|
||||
placeholder="${translate('modals.model.triggerWords.addPlaceholder', {}, 'Type to add...')}"
|
||||
data-action="add-trigger-word-input"
|
||||
autofocus>
|
||||
` : triggerWords.length === 0 ? `
|
||||
<span class="metadata__tag metadata__tag--placeholder">${translate('modals.model.triggerWords.noTriggerWordsNeeded', {}, 'No trigger words needed')}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render usage tip editor
|
||||
*/
|
||||
renderUsageTipEditor() {
|
||||
return `
|
||||
<div class="usage-tip-editor">
|
||||
<select class="usage-tip-key" data-action="usage-tip-key-change">
|
||||
<option value="">${translate('modals.model.usageTips.addPresetParameter', {}, 'Select parameter...')}</option>
|
||||
<option value="strength">${translate('modals.model.usageTips.strength', {}, 'Strength')}</option>
|
||||
<option value="strength_min">${translate('modals.model.usageTips.strengthMin', {}, 'Strength Min')}</option>
|
||||
<option value="strength_max">${translate('modals.model.usageTips.strengthMax', {}, 'Strength Max')}</option>
|
||||
<option value="clip_strength">${translate('modals.model.usageTips.clipStrength', {}, 'Clip Strength')}</option>
|
||||
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
|
||||
</select>
|
||||
<input type="text"
|
||||
class="usage-tip-value"
|
||||
placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}"
|
||||
data-action="usage-tip-value-input">
|
||||
<button class="usage-tip-add" data-action="add-usage-tip">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button class="usage-tip-cancel" data-action="cancel-usage-tips">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render notes section
|
||||
*/
|
||||
renderNotes(notes) {
|
||||
return `
|
||||
<div class="metadata__section metadata__section--notes">
|
||||
<div class="metadata__section-header">
|
||||
<span class="metadata__section-title">${translate('modals.model.metadata.additionalNotes', {}, 'Notes')}</span>
|
||||
<span class="metadata__save-indicator" data-save-indicator style="display: none;">
|
||||
<i class="fas fa-check"></i> Saved
|
||||
</span>
|
||||
</div>
|
||||
<textarea class="metadata__notes"
|
||||
placeholder="${translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}"
|
||||
data-action="notes-input">${escapeHtml(notes || '')}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tabs
|
||||
*/
|
||||
renderTabs() {
|
||||
const tabs = [
|
||||
{ id: 'description', label: translate('modals.model.tabs.description', {}, 'Description') },
|
||||
{ id: 'versions', label: translate('modals.model.tabs.versions', {}, 'Versions') },
|
||||
];
|
||||
|
||||
if (this.modelType === 'loras') {
|
||||
tabs.push({ id: 'recipes', label: translate('modals.model.tabs.recipes', {}, 'Recipes') });
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="tabs">
|
||||
${tabs.map(tab => `
|
||||
<button class="tab ${tab.id === this.activeTab ? 'active' : ''}"
|
||||
data-tab="${tab.id}"
|
||||
data-action="switch-tab">
|
||||
<span class="tab__label">${tab.label}</span>
|
||||
${tab.id === 'versions' && this.model.update_available ? `
|
||||
<span class="tab__badge tab__badge--pulse">${translate('modals.model.tabs.update', {}, 'Update')}</span>
|
||||
` : ''}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tab panels
|
||||
*/
|
||||
renderTabPanels() {
|
||||
const civitai = this.model.civitai || {};
|
||||
|
||||
return `
|
||||
<div class="tab-panels">
|
||||
<div class="tab-panel ${this.activeTab === 'description' ? 'active' : ''}" data-panel="description">
|
||||
<div class="accordion expanded">
|
||||
<div class="accordion__header" data-action="toggle-accordion">
|
||||
<span class="accordion__title">${translate('modals.model.metadata.aboutThisVersion', {}, 'About this version')}</span>
|
||||
<i class="accordion__icon fas fa-chevron-down"></i>
|
||||
</div>
|
||||
<div class="accordion__content">
|
||||
<div class="accordion__body">
|
||||
${civitai.description ? `
|
||||
<div class="markdown-content">${civitai.description}</div>
|
||||
` : `
|
||||
<p class="text-muted">${translate('modals.model.description.noDescription', {}, 'No description available')}</p>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion">
|
||||
<div class="accordion__header" data-action="toggle-accordion">
|
||||
<span class="accordion__title">${translate('modals.model.accordion.modelDescription', {}, 'Model Description')}</span>
|
||||
<i class="accordion__icon fas fa-chevron-down"></i>
|
||||
</div>
|
||||
<div class="accordion__content">
|
||||
<div class="accordion__body">
|
||||
${civitai.model?.description ? `
|
||||
<div class="markdown-content">${civitai.model.description}</div>
|
||||
` : `
|
||||
<p class="text-muted">${translate('modals.model.description.noDescription', {}, 'No description available')}</p>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel ${this.activeTab === 'versions' ? 'active' : ''}" data-panel="versions">
|
||||
<div class="versions-tab-container"></div>
|
||||
</div>
|
||||
|
||||
${this.modelType === 'loras' ? `
|
||||
<div class="tab-panel ${this.activeTab === 'recipes' ? 'active' : ''}" data-panel="recipes">
|
||||
<div class="recipes-tab-container"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event listeners
|
||||
*/
|
||||
bindEvents() {
|
||||
this.element.addEventListener('click', (e) => {
|
||||
const target = e.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
|
||||
switch (action) {
|
||||
case 'switch-tab':
|
||||
const tabId = target.dataset.tab;
|
||||
this.switchTab(tabId);
|
||||
break;
|
||||
case 'toggle-accordion':
|
||||
target.closest('.accordion')?.classList.toggle('expanded');
|
||||
break;
|
||||
case 'open-location':
|
||||
this.openFileLocation();
|
||||
break;
|
||||
case 'view-creator':
|
||||
const username = target.dataset.username || target.closest('[data-username]')?.dataset.username;
|
||||
if (username) {
|
||||
window.open(`https://civitai.com/user/${username}`, '_blank');
|
||||
}
|
||||
break;
|
||||
case 'edit-name':
|
||||
this.editModelName();
|
||||
break;
|
||||
case 'edit-usage-tips':
|
||||
this.startEditingUsageTips();
|
||||
break;
|
||||
case 'cancel-usage-tips':
|
||||
this.cancelEditingUsageTips();
|
||||
break;
|
||||
case 'add-usage-tip':
|
||||
this.addUsageTip();
|
||||
break;
|
||||
case 'remove-usage-tip':
|
||||
const key = target.dataset.key;
|
||||
if (key) this.removeUsageTip(key);
|
||||
break;
|
||||
case 'edit-trigger-words':
|
||||
this.startEditingTriggerWords();
|
||||
break;
|
||||
case 'cancel-trigger-words':
|
||||
this.cancelEditingTriggerWords();
|
||||
break;
|
||||
case 'save-trigger-words':
|
||||
this.saveTriggerWords();
|
||||
break;
|
||||
case 'copy-trigger-words':
|
||||
this.copyAllTriggerWords();
|
||||
break;
|
||||
case 'copy-trigger-word':
|
||||
const word = target.dataset.word;
|
||||
if (word) this.copyTriggerWord(word);
|
||||
break;
|
||||
case 'remove-trigger-word':
|
||||
const wordToRemove = target.dataset.word || target.closest('[data-word]')?.dataset.word;
|
||||
if (wordToRemove) this.removeTriggerWord(wordToRemove);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle input events
|
||||
this.element.addEventListener('input', (e) => {
|
||||
if (e.target.dataset.action === 'notes-input') {
|
||||
this.handleNotesInput(e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
this.element.addEventListener('keydown', (e) => {
|
||||
if (e.target.dataset.action === 'add-trigger-word-input' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const value = e.target.value.trim();
|
||||
if (value) {
|
||||
this.addTriggerWord(value);
|
||||
e.target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (e.target.dataset.action === 'usage-tip-value-input' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.addUsageTip();
|
||||
}
|
||||
});
|
||||
|
||||
// Load initial tab content
|
||||
if (this.activeTab === 'versions') {
|
||||
this.loadVersionsTab();
|
||||
} else if (this.activeTab === 'recipes') {
|
||||
this.loadRecipesTab();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch active tab
|
||||
*/
|
||||
switchTab(tabId) {
|
||||
this.activeTab = tabId;
|
||||
|
||||
// Update tab buttons
|
||||
this.element.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabId);
|
||||
});
|
||||
|
||||
// Update panels
|
||||
this.element.querySelectorAll('.tab-panel').forEach(panel => {
|
||||
panel.classList.toggle('active', panel.dataset.panel === tabId);
|
||||
});
|
||||
|
||||
// Load tab-specific data
|
||||
if (tabId === 'versions') {
|
||||
this.loadVersionsTab();
|
||||
} else if (tabId === 'recipes') {
|
||||
this.loadRecipesTab();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load versions tab
|
||||
*/
|
||||
loadVersionsTab() {
|
||||
if (!this.versionsTab) {
|
||||
const container = this.element.querySelector('.versions-tab-container');
|
||||
if (container) {
|
||||
this.versionsTab = new VersionsTab(container);
|
||||
this.versionsTab.render({ model: this.model, modelType: this.modelType });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load recipes tab
|
||||
*/
|
||||
loadRecipesTab() {
|
||||
if (!this.recipesTab) {
|
||||
const container = this.element.querySelector('.recipes-tab-container');
|
||||
if (container) {
|
||||
this.recipesTab = new RecipesTab(container);
|
||||
this.recipesTab.render({ model: this.model });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle notes input with auto-save
|
||||
*/
|
||||
handleNotesInput(value) {
|
||||
// Clear existing timer
|
||||
if (this.notesDebounceTimer) {
|
||||
clearTimeout(this.notesDebounceTimer);
|
||||
}
|
||||
|
||||
// Show saving indicator
|
||||
const indicator = this.element.querySelector('[data-save-indicator]');
|
||||
if (indicator) {
|
||||
indicator.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving...';
|
||||
indicator.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
// Debounce save
|
||||
this.notesDebounceTimer = setTimeout(() => {
|
||||
this.saveNotes(value);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save notes to server
|
||||
*/
|
||||
async saveNotes(notes) {
|
||||
if (!this.model?.file_path) return;
|
||||
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
await client.saveModelMetadata(this.model.file_path, { notes });
|
||||
|
||||
const indicator = this.element.querySelector('[data-save-indicator]');
|
||||
if (indicator) {
|
||||
indicator.innerHTML = '<i class="fas fa-check"></i> Saved';
|
||||
setTimeout(() => {
|
||||
indicator.style.display = 'none';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
showToast('modals.model.notes.saved', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to save notes:', err);
|
||||
|
||||
const indicator = this.element.querySelector('[data-save-indicator]');
|
||||
if (indicator) {
|
||||
indicator.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Failed';
|
||||
}
|
||||
|
||||
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing usage tips
|
||||
*/
|
||||
startEditingUsageTips() {
|
||||
this.isEditingUsageTips = true;
|
||||
this.refreshLoraSpecificSection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel editing usage tips
|
||||
*/
|
||||
cancelEditingUsageTips() {
|
||||
this.isEditingUsageTips = false;
|
||||
this.refreshLoraSpecificSection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add usage tip
|
||||
*/
|
||||
async addUsageTip() {
|
||||
const keySelect = this.element.querySelector('.usage-tip-key');
|
||||
const valueInput = this.element.querySelector('.usage-tip-value');
|
||||
|
||||
const key = keySelect?.value;
|
||||
const value = valueInput?.value.trim();
|
||||
|
||||
if (!key || !value) return;
|
||||
|
||||
try {
|
||||
const usageTips = this.model.usage_tips ? JSON.parse(this.model.usage_tips) : {};
|
||||
usageTips[key] = value;
|
||||
|
||||
const client = getModelApiClient(this.modelType);
|
||||
await client.saveModelMetadata(this.model.file_path, { usage_tips: JSON.stringify(usageTips) });
|
||||
|
||||
this.model.usage_tips = JSON.stringify(usageTips);
|
||||
this.isEditingUsageTips = false;
|
||||
this.refreshLoraSpecificSection();
|
||||
showToast('common.actions.save', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to save usage tip:', err);
|
||||
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove usage tip
|
||||
*/
|
||||
async removeUsageTip(key) {
|
||||
try {
|
||||
const usageTips = this.model.usage_tips ? JSON.parse(this.model.usage_tips) : {};
|
||||
delete usageTips[key];
|
||||
|
||||
const client = getModelApiClient(this.modelType);
|
||||
await client.saveModelMetadata(this.model.file_path, {
|
||||
usage_tips: Object.keys(usageTips).length > 0 ? JSON.stringify(usageTips) : null
|
||||
});
|
||||
|
||||
this.model.usage_tips = Object.keys(usageTips).length > 0 ? JSON.stringify(usageTips) : null;
|
||||
this.refreshLoraSpecificSection();
|
||||
showToast('common.actions.delete', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to remove usage tip:', err);
|
||||
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing trigger words
|
||||
*/
|
||||
startEditingTriggerWords() {
|
||||
this.isEditingTriggerWords = true;
|
||||
this.editingTriggerWords = [...(this.model.civitai?.trainedWords || [])];
|
||||
this.refreshLoraSpecificSection();
|
||||
|
||||
// Focus input
|
||||
setTimeout(() => {
|
||||
const input = this.element.querySelector('.metadata__tag-input');
|
||||
if (input) input.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel editing trigger words
|
||||
*/
|
||||
cancelEditingTriggerWords() {
|
||||
this.isEditingTriggerWords = false;
|
||||
this.editingTriggerWords = [];
|
||||
this.refreshLoraSpecificSection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add trigger word during editing
|
||||
*/
|
||||
addTriggerWord(word) {
|
||||
if (!word.trim()) return;
|
||||
if (this.editingTriggerWords.includes(word.trim())) {
|
||||
showToast('modals.model.triggerWords.validation.duplicate', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
this.editingTriggerWords.push(word.trim());
|
||||
this.refreshLoraSpecificSection();
|
||||
|
||||
// Focus input again
|
||||
setTimeout(() => {
|
||||
const input = this.element.querySelector('.metadata__tag-input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove trigger word during editing
|
||||
*/
|
||||
removeTriggerWord(word) {
|
||||
this.editingTriggerWords = this.editingTriggerWords.filter(w => w !== word);
|
||||
this.refreshLoraSpecificSection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save trigger words
|
||||
*/
|
||||
async saveTriggerWords() {
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
await client.saveModelMetadata(this.model.file_path, {
|
||||
trained_words: this.editingTriggerWords
|
||||
});
|
||||
|
||||
// Update local model data
|
||||
if (!this.model.civitai) this.model.civitai = {};
|
||||
this.model.civitai.trainedWords = [...this.editingTriggerWords];
|
||||
|
||||
this.isEditingTriggerWords = false;
|
||||
this.editingTriggerWords = [];
|
||||
this.refreshLoraSpecificSection();
|
||||
showToast('common.actions.save', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to save trigger words:', err);
|
||||
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy single trigger word
|
||||
*/
|
||||
async copyTriggerWord(word) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(word);
|
||||
showToast('modals.model.triggerWords.copyWord', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy trigger word:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy all trigger words
|
||||
*/
|
||||
async copyAllTriggerWords() {
|
||||
const words = this.model.civitai?.trainedWords || [];
|
||||
if (words.length === 0) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(words.join(', '));
|
||||
showToast('modals.model.triggerWords.copyWord', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy trigger words:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh LoRA specific section
|
||||
*/
|
||||
refreshLoraSpecificSection() {
|
||||
if (this.modelType !== 'loras') return;
|
||||
|
||||
const sections = this.element.querySelectorAll('.metadata__section');
|
||||
// First two sections are usage tips and trigger words
|
||||
if (sections.length >= 2) {
|
||||
const newHtml = this.renderLoraSpecific();
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = newHtml;
|
||||
|
||||
const newSections = tempDiv.querySelectorAll('.metadata__section');
|
||||
if (newSections.length >= 2) {
|
||||
sections[0].replaceWith(newSections[0]);
|
||||
sections[1].replaceWith(newSections[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit model name
|
||||
*/
|
||||
async editModelName() {
|
||||
const currentName = this.model.model_name || '';
|
||||
const newName = prompt(
|
||||
translate('modals.model.actions.editModelName', {}, 'Edit model name'),
|
||||
currentName
|
||||
);
|
||||
|
||||
if (newName !== null && newName.trim() !== '' && newName !== currentName) {
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
await client.saveModelMetadata(this.model.file_path, { model_name: newName.trim() });
|
||||
|
||||
this.model.model_name = newName.trim();
|
||||
this.element.querySelector('.metadata__name').textContent = newName.trim();
|
||||
showToast('common.actions.save', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to save model name:', err);
|
||||
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open file location
|
||||
*/
|
||||
async openFileLocation() {
|
||||
if (!this.model?.file_path) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/lm/open-file-location', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: this.model.file_path })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to open file location');
|
||||
|
||||
showToast('modals.model.openFileLocation.success', {}, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to open file location:', err);
|
||||
showToast('modals.model.openFileLocation.failed', {}, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
374
static/js/components/model-modal/ModelModal.js
Normal file
374
static/js/components/model-modal/ModelModal.js
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* ModelModal - Main Controller for Split-View Overlay
|
||||
*
|
||||
* Architecture:
|
||||
* - Overlay container (split-view grid)
|
||||
* - Left: Showcase (ExampleShowcase component)
|
||||
* - Right: Metadata + Tabs (MetadataPanel component)
|
||||
* - Global keyboard navigation (↑↓ for model, ←→ for examples)
|
||||
*/
|
||||
|
||||
import { Showcase } from './Showcase.js';
|
||||
import { MetadataPanel } from './MetadataPanel.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
|
||||
export class ModelModal {
|
||||
static instance = null;
|
||||
static overlayElement = null;
|
||||
static currentModel = null;
|
||||
static currentModelType = null;
|
||||
static showcase = null;
|
||||
static metadataPanel = null;
|
||||
static isNavigating = false;
|
||||
static keyboardHandler = null;
|
||||
static hasShownHint = false;
|
||||
|
||||
/**
|
||||
* Show the model modal with split-view overlay
|
||||
* @param {Object} model - Model data object
|
||||
* @param {string} modelType - Type of model ('loras', 'checkpoints', 'embeddings')
|
||||
*/
|
||||
static async show(model, modelType) {
|
||||
// If already open, animate transition to new model
|
||||
if (this.isOpen()) {
|
||||
await this.transitionToModel(model, modelType);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentModel = model;
|
||||
this.currentModelType = modelType;
|
||||
this.isNavigating = false;
|
||||
|
||||
// Fetch complete metadata
|
||||
let completeCivitaiData = model.civitai || {};
|
||||
if (model.file_path) {
|
||||
try {
|
||||
const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path);
|
||||
completeCivitaiData = fullMetadata || model.civitai || {};
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch complete metadata:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.currentModel = {
|
||||
...model,
|
||||
civitai: completeCivitaiData
|
||||
};
|
||||
|
||||
// Create overlay
|
||||
this.createOverlay();
|
||||
|
||||
// Initialize components
|
||||
this.showcase = new Showcase(this.overlayElement.querySelector('.showcase'));
|
||||
this.metadataPanel = new MetadataPanel(this.overlayElement.querySelector('.metadata'));
|
||||
|
||||
// Render content
|
||||
await this.render();
|
||||
|
||||
// Setup keyboard navigation
|
||||
this.setupKeyboardNavigation();
|
||||
|
||||
// Lock body scroll
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
// Show hint on first use
|
||||
if (!this.hasShownHint) {
|
||||
this.showKeyboardHint();
|
||||
this.hasShownHint = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the overlay DOM structure
|
||||
*/
|
||||
static createOverlay() {
|
||||
// Check sidebar state for layout adjustment
|
||||
const sidebar = document.querySelector('.folder-sidebar');
|
||||
const isSidebarCollapsed = sidebar?.classList.contains('collapsed');
|
||||
|
||||
this.overlayElement = document.createElement('div');
|
||||
this.overlayElement.className = `model-overlay ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`;
|
||||
this.overlayElement.id = 'modelModal';
|
||||
this.overlayElement.innerHTML = `
|
||||
<button class="model-overlay__close" title="${translate('common.close', {}, 'Close')}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<div class="model-overlay__hint">
|
||||
↑↓ ${translate('modals.model.navigation.switchModel', {}, 'Switch model')} |
|
||||
←→ ${translate('modals.model.navigation.browseExamples', {}, 'Browse examples')} |
|
||||
ESC ${translate('common.close', {}, 'Close')}
|
||||
</div>
|
||||
<div class="showcase"></div>
|
||||
<div class="metadata"></div>
|
||||
`;
|
||||
|
||||
// Close button handler
|
||||
this.overlayElement.querySelector('.model-overlay__close').addEventListener('click', () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
// Click outside to close
|
||||
this.overlayElement.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlayElement) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(this.overlayElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render content into components
|
||||
*/
|
||||
static async render() {
|
||||
if (!this.currentModel) return;
|
||||
|
||||
// Prepare images data
|
||||
const regularImages = this.currentModel.civitai?.images || [];
|
||||
const customImages = this.currentModel.civitai?.customImages || [];
|
||||
const allImages = [...regularImages, ...customImages];
|
||||
|
||||
// Render showcase
|
||||
this.showcase.render({
|
||||
images: allImages,
|
||||
modelHash: this.currentModel.sha256,
|
||||
filePath: this.currentModel.file_path
|
||||
});
|
||||
|
||||
// Render metadata panel
|
||||
this.metadataPanel.render({
|
||||
model: this.currentModel,
|
||||
modelType: this.currentModelType
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to a different model with animation
|
||||
*/
|
||||
static async transitionToModel(model, modelType) {
|
||||
// Ensure components are initialized
|
||||
if (!this.showcase || !this.metadataPanel) {
|
||||
console.warn('Showcase or MetadataPanel not initialized, falling back to show()');
|
||||
await this.show(model, modelType);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fade out current content
|
||||
this.showcase?.element?.classList.add('transitioning');
|
||||
this.metadataPanel?.element?.classList.add('transitioning');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Fetch complete metadata for new model
|
||||
let completeCivitaiData = model.civitai || {};
|
||||
if (model.file_path) {
|
||||
try {
|
||||
const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path);
|
||||
completeCivitaiData = fullMetadata || model.civitai || {};
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch complete metadata:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update model data in-place
|
||||
this.currentModel = {
|
||||
...model,
|
||||
civitai: completeCivitaiData
|
||||
};
|
||||
this.currentModelType = modelType;
|
||||
|
||||
// Render new content in-place
|
||||
await this.render();
|
||||
|
||||
// Fade in new content
|
||||
this.showcase?.element?.classList.remove('transitioning');
|
||||
this.metadataPanel?.element?.classList.remove('transitioning');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal
|
||||
*/
|
||||
static close(animate = true) {
|
||||
if (!this.overlayElement) return;
|
||||
|
||||
// Cleanup keyboard handler
|
||||
this.cleanupKeyboardNavigation();
|
||||
|
||||
// Animate out
|
||||
if (animate) {
|
||||
this.overlayElement.classList.add('closing');
|
||||
setTimeout(() => {
|
||||
this.removeOverlay();
|
||||
}, 200);
|
||||
} else {
|
||||
this.removeOverlay();
|
||||
}
|
||||
|
||||
// Unlock body scroll
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove overlay from DOM
|
||||
*/
|
||||
static removeOverlay() {
|
||||
if (this.overlayElement) {
|
||||
this.overlayElement.remove();
|
||||
this.overlayElement = null;
|
||||
}
|
||||
this.showcase = null;
|
||||
this.metadataPanel = null;
|
||||
this.currentModel = null;
|
||||
this.currentModelType = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if modal is currently open
|
||||
*/
|
||||
static isOpen() {
|
||||
return !!this.overlayElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global keyboard navigation
|
||||
*/
|
||||
static setupKeyboardNavigation() {
|
||||
this.keyboardHandler = (e) => {
|
||||
// Ignore if user is typing in an input
|
||||
if (this.isUserTyping()) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this.navigateModel('prev');
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this.navigateModel('next');
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
this.showcase?.prevImage();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
this.showcase?.nextImage();
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
break;
|
||||
case 'i':
|
||||
case 'I':
|
||||
if (!this.isUserTyping()) {
|
||||
e.preventDefault();
|
||||
this.showcase?.toggleParams();
|
||||
}
|
||||
break;
|
||||
case 'c':
|
||||
case 'C':
|
||||
if (!this.isUserTyping()) {
|
||||
e.preventDefault();
|
||||
this.showcase?.copyPrompt();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', this.keyboardHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup keyboard navigation
|
||||
*/
|
||||
static cleanupKeyboardNavigation() {
|
||||
if (this.keyboardHandler) {
|
||||
document.removeEventListener('keydown', this.keyboardHandler);
|
||||
this.keyboardHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is currently typing in an input/editable field
|
||||
*/
|
||||
static isUserTyping() {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
const tagName = activeElement.tagName?.toLowerCase();
|
||||
const isEditable = activeElement.isContentEditable;
|
||||
const isInput = ['input', 'textarea', 'select'].includes(tagName);
|
||||
|
||||
return isEditable || isInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to previous/next model using virtual scroller
|
||||
*/
|
||||
static async navigateModel(direction) {
|
||||
if (this.isNavigating || !this.currentModel?.file_path) return;
|
||||
|
||||
const scroller = state.virtualScroller;
|
||||
if (!scroller || typeof scroller.getAdjacentItemByFilePath !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isNavigating = true;
|
||||
|
||||
try {
|
||||
const adjacent = await scroller.getAdjacentItemByFilePath(
|
||||
this.currentModel.file_path,
|
||||
direction
|
||||
);
|
||||
|
||||
if (!adjacent?.item) {
|
||||
const toastKey = direction === 'prev'
|
||||
? 'modals.model.navigation.noPrevious'
|
||||
: 'modals.model.navigation.noNext';
|
||||
const fallback = direction === 'prev'
|
||||
? 'No previous model available'
|
||||
: 'No next model available';
|
||||
// Show toast notification (imported from utils)
|
||||
import('../../utils/uiHelpers.js').then(({ showToast }) => {
|
||||
showToast(toastKey, {}, 'info', fallback);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.transitionToModel(adjacent.item, this.currentModelType);
|
||||
} finally {
|
||||
this.isNavigating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show keyboard shortcut hint
|
||||
*/
|
||||
static showKeyboardHint() {
|
||||
const hint = this.overlayElement?.querySelector('.model-overlay__hint');
|
||||
if (hint) {
|
||||
// Animation is handled by CSS, just ensure it's visible
|
||||
hint.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sidebar state when sidebar is toggled
|
||||
*/
|
||||
static updateSidebarState(collapsed) {
|
||||
if (!this.overlayElement) return;
|
||||
|
||||
if (collapsed) {
|
||||
this.overlayElement.classList.add('sidebar-collapsed');
|
||||
} else {
|
||||
this.overlayElement.classList.remove('sidebar-collapsed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for sidebar toggle events
|
||||
document.addEventListener('sidebar-toggle', (e) => {
|
||||
ModelModal.updateSidebarState(e.detail.collapsed);
|
||||
});
|
||||
321
static/js/components/model-modal/RecipesTab.js
Normal file
321
static/js/components/model-modal/RecipesTab.js
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* RecipesTab - Recipe cards grid component for LoRA models
|
||||
* Features:
|
||||
* - Recipe cards grid layout
|
||||
* - Copy/View actions
|
||||
* - LoRA availability status badges
|
||||
*/
|
||||
|
||||
import { escapeHtml } from '../shared/utils.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
|
||||
export class RecipesTab {
|
||||
constructor(container) {
|
||||
this.element = container;
|
||||
this.model = null;
|
||||
this.recipes = [];
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the recipes tab
|
||||
*/
|
||||
async render({ model }) {
|
||||
this.model = model;
|
||||
this.element.innerHTML = this.getLoadingTemplate();
|
||||
|
||||
await this.loadRecipes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get loading template
|
||||
*/
|
||||
getLoadingTemplate() {
|
||||
return `
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>${translate('modals.model.loading.recipes', {}, 'Loading recipes...')}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load recipes from API
|
||||
*/
|
||||
async loadRecipes() {
|
||||
const sha256 = this.model?.sha256;
|
||||
|
||||
if (!sha256) {
|
||||
this.renderError('Missing model hash');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/lm/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to load recipes');
|
||||
}
|
||||
|
||||
this.recipes = data.recipes || [];
|
||||
this.renderRecipes();
|
||||
} catch (error) {
|
||||
console.error('Failed to load recipes:', error);
|
||||
this.renderError(error.message);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render error state
|
||||
*/
|
||||
renderError(message) {
|
||||
this.element.innerHTML = `
|
||||
<div class="recipes-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<p>${escapeHtml(message || 'Failed to load recipes. Please try again later.')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render empty state
|
||||
*/
|
||||
renderEmpty() {
|
||||
this.element.innerHTML = `
|
||||
<div class="recipes-empty">
|
||||
<i class="fas fa-book-open"></i>
|
||||
<p>${translate('recipes.noRecipesFound', {}, 'No recipes found that use this LoRA.')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render recipes grid
|
||||
*/
|
||||
renderRecipes() {
|
||||
if (!this.recipes || this.recipes.length === 0) {
|
||||
this.renderEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
const loraName = this.model?.model_name || '';
|
||||
|
||||
this.element.innerHTML = `
|
||||
<div class="recipes-header">
|
||||
<div class="recipes-header__text">
|
||||
<span class="recipes-header__eyebrow">Linked recipes</span>
|
||||
<h3>${this.recipes.length} recipe${this.recipes.length > 1 ? 's' : ''} using this LoRA</h3>
|
||||
<p class="recipes-header__description">
|
||||
${loraName ? `Discover workflows crafted for ${escapeHtml(loraName)}.` : 'Discover workflows crafted for this model.'}
|
||||
</p>
|
||||
</div>
|
||||
<button class="recipes-header__view-all" data-action="view-all">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
<span>View all recipes</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="recipes-grid">
|
||||
${this.recipes.map(recipe => this.renderRecipeCard(recipe)).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single recipe card
|
||||
*/
|
||||
renderRecipeCard(recipe) {
|
||||
const baseModel = recipe.base_model || '';
|
||||
const loras = recipe.loras || [];
|
||||
const lorasCount = loras.length;
|
||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||
|
||||
let statusClass = 'empty';
|
||||
let statusLabel = 'No linked LoRAs';
|
||||
let statusTitle = 'No LoRAs in this recipe';
|
||||
|
||||
if (lorasCount > 0) {
|
||||
if (allLorasAvailable) {
|
||||
statusClass = 'ready';
|
||||
statusLabel = `${lorasCount} LoRA${lorasCount > 1 ? 's' : ''} ready`;
|
||||
statusTitle = 'All LoRAs available - Ready to use';
|
||||
} else {
|
||||
statusClass = 'missing';
|
||||
statusLabel = `Missing ${missingLorasCount} of ${lorasCount}`;
|
||||
statusTitle = `${missingLorasCount} of ${lorasCount} LoRAs missing`;
|
||||
}
|
||||
}
|
||||
|
||||
const imageUrl = recipe.file_url ||
|
||||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
||||
'/loras_static/images/no-preview.png');
|
||||
|
||||
return `
|
||||
<article class="recipe-card"
|
||||
data-recipe-id="${escapeHtml(recipe.id || '')}"
|
||||
data-file-path="${escapeHtml(recipe.file_path || '')}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="${recipe.title ? `View recipe ${escapeHtml(recipe.title)}` : 'View recipe details'}">
|
||||
<div class="recipe-card__media">
|
||||
<img src="${escapeHtml(imageUrl)}"
|
||||
alt="${recipe.title ? escapeHtml(recipe.title) + ' preview' : 'Recipe preview'}"
|
||||
loading="lazy">
|
||||
<div class="recipe-card__media-top">
|
||||
<button class="recipe-card__copy" data-action="copy-recipe" title="Copy recipe syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-card__body">
|
||||
<h4 class="recipe-card__title" title="${escapeHtml(recipe.title || 'Untitled recipe')}">
|
||||
${escapeHtml(recipe.title || 'Untitled recipe')}
|
||||
</h4>
|
||||
<div class="recipe-card__meta">
|
||||
${baseModel ? `<span class="recipe-card__badge recipe-card__badge--base">${escapeHtml(baseModel)}</span>` : ''}
|
||||
<span class="recipe-card__badge recipe-card__badge--${statusClass}" title="${escapeHtml(statusTitle)}">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
<span>${escapeHtml(statusLabel)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="recipe-card__cta">
|
||||
<span>View details</span>
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event listeners
|
||||
*/
|
||||
bindEvents() {
|
||||
this.element.addEventListener('click', async (e) => {
|
||||
const target = e.target.closest('[data-action]');
|
||||
|
||||
if (target) {
|
||||
const action = target.dataset.action;
|
||||
|
||||
if (action === 'view-all') {
|
||||
await this.navigateToRecipesPage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'copy-recipe') {
|
||||
const card = target.closest('.recipe-card');
|
||||
const recipeId = card?.dataset.recipeId;
|
||||
if (recipeId) {
|
||||
e.stopPropagation();
|
||||
this.copyRecipeSyntax(recipeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Card click - navigate to recipe
|
||||
const card = e.target.closest('.recipe-card');
|
||||
if (card && !e.target.closest('[data-action]')) {
|
||||
const recipeId = card.dataset.recipeId;
|
||||
if (recipeId) {
|
||||
await this.navigateToRecipeDetails(recipeId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation for cards
|
||||
this.element.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
const card = e.target.closest('.recipe-card');
|
||||
if (card) {
|
||||
e.preventDefault();
|
||||
const recipeId = card.dataset.recipeId;
|
||||
if (recipeId) {
|
||||
await this.navigateToRecipeDetails(recipeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy recipe syntax to clipboard
|
||||
*/
|
||||
async copyRecipeSyntax(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.noRecipeId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/lm/recipe/${recipeId}/syntax`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.syntax) {
|
||||
await copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
||||
} else {
|
||||
throw new Error(data.error || 'No syntax returned');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy recipe syntax:', err);
|
||||
showToast('toast.recipes.copyFailed', { message: err.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to recipes page with filter
|
||||
*/
|
||||
async navigateToRecipesPage() {
|
||||
// Close the modal
|
||||
const { ModelModal } = await import('./ModelModal.js');
|
||||
ModelModal.close();
|
||||
|
||||
// Clear any previous filters
|
||||
removeSessionItem('filterLoraName');
|
||||
removeSessionItem('filterLoraHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
// Store the LoRA name and hash filter in sessionStorage
|
||||
setSessionItem('lora_to_recipe_filterLoraName', this.model?.model_name || '');
|
||||
setSessionItem('lora_to_recipe_filterLoraHash', this.model?.sha256 || '');
|
||||
|
||||
// Navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to specific recipe details
|
||||
*/
|
||||
async navigateToRecipeDetails(recipeId) {
|
||||
// Close the modal
|
||||
const { ModelModal } = await import('./ModelModal.js');
|
||||
ModelModal.close();
|
||||
|
||||
// Clear any previous filters
|
||||
removeSessionItem('filterLoraName');
|
||||
removeSessionItem('filterLoraHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
// Store the recipe ID in sessionStorage to load on recipes page
|
||||
setSessionItem('viewRecipeId', recipeId);
|
||||
|
||||
// Navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh recipes
|
||||
*/
|
||||
async refresh() {
|
||||
await this.loadRecipes();
|
||||
}
|
||||
}
|
||||
1501
static/js/components/model-modal/Showcase.js
Normal file
1501
static/js/components/model-modal/Showcase.js
Normal file
File diff suppressed because it is too large
Load Diff
627
static/js/components/model-modal/VersionsTab.js
Normal file
627
static/js/components/model-modal/VersionsTab.js
Normal file
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* VersionsTab - Model versions list component
|
||||
* Features:
|
||||
* - Version cards with preview, badges, and actions
|
||||
* - Download/Delete/Ignore actions
|
||||
* - Base model filter toggle
|
||||
* - Reference: static/js/components/shared/ModelVersionsTab.js
|
||||
*/
|
||||
|
||||
import { escapeHtml, formatFileSize } from '../shared/utils.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
||||
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
||||
|
||||
const DISPLAY_FILTER_MODES = Object.freeze({
|
||||
SAME_BASE: 'same_base',
|
||||
ANY: 'any',
|
||||
});
|
||||
|
||||
export class VersionsTab {
|
||||
constructor(container) {
|
||||
this.element = container;
|
||||
this.model = null;
|
||||
this.modelType = null;
|
||||
this.versions = [];
|
||||
this.isLoading = false;
|
||||
this.displayMode = DISPLAY_FILTER_MODES.ANY;
|
||||
this.record = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the versions tab
|
||||
*/
|
||||
async render({ model, modelType }) {
|
||||
this.model = model;
|
||||
this.modelType = modelType;
|
||||
this.element.innerHTML = this.getLoadingTemplate();
|
||||
|
||||
await this.loadVersions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get loading template
|
||||
*/
|
||||
getLoadingTemplate() {
|
||||
return `
|
||||
<div class="versions-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>${translate('modals.model.loading.versions', {}, 'Loading versions...')}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load versions from API
|
||||
*/
|
||||
async loadVersions() {
|
||||
const modelId = this.model?.civitai?.modelId;
|
||||
|
||||
if (!modelId) {
|
||||
this.renderError(translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
const response = await client.fetchModelUpdateVersions(modelId, { refresh: false });
|
||||
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || 'Failed to load versions');
|
||||
}
|
||||
|
||||
this.record = response.record;
|
||||
this.renderVersions();
|
||||
} catch (error) {
|
||||
console.error('Failed to load versions:', error);
|
||||
this.renderError(error.message);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render error state
|
||||
*/
|
||||
renderError(message) {
|
||||
this.element.innerHTML = `
|
||||
<div class="versions-error">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>${escapeHtml(message || translate('modals.model.versions.error', {}, 'Failed to load versions.'))}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render empty state
|
||||
*/
|
||||
renderEmpty() {
|
||||
this.element.innerHTML = `
|
||||
<div class="versions-empty">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<p>${translate('modals.model.versions.empty', {}, 'No version history available for this model yet.')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render versions list
|
||||
*/
|
||||
renderVersions() {
|
||||
if (!this.record || !Array.isArray(this.record.versions) || this.record.versions.length === 0) {
|
||||
this.renderEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVersionId = this.model?.civitai?.versionId;
|
||||
const sortedVersions = [...this.record.versions].sort((a, b) => Number(b.versionId) - Number(a.versionId));
|
||||
|
||||
// Filter versions based on display mode
|
||||
const filteredVersions = this.filterVersions(sortedVersions, currentVersionId);
|
||||
|
||||
if (filteredVersions.length === 0) {
|
||||
this.renderFilteredEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
this.element.innerHTML = `
|
||||
${this.renderToolbar()}
|
||||
<div class="versions-list">
|
||||
${filteredVersions.map(version => this.renderVersionCard(version, currentVersionId)).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter versions based on display mode
|
||||
*/
|
||||
filterVersions(versions, currentVersionId) {
|
||||
const currentVersion = versions.find(v => v.versionId === currentVersionId);
|
||||
const currentBaseModel = currentVersion?.baseModel;
|
||||
|
||||
if (this.displayMode !== DISPLAY_FILTER_MODES.SAME_BASE || !currentBaseModel) {
|
||||
return versions;
|
||||
}
|
||||
|
||||
return versions.filter(version => {
|
||||
const versionBase = version.baseModel?.toLowerCase().trim();
|
||||
const targetBase = currentBaseModel.toLowerCase().trim();
|
||||
return versionBase === targetBase;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render filtered empty state
|
||||
*/
|
||||
renderFilteredEmpty() {
|
||||
const currentVersion = this.record.versions.find(v => v.versionId === this.model?.civitai?.versionId);
|
||||
const baseModelLabel = currentVersion?.baseModel || translate('modals.model.metadata.unknown', {}, 'Unknown');
|
||||
|
||||
this.element.innerHTML = `
|
||||
${this.renderToolbar()}
|
||||
<div class="versions-empty versions-empty-filter">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<p>${translate('modals.model.versions.filters.empty', { baseModel: baseModelLabel }, 'No versions match the current base model filter.')}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render toolbar with actions
|
||||
*/
|
||||
renderToolbar() {
|
||||
const ignoreText = this.record.shouldIgnore
|
||||
? translate('modals.model.versions.actions.resumeModelUpdates', {}, 'Resume updates for this model')
|
||||
: translate('modals.model.versions.actions.ignoreModelUpdates', {}, 'Ignore updates for this model');
|
||||
|
||||
const isFilteringActive = this.displayMode === DISPLAY_FILTER_MODES.SAME_BASE;
|
||||
const toggleTooltip = isFilteringActive
|
||||
? translate('modals.model.versions.filters.tooltip.showAllVersions', {}, 'Switch to showing all versions')
|
||||
: translate('modals.model.versions.filters.tooltip.showSameBaseVersions', {}, 'Switch to showing only versions with the current base model');
|
||||
|
||||
return `
|
||||
<header class="versions-toolbar">
|
||||
<div class="versions-toolbar-info">
|
||||
<div class="versions-toolbar-info-heading">
|
||||
<h3>${translate('modals.model.versions.heading', {}, 'Model versions')}</h3>
|
||||
<button class="versions-filter-toggle ${isFilteringActive ? 'active' : ''}"
|
||||
data-action="toggle-filter"
|
||||
title="${escapeHtml(toggleTooltip)}"
|
||||
type="button">
|
||||
<i class="fas fa-th-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p>${translate('modals.model.versions.copy', { count: this.record.versions.length }, 'Track and manage every version of this model in one place.')}</p>
|
||||
</div>
|
||||
<div class="versions-toolbar-actions">
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-action="toggle-model-ignore">
|
||||
${escapeHtml(ignoreText)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single version card
|
||||
*/
|
||||
renderVersionCard(version, currentVersionId) {
|
||||
const isCurrent = version.versionId === currentVersionId;
|
||||
const isInLibrary = version.isInLibrary;
|
||||
const isNewer = this.isNewerVersion(version);
|
||||
const badges = this.buildBadges(version, isCurrent, isNewer);
|
||||
const actions = this.buildActions(version);
|
||||
|
||||
const metaParts = [];
|
||||
if (version.baseModel) metaParts.push(`<span class="version-meta-primary">${escapeHtml(version.baseModel)}</span>`);
|
||||
if (version.releasedAt) {
|
||||
const date = new Date(version.releasedAt);
|
||||
if (!isNaN(date.getTime())) {
|
||||
metaParts.push(escapeHtml(date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })));
|
||||
}
|
||||
}
|
||||
if (version.sizeBytes > 0) metaParts.push(escapeHtml(formatFileSize(version.sizeBytes)));
|
||||
|
||||
const metaMarkup = metaParts.length > 0
|
||||
? metaParts.map(m => `<span class="version-meta-item">${m}</span>`).join('<span class="version-meta-separator">•</span>')
|
||||
: escapeHtml(translate('modals.model.versions.labels.noDetails', {}, 'No additional details'));
|
||||
|
||||
const civitaiUrl = this.buildCivitaiUrl(version.modelId, version.versionId);
|
||||
const clickAction = civitaiUrl ? `data-civitai-url="${escapeHtml(civitaiUrl)}"` : '';
|
||||
|
||||
return `
|
||||
<div class="version-card ${isCurrent ? 'is-current' : ''} ${civitaiUrl ? 'is-clickable' : ''}"
|
||||
data-version-id="${version.versionId}"
|
||||
${clickAction}>
|
||||
${this.renderMedia(version)}
|
||||
<div class="version-details">
|
||||
<div class="version-title">
|
||||
<span class="version-name">${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))}</span>
|
||||
</div>
|
||||
<div class="version-badges">${badges}</div>
|
||||
<div class="version-meta">${metaMarkup}</div>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
${actions}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if version is newer than any in library
|
||||
*/
|
||||
isNewerVersion(version) {
|
||||
if (!this.record?.inLibraryVersionIds?.length) return false;
|
||||
if (version.isInLibrary) return false;
|
||||
const maxInLibrary = Math.max(...this.record.inLibraryVersionIds);
|
||||
return version.versionId > maxInLibrary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build badges HTML
|
||||
*/
|
||||
buildBadges(version, isCurrent, isNewer) {
|
||||
const badges = [];
|
||||
|
||||
if (isCurrent) {
|
||||
badges.push(this.createBadge(
|
||||
translate('modals.model.versions.badges.current', {}, 'Current Version'),
|
||||
'current'
|
||||
));
|
||||
}
|
||||
|
||||
if (version.isInLibrary) {
|
||||
badges.push(this.createBadge(
|
||||
translate('modals.model.versions.badges.inLibrary', {}, 'In Library'),
|
||||
'success'
|
||||
));
|
||||
} else if (isNewer && !version.shouldIgnore) {
|
||||
badges.push(this.createBadge(
|
||||
translate('modals.model.versions.badges.newer', {}, 'Newer Version'),
|
||||
'info'
|
||||
));
|
||||
}
|
||||
|
||||
if (version.shouldIgnore) {
|
||||
badges.push(this.createBadge(
|
||||
translate('modals.model.versions.badges.ignored', {}, 'Ignored'),
|
||||
'muted'
|
||||
));
|
||||
}
|
||||
|
||||
return badges.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a badge element
|
||||
*/
|
||||
createBadge(label, tone) {
|
||||
return `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build actions HTML
|
||||
*/
|
||||
buildActions(version) {
|
||||
const actions = [];
|
||||
|
||||
if (!version.isInLibrary) {
|
||||
actions.push(`
|
||||
<button class="version-action version-action-primary" data-action="download">
|
||||
${escapeHtml(translate('modals.model.versions.actions.download', {}, 'Download'))}
|
||||
</button>
|
||||
`);
|
||||
} else if (version.filePath) {
|
||||
actions.push(`
|
||||
<button class="version-action version-action-danger" data-action="delete">
|
||||
${escapeHtml(translate('modals.model.versions.actions.delete', {}, 'Delete'))}
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
|
||||
const ignoreLabel = version.shouldIgnore
|
||||
? translate('modals.model.versions.actions.unignore', {}, 'Unignore')
|
||||
: translate('modals.model.versions.actions.ignore', {}, 'Ignore');
|
||||
|
||||
actions.push(`
|
||||
<button class="version-action version-action-ghost" data-action="toggle-ignore">
|
||||
${escapeHtml(ignoreLabel)}
|
||||
</button>
|
||||
`);
|
||||
|
||||
return actions.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render media (image/video)
|
||||
*/
|
||||
renderMedia(version) {
|
||||
if (!version.previewUrl) {
|
||||
return `
|
||||
<div class="version-media version-media-placeholder">
|
||||
${escapeHtml(translate('modals.model.versions.media.placeholder', {}, 'No preview'))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.isVideoUrl(version.previewUrl)) {
|
||||
return `
|
||||
<div class="version-media">
|
||||
<video src="${escapeHtml(version.previewUrl)}"
|
||||
controls muted loop playsinline preload="metadata">
|
||||
</video>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="version-media">
|
||||
<img src="${escapeHtml(version.previewUrl)}"
|
||||
alt="${escapeHtml(version.name || 'preview')}"
|
||||
loading="lazy">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL is a video
|
||||
*/
|
||||
isVideoUrl(url) {
|
||||
if (!url) return false;
|
||||
const extension = url.split('.').pop()?.toLowerCase()?.split('?')[0];
|
||||
return VIDEO_EXTENSIONS.includes(`.${extension}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Civitai URL
|
||||
*/
|
||||
buildCivitaiUrl(modelId, versionId) {
|
||||
if (!modelId || !versionId) return null;
|
||||
return `https://civitai.com/models/${encodeURIComponent(modelId)}?modelVersionId=${encodeURIComponent(versionId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event listeners
|
||||
*/
|
||||
bindEvents() {
|
||||
this.element.addEventListener('click', (e) => {
|
||||
const target = e.target.closest('[data-action]');
|
||||
if (!target) {
|
||||
// Check if clicked on a clickable card
|
||||
const card = e.target.closest('.version-card.is-clickable');
|
||||
if (card && !e.target.closest('.version-actions')) {
|
||||
const url = card.dataset.civitaiUrl;
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const action = target.dataset.action;
|
||||
const card = target.closest('.version-card');
|
||||
const versionId = card ? parseInt(card.dataset.versionId, 10) : null;
|
||||
|
||||
switch (action) {
|
||||
case 'toggle-filter':
|
||||
this.toggleFilterMode();
|
||||
break;
|
||||
case 'toggle-model-ignore':
|
||||
this.handleToggleModelIgnore();
|
||||
break;
|
||||
case 'download':
|
||||
if (versionId) this.handleDownload(versionId, target);
|
||||
break;
|
||||
case 'delete':
|
||||
if (versionId) this.handleDelete(versionId, target);
|
||||
break;
|
||||
case 'toggle-ignore':
|
||||
if (versionId) this.handleToggleVersionIgnore(versionId, target);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle filter mode
|
||||
*/
|
||||
toggleFilterMode() {
|
||||
this.displayMode = this.displayMode === DISPLAY_FILTER_MODES.SAME_BASE
|
||||
? DISPLAY_FILTER_MODES.ANY
|
||||
: DISPLAY_FILTER_MODES.SAME_BASE;
|
||||
this.renderVersions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle toggle model ignore
|
||||
*/
|
||||
async handleToggleModelIgnore() {
|
||||
if (!this.record) return;
|
||||
|
||||
const modelId = this.record.modelId;
|
||||
const nextValue = !this.record.shouldIgnore;
|
||||
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
const response = await client.setModelUpdateIgnore(modelId, nextValue);
|
||||
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || 'Request failed');
|
||||
}
|
||||
|
||||
this.record = response.record;
|
||||
this.renderVersions();
|
||||
|
||||
const toastKey = nextValue
|
||||
? 'modals.model.versions.toast.modelIgnored'
|
||||
: 'modals.model.versions.toast.modelResumed';
|
||||
showToast(toastKey, {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle model ignore:', error);
|
||||
showToast(error?.message || 'Failed to update ignore preference', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle download version
|
||||
*/
|
||||
async handleDownload(versionId, button) {
|
||||
const version = this.record.versions.find(v => v.versionId === versionId);
|
||||
if (!version) return;
|
||||
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
await downloadManager.downloadVersionWithDefaults(
|
||||
this.modelType,
|
||||
this.record.modelId,
|
||||
versionId,
|
||||
{ versionName: version.name || `#${versionId}` }
|
||||
);
|
||||
|
||||
// Reload versions after download starts
|
||||
setTimeout(() => this.loadVersions(), 1000);
|
||||
} catch (error) {
|
||||
console.error('Failed to download version:', error);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle delete version
|
||||
*/
|
||||
async handleDelete(versionId, button) {
|
||||
const version = this.record.versions.find(v => v.versionId === versionId);
|
||||
if (!version?.filePath) return;
|
||||
|
||||
const confirmed = await this.showDeleteConfirmation(version);
|
||||
if (!confirmed) return;
|
||||
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
await client.deleteModel(version.filePath);
|
||||
|
||||
showToast('modals.model.versions.toast.versionDeleted', {}, 'success');
|
||||
await this.loadVersions();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete version:', error);
|
||||
showToast(error?.message || 'Failed to delete version', {}, 'error');
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show delete confirmation modal
|
||||
*/
|
||||
async showDeleteConfirmation(version) {
|
||||
return new Promise((resolve) => {
|
||||
const modalRecord = modalManager?.getModal?.('deleteModal');
|
||||
if (!modalRecord?.element) {
|
||||
// Fallback to browser confirm
|
||||
const message = translate('modals.model.versions.confirm.delete', {}, 'Delete this version from your library?');
|
||||
resolve(window.confirm(message));
|
||||
return;
|
||||
}
|
||||
|
||||
const title = translate('modals.model.versions.actions.delete', {}, 'Delete');
|
||||
const message = translate('modals.model.versions.confirm.delete', {}, 'Delete this version from your library?');
|
||||
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
|
||||
|
||||
const content = `
|
||||
<div class="modal-content delete-modal-content version-delete-modal">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<p class="delete-message">${escapeHtml(message)}</p>
|
||||
<div class="delete-model-info">
|
||||
<div class="delete-preview">
|
||||
${version.previewUrl ? `
|
||||
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(versionName)}"
|
||||
onerror="this.src='${PREVIEW_PLACEHOLDER_URL}'">
|
||||
` : `<img src="${PREVIEW_PLACEHOLDER_URL}" alt="${escapeHtml(versionName)}">`}
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
<h3>${escapeHtml(versionName)}</h3>
|
||||
${version.baseModel ? `<p class="version-base-model">${escapeHtml(version.baseModel)}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel">${escapeHtml(translate('common.actions.cancel', {}, 'Cancel'))}</button>
|
||||
<button class="delete-btn" data-action="confirm">${escapeHtml(translate('common.actions.delete', {}, 'Delete'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modalManager.showModal('deleteModal', content);
|
||||
|
||||
const modalElement = modalRecord.element;
|
||||
const handleAction = (e) => {
|
||||
const action = e.target.closest('[data-action]')?.dataset.action;
|
||||
if (action === 'confirm') {
|
||||
modalManager.closeModal('deleteModal');
|
||||
resolve(true);
|
||||
} else if (action === 'cancel') {
|
||||
modalManager.closeModal('deleteModal');
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
modalElement.addEventListener('click', handleAction, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle toggle version ignore
|
||||
*/
|
||||
async handleToggleVersionIgnore(versionId, button) {
|
||||
const version = this.record.versions.find(v => v.versionId === versionId);
|
||||
if (!version) return;
|
||||
|
||||
const nextValue = !version.shouldIgnore;
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const client = getModelApiClient(this.modelType);
|
||||
const response = await client.setVersionUpdateIgnore(
|
||||
this.record.modelId,
|
||||
versionId,
|
||||
nextValue
|
||||
);
|
||||
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || 'Request failed');
|
||||
}
|
||||
|
||||
this.record = response.record;
|
||||
this.renderVersions();
|
||||
|
||||
const updatedVersion = response.record.versions.find(v => v.versionId === versionId);
|
||||
const toastKey = updatedVersion?.shouldIgnore
|
||||
? 'modals.model.versions.toast.versionIgnored'
|
||||
: 'modals.model.versions.toast.versionUnignored';
|
||||
showToast(toastKey, {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle version ignore:', error);
|
||||
showToast(error?.message || 'Failed to update version preference', {}, 'error');
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh versions
|
||||
*/
|
||||
async refresh() {
|
||||
await this.loadVersions();
|
||||
}
|
||||
}
|
||||
16
static/js/components/model-modal/index.js
Normal file
16
static/js/components/model-modal/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Model Modal - New Split-View Overlay Design
|
||||
* Phase 1 Implementation
|
||||
*/
|
||||
|
||||
import { ModelModal } from './ModelModal.js';
|
||||
|
||||
// Export the public API
|
||||
export const modelModal = {
|
||||
show: ModelModal.show.bind(ModelModal),
|
||||
close: ModelModal.close.bind(ModelModal),
|
||||
isOpen: ModelModal.isOpen.bind(ModelModal),
|
||||
};
|
||||
|
||||
// Default export for convenience
|
||||
export default modelModal;
|
||||
@@ -433,10 +433,9 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.usage_count = String(model.usage_count);
|
||||
card.dataset.notes = model.notes || '';
|
||||
card.dataset.base_model = model.base_model || 'Unknown';
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||
|
||||
// To only show usage_count when sorting by usage.
|
||||
const pageState = getCurrentPageState();
|
||||
@@ -483,10 +482,6 @@ export function createModelCard(model, modelType) {
|
||||
card.classList.add('nsfw-content');
|
||||
}
|
||||
|
||||
if (model.skip_metadata_refresh) {
|
||||
card.classList.add('skip-refresh');
|
||||
}
|
||||
|
||||
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
|
||||
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
|
||||
card.classList.add('selected');
|
||||
@@ -613,11 +608,6 @@ export function createModelCard(model, modelType) {
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</span>
|
||||
` : ''}
|
||||
${model.skip_metadata_refresh ? `
|
||||
<span class="model-skip-refresh-badge" title="${translate('modelCard.badges.skipRefresh', {}, 'Metadata refresh skipped')}">
|
||||
<i class="fas fa-ban"></i>
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
${actionIcons}
|
||||
|
||||
@@ -22,6 +22,12 @@ import { loadRecipesForLora } from './RecipeTab.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
|
||||
// Import new ModelModal for split-view overlay (Phase 1)
|
||||
import { modelModal as newModelModal } from '../model-modal/index.js';
|
||||
|
||||
// Feature flag: Use new split-view design
|
||||
const USE_NEW_MODAL = true;
|
||||
|
||||
function getModalFilePath(fallback = '') {
|
||||
const modalElement = document.getElementById('modelModal');
|
||||
if (modalElement && modalElement.dataset && modalElement.dataset.filePath) {
|
||||
@@ -238,6 +244,12 @@ function renderLicenseIcons(modelData) {
|
||||
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
|
||||
*/
|
||||
export async function showModelModal(model, modelType) {
|
||||
// Use new split-view overlay design when feature flag is enabled
|
||||
if (USE_NEW_MODAL) {
|
||||
return newModelModal.show(model, modelType);
|
||||
}
|
||||
|
||||
// Legacy implementation below (deprecated, kept for fallback)
|
||||
const modalId = 'modelModal';
|
||||
const modalTitle = model.model_name;
|
||||
cleanupNavigationShortcuts();
|
||||
@@ -1020,11 +1032,5 @@ async function openFileLocation(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Export the model modal API
|
||||
const modelModal = {
|
||||
show: showModelModal,
|
||||
toggleShowcase,
|
||||
scrollToTop
|
||||
};
|
||||
|
||||
export { modelModal };
|
||||
// Re-export for compatibility
|
||||
export { toggleShowcase, scrollToTop };
|
||||
|
||||
@@ -123,70 +123,7 @@ function formatDateLabel(value) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format EA end time as smart relative time
|
||||
* - < 1 day: "in Xh" (hours)
|
||||
* - 1-7 days: "in Xd" (days)
|
||||
* - > 7 days: "Jan 15" (short date)
|
||||
*/
|
||||
function formatEarlyAccessTime(endsAt) {
|
||||
if (!endsAt) {
|
||||
return null;
|
||||
}
|
||||
const endDate = new Date(endsAt);
|
||||
if (Number.isNaN(endDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = endDate.getTime() - now.getTime();
|
||||
const diffHours = diffMs / (1000 * 60 * 60);
|
||||
const diffDays = diffHours / 24;
|
||||
|
||||
if (diffHours < 1) {
|
||||
return translate('modals.model.versions.eaTime.endingSoon', {}, 'ending soon');
|
||||
}
|
||||
if (diffHours < 24) {
|
||||
const hours = Math.ceil(diffHours);
|
||||
return translate(
|
||||
'modals.model.versions.eaTime.hours',
|
||||
{ count: hours },
|
||||
`in ${hours}h`
|
||||
);
|
||||
}
|
||||
if (diffDays <= 7) {
|
||||
const days = Math.ceil(diffDays);
|
||||
return translate(
|
||||
'modals.model.versions.eaTime.days',
|
||||
{ count: days },
|
||||
`in ${days}d`
|
||||
);
|
||||
}
|
||||
// More than 7 days: show short date
|
||||
return endDate.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function isEarlyAccessActive(version) {
|
||||
// Two-phase detection:
|
||||
// 1. Use pre-computed isEarlyAccess flag if available (from backend)
|
||||
// 2. Otherwise check exact end time if available
|
||||
if (typeof version.isEarlyAccess === 'boolean') {
|
||||
return version.isEarlyAccess;
|
||||
}
|
||||
if (!version.earlyAccessEndsAt) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new Date(version.earlyAccessEndsAt) > new Date();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildMetaMarkup(version, options = {}) {
|
||||
function buildMetaMarkup(version) {
|
||||
const segments = [];
|
||||
if (version.baseModel) {
|
||||
segments.push(
|
||||
@@ -201,14 +138,6 @@ function buildMetaMarkup(version, options = {}) {
|
||||
segments.push(escapeHtml(formatFileSize(version.sizeBytes)));
|
||||
}
|
||||
|
||||
// Add early access info if applicable
|
||||
if (options.showEarlyAccess && isEarlyAccessActive(version)) {
|
||||
const eaTime = formatEarlyAccessTime(version.earlyAccessEndsAt);
|
||||
if (eaTime) {
|
||||
segments.push(`<span class="version-meta-ea"><i class="fas fa-clock"></i> ${escapeHtml(eaTime)}</span>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!segments.length) {
|
||||
return escapeHtml(
|
||||
translate('modals.model.versions.labels.noDetails', {}, 'No additional details')
|
||||
@@ -306,7 +235,6 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
||||
|
||||
const strategy = state?.global?.settings?.update_flag_strategy;
|
||||
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
|
||||
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
|
||||
|
||||
if (!sameBaseMode) {
|
||||
return Boolean(record?.hasUpdate);
|
||||
@@ -350,9 +278,6 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
||||
if (version.isInLibrary || version.shouldIgnore) {
|
||||
return false;
|
||||
}
|
||||
if (hideEarlyAccess && isEarlyAccessActive(version)) {
|
||||
return false;
|
||||
}
|
||||
const versionBase = normalizeBaseModelName(version.baseModel);
|
||||
if (versionBase !== normalizedBase) {
|
||||
return false;
|
||||
@@ -424,7 +349,6 @@ function renderRow(version, options) {
|
||||
const isNewer =
|
||||
typeof latestLibraryVersionId === 'number' &&
|
||||
version.versionId > latestLibraryVersionId;
|
||||
const isEarlyAccess = isEarlyAccessActive(version);
|
||||
const badges = [];
|
||||
|
||||
if (isCurrent) {
|
||||
@@ -437,10 +361,6 @@ function renderRow(version, options) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info'));
|
||||
}
|
||||
|
||||
if (isEarlyAccess) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access'), 'early-access'));
|
||||
}
|
||||
|
||||
if (version.shouldIgnore) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.ignored', {}, 'Ignored'), 'muted'));
|
||||
}
|
||||
@@ -457,10 +377,8 @@ function renderRow(version, options) {
|
||||
|
||||
const actions = [];
|
||||
if (!version.isInLibrary) {
|
||||
// Download button with optional EA bolt icon
|
||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||
actions.push(
|
||||
`<button class="version-action version-action-primary" data-version-action="download">${downloadIcon}${escapeHtml(downloadLabel)}</button>`
|
||||
`<button class="version-action version-action-primary" data-version-action="download">${escapeHtml(downloadLabel)}</button>`
|
||||
);
|
||||
} else if (version.filePath) {
|
||||
actions.push(
|
||||
@@ -484,7 +402,7 @@ function renderRow(version, options) {
|
||||
);
|
||||
|
||||
const rowAttributes = [
|
||||
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}${isEarlyAccess ? ' is-early-access' : ''}"`,
|
||||
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}"`,
|
||||
`data-version-id="${escapeHtml(version.versionId)}"`,
|
||||
];
|
||||
if (linkTarget) {
|
||||
@@ -501,7 +419,7 @@ function renderRow(version, options) {
|
||||
</div>
|
||||
<div class="version-badges">${badges.join('')}</div>
|
||||
<div class="version-meta">
|
||||
${buildMetaMarkup(version, { showEarlyAccess: true })}
|
||||
${buildMetaMarkup(version)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
@@ -1091,56 +1009,6 @@ export function initVersionsTab({
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveDownloadPathFromCurrentVersion() {
|
||||
if (!normalizedCurrentVersionId || !controller.record?.versions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentVersion = controller.record.versions.find(
|
||||
v => v.versionId === normalizedCurrentVersionId && v.isInLibrary && v.filePath
|
||||
);
|
||||
if (!currentVersion?.filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = ensureClient();
|
||||
const rootsData = await client.fetchModelRoots();
|
||||
const roots = rootsData?.roots;
|
||||
if (!Array.isArray(roots) || roots.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedFilePath = currentVersion.filePath.replace(/\\/g, '/');
|
||||
let matchedRoot = null;
|
||||
let relativePath = null;
|
||||
|
||||
for (const root of roots) {
|
||||
const normalizedRoot = root.replace(/\\/g, '/');
|
||||
if (normalizedFilePath.startsWith(normalizedRoot)) {
|
||||
matchedRoot = root;
|
||||
relativePath = normalizedFilePath.slice(normalizedRoot.length);
|
||||
if (relativePath.startsWith('/')) {
|
||||
relativePath = relativePath.slice(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedRoot || !relativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastSlash = relativePath.lastIndexOf('/');
|
||||
const targetFolder = lastSlash > 0 ? relativePath.slice(0, lastSlash) : '';
|
||||
|
||||
return { modelRoot: matchedRoot, targetFolder };
|
||||
} catch (error) {
|
||||
console.debug('Failed to resolve download path from current version:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadVersion(button, versionId) {
|
||||
if (!controller.record) {
|
||||
return;
|
||||
@@ -1155,11 +1023,8 @@ export function initVersionsTab({
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const pathInfo = await resolveDownloadPathFromCurrentVersion();
|
||||
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
|
||||
versionName: version.name || `#${version.versionId}`,
|
||||
modelRoot: pathInfo?.modelRoot || '',
|
||||
targetFolder: pathInfo?.targetFolder || '',
|
||||
});
|
||||
|
||||
if (success) {
|
||||
@@ -1195,11 +1060,6 @@ export function initVersionsTab({
|
||||
|
||||
const actionButton = event.target.closest('[data-version-action]');
|
||||
if (actionButton) {
|
||||
// Check if browser extension has already handled this action
|
||||
if (actionButton.dataset.lmExtensionHandled === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = actionButton.closest('.model-version-row');
|
||||
if (!row) {
|
||||
return;
|
||||
@@ -1248,11 +1108,6 @@ export function initVersionsTab({
|
||||
window.open(targetUrl, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
// Listen for extension-triggered refresh requests
|
||||
container.addEventListener('lm:refreshVersions', async () => {
|
||||
await refresh();
|
||||
});
|
||||
|
||||
return {
|
||||
load: options => loadVersions(options),
|
||||
refresh,
|
||||
|
||||
@@ -455,49 +455,34 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Upload files one at a time to avoid exceeding server size limits
|
||||
let lastSuccessResult = null;
|
||||
let successCount = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const file of validFiles) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('model_hash', modelHash);
|
||||
formData.append('files', file);
|
||||
|
||||
const response = await fetch('/api/lm/import-example-images', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
errors.push(`${file.name}: ${result.error || 'Unknown error'}`);
|
||||
} else {
|
||||
lastSuccessResult = result;
|
||||
successCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`${file.name}: ${err.message}`);
|
||||
}
|
||||
// Use FormData to upload files
|
||||
const formData = new FormData();
|
||||
formData.append('model_hash', modelHash);
|
||||
|
||||
validFiles.forEach(file => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
// Call API to import files
|
||||
const response = await fetch('/api/lm/import-example-images', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to import example files');
|
||||
}
|
||||
|
||||
if (successCount === 0) {
|
||||
throw new Error(errors.join('; '));
|
||||
}
|
||||
|
||||
const result = lastSuccessResult;
|
||||
|
||||
|
||||
// Get updated local files
|
||||
const updatedFilesResponse = await fetch(`/api/lm/example-image-files?model_hash=${modelHash}`);
|
||||
const updatedFilesResult = await updatedFilesResponse.json();
|
||||
|
||||
|
||||
if (!updatedFilesResult.success) {
|
||||
throw new Error(updatedFilesResult.error || 'Failed to get updated file list');
|
||||
}
|
||||
|
||||
|
||||
// Re-render the showcase content
|
||||
const showcaseTab = document.getElementById('showcase-tab');
|
||||
if (showcaseTab) {
|
||||
@@ -507,22 +492,18 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
||||
// Combine both arrays for rendering
|
||||
const allImages = [...regularImages, ...customImages];
|
||||
showcaseTab.innerHTML = renderShowcaseContent(allImages, updatedFilesResult.files, true);
|
||||
|
||||
|
||||
// Re-initialize showcase functionality
|
||||
const carousel = showcaseTab.querySelector('.carousel');
|
||||
if (carousel && !carousel.classList.contains('collapsed')) {
|
||||
initShowcaseContent(carousel);
|
||||
}
|
||||
|
||||
|
||||
// Initialize the import UI for the new content
|
||||
initExampleImport(modelHash, showcaseTab);
|
||||
|
||||
if (errors.length > 0) {
|
||||
showToast('toast.import.imagesPartial', { success: successCount, failed: errors.length }, 'warning');
|
||||
} else {
|
||||
showToast('toast.import.imagesImported', {}, 'success');
|
||||
}
|
||||
|
||||
|
||||
showToast('toast.import.imagesImported', {}, 'success');
|
||||
|
||||
// Update VirtualScroller if available
|
||||
if (state.virtualScroller && result.model_file_path) {
|
||||
// Create an update object with only the necessary properties
|
||||
@@ -532,7 +513,7 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
||||
customImages: customImages
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Update the item in the virtual scroller
|
||||
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
|
||||
}
|
||||
|
||||
@@ -40,8 +40,7 @@ export class BulkManager {
|
||||
moveAll: true,
|
||||
autoOrganize: true,
|
||||
deleteAll: true,
|
||||
setContentRating: true,
|
||||
skipMetadataRefresh: true
|
||||
setContentRating: true
|
||||
},
|
||||
[MODEL_TYPES.EMBEDDING]: {
|
||||
addTags: true,
|
||||
@@ -52,8 +51,7 @@ export class BulkManager {
|
||||
moveAll: true,
|
||||
autoOrganize: true,
|
||||
deleteAll: true,
|
||||
setContentRating: false,
|
||||
skipMetadataRefresh: true
|
||||
setContentRating: false
|
||||
},
|
||||
[MODEL_TYPES.CHECKPOINT]: {
|
||||
addTags: true,
|
||||
@@ -64,8 +62,7 @@ export class BulkManager {
|
||||
moveAll: false,
|
||||
autoOrganize: true,
|
||||
deleteAll: true,
|
||||
setContentRating: true,
|
||||
skipMetadataRefresh: true
|
||||
setContentRating: true
|
||||
},
|
||||
recipes: {
|
||||
addTags: false,
|
||||
@@ -76,8 +73,7 @@ export class BulkManager {
|
||||
moveAll: true,
|
||||
autoOrganize: false,
|
||||
deleteAll: true,
|
||||
setContentRating: false,
|
||||
skipMetadataRefresh: false
|
||||
setContentRating: false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1199,59 +1195,6 @@ export class BulkManager {
|
||||
return successCount > 0;
|
||||
}
|
||||
|
||||
async setSkipMetadataRefresh(value) {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const totalCount = state.selectedModels.size;
|
||||
|
||||
state.loadingManager.showSimpleLoading(
|
||||
translate('toast.models.skipMetadataRefreshUpdating', { count: totalCount })
|
||||
);
|
||||
let cancelled = false;
|
||||
state.loadingManager.showCancelButton(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
|
||||
try {
|
||||
const apiClient = getModelApiClient();
|
||||
for (const filePath of state.selectedModels) {
|
||||
if (cancelled) {
|
||||
showToast('toast.api.operationCancelled', {}, 'info');
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await apiClient.saveModelMetadata(filePath, { skip_metadata_refresh: value });
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
failureCount++;
|
||||
console.error(`Failed to set skip_metadata_refresh for ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
state.loadingManager?.hide?.();
|
||||
}
|
||||
|
||||
if (successCount === totalCount) {
|
||||
const toastKey = value
|
||||
? 'toast.models.skipMetadataRefreshSet'
|
||||
: 'toast.models.skipMetadataRefreshCleared';
|
||||
showToast(toastKey, { count: successCount }, 'success');
|
||||
} else if (successCount > 0) {
|
||||
showToast('toast.models.skipMetadataRefreshPartial', {
|
||||
success: successCount,
|
||||
failed: failureCount
|
||||
}, 'warning');
|
||||
} else {
|
||||
showToast('toast.models.skipMetadataRefreshFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize bulk base model interface
|
||||
*/
|
||||
|
||||
@@ -620,12 +620,7 @@ export class DownloadManager {
|
||||
});
|
||||
}
|
||||
|
||||
async downloadVersionWithDefaults(modelType, modelId, versionId, {
|
||||
versionName = '',
|
||||
source = null,
|
||||
modelRoot = '',
|
||||
targetFolder = ''
|
||||
} = {}) {
|
||||
async downloadVersionWithDefaults(modelType, modelId, versionId, { versionName = '', source = null } = {}) {
|
||||
try {
|
||||
this.apiClient = getModelApiClient(modelType);
|
||||
} catch (error) {
|
||||
@@ -635,14 +630,13 @@ export class DownloadManager {
|
||||
this.modelId = modelId ? modelId.toString() : null;
|
||||
this.source = source;
|
||||
|
||||
const useDefaultPaths = !modelRoot;
|
||||
return this.executeDownloadWithProgress({
|
||||
modelId,
|
||||
versionId,
|
||||
versionName,
|
||||
modelRoot: modelRoot || '',
|
||||
targetFolder: targetFolder || '',
|
||||
useDefaultPaths,
|
||||
modelRoot: '',
|
||||
targetFolder: '',
|
||||
useDefaultPaths: true,
|
||||
source,
|
||||
closeModal: false,
|
||||
});
|
||||
@@ -750,8 +744,3 @@ export class DownloadManager {
|
||||
|
||||
// Create global instance
|
||||
export const downloadManager = new DownloadManager();
|
||||
|
||||
// Expose to window for browser extension integration
|
||||
if (typeof window !== 'undefined') {
|
||||
window.downloadManager = downloadManager;
|
||||
}
|
||||
|
||||
@@ -140,12 +140,6 @@ export class ModalManager {
|
||||
this.registerModal('recipeModal', {
|
||||
element: recipeModal,
|
||||
onClose: () => {
|
||||
// Stop any playing video
|
||||
const video = recipeModal.querySelector('video');
|
||||
if (video) {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
}
|
||||
this.getModal('recipeModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
|
||||
@@ -133,10 +133,6 @@ export class SettingsManager {
|
||||
backendSettings?.auto_organize_exclusions ?? defaults.auto_organize_exclusions
|
||||
);
|
||||
|
||||
merged.metadata_refresh_skip_paths = this.normalizePatternList(
|
||||
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
|
||||
);
|
||||
|
||||
Object.keys(merged).forEach(key => this.backendSettingKeys.add(key));
|
||||
|
||||
return merged;
|
||||
@@ -353,16 +349,6 @@ export class SettingsManager {
|
||||
});
|
||||
}
|
||||
|
||||
const metadataRefreshSkipPathsInput = document.getElementById('metadataRefreshSkipPaths');
|
||||
if (metadataRefreshSkipPathsInput) {
|
||||
metadataRefreshSkipPathsInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.saveMetadataRefreshSkipPaths();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.setupPriorityTagInputs();
|
||||
|
||||
this.initialized = true;
|
||||
@@ -424,16 +410,6 @@ export class SettingsManager {
|
||||
autoOrganizeExclusionsError.textContent = '';
|
||||
}
|
||||
|
||||
const metadataRefreshSkipPathsInput = document.getElementById('metadataRefreshSkipPaths');
|
||||
if (metadataRefreshSkipPathsInput) {
|
||||
const skipPaths = this.normalizePatternList(state.global.settings.metadata_refresh_skip_paths);
|
||||
metadataRefreshSkipPathsInput.value = skipPaths.join(', ');
|
||||
}
|
||||
const metadataRefreshSkipPathsError = document.getElementById('metadataRefreshSkipPathsError');
|
||||
if (metadataRefreshSkipPathsError) {
|
||||
metadataRefreshSkipPathsError.textContent = '';
|
||||
}
|
||||
|
||||
// Set video autoplay on hover setting
|
||||
const autoplayOnHoverCheckbox = document.getElementById('autoplayOnHover');
|
||||
if (autoplayOnHoverCheckbox) {
|
||||
@@ -475,12 +451,6 @@ export class SettingsManager {
|
||||
updateFlagStrategySelect.value = state.global.settings.update_flag_strategy || 'same_base';
|
||||
}
|
||||
|
||||
// Set hide early access updates setting
|
||||
const hideEarlyAccessUpdatesCheckbox = document.getElementById('hideEarlyAccessUpdates');
|
||||
if (hideEarlyAccessUpdatesCheckbox) {
|
||||
hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false;
|
||||
}
|
||||
|
||||
// Set optimize example images setting
|
||||
const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages');
|
||||
if (optimizeExampleImagesCheckbox) {
|
||||
@@ -1751,58 +1721,6 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
async saveMetadataRefreshSkipPaths() {
|
||||
const input = document.getElementById('metadataRefreshSkipPaths');
|
||||
const errorElement = document.getElementById('metadataRefreshSkipPathsError');
|
||||
if (!input) return;
|
||||
|
||||
const normalized = this.normalizePatternList(input.value);
|
||||
|
||||
if (input.value.trim() && normalized.length === 0) {
|
||||
if (errorElement) {
|
||||
errorElement.textContent = translate(
|
||||
'settings.metadataRefreshSkipPaths.validation.noPaths',
|
||||
{},
|
||||
'Enter at least one path separated by commas.'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.normalizePatternList(state.global.settings.metadata_refresh_skip_paths);
|
||||
if (normalized.join('|') === current.join('|')) {
|
||||
if (errorElement) {
|
||||
errorElement.textContent = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (errorElement) {
|
||||
errorElement.textContent = '';
|
||||
}
|
||||
|
||||
await this.saveSetting('metadata_refresh_skip_paths', normalized);
|
||||
input.value = normalized.join(', ');
|
||||
|
||||
showToast(
|
||||
'toast.settings.settingsUpdated',
|
||||
{ setting: translate('settings.metadataRefreshSkipPaths.label') },
|
||||
'success'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to save metadata refresh skip paths:', error);
|
||||
if (errorElement) {
|
||||
errorElement.textContent = translate(
|
||||
'settings.metadataRefreshSkipPaths.validation.saveFailed',
|
||||
{ message: error.message },
|
||||
`Unable to save skip paths: ${error.message}`
|
||||
);
|
||||
}
|
||||
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async saveInputSetting(elementId, settingKey) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
@@ -34,9 +34,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
compact_mode: false,
|
||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||
update_flag_strategy: 'same_base',
|
||||
hide_early_access_updates: false,
|
||||
auto_organize_exclusions: [],
|
||||
metadata_refresh_skip_paths: [],
|
||||
});
|
||||
|
||||
export function createDefaultSettings() {
|
||||
|
||||
@@ -80,12 +80,6 @@
|
||||
<div class="context-menu-item" data-action="set-content-rating">
|
||||
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="skip-metadata-refresh">
|
||||
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="resume-metadata-refresh">
|
||||
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="move-all">
|
||||
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
|
||||
|
||||
@@ -355,23 +355,6 @@
|
||||
{{ t('settings.updateFlagStrategy.help') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="hideEarlyAccessUpdates">{{ t('settings.hideEarlyAccessUpdates.label') }}</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="hideEarlyAccessUpdates"
|
||||
onchange="settingsManager.saveToggleSetting('hideEarlyAccessUpdates', 'hide_early_access_updates')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">
|
||||
{{ t('settings.hideEarlyAccessUpdates.help') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default Path Customization Section -->
|
||||
@@ -553,25 +536,6 @@
|
||||
<div class="settings-input-error-message" id="autoOrganizeExclusionsError"></div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item priority-tags-item auto-organize-exclusions-item">
|
||||
<div class="setting-row priority-tags-header">
|
||||
<div class="setting-info priority-tags-info">
|
||||
<label>{{ t('settings.metadataRefreshSkipPaths.label') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">
|
||||
{{ t('settings.metadataRefreshSkipPaths.help') }}
|
||||
</div>
|
||||
<textarea
|
||||
id="metadataRefreshSkipPaths"
|
||||
class="priority-tags-input auto-organize-exclusions-input"
|
||||
rows="3"
|
||||
placeholder="{{ t('settings.metadataRefreshSkipPaths.placeholder') }}"
|
||||
onblur="settingsManager.saveMetadataRefreshSkipPaths()"
|
||||
></textarea>
|
||||
<div class="settings-input-error-message" id="metadataRefreshSkipPathsError"></div>
|
||||
</div>
|
||||
|
||||
<!-- Add Example Images Settings Section -->
|
||||
<div class="settings-section">
|
||||
<h3>{{ t('settings.sections.exampleImages') }}</h3>
|
||||
|
||||
@@ -15,27 +15,6 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PY_INIT = REPO_ROOT / "py" / "__init__.py"
|
||||
|
||||
|
||||
class MockModule(types.ModuleType):
|
||||
"""A mock module class that is hashable (unlike SimpleNamespace).
|
||||
|
||||
This allows the module to be stored in sets/dicts without causing issues
|
||||
with tools like Hypothesis that iterate over sys.modules.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, **kwargs):
|
||||
super().__init__(name)
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.__name__)
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, MockModule):
|
||||
return self.__name__ == other.__name__
|
||||
return NotImplemented
|
||||
|
||||
|
||||
def _load_repo_package(name: str) -> types.ModuleType:
|
||||
"""Ensure the repository's ``py`` package is importable under *name*."""
|
||||
|
||||
@@ -62,32 +41,32 @@ _repo_package = _load_repo_package("py")
|
||||
sys.modules.setdefault("py_local", _repo_package)
|
||||
|
||||
# Mock ComfyUI modules before any imports from the main project
|
||||
server_mock = MockModule("server")
|
||||
server_mock = types.SimpleNamespace()
|
||||
server_mock.PromptServer = mock.MagicMock()
|
||||
sys.modules['server'] = server_mock
|
||||
|
||||
folder_paths_mock = MockModule("folder_paths")
|
||||
folder_paths_mock = types.SimpleNamespace()
|
||||
folder_paths_mock.get_folder_paths = mock.MagicMock(return_value=[])
|
||||
folder_paths_mock.folder_names_and_paths = {}
|
||||
sys.modules['folder_paths'] = folder_paths_mock
|
||||
|
||||
# Mock other ComfyUI modules that might be imported
|
||||
comfy_mock = MockModule("comfy")
|
||||
comfy_mock.utils = MockModule("comfy.utils")
|
||||
comfy_mock.model_management = MockModule("comfy.model_management")
|
||||
comfy_mock.comfy_types = MockModule("comfy.comfy_types")
|
||||
comfy_mock = types.SimpleNamespace()
|
||||
comfy_mock.utils = types.SimpleNamespace()
|
||||
comfy_mock.model_management = types.SimpleNamespace()
|
||||
comfy_mock.comfy_types = types.SimpleNamespace()
|
||||
comfy_mock.comfy_types.IO = mock.MagicMock()
|
||||
sys.modules['comfy'] = comfy_mock
|
||||
sys.modules['comfy.utils'] = comfy_mock.utils
|
||||
sys.modules['comfy.model_management'] = comfy_mock.model_management
|
||||
sys.modules['comfy.comfy_types'] = comfy_mock.comfy_types
|
||||
|
||||
execution_mock = MockModule("execution")
|
||||
execution_mock = types.SimpleNamespace()
|
||||
execution_mock.PromptExecutor = mock.MagicMock()
|
||||
sys.modules['execution'] = execution_mock
|
||||
|
||||
# Mock ComfyUI nodes module
|
||||
nodes_mock = MockModule("nodes")
|
||||
nodes_mock = types.SimpleNamespace()
|
||||
nodes_mock.LoraLoader = mock.MagicMock()
|
||||
nodes_mock.SaveImage = mock.MagicMock()
|
||||
nodes_mock.NODE_CLASS_MAPPINGS = {}
|
||||
@@ -126,6 +105,35 @@ def _isolate_settings_dir(tmp_path_factory, monkeypatch, request):
|
||||
settings_manager_module.reset_settings_manager()
|
||||
|
||||
|
||||
def pytest_pyfunc_call(pyfuncitem):
|
||||
"""Allow bare async tests to run without pytest.mark.asyncio."""
|
||||
test_function = pyfuncitem.function
|
||||
if inspect.iscoroutinefunction(test_function):
|
||||
func = pyfuncitem.obj
|
||||
signature = inspect.signature(func)
|
||||
accepted_kwargs: Dict[str, Any] = {}
|
||||
for name, parameter in signature.parameters.items():
|
||||
if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
|
||||
continue
|
||||
if parameter.kind is inspect.Parameter.VAR_KEYWORD:
|
||||
accepted_kwargs = dict(pyfuncitem.funcargs)
|
||||
break
|
||||
if name in pyfuncitem.funcargs:
|
||||
accepted_kwargs[name] = pyfuncitem.funcargs[name]
|
||||
|
||||
original_policy = asyncio.get_event_loop_policy()
|
||||
policy = pyfuncitem.funcargs.get("event_loop_policy")
|
||||
if policy is not None and policy is not original_policy:
|
||||
asyncio.set_event_loop_policy(policy)
|
||||
try:
|
||||
asyncio.run(func(**accepted_kwargs))
|
||||
finally:
|
||||
if policy is not None and policy is not original_policy:
|
||||
asyncio.set_event_loop_policy(original_policy)
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockHashIndex:
|
||||
"""Minimal hash index stub mirroring the scanner contract."""
|
||||
@@ -290,75 +298,3 @@ def mock_scanner(mock_cache: MockCache, mock_hash_index: MockHashIndex) -> MockS
|
||||
def mock_service(mock_scanner: MockScanner) -> MockModelService:
|
||||
return MockModelService(scanner=mock_scanner)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_downloader():
|
||||
"""Provide a configurable mock downloader."""
|
||||
class MockDownloader:
|
||||
def __init__(self):
|
||||
self.download_calls = []
|
||||
self.should_fail = False
|
||||
self.return_value = (True, "success")
|
||||
|
||||
async def download_file(self, url, target_path, **kwargs):
|
||||
self.download_calls.append({"url": url, "target_path": target_path, "kwargs": kwargs})
|
||||
if self.should_fail:
|
||||
return False, "Download failed"
|
||||
return self.return_value
|
||||
|
||||
return MockDownloader()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_websocket_manager():
|
||||
"""Provide a recording WebSocket manager."""
|
||||
class RecordingWebSocketManager:
|
||||
def __init__(self):
|
||||
self.payloads = []
|
||||
self.broadcast_count = 0
|
||||
|
||||
async def broadcast(self, payload):
|
||||
self.payloads.append(payload)
|
||||
self.broadcast_count += 1
|
||||
|
||||
def get_payloads_by_type(self, msg_type: str):
|
||||
"""Get all payloads of a specific message type."""
|
||||
return [p for p in self.payloads if p.get("type") == msg_type]
|
||||
|
||||
return RecordingWebSocketManager()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singletons():
|
||||
"""Reset all singletons before each test to ensure isolation."""
|
||||
# Import here to avoid circular imports
|
||||
from py.services.download_manager import DownloadManager
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.services.model_scanner import ModelScanner
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
|
||||
# Reset DownloadManager singleton
|
||||
DownloadManager._instance = None
|
||||
|
||||
# Reset ServiceRegistry
|
||||
ServiceRegistry._services = {}
|
||||
ServiceRegistry._initialized = False
|
||||
|
||||
# Reset ModelScanner instances
|
||||
if hasattr(ModelScanner, '_instances'):
|
||||
ModelScanner._instances.clear()
|
||||
|
||||
# Reset SettingsManager
|
||||
settings_manager = get_settings_manager()
|
||||
if hasattr(settings_manager, '_reset'):
|
||||
settings_manager._reset()
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup after test
|
||||
DownloadManager._instance = None
|
||||
ServiceRegistry._services = {}
|
||||
ServiceRegistry._initialized = False
|
||||
if hasattr(ModelScanner, '_instances'):
|
||||
ModelScanner._instances.clear()
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Integration tests package."""
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Shared fixtures for integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator, Dict, Generator, List
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_download_dir(tmp_path: Path) -> Path:
|
||||
"""Create a temporary directory for download tests."""
|
||||
download_dir = tmp_path / "downloads"
|
||||
download_dir.mkdir()
|
||||
return download_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_model_file() -> bytes:
|
||||
"""Create sample model file content for testing."""
|
||||
return b"fake model data for testing purposes"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_recipe_data() -> Dict[str, Any]:
|
||||
"""Create sample recipe data for testing."""
|
||||
return {
|
||||
"id": "test-recipe-001",
|
||||
"title": "Test Recipe",
|
||||
"file_path": "/path/to/recipe.png",
|
||||
"folder": "test-folder",
|
||||
"base_model": "SD1.5",
|
||||
"fingerprint": "abc123def456",
|
||||
"created_date": 1700000000.0,
|
||||
"modified": 1700000100.0,
|
||||
"favorite": False,
|
||||
"repair_version": 1,
|
||||
"preview_nsfw_level": 0,
|
||||
"loras": [
|
||||
{"hash": "lora1hash", "file_name": "test_lora1", "strength": 0.8},
|
||||
{"hash": "lora2hash", "file_name": "test_lora2", "strength": 1.0},
|
||||
],
|
||||
"checkpoint": {"name": "model.safetensors", "hash": "cphash123"},
|
||||
"gen_params": {
|
||||
"prompt": "masterpiece, best quality, test subject",
|
||||
"negative_prompt": "low quality, blurry",
|
||||
"steps": 20,
|
||||
"cfg": 7.0,
|
||||
"sampler": "DPM++ 2M Karras",
|
||||
},
|
||||
"tags": ["test", "integration", "recipe"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_websocket_manager():
|
||||
"""Provide a recording WebSocket manager for integration tests."""
|
||||
class RecordingWebSocketManager:
|
||||
def __init__(self):
|
||||
self.payloads: List[Dict[str, Any]] = []
|
||||
self.download_progress: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
async def broadcast(self, payload: Dict[str, Any]) -> None:
|
||||
self.payloads.append(payload)
|
||||
|
||||
async def broadcast_download_progress(
|
||||
self, download_id: str, data: Dict[str, Any]
|
||||
) -> None:
|
||||
if download_id not in self.download_progress:
|
||||
self.download_progress[download_id] = []
|
||||
self.download_progress[download_id].append(data)
|
||||
|
||||
def get_download_progress(self, download_id: str) -> Dict[str, Any] | None:
|
||||
progress_list = self.download_progress.get(download_id, [])
|
||||
if not progress_list:
|
||||
return None
|
||||
# Return the latest progress
|
||||
latest = progress_list[-1]
|
||||
return {"download_id": download_id, **latest}
|
||||
|
||||
return RecordingWebSocketManager()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_scanner():
|
||||
"""Provide a mock model scanner with configurable behavior."""
|
||||
class MockScanner:
|
||||
def __init__(self):
|
||||
self._cache = MagicMock()
|
||||
self._cache.raw_data = []
|
||||
self._hash_index = MagicMock()
|
||||
self.model_type = "lora"
|
||||
self._tags_count: Dict[str, int] = {}
|
||||
self._excluded_models: List[str] = []
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False):
|
||||
return self._cache
|
||||
|
||||
async def update_single_model_cache(
|
||||
self, original_path: str, new_path: str, metadata: Dict[str, Any]
|
||||
) -> bool:
|
||||
for item in self._cache.raw_data:
|
||||
if item.get("file_path") == original_path:
|
||||
item.update(metadata)
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_by_path(self, path: str) -> None:
|
||||
pass
|
||||
|
||||
return MockScanner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_metadata_manager():
|
||||
"""Provide a mock metadata manager."""
|
||||
class MockMetadataManager:
|
||||
def __init__(self):
|
||||
self.saved_metadata: List[tuple] = []
|
||||
self.loaded_payloads: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
async def save_metadata(self, file_path: str, metadata: Dict[str, Any]) -> None:
|
||||
self.saved_metadata.append((file_path, metadata.copy()))
|
||||
|
||||
async def load_metadata_payload(self, file_path: str) -> Dict[str, Any]:
|
||||
return self.loaded_payloads.get(file_path, {})
|
||||
|
||||
def set_payload(self, file_path: str, payload: Dict[str, Any]) -> None:
|
||||
self.loaded_payloads[file_path] = payload
|
||||
|
||||
return MockMetadataManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_download_coordinator():
|
||||
"""Provide a mock download coordinator."""
|
||||
class MockDownloadCoordinator:
|
||||
def __init__(self):
|
||||
self.active_downloads: Dict[str, Any] = {}
|
||||
self.cancelled_downloads: List[str] = []
|
||||
self.paused_downloads: List[str] = []
|
||||
self.resumed_downloads: List[str] = []
|
||||
|
||||
async def cancel_download(self, download_id: str) -> Dict[str, Any]:
|
||||
self.cancelled_downloads.append(download_id)
|
||||
return {"success": True, "message": f"Download {download_id} cancelled"}
|
||||
|
||||
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
||||
self.paused_downloads.append(download_id)
|
||||
return {"success": True, "message": f"Download {download_id} paused"}
|
||||
|
||||
async def resume_download(self, download_id: str) -> Dict[str, Any]:
|
||||
self.resumed_downloads.append(download_id)
|
||||
return {"success": True, "message": f"Download {download_id} resumed"}
|
||||
|
||||
return MockDownloadCoordinator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_http_server(
|
||||
tmp_path: Path,
|
||||
) -> AsyncGenerator[tuple[str, int], None]:
|
||||
"""Create a test HTTP server that serves files from a temporary directory."""
|
||||
from aiohttp import web
|
||||
|
||||
async def handle_download(request):
|
||||
"""Handle file download requests."""
|
||||
filename = request.match_info.get("filename", "test_model.safetensors")
|
||||
file_path = tmp_path / filename
|
||||
if file_path.exists():
|
||||
return web.FileResponse(path=file_path)
|
||||
return web.Response(status=404, text="File not found")
|
||||
|
||||
async def handle_status(request):
|
||||
"""Return server status."""
|
||||
return web.json_response({"status": "ok", "server": "test"})
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/download/{filename}", handle_download)
|
||||
app.router.add_get("/status", handle_status)
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
# Use port 0 to get an available port
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
yield base_url, port
|
||||
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_loop():
|
||||
"""Create an event loop for async tests."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
@@ -1,238 +0,0 @@
|
||||
"""Integration tests for download flow.
|
||||
|
||||
These tests verify the complete download workflow including:
|
||||
1. Route receives download request
|
||||
2. DownloadCoordinator schedules it
|
||||
3. DownloadManager executes actual download
|
||||
4. Downloader makes HTTP request (to test server)
|
||||
5. Progress is broadcast via WebSocket
|
||||
6. File is saved and cache updated
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, Mock
|
||||
|
||||
import pytest
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||
|
||||
|
||||
class TestDownloadFlowIntegration:
|
||||
"""Integration tests for complete download workflow."""
|
||||
|
||||
async def test_download_with_mocked_network(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
temp_download_dir: Path,
|
||||
):
|
||||
"""Verify download flow with mocked network calls."""
|
||||
from py.services.downloader import Downloader
|
||||
|
||||
# Setup test content
|
||||
test_content = b"fake model data for integration test"
|
||||
target_path = temp_download_dir / "downloaded_model.safetensors"
|
||||
|
||||
# Create downloader and directly mock the download method to avoid network issues
|
||||
downloader = Downloader()
|
||||
|
||||
# Mock the actual download to avoid network calls
|
||||
original_download = downloader.download_file
|
||||
|
||||
async def mock_download_file(url, save_path, **kwargs):
|
||||
# Simulate successful download by writing file directly
|
||||
Path(save_path).write_bytes(test_content)
|
||||
return True, save_path
|
||||
|
||||
with patch.object(downloader, 'download_file', side_effect=mock_download_file):
|
||||
# Execute download
|
||||
success, message = await downloader.download_file(
|
||||
url="http://test.com/model.safetensors",
|
||||
save_path=str(target_path),
|
||||
)
|
||||
|
||||
# Verify download succeeded
|
||||
assert success is True, f"Download failed: {message}"
|
||||
assert target_path.exists()
|
||||
assert target_path.read_bytes() == test_content
|
||||
|
||||
async def test_download_with_progress_broadcast(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mock_websocket_manager,
|
||||
):
|
||||
"""Verify progress updates are broadcast during download."""
|
||||
ws_manager = mock_websocket_manager
|
||||
|
||||
# Simulate progress updates
|
||||
download_id = "test-download-001"
|
||||
progress_updates = [
|
||||
{"status": "started", "progress": 0},
|
||||
{"status": "downloading", "progress": 25},
|
||||
{"status": "downloading", "progress": 50},
|
||||
{"status": "downloading", "progress": 75},
|
||||
{"status": "completed", "progress": 100},
|
||||
]
|
||||
|
||||
for update in progress_updates:
|
||||
await ws_manager.broadcast_download_progress(download_id, update)
|
||||
|
||||
# Verify all updates were recorded
|
||||
assert download_id in ws_manager.download_progress
|
||||
assert len(ws_manager.download_progress[download_id]) == 5
|
||||
|
||||
# Verify final status
|
||||
final_progress = ws_manager.download_progress[download_id][-1]
|
||||
assert final_progress["status"] == "completed"
|
||||
assert final_progress["progress"] == 100
|
||||
|
||||
async def test_download_error_handling(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
temp_download_dir: Path,
|
||||
):
|
||||
"""Verify download errors are handled gracefully."""
|
||||
from py.services.downloader import Downloader
|
||||
|
||||
downloader = Downloader()
|
||||
target_path = temp_download_dir / "failed_download.safetensors"
|
||||
|
||||
# Mock download to simulate failure
|
||||
async def mock_failed_download(url, save_path, **kwargs):
|
||||
return False, "Network error: Connection failed"
|
||||
|
||||
with patch.object(downloader, 'download_file', side_effect=mock_failed_download):
|
||||
# Execute download
|
||||
success, message = await downloader.download_file(
|
||||
url="http://invalid.url/test.safetensors",
|
||||
save_path=str(target_path),
|
||||
)
|
||||
|
||||
# Verify failure is reported
|
||||
assert success is False
|
||||
assert isinstance(message, str)
|
||||
assert "error" in message.lower() or "fail" in message.lower() or "network" in message.lower()
|
||||
|
||||
async def test_download_cancellation_flow(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mock_download_coordinator,
|
||||
):
|
||||
"""Verify download cancellation works correctly."""
|
||||
coordinator = mock_download_coordinator()
|
||||
download_id = "test-cancel-001"
|
||||
|
||||
# Simulate cancellation
|
||||
result = await coordinator.cancel_download(download_id)
|
||||
|
||||
assert result["success"] is True
|
||||
assert download_id in coordinator.cancelled_downloads
|
||||
|
||||
async def test_concurrent_download_management(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""Verify multiple downloads can be managed concurrently."""
|
||||
from py.services.download_manager import DownloadManager
|
||||
|
||||
# Reset singleton
|
||||
DownloadManager._instance = None
|
||||
|
||||
download_manager = await DownloadManager.get_instance()
|
||||
|
||||
# Simulate multiple active downloads
|
||||
download_ids = [f"concurrent-{i}" for i in range(3)]
|
||||
|
||||
for download_id in download_ids:
|
||||
download_manager._active_downloads[download_id] = {
|
||||
"id": download_id,
|
||||
"status": "downloading",
|
||||
"progress": 0,
|
||||
}
|
||||
|
||||
# Verify all downloads are tracked
|
||||
assert len(download_manager._active_downloads) == 3
|
||||
for download_id in download_ids:
|
||||
assert download_id in download_manager._active_downloads
|
||||
|
||||
# Cleanup
|
||||
DownloadManager._instance = None
|
||||
|
||||
|
||||
class TestDownloadRouteIntegration:
|
||||
"""Integration tests for download route handlers."""
|
||||
|
||||
async def test_download_model_endpoint_validation(self):
|
||||
"""Verify download endpoint validates required parameters."""
|
||||
from py.routes.handlers.model_handlers import ModelDownloadHandler
|
||||
|
||||
# Create mock dependencies
|
||||
mock_ws_manager = MagicMock()
|
||||
mock_logger = MagicMock()
|
||||
mock_use_case = AsyncMock()
|
||||
mock_coordinator = AsyncMock()
|
||||
|
||||
handler = ModelDownloadHandler(
|
||||
ws_manager=mock_ws_manager,
|
||||
logger=mock_logger,
|
||||
download_use_case=mock_use_case,
|
||||
download_coordinator=mock_coordinator,
|
||||
)
|
||||
|
||||
# Test with missing model_id
|
||||
request = make_mocked_request("GET", "/api/download?model_version_id=123")
|
||||
response = await handler.download_model_get(request)
|
||||
|
||||
assert response.status == 400
|
||||
# Response might be JSON or text, check both
|
||||
if hasattr(response, 'text'):
|
||||
error_text = response.text.lower()
|
||||
else:
|
||||
body = response.body
|
||||
if body:
|
||||
error_text = body.decode().lower() if isinstance(body, bytes) else str(body).lower()
|
||||
else:
|
||||
error_text = ""
|
||||
|
||||
assert "model_id" in error_text or "missing" in error_text or error_text == ""
|
||||
|
||||
async def test_download_progress_endpoint(self):
|
||||
"""Verify download progress endpoint returns correct data."""
|
||||
from py.routes.handlers.model_handlers import ModelDownloadHandler
|
||||
|
||||
mock_ws_manager = MagicMock()
|
||||
mock_ws_manager.get_download_progress.return_value = {
|
||||
"download_id": "test-123",
|
||||
"status": "downloading",
|
||||
"progress": 50,
|
||||
}
|
||||
|
||||
handler = ModelDownloadHandler(
|
||||
ws_manager=mock_ws_manager,
|
||||
logger=MagicMock(),
|
||||
download_use_case=AsyncMock(),
|
||||
download_coordinator=AsyncMock(),
|
||||
)
|
||||
|
||||
request = make_mocked_request(
|
||||
"GET", "/api/download/progress/test-123", match_info={"download_id": "test-123"}
|
||||
)
|
||||
response = await handler.get_download_progress(request)
|
||||
|
||||
assert response.status == 200
|
||||
# Response body handling
|
||||
if hasattr(response, 'text') and response.text:
|
||||
data = json.loads(response.text)
|
||||
else:
|
||||
body = response.body
|
||||
data = json.loads(body.decode() if isinstance(body, bytes) else str(body))
|
||||
|
||||
assert data.get("success") is True or data.get("progress") == 50 or "data" in data
|
||||
@@ -1,259 +0,0 @@
|
||||
"""Integration tests for recipe flow.
|
||||
|
||||
These tests verify the complete recipe workflow including:
|
||||
1. Import recipe from image
|
||||
2. Parse metadata and extract models
|
||||
3. Save to cache and database
|
||||
4. Retrieve and display
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import aiohttp
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||
|
||||
|
||||
class TestRecipeFlowIntegration:
|
||||
"""Integration tests for complete recipe workflow."""
|
||||
|
||||
async def test_recipe_save_and_retrieve_flow(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
sample_recipe_data: Dict[str, Any],
|
||||
):
|
||||
"""Verify recipe can be saved and retrieved."""
|
||||
from py.services.persistent_recipe_cache import PersistentRecipeCache
|
||||
|
||||
db_path = tmp_path / "test_recipe_cache.sqlite"
|
||||
cache = PersistentRecipeCache(db_path=str(db_path))
|
||||
|
||||
# Save recipe
|
||||
recipes = [sample_recipe_data]
|
||||
json_paths = {sample_recipe_data["id"]: "/path/to/test.recipe.json"}
|
||||
cache.save_cache(recipes, json_paths)
|
||||
|
||||
# Retrieve recipe
|
||||
loaded = cache.load_cache()
|
||||
|
||||
assert loaded is not None
|
||||
assert len(loaded.raw_data) == 1
|
||||
|
||||
loaded_recipe = loaded.raw_data[0]
|
||||
assert loaded_recipe["id"] == sample_recipe_data["id"]
|
||||
assert loaded_recipe["title"] == sample_recipe_data["title"]
|
||||
assert loaded_recipe["base_model"] == sample_recipe_data["base_model"]
|
||||
|
||||
async def test_recipe_update_flow(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
sample_recipe_data: Dict[str, Any],
|
||||
):
|
||||
"""Verify recipe can be updated and changes persisted."""
|
||||
from py.services.persistent_recipe_cache import PersistentRecipeCache
|
||||
|
||||
db_path = tmp_path / "test_recipe_cache.sqlite"
|
||||
cache = PersistentRecipeCache(db_path=str(db_path))
|
||||
|
||||
# Save initial recipe
|
||||
cache.save_cache([sample_recipe_data])
|
||||
|
||||
# Update recipe
|
||||
updated_recipe = dict(sample_recipe_data)
|
||||
updated_recipe["title"] = "Updated Recipe Title"
|
||||
updated_recipe["favorite"] = True
|
||||
|
||||
cache.update_recipe(updated_recipe, "/path/to/test.recipe.json")
|
||||
|
||||
# Verify update
|
||||
loaded = cache.load_cache()
|
||||
loaded_recipe = loaded.raw_data[0]
|
||||
|
||||
assert loaded_recipe["title"] == "Updated Recipe Title"
|
||||
assert loaded_recipe["favorite"] is True
|
||||
|
||||
async def test_recipe_delete_flow(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
sample_recipe_data: Dict[str, Any],
|
||||
):
|
||||
"""Verify recipe can be deleted."""
|
||||
from py.services.persistent_recipe_cache import PersistentRecipeCache
|
||||
|
||||
db_path = tmp_path / "test_recipe_cache.sqlite"
|
||||
cache = PersistentRecipeCache(db_path=str(db_path))
|
||||
|
||||
# Save recipe
|
||||
cache.save_cache([sample_recipe_data])
|
||||
assert cache.get_recipe_count() == 1
|
||||
|
||||
# Delete recipe
|
||||
cache.remove_recipe(sample_recipe_data["id"])
|
||||
|
||||
# Verify deletion
|
||||
assert cache.get_recipe_count() == 0
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is None or len(loaded.raw_data) == 0
|
||||
|
||||
async def test_recipe_model_extraction(
|
||||
self,
|
||||
sample_recipe_data: Dict[str, Any],
|
||||
):
|
||||
"""Verify models are correctly extracted from recipe data."""
|
||||
loras = sample_recipe_data.get("loras", [])
|
||||
checkpoint = sample_recipe_data.get("checkpoint")
|
||||
|
||||
# Verify LoRAs are present
|
||||
assert len(loras) == 2
|
||||
assert loras[0]["file_name"] == "test_lora1"
|
||||
assert loras[0]["strength"] == 0.8
|
||||
assert loras[1]["file_name"] == "test_lora2"
|
||||
assert loras[1]["strength"] == 1.0
|
||||
|
||||
# Verify checkpoint is present
|
||||
assert checkpoint is not None
|
||||
assert checkpoint["name"] == "model.safetensors"
|
||||
assert checkpoint["hash"] == "cphash123"
|
||||
|
||||
async def test_recipe_generation_params(
|
||||
self,
|
||||
sample_recipe_data: Dict[str, Any],
|
||||
):
|
||||
"""Verify generation parameters are correctly stored."""
|
||||
gen_params = sample_recipe_data.get("gen_params", {})
|
||||
|
||||
assert gen_params["prompt"] == "masterpiece, best quality, test subject"
|
||||
assert gen_params["negative_prompt"] == "low quality, blurry"
|
||||
assert gen_params["steps"] == 20
|
||||
assert gen_params["cfg"] == 7.0
|
||||
assert gen_params["sampler"] == "DPM++ 2M Karras"
|
||||
|
||||
|
||||
class TestRecipeCacheConcurrency:
|
||||
"""Integration tests for recipe cache concurrent access."""
|
||||
|
||||
async def test_concurrent_recipe_reads(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
sample_recipe_data: Dict[str, Any],
|
||||
):
|
||||
"""Verify concurrent reads don't corrupt data."""
|
||||
from py.services.persistent_recipe_cache import PersistentRecipeCache
|
||||
import asyncio
|
||||
|
||||
db_path = tmp_path / "test_concurrent.sqlite"
|
||||
cache = PersistentRecipeCache(db_path=str(db_path))
|
||||
|
||||
# Save multiple recipes
|
||||
recipes = [
|
||||
{**sample_recipe_data, "id": f"recipe-{i}"}
|
||||
for i in range(10)
|
||||
]
|
||||
cache.save_cache(recipes)
|
||||
|
||||
# Concurrent reads
|
||||
async def read_recipes():
|
||||
return cache.load_cache()
|
||||
|
||||
tasks = [read_recipes() for _ in range(5)]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# All reads should succeed and return same data
|
||||
for result in results:
|
||||
assert result is not None
|
||||
assert len(result.raw_data) == 10
|
||||
|
||||
async def test_concurrent_read_write(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
sample_recipe_data: Dict[str, Any],
|
||||
):
|
||||
"""Verify concurrent read/write operations are safe."""
|
||||
from py.services.persistent_recipe_cache import PersistentRecipeCache
|
||||
import asyncio
|
||||
|
||||
db_path = tmp_path / "test_concurrent.sqlite"
|
||||
cache = PersistentRecipeCache(db_path=str(db_path))
|
||||
|
||||
# Initial save
|
||||
cache.save_cache([sample_recipe_data])
|
||||
|
||||
async def read_operation():
|
||||
await asyncio.sleep(0.01) # Small delay to interleave operations
|
||||
return cache.load_cache()
|
||||
|
||||
async def write_operation(recipe_id: str):
|
||||
await asyncio.sleep(0.005) # Small delay
|
||||
recipe = {**sample_recipe_data, "id": recipe_id}
|
||||
cache.update_recipe(recipe, f"/path/to/{recipe_id}.json")
|
||||
|
||||
# Mix of read and write operations
|
||||
tasks = [
|
||||
read_operation(),
|
||||
write_operation("recipe-002"),
|
||||
read_operation(),
|
||||
write_operation("recipe-003"),
|
||||
read_operation(),
|
||||
]
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# No exceptions should occur
|
||||
for result in results:
|
||||
assert not isinstance(result, Exception), f"Exception occurred: {result}"
|
||||
|
||||
# Final state should be valid
|
||||
final = cache.load_cache()
|
||||
assert final is not None
|
||||
assert cache.get_recipe_count() >= 1
|
||||
|
||||
|
||||
class TestRecipeRouteIntegration:
|
||||
"""Integration tests for recipe route handlers."""
|
||||
|
||||
async def test_recipe_list_endpoint(self):
|
||||
"""Verify recipe list endpoint returns correct format."""
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
# This would test the actual route handler
|
||||
# For now, we verify the expected response structure
|
||||
expected_response = {
|
||||
"success": True,
|
||||
"recipes": [],
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
assert "success" in expected_response
|
||||
assert "recipes" in expected_response
|
||||
|
||||
async def test_recipe_metadata_parsing(self):
|
||||
"""Verify recipe metadata is parsed correctly from various formats."""
|
||||
# Simple metadata parsing test without external dependency
|
||||
meta_str = """prompt: masterpiece, best quality
|
||||
negative_prompt: low quality
|
||||
steps: 20
|
||||
cfg: 7.0"""
|
||||
|
||||
# Basic parsing logic for testing
|
||||
def parse_simple_metadata(text: str) -> dict:
|
||||
result = {}
|
||||
for line in text.strip().split('\n'):
|
||||
if ':' in line:
|
||||
key, value = line.split(':', 1)
|
||||
result[key.strip()] = value.strip()
|
||||
return result
|
||||
|
||||
result = parse_simple_metadata(meta_str)
|
||||
|
||||
assert result is not None
|
||||
assert "prompt" in result
|
||||
assert "negative_prompt" in result
|
||||
assert result["prompt"] == "masterpiece, best quality"
|
||||
@@ -1,174 +0,0 @@
|
||||
"""Performance benchmarks using pytest-benchmark.
|
||||
|
||||
These tests measure the performance of critical operations to detect
|
||||
regressions and ensure acceptable performance with large datasets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import string
|
||||
import pytest
|
||||
|
||||
from py.services.model_hash_index import ModelHashIndex
|
||||
from py.utils.utils import fuzzy_match, calculate_recipe_fingerprint
|
||||
|
||||
|
||||
class TestHashIndexPerformance:
|
||||
"""Performance benchmarks for hash index operations."""
|
||||
|
||||
def test_hash_index_lookup_small(self, benchmark):
|
||||
"""Benchmark hash index lookup with 100 models."""
|
||||
index, target_hash = self._create_hash_index_with_n_models(100, return_target=True)
|
||||
|
||||
def lookup():
|
||||
return index.get_path(target_hash)
|
||||
|
||||
result = benchmark(lookup)
|
||||
assert result is not None
|
||||
|
||||
def test_hash_index_lookup_medium(self, benchmark):
|
||||
"""Benchmark hash index lookup with 1,000 models."""
|
||||
index, target_hash = self._create_hash_index_with_n_models(1000, return_target=True)
|
||||
|
||||
def lookup():
|
||||
return index.get_path(target_hash)
|
||||
|
||||
result = benchmark(lookup)
|
||||
assert result is not None
|
||||
|
||||
def test_hash_index_lookup_large(self, benchmark):
|
||||
"""Benchmark hash index lookup with 10,000 models."""
|
||||
index, target_hash = self._create_hash_index_with_n_models(10000, return_target=True)
|
||||
|
||||
def lookup():
|
||||
return index.get_path(target_hash)
|
||||
|
||||
result = benchmark(lookup)
|
||||
assert result is not None
|
||||
|
||||
def test_hash_index_add_entry_small(self, benchmark):
|
||||
"""Benchmark adding entries to hash index with 100 existing models."""
|
||||
index = self._create_hash_index_with_n_models(100)
|
||||
new_hash = f"new_hash_{self._random_string(16)}"
|
||||
new_path = "/path/to/new_model.safetensors"
|
||||
|
||||
def add_entry():
|
||||
index.add_entry(new_hash, new_path)
|
||||
|
||||
benchmark(add_entry)
|
||||
|
||||
def test_hash_index_add_entry_large(self, benchmark):
|
||||
"""Benchmark adding entries to hash index with 10,000 existing models."""
|
||||
index = self._create_hash_index_with_n_models(10000)
|
||||
new_hash = f"new_hash_{self._random_string(16)}"
|
||||
new_path = "/path/to/new_model.safetensors"
|
||||
|
||||
def add_entry():
|
||||
index.add_entry(new_hash, new_path)
|
||||
|
||||
benchmark(add_entry)
|
||||
|
||||
def _create_hash_index_with_n_models(self, n: int, return_target: bool = False):
|
||||
"""Create a hash index with n mock models.
|
||||
|
||||
Args:
|
||||
n: Number of models to create
|
||||
return_target: If True, returns the hash of the middle model for lookup testing
|
||||
|
||||
Returns:
|
||||
ModelHashIndex or tuple of (ModelHashIndex, target_hash)
|
||||
"""
|
||||
index = ModelHashIndex()
|
||||
target_hash = None
|
||||
target_index = n // 2
|
||||
for i in range(n):
|
||||
sha256 = f"hash_{i:08d}_{self._random_string(24)}"
|
||||
file_path = f"/path/to/model_{i}.safetensors"
|
||||
index.add_entry(sha256, file_path)
|
||||
if i == target_index:
|
||||
target_hash = sha256
|
||||
if return_target:
|
||||
return index, target_hash
|
||||
return index
|
||||
|
||||
def _random_string(self, length: int) -> str:
|
||||
"""Generate a random string of fixed length."""
|
||||
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
|
||||
|
||||
|
||||
class TestFuzzyMatchPerformance:
|
||||
"""Performance benchmarks for fuzzy matching."""
|
||||
|
||||
def test_fuzzy_match_short_text(self, benchmark):
|
||||
"""Benchmark fuzzy matching with short text."""
|
||||
text = "lora model for character generation"
|
||||
pattern = "character lora"
|
||||
|
||||
def match():
|
||||
return fuzzy_match(text, pattern)
|
||||
|
||||
benchmark(match)
|
||||
|
||||
def test_fuzzy_match_long_text(self, benchmark):
|
||||
"""Benchmark fuzzy matching with long text."""
|
||||
text = "This is a very long description of a LoRA model that contains many words and details about what it does and how it works for character generation in stable diffusion"
|
||||
pattern = "character generation stable diffusion"
|
||||
|
||||
def match():
|
||||
return fuzzy_match(text, pattern)
|
||||
|
||||
benchmark(match)
|
||||
|
||||
def test_fuzzy_match_many_words(self, benchmark):
|
||||
"""Benchmark fuzzy matching with many search words."""
|
||||
text = "lora model anime style character portrait high quality detailed"
|
||||
pattern = "anime style character portrait high quality"
|
||||
|
||||
def match():
|
||||
return fuzzy_match(text, pattern)
|
||||
|
||||
benchmark(match)
|
||||
|
||||
|
||||
class TestRecipeFingerprintPerformance:
|
||||
"""Performance benchmarks for recipe fingerprint calculation."""
|
||||
|
||||
def test_fingerprint_small_recipe(self, benchmark):
|
||||
"""Benchmark fingerprint calculation with 5 LoRAs."""
|
||||
loras = self._create_loras(5)
|
||||
|
||||
def calculate():
|
||||
return calculate_recipe_fingerprint(loras)
|
||||
|
||||
benchmark(calculate)
|
||||
|
||||
def test_fingerprint_medium_recipe(self, benchmark):
|
||||
"""Benchmark fingerprint calculation with 50 LoRAs."""
|
||||
loras = self._create_loras(50)
|
||||
|
||||
def calculate():
|
||||
return calculate_recipe_fingerprint(loras)
|
||||
|
||||
benchmark(calculate)
|
||||
|
||||
def test_fingerprint_large_recipe(self, benchmark):
|
||||
"""Benchmark fingerprint calculation with 200 LoRAs."""
|
||||
loras = self._create_loras(200)
|
||||
|
||||
def calculate():
|
||||
return calculate_recipe_fingerprint(loras)
|
||||
|
||||
benchmark(calculate)
|
||||
|
||||
def _create_loras(self, n: int) -> list:
|
||||
"""Create a list of n mock LoRA dictionaries."""
|
||||
loras = []
|
||||
for i in range(n):
|
||||
lora = {
|
||||
"hash": f"abc{i:08d}",
|
||||
"strength": round(random.uniform(0.0, 2.0), 2),
|
||||
"modelVersionId": i,
|
||||
}
|
||||
loras.append(lora)
|
||||
return loras
|
||||
@@ -1,68 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: TestModelLibraryHandlerSnapshots.test_check_model_exists_empty_response
|
||||
dict({
|
||||
'modelType': None,
|
||||
'success': True,
|
||||
'versions': list([
|
||||
]),
|
||||
})
|
||||
# ---
|
||||
# name: TestNodeRegistryHandlerSnapshots.test_register_nodes_error_response
|
||||
dict({
|
||||
'message': '0 nodes registered successfully',
|
||||
'success': True,
|
||||
})
|
||||
# ---
|
||||
# name: TestNodeRegistryHandlerSnapshots.test_register_nodes_success_response
|
||||
dict({
|
||||
'message': '1 nodes registered successfully',
|
||||
'success': True,
|
||||
})
|
||||
# ---
|
||||
# name: TestSettingsHandlerSnapshots.test_get_settings_response_format
|
||||
dict({
|
||||
'messages': list([
|
||||
]),
|
||||
'settings': dict({
|
||||
'civitai_api_key': 'test-key',
|
||||
'language': 'en',
|
||||
'theme': 'dark',
|
||||
}),
|
||||
'success': True,
|
||||
})
|
||||
# ---
|
||||
# name: TestSettingsHandlerSnapshots.test_update_settings_success_response
|
||||
dict({
|
||||
'success': True,
|
||||
})
|
||||
# ---
|
||||
# name: TestUtilityFunctionSnapshots.test_calculate_recipe_fingerprint_various_inputs
|
||||
list([
|
||||
'',
|
||||
'abc123:1.0',
|
||||
'abc123:1.0|def456:0.75',
|
||||
'abc123:0.5|def456:1.0',
|
||||
'abc123:0.8',
|
||||
'12345:1.0',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
])
|
||||
# ---
|
||||
# name: TestUtilityFunctionSnapshots.test_sanitize_folder_name_various_inputs
|
||||
dict({
|
||||
'': '',
|
||||
' spaces ': 'spaces',
|
||||
'___underscores___': 'underscores',
|
||||
'folder with spaces': 'folder with spaces',
|
||||
'folder"with"quotes': 'folder_with_quotes',
|
||||
'folder*with*asterisks': 'folder_with_asterisks',
|
||||
'folder.with.dots': 'folder.with.dots',
|
||||
'folder/with/slashes': 'folder_with_slashes',
|
||||
'folder<with>brackets': 'folder_with_brackets',
|
||||
'folder?with?questions': 'folder_with_questions',
|
||||
'folder\\with\\backslashes': 'folder_with_backslashes',
|
||||
'folder|with|pipes': 'folder_with_pipes',
|
||||
'normal_folder': 'normal_folder',
|
||||
})
|
||||
# ---
|
||||
@@ -1,233 +0,0 @@
|
||||
"""Snapshot tests for API response formats using Syrupy.
|
||||
|
||||
These tests verify that API responses maintain consistent structure and format
|
||||
by comparing against stored snapshots. This catches unexpected changes to
|
||||
response schemas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from syrupy import SnapshotAssertion
|
||||
|
||||
from py.routes.handlers.misc_handlers import (
|
||||
ModelLibraryHandler,
|
||||
NodeRegistry,
|
||||
NodeRegistryHandler,
|
||||
ServiceRegistryAdapter,
|
||||
SettingsHandler,
|
||||
)
|
||||
from py.utils.utils import calculate_recipe_fingerprint, sanitize_folder_name
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
"""Fake HTTP request for testing."""
|
||||
|
||||
def __init__(self, *, json_data=None, query=None):
|
||||
self._json_data = json_data or {}
|
||||
self.query = query or {}
|
||||
|
||||
async def json(self):
|
||||
return self._json_data
|
||||
|
||||
|
||||
class DummySettings:
|
||||
"""Dummy settings service for testing."""
|
||||
|
||||
def __init__(self, data=None):
|
||||
self.data = data or {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self.data.get(key, default)
|
||||
|
||||
def set(self, key, value):
|
||||
self.data[key] = value
|
||||
|
||||
def keys(self):
|
||||
return self.data.keys()
|
||||
|
||||
|
||||
async def noop_async(*_args, **_kwargs):
|
||||
"""No-op async function."""
|
||||
return None
|
||||
|
||||
|
||||
class FakePromptServer:
|
||||
"""Fake prompt server for testing."""
|
||||
|
||||
sent = []
|
||||
|
||||
class Instance:
|
||||
def send_sync(self, event, payload):
|
||||
FakePromptServer.sent.append((event, payload))
|
||||
|
||||
instance = Instance()
|
||||
|
||||
|
||||
class TestSettingsHandlerSnapshots:
|
||||
"""Snapshot tests for SettingsHandler responses."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_settings_response_format(self, snapshot: SnapshotAssertion):
|
||||
"""Verify get_settings response format matches snapshot."""
|
||||
settings_service = DummySettings({
|
||||
"civitai_api_key": "test-key",
|
||||
"language": "en",
|
||||
"theme": "dark"
|
||||
})
|
||||
handler = SettingsHandler(
|
||||
settings_service=settings_service,
|
||||
metadata_provider_updater=noop_async,
|
||||
downloader_factory=lambda: None,
|
||||
)
|
||||
|
||||
response = await handler.get_settings(FakeRequest())
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload == snapshot
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_settings_success_response(self, snapshot: SnapshotAssertion):
|
||||
"""Verify successful update_settings response format."""
|
||||
settings_service = DummySettings()
|
||||
handler = SettingsHandler(
|
||||
settings_service=settings_service,
|
||||
metadata_provider_updater=noop_async,
|
||||
downloader_factory=lambda: None,
|
||||
)
|
||||
|
||||
request = FakeRequest(json_data={"language": "zh"})
|
||||
response = await handler.update_settings(request)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload == snapshot
|
||||
|
||||
|
||||
class TestNodeRegistryHandlerSnapshots:
|
||||
"""Snapshot tests for NodeRegistryHandler responses."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_nodes_success_response(self, snapshot: SnapshotAssertion):
|
||||
"""Verify successful register_nodes response format."""
|
||||
node_registry = NodeRegistry()
|
||||
handler = NodeRegistryHandler(
|
||||
node_registry=node_registry,
|
||||
prompt_server=FakePromptServer,
|
||||
standalone_mode=False,
|
||||
)
|
||||
|
||||
request = FakeRequest(
|
||||
json_data={
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": 1,
|
||||
"graph_id": "root",
|
||||
"type": "Lora Loader (LoraManager)",
|
||||
"title": "Test Loader",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
response = await handler.register_nodes(request)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload == snapshot
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_nodes_error_response(self, snapshot: SnapshotAssertion):
|
||||
"""Verify error register_nodes response format."""
|
||||
node_registry = NodeRegistry()
|
||||
handler = NodeRegistryHandler(
|
||||
node_registry=node_registry,
|
||||
prompt_server=FakePromptServer,
|
||||
standalone_mode=False,
|
||||
)
|
||||
|
||||
request = FakeRequest(json_data={"nodes": []})
|
||||
response = await handler.register_nodes(request)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload == snapshot
|
||||
|
||||
|
||||
class TestUtilityFunctionSnapshots:
|
||||
"""Snapshot tests for utility function outputs."""
|
||||
|
||||
def test_sanitize_folder_name_various_inputs(self, snapshot: SnapshotAssertion):
|
||||
"""Verify sanitize_folder_name produces expected outputs."""
|
||||
test_inputs = [
|
||||
"normal_folder",
|
||||
"folder with spaces",
|
||||
"folder/with/slashes",
|
||||
'folder\\with\\backslashes',
|
||||
'folder<with>brackets',
|
||||
'folder"with"quotes',
|
||||
'folder|with|pipes',
|
||||
'folder?with?questions',
|
||||
'folder*with*asterisks',
|
||||
'',
|
||||
' spaces ',
|
||||
'folder.with.dots',
|
||||
'___underscores___',
|
||||
]
|
||||
|
||||
results = {input_name: sanitize_folder_name(input_name) for input_name in test_inputs}
|
||||
assert results == snapshot
|
||||
|
||||
def test_calculate_recipe_fingerprint_various_inputs(self, snapshot: SnapshotAssertion):
|
||||
"""Verify calculate_recipe_fingerprint produces expected outputs."""
|
||||
test_cases = [
|
||||
[],
|
||||
[{"hash": "abc123", "strength": 1.0}],
|
||||
[
|
||||
{"hash": "abc123", "strength": 1.0},
|
||||
{"hash": "def456", "strength": 0.75},
|
||||
],
|
||||
[
|
||||
{"hash": "DEF456", "strength": 1.0},
|
||||
{"hash": "ABC123", "strength": 0.5},
|
||||
],
|
||||
[{"hash": "abc123", "weight": 0.8}],
|
||||
[{"modelVersionId": 12345, "strength": 1.0}],
|
||||
[{"hash": "abc123", "exclude": True, "strength": 1.0}],
|
||||
[{"hash": "", "strength": 1.0}],
|
||||
[{"strength": 1.0}],
|
||||
]
|
||||
|
||||
results = [calculate_recipe_fingerprint(loras) for loras in test_cases]
|
||||
assert results == snapshot
|
||||
|
||||
|
||||
class TestModelLibraryHandlerSnapshots:
|
||||
"""Snapshot tests for ModelLibraryHandler responses."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_model_exists_empty_response(self, snapshot: SnapshotAssertion):
|
||||
"""Verify check_model_exists with no versions response format."""
|
||||
|
||||
class EmptyVersionScanner:
|
||||
async def check_model_version_exists(self, _version_id):
|
||||
return False
|
||||
|
||||
async def get_model_versions_by_id(self, _model_id):
|
||||
return []
|
||||
|
||||
async def scanner_factory():
|
||||
return EmptyVersionScanner()
|
||||
|
||||
handler = ModelLibraryHandler(
|
||||
ServiceRegistryAdapter(
|
||||
get_lora_scanner=scanner_factory,
|
||||
get_checkpoint_scanner=scanner_factory,
|
||||
get_embedding_scanner=scanner_factory,
|
||||
),
|
||||
metadata_provider_factory=lambda: None,
|
||||
)
|
||||
|
||||
response = await handler.check_model_exists(FakeRequest(query={"modelId": "1"}))
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload == snapshot
|
||||
@@ -44,9 +44,6 @@ class DummySettings:
|
||||
def delete(self, key):
|
||||
self.data.pop(key, None)
|
||||
|
||||
def keys(self):
|
||||
return self.data.keys()
|
||||
|
||||
|
||||
class DummyDownloader:
|
||||
def __init__(self):
|
||||
@@ -65,14 +62,8 @@ async def dummy_downloader_factory():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_settings_excludes_no_sync_keys():
|
||||
"""Verify that settings in _NO_SYNC_KEYS are not synced, but others are."""
|
||||
settings_service = DummySettings({
|
||||
"civitai_api_key": "abc",
|
||||
"hash_chunk_size_mb": 10,
|
||||
"folder_paths": {"/some/path"},
|
||||
"regular_setting": "value",
|
||||
})
|
||||
async def test_get_settings_filters_sync_keys():
|
||||
settings_service = DummySettings({"civitai_api_key": "abc", "extraneous": "value"})
|
||||
handler = SettingsHandler(
|
||||
settings_service=settings_service,
|
||||
metadata_provider_updater=noop_async,
|
||||
@@ -83,12 +74,7 @@ async def test_get_settings_excludes_no_sync_keys():
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload["success"] is True
|
||||
# Regular settings should be synced
|
||||
assert payload["settings"]["civitai_api_key"] == "abc"
|
||||
assert payload["settings"]["regular_setting"] == "value"
|
||||
# _NO_SYNC_KEYS should not be synced
|
||||
assert "hash_chunk_size_mb" not in payload["settings"]
|
||||
assert "folder_paths" not in payload["settings"]
|
||||
assert payload["settings"] == {"civitai_api_key": "abc"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -66,7 +66,6 @@ async def test_build_version_context_includes_static_urls():
|
||||
service=service,
|
||||
update_service=SimpleNamespace(),
|
||||
metadata_provider_selector=lambda *_: None,
|
||||
settings_service=SimpleNamespace(get=lambda *_: False),
|
||||
logger=logging.getLogger(__name__),
|
||||
)
|
||||
|
||||
@@ -146,7 +145,6 @@ async def test_refresh_model_updates_filters_records_without_updates():
|
||||
service=service,
|
||||
update_service=update_service,
|
||||
metadata_provider_selector=metadata_selector,
|
||||
settings_service=SimpleNamespace(get=lambda *_: False),
|
||||
logger=logging.getLogger(__name__),
|
||||
)
|
||||
|
||||
@@ -209,7 +207,6 @@ async def test_refresh_model_updates_with_target_ids():
|
||||
service=service,
|
||||
update_service=update_service,
|
||||
metadata_provider_selector=metadata_selector,
|
||||
settings_service=SimpleNamespace(get=lambda *_: False),
|
||||
logger=logging.getLogger(__name__),
|
||||
)
|
||||
|
||||
@@ -261,7 +258,6 @@ async def test_refresh_model_updates_accepts_snake_case_ids():
|
||||
service=service,
|
||||
update_service=update_service,
|
||||
metadata_provider_selector=metadata_selector,
|
||||
settings_service=SimpleNamespace(get=lambda *_: False),
|
||||
logger=logging.getLogger(__name__),
|
||||
)
|
||||
|
||||
@@ -341,7 +337,6 @@ async def test_fetch_missing_license_data_updates_metadata(monkeypatch):
|
||||
service=DummyService(cache),
|
||||
update_service=SimpleNamespace(),
|
||||
metadata_provider_selector=metadata_selector,
|
||||
settings_service=SimpleNamespace(get=lambda *_: False),
|
||||
logger=logging.getLogger(__name__),
|
||||
)
|
||||
|
||||
@@ -428,7 +423,6 @@ async def test_fetch_missing_license_data_filters_model_ids(monkeypatch):
|
||||
service=DummyService(cache),
|
||||
update_service=SimpleNamespace(),
|
||||
metadata_provider_selector=metadata_selector,
|
||||
settings_service=SimpleNamespace(get=lambda *_: False),
|
||||
logger=logging.getLogger(__name__),
|
||||
)
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class StubUpdateService:
|
||||
self.bulk_calls = []
|
||||
self.bulk_error = bulk_error
|
||||
|
||||
async def has_updates_bulk(self, model_type, model_ids, hide_early_access: bool = False):
|
||||
async def has_updates_bulk(self, model_type, model_ids):
|
||||
self.bulk_calls.append((model_type, list(model_ids)))
|
||||
if self.bulk_error:
|
||||
raise RuntimeError("bulk failure")
|
||||
@@ -91,7 +91,7 @@ class StubUpdateService:
|
||||
results[model_id] = result
|
||||
return results
|
||||
|
||||
async def has_update(self, model_type, model_id, hide_early_access: bool = False):
|
||||
async def has_update(self, model_type, model_id):
|
||||
self.calls.append((model_type, model_id))
|
||||
result = self.decisions.get(model_id, False)
|
||||
if isinstance(result, Exception):
|
||||
|
||||
1402
tests/services/test_download_manager.py
Normal file
1402
tests/services/test_download_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,445 +0,0 @@
|
||||
"""Core functionality tests for DownloadManager."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.download_manager import DownloadManager
|
||||
from py.services import download_manager
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.services.settings_manager import SettingsManager, get_settings_manager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_download_manager():
|
||||
"""Ensure each test operates on a fresh singleton."""
|
||||
DownloadManager._instance = None
|
||||
yield
|
||||
DownloadManager._instance = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_settings(monkeypatch, tmp_path):
|
||||
"""Point settings writes at a temporary directory to avoid touching real files."""
|
||||
manager = get_settings_manager()
|
||||
default_settings = manager._get_default_settings()
|
||||
default_settings.update(
|
||||
{
|
||||
"default_lora_root": str(tmp_path),
|
||||
"default_checkpoint_root": str(tmp_path / "checkpoints"),
|
||||
"default_embedding_root": str(tmp_path / "embeddings"),
|
||||
"download_path_templates": {
|
||||
"lora": "{base_model}/{first_tag}",
|
||||
"checkpoint": "{base_model}/{first_tag}",
|
||||
"embedding": "{base_model}/{first_tag}",
|
||||
},
|
||||
"base_model_path_mappings": {"BaseModel": "MappedModel"},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(manager, "settings", default_settings)
|
||||
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def stub_metadata(monkeypatch):
|
||||
class _StubMetadata:
|
||||
def __init__(self, save_path: str):
|
||||
self.file_path = save_path
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = Path(save_path).stem
|
||||
|
||||
def _factory(save_path: str):
|
||||
return _StubMetadata(save_path)
|
||||
|
||||
def _make_class():
|
||||
@staticmethod
|
||||
def from_civitai_info(_version_info, _file_info, save_path):
|
||||
return _factory(save_path)
|
||||
|
||||
return type("StubMetadata", (), {"from_civitai_info": from_civitai_info})
|
||||
|
||||
stub_class = _make_class()
|
||||
monkeypatch.setattr(download_manager, "LoraMetadata", stub_class)
|
||||
monkeypatch.setattr(download_manager, "CheckpointMetadata", stub_class)
|
||||
monkeypatch.setattr(download_manager, "EmbeddingMetadata", stub_class)
|
||||
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self, exists: bool = False):
|
||||
self.exists = exists
|
||||
self.calls = []
|
||||
|
||||
async def check_model_version_exists(self, version_id):
|
||||
self.calls.append(version_id)
|
||||
return self.exists
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scanners(monkeypatch):
|
||||
lora_scanner = DummyScanner()
|
||||
checkpoint_scanner = DummyScanner()
|
||||
embedding_scanner = DummyScanner()
|
||||
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=lora_scanner)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry,
|
||||
"get_checkpoint_scanner",
|
||||
AsyncMock(return_value=checkpoint_scanner),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry,
|
||||
"get_embedding_scanner",
|
||||
AsyncMock(return_value=embedding_scanner),
|
||||
)
|
||||
|
||||
return SimpleNamespace(
|
||||
lora=lora_scanner,
|
||||
checkpoint=checkpoint_scanner,
|
||||
embedding=embedding_scanner,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metadata_provider(monkeypatch):
|
||||
class DummyProvider:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def get_model_version(self, model_id, model_version_id):
|
||||
self.calls.append((model_id, model_version_id))
|
||||
return {
|
||||
"id": 42,
|
||||
"model": {"type": "LoRA", "tags": ["fantasy"]},
|
||||
"baseModel": "BaseModel",
|
||||
"creator": {"username": "Author"},
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"downloadUrl": "https://example.invalid/file.safetensors",
|
||||
"name": "file.safetensors",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
provider = DummyProvider()
|
||||
monkeypatch.setattr(
|
||||
download_manager,
|
||||
"get_default_metadata_provider",
|
||||
AsyncMock(return_value=provider),
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def noop_cleanup(monkeypatch):
|
||||
async def _cleanup(self, task_id):
|
||||
if task_id in self._active_downloads:
|
||||
self._active_downloads[task_id]["cleaned"] = True
|
||||
|
||||
monkeypatch.setattr(DownloadManager, "_cleanup_download_record", _cleanup)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_requires_identifier():
|
||||
"""Test that download fails when no identifier is provided."""
|
||||
manager = DownloadManager()
|
||||
result = await manager.download_from_civitai()
|
||||
assert result == {
|
||||
"success": False,
|
||||
"error": "Either model_id or model_version_id must be provided",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_download_uses_defaults(
|
||||
monkeypatch, scanners, metadata_provider, tmp_path
|
||||
):
|
||||
"""Test successful download with default settings."""
|
||||
manager = DownloadManager()
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_execute_download(
|
||||
self,
|
||||
*,
|
||||
download_urls,
|
||||
save_dir,
|
||||
metadata,
|
||||
version_info,
|
||||
relative_path,
|
||||
progress_callback,
|
||||
model_type,
|
||||
download_id,
|
||||
):
|
||||
captured.update(
|
||||
{
|
||||
"download_urls": download_urls,
|
||||
"save_dir": Path(save_dir),
|
||||
"relative_path": relative_path,
|
||||
"progress_callback": progress_callback,
|
||||
"model_type": model_type,
|
||||
"download_id": download_id,
|
||||
"metadata_path": metadata.file_path,
|
||||
}
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", fake_execute_download, raising=False
|
||||
)
|
||||
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=99,
|
||||
save_dir=str(tmp_path),
|
||||
use_default_paths=True,
|
||||
progress_callback=None,
|
||||
source=None,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "download_id" in result
|
||||
assert manager._download_tasks == {}
|
||||
assert manager._active_downloads[result["download_id"]]["status"] == "completed"
|
||||
|
||||
assert captured["relative_path"] == "MappedModel/fantasy"
|
||||
expected_dir = (
|
||||
Path(get_settings_manager().get("default_lora_root"))
|
||||
/ "MappedModel"
|
||||
/ "fantasy"
|
||||
)
|
||||
assert captured["save_dir"] == expected_dir
|
||||
assert captured["model_type"] == "lora"
|
||||
assert captured["download_urls"] == ["https://example.invalid/file.safetensors"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_uses_active_mirrors(
|
||||
monkeypatch, scanners, metadata_provider, tmp_path
|
||||
):
|
||||
"""Test that active mirrors are used when available."""
|
||||
manager = DownloadManager()
|
||||
|
||||
metadata_with_mirrors = {
|
||||
"id": 42,
|
||||
"model": {"type": "LoRA", "tags": ["fantasy"]},
|
||||
"baseModel": "BaseModel",
|
||||
"creator": {"username": "Author"},
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"downloadUrl": "https://example.invalid/file.safetensors",
|
||||
"mirrors": [
|
||||
{
|
||||
"url": "https://mirror.example/file.safetensors",
|
||||
"deletedAt": None,
|
||||
},
|
||||
{
|
||||
"url": "https://mirror.example/old.safetensors",
|
||||
"deletedAt": "2024-01-01",
|
||||
},
|
||||
],
|
||||
"name": "file.safetensors",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
metadata_provider.get_model_version = AsyncMock(return_value=metadata_with_mirrors)
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_execute_download(
|
||||
self,
|
||||
*,
|
||||
download_urls,
|
||||
save_dir,
|
||||
metadata,
|
||||
version_info,
|
||||
relative_path,
|
||||
progress_callback,
|
||||
model_type,
|
||||
download_id,
|
||||
):
|
||||
captured["download_urls"] = download_urls
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", fake_execute_download, raising=False
|
||||
)
|
||||
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=99,
|
||||
save_dir=str(tmp_path),
|
||||
use_default_paths=True,
|
||||
progress_callback=None,
|
||||
source=None,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["download_urls"] == ["https://mirror.example/file.safetensors"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_aborts_when_version_exists(
|
||||
monkeypatch, scanners, metadata_provider
|
||||
):
|
||||
"""Test that download aborts when version already exists."""
|
||||
scanners.lora.exists = True
|
||||
|
||||
manager = DownloadManager()
|
||||
|
||||
execute_mock = AsyncMock(return_value={"success": True})
|
||||
monkeypatch.setattr(DownloadManager, "_execute_download", execute_mock)
|
||||
|
||||
result = await manager.download_from_civitai(model_version_id=101, save_dir="/tmp")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Model version already exists in lora library"
|
||||
assert "download_id" in result
|
||||
assert execute_mock.await_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_handles_metadata_errors(monkeypatch, scanners):
|
||||
"""Test that download handles metadata fetch failures gracefully."""
|
||||
async def failing_provider(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager,
|
||||
"get_default_metadata_provider",
|
||||
AsyncMock(
|
||||
return_value=SimpleNamespace(get_model_version=AsyncMock(return_value=None))
|
||||
),
|
||||
)
|
||||
|
||||
manager = DownloadManager()
|
||||
|
||||
result = await manager.download_from_civitai(model_version_id=5, save_dir="/tmp")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Failed to fetch model metadata"
|
||||
assert "download_id" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_rejects_unsupported_model_type(monkeypatch, scanners):
|
||||
"""Test that unsupported model types are rejected."""
|
||||
class Provider:
|
||||
async def get_model_version(self, *_args, **_kwargs):
|
||||
return {
|
||||
"model": {"type": "Unsupported", "tags": []},
|
||||
"files": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager,
|
||||
"get_default_metadata_provider",
|
||||
AsyncMock(return_value=Provider()),
|
||||
)
|
||||
|
||||
manager = DownloadManager()
|
||||
|
||||
result = await manager.download_from_civitai(model_version_id=5, save_dir="/tmp")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"].startswith("Model type")
|
||||
|
||||
|
||||
def test_embedding_relative_path_replaces_spaces():
|
||||
"""Test that embedding paths replace spaces with underscores."""
|
||||
manager = DownloadManager()
|
||||
|
||||
version_info = {
|
||||
"baseModel": "Base Model",
|
||||
"model": {"tags": ["tag with space"]},
|
||||
"creator": {"username": "Author Name"},
|
||||
}
|
||||
|
||||
relative_path = manager._calculate_relative_path(version_info, "embedding")
|
||||
|
||||
assert relative_path == "Base_Model/tag_with_space"
|
||||
|
||||
|
||||
def test_relative_path_supports_model_and_version_placeholders():
|
||||
"""Test that relative path supports {model_name} and {version_name} placeholders."""
|
||||
manager = DownloadManager()
|
||||
settings_manager = get_settings_manager()
|
||||
settings_manager.settings["download_path_templates"]["lora"] = (
|
||||
"{model_name}/{version_name}"
|
||||
)
|
||||
|
||||
version_info = {
|
||||
"baseModel": "BaseModel",
|
||||
"name": "Version One",
|
||||
"model": {"name": "Fancy Model", "tags": []},
|
||||
}
|
||||
|
||||
relative_path = manager._calculate_relative_path(version_info, "lora")
|
||||
|
||||
assert relative_path == "Fancy Model/Version One"
|
||||
|
||||
|
||||
def test_relative_path_sanitizes_model_and_version_placeholders():
|
||||
"""Test that relative path sanitizes special characters in placeholders."""
|
||||
manager = DownloadManager()
|
||||
settings_manager = get_settings_manager()
|
||||
settings_manager.settings["download_path_templates"]["lora"] = (
|
||||
"{model_name}/{version_name}"
|
||||
)
|
||||
|
||||
version_info = {
|
||||
"baseModel": "BaseModel",
|
||||
"name": "Version:One?",
|
||||
"model": {"name": "Fancy:Model*", "tags": []},
|
||||
}
|
||||
|
||||
relative_path = manager._calculate_relative_path(version_info, "lora")
|
||||
|
||||
assert relative_path == "Fancy_Model/Version_One"
|
||||
|
||||
|
||||
def test_distribute_preview_to_entries_moves_and_copies(tmp_path):
|
||||
"""Test that preview distribution moves file to first entry and copies to others."""
|
||||
manager = DownloadManager()
|
||||
preview_file = tmp_path / "bundle.webp"
|
||||
preview_file.write_bytes(b"image-data")
|
||||
|
||||
entries = [
|
||||
SimpleNamespace(file_path=str(tmp_path / "model-one.safetensors")),
|
||||
SimpleNamespace(file_path=str(tmp_path / "model-two.safetensors")),
|
||||
]
|
||||
|
||||
targets = manager._distribute_preview_to_entries(str(preview_file), entries)
|
||||
|
||||
assert targets == [
|
||||
str(tmp_path / "model-one.webp"),
|
||||
str(tmp_path / "model-two.webp"),
|
||||
]
|
||||
assert not preview_file.exists()
|
||||
assert Path(targets[0]).read_bytes() == b"image-data"
|
||||
assert Path(targets[1]).read_bytes() == b"image-data"
|
||||
|
||||
|
||||
def test_distribute_preview_to_entries_keeps_existing_file(tmp_path):
|
||||
"""Test that existing preview files are not overwritten."""
|
||||
manager = DownloadManager()
|
||||
existing_preview = tmp_path / "model-one.webp"
|
||||
existing_preview.write_bytes(b"preview")
|
||||
|
||||
entries = [
|
||||
SimpleNamespace(file_path=str(tmp_path / "model-one.safetensors")),
|
||||
SimpleNamespace(file_path=str(tmp_path / "model-two.safetensors")),
|
||||
]
|
||||
|
||||
targets = manager._distribute_preview_to_entries(str(existing_preview), entries)
|
||||
|
||||
assert targets[0] == str(existing_preview)
|
||||
assert Path(targets[1]).read_bytes() == b"preview"
|
||||
@@ -1,590 +0,0 @@
|
||||
"""Concurrent operations and advanced scenarios tests for DownloadManager."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.download_manager import DownloadManager
|
||||
from py.services import download_manager
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.services.settings_manager import SettingsManager, get_settings_manager
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_download_manager():
|
||||
"""Ensure each test operates on a fresh singleton."""
|
||||
DownloadManager._instance = None
|
||||
yield
|
||||
DownloadManager._instance = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_settings(monkeypatch, tmp_path):
|
||||
"""Point settings writes at a temporary directory to avoid touching real files."""
|
||||
manager = get_settings_manager()
|
||||
default_settings = manager._get_default_settings()
|
||||
default_settings.update(
|
||||
{
|
||||
"default_lora_root": str(tmp_path),
|
||||
"default_checkpoint_root": str(tmp_path / "checkpoints"),
|
||||
"default_embedding_root": str(tmp_path / "embeddings"),
|
||||
"download_path_templates": {
|
||||
"lora": "{base_model}/{first_tag}",
|
||||
"checkpoint": "{base_model}/{first_tag}",
|
||||
"embedding": "{base_model}/{first_tag}",
|
||||
},
|
||||
"base_model_path_mappings": {"BaseModel": "MappedModel"},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(manager, "settings", default_settings)
|
||||
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
|
||||
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self, exists: bool = False):
|
||||
self.exists = exists
|
||||
self.calls = []
|
||||
|
||||
async def check_model_version_exists(self, version_id):
|
||||
self.calls.append(version_id)
|
||||
return self.exists
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scanners(monkeypatch):
|
||||
lora_scanner = DummyScanner()
|
||||
checkpoint_scanner = DummyScanner()
|
||||
embedding_scanner = DummyScanner()
|
||||
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=lora_scanner)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry,
|
||||
"get_checkpoint_scanner",
|
||||
AsyncMock(return_value=checkpoint_scanner),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry,
|
||||
"get_embedding_scanner",
|
||||
AsyncMock(return_value=embedding_scanner),
|
||||
)
|
||||
|
||||
return SimpleNamespace(
|
||||
lora=lora_scanner,
|
||||
checkpoint=checkpoint_scanner,
|
||||
embedding=embedding_scanner,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_download_uses_rewritten_civitai_preview(monkeypatch, tmp_path):
|
||||
"""Test that CivitAI preview URLs are rewritten for optimization."""
|
||||
manager = DownloadManager()
|
||||
save_dir = tmp_path / "downloads"
|
||||
save_dir.mkdir()
|
||||
target_path = save_dir / "file.safetensors"
|
||||
|
||||
manager._active_downloads["dl"] = {}
|
||||
|
||||
class DummyMetadata:
|
||||
def __init__(self, path: Path):
|
||||
self.file_path = str(path)
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.preview_nsfw_level = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
|
||||
def update_file_info(self, _path):
|
||||
return None
|
||||
|
||||
def to_dict(self):
|
||||
return {"file_path": self.file_path}
|
||||
|
||||
metadata = DummyMetadata(target_path)
|
||||
version_info = {
|
||||
"images": [
|
||||
{
|
||||
"url": "https://image.civitai.com/container/example/original=true/sample.jpeg",
|
||||
"type": "image",
|
||||
"nsfwLevel": 2,
|
||||
}
|
||||
]
|
||||
}
|
||||
download_urls = ["https://example.invalid/file.safetensors"]
|
||||
|
||||
class DummyDownloader:
|
||||
def __init__(self):
|
||||
self.file_calls: list[tuple[str, str]] = []
|
||||
self.memory_calls = 0
|
||||
|
||||
async def download_file(self, url, path, progress_callback=None, use_auth=None):
|
||||
self.file_calls.append((url, path))
|
||||
if url.endswith(".jpeg"):
|
||||
Path(path).write_bytes(b"preview")
|
||||
return True, None
|
||||
if url.endswith(".safetensors"):
|
||||
Path(path).write_bytes(b"model")
|
||||
return True, None
|
||||
return False, "unexpected url"
|
||||
|
||||
async def download_to_memory(self, *_args, **_kwargs):
|
||||
self.memory_calls += 1
|
||||
return False, b"", {}
|
||||
|
||||
dummy_downloader = DummyDownloader()
|
||||
monkeypatch.setattr(
|
||||
download_manager, "get_downloader", AsyncMock(return_value=dummy_downloader)
|
||||
)
|
||||
|
||||
optimize_called = {"value": False}
|
||||
|
||||
def fake_optimize_image(**_kwargs):
|
||||
optimize_called["value"] = True
|
||||
return b"", {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager.ExifUtils, "optimize_image", staticmethod(fake_optimize_image)
|
||||
)
|
||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
||||
|
||||
dummy_scanner = SimpleNamespace(add_model_to_cache=AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
|
||||
)
|
||||
|
||||
result = await manager._execute_download(
|
||||
download_urls=download_urls,
|
||||
save_dir=str(save_dir),
|
||||
metadata=metadata,
|
||||
version_info=version_info,
|
||||
relative_path="",
|
||||
progress_callback=None,
|
||||
model_type="lora",
|
||||
download_id="dl",
|
||||
)
|
||||
|
||||
assert result == {"success": True}
|
||||
preview_urls = [
|
||||
url for url, _ in dummy_downloader.file_calls if url.endswith(".jpeg")
|
||||
]
|
||||
assert any("width=450,optimized=true" in url for url in preview_urls)
|
||||
assert dummy_downloader.memory_calls == 0
|
||||
assert optimize_called["value"] is False
|
||||
assert metadata.preview_url.endswith(".jpeg")
|
||||
assert metadata.preview_nsfw_level == 2
|
||||
stored_preview = manager._active_downloads["dl"]["preview_path"]
|
||||
assert stored_preview.endswith(".jpeg")
|
||||
assert Path(stored_preview).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_download_respects_blur_setting(monkeypatch, tmp_path):
|
||||
"""Test that blur setting filters NSFW images."""
|
||||
manager = DownloadManager()
|
||||
save_dir = tmp_path / "downloads"
|
||||
save_dir.mkdir()
|
||||
target_path = save_dir / "file.safetensors"
|
||||
|
||||
manager._active_downloads["dl"] = {}
|
||||
|
||||
class DummyMetadata:
|
||||
def __init__(self, path: Path):
|
||||
self.file_path = str(path)
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.preview_nsfw_level = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
|
||||
def update_file_info(self, _path):
|
||||
return None
|
||||
|
||||
def to_dict(self):
|
||||
return {"file_path": self.file_path}
|
||||
|
||||
metadata = DummyMetadata(target_path)
|
||||
version_info = {
|
||||
"images": [
|
||||
{
|
||||
"url": "https://image.civitai.com/container/example/original=true/nsfw.jpeg",
|
||||
"type": "image",
|
||||
"nsfwLevel": 8,
|
||||
},
|
||||
{
|
||||
"url": "https://image.civitai.com/container/example/original=true/safe.jpeg",
|
||||
"type": "image",
|
||||
"nsfwLevel": 1,
|
||||
},
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"downloadUrl": "https://example.invalid/file.safetensors",
|
||||
"name": "file.safetensors",
|
||||
}
|
||||
],
|
||||
}
|
||||
download_urls = ["https://example.invalid/file.safetensors"]
|
||||
|
||||
class DummyDownloader:
|
||||
def __init__(self):
|
||||
self.file_calls: list[tuple[str, str]] = []
|
||||
|
||||
async def download_file(self, url, path, progress_callback=None, use_auth=None):
|
||||
self.file_calls.append((url, path))
|
||||
if url.endswith(".safetensors"):
|
||||
Path(path).write_bytes(b"model")
|
||||
return True, None
|
||||
if "safe.jpeg" in url:
|
||||
Path(path).write_bytes(b"preview")
|
||||
return True, None
|
||||
return False, "unexpected url"
|
||||
|
||||
async def download_to_memory(self, *_args, **_kwargs):
|
||||
return False, b"", {}
|
||||
|
||||
dummy_downloader = DummyDownloader()
|
||||
|
||||
class StubSettingsManager:
|
||||
def __init__(self, blur: bool) -> None:
|
||||
self.blur = blur
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
if key == "blur_mature_content":
|
||||
return self.blur
|
||||
return default
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager,
|
||||
"get_settings_manager",
|
||||
lambda: StubSettingsManager(True),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager, "get_downloader", AsyncMock(return_value=dummy_downloader)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
download_manager.ExifUtils,
|
||||
"optimize_image",
|
||||
staticmethod(lambda **_kwargs: (b"", {})),
|
||||
)
|
||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
||||
|
||||
dummy_scanner = SimpleNamespace(add_model_to_cache=AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
|
||||
)
|
||||
|
||||
result = await manager._execute_download(
|
||||
download_urls=download_urls,
|
||||
save_dir=str(save_dir),
|
||||
metadata=metadata,
|
||||
version_info=version_info,
|
||||
relative_path="",
|
||||
progress_callback=None,
|
||||
model_type="lora",
|
||||
download_id="dl",
|
||||
)
|
||||
|
||||
assert result == {"success": True}
|
||||
preview_urls = [
|
||||
url for url, _ in dummy_downloader.file_calls if url.endswith(".jpeg")
|
||||
]
|
||||
assert preview_urls
|
||||
assert all("nsfw.jpeg" not in url for url in preview_urls)
|
||||
assert any("safe.jpeg" in url for url in preview_urls)
|
||||
assert metadata.preview_nsfw_level == 1
|
||||
stored_preview = manager._active_downloads["dl"].get("preview_path")
|
||||
assert stored_preview and stored_preview.endswith(".jpeg")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_civarchive_source_uses_civarchive_provider(
|
||||
monkeypatch, scanners, tmp_path
|
||||
):
|
||||
"""Test that civarchive source uses CivArchive provider."""
|
||||
manager = DownloadManager()
|
||||
|
||||
captured_providers = []
|
||||
|
||||
class CivArchiveProvider:
|
||||
async def get_model_version(self, model_id, model_version_id):
|
||||
captured_providers.append("civarchive")
|
||||
return {
|
||||
"id": 119514,
|
||||
"model": {"type": "LoRA", "tags": ["celebrity"]},
|
||||
"baseModel": "SD 1.5",
|
||||
"creator": {"username": "dogu_cat"},
|
||||
"source": "civarchive",
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"mirrors": [
|
||||
{
|
||||
"url": "https://huggingface.co/file.safetensors",
|
||||
"deletedAt": None,
|
||||
},
|
||||
{
|
||||
"url": "https://civitai.com/api/download/models/119514",
|
||||
"deletedAt": "2025-05-23T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
"name": "file.safetensors",
|
||||
"hashes": {"SHA256": "abc123"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
class DefaultProvider:
|
||||
async def get_model_version(self, model_id, model_version_id):
|
||||
captured_providers.append("default")
|
||||
return {
|
||||
"id": 119514,
|
||||
"model": {"type": "LoRA", "tags": ["celebrity"]},
|
||||
"baseModel": "SD 1.5",
|
||||
"creator": {"username": "dogu_cat"},
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"downloadUrl": "https://civitai.com/api/download/models/119514",
|
||||
"name": "file.safetensors",
|
||||
"hashes": {"SHA256": "abc123"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def get_metadata_provider(provider_name):
|
||||
if provider_name == "civarchive_api":
|
||||
return CivArchiveProvider()
|
||||
return None
|
||||
|
||||
async def get_default_metadata_provider():
|
||||
return DefaultProvider()
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager, "get_metadata_provider", get_metadata_provider
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
download_manager, "get_default_metadata_provider", get_default_metadata_provider
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_execute_download(
|
||||
self,
|
||||
*,
|
||||
download_urls,
|
||||
save_dir,
|
||||
metadata,
|
||||
version_info,
|
||||
relative_path,
|
||||
progress_callback,
|
||||
model_type,
|
||||
download_id,
|
||||
):
|
||||
captured["download_urls"] = download_urls
|
||||
captured["version_info"] = version_info
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", fake_execute_download, raising=False
|
||||
)
|
||||
|
||||
result = await manager.download_from_civitai(
|
||||
model_id=110828,
|
||||
model_version_id=119514,
|
||||
save_dir=str(tmp_path),
|
||||
use_default_paths=True,
|
||||
progress_callback=None,
|
||||
source="civarchive",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured_providers == ["civarchive"]
|
||||
assert captured["version_info"]["source"] == "civarchive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_civarchive_source_prioritizes_non_civitai_urls(
|
||||
monkeypatch, scanners, tmp_path
|
||||
):
|
||||
"""Test that civarchive source prioritizes non-CivitAI URLs."""
|
||||
manager = DownloadManager()
|
||||
|
||||
class CivArchiveProvider:
|
||||
async def get_model_version(self, model_id, model_version_id):
|
||||
return {
|
||||
"id": 119514,
|
||||
"model": {"type": "LoRA", "tags": ["celebrity"]},
|
||||
"baseModel": "SD 1.5",
|
||||
"creator": {"username": "dogu_cat"},
|
||||
"source": "civarchive",
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"mirrors": [
|
||||
{
|
||||
"url": "https://huggingface.co/file.safetensors",
|
||||
"deletedAt": None,
|
||||
"source": "huggingface",
|
||||
},
|
||||
{
|
||||
"url": "https://civitai.com/api/download/models/119514",
|
||||
"deletedAt": None,
|
||||
"source": "civitai",
|
||||
},
|
||||
{
|
||||
"url": "https://another-mirror.org/file.safetensors",
|
||||
"deletedAt": None,
|
||||
"source": "other",
|
||||
},
|
||||
],
|
||||
"name": "file.safetensors",
|
||||
"hashes": {"SHA256": "abc123"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def get_metadata_provider(provider_name):
|
||||
if provider_name == "civarchive_api":
|
||||
return CivArchiveProvider()
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager, "get_metadata_provider", get_metadata_provider
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_execute_download(
|
||||
self,
|
||||
*,
|
||||
download_urls,
|
||||
save_dir,
|
||||
metadata,
|
||||
version_info,
|
||||
relative_path,
|
||||
progress_callback,
|
||||
model_type,
|
||||
download_id,
|
||||
):
|
||||
captured["download_urls"] = download_urls
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", fake_execute_download, raising=False
|
||||
)
|
||||
|
||||
result = await manager.download_from_civitai(
|
||||
model_id=110828,
|
||||
model_version_id=119514,
|
||||
save_dir=str(tmp_path),
|
||||
use_default_paths=True,
|
||||
progress_callback=None,
|
||||
source="civarchive",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["download_urls"] == [
|
||||
"https://huggingface.co/file.safetensors",
|
||||
"https://another-mirror.org/file.safetensors",
|
||||
"https://civitai.com/api/download/models/119514",
|
||||
]
|
||||
assert captured["download_urls"][0] == "https://huggingface.co/file.safetensors"
|
||||
assert captured["download_urls"][1] == "https://another-mirror.org/file.safetensors"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_civarchive_source_fallback_to_default_provider(
|
||||
monkeypatch, scanners, tmp_path
|
||||
):
|
||||
"""Test fallback to default provider when civarchive provider fails."""
|
||||
manager = DownloadManager()
|
||||
|
||||
class CivArchiveProvider:
|
||||
async def get_model_version(self, model_id, model_version_id):
|
||||
return None
|
||||
|
||||
class DefaultProvider:
|
||||
async def get_model_version(self, model_id, model_version_id):
|
||||
return {
|
||||
"id": 119514,
|
||||
"model": {"type": "LoRA", "tags": ["celebrity"]},
|
||||
"baseModel": "SD 1.5",
|
||||
"creator": {"username": "dogu_cat"},
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"downloadUrl": "https://civitai.com/api/download/models/119514",
|
||||
"name": "file.safetensors",
|
||||
"hashes": {"SHA256": "abc123"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
captured_providers = []
|
||||
|
||||
async def get_metadata_provider(provider_name):
|
||||
if provider_name == "civarchive_api":
|
||||
captured_providers.append("civarchive_api")
|
||||
return CivArchiveProvider()
|
||||
return None
|
||||
|
||||
async def get_default_metadata_provider():
|
||||
captured_providers.append("default")
|
||||
return DefaultProvider()
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager, "get_metadata_provider", get_metadata_provider
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
download_manager, "get_default_metadata_provider", get_default_metadata_provider
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_execute_download(
|
||||
self,
|
||||
*,
|
||||
download_urls,
|
||||
save_dir,
|
||||
metadata,
|
||||
version_info,
|
||||
relative_path,
|
||||
progress_callback,
|
||||
model_type,
|
||||
download_id,
|
||||
):
|
||||
captured["download_urls"] = download_urls
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", fake_execute_download, raising=False
|
||||
)
|
||||
|
||||
result = await manager.download_from_civitai(
|
||||
model_id=110828,
|
||||
model_version_id=119514,
|
||||
save_dir=str(tmp_path),
|
||||
use_default_paths=True,
|
||||
progress_callback=None,
|
||||
source="civarchive",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured_providers == ["civarchive_api", "default"]
|
||||
@@ -1,543 +0,0 @@
|
||||
"""Error handling and execution tests for DownloadManager."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.download_manager import DownloadManager
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
from py.services import download_manager
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.services.settings_manager import SettingsManager, get_settings_manager
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_download_manager():
|
||||
"""Ensure each test operates on a fresh singleton."""
|
||||
DownloadManager._instance = None
|
||||
yield
|
||||
DownloadManager._instance = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_settings(monkeypatch, tmp_path):
|
||||
"""Point settings writes at a temporary directory to avoid touching real files."""
|
||||
manager = get_settings_manager()
|
||||
default_settings = manager._get_default_settings()
|
||||
default_settings.update(
|
||||
{
|
||||
"default_lora_root": str(tmp_path),
|
||||
"default_checkpoint_root": str(tmp_path / "checkpoints"),
|
||||
"default_embedding_root": str(tmp_path / "embeddings"),
|
||||
"download_path_templates": {
|
||||
"lora": "{base_model}/{first_tag}",
|
||||
"checkpoint": "{base_model}/{first_tag}",
|
||||
"embedding": "{base_model}/{first_tag}",
|
||||
},
|
||||
"base_model_path_mappings": {"BaseModel": "MappedModel"},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(manager, "settings", default_settings)
|
||||
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_download_retries_urls(monkeypatch, tmp_path):
|
||||
"""Test that download retries multiple URLs on failure."""
|
||||
manager = DownloadManager()
|
||||
|
||||
save_dir = tmp_path / "downloads"
|
||||
save_dir.mkdir()
|
||||
initial_path = save_dir / "file.safetensors"
|
||||
|
||||
class DummyMetadata:
|
||||
def __init__(self, path: Path):
|
||||
self.file_path = str(path)
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
|
||||
def update_file_info(self, _path):
|
||||
return None
|
||||
|
||||
def to_dict(self):
|
||||
return {"file_path": self.file_path}
|
||||
|
||||
metadata = DummyMetadata(initial_path)
|
||||
version_info = {"images": []}
|
||||
download_urls = [
|
||||
"https://first.example/file.safetensors",
|
||||
"https://second.example/file.safetensors",
|
||||
]
|
||||
|
||||
class DummyDownloader:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def download_file(self, url, path, progress_callback=None, use_auth=None):
|
||||
self.calls.append((url, path, use_auth))
|
||||
if len(self.calls) == 1:
|
||||
return False, "first failed"
|
||||
# Create the target file to simulate a successful download
|
||||
Path(path).write_text("content")
|
||||
return True, "second success"
|
||||
|
||||
dummy_downloader = DummyDownloader()
|
||||
monkeypatch.setattr(
|
||||
download_manager, "get_downloader", AsyncMock(return_value=dummy_downloader)
|
||||
)
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def add_model_to_cache(self, metadata_dict, relative_path):
|
||||
self.calls.append((metadata_dict, relative_path))
|
||||
|
||||
dummy_scanner = DummyScanner()
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
DownloadManager,
|
||||
"_get_checkpoint_scanner",
|
||||
AsyncMock(return_value=dummy_scanner),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry, "get_embedding_scanner", AsyncMock(return_value=dummy_scanner)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
||||
|
||||
result = await manager._execute_download(
|
||||
download_urls=download_urls,
|
||||
save_dir=str(save_dir),
|
||||
metadata=metadata,
|
||||
version_info=version_info,
|
||||
relative_path="",
|
||||
progress_callback=None,
|
||||
model_type="lora",
|
||||
download_id=None,
|
||||
)
|
||||
|
||||
assert result == {"success": True}
|
||||
assert [url for url, *_ in dummy_downloader.calls] == download_urls
|
||||
assert dummy_scanner.calls # ensure cache updated
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_download_adjusts_checkpoint_sub_type(monkeypatch, tmp_path):
|
||||
"""Test that checkpoint sub_type is adjusted during download."""
|
||||
manager = DownloadManager()
|
||||
|
||||
root_dir = tmp_path / "checkpoints"
|
||||
root_dir.mkdir()
|
||||
save_dir = root_dir
|
||||
target_path = save_dir / "model.safetensors"
|
||||
|
||||
class DummyMetadata:
|
||||
def __init__(self, path: Path):
|
||||
self.file_path = path.as_posix()
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.preview_nsfw_level = 0
|
||||
self.sub_type = "checkpoint"
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
|
||||
def update_file_info(self, updated_path):
|
||||
self.file_path = Path(updated_path).as_posix()
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"file_path": self.file_path,
|
||||
"sub_type": self.sub_type,
|
||||
"sha256": self.sha256,
|
||||
}
|
||||
|
||||
metadata = DummyMetadata(target_path)
|
||||
version_info = {"images": []}
|
||||
download_urls = ["https://example.invalid/model.safetensors"]
|
||||
|
||||
class DummyDownloader:
|
||||
async def download_file(
|
||||
self, _url, path, progress_callback=None, use_auth=None
|
||||
):
|
||||
Path(path).write_text("content")
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager,
|
||||
"get_downloader",
|
||||
AsyncMock(return_value=DummyDownloader()),
|
||||
)
|
||||
|
||||
class DummyCheckpointScanner:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root.as_posix()
|
||||
self.add_calls = []
|
||||
|
||||
def _find_root_for_file(self, file_path: str):
|
||||
return self.root if file_path.startswith(self.root) else None
|
||||
|
||||
def adjust_metadata(
|
||||
self, metadata_obj, _file_path: str, root_path: Optional[str]
|
||||
):
|
||||
if root_path:
|
||||
metadata_obj.sub_type = "diffusion_model"
|
||||
return metadata_obj
|
||||
|
||||
def adjust_cached_entry(self, entry):
|
||||
if entry.get("file_path", "").startswith(self.root):
|
||||
entry["sub_type"] = "diffusion_model"
|
||||
return entry
|
||||
|
||||
async def add_model_to_cache(self, metadata_dict, relative_path):
|
||||
self.add_calls.append((metadata_dict, relative_path))
|
||||
return True
|
||||
|
||||
dummy_scanner = DummyCheckpointScanner(root_dir)
|
||||
monkeypatch.setattr(
|
||||
DownloadManager,
|
||||
"_get_checkpoint_scanner",
|
||||
AsyncMock(return_value=dummy_scanner),
|
||||
)
|
||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
||||
|
||||
result = await manager._execute_download(
|
||||
download_urls=download_urls,
|
||||
save_dir=str(save_dir),
|
||||
metadata=metadata,
|
||||
version_info=version_info,
|
||||
relative_path="",
|
||||
progress_callback=None,
|
||||
model_type="checkpoint",
|
||||
download_id=None,
|
||||
)
|
||||
|
||||
assert result == {"success": True}
|
||||
assert metadata.sub_type == "diffusion_model"
|
||||
saved_metadata = MetadataManager.save_metadata.await_args.args[1]
|
||||
assert saved_metadata.sub_type == "diffusion_model"
|
||||
assert dummy_scanner.add_calls
|
||||
cached_entry, _ = dummy_scanner.add_calls[0]
|
||||
assert cached_entry["sub_type"] == "diffusion_model"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path):
|
||||
"""Test extraction of single model from ZIP file."""
|
||||
manager = DownloadManager()
|
||||
save_dir = tmp_path / "downloads"
|
||||
save_dir.mkdir()
|
||||
zip_path = save_dir / "bundle.zip"
|
||||
|
||||
class DummyMetadata:
|
||||
def __init__(self, path: Path):
|
||||
self.file_path = str(path)
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
|
||||
def update_file_info(self, updated_path):
|
||||
self.file_path = str(updated_path)
|
||||
self.file_name = Path(updated_path).stem
|
||||
|
||||
def to_dict(self):
|
||||
return {"file_path": self.file_path}
|
||||
|
||||
metadata = DummyMetadata(zip_path)
|
||||
version_info = {"images": []}
|
||||
download_urls = ["https://example.invalid/model.zip"]
|
||||
|
||||
class DummyDownloader:
|
||||
async def download_file(self, *_args, **_kwargs):
|
||||
with zipfile.ZipFile(str(zip_path), "w") as archive:
|
||||
archive.writestr("inner/model.safetensors", b"model")
|
||||
archive.writestr("docs/readme.txt", b"ignore")
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager, "get_downloader", AsyncMock(return_value=DummyDownloader())
|
||||
)
|
||||
dummy_scanner = SimpleNamespace(add_model_to_cache=AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
|
||||
)
|
||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
||||
hash_calculator = AsyncMock(return_value="hash-single")
|
||||
monkeypatch.setattr(download_manager, "calculate_sha256", hash_calculator)
|
||||
|
||||
result = await manager._execute_download(
|
||||
download_urls=download_urls,
|
||||
save_dir=str(save_dir),
|
||||
metadata=metadata,
|
||||
version_info=version_info,
|
||||
relative_path="",
|
||||
progress_callback=None,
|
||||
model_type="lora",
|
||||
download_id=None,
|
||||
)
|
||||
|
||||
assert result == {"success": True}
|
||||
assert not zip_path.exists()
|
||||
extracted = save_dir / "model.safetensors"
|
||||
assert extracted.exists()
|
||||
assert hash_calculator.await_args.args[0] == str(extracted)
|
||||
saved_call = MetadataManager.save_metadata.await_args
|
||||
assert saved_call.args[0] == str(extracted)
|
||||
assert saved_call.args[1].sha256 == "hash-single"
|
||||
assert dummy_scanner.add_model_to_cache.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_download_extracts_zip_multiple_models(monkeypatch, tmp_path):
|
||||
"""Test extraction of multiple models from ZIP file."""
|
||||
manager = DownloadManager()
|
||||
save_dir = tmp_path / "downloads"
|
||||
save_dir.mkdir()
|
||||
zip_path = save_dir / "bundle.zip"
|
||||
|
||||
class DummyMetadata:
|
||||
def __init__(self, path: Path):
|
||||
self.file_path = str(path)
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
|
||||
def update_file_info(self, updated_path):
|
||||
self.file_path = str(updated_path)
|
||||
self.file_name = Path(updated_path).stem
|
||||
|
||||
def to_dict(self):
|
||||
return {"file_path": self.file_path}
|
||||
|
||||
metadata = DummyMetadata(zip_path)
|
||||
version_info = {"images": []}
|
||||
download_urls = ["https://example.invalid/model.zip"]
|
||||
|
||||
class DummyDownloader:
|
||||
async def download_file(self, *_args, **_kwargs):
|
||||
with zipfile.ZipFile(str(zip_path), "w") as archive:
|
||||
archive.writestr("first/model-one.safetensors", b"one")
|
||||
archive.writestr("second/model-two.safetensors", b"two")
|
||||
archive.writestr("readme.md", b"ignore")
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager, "get_downloader", AsyncMock(return_value=DummyDownloader())
|
||||
)
|
||||
dummy_scanner = SimpleNamespace(add_model_to_cache=AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
|
||||
)
|
||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
||||
hash_calculator = AsyncMock(side_effect=["hash-one", "hash-two"])
|
||||
monkeypatch.setattr(download_manager, "calculate_sha256", hash_calculator)
|
||||
|
||||
result = await manager._execute_download(
|
||||
download_urls=download_urls,
|
||||
save_dir=str(save_dir),
|
||||
metadata=metadata,
|
||||
version_info=version_info,
|
||||
relative_path="",
|
||||
progress_callback=None,
|
||||
model_type="lora",
|
||||
download_id=None,
|
||||
)
|
||||
|
||||
assert result == {"success": True}
|
||||
assert not zip_path.exists()
|
||||
extracted_one = save_dir / "model-one.safetensors"
|
||||
extracted_two = save_dir / "model-two.safetensors"
|
||||
assert extracted_one.exists()
|
||||
assert extracted_two.exists()
|
||||
|
||||
assert hash_calculator.await_count == 2
|
||||
assert MetadataManager.save_metadata.await_count == 2
|
||||
assert dummy_scanner.add_model_to_cache.await_count == 2
|
||||
|
||||
metadata_calls = MetadataManager.save_metadata.await_args_list
|
||||
assert metadata_calls[0].args[0] == str(extracted_one)
|
||||
assert metadata_calls[0].args[1].sha256 == "hash-one"
|
||||
assert metadata_calls[1].args[0] == str(extracted_two)
|
||||
assert metadata_calls[1].args[1].sha256 == "hash-two"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_download_extracts_zip_pt_embedding(monkeypatch, tmp_path):
|
||||
"""Test extraction of .pt embedding files from ZIP."""
|
||||
manager = DownloadManager()
|
||||
save_dir = tmp_path / "downloads"
|
||||
save_dir.mkdir()
|
||||
zip_path = save_dir / "bundle.zip"
|
||||
|
||||
class DummyMetadata:
|
||||
def __init__(self, path: Path):
|
||||
self.file_path = str(path)
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
|
||||
def update_file_info(self, updated_path):
|
||||
self.file_path = str(updated_path)
|
||||
self.file_name = Path(updated_path).stem
|
||||
|
||||
def to_dict(self):
|
||||
return {"file_path": self.file_path}
|
||||
|
||||
metadata = DummyMetadata(zip_path)
|
||||
version_info = {"images": []}
|
||||
download_urls = ["https://example.invalid/model.zip"]
|
||||
|
||||
class DummyDownloader:
|
||||
async def download_file(self, *_args, **_kwargs):
|
||||
with zipfile.ZipFile(str(zip_path), "w") as archive:
|
||||
archive.writestr("inner/embedding.pt", b"embedding")
|
||||
archive.writestr("docs/readme.txt", b"ignore")
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_manager, "get_downloader", AsyncMock(return_value=DummyDownloader())
|
||||
)
|
||||
dummy_scanner = SimpleNamespace(add_model_to_cache=AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry, "get_embedding_scanner", AsyncMock(return_value=dummy_scanner)
|
||||
)
|
||||
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
||||
hash_calculator = AsyncMock(return_value="hash-pt")
|
||||
monkeypatch.setattr(download_manager, "calculate_sha256", hash_calculator)
|
||||
|
||||
result = await manager._execute_download(
|
||||
download_urls=download_urls,
|
||||
save_dir=str(save_dir),
|
||||
metadata=metadata,
|
||||
version_info=version_info,
|
||||
relative_path="",
|
||||
progress_callback=None,
|
||||
model_type="embedding",
|
||||
download_id=None,
|
||||
)
|
||||
|
||||
assert result == {"success": True}
|
||||
assert not zip_path.exists()
|
||||
extracted = save_dir / "embedding.pt"
|
||||
assert extracted.exists()
|
||||
assert hash_calculator.await_args.args[0] == str(extracted)
|
||||
saved_call = MetadataManager.save_metadata.await_args
|
||||
assert saved_call.args[0] == str(extracted)
|
||||
assert saved_call.args[1].sha256 == "hash-pt"
|
||||
assert dummy_scanner.add_model_to_cache.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_download_updates_state():
|
||||
"""Test that pause_download updates download state correctly."""
|
||||
manager = DownloadManager()
|
||||
|
||||
download_id = "dl"
|
||||
manager._download_tasks[download_id] = object()
|
||||
pause_control = DownloadStreamControl()
|
||||
manager._pause_events[download_id] = pause_control
|
||||
manager._active_downloads[download_id] = {
|
||||
"status": "downloading",
|
||||
"bytes_per_second": 42.0,
|
||||
}
|
||||
|
||||
result = await manager.pause_download(download_id)
|
||||
|
||||
assert result == {"success": True, "message": "Download paused successfully"}
|
||||
assert download_id in manager._pause_events
|
||||
assert manager._pause_events[download_id].is_set() is False
|
||||
assert manager._active_downloads[download_id]["status"] == "paused"
|
||||
assert manager._active_downloads[download_id]["bytes_per_second"] == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_download_rejects_unknown_task():
|
||||
"""Test that pause_download rejects unknown download tasks."""
|
||||
manager = DownloadManager()
|
||||
|
||||
result = await manager.pause_download("missing")
|
||||
|
||||
assert result == {"success": False, "error": "Download task not found"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_download_sets_event_and_status():
|
||||
"""Test that resume_download sets event and updates status."""
|
||||
manager = DownloadManager()
|
||||
|
||||
download_id = "dl"
|
||||
pause_control = DownloadStreamControl()
|
||||
pause_control.pause()
|
||||
pause_control.mark_progress()
|
||||
manager._pause_events[download_id] = pause_control
|
||||
manager._active_downloads[download_id] = {
|
||||
"status": "paused",
|
||||
"bytes_per_second": 0.0,
|
||||
}
|
||||
|
||||
result = await manager.resume_download(download_id)
|
||||
|
||||
assert result == {"success": True, "message": "Download resumed successfully"}
|
||||
assert manager._pause_events[download_id].is_set() is True
|
||||
assert manager._active_downloads[download_id]["status"] == "downloading"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_download_requests_reconnect_for_stalled_stream():
|
||||
"""Test that resume_download requests reconnect for stalled streams."""
|
||||
manager = DownloadManager()
|
||||
|
||||
download_id = "dl"
|
||||
pause_control = DownloadStreamControl(stall_timeout=40)
|
||||
pause_control.pause()
|
||||
pause_control.last_progress_timestamp = datetime.now().timestamp() - 120
|
||||
manager._pause_events[download_id] = pause_control
|
||||
manager._active_downloads[download_id] = {
|
||||
"status": "paused",
|
||||
"bytes_per_second": 0.0,
|
||||
}
|
||||
|
||||
result = await manager.resume_download(download_id)
|
||||
|
||||
assert result == {"success": True, "message": "Download resumed successfully"}
|
||||
assert pause_control.is_set() is True
|
||||
assert pause_control.has_reconnect_request() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_download_rejects_when_not_paused():
|
||||
"""Test that resume_download rejects when download is not paused."""
|
||||
manager = DownloadManager()
|
||||
|
||||
download_id = "dl"
|
||||
pause_control = DownloadStreamControl()
|
||||
manager._pause_events[download_id] = pause_control
|
||||
|
||||
result = await manager.resume_download(download_id)
|
||||
|
||||
assert result == {"success": False, "error": "Download is not paused"}
|
||||
@@ -1,311 +0,0 @@
|
||||
"""Error path tests for downloader module.
|
||||
|
||||
Tests HTTP error handling and network error scenarios.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
import aiohttp
|
||||
|
||||
from py.services.downloader import Downloader, DownloadStalledError, DownloadRestartRequested
|
||||
|
||||
|
||||
class TestDownloadStreamControl:
|
||||
"""Test DownloadStreamControl functionality."""
|
||||
|
||||
def test_pause_clears_event(self):
|
||||
"""Verify pause() clears the event."""
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
|
||||
control = DownloadStreamControl()
|
||||
assert control.is_set() is True # Initially set
|
||||
|
||||
control.pause()
|
||||
assert control.is_set() is False
|
||||
assert control.is_paused() is True
|
||||
|
||||
def test_resume_sets_event(self):
|
||||
"""Verify resume() sets the event."""
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
|
||||
control = DownloadStreamControl()
|
||||
control.pause()
|
||||
assert control.is_set() is False
|
||||
|
||||
control.resume()
|
||||
assert control.is_set() is True
|
||||
assert control.is_paused() is False
|
||||
|
||||
def test_reconnect_request_tracking(self):
|
||||
"""Verify reconnect request tracking works correctly."""
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
|
||||
control = DownloadStreamControl()
|
||||
assert control.has_reconnect_request() is False
|
||||
|
||||
control.request_reconnect()
|
||||
assert control.has_reconnect_request() is True
|
||||
|
||||
# Consume the request
|
||||
consumed = control.consume_reconnect_request()
|
||||
assert consumed is True
|
||||
assert control.has_reconnect_request() is False
|
||||
|
||||
def test_mark_progress_clears_reconnect(self):
|
||||
"""Verify mark_progress clears reconnect requests."""
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
|
||||
control = DownloadStreamControl()
|
||||
control.request_reconnect()
|
||||
assert control.has_reconnect_request() is True
|
||||
|
||||
control.mark_progress()
|
||||
assert control.has_reconnect_request() is False
|
||||
assert control.last_progress_timestamp is not None
|
||||
|
||||
def test_time_since_last_progress(self):
|
||||
"""Verify time_since_last_progress calculation."""
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
import time
|
||||
|
||||
control = DownloadStreamControl()
|
||||
|
||||
# Initially None
|
||||
assert control.time_since_last_progress() is None
|
||||
|
||||
# After marking progress
|
||||
now = time.time()
|
||||
control.mark_progress(timestamp=now)
|
||||
|
||||
elapsed = control.time_since_last_progress(now=now + 5)
|
||||
assert elapsed == 5.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_resume(self):
|
||||
"""Verify wait() blocks until resumed."""
|
||||
from py.services.downloader import DownloadStreamControl
|
||||
import asyncio
|
||||
|
||||
control = DownloadStreamControl()
|
||||
control.pause()
|
||||
|
||||
# Start a task that will wait
|
||||
wait_task = asyncio.create_task(control.wait())
|
||||
|
||||
# Give it a moment to start waiting
|
||||
await asyncio.sleep(0.01)
|
||||
assert not wait_task.done()
|
||||
|
||||
# Resume should unblock
|
||||
control.resume()
|
||||
await asyncio.wait_for(wait_task, timeout=0.1)
|
||||
|
||||
|
||||
class TestDownloaderConfiguration:
|
||||
"""Test downloader configuration and initialization."""
|
||||
|
||||
def test_downloader_singleton_pattern(self):
|
||||
"""Verify Downloader follows singleton pattern."""
|
||||
# Reset first
|
||||
Downloader._instance = None
|
||||
|
||||
# Both should return same instance
|
||||
async def get_instances():
|
||||
instance1 = await Downloader.get_instance()
|
||||
instance2 = await Downloader.get_instance()
|
||||
return instance1, instance2
|
||||
|
||||
import asyncio
|
||||
instance1, instance2 = asyncio.run(get_instances())
|
||||
|
||||
assert instance1 is instance2
|
||||
|
||||
# Cleanup
|
||||
Downloader._instance = None
|
||||
|
||||
def test_default_configuration_values(self):
|
||||
"""Verify default configuration values are set correctly."""
|
||||
Downloader._instance = None
|
||||
|
||||
downloader = Downloader()
|
||||
|
||||
assert downloader.chunk_size == 4 * 1024 * 1024 # 4MB
|
||||
assert downloader.max_retries == 5
|
||||
assert downloader.base_delay == 2.0
|
||||
assert downloader.session_timeout == 300
|
||||
|
||||
# Cleanup
|
||||
Downloader._instance = None
|
||||
|
||||
def test_default_headers_include_user_agent(self):
|
||||
"""Verify default headers include User-Agent."""
|
||||
Downloader._instance = None
|
||||
|
||||
downloader = Downloader()
|
||||
|
||||
assert 'User-Agent' in downloader.default_headers
|
||||
assert 'ComfyUI-LoRA-Manager' in downloader.default_headers['User-Agent']
|
||||
assert downloader.default_headers['Accept-Encoding'] == 'identity'
|
||||
|
||||
# Cleanup
|
||||
Downloader._instance = None
|
||||
|
||||
def test_stall_timeout_resolution(self):
|
||||
"""Verify stall timeout is resolved correctly."""
|
||||
Downloader._instance = None
|
||||
|
||||
downloader = Downloader()
|
||||
timeout = downloader._resolve_stall_timeout()
|
||||
|
||||
# Should be at least 30 seconds
|
||||
assert timeout >= 30.0
|
||||
|
||||
# Cleanup
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
class TestDownloadProgress:
|
||||
"""Test DownloadProgress dataclass."""
|
||||
|
||||
def test_download_progress_creation(self):
|
||||
"""Verify DownloadProgress can be created with correct values."""
|
||||
from py.services.downloader import DownloadProgress
|
||||
from datetime import datetime
|
||||
|
||||
progress = DownloadProgress(
|
||||
percent_complete=50.0,
|
||||
bytes_downloaded=500,
|
||||
total_bytes=1000,
|
||||
bytes_per_second=100.5,
|
||||
timestamp=datetime.now().timestamp(),
|
||||
)
|
||||
|
||||
assert progress.percent_complete == 50.0
|
||||
assert progress.bytes_downloaded == 500
|
||||
assert progress.total_bytes == 1000
|
||||
assert progress.bytes_per_second == 100.5
|
||||
assert progress.timestamp is not None
|
||||
|
||||
|
||||
class TestDownloaderExceptions:
|
||||
"""Test custom exception classes."""
|
||||
|
||||
def test_download_stalled_error(self):
|
||||
"""Verify DownloadStalledError can be raised and caught."""
|
||||
with pytest.raises(DownloadStalledError) as exc_info:
|
||||
raise DownloadStalledError("Download stalled for 120 seconds")
|
||||
|
||||
assert "stalled" in str(exc_info.value).lower()
|
||||
|
||||
def test_download_restart_requested_error(self):
|
||||
"""Verify DownloadRestartRequested can be raised and caught."""
|
||||
with pytest.raises(DownloadRestartRequested) as exc_info:
|
||||
raise DownloadRestartRequested("Reconnect requested after resume")
|
||||
|
||||
assert "reconnect" in str(exc_info.value).lower() or "restart" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
class TestDownloaderAuthHeaders:
|
||||
"""Test authentication header generation."""
|
||||
|
||||
def test_get_auth_headers_without_auth(self):
|
||||
"""Verify auth headers without authentication."""
|
||||
Downloader._instance = None
|
||||
downloader = Downloader()
|
||||
|
||||
headers = downloader._get_auth_headers(use_auth=False)
|
||||
|
||||
assert 'User-Agent' in headers
|
||||
assert 'Authorization' not in headers
|
||||
|
||||
Downloader._instance = None
|
||||
|
||||
def test_get_auth_headers_with_auth_no_api_key(self, monkeypatch):
|
||||
"""Verify auth headers with auth but no API key configured."""
|
||||
Downloader._instance = None
|
||||
downloader = Downloader()
|
||||
|
||||
# Mock settings manager to return no API key
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.get.return_value = None
|
||||
|
||||
with patch('py.services.downloader.get_settings_manager', return_value=mock_settings):
|
||||
headers = downloader._get_auth_headers(use_auth=True)
|
||||
|
||||
# Should still have User-Agent but no Authorization
|
||||
assert 'User-Agent' in headers
|
||||
assert 'Authorization' not in headers
|
||||
|
||||
Downloader._instance = None
|
||||
|
||||
def test_get_auth_headers_with_auth_and_api_key(self, monkeypatch):
|
||||
"""Verify auth headers with auth and API key configured."""
|
||||
Downloader._instance = None
|
||||
downloader = Downloader()
|
||||
|
||||
# Mock settings manager to return API key
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.get.return_value = "test-api-key-12345"
|
||||
|
||||
with patch('py.services.downloader.get_settings_manager', return_value=mock_settings):
|
||||
headers = downloader._get_auth_headers(use_auth=True)
|
||||
|
||||
# Should have both User-Agent and Authorization
|
||||
assert 'User-Agent' in headers
|
||||
assert 'Authorization' in headers
|
||||
assert 'test-api-key-12345' in headers['Authorization']
|
||||
assert headers['Content-Type'] == 'application/json'
|
||||
|
||||
Downloader._instance = None
|
||||
|
||||
|
||||
class TestDownloaderSessionManagement:
|
||||
"""Test session management functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_refresh_session_when_none(self):
|
||||
"""Verify session refresh is needed when session is None."""
|
||||
Downloader._instance = None
|
||||
downloader = Downloader()
|
||||
|
||||
# Initially should need refresh
|
||||
assert downloader._should_refresh_session() is True
|
||||
|
||||
Downloader._instance = None
|
||||
|
||||
def test_should_not_refresh_new_session(self):
|
||||
"""Verify new session doesn't need refresh."""
|
||||
Downloader._instance = None
|
||||
downloader = Downloader()
|
||||
|
||||
# Simulate a fresh session
|
||||
downloader._session_created_at = MagicMock()
|
||||
downloader._session = MagicMock()
|
||||
|
||||
# Mock datetime to return current time
|
||||
from datetime import datetime, timedelta
|
||||
current_time = datetime.now()
|
||||
downloader._session_created_at = current_time
|
||||
|
||||
# Should not need refresh for new session
|
||||
assert downloader._should_refresh_session() is False
|
||||
|
||||
Downloader._instance = None
|
||||
|
||||
def test_should_refresh_old_session(self):
|
||||
"""Verify old session needs refresh."""
|
||||
Downloader._instance = None
|
||||
downloader = Downloader()
|
||||
|
||||
# Simulate an old session (older than timeout)
|
||||
from datetime import datetime, timedelta
|
||||
old_time = datetime.now() - timedelta(seconds=downloader.session_timeout + 1)
|
||||
downloader._session_created_at = old_time
|
||||
downloader._session = MagicMock()
|
||||
|
||||
# Should need refresh for old session
|
||||
assert downloader._should_refresh_session() is True
|
||||
|
||||
Downloader._instance = None
|
||||
@@ -322,339 +322,3 @@ async def test_delete_model_removes_gguf_file(tmp_path: Path):
|
||||
assert not metadata_path.exists()
|
||||
assert not preview_path.exists()
|
||||
assert any(item.endswith("model.gguf") for item in result["deleted_files"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for exclude_model functionality
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exclude_model_marks_as_excluded(tmp_path: Path):
|
||||
"""Verify exclude_model marks model as excluded and updates metadata."""
|
||||
model_path = tmp_path / "test_model.safetensors"
|
||||
model_path.write_bytes(b"content")
|
||||
|
||||
metadata_path = tmp_path / "test_model.metadata.json"
|
||||
metadata_payload = {"file_name": "test_model", "file_path": str(model_path)}
|
||||
metadata_path.write_text(json.dumps(metadata_payload))
|
||||
|
||||
raw_data = [
|
||||
{
|
||||
"file_path": str(model_path),
|
||||
"tags": ["tag1", "tag2"],
|
||||
}
|
||||
]
|
||||
|
||||
class ExcludeTestScanner:
|
||||
def __init__(self, raw_data):
|
||||
self.cache = DummyCache(raw_data)
|
||||
self.model_type = "lora"
|
||||
self._tags_count = {"tag1": 1, "tag2": 1}
|
||||
self._hash_index = DummyHashIndex()
|
||||
self._excluded_models = []
|
||||
|
||||
async def get_cached_data(self):
|
||||
return self.cache
|
||||
|
||||
scanner = ExcludeTestScanner(raw_data)
|
||||
|
||||
saved_metadata = []
|
||||
|
||||
class SavingMetadataManager:
|
||||
async def save_metadata(self, path: str, metadata: dict):
|
||||
saved_metadata.append((path, metadata.copy()))
|
||||
|
||||
async def metadata_loader(path: str):
|
||||
return metadata_payload.copy()
|
||||
|
||||
service = ModelLifecycleService(
|
||||
scanner=scanner,
|
||||
metadata_manager=SavingMetadataManager(),
|
||||
metadata_loader=metadata_loader,
|
||||
)
|
||||
|
||||
result = await service.exclude_model(str(model_path))
|
||||
|
||||
assert result["success"] is True
|
||||
assert "excluded" in result["message"].lower()
|
||||
assert saved_metadata[0][1]["exclude"] is True
|
||||
assert str(model_path) in scanner._excluded_models
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exclude_model_updates_tag_counts(tmp_path: Path):
|
||||
"""Verify exclude_model decrements tag counts correctly."""
|
||||
model_path = tmp_path / "test_model.safetensors"
|
||||
model_path.write_bytes(b"content")
|
||||
|
||||
metadata_path = tmp_path / "test_model.metadata.json"
|
||||
metadata_path.write_text(json.dumps({}))
|
||||
|
||||
raw_data = [
|
||||
{
|
||||
"file_path": str(model_path),
|
||||
"tags": ["tag1", "tag2"],
|
||||
}
|
||||
]
|
||||
|
||||
class TagCountScanner:
|
||||
def __init__(self, raw_data):
|
||||
self.cache = DummyCache(raw_data)
|
||||
self.model_type = "lora"
|
||||
self._tags_count = {"tag1": 2, "tag2": 1}
|
||||
self._hash_index = DummyHashIndex()
|
||||
self._excluded_models = []
|
||||
|
||||
async def get_cached_data(self):
|
||||
return self.cache
|
||||
|
||||
scanner = TagCountScanner(raw_data)
|
||||
|
||||
class DummyMetadataManagerLocal:
|
||||
async def save_metadata(self, path: str, metadata: dict):
|
||||
pass
|
||||
|
||||
async def metadata_loader(path: str):
|
||||
return {}
|
||||
|
||||
service = ModelLifecycleService(
|
||||
scanner=scanner,
|
||||
metadata_manager=DummyMetadataManagerLocal(),
|
||||
metadata_loader=metadata_loader,
|
||||
)
|
||||
|
||||
await service.exclude_model(str(model_path))
|
||||
|
||||
# tag2 count should become 0 and be removed
|
||||
assert "tag2" not in scanner._tags_count
|
||||
# tag1 count should decrement to 1
|
||||
assert scanner._tags_count["tag1"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exclude_model_empty_path_raises_error():
|
||||
"""Verify exclude_model raises ValueError for empty path."""
|
||||
service = ModelLifecycleService(
|
||||
scanner=VersionAwareScanner([]),
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Model path is required"):
|
||||
await service.exclude_model("")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for bulk_delete_models functionality
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_models_deletes_multiple_files(tmp_path: Path):
|
||||
"""Verify bulk_delete_models deletes multiple models via scanner."""
|
||||
model1_path = tmp_path / "model1.safetensors"
|
||||
model1_path.write_bytes(b"content1")
|
||||
model2_path = tmp_path / "model2.safetensors"
|
||||
model2_path.write_bytes(b"content2")
|
||||
|
||||
file_paths = [str(model1_path), str(model2_path)]
|
||||
|
||||
class BulkDeleteScanner:
|
||||
def __init__(self):
|
||||
self.model_type = "lora"
|
||||
self.bulk_delete_calls = []
|
||||
|
||||
async def bulk_delete_models(self, paths):
|
||||
self.bulk_delete_calls.append(paths)
|
||||
return {"success": True, "deleted": paths}
|
||||
|
||||
scanner = BulkDeleteScanner()
|
||||
|
||||
service = ModelLifecycleService(
|
||||
scanner=scanner,
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
|
||||
result = await service.bulk_delete_models(file_paths)
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(scanner.bulk_delete_calls) == 1
|
||||
assert scanner.bulk_delete_calls[0] == file_paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_models_empty_list_raises_error():
|
||||
"""Verify bulk_delete_models raises ValueError for empty list."""
|
||||
service = ModelLifecycleService(
|
||||
scanner=VersionAwareScanner([]),
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="No file paths provided"):
|
||||
await service.bulk_delete_models([])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for error paths and edge cases
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_empty_path_raises_error():
|
||||
"""Verify delete_model raises ValueError for empty path."""
|
||||
service = ModelLifecycleService(
|
||||
scanner=VersionAwareScanner([]),
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Model path is required"):
|
||||
await service.delete_model("")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_model_empty_path_raises_error():
|
||||
"""Verify rename_model raises ValueError for empty path."""
|
||||
service = ModelLifecycleService(
|
||||
scanner=DummyScanner(),
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="required"):
|
||||
await service.rename_model(file_path="", new_file_name="new_name")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_model_empty_name_raises_error(tmp_path: Path):
|
||||
"""Verify rename_model raises ValueError for empty new name."""
|
||||
model_path = tmp_path / "model.safetensors"
|
||||
model_path.write_bytes(b"content")
|
||||
|
||||
service = ModelLifecycleService(
|
||||
scanner=DummyScanner(),
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="required"):
|
||||
await service.rename_model(file_path=str(model_path), new_file_name="")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_model_invalid_characters_raises_error(tmp_path: Path):
|
||||
"""Verify rename_model raises ValueError for invalid characters."""
|
||||
model_path = tmp_path / "model.safetensors"
|
||||
model_path.write_bytes(b"content")
|
||||
|
||||
service = ModelLifecycleService(
|
||||
scanner=DummyScanner(),
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
|
||||
invalid_names = [
|
||||
"model/name",
|
||||
"model\\\\name",
|
||||
"model:name",
|
||||
"model*name",
|
||||
"model?name",
|
||||
'model"name',
|
||||
"model<name>",
|
||||
"model|name",
|
||||
]
|
||||
|
||||
for invalid_name in invalid_names:
|
||||
with pytest.raises(ValueError, match="Invalid characters"):
|
||||
await service.rename_model(
|
||||
file_path=str(model_path), new_file_name=invalid_name
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_model_existing_file_raises_error(tmp_path: Path):
|
||||
"""Verify rename_model raises ValueError if target exists."""
|
||||
old_name = "model"
|
||||
new_name = "existing"
|
||||
extension = ".safetensors"
|
||||
|
||||
old_path = tmp_path / f"{old_name}{extension}"
|
||||
old_path.write_bytes(b"content")
|
||||
|
||||
# Create existing file with target name
|
||||
existing_path = tmp_path / f"{new_name}{extension}"
|
||||
existing_path.write_bytes(b"existing content")
|
||||
|
||||
service = ModelLifecycleService(
|
||||
scanner=DummyScanner(),
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await service.rename_model(
|
||||
file_path=str(old_path), new_file_name=new_name
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for _extract_model_id_from_payload utility
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_model_id_from_civitai_payload():
|
||||
"""Verify model ID extraction from civitai-formatted payload."""
|
||||
service = ModelLifecycleService(
|
||||
scanner=DummyScanner(),
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
|
||||
# Test civitai.modelId
|
||||
payload1 = {"civitai": {"modelId": 12345}}
|
||||
assert service._extract_model_id_from_payload(payload1) == 12345
|
||||
|
||||
# Test civitai.model.id nested
|
||||
payload2 = {"civitai": {"model": {"id": 67890}}}
|
||||
assert service._extract_model_id_from_payload(payload2) == 67890
|
||||
|
||||
# Test model_id fallback
|
||||
payload3 = {"model_id": 11111}
|
||||
assert service._extract_model_id_from_payload(payload3) == 11111
|
||||
|
||||
# Test civitai_model_id fallback
|
||||
payload4 = {"civitai_model_id": 22222}
|
||||
assert service._extract_model_id_from_payload(payload4) == 22222
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_model_id_returns_none_for_invalid_payload():
|
||||
"""Verify model ID extraction returns None for invalid payloads."""
|
||||
service = ModelLifecycleService(
|
||||
scanner=DummyScanner(),
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
|
||||
assert service._extract_model_id_from_payload({}) is None
|
||||
assert service._extract_model_id_from_payload(None) is None
|
||||
assert service._extract_model_id_from_payload("string") is None
|
||||
assert service._extract_model_id_from_payload({"civitai": None}) is None
|
||||
assert service._extract_model_id_from_payload({"civitai": {}}) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_model_id_handles_string_values():
|
||||
"""Verify model ID extraction handles string values."""
|
||||
service = ModelLifecycleService(
|
||||
scanner=DummyScanner(),
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
|
||||
payload = {"civitai": {"modelId": "54321"}}
|
||||
assert service._extract_model_id_from_payload(payload) == 54321
|
||||
|
||||
@@ -255,213 +255,3 @@ class TestPersistentRecipeCache:
|
||||
assert len(loras) == 2
|
||||
assert loras[0]["modelVersionId"] == 12345
|
||||
assert loras[1]["clip_strength"] == 0.8
|
||||
|
||||
# =============================================================================
|
||||
# Tests for concurrent access (from Phase 2 improvement plan)
|
||||
# =============================================================================
|
||||
|
||||
def test_concurrent_reads_do_not_corrupt_data(self, temp_db_path, sample_recipes):
|
||||
"""Verify concurrent reads don't corrupt database state."""
|
||||
import threading
|
||||
import time
|
||||
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
cache.save_cache(sample_recipes)
|
||||
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
def read_operation():
|
||||
try:
|
||||
for _ in range(10):
|
||||
loaded = cache.load_cache()
|
||||
if loaded is not None:
|
||||
results.append(len(loaded.raw_data))
|
||||
time.sleep(0.01)
|
||||
except Exception as e:
|
||||
errors.append(str(e))
|
||||
|
||||
# Start multiple reader threads
|
||||
threads = [threading.Thread(target=read_operation) for _ in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# No errors should occur
|
||||
assert len(errors) == 0, f"Errors during concurrent reads: {errors}"
|
||||
# All reads should return consistent data
|
||||
assert all(count == 2 for count in results), "Inconsistent read results"
|
||||
|
||||
def test_concurrent_write_and_read(self, temp_db_path, sample_recipes):
|
||||
"""Verify thread safety under concurrent writes and reads."""
|
||||
import threading
|
||||
import time
|
||||
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
cache.save_cache(sample_recipes)
|
||||
|
||||
write_errors = []
|
||||
read_errors = []
|
||||
write_count = [0]
|
||||
|
||||
def write_operation():
|
||||
try:
|
||||
for i in range(5):
|
||||
recipe = {
|
||||
"id": f"concurrent-{i}",
|
||||
"title": f"Concurrent Recipe {i}",
|
||||
}
|
||||
cache.update_recipe(recipe)
|
||||
write_count[0] += 1
|
||||
time.sleep(0.02)
|
||||
except Exception as e:
|
||||
write_errors.append(str(e))
|
||||
|
||||
def read_operation():
|
||||
try:
|
||||
for _ in range(10):
|
||||
cache.load_cache()
|
||||
cache.get_recipe_count()
|
||||
time.sleep(0.01)
|
||||
except Exception as e:
|
||||
read_errors.append(str(e))
|
||||
|
||||
# Mix of read and write threads
|
||||
threads = (
|
||||
[threading.Thread(target=write_operation) for _ in range(2)]
|
||||
+ [threading.Thread(target=read_operation) for _ in range(3)]
|
||||
)
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# No errors should occur
|
||||
assert len(write_errors) == 0, f"Write errors: {write_errors}"
|
||||
assert len(read_errors) == 0, f"Read errors: {read_errors}"
|
||||
# Writes should complete successfully
|
||||
assert write_count[0] > 0
|
||||
|
||||
def test_concurrent_updates_to_same_recipe(self, temp_db_path):
|
||||
"""Verify concurrent updates to the same recipe don't corrupt data."""
|
||||
import threading
|
||||
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
|
||||
# Initialize with one recipe
|
||||
initial_recipe = {
|
||||
"id": "concurrent-update",
|
||||
"title": "Initial Title",
|
||||
"version": 1,
|
||||
}
|
||||
cache.save_cache([initial_recipe])
|
||||
|
||||
errors = []
|
||||
successful_updates = []
|
||||
|
||||
def update_operation(thread_id):
|
||||
try:
|
||||
for i in range(5):
|
||||
recipe = {
|
||||
"id": "concurrent-update",
|
||||
"title": f"Title from thread {thread_id} update {i}",
|
||||
"version": i + 1,
|
||||
}
|
||||
cache.update_recipe(recipe)
|
||||
successful_updates.append((thread_id, i))
|
||||
except Exception as e:
|
||||
errors.append(f"Thread {thread_id}: {e}")
|
||||
|
||||
# Multiple threads updating the same recipe
|
||||
threads = [
|
||||
threading.Thread(target=update_operation, args=(i,)) for i in range(3)
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# No errors should occur
|
||||
assert len(errors) == 0, f"Update errors: {errors}"
|
||||
# All updates should complete
|
||||
assert len(successful_updates) == 15
|
||||
|
||||
# Final state should be valid
|
||||
final_count = cache.get_recipe_count()
|
||||
assert final_count == 1
|
||||
|
||||
def test_schema_initialization_thread_safety(self, temp_db_path):
|
||||
"""Verify schema initialization is thread-safe."""
|
||||
import threading
|
||||
|
||||
errors = []
|
||||
initialized_caches = []
|
||||
|
||||
def create_cache():
|
||||
try:
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
initialized_caches.append(cache)
|
||||
except Exception as e:
|
||||
errors.append(str(e))
|
||||
|
||||
# Multiple threads creating cache simultaneously
|
||||
threads = [threading.Thread(target=create_cache) for _ in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# No errors should occur
|
||||
assert len(errors) == 0, f"Initialization errors: {errors}"
|
||||
# All caches should be created
|
||||
assert len(initialized_caches) == 5
|
||||
|
||||
def test_concurrent_save_and_remove(self, temp_db_path, sample_recipes):
|
||||
"""Verify concurrent save and remove operations don't corrupt database."""
|
||||
import threading
|
||||
import time
|
||||
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
|
||||
errors = []
|
||||
operation_counts = {"saves": 0, "removes": 0}
|
||||
|
||||
def save_operation():
|
||||
try:
|
||||
for i in range(5):
|
||||
recipes = [
|
||||
{"id": f"recipe-{j}", "title": f"Recipe {j}"}
|
||||
for j in range(i * 2, i * 2 + 2)
|
||||
]
|
||||
cache.save_cache(recipes)
|
||||
operation_counts["saves"] += 1
|
||||
time.sleep(0.015)
|
||||
except Exception as e:
|
||||
errors.append(f"Save error: {e}")
|
||||
|
||||
def remove_operation():
|
||||
try:
|
||||
for i in range(5):
|
||||
cache.remove_recipe(f"recipe-{i}")
|
||||
operation_counts["removes"] += 1
|
||||
time.sleep(0.02)
|
||||
except Exception as e:
|
||||
errors.append(f"Remove error: {e}")
|
||||
|
||||
# Concurrent save and remove threads
|
||||
threads = [
|
||||
threading.Thread(target=save_operation),
|
||||
threading.Thread(target=remove_operation),
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# No errors should occur
|
||||
assert len(errors) == 0, f"Operation errors: {errors}"
|
||||
# Operations should complete
|
||||
assert operation_counts["saves"] == 5
|
||||
assert operation_counts["removes"] == 5
|
||||
|
||||
529
tests/utils/test_cache_paths.py
Normal file
529
tests/utils/test_cache_paths.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""Unit tests for the cache_paths module."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.cache_paths import (
|
||||
CacheType,
|
||||
cleanup_legacy_cache_files,
|
||||
get_cache_base_dir,
|
||||
get_cache_file_path,
|
||||
get_legacy_cache_files_for_cleanup,
|
||||
get_legacy_cache_paths,
|
||||
resolve_cache_path_with_migration,
|
||||
)
|
||||
|
||||
|
||||
class TestCacheType:
|
||||
"""Tests for the CacheType enum."""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert CacheType.MODEL.value == "model"
|
||||
assert CacheType.RECIPE.value == "recipe"
|
||||
assert CacheType.RECIPE_FTS.value == "recipe_fts"
|
||||
assert CacheType.TAG_FTS.value == "tag_fts"
|
||||
assert CacheType.SYMLINK.value == "symlink"
|
||||
|
||||
|
||||
class TestGetCacheBaseDir:
|
||||
"""Tests for get_cache_base_dir function."""
|
||||
|
||||
def test_returns_cache_subdirectory(self):
|
||||
cache_dir = get_cache_base_dir(create=True)
|
||||
assert cache_dir.endswith("cache")
|
||||
assert os.path.isdir(cache_dir)
|
||||
|
||||
def test_creates_directory_when_requested(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
cache_dir = get_cache_base_dir(create=True)
|
||||
assert os.path.isdir(cache_dir)
|
||||
assert cache_dir == str(settings_dir / "cache")
|
||||
|
||||
|
||||
class TestGetCacheFilePath:
|
||||
"""Tests for get_cache_file_path function."""
|
||||
|
||||
def test_model_cache_path(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.MODEL, "my_library", create_dir=True)
|
||||
expected = settings_dir / "cache" / "model" / "my_library.sqlite"
|
||||
assert path == str(expected)
|
||||
assert os.path.isdir(expected.parent)
|
||||
|
||||
def test_recipe_cache_path(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.RECIPE, "default", create_dir=True)
|
||||
expected = settings_dir / "cache" / "recipe" / "default.sqlite"
|
||||
assert path == str(expected)
|
||||
|
||||
def test_recipe_fts_path(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.RECIPE_FTS, create_dir=True)
|
||||
expected = settings_dir / "cache" / "fts" / "recipe_fts.sqlite"
|
||||
assert path == str(expected)
|
||||
|
||||
def test_tag_fts_path(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.TAG_FTS, create_dir=True)
|
||||
expected = settings_dir / "cache" / "fts" / "tag_fts.sqlite"
|
||||
assert path == str(expected)
|
||||
|
||||
def test_symlink_path(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.SYMLINK, create_dir=True)
|
||||
expected = settings_dir / "cache" / "symlink" / "symlink_map.json"
|
||||
assert path == str(expected)
|
||||
|
||||
def test_sanitizes_library_name(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.MODEL, "my/bad:name", create_dir=True)
|
||||
assert "my_bad_name" in path
|
||||
|
||||
def test_none_library_name_defaults_to_default(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.MODEL, None, create_dir=True)
|
||||
assert "default.sqlite" in path
|
||||
|
||||
|
||||
class TestGetLegacyCachePaths:
|
||||
"""Tests for get_legacy_cache_paths function."""
|
||||
|
||||
def test_model_legacy_paths_for_default(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
paths = get_legacy_cache_paths(CacheType.MODEL, "default")
|
||||
assert len(paths) == 2
|
||||
assert str(settings_dir / "model_cache" / "default.sqlite") in paths
|
||||
assert str(settings_dir / "model_cache.sqlite") in paths
|
||||
|
||||
def test_model_legacy_paths_for_named_library(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
paths = get_legacy_cache_paths(CacheType.MODEL, "my_library")
|
||||
assert len(paths) == 1
|
||||
assert str(settings_dir / "model_cache" / "my_library.sqlite") in paths
|
||||
|
||||
def test_recipe_legacy_paths(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
paths = get_legacy_cache_paths(CacheType.RECIPE, "default")
|
||||
assert len(paths) == 2
|
||||
assert str(settings_dir / "recipe_cache" / "default.sqlite") in paths
|
||||
assert str(settings_dir / "recipe_cache.sqlite") in paths
|
||||
|
||||
def test_recipe_fts_legacy_path(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
paths = get_legacy_cache_paths(CacheType.RECIPE_FTS)
|
||||
assert len(paths) == 1
|
||||
assert str(settings_dir / "recipe_fts.sqlite") in paths
|
||||
|
||||
def test_tag_fts_legacy_path(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
paths = get_legacy_cache_paths(CacheType.TAG_FTS)
|
||||
assert len(paths) == 1
|
||||
assert str(settings_dir / "tag_fts.sqlite") in paths
|
||||
|
||||
def test_symlink_legacy_path(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
paths = get_legacy_cache_paths(CacheType.SYMLINK)
|
||||
assert len(paths) == 1
|
||||
assert str(settings_dir / "cache" / "symlink_map.json") in paths
|
||||
|
||||
|
||||
class TestResolveCachePathWithMigration:
|
||||
"""Tests for resolve_cache_path_with_migration function."""
|
||||
|
||||
def test_returns_env_override_when_set(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
override_path = "/custom/path/cache.sqlite"
|
||||
path = resolve_cache_path_with_migration(
|
||||
CacheType.MODEL,
|
||||
library_name="default",
|
||||
env_override=override_path,
|
||||
)
|
||||
assert path == override_path
|
||||
|
||||
def test_returns_canonical_path_when_exists(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create the canonical path
|
||||
canonical = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
canonical.parent.mkdir(parents=True)
|
||||
canonical.write_text("existing")
|
||||
|
||||
path = resolve_cache_path_with_migration(CacheType.MODEL, "default")
|
||||
assert path == str(canonical)
|
||||
|
||||
def test_migrates_from_legacy_root_level_cache(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create legacy cache at root level
|
||||
legacy_path = settings_dir / "model_cache.sqlite"
|
||||
legacy_path.write_text("legacy data")
|
||||
|
||||
path = resolve_cache_path_with_migration(CacheType.MODEL, "default")
|
||||
|
||||
# Should return canonical path
|
||||
canonical = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
assert path == str(canonical)
|
||||
|
||||
# File should be copied to canonical location
|
||||
assert canonical.exists()
|
||||
assert canonical.read_text() == "legacy data"
|
||||
|
||||
# Legacy file should be automatically cleaned up
|
||||
assert not legacy_path.exists()
|
||||
|
||||
def test_migrates_from_legacy_per_library_cache(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create legacy per-library cache
|
||||
legacy_dir = settings_dir / "model_cache"
|
||||
legacy_dir.mkdir()
|
||||
legacy_path = legacy_dir / "my_library.sqlite"
|
||||
legacy_path.write_text("legacy library data")
|
||||
|
||||
path = resolve_cache_path_with_migration(CacheType.MODEL, "my_library")
|
||||
|
||||
# Should return canonical path
|
||||
canonical = settings_dir / "cache" / "model" / "my_library.sqlite"
|
||||
assert path == str(canonical)
|
||||
assert canonical.exists()
|
||||
assert canonical.read_text() == "legacy library data"
|
||||
|
||||
# Legacy file should be automatically cleaned up
|
||||
assert not legacy_path.exists()
|
||||
|
||||
# Empty legacy directory should be cleaned up
|
||||
assert not legacy_dir.exists()
|
||||
|
||||
def test_prefers_per_library_over_root_for_migration(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create both legacy caches
|
||||
legacy_root = settings_dir / "model_cache.sqlite"
|
||||
legacy_root.write_text("root legacy")
|
||||
|
||||
legacy_dir = settings_dir / "model_cache"
|
||||
legacy_dir.mkdir()
|
||||
legacy_lib = legacy_dir / "default.sqlite"
|
||||
legacy_lib.write_text("library legacy")
|
||||
|
||||
path = resolve_cache_path_with_migration(CacheType.MODEL, "default")
|
||||
|
||||
canonical = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
assert path == str(canonical)
|
||||
# Should migrate from per-library path (first in legacy list)
|
||||
assert canonical.read_text() == "library legacy"
|
||||
|
||||
def test_returns_canonical_path_when_no_legacy_exists(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = resolve_cache_path_with_migration(CacheType.MODEL, "new_library")
|
||||
|
||||
canonical = settings_dir / "cache" / "model" / "new_library.sqlite"
|
||||
assert path == str(canonical)
|
||||
# Directory should be created
|
||||
assert canonical.parent.exists()
|
||||
# But file should not exist yet
|
||||
assert not canonical.exists()
|
||||
|
||||
|
||||
class TestLegacyCacheCleanup:
|
||||
"""Tests for legacy cache cleanup functions."""
|
||||
|
||||
def test_get_legacy_cache_files_for_cleanup(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create canonical and legacy files
|
||||
canonical = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
canonical.parent.mkdir(parents=True)
|
||||
canonical.write_text("canonical")
|
||||
|
||||
legacy = settings_dir / "model_cache.sqlite"
|
||||
legacy.write_text("legacy")
|
||||
|
||||
files = get_legacy_cache_files_for_cleanup()
|
||||
assert str(legacy) in files
|
||||
|
||||
def test_cleanup_legacy_cache_files_dry_run(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create canonical and legacy files
|
||||
canonical = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
canonical.parent.mkdir(parents=True)
|
||||
canonical.write_text("canonical")
|
||||
|
||||
legacy = settings_dir / "model_cache.sqlite"
|
||||
legacy.write_text("legacy")
|
||||
|
||||
removed = cleanup_legacy_cache_files(dry_run=True)
|
||||
assert str(legacy) in removed
|
||||
# File should still exist (dry run)
|
||||
assert legacy.exists()
|
||||
|
||||
def test_cleanup_legacy_cache_files_actual(self, tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create canonical and legacy files
|
||||
canonical = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
canonical.parent.mkdir(parents=True)
|
||||
canonical.write_text("canonical")
|
||||
|
||||
legacy = settings_dir / "model_cache.sqlite"
|
||||
legacy.write_text("legacy")
|
||||
|
||||
removed = cleanup_legacy_cache_files(dry_run=False)
|
||||
assert str(legacy) in removed
|
||||
# File should be deleted
|
||||
assert not legacy.exists()
|
||||
|
||||
|
||||
class TestAutomaticCleanup:
|
||||
"""Tests for automatic cleanup during migration."""
|
||||
|
||||
def test_automatic_cleanup_on_migration(self, tmp_path, monkeypatch):
|
||||
"""Test that legacy files are automatically cleaned up after migration."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create a legacy cache file
|
||||
legacy_dir = settings_dir / "model_cache"
|
||||
legacy_dir.mkdir()
|
||||
legacy_file = legacy_dir / "default.sqlite"
|
||||
legacy_file.write_text("test data")
|
||||
|
||||
# Verify legacy file exists
|
||||
assert legacy_file.exists()
|
||||
|
||||
# Trigger migration (this should auto-cleanup)
|
||||
resolved_path = resolve_cache_path_with_migration(CacheType.MODEL, "default")
|
||||
|
||||
# Verify canonical file exists
|
||||
canonical_path = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
assert resolved_path == str(canonical_path)
|
||||
assert canonical_path.exists()
|
||||
assert canonical_path.read_text() == "test data"
|
||||
|
||||
# Verify legacy file was cleaned up
|
||||
assert not legacy_file.exists()
|
||||
|
||||
# Verify empty directory was cleaned up
|
||||
assert not legacy_dir.exists()
|
||||
|
||||
def test_automatic_cleanup_with_verification(self, tmp_path, monkeypatch):
|
||||
"""Test that cleanup verifies file integrity before deletion."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create legacy cache
|
||||
legacy_dir = settings_dir / "recipe_cache"
|
||||
legacy_dir.mkdir()
|
||||
legacy_file = legacy_dir / "my_library.sqlite"
|
||||
legacy_file.write_text("data")
|
||||
|
||||
# Trigger migration
|
||||
resolved_path = resolve_cache_path_with_migration(CacheType.RECIPE, "my_library")
|
||||
canonical_path = settings_dir / "cache" / "recipe" / "my_library.sqlite"
|
||||
|
||||
# Both should exist initially (migration successful)
|
||||
assert canonical_path.exists()
|
||||
assert legacy_file.exists() is False # Auto-cleanup removes it
|
||||
|
||||
# File content should match (integrity check)
|
||||
assert canonical_path.read_text() == "data"
|
||||
|
||||
# Directory should be cleaned up
|
||||
assert not legacy_dir.exists()
|
||||
|
||||
def test_automatic_cleanup_multiple_cache_types(self, tmp_path, monkeypatch):
|
||||
"""Test automatic cleanup for different cache types."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Test RECIPE_FTS migration
|
||||
legacy_fts = settings_dir / "recipe_fts.sqlite"
|
||||
legacy_fts.write_text("fts data")
|
||||
resolve_cache_path_with_migration(CacheType.RECIPE_FTS)
|
||||
canonical_fts = settings_dir / "cache" / "fts" / "recipe_fts.sqlite"
|
||||
|
||||
assert canonical_fts.exists()
|
||||
assert not legacy_fts.exists()
|
||||
|
||||
# Test TAG_FTS migration
|
||||
legacy_tag = settings_dir / "tag_fts.sqlite"
|
||||
legacy_tag.write_text("tag data")
|
||||
resolve_cache_path_with_migration(CacheType.TAG_FTS)
|
||||
canonical_tag = settings_dir / "cache" / "fts" / "tag_fts.sqlite"
|
||||
|
||||
assert canonical_tag.exists()
|
||||
assert not legacy_tag.exists()
|
||||
@@ -1,248 +0,0 @@
|
||||
"""Cache path migration tests."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.cache_paths import (
|
||||
CacheType,
|
||||
resolve_cache_path_with_migration,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveCachePathWithMigration:
|
||||
"""Tests for resolve_cache_path_with_migration function."""
|
||||
|
||||
def test_returns_env_override_when_set(self, tmp_path, monkeypatch):
|
||||
"""Test that env override takes precedence."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
override_path = "/custom/path/cache.sqlite"
|
||||
path = resolve_cache_path_with_migration(
|
||||
CacheType.MODEL,
|
||||
library_name="default",
|
||||
env_override=override_path,
|
||||
)
|
||||
assert path == override_path
|
||||
|
||||
def test_returns_canonical_path_when_exists(self, tmp_path, monkeypatch):
|
||||
"""Test that canonical path is returned when it exists."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create the canonical path
|
||||
canonical = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
canonical.parent.mkdir(parents=True)
|
||||
canonical.write_text("existing")
|
||||
|
||||
path = resolve_cache_path_with_migration(CacheType.MODEL, "default")
|
||||
assert path == str(canonical)
|
||||
|
||||
def test_migrates_from_legacy_root_level_cache(self, tmp_path, monkeypatch):
|
||||
"""Test migration from root-level legacy cache."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create legacy cache at root level
|
||||
legacy_path = settings_dir / "model_cache.sqlite"
|
||||
legacy_path.write_text("legacy data")
|
||||
|
||||
path = resolve_cache_path_with_migration(CacheType.MODEL, "default")
|
||||
|
||||
# Should return canonical path
|
||||
canonical = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
assert path == str(canonical)
|
||||
|
||||
# File should be copied to canonical location
|
||||
assert canonical.exists()
|
||||
assert canonical.read_text() == "legacy data"
|
||||
|
||||
# Legacy file should be automatically cleaned up
|
||||
assert not legacy_path.exists()
|
||||
|
||||
def test_migrates_from_legacy_per_library_cache(self, tmp_path, monkeypatch):
|
||||
"""Test migration from per-library legacy cache."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create legacy per-library cache
|
||||
legacy_dir = settings_dir / "model_cache"
|
||||
legacy_dir.mkdir()
|
||||
legacy_path = legacy_dir / "my_library.sqlite"
|
||||
legacy_path.write_text("legacy library data")
|
||||
|
||||
path = resolve_cache_path_with_migration(CacheType.MODEL, "my_library")
|
||||
|
||||
# Should return canonical path
|
||||
canonical = settings_dir / "cache" / "model" / "my_library.sqlite"
|
||||
assert path == str(canonical)
|
||||
assert canonical.exists()
|
||||
assert canonical.read_text() == "legacy library data"
|
||||
|
||||
# Legacy file should be automatically cleaned up
|
||||
assert not legacy_path.exists()
|
||||
|
||||
# Empty legacy directory should be cleaned up
|
||||
assert not legacy_dir.exists()
|
||||
|
||||
def test_prefers_per_library_over_root_for_migration(self, tmp_path, monkeypatch):
|
||||
"""Test that per-library cache is preferred over root for migration."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create both legacy caches
|
||||
legacy_root = settings_dir / "model_cache.sqlite"
|
||||
legacy_root.write_text("root legacy")
|
||||
|
||||
legacy_dir = settings_dir / "model_cache"
|
||||
legacy_dir.mkdir()
|
||||
legacy_lib = legacy_dir / "default.sqlite"
|
||||
legacy_lib.write_text("library legacy")
|
||||
|
||||
path = resolve_cache_path_with_migration(CacheType.MODEL, "default")
|
||||
|
||||
canonical = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
assert path == str(canonical)
|
||||
# Should migrate from per-library path (first in legacy list)
|
||||
assert canonical.read_text() == "library legacy"
|
||||
|
||||
def test_returns_canonical_path_when_no_legacy_exists(self, tmp_path, monkeypatch):
|
||||
"""Test that canonical path is returned when no legacy exists."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = resolve_cache_path_with_migration(CacheType.MODEL, "new_library")
|
||||
|
||||
canonical = settings_dir / "cache" / "model" / "new_library.sqlite"
|
||||
assert path == str(canonical)
|
||||
# Directory should be created
|
||||
assert canonical.parent.exists()
|
||||
# But file should not exist yet
|
||||
assert not canonical.exists()
|
||||
|
||||
|
||||
class TestAutomaticCleanup:
|
||||
"""Tests for automatic cleanup during migration."""
|
||||
|
||||
def test_automatic_cleanup_on_migration(self, tmp_path, monkeypatch):
|
||||
"""Test that legacy files are automatically cleaned up after migration."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create a legacy cache file
|
||||
legacy_dir = settings_dir / "model_cache"
|
||||
legacy_dir.mkdir()
|
||||
legacy_file = legacy_dir / "default.sqlite"
|
||||
legacy_file.write_text("test data")
|
||||
|
||||
# Verify legacy file exists
|
||||
assert legacy_file.exists()
|
||||
|
||||
# Trigger migration (this should auto-cleanup)
|
||||
resolved_path = resolve_cache_path_with_migration(CacheType.MODEL, "default")
|
||||
|
||||
# Verify canonical file exists
|
||||
canonical_path = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
assert resolved_path == str(canonical_path)
|
||||
assert canonical_path.exists()
|
||||
assert canonical_path.read_text() == "test data"
|
||||
|
||||
# Verify legacy file was cleaned up
|
||||
assert not legacy_file.exists()
|
||||
|
||||
# Verify empty directory was cleaned up
|
||||
assert not legacy_dir.exists()
|
||||
|
||||
def test_automatic_cleanup_with_verification(self, tmp_path, monkeypatch):
|
||||
"""Test that cleanup verifies file integrity before deletion."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create legacy cache
|
||||
legacy_dir = settings_dir / "recipe_cache"
|
||||
legacy_dir.mkdir()
|
||||
legacy_file = legacy_dir / "my_library.sqlite"
|
||||
legacy_file.write_text("data")
|
||||
|
||||
# Trigger migration
|
||||
resolved_path = resolve_cache_path_with_migration(CacheType.RECIPE, "my_library")
|
||||
canonical_path = settings_dir / "cache" / "recipe" / "my_library.sqlite"
|
||||
|
||||
# Both should exist initially (migration successful)
|
||||
assert canonical_path.exists()
|
||||
assert legacy_file.exists() is False # Auto-cleanup removes it
|
||||
|
||||
# File content should match (integrity check)
|
||||
assert canonical_path.read_text() == "data"
|
||||
|
||||
# Directory should be cleaned up
|
||||
assert not legacy_dir.exists()
|
||||
|
||||
def test_automatic_cleanup_multiple_cache_types(self, tmp_path, monkeypatch):
|
||||
"""Test automatic cleanup for different cache types."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Test RECIPE_FTS migration
|
||||
legacy_fts = settings_dir / "recipe_fts.sqlite"
|
||||
legacy_fts.write_text("fts data")
|
||||
resolve_cache_path_with_migration(CacheType.RECIPE_FTS)
|
||||
canonical_fts = settings_dir / "cache" / "fts" / "recipe_fts.sqlite"
|
||||
|
||||
assert canonical_fts.exists()
|
||||
assert not legacy_fts.exists()
|
||||
|
||||
# Test TAG_FTS migration
|
||||
legacy_tag = settings_dir / "tag_fts.sqlite"
|
||||
legacy_tag.write_text("tag data")
|
||||
resolve_cache_path_with_migration(CacheType.TAG_FTS)
|
||||
canonical_tag = settings_dir / "cache" / "fts" / "tag_fts.sqlite"
|
||||
|
||||
assert canonical_tag.exists()
|
||||
assert not legacy_tag.exists()
|
||||
@@ -1,149 +0,0 @@
|
||||
"""Cache path resolution tests."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.cache_paths import (
|
||||
CacheType,
|
||||
get_cache_base_dir,
|
||||
get_cache_file_path,
|
||||
)
|
||||
|
||||
|
||||
class TestCacheType:
|
||||
"""Tests for the CacheType enum."""
|
||||
|
||||
def test_enum_values(self):
|
||||
"""Test that CacheType enum has correct values."""
|
||||
assert CacheType.MODEL.value == "model"
|
||||
assert CacheType.RECIPE.value == "recipe"
|
||||
assert CacheType.RECIPE_FTS.value == "recipe_fts"
|
||||
assert CacheType.TAG_FTS.value == "tag_fts"
|
||||
assert CacheType.SYMLINK.value == "symlink"
|
||||
|
||||
|
||||
class TestGetCacheBaseDir:
|
||||
"""Tests for get_cache_base_dir function."""
|
||||
|
||||
def test_returns_cache_subdirectory(self):
|
||||
"""Test that cache base dir ends with 'cache'."""
|
||||
cache_dir = get_cache_base_dir(create=True)
|
||||
assert cache_dir.endswith("cache")
|
||||
assert os.path.isdir(cache_dir)
|
||||
|
||||
def test_creates_directory_when_requested(self, tmp_path, monkeypatch):
|
||||
"""Test that directory is created when requested."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
cache_dir = get_cache_base_dir(create=True)
|
||||
assert os.path.isdir(cache_dir)
|
||||
assert cache_dir == str(settings_dir / "cache")
|
||||
|
||||
|
||||
class TestGetCacheFilePath:
|
||||
"""Tests for get_cache_file_path function."""
|
||||
|
||||
def test_model_cache_path(self, tmp_path, monkeypatch):
|
||||
"""Test model cache file path generation."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.MODEL, "my_library", create_dir=True)
|
||||
expected = settings_dir / "cache" / "model" / "my_library.sqlite"
|
||||
assert path == str(expected)
|
||||
assert os.path.isdir(expected.parent)
|
||||
|
||||
def test_recipe_cache_path(self, tmp_path, monkeypatch):
|
||||
"""Test recipe cache file path generation."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.RECIPE, "default", create_dir=True)
|
||||
expected = settings_dir / "cache" / "recipe" / "default.sqlite"
|
||||
assert path == str(expected)
|
||||
|
||||
def test_recipe_fts_path(self, tmp_path, monkeypatch):
|
||||
"""Test recipe FTS cache file path generation."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.RECIPE_FTS, create_dir=True)
|
||||
expected = settings_dir / "cache" / "fts" / "recipe_fts.sqlite"
|
||||
assert path == str(expected)
|
||||
|
||||
def test_tag_fts_path(self, tmp_path, monkeypatch):
|
||||
"""Test tag FTS cache file path generation."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.TAG_FTS, create_dir=True)
|
||||
expected = settings_dir / "cache" / "fts" / "tag_fts.sqlite"
|
||||
assert path == str(expected)
|
||||
|
||||
def test_symlink_path(self, tmp_path, monkeypatch):
|
||||
"""Test symlink cache file path generation."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.SYMLINK, create_dir=True)
|
||||
expected = settings_dir / "cache" / "symlink" / "symlink_map.json"
|
||||
assert path == str(expected)
|
||||
|
||||
def test_sanitizes_library_name(self, tmp_path, monkeypatch):
|
||||
"""Test that library names are sanitized in paths."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.MODEL, "my/bad:name", create_dir=True)
|
||||
assert "my_bad_name" in path
|
||||
|
||||
def test_none_library_name_defaults_to_default(self, tmp_path, monkeypatch):
|
||||
"""Test that None library name defaults to 'default'."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
path = get_cache_file_path(CacheType.MODEL, None, create_dir=True)
|
||||
assert "default.sqlite" in path
|
||||
@@ -1,174 +0,0 @@
|
||||
"""Cache path validation tests."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.cache_paths import (
|
||||
CacheType,
|
||||
get_legacy_cache_paths,
|
||||
get_legacy_cache_files_for_cleanup,
|
||||
cleanup_legacy_cache_files,
|
||||
)
|
||||
|
||||
|
||||
class TestGetLegacyCachePaths:
|
||||
"""Tests for get_legacy_cache_paths function."""
|
||||
|
||||
def test_model_legacy_paths_for_default(self, tmp_path, monkeypatch):
|
||||
"""Test legacy paths for default model cache."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
paths = get_legacy_cache_paths(CacheType.MODEL, "default")
|
||||
assert len(paths) == 2
|
||||
assert str(settings_dir / "model_cache" / "default.sqlite") in paths
|
||||
assert str(settings_dir / "model_cache.sqlite") in paths
|
||||
|
||||
def test_model_legacy_paths_for_named_library(self, tmp_path, monkeypatch):
|
||||
"""Test legacy paths for named model library."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
paths = get_legacy_cache_paths(CacheType.MODEL, "my_library")
|
||||
assert len(paths) == 1
|
||||
assert str(settings_dir / "model_cache" / "my_library.sqlite") in paths
|
||||
|
||||
def test_recipe_legacy_paths(self, tmp_path, monkeypatch):
|
||||
"""Test legacy paths for recipe cache."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
paths = get_legacy_cache_paths(CacheType.RECIPE, "default")
|
||||
assert len(paths) == 2
|
||||
assert str(settings_dir / "recipe_cache" / "default.sqlite") in paths
|
||||
assert str(settings_dir / "recipe_cache.sqlite") in paths
|
||||
|
||||
def test_recipe_fts_legacy_path(self, tmp_path, monkeypatch):
|
||||
"""Test legacy path for recipe FTS cache."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
paths = get_legacy_cache_paths(CacheType.RECIPE_FTS)
|
||||
assert len(paths) == 1
|
||||
assert str(settings_dir / "recipe_fts.sqlite") in paths
|
||||
|
||||
def test_tag_fts_legacy_path(self, tmp_path, monkeypatch):
|
||||
"""Test legacy path for tag FTS cache."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
paths = get_legacy_cache_paths(CacheType.TAG_FTS)
|
||||
assert len(paths) == 1
|
||||
assert str(settings_dir / "tag_fts.sqlite") in paths
|
||||
|
||||
def test_symlink_legacy_path(self, tmp_path, monkeypatch):
|
||||
"""Test legacy path for symlink cache."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
paths = get_legacy_cache_paths(CacheType.SYMLINK)
|
||||
assert len(paths) == 1
|
||||
assert str(settings_dir / "cache" / "symlink_map.json") in paths
|
||||
|
||||
|
||||
class TestLegacyCacheCleanup:
|
||||
"""Tests for legacy cache cleanup functions."""
|
||||
|
||||
def test_get_legacy_cache_files_for_cleanup(self, tmp_path, monkeypatch):
|
||||
"""Test detection of legacy cache files for cleanup."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create canonical and legacy files
|
||||
canonical = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
canonical.parent.mkdir(parents=True)
|
||||
canonical.write_text("canonical")
|
||||
|
||||
legacy = settings_dir / "model_cache.sqlite"
|
||||
legacy.write_text("legacy")
|
||||
|
||||
files = get_legacy_cache_files_for_cleanup()
|
||||
assert str(legacy) in files
|
||||
|
||||
def test_cleanup_legacy_cache_files_dry_run(self, tmp_path, monkeypatch):
|
||||
"""Test dry run cleanup does not delete files."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create canonical and legacy files
|
||||
canonical = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
canonical.parent.mkdir(parents=True)
|
||||
canonical.write_text("canonical")
|
||||
|
||||
legacy = settings_dir / "model_cache.sqlite"
|
||||
legacy.write_text("legacy")
|
||||
|
||||
removed = cleanup_legacy_cache_files(dry_run=True)
|
||||
assert str(legacy) in removed
|
||||
# File should still exist (dry run)
|
||||
assert legacy.exists()
|
||||
|
||||
def test_cleanup_legacy_cache_files_actual(self, tmp_path, monkeypatch):
|
||||
"""Test actual cleanup deletes legacy files."""
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
|
||||
def fake_get_settings_dir(create: bool = True) -> str:
|
||||
return str(settings_dir)
|
||||
|
||||
monkeypatch.setattr("py.utils.cache_paths.get_settings_dir", fake_get_settings_dir)
|
||||
|
||||
# Create canonical and legacy files
|
||||
canonical = settings_dir / "cache" / "model" / "default.sqlite"
|
||||
canonical.parent.mkdir(parents=True)
|
||||
canonical.write_text("canonical")
|
||||
|
||||
legacy = settings_dir / "model_cache.sqlite"
|
||||
legacy.write_text("legacy")
|
||||
|
||||
removed = cleanup_legacy_cache_files(dry_run=False)
|
||||
assert str(legacy) in removed
|
||||
# File should be deleted
|
||||
assert not legacy.exists()
|
||||
@@ -203,150 +203,3 @@ async def test_pause_or_resume_without_running_download(
|
||||
|
||||
with pytest.raises(download_module.DownloadNotRunningError):
|
||||
await manager.stop_download(object())
|
||||
|
||||
|
||||
async def test_download_task_callback_executes_on_completion(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""Test that _handle_download_task_done callback is executed when download completes."""
|
||||
settings_manager = get_settings_manager()
|
||||
settings_manager.settings["example_images_path"] = str(tmp_path)
|
||||
settings_manager.settings["libraries"] = {"default": {}}
|
||||
settings_manager.settings["active_library"] = "default"
|
||||
|
||||
ws_manager = RecordingWebSocketManager()
|
||||
manager = download_module.DownloadManager(ws_manager=ws_manager)
|
||||
|
||||
callback_executed = asyncio.Event()
|
||||
original_callback = manager._handle_download_task_done
|
||||
|
||||
def tracking_callback(task, output_dir):
|
||||
original_callback(task, output_dir)
|
||||
callback_executed.set()
|
||||
|
||||
monkeypatch.setattr(
|
||||
manager, "_handle_download_task_done", tracking_callback
|
||||
)
|
||||
|
||||
async def fake_download(self, *_args):
|
||||
# Simulate successful completion
|
||||
async with self._state_lock:
|
||||
self._progress["status"] = "completed"
|
||||
self._is_downloading = False
|
||||
self._download_task = None
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_module.DownloadManager,
|
||||
"_download_all_example_images",
|
||||
fake_download,
|
||||
)
|
||||
|
||||
result = await manager.start_download({"model_types": ["lora"], "delay": 0})
|
||||
assert result["success"] is True
|
||||
|
||||
# Wait for callback to execute
|
||||
await asyncio.wait_for(callback_executed.wait(), timeout=1)
|
||||
assert manager._progress["status"] == "completed"
|
||||
|
||||
|
||||
async def test_download_task_callback_handles_errors(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""Test that _handle_download_task_done properly handles task errors and saves progress."""
|
||||
settings_manager = get_settings_manager()
|
||||
settings_manager.settings["example_images_path"] = str(tmp_path)
|
||||
settings_manager.settings["libraries"] = {"default": {}}
|
||||
settings_manager.settings["active_library"] = "default"
|
||||
|
||||
ws_manager = RecordingWebSocketManager()
|
||||
manager = download_module.DownloadManager(ws_manager=ws_manager)
|
||||
|
||||
callback_executed = asyncio.Event()
|
||||
progress_saved = False
|
||||
original_save_progress = manager._save_progress
|
||||
|
||||
def tracking_save_progress(output_dir):
|
||||
nonlocal progress_saved
|
||||
progress_saved = True
|
||||
return original_save_progress(output_dir)
|
||||
|
||||
monkeypatch.setattr(manager, "_save_progress", tracking_save_progress)
|
||||
|
||||
original_callback = manager._handle_download_task_done
|
||||
|
||||
def tracking_callback(task, output_dir):
|
||||
original_callback(task, output_dir)
|
||||
callback_executed.set()
|
||||
|
||||
monkeypatch.setattr(
|
||||
manager, "_handle_download_task_done", tracking_callback
|
||||
)
|
||||
|
||||
async def fake_download_with_error(self, *_args):
|
||||
raise RuntimeError("Simulated download error")
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_module.DownloadManager,
|
||||
"_download_all_example_images",
|
||||
fake_download_with_error,
|
||||
)
|
||||
|
||||
result = await manager.start_download({"model_types": ["lora"], "delay": 0})
|
||||
assert result["success"] is True
|
||||
|
||||
# Wait for callback to execute (it should handle the error)
|
||||
await asyncio.wait_for(callback_executed.wait(), timeout=1)
|
||||
# Progress should be saved even on error
|
||||
assert progress_saved is True
|
||||
|
||||
|
||||
async def test_get_status_returns_correct_state(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""Test that get_status returns the correct download state."""
|
||||
settings_manager = get_settings_manager()
|
||||
settings_manager.settings["example_images_path"] = str(tmp_path)
|
||||
settings_manager.settings["libraries"] = {"default": {}}
|
||||
settings_manager.settings["active_library"] = "default"
|
||||
|
||||
ws_manager = RecordingWebSocketManager()
|
||||
manager = download_module.DownloadManager(ws_manager=ws_manager)
|
||||
|
||||
# Test idle state
|
||||
status = await manager.get_status(object())
|
||||
assert status["success"] is True
|
||||
assert status["is_downloading"] is False
|
||||
assert status["status"]["status"] == "idle"
|
||||
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def fake_download(self, *_args):
|
||||
started.set()
|
||||
await release.wait()
|
||||
async with self._state_lock:
|
||||
self._is_downloading = False
|
||||
self._download_task = None
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_module.DownloadManager,
|
||||
"_download_all_example_images",
|
||||
fake_download,
|
||||
)
|
||||
|
||||
# Start download
|
||||
result = await manager.start_download({"model_types": ["lora"], "delay": 0})
|
||||
assert result["success"] is True
|
||||
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
|
||||
# Test running state
|
||||
status = await manager.get_status(object())
|
||||
assert status["success"] is True
|
||||
assert status["is_downloading"] is True
|
||||
assert status["status"]["status"] == "running"
|
||||
|
||||
# Cleanup
|
||||
release.set()
|
||||
if manager._download_task:
|
||||
await asyncio.wait_for(manager._download_task, timeout=1)
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
"""Property-based tests using Hypothesis.
|
||||
|
||||
These tests verify fundamental properties of utility functions using
|
||||
property-based testing to catch edge cases and ensure correctness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings, strategies as st
|
||||
|
||||
from py.utils.cache_paths import _sanitize_library_name
|
||||
from py.utils.file_utils import get_preview_extension, normalize_path
|
||||
from py.utils.model_utils import determine_base_model
|
||||
from py.utils.utils import (
|
||||
calculate_recipe_fingerprint,
|
||||
fuzzy_match,
|
||||
sanitize_folder_name,
|
||||
)
|
||||
|
||||
|
||||
class TestSanitizeFolderName:
|
||||
"""Property-based tests for sanitize_folder_name function."""
|
||||
|
||||
@given(st.text(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- '))
|
||||
def test_sanitize_is_idempotent_for_ascii(self, name: str):
|
||||
"""Sanitizing an already sanitized ASCII name should not change it."""
|
||||
sanitized = sanitize_folder_name(name)
|
||||
resanitized = sanitize_folder_name(sanitized)
|
||||
assert sanitized == resanitized
|
||||
|
||||
@given(st.text())
|
||||
def test_sanitize_never_contains_invalid_chars(self, name: str):
|
||||
"""Sanitized names should never contain filesystem-invalid characters."""
|
||||
sanitized = sanitize_folder_name(name)
|
||||
invalid_chars = '<>:"/\\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08'
|
||||
for char in invalid_chars:
|
||||
assert char not in sanitized
|
||||
|
||||
@given(st.text())
|
||||
def test_sanitize_never_returns_none(self, name: str):
|
||||
"""Sanitize should never return None (always returns a string)."""
|
||||
result = sanitize_folder_name(name)
|
||||
assert result is not None
|
||||
assert isinstance(result, str)
|
||||
|
||||
@given(st.text(min_size=1))
|
||||
def test_sanitize_preserves_some_content(self, name: str):
|
||||
"""Sanitizing a non-empty string should not produce an empty result
|
||||
unless the input was only invalid characters."""
|
||||
result = sanitize_folder_name(name)
|
||||
# If input had valid characters, output should not be empty
|
||||
has_valid_chars = any(c.isalnum() or c in '._-' for c in name)
|
||||
if has_valid_chars:
|
||||
assert result != ""
|
||||
|
||||
|
||||
class TestSanitizeLibraryName:
|
||||
"""Property-based tests for _sanitize_library_name function."""
|
||||
|
||||
@given(st.text() | st.none())
|
||||
def test_sanitize_library_name_is_idempotent(self, library_name: str | None):
|
||||
"""Sanitizing an already sanitized library name should not change it."""
|
||||
sanitized = _sanitize_library_name(library_name)
|
||||
resanitized = _sanitize_library_name(sanitized)
|
||||
assert sanitized == resanitized
|
||||
|
||||
@given(st.text())
|
||||
def test_sanitize_library_name_only_contains_safe_chars(self, library_name: str):
|
||||
"""Sanitized library names should only contain safe filename characters."""
|
||||
sanitized = _sanitize_library_name(library_name)
|
||||
# Should only contain alphanumeric, underscore, dot, and hyphen
|
||||
for char in sanitized:
|
||||
assert char.isalnum() or char in '._-'
|
||||
|
||||
|
||||
class TestNormalizePath:
|
||||
"""Property-based tests for normalize_path function."""
|
||||
|
||||
@given(st.text(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/\\') | st.none())
|
||||
def test_normalize_path_is_idempotent_for_ascii(self, path: str | None):
|
||||
"""Normalizing an already normalized ASCII path should not change it."""
|
||||
normalized = normalize_path(path)
|
||||
renormalized = normalize_path(normalized)
|
||||
assert normalized == renormalized
|
||||
|
||||
@given(st.text())
|
||||
def test_normalized_path_returns_string(self, path: str):
|
||||
"""Normalized path should always return a string (or None)."""
|
||||
normalized = normalize_path(path)
|
||||
# Result is either None or a string
|
||||
assert normalized is None or isinstance(normalized, str)
|
||||
|
||||
|
||||
class TestFuzzyMatch:
|
||||
"""Property-based tests for fuzzy_match function."""
|
||||
|
||||
@given(st.text(), st.text())
|
||||
def test_fuzzy_match_empty_pattern_returns_false(self, text: str, pattern: str):
|
||||
"""Empty pattern should never match (except empty text with exact match)."""
|
||||
if not pattern:
|
||||
result = fuzzy_match(text, pattern)
|
||||
assert result is False
|
||||
|
||||
@given(st.text(min_size=1), st.text(min_size=1))
|
||||
def test_fuzzy_match_exact_substring_always_matches(self, text: str, pattern: str):
|
||||
"""If pattern is a substring of text (case-insensitive), it should match."""
|
||||
# Create a case where pattern is definitely in text
|
||||
combined = text.lower() + " " + pattern.lower()
|
||||
result = fuzzy_match(combined, pattern.lower())
|
||||
assert result is True
|
||||
|
||||
@given(st.text(min_size=1), st.text(min_size=1))
|
||||
def test_fuzzy_match_substring_always_matches(self, text: str, pattern: str):
|
||||
"""If pattern is a substring of text, it should always match."""
|
||||
if pattern in text:
|
||||
result = fuzzy_match(text, pattern)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestDetermineBaseModel:
|
||||
"""Property-based tests for determine_base_model function."""
|
||||
|
||||
@given(st.text() | st.none())
|
||||
def test_determine_base_model_never_returns_none(self, version_string: str | None):
|
||||
"""Function should never return None (always returns a string)."""
|
||||
result = determine_base_model(version_string)
|
||||
assert result is not None
|
||||
assert isinstance(result, str)
|
||||
|
||||
@given(st.text())
|
||||
def test_determine_base_model_case_insensitive(self, version: str):
|
||||
"""Base model detection should be case-insensitive."""
|
||||
lower_result = determine_base_model(version.lower())
|
||||
upper_result = determine_base_model(version.upper())
|
||||
# Results should be the same for known mappings
|
||||
if version.lower() in ['sdxl', 'sd_1.5', 'pony', 'flux1']:
|
||||
assert lower_result == upper_result
|
||||
|
||||
|
||||
class TestGetPreviewExtension:
|
||||
"""Property-based tests for get_preview_extension function."""
|
||||
|
||||
@given(st.text())
|
||||
def test_get_preview_extension_returns_string(self, preview_path: str):
|
||||
"""Function should always return a string."""
|
||||
result = get_preview_extension(preview_path)
|
||||
assert isinstance(result, str)
|
||||
|
||||
@given(st.text(alphabet='abcdefghijklmnopqrstuvwxyz._'))
|
||||
def test_get_preview_extension_starts_with_dot(self, preview_path: str):
|
||||
"""Extension should always start with a dot for valid paths."""
|
||||
if '.' in preview_path:
|
||||
result = get_preview_extension(preview_path)
|
||||
if result:
|
||||
assert result.startswith('.')
|
||||
|
||||
|
||||
class TestCalculateRecipeFingerprint:
|
||||
"""Property-based tests for calculate_recipe_fingerprint function."""
|
||||
|
||||
@given(st.lists(st.dictionaries(st.text(), st.text() | st.integers() | st.floats(), min_size=1), min_size=0, max_size=50))
|
||||
def test_fingerprint_is_deterministic(self, loras: list):
|
||||
"""Same input should always produce same fingerprint."""
|
||||
fp1 = calculate_recipe_fingerprint(loras)
|
||||
fp2 = calculate_recipe_fingerprint(loras)
|
||||
assert fp1 == fp2
|
||||
|
||||
@given(st.lists(st.dictionaries(st.text(), st.text() | st.integers() | st.floats(), min_size=1), min_size=0, max_size=50))
|
||||
def test_fingerprint_returns_string(self, loras: list):
|
||||
"""Function should always return a string."""
|
||||
result = calculate_recipe_fingerprint(loras)
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_fingerprint_empty_list_returns_empty_string(self):
|
||||
"""Empty list should return empty string."""
|
||||
result = calculate_recipe_fingerprint([])
|
||||
assert result == ""
|
||||
|
||||
@given(st.lists(st.dictionaries(st.text(), st.text() | st.integers() | st.floats(), min_size=1), min_size=1, max_size=10))
|
||||
def test_fingerprint_different_inputs_produce_different_results(self, loras1: list):
|
||||
"""Different inputs should generally produce different fingerprints."""
|
||||
# Create a different input by modifying the first LoRA
|
||||
loras2 = loras1.copy()
|
||||
if loras2:
|
||||
loras2[0] = {**loras2[0], 'hash': 'different_hash_12345'}
|
||||
|
||||
fp1 = calculate_recipe_fingerprint(loras1)
|
||||
fp2 = calculate_recipe_fingerprint(loras2)
|
||||
|
||||
# If the first LoRA had a hash, fingerprints should differ
|
||||
if loras1 and loras1[0].get('hash'):
|
||||
assert fp1 != fp2
|
||||
@@ -117,16 +117,6 @@ onMounted(() => {
|
||||
// Register textarea reference with widget
|
||||
if (textareaRef.value) {
|
||||
props.widget.inputEl = textareaRef.value
|
||||
|
||||
// Also store on the container element for cloned widgets (subgraph promotion)
|
||||
// When widgets are promoted to subgraph nodes, the cloned widget shares the same
|
||||
// DOM element but has its own inputEl property. We store the reference on the
|
||||
// container so both original and cloned widgets can access it.
|
||||
const container = textareaRef.value.closest('[id^="autocomplete-text-widget-"]') as HTMLElement
|
||||
if (container && (container as any).__widgetInputEl) {
|
||||
(container as any).__widgetInputEl.inputEl = textareaRef.value
|
||||
}
|
||||
|
||||
// Initialize hasText state
|
||||
hasText.value = textareaRef.value.value.length > 0
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div ref="tagsContainerRef" class="tags-container" @scroll="handleScroll">
|
||||
<div class="tags-container">
|
||||
<button
|
||||
v-for="tag in visibleTags"
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.tag"
|
||||
type="button"
|
||||
class="tag-chip"
|
||||
@@ -42,12 +42,9 @@
|
||||
>
|
||||
{{ tag.tag }}
|
||||
</button>
|
||||
<div v-if="visibleTags.length === 0" class="no-results">
|
||||
<div v-if="filteredTags.length === 0" class="no-results">
|
||||
No tags found
|
||||
</div>
|
||||
<div v-if="hasMoreTags" class="load-more-hint">
|
||||
Scroll to load more...
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -81,11 +78,6 @@ const subtitle = computed(() =>
|
||||
|
||||
const searchQuery = ref('')
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
const tagsContainerRef = ref<HTMLElement | null>(null)
|
||||
const displayedCount = ref(200)
|
||||
|
||||
const BATCH_SIZE = 200
|
||||
const SCROLL_THRESHOLD = 100
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
if (!searchQuery.value) {
|
||||
@@ -95,20 +87,6 @@ const filteredTags = computed(() => {
|
||||
return props.tags.filter(t => t.tag.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
const visibleTags = computed(() => {
|
||||
// When searching, show all filtered results
|
||||
if (searchQuery.value) {
|
||||
return filteredTags.value
|
||||
}
|
||||
// Otherwise, use virtual scrolling
|
||||
return filteredTags.value.slice(0, displayedCount.value)
|
||||
})
|
||||
|
||||
const hasMoreTags = computed(() => {
|
||||
if (searchQuery.value) return false
|
||||
return displayedCount.value < filteredTags.value.length
|
||||
})
|
||||
|
||||
const isSelected = (tag: string) => {
|
||||
return props.selected.includes(tag)
|
||||
}
|
||||
@@ -122,40 +100,16 @@ const toggleTag = (tag: string) => {
|
||||
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
displayedCount.value = BATCH_SIZE
|
||||
searchInputRef.value?.focus()
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (searchQuery.value) return
|
||||
|
||||
const container = tagsContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const scrollBottom = scrollHeight - scrollTop - clientHeight
|
||||
|
||||
// Load more tags when user scrolls near bottom
|
||||
if (scrollBottom < SCROLL_THRESHOLD && hasMoreTags.value) {
|
||||
displayedCount.value = Math.min(
|
||||
displayedCount.value + BATCH_SIZE,
|
||||
filteredTags.value.length
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, (isVisible) => {
|
||||
if (isVisible) {
|
||||
displayedCount.value = BATCH_SIZE
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.tags, () => {
|
||||
displayedCount.value = BATCH_SIZE
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -291,14 +245,4 @@ watch(() => props.tags, () => {
|
||||
opacity: 0.5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.load-more-hint {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.4;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function useLoraPoolApi() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTags = async (limit = 0): Promise<TagOption[]> => {
|
||||
const fetchTags = async (limit = 100): Promise<TagOption[]> => {
|
||||
try {
|
||||
const response = await fetch(`/api/lm/loras/top-tags?limit=${limit}`)
|
||||
const data = await response.json()
|
||||
|
||||
@@ -405,29 +405,19 @@ function createAutocompleteTextWidgetFactory(
|
||||
|
||||
forwardMiddleMouseToCanvas(container)
|
||||
|
||||
// Store textarea reference on the container element so cloned widgets can access it
|
||||
// This is necessary because when widgets are promoted to subgraph nodes,
|
||||
// the cloned widget shares the same element but needs access to inputEl
|
||||
const widgetElementRef = { inputEl: undefined as HTMLTextAreaElement | undefined }
|
||||
;(container as any).__widgetInputEl = widgetElementRef
|
||||
|
||||
const widget = node.addDOMWidget(
|
||||
widgetName,
|
||||
`AUTOCOMPLETE_TEXT_${modelType.toUpperCase()}`,
|
||||
container,
|
||||
{
|
||||
getValue() {
|
||||
// Access inputEl from widget or from the shared element reference
|
||||
const inputEl = widget.inputEl ?? (container as any).__widgetInputEl?.inputEl
|
||||
return inputEl?.value ?? ''
|
||||
return widget.inputEl?.value ?? ''
|
||||
},
|
||||
setValue(v: string) {
|
||||
// Access inputEl from widget or from the shared element reference
|
||||
const inputEl = widget.inputEl ?? (container as any).__widgetInputEl?.inputEl
|
||||
if (inputEl) {
|
||||
inputEl.value = v ?? ''
|
||||
if (widget.inputEl) {
|
||||
widget.inputEl.value = v ?? ''
|
||||
// Notify Vue component of value change via custom event
|
||||
inputEl.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
|
||||
widget.inputEl.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
|
||||
detail: { value: v ?? '' }
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -35,16 +35,12 @@
|
||||
font-size: 13px;
|
||||
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
line-height: 1.4;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lm-tooltip__license-overlay {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user