Compare commits

..

1 Commits

Author SHA1 Message Date
Will Miao
0a340d397c feat(misc): add VAE and Upscaler model management page 2026-01-31 07:28:10 +08:00
147 changed files with 2041 additions and 19067 deletions

View File

@@ -1,201 +0,0 @@
---
name: lora-manager-e2e
description: End-to-end testing and validation for LoRa Manager features. Use when performing automated E2E validation of LoRa Manager standalone mode, including starting/restarting the server, using Chrome DevTools MCP to interact with the web UI at http://127.0.0.1:8188/loras, and verifying frontend-to-backend functionality. Covers workflow validation, UI interaction testing, and integration testing between the standalone Python backend and the browser frontend.
---
# LoRa Manager E2E Testing
This skill provides workflows and utilities for end-to-end testing of LoRa Manager using Chrome DevTools MCP.
## Prerequisites
- LoRa Manager project cloned and dependencies installed (`pip install -r requirements.txt`)
- Chrome browser available for debugging
- Chrome DevTools MCP connected
## Quick Start Workflow
### 1. Start LoRa Manager Standalone
```python
# Use the provided script to start the server
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port 8188
```
Or manually:
```bash
cd /home/miao/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager
python standalone.py --port 8188
```
Wait for server ready message before proceeding.
### 2. Open Chrome Debug Mode
```bash
# Chrome with remote debugging on port 9222
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:8188/loras
```
### 3. Connect Chrome DevTools MCP
Ensure the MCP server is connected to Chrome at `http://localhost:9222`.
### 4. Navigate and Interact
Use Chrome DevTools MCP tools to:
- Take snapshots: `take_snapshot`
- Click elements: `click`
- Fill forms: `fill` or `fill_form`
- Evaluate scripts: `evaluate_script`
- Wait for elements: `wait_for`
## Common E2E Test Patterns
### Pattern: Full Page Load Verification
```python
# Navigate to LoRA list page
navigate_page(type="url", url="http://127.0.0.1:8188/loras")
# Wait for page to load
wait_for(text="LoRAs", timeout=10000)
# Take snapshot to verify UI state
snapshot = take_snapshot()
```
### Pattern: Restart Server for Configuration Changes
```python
# Stop current server (if running)
# Start with new configuration
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port 8188 --restart
# Wait and refresh browser
navigate_page(type="reload", ignoreCache=True)
wait_for(text="LoRAs", timeout=15000)
```
### Pattern: Verify Backend API via Frontend
```python
# Execute script in browser to call backend API
result = evaluate_script(function="""
async () => {
const response = await fetch('/loras/api/list');
const data = await response.json();
return { count: data.length, firstItem: data[0]?.name };
}
""")
```
### Pattern: Form Submission Flow
```python
# Fill a form (e.g., search or filter)
fill_form(elements=[
{"uid": "search-input", "value": "character"},
])
# Click submit button
click(uid="search-button")
# Wait for results
wait_for(text="Results", timeout=5000)
# Verify results via snapshot
snapshot = take_snapshot()
```
### Pattern: Modal Dialog Interaction
```python
# Open modal (e.g., add LoRA)
click(uid="add-lora-button")
# Wait for modal to appear
wait_for(text="Add LoRA", timeout=3000)
# Fill modal form
fill_form(elements=[
{"uid": "lora-name", "value": "Test LoRA"},
{"uid": "lora-path", "value": "/path/to/lora.safetensors"},
])
# Submit
click(uid="modal-submit-button")
# Wait for success message or close
wait_for(text="Success", timeout=5000)
```
## Available Scripts
### scripts/start_server.py
Starts or restarts the LoRa Manager standalone server.
```bash
python scripts/start_server.py [--port PORT] [--restart] [--wait]
```
Options:
- `--port`: Server port (default: 8188)
- `--restart`: Kill existing server before starting
- `--wait`: Wait for server to be ready before exiting
### scripts/wait_for_server.py
Polls server until ready or timeout.
```bash
python scripts/wait_for_server.py [--port PORT] [--timeout SECONDS]
```
## Test Scenarios Reference
See [references/test-scenarios.md](references/test-scenarios.md) for detailed test scenarios including:
- LoRA list display and filtering
- Model metadata editing
- Recipe creation and management
- Settings configuration
- Import/export functionality
## Network Request Verification
Use `list_network_requests` and `get_network_request` to verify API calls:
```python
# List recent XHR/fetch requests
requests = list_network_requests(resourceTypes=["xhr", "fetch"])
# Get details of specific request
details = get_network_request(reqid=123)
```
## Console Message Monitoring
```python
# Check for errors or warnings
messages = list_console_messages(types=["error", "warn"])
```
## Performance Testing
```python
# Start performance trace
performance_start_trace(reload=True, autoStop=False)
# Perform actions...
# Stop and analyze
results = performance_stop_trace()
```
## Cleanup
Always ensure proper cleanup after tests:
1. Stop the standalone server
2. Close browser pages (keep at least one open)
3. Clear temporary data if needed

View File

@@ -1,324 +0,0 @@
# Chrome DevTools MCP Cheatsheet for LoRa Manager
Quick reference for common MCP commands used in LoRa Manager E2E testing.
## Navigation
```python
# Navigate to LoRA list page
navigate_page(type="url", url="http://127.0.0.1:8188/loras")
# Reload page with cache clear
navigate_page(type="reload", ignoreCache=True)
# Go back/forward
navigate_page(type="back")
navigate_page(type="forward")
```
## Waiting
```python
# Wait for text to appear
wait_for(text="LoRAs", timeout=10000)
# Wait for specific element (via evaluate_script)
evaluate_script(function="""
() => {
return new Promise((resolve) => {
const check = () => {
if (document.querySelector('.lora-card')) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
});
}
""")
```
## Taking Snapshots
```python
# Full page snapshot
snapshot = take_snapshot()
# Verbose snapshot (more details)
snapshot = take_snapshot(verbose=True)
# Save to file
take_snapshot(filePath="test-snapshots/page-load.json")
```
## Element Interaction
```python
# Click element
click(uid="element-uid-from-snapshot")
# Double click
click(uid="element-uid", dblClick=True)
# Fill input
fill(uid="search-input", value="test query")
# Fill multiple inputs
fill_form(elements=[
{"uid": "input-1", "value": "value 1"},
{"uid": "input-2", "value": "value 2"},
])
# Hover
hover(uid="lora-card-1")
# Upload file
upload_file(uid="file-input", filePath="/path/to/file.safetensors")
```
## Keyboard Input
```python
# Press key
press_key(key="Enter")
press_key(key="Escape")
press_key(key="Tab")
# Keyboard shortcuts
press_key(key="Control+A") # Select all
press_key(key="Control+F") # Find
```
## JavaScript Evaluation
```python
# Simple evaluation
result = evaluate_script(function="() => document.title")
# Async evaluation
result = evaluate_script(function="""
async () => {
const response = await fetch('/loras/api/list');
return await response.json();
}
""")
# Check element existence
exists = evaluate_script(function="""
() => document.querySelector('.lora-card') !== null
""")
# Get element count
count = evaluate_script(function="""
() => document.querySelectorAll('.lora-card').length
""")
```
## Network Monitoring
```python
# List all network requests
requests = list_network_requests()
# Filter by resource type
xhr_requests = list_network_requests(resourceTypes=["xhr", "fetch"])
# Get specific request details
details = get_network_request(reqid=123)
# Include preserved requests from previous navigations
all_requests = list_network_requests(includePreservedRequests=True)
```
## Console Monitoring
```python
# List all console messages
messages = list_console_messages()
# Filter by type
errors = list_console_messages(types=["error", "warn"])
# Include preserved messages
all_messages = list_console_messages(includePreservedMessages=True)
# Get specific message
details = get_console_message(msgid=1)
```
## Performance Testing
```python
# Start trace with page reload
performance_start_trace(reload=True, autoStop=False)
# Start trace without reload
performance_start_trace(reload=False, autoStop=True, filePath="trace.json.gz")
# Stop trace
results = performance_stop_trace()
# Stop and save
performance_stop_trace(filePath="trace-results.json.gz")
# Analyze specific insight
insight = performance_analyze_insight(
insightSetId="results.insightSets[0].id",
insightName="LCPBreakdown"
)
```
## Page Management
```python
# List open pages
pages = list_pages()
# Select a page
select_page(pageId=0, bringToFront=True)
# Create new page
new_page(url="http://127.0.0.1:8188/loras")
# Close page (keep at least one open!)
close_page(pageId=1)
# Resize page
resize_page(width=1920, height=1080)
```
## Screenshots
```python
# Full page screenshot
take_screenshot(fullPage=True)
# Viewport screenshot
take_screenshot()
# Element screenshot
take_screenshot(uid="lora-card-1")
# Save to file
take_screenshot(filePath="screenshots/page.png", format="png")
# JPEG with quality
take_screenshot(filePath="screenshots/page.jpg", format="jpeg", quality=90)
```
## Dialog Handling
```python
# Accept dialog
handle_dialog(action="accept")
# Accept with text input
handle_dialog(action="accept", promptText="user input")
# Dismiss dialog
handle_dialog(action="dismiss")
```
## Device Emulation
```python
# Mobile viewport
emulate(viewport={"width": 375, "height": 667, "isMobile": True, "hasTouch": True})
# Tablet viewport
emulate(viewport={"width": 768, "height": 1024, "isMobile": True, "hasTouch": True})
# Desktop viewport
emulate(viewport={"width": 1920, "height": 1080})
# Network throttling
emulate(networkConditions="Slow 3G")
emulate(networkConditions="Fast 4G")
# CPU throttling
emulate(cpuThrottlingRate=4) # 4x slowdown
# Geolocation
emulate(geolocation={"latitude": 37.7749, "longitude": -122.4194})
# User agent
emulate(userAgent="Mozilla/5.0 (Custom)")
# Reset emulation
emulate(viewport=None, networkConditions="No emulation", userAgent=None)
```
## Drag and Drop
```python
# Drag element to another
drag(from_uid="draggable-item", to_uid="drop-zone")
```
## Common LoRa Manager Test Patterns
### Verify LoRA Cards Loaded
```python
navigate_page(type="url", url="http://127.0.0.1:8188/loras")
wait_for(text="LoRAs", timeout=10000)
# Check if cards loaded
result = evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
return {
count: cards.length,
hasData: cards.length > 0
};
}
""")
```
### Search and Verify Results
```python
fill(uid="search-input", value="character")
press_key(key="Enter")
wait_for(timeout=2000) # Wait for debounce
# Check results
result = evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
const names = Array.from(cards).map(c => c.dataset.name || c.textContent);
return { count: cards.length, names };
}
""")
```
### Check API Response
```python
# Trigger API call
evaluate_script(function="""
() => window.loraApiCallPromise = fetch('/loras/api/list').then(r => r.json())
""")
# Wait and get result
import time
time.sleep(1)
result = evaluate_script(function="""
async () => await window.loraApiCallPromise
""")
```
### Monitor Console for Errors
```python
# Before test: clear console (navigate reloads)
navigate_page(type="reload")
# ... perform actions ...
# Check for errors
errors = list_console_messages(types=["error"])
assert len(errors) == 0, f"Console errors: {errors}"
```

View File

@@ -1,272 +0,0 @@
# LoRa Manager E2E Test Scenarios
This document provides detailed test scenarios for end-to-end validation of LoRa Manager features.
## Table of Contents
1. [LoRA List Page](#lora-list-page)
2. [Model Details](#model-details)
3. [Recipes](#recipes)
4. [Settings](#settings)
5. [Import/Export](#importexport)
---
## LoRA List Page
### Scenario: Page Load and Display
**Objective**: Verify the LoRA list page loads correctly and displays models.
**Steps**:
1. Navigate to `http://127.0.0.1:8188/loras`
2. Wait for page title "LoRAs" to appear
3. Take snapshot to verify:
- Header with "LoRAs" title is visible
- Search/filter controls are present
- Grid/list view toggle exists
- LoRA cards are displayed (if models exist)
- Pagination controls (if applicable)
**Expected Result**: Page loads without errors, UI elements are present.
### Scenario: Search Functionality
**Objective**: Verify search filters LoRA models correctly.
**Steps**:
1. Ensure at least one LoRA exists with known name (e.g., "test-character")
2. Navigate to LoRA list page
3. Enter search term in search box: "test"
4. Press Enter or click search button
5. Wait for results to update
**Expected Result**: Only LoRAs matching search term are displayed.
**Verification Script**:
```python
# After search, verify filtered results
evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
const names = Array.from(cards).map(c => c.dataset.name);
return { count: cards.length, names };
}
""")
```
### Scenario: Filter by Tags
**Objective**: Verify tag filtering works correctly.
**Steps**:
1. Navigate to LoRA list page
2. Click on a tag (e.g., "character", "style")
3. Wait for filtered results
**Expected Result**: Only LoRAs with selected tag are displayed.
### Scenario: View Mode Toggle
**Objective**: Verify grid/list view toggle works.
**Steps**:
1. Navigate to LoRA list page
2. Click list view button
3. Verify list layout
4. Click grid view button
5. Verify grid layout
**Expected Result**: View mode changes correctly, layout updates.
---
## Model Details
### Scenario: Open Model Details
**Objective**: Verify clicking a LoRA opens its details.
**Steps**:
1. Navigate to LoRA list page
2. Click on a LoRA card
3. Wait for details panel/modal to open
**Expected Result**: Details panel shows:
- Model name
- Preview image
- Metadata (trigger words, tags, etc.)
- Action buttons (edit, delete, etc.)
### Scenario: Edit Model Metadata
**Objective**: Verify metadata editing works end-to-end.
**Steps**:
1. Open a LoRA's details
2. Click "Edit" button
3. Modify trigger words field
4. Add/remove tags
5. Save changes
6. Refresh page
7. Reopen the same LoRA
**Expected Result**: Changes persist after refresh.
### Scenario: Delete Model
**Objective**: Verify model deletion works.
**Steps**:
1. Open a LoRA's details
2. Click "Delete" button
3. Confirm deletion in dialog
4. Wait for removal
**Expected Result**: Model removed from list, success message shown.
---
## Recipes
### Scenario: Recipe List Display
**Objective**: Verify recipes page loads and displays recipes.
**Steps**:
1. Navigate to `http://127.0.0.1:8188/recipes`
2. Wait for "Recipes" title
3. Take snapshot
**Expected Result**: Recipe list displayed with cards/items.
### Scenario: Create New Recipe
**Objective**: Verify recipe creation workflow.
**Steps**:
1. Navigate to recipes page
2. Click "New Recipe" button
3. Fill recipe form:
- Name: "Test Recipe"
- Description: "E2E test recipe"
- Add LoRA models
4. Save recipe
5. Verify recipe appears in list
**Expected Result**: New recipe created and displayed.
### Scenario: Apply Recipe
**Objective**: Verify applying a recipe to ComfyUI.
**Steps**:
1. Open a recipe
2. Click "Apply" or "Load in ComfyUI"
3. Verify action completes
**Expected Result**: Recipe applied successfully.
---
## Settings
### Scenario: Settings Page Load
**Objective**: Verify settings page displays correctly.
**Steps**:
1. Navigate to `http://127.0.0.1:8188/settings`
2. Wait for "Settings" title
3. Take snapshot
**Expected Result**: Settings form with various options displayed.
### Scenario: Change Setting and Restart
**Objective**: Verify settings persist after restart.
**Steps**:
1. Navigate to settings page
2. Change a setting (e.g., default view mode)
3. Save settings
4. Restart server: `python scripts/start_server.py --restart --wait`
5. Refresh browser page
6. Navigate to settings
**Expected Result**: Changed setting value persists.
---
## Import/Export
### Scenario: Export Models List
**Objective**: Verify export functionality.
**Steps**:
1. Navigate to LoRA list
2. Click "Export" button
3. Select format (JSON/CSV)
4. Download file
**Expected Result**: File downloaded with correct data.
### Scenario: Import Models
**Objective**: Verify import functionality.
**Steps**:
1. Prepare import file
2. Navigate to import page
3. Upload file
4. Verify import results
**Expected Result**: Models imported successfully, confirmation shown.
---
## API Integration Tests
### Scenario: Verify API Endpoints
**Objective**: Verify backend API responds correctly.
**Test via browser console**:
```javascript
// List LoRAs
fetch('/loras/api/list').then(r => r.json()).then(console.log)
// Get LoRA details
fetch('/loras/api/detail/<id>').then(r => r.json()).then(console.log)
// Search LoRAs
fetch('/loras/api/search?q=test').then(r => r.json()).then(console.log)
```
**Expected Result**: APIs return valid JSON with expected structure.
---
## Console Error Monitoring
During all tests, monitor browser console for errors:
```python
# Check for JavaScript errors
messages = list_console_messages(types=["error"])
assert len(messages) == 0, f"Console errors found: {messages}"
```
## Network Request Verification
Verify key API calls are made:
```python
# List XHR requests
requests = list_network_requests(resourceTypes=["xhr", "fetch"])
# Look for specific endpoints
lora_list_requests = [r for r in requests if "/api/list" in r.get("url", "")]
assert len(lora_list_requests) > 0, "LoRA list API not called"
```

View File

@@ -1,193 +0,0 @@
#!/usr/bin/env python3
"""
Example E2E test demonstrating LoRa Manager testing workflow.
This script shows how to:
1. Start the standalone server
2. Use Chrome DevTools MCP to interact with the UI
3. Verify functionality end-to-end
Note: This is a template. Actual execution requires Chrome DevTools MCP.
"""
import subprocess
import sys
import time
def run_test():
"""Run example E2E test flow."""
print("=" * 60)
print("LoRa Manager E2E Test Example")
print("=" * 60)
# Step 1: Start server
print("\n[1/5] Starting LoRa Manager standalone server...")
result = subprocess.run(
[sys.executable, "start_server.py", "--port", "8188", "--wait", "--timeout", "30"],
capture_output=True,
text=True
)
if result.returncode != 0:
print(f"Failed to start server: {result.stderr}")
return 1
print("Server ready!")
# Step 2: Open Chrome (manual step - show command)
print("\n[2/5] Open Chrome with debug mode:")
print("google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:8188/loras")
print("(In actual test, this would be automated via MCP)")
# Step 3: Navigate and verify page load
print("\n[3/5] Page Load Verification:")
print("""
MCP Commands to execute:
1. navigate_page(type="url", url="http://127.0.0.1:8188/loras")
2. wait_for(text="LoRAs", timeout=10000)
3. snapshot = take_snapshot()
""")
# Step 4: Test search functionality
print("\n[4/5] Search Functionality Test:")
print("""
MCP Commands to execute:
1. fill(uid="search-input", value="test")
2. press_key(key="Enter")
3. wait_for(text="Results", timeout=5000)
4. result = evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
return { count: cards.length };
}
""")
""")
# Step 5: Verify API
print("\n[5/5] API Verification:")
print("""
MCP Commands to execute:
1. api_result = evaluate_script(function="""
async () => {
const response = await fetch('/loras/api/list');
const data = await response.json();
return { count: data.length, status: response.status };
}
""")
2. Verify api_result['status'] == 200
""")
print("\n" + "=" * 60)
print("Test flow completed!")
print("=" * 60)
return 0
def example_restart_flow():
"""Example: Testing configuration change that requires restart."""
print("\n" + "=" * 60)
print("Example: Server Restart Flow")
print("=" * 60)
print("""
Scenario: Change setting and verify after restart
Steps:
1. Navigate to settings page
- navigate_page(type="url", url="http://127.0.0.1:8188/settings")
2. Change a setting (e.g., theme)
- fill(uid="theme-select", value="dark")
- click(uid="save-settings-button")
3. Restart server
- subprocess.run([python, "start_server.py", "--restart", "--wait"])
4. Refresh browser
- navigate_page(type="reload", ignoreCache=True)
- wait_for(text="LoRAs", timeout=15000)
5. Verify setting persisted
- navigate_page(type="url", url="http://127.0.0.1:8188/settings")
- theme = evaluate_script(function="() => document.querySelector('#theme-select').value")
- assert theme == "dark"
""")
def example_modal_interaction():
"""Example: Testing modal dialog interaction."""
print("\n" + "=" * 60)
print("Example: Modal Dialog Interaction")
print("=" * 60)
print("""
Scenario: Add new LoRA via modal
Steps:
1. Open modal
- click(uid="add-lora-button")
- wait_for(text="Add LoRA", timeout=3000)
2. Fill form
- fill_form(elements=[
{"uid": "lora-name", "value": "Test Character"},
{"uid": "lora-path", "value": "/models/test.safetensors"},
])
3. Submit
- click(uid="modal-submit-button")
4. Verify success
- wait_for(text="Successfully added", timeout=5000)
- snapshot = take_snapshot()
""")
def example_network_monitoring():
"""Example: Network request monitoring."""
print("\n" + "=" * 60)
print("Example: Network Request Monitoring")
print("=" * 60)
print("""
Scenario: Verify API calls during user interaction
Steps:
1. Clear network log (implicit on navigation)
- navigate_page(type="url", url="http://127.0.0.1:8188/loras")
2. Perform action that triggers API call
- fill(uid="search-input", value="character")
- press_key(key="Enter")
3. List network requests
- requests = list_network_requests(resourceTypes=["xhr", "fetch"])
4. Find search API call
- search_requests = [r for r in requests if "/api/search" in r.get("url", "")]
- assert len(search_requests) > 0, "Search API was not called"
5. Get request details
- if search_requests:
details = get_network_request(reqid=search_requests[0]["reqid"])
- Verify request method, response status, etc.
""")
if __name__ == "__main__":
print("LoRa Manager E2E Test Examples\n")
print("This script demonstrates E2E testing patterns.\n")
print("Note: Actual execution requires Chrome DevTools MCP connection.\n")
run_test()
example_restart_flow()
example_modal_interaction()
example_network_monitoring()
print("\n" + "=" * 60)
print("All examples shown!")
print("=" * 60)

View File

@@ -1,169 +0,0 @@
#!/usr/bin/env python3
"""
Start or restart LoRa Manager standalone server for E2E testing.
"""
import argparse
import subprocess
import sys
import time
import socket
import signal
import os
def find_server_process(port: int) -> list[int]:
"""Find PIDs of processes listening on the given port."""
try:
result = subprocess.run(
["lsof", "-ti", f":{port}"],
capture_output=True,
text=True,
check=False
)
if result.returncode == 0 and result.stdout.strip():
return [int(pid) for pid in result.stdout.strip().split("\n") if pid]
except FileNotFoundError:
# lsof not available, try netstat
try:
result = subprocess.run(
["netstat", "-tlnp"],
capture_output=True,
text=True,
check=False
)
pids = []
for line in result.stdout.split("\n"):
if f":{port}" in line:
parts = line.split()
for part in parts:
if "/" in part:
try:
pid = int(part.split("/")[0])
pids.append(pid)
except ValueError:
pass
return pids
except FileNotFoundError:
pass
return []
def kill_server(port: int) -> None:
"""Kill processes using the specified port."""
pids = find_server_process(port)
for pid in pids:
try:
os.kill(pid, signal.SIGTERM)
print(f"Sent SIGTERM to process {pid}")
except ProcessLookupError:
pass
# Wait for processes to terminate
time.sleep(1)
# Force kill if still running
pids = find_server_process(port)
for pid in pids:
try:
os.kill(pid, signal.SIGKILL)
print(f"Sent SIGKILL to process {pid}")
except ProcessLookupError:
pass
def is_server_ready(port: int, timeout: float = 0.5) -> bool:
"""Check if server is accepting connections."""
try:
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
return True
except (socket.timeout, ConnectionRefusedError, OSError):
return False
def wait_for_server(port: int, timeout: int = 30) -> bool:
"""Wait for server to become ready."""
start = time.time()
while time.time() - start < timeout:
if is_server_ready(port):
return True
time.sleep(0.5)
return False
def main() -> int:
parser = argparse.ArgumentParser(
description="Start LoRa Manager standalone server for E2E testing"
)
parser.add_argument(
"--port",
type=int,
default=8188,
help="Server port (default: 8188)"
)
parser.add_argument(
"--restart",
action="store_true",
help="Kill existing server before starting"
)
parser.add_argument(
"--wait",
action="store_true",
help="Wait for server to be ready before exiting"
)
parser.add_argument(
"--timeout",
type=int,
default=30,
help="Timeout for waiting (default: 30)"
)
args = parser.parse_args()
# Get project root (parent of .agents directory)
script_dir = os.path.dirname(os.path.abspath(__file__))
skill_dir = os.path.dirname(script_dir)
project_root = os.path.dirname(os.path.dirname(os.path.dirname(skill_dir)))
# Restart if requested
if args.restart:
print(f"Killing existing server on port {args.port}...")
kill_server(args.port)
time.sleep(1)
# Check if already running
if is_server_ready(args.port):
print(f"Server already running on port {args.port}")
return 0
# Start server
print(f"Starting LoRa Manager standalone server on port {args.port}...")
cmd = [sys.executable, "standalone.py", "--port", str(args.port)]
# Start in background
process = subprocess.Popen(
cmd,
cwd=project_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True
)
print(f"Server process started with PID {process.pid}")
# Wait for ready if requested
if args.wait:
print(f"Waiting for server to be ready (timeout: {args.timeout}s)...")
if wait_for_server(args.port, args.timeout):
print(f"Server ready at http://127.0.0.1:{args.port}/loras")
return 0
else:
print(f"Timeout waiting for server")
return 1
print(f"Server starting at http://127.0.0.1:{args.port}/loras")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,61 +0,0 @@
#!/usr/bin/env python3
"""
Wait for LoRa Manager server to become ready.
"""
import argparse
import socket
import sys
import time
def is_server_ready(port: int, timeout: float = 0.5) -> bool:
"""Check if server is accepting connections."""
try:
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
return True
except (socket.timeout, ConnectionRefusedError, OSError):
return False
def wait_for_server(port: int, timeout: int = 30) -> bool:
"""Wait for server to become ready."""
start = time.time()
while time.time() - start < timeout:
if is_server_ready(port):
return True
time.sleep(0.5)
return False
def main() -> int:
parser = argparse.ArgumentParser(
description="Wait for LoRa Manager server to become ready"
)
parser.add_argument(
"--port",
type=int,
default=8188,
help="Server port (default: 8188)"
)
parser.add_argument(
"--timeout",
type=int,
default=30,
help="Timeout in seconds (default: 30)"
)
args = parser.parse_args()
print(f"Waiting for server on port {args.port} (timeout: {args.timeout}s)...")
if wait_for_server(args.port, args.timeout):
print(f"Server ready at http://127.0.0.1:{args.port}/loras")
return 0
else:
print(f"Timeout: Server not ready after {args.timeout}s")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -34,15 +34,6 @@ Enhance your Civitai browsing experience with our companion browser extension! S
## Release Notes
### v0.9.15
* **Filter Presets** - Save filter combinations as presets for quick switching and reapplication.
* **Bug Fixes** - Fixed various bugs for improved stability.
### v0.9.14
* **LoRA Cycler Node** - Introduced a new LoRA Cycler node that enables iteration through specified LoRAs with support for repeat count and pause iteration functionality. Refer to the new "Lora Cycler" template workflow for concrete example.
* **Enhanced Prompt Node with Tag Autocomplete** - Enhanced the Prompt node with comprehensive tag autocomplete based on merged Danbooru + e621 tags. Supports tag search and autocomplete functionality. Implemented a command system with shortcuts like `/char` or `/artist` for category-specific tag searching. Added `/ac` or `/noac` commands to quickly enable or disable autocomplete. Refer to the "Lora Manager Basic" template workflow in ComfyUI -> Templates -> ComfyUI-Lora-Manager for detailed tips.
* **Bug Fixes & Stability** - Addressed multiple bugs and improved overall stability.
### v0.9.12
* **LoRA Randomizer System** - Introduced a comprehensive LoRA randomization system featuring LoRA Pool and LoRA Randomizer nodes for flexible and dynamic generation workflows.
* **LoRA Randomizer Template** - Refer to the new "LoRA Randomizer" template workflow for detailed examples of flexible randomization modes, lock & reuse options, and other features.

View File

@@ -1,449 +0,0 @@
# 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 个 TabExamples/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*

File diff suppressed because one or more lines are too long

View File

@@ -179,6 +179,7 @@
"recipes": "Rezepte",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"misc": "[TODO: Translate] Misc",
"statistics": "Statistiken"
},
"search": {
@@ -187,7 +188,8 @@
"loras": "LoRAs suchen...",
"recipes": "Rezepte suchen...",
"checkpoints": "Checkpoints suchen...",
"embeddings": "Embeddings suchen..."
"embeddings": "Embeddings suchen...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
},
"options": "Suchoptionen",
"searchIn": "Suchen in:",
@@ -223,11 +225,7 @@
"noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt",
"noTags": "Keine Tags",
"clearAll": "Alle Filter löschen",
"any": "Beliebig",
"all": "Alle",
"tagLogicAny": "Jedes Tag abgleichen (ODER)",
"tagLogicAll": "Alle Tags abgleichen (UND)"
"clearAll": "Alle Filter löschen"
},
"theme": {
"toggle": "Theme wechseln",
@@ -692,6 +690,16 @@
"embeddings": {
"title": "Embedding-Modelle"
},
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": {
"modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen",
@@ -1108,6 +1116,10 @@
"title": "Statistiken werden initialisiert",
"message": "Modelldaten für Statistiken werden verarbeitet. Dies kann einige Minuten dauern..."
},
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": {
"title": "Tipps & Tricks",
"civitai": {
@@ -1167,12 +1179,18 @@
"recipeAdded": "Rezept zum Workflow hinzugefügt",
"recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt"
},
"nodeSelector": {
"recipe": "Rezept",
"lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "Ersetzen",
"append": "Anhängen",
"selectTargetNode": "Zielknoten auswählen",
@@ -1576,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "Cache-Korruption erkannt"
},
"degraded": {
"title": "Cache-Probleme erkannt"
},
"content": "{invalid} von {total} Cache-Einträgen sind ungültig ({rate}). Dies kann zu fehlenden Modellen oder Fehlern führen. Ein Neuaufbau des Caches wird empfohlen.",
"rebuildCache": "Cache neu aufbauen",
"dismiss": "Verwerfen",
"rebuilding": "Cache wird neu aufgebaut...",
"rebuildFailed": "Fehler beim Neuaufbau des Caches: {error}",
"retry": "Wiederholen"
}
}
}

View File

@@ -179,6 +179,7 @@
"recipes": "Recipes",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"misc": "Misc",
"statistics": "Stats"
},
"search": {
@@ -187,7 +188,8 @@
"loras": "Search LoRAs...",
"recipes": "Search recipes...",
"checkpoints": "Search checkpoints...",
"embeddings": "Search embeddings..."
"embeddings": "Search embeddings...",
"misc": "Search VAE/Upscaler models..."
},
"options": "Search Options",
"searchIn": "Search In:",
@@ -223,11 +225,7 @@
"noCreditRequired": "No Credit Required",
"allowSellingGeneratedContent": "Allow Selling",
"noTags": "No tags",
"clearAll": "Clear All Filters",
"any": "Any",
"all": "All",
"tagLogicAny": "Match any tag (OR)",
"tagLogicAll": "Match all tags (AND)"
"clearAll": "Clear All Filters"
},
"theme": {
"toggle": "Toggle theme",
@@ -692,6 +690,16 @@
"embeddings": {
"title": "Embedding Models"
},
"misc": {
"title": "VAE & Upscaler Models",
"modelTypes": {
"vae": "VAE",
"upscaler": "Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "Move to {otherType} Folder"
}
},
"sidebar": {
"modelRoot": "Root",
"collapseAll": "Collapse All Folders",
@@ -911,12 +919,7 @@
"viewOnCivitai": "View on Civitai",
"viewOnCivitaiText": "View on Civitai",
"viewCreatorProfile": "View Creator Profile",
"openFileLocation": "Open File Location",
"viewParams": "View parameters",
"setPreview": "Set as preview",
"previewSet": "Preview updated successfully",
"previewFailed": "Failed to update preview",
"delete": "Delete"
"openFileLocation": "Open File Location"
},
"openFileLocation": {
"success": "File location opened successfully",
@@ -935,15 +938,13 @@
"additionalNotes": "Additional Notes",
"notesHint": "Press Enter to save, Shift+Enter for new line",
"addNotesPlaceholder": "Add your notes here...",
"aboutThisVersion": "About this version",
"triggerWords": "Trigger Words"
"aboutThisVersion": "About this version"
},
"notes": {
"saved": "Notes saved successfully",
"saveFailed": "Failed to save notes"
},
"usageTips": {
"add": "Add",
"addPresetParameter": "Add preset parameter...",
"strengthMin": "Strength Min",
"strengthMax": "Strength Max",
@@ -952,24 +953,17 @@
"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 words needed",
"noTriggerWordsNeeded": "No trigger word needed",
"edit": "Edit trigger words",
"cancel": "Cancel editing",
"save": "Save changes",
"addPlaceholder": "Type to add trigger word...",
"addPlaceholder": "Type to add or click suggestions below",
"copyWord": "Copy trigger word",
"copyAll": "Copy all trigger words",
"deleteWord": "Delete trigger word",
"suggestions": {
"noSuggestions": "No suggestions available",
@@ -979,9 +973,6 @@
"wordSuggestions": "Word Suggestions",
"wordsFound": "{count} words found",
"loading": "Loading suggestions..."
},
"validation": {
"duplicate": "This trigger word already exists"
}
},
"description": {
@@ -1007,11 +998,7 @@
"previousWithShortcut": "Previous model (←)",
"nextWithShortcut": "Next model (→)",
"noPrevious": "No previous model available",
"noNext": "No next model available",
"previous": "Previous",
"next": "Next",
"switchModel": "Switch model",
"browseExamples": "Browse examples"
"noNext": "No next model available"
},
"license": {
"noImageSell": "No selling generated content",
@@ -1023,23 +1010,6 @@
"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...",
@@ -1146,6 +1116,10 @@
"title": "Initializing Statistics",
"message": "Processing model data for statistics. This may take a few minutes..."
},
"misc": {
"title": "Initializing Misc Model Manager",
"message": "Scanning VAE and Upscaler models..."
},
"tips": {
"title": "Tips & Tricks",
"civitai": {
@@ -1205,12 +1179,18 @@
"recipeAdded": "Recipe appended to workflow",
"recipeReplaced": "Recipe replaced in workflow",
"recipeFailedToSend": "Failed to send recipe to workflow",
"vaeUpdated": "VAE updated in workflow",
"vaeFailed": "Failed to update VAE in workflow",
"upscalerUpdated": "Upscaler updated in workflow",
"upscalerFailed": "Failed to update upscaler in workflow",
"noMatchingNodes": "No compatible nodes available in the current workflow",
"noTargetNodeSelected": "No target node selected"
},
"nodeSelector": {
"recipe": "Recipe",
"lora": "LoRA",
"vae": "VAE",
"upscaler": "Upscaler",
"replace": "Replace",
"append": "Append",
"selectTargetNode": "Select target node",
@@ -1614,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "Cache Corruption Detected"
},
"degraded": {
"title": "Cache Issues Detected"
},
"content": "{invalid} of {total} cache entries are invalid ({rate}). This may cause missing models or errors. Rebuilding the cache is recommended.",
"rebuildCache": "Rebuild Cache",
"dismiss": "Dismiss",
"rebuilding": "Rebuilding cache...",
"rebuildFailed": "Failed to rebuild cache: {error}",
"retry": "Retry"
}
}
}

View File

@@ -179,6 +179,7 @@
"recipes": "Recetas",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"misc": "[TODO: Translate] Misc",
"statistics": "Estadísticas"
},
"search": {
@@ -187,7 +188,8 @@
"loras": "Buscar LoRAs...",
"recipes": "Buscar recetas...",
"checkpoints": "Buscar checkpoints...",
"embeddings": "Buscar embeddings..."
"embeddings": "Buscar embeddings...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
},
"options": "Opciones de búsqueda",
"searchIn": "Buscar en:",
@@ -223,11 +225,7 @@
"noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida",
"noTags": "Sin etiquetas",
"clearAll": "Limpiar todos los filtros",
"any": "Cualquiera",
"all": "Todos",
"tagLogicAny": "Coincidir con cualquier etiqueta (O)",
"tagLogicAll": "Coincidir con todas las etiquetas (Y)"
"clearAll": "Limpiar todos los filtros"
},
"theme": {
"toggle": "Cambiar tema",
@@ -692,6 +690,16 @@
"embeddings": {
"title": "Modelos embedding"
},
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": {
"modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas",
@@ -1108,6 +1116,10 @@
"title": "Inicializando estadísticas",
"message": "Procesando datos del modelo para estadísticas. Esto puede tomar unos minutos..."
},
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": {
"title": "Consejos y trucos",
"civitai": {
@@ -1167,12 +1179,18 @@
"recipeAdded": "Receta añadida al flujo de trabajo",
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino"
},
"nodeSelector": {
"recipe": "Receta",
"lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "Reemplazar",
"append": "Añadir",
"selectTargetNode": "Seleccionar nodo de destino",
@@ -1576,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "Corrupción de caché detectada"
},
"degraded": {
"title": "Problemas de caché detectados"
},
"content": "{invalid} de {total} entradas de caché son inválidas ({rate}). Esto puede causar modelos faltantes o errores. Se recomienda reconstruir la caché.",
"rebuildCache": "Reconstruir caché",
"dismiss": "Descartar",
"rebuilding": "Reconstruyendo caché...",
"rebuildFailed": "Error al reconstruir la caché: {error}",
"retry": "Reintentar"
}
}
}

View File

@@ -179,6 +179,7 @@
"recipes": "Recipes",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"misc": "[TODO: Translate] Misc",
"statistics": "Statistiques"
},
"search": {
@@ -187,7 +188,8 @@
"loras": "Rechercher des LoRAs...",
"recipes": "Rechercher des recipes...",
"checkpoints": "Rechercher des checkpoints...",
"embeddings": "Rechercher des embeddings..."
"embeddings": "Rechercher des embeddings...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
},
"options": "Options de recherche",
"searchIn": "Rechercher dans :",
@@ -223,11 +225,7 @@
"noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée",
"noTags": "Aucun tag",
"clearAll": "Effacer tous les filtres",
"any": "N'importe quel",
"all": "Tous",
"tagLogicAny": "Correspondre à n'importe quel tag (OU)",
"tagLogicAll": "Correspondre à tous les tags (ET)"
"clearAll": "Effacer tous les filtres"
},
"theme": {
"toggle": "Basculer le thème",
@@ -692,6 +690,16 @@
"embeddings": {
"title": "Modèles Embedding"
},
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": {
"modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers",
@@ -1108,6 +1116,10 @@
"title": "Initialisation des statistiques",
"message": "Traitement des données de modèle pour les statistiques. Cela peut prendre quelques minutes..."
},
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": {
"title": "Astuces et conseils",
"civitai": {
@@ -1167,12 +1179,18 @@
"recipeAdded": "Recipe ajoutée au workflow",
"recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noTargetNodeSelected": "Aucun nœud cible sélectionné"
},
"nodeSelector": {
"recipe": "Recipe",
"lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "Remplacer",
"append": "Ajouter",
"selectTargetNode": "Sélectionner le nœud cible",
@@ -1576,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "Corruption du cache détectée"
},
"degraded": {
"title": "Problèmes de cache détectés"
},
"content": "{invalid} des {total} entrées de cache sont invalides ({rate}). Cela peut provoquer des modèles manquants ou des erreurs. Il est recommandé de reconstruire le cache.",
"rebuildCache": "Reconstruire le cache",
"dismiss": "Ignorer",
"rebuilding": "Reconstruction du cache...",
"rebuildFailed": "Échec de la reconstruction du cache : {error}",
"retry": "Réessayer"
}
}
}

View File

@@ -179,6 +179,7 @@
"recipes": "מתכונים",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"misc": "[TODO: Translate] Misc",
"statistics": "סטטיסטיקה"
},
"search": {
@@ -187,7 +188,8 @@
"loras": "חפש LoRAs...",
"recipes": "חפש מתכונים...",
"checkpoints": "חפש checkpoints...",
"embeddings": "חפש embeddings..."
"embeddings": "חפש embeddings...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
},
"options": "אפשרויות חיפוש",
"searchIn": "חפש ב:",
@@ -223,11 +225,7 @@
"noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה",
"noTags": "ללא תגיות",
"clearAll": "נקה את כל המסננים",
"any": "כלשהו",
"all": "כל התגים",
"tagLogicAny": "התאם כל תג (או)",
"tagLogicAll": "התאם את כל התגים (וגם)"
"clearAll": "נקה את כל המסננים"
},
"theme": {
"toggle": "החלף ערכת נושא",
@@ -692,6 +690,16 @@
"embeddings": {
"title": "מודלי Embedding"
},
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": {
"modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות",
@@ -1108,6 +1116,10 @@
"title": "מאתחל סטטיסטיקה",
"message": "מעבד נתוני מודלים עבור סטטיסטיקה. זה עשוי לקחת מספר דקות..."
},
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": {
"title": "טיפים וטריקים",
"civitai": {
@@ -1167,12 +1179,18 @@
"recipeAdded": "מתכון נוסף ל-workflow",
"recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noTargetNodeSelected": "לא נבחר צומת יעד"
},
"nodeSelector": {
"recipe": "מתכון",
"lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "החלף",
"append": "הוסף",
"selectTargetNode": "בחר צומת יעד",
@@ -1576,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "זוהתה שחיתות במטמון"
},
"degraded": {
"title": "זוהו בעיות במטמון"
},
"content": "{invalid} מתוך {total} רשומות מטמון אינן תקינות ({rate}). זה עלול לגרום לדגמים חסרים או לשגיאות. מומלץ לבנות מחדש את המטמון.",
"rebuildCache": "בניית מטמון מחדש",
"dismiss": "ביטול",
"rebuilding": "בונה מחדש את המטמון...",
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
"retry": "נסה שוב"
}
}
}

View File

@@ -179,6 +179,7 @@
"recipes": "レシピ",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"misc": "[TODO: Translate] Misc",
"statistics": "統計"
},
"search": {
@@ -187,7 +188,8 @@
"loras": "LoRAを検索...",
"recipes": "レシピを検索...",
"checkpoints": "checkpointを検索...",
"embeddings": "embeddingを検索..."
"embeddings": "embeddingを検索...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
},
"options": "検索オプション",
"searchIn": "検索対象:",
@@ -223,11 +225,7 @@
"noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可",
"noTags": "タグなし",
"clearAll": "すべてのフィルタをクリア",
"any": "いずれか",
"all": "すべて",
"tagLogicAny": "いずれかのタグに一致 (OR)",
"tagLogicAll": "すべてのタグに一致 (AND)"
"clearAll": "すべてのフィルタをクリア"
},
"theme": {
"toggle": "テーマの切り替え",
@@ -692,6 +690,16 @@
"embeddings": {
"title": "Embeddingモデル"
},
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": {
"modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ",
@@ -1108,6 +1116,10 @@
"title": "統計を初期化中",
"message": "統計用のモデルデータを処理中。数分かかる場合があります..."
},
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": {
"title": "ヒント&コツ",
"civitai": {
@@ -1167,12 +1179,18 @@
"recipeAdded": "レシピがワークフローに追加されました",
"recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noTargetNodeSelected": "ターゲットノードが選択されていません"
},
"nodeSelector": {
"recipe": "レシピ",
"lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "置換",
"append": "追加",
"selectTargetNode": "ターゲットノードを選択",
@@ -1576,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "キャッシュの破損が検出されました"
},
"degraded": {
"title": "キャッシュの問題が検出されました"
},
"content": "{total}個のキャッシュエントリのうち{invalid}個が無効です({rate})。モデルが見つからない原因になったり、エラーが発生する可能性があります。キャッシュの再構築を推奨します。",
"rebuildCache": "キャッシュを再構築",
"dismiss": "閉じる",
"rebuilding": "キャッシュを再構築中...",
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
"retry": "再試行"
}
}
}

View File

@@ -179,6 +179,7 @@
"recipes": "레시피",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"misc": "[TODO: Translate] Misc",
"statistics": "통계"
},
"search": {
@@ -187,7 +188,8 @@
"loras": "LoRA 검색...",
"recipes": "레시피 검색...",
"checkpoints": "Checkpoint 검색...",
"embeddings": "Embedding 검색..."
"embeddings": "Embedding 검색...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
},
"options": "검색 옵션",
"searchIn": "검색 범위:",
@@ -223,11 +225,7 @@
"noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용",
"noTags": "태그 없음",
"clearAll": "모든 필터 지우기",
"any": "아무",
"all": "모두",
"tagLogicAny": "모든 태그 일치 (OR)",
"tagLogicAll": "모든 태그 일치 (AND)"
"clearAll": "모든 필터 지우기"
},
"theme": {
"toggle": "테마 토글",
@@ -692,6 +690,16 @@
"embeddings": {
"title": "Embedding 모델"
},
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": {
"modelRoot": "루트",
"collapseAll": "모든 폴더 접기",
@@ -1108,6 +1116,10 @@
"title": "통계 초기화 중",
"message": "통계를 위한 모델 데이터를 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
},
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": {
"title": "팁 & 요령",
"civitai": {
@@ -1167,12 +1179,18 @@
"recipeAdded": "레시피가 워크플로에 추가되었습니다",
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다"
},
"nodeSelector": {
"recipe": "레시피",
"lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "교체",
"append": "추가",
"selectTargetNode": "대상 노드 선택",
@@ -1576,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "캐시 손상이 감지되었습니다"
},
"degraded": {
"title": "캐시 문제가 감지되었습니다"
},
"content": "{total}개의 캐시 항목 중 {invalid}개가 유효하지 않습니다 ({rate}). 모델 누락이나 오류가 발생할 수 있습니다. 캐시를 재구축하는 것이 좋습니다.",
"rebuildCache": "캐시 재구축",
"dismiss": "무시",
"rebuilding": "캐시 재구축 중...",
"rebuildFailed": "캐시 재구축 실패: {error}",
"retry": "다시 시도"
}
}
}

View File

@@ -179,6 +179,7 @@
"recipes": "Рецепты",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"misc": "[TODO: Translate] Misc",
"statistics": "Статистика"
},
"search": {
@@ -187,7 +188,8 @@
"loras": "Поиск LoRAs...",
"recipes": "Поиск рецептов...",
"checkpoints": "Поиск checkpoints...",
"embeddings": "Поиск embeddings..."
"embeddings": "Поиск embeddings...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
},
"options": "Опции поиска",
"searchIn": "Искать в:",
@@ -223,11 +225,7 @@
"noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена",
"noTags": "Без тегов",
"clearAll": "Очистить все фильтры",
"any": "Любой",
"all": "Все",
"tagLogicAny": "Совпадение с любым тегом (ИЛИ)",
"tagLogicAll": "Совпадение со всеми тегами (И)"
"clearAll": "Очистить все фильтры"
},
"theme": {
"toggle": "Переключить тему",
@@ -692,6 +690,16 @@
"embeddings": {
"title": "Модели Embedding"
},
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": {
"modelRoot": "Корень",
"collapseAll": "Свернуть все папки",
@@ -1108,6 +1116,10 @@
"title": "Инициализация статистики",
"message": "Обработка данных моделей для статистики. Это может занять несколько минут..."
},
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": {
"title": "Советы и хитрости",
"civitai": {
@@ -1167,12 +1179,18 @@
"recipeAdded": "Рецепт добавлен в workflow",
"recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noTargetNodeSelected": "Целевой узел не выбран"
},
"nodeSelector": {
"recipe": "Рецепт",
"lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "Заменить",
"append": "Добавить",
"selectTargetNode": "Выберите целевой узел",
@@ -1576,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "Обнаружено повреждение кэша"
},
"degraded": {
"title": "Обнаружены проблемы с кэшем"
},
"content": "{invalid} из {total} записей кэша недействительны ({rate}). Это может привести к отсутствию моделей или ошибкам. Рекомендуется перестроить кэш.",
"rebuildCache": "Перестроить кэш",
"dismiss": "Отклонить",
"rebuilding": "Перестроение кэша...",
"rebuildFailed": "Не удалось перестроить кэш: {error}",
"retry": "Повторить"
}
}
}

View File

@@ -179,6 +179,7 @@
"recipes": "配方",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"misc": "[TODO: Translate] Misc",
"statistics": "统计"
},
"search": {
@@ -187,7 +188,8 @@
"loras": "搜索 LoRA...",
"recipes": "搜索配方...",
"checkpoints": "搜索 Checkpoint...",
"embeddings": "搜索 Embedding..."
"embeddings": "搜索 Embedding...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
},
"options": "搜索选项",
"searchIn": "搜索范围:",
@@ -223,11 +225,7 @@
"noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售",
"noTags": "无标签",
"clearAll": "清除所有筛选",
"any": "任一",
"all": "全部",
"tagLogicAny": "匹配任一标签 (或)",
"tagLogicAll": "匹配所有标签 (与)"
"clearAll": "清除所有筛选"
},
"theme": {
"toggle": "切换主题",
@@ -692,6 +690,16 @@
"embeddings": {
"title": "Embedding 模型"
},
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": {
"modelRoot": "根目录",
"collapseAll": "折叠所有文件夹",
@@ -1108,6 +1116,10 @@
"title": "初始化统计",
"message": "正在处理模型数据以生成统计信息。这可能需要几分钟..."
},
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": {
"title": "技巧与提示",
"civitai": {
@@ -1167,12 +1179,18 @@
"recipeAdded": "配方已追加到工作流",
"recipeReplaced": "配方已替换到工作流",
"recipeFailedToSend": "发送配方到工作流失败",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "当前工作流中没有兼容的节点",
"noTargetNodeSelected": "未选择目标节点"
},
"nodeSelector": {
"recipe": "配方",
"lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "替换",
"append": "追加",
"selectTargetNode": "选择目标节点",
@@ -1576,20 +1594,6 @@
"content": "来爱发电为Lora Manager项目发电支持项目持续开发的同时获取浏览器插件验证码按季支付更优惠支付宝/微信方便支付。感谢支持!🚀",
"supportCta": "为LM发电",
"learnMore": "浏览器插件教程"
},
"cacheHealth": {
"corrupted": {
"title": "检测到缓存损坏"
},
"degraded": {
"title": "检测到缓存问题"
},
"content": "{total} 个缓存条目中有 {invalid} 个无效({rate})。这可能导致模型丢失或错误。建议重建缓存。",
"rebuildCache": "重建缓存",
"dismiss": "忽略",
"rebuilding": "正在重建缓存...",
"rebuildFailed": "重建缓存失败:{error}",
"retry": "重试"
}
}
}

View File

@@ -179,6 +179,7 @@
"recipes": "配方",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"misc": "[TODO: Translate] Misc",
"statistics": "統計"
},
"search": {
@@ -187,7 +188,8 @@
"loras": "搜尋 LoRA...",
"recipes": "搜尋配方...",
"checkpoints": "搜尋 checkpoint...",
"embeddings": "搜尋 embedding..."
"embeddings": "搜尋 embedding...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
},
"options": "搜尋選項",
"searchIn": "搜尋範圍:",
@@ -223,11 +225,7 @@
"noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售",
"noTags": "無標籤",
"clearAll": "清除所有篩選",
"any": "任一",
"all": "全部",
"tagLogicAny": "符合任一票籤 (或)",
"tagLogicAll": "符合所有標籤 (與)"
"clearAll": "清除所有篩選"
},
"theme": {
"toggle": "切換主題",
@@ -692,6 +690,16 @@
"embeddings": {
"title": "Embedding 模型"
},
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": {
"modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾",
@@ -1108,6 +1116,10 @@
"title": "初始化統計",
"message": "正在處理模型資料以產生統計,可能需要幾分鐘..."
},
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": {
"title": "小技巧",
"civitai": {
@@ -1167,12 +1179,18 @@
"recipeAdded": "配方已附加到工作流",
"recipeReplaced": "配方已取代於工作流",
"recipeFailedToSend": "傳送配方到工作流失敗",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "目前工作流程中沒有相容的節點",
"noTargetNodeSelected": "未選擇目標節點"
},
"nodeSelector": {
"recipe": "配方",
"lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "取代",
"append": "附加",
"selectTargetNode": "選擇目標節點",
@@ -1576,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "檢測到快取損壞"
},
"degraded": {
"title": "檢測到快取問題"
},
"content": "{total} 個快取項目中有 {invalid} 個無效({rate})。這可能會導致模型遺失或錯誤。建議重建快取。",
"rebuildCache": "重建快取",
"dismiss": "關閉",
"rebuilding": "重建快取中...",
"rebuildFailed": "重建快取失敗:{error}",
"retry": "重試"
}
}
}

View File

@@ -4,9 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"test": "npm run test:js && npm run test:vue",
"test:js": "vitest run",
"test:vue": "cd vue-widgets && npx vitest run",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "node scripts/run_frontend_coverage.js"
},

View File

@@ -89,8 +89,11 @@ class Config:
self.checkpoints_roots = None
self.unet_roots = None
self.embeddings_roots = None
self.vae_roots = None
self.upscaler_roots = None
self.base_models_roots = self._init_checkpoint_paths()
self.embeddings_roots = self._init_embedding_paths()
self.misc_roots = self._init_misc_paths()
# Scan symbolic links during initialization
self._initialize_symlink_mappings()
@@ -151,6 +154,8 @@ class Config:
'checkpoints': list(self.checkpoints_roots or []),
'unet': list(self.unet_roots or []),
'embeddings': list(self.embeddings_roots or []),
'vae': list(self.vae_roots or []),
'upscale_models': list(self.upscaler_roots or []),
}
normalized_target_paths = _normalize_folder_paths_for_comparison(target_folder_paths)
@@ -250,6 +255,7 @@ class Config:
roots.extend(self.loras_roots or [])
roots.extend(self.base_models_roots or [])
roots.extend(self.embeddings_roots or [])
roots.extend(self.misc_roots or [])
return roots
def _build_symlink_fingerprint(self) -> Dict[str, object]:
@@ -441,53 +447,82 @@ class Config:
logger.info("Failed to write symlink cache %s: %s", cache_path, exc)
def _scan_symbolic_links(self):
"""Scan symbolic links in LoRA, Checkpoint, and Embedding root directories.
Only scans the first level of each root directory to avoid performance
issues with large file systems. Detects symlinks and Windows junctions
at the root level only (not nested symlinks in subdirectories).
"""
"""Scan all symbolic links in LoRA, Checkpoint, and Embedding root directories"""
start = time.perf_counter()
# Reset mappings before rescanning to avoid stale entries
self._path_mappings.clear()
self._seed_root_symlink_mappings()
visited_dirs: Set[str] = set()
for root in self._symlink_roots():
self._scan_first_level_symlinks(root)
self._scan_directory_links(root, visited_dirs)
logger.debug(
"Symlink scan finished in %.2f ms with %d mappings",
(time.perf_counter() - start) * 1000,
len(self._path_mappings),
)
def _scan_first_level_symlinks(self, root: str):
"""Scan only the first level of a directory for symlinks.
This avoids traversing the entire directory tree which can be extremely
slow for large model collections. Only symlinks directly under the root
are detected.
"""
def _scan_directory_links(self, root: str, visited_dirs: Set[str]):
"""Iteratively scan directory symlinks to avoid deep recursion."""
try:
with os.scandir(root) as it:
for entry in it:
try:
# Only detect symlinks including Windows junctions
# Skip normal directories to avoid deep traversal
if not self._entry_is_symlink(entry):
continue
# Note: We only use realpath for the initial root if it's not already resolved
# to ensure we have a valid entry point.
root_real = self._normalize_path(os.path.realpath(root))
except OSError:
root_real = self._normalize_path(root)
# Resolve the symlink target
target_path = os.path.realpath(entry.path)
if not os.path.isdir(target_path):
continue
if root_real in visited_dirs:
return
self.add_path_mapping(entry.path, target_path)
except Exception as inner_exc:
logger.debug(
"Error processing directory entry %s: %s", entry.path, inner_exc
)
except Exception as e:
logger.error(f"Error scanning links in {root}: {e}")
visited_dirs.add(root_real)
# Stack entries: (display_path, real_resolved_path)
stack: List[Tuple[str, str]] = [(root, root_real)]
while stack:
current_display, current_real = stack.pop()
try:
with os.scandir(current_display) as it:
for entry in it:
try:
# 1. Detect symlinks including Windows junctions
is_link = self._entry_is_symlink(entry)
if is_link:
# Only resolve realpath when we actually find a link
target_path = os.path.realpath(entry.path)
if not os.path.isdir(target_path):
continue
normalized_target = self._normalize_path(target_path)
self.add_path_mapping(entry.path, target_path)
if normalized_target in visited_dirs:
continue
visited_dirs.add(normalized_target)
stack.append((target_path, normalized_target))
continue
# 2. Process normal directories
if not entry.is_dir(follow_symlinks=False):
continue
# For normal directories, we avoid realpath() call by
# incrementally building the real path relative to current_real.
# This is safe because 'entry' is NOT a symlink.
entry_real = self._normalize_path(os.path.join(current_real, entry.name))
if entry_real in visited_dirs:
continue
visited_dirs.add(entry_real)
stack.append((entry.path, entry_real))
except Exception as inner_exc:
logger.debug(
"Error processing directory entry %s: %s", entry.path, inner_exc
)
except Exception as e:
logger.error(f"Error scanning links in {current_display}: {e}")
@@ -570,6 +605,8 @@ class Config:
preview_roots.update(self._expand_preview_root(root))
for root in self.embeddings_roots or []:
preview_roots.update(self._expand_preview_root(root))
for root in self.misc_roots or []:
preview_roots.update(self._expand_preview_root(root))
for target, link in self._path_mappings.items():
preview_roots.update(self._expand_preview_root(target))
@@ -577,11 +614,12 @@ class Config:
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()}
logger.debug(
"Preview roots rebuilt: %d paths from %d lora roots, %d checkpoint roots, %d embedding roots, %d symlink mappings",
"Preview roots rebuilt: %d paths from %d lora roots, %d checkpoint roots, %d embedding roots, %d misc roots, %d symlink mappings",
len(self._preview_root_paths),
len(self.loras_roots or []),
len(self.base_models_roots or []),
len(self.embeddings_roots or []),
len(self.misc_roots or []),
len(self._path_mappings),
)
@@ -645,23 +683,6 @@ class Config:
checkpoint_map = self._dedupe_existing_paths(checkpoint_paths)
unet_map = self._dedupe_existing_paths(unet_paths)
# Detect when checkpoints and unet share the same physical location
# This is a configuration issue that can cause duplicate model entries
overlapping_real_paths = set(checkpoint_map.keys()) & set(unet_map.keys())
if overlapping_real_paths:
logger.warning(
"Detected overlapping paths between 'checkpoints' and 'diffusion_models' (unet). "
"They should not point to the same physical folder as they are different model types. "
"Please fix your ComfyUI path configuration to separate these folders. "
"Falling back to 'checkpoints' for backward compatibility. "
"Overlapping real paths: %s",
[checkpoint_map.get(rp, rp) for rp in overlapping_real_paths]
)
# Remove overlapping paths from unet_map to prioritize checkpoints
for rp in overlapping_real_paths:
if rp in unet_map:
del unet_map[rp]
merged_map: Dict[str, str] = {}
for real_path, original in {**checkpoint_map, **unet_map}.items():
if real_path not in merged_map:
@@ -757,6 +778,49 @@ class Config:
logger.warning(f"Error initializing embedding paths: {e}")
return []
def _init_misc_paths(self) -> List[str]:
"""Initialize and validate misc (VAE and upscaler) paths from ComfyUI settings"""
try:
raw_vae_paths = folder_paths.get_folder_paths("vae")
raw_upscaler_paths = folder_paths.get_folder_paths("upscale_models")
unique_paths = self._prepare_misc_paths(raw_vae_paths, raw_upscaler_paths)
logger.info("Found misc roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
if not unique_paths:
logger.warning("No valid VAE or upscaler folders found in ComfyUI configuration")
return []
return unique_paths
except Exception as e:
logger.warning(f"Error initializing misc paths: {e}")
return []
def _prepare_misc_paths(
self, vae_paths: Iterable[str], upscaler_paths: Iterable[str]
) -> List[str]:
vae_map = self._dedupe_existing_paths(vae_paths)
upscaler_map = self._dedupe_existing_paths(upscaler_paths)
merged_map: Dict[str, str] = {}
for real_path, original in {**vae_map, **upscaler_map}.items():
if real_path not in merged_map:
merged_map[real_path] = original
unique_paths = sorted(merged_map.values(), key=lambda p: p.lower())
vae_values = set(vae_map.values())
upscaler_values = set(upscaler_map.values())
self.vae_roots = [p for p in unique_paths if p in vae_values]
self.upscaler_roots = [p for p in unique_paths if p in upscaler_values]
for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
if real_path != original_path:
self.add_path_mapping(original_path, real_path)
return unique_paths
def get_preview_static_url(self, preview_path: str) -> str:
if not preview_path:
return ""
@@ -766,23 +830,7 @@ class Config:
return f'/api/lm/previews?path={encoded_path}'
def is_preview_path_allowed(self, preview_path: str) -> bool:
"""Return ``True`` if ``preview_path`` is within an allowed directory.
If the path is initially rejected, attempts to discover deep symlinks
that were not scanned during initialization. If a symlink is found,
updates the in-memory path mappings and retries the check.
"""
if self._is_path_in_allowed_roots(preview_path):
return True
if self._try_discover_deep_symlink(preview_path):
return self._is_path_in_allowed_roots(preview_path)
return False
def _is_path_in_allowed_roots(self, preview_path: str) -> bool:
"""Check if preview_path is within allowed preview roots without modification."""
"""Return ``True`` if ``preview_path`` is within an allowed directory."""
if not preview_path:
return False
@@ -792,72 +840,29 @@ class Config:
except Exception:
return False
# Use os.path.normcase for case-insensitive comparison on Windows.
# On Windows, Path.relative_to() is case-sensitive for drive letters,
# causing paths like 'a:/folder' to not match 'A:/folder'.
candidate_str = os.path.normcase(str(candidate))
for root in self._preview_root_paths:
root_str = os.path.normcase(str(root))
# Check if candidate is equal to or under the root directory
if candidate_str == root_str or candidate_str.startswith(root_str + os.sep):
return True
logger.debug(
"Path not in allowed roots: %s (candidate=%s, num_roots=%d)",
preview_path,
candidate_str,
len(self._preview_root_paths),
)
return False
def _try_discover_deep_symlink(self, preview_path: str) -> bool:
"""Attempt to discover a deep symlink that contains the preview_path.
Walks up from the preview path to the root directories, checking each
parent directory for symlinks. If a symlink is found, updates the
in-memory path mappings and preview roots.
Only updates in-memory state (self._path_mappings and self._preview_root_paths),
does not modify the persistent cache file.
Returns:
True if a symlink was discovered and mappings updated, False otherwise.
"""
if not preview_path:
return False
try:
candidate = Path(preview_path).expanduser()
except Exception:
return False
current = candidate
while True:
try:
if self._is_link(str(current)):
try:
target = os.path.realpath(str(current))
normalized_target = self._normalize_path(target)
normalized_link = self._normalize_path(str(current))
self._path_mappings[normalized_target] = normalized_link
self._preview_root_paths.update(self._expand_preview_root(normalized_target))
self._preview_root_paths.update(self._expand_preview_root(normalized_link))
logger.debug(
"Discovered deep symlink: %s -> %s (preview path: %s)",
normalized_link,
normalized_target,
preview_path
)
return True
except OSError:
pass
except OSError:
pass
parent = current.parent
if parent == current:
break
current = parent
if self._preview_root_paths:
logger.debug(
"Preview path rejected: %s (candidate=%s, num_roots=%d, first_root=%s)",
preview_path,
candidate_str,
len(self._preview_root_paths),
os.path.normcase(str(next(iter(self._preview_root_paths)))),
)
else:
logger.debug(
"Preview path rejected (no roots configured): %s",
preview_path,
)
return False

View File

@@ -184,15 +184,17 @@ class LoraManager:
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
misc_scanner = await ServiceRegistry.get_misc_scanner()
# Initialize recipe scanner if needed
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
# Create low-priority initialization tasks
init_tasks = [
asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init'),
asyncio.create_task(checkpoint_scanner.initialize_in_background(), name='checkpoint_cache_init'),
asyncio.create_task(embedding_scanner.initialize_in_background(), name='embedding_cache_init'),
asyncio.create_task(misc_scanner.initialize_in_background(), name='misc_cache_init'),
asyncio.create_task(recipe_scanner.initialize_in_background(), name='recipe_cache_init')
]
@@ -252,8 +254,9 @@ class LoraManager:
# Collect all model roots
all_roots = set()
all_roots.update(config.loras_roots)
all_roots.update(config.base_models_roots)
all_roots.update(config.base_models_roots)
all_roots.update(config.embeddings_roots)
all_roots.update(config.misc_roots or [])
total_deleted = 0
total_size_freed = 0

View File

@@ -1,7 +1,4 @@
import os
import logging
logger = logging.getLogger(__name__)
# Check if running in standalone mode
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
@@ -17,7 +14,7 @@ if not standalone_mode:
# Initialize registry
registry = MetadataRegistry()
logger.info("ComfyUI Metadata Collector initialized")
print("ComfyUI Metadata Collector initialized")
def get_metadata(prompt_id=None):
"""Helper function to get metadata from the registry"""
@@ -26,7 +23,7 @@ if not standalone_mode:
else:
# Standalone mode - provide dummy implementations
def init():
logger.info("ComfyUI Metadata Collector disabled in standalone mode")
print("ComfyUI Metadata Collector disabled in standalone mode")
def get_metadata(prompt_id=None):
"""Dummy implementation for standalone mode"""

View File

@@ -1,10 +1,7 @@
import sys
import inspect
import logging
from .metadata_registry import MetadataRegistry
logger = logging.getLogger(__name__)
class MetadataHook:
"""Install hooks for metadata collection"""
@@ -26,7 +23,7 @@ class MetadataHook:
# If we can't find the execution module, we can't install hooks
if execution is None:
logger.warning("Could not locate ComfyUI execution module, metadata collection disabled")
print("Could not locate ComfyUI execution module, metadata collection disabled")
return
# Detect whether we're using the new async version of ComfyUI
@@ -40,16 +37,16 @@ class MetadataHook:
is_async = inspect.iscoroutinefunction(execution._map_node_over_list)
if is_async:
logger.info("Detected async ComfyUI execution, installing async metadata hooks")
print("Detected async ComfyUI execution, installing async metadata hooks")
MetadataHook._install_async_hooks(execution, map_node_func_name)
else:
logger.info("Detected sync ComfyUI execution, installing sync metadata hooks")
print("Detected sync ComfyUI execution, installing sync metadata hooks")
MetadataHook._install_sync_hooks(execution)
logger.info("Metadata collection hooks installed for runtime values")
print("Metadata collection hooks installed for runtime values")
except Exception as e:
logger.error(f"Error installing metadata hooks: {str(e)}")
print(f"Error installing metadata hooks: {str(e)}")
@staticmethod
def _install_sync_hooks(execution):
@@ -85,7 +82,7 @@ class MetadataHook:
if node_id is not None:
registry.record_node_execution(node_id, class_type, input_data_all, None)
except Exception as e:
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
print(f"Error collecting metadata (pre-execution): {str(e)}")
# Execute the original function
results = original_map_node_over_list(obj, input_data_all, func, allow_interrupt, execution_block_cb, pre_execute_cb)
@@ -116,7 +113,7 @@ class MetadataHook:
if node_id is not None:
registry.update_node_execution(node_id, class_type, results)
except Exception as e:
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
print(f"Error collecting metadata (post-execution): {str(e)}")
return results
@@ -162,7 +159,7 @@ class MetadataHook:
if node_id is not None:
registry.record_node_execution(node_id, class_type, input_data_all, None)
except Exception as e:
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
print(f"Error collecting metadata (pre-execution): {str(e)}")
# Call original function with all args/kwargs
results = await original_map_node_over_list(
@@ -179,7 +176,7 @@ class MetadataHook:
if node_id is not None:
registry.update_node_execution(node_id, class_type, results)
except Exception as e:
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
print(f"Error collecting metadata (post-execution): {str(e)}")
return results

View File

@@ -126,7 +126,9 @@ class LoraCyclerLM:
"current_index": [clamped_index],
"next_index": [next_index],
"total_count": [total_count],
"current_lora_name": [current_lora["file_name"]],
"current_lora_name": [
current_lora.get("model_name", current_lora["file_name"])
],
"current_lora_filename": [current_lora["file_name"]],
"next_lora_name": [next_display_name],
"next_lora_filename": [next_lora["file_name"]],

View File

@@ -8,9 +8,6 @@ from ..metadata_collector.metadata_processor import MetadataProcessor
from ..metadata_collector import get_metadata
from PIL import Image, PngImagePlugin
import piexif
import logging
logger = logging.getLogger(__name__)
class SaveImageLM:
NAME = "Save Image (LoraManager)"
@@ -388,7 +385,7 @@ class SaveImageLM:
exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes
except Exception as e:
logger.error(f"Error adding EXIF data: {e}")
print(f"Error adding EXIF data: {e}")
img.save(file_path, format="JPEG", **save_kwargs)
elif file_format == "webp":
try:
@@ -406,7 +403,7 @@ class SaveImageLM:
exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes
except Exception as e:
logger.error(f"Error adding EXIF data: {e}")
print(f"Error adding EXIF data: {e}")
img.save(file_path, format="WEBP", **save_kwargs)
@@ -417,7 +414,7 @@ class SaveImageLM:
})
except Exception as e:
logger.error(f"Error saving image: {e}")
print(f"Error saving image: {e}")
return results

View File

@@ -60,22 +60,6 @@ class TriggerWordToggleLM:
else:
return data
def _normalize_trigger_words(self, trigger_words):
"""Normalize trigger words by splitting by both single and double commas, stripping whitespace, and filtering empty strings"""
if not trigger_words or not isinstance(trigger_words, str):
return set()
# Split by double commas first to preserve groups, then by single commas
groups = re.split(r",{2,}", trigger_words)
words = []
for group in groups:
# Split each group by single comma
group_words = [word.strip() for word in group.split(",")]
words.extend(group_words)
# Filter out empty strings and return as set
return set(word for word in words if word)
def process_trigger_words(
self,
id,
@@ -97,7 +81,7 @@ class TriggerWordToggleLM:
if (
trigger_words_override
and isinstance(trigger_words_override, str)
and self._normalize_trigger_words(trigger_words_override) != self._normalize_trigger_words(trigger_words)
and trigger_words_override != trigger_words
):
filtered_triggers = trigger_words_override
return (filtered_triggers,)

View File

@@ -30,7 +30,6 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"),
RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"),
RouteDefinition("POST", "/api/lm/example-images/set-nsfw-level", "set_example_image_nsfw_level"),
RouteDefinition("POST", "/api/lm/check-example-images-needed", "check_example_images_needed"),
)

View File

@@ -92,19 +92,6 @@ class ExampleImagesDownloadHandler:
except ExampleImagesDownloadError as exc:
return web.json_response({'success': False, 'error': str(exc)}, status=500)
async def check_example_images_needed(self, request: web.Request) -> web.StreamResponse:
"""Lightweight check to see if any models need example images downloaded."""
try:
payload = await request.json()
model_types = payload.get('model_types', ['lora', 'checkpoint', 'embedding'])
result = await self._download_manager.check_pending_models(model_types)
return web.json_response(result)
except Exception as exc:
return web.json_response(
{'success': False, 'error': str(exc)},
status=500
)
class ExampleImagesManagementHandler:
"""HTTP adapters for import/delete endpoints."""
@@ -174,7 +161,6 @@ class ExampleImagesHandlerSet:
"resume_example_images": self.download.resume_example_images,
"stop_example_images": self.download.stop_example_images,
"force_download_example_images": self.download.force_download_example_images,
"check_example_images_needed": self.download.check_example_images_needed,
"import_example_images": self.management.import_example_images,
"delete_example_image": self.management.delete_example_image,
"set_example_image_nsfw_level": self.management.set_example_image_nsfw_level,

View File

@@ -6,7 +6,6 @@ import asyncio
import json
import logging
import os
import re
import time
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional
@@ -270,11 +269,6 @@ class ModelListingHandler:
request.query.get("update_available_only", "false").lower() == "true"
)
# Tag logic: "any" (OR) or "all" (AND) for include tags
tag_logic = request.query.get("tag_logic", "any").lower()
if tag_logic not in ("any", "all"):
tag_logic = "any"
# New license-based query filters
credit_required = request.query.get("credit_required")
if credit_required is not None:
@@ -303,7 +297,6 @@ class ModelListingHandler:
"fuzzy_search": fuzzy_search,
"base_models": base_models,
"tags": tag_filters,
"tag_logic": tag_logic,
"search_options": search_options,
"hash_filters": hash_filters,
"favorites_only": favorites_only,
@@ -762,22 +755,19 @@ class ModelQueryHandler:
async def find_duplicate_models(self, request: web.Request) -> web.Response:
try:
filters = self._parse_duplicate_filters(request)
duplicates = self._service.find_duplicate_hashes()
result = []
cache = await self._service.scanner.get_cached_data()
for sha256, paths in duplicates.items():
# Collect all models in this group
all_models = []
group = {"hash": sha256, "models": []}
for path in paths:
model = next(
(m for m in cache.raw_data if m["file_path"] == path), None
)
if model:
all_models.append(model)
# Include primary if not already in paths
group["models"].append(
await self._service.format_response(model)
)
primary_path = self._service.get_path_by_hash(sha256)
if primary_path and primary_path not in paths:
primary_model = next(
@@ -785,25 +775,11 @@ class ModelQueryHandler:
None,
)
if primary_model:
all_models.insert(0, primary_model)
# Apply filters
filtered = self._apply_duplicate_filters(all_models, filters)
# Sort: originals first, copies last
sorted_models = self._sort_duplicate_group(filtered)
# Format response
group = {"hash": sha256, "models": []}
for model in sorted_models:
group["models"].append(
await self._service.format_response(model)
)
# Only include groups with 2+ models after filtering
group["models"].insert(
0, await self._service.format_response(primary_model)
)
if len(group["models"]) > 1:
result.append(group)
return web.json_response(
{"success": True, "duplicates": result, "count": len(result)}
)
@@ -816,83 +792,6 @@ class ModelQueryHandler:
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
def _parse_duplicate_filters(self, request: web.Request) -> Dict[str, Any]:
"""Parse filter parameters from the request for duplicate finding."""
return {
"base_models": request.query.getall("base_model", []),
"tag_include": request.query.getall("tag_include", []),
"tag_exclude": request.query.getall("tag_exclude", []),
"model_types": request.query.getall("model_type", []),
"folder": request.query.get("folder"),
"favorites_only": request.query.get("favorites_only", "").lower() == "true",
}
def _apply_duplicate_filters(self, models: List[Dict[str, Any]], filters: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Apply filters to a list of models within a duplicate group."""
result = models
# Apply base model filter
if filters.get("base_models"):
base_set = set(filters["base_models"])
result = [m for m in result if m.get("base_model") in base_set]
# Apply tag filters (include)
for tag in filters.get("tag_include", []):
if tag == "__no_tags__":
result = [m for m in result if not m.get("tags")]
else:
result = [m for m in result if tag in (m.get("tags") or [])]
# Apply tag filters (exclude)
for tag in filters.get("tag_exclude", []):
if tag == "__no_tags__":
result = [m for m in result if m.get("tags")]
else:
result = [m for m in result if tag not in (m.get("tags") or [])]
# Apply model type filter
if filters.get("model_types"):
type_set = {t.lower() for t in filters["model_types"]}
result = [
m for m in result if (m.get("model_type") or "").lower() in type_set
]
# Apply folder filter
if filters.get("folder"):
folder = filters["folder"]
result = [m for m in result if m.get("folder", "").startswith(folder)]
# Apply favorites filter
if filters.get("favorites_only"):
result = [m for m in result if m.get("favorite", False)]
return result
def _sort_duplicate_group(self, models: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Sort models: originals first (left), copies (with -????. pattern) last (right)."""
if len(models) <= 1:
return models
min_len = min(len(m.get("file_name", "")) for m in models)
def copy_score(m):
fn = m.get("file_name", "")
score = 0
# Match -0001.safetensors, -1234.safetensors etc.
if re.search(r"-\d{4}\.", fn):
score += 100
# Match (1), (2) etc.
if re.search(r"\(\d+\)", fn):
score += 50
# Match 'copy' in filename
if "copy" in fn.lower():
score += 50
# Longer filenames are more likely copies
score += len(fn) - min_len
return (score, fn.lower())
return sorted(models, key=copy_score)
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
try:
duplicates = self._service.find_duplicate_filenames()

View File

@@ -33,10 +33,6 @@ class PreviewHandler:
raise web.HTTPBadRequest(text="Invalid preview path encoding") from exc
normalized = decoded_path.replace("\\", "/")
if not self._config.is_preview_path_allowed(normalized):
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
candidate = Path(normalized)
try:
resolved = candidate.expanduser().resolve(strict=False)
@@ -44,8 +40,12 @@ class PreviewHandler:
logger.debug("Failed to resolve preview path %s: %s", normalized, exc)
raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc
resolved_str = str(resolved)
if not self._config.is_preview_path_allowed(resolved_str):
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
if not resolved.is_file():
logger.debug("Preview file not found at %s", str(resolved))
logger.debug("Preview file not found at %s", resolved_str)
raise web.HTTPNotFound(text="Preview file not found")
# aiohttp's FileResponse handles range requests and content headers for us.

View File

@@ -412,11 +412,10 @@ class RecipeQueryHandler:
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
fingerprint_groups = await recipe_scanner.find_all_duplicate_recipes()
url_groups = await recipe_scanner.find_duplicate_recipes_by_source()
duplicate_groups = await recipe_scanner.find_all_duplicate_recipes()
response_data = []
for fingerprint, recipe_ids in fingerprint_groups.items():
for fingerprint, recipe_ids in duplicate_groups.items():
if len(recipe_ids) <= 1:
continue
@@ -440,44 +439,12 @@ class RecipeQueryHandler:
recipes.sort(key=lambda entry: entry.get("modified", 0), reverse=True)
response_data.append(
{
"type": "fingerprint",
"fingerprint": fingerprint,
"count": len(recipes),
"recipes": recipes,
}
)
for url, recipe_ids in url_groups.items():
if len(recipe_ids) <= 1:
continue
recipes = []
for recipe_id in recipe_ids:
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
if recipe:
recipes.append(
{
"id": recipe.get("id"),
"title": recipe.get("title"),
"file_url": recipe.get("file_url")
or self._format_recipe_file_url(recipe.get("file_path", "")),
"modified": recipe.get("modified"),
"created_date": recipe.get("created_date"),
"lora_count": len(recipe.get("loras", [])),
}
)
if len(recipes) >= 2:
recipes.sort(key=lambda entry: entry.get("modified", 0), reverse=True)
response_data.append(
{
"type": "source_url",
"fingerprint": url,
"count": len(recipes),
"recipes": recipes,
}
)
response_data.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response({"success": True, "duplicate_groups": response_data})
except Exception as exc:
@@ -1054,7 +1021,7 @@ class RecipeManagementHandler:
"exclude": False,
}
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any]:
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str]:
civitai_client = self._civitai_client_getter()
downloader = await self._downloader_factory()
temp_path = None
@@ -1062,7 +1029,6 @@ class RecipeManagementHandler:
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_path = temp_file.name
download_url = image_url
image_info = None
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
if civitai_match:
if civitai_client is None:

View File

@@ -0,0 +1,112 @@
import logging
from typing import Dict
from aiohttp import web
from .base_model_routes import BaseModelRoutes
from .model_route_registrar import ModelRouteRegistrar
from ..services.misc_service import MiscService
from ..services.service_registry import ServiceRegistry
from ..config import config
logger = logging.getLogger(__name__)
class MiscModelRoutes(BaseModelRoutes):
"""Misc-specific route controller (VAE, Upscaler)"""
def __init__(self):
"""Initialize Misc routes with Misc service"""
super().__init__()
self.template_name = "misc.html"
async def initialize_services(self):
"""Initialize services from ServiceRegistry"""
misc_scanner = await ServiceRegistry.get_misc_scanner()
update_service = await ServiceRegistry.get_model_update_service()
self.service = MiscService(misc_scanner, update_service=update_service)
self.set_model_update_service(update_service)
# Attach service dependencies
self.attach_service(self.service)
def setup_routes(self, app: web.Application):
"""Setup Misc routes"""
# Schedule service initialization on app startup
app.on_startup.append(lambda _: self.initialize_services())
# Setup common routes with 'misc' prefix (includes page route)
super().setup_routes(app, 'misc')
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
"""Setup Misc-specific routes"""
# Misc info by name
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/info/{name}', prefix, self.get_misc_info)
# VAE roots and Upscaler roots
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/vae_roots', prefix, self.get_vae_roots)
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/upscaler_roots', prefix, self.get_upscaler_roots)
def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for Misc (VAE or Upscaler)"""
return model_type.lower() in ['vae', 'upscaler']
def _get_expected_model_types(self) -> str:
"""Get expected model types string for error messages"""
return "VAE or Upscaler"
def _parse_specific_params(self, request: web.Request) -> Dict:
"""Parse Misc-specific parameters"""
params: Dict = {}
if 'misc_hash' in request.query:
params['hash_filters'] = {'single_hash': request.query['misc_hash'].lower()}
elif 'misc_hashes' in request.query:
params['hash_filters'] = {
'multiple_hashes': [h.lower() for h in request.query['misc_hashes'].split(',')]
}
return params
async def get_misc_info(self, request: web.Request) -> web.Response:
"""Get detailed information for a specific misc model by name"""
try:
name = request.match_info.get('name', '')
misc_info = await self.service.get_model_info_by_name(name)
if misc_info:
return web.json_response(misc_info)
else:
return web.json_response({"error": "Misc model not found"}, status=404)
except Exception as e:
logger.error(f"Error in get_misc_info: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
async def get_vae_roots(self, request: web.Request) -> web.Response:
"""Return the list of VAE roots from config"""
try:
roots = config.vae_roots
return web.json_response({
"success": True,
"roots": roots
})
except Exception as e:
logger.error(f"Error getting VAE roots: {e}", exc_info=True)
return web.json_response({
"success": False,
"error": str(e)
}, status=500)
async def get_upscaler_roots(self, request: web.Request) -> web.Response:
"""Return the list of upscaler roots from config"""
try:
roots = config.upscaler_roots
return web.json_response({
"success": True,
"roots": roots
})
except Exception as e:
logger.error(f"Error getting upscaler roots: {e}", exc_info=True)
return web.json_response({
"success": False,
"error": str(e)
}, status=500)

View File

@@ -81,7 +81,6 @@ class BaseModelService(ABC):
update_available_only: bool = False,
credit_required: Optional[bool] = None,
allow_selling_generated_content: Optional[bool] = None,
tag_logic: str = "any",
**kwargs,
) -> Dict:
"""Get paginated and filtered model data"""
@@ -110,7 +109,6 @@ class BaseModelService(ABC):
tags=tags,
favorites_only=favorites_only,
search_options=search_options,
tag_logic=tag_logic,
)
if search:
@@ -243,7 +241,6 @@ class BaseModelService(ABC):
tags: Optional[Dict[str, str]] = None,
favorites_only: bool = False,
search_options: dict = None,
tag_logic: str = "any",
) -> List[Dict]:
"""Apply common filters that work across all model types"""
normalized_options = self.search_strategy.normalize_options(search_options)
@@ -256,7 +253,6 @@ class BaseModelService(ABC):
tags=tags,
favorites_only=favorites_only,
search_options=normalized_options,
tag_logic=tag_logic,
)
return self.filter_set.apply(data, criteria)

View File

@@ -1,259 +0,0 @@
"""
Cache Entry Validator
Validates and repairs cache entries to prevent runtime errors from
missing or invalid critical fields.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import logging
import os
logger = logging.getLogger(__name__)
@dataclass
class ValidationResult:
"""Result of validating a single cache entry."""
is_valid: bool
repaired: bool
errors: List[str] = field(default_factory=list)
entry: Optional[Dict[str, Any]] = None
class CacheEntryValidator:
"""
Validates and repairs cache entry core fields.
Critical fields that cause runtime errors when missing:
- file_path: KeyError in multiple locations
- sha256: KeyError/AttributeError in hash operations
Medium severity fields that may cause sorting/display issues:
- size: KeyError during sorting
- modified: KeyError during sorting
- model_name: AttributeError on .lower() calls
Low severity fields:
- tags: KeyError/TypeError in recipe operations
"""
# Field definitions: (default_value, is_required)
CORE_FIELDS: Dict[str, Tuple[Any, bool]] = {
'file_path': ('', True),
'sha256': ('', True),
'file_name': ('', False),
'model_name': ('', False),
'folder': ('', False),
'size': (0, False),
'modified': (0.0, False),
'tags': ([], False),
'preview_url': ('', False),
'base_model': ('', False),
'from_civitai': (True, False),
'favorite': (False, False),
'exclude': (False, False),
'db_checked': (False, False),
'preview_nsfw_level': (0, False),
'notes': ('', False),
'usage_tips': ('', False),
}
@classmethod
def validate(cls, entry: Dict[str, Any], *, auto_repair: bool = True) -> ValidationResult:
"""
Validate a single cache entry.
Args:
entry: The cache entry dictionary to validate
auto_repair: If True, attempt to repair missing/invalid fields
Returns:
ValidationResult with validation status and optionally repaired entry
"""
if entry is None:
return ValidationResult(
is_valid=False,
repaired=False,
errors=['Entry is None'],
entry=None
)
if not isinstance(entry, dict):
return ValidationResult(
is_valid=False,
repaired=False,
errors=[f'Entry is not a dict: {type(entry).__name__}'],
entry=None
)
errors: List[str] = []
repaired = False
working_entry = dict(entry) if auto_repair else entry
for field_name, (default_value, is_required) in cls.CORE_FIELDS.items():
value = working_entry.get(field_name)
# Check if field is missing or None
if value is None:
if is_required:
errors.append(f"Required field '{field_name}' is missing or None")
if auto_repair:
working_entry[field_name] = cls._get_default_copy(default_value)
repaired = True
continue
# Validate field type and value
field_error = cls._validate_field(field_name, value, default_value)
if field_error:
errors.append(field_error)
if auto_repair:
working_entry[field_name] = cls._get_default_copy(default_value)
repaired = True
# Special validation: file_path must not be empty for required field
file_path = working_entry.get('file_path', '')
if not file_path or (isinstance(file_path, str) and not file_path.strip()):
errors.append("Required field 'file_path' is empty")
# Cannot repair empty file_path - entry is invalid
return ValidationResult(
is_valid=False,
repaired=repaired,
errors=errors,
entry=working_entry if auto_repair else None
)
# Special validation: sha256 must not be empty for required field
sha256 = working_entry.get('sha256', '')
if not sha256 or (isinstance(sha256, str) and not sha256.strip()):
errors.append("Required field 'sha256' is empty")
# Cannot repair empty sha256 - entry is invalid
return ValidationResult(
is_valid=False,
repaired=repaired,
errors=errors,
entry=working_entry if auto_repair else None
)
# Normalize sha256 to lowercase if needed
if isinstance(sha256, str):
normalized_sha = sha256.lower().strip()
if normalized_sha != sha256:
working_entry['sha256'] = normalized_sha
repaired = True
# Determine if entry is valid
# Entry is valid if no critical required field errors remain after repair
# Critical fields are file_path and sha256
CRITICAL_REQUIRED_FIELDS = {'file_path', 'sha256'}
has_critical_errors = any(
"Required field" in error and
any(f"'{field}'" in error for field in CRITICAL_REQUIRED_FIELDS)
for error in errors
)
is_valid = not has_critical_errors
return ValidationResult(
is_valid=is_valid,
repaired=repaired,
errors=errors,
entry=working_entry if auto_repair else entry
)
@classmethod
def validate_batch(
cls,
entries: List[Dict[str, Any]],
*,
auto_repair: bool = True
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
Validate a batch of cache entries.
Args:
entries: List of cache entry dictionaries to validate
auto_repair: If True, attempt to repair missing/invalid fields
Returns:
Tuple of (valid_entries, invalid_entries)
"""
if not entries:
return [], []
valid_entries: List[Dict[str, Any]] = []
invalid_entries: List[Dict[str, Any]] = []
for entry in entries:
result = cls.validate(entry, auto_repair=auto_repair)
if result.is_valid:
# Use repaired entry if available, otherwise original
valid_entries.append(result.entry if result.entry else entry)
else:
invalid_entries.append(entry)
# Log invalid entries for debugging
file_path = entry.get('file_path', '<unknown>') if isinstance(entry, dict) else '<not a dict>'
logger.warning(
f"Invalid cache entry for '{file_path}': {', '.join(result.errors)}"
)
return valid_entries, invalid_entries
@classmethod
def _validate_field(cls, field_name: str, value: Any, default_value: Any) -> Optional[str]:
"""
Validate a specific field value.
Returns an error message if invalid, None if valid.
"""
expected_type = type(default_value)
# Special handling for numeric types
if expected_type == int:
if not isinstance(value, (int, float)):
return f"Field '{field_name}' should be numeric, got {type(value).__name__}"
elif expected_type == float:
if not isinstance(value, (int, float)):
return f"Field '{field_name}' should be numeric, got {type(value).__name__}"
elif expected_type == bool:
# Be lenient with boolean fields - accept truthy/falsy values
pass
elif expected_type == str:
if not isinstance(value, str):
return f"Field '{field_name}' should be string, got {type(value).__name__}"
elif expected_type == list:
if not isinstance(value, (list, tuple)):
return f"Field '{field_name}' should be list, got {type(value).__name__}"
return None
@classmethod
def _get_default_copy(cls, default_value: Any) -> Any:
"""Get a copy of the default value to avoid shared mutable state."""
if isinstance(default_value, list):
return list(default_value)
if isinstance(default_value, dict):
return dict(default_value)
return default_value
@classmethod
def get_file_path_safe(cls, entry: Dict[str, Any], default: str = '') -> str:
"""Safely get file_path from an entry."""
if not isinstance(entry, dict):
return default
value = entry.get('file_path')
if isinstance(value, str):
return value
return default
@classmethod
def get_sha256_safe(cls, entry: Dict[str, Any], default: str = '') -> str:
"""Safely get sha256 from an entry."""
if not isinstance(entry, dict):
return default
value = entry.get('sha256')
if isinstance(value, str):
return value.lower()
return default

View File

@@ -1,201 +0,0 @@
"""
Cache Health Monitor
Monitors cache health status and determines when user intervention is needed.
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional
import logging
from .cache_entry_validator import CacheEntryValidator, ValidationResult
logger = logging.getLogger(__name__)
class CacheHealthStatus(Enum):
"""Health status of the cache."""
HEALTHY = "healthy"
DEGRADED = "degraded"
CORRUPTED = "corrupted"
@dataclass
class HealthReport:
"""Report of cache health check."""
status: CacheHealthStatus
total_entries: int
valid_entries: int
invalid_entries: int
repaired_entries: int
invalid_paths: List[str] = field(default_factory=list)
message: str = ""
@property
def corruption_rate(self) -> float:
"""Calculate the percentage of invalid entries."""
if self.total_entries <= 0:
return 0.0
return self.invalid_entries / self.total_entries
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
'status': self.status.value,
'total_entries': self.total_entries,
'valid_entries': self.valid_entries,
'invalid_entries': self.invalid_entries,
'repaired_entries': self.repaired_entries,
'corruption_rate': f"{self.corruption_rate:.1%}",
'invalid_paths': self.invalid_paths[:10], # Limit to first 10
'message': self.message,
}
class CacheHealthMonitor:
"""
Monitors cache health and determines appropriate status.
Thresholds:
- HEALTHY: 0% invalid entries
- DEGRADED: 0-5% invalid entries (auto-repaired, user should rebuild)
- CORRUPTED: >5% invalid entries (significant data loss likely)
"""
# Threshold percentages
DEGRADED_THRESHOLD = 0.01 # 1% - show warning
CORRUPTED_THRESHOLD = 0.05 # 5% - critical warning
def __init__(
self,
*,
degraded_threshold: float = DEGRADED_THRESHOLD,
corrupted_threshold: float = CORRUPTED_THRESHOLD
):
"""
Initialize the health monitor.
Args:
degraded_threshold: Corruption rate threshold for DEGRADED status
corrupted_threshold: Corruption rate threshold for CORRUPTED status
"""
self.degraded_threshold = degraded_threshold
self.corrupted_threshold = corrupted_threshold
def check_health(
self,
entries: List[Dict[str, Any]],
*,
auto_repair: bool = True
) -> HealthReport:
"""
Check the health of cache entries.
Args:
entries: List of cache entry dictionaries to check
auto_repair: If True, attempt to repair entries during validation
Returns:
HealthReport with status and statistics
"""
if not entries:
return HealthReport(
status=CacheHealthStatus.HEALTHY,
total_entries=0,
valid_entries=0,
invalid_entries=0,
repaired_entries=0,
message="Cache is empty"
)
total_entries = len(entries)
valid_entries: List[Dict[str, Any]] = []
invalid_entries: List[Dict[str, Any]] = []
repaired_count = 0
invalid_paths: List[str] = []
for entry in entries:
result = CacheEntryValidator.validate(entry, auto_repair=auto_repair)
if result.is_valid:
valid_entries.append(result.entry if result.entry else entry)
if result.repaired:
repaired_count += 1
else:
invalid_entries.append(entry)
# Extract file path for reporting
file_path = CacheEntryValidator.get_file_path_safe(entry, '<unknown>')
invalid_paths.append(file_path)
invalid_count = len(invalid_entries)
valid_count = len(valid_entries)
# Determine status based on corruption rate
corruption_rate = invalid_count / total_entries if total_entries > 0 else 0.0
if invalid_count == 0:
status = CacheHealthStatus.HEALTHY
message = "Cache is healthy"
elif corruption_rate >= self.corrupted_threshold:
status = CacheHealthStatus.CORRUPTED
message = (
f"Cache is corrupted: {invalid_count} invalid entries "
f"({corruption_rate:.1%}). Rebuild recommended."
)
elif corruption_rate >= self.degraded_threshold or invalid_count > 0:
status = CacheHealthStatus.DEGRADED
message = (
f"Cache has {invalid_count} invalid entries "
f"({corruption_rate:.1%}). Consider rebuilding cache."
)
else:
# This shouldn't happen, but handle gracefully
status = CacheHealthStatus.HEALTHY
message = "Cache is healthy"
# Log the health check result
if status != CacheHealthStatus.HEALTHY:
logger.warning(
f"Cache health check: {status.value} - "
f"{invalid_count}/{total_entries} invalid, "
f"{repaired_count} repaired"
)
if invalid_paths:
logger.debug(f"Invalid entry paths: {invalid_paths[:5]}")
return HealthReport(
status=status,
total_entries=total_entries,
valid_entries=valid_count,
invalid_entries=invalid_count,
repaired_entries=repaired_count,
invalid_paths=invalid_paths,
message=message
)
def should_notify_user(self, report: HealthReport) -> bool:
"""
Determine if the user should be notified about cache health.
Args:
report: The health report to evaluate
Returns:
True if user should be notified
"""
return report.status != CacheHealthStatus.HEALTHY
def get_notification_severity(self, report: HealthReport) -> str:
"""
Get the severity level for user notification.
Args:
report: The health report to evaluate
Returns:
Severity string: 'warning' or 'error'
"""
if report.status == CacheHealthStatus.CORRUPTED:
return 'error'
return 'warning'

View File

@@ -9,7 +9,7 @@ from collections import OrderedDict
import uuid
from typing import Dict, List, Optional, Set, Tuple
from urllib.parse import urlparse
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata, MiscMetadata
from ..utils.constants import CARD_PREVIEW_WIDTH, DIFFUSION_MODEL_BASE_MODELS, VALID_LORA_TYPES
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media
@@ -60,6 +60,10 @@ class DownloadManager:
"""Get the checkpoint scanner from registry"""
return await ServiceRegistry.get_checkpoint_scanner()
async def _get_misc_scanner(self):
"""Get the misc scanner from registry"""
return await ServiceRegistry.get_misc_scanner()
async def download_from_civitai(
self,
model_id: int = None,
@@ -275,6 +279,7 @@ class DownloadManager:
lora_scanner = await self._get_lora_scanner()
checkpoint_scanner = await self._get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
misc_scanner = await self._get_misc_scanner()
# Check lora scanner first
if await lora_scanner.check_model_version_exists(model_version_id):
@@ -299,6 +304,13 @@ class DownloadManager:
"error": "Model version already exists in embedding library",
}
# Check misc scanner (VAE, Upscaler)
if await misc_scanner.check_model_version_exists(model_version_id):
return {
"success": False,
"error": "Model version already exists in misc library",
}
# Use CivArchive provider directly when source is 'civarchive'
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
if source == "civarchive":
@@ -337,6 +349,10 @@ class DownloadManager:
model_type = "lora"
elif model_type_from_info == "textualinversion":
model_type = "embedding"
elif model_type_from_info == "vae":
model_type = "misc"
elif model_type_from_info == "upscaler":
model_type = "misc"
else:
return {
"success": False,
@@ -379,6 +395,14 @@ class DownloadManager:
"success": False,
"error": "Model version already exists in embedding library",
}
elif model_type == "misc":
# Check misc scanner (VAE, Upscaler)
misc_scanner = await self._get_misc_scanner()
if await misc_scanner.check_model_version_exists(version_id):
return {
"success": False,
"error": "Model version already exists in misc library",
}
# Handle use_default_paths
if use_default_paths:
@@ -413,6 +437,26 @@ class DownloadManager:
"error": "Default embedding root path not set in settings",
}
save_dir = default_path
elif model_type == "misc":
from ..config import config
civitai_type = version_info.get("model", {}).get("type", "").lower()
if civitai_type == "vae":
default_paths = config.vae_roots
error_msg = "VAE root path not configured"
elif civitai_type == "upscaler":
default_paths = config.upscaler_roots
error_msg = "Upscaler root path not configured"
else:
default_paths = config.misc_roots
error_msg = "Misc root path not configured"
if not default_paths:
return {
"success": False,
"error": error_msg,
}
save_dir = default_paths[0] if default_paths else ""
# Calculate relative path using template
relative_path = self._calculate_relative_path(version_info, model_type)
@@ -515,6 +559,11 @@ class DownloadManager:
version_info, file_info, save_path
)
logger.info(f"Creating EmbeddingMetadata for {file_name}")
elif model_type == "misc":
metadata = MiscMetadata.from_civitai_info(
version_info, file_info, save_path
)
logger.info(f"Creating MiscMetadata for {file_name}")
# 6. Start download process
result = await self._execute_download(
@@ -620,6 +669,8 @@ class DownloadManager:
scanner = await self._get_checkpoint_scanner()
elif model_type == "embedding":
scanner = await ServiceRegistry.get_embedding_scanner()
elif model_type == "misc":
scanner = await self._get_misc_scanner()
except Exception as exc:
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
@@ -1016,6 +1067,9 @@ class DownloadManager:
elif model_type == "embedding":
scanner = await ServiceRegistry.get_embedding_scanner()
logger.info(f"Updating embedding cache for {actual_file_paths[0]}")
elif model_type == "misc":
scanner = await self._get_misc_scanner()
logger.info(f"Updating misc cache for {actual_file_paths[0]}")
adjust_cached_entry = (
getattr(scanner, "adjust_cached_entry", None)
@@ -1125,6 +1179,14 @@ class DownloadManager:
".pkl",
".sft",
}
if model_type == "misc":
return {
".ckpt",
".pt",
".bin",
".pth",
".safetensors",
}
return {".safetensors"}
async def _extract_model_files_from_archive(

View File

@@ -30,36 +30,36 @@ class LoraScanner(ModelScanner):
async def diagnose_hash_index(self):
"""Diagnostic method to verify hash index functionality"""
logger.debug("\n\n*** DIAGNOSING LORA HASH INDEX ***\n\n")
print("\n\n*** DIAGNOSING LORA HASH INDEX ***\n\n", file=sys.stderr)
# First check if the hash index has any entries
if hasattr(self, '_hash_index'):
index_entries = len(self._hash_index._hash_to_path)
logger.debug(f"Hash index has {index_entries} entries")
print(f"Hash index has {index_entries} entries", file=sys.stderr)
# Print a few example entries if available
if index_entries > 0:
logger.debug("\nSample hash index entries:")
print("\nSample hash index entries:", file=sys.stderr)
count = 0
for hash_val, path in self._hash_index._hash_to_path.items():
if count < 5: # Just show the first 5
logger.debug(f"Hash: {hash_val[:8]}... -> Path: {path}")
print(f"Hash: {hash_val[:8]}... -> Path: {path}", file=sys.stderr)
count += 1
else:
break
else:
logger.debug("Hash index not initialized")
print("Hash index not initialized", file=sys.stderr)
# Try looking up by a known hash for testing
if not hasattr(self, '_hash_index') or not self._hash_index._hash_to_path:
logger.debug("No hash entries to test lookup with")
print("No hash entries to test lookup with", file=sys.stderr)
return
test_hash = next(iter(self._hash_index._hash_to_path.keys()))
test_path = self._hash_index.get_path(test_hash)
logger.debug(f"\nTest lookup by hash: {test_hash[:8]}... -> {test_path}")
print(f"\nTest lookup by hash: {test_hash[:8]}... -> {test_path}", file=sys.stderr)
# Also test reverse lookup
test_hash_result = self._hash_index.get_hash(test_path)
logger.debug(f"Test reverse lookup: {test_path} -> {test_hash_result[:8]}...\n\n")
print(f"Test reverse lookup: {test_path} -> {test_hash_result[:8]}...\n\n", file=sys.stderr)

View File

@@ -44,8 +44,6 @@ async def initialize_metadata_providers():
logger.debug(f"SQLite metadata provider registered with database: {db_path}")
else:
logger.warning("Metadata archive database is enabled but database file not found")
logger.info("Automatically disabling enable_metadata_archive_db setting")
settings_manager.set('enable_metadata_archive_db', False)
except Exception as e:
logger.error(f"Failed to initialize SQLite metadata provider: {e}")

View File

@@ -243,27 +243,17 @@ class MetadataSyncService:
last_error = error or last_error
if civitai_metadata is None or metadata_provider is None:
# Track if we need to save metadata
needs_save = False
if sqlite_attempted:
model_data["db_checked"] = True
needs_save = True
if civitai_api_not_found:
model_data["from_civitai"] = False
model_data["civitai_deleted"] = True
model_data["db_checked"] = sqlite_attempted or (enable_archive and model_data.get("db_checked", False))
model_data["last_checked_at"] = datetime.now().timestamp()
needs_save = True
# Save metadata if any state was updated
if needs_save:
data_to_save = model_data.copy()
data_to_save.pop("folder", None)
# Update last_checked_at for sqlite-only attempts if not already set
if "last_checked_at" not in data_to_save:
data_to_save["last_checked_at"] = datetime.now().timestamp()
await self._metadata_manager.save_metadata(file_path, data_to_save)
default_error = (

View File

@@ -0,0 +1,55 @@
import logging
from typing import Any, Dict, List, Optional
from ..utils.models import MiscMetadata
from ..config import config
from .model_scanner import ModelScanner
from .model_hash_index import ModelHashIndex
logger = logging.getLogger(__name__)
class MiscScanner(ModelScanner):
"""Service for scanning and managing misc files (VAE, Upscaler)"""
def __init__(self):
# Define supported file extensions (combined from VAE and upscaler)
file_extensions = {'.safetensors', '.pt', '.bin', '.ckpt', '.pth'}
super().__init__(
model_type="misc",
model_class=MiscMetadata,
file_extensions=file_extensions,
hash_index=ModelHashIndex()
)
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
"""Resolve the sub-type based on the root path."""
if not root_path:
return None
if config.vae_roots and root_path in config.vae_roots:
return "vae"
if config.upscaler_roots and root_path in config.upscaler_roots:
return "upscaler"
return None
def adjust_metadata(self, metadata, file_path, root_path):
"""Adjust metadata during scanning to set sub_type."""
sub_type = self._resolve_sub_type(root_path)
if sub_type:
metadata.sub_type = sub_type
return metadata
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""Adjust entries loaded from the persisted cache to ensure sub_type is set."""
sub_type = self._resolve_sub_type(
self._find_root_for_file(entry.get("file_path"))
)
if sub_type:
entry["sub_type"] = sub_type
return entry
def get_model_roots(self) -> List[str]:
"""Get misc root directories (VAE and upscaler)"""
return config.misc_roots

View File

@@ -0,0 +1,55 @@
import os
import logging
from typing import Dict
from .base_model_service import BaseModelService
from ..utils.models import MiscMetadata
from ..config import config
logger = logging.getLogger(__name__)
class MiscService(BaseModelService):
"""Misc-specific service implementation (VAE, Upscaler)"""
def __init__(self, scanner, update_service=None):
"""Initialize Misc service
Args:
scanner: Misc scanner instance
update_service: Optional service for remote update tracking.
"""
super().__init__("misc", scanner, MiscMetadata, update_service=update_service)
async def format_response(self, misc_data: Dict) -> Dict:
"""Format Misc data for API response"""
# Get sub_type from cache entry (new canonical field)
sub_type = misc_data.get("sub_type", "vae")
return {
"model_name": misc_data["model_name"],
"file_name": misc_data["file_name"],
"preview_url": config.get_preview_static_url(misc_data.get("preview_url", "")),
"preview_nsfw_level": misc_data.get("preview_nsfw_level", 0),
"base_model": misc_data.get("base_model", ""),
"folder": misc_data["folder"],
"sha256": misc_data.get("sha256", ""),
"file_path": misc_data["file_path"].replace(os.sep, "/"),
"file_size": misc_data.get("size", 0),
"modified": misc_data.get("modified", ""),
"tags": misc_data.get("tags", []),
"from_civitai": misc_data.get("from_civitai", True),
"usage_count": misc_data.get("usage_count", 0),
"notes": misc_data.get("notes", ""),
"sub_type": sub_type,
"favorite": misc_data.get("favorite", False),
"update_available": bool(misc_data.get("update_available", False)),
"civitai": self.filter_civitai_data(misc_data.get("civitai", {}), minimal=True)
}
def find_duplicate_hashes(self) -> Dict:
"""Find Misc models with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes()
def find_duplicate_filenames(self) -> Dict:
"""Find Misc models with conflicting filenames"""
return self.scanner._hash_index.get_duplicate_filenames()

View File

@@ -5,6 +5,7 @@ import logging
logger = logging.getLogger(__name__)
from typing import Any, Dict, List, Optional, Tuple
from dataclasses import dataclass, field
from operator import itemgetter
from natsort import natsorted
# Supported sort modes: (sort_key, order)
@@ -228,17 +229,17 @@ class ModelCache:
reverse=reverse
)
elif sort_key == 'date':
# Sort by modified timestamp (use .get() with default to handle missing fields)
# Sort by modified timestamp
result = sorted(
data,
key=lambda x: x.get('modified', 0.0),
key=itemgetter('modified'),
reverse=reverse
)
elif sort_key == 'size':
# Sort by file size (use .get() with default to handle missing fields)
# Sort by file size
result = sorted(
data,
key=lambda x: x.get('size', 0),
key=itemgetter('size'),
reverse=reverse
)
elif sort_key == 'usage':

View File

@@ -676,12 +676,10 @@ class ModelMetadataProviderManager:
def _get_provider(self, provider_name: str = None) -> ModelMetadataProvider:
"""Get provider by name or default provider"""
if provider_name:
if provider_name not in self.providers:
raise ValueError(f"Provider '{provider_name}' is not registered")
if provider_name and provider_name in self.providers:
return self.providers[provider_name]
if self.default_provider is None:
raise ValueError("No default provider set and no valid provider specified")
return self.providers[self.default_provider]

View File

@@ -99,7 +99,6 @@ class FilterCriteria:
favorites_only: bool = False
search_options: Optional[Dict[str, Any]] = None
model_types: Optional[Sequence[str]] = None
tag_logic: str = "any" # "any" (OR) or "all" (AND)
class ModelCacheRepository:
@@ -301,29 +300,11 @@ class ModelFilterSet:
include_tags = {tag for tag in tag_filters if tag}
if include_tags:
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
def matches_include(item_tags):
if not item_tags and "__no_tags__" in include_tags:
return True
if tag_logic == "all":
# AND logic: item must have ALL include tags
# Special case: __no_tags__ is handled separately
non_special_tags = include_tags - {"__no_tags__"}
if "__no_tags__" in include_tags:
# If __no_tags__ is selected along with other tags,
# treat it as "no tags OR (all other tags)"
if not item_tags:
return True
# Otherwise, check if all non-special tags match
if non_special_tags:
return all(tag in (item_tags or []) for tag in non_special_tags)
return True
# Normal case: all tags must match
return all(tag in (item_tags or []) for tag in non_special_tags)
else:
# OR logic (default): item must have ANY include tag
return any(tag in include_tags for tag in (item_tags or []))
return any(tag in include_tags for tag in (item_tags or []))
items = [item for item in items if matches_include(item.get("tags"))]

View File

@@ -20,8 +20,6 @@ from .service_registry import ServiceRegistry
from .websocket_manager import ws_manager
from .persistent_model_cache import get_persistent_cache
from .settings_manager import get_settings_manager
from .cache_entry_validator import CacheEntryValidator
from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
logger = logging.getLogger(__name__)
@@ -470,39 +468,6 @@ class ModelScanner:
for tag in adjusted_item.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1
# Validate cache entries and check health
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
adjusted_raw_data, auto_repair=True
)
if invalid_entries:
monitor = CacheHealthMonitor()
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
if report.status != CacheHealthStatus.HEALTHY:
# Broadcast health warning to frontend
await ws_manager.broadcast_cache_health_warning(report, page_type)
logger.warning(
f"{self.model_type.capitalize()} Scanner: Cache health issue detected - "
f"{report.invalid_entries} invalid entries, {report.repaired_entries} repaired"
)
# Use only valid entries
adjusted_raw_data = valid_entries
# Rebuild tags count from valid entries only
tags_count = {}
for item in adjusted_raw_data:
for tag in item.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1
# Remove invalid entries from hash index
for invalid_entry in invalid_entries:
file_path = CacheEntryValidator.get_file_path_safe(invalid_entry)
sha256 = CacheEntryValidator.get_sha256_safe(invalid_entry)
if file_path:
hash_index.remove_by_path(file_path, sha256)
scan_result = CacheBuildResult(
raw_data=adjusted_raw_data,
hash_index=hash_index,
@@ -686,6 +651,7 @@ class ModelScanner:
async def _initialize_cache(self) -> None:
"""Initialize or refresh the cache"""
print("init start", flush=True)
self._is_initializing = True # Set flag
try:
start_time = time.time()
@@ -699,6 +665,7 @@ class ModelScanner:
scan_result = await self._gather_model_data()
await self._apply_scan_result(scan_result)
await self._save_persistent_cache(scan_result)
print("init end", flush=True)
logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
@@ -809,18 +776,6 @@ class ModelScanner:
model_data = self.adjust_cached_entry(dict(model_data))
if not model_data:
continue
# Validate the new entry before adding
validation_result = CacheEntryValidator.validate(
model_data, auto_repair=True
)
if not validation_result.is_valid:
logger.warning(
f"Skipping invalid entry during reconcile: {path}"
)
continue
model_data = validation_result.entry
self._ensure_license_flags(model_data)
# Add to cache
self._cache.raw_data.append(model_data)
@@ -1135,17 +1090,6 @@ class ModelScanner:
processed_files += 1
if result:
# Validate the entry before adding
validation_result = CacheEntryValidator.validate(
result, auto_repair=True
)
if not validation_result.is_valid:
logger.warning(
f"Skipping invalid scan result: {file_path}"
)
continue
result = validation_result.entry
self._ensure_license_flags(result)
raw_data.append(result)

View File

@@ -118,19 +118,24 @@ class ModelServiceFactory:
def register_default_model_types():
"""Register the default model types (LoRA, Checkpoint, and Embedding)"""
"""Register the default model types (LoRA, Checkpoint, Embedding, and Misc)"""
from ..services.lora_service import LoraService
from ..services.checkpoint_service import CheckpointService
from ..services.embedding_service import EmbeddingService
from ..services.misc_service import MiscService
from ..routes.lora_routes import LoraRoutes
from ..routes.checkpoint_routes import CheckpointRoutes
from ..routes.embedding_routes import EmbeddingRoutes
from ..routes.misc_model_routes import MiscModelRoutes
# Register LoRA model type
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
# Register Checkpoint model type
ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes)
# Register Embedding model type
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
# Register Misc model type (VAE, Upscaler)
ModelServiceFactory.register_model_type('misc', MiscService, MiscModelRoutes)

View File

@@ -9,7 +9,7 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple
from ..config import config
from .recipe_cache import RecipeCache
from .recipe_fts_index import RecipeFTSIndex
from .persistent_recipe_cache import PersistentRecipeCache, get_persistent_recipe_cache, PersistedRecipeData
from .persistent_recipe_cache import PersistentRecipeCache, get_persistent_recipe_cache
from .service_registry import ServiceRegistry
from .lora_scanner import LoraScanner
from .metadata_service import get_default_metadata_provider
@@ -431,16 +431,6 @@ class RecipeScanner:
4. Persist results for next startup
"""
try:
# Ensure cache exists to avoid None reference errors
if self._cache is None:
self._cache = RecipeCache(
raw_data=[],
sorted_by_name=[],
sorted_by_date=[],
folders=[],
folder_tree={},
)
# Create a new event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
@@ -502,7 +492,7 @@ class RecipeScanner:
def _reconcile_recipe_cache(
self,
persisted: PersistedRecipeData,
persisted: "PersistedRecipeData",
recipes_dir: str,
) -> Tuple[List[Dict], bool, Dict[str, str]]:
"""Reconcile persisted cache with current filesystem state.
@@ -514,6 +504,8 @@ class RecipeScanner:
Returns:
Tuple of (recipes list, changed flag, json_paths dict).
"""
from .persistent_recipe_cache import PersistedRecipeData
recipes: List[Dict] = []
json_paths: Dict[str, str] = {}
changed = False
@@ -530,37 +522,32 @@ class RecipeScanner:
except OSError:
continue
# Build recipe_id -> recipe lookup (O(n) instead of O(n²))
recipe_by_id: Dict[str, Dict] = {
# Build lookup of persisted recipes by json_path
persisted_by_path: Dict[str, Dict] = {}
for recipe in persisted.raw_data:
recipe_id = str(recipe.get('id', ''))
if recipe_id:
# Find the json_path from file_stats
for json_path, (mtime, size) in persisted.file_stats.items():
if os.path.basename(json_path).startswith(recipe_id):
persisted_by_path[json_path] = recipe
break
# Also index by recipe ID for faster lookups
persisted_by_id: Dict[str, Dict] = {
str(r.get('id', '')): r for r in persisted.raw_data if r.get('id')
}
# Build json_path -> recipe lookup from file_stats (O(m))
persisted_by_path: Dict[str, Dict] = {}
for json_path in persisted.file_stats.keys():
basename = os.path.basename(json_path)
if basename.lower().endswith('.recipe.json'):
recipe_id = basename[:-len('.recipe.json')]
if recipe_id in recipe_by_id:
persisted_by_path[json_path] = recipe_by_id[recipe_id]
# Process current files
for file_path, (current_mtime, current_size) in current_files.items():
cached_stats = persisted.file_stats.get(file_path)
# Extract recipe_id from current file for fallback lookup
basename = os.path.basename(file_path)
recipe_id_from_file = basename[:-len('.recipe.json')] if basename.lower().endswith('.recipe.json') else None
if cached_stats:
cached_mtime, cached_size = cached_stats
# Check if file is unchanged
if abs(current_mtime - cached_mtime) < 1.0 and current_size == cached_size:
# Try direct path lookup first
# Use cached data
cached_recipe = persisted_by_path.get(file_path)
# Fallback to recipe_id lookup if path lookup fails
if not cached_recipe and recipe_id_from_file:
cached_recipe = recipe_by_id.get(recipe_id_from_file)
if cached_recipe:
recipe_id = str(cached_recipe.get('id', ''))
# Track folder from file path
@@ -2231,26 +2218,3 @@ class RecipeScanner:
duplicate_groups = {k: v for k, v in fingerprint_groups.items() if len(v) > 1}
return duplicate_groups
async def find_duplicate_recipes_by_source(self) -> dict:
"""Find all recipe duplicates based on source_path (Civitai image URLs)
Returns:
Dictionary where keys are source URLs and values are lists of recipe IDs
"""
cache = await self.get_cached_data()
url_groups = {}
for recipe in cache.raw_data:
source_url = recipe.get('source_path', '').strip()
if not source_url:
continue
if source_url not in url_groups:
url_groups[source_url] = []
url_groups[source_url].append(recipe.get('id'))
duplicate_groups = {k: v for k, v in url_groups.items() if len(v) > 1}
return duplicate_groups

View File

@@ -233,23 +233,44 @@ class ServiceRegistry:
async def get_embedding_scanner(cls):
"""Get or create Embedding scanner instance"""
service_name = "embedding_scanner"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
# Double-check after acquiring lock
if service_name in cls._services:
return cls._services[service_name]
# Import here to avoid circular imports
from .embedding_scanner import EmbeddingScanner
scanner = await EmbeddingScanner.get_instance()
cls._services[service_name] = scanner
logger.debug(f"Created and registered {service_name}")
return scanner
@classmethod
async def get_misc_scanner(cls):
"""Get or create Misc scanner instance (VAE, Upscaler)"""
service_name = "misc_scanner"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
# Double-check after acquiring lock
if service_name in cls._services:
return cls._services[service_name]
# Import here to avoid circular imports
from .misc_scanner import MiscScanner
scanner = await MiscScanner.get_instance()
cls._services[service_name] = scanner
logger.debug(f"Created and registered {service_name}")
return scanner
@classmethod
def clear_services(cls):
"""Clear all registered services - mainly for testing"""

View File

@@ -28,9 +28,6 @@ CORE_USER_SETTING_KEYS: Tuple[str, ...] = (
"folder_paths",
)
# Threshold for aggressive cleanup: if file contains this many default keys, clean it up
DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
DEFAULT_SETTINGS: Dict[str, Any] = {
"civitai_api_key": "",
@@ -66,7 +63,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"compact_mode": False,
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
"model_name_display": "model_name",
"model_card_footer_action": "replace_preview",
"model_card_footer_action": "example_images",
"update_flag_strategy": "same_base",
"auto_organize_exclusions": [],
}
@@ -98,9 +95,6 @@ class SettingsManager:
if self._needs_initial_save:
self._save_settings()
self._needs_initial_save = False
else:
# Clean up existing settings file by removing default values
self._cleanup_default_values_from_disk()
def _detect_standalone_mode(self) -> bool:
"""Return ``True`` when running in standalone mode."""
@@ -232,7 +226,7 @@ class SettingsManager:
return merged
def _ensure_default_settings(self) -> None:
"""Ensure all default settings keys exist in memory (but don't save defaults to disk)"""
"""Ensure all default settings keys exist"""
defaults = self._get_default_settings()
updated_existing = False
inserted_defaults = False
@@ -271,10 +265,10 @@ class SettingsManager:
self.settings[key] = value
inserted_defaults = True
# Save only if existing values were normalized/updated
if updated_existing:
if updated_existing or (
inserted_defaults and self._bootstrap_reason in {"invalid", "unreadable"}
):
self._save_settings()
# Note: inserted_defaults no longer triggers save - defaults stay in memory only
def _migrate_to_library_registry(self) -> None:
"""Ensure settings include the multi-library registry structure."""
@@ -717,42 +711,6 @@ class SettingsManager:
self._startup_messages.append(payload)
def _cleanup_default_values_from_disk(self) -> None:
"""Remove default values from existing settings.json to keep it clean.
Only performs cleanup if the file contains a significant number of default
values (indicating it's "bloated"). Small files (like template-based configs)
are preserved as-is to avoid unexpected changes.
"""
# Only cleanup existing files (not new ones)
if self._bootstrap_reason == "missing" or self._original_disk_payload is None:
return
defaults = self._get_default_settings()
disk_keys = set(self._original_disk_payload.keys())
# Count how many keys on disk are set to their default values
default_value_keys = set()
for key in disk_keys:
if key in CORE_USER_SETTING_KEYS:
continue # Core keys don't count as "cleanup candidates"
disk_value = self._original_disk_payload.get(key)
default_value = defaults.get(key)
# Compare using JSON serialization for complex objects
if json.dumps(disk_value, sort_keys=True, default=str) == json.dumps(default_value, sort_keys=True, default=str):
default_value_keys.add(key)
# Only cleanup if there are "many" default keys (indicating a bloated file)
# This preserves small/template-based configs while cleaning up legacy bloated files
if len(default_value_keys) >= DEFAULT_KEYS_CLEANUP_THRESHOLD:
logger.info(
"Cleaning up %d default value(s) from settings.json to keep it minimal",
len(default_value_keys)
)
self._save_settings()
# Update original payload to match what we just saved
self._original_disk_payload = self._serialize_settings_for_disk()
def _collect_configuration_warnings(self) -> None:
if not self._standalone_mode:
return
@@ -1143,12 +1101,7 @@ class SettingsManager:
self._seed_template = None
def _serialize_settings_for_disk(self) -> Dict[str, Any]:
"""Return the settings payload that should be persisted to disk.
Only saves settings that differ from defaults, keeping the config file
clean and focused on user customizations. Default values are still
available at runtime via _get_default_settings().
"""
"""Return the settings payload that should be persisted to disk."""
if self._bootstrap_reason == "missing":
minimal: Dict[str, Any] = {}
@@ -1162,25 +1115,7 @@ class SettingsManager:
return minimal
# Only save settings that differ from defaults
defaults = self._get_default_settings()
minimal = {}
for key, value in self.settings.items():
default_value = defaults.get(key)
# Core settings are always saved (even if equal to default)
if key in CORE_USER_SETTING_KEYS:
minimal[key] = copy.deepcopy(value)
# Complex objects need deep comparison
elif isinstance(value, (dict, list)) and default_value is not None:
if json.dumps(value, sort_keys=True, default=str) != json.dumps(default_value, sort_keys=True, default=str):
minimal[key] = copy.deepcopy(value)
# Simple values use direct comparison
elif value != default_value:
minimal[key] = copy.deepcopy(value)
return minimal
return copy.deepcopy(self.settings)
def get_libraries(self) -> Dict[str, Dict[str, Any]]:
"""Return a copy of the registered libraries."""

View File

@@ -48,14 +48,9 @@ class BulkMetadataRefreshUseCase:
for model in cache.raw_data
if model.get("sha256")
and (not model.get("civitai") or not model["civitai"].get("id"))
and not (
# Skip models confirmed not on CivitAI when no need to retry
model.get("from_civitai") is False
and model.get("civitai_deleted") is True
and (
not enable_metadata_archive_db
or model.get("db_checked", False)
)
and (
(enable_metadata_archive_db and not model.get("db_checked", False))
or (not enable_metadata_archive_db and model.get("from_civitai") is True)
)
]

View File

@@ -255,42 +255,6 @@ class WebSocketManager:
self._download_progress.pop(download_id, None)
logger.debug(f"Cleaned up old download progress for {download_id}")
async def broadcast_cache_health_warning(self, report: 'HealthReport', page_type: str = None):
"""
Broadcast cache health warning to frontend.
Args:
report: HealthReport instance from CacheHealthMonitor
page_type: The page type (loras, checkpoints, embeddings)
"""
from .cache_health_monitor import CacheHealthStatus
# Only broadcast if there are issues
if report.status == CacheHealthStatus.HEALTHY:
return
payload = {
'type': 'cache_health_warning',
'status': report.status.value,
'message': report.message,
'pageType': page_type,
'details': {
'total': report.total_entries,
'valid': report.valid_entries,
'invalid': report.invalid_entries,
'repaired': report.repaired_entries,
'corruption_rate': f"{report.corruption_rate:.1%}",
'invalid_paths': report.invalid_paths[:5], # Limit to first 5
}
}
logger.info(
f"Broadcasting cache health warning: {report.status.value} "
f"({report.invalid_entries} invalid entries)"
)
await self.broadcast(payload)
def get_connected_clients_count(self) -> int:
"""Get number of connected clients"""
return len(self._websockets)

View File

@@ -49,6 +49,7 @@ SUPPORTED_MEDIA_EXTENSIONS = {
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
VALID_EMBEDDING_SUB_TYPES = ["embedding"]
VALID_MISC_SUB_TYPES = ["vae", "upscaler"]
# Backward compatibility alias
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
@@ -94,6 +95,7 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
"lora": ", ".join(CIVITAI_MODEL_TAGS),
"checkpoint": ", ".join(CIVITAI_MODEL_TAGS),
"embedding": ", ".join(CIVITAI_MODEL_TAGS),
"misc": ", ".join(CIVITAI_MODEL_TAGS),
}
# baseModel values from CivitAI that should be treated as diffusion models (unet)

View File

@@ -216,11 +216,6 @@ class DownloadManager:
self._progress["failed_models"] = set()
self._is_downloading = True
snapshot = self._progress.snapshot()
# Create the download task without awaiting it
# This ensures the HTTP response is returned immediately
# while the actual processing happens in the background
self._download_task = asyncio.create_task(
self._download_all_example_images(
output_dir,
@@ -232,10 +227,7 @@ class DownloadManager:
)
)
# Add a callback to handle task completion/errors
self._download_task.add_done_callback(
lambda t: self._handle_download_task_done(t, output_dir)
)
snapshot = self._progress.snapshot()
except ExampleImagesDownloadError:
# Re-raise our own exception types without wrapping
self._is_downloading = False
@@ -249,25 +241,10 @@ class DownloadManager:
)
raise ExampleImagesDownloadError(str(e)) from e
# Broadcast progress in the background without blocking the response
# This ensures the HTTP response is returned immediately
asyncio.create_task(self._broadcast_progress(status="running"))
await self._broadcast_progress(status="running")
return {"success": True, "message": "Download started", "status": snapshot}
def _handle_download_task_done(self, task: asyncio.Task, output_dir: str) -> None:
"""Handle download task completion, including saving progress on error."""
try:
# This will re-raise any exception from the task
task.result()
except Exception as e:
logger.error(f"Download task failed with error: {e}", exc_info=True)
# Ensure progress is saved even on failure
try:
self._save_progress(output_dir)
except Exception as save_error:
logger.error(f"Failed to save progress after task failure: {save_error}")
async def get_status(self, request):
"""Get the current status of example images download."""
@@ -277,130 +254,6 @@ class DownloadManager:
"status": self._progress.snapshot(),
}
async def check_pending_models(self, model_types: list[str]) -> dict:
"""Quickly check how many models need example images downloaded.
This is a lightweight check that avoids the overhead of starting
a full download task when no work is needed.
Returns:
dict with keys:
- total_models: Total number of models across specified types
- pending_count: Number of models needing example images
- processed_count: Number of already processed models
- failed_count: Number of models marked as failed
- needs_download: True if there are pending models to process
"""
from ..services.service_registry import ServiceRegistry
if self._is_downloading:
return {
"success": True,
"is_downloading": True,
"total_models": 0,
"pending_count": 0,
"processed_count": 0,
"failed_count": 0,
"needs_download": False,
"message": "Download already in progress",
}
try:
# Get scanners
scanners = []
if "lora" in model_types:
lora_scanner = await ServiceRegistry.get_lora_scanner()
scanners.append(("lora", lora_scanner))
if "checkpoint" in model_types:
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
scanners.append(("checkpoint", checkpoint_scanner))
if "embedding" in model_types:
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
scanners.append(("embedding", embedding_scanner))
# 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")
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
# Count models
total_models = 0
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
if model.get("sha256"):
models_with_hash += 1
# 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 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)
return {
"success": True,
"is_downloading": False,
"total_models": total_models,
"pending_count": pending_count,
"processed_count": len(processed_models),
"failed_count": len(failed_models),
"needs_download": pending_count > 0,
}
except Exception as e:
logger.error(f"Error checking pending models: {e}", exc_info=True)
return {
"success": False,
"error": str(e),
"total_models": 0,
"pending_count": 0,
"processed_count": 0,
"failed_count": 0,
"needs_download": False,
}
async def pause_download(self, request):
"""Pause the example images download."""

View File

@@ -43,15 +43,8 @@ class ExampleImagesProcessor:
return media_url
@staticmethod
def _get_file_extension_from_content_or_headers(content, headers, fallback_url=None, media_type_hint=None):
"""Determine file extension from content magic bytes or headers
Args:
content: File content bytes
headers: HTTP response headers
fallback_url: Original URL for extension extraction
media_type_hint: Optional media type hint from metadata (e.g., "video" or "image")
"""
def _get_file_extension_from_content_or_headers(content, headers, fallback_url=None):
"""Determine file extension from content magic bytes or headers"""
# Check magic bytes for common formats
if content:
if content.startswith(b'\xFF\xD8\xFF'):
@@ -89,10 +82,6 @@ class ExampleImagesProcessor:
if ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or ext in SUPPORTED_MEDIA_EXTENSIONS['videos']:
return ext
# Use media type hint from metadata if available
if media_type_hint == "video":
return '.mp4'
# Default fallback
return '.jpg'
@@ -147,7 +136,7 @@ class ExampleImagesProcessor:
if success:
# Determine file extension from content or headers
media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
content, headers, original_url, image.get("type")
content, headers, original_url
)
# Check if the detected file type is supported
@@ -230,7 +219,7 @@ class ExampleImagesProcessor:
if success:
# Determine file extension from content or headers
media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
content, headers, original_url, image.get("type")
content, headers, original_url
)
# Check if the detected file type is supported

View File

@@ -17,7 +17,7 @@ async def extract_lora_metadata(file_path: str) -> Dict:
base_model = determine_base_model(metadata.get("ss_base_model_version"))
return {"base_model": base_model}
except Exception as e:
logger.error(f"Error reading metadata from {file_path}: {str(e)}")
print(f"Error reading metadata from {file_path}: {str(e)}")
return {"base_model": "Unknown"}
async def extract_checkpoint_metadata(file_path: str) -> dict:

View File

@@ -223,7 +223,7 @@ class MetadataManager:
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
sub_type="checkpoint",
model_type="checkpoint",
from_civitai=True
)
elif model_class.__name__ == "EmbeddingMetadata":
@@ -238,7 +238,6 @@ class MetadataManager:
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
sub_type="embedding",
from_civitai=True
)
else: # Default to LoraMetadata

View File

@@ -219,7 +219,7 @@ class EmbeddingMetadata(BaseModelMetadata):
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 = ""
@@ -228,7 +228,53 @@ class EmbeddingMetadata(BaseModelMetadata):
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=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['hashes'].get('SHA256', '').lower(),
base_model=base_model,
preview_url=None, # Will be updated after preview download
preview_nsfw_level=0,
from_civitai=True,
civitai=version_info,
sub_type=sub_type,
tags=tags,
modelDescription=description
)
@dataclass
class MiscMetadata(BaseModelMetadata):
"""Represents the metadata structure for a Misc model (VAE, Upscaler)"""
sub_type: str = "vae"
@classmethod
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'MiscMetadata':
"""Create MiscMetadata instance from Civitai version info"""
file_name = file_info['name']
base_model = determine_base_model(version_info.get('baseModel', ''))
# Determine sub_type from CivitAI model type
civitai_type = version_info.get('model', {}).get('type', '').lower()
if civitai_type == 'vae':
sub_type = 'vae'
elif civitai_type == 'upscaler':
sub_type = 'upscaler'
else:
sub_type = 'vae' # Default to vae
# Extract tags and description if available
tags = []
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=version_info.get('model').get('name', os.path.splitext(file_name)[0]),

View File

@@ -138,15 +138,19 @@ def calculate_recipe_fingerprint(loras):
if not loras:
return ""
# Filter valid entries and extract hash and strength
valid_loras = []
for lora in loras:
# Skip excluded loras
if lora.get("exclude", False):
continue
# Get the hash - use modelVersionId as fallback if hash is empty
hash_value = lora.get("hash", "").lower()
if not hash_value and lora.get("modelVersionId"):
if not hash_value and lora.get("isDeleted", False) and lora.get("modelVersionId"):
hash_value = str(lora.get("modelVersionId"))
# Skip entries without a valid hash
if not hash_value:
continue

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "0.9.15"
version = "0.9.13"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

0
scripts/sync_translation_keys.py Executable file → Normal file
View File

View File

@@ -65,8 +65,6 @@ body {
--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;

View File

@@ -113,12 +113,6 @@
max-width: 110px;
}
/* Compact mode: hide sub-type to save space */
.compact-density .model-sub-type,
.compact-density .model-separator {
display: none;
}
.compact-density .card-actions i {
font-size: 0.95em;
padding: 3px;

View File

@@ -1,354 +0,0 @@
/* 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);
}
}

View File

@@ -1,167 +0,0 @@
/* 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;
}

View File

@@ -1,272 +0,0 @@
/* 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;
}
}

View File

@@ -1,566 +0,0 @@
/* 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);
}

View File

@@ -1,153 +0,0 @@
/* 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 */
}
}

View File

@@ -1,151 +0,0 @@
/* 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;
}
}

View File

@@ -1,163 +0,0 @@
/* 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;
}
}

View File

@@ -1,378 +0,0 @@
/* 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;
}
}

View File

@@ -512,10 +512,6 @@
.filter-preset.active .preset-delete-btn {
color: white;
opacity: 0;
}
.filter-preset:hover.active .preset-delete-btn {
opacity: 0.8;
}
@@ -533,16 +529,13 @@
align-items: center;
gap: 6px;
white-space: nowrap;
max-width: 120px; /* Prevent long names from breaking layout */
overflow: hidden;
text-overflow: ellipsis;
}
.preset-delete-btn {
background: none;
border: none;
color: var(--text-color);
opacity: 0; /* Hidden by default */
opacity: 0.5;
cursor: pointer;
padding: 4px;
display: flex;
@@ -553,10 +546,6 @@
margin-left: auto;
}
.filter-preset:hover .preset-delete-btn {
opacity: 0.5; /* Show on hover */
}
.preset-delete-btn:hover {
opacity: 1;
color: var(--lora-error, #e74c3c);
@@ -673,57 +662,6 @@
/* Tag Logic Toggle Styles */
.filter-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.filter-section-header h4 {
margin: 0;
}
.tag-logic-toggle {
display: flex;
background-color: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
overflow: hidden;
}
.tag-logic-option {
background: none;
border: none;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
color: var(--text-color);
opacity: 0.7;
transition: all 0.2s ease;
font-weight: 500;
}
.tag-logic-option:hover {
opacity: 1;
background-color: var(--lora-surface-hover);
}
.tag-logic-option.active {
background-color: var(--lora-accent);
color: white;
opacity: 1;
}
.tag-logic-option:first-child {
border-right: 1px solid var(--border-color);
}
.tag-logic-option.active:first-child {
border-right: 1px solid rgba(255, 255, 255, 0.3);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.search-options-panel,

View File

@@ -27,18 +27,6 @@
@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';

View File

@@ -9,7 +9,8 @@ import { state } from '../state/index.js';
export const MODEL_TYPES = {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings' // Future model type
EMBEDDING: 'embeddings',
MISC: 'misc'
};
// Base API configuration for each model type
@@ -40,6 +41,15 @@ export const MODEL_CONFIG = {
supportsBulkOperations: true,
supportsMove: true,
templateName: 'embeddings.html'
},
[MODEL_TYPES.MISC]: {
displayName: 'Misc',
singularName: 'misc',
defaultPageSize: 100,
supportsLetterFilter: false,
supportsBulkOperations: true,
supportsMove: true,
templateName: 'misc.html'
}
};
@@ -133,6 +143,11 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
},
[MODEL_TYPES.EMBEDDING]: {
metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`,
},
[MODEL_TYPES.MISC]: {
metadata: `/api/lm/${MODEL_TYPES.MISC}/metadata`,
vae_roots: `/api/lm/${MODEL_TYPES.MISC}/vae_roots`,
upscaler_roots: `/api/lm/${MODEL_TYPES.MISC}/upscaler_roots`,
}
};

View File

@@ -924,11 +924,6 @@ export class BaseModelApiClient {
params.append('model_type', type);
});
}
// Add tag logic parameter (any = OR, all = AND)
if (pageState.filters.tagLogic) {
params.append('tag_logic', pageState.filters.tagLogic);
}
}
this._addModelSpecificParams(params, pageState);

62
static/js/api/miscApi.js Normal file
View File

@@ -0,0 +1,62 @@
import { BaseModelApiClient } from './baseModelApi.js';
import { getSessionItem } from '../utils/storageHelpers.js';
export class MiscApiClient extends BaseModelApiClient {
_addModelSpecificParams(params, pageState) {
const filterMiscHash = getSessionItem('recipe_to_misc_filterHash');
const filterMiscHashes = getSessionItem('recipe_to_misc_filterHashes');
if (filterMiscHash) {
params.append('misc_hash', filterMiscHash);
} else if (filterMiscHashes) {
try {
if (Array.isArray(filterMiscHashes) && filterMiscHashes.length > 0) {
params.append('misc_hashes', filterMiscHashes.join(','));
}
} catch (error) {
console.error('Error parsing misc hashes from session storage:', error);
}
}
if (pageState.subType) {
params.append('sub_type', pageState.subType);
}
}
async getMiscInfo(filePath) {
try {
const response = await fetch(this.apiConfig.endpoints.specific.info, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath })
});
if (!response.ok) throw new Error('Failed to fetch misc info');
return await response.json();
} catch (error) {
console.error('Error fetching misc info:', error);
throw error;
}
}
async getVaeRoots() {
try {
const response = await fetch(this.apiConfig.endpoints.specific.vae_roots, { method: 'GET' });
if (!response.ok) throw new Error('Failed to fetch VAE roots');
return await response.json();
} catch (error) {
console.error('Error fetching VAE roots:', error);
throw error;
}
}
async getUpscalerRoots() {
try {
const response = await fetch(this.apiConfig.endpoints.specific.upscaler_roots, { method: 'GET' });
if (!response.ok) throw new Error('Failed to fetch upscaler roots');
return await response.json();
} catch (error) {
console.error('Error fetching upscaler roots:', error);
throw error;
}
}
}

View File

@@ -1,6 +1,7 @@
import { LoraApiClient } from './loraApi.js';
import { CheckpointApiClient } from './checkpointApi.js';
import { EmbeddingApiClient } from './embeddingApi.js';
import { MiscApiClient } from './miscApi.js';
import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
import { state } from '../state/index.js';
@@ -12,6 +13,8 @@ export function createModelApiClient(modelType) {
return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT);
case MODEL_TYPES.EMBEDDING:
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
case MODEL_TYPES.MISC:
return new MiscApiClient(MODEL_TYPES.MISC);
default:
throw new Error(`Unsupported model type: ${modelType}`);
}

View File

@@ -0,0 +1,85 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
import { moveManager } from '../../managers/MoveManager.js';
import { i18n } from '../../i18n/index.js';
export class MiscContextMenu extends BaseContextMenu {
constructor() {
super('miscContextMenu', '.model-card');
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'misc';
this.resetAndReload = resetAndReload;
this.initNSFWSelector();
}
// Implementation needed by the mixin
async saveModelMetadata(filePath, data) {
return getModelApiClient().saveModelMetadata(filePath, data);
}
showMenu(x, y, card) {
super.showMenu(x, y, card);
// Update the "Move to other root" label based on current model type
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
if (moveOtherItem) {
const currentType = card.dataset.sub_type || 'vae';
const otherType = currentType === 'vae' ? 'upscaler' : 'vae';
const typeLabel = i18n.t(`misc.modelTypes.${otherType}`);
moveOtherItem.innerHTML = `<i class="fas fa-exchange-alt"></i> ${i18n.t('misc.contextMenu.moveToOtherTypeFolder', { otherType: typeLabel })}`;
}
}
handleMenuAction(action) {
// First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
return;
}
const apiClient = getModelApiClient();
// Otherwise handle misc-specific actions
switch (action) {
case 'details':
// Show misc details
this.currentCard.click();
break;
case 'replace-preview':
// Add new action for replacing preview images
apiClient.replaceModelPreview(this.currentCard.dataset.filepath);
break;
case 'delete':
showDeleteModal(this.currentCard.dataset.filepath);
break;
case 'copyname':
// Copy misc model name
if (this.currentCard.querySelector('.fa-copy')) {
this.currentCard.querySelector('.fa-copy').click();
}
break;
case 'refresh-metadata':
// Refresh metadata from CivitAI
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
break;
case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath, this.currentCard.dataset.sub_type);
break;
case 'move-other':
{
const currentType = this.currentCard.dataset.sub_type || 'vae';
const otherType = currentType === 'vae' ? 'upscaler' : 'vae';
moveManager.showMoveModal(this.currentCard.dataset.filepath, otherType);
}
break;
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
}
}
}
// Mix in shared methods
Object.assign(MiscContextMenu.prototype, ModelContextMenuMixin);

View File

@@ -2,6 +2,7 @@ export { LoraContextMenu } from './LoraContextMenu.js';
export { RecipeContextMenu } from './RecipeContextMenu.js';
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
export { MiscContextMenu } from './MiscContextMenu.js';
export { GlobalContextMenu } from './GlobalContextMenu.js';
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
@@ -9,6 +10,7 @@ import { LoraContextMenu } from './LoraContextMenu.js';
import { RecipeContextMenu } from './RecipeContextMenu.js';
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
import { MiscContextMenu } from './MiscContextMenu.js';
import { GlobalContextMenu } from './GlobalContextMenu.js';
// Factory method to create page-specific context menu instances
@@ -22,6 +24,8 @@ export function createPageContextMenu(pageType) {
return new CheckpointContextMenu();
case 'embeddings':
return new EmbeddingContextMenu();
case 'misc':
return new MiscContextMenu();
default:
return null;
}

View File

@@ -32,6 +32,7 @@ export class HeaderManager {
if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/embeddings')) return 'embeddings';
if (path.includes('/statistics')) return 'statistics';
if (path.includes('/misc')) return 'misc';
if (path.includes('/loras')) return 'loras';
return 'unknown';
}

View File

@@ -48,18 +48,15 @@ export class ModelDuplicatesManager {
// Method to check for duplicates count using existing endpoint
async checkDuplicatesCount() {
try {
const params = this._buildFilterQueryParams();
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
const response = await fetch(url);
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`Failed to get duplicates count: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
const duplicatesCount = (data.duplicates || []).length;
this.updateDuplicatesBadge(duplicatesCount);
@@ -106,34 +103,29 @@ export class ModelDuplicatesManager {
async findDuplicates() {
try {
const params = this._buildFilterQueryParams();
// Determine API endpoint based on model type
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
const response = await fetch(url);
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`Failed to find duplicates: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Unknown error finding duplicates');
}
this.duplicateGroups = data.duplicates || [];
// Update the badge with the current count
this.updateDuplicatesBadge(this.duplicateGroups.length);
if (this.duplicateGroups.length === 0) {
showToast('toast.duplicates.noDuplicatesFound', { type: this.modelType }, 'info');
// If already in duplicate mode, exit to clear the display
if (this.inDuplicateMode) {
this.exitDuplicateMode();
}
return false;
}
this.enterDuplicateMode();
return true;
} catch (error) {
@@ -142,51 +134,6 @@ export class ModelDuplicatesManager {
return false;
}
}
/**
* Build query parameters from current filter state for duplicate finding.
* @returns {URLSearchParams} The query parameters to append to the API endpoint
*/
_buildFilterQueryParams() {
const params = new URLSearchParams();
const pageState = getCurrentPageState();
const filters = pageState?.filters;
if (!filters) return params;
// Base model filters
if (filters.baseModel && Array.isArray(filters.baseModel)) {
filters.baseModel.forEach(m => params.append('base_model', m));
}
// Tag filters (tri-state: include/exclude)
if (filters.tags && typeof filters.tags === 'object') {
Object.entries(filters.tags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('tag_include', tag);
} else if (state === 'exclude') {
params.append('tag_exclude', tag);
}
});
}
// Model type filters
if (filters.modelTypes && Array.isArray(filters.modelTypes)) {
filters.modelTypes.forEach(t => params.append('model_type', t));
}
// Folder filter (from active folder state)
if (pageState.activeFolder) {
params.append('folder', pageState.activeFolder);
}
// Favorites filter
if (pageState.showFavoritesOnly) {
params.append('favorites_only', 'true');
}
return params;
}
enterDuplicateMode() {
this.inDuplicateMode = true;

View File

@@ -26,7 +26,6 @@ class RecipeCard {
card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0;
card.dataset.created = this.recipe.created_date;
card.dataset.id = this.recipe.id || '';
card.dataset.folder = this.recipe.folder || '';
// Get base model with fallback
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';

View File

@@ -0,0 +1,119 @@
// MiscControls.js - Specific implementation for the Misc (VAE/Upscaler) page
import { PageControls } from './PageControls.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { downloadManager } from '../../managers/DownloadManager.js';
/**
* MiscControls class - Extends PageControls for Misc-specific functionality
*/
export class MiscControls extends PageControls {
constructor() {
// Initialize with 'misc' page type
super('misc');
// Register API methods specific to the Misc page
this.registerMiscAPI();
// Check for custom filters (e.g., from recipe navigation)
this.checkCustomFilters();
}
/**
* Register Misc-specific API methods
*/
registerMiscAPI() {
const miscAPI = {
// Core API functions
loadMoreModels: async (resetPage = false, updateFolders = false) => {
return await getModelApiClient().loadMoreWithVirtualScroll(resetPage, updateFolders);
},
resetAndReload: async (updateFolders = false) => {
return await resetAndReload(updateFolders);
},
refreshModels: async (fullRebuild = false) => {
return await getModelApiClient().refreshModels(fullRebuild);
},
// Add fetch from Civitai functionality for misc models
fetchFromCivitai: async () => {
return await getModelApiClient().fetchCivitaiMetadata();
},
// Add show download modal functionality
showDownloadModal: () => {
downloadManager.showDownloadModal();
},
toggleBulkMode: () => {
if (window.bulkManager) {
window.bulkManager.toggleBulkMode();
} else {
console.error('Bulk manager not available');
}
},
clearCustomFilter: async () => {
await this.clearCustomFilter();
}
};
// Register the API
this.registerAPI(miscAPI);
}
/**
* Check for custom filters sent from other pages (e.g., recipe modal)
*/
checkCustomFilters() {
const filterMiscHash = getSessionItem('recipe_to_misc_filterHash');
const filterRecipeName = getSessionItem('filterMiscRecipeName');
if (filterMiscHash && filterRecipeName) {
const indicator = document.getElementById('customFilterIndicator');
const filterText = indicator?.querySelector('.customFilterText');
if (indicator && filterText) {
indicator.classList.remove('hidden');
const displayText = `Viewing misc model from: ${filterRecipeName}`;
filterText.textContent = this._truncateText(displayText, 30);
filterText.setAttribute('title', displayText);
const filterElement = indicator.querySelector('.filter-active');
if (filterElement) {
filterElement.classList.add('animate');
setTimeout(() => filterElement.classList.remove('animate'), 600);
}
}
}
}
/**
* Clear misc custom filter and reload
*/
async clearCustomFilter() {
removeSessionItem('recipe_to_misc_filterHash');
removeSessionItem('recipe_to_misc_filterHashes');
removeSessionItem('filterMiscRecipeName');
const indicator = document.getElementById('customFilterIndicator');
if (indicator) {
indicator.classList.add('hidden');
}
await resetAndReload();
}
/**
* Helper to truncate text with ellipsis
* @param {string} text
* @param {number} maxLength
* @returns {string}
*/
_truncateText(text, maxLength) {
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
}
}

View File

@@ -3,13 +3,14 @@ import { PageControls } from './PageControls.js';
import { LorasControls } from './LorasControls.js';
import { CheckpointsControls } from './CheckpointsControls.js';
import { EmbeddingsControls } from './EmbeddingsControls.js';
import { MiscControls } from './MiscControls.js';
// Export the classes
export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls };
export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls, MiscControls };
/**
* Factory function to create the appropriate controls based on page type
* @param {string} pageType - The type of page ('loras', 'checkpoints', or 'embeddings')
* @param {string} pageType - The type of page ('loras', 'checkpoints', 'embeddings', or 'misc')
* @returns {PageControls} - The appropriate controls instance
*/
export function createPageControls(pageType) {
@@ -19,6 +20,8 @@ export function createPageControls(pageType) {
return new CheckpointsControls();
} else if (pageType === 'embeddings') {
return new EmbeddingsControls();
} else if (pageType === 'misc') {
return new MiscControls();
} else {
console.error(`Unknown page type: ${pageType}`);
return null;

View File

@@ -198,12 +198,6 @@ class InitializationManager {
handleProgressUpdate(data) {
if (!data) return;
console.log('Received progress update:', data);
// Handle cache health warning messages
if (data.type === 'cache_health_warning') {
this.handleCacheHealthWarning(data);
return;
}
// Check if this update is for our page type
if (data.pageType && data.pageType !== this.pageType) {
@@ -472,29 +466,6 @@ class InitializationManager {
}
}
/**
* Handle cache health warning messages from WebSocket
*/
handleCacheHealthWarning(data) {
console.log('Cache health warning received:', data);
// Import bannerService dynamically to avoid circular dependencies
import('../managers/BannerService.js').then(({ bannerService }) => {
// Initialize banner service if not already done
if (!bannerService.initialized) {
bannerService.initialize().then(() => {
bannerService.registerCacheHealthBanner(data);
}).catch(err => {
console.error('Failed to initialize banner service:', err);
});
} else {
bannerService.registerCacheHealthBanner(data);
}
}).catch(err => {
console.error('Failed to load banner service:', err);
});
}
/**
* Clean up resources when the component is destroyed
*/

View File

@@ -1,871 +0,0 @@
/**
* 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');
}
}
}

View File

@@ -1,374 +0,0 @@
/**
* 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);
});

View File

@@ -1,321 +0,0 @@
/**
* 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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,627 +0,0 @@
/**
* 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();
}
}

View File

@@ -1,16 +0,0 @@
/**
* 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;

View File

@@ -214,6 +214,52 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
missingNodesMessage,
missingTargetMessage,
});
} else if (modelType === MODEL_TYPES.MISC) {
const modelPath = card.dataset.filepath;
if (!modelPath) {
const message = translate('modelCard.sendToWorkflow.missingPath', {}, 'Unable to determine model path for this card');
showToast(message, {}, 'error');
return;
}
const subtype = (card.dataset.sub_type || 'vae').toLowerCase();
const isVae = subtype === 'vae';
const widgetName = isVae ? 'vae_name' : 'model_name';
const actionTypeText = translate(
isVae ? 'uiHelpers.nodeSelector.vae' : 'uiHelpers.nodeSelector.upscaler',
{},
isVae ? 'VAE' : 'Upscaler'
);
const successMessage = translate(
isVae ? 'uiHelpers.workflow.vaeUpdated' : 'uiHelpers.workflow.upscalerUpdated',
{},
isVae ? 'VAE updated in workflow' : 'Upscaler updated in workflow'
);
const failureMessage = translate(
isVae ? 'uiHelpers.workflow.vaeFailed' : 'uiHelpers.workflow.upscalerFailed',
{},
isVae ? 'Failed to update VAE node' : 'Failed to update upscaler node'
);
const missingNodesMessage = translate(
'uiHelpers.workflow.noMatchingNodes',
{},
'No compatible nodes available in the current workflow'
);
const missingTargetMessage = translate(
'uiHelpers.workflow.noTargetNodeSelected',
{},
'No target node selected'
);
sendModelPathToWorkflow(modelPath, {
widgetName,
collectionType: MODEL_TYPES.MISC,
actionTypeText,
successMessage,
failureMessage,
missingNodesMessage,
missingTargetMessage,
});
} else {
showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info');
}
@@ -230,6 +276,10 @@ function handleCopyAction(card, modelType) {
} else if (modelType === MODEL_TYPES.EMBEDDING) {
const embeddingName = card.dataset.file_name;
copyToClipboard(embeddingName, 'Embedding name copied');
} else if (modelType === MODEL_TYPES.MISC) {
const miscName = card.dataset.file_name;
const message = translate('modelCard.actions.miscNameCopied', {}, 'Model name copied');
copyToClipboard(miscName, message);
}
}

View File

@@ -22,12 +22,6 @@ 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) {
@@ -244,12 +238,6 @@ 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();
@@ -1032,5 +1020,11 @@ async function openFileLocation(filePath) {
}
}
// Re-export for compatibility
export { toggleShowcase, scrollToTop };
// Export the model modal API
const modelModal = {
show: showModelModal,
toggleShowcase,
scrollToTop
};
export { modelModal };

View File

@@ -99,7 +99,7 @@ export class AppCore {
initializePageFeatures() {
const pageType = this.getPageType();
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) {
if (['loras', 'recipes', 'checkpoints', 'embeddings', 'misc'].includes(pageType)) {
this.initializeContextMenus(pageType);
initializeInfiniteScroll(pageType);
}

View File

@@ -4,11 +4,9 @@ import {
removeStorageItem
} from '../utils/storageHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { state } from '../state/index.js'
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
const CACHE_HEALTH_BANNER_ID = 'cache-health-warning';
const COMMUNITY_SUPPORT_BANNER_DELAY_MS = 5 * 24 * 60 * 60 * 1000; // 5 days
const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
@@ -295,177 +293,6 @@ class BannerService {
location.reload();
}
/**
* Register a cache health warning banner
* @param {Object} healthData - Health data from WebSocket
*/
registerCacheHealthBanner(healthData) {
if (!healthData || healthData.status === 'healthy') {
return;
}
// Remove existing cache health banner if any
this.removeBannerElement(CACHE_HEALTH_BANNER_ID);
const isCorrupted = healthData.status === 'corrupted';
const titleKey = isCorrupted
? 'banners.cacheHealth.corrupted.title'
: 'banners.cacheHealth.degraded.title';
const defaultTitle = isCorrupted
? 'Cache Corruption Detected'
: 'Cache Issues Detected';
const title = translate(titleKey, {}, defaultTitle);
const contentKey = 'banners.cacheHealth.content';
const defaultContent = 'Found {invalid} of {total} cache entries are invalid ({rate}). This may cause missing models or errors. Rebuilding the cache is recommended.';
const content = translate(contentKey, {
invalid: healthData.details?.invalid || 0,
total: healthData.details?.total || 0,
rate: healthData.details?.corruption_rate || '0%'
}, defaultContent);
this.registerBanner(CACHE_HEALTH_BANNER_ID, {
id: CACHE_HEALTH_BANNER_ID,
title: title,
content: content,
pageType: healthData.pageType,
actions: [
{
text: translate('banners.cacheHealth.rebuildCache', {}, 'Rebuild Cache'),
icon: 'fas fa-sync-alt',
action: 'rebuild-cache',
type: 'primary'
},
{
text: translate('banners.cacheHealth.dismiss', {}, 'Dismiss'),
icon: 'fas fa-times',
action: 'dismiss',
type: 'secondary'
}
],
dismissible: true,
priority: 10, // High priority
onRegister: (bannerElement) => {
// Attach click handlers for actions
const rebuildBtn = bannerElement.querySelector('[data-action="rebuild-cache"]');
const dismissBtn = bannerElement.querySelector('[data-action="dismiss"]');
if (rebuildBtn) {
rebuildBtn.addEventListener('click', (e) => {
e.preventDefault();
this.handleRebuildCache(bannerElement, healthData.pageType);
});
}
if (dismissBtn) {
dismissBtn.addEventListener('click', (e) => {
e.preventDefault();
this.dismissBanner(CACHE_HEALTH_BANNER_ID);
});
}
}
});
}
/**
* Handle rebuild cache action from banner
* @param {HTMLElement} bannerElement - The banner element
* @param {string} pageType - The page type (loras, checkpoints, embeddings)
*/
async handleRebuildCache(bannerElement, pageType) {
const currentPageType = pageType || this.getCurrentPageType();
try {
const apiClient = getModelApiClient(currentPageType);
// Update banner to show rebuilding status
const actionsContainer = bannerElement.querySelector('.banner-actions');
if (actionsContainer) {
actionsContainer.innerHTML = `
<span class="banner-loading">
<i class="fas fa-spinner fa-spin"></i>
<span>${translate('banners.cacheHealth.rebuilding', {}, 'Rebuilding cache...')}</span>
</span>
`;
}
await apiClient.refreshModels(true);
// Remove banner on success without marking as dismissed
this.removeBannerElement(CACHE_HEALTH_BANNER_ID);
} catch (error) {
console.error('Cache rebuild failed:', error);
const actionsContainer = bannerElement.querySelector('.banner-actions');
if (actionsContainer) {
actionsContainer.innerHTML = `
<span class="banner-error">
<i class="fas fa-exclamation-triangle"></i>
<span>${translate('banners.cacheHealth.rebuildFailed', {}, 'Rebuild failed. Please try again.')}</span>
</span>
<a href="#" class="banner-action banner-action-primary" data-action="rebuild-cache">
<i class="fas fa-sync-alt"></i>
<span>${translate('banners.cacheHealth.retry', {}, 'Retry')}</span>
</a>
`;
// Re-attach click handler
const retryBtn = actionsContainer.querySelector('[data-action="rebuild-cache"]');
if (retryBtn) {
retryBtn.addEventListener('click', (e) => {
e.preventDefault();
this.handleRebuildCache(bannerElement, pageType);
});
}
}
}
}
/**
* Get the current page type from the URL
* @returns {string} Page type (loras, checkpoints, embeddings, recipes)
*/
getCurrentPageType() {
const path = window.location.pathname;
if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/embeddings')) return 'embeddings';
if (path.includes('/recipes')) return 'recipes';
return 'loras';
}
/**
* Get the rebuild cache endpoint for the given page type
* @param {string} pageType - The page type
* @returns {string} The API endpoint URL
*/
getRebuildEndpoint(pageType) {
const endpoints = {
'loras': '/api/lm/loras/reload?rebuild=true',
'checkpoints': '/api/lm/checkpoints/reload?rebuild=true',
'embeddings': '/api/lm/embeddings/reload?rebuild=true'
};
return endpoints[pageType] || endpoints['loras'];
}
/**
* Remove a banner element from DOM without marking as dismissed
* @param {string} bannerId - Banner ID to remove
*/
removeBannerElement(bannerId) {
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
if (bannerElement) {
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
setTimeout(() => {
bannerElement.remove();
this.updateContainerVisibility();
}, 300);
}
// Also remove from banners map
this.banners.delete(bannerId);
}
prepareCommunitySupportBanner() {
if (this.isBannerDismissed(COMMUNITY_SUPPORT_BANNER_ID)) {
return;

View File

@@ -64,6 +64,17 @@ export class BulkManager {
deleteAll: true,
setContentRating: true
},
[MODEL_TYPES.MISC]: {
addTags: true,
sendToWorkflow: false,
copyAll: false,
refreshAll: true,
checkUpdates: true,
moveAll: true,
autoOrganize: true,
deleteAll: true,
setContentRating: true
},
recipes: {
addTags: false,
sendToWorkflow: false,

View File

@@ -21,7 +21,7 @@ export class ExampleImagesManager {
// Auto download properties
this.autoDownloadInterval = null;
this.lastAutoDownloadCheck = 0;
this.autoDownloadCheckInterval = 30 * 60 * 1000; // 30 minutes in milliseconds
this.autoDownloadCheckInterval = 10 * 60 * 1000; // 10 minutes in milliseconds
this.pageInitTime = Date.now(); // Track when page was initialized
// Initialize download path field and check download status
@@ -808,58 +808,19 @@ export class ExampleImagesManager {
return;
}
this.lastAutoDownloadCheck = now;
if (!this.canAutoDownload()) {
console.log('Auto download conditions not met, skipping check');
return;
}
try {
console.log('Performing auto download pre-check...');
console.log('Performing auto download check...');
// Step 1: Lightweight pre-check to see if any work is needed
const checkResponse = await fetch('/api/lm/check-example-images-needed', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model_types: ['lora', 'checkpoint', 'embedding']
})
});
if (!checkResponse.ok) {
console.warn('Auto download pre-check HTTP error:', checkResponse.status);
return;
}
const checkData = await checkResponse.json();
if (!checkData.success) {
console.warn('Auto download pre-check failed:', checkData.error);
return;
}
// Update the check timestamp only after successful pre-check
this.lastAutoDownloadCheck = now;
// If download already in progress, skip
if (checkData.is_downloading) {
console.log('Download already in progress, skipping auto check');
return;
}
// If no models need downloading, skip
if (!checkData.needs_download || checkData.pending_count === 0) {
console.log(`Auto download pre-check complete: ${checkData.processed_count}/${checkData.total_models} models already processed, no work needed`);
return;
}
console.log(`Auto download pre-check: ${checkData.pending_count} models need processing, starting download...`);
// Step 2: Start the actual download (fire-and-forget)
const optimize = state.global.settings.optimize_example_images;
fetch('/api/lm/download-example-images', {
const response = await fetch('/api/lm/download-example-images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -869,29 +830,18 @@ export class ExampleImagesManager {
model_types: ['lora', 'checkpoint', 'embedding'],
auto_mode: true // Flag to indicate this is an automatic download
})
}).then(response => {
if (!response.ok) {
console.warn('Auto download start HTTP error:', response.status);
return null;
}
return response.json();
}).then(data => {
if (data && !data.success) {
console.warn('Auto download start failed:', data.error);
// If already in progress, push back the next check to avoid hammering the API
if (data.error && data.error.includes('already in progress')) {
console.log('Download already in progress, backing off next check');
this.lastAutoDownloadCheck = now + (5 * 60 * 1000); // Back off for 5 extra minutes
}
} else if (data && data.success) {
console.log('Auto download started:', data.message || 'Download started');
}
}).catch(error => {
console.error('Auto download start error:', error);
});
// Immediately return without waiting for the download fetch to complete
// This keeps the UI responsive
const data = await response.json();
if (!data.success) {
console.warn('Auto download check failed:', data.error);
// If already in progress, push back the next check to avoid hammering the API
if (data.error && data.error.includes('already in progress')) {
console.log('Download already in progress, backing off next check');
this.lastAutoDownloadCheck = now + (5 * 60 * 1000); // Back off for 5 extra minutes
}
}
} catch (error) {
console.error('Auto download check error:', error);
}

View File

@@ -63,9 +63,6 @@ export class FilterManager {
this.initializeLicenseFilters();
}
// Initialize tag logic toggle
this.initializeTagLogicToggle();
// Add click handler for filter button
if (this.filterButton) {
this.filterButton.addEventListener('click', () => {
@@ -87,45 +84,6 @@ export class FilterManager {
this.loadFiltersFromStorage();
}
initializeTagLogicToggle() {
const toggleContainer = document.getElementById('tagLogicToggle');
if (!toggleContainer) return;
const options = toggleContainer.querySelectorAll('.tag-logic-option');
options.forEach(option => {
option.addEventListener('click', async () => {
const value = option.dataset.value;
if (this.filters.tagLogic === value) return;
this.filters.tagLogic = value;
this.updateTagLogicToggleUI();
// Auto-apply filter when logic changes
await this.applyFilters(false);
});
});
// Set initial state
this.updateTagLogicToggleUI();
}
updateTagLogicToggleUI() {
const toggleContainer = document.getElementById('tagLogicToggle');
if (!toggleContainer) return;
const options = toggleContainer.querySelectorAll('.tag-logic-option');
const currentLogic = this.filters.tagLogic || 'any';
options.forEach(option => {
if (option.dataset.value === currentLogic) {
option.classList.add('active');
} else {
option.classList.remove('active');
}
});
}
async loadTopTags() {
try {
// Show loading state
@@ -591,17 +549,6 @@ export class FilterManager {
showToast('toast.filters.cleared', {}, 'info');
}
}
// Refresh duplicates with new filters
if (window.modelDuplicatesManager) {
if (window.modelDuplicatesManager.inDuplicateMode) {
// In duplicate mode: refresh the duplicate list
await window.modelDuplicatesManager.findDuplicates();
} else {
// Not in duplicate mode: just update badge count
window.modelDuplicatesManager.checkDuplicatesCount();
}
}
}
async clearFilters() {
@@ -615,13 +562,9 @@ export class FilterManager {
baseModel: [],
tags: {},
license: {},
modelTypes: [],
tagLogic: 'any'
modelTypes: []
});
// Update tag logic toggle UI
this.updateTagLogicToggleUI();
// Update state
const pageState = getCurrentPageState();
pageState.filters = this.cloneFilters();
@@ -666,7 +609,6 @@ export class FilterManager {
pageState.filters = this.cloneFilters();
this.updateTagSelections();
this.updateTagLogicToggleUI();
this.updateActiveFiltersCount();
if (this.hasActiveFilters()) {
@@ -702,8 +644,7 @@ export class FilterManager {
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
tags: this.normalizeTagFilters(source.tags),
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
tagLogic: source.tagLogic || 'any'
modelTypes: this.normalizeModelTypeFilters(source.modelTypes)
};
}
@@ -785,8 +726,7 @@ export class FilterManager {
baseModel: [...(this.filters.baseModel || [])],
tags: { ...(this.filters.tags || {}) },
license: { ...(this.filters.license || {}) },
modelTypes: [...(this.filters.modelTypes || [])],
tagLogic: this.filters.tagLogic || 'any'
modelTypes: [...(this.filters.modelTypes || [])]
};
}

View File

@@ -751,7 +751,12 @@ export class FilterPresetManager {
const presetName = document.createElement('span');
presetName.className = 'preset-name';
presetName.textContent = preset.name;
if (isActive) {
presetName.innerHTML = `<i class="fas fa-check"></i> ${preset.name}`;
} else {
presetName.textContent = preset.name;
}
presetName.title = translate('header.filter.presetClickTooltip', { name: preset.name }, `Click to apply preset "${preset.name}"`);
const deleteBtn = document.createElement('button');

51
static/js/misc.js Normal file
View File

@@ -0,0 +1,51 @@
import { appCore } from './core.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { createPageControls } from './components/controls/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
// Initialize the Misc (VAE/Upscaler) page
export class MiscPageManager {
constructor() {
// Initialize page controls
this.pageControls = createPageControls(MODEL_TYPES.MISC);
// Initialize the ModelDuplicatesManager
this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.MISC);
// Expose only necessary functions to global scope
this._exposeRequiredGlobalFunctions();
}
_exposeRequiredGlobalFunctions() {
// Minimal set of functions that need to remain global
window.confirmDelete = confirmDelete;
window.closeDeleteModal = closeDeleteModal;
window.confirmExclude = confirmExclude;
window.closeExcludeModal = closeExcludeModal;
// Expose duplicates manager
window.modelDuplicatesManager = this.duplicatesManager;
}
async initialize() {
// Initialize common page features (including context menus)
appCore.initializePageFeatures();
console.log('Misc Manager initialized');
}
}
export async function initializeMiscPage() {
// Initialize core application
await appCore.initialize();
// Initialize misc page
const miscPage = new MiscPageManager();
await miscPage.initialize();
return miscPage;
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', initializeMiscPage);

Some files were not shown because too many files have changed in this diff Show More