mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-23 06:02:11 -03:00
Compare commits
47 Commits
misc-page
...
modal-rewo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26884630d3 | ||
|
|
66e9d77c67 | ||
|
|
5ffca15172 | ||
|
|
4d9115339b | ||
|
|
469f7a1829 | ||
|
|
d27e3c8126 | ||
|
|
7bc63d7631 | ||
|
|
1606a3ff46 | ||
|
|
b313f36be9 | ||
|
|
fa3625ff72 | ||
|
|
895d13dc96 | ||
|
|
b7e0821f66 | ||
|
|
36e3e62e70 | ||
|
|
7bcf4e4491 | ||
|
|
c12aefa82a | ||
|
|
990a3527e4 | ||
|
|
655d3cab71 | ||
|
|
358e658459 | ||
|
|
f28c32f2b1 | ||
|
|
f5dbd6b8e8 | ||
|
|
2c026a2646 | ||
|
|
bd83f7520e | ||
|
|
b9a4e7a09b | ||
|
|
c30e57ede8 | ||
|
|
0dba1b336d | ||
|
|
820afe9319 | ||
|
|
5a97f4bc75 | ||
|
|
94da404cc5 | ||
|
|
1da476d858 | ||
|
|
1daaff6bd4 | ||
|
|
e252e44403 | ||
|
|
778ad8abd2 | ||
|
|
68cf381b50 | ||
|
|
337f73e711 | ||
|
|
04ba966a6e | ||
|
|
71c8cf84e0 | ||
|
|
db1aec94e5 | ||
|
|
553e1868e1 | ||
|
|
938ceb49b2 | ||
|
|
c0f03b79a8 | ||
|
|
a492638133 | ||
|
|
e17d6c8ebf | ||
|
|
ffcfe5ea3e | ||
|
|
719e18adb6 | ||
|
|
92d471daf5 | ||
|
|
66babf9ee1 | ||
|
|
60df2df324 |
201
.agents/skills/lora-manager-e2e/SKILL.md
Normal file
201
.agents/skills/lora-manager-e2e/SKILL.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
324
.agents/skills/lora-manager-e2e/references/mcp-cheatsheet.md
Normal file
324
.agents/skills/lora-manager-e2e/references/mcp-cheatsheet.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
# 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}"
|
||||||
|
```
|
||||||
272
.agents/skills/lora-manager-e2e/references/test-scenarios.md
Normal file
272
.agents/skills/lora-manager-e2e/references/test-scenarios.md
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
# 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"
|
||||||
|
```
|
||||||
193
.agents/skills/lora-manager-e2e/scripts/example_e2e_test.py
Executable file
193
.agents/skills/lora-manager-e2e/scripts/example_e2e_test.py
Executable file
@@ -0,0 +1,193 @@
|
|||||||
|
#!/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)
|
||||||
169
.agents/skills/lora-manager-e2e/scripts/start_server.py
Executable file
169
.agents/skills/lora-manager-e2e/scripts/start_server.py
Executable file
@@ -0,0 +1,169 @@
|
|||||||
|
#!/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())
|
||||||
61
.agents/skills/lora-manager-e2e/scripts/wait_for_server.py
Executable file
61
.agents/skills/lora-manager-e2e/scripts/wait_for_server.py
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/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())
|
||||||
@@ -34,6 +34,15 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
|||||||
|
|
||||||
## Release Notes
|
## 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
|
### 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 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.
|
* **LoRA Randomizer Template** - Refer to the new "LoRA Randomizer" template workflow for detailed examples of flexible randomization modes, lock & reuse options, and other features.
|
||||||
|
|||||||
449
docs/plan/model-modal-redesign.md
Normal file
449
docs/plan/model-modal-redesign.md
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
# Model Modal UI/UX 重构计划
|
||||||
|
|
||||||
|
> **Status**: Phase 1 Complete ✓
|
||||||
|
> **Created**: 2026-02-06
|
||||||
|
> **Target**: v2.x Release
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
### 1.1 背景与问题
|
||||||
|
|
||||||
|
当前 Model Modal 存在以下 UX 问题:
|
||||||
|
|
||||||
|
1. **空间利用率低** - 固定 800px 宽度,大屏环境下大量留白
|
||||||
|
2. **Tab 切换繁琐** - 4 个 Tab(Examples/Description/Versions/Recipes)隐藏了重要信息
|
||||||
|
3. **Examples 浏览不便** - 需持续向下滚动,无快速导航
|
||||||
|
4. **添加自定义示例困难** - 需滚动到底部,操作路径长
|
||||||
|
|
||||||
|
### 1.2 设计目标
|
||||||
|
|
||||||
|
- **空间效率**: 利用 header 以下、sidebar 右侧的全部可用空间
|
||||||
|
- **浏览体验**: 类似 Midjourney 的沉浸式图片浏览
|
||||||
|
- **信息架构**: 关键元数据固定可见,次要信息可折叠
|
||||||
|
- **操作效率**: 直觉化的键盘导航,减少点击次数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 设计方案
|
||||||
|
|
||||||
|
### 2.1 布局架构: Split-View Overlay
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ HEADER (保持现有) │
|
||||||
|
├──────────┬───────────────────────────────────────────────────────────┤
|
||||||
|
│ │ ┌───────────────────────────┬────────────────────────┐ │
|
||||||
|
│ FOLDER │ │ │ MODEL HEADER │ │
|
||||||
|
│ SIDEBAR │ │ EXAMPLES SHOWCASE │ ├─ Name │ │
|
||||||
|
│ (可折叠) │ │ │ ├─ Creator + Actions │ │
|
||||||
|
│ │ │ ┌─────────────────┐ │ ├─ Tags │ │
|
||||||
|
│ │ │ │ │ ├────────────────────────┤ │
|
||||||
|
│ │ │ │ MAIN IMAGE │ │ COMPACT METADATA │ │
|
||||||
|
│ │ │ │ (自适应高度) │ │ ├─ Ver | Base | Size │ │
|
||||||
|
│ │ │ │ │ │ ├─ Location │ │
|
||||||
|
│ │ │ └─────────────────┘ │ ├─ Usage Tips │ │
|
||||||
|
│ │ │ │ ├─ Trigger Words │ │
|
||||||
|
│ │ │ [PARAMS PREVIEW] │ ├─ Notes │ │
|
||||||
|
│ │ │ (Prompt + Copy) ├────────────────────────┤ │
|
||||||
|
│ │ │ │ CONTENT TABS │ │
|
||||||
|
│ │ │ ┌─────────────────┐ │ [Desc][Versions][Rec] │ │
|
||||||
|
│ │ │ │ THUMBNAIL RAIL │ │ │ │
|
||||||
|
│ │ │ │ [1][2][3][4][+]│ │ TAB CONTENT AREA │ │
|
||||||
|
│ │ │ └─────────────────┘ │ (Accordion / List) │ │
|
||||||
|
│ │ └───────────────────────────┴────────────────────────┘ │
|
||||||
|
└──────────┴───────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**尺寸规格**:
|
||||||
|
- Sidebar 展开: Left 60% | Right 40%
|
||||||
|
- Sidebar 折叠: Left 65% | Right 35%
|
||||||
|
- 最小宽度: 1200px (低于此值触发移动端适配)
|
||||||
|
|
||||||
|
### 2.2 左侧: Examples Showcase
|
||||||
|
|
||||||
|
#### 2.2.1 组件结构
|
||||||
|
|
||||||
|
| 组件 | 描述 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| Main Image | 自适应容器,保持原始比例,最大高度 70vh | P0 |
|
||||||
|
| Params Panel | 底部滑出面板,显示 Prompt/Negative/Params | P0 |
|
||||||
|
| Thumbnail Rail | 底部横向滚动条,支持点击跳转 | P0 |
|
||||||
|
| Add Button | Rail 最右侧 "+" 按钮,打开上传区 | P0 |
|
||||||
|
| Nav Arrows | 图片左右两侧悬停显示 | P1 |
|
||||||
|
|
||||||
|
#### 2.2.2 图片悬停操作
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ [👁] [📌] [🗑] │ ← 查看参数 | 设为预览 | 删除
|
||||||
|
│ │
|
||||||
|
│ IMAGE │
|
||||||
|
│ │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2.3 键盘导航
|
||||||
|
|
||||||
|
| 按键 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| ← | 上一个 Example | 循环(首张时到最后一张) |
|
||||||
|
| → | 下一个 Example | 循环(末张时到第一张) |
|
||||||
|
| I | Toggle Params Panel | 显示/隐藏图片参数 |
|
||||||
|
| C | Copy Prompt | 复制当前 Prompt 到剪贴板 |
|
||||||
|
|
||||||
|
### 2.3 右侧: Metadata + Content
|
||||||
|
|
||||||
|
#### 2.3.1 固定头部 (不可折叠)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────┐
|
||||||
|
│ MODEL NAME [×] │
|
||||||
|
│ [👤 Creator] [🌐 Civ] │
|
||||||
|
│ [tag1] [tag2] [tag3] │
|
||||||
|
├────────────────────────┤
|
||||||
|
│ Ver: v1.0 Size: 96MB │
|
||||||
|
│ Base: SDXL │
|
||||||
|
│ 📁 /path/to/file │
|
||||||
|
├────────────────────────┤
|
||||||
|
│ USAGE TIPS [✏️] │
|
||||||
|
│ [strength: 0.8] [+] │
|
||||||
|
├────────────────────────┤
|
||||||
|
│ TRIGGER WORDS [✏️] │
|
||||||
|
│ [word1] [word2] [📋] │
|
||||||
|
├────────────────────────┤
|
||||||
|
│ NOTES [✏️] │
|
||||||
|
│ "Add your notes..." │
|
||||||
|
└────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3.2 Tabs 设计
|
||||||
|
|
||||||
|
保留横向 Tab 切换,但优化内容展示:
|
||||||
|
|
||||||
|
| Tab | 内容 | 交互方式 |
|
||||||
|
|-----|------|----------|
|
||||||
|
| Description | About this version + Model Description | Accordion 折叠 |
|
||||||
|
| Versions | 版本列表卡片 | 完整列表视图 |
|
||||||
|
| Recipes | Recipe 卡片网格 | 网格布局 |
|
||||||
|
|
||||||
|
**Accordion 行为**:
|
||||||
|
- 手风琴模式:同时只能展开一个 section
|
||||||
|
- 默认:About this version 展开,Description 折叠
|
||||||
|
- 动画:300ms ease-out
|
||||||
|
|
||||||
|
### 2.4 全局导航
|
||||||
|
|
||||||
|
#### 2.4.1 Model 切换
|
||||||
|
|
||||||
|
| 按键 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| ↑ | 上一个 Model |
|
||||||
|
| ↓ | 下一个 Model |
|
||||||
|
|
||||||
|
**切换动画**:
|
||||||
|
1. 当前 Modal 淡出 (150ms)
|
||||||
|
2. 加载新 Model 数据
|
||||||
|
3. 新 Modal 淡入 (150ms)
|
||||||
|
4. 保持当前 Tab 状态(不重置到默认)
|
||||||
|
|
||||||
|
#### 2.4.2 首次使用提示
|
||||||
|
|
||||||
|
Modal 首次打开时,顶部显示提示条:
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 💡 Tip: ↑↓ 切换模型 | ←→ 浏览示例 | I 查看参数 | ESC 关闭 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
- 3 秒后自动淡出
|
||||||
|
- 提供 "不再显示" 选项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术实现
|
||||||
|
|
||||||
|
### 3.1 文件结构变更
|
||||||
|
|
||||||
|
```
|
||||||
|
static/
|
||||||
|
├── js/
|
||||||
|
│ └── components/
|
||||||
|
│ └── model-modal/ # 新目录
|
||||||
|
│ ├── index.js # 主入口
|
||||||
|
│ ├── ModelModal.js # Modal 容器
|
||||||
|
│ ├── ExampleShowcase.js # 左侧展示
|
||||||
|
│ ├── ThumbnailRail.js # 缩略图导航
|
||||||
|
│ ├── MetadataPanel.js # 右侧元数据
|
||||||
|
│ ├── ContentTabs.js # Tabs 容器
|
||||||
|
│ └── accordions/ # Accordion 组件
|
||||||
|
│ ├── DescriptionAccordion.js
|
||||||
|
│ └── VersionsList.js
|
||||||
|
├── css/
|
||||||
|
│ └── components/
|
||||||
|
│ └── model-modal/ # 新目录
|
||||||
|
│ ├── modal-overlay.css
|
||||||
|
│ ├── showcase.css
|
||||||
|
│ ├── thumbnail-rail.css
|
||||||
|
│ ├── metadata.css
|
||||||
|
│ └── tabs.css
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 核心 CSS 架构
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* modal-overlay.css */
|
||||||
|
.model-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--header-height);
|
||||||
|
left: var(--sidebar-width, 250px);
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 0.8fr;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
background: var(--bg-color);
|
||||||
|
animation: modalSlideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-overlay.sidebar-collapsed {
|
||||||
|
left: var(--sidebar-collapsed-width, 60px);
|
||||||
|
grid-template-columns: 1.3fr 0.7fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.model-overlay {
|
||||||
|
left: 0;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 响应式断点
|
||||||
|
|
||||||
|
| 断点 | 布局 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| > 1400px | Split 60/40 | 大屏优化 |
|
||||||
|
| 1200-1400px | Split 50/50 | 标准桌面 |
|
||||||
|
| 768-1200px | Split 50/50 | 小屏桌面/平板 |
|
||||||
|
| < 768px | Stack | 移动端:Examples 在上,Metadata 在下 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 实施阶段
|
||||||
|
|
||||||
|
### Phase 1: 核心重构 (预计 2-3 周)
|
||||||
|
|
||||||
|
**目标**: MVP 可用,基础功能完整
|
||||||
|
|
||||||
|
**任务清单**:
|
||||||
|
|
||||||
|
- [ ] 创建新的文件结构和基础组件
|
||||||
|
- [ ] 实现 Split-View Overlay 布局
|
||||||
|
- [ ] CSS Grid 布局系统
|
||||||
|
- [ ] Sidebar 状态联动
|
||||||
|
- [ ] 响应式断点处理
|
||||||
|
- [ ] 迁移左侧 Examples 区域
|
||||||
|
- [ ] Main Image 自适应容器
|
||||||
|
- [ ] Thumbnail Rail 组件
|
||||||
|
- [ ] Params Panel 滑出动画
|
||||||
|
- [ ] 实现新的快捷键系统
|
||||||
|
- [ ] ↑↓ 切换 Model
|
||||||
|
- [ ] ←→ 切换 Example
|
||||||
|
- [ ] I/C/ESC 功能键
|
||||||
|
- [ ] 移除旧 Modal 的 max-width 限制
|
||||||
|
- [ ] 基础动画过渡
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 新布局在各种屏幕尺寸下正常显示
|
||||||
|
- [ ] 键盘导航正常工作
|
||||||
|
- [ ] 无阻塞性 Bug
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: 体验优化 (预计 1-2 周)
|
||||||
|
|
||||||
|
**目标**: 信息架构优化,交互细节完善
|
||||||
|
|
||||||
|
**任务清单**:
|
||||||
|
|
||||||
|
- [ ] Accordion 组件实现
|
||||||
|
- [ ] Description Tab 的折叠面板
|
||||||
|
- [ ] 手风琴交互逻辑
|
||||||
|
- [ ] 动画优化
|
||||||
|
- [ ] 右侧 Metadata 区域固定化
|
||||||
|
- [ ] 滚动行为优化
|
||||||
|
- [ ] 编辑功能迁移
|
||||||
|
- [ ] Example 添加流程优化
|
||||||
|
- [ ] Rail 上的 "+" 按钮
|
||||||
|
- [ ] Inline Upload Area
|
||||||
|
- [ ] 拖拽上传支持
|
||||||
|
- [ ] Model 切换动画优化
|
||||||
|
- [ ] 淡入淡出效果
|
||||||
|
- [ ] 加载状态指示
|
||||||
|
- [ ] 首次使用提示
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] Accordion 交互流畅
|
||||||
|
- [ ] 添加 Example 操作路径 < 2 步
|
||||||
|
- [ ] Model 切换视觉反馈清晰
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: 功能完整化 (预计 1-2 周)
|
||||||
|
|
||||||
|
**目标**: 所有现有功能迁移完成
|
||||||
|
|
||||||
|
**任务清单**:
|
||||||
|
|
||||||
|
- [ ] Versions Tab 完整实现
|
||||||
|
- [ ] 版本列表卡片
|
||||||
|
- [ ] 下载/忽略/删除操作
|
||||||
|
- [ ] 更新状态 Badge
|
||||||
|
- [ ] Recipes Tab 完整实现
|
||||||
|
- [ ] Recipe 卡片网格
|
||||||
|
- [ ] 复制/应用操作
|
||||||
|
- [ ] Tab 状态保持
|
||||||
|
- [ ] 切换 Model 时保持当前 Tab
|
||||||
|
- [ ] Tab 内容滚动位置记忆
|
||||||
|
- [ ] 所有编辑功能迁移
|
||||||
|
- [ ] Model Name 编辑
|
||||||
|
- [ ] Base Model 编辑
|
||||||
|
- [ ] File Name 编辑
|
||||||
|
- [ ] Tags 编辑
|
||||||
|
- [ ] Usage Tips 编辑
|
||||||
|
- [ ] Notes 编辑
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 所有现有功能可用
|
||||||
|
- [ ] 单元测试覆盖率 > 80%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: 打磨与优化 (预计 1 周)
|
||||||
|
|
||||||
|
**目标**: 性能优化,边缘 case 处理
|
||||||
|
|
||||||
|
**任务清单**:
|
||||||
|
|
||||||
|
- [ ] 移动端适配完善
|
||||||
|
- [ ] Stack 布局优化
|
||||||
|
- [ ] 触摸手势支持(滑动切换)
|
||||||
|
- [ ] 性能优化
|
||||||
|
- [ ] 图片懒加载优化
|
||||||
|
- [ ] 虚拟滚动(大量 Examples 时)
|
||||||
|
- [ ] 减少重渲染
|
||||||
|
- [ ] 无障碍支持
|
||||||
|
- [ ] ARIA 标签
|
||||||
|
- [ ] 键盘导航焦点管理
|
||||||
|
- [ ] 屏幕阅读器测试
|
||||||
|
- [ ] 动画性能优化
|
||||||
|
- [ ] will-change 优化
|
||||||
|
- [ ] 减少 layout thrashing
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] Lighthouse Performance > 90
|
||||||
|
- [ ] 无障碍检查无严重问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: 发布准备 (预计 3-5 天)
|
||||||
|
|
||||||
|
**目标**: 稳定版本,文档完整
|
||||||
|
|
||||||
|
**任务清单**:
|
||||||
|
|
||||||
|
- [ ] Bug 修复
|
||||||
|
- [ ] 用户测试
|
||||||
|
- [ ] 更新文档
|
||||||
|
- [ ] README 更新
|
||||||
|
- [ ] 快捷键说明
|
||||||
|
- [ ] 截图/GIF 演示
|
||||||
|
- [ ] 发布说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 风险与应对
|
||||||
|
|
||||||
|
| 风险 | 影响 | 应对策略 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 用户不适应新布局 | 中 | 提供设置选项,允许切换回旧版(临时) |
|
||||||
|
| 性能问题(大量 Examples) | 高 | Phase 4 重点优化,必要时虚拟滚动 |
|
||||||
|
| 移动端体验不佳 | 中 | 单独设计移动端布局,非简单缩放 |
|
||||||
|
| 与现有扩展冲突 | 低 | 充分的回归测试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 关联文件
|
||||||
|
|
||||||
|
### 6.1 需修改的现有文件
|
||||||
|
|
||||||
|
```
|
||||||
|
static/js/components/shared/ModelModal.js # 完全重构
|
||||||
|
static/js/components/shared/showcase/ # 迁移至新目录
|
||||||
|
static/css/components/lora-modal/ # 样式重写
|
||||||
|
static/css/components/modal/_base.css # Overlay 样式调整
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 参考资源
|
||||||
|
|
||||||
|
- [Midjourney Explore](https://www.midjourney.com/explore) - 交互参考
|
||||||
|
- [Pinterest Pin View](https://www.pinterest.com) - 布局参考
|
||||||
|
- [AGENTS.md](/AGENTS.md) - 项目代码规范
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Checklist
|
||||||
|
|
||||||
|
### 7.1 启动前
|
||||||
|
|
||||||
|
- [ ] 创建 feature branch: `feature/model-modal-redesign`
|
||||||
|
- [ ] 设置开发环境
|
||||||
|
- [ ] 准备测试数据集(多种 Model 类型)
|
||||||
|
|
||||||
|
### 7.2 每个 Phase 完成时
|
||||||
|
|
||||||
|
- [ ] 代码审查
|
||||||
|
- [ ] 功能测试
|
||||||
|
- [ ] 更新本文档状态
|
||||||
|
|
||||||
|
### 7.3 发布前
|
||||||
|
|
||||||
|
- [ ] 完整回归测试
|
||||||
|
- [ ] 更新 CHANGELOG
|
||||||
|
- [ ] 更新版本号
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 附录
|
||||||
|
|
||||||
|
### 8.1 命名规范
|
||||||
|
|
||||||
|
| 类型 | 规范 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| 文件 | kebab-case | `thumbnail-rail.js` |
|
||||||
|
| 组件 | PascalCase | `ThumbnailRail` |
|
||||||
|
| CSS 类 | BEM | `.thumbnail-rail__item--active` |
|
||||||
|
| 变量 | camelCase | `currentExampleIndex` |
|
||||||
|
|
||||||
|
### 8.2 颜色规范
|
||||||
|
|
||||||
|
使用现有 CSS 变量,不引入新颜色:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--lora-accent: #4299e1;
|
||||||
|
--lora-accent-l: 60%;
|
||||||
|
--lora-accent-c: 0.2;
|
||||||
|
--lora-accent-h: 250;
|
||||||
|
--lora-surface: var(--card-bg);
|
||||||
|
--lora-border: var(--border-color);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last Updated: 2026-02-06*
|
||||||
File diff suppressed because one or more lines are too long
@@ -179,7 +179,6 @@
|
|||||||
"recipes": "Rezepte",
|
"recipes": "Rezepte",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
"misc": "[TODO: Translate] Misc",
|
|
||||||
"statistics": "Statistiken"
|
"statistics": "Statistiken"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -188,8 +187,7 @@
|
|||||||
"loras": "LoRAs suchen...",
|
"loras": "LoRAs suchen...",
|
||||||
"recipes": "Rezepte suchen...",
|
"recipes": "Rezepte suchen...",
|
||||||
"checkpoints": "Checkpoints suchen...",
|
"checkpoints": "Checkpoints suchen...",
|
||||||
"embeddings": "Embeddings suchen...",
|
"embeddings": "Embeddings suchen..."
|
||||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
|
||||||
},
|
},
|
||||||
"options": "Suchoptionen",
|
"options": "Suchoptionen",
|
||||||
"searchIn": "Suchen in:",
|
"searchIn": "Suchen in:",
|
||||||
@@ -225,7 +223,11 @@
|
|||||||
"noCreditRequired": "Kein Credit erforderlich",
|
"noCreditRequired": "Kein Credit erforderlich",
|
||||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||||
"noTags": "Keine Tags",
|
"noTags": "Keine Tags",
|
||||||
"clearAll": "Alle Filter löschen"
|
"clearAll": "Alle Filter löschen",
|
||||||
|
"any": "Beliebig",
|
||||||
|
"all": "Alle",
|
||||||
|
"tagLogicAny": "Jedes Tag abgleichen (ODER)",
|
||||||
|
"tagLogicAll": "Alle Tags abgleichen (UND)"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"toggle": "Theme wechseln",
|
"toggle": "Theme wechseln",
|
||||||
@@ -690,16 +692,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding-Modelle"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "Stammverzeichnis",
|
"modelRoot": "Stammverzeichnis",
|
||||||
"collapseAll": "Alle Ordner einklappen",
|
"collapseAll": "Alle Ordner einklappen",
|
||||||
@@ -1116,10 +1108,6 @@
|
|||||||
"title": "Statistiken werden initialisiert",
|
"title": "Statistiken werden initialisiert",
|
||||||
"message": "Modelldaten für Statistiken werden verarbeitet. Dies kann einige Minuten dauern..."
|
"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": {
|
"tips": {
|
||||||
"title": "Tipps & Tricks",
|
"title": "Tipps & Tricks",
|
||||||
"civitai": {
|
"civitai": {
|
||||||
@@ -1179,18 +1167,12 @@
|
|||||||
"recipeAdded": "Rezept zum Workflow hinzugefügt",
|
"recipeAdded": "Rezept zum Workflow hinzugefügt",
|
||||||
"recipeReplaced": "Rezept im Workflow ersetzt",
|
"recipeReplaced": "Rezept im Workflow ersetzt",
|
||||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
"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",
|
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
||||||
"noTargetNodeSelected": "Kein Zielknoten ausgewählt"
|
"noTargetNodeSelected": "Kein Zielknoten ausgewählt"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Rezept",
|
"recipe": "Rezept",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"vae": "[TODO: Translate] VAE",
|
|
||||||
"upscaler": "[TODO: Translate] Upscaler",
|
|
||||||
"replace": "Ersetzen",
|
"replace": "Ersetzen",
|
||||||
"append": "Anhängen",
|
"append": "Anhängen",
|
||||||
"selectTargetNode": "Zielknoten auswählen",
|
"selectTargetNode": "Zielknoten auswählen",
|
||||||
@@ -1594,6 +1576,20 @@
|
|||||||
"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.",
|
"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",
|
"supportCta": "Support on Ko-fi",
|
||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,6 @@
|
|||||||
"recipes": "Recipes",
|
"recipes": "Recipes",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
"misc": "Misc",
|
|
||||||
"statistics": "Stats"
|
"statistics": "Stats"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -188,8 +187,7 @@
|
|||||||
"loras": "Search LoRAs...",
|
"loras": "Search LoRAs...",
|
||||||
"recipes": "Search recipes...",
|
"recipes": "Search recipes...",
|
||||||
"checkpoints": "Search checkpoints...",
|
"checkpoints": "Search checkpoints...",
|
||||||
"embeddings": "Search embeddings...",
|
"embeddings": "Search embeddings..."
|
||||||
"misc": "Search VAE/Upscaler models..."
|
|
||||||
},
|
},
|
||||||
"options": "Search Options",
|
"options": "Search Options",
|
||||||
"searchIn": "Search In:",
|
"searchIn": "Search In:",
|
||||||
@@ -225,7 +223,11 @@
|
|||||||
"noCreditRequired": "No Credit Required",
|
"noCreditRequired": "No Credit Required",
|
||||||
"allowSellingGeneratedContent": "Allow Selling",
|
"allowSellingGeneratedContent": "Allow Selling",
|
||||||
"noTags": "No tags",
|
"noTags": "No tags",
|
||||||
"clearAll": "Clear All Filters"
|
"clearAll": "Clear All Filters",
|
||||||
|
"any": "Any",
|
||||||
|
"all": "All",
|
||||||
|
"tagLogicAny": "Match any tag (OR)",
|
||||||
|
"tagLogicAll": "Match all tags (AND)"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"toggle": "Toggle theme",
|
"toggle": "Toggle theme",
|
||||||
@@ -690,16 +692,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding Models"
|
"title": "Embedding Models"
|
||||||
},
|
},
|
||||||
"misc": {
|
|
||||||
"title": "VAE & Upscaler Models",
|
|
||||||
"modelTypes": {
|
|
||||||
"vae": "VAE",
|
|
||||||
"upscaler": "Upscaler"
|
|
||||||
},
|
|
||||||
"contextMenu": {
|
|
||||||
"moveToOtherTypeFolder": "Move to {otherType} Folder"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Root",
|
"modelRoot": "Root",
|
||||||
"collapseAll": "Collapse All Folders",
|
"collapseAll": "Collapse All Folders",
|
||||||
@@ -919,7 +911,12 @@
|
|||||||
"viewOnCivitai": "View on Civitai",
|
"viewOnCivitai": "View on Civitai",
|
||||||
"viewOnCivitaiText": "View on Civitai",
|
"viewOnCivitaiText": "View on Civitai",
|
||||||
"viewCreatorProfile": "View Creator Profile",
|
"viewCreatorProfile": "View Creator Profile",
|
||||||
"openFileLocation": "Open File Location"
|
"openFileLocation": "Open File Location",
|
||||||
|
"viewParams": "View parameters",
|
||||||
|
"setPreview": "Set as preview",
|
||||||
|
"previewSet": "Preview updated successfully",
|
||||||
|
"previewFailed": "Failed to update preview",
|
||||||
|
"delete": "Delete"
|
||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
"success": "File location opened successfully",
|
"success": "File location opened successfully",
|
||||||
@@ -938,13 +935,15 @@
|
|||||||
"additionalNotes": "Additional Notes",
|
"additionalNotes": "Additional Notes",
|
||||||
"notesHint": "Press Enter to save, Shift+Enter for new line",
|
"notesHint": "Press Enter to save, Shift+Enter for new line",
|
||||||
"addNotesPlaceholder": "Add your notes here...",
|
"addNotesPlaceholder": "Add your notes here...",
|
||||||
"aboutThisVersion": "About this version"
|
"aboutThisVersion": "About this version",
|
||||||
|
"triggerWords": "Trigger Words"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notes saved successfully",
|
"saved": "Notes saved successfully",
|
||||||
"saveFailed": "Failed to save notes"
|
"saveFailed": "Failed to save notes"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
|
"add": "Add",
|
||||||
"addPresetParameter": "Add preset parameter...",
|
"addPresetParameter": "Add preset parameter...",
|
||||||
"strengthMin": "Strength Min",
|
"strengthMin": "Strength Min",
|
||||||
"strengthMax": "Strength Max",
|
"strengthMax": "Strength Max",
|
||||||
@@ -953,17 +952,24 @@
|
|||||||
"clipStrength": "Clip Strength",
|
"clipStrength": "Clip Strength",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Value",
|
"valuePlaceholder": "Value",
|
||||||
"add": "Add",
|
|
||||||
"invalidRange": "Invalid range format. Use x.x-y.y"
|
"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": {
|
"triggerWords": {
|
||||||
"label": "Trigger Words",
|
"label": "Trigger Words",
|
||||||
"noTriggerWordsNeeded": "No trigger word needed",
|
"noTriggerWordsNeeded": "No trigger words needed",
|
||||||
"edit": "Edit trigger words",
|
"edit": "Edit trigger words",
|
||||||
"cancel": "Cancel editing",
|
"cancel": "Cancel editing",
|
||||||
"save": "Save changes",
|
"save": "Save changes",
|
||||||
"addPlaceholder": "Type to add or click suggestions below",
|
"addPlaceholder": "Type to add trigger word...",
|
||||||
"copyWord": "Copy trigger word",
|
"copyWord": "Copy trigger word",
|
||||||
|
"copyAll": "Copy all trigger words",
|
||||||
"deleteWord": "Delete trigger word",
|
"deleteWord": "Delete trigger word",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "No suggestions available",
|
"noSuggestions": "No suggestions available",
|
||||||
@@ -973,6 +979,9 @@
|
|||||||
"wordSuggestions": "Word Suggestions",
|
"wordSuggestions": "Word Suggestions",
|
||||||
"wordsFound": "{count} words found",
|
"wordsFound": "{count} words found",
|
||||||
"loading": "Loading suggestions..."
|
"loading": "Loading suggestions..."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"duplicate": "This trigger word already exists"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
@@ -998,7 +1007,11 @@
|
|||||||
"previousWithShortcut": "Previous model (←)",
|
"previousWithShortcut": "Previous model (←)",
|
||||||
"nextWithShortcut": "Next model (→)",
|
"nextWithShortcut": "Next model (→)",
|
||||||
"noPrevious": "No previous model available",
|
"noPrevious": "No previous model available",
|
||||||
"noNext": "No next model available"
|
"noNext": "No next model available",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"switchModel": "Switch model",
|
||||||
|
"browseExamples": "Browse examples"
|
||||||
},
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"noImageSell": "No selling generated content",
|
"noImageSell": "No selling generated content",
|
||||||
@@ -1010,6 +1023,23 @@
|
|||||||
"noReLicense": "Same permissions required",
|
"noReLicense": "Same permissions required",
|
||||||
"restrictionsLabel": "License restrictions"
|
"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": {
|
"loading": {
|
||||||
"exampleImages": "Loading example images...",
|
"exampleImages": "Loading example images...",
|
||||||
"description": "Loading model description...",
|
"description": "Loading model description...",
|
||||||
@@ -1116,10 +1146,6 @@
|
|||||||
"title": "Initializing Statistics",
|
"title": "Initializing Statistics",
|
||||||
"message": "Processing model data for statistics. This may take a few minutes..."
|
"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": {
|
"tips": {
|
||||||
"title": "Tips & Tricks",
|
"title": "Tips & Tricks",
|
||||||
"civitai": {
|
"civitai": {
|
||||||
@@ -1179,18 +1205,12 @@
|
|||||||
"recipeAdded": "Recipe appended to workflow",
|
"recipeAdded": "Recipe appended to workflow",
|
||||||
"recipeReplaced": "Recipe replaced in workflow",
|
"recipeReplaced": "Recipe replaced in workflow",
|
||||||
"recipeFailedToSend": "Failed to send recipe to 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",
|
"noMatchingNodes": "No compatible nodes available in the current workflow",
|
||||||
"noTargetNodeSelected": "No target node selected"
|
"noTargetNodeSelected": "No target node selected"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Recipe",
|
"recipe": "Recipe",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"vae": "VAE",
|
|
||||||
"upscaler": "Upscaler",
|
|
||||||
"replace": "Replace",
|
"replace": "Replace",
|
||||||
"append": "Append",
|
"append": "Append",
|
||||||
"selectTargetNode": "Select target node",
|
"selectTargetNode": "Select target node",
|
||||||
@@ -1594,6 +1614,20 @@
|
|||||||
"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.",
|
"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",
|
"supportCta": "Support on Ko-fi",
|
||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,6 @@
|
|||||||
"recipes": "Recetas",
|
"recipes": "Recetas",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
"misc": "[TODO: Translate] Misc",
|
|
||||||
"statistics": "Estadísticas"
|
"statistics": "Estadísticas"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -188,8 +187,7 @@
|
|||||||
"loras": "Buscar LoRAs...",
|
"loras": "Buscar LoRAs...",
|
||||||
"recipes": "Buscar recetas...",
|
"recipes": "Buscar recetas...",
|
||||||
"checkpoints": "Buscar checkpoints...",
|
"checkpoints": "Buscar checkpoints...",
|
||||||
"embeddings": "Buscar embeddings...",
|
"embeddings": "Buscar embeddings..."
|
||||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
|
||||||
},
|
},
|
||||||
"options": "Opciones de búsqueda",
|
"options": "Opciones de búsqueda",
|
||||||
"searchIn": "Buscar en:",
|
"searchIn": "Buscar en:",
|
||||||
@@ -225,7 +223,11 @@
|
|||||||
"noCreditRequired": "Sin crédito requerido",
|
"noCreditRequired": "Sin crédito requerido",
|
||||||
"allowSellingGeneratedContent": "Venta permitida",
|
"allowSellingGeneratedContent": "Venta permitida",
|
||||||
"noTags": "Sin etiquetas",
|
"noTags": "Sin etiquetas",
|
||||||
"clearAll": "Limpiar todos los filtros"
|
"clearAll": "Limpiar todos los filtros",
|
||||||
|
"any": "Cualquiera",
|
||||||
|
"all": "Todos",
|
||||||
|
"tagLogicAny": "Coincidir con cualquier etiqueta (O)",
|
||||||
|
"tagLogicAll": "Coincidir con todas las etiquetas (Y)"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"toggle": "Cambiar tema",
|
"toggle": "Cambiar tema",
|
||||||
@@ -690,16 +692,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Modelos embedding"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "Raíz",
|
"modelRoot": "Raíz",
|
||||||
"collapseAll": "Colapsar todas las carpetas",
|
"collapseAll": "Colapsar todas las carpetas",
|
||||||
@@ -1116,10 +1108,6 @@
|
|||||||
"title": "Inicializando estadísticas",
|
"title": "Inicializando estadísticas",
|
||||||
"message": "Procesando datos del modelo para estadísticas. Esto puede tomar unos minutos..."
|
"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": {
|
"tips": {
|
||||||
"title": "Consejos y trucos",
|
"title": "Consejos y trucos",
|
||||||
"civitai": {
|
"civitai": {
|
||||||
@@ -1179,18 +1167,12 @@
|
|||||||
"recipeAdded": "Receta añadida al flujo de trabajo",
|
"recipeAdded": "Receta añadida al flujo de trabajo",
|
||||||
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
||||||
"recipeFailedToSend": "Error al enviar receta al 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",
|
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
||||||
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino"
|
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Receta",
|
"recipe": "Receta",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"vae": "[TODO: Translate] VAE",
|
|
||||||
"upscaler": "[TODO: Translate] Upscaler",
|
|
||||||
"replace": "Reemplazar",
|
"replace": "Reemplazar",
|
||||||
"append": "Añadir",
|
"append": "Añadir",
|
||||||
"selectTargetNode": "Seleccionar nodo de destino",
|
"selectTargetNode": "Seleccionar nodo de destino",
|
||||||
@@ -1594,6 +1576,20 @@
|
|||||||
"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.",
|
"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",
|
"supportCta": "Support on Ko-fi",
|
||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,6 @@
|
|||||||
"recipes": "Recipes",
|
"recipes": "Recipes",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
"misc": "[TODO: Translate] Misc",
|
|
||||||
"statistics": "Statistiques"
|
"statistics": "Statistiques"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -188,8 +187,7 @@
|
|||||||
"loras": "Rechercher des LoRAs...",
|
"loras": "Rechercher des LoRAs...",
|
||||||
"recipes": "Rechercher des recipes...",
|
"recipes": "Rechercher des recipes...",
|
||||||
"checkpoints": "Rechercher des checkpoints...",
|
"checkpoints": "Rechercher des checkpoints...",
|
||||||
"embeddings": "Rechercher des embeddings...",
|
"embeddings": "Rechercher des embeddings..."
|
||||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
|
||||||
},
|
},
|
||||||
"options": "Options de recherche",
|
"options": "Options de recherche",
|
||||||
"searchIn": "Rechercher dans :",
|
"searchIn": "Rechercher dans :",
|
||||||
@@ -225,7 +223,11 @@
|
|||||||
"noCreditRequired": "Crédit non requis",
|
"noCreditRequired": "Crédit non requis",
|
||||||
"allowSellingGeneratedContent": "Vente autorisée",
|
"allowSellingGeneratedContent": "Vente autorisée",
|
||||||
"noTags": "Aucun tag",
|
"noTags": "Aucun tag",
|
||||||
"clearAll": "Effacer tous les filtres"
|
"clearAll": "Effacer tous les filtres",
|
||||||
|
"any": "N'importe quel",
|
||||||
|
"all": "Tous",
|
||||||
|
"tagLogicAny": "Correspondre à n'importe quel tag (OU)",
|
||||||
|
"tagLogicAll": "Correspondre à tous les tags (ET)"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"toggle": "Basculer le thème",
|
"toggle": "Basculer le thème",
|
||||||
@@ -690,16 +692,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Modèles Embedding"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "Racine",
|
"modelRoot": "Racine",
|
||||||
"collapseAll": "Réduire tous les dossiers",
|
"collapseAll": "Réduire tous les dossiers",
|
||||||
@@ -1116,10 +1108,6 @@
|
|||||||
"title": "Initialisation des statistiques",
|
"title": "Initialisation des statistiques",
|
||||||
"message": "Traitement des données de modèle pour les statistiques. Cela peut prendre quelques minutes..."
|
"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": {
|
"tips": {
|
||||||
"title": "Astuces et conseils",
|
"title": "Astuces et conseils",
|
||||||
"civitai": {
|
"civitai": {
|
||||||
@@ -1179,18 +1167,12 @@
|
|||||||
"recipeAdded": "Recipe ajoutée au workflow",
|
"recipeAdded": "Recipe ajoutée au workflow",
|
||||||
"recipeReplaced": "Recipe remplacée dans le workflow",
|
"recipeReplaced": "Recipe remplacée dans le workflow",
|
||||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au 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",
|
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
||||||
"noTargetNodeSelected": "Aucun nœud cible sélectionné"
|
"noTargetNodeSelected": "Aucun nœud cible sélectionné"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Recipe",
|
"recipe": "Recipe",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"vae": "[TODO: Translate] VAE",
|
|
||||||
"upscaler": "[TODO: Translate] Upscaler",
|
|
||||||
"replace": "Remplacer",
|
"replace": "Remplacer",
|
||||||
"append": "Ajouter",
|
"append": "Ajouter",
|
||||||
"selectTargetNode": "Sélectionner le nœud cible",
|
"selectTargetNode": "Sélectionner le nœud cible",
|
||||||
@@ -1594,6 +1576,20 @@
|
|||||||
"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.",
|
"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",
|
"supportCta": "Support on Ko-fi",
|
||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,6 @@
|
|||||||
"recipes": "מתכונים",
|
"recipes": "מתכונים",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
"misc": "[TODO: Translate] Misc",
|
|
||||||
"statistics": "סטטיסטיקה"
|
"statistics": "סטטיסטיקה"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -188,8 +187,7 @@
|
|||||||
"loras": "חפש LoRAs...",
|
"loras": "חפש LoRAs...",
|
||||||
"recipes": "חפש מתכונים...",
|
"recipes": "חפש מתכונים...",
|
||||||
"checkpoints": "חפש checkpoints...",
|
"checkpoints": "חפש checkpoints...",
|
||||||
"embeddings": "חפש embeddings...",
|
"embeddings": "חפש embeddings..."
|
||||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
|
||||||
},
|
},
|
||||||
"options": "אפשרויות חיפוש",
|
"options": "אפשרויות חיפוש",
|
||||||
"searchIn": "חפש ב:",
|
"searchIn": "חפש ב:",
|
||||||
@@ -225,7 +223,11 @@
|
|||||||
"noCreditRequired": "ללא קרדיט נדרש",
|
"noCreditRequired": "ללא קרדיט נדרש",
|
||||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||||
"noTags": "ללא תגיות",
|
"noTags": "ללא תגיות",
|
||||||
"clearAll": "נקה את כל המסננים"
|
"clearAll": "נקה את כל המסננים",
|
||||||
|
"any": "כלשהו",
|
||||||
|
"all": "כל התגים",
|
||||||
|
"tagLogicAny": "התאם כל תג (או)",
|
||||||
|
"tagLogicAll": "התאם את כל התגים (וגם)"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"toggle": "החלף ערכת נושא",
|
"toggle": "החלף ערכת נושא",
|
||||||
@@ -690,16 +692,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "מודלי Embedding"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "שורש",
|
"modelRoot": "שורש",
|
||||||
"collapseAll": "כווץ את כל התיקיות",
|
"collapseAll": "כווץ את כל התיקיות",
|
||||||
@@ -1116,10 +1108,6 @@
|
|||||||
"title": "מאתחל סטטיסטיקה",
|
"title": "מאתחל סטטיסטיקה",
|
||||||
"message": "מעבד נתוני מודלים עבור סטטיסטיקה. זה עשוי לקחת מספר דקות..."
|
"message": "מעבד נתוני מודלים עבור סטטיסטיקה. זה עשוי לקחת מספר דקות..."
|
||||||
},
|
},
|
||||||
"misc": {
|
|
||||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
|
||||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
|
||||||
},
|
|
||||||
"tips": {
|
"tips": {
|
||||||
"title": "טיפים וטריקים",
|
"title": "טיפים וטריקים",
|
||||||
"civitai": {
|
"civitai": {
|
||||||
@@ -1179,18 +1167,12 @@
|
|||||||
"recipeAdded": "מתכון נוסף ל-workflow",
|
"recipeAdded": "מתכון נוסף ל-workflow",
|
||||||
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
||||||
"recipeFailedToSend": "שליחת מתכון ל-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 הנוכחי",
|
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
||||||
"noTargetNodeSelected": "לא נבחר צומת יעד"
|
"noTargetNodeSelected": "לא נבחר צומת יעד"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "מתכון",
|
"recipe": "מתכון",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"vae": "[TODO: Translate] VAE",
|
|
||||||
"upscaler": "[TODO: Translate] Upscaler",
|
|
||||||
"replace": "החלף",
|
"replace": "החלף",
|
||||||
"append": "הוסף",
|
"append": "הוסף",
|
||||||
"selectTargetNode": "בחר צומת יעד",
|
"selectTargetNode": "בחר צומת יעד",
|
||||||
@@ -1594,6 +1576,20 @@
|
|||||||
"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.",
|
"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",
|
"supportCta": "Support on Ko-fi",
|
||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
|
},
|
||||||
|
"cacheHealth": {
|
||||||
|
"corrupted": {
|
||||||
|
"title": "זוהתה שחיתות במטמון"
|
||||||
|
},
|
||||||
|
"degraded": {
|
||||||
|
"title": "זוהו בעיות במטמון"
|
||||||
|
},
|
||||||
|
"content": "{invalid} מתוך {total} רשומות מטמון אינן תקינות ({rate}). זה עלול לגרום לדגמים חסרים או לשגיאות. מומלץ לבנות מחדש את המטמון.",
|
||||||
|
"rebuildCache": "בניית מטמון מחדש",
|
||||||
|
"dismiss": "ביטול",
|
||||||
|
"rebuilding": "בונה מחדש את המטמון...",
|
||||||
|
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
|
||||||
|
"retry": "נסה שוב"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,6 @@
|
|||||||
"recipes": "レシピ",
|
"recipes": "レシピ",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
"misc": "[TODO: Translate] Misc",
|
|
||||||
"statistics": "統計"
|
"statistics": "統計"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -188,8 +187,7 @@
|
|||||||
"loras": "LoRAを検索...",
|
"loras": "LoRAを検索...",
|
||||||
"recipes": "レシピを検索...",
|
"recipes": "レシピを検索...",
|
||||||
"checkpoints": "checkpointを検索...",
|
"checkpoints": "checkpointを検索...",
|
||||||
"embeddings": "embeddingを検索...",
|
"embeddings": "embeddingを検索..."
|
||||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
|
||||||
},
|
},
|
||||||
"options": "検索オプション",
|
"options": "検索オプション",
|
||||||
"searchIn": "検索対象:",
|
"searchIn": "検索対象:",
|
||||||
@@ -225,7 +223,11 @@
|
|||||||
"noCreditRequired": "クレジット不要",
|
"noCreditRequired": "クレジット不要",
|
||||||
"allowSellingGeneratedContent": "販売許可",
|
"allowSellingGeneratedContent": "販売許可",
|
||||||
"noTags": "タグなし",
|
"noTags": "タグなし",
|
||||||
"clearAll": "すべてのフィルタをクリア"
|
"clearAll": "すべてのフィルタをクリア",
|
||||||
|
"any": "いずれか",
|
||||||
|
"all": "すべて",
|
||||||
|
"tagLogicAny": "いずれかのタグに一致 (OR)",
|
||||||
|
"tagLogicAll": "すべてのタグに一致 (AND)"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"toggle": "テーマの切り替え",
|
"toggle": "テーマの切り替え",
|
||||||
@@ -690,16 +692,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embeddingモデル"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "ルート",
|
"modelRoot": "ルート",
|
||||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||||
@@ -1116,10 +1108,6 @@
|
|||||||
"title": "統計を初期化中",
|
"title": "統計を初期化中",
|
||||||
"message": "統計用のモデルデータを処理中。数分かかる場合があります..."
|
"message": "統計用のモデルデータを処理中。数分かかる場合があります..."
|
||||||
},
|
},
|
||||||
"misc": {
|
|
||||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
|
||||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
|
||||||
},
|
|
||||||
"tips": {
|
"tips": {
|
||||||
"title": "ヒント&コツ",
|
"title": "ヒント&コツ",
|
||||||
"civitai": {
|
"civitai": {
|
||||||
@@ -1179,18 +1167,12 @@
|
|||||||
"recipeAdded": "レシピがワークフローに追加されました",
|
"recipeAdded": "レシピがワークフローに追加されました",
|
||||||
"recipeReplaced": "レシピがワークフローで置換されました",
|
"recipeReplaced": "レシピがワークフローで置換されました",
|
||||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
"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": "現在のワークフローには互換性のあるノードがありません",
|
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||||
"noTargetNodeSelected": "ターゲットノードが選択されていません"
|
"noTargetNodeSelected": "ターゲットノードが選択されていません"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "レシピ",
|
"recipe": "レシピ",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"vae": "[TODO: Translate] VAE",
|
|
||||||
"upscaler": "[TODO: Translate] Upscaler",
|
|
||||||
"replace": "置換",
|
"replace": "置換",
|
||||||
"append": "追加",
|
"append": "追加",
|
||||||
"selectTargetNode": "ターゲットノードを選択",
|
"selectTargetNode": "ターゲットノードを選択",
|
||||||
@@ -1594,6 +1576,20 @@
|
|||||||
"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.",
|
"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",
|
"supportCta": "Support on Ko-fi",
|
||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
|
},
|
||||||
|
"cacheHealth": {
|
||||||
|
"corrupted": {
|
||||||
|
"title": "キャッシュの破損が検出されました"
|
||||||
|
},
|
||||||
|
"degraded": {
|
||||||
|
"title": "キャッシュの問題が検出されました"
|
||||||
|
},
|
||||||
|
"content": "{total}個のキャッシュエントリのうち{invalid}個が無効です({rate})。モデルが見つからない原因になったり、エラーが発生する可能性があります。キャッシュの再構築を推奨します。",
|
||||||
|
"rebuildCache": "キャッシュを再構築",
|
||||||
|
"dismiss": "閉じる",
|
||||||
|
"rebuilding": "キャッシュを再構築中...",
|
||||||
|
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
|
||||||
|
"retry": "再試行"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,6 @@
|
|||||||
"recipes": "레시피",
|
"recipes": "레시피",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
"misc": "[TODO: Translate] Misc",
|
|
||||||
"statistics": "통계"
|
"statistics": "통계"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -188,8 +187,7 @@
|
|||||||
"loras": "LoRA 검색...",
|
"loras": "LoRA 검색...",
|
||||||
"recipes": "레시피 검색...",
|
"recipes": "레시피 검색...",
|
||||||
"checkpoints": "Checkpoint 검색...",
|
"checkpoints": "Checkpoint 검색...",
|
||||||
"embeddings": "Embedding 검색...",
|
"embeddings": "Embedding 검색..."
|
||||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
|
||||||
},
|
},
|
||||||
"options": "검색 옵션",
|
"options": "검색 옵션",
|
||||||
"searchIn": "검색 범위:",
|
"searchIn": "검색 범위:",
|
||||||
@@ -225,7 +223,11 @@
|
|||||||
"noCreditRequired": "크레딧 표기 없음",
|
"noCreditRequired": "크레딧 표기 없음",
|
||||||
"allowSellingGeneratedContent": "판매 허용",
|
"allowSellingGeneratedContent": "판매 허용",
|
||||||
"noTags": "태그 없음",
|
"noTags": "태그 없음",
|
||||||
"clearAll": "모든 필터 지우기"
|
"clearAll": "모든 필터 지우기",
|
||||||
|
"any": "아무",
|
||||||
|
"all": "모두",
|
||||||
|
"tagLogicAny": "모든 태그 일치 (OR)",
|
||||||
|
"tagLogicAll": "모든 태그 일치 (AND)"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"toggle": "테마 토글",
|
"toggle": "테마 토글",
|
||||||
@@ -690,16 +692,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 모델"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "루트",
|
"modelRoot": "루트",
|
||||||
"collapseAll": "모든 폴더 접기",
|
"collapseAll": "모든 폴더 접기",
|
||||||
@@ -1116,10 +1108,6 @@
|
|||||||
"title": "통계 초기화 중",
|
"title": "통계 초기화 중",
|
||||||
"message": "통계를 위한 모델 데이터를 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
"message": "통계를 위한 모델 데이터를 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||||
},
|
},
|
||||||
"misc": {
|
|
||||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
|
||||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
|
||||||
},
|
|
||||||
"tips": {
|
"tips": {
|
||||||
"title": "팁 & 요령",
|
"title": "팁 & 요령",
|
||||||
"civitai": {
|
"civitai": {
|
||||||
@@ -1179,18 +1167,12 @@
|
|||||||
"recipeAdded": "레시피가 워크플로에 추가되었습니다",
|
"recipeAdded": "레시피가 워크플로에 추가되었습니다",
|
||||||
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
||||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
"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": "현재 워크플로에서 호환되는 노드가 없습니다",
|
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다"
|
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "레시피",
|
"recipe": "레시피",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"vae": "[TODO: Translate] VAE",
|
|
||||||
"upscaler": "[TODO: Translate] Upscaler",
|
|
||||||
"replace": "교체",
|
"replace": "교체",
|
||||||
"append": "추가",
|
"append": "추가",
|
||||||
"selectTargetNode": "대상 노드 선택",
|
"selectTargetNode": "대상 노드 선택",
|
||||||
@@ -1594,6 +1576,20 @@
|
|||||||
"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.",
|
"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",
|
"supportCta": "Support on Ko-fi",
|
||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
|
},
|
||||||
|
"cacheHealth": {
|
||||||
|
"corrupted": {
|
||||||
|
"title": "캐시 손상이 감지되었습니다"
|
||||||
|
},
|
||||||
|
"degraded": {
|
||||||
|
"title": "캐시 문제가 감지되었습니다"
|
||||||
|
},
|
||||||
|
"content": "{total}개의 캐시 항목 중 {invalid}개가 유효하지 않습니다 ({rate}). 모델 누락이나 오류가 발생할 수 있습니다. 캐시를 재구축하는 것이 좋습니다.",
|
||||||
|
"rebuildCache": "캐시 재구축",
|
||||||
|
"dismiss": "무시",
|
||||||
|
"rebuilding": "캐시 재구축 중...",
|
||||||
|
"rebuildFailed": "캐시 재구축 실패: {error}",
|
||||||
|
"retry": "다시 시도"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,6 @@
|
|||||||
"recipes": "Рецепты",
|
"recipes": "Рецепты",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
"misc": "[TODO: Translate] Misc",
|
|
||||||
"statistics": "Статистика"
|
"statistics": "Статистика"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -188,8 +187,7 @@
|
|||||||
"loras": "Поиск LoRAs...",
|
"loras": "Поиск LoRAs...",
|
||||||
"recipes": "Поиск рецептов...",
|
"recipes": "Поиск рецептов...",
|
||||||
"checkpoints": "Поиск checkpoints...",
|
"checkpoints": "Поиск checkpoints...",
|
||||||
"embeddings": "Поиск embeddings...",
|
"embeddings": "Поиск embeddings..."
|
||||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
|
||||||
},
|
},
|
||||||
"options": "Опции поиска",
|
"options": "Опции поиска",
|
||||||
"searchIn": "Искать в:",
|
"searchIn": "Искать в:",
|
||||||
@@ -225,7 +223,11 @@
|
|||||||
"noCreditRequired": "Без указания авторства",
|
"noCreditRequired": "Без указания авторства",
|
||||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||||
"noTags": "Без тегов",
|
"noTags": "Без тегов",
|
||||||
"clearAll": "Очистить все фильтры"
|
"clearAll": "Очистить все фильтры",
|
||||||
|
"any": "Любой",
|
||||||
|
"all": "Все",
|
||||||
|
"tagLogicAny": "Совпадение с любым тегом (ИЛИ)",
|
||||||
|
"tagLogicAll": "Совпадение со всеми тегами (И)"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"toggle": "Переключить тему",
|
"toggle": "Переключить тему",
|
||||||
@@ -690,16 +692,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Модели Embedding"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "Корень",
|
"modelRoot": "Корень",
|
||||||
"collapseAll": "Свернуть все папки",
|
"collapseAll": "Свернуть все папки",
|
||||||
@@ -1116,10 +1108,6 @@
|
|||||||
"title": "Инициализация статистики",
|
"title": "Инициализация статистики",
|
||||||
"message": "Обработка данных моделей для статистики. Это может занять несколько минут..."
|
"message": "Обработка данных моделей для статистики. Это может занять несколько минут..."
|
||||||
},
|
},
|
||||||
"misc": {
|
|
||||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
|
||||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
|
||||||
},
|
|
||||||
"tips": {
|
"tips": {
|
||||||
"title": "Советы и хитрости",
|
"title": "Советы и хитрости",
|
||||||
"civitai": {
|
"civitai": {
|
||||||
@@ -1179,18 +1167,12 @@
|
|||||||
"recipeAdded": "Рецепт добавлен в workflow",
|
"recipeAdded": "Рецепт добавлен в workflow",
|
||||||
"recipeReplaced": "Рецепт заменён в workflow",
|
"recipeReplaced": "Рецепт заменён в workflow",
|
||||||
"recipeFailedToSend": "Не удалось отправить рецепт в 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 нет совместимых узлов",
|
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||||
"noTargetNodeSelected": "Целевой узел не выбран"
|
"noTargetNodeSelected": "Целевой узел не выбран"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Рецепт",
|
"recipe": "Рецепт",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"vae": "[TODO: Translate] VAE",
|
|
||||||
"upscaler": "[TODO: Translate] Upscaler",
|
|
||||||
"replace": "Заменить",
|
"replace": "Заменить",
|
||||||
"append": "Добавить",
|
"append": "Добавить",
|
||||||
"selectTargetNode": "Выберите целевой узел",
|
"selectTargetNode": "Выберите целевой узел",
|
||||||
@@ -1594,6 +1576,20 @@
|
|||||||
"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.",
|
"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",
|
"supportCta": "Support on Ko-fi",
|
||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
|
},
|
||||||
|
"cacheHealth": {
|
||||||
|
"corrupted": {
|
||||||
|
"title": "Обнаружено повреждение кэша"
|
||||||
|
},
|
||||||
|
"degraded": {
|
||||||
|
"title": "Обнаружены проблемы с кэшем"
|
||||||
|
},
|
||||||
|
"content": "{invalid} из {total} записей кэша недействительны ({rate}). Это может привести к отсутствию моделей или ошибкам. Рекомендуется перестроить кэш.",
|
||||||
|
"rebuildCache": "Перестроить кэш",
|
||||||
|
"dismiss": "Отклонить",
|
||||||
|
"rebuilding": "Перестроение кэша...",
|
||||||
|
"rebuildFailed": "Не удалось перестроить кэш: {error}",
|
||||||
|
"retry": "Повторить"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,6 @@
|
|||||||
"recipes": "配方",
|
"recipes": "配方",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
"misc": "[TODO: Translate] Misc",
|
|
||||||
"statistics": "统计"
|
"statistics": "统计"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -188,8 +187,7 @@
|
|||||||
"loras": "搜索 LoRA...",
|
"loras": "搜索 LoRA...",
|
||||||
"recipes": "搜索配方...",
|
"recipes": "搜索配方...",
|
||||||
"checkpoints": "搜索 Checkpoint...",
|
"checkpoints": "搜索 Checkpoint...",
|
||||||
"embeddings": "搜索 Embedding...",
|
"embeddings": "搜索 Embedding..."
|
||||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
|
||||||
},
|
},
|
||||||
"options": "搜索选项",
|
"options": "搜索选项",
|
||||||
"searchIn": "搜索范围:",
|
"searchIn": "搜索范围:",
|
||||||
@@ -225,7 +223,11 @@
|
|||||||
"noCreditRequired": "无需署名",
|
"noCreditRequired": "无需署名",
|
||||||
"allowSellingGeneratedContent": "允许销售",
|
"allowSellingGeneratedContent": "允许销售",
|
||||||
"noTags": "无标签",
|
"noTags": "无标签",
|
||||||
"clearAll": "清除所有筛选"
|
"clearAll": "清除所有筛选",
|
||||||
|
"any": "任一",
|
||||||
|
"all": "全部",
|
||||||
|
"tagLogicAny": "匹配任一标签 (或)",
|
||||||
|
"tagLogicAll": "匹配所有标签 (与)"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"toggle": "切换主题",
|
"toggle": "切换主题",
|
||||||
@@ -690,16 +692,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 模型"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "根目录",
|
"modelRoot": "根目录",
|
||||||
"collapseAll": "折叠所有文件夹",
|
"collapseAll": "折叠所有文件夹",
|
||||||
@@ -1116,10 +1108,6 @@
|
|||||||
"title": "初始化统计",
|
"title": "初始化统计",
|
||||||
"message": "正在处理模型数据以生成统计信息。这可能需要几分钟..."
|
"message": "正在处理模型数据以生成统计信息。这可能需要几分钟..."
|
||||||
},
|
},
|
||||||
"misc": {
|
|
||||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
|
||||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
|
||||||
},
|
|
||||||
"tips": {
|
"tips": {
|
||||||
"title": "技巧与提示",
|
"title": "技巧与提示",
|
||||||
"civitai": {
|
"civitai": {
|
||||||
@@ -1179,18 +1167,12 @@
|
|||||||
"recipeAdded": "配方已追加到工作流",
|
"recipeAdded": "配方已追加到工作流",
|
||||||
"recipeReplaced": "配方已替换到工作流",
|
"recipeReplaced": "配方已替换到工作流",
|
||||||
"recipeFailedToSend": "发送配方到工作流失败",
|
"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": "当前工作流中没有兼容的节点",
|
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
||||||
"noTargetNodeSelected": "未选择目标节点"
|
"noTargetNodeSelected": "未选择目标节点"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "配方",
|
"recipe": "配方",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"vae": "[TODO: Translate] VAE",
|
|
||||||
"upscaler": "[TODO: Translate] Upscaler",
|
|
||||||
"replace": "替换",
|
"replace": "替换",
|
||||||
"append": "追加",
|
"append": "追加",
|
||||||
"selectTargetNode": "选择目标节点",
|
"selectTargetNode": "选择目标节点",
|
||||||
@@ -1594,6 +1576,20 @@
|
|||||||
"content": "来爱发电为Lora Manager项目发电,支持项目持续开发的同时,获取浏览器插件验证码,按季支付更优惠!支付宝/微信方便支付。感谢支持!🚀",
|
"content": "来爱发电为Lora Manager项目发电,支持项目持续开发的同时,获取浏览器插件验证码,按季支付更优惠!支付宝/微信方便支付。感谢支持!🚀",
|
||||||
"supportCta": "为LM发电",
|
"supportCta": "为LM发电",
|
||||||
"learnMore": "浏览器插件教程"
|
"learnMore": "浏览器插件教程"
|
||||||
|
},
|
||||||
|
"cacheHealth": {
|
||||||
|
"corrupted": {
|
||||||
|
"title": "检测到缓存损坏"
|
||||||
|
},
|
||||||
|
"degraded": {
|
||||||
|
"title": "检测到缓存问题"
|
||||||
|
},
|
||||||
|
"content": "{total} 个缓存条目中有 {invalid} 个无效({rate})。这可能导致模型丢失或错误。建议重建缓存。",
|
||||||
|
"rebuildCache": "重建缓存",
|
||||||
|
"dismiss": "忽略",
|
||||||
|
"rebuilding": "正在重建缓存...",
|
||||||
|
"rebuildFailed": "重建缓存失败:{error}",
|
||||||
|
"retry": "重试"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,6 @@
|
|||||||
"recipes": "配方",
|
"recipes": "配方",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
"misc": "[TODO: Translate] Misc",
|
|
||||||
"statistics": "統計"
|
"statistics": "統計"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -188,8 +187,7 @@
|
|||||||
"loras": "搜尋 LoRA...",
|
"loras": "搜尋 LoRA...",
|
||||||
"recipes": "搜尋配方...",
|
"recipes": "搜尋配方...",
|
||||||
"checkpoints": "搜尋 checkpoint...",
|
"checkpoints": "搜尋 checkpoint...",
|
||||||
"embeddings": "搜尋 embedding...",
|
"embeddings": "搜尋 embedding..."
|
||||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
|
||||||
},
|
},
|
||||||
"options": "搜尋選項",
|
"options": "搜尋選項",
|
||||||
"searchIn": "搜尋範圍:",
|
"searchIn": "搜尋範圍:",
|
||||||
@@ -225,7 +223,11 @@
|
|||||||
"noCreditRequired": "無需署名",
|
"noCreditRequired": "無需署名",
|
||||||
"allowSellingGeneratedContent": "允許銷售",
|
"allowSellingGeneratedContent": "允許銷售",
|
||||||
"noTags": "無標籤",
|
"noTags": "無標籤",
|
||||||
"clearAll": "清除所有篩選"
|
"clearAll": "清除所有篩選",
|
||||||
|
"any": "任一",
|
||||||
|
"all": "全部",
|
||||||
|
"tagLogicAny": "符合任一票籤 (或)",
|
||||||
|
"tagLogicAll": "符合所有標籤 (與)"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"toggle": "切換主題",
|
"toggle": "切換主題",
|
||||||
@@ -690,16 +692,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 模型"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "根目錄",
|
"modelRoot": "根目錄",
|
||||||
"collapseAll": "全部摺疊資料夾",
|
"collapseAll": "全部摺疊資料夾",
|
||||||
@@ -1116,10 +1108,6 @@
|
|||||||
"title": "初始化統計",
|
"title": "初始化統計",
|
||||||
"message": "正在處理模型資料以產生統計,可能需要幾分鐘..."
|
"message": "正在處理模型資料以產生統計,可能需要幾分鐘..."
|
||||||
},
|
},
|
||||||
"misc": {
|
|
||||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
|
||||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
|
||||||
},
|
|
||||||
"tips": {
|
"tips": {
|
||||||
"title": "小技巧",
|
"title": "小技巧",
|
||||||
"civitai": {
|
"civitai": {
|
||||||
@@ -1179,18 +1167,12 @@
|
|||||||
"recipeAdded": "配方已附加到工作流",
|
"recipeAdded": "配方已附加到工作流",
|
||||||
"recipeReplaced": "配方已取代於工作流",
|
"recipeReplaced": "配方已取代於工作流",
|
||||||
"recipeFailedToSend": "傳送配方到工作流失敗",
|
"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": "目前工作流程中沒有相容的節點",
|
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
||||||
"noTargetNodeSelected": "未選擇目標節點"
|
"noTargetNodeSelected": "未選擇目標節點"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "配方",
|
"recipe": "配方",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"vae": "[TODO: Translate] VAE",
|
|
||||||
"upscaler": "[TODO: Translate] Upscaler",
|
|
||||||
"replace": "取代",
|
"replace": "取代",
|
||||||
"append": "附加",
|
"append": "附加",
|
||||||
"selectTargetNode": "選擇目標節點",
|
"selectTargetNode": "選擇目標節點",
|
||||||
@@ -1594,6 +1576,20 @@
|
|||||||
"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.",
|
"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",
|
"supportCta": "Support on Ko-fi",
|
||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
|
},
|
||||||
|
"cacheHealth": {
|
||||||
|
"corrupted": {
|
||||||
|
"title": "檢測到快取損壞"
|
||||||
|
},
|
||||||
|
"degraded": {
|
||||||
|
"title": "檢測到快取問題"
|
||||||
|
},
|
||||||
|
"content": "{total} 個快取項目中有 {invalid} 個無效({rate})。這可能會導致模型遺失或錯誤。建議重建快取。",
|
||||||
|
"rebuildCache": "重建快取",
|
||||||
|
"dismiss": "關閉",
|
||||||
|
"rebuilding": "重建快取中...",
|
||||||
|
"rebuildFailed": "重建快取失敗:{error}",
|
||||||
|
"retry": "重試"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"test": "npm run test:js && npm run test:vue",
|
||||||
|
"test:js": "vitest run",
|
||||||
|
"test:vue": "cd vue-widgets && npx vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "node scripts/run_frontend_coverage.js"
|
"test:coverage": "node scripts/run_frontend_coverage.js"
|
||||||
},
|
},
|
||||||
|
|||||||
259
py/config.py
259
py/config.py
@@ -89,11 +89,8 @@ class Config:
|
|||||||
self.checkpoints_roots = None
|
self.checkpoints_roots = None
|
||||||
self.unet_roots = None
|
self.unet_roots = None
|
||||||
self.embeddings_roots = None
|
self.embeddings_roots = None
|
||||||
self.vae_roots = None
|
|
||||||
self.upscaler_roots = None
|
|
||||||
self.base_models_roots = self._init_checkpoint_paths()
|
self.base_models_roots = self._init_checkpoint_paths()
|
||||||
self.embeddings_roots = self._init_embedding_paths()
|
self.embeddings_roots = self._init_embedding_paths()
|
||||||
self.misc_roots = self._init_misc_paths()
|
|
||||||
# Scan symbolic links during initialization
|
# Scan symbolic links during initialization
|
||||||
self._initialize_symlink_mappings()
|
self._initialize_symlink_mappings()
|
||||||
|
|
||||||
@@ -154,8 +151,6 @@ class Config:
|
|||||||
'checkpoints': list(self.checkpoints_roots or []),
|
'checkpoints': list(self.checkpoints_roots or []),
|
||||||
'unet': list(self.unet_roots or []),
|
'unet': list(self.unet_roots or []),
|
||||||
'embeddings': list(self.embeddings_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)
|
normalized_target_paths = _normalize_folder_paths_for_comparison(target_folder_paths)
|
||||||
@@ -255,7 +250,6 @@ class Config:
|
|||||||
roots.extend(self.loras_roots or [])
|
roots.extend(self.loras_roots or [])
|
||||||
roots.extend(self.base_models_roots or [])
|
roots.extend(self.base_models_roots or [])
|
||||||
roots.extend(self.embeddings_roots or [])
|
roots.extend(self.embeddings_roots or [])
|
||||||
roots.extend(self.misc_roots or [])
|
|
||||||
return roots
|
return roots
|
||||||
|
|
||||||
def _build_symlink_fingerprint(self) -> Dict[str, object]:
|
def _build_symlink_fingerprint(self) -> Dict[str, object]:
|
||||||
@@ -447,82 +441,53 @@ class Config:
|
|||||||
logger.info("Failed to write symlink cache %s: %s", cache_path, exc)
|
logger.info("Failed to write symlink cache %s: %s", cache_path, exc)
|
||||||
|
|
||||||
def _scan_symbolic_links(self):
|
def _scan_symbolic_links(self):
|
||||||
"""Scan all symbolic links in LoRA, Checkpoint, and Embedding root directories"""
|
"""Scan 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).
|
||||||
|
"""
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
|
|
||||||
# Reset mappings before rescanning to avoid stale entries
|
# Reset mappings before rescanning to avoid stale entries
|
||||||
self._path_mappings.clear()
|
self._path_mappings.clear()
|
||||||
self._seed_root_symlink_mappings()
|
self._seed_root_symlink_mappings()
|
||||||
visited_dirs: Set[str] = set()
|
|
||||||
for root in self._symlink_roots():
|
for root in self._symlink_roots():
|
||||||
self._scan_directory_links(root, visited_dirs)
|
self._scan_first_level_symlinks(root)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Symlink scan finished in %.2f ms with %d mappings",
|
"Symlink scan finished in %.2f ms with %d mappings",
|
||||||
(time.perf_counter() - start) * 1000,
|
(time.perf_counter() - start) * 1000,
|
||||||
len(self._path_mappings),
|
len(self._path_mappings),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _scan_directory_links(self, root: str, visited_dirs: Set[str]):
|
def _scan_first_level_symlinks(self, root: str):
|
||||||
"""Iteratively scan directory symlinks to avoid deep recursion."""
|
"""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.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Note: We only use realpath for the initial root if it's not already resolved
|
with os.scandir(root) as it:
|
||||||
# to ensure we have a valid entry point.
|
for entry in it:
|
||||||
root_real = self._normalize_path(os.path.realpath(root))
|
try:
|
||||||
except OSError:
|
# Only detect symlinks including Windows junctions
|
||||||
root_real = self._normalize_path(root)
|
# Skip normal directories to avoid deep traversal
|
||||||
|
if not self._entry_is_symlink(entry):
|
||||||
|
continue
|
||||||
|
|
||||||
if root_real in visited_dirs:
|
# Resolve the symlink target
|
||||||
return
|
target_path = os.path.realpath(entry.path)
|
||||||
|
if not os.path.isdir(target_path):
|
||||||
|
continue
|
||||||
|
|
||||||
visited_dirs.add(root_real)
|
self.add_path_mapping(entry.path, target_path)
|
||||||
# Stack entries: (display_path, real_resolved_path)
|
except Exception as inner_exc:
|
||||||
stack: List[Tuple[str, str]] = [(root, root_real)]
|
logger.debug(
|
||||||
|
"Error processing directory entry %s: %s", entry.path, inner_exc
|
||||||
while stack:
|
)
|
||||||
current_display, current_real = stack.pop()
|
except Exception as e:
|
||||||
try:
|
logger.error(f"Error scanning links in {root}: {e}")
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -605,8 +570,6 @@ class Config:
|
|||||||
preview_roots.update(self._expand_preview_root(root))
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
for root in self.embeddings_roots or []:
|
for root in self.embeddings_roots or []:
|
||||||
preview_roots.update(self._expand_preview_root(root))
|
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():
|
for target, link in self._path_mappings.items():
|
||||||
preview_roots.update(self._expand_preview_root(target))
|
preview_roots.update(self._expand_preview_root(target))
|
||||||
@@ -614,12 +577,11 @@ class Config:
|
|||||||
|
|
||||||
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()}
|
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()}
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Preview roots rebuilt: %d paths from %d lora roots, %d checkpoint roots, %d embedding roots, %d misc roots, %d symlink mappings",
|
"Preview roots rebuilt: %d paths from %d lora roots, %d checkpoint roots, %d embedding roots, %d symlink mappings",
|
||||||
len(self._preview_root_paths),
|
len(self._preview_root_paths),
|
||||||
len(self.loras_roots or []),
|
len(self.loras_roots or []),
|
||||||
len(self.base_models_roots or []),
|
len(self.base_models_roots or []),
|
||||||
len(self.embeddings_roots or []),
|
len(self.embeddings_roots or []),
|
||||||
len(self.misc_roots or []),
|
|
||||||
len(self._path_mappings),
|
len(self._path_mappings),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -683,6 +645,23 @@ class Config:
|
|||||||
checkpoint_map = self._dedupe_existing_paths(checkpoint_paths)
|
checkpoint_map = self._dedupe_existing_paths(checkpoint_paths)
|
||||||
unet_map = self._dedupe_existing_paths(unet_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] = {}
|
merged_map: Dict[str, str] = {}
|
||||||
for real_path, original in {**checkpoint_map, **unet_map}.items():
|
for real_path, original in {**checkpoint_map, **unet_map}.items():
|
||||||
if real_path not in merged_map:
|
if real_path not in merged_map:
|
||||||
@@ -778,49 +757,6 @@ class Config:
|
|||||||
logger.warning(f"Error initializing embedding paths: {e}")
|
logger.warning(f"Error initializing embedding paths: {e}")
|
||||||
return []
|
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:
|
def get_preview_static_url(self, preview_path: str) -> str:
|
||||||
if not preview_path:
|
if not preview_path:
|
||||||
return ""
|
return ""
|
||||||
@@ -830,7 +766,23 @@ class Config:
|
|||||||
return f'/api/lm/previews?path={encoded_path}'
|
return f'/api/lm/previews?path={encoded_path}'
|
||||||
|
|
||||||
def is_preview_path_allowed(self, preview_path: str) -> bool:
|
def is_preview_path_allowed(self, preview_path: str) -> bool:
|
||||||
"""Return ``True`` if ``preview_path`` is within an allowed directory."""
|
"""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."""
|
||||||
|
|
||||||
if not preview_path:
|
if not preview_path:
|
||||||
return False
|
return False
|
||||||
@@ -840,29 +792,72 @@ class Config:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Use os.path.normcase for case-insensitive comparison on Windows.
|
|
||||||
# On Windows, Path.relative_to() is case-sensitive for drive letters,
|
|
||||||
# causing paths like 'a:/folder' to not match 'A:/folder'.
|
|
||||||
candidate_str = os.path.normcase(str(candidate))
|
candidate_str = os.path.normcase(str(candidate))
|
||||||
for root in self._preview_root_paths:
|
for root in self._preview_root_paths:
|
||||||
root_str = os.path.normcase(str(root))
|
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):
|
if candidate_str == root_str or candidate_str.startswith(root_str + os.sep):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if self._preview_root_paths:
|
logger.debug(
|
||||||
logger.debug(
|
"Path not in allowed roots: %s (candidate=%s, num_roots=%d)",
|
||||||
"Preview path rejected: %s (candidate=%s, num_roots=%d, first_root=%s)",
|
preview_path,
|
||||||
preview_path,
|
candidate_str,
|
||||||
candidate_str,
|
len(self._preview_root_paths),
|
||||||
len(self._preview_root_paths),
|
)
|
||||||
os.path.normcase(str(next(iter(self._preview_root_paths)))),
|
|
||||||
)
|
return False
|
||||||
else:
|
|
||||||
logger.debug(
|
def _try_discover_deep_symlink(self, preview_path: str) -> bool:
|
||||||
"Preview path rejected (no roots configured): %s",
|
"""Attempt to discover a deep symlink that contains the preview_path.
|
||||||
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
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -184,7 +184,6 @@ class LoraManager:
|
|||||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
misc_scanner = await ServiceRegistry.get_misc_scanner()
|
|
||||||
|
|
||||||
# Initialize recipe scanner if needed
|
# Initialize recipe scanner if needed
|
||||||
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||||
@@ -194,7 +193,6 @@ class LoraManager:
|
|||||||
asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init'),
|
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(checkpoint_scanner.initialize_in_background(), name='checkpoint_cache_init'),
|
||||||
asyncio.create_task(embedding_scanner.initialize_in_background(), name='embedding_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')
|
asyncio.create_task(recipe_scanner.initialize_in_background(), name='recipe_cache_init')
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -256,7 +254,6 @@ class LoraManager:
|
|||||||
all_roots.update(config.loras_roots)
|
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.embeddings_roots)
|
||||||
all_roots.update(config.misc_roots or [])
|
|
||||||
|
|
||||||
total_deleted = 0
|
total_deleted = 0
|
||||||
total_size_freed = 0
|
total_size_freed = 0
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Check if running in standalone mode
|
# 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"
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||||
@@ -14,7 +17,7 @@ if not standalone_mode:
|
|||||||
# Initialize registry
|
# Initialize registry
|
||||||
registry = MetadataRegistry()
|
registry = MetadataRegistry()
|
||||||
|
|
||||||
print("ComfyUI Metadata Collector initialized")
|
logger.info("ComfyUI Metadata Collector initialized")
|
||||||
|
|
||||||
def get_metadata(prompt_id=None):
|
def get_metadata(prompt_id=None):
|
||||||
"""Helper function to get metadata from the registry"""
|
"""Helper function to get metadata from the registry"""
|
||||||
@@ -23,7 +26,7 @@ if not standalone_mode:
|
|||||||
else:
|
else:
|
||||||
# Standalone mode - provide dummy implementations
|
# Standalone mode - provide dummy implementations
|
||||||
def init():
|
def init():
|
||||||
print("ComfyUI Metadata Collector disabled in standalone mode")
|
logger.info("ComfyUI Metadata Collector disabled in standalone mode")
|
||||||
|
|
||||||
def get_metadata(prompt_id=None):
|
def get_metadata(prompt_id=None):
|
||||||
"""Dummy implementation for standalone mode"""
|
"""Dummy implementation for standalone mode"""
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import sys
|
import sys
|
||||||
import inspect
|
import inspect
|
||||||
|
import logging
|
||||||
from .metadata_registry import MetadataRegistry
|
from .metadata_registry import MetadataRegistry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class MetadataHook:
|
class MetadataHook:
|
||||||
"""Install hooks for metadata collection"""
|
"""Install hooks for metadata collection"""
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ class MetadataHook:
|
|||||||
|
|
||||||
# If we can't find the execution module, we can't install hooks
|
# If we can't find the execution module, we can't install hooks
|
||||||
if execution is None:
|
if execution is None:
|
||||||
print("Could not locate ComfyUI execution module, metadata collection disabled")
|
logger.warning("Could not locate ComfyUI execution module, metadata collection disabled")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Detect whether we're using the new async version of ComfyUI
|
# Detect whether we're using the new async version of ComfyUI
|
||||||
@@ -37,16 +40,16 @@ class MetadataHook:
|
|||||||
is_async = inspect.iscoroutinefunction(execution._map_node_over_list)
|
is_async = inspect.iscoroutinefunction(execution._map_node_over_list)
|
||||||
|
|
||||||
if is_async:
|
if is_async:
|
||||||
print("Detected async ComfyUI execution, installing async metadata hooks")
|
logger.info("Detected async ComfyUI execution, installing async metadata hooks")
|
||||||
MetadataHook._install_async_hooks(execution, map_node_func_name)
|
MetadataHook._install_async_hooks(execution, map_node_func_name)
|
||||||
else:
|
else:
|
||||||
print("Detected sync ComfyUI execution, installing sync metadata hooks")
|
logger.info("Detected sync ComfyUI execution, installing sync metadata hooks")
|
||||||
MetadataHook._install_sync_hooks(execution)
|
MetadataHook._install_sync_hooks(execution)
|
||||||
|
|
||||||
print("Metadata collection hooks installed for runtime values")
|
logger.info("Metadata collection hooks installed for runtime values")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error installing metadata hooks: {str(e)}")
|
logger.error(f"Error installing metadata hooks: {str(e)}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _install_sync_hooks(execution):
|
def _install_sync_hooks(execution):
|
||||||
@@ -82,7 +85,7 @@ class MetadataHook:
|
|||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error collecting metadata (pre-execution): {str(e)}")
|
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
||||||
|
|
||||||
# Execute the original function
|
# Execute the original function
|
||||||
results = original_map_node_over_list(obj, input_data_all, func, allow_interrupt, execution_block_cb, pre_execute_cb)
|
results = original_map_node_over_list(obj, input_data_all, func, allow_interrupt, execution_block_cb, pre_execute_cb)
|
||||||
@@ -113,7 +116,7 @@ class MetadataHook:
|
|||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.update_node_execution(node_id, class_type, results)
|
registry.update_node_execution(node_id, class_type, results)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error collecting metadata (post-execution): {str(e)}")
|
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -159,7 +162,7 @@ class MetadataHook:
|
|||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error collecting metadata (pre-execution): {str(e)}")
|
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
||||||
|
|
||||||
# Call original function with all args/kwargs
|
# Call original function with all args/kwargs
|
||||||
results = await original_map_node_over_list(
|
results = await original_map_node_over_list(
|
||||||
@@ -176,7 +179,7 @@ class MetadataHook:
|
|||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.update_node_execution(node_id, class_type, results)
|
registry.update_node_execution(node_id, class_type, results)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error collecting metadata (post-execution): {str(e)}")
|
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|||||||
@@ -126,9 +126,7 @@ class LoraCyclerLM:
|
|||||||
"current_index": [clamped_index],
|
"current_index": [clamped_index],
|
||||||
"next_index": [next_index],
|
"next_index": [next_index],
|
||||||
"total_count": [total_count],
|
"total_count": [total_count],
|
||||||
"current_lora_name": [
|
"current_lora_name": [current_lora["file_name"]],
|
||||||
current_lora.get("model_name", current_lora["file_name"])
|
|
||||||
],
|
|
||||||
"current_lora_filename": [current_lora["file_name"]],
|
"current_lora_filename": [current_lora["file_name"]],
|
||||||
"next_lora_name": [next_display_name],
|
"next_lora_name": [next_display_name],
|
||||||
"next_lora_filename": [next_lora["file_name"]],
|
"next_lora_filename": [next_lora["file_name"]],
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ from ..metadata_collector.metadata_processor import MetadataProcessor
|
|||||||
from ..metadata_collector import get_metadata
|
from ..metadata_collector import get_metadata
|
||||||
from PIL import Image, PngImagePlugin
|
from PIL import Image, PngImagePlugin
|
||||||
import piexif
|
import piexif
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class SaveImageLM:
|
class SaveImageLM:
|
||||||
NAME = "Save Image (LoraManager)"
|
NAME = "Save Image (LoraManager)"
|
||||||
@@ -385,7 +388,7 @@ class SaveImageLM:
|
|||||||
exif_bytes = piexif.dump(exif_dict)
|
exif_bytes = piexif.dump(exif_dict)
|
||||||
save_kwargs["exif"] = exif_bytes
|
save_kwargs["exif"] = exif_bytes
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error adding EXIF data: {e}")
|
logger.error(f"Error adding EXIF data: {e}")
|
||||||
img.save(file_path, format="JPEG", **save_kwargs)
|
img.save(file_path, format="JPEG", **save_kwargs)
|
||||||
elif file_format == "webp":
|
elif file_format == "webp":
|
||||||
try:
|
try:
|
||||||
@@ -403,7 +406,7 @@ class SaveImageLM:
|
|||||||
exif_bytes = piexif.dump(exif_dict)
|
exif_bytes = piexif.dump(exif_dict)
|
||||||
save_kwargs["exif"] = exif_bytes
|
save_kwargs["exif"] = exif_bytes
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error adding EXIF data: {e}")
|
logger.error(f"Error adding EXIF data: {e}")
|
||||||
|
|
||||||
img.save(file_path, format="WEBP", **save_kwargs)
|
img.save(file_path, format="WEBP", **save_kwargs)
|
||||||
|
|
||||||
@@ -414,7 +417,7 @@ class SaveImageLM:
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving image: {e}")
|
logger.error(f"Error saving image: {e}")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,22 @@ class TriggerWordToggleLM:
|
|||||||
else:
|
else:
|
||||||
return data
|
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(
|
def process_trigger_words(
|
||||||
self,
|
self,
|
||||||
id,
|
id,
|
||||||
@@ -81,7 +97,7 @@ class TriggerWordToggleLM:
|
|||||||
if (
|
if (
|
||||||
trigger_words_override
|
trigger_words_override
|
||||||
and isinstance(trigger_words_override, str)
|
and isinstance(trigger_words_override, str)
|
||||||
and trigger_words_override != trigger_words
|
and self._normalize_trigger_words(trigger_words_override) != self._normalize_trigger_words(trigger_words)
|
||||||
):
|
):
|
||||||
filtered_triggers = trigger_words_override
|
filtered_triggers = trigger_words_override
|
||||||
return (filtered_triggers,)
|
return (filtered_triggers,)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"),
|
RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"),
|
||||||
RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"),
|
RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"),
|
||||||
RouteDefinition("POST", "/api/lm/example-images/set-nsfw-level", "set_example_image_nsfw_level"),
|
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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,19 @@ class ExampleImagesDownloadHandler:
|
|||||||
except ExampleImagesDownloadError as exc:
|
except ExampleImagesDownloadError as exc:
|
||||||
return web.json_response({'success': False, 'error': str(exc)}, status=500)
|
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:
|
class ExampleImagesManagementHandler:
|
||||||
"""HTTP adapters for import/delete endpoints."""
|
"""HTTP adapters for import/delete endpoints."""
|
||||||
@@ -161,6 +174,7 @@ class ExampleImagesHandlerSet:
|
|||||||
"resume_example_images": self.download.resume_example_images,
|
"resume_example_images": self.download.resume_example_images,
|
||||||
"stop_example_images": self.download.stop_example_images,
|
"stop_example_images": self.download.stop_example_images,
|
||||||
"force_download_example_images": self.download.force_download_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,
|
"import_example_images": self.management.import_example_images,
|
||||||
"delete_example_image": self.management.delete_example_image,
|
"delete_example_image": self.management.delete_example_image,
|
||||||
"set_example_image_nsfw_level": self.management.set_example_image_nsfw_level,
|
"set_example_image_nsfw_level": self.management.set_example_image_nsfw_level,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional
|
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional
|
||||||
@@ -269,6 +270,11 @@ class ModelListingHandler:
|
|||||||
request.query.get("update_available_only", "false").lower() == "true"
|
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
|
# New license-based query filters
|
||||||
credit_required = request.query.get("credit_required")
|
credit_required = request.query.get("credit_required")
|
||||||
if credit_required is not None:
|
if credit_required is not None:
|
||||||
@@ -297,6 +303,7 @@ class ModelListingHandler:
|
|||||||
"fuzzy_search": fuzzy_search,
|
"fuzzy_search": fuzzy_search,
|
||||||
"base_models": base_models,
|
"base_models": base_models,
|
||||||
"tags": tag_filters,
|
"tags": tag_filters,
|
||||||
|
"tag_logic": tag_logic,
|
||||||
"search_options": search_options,
|
"search_options": search_options,
|
||||||
"hash_filters": hash_filters,
|
"hash_filters": hash_filters,
|
||||||
"favorites_only": favorites_only,
|
"favorites_only": favorites_only,
|
||||||
@@ -755,19 +762,22 @@ class ModelQueryHandler:
|
|||||||
|
|
||||||
async def find_duplicate_models(self, request: web.Request) -> web.Response:
|
async def find_duplicate_models(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
|
filters = self._parse_duplicate_filters(request)
|
||||||
duplicates = self._service.find_duplicate_hashes()
|
duplicates = self._service.find_duplicate_hashes()
|
||||||
result = []
|
result = []
|
||||||
cache = await self._service.scanner.get_cached_data()
|
cache = await self._service.scanner.get_cached_data()
|
||||||
|
|
||||||
for sha256, paths in duplicates.items():
|
for sha256, paths in duplicates.items():
|
||||||
group = {"hash": sha256, "models": []}
|
# Collect all models in this group
|
||||||
|
all_models = []
|
||||||
for path in paths:
|
for path in paths:
|
||||||
model = next(
|
model = next(
|
||||||
(m for m in cache.raw_data if m["file_path"] == path), None
|
(m for m in cache.raw_data if m["file_path"] == path), None
|
||||||
)
|
)
|
||||||
if model:
|
if model:
|
||||||
group["models"].append(
|
all_models.append(model)
|
||||||
await self._service.format_response(model)
|
|
||||||
)
|
# Include primary if not already in paths
|
||||||
primary_path = self._service.get_path_by_hash(sha256)
|
primary_path = self._service.get_path_by_hash(sha256)
|
||||||
if primary_path and primary_path not in paths:
|
if primary_path and primary_path not in paths:
|
||||||
primary_model = next(
|
primary_model = next(
|
||||||
@@ -775,11 +785,25 @@ class ModelQueryHandler:
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if primary_model:
|
if primary_model:
|
||||||
group["models"].insert(
|
all_models.insert(0, primary_model)
|
||||||
0, await self._service.format_response(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
|
||||||
if len(group["models"]) > 1:
|
if len(group["models"]) > 1:
|
||||||
result.append(group)
|
result.append(group)
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": True, "duplicates": result, "count": len(result)}
|
{"success": True, "duplicates": result, "count": len(result)}
|
||||||
)
|
)
|
||||||
@@ -792,6 +816,83 @@ class ModelQueryHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
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:
|
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
duplicates = self._service.find_duplicate_filenames()
|
duplicates = self._service.find_duplicate_filenames()
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class PreviewHandler:
|
|||||||
raise web.HTTPBadRequest(text="Invalid preview path encoding") from exc
|
raise web.HTTPBadRequest(text="Invalid preview path encoding") from exc
|
||||||
|
|
||||||
normalized = decoded_path.replace("\\", "/")
|
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)
|
candidate = Path(normalized)
|
||||||
try:
|
try:
|
||||||
resolved = candidate.expanduser().resolve(strict=False)
|
resolved = candidate.expanduser().resolve(strict=False)
|
||||||
@@ -40,12 +44,8 @@ class PreviewHandler:
|
|||||||
logger.debug("Failed to resolve preview path %s: %s", normalized, exc)
|
logger.debug("Failed to resolve preview path %s: %s", normalized, exc)
|
||||||
raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc
|
raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc
|
||||||
|
|
||||||
resolved_str = str(resolved)
|
|
||||||
if not self._config.is_preview_path_allowed(resolved_str):
|
|
||||||
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
|
|
||||||
|
|
||||||
if not resolved.is_file():
|
if not resolved.is_file():
|
||||||
logger.debug("Preview file not found at %s", resolved_str)
|
logger.debug("Preview file not found at %s", str(resolved))
|
||||||
raise web.HTTPNotFound(text="Preview file not found")
|
raise web.HTTPNotFound(text="Preview file not found")
|
||||||
|
|
||||||
# aiohttp's FileResponse handles range requests and content headers for us.
|
# aiohttp's FileResponse handles range requests and content headers for us.
|
||||||
|
|||||||
@@ -412,10 +412,11 @@ class RecipeQueryHandler:
|
|||||||
if recipe_scanner is None:
|
if recipe_scanner is None:
|
||||||
raise RuntimeError("Recipe scanner unavailable")
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
duplicate_groups = await recipe_scanner.find_all_duplicate_recipes()
|
fingerprint_groups = await recipe_scanner.find_all_duplicate_recipes()
|
||||||
|
url_groups = await recipe_scanner.find_duplicate_recipes_by_source()
|
||||||
response_data = []
|
response_data = []
|
||||||
|
|
||||||
for fingerprint, recipe_ids in duplicate_groups.items():
|
for fingerprint, recipe_ids in fingerprint_groups.items():
|
||||||
if len(recipe_ids) <= 1:
|
if len(recipe_ids) <= 1:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -439,12 +440,44 @@ class RecipeQueryHandler:
|
|||||||
recipes.sort(key=lambda entry: entry.get("modified", 0), reverse=True)
|
recipes.sort(key=lambda entry: entry.get("modified", 0), reverse=True)
|
||||||
response_data.append(
|
response_data.append(
|
||||||
{
|
{
|
||||||
|
"type": "fingerprint",
|
||||||
"fingerprint": fingerprint,
|
"fingerprint": fingerprint,
|
||||||
"count": len(recipes),
|
"count": len(recipes),
|
||||||
"recipes": 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)
|
response_data.sort(key=lambda entry: entry["count"], reverse=True)
|
||||||
return web.json_response({"success": True, "duplicate_groups": response_data})
|
return web.json_response({"success": True, "duplicate_groups": response_data})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -1021,7 +1054,7 @@ class RecipeManagementHandler:
|
|||||||
"exclude": False,
|
"exclude": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str]:
|
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any]:
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
downloader = await self._downloader_factory()
|
downloader = await self._downloader_factory()
|
||||||
temp_path = None
|
temp_path = None
|
||||||
@@ -1029,6 +1062,7 @@ class RecipeManagementHandler:
|
|||||||
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
||||||
temp_path = temp_file.name
|
temp_path = temp_file.name
|
||||||
download_url = image_url
|
download_url = image_url
|
||||||
|
image_info = None
|
||||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
|
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
|
||||||
if civitai_match:
|
if civitai_match:
|
||||||
if civitai_client is None:
|
if civitai_client is None:
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -81,6 +81,7 @@ class BaseModelService(ABC):
|
|||||||
update_available_only: bool = False,
|
update_available_only: bool = False,
|
||||||
credit_required: Optional[bool] = None,
|
credit_required: Optional[bool] = None,
|
||||||
allow_selling_generated_content: Optional[bool] = None,
|
allow_selling_generated_content: Optional[bool] = None,
|
||||||
|
tag_logic: str = "any",
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Get paginated and filtered model data"""
|
"""Get paginated and filtered model data"""
|
||||||
@@ -109,6 +110,7 @@ class BaseModelService(ABC):
|
|||||||
tags=tags,
|
tags=tags,
|
||||||
favorites_only=favorites_only,
|
favorites_only=favorites_only,
|
||||||
search_options=search_options,
|
search_options=search_options,
|
||||||
|
tag_logic=tag_logic,
|
||||||
)
|
)
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
@@ -241,6 +243,7 @@ class BaseModelService(ABC):
|
|||||||
tags: Optional[Dict[str, str]] = None,
|
tags: Optional[Dict[str, str]] = None,
|
||||||
favorites_only: bool = False,
|
favorites_only: bool = False,
|
||||||
search_options: dict = None,
|
search_options: dict = None,
|
||||||
|
tag_logic: str = "any",
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""Apply common filters that work across all model types"""
|
"""Apply common filters that work across all model types"""
|
||||||
normalized_options = self.search_strategy.normalize_options(search_options)
|
normalized_options = self.search_strategy.normalize_options(search_options)
|
||||||
@@ -253,6 +256,7 @@ class BaseModelService(ABC):
|
|||||||
tags=tags,
|
tags=tags,
|
||||||
favorites_only=favorites_only,
|
favorites_only=favorites_only,
|
||||||
search_options=normalized_options,
|
search_options=normalized_options,
|
||||||
|
tag_logic=tag_logic,
|
||||||
)
|
)
|
||||||
return self.filter_set.apply(data, criteria)
|
return self.filter_set.apply(data, criteria)
|
||||||
|
|
||||||
|
|||||||
259
py/services/cache_entry_validator.py
Normal file
259
py/services/cache_entry_validator.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
201
py/services/cache_health_monitor.py
Normal file
201
py/services/cache_health_monitor.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
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'
|
||||||
@@ -9,7 +9,7 @@ from collections import OrderedDict
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Dict, List, Optional, Set, Tuple
|
from typing import Dict, List, Optional, Set, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata, MiscMetadata
|
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||||
from ..utils.constants import CARD_PREVIEW_WIDTH, DIFFUSION_MODEL_BASE_MODELS, VALID_LORA_TYPES
|
from ..utils.constants import CARD_PREVIEW_WIDTH, DIFFUSION_MODEL_BASE_MODELS, VALID_LORA_TYPES
|
||||||
from ..utils.civitai_utils import rewrite_preview_url
|
from ..utils.civitai_utils import rewrite_preview_url
|
||||||
from ..utils.preview_selection import select_preview_media
|
from ..utils.preview_selection import select_preview_media
|
||||||
@@ -60,10 +60,6 @@ class DownloadManager:
|
|||||||
"""Get the checkpoint scanner from registry"""
|
"""Get the checkpoint scanner from registry"""
|
||||||
return await ServiceRegistry.get_checkpoint_scanner()
|
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(
|
async def download_from_civitai(
|
||||||
self,
|
self,
|
||||||
model_id: int = None,
|
model_id: int = None,
|
||||||
@@ -279,7 +275,6 @@ class DownloadManager:
|
|||||||
lora_scanner = await self._get_lora_scanner()
|
lora_scanner = await self._get_lora_scanner()
|
||||||
checkpoint_scanner = await self._get_checkpoint_scanner()
|
checkpoint_scanner = await self._get_checkpoint_scanner()
|
||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
misc_scanner = await self._get_misc_scanner()
|
|
||||||
|
|
||||||
# Check lora scanner first
|
# Check lora scanner first
|
||||||
if await lora_scanner.check_model_version_exists(model_version_id):
|
if await lora_scanner.check_model_version_exists(model_version_id):
|
||||||
@@ -304,13 +299,6 @@ class DownloadManager:
|
|||||||
"error": "Model version already exists in embedding library",
|
"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'
|
# Use CivArchive provider directly when source is 'civarchive'
|
||||||
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
|
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
|
||||||
if source == "civarchive":
|
if source == "civarchive":
|
||||||
@@ -349,10 +337,6 @@ class DownloadManager:
|
|||||||
model_type = "lora"
|
model_type = "lora"
|
||||||
elif model_type_from_info == "textualinversion":
|
elif model_type_from_info == "textualinversion":
|
||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
elif model_type_from_info == "vae":
|
|
||||||
model_type = "misc"
|
|
||||||
elif model_type_from_info == "upscaler":
|
|
||||||
model_type = "misc"
|
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -395,14 +379,6 @@ class DownloadManager:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"error": "Model version already exists in embedding library",
|
"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
|
# Handle use_default_paths
|
||||||
if use_default_paths:
|
if use_default_paths:
|
||||||
@@ -437,26 +413,6 @@ class DownloadManager:
|
|||||||
"error": "Default embedding root path not set in settings",
|
"error": "Default embedding root path not set in settings",
|
||||||
}
|
}
|
||||||
save_dir = default_path
|
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
|
# Calculate relative path using template
|
||||||
relative_path = self._calculate_relative_path(version_info, model_type)
|
relative_path = self._calculate_relative_path(version_info, model_type)
|
||||||
@@ -559,11 +515,6 @@ class DownloadManager:
|
|||||||
version_info, file_info, save_path
|
version_info, file_info, save_path
|
||||||
)
|
)
|
||||||
logger.info(f"Creating EmbeddingMetadata for {file_name}")
|
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
|
# 6. Start download process
|
||||||
result = await self._execute_download(
|
result = await self._execute_download(
|
||||||
@@ -669,8 +620,6 @@ class DownloadManager:
|
|||||||
scanner = await self._get_checkpoint_scanner()
|
scanner = await self._get_checkpoint_scanner()
|
||||||
elif model_type == "embedding":
|
elif model_type == "embedding":
|
||||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
elif model_type == "misc":
|
|
||||||
scanner = await self._get_misc_scanner()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
|
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
|
||||||
|
|
||||||
@@ -1067,9 +1016,6 @@ class DownloadManager:
|
|||||||
elif model_type == "embedding":
|
elif model_type == "embedding":
|
||||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
logger.info(f"Updating embedding cache for {actual_file_paths[0]}")
|
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 = (
|
adjust_cached_entry = (
|
||||||
getattr(scanner, "adjust_cached_entry", None)
|
getattr(scanner, "adjust_cached_entry", None)
|
||||||
@@ -1179,14 +1125,6 @@ class DownloadManager:
|
|||||||
".pkl",
|
".pkl",
|
||||||
".sft",
|
".sft",
|
||||||
}
|
}
|
||||||
if model_type == "misc":
|
|
||||||
return {
|
|
||||||
".ckpt",
|
|
||||||
".pt",
|
|
||||||
".bin",
|
|
||||||
".pth",
|
|
||||||
".safetensors",
|
|
||||||
}
|
|
||||||
return {".safetensors"}
|
return {".safetensors"}
|
||||||
|
|
||||||
async def _extract_model_files_from_archive(
|
async def _extract_model_files_from_archive(
|
||||||
|
|||||||
@@ -30,36 +30,36 @@ class LoraScanner(ModelScanner):
|
|||||||
|
|
||||||
async def diagnose_hash_index(self):
|
async def diagnose_hash_index(self):
|
||||||
"""Diagnostic method to verify hash index functionality"""
|
"""Diagnostic method to verify hash index functionality"""
|
||||||
print("\n\n*** DIAGNOSING LORA HASH INDEX ***\n\n", file=sys.stderr)
|
logger.debug("\n\n*** DIAGNOSING LORA HASH INDEX ***\n\n")
|
||||||
|
|
||||||
# First check if the hash index has any entries
|
# First check if the hash index has any entries
|
||||||
if hasattr(self, '_hash_index'):
|
if hasattr(self, '_hash_index'):
|
||||||
index_entries = len(self._hash_index._hash_to_path)
|
index_entries = len(self._hash_index._hash_to_path)
|
||||||
print(f"Hash index has {index_entries} entries", file=sys.stderr)
|
logger.debug(f"Hash index has {index_entries} entries")
|
||||||
|
|
||||||
# Print a few example entries if available
|
# Print a few example entries if available
|
||||||
if index_entries > 0:
|
if index_entries > 0:
|
||||||
print("\nSample hash index entries:", file=sys.stderr)
|
logger.debug("\nSample hash index entries:")
|
||||||
count = 0
|
count = 0
|
||||||
for hash_val, path in self._hash_index._hash_to_path.items():
|
for hash_val, path in self._hash_index._hash_to_path.items():
|
||||||
if count < 5: # Just show the first 5
|
if count < 5: # Just show the first 5
|
||||||
print(f"Hash: {hash_val[:8]}... -> Path: {path}", file=sys.stderr)
|
logger.debug(f"Hash: {hash_val[:8]}... -> Path: {path}")
|
||||||
count += 1
|
count += 1
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
print("Hash index not initialized", file=sys.stderr)
|
logger.debug("Hash index not initialized")
|
||||||
|
|
||||||
# Try looking up by a known hash for testing
|
# Try looking up by a known hash for testing
|
||||||
if not hasattr(self, '_hash_index') or not self._hash_index._hash_to_path:
|
if not hasattr(self, '_hash_index') or not self._hash_index._hash_to_path:
|
||||||
print("No hash entries to test lookup with", file=sys.stderr)
|
logger.debug("No hash entries to test lookup with")
|
||||||
return
|
return
|
||||||
|
|
||||||
test_hash = next(iter(self._hash_index._hash_to_path.keys()))
|
test_hash = next(iter(self._hash_index._hash_to_path.keys()))
|
||||||
test_path = self._hash_index.get_path(test_hash)
|
test_path = self._hash_index.get_path(test_hash)
|
||||||
print(f"\nTest lookup by hash: {test_hash[:8]}... -> {test_path}", file=sys.stderr)
|
logger.debug(f"\nTest lookup by hash: {test_hash[:8]}... -> {test_path}")
|
||||||
|
|
||||||
# Also test reverse lookup
|
# Also test reverse lookup
|
||||||
test_hash_result = self._hash_index.get_hash(test_path)
|
test_hash_result = self._hash_index.get_hash(test_path)
|
||||||
print(f"Test reverse lookup: {test_path} -> {test_hash_result[:8]}...\n\n", file=sys.stderr)
|
logger.debug(f"Test reverse lookup: {test_path} -> {test_hash_result[:8]}...\n\n")
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ async def initialize_metadata_providers():
|
|||||||
logger.debug(f"SQLite metadata provider registered with database: {db_path}")
|
logger.debug(f"SQLite metadata provider registered with database: {db_path}")
|
||||||
else:
|
else:
|
||||||
logger.warning("Metadata archive database is enabled but database file not found")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize SQLite metadata provider: {e}")
|
logger.error(f"Failed to initialize SQLite metadata provider: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -243,17 +243,27 @@ class MetadataSyncService:
|
|||||||
last_error = error or last_error
|
last_error = error or last_error
|
||||||
|
|
||||||
if civitai_metadata is None or metadata_provider is None:
|
if civitai_metadata is None or metadata_provider is None:
|
||||||
|
# Track if we need to save metadata
|
||||||
|
needs_save = False
|
||||||
|
|
||||||
if sqlite_attempted:
|
if sqlite_attempted:
|
||||||
model_data["db_checked"] = True
|
model_data["db_checked"] = True
|
||||||
|
needs_save = True
|
||||||
|
|
||||||
if civitai_api_not_found:
|
if civitai_api_not_found:
|
||||||
model_data["from_civitai"] = False
|
model_data["from_civitai"] = False
|
||||||
model_data["civitai_deleted"] = True
|
model_data["civitai_deleted"] = True
|
||||||
model_data["db_checked"] = sqlite_attempted or (enable_archive and model_data.get("db_checked", False))
|
model_data["db_checked"] = sqlite_attempted or (enable_archive and model_data.get("db_checked", False))
|
||||||
model_data["last_checked_at"] = datetime.now().timestamp()
|
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 = model_data.copy()
|
||||||
data_to_save.pop("folder", None)
|
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)
|
await self._metadata_manager.save_metadata(file_path, data_to_save)
|
||||||
|
|
||||||
default_error = (
|
default_error = (
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -5,7 +5,6 @@ import logging
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from operator import itemgetter
|
|
||||||
from natsort import natsorted
|
from natsort import natsorted
|
||||||
|
|
||||||
# Supported sort modes: (sort_key, order)
|
# Supported sort modes: (sort_key, order)
|
||||||
@@ -229,17 +228,17 @@ class ModelCache:
|
|||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
elif sort_key == 'date':
|
elif sort_key == 'date':
|
||||||
# Sort by modified timestamp
|
# Sort by modified timestamp (use .get() with default to handle missing fields)
|
||||||
result = sorted(
|
result = sorted(
|
||||||
data,
|
data,
|
||||||
key=itemgetter('modified'),
|
key=lambda x: x.get('modified', 0.0),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
elif sort_key == 'size':
|
elif sort_key == 'size':
|
||||||
# Sort by file size
|
# Sort by file size (use .get() with default to handle missing fields)
|
||||||
result = sorted(
|
result = sorted(
|
||||||
data,
|
data,
|
||||||
key=itemgetter('size'),
|
key=lambda x: x.get('size', 0),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
elif sort_key == 'usage':
|
elif sort_key == 'usage':
|
||||||
|
|||||||
@@ -676,7 +676,9 @@ class ModelMetadataProviderManager:
|
|||||||
|
|
||||||
def _get_provider(self, provider_name: str = None) -> ModelMetadataProvider:
|
def _get_provider(self, provider_name: str = None) -> ModelMetadataProvider:
|
||||||
"""Get provider by name or default provider"""
|
"""Get provider by name or default provider"""
|
||||||
if provider_name and provider_name in self.providers:
|
if provider_name:
|
||||||
|
if provider_name not in self.providers:
|
||||||
|
raise ValueError(f"Provider '{provider_name}' is not registered")
|
||||||
return self.providers[provider_name]
|
return self.providers[provider_name]
|
||||||
|
|
||||||
if self.default_provider is None:
|
if self.default_provider is None:
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ class FilterCriteria:
|
|||||||
favorites_only: bool = False
|
favorites_only: bool = False
|
||||||
search_options: Optional[Dict[str, Any]] = None
|
search_options: Optional[Dict[str, Any]] = None
|
||||||
model_types: Optional[Sequence[str]] = None
|
model_types: Optional[Sequence[str]] = None
|
||||||
|
tag_logic: str = "any" # "any" (OR) or "all" (AND)
|
||||||
|
|
||||||
|
|
||||||
class ModelCacheRepository:
|
class ModelCacheRepository:
|
||||||
@@ -300,11 +301,29 @@ class ModelFilterSet:
|
|||||||
include_tags = {tag for tag in tag_filters if tag}
|
include_tags = {tag for tag in tag_filters if tag}
|
||||||
|
|
||||||
if include_tags:
|
if include_tags:
|
||||||
|
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
|
||||||
|
|
||||||
def matches_include(item_tags):
|
def matches_include(item_tags):
|
||||||
if not item_tags and "__no_tags__" in include_tags:
|
if not item_tags and "__no_tags__" in include_tags:
|
||||||
return True
|
return True
|
||||||
return any(tag in include_tags for tag in (item_tags or []))
|
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 []))
|
||||||
|
|
||||||
items = [item for item in items if matches_include(item.get("tags"))]
|
items = [item for item in items if matches_include(item.get("tags"))]
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from .service_registry import ServiceRegistry
|
|||||||
from .websocket_manager import ws_manager
|
from .websocket_manager import ws_manager
|
||||||
from .persistent_model_cache import get_persistent_cache
|
from .persistent_model_cache import get_persistent_cache
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
|
from .cache_entry_validator import CacheEntryValidator
|
||||||
|
from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -468,6 +470,39 @@ class ModelScanner:
|
|||||||
for tag in adjusted_item.get('tags') or []:
|
for tag in adjusted_item.get('tags') or []:
|
||||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
|
# Validate cache entries and check health
|
||||||
|
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(
|
scan_result = CacheBuildResult(
|
||||||
raw_data=adjusted_raw_data,
|
raw_data=adjusted_raw_data,
|
||||||
hash_index=hash_index,
|
hash_index=hash_index,
|
||||||
@@ -651,7 +686,6 @@ class ModelScanner:
|
|||||||
|
|
||||||
async def _initialize_cache(self) -> None:
|
async def _initialize_cache(self) -> None:
|
||||||
"""Initialize or refresh the cache"""
|
"""Initialize or refresh the cache"""
|
||||||
print("init start", flush=True)
|
|
||||||
self._is_initializing = True # Set flag
|
self._is_initializing = True # Set flag
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -665,7 +699,6 @@ class ModelScanner:
|
|||||||
scan_result = await self._gather_model_data()
|
scan_result = await self._gather_model_data()
|
||||||
await self._apply_scan_result(scan_result)
|
await self._apply_scan_result(scan_result)
|
||||||
await self._save_persistent_cache(scan_result)
|
await self._save_persistent_cache(scan_result)
|
||||||
print("init end", flush=True)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
||||||
@@ -776,6 +809,18 @@ class ModelScanner:
|
|||||||
model_data = self.adjust_cached_entry(dict(model_data))
|
model_data = self.adjust_cached_entry(dict(model_data))
|
||||||
if not model_data:
|
if not model_data:
|
||||||
continue
|
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)
|
self._ensure_license_flags(model_data)
|
||||||
# Add to cache
|
# Add to cache
|
||||||
self._cache.raw_data.append(model_data)
|
self._cache.raw_data.append(model_data)
|
||||||
@@ -1090,6 +1135,17 @@ class ModelScanner:
|
|||||||
processed_files += 1
|
processed_files += 1
|
||||||
|
|
||||||
if result:
|
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)
|
self._ensure_license_flags(result)
|
||||||
raw_data.append(result)
|
raw_data.append(result)
|
||||||
|
|
||||||
|
|||||||
@@ -118,15 +118,13 @@ class ModelServiceFactory:
|
|||||||
|
|
||||||
|
|
||||||
def register_default_model_types():
|
def register_default_model_types():
|
||||||
"""Register the default model types (LoRA, Checkpoint, Embedding, and Misc)"""
|
"""Register the default model types (LoRA, Checkpoint, and Embedding)"""
|
||||||
from ..services.lora_service import LoraService
|
from ..services.lora_service import LoraService
|
||||||
from ..services.checkpoint_service import CheckpointService
|
from ..services.checkpoint_service import CheckpointService
|
||||||
from ..services.embedding_service import EmbeddingService
|
from ..services.embedding_service import EmbeddingService
|
||||||
from ..services.misc_service import MiscService
|
|
||||||
from ..routes.lora_routes import LoraRoutes
|
from ..routes.lora_routes import LoraRoutes
|
||||||
from ..routes.checkpoint_routes import CheckpointRoutes
|
from ..routes.checkpoint_routes import CheckpointRoutes
|
||||||
from ..routes.embedding_routes import EmbeddingRoutes
|
from ..routes.embedding_routes import EmbeddingRoutes
|
||||||
from ..routes.misc_model_routes import MiscModelRoutes
|
|
||||||
|
|
||||||
# Register LoRA model type
|
# Register LoRA model type
|
||||||
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
|
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
|
||||||
@@ -136,6 +134,3 @@ def register_default_model_types():
|
|||||||
|
|
||||||
# Register Embedding model type
|
# Register Embedding model type
|
||||||
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
|
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
|
||||||
|
|
||||||
# Register Misc model type (VAE, Upscaler)
|
|
||||||
ModelServiceFactory.register_model_type('misc', MiscService, MiscModelRoutes)
|
|
||||||
@@ -9,7 +9,7 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple
|
|||||||
from ..config import config
|
from ..config import config
|
||||||
from .recipe_cache import RecipeCache
|
from .recipe_cache import RecipeCache
|
||||||
from .recipe_fts_index import RecipeFTSIndex
|
from .recipe_fts_index import RecipeFTSIndex
|
||||||
from .persistent_recipe_cache import PersistentRecipeCache, get_persistent_recipe_cache
|
from .persistent_recipe_cache import PersistentRecipeCache, get_persistent_recipe_cache, PersistedRecipeData
|
||||||
from .service_registry import ServiceRegistry
|
from .service_registry import ServiceRegistry
|
||||||
from .lora_scanner import LoraScanner
|
from .lora_scanner import LoraScanner
|
||||||
from .metadata_service import get_default_metadata_provider
|
from .metadata_service import get_default_metadata_provider
|
||||||
@@ -431,6 +431,16 @@ class RecipeScanner:
|
|||||||
4. Persist results for next startup
|
4. Persist results for next startup
|
||||||
"""
|
"""
|
||||||
try:
|
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
|
# Create a new event loop for this thread
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
@@ -492,7 +502,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
def _reconcile_recipe_cache(
|
def _reconcile_recipe_cache(
|
||||||
self,
|
self,
|
||||||
persisted: "PersistedRecipeData",
|
persisted: PersistedRecipeData,
|
||||||
recipes_dir: str,
|
recipes_dir: str,
|
||||||
) -> Tuple[List[Dict], bool, Dict[str, str]]:
|
) -> Tuple[List[Dict], bool, Dict[str, str]]:
|
||||||
"""Reconcile persisted cache with current filesystem state.
|
"""Reconcile persisted cache with current filesystem state.
|
||||||
@@ -504,8 +514,6 @@ class RecipeScanner:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (recipes list, changed flag, json_paths dict).
|
Tuple of (recipes list, changed flag, json_paths dict).
|
||||||
"""
|
"""
|
||||||
from .persistent_recipe_cache import PersistedRecipeData
|
|
||||||
|
|
||||||
recipes: List[Dict] = []
|
recipes: List[Dict] = []
|
||||||
json_paths: Dict[str, str] = {}
|
json_paths: Dict[str, str] = {}
|
||||||
changed = False
|
changed = False
|
||||||
@@ -522,32 +530,37 @@ class RecipeScanner:
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Build lookup of persisted recipes by json_path
|
# Build recipe_id -> recipe lookup (O(n) instead of O(n²))
|
||||||
persisted_by_path: Dict[str, Dict] = {}
|
recipe_by_id: 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')
|
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
|
# Process current files
|
||||||
for file_path, (current_mtime, current_size) in current_files.items():
|
for file_path, (current_mtime, current_size) in current_files.items():
|
||||||
cached_stats = persisted.file_stats.get(file_path)
|
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:
|
if cached_stats:
|
||||||
cached_mtime, cached_size = cached_stats
|
cached_mtime, cached_size = cached_stats
|
||||||
# Check if file is unchanged
|
# Check if file is unchanged
|
||||||
if abs(current_mtime - cached_mtime) < 1.0 and current_size == cached_size:
|
if abs(current_mtime - cached_mtime) < 1.0 and current_size == cached_size:
|
||||||
# Use cached data
|
# Try direct path lookup first
|
||||||
cached_recipe = persisted_by_path.get(file_path)
|
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:
|
if cached_recipe:
|
||||||
recipe_id = str(cached_recipe.get('id', ''))
|
recipe_id = str(cached_recipe.get('id', ''))
|
||||||
# Track folder from file path
|
# Track folder from file path
|
||||||
@@ -2218,3 +2231,26 @@ class RecipeScanner:
|
|||||||
duplicate_groups = {k: v for k, v in fingerprint_groups.items() if len(v) > 1}
|
duplicate_groups = {k: v for k, v in fingerprint_groups.items() if len(v) > 1}
|
||||||
|
|
||||||
return duplicate_groups
|
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
|
||||||
|
|||||||
@@ -250,27 +250,6 @@ class ServiceRegistry:
|
|||||||
logger.debug(f"Created and registered {service_name}")
|
logger.debug(f"Created and registered {service_name}")
|
||||||
return scanner
|
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
|
@classmethod
|
||||||
def clear_services(cls):
|
def clear_services(cls):
|
||||||
"""Clear all registered services - mainly for testing"""
|
"""Clear all registered services - mainly for testing"""
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ CORE_USER_SETTING_KEYS: Tuple[str, ...] = (
|
|||||||
"folder_paths",
|
"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] = {
|
DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||||
"civitai_api_key": "",
|
"civitai_api_key": "",
|
||||||
@@ -63,7 +66,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"compact_mode": False,
|
"compact_mode": False,
|
||||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||||
"model_name_display": "model_name",
|
"model_name_display": "model_name",
|
||||||
"model_card_footer_action": "example_images",
|
"model_card_footer_action": "replace_preview",
|
||||||
"update_flag_strategy": "same_base",
|
"update_flag_strategy": "same_base",
|
||||||
"auto_organize_exclusions": [],
|
"auto_organize_exclusions": [],
|
||||||
}
|
}
|
||||||
@@ -95,6 +98,9 @@ class SettingsManager:
|
|||||||
if self._needs_initial_save:
|
if self._needs_initial_save:
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
self._needs_initial_save = False
|
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:
|
def _detect_standalone_mode(self) -> bool:
|
||||||
"""Return ``True`` when running in standalone mode."""
|
"""Return ``True`` when running in standalone mode."""
|
||||||
@@ -226,7 +232,7 @@ class SettingsManager:
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
def _ensure_default_settings(self) -> None:
|
def _ensure_default_settings(self) -> None:
|
||||||
"""Ensure all default settings keys exist"""
|
"""Ensure all default settings keys exist in memory (but don't save defaults to disk)"""
|
||||||
defaults = self._get_default_settings()
|
defaults = self._get_default_settings()
|
||||||
updated_existing = False
|
updated_existing = False
|
||||||
inserted_defaults = False
|
inserted_defaults = False
|
||||||
@@ -265,10 +271,10 @@ class SettingsManager:
|
|||||||
self.settings[key] = value
|
self.settings[key] = value
|
||||||
inserted_defaults = True
|
inserted_defaults = True
|
||||||
|
|
||||||
if updated_existing or (
|
# Save only if existing values were normalized/updated
|
||||||
inserted_defaults and self._bootstrap_reason in {"invalid", "unreadable"}
|
if updated_existing:
|
||||||
):
|
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
# Note: inserted_defaults no longer triggers save - defaults stay in memory only
|
||||||
|
|
||||||
def _migrate_to_library_registry(self) -> None:
|
def _migrate_to_library_registry(self) -> None:
|
||||||
"""Ensure settings include the multi-library registry structure."""
|
"""Ensure settings include the multi-library registry structure."""
|
||||||
@@ -711,6 +717,42 @@ class SettingsManager:
|
|||||||
|
|
||||||
self._startup_messages.append(payload)
|
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:
|
def _collect_configuration_warnings(self) -> None:
|
||||||
if not self._standalone_mode:
|
if not self._standalone_mode:
|
||||||
return
|
return
|
||||||
@@ -1101,7 +1143,12 @@ class SettingsManager:
|
|||||||
self._seed_template = None
|
self._seed_template = None
|
||||||
|
|
||||||
def _serialize_settings_for_disk(self) -> Dict[str, Any]:
|
def _serialize_settings_for_disk(self) -> Dict[str, Any]:
|
||||||
"""Return the settings payload that should be persisted to disk."""
|
"""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().
|
||||||
|
"""
|
||||||
|
|
||||||
if self._bootstrap_reason == "missing":
|
if self._bootstrap_reason == "missing":
|
||||||
minimal: Dict[str, Any] = {}
|
minimal: Dict[str, Any] = {}
|
||||||
@@ -1115,7 +1162,25 @@ class SettingsManager:
|
|||||||
|
|
||||||
return minimal
|
return minimal
|
||||||
|
|
||||||
return copy.deepcopy(self.settings)
|
# 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
|
||||||
|
|
||||||
def get_libraries(self) -> Dict[str, Dict[str, Any]]:
|
def get_libraries(self) -> Dict[str, Dict[str, Any]]:
|
||||||
"""Return a copy of the registered libraries."""
|
"""Return a copy of the registered libraries."""
|
||||||
|
|||||||
@@ -48,9 +48,14 @@ class BulkMetadataRefreshUseCase:
|
|||||||
for model in cache.raw_data
|
for model in cache.raw_data
|
||||||
if model.get("sha256")
|
if model.get("sha256")
|
||||||
and (not model.get("civitai") or not model["civitai"].get("id"))
|
and (not model.get("civitai") or not model["civitai"].get("id"))
|
||||||
and (
|
and not (
|
||||||
(enable_metadata_archive_db and not model.get("db_checked", False))
|
# Skip models confirmed not on CivitAI when no need to retry
|
||||||
or (not enable_metadata_archive_db and model.get("from_civitai") is True)
|
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)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,42 @@ class WebSocketManager:
|
|||||||
self._download_progress.pop(download_id, None)
|
self._download_progress.pop(download_id, None)
|
||||||
logger.debug(f"Cleaned up old download progress for {download_id}")
|
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:
|
def get_connected_clients_count(self) -> int:
|
||||||
"""Get number of connected clients"""
|
"""Get number of connected clients"""
|
||||||
return len(self._websockets)
|
return len(self._websockets)
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ SUPPORTED_MEDIA_EXTENSIONS = {
|
|||||||
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
||||||
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
||||||
VALID_EMBEDDING_SUB_TYPES = ["embedding"]
|
VALID_EMBEDDING_SUB_TYPES = ["embedding"]
|
||||||
VALID_MISC_SUB_TYPES = ["vae", "upscaler"]
|
|
||||||
|
|
||||||
# Backward compatibility alias
|
# Backward compatibility alias
|
||||||
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
|
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
|
||||||
@@ -95,7 +94,6 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
|
|||||||
"lora": ", ".join(CIVITAI_MODEL_TAGS),
|
"lora": ", ".join(CIVITAI_MODEL_TAGS),
|
||||||
"checkpoint": ", ".join(CIVITAI_MODEL_TAGS),
|
"checkpoint": ", ".join(CIVITAI_MODEL_TAGS),
|
||||||
"embedding": ", ".join(CIVITAI_MODEL_TAGS),
|
"embedding": ", ".join(CIVITAI_MODEL_TAGS),
|
||||||
"misc": ", ".join(CIVITAI_MODEL_TAGS),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# baseModel values from CivitAI that should be treated as diffusion models (unet)
|
# baseModel values from CivitAI that should be treated as diffusion models (unet)
|
||||||
|
|||||||
@@ -216,6 +216,11 @@ class DownloadManager:
|
|||||||
self._progress["failed_models"] = set()
|
self._progress["failed_models"] = set()
|
||||||
|
|
||||||
self._is_downloading = True
|
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_task = asyncio.create_task(
|
||||||
self._download_all_example_images(
|
self._download_all_example_images(
|
||||||
output_dir,
|
output_dir,
|
||||||
@@ -227,7 +232,10 @@ class DownloadManager:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
snapshot = self._progress.snapshot()
|
# Add a callback to handle task completion/errors
|
||||||
|
self._download_task.add_done_callback(
|
||||||
|
lambda t: self._handle_download_task_done(t, output_dir)
|
||||||
|
)
|
||||||
except ExampleImagesDownloadError:
|
except ExampleImagesDownloadError:
|
||||||
# Re-raise our own exception types without wrapping
|
# Re-raise our own exception types without wrapping
|
||||||
self._is_downloading = False
|
self._is_downloading = False
|
||||||
@@ -241,10 +249,25 @@ class DownloadManager:
|
|||||||
)
|
)
|
||||||
raise ExampleImagesDownloadError(str(e)) from e
|
raise ExampleImagesDownloadError(str(e)) from e
|
||||||
|
|
||||||
await self._broadcast_progress(status="running")
|
# 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"))
|
||||||
|
|
||||||
return {"success": True, "message": "Download started", "status": snapshot}
|
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):
|
async def get_status(self, request):
|
||||||
"""Get the current status of example images download."""
|
"""Get the current status of example images download."""
|
||||||
|
|
||||||
@@ -254,6 +277,130 @@ class DownloadManager:
|
|||||||
"status": self._progress.snapshot(),
|
"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):
|
async def pause_download(self, request):
|
||||||
"""Pause the example images download."""
|
"""Pause the example images download."""
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,15 @@ class ExampleImagesProcessor:
|
|||||||
return media_url
|
return media_url
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_file_extension_from_content_or_headers(content, headers, fallback_url=None):
|
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"""
|
"""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")
|
||||||
|
"""
|
||||||
# Check magic bytes for common formats
|
# Check magic bytes for common formats
|
||||||
if content:
|
if content:
|
||||||
if content.startswith(b'\xFF\xD8\xFF'):
|
if content.startswith(b'\xFF\xD8\xFF'):
|
||||||
@@ -82,6 +89,10 @@ class ExampleImagesProcessor:
|
|||||||
if ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or ext in SUPPORTED_MEDIA_EXTENSIONS['videos']:
|
if ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or ext in SUPPORTED_MEDIA_EXTENSIONS['videos']:
|
||||||
return ext
|
return ext
|
||||||
|
|
||||||
|
# Use media type hint from metadata if available
|
||||||
|
if media_type_hint == "video":
|
||||||
|
return '.mp4'
|
||||||
|
|
||||||
# Default fallback
|
# Default fallback
|
||||||
return '.jpg'
|
return '.jpg'
|
||||||
|
|
||||||
@@ -136,7 +147,7 @@ class ExampleImagesProcessor:
|
|||||||
if success:
|
if success:
|
||||||
# Determine file extension from content or headers
|
# Determine file extension from content or headers
|
||||||
media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
|
media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
|
||||||
content, headers, original_url
|
content, headers, original_url, image.get("type")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if the detected file type is supported
|
# Check if the detected file type is supported
|
||||||
@@ -219,7 +230,7 @@ class ExampleImagesProcessor:
|
|||||||
if success:
|
if success:
|
||||||
# Determine file extension from content or headers
|
# Determine file extension from content or headers
|
||||||
media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
|
media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
|
||||||
content, headers, original_url
|
content, headers, original_url, image.get("type")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if the detected file type is supported
|
# Check if the detected file type is supported
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ async def extract_lora_metadata(file_path: str) -> Dict:
|
|||||||
base_model = determine_base_model(metadata.get("ss_base_model_version"))
|
base_model = determine_base_model(metadata.get("ss_base_model_version"))
|
||||||
return {"base_model": base_model}
|
return {"base_model": base_model}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading metadata from {file_path}: {str(e)}")
|
logger.error(f"Error reading metadata from {file_path}: {str(e)}")
|
||||||
return {"base_model": "Unknown"}
|
return {"base_model": "Unknown"}
|
||||||
|
|
||||||
async def extract_checkpoint_metadata(file_path: str) -> dict:
|
async def extract_checkpoint_metadata(file_path: str) -> dict:
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ class MetadataManager:
|
|||||||
preview_url=normalize_path(preview_url),
|
preview_url=normalize_path(preview_url),
|
||||||
tags=[],
|
tags=[],
|
||||||
modelDescription="",
|
modelDescription="",
|
||||||
model_type="checkpoint",
|
sub_type="checkpoint",
|
||||||
from_civitai=True
|
from_civitai=True
|
||||||
)
|
)
|
||||||
elif model_class.__name__ == "EmbeddingMetadata":
|
elif model_class.__name__ == "EmbeddingMetadata":
|
||||||
@@ -238,6 +238,7 @@ class MetadataManager:
|
|||||||
preview_url=normalize_path(preview_url),
|
preview_url=normalize_path(preview_url),
|
||||||
tags=[],
|
tags=[],
|
||||||
modelDescription="",
|
modelDescription="",
|
||||||
|
sub_type="embedding",
|
||||||
from_civitai=True
|
from_civitai=True
|
||||||
)
|
)
|
||||||
else: # Default to LoraMetadata
|
else: # Default to LoraMetadata
|
||||||
|
|||||||
@@ -246,49 +246,3 @@ class EmbeddingMetadata(BaseModelMetadata):
|
|||||||
modelDescription=description
|
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]),
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|||||||
@@ -138,19 +138,15 @@ def calculate_recipe_fingerprint(loras):
|
|||||||
if not loras:
|
if not loras:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Filter valid entries and extract hash and strength
|
|
||||||
valid_loras = []
|
valid_loras = []
|
||||||
for lora in loras:
|
for lora in loras:
|
||||||
# Skip excluded loras
|
|
||||||
if lora.get("exclude", False):
|
if lora.get("exclude", False):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get the hash - use modelVersionId as fallback if hash is empty
|
|
||||||
hash_value = lora.get("hash", "").lower()
|
hash_value = lora.get("hash", "").lower()
|
||||||
if not hash_value and lora.get("isDeleted", False) and lora.get("modelVersionId"):
|
if not hash_value and lora.get("modelVersionId"):
|
||||||
hash_value = str(lora.get("modelVersionId"))
|
hash_value = str(lora.get("modelVersionId"))
|
||||||
|
|
||||||
# Skip entries without a valid hash
|
|
||||||
if not hash_value:
|
if not hash_value:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "0.9.13"
|
version = "0.9.15"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
0
scripts/sync_translation_keys.py
Normal file → Executable file
0
scripts/sync_translation_keys.py
Normal file → Executable file
@@ -65,6 +65,8 @@ body {
|
|||||||
--space-1: calc(8px * 1);
|
--space-1: calc(8px * 1);
|
||||||
--space-2: calc(8px * 2);
|
--space-2: calc(8px * 2);
|
||||||
--space-3: calc(8px * 3);
|
--space-3: calc(8px * 3);
|
||||||
|
--space-4: calc(8px * 4);
|
||||||
|
--space-5: calc(8px * 5);
|
||||||
|
|
||||||
/* Z-index Scale */
|
/* Z-index Scale */
|
||||||
--z-base: 10;
|
--z-base: 10;
|
||||||
|
|||||||
@@ -113,6 +113,12 @@
|
|||||||
max-width: 110px;
|
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 {
|
.compact-density .card-actions i {
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
|
|||||||
354
static/css/components/model-modal/metadata.css
Normal file
354
static/css/components/model-modal/metadata.css
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
/* Metadata Panel - Right Panel */
|
||||||
|
|
||||||
|
.metadata {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-left: 1px solid var(--lora-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header section */
|
||||||
|
.metadata__header {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__name {
|
||||||
|
font-size: 1.4em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__edit-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__header:hover .metadata__edit-btn {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__edit-btn:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
background: var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Creator and actions */
|
||||||
|
.metadata__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__creator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .metadata__creator {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__creator:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__creator-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--lora-accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__creator-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__creator-avatar i {
|
||||||
|
color: white;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__creator-name {
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__civitai-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__civitai-link:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* License icons */
|
||||||
|
.metadata__licenses {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__license-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: inline-block;
|
||||||
|
background-color: var(--text-muted);
|
||||||
|
-webkit-mask: var(--license-icon-image) center/contain no-repeat;
|
||||||
|
mask: var(--license-icon-image) center/contain no-repeat;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__license-icon:hover {
|
||||||
|
background-color: var(--text-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.metadata__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
border: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__tag:hover {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info grid */
|
||||||
|
.metadata__info {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__info-item--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__info-label {
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__info-value {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__info-value--mono {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__info-value--path {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-style: dotted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__info-value--path:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editable sections */
|
||||||
|
.metadata__section {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__section-title {
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__section-edit {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
transition: opacity 0.2s, background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__section:hover .metadata__section-edit {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__section-edit:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
background: var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Usage tips / Trigger words */
|
||||||
|
.metadata__tags--editable {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__tag--editable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__tag--editable:hover {
|
||||||
|
background: var(--lora-error);
|
||||||
|
border-color: var(--lora-error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__tag--add {
|
||||||
|
background: transparent;
|
||||||
|
border-style: dashed;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__tag--add:hover {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notes textarea */
|
||||||
|
.metadata__notes {
|
||||||
|
min-height: 60px;
|
||||||
|
max-height: 120px;
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-color);
|
||||||
|
resize: vertical;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__notes:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__notes::placeholder {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area (tabs + scrollable content) */
|
||||||
|
.metadata__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.metadata__header {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__name {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__info {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata__section {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
static/css/components/model-modal/overlay.css
Normal file
167
static/css/components/model-modal/overlay.css
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/* Model Modal Overlay - Split View Layout */
|
||||||
|
|
||||||
|
.model-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--header-height, 48px);
|
||||||
|
left: var(--sidebar-width, 250px);
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: var(--z-modal, 1000);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 0.8fr;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
background: var(--bg-color) !important;
|
||||||
|
opacity: 0;
|
||||||
|
animation: modalOverlayFadeIn 0.25s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-overlay.sidebar-collapsed {
|
||||||
|
left: var(--sidebar-collapsed-width, 60px);
|
||||||
|
grid-template-columns: 1.3fr 0.7fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalOverlayFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-overlay.closing {
|
||||||
|
opacity: 1 !important;
|
||||||
|
animation: modalOverlayFadeOut 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalOverlayFadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Close button */
|
||||||
|
.model-overlay__close {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-2);
|
||||||
|
right: var(--space-2);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-overlay__close:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard shortcut hint */
|
||||||
|
.model-overlay__hint {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-2);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
opacity: 0;
|
||||||
|
animation: hintFadeIn 0.3s ease-out 0.5s forwards, hintFadeOut 0.3s ease-out 3.5s forwards;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hintFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hintFadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-overlay__hint.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive breakpoints */
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.model-overlay {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-overlay.sidebar-collapsed {
|
||||||
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.model-overlay {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-overlay.sidebar-collapsed {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: stack layout */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.model-overlay {
|
||||||
|
left: 0;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-overlay.sidebar-collapsed {
|
||||||
|
left: 0;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body scroll lock when modal is open */
|
||||||
|
body.modal-open {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition effect for content when switching models */
|
||||||
|
.showcase,
|
||||||
|
.metadata {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase.transitioning,
|
||||||
|
.metadata.transitioning {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
272
static/css/components/model-modal/recipes.css
Normal file
272
static/css/components/model-modal/recipes.css
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/* Recipes Tab Styles */
|
||||||
|
|
||||||
|
.recipes-loading,
|
||||||
|
.recipes-error,
|
||||||
|
.recipes-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-loading i,
|
||||||
|
.recipes-error i,
|
||||||
|
.recipes-empty i {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-error i {
|
||||||
|
color: var(--lora-error);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.recipes-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
margin: calc(-1 * var(--space-2)) calc(-1 * var(--space-2)) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-header__text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-header__eyebrow {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-header h3 {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-header__description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-header__view-all {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-header__view-all:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe Cards Grid */
|
||||||
|
.recipes-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe Card */
|
||||||
|
.recipe-card {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card:hover {
|
||||||
|
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe Card Media */
|
||||||
|
.recipe-card__media {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__media img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card:hover .recipe-card__media img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__media-top {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-2);
|
||||||
|
right: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card:hover .recipe-card__media-top {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__copy {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__copy:hover {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe Card Body */
|
||||||
|
.recipe-card__body {
|
||||||
|
padding: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__title {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__badge--base {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__badge--empty {
|
||||||
|
background: var(--lora-border);
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__badge--ready {
|
||||||
|
background: oklch(60% 0.15 145);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__badge--missing {
|
||||||
|
background: oklch(60% 0.15 30);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__cta {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
border-top: 1px solid var(--lora-border);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card:hover .recipe-card__cta {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__cta i {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card:hover .recipe-card__cta i {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.recipes-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-header__view-all {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__media-top {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
566
static/css/components/model-modal/showcase.css
Normal file
566
static/css/components/model-modal/showcase.css
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
/* Examples Showcase - Left Panel */
|
||||||
|
|
||||||
|
.showcase {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main image container */
|
||||||
|
.showcase__main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__image-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__image.loading {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media container for images and videos */
|
||||||
|
.showcase__media-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-media-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__media-inner {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__media {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: filter 0.2s ease, opacity 0.3s ease;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__media.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__media.blurred {
|
||||||
|
filter: blur(25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NSFW notice for main media - redesigned to avoid conflicts with card.css */
|
||||||
|
.showcase__nsfw-notice {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
z-index: 5;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__nsfw-notice-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__nsfw-notice-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show content button in NSFW notice - styled like card.css show-content-btn */
|
||||||
|
.showcase__nsfw-show-btn {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 6px var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__nsfw-show-btn:hover {
|
||||||
|
background: oklch(58% 0.28 256);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__nsfw-show-btn i {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control button active state for blur toggle */
|
||||||
|
.showcase__control-btn.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video indicator for thumbnails */
|
||||||
|
.thumbnail-rail__video-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NSFW blur for thumbnails */
|
||||||
|
.thumbnail-rail__item.nsfw-blur img {
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation arrows */
|
||||||
|
.showcase__nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase:hover .showcase__nav {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__nav:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
transform: translateY(-50%) scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__nav--prev {
|
||||||
|
left: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__nav--next {
|
||||||
|
right: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__nav:disabled {
|
||||||
|
opacity: 0.3 !important;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image controls overlay */
|
||||||
|
.showcase__controls {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-2);
|
||||||
|
right: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image counter */
|
||||||
|
.showcase__counter {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-2);
|
||||||
|
left: var(--space-2);
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: white;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__image-wrapper:hover .showcase__counter {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__counter-current {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 2ch;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__counter-separator {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__counter-total {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__image-wrapper:hover .showcase__controls {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__control-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__control-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__control-btn--primary:hover {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__control-btn--danger:hover {
|
||||||
|
background: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state for toggle buttons */
|
||||||
|
.showcase__control-btn.active {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__control-btn.active:hover {
|
||||||
|
background: var(--lora-accent-hover, #3182ce);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Params panel (slide up) */
|
||||||
|
.showcase__params {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-top: 1px solid var(--lora-border);
|
||||||
|
padding: var(--space-3);
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
z-index: 6;
|
||||||
|
max-height: 50%;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__params.visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__params-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__params-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__params-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__params-close:hover {
|
||||||
|
background: var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prompt display */
|
||||||
|
.showcase__prompt {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__prompt-label {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__prompt-text {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__prompt-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-1);
|
||||||
|
right: var(--space-1);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
transition: opacity 0.2s, background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__prompt-copy:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.showcase__loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__loading i {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loading state */
|
||||||
|
.showcase__skeleton {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-animation {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-spinner {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error state */
|
||||||
|
.showcase__error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--lora-error);
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__error i {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__error p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.showcase__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__empty i {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.showcase__main {
|
||||||
|
padding: var(--space-2);
|
||||||
|
min-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__image {
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__nav {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__nav--prev {
|
||||||
|
left: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__nav--next {
|
||||||
|
right: var(--space-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Lazy Loading Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Thumbnail lazy loading placeholder */
|
||||||
|
.thumbnail-rail__item img {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loaded state */
|
||||||
|
.thumbnail-rail__item img.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state with skeleton animation */
|
||||||
|
.thumbnail-rail__item img.lazy-load {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--lora-surface) 25%,
|
||||||
|
var(--lora-border) 50%,
|
||||||
|
var(--lora-surface) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: lazy-loading-shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lazy-loading-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error state for failed loads */
|
||||||
|
.thumbnail-rail__item img.load-error {
|
||||||
|
opacity: 0.3;
|
||||||
|
background: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cached image - subtle highlight */
|
||||||
|
.thumbnail-rail__item img[data-cached="true"] {
|
||||||
|
border: 1px solid var(--lora-accent);
|
||||||
|
}
|
||||||
153
static/css/components/model-modal/tabs.css
Normal file
153
static/css/components/model-modal/tabs.css
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/* Tabs - Content Area */
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2) var(--space-1);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
border-bottom-color: var(--lora-accent);
|
||||||
|
opacity: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: var(--badge-update-bg);
|
||||||
|
color: var(--badge-update-text);
|
||||||
|
font-size: 0.65em;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab__badge--pulse {
|
||||||
|
animation: tabBadgePulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tabBadgePulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in oklch, var(--badge-update-bg) 50%, transparent);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in oklch, var(--badge-update-bg) 0%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab content */
|
||||||
|
.tab-panels {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel.active {
|
||||||
|
display: block;
|
||||||
|
animation: tabPanelFadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tabPanelFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accordion within tab panels */
|
||||||
|
.accordion {
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion__header:hover {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion__icon {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion.expanded .accordion__icon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion__content {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion.expanded .accordion__content {
|
||||||
|
max-height: 500px; /* Adjust based on content */
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion__body {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-top: 1px solid var(--lora-border);
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tab {
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding: var(--space-2) var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab__badge {
|
||||||
|
display: none; /* Hide badges on small screens */
|
||||||
|
}
|
||||||
|
}
|
||||||
151
static/css/components/model-modal/thumbnail-rail.css
Normal file
151
static/css/components/model-modal/thumbnail-rail.css
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/* Thumbnail Rail - Bottom of Showcase */
|
||||||
|
|
||||||
|
.thumbnail-rail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-top: 1px solid var(--lora-border);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--lora-border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-rail::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-rail::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-rail::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--lora-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumbnail item */
|
||||||
|
.thumbnail-rail__item {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-rail__item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-rail__item img.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-rail__item:hover {
|
||||||
|
border-color: var(--lora-border);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-rail__item.active {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NSFW blur for thumbnails - BEM naming to avoid conflicts with global .nsfw-blur */
|
||||||
|
.thumbnail-rail__item--nsfw-blurred img {
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy support for old class names (deprecated) */
|
||||||
|
.thumbnail-rail__item.nsfw img {
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-rail__nsfw-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.65em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add button */
|
||||||
|
.thumbnail-rail__add {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 2px dashed var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-rail__add:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
opacity: 1;
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-rail__add i {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload area (inline expansion) */
|
||||||
|
.thumbnail-rail__upload {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-top: 1px solid var(--lora-border);
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-rail__upload.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.thumbnail-rail {
|
||||||
|
padding: var(--space-2);
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-rail__item,
|
||||||
|
.thumbnail-rail__add {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
static/css/components/model-modal/upload.css
Normal file
163
static/css/components/model-modal/upload.css
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/* Upload Area Styles */
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-top: 1px solid var(--lora-border);
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
z-index: 10;
|
||||||
|
max-height: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area.visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__content {
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropzone */
|
||||||
|
.upload-area__dropzone {
|
||||||
|
border: 2px dashed var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__dropzone:hover {
|
||||||
|
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5);
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__dropzone.dragover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder */
|
||||||
|
.upload-area__placeholder {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__placeholder i {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__title {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uploading State */
|
||||||
|
.upload-area__uploading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__uploading i {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__uploading p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.upload-area__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__cancel {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.9em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__cancel:hover {
|
||||||
|
border-color: var(--lora-error);
|
||||||
|
color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add Button in Empty State */
|
||||||
|
.showcase__add-btn {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--lora-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase__add-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.upload-area {
|
||||||
|
max-height: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__content {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__dropzone {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area__placeholder i {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
378
static/css/components/model-modal/versions.css
Normal file
378
static/css/components/model-modal/versions.css
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
/* Versions Tab Styles */
|
||||||
|
|
||||||
|
.versions-loading,
|
||||||
|
.versions-error,
|
||||||
|
.versions-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-loading i,
|
||||||
|
.versions-error i,
|
||||||
|
.versions-empty i {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-error i {
|
||||||
|
color: var(--lora-error);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-empty-filter {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.versions-toolbar {
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
margin: calc(-1 * var(--space-2)) calc(-1 * var(--space-2)) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toolbar-info-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toolbar-info-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toolbar-info p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toolbar-actions {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-filter-toggle {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-filter-toggle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-filter-toggle.active {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--lora-accent);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toolbar-btn {
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toolbar-btn-primary {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toolbar-btn-primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version Cards List */
|
||||||
|
.versions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version Card */
|
||||||
|
.version-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-card:hover {
|
||||||
|
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-card.is-current {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-card.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-card.is-clickable:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version Media */
|
||||||
|
.version-media {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-media img,
|
||||||
|
.version-media video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-media-placeholder {
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.5;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version Details */
|
||||||
|
.version-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95em;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badge-current {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badge-success {
|
||||||
|
background: var(--lora-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badge-info {
|
||||||
|
background: var(--badge-update-bg);
|
||||||
|
color: var(--badge-update-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badge-muted {
|
||||||
|
background: var(--lora-border);
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-meta {
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-meta-separator {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-meta-primary {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version Actions */
|
||||||
|
.version-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-action {
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-action-primary {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-action-primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-action-danger {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--lora-error);
|
||||||
|
color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-action-danger:hover {
|
||||||
|
background: var(--lora-error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-action-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--lora-border);
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-action-ghost:hover {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-action:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete Modal for Version */
|
||||||
|
.version-delete-modal .delete-model-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-delete-modal .delete-preview {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-delete-modal .delete-preview img,
|
||||||
|
.version-delete-modal .delete-preview video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-delete-modal .delete-info h3 {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-delete-modal .version-base-model {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.version-card {
|
||||||
|
grid-template-columns: 60px 1fr auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-media {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-name {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-actions {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-action {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toolbar-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toolbar-btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -512,6 +512,10 @@
|
|||||||
|
|
||||||
.filter-preset.active .preset-delete-btn {
|
.filter-preset.active .preset-delete-btn {
|
||||||
color: white;
|
color: white;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-preset:hover.active .preset-delete-btn {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,13 +533,16 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
max-width: 120px; /* Prevent long names from breaking layout */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-delete-btn {
|
.preset-delete-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
opacity: 0.5;
|
opacity: 0; /* Hidden by default */
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -546,6 +553,10 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-preset:hover .preset-delete-btn {
|
||||||
|
opacity: 0.5; /* Show on hover */
|
||||||
|
}
|
||||||
|
|
||||||
.preset-delete-btn:hover {
|
.preset-delete-btn:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: var(--lora-error, #e74c3c);
|
color: var(--lora-error, #e74c3c);
|
||||||
@@ -662,6 +673,57 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* 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 */
|
/* Mobile adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.search-options-panel,
|
.search-options-panel,
|
||||||
|
|||||||
@@ -27,6 +27,18 @@
|
|||||||
@import 'components/lora-modal/showcase.css';
|
@import 'components/lora-modal/showcase.css';
|
||||||
@import 'components/lora-modal/triggerwords.css';
|
@import 'components/lora-modal/triggerwords.css';
|
||||||
@import 'components/lora-modal/versions.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/shared/edit-metadata.css';
|
||||||
@import 'components/search-filter.css';
|
@import 'components/search-filter.css';
|
||||||
@import 'components/bulk.css';
|
@import 'components/bulk.css';
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { state } from '../state/index.js';
|
|||||||
export const MODEL_TYPES = {
|
export const MODEL_TYPES = {
|
||||||
LORA: 'loras',
|
LORA: 'loras',
|
||||||
CHECKPOINT: 'checkpoints',
|
CHECKPOINT: 'checkpoints',
|
||||||
EMBEDDING: 'embeddings',
|
EMBEDDING: 'embeddings' // Future model type
|
||||||
MISC: 'misc'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Base API configuration for each model type
|
// Base API configuration for each model type
|
||||||
@@ -41,15 +40,6 @@ export const MODEL_CONFIG = {
|
|||||||
supportsBulkOperations: true,
|
supportsBulkOperations: true,
|
||||||
supportsMove: true,
|
supportsMove: true,
|
||||||
templateName: 'embeddings.html'
|
templateName: 'embeddings.html'
|
||||||
},
|
|
||||||
[MODEL_TYPES.MISC]: {
|
|
||||||
displayName: 'Misc',
|
|
||||||
singularName: 'misc',
|
|
||||||
defaultPageSize: 100,
|
|
||||||
supportsLetterFilter: false,
|
|
||||||
supportsBulkOperations: true,
|
|
||||||
supportsMove: true,
|
|
||||||
templateName: 'misc.html'
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,11 +133,6 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
|
|||||||
},
|
},
|
||||||
[MODEL_TYPES.EMBEDDING]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`,
|
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`,
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -924,6 +924,11 @@ export class BaseModelApiClient {
|
|||||||
params.append('model_type', type);
|
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);
|
this._addModelSpecificParams(params, pageState);
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { LoraApiClient } from './loraApi.js';
|
import { LoraApiClient } from './loraApi.js';
|
||||||
import { CheckpointApiClient } from './checkpointApi.js';
|
import { CheckpointApiClient } from './checkpointApi.js';
|
||||||
import { EmbeddingApiClient } from './embeddingApi.js';
|
import { EmbeddingApiClient } from './embeddingApi.js';
|
||||||
import { MiscApiClient } from './miscApi.js';
|
|
||||||
import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
|
import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
|
|
||||||
@@ -13,8 +12,6 @@ export function createModelApiClient(modelType) {
|
|||||||
return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT);
|
return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT);
|
||||||
case MODEL_TYPES.EMBEDDING:
|
case MODEL_TYPES.EMBEDDING:
|
||||||
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
|
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
|
||||||
case MODEL_TYPES.MISC:
|
|
||||||
return new MiscApiClient(MODEL_TYPES.MISC);
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported model type: ${modelType}`);
|
throw new Error(`Unsupported model type: ${modelType}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -2,7 +2,6 @@ export { LoraContextMenu } from './LoraContextMenu.js';
|
|||||||
export { RecipeContextMenu } from './RecipeContextMenu.js';
|
export { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||||
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||||
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
||||||
export { MiscContextMenu } from './MiscContextMenu.js';
|
|
||||||
export { GlobalContextMenu } from './GlobalContextMenu.js';
|
export { GlobalContextMenu } from './GlobalContextMenu.js';
|
||||||
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
|
|
||||||
@@ -10,7 +9,6 @@ import { LoraContextMenu } from './LoraContextMenu.js';
|
|||||||
import { RecipeContextMenu } from './RecipeContextMenu.js';
|
import { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||||
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||||
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
||||||
import { MiscContextMenu } from './MiscContextMenu.js';
|
|
||||||
import { GlobalContextMenu } from './GlobalContextMenu.js';
|
import { GlobalContextMenu } from './GlobalContextMenu.js';
|
||||||
|
|
||||||
// Factory method to create page-specific context menu instances
|
// Factory method to create page-specific context menu instances
|
||||||
@@ -24,8 +22,6 @@ export function createPageContextMenu(pageType) {
|
|||||||
return new CheckpointContextMenu();
|
return new CheckpointContextMenu();
|
||||||
case 'embeddings':
|
case 'embeddings':
|
||||||
return new EmbeddingContextMenu();
|
return new EmbeddingContextMenu();
|
||||||
case 'misc':
|
|
||||||
return new MiscContextMenu();
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export class HeaderManager {
|
|||||||
if (path.includes('/checkpoints')) return 'checkpoints';
|
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||||
if (path.includes('/embeddings')) return 'embeddings';
|
if (path.includes('/embeddings')) return 'embeddings';
|
||||||
if (path.includes('/statistics')) return 'statistics';
|
if (path.includes('/statistics')) return 'statistics';
|
||||||
if (path.includes('/misc')) return 'misc';
|
|
||||||
if (path.includes('/loras')) return 'loras';
|
if (path.includes('/loras')) return 'loras';
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,11 @@ export class ModelDuplicatesManager {
|
|||||||
// Method to check for duplicates count using existing endpoint
|
// Method to check for duplicates count using existing endpoint
|
||||||
async checkDuplicatesCount() {
|
async checkDuplicatesCount() {
|
||||||
try {
|
try {
|
||||||
|
const params = this._buildFilterQueryParams();
|
||||||
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
|
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
|
||||||
const response = await fetch(endpoint);
|
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to get duplicates count: ${response.statusText}`);
|
throw new Error(`Failed to get duplicates count: ${response.statusText}`);
|
||||||
@@ -103,10 +106,11 @@ export class ModelDuplicatesManager {
|
|||||||
|
|
||||||
async findDuplicates() {
|
async findDuplicates() {
|
||||||
try {
|
try {
|
||||||
// Determine API endpoint based on model type
|
const params = this._buildFilterQueryParams();
|
||||||
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
|
const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
|
||||||
|
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
|
||||||
|
|
||||||
const response = await fetch(endpoint);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to find duplicates: ${response.statusText}`);
|
throw new Error(`Failed to find duplicates: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
@@ -123,6 +127,10 @@ export class ModelDuplicatesManager {
|
|||||||
|
|
||||||
if (this.duplicateGroups.length === 0) {
|
if (this.duplicateGroups.length === 0) {
|
||||||
showToast('toast.duplicates.noDuplicatesFound', { type: this.modelType }, 'info');
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +143,51 @@ export class ModelDuplicatesManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
enterDuplicateMode() {
|
||||||
this.inDuplicateMode = true;
|
this.inDuplicateMode = true;
|
||||||
this.selectedForDeletion.clear();
|
this.selectedForDeletion.clear();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class RecipeCard {
|
|||||||
card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0;
|
card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0;
|
||||||
card.dataset.created = this.recipe.created_date;
|
card.dataset.created = this.recipe.created_date;
|
||||||
card.dataset.id = this.recipe.id || '';
|
card.dataset.id = this.recipe.id || '';
|
||||||
|
card.dataset.folder = this.recipe.folder || '';
|
||||||
|
|
||||||
// Get base model with fallback
|
// Get base model with fallback
|
||||||
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
|
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,13 @@ import { PageControls } from './PageControls.js';
|
|||||||
import { LorasControls } from './LorasControls.js';
|
import { LorasControls } from './LorasControls.js';
|
||||||
import { CheckpointsControls } from './CheckpointsControls.js';
|
import { CheckpointsControls } from './CheckpointsControls.js';
|
||||||
import { EmbeddingsControls } from './EmbeddingsControls.js';
|
import { EmbeddingsControls } from './EmbeddingsControls.js';
|
||||||
import { MiscControls } from './MiscControls.js';
|
|
||||||
|
|
||||||
// Export the classes
|
// Export the classes
|
||||||
export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls, MiscControls };
|
export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory function to create the appropriate controls based on page type
|
* Factory function to create the appropriate controls based on page type
|
||||||
* @param {string} pageType - The type of page ('loras', 'checkpoints', 'embeddings', or 'misc')
|
* @param {string} pageType - The type of page ('loras', 'checkpoints', or 'embeddings')
|
||||||
* @returns {PageControls} - The appropriate controls instance
|
* @returns {PageControls} - The appropriate controls instance
|
||||||
*/
|
*/
|
||||||
export function createPageControls(pageType) {
|
export function createPageControls(pageType) {
|
||||||
@@ -20,8 +19,6 @@ export function createPageControls(pageType) {
|
|||||||
return new CheckpointsControls();
|
return new CheckpointsControls();
|
||||||
} else if (pageType === 'embeddings') {
|
} else if (pageType === 'embeddings') {
|
||||||
return new EmbeddingsControls();
|
return new EmbeddingsControls();
|
||||||
} else if (pageType === 'misc') {
|
|
||||||
return new MiscControls();
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`Unknown page type: ${pageType}`);
|
console.error(`Unknown page type: ${pageType}`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -199,6 +199,12 @@ class InitializationManager {
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
console.log('Received progress update:', data);
|
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
|
// Check if this update is for our page type
|
||||||
if (data.pageType && data.pageType !== this.pageType) {
|
if (data.pageType && data.pageType !== this.pageType) {
|
||||||
console.log(`Ignoring update for ${data.pageType}, we're on ${this.pageType}`);
|
console.log(`Ignoring update for ${data.pageType}, we're on ${this.pageType}`);
|
||||||
@@ -466,6 +472,29 @@ 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
|
* Clean up resources when the component is destroyed
|
||||||
*/
|
*/
|
||||||
|
|||||||
871
static/js/components/model-modal/MetadataPanel.js
Normal file
871
static/js/components/model-modal/MetadataPanel.js
Normal file
@@ -0,0 +1,871 @@
|
|||||||
|
/**
|
||||||
|
* MetadataPanel - Right panel for model metadata and tabs
|
||||||
|
* Features:
|
||||||
|
* - Fixed header with model info
|
||||||
|
* - Compact metadata grid
|
||||||
|
* - Editable fields (usage tips, trigger words, notes)
|
||||||
|
* - Tabs with accordion content (Description, Versions, Recipes)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escapeHtml, formatFileSize } from '../shared/utils.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
import { VersionsTab } from './VersionsTab.js';
|
||||||
|
import { RecipesTab } from './RecipesTab.js';
|
||||||
|
|
||||||
|
export class MetadataPanel {
|
||||||
|
constructor(container) {
|
||||||
|
this.element = container;
|
||||||
|
this.model = null;
|
||||||
|
this.modelType = null;
|
||||||
|
this.activeTab = 'description';
|
||||||
|
this.versionsTab = null;
|
||||||
|
this.recipesTab = null;
|
||||||
|
this.notesDebounceTimer = null;
|
||||||
|
this.isEditingUsageTips = false;
|
||||||
|
this.isEditingTriggerWords = false;
|
||||||
|
this.editingTriggerWords = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the metadata panel
|
||||||
|
*/
|
||||||
|
render({ model, modelType }) {
|
||||||
|
this.model = model;
|
||||||
|
this.modelType = modelType;
|
||||||
|
|
||||||
|
this.element.innerHTML = this.getTemplate();
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the HTML template
|
||||||
|
*/
|
||||||
|
getTemplate() {
|
||||||
|
const m = this.model;
|
||||||
|
const civitai = m.civitai || {};
|
||||||
|
const creator = civitai.creator || {};
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="metadata__header">
|
||||||
|
<div class="metadata__title-row">
|
||||||
|
<h2 class="metadata__name">${escapeHtml(m.model_name || 'Unknown')}</h2>
|
||||||
|
<button class="metadata__edit-btn" data-action="edit-name" title="${translate('modals.model.actions.editModelName', {}, 'Edit model name')}">
|
||||||
|
<i class="fas fa-pencil-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata__actions">
|
||||||
|
${creator.username ? `
|
||||||
|
<div class="metadata__creator" data-action="view-creator" data-username="${escapeHtml(creator.username)}">
|
||||||
|
${creator.image ? `
|
||||||
|
<div class="metadata__creator-avatar">
|
||||||
|
<img src="${creator.image}" alt="${escapeHtml(creator.username)}" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
|
<i class="fas fa-user" style="display: none;"></i>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="metadata__creator-avatar">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
<span class="metadata__creator-name">${escapeHtml(creator.username)}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${m.from_civitai ? `
|
||||||
|
<a class="metadata__civitai-link" href="https://civitai.com/models/${civitai.modelId}" target="_blank" rel="noopener">
|
||||||
|
<i class="fas fa-globe"></i>
|
||||||
|
<span>${translate('modals.model.actions.viewOnCivitai', {}, 'Civitai')}</span>
|
||||||
|
</a>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${this.renderLicenseIcons()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.renderTags(m.tags)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata__info">
|
||||||
|
<div class="metadata__info-grid">
|
||||||
|
<div class="metadata__info-item">
|
||||||
|
<span class="metadata__info-label">${translate('modals.model.metadata.version', {}, 'Version')}</span>
|
||||||
|
<span class="metadata__info-value">${escapeHtml(civitai.name || 'N/A')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata__info-item">
|
||||||
|
<span class="metadata__info-label">${translate('modals.model.metadata.size', {}, 'Size')}</span>
|
||||||
|
<span class="metadata__info-value metadata__info-value--mono">${formatFileSize(m.file_size)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata__info-item">
|
||||||
|
<span class="metadata__info-label">${translate('modals.model.metadata.baseModel', {}, 'Base Model')}</span>
|
||||||
|
<span class="metadata__info-value">${escapeHtml(m.base_model || translate('modals.model.metadata.unknown', {}, 'Unknown'))}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata__info-item">
|
||||||
|
<span class="metadata__info-label">${translate('modals.model.metadata.fileName', {}, 'File Name')}</span>
|
||||||
|
<span class="metadata__info-value metadata__info-value--mono">${escapeHtml(m.file_name || 'N/A')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata__info-item metadata__info-item--full">
|
||||||
|
<span class="metadata__info-label">${translate('modals.model.metadata.location', {}, 'Location')}</span>
|
||||||
|
<span class="metadata__info-value metadata__info-value--path" data-action="open-location" title="${translate('modals.model.actions.openFileLocation', {}, 'Open file location')}">
|
||||||
|
${escapeHtml((m.file_path || '').replace(/[^/]+$/, '') || 'N/A')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.modelType === 'loras' ? this.renderLoraSpecific() : ''}
|
||||||
|
|
||||||
|
${this.renderNotes(m.notes)}
|
||||||
|
|
||||||
|
<div class="metadata__content">
|
||||||
|
${this.renderTabs()}
|
||||||
|
${this.renderTabPanels()}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render license icons
|
||||||
|
*/
|
||||||
|
renderLicenseIcons() {
|
||||||
|
const license = this.model.civitai?.model;
|
||||||
|
if (!license) return '';
|
||||||
|
|
||||||
|
const icons = [];
|
||||||
|
|
||||||
|
if (license.allowNoCredit === false) {
|
||||||
|
icons.push({ icon: 'user-check', title: translate('modals.model.license.creditRequired', {}, 'Creator credit required') });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.allowCommercialUse) {
|
||||||
|
const restrictions = this.resolveCommercialRestrictions(license.allowCommercialUse);
|
||||||
|
restrictions.forEach(r => {
|
||||||
|
icons.push({ icon: r.icon, title: r.title });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.allowDerivatives === false) {
|
||||||
|
icons.push({ icon: 'exchange-off', title: translate('modals.model.license.noDerivatives', {}, 'No sharing merges') });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.allowDifferentLicense === false) {
|
||||||
|
icons.push({ icon: 'rotate-2', title: translate('modals.model.license.noReLicense', {}, 'Same permissions required') });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (icons.length === 0) return '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="metadata__licenses">
|
||||||
|
${icons.map(icon => `
|
||||||
|
<span class="metadata__license-icon"
|
||||||
|
style="--license-icon-image: url('/loras_static/images/tabler/${icon.icon}.svg')"
|
||||||
|
title="${escapeHtml(icon.title)}"
|
||||||
|
role="img"
|
||||||
|
aria-label="${escapeHtml(icon.title)}">
|
||||||
|
</span>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve commercial restrictions
|
||||||
|
*/
|
||||||
|
resolveCommercialRestrictions(value) {
|
||||||
|
const COMMERCIAL_CONFIG = [
|
||||||
|
{ key: 'image', icon: 'photo-off', title: translate('modals.model.license.noImageSell', {}, 'No selling generated content') },
|
||||||
|
{ key: 'rentcivit', icon: 'brush-off', title: translate('modals.model.license.noRentCivit', {}, 'No Civitai generation') },
|
||||||
|
{ key: 'rent', icon: 'world-off', title: translate('modals.model.license.noRent', {}, 'No generation services') },
|
||||||
|
{ key: 'sell', icon: 'shopping-cart-off', title: translate('modals.model.license.noSell', {}, 'No selling models') },
|
||||||
|
];
|
||||||
|
|
||||||
|
let allowed = new Set();
|
||||||
|
const values = Array.isArray(value) ? value : [value];
|
||||||
|
|
||||||
|
values.forEach(v => {
|
||||||
|
if (!v && v !== '') return;
|
||||||
|
const cleaned = String(v).trim().toLowerCase().replace(/[\s_-]+/g, '').replace(/[^a-z]/g, '');
|
||||||
|
if (cleaned) allowed.add(cleaned);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allowed.has('sell')) {
|
||||||
|
allowed.add('rent');
|
||||||
|
allowed.add('rentcivit');
|
||||||
|
allowed.add('image');
|
||||||
|
}
|
||||||
|
if (allowed.has('rent')) {
|
||||||
|
allowed.add('rentcivit');
|
||||||
|
}
|
||||||
|
|
||||||
|
return COMMERCIAL_CONFIG.filter(config => !allowed.has(config.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render tags
|
||||||
|
*/
|
||||||
|
renderTags(tags) {
|
||||||
|
if (!tags || tags.length === 0) return '';
|
||||||
|
|
||||||
|
const visibleTags = tags.slice(0, 8);
|
||||||
|
const remaining = tags.length - visibleTags.length;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="metadata__tags">
|
||||||
|
${visibleTags.map(tag => `
|
||||||
|
<span class="metadata__tag">${escapeHtml(tag)}</span>
|
||||||
|
`).join('')}
|
||||||
|
${remaining > 0 ? `<span class="metadata__tag">+${remaining}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render LoRA specific sections with editing
|
||||||
|
*/
|
||||||
|
renderLoraSpecific() {
|
||||||
|
const m = this.model;
|
||||||
|
const usageTips = m.usage_tips ? JSON.parse(m.usage_tips) : {};
|
||||||
|
const triggerWords = this.isEditingTriggerWords
|
||||||
|
? this.editingTriggerWords
|
||||||
|
: (m.civitai?.trainedWords || []);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="metadata__section">
|
||||||
|
<div class="metadata__section-header">
|
||||||
|
<span class="metadata__section-title">${translate('modals.model.metadata.usageTips', {}, 'Usage Tips')}</span>
|
||||||
|
${!this.isEditingUsageTips ? `
|
||||||
|
<button class="metadata__section-edit" data-action="edit-usage-tips" title="${translate('modals.model.usageTips.add', {}, 'Add usage tip')}">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="metadata__tags--editable">
|
||||||
|
${Object.entries(usageTips).map(([key, value]) => `
|
||||||
|
<span class="metadata__tag metadata__tag--editable" data-key="${escapeHtml(key)}" data-action="remove-usage-tip" title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||||
|
${escapeHtml(key)}: ${escapeHtml(String(value))}
|
||||||
|
</span>
|
||||||
|
`).join('')}
|
||||||
|
${this.isEditingUsageTips ? this.renderUsageTipEditor() : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata__section">
|
||||||
|
<div class="metadata__section-header">
|
||||||
|
<span class="metadata__section-title">${translate('modals.model.triggerWords.label', {}, 'Trigger Words')}</span>
|
||||||
|
<div class="metadata__section-actions">
|
||||||
|
${!this.isEditingTriggerWords ? `
|
||||||
|
<button class="metadata__section-edit" data-action="copy-trigger-words" title="${translate('modals.model.triggerWords.copyWord', {}, 'Copy all trigger words')}">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
<button class="metadata__section-edit" data-action="edit-trigger-words" title="${translate('modals.model.triggerWords.edit', {}, 'Edit trigger words')}">
|
||||||
|
<i class="fas fa-pencil-alt"></i>
|
||||||
|
</button>
|
||||||
|
` : `
|
||||||
|
<button class="metadata__section-edit" data-action="cancel-trigger-words" title="${translate('common.actions.cancel', {}, 'Cancel')}">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
<button class="metadata__section-edit metadata__section-edit--primary" data-action="save-trigger-words" title="${translate('common.actions.save', {}, 'Save')}">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</button>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="metadata__tags--editable">
|
||||||
|
${triggerWords.map(word => `
|
||||||
|
<span class="metadata__tag ${this.isEditingTriggerWords ? 'metadata__tag--removable' : 'metadata__tag--editable'}"
|
||||||
|
data-word="${escapeHtml(word)}"
|
||||||
|
${this.isEditingTriggerWords ? 'data-action="remove-trigger-word"' : 'data-action="copy-trigger-word"'}
|
||||||
|
title="${this.isEditingTriggerWords ? translate('common.actions.delete', {}, 'Delete') : translate('modals.model.triggerWords.copyWord', {}, 'Copy trigger word')}">
|
||||||
|
${escapeHtml(word)}
|
||||||
|
${this.isEditingTriggerWords ? '<i class="fas fa-times"></i>' : ''}
|
||||||
|
</span>
|
||||||
|
`).join('')}
|
||||||
|
${this.isEditingTriggerWords ? `
|
||||||
|
<input type="text"
|
||||||
|
class="metadata__tag-input"
|
||||||
|
placeholder="${translate('modals.model.triggerWords.addPlaceholder', {}, 'Type to add...')}"
|
||||||
|
data-action="add-trigger-word-input"
|
||||||
|
autofocus>
|
||||||
|
` : triggerWords.length === 0 ? `
|
||||||
|
<span class="metadata__tag metadata__tag--placeholder">${translate('modals.model.triggerWords.noTriggerWordsNeeded', {}, 'No trigger words needed')}</span>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render usage tip editor
|
||||||
|
*/
|
||||||
|
renderUsageTipEditor() {
|
||||||
|
return `
|
||||||
|
<div class="usage-tip-editor">
|
||||||
|
<select class="usage-tip-key" data-action="usage-tip-key-change">
|
||||||
|
<option value="">${translate('modals.model.usageTips.addPresetParameter', {}, 'Select parameter...')}</option>
|
||||||
|
<option value="strength">${translate('modals.model.usageTips.strength', {}, 'Strength')}</option>
|
||||||
|
<option value="strength_min">${translate('modals.model.usageTips.strengthMin', {}, 'Strength Min')}</option>
|
||||||
|
<option value="strength_max">${translate('modals.model.usageTips.strengthMax', {}, 'Strength Max')}</option>
|
||||||
|
<option value="clip_strength">${translate('modals.model.usageTips.clipStrength', {}, 'Clip Strength')}</option>
|
||||||
|
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
|
||||||
|
</select>
|
||||||
|
<input type="text"
|
||||||
|
class="usage-tip-value"
|
||||||
|
placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}"
|
||||||
|
data-action="usage-tip-value-input">
|
||||||
|
<button class="usage-tip-add" data-action="add-usage-tip">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</button>
|
||||||
|
<button class="usage-tip-cancel" data-action="cancel-usage-tips">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render notes section
|
||||||
|
*/
|
||||||
|
renderNotes(notes) {
|
||||||
|
return `
|
||||||
|
<div class="metadata__section metadata__section--notes">
|
||||||
|
<div class="metadata__section-header">
|
||||||
|
<span class="metadata__section-title">${translate('modals.model.metadata.additionalNotes', {}, 'Notes')}</span>
|
||||||
|
<span class="metadata__save-indicator" data-save-indicator style="display: none;">
|
||||||
|
<i class="fas fa-check"></i> Saved
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<textarea class="metadata__notes"
|
||||||
|
placeholder="${translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}"
|
||||||
|
data-action="notes-input">${escapeHtml(notes || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render tabs
|
||||||
|
*/
|
||||||
|
renderTabs() {
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'description', label: translate('modals.model.tabs.description', {}, 'Description') },
|
||||||
|
{ id: 'versions', label: translate('modals.model.tabs.versions', {}, 'Versions') },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.modelType === 'loras') {
|
||||||
|
tabs.push({ id: 'recipes', label: translate('modals.model.tabs.recipes', {}, 'Recipes') });
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="tabs">
|
||||||
|
${tabs.map(tab => `
|
||||||
|
<button class="tab ${tab.id === this.activeTab ? 'active' : ''}"
|
||||||
|
data-tab="${tab.id}"
|
||||||
|
data-action="switch-tab">
|
||||||
|
<span class="tab__label">${tab.label}</span>
|
||||||
|
${tab.id === 'versions' && this.model.update_available ? `
|
||||||
|
<span class="tab__badge tab__badge--pulse">${translate('modals.model.tabs.update', {}, 'Update')}</span>
|
||||||
|
` : ''}
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render tab panels
|
||||||
|
*/
|
||||||
|
renderTabPanels() {
|
||||||
|
const civitai = this.model.civitai || {};
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="tab-panels">
|
||||||
|
<div class="tab-panel ${this.activeTab === 'description' ? 'active' : ''}" data-panel="description">
|
||||||
|
<div class="accordion expanded">
|
||||||
|
<div class="accordion__header" data-action="toggle-accordion">
|
||||||
|
<span class="accordion__title">${translate('modals.model.metadata.aboutThisVersion', {}, 'About this version')}</span>
|
||||||
|
<i class="accordion__icon fas fa-chevron-down"></i>
|
||||||
|
</div>
|
||||||
|
<div class="accordion__content">
|
||||||
|
<div class="accordion__body">
|
||||||
|
${civitai.description ? `
|
||||||
|
<div class="markdown-content">${civitai.description}</div>
|
||||||
|
` : `
|
||||||
|
<p class="text-muted">${translate('modals.model.description.noDescription', {}, 'No description available')}</p>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="accordion">
|
||||||
|
<div class="accordion__header" data-action="toggle-accordion">
|
||||||
|
<span class="accordion__title">${translate('modals.model.accordion.modelDescription', {}, 'Model Description')}</span>
|
||||||
|
<i class="accordion__icon fas fa-chevron-down"></i>
|
||||||
|
</div>
|
||||||
|
<div class="accordion__content">
|
||||||
|
<div class="accordion__body">
|
||||||
|
${civitai.model?.description ? `
|
||||||
|
<div class="markdown-content">${civitai.model.description}</div>
|
||||||
|
` : `
|
||||||
|
<p class="text-muted">${translate('modals.model.description.noDescription', {}, 'No description available')}</p>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel ${this.activeTab === 'versions' ? 'active' : ''}" data-panel="versions">
|
||||||
|
<div class="versions-tab-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.modelType === 'loras' ? `
|
||||||
|
<div class="tab-panel ${this.activeTab === 'recipes' ? 'active' : ''}" data-panel="recipes">
|
||||||
|
<div class="recipes-tab-container"></div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind event listeners
|
||||||
|
*/
|
||||||
|
bindEvents() {
|
||||||
|
this.element.addEventListener('click', (e) => {
|
||||||
|
const target = e.target.closest('[data-action]');
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const action = target.dataset.action;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'switch-tab':
|
||||||
|
const tabId = target.dataset.tab;
|
||||||
|
this.switchTab(tabId);
|
||||||
|
break;
|
||||||
|
case 'toggle-accordion':
|
||||||
|
target.closest('.accordion')?.classList.toggle('expanded');
|
||||||
|
break;
|
||||||
|
case 'open-location':
|
||||||
|
this.openFileLocation();
|
||||||
|
break;
|
||||||
|
case 'view-creator':
|
||||||
|
const username = target.dataset.username || target.closest('[data-username]')?.dataset.username;
|
||||||
|
if (username) {
|
||||||
|
window.open(`https://civitai.com/user/${username}`, '_blank');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'edit-name':
|
||||||
|
this.editModelName();
|
||||||
|
break;
|
||||||
|
case 'edit-usage-tips':
|
||||||
|
this.startEditingUsageTips();
|
||||||
|
break;
|
||||||
|
case 'cancel-usage-tips':
|
||||||
|
this.cancelEditingUsageTips();
|
||||||
|
break;
|
||||||
|
case 'add-usage-tip':
|
||||||
|
this.addUsageTip();
|
||||||
|
break;
|
||||||
|
case 'remove-usage-tip':
|
||||||
|
const key = target.dataset.key;
|
||||||
|
if (key) this.removeUsageTip(key);
|
||||||
|
break;
|
||||||
|
case 'edit-trigger-words':
|
||||||
|
this.startEditingTriggerWords();
|
||||||
|
break;
|
||||||
|
case 'cancel-trigger-words':
|
||||||
|
this.cancelEditingTriggerWords();
|
||||||
|
break;
|
||||||
|
case 'save-trigger-words':
|
||||||
|
this.saveTriggerWords();
|
||||||
|
break;
|
||||||
|
case 'copy-trigger-words':
|
||||||
|
this.copyAllTriggerWords();
|
||||||
|
break;
|
||||||
|
case 'copy-trigger-word':
|
||||||
|
const word = target.dataset.word;
|
||||||
|
if (word) this.copyTriggerWord(word);
|
||||||
|
break;
|
||||||
|
case 'remove-trigger-word':
|
||||||
|
const wordToRemove = target.dataset.word || target.closest('[data-word]')?.dataset.word;
|
||||||
|
if (wordToRemove) this.removeTriggerWord(wordToRemove);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle input events
|
||||||
|
this.element.addEventListener('input', (e) => {
|
||||||
|
if (e.target.dataset.action === 'notes-input') {
|
||||||
|
this.handleNotesInput(e.target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.element.addEventListener('keydown', (e) => {
|
||||||
|
if (e.target.dataset.action === 'add-trigger-word-input' && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const value = e.target.value.trim();
|
||||||
|
if (value) {
|
||||||
|
this.addTriggerWord(value);
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.dataset.action === 'usage-tip-value-input' && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.addUsageTip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load initial tab content
|
||||||
|
if (this.activeTab === 'versions') {
|
||||||
|
this.loadVersionsTab();
|
||||||
|
} else if (this.activeTab === 'recipes') {
|
||||||
|
this.loadRecipesTab();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch active tab
|
||||||
|
*/
|
||||||
|
switchTab(tabId) {
|
||||||
|
this.activeTab = tabId;
|
||||||
|
|
||||||
|
// Update tab buttons
|
||||||
|
this.element.querySelectorAll('.tab').forEach(tab => {
|
||||||
|
tab.classList.toggle('active', tab.dataset.tab === tabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update panels
|
||||||
|
this.element.querySelectorAll('.tab-panel').forEach(panel => {
|
||||||
|
panel.classList.toggle('active', panel.dataset.panel === tabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load tab-specific data
|
||||||
|
if (tabId === 'versions') {
|
||||||
|
this.loadVersionsTab();
|
||||||
|
} else if (tabId === 'recipes') {
|
||||||
|
this.loadRecipesTab();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load versions tab
|
||||||
|
*/
|
||||||
|
loadVersionsTab() {
|
||||||
|
if (!this.versionsTab) {
|
||||||
|
const container = this.element.querySelector('.versions-tab-container');
|
||||||
|
if (container) {
|
||||||
|
this.versionsTab = new VersionsTab(container);
|
||||||
|
this.versionsTab.render({ model: this.model, modelType: this.modelType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load recipes tab
|
||||||
|
*/
|
||||||
|
loadRecipesTab() {
|
||||||
|
if (!this.recipesTab) {
|
||||||
|
const container = this.element.querySelector('.recipes-tab-container');
|
||||||
|
if (container) {
|
||||||
|
this.recipesTab = new RecipesTab(container);
|
||||||
|
this.recipesTab.render({ model: this.model });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle notes input with auto-save
|
||||||
|
*/
|
||||||
|
handleNotesInput(value) {
|
||||||
|
// Clear existing timer
|
||||||
|
if (this.notesDebounceTimer) {
|
||||||
|
clearTimeout(this.notesDebounceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show saving indicator
|
||||||
|
const indicator = this.element.querySelector('[data-save-indicator]');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving...';
|
||||||
|
indicator.style.display = 'inline-flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce save
|
||||||
|
this.notesDebounceTimer = setTimeout(() => {
|
||||||
|
this.saveNotes(value);
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save notes to server
|
||||||
|
*/
|
||||||
|
async saveNotes(notes) {
|
||||||
|
if (!this.model?.file_path) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getModelApiClient(this.modelType);
|
||||||
|
await client.saveModelMetadata(this.model.file_path, { notes });
|
||||||
|
|
||||||
|
const indicator = this.element.querySelector('[data-save-indicator]');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.innerHTML = '<i class="fas fa-check"></i> Saved';
|
||||||
|
setTimeout(() => {
|
||||||
|
indicator.style.display = 'none';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('modals.model.notes.saved', {}, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save notes:', err);
|
||||||
|
|
||||||
|
const indicator = this.element.querySelector('[data-save-indicator]');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start editing usage tips
|
||||||
|
*/
|
||||||
|
startEditingUsageTips() {
|
||||||
|
this.isEditingUsageTips = true;
|
||||||
|
this.refreshLoraSpecificSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel editing usage tips
|
||||||
|
*/
|
||||||
|
cancelEditingUsageTips() {
|
||||||
|
this.isEditingUsageTips = false;
|
||||||
|
this.refreshLoraSpecificSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add usage tip
|
||||||
|
*/
|
||||||
|
async addUsageTip() {
|
||||||
|
const keySelect = this.element.querySelector('.usage-tip-key');
|
||||||
|
const valueInput = this.element.querySelector('.usage-tip-value');
|
||||||
|
|
||||||
|
const key = keySelect?.value;
|
||||||
|
const value = valueInput?.value.trim();
|
||||||
|
|
||||||
|
if (!key || !value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const usageTips = this.model.usage_tips ? JSON.parse(this.model.usage_tips) : {};
|
||||||
|
usageTips[key] = value;
|
||||||
|
|
||||||
|
const client = getModelApiClient(this.modelType);
|
||||||
|
await client.saveModelMetadata(this.model.file_path, { usage_tips: JSON.stringify(usageTips) });
|
||||||
|
|
||||||
|
this.model.usage_tips = JSON.stringify(usageTips);
|
||||||
|
this.isEditingUsageTips = false;
|
||||||
|
this.refreshLoraSpecificSection();
|
||||||
|
showToast('common.actions.save', {}, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save usage tip:', err);
|
||||||
|
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove usage tip
|
||||||
|
*/
|
||||||
|
async removeUsageTip(key) {
|
||||||
|
try {
|
||||||
|
const usageTips = this.model.usage_tips ? JSON.parse(this.model.usage_tips) : {};
|
||||||
|
delete usageTips[key];
|
||||||
|
|
||||||
|
const client = getModelApiClient(this.modelType);
|
||||||
|
await client.saveModelMetadata(this.model.file_path, {
|
||||||
|
usage_tips: Object.keys(usageTips).length > 0 ? JSON.stringify(usageTips) : null
|
||||||
|
});
|
||||||
|
|
||||||
|
this.model.usage_tips = Object.keys(usageTips).length > 0 ? JSON.stringify(usageTips) : null;
|
||||||
|
this.refreshLoraSpecificSection();
|
||||||
|
showToast('common.actions.delete', {}, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to remove usage tip:', err);
|
||||||
|
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start editing trigger words
|
||||||
|
*/
|
||||||
|
startEditingTriggerWords() {
|
||||||
|
this.isEditingTriggerWords = true;
|
||||||
|
this.editingTriggerWords = [...(this.model.civitai?.trainedWords || [])];
|
||||||
|
this.refreshLoraSpecificSection();
|
||||||
|
|
||||||
|
// Focus input
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = this.element.querySelector('.metadata__tag-input');
|
||||||
|
if (input) input.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel editing trigger words
|
||||||
|
*/
|
||||||
|
cancelEditingTriggerWords() {
|
||||||
|
this.isEditingTriggerWords = false;
|
||||||
|
this.editingTriggerWords = [];
|
||||||
|
this.refreshLoraSpecificSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add trigger word during editing
|
||||||
|
*/
|
||||||
|
addTriggerWord(word) {
|
||||||
|
if (!word.trim()) return;
|
||||||
|
if (this.editingTriggerWords.includes(word.trim())) {
|
||||||
|
showToast('modals.model.triggerWords.validation.duplicate', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.editingTriggerWords.push(word.trim());
|
||||||
|
this.refreshLoraSpecificSection();
|
||||||
|
|
||||||
|
// Focus input again
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = this.element.querySelector('.metadata__tag-input');
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove trigger word during editing
|
||||||
|
*/
|
||||||
|
removeTriggerWord(word) {
|
||||||
|
this.editingTriggerWords = this.editingTriggerWords.filter(w => w !== word);
|
||||||
|
this.refreshLoraSpecificSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save trigger words
|
||||||
|
*/
|
||||||
|
async saveTriggerWords() {
|
||||||
|
try {
|
||||||
|
const client = getModelApiClient(this.modelType);
|
||||||
|
await client.saveModelMetadata(this.model.file_path, {
|
||||||
|
trained_words: this.editingTriggerWords
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local model data
|
||||||
|
if (!this.model.civitai) this.model.civitai = {};
|
||||||
|
this.model.civitai.trainedWords = [...this.editingTriggerWords];
|
||||||
|
|
||||||
|
this.isEditingTriggerWords = false;
|
||||||
|
this.editingTriggerWords = [];
|
||||||
|
this.refreshLoraSpecificSection();
|
||||||
|
showToast('common.actions.save', {}, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save trigger words:', err);
|
||||||
|
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy single trigger word
|
||||||
|
*/
|
||||||
|
async copyTriggerWord(word) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(word);
|
||||||
|
showToast('modals.model.triggerWords.copyWord', {}, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy trigger word:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy all trigger words
|
||||||
|
*/
|
||||||
|
async copyAllTriggerWords() {
|
||||||
|
const words = this.model.civitai?.trainedWords || [];
|
||||||
|
if (words.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(words.join(', '));
|
||||||
|
showToast('modals.model.triggerWords.copyWord', {}, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy trigger words:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh LoRA specific section
|
||||||
|
*/
|
||||||
|
refreshLoraSpecificSection() {
|
||||||
|
if (this.modelType !== 'loras') return;
|
||||||
|
|
||||||
|
const sections = this.element.querySelectorAll('.metadata__section');
|
||||||
|
// First two sections are usage tips and trigger words
|
||||||
|
if (sections.length >= 2) {
|
||||||
|
const newHtml = this.renderLoraSpecific();
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = newHtml;
|
||||||
|
|
||||||
|
const newSections = tempDiv.querySelectorAll('.metadata__section');
|
||||||
|
if (newSections.length >= 2) {
|
||||||
|
sections[0].replaceWith(newSections[0]);
|
||||||
|
sections[1].replaceWith(newSections[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit model name
|
||||||
|
*/
|
||||||
|
async editModelName() {
|
||||||
|
const currentName = this.model.model_name || '';
|
||||||
|
const newName = prompt(
|
||||||
|
translate('modals.model.actions.editModelName', {}, 'Edit model name'),
|
||||||
|
currentName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newName !== null && newName.trim() !== '' && newName !== currentName) {
|
||||||
|
try {
|
||||||
|
const client = getModelApiClient(this.modelType);
|
||||||
|
await client.saveModelMetadata(this.model.file_path, { model_name: newName.trim() });
|
||||||
|
|
||||||
|
this.model.model_name = newName.trim();
|
||||||
|
this.element.querySelector('.metadata__name').textContent = newName.trim();
|
||||||
|
showToast('common.actions.save', {}, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save model name:', err);
|
||||||
|
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open file location
|
||||||
|
*/
|
||||||
|
async openFileLocation() {
|
||||||
|
if (!this.model?.file_path) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/open-file-location', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ file_path: this.model.file_path })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to open file location');
|
||||||
|
|
||||||
|
showToast('modals.model.openFileLocation.success', {}, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to open file location:', err);
|
||||||
|
showToast('modals.model.openFileLocation.failed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
374
static/js/components/model-modal/ModelModal.js
Normal file
374
static/js/components/model-modal/ModelModal.js
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
/**
|
||||||
|
* ModelModal - Main Controller for Split-View Overlay
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* - Overlay container (split-view grid)
|
||||||
|
* - Left: Showcase (ExampleShowcase component)
|
||||||
|
* - Right: Metadata + Tabs (MetadataPanel component)
|
||||||
|
* - Global keyboard navigation (↑↓ for model, ←→ for examples)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Showcase } from './Showcase.js';
|
||||||
|
import { MetadataPanel } from './MetadataPanel.js';
|
||||||
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
|
||||||
|
export class ModelModal {
|
||||||
|
static instance = null;
|
||||||
|
static overlayElement = null;
|
||||||
|
static currentModel = null;
|
||||||
|
static currentModelType = null;
|
||||||
|
static showcase = null;
|
||||||
|
static metadataPanel = null;
|
||||||
|
static isNavigating = false;
|
||||||
|
static keyboardHandler = null;
|
||||||
|
static hasShownHint = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the model modal with split-view overlay
|
||||||
|
* @param {Object} model - Model data object
|
||||||
|
* @param {string} modelType - Type of model ('loras', 'checkpoints', 'embeddings')
|
||||||
|
*/
|
||||||
|
static async show(model, modelType) {
|
||||||
|
// If already open, animate transition to new model
|
||||||
|
if (this.isOpen()) {
|
||||||
|
await this.transitionToModel(model, modelType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentModel = model;
|
||||||
|
this.currentModelType = modelType;
|
||||||
|
this.isNavigating = false;
|
||||||
|
|
||||||
|
// Fetch complete metadata
|
||||||
|
let completeCivitaiData = model.civitai || {};
|
||||||
|
if (model.file_path) {
|
||||||
|
try {
|
||||||
|
const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path);
|
||||||
|
completeCivitaiData = fullMetadata || model.civitai || {};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch complete metadata:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentModel = {
|
||||||
|
...model,
|
||||||
|
civitai: completeCivitaiData
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create overlay
|
||||||
|
this.createOverlay();
|
||||||
|
|
||||||
|
// Initialize components
|
||||||
|
this.showcase = new Showcase(this.overlayElement.querySelector('.showcase'));
|
||||||
|
this.metadataPanel = new MetadataPanel(this.overlayElement.querySelector('.metadata'));
|
||||||
|
|
||||||
|
// Render content
|
||||||
|
await this.render();
|
||||||
|
|
||||||
|
// Setup keyboard navigation
|
||||||
|
this.setupKeyboardNavigation();
|
||||||
|
|
||||||
|
// Lock body scroll
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
|
||||||
|
// Show hint on first use
|
||||||
|
if (!this.hasShownHint) {
|
||||||
|
this.showKeyboardHint();
|
||||||
|
this.hasShownHint = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the overlay DOM structure
|
||||||
|
*/
|
||||||
|
static createOverlay() {
|
||||||
|
// Check sidebar state for layout adjustment
|
||||||
|
const sidebar = document.querySelector('.folder-sidebar');
|
||||||
|
const isSidebarCollapsed = sidebar?.classList.contains('collapsed');
|
||||||
|
|
||||||
|
this.overlayElement = document.createElement('div');
|
||||||
|
this.overlayElement.className = `model-overlay ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`;
|
||||||
|
this.overlayElement.id = 'modelModal';
|
||||||
|
this.overlayElement.innerHTML = `
|
||||||
|
<button class="model-overlay__close" title="${translate('common.close', {}, 'Close')}">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
<div class="model-overlay__hint">
|
||||||
|
↑↓ ${translate('modals.model.navigation.switchModel', {}, 'Switch model')} |
|
||||||
|
←→ ${translate('modals.model.navigation.browseExamples', {}, 'Browse examples')} |
|
||||||
|
ESC ${translate('common.close', {}, 'Close')}
|
||||||
|
</div>
|
||||||
|
<div class="showcase"></div>
|
||||||
|
<div class="metadata"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Close button handler
|
||||||
|
this.overlayElement.querySelector('.model-overlay__close').addEventListener('click', () => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside to close
|
||||||
|
this.overlayElement.addEventListener('click', (e) => {
|
||||||
|
if (e.target === this.overlayElement) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(this.overlayElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render content into components
|
||||||
|
*/
|
||||||
|
static async render() {
|
||||||
|
if (!this.currentModel) return;
|
||||||
|
|
||||||
|
// Prepare images data
|
||||||
|
const regularImages = this.currentModel.civitai?.images || [];
|
||||||
|
const customImages = this.currentModel.civitai?.customImages || [];
|
||||||
|
const allImages = [...regularImages, ...customImages];
|
||||||
|
|
||||||
|
// Render showcase
|
||||||
|
this.showcase.render({
|
||||||
|
images: allImages,
|
||||||
|
modelHash: this.currentModel.sha256,
|
||||||
|
filePath: this.currentModel.file_path
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render metadata panel
|
||||||
|
this.metadataPanel.render({
|
||||||
|
model: this.currentModel,
|
||||||
|
modelType: this.currentModelType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition to a different model with animation
|
||||||
|
*/
|
||||||
|
static async transitionToModel(model, modelType) {
|
||||||
|
// Ensure components are initialized
|
||||||
|
if (!this.showcase || !this.metadataPanel) {
|
||||||
|
console.warn('Showcase or MetadataPanel not initialized, falling back to show()');
|
||||||
|
await this.show(model, modelType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade out current content
|
||||||
|
this.showcase?.element?.classList.add('transitioning');
|
||||||
|
this.metadataPanel?.element?.classList.add('transitioning');
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
// Fetch complete metadata for new model
|
||||||
|
let completeCivitaiData = model.civitai || {};
|
||||||
|
if (model.file_path) {
|
||||||
|
try {
|
||||||
|
const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path);
|
||||||
|
completeCivitaiData = fullMetadata || model.civitai || {};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch complete metadata:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update model data in-place
|
||||||
|
this.currentModel = {
|
||||||
|
...model,
|
||||||
|
civitai: completeCivitaiData
|
||||||
|
};
|
||||||
|
this.currentModelType = modelType;
|
||||||
|
|
||||||
|
// Render new content in-place
|
||||||
|
await this.render();
|
||||||
|
|
||||||
|
// Fade in new content
|
||||||
|
this.showcase?.element?.classList.remove('transitioning');
|
||||||
|
this.metadataPanel?.element?.classList.remove('transitioning');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the modal
|
||||||
|
*/
|
||||||
|
static close(animate = true) {
|
||||||
|
if (!this.overlayElement) return;
|
||||||
|
|
||||||
|
// Cleanup keyboard handler
|
||||||
|
this.cleanupKeyboardNavigation();
|
||||||
|
|
||||||
|
// Animate out
|
||||||
|
if (animate) {
|
||||||
|
this.overlayElement.classList.add('closing');
|
||||||
|
setTimeout(() => {
|
||||||
|
this.removeOverlay();
|
||||||
|
}, 200);
|
||||||
|
} else {
|
||||||
|
this.removeOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock body scroll
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove overlay from DOM
|
||||||
|
*/
|
||||||
|
static removeOverlay() {
|
||||||
|
if (this.overlayElement) {
|
||||||
|
this.overlayElement.remove();
|
||||||
|
this.overlayElement = null;
|
||||||
|
}
|
||||||
|
this.showcase = null;
|
||||||
|
this.metadataPanel = null;
|
||||||
|
this.currentModel = null;
|
||||||
|
this.currentModelType = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if modal is currently open
|
||||||
|
*/
|
||||||
|
static isOpen() {
|
||||||
|
return !!this.overlayElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup global keyboard navigation
|
||||||
|
*/
|
||||||
|
static setupKeyboardNavigation() {
|
||||||
|
this.keyboardHandler = (e) => {
|
||||||
|
// Ignore if user is typing in an input
|
||||||
|
if (this.isUserTyping()) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
this.navigateModel('prev');
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
this.navigateModel('next');
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
this.showcase?.prevImage();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
this.showcase?.nextImage();
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
this.close();
|
||||||
|
break;
|
||||||
|
case 'i':
|
||||||
|
case 'I':
|
||||||
|
if (!this.isUserTyping()) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.showcase?.toggleParams();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'c':
|
||||||
|
case 'C':
|
||||||
|
if (!this.isUserTyping()) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.showcase?.copyPrompt();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', this.keyboardHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup keyboard navigation
|
||||||
|
*/
|
||||||
|
static cleanupKeyboardNavigation() {
|
||||||
|
if (this.keyboardHandler) {
|
||||||
|
document.removeEventListener('keydown', this.keyboardHandler);
|
||||||
|
this.keyboardHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is currently typing in an input/editable field
|
||||||
|
*/
|
||||||
|
static isUserTyping() {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (!activeElement) return false;
|
||||||
|
|
||||||
|
const tagName = activeElement.tagName?.toLowerCase();
|
||||||
|
const isEditable = activeElement.isContentEditable;
|
||||||
|
const isInput = ['input', 'textarea', 'select'].includes(tagName);
|
||||||
|
|
||||||
|
return isEditable || isInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to previous/next model using virtual scroller
|
||||||
|
*/
|
||||||
|
static async navigateModel(direction) {
|
||||||
|
if (this.isNavigating || !this.currentModel?.file_path) return;
|
||||||
|
|
||||||
|
const scroller = state.virtualScroller;
|
||||||
|
if (!scroller || typeof scroller.getAdjacentItemByFilePath !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isNavigating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adjacent = await scroller.getAdjacentItemByFilePath(
|
||||||
|
this.currentModel.file_path,
|
||||||
|
direction
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!adjacent?.item) {
|
||||||
|
const toastKey = direction === 'prev'
|
||||||
|
? 'modals.model.navigation.noPrevious'
|
||||||
|
: 'modals.model.navigation.noNext';
|
||||||
|
const fallback = direction === 'prev'
|
||||||
|
? 'No previous model available'
|
||||||
|
: 'No next model available';
|
||||||
|
// Show toast notification (imported from utils)
|
||||||
|
import('../../utils/uiHelpers.js').then(({ showToast }) => {
|
||||||
|
showToast(toastKey, {}, 'info', fallback);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.transitionToModel(adjacent.item, this.currentModelType);
|
||||||
|
} finally {
|
||||||
|
this.isNavigating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show keyboard shortcut hint
|
||||||
|
*/
|
||||||
|
static showKeyboardHint() {
|
||||||
|
const hint = this.overlayElement?.querySelector('.model-overlay__hint');
|
||||||
|
if (hint) {
|
||||||
|
// Animation is handled by CSS, just ensure it's visible
|
||||||
|
hint.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update sidebar state when sidebar is toggled
|
||||||
|
*/
|
||||||
|
static updateSidebarState(collapsed) {
|
||||||
|
if (!this.overlayElement) return;
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
this.overlayElement.classList.add('sidebar-collapsed');
|
||||||
|
} else {
|
||||||
|
this.overlayElement.classList.remove('sidebar-collapsed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for sidebar toggle events
|
||||||
|
document.addEventListener('sidebar-toggle', (e) => {
|
||||||
|
ModelModal.updateSidebarState(e.detail.collapsed);
|
||||||
|
});
|
||||||
321
static/js/components/model-modal/RecipesTab.js
Normal file
321
static/js/components/model-modal/RecipesTab.js
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* RecipesTab - Recipe cards grid component for LoRA models
|
||||||
|
* Features:
|
||||||
|
* - Recipe cards grid layout
|
||||||
|
* - Copy/View actions
|
||||||
|
* - LoRA availability status badges
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escapeHtml } from '../shared/utils.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||||
|
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||||
|
|
||||||
|
export class RecipesTab {
|
||||||
|
constructor(container) {
|
||||||
|
this.element = container;
|
||||||
|
this.model = null;
|
||||||
|
this.recipes = [];
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the recipes tab
|
||||||
|
*/
|
||||||
|
async render({ model }) {
|
||||||
|
this.model = model;
|
||||||
|
this.element.innerHTML = this.getLoadingTemplate();
|
||||||
|
|
||||||
|
await this.loadRecipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get loading template
|
||||||
|
*/
|
||||||
|
getLoadingTemplate() {
|
||||||
|
return `
|
||||||
|
<div class="recipes-loading">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
<span>${translate('modals.model.loading.recipes', {}, 'Loading recipes...')}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load recipes from API
|
||||||
|
*/
|
||||||
|
async loadRecipes() {
|
||||||
|
const sha256 = this.model?.sha256;
|
||||||
|
|
||||||
|
if (!sha256) {
|
||||||
|
this.renderError('Missing model hash');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/lm/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Failed to load recipes');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recipes = data.recipes || [];
|
||||||
|
this.renderRecipes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load recipes:', error);
|
||||||
|
this.renderError(error.message);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render error state
|
||||||
|
*/
|
||||||
|
renderError(message) {
|
||||||
|
this.element.innerHTML = `
|
||||||
|
<div class="recipes-error">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
<p>${escapeHtml(message || 'Failed to load recipes. Please try again later.')}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render empty state
|
||||||
|
*/
|
||||||
|
renderEmpty() {
|
||||||
|
this.element.innerHTML = `
|
||||||
|
<div class="recipes-empty">
|
||||||
|
<i class="fas fa-book-open"></i>
|
||||||
|
<p>${translate('recipes.noRecipesFound', {}, 'No recipes found that use this LoRA.')}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render recipes grid
|
||||||
|
*/
|
||||||
|
renderRecipes() {
|
||||||
|
if (!this.recipes || this.recipes.length === 0) {
|
||||||
|
this.renderEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loraName = this.model?.model_name || '';
|
||||||
|
|
||||||
|
this.element.innerHTML = `
|
||||||
|
<div class="recipes-header">
|
||||||
|
<div class="recipes-header__text">
|
||||||
|
<span class="recipes-header__eyebrow">Linked recipes</span>
|
||||||
|
<h3>${this.recipes.length} recipe${this.recipes.length > 1 ? 's' : ''} using this LoRA</h3>
|
||||||
|
<p class="recipes-header__description">
|
||||||
|
${loraName ? `Discover workflows crafted for ${escapeHtml(loraName)}.` : 'Discover workflows crafted for this model.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="recipes-header__view-all" data-action="view-all">
|
||||||
|
<i class="fas fa-external-link-alt"></i>
|
||||||
|
<span>View all recipes</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="recipes-grid">
|
||||||
|
${this.recipes.map(recipe => this.renderRecipeCard(recipe)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single recipe card
|
||||||
|
*/
|
||||||
|
renderRecipeCard(recipe) {
|
||||||
|
const baseModel = recipe.base_model || '';
|
||||||
|
const loras = recipe.loras || [];
|
||||||
|
const lorasCount = loras.length;
|
||||||
|
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
||||||
|
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||||
|
|
||||||
|
let statusClass = 'empty';
|
||||||
|
let statusLabel = 'No linked LoRAs';
|
||||||
|
let statusTitle = 'No LoRAs in this recipe';
|
||||||
|
|
||||||
|
if (lorasCount > 0) {
|
||||||
|
if (allLorasAvailable) {
|
||||||
|
statusClass = 'ready';
|
||||||
|
statusLabel = `${lorasCount} LoRA${lorasCount > 1 ? 's' : ''} ready`;
|
||||||
|
statusTitle = 'All LoRAs available - Ready to use';
|
||||||
|
} else {
|
||||||
|
statusClass = 'missing';
|
||||||
|
statusLabel = `Missing ${missingLorasCount} of ${lorasCount}`;
|
||||||
|
statusTitle = `${missingLorasCount} of ${lorasCount} LoRAs missing`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = recipe.file_url ||
|
||||||
|
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
||||||
|
'/loras_static/images/no-preview.png');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="recipe-card"
|
||||||
|
data-recipe-id="${escapeHtml(recipe.id || '')}"
|
||||||
|
data-file-path="${escapeHtml(recipe.file_path || '')}"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="${recipe.title ? `View recipe ${escapeHtml(recipe.title)}` : 'View recipe details'}">
|
||||||
|
<div class="recipe-card__media">
|
||||||
|
<img src="${escapeHtml(imageUrl)}"
|
||||||
|
alt="${recipe.title ? escapeHtml(recipe.title) + ' preview' : 'Recipe preview'}"
|
||||||
|
loading="lazy">
|
||||||
|
<div class="recipe-card__media-top">
|
||||||
|
<button class="recipe-card__copy" data-action="copy-recipe" title="Copy recipe syntax">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-card__body">
|
||||||
|
<h4 class="recipe-card__title" title="${escapeHtml(recipe.title || 'Untitled recipe')}">
|
||||||
|
${escapeHtml(recipe.title || 'Untitled recipe')}
|
||||||
|
</h4>
|
||||||
|
<div class="recipe-card__meta">
|
||||||
|
${baseModel ? `<span class="recipe-card__badge recipe-card__badge--base">${escapeHtml(baseModel)}</span>` : ''}
|
||||||
|
<span class="recipe-card__badge recipe-card__badge--${statusClass}" title="${escapeHtml(statusTitle)}">
|
||||||
|
<i class="fas fa-layer-group"></i>
|
||||||
|
<span>${escapeHtml(statusLabel)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-card__cta">
|
||||||
|
<span>View details</span>
|
||||||
|
<i class="fas fa-arrow-right"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind event listeners
|
||||||
|
*/
|
||||||
|
bindEvents() {
|
||||||
|
this.element.addEventListener('click', async (e) => {
|
||||||
|
const target = e.target.closest('[data-action]');
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
const action = target.dataset.action;
|
||||||
|
|
||||||
|
if (action === 'view-all') {
|
||||||
|
await this.navigateToRecipesPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'copy-recipe') {
|
||||||
|
const card = target.closest('.recipe-card');
|
||||||
|
const recipeId = card?.dataset.recipeId;
|
||||||
|
if (recipeId) {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.copyRecipeSyntax(recipeId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card click - navigate to recipe
|
||||||
|
const card = e.target.closest('.recipe-card');
|
||||||
|
if (card && !e.target.closest('[data-action]')) {
|
||||||
|
const recipeId = card.dataset.recipeId;
|
||||||
|
if (recipeId) {
|
||||||
|
await this.navigateToRecipeDetails(recipeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard navigation for cards
|
||||||
|
this.element.addEventListener('keydown', async (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
const card = e.target.closest('.recipe-card');
|
||||||
|
if (card) {
|
||||||
|
e.preventDefault();
|
||||||
|
const recipeId = card.dataset.recipeId;
|
||||||
|
if (recipeId) {
|
||||||
|
await this.navigateToRecipeDetails(recipeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy recipe syntax to clipboard
|
||||||
|
*/
|
||||||
|
async copyRecipeSyntax(recipeId) {
|
||||||
|
if (!recipeId) {
|
||||||
|
showToast('toast.recipes.noRecipeId', {}, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/lm/recipe/${recipeId}/syntax`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.syntax) {
|
||||||
|
await copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'No syntax returned');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy recipe syntax:', err);
|
||||||
|
showToast('toast.recipes.copyFailed', { message: err.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to recipes page with filter
|
||||||
|
*/
|
||||||
|
async navigateToRecipesPage() {
|
||||||
|
// Close the modal
|
||||||
|
const { ModelModal } = await import('./ModelModal.js');
|
||||||
|
ModelModal.close();
|
||||||
|
|
||||||
|
// Clear any previous filters
|
||||||
|
removeSessionItem('filterLoraName');
|
||||||
|
removeSessionItem('filterLoraHash');
|
||||||
|
removeSessionItem('viewRecipeId');
|
||||||
|
|
||||||
|
// Store the LoRA name and hash filter in sessionStorage
|
||||||
|
setSessionItem('lora_to_recipe_filterLoraName', this.model?.model_name || '');
|
||||||
|
setSessionItem('lora_to_recipe_filterLoraHash', this.model?.sha256 || '');
|
||||||
|
|
||||||
|
// Navigate to recipes page
|
||||||
|
window.location.href = '/loras/recipes';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to specific recipe details
|
||||||
|
*/
|
||||||
|
async navigateToRecipeDetails(recipeId) {
|
||||||
|
// Close the modal
|
||||||
|
const { ModelModal } = await import('./ModelModal.js');
|
||||||
|
ModelModal.close();
|
||||||
|
|
||||||
|
// Clear any previous filters
|
||||||
|
removeSessionItem('filterLoraName');
|
||||||
|
removeSessionItem('filterLoraHash');
|
||||||
|
removeSessionItem('viewRecipeId');
|
||||||
|
|
||||||
|
// Store the recipe ID in sessionStorage to load on recipes page
|
||||||
|
setSessionItem('viewRecipeId', recipeId);
|
||||||
|
|
||||||
|
// Navigate to recipes page
|
||||||
|
window.location.href = '/loras/recipes';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh recipes
|
||||||
|
*/
|
||||||
|
async refresh() {
|
||||||
|
await this.loadRecipes();
|
||||||
|
}
|
||||||
|
}
|
||||||
1501
static/js/components/model-modal/Showcase.js
Normal file
1501
static/js/components/model-modal/Showcase.js
Normal file
File diff suppressed because it is too large
Load Diff
627
static/js/components/model-modal/VersionsTab.js
Normal file
627
static/js/components/model-modal/VersionsTab.js
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
/**
|
||||||
|
* VersionsTab - Model versions list component
|
||||||
|
* Features:
|
||||||
|
* - Version cards with preview, badges, and actions
|
||||||
|
* - Download/Delete/Ignore actions
|
||||||
|
* - Base model filter toggle
|
||||||
|
* - Reference: static/js/components/shared/ModelVersionsTab.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escapeHtml, formatFileSize } from '../shared/utils.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||||
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
|
|
||||||
|
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
||||||
|
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
||||||
|
|
||||||
|
const DISPLAY_FILTER_MODES = Object.freeze({
|
||||||
|
SAME_BASE: 'same_base',
|
||||||
|
ANY: 'any',
|
||||||
|
});
|
||||||
|
|
||||||
|
export class VersionsTab {
|
||||||
|
constructor(container) {
|
||||||
|
this.element = container;
|
||||||
|
this.model = null;
|
||||||
|
this.modelType = null;
|
||||||
|
this.versions = [];
|
||||||
|
this.isLoading = false;
|
||||||
|
this.displayMode = DISPLAY_FILTER_MODES.ANY;
|
||||||
|
this.record = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the versions tab
|
||||||
|
*/
|
||||||
|
async render({ model, modelType }) {
|
||||||
|
this.model = model;
|
||||||
|
this.modelType = modelType;
|
||||||
|
this.element.innerHTML = this.getLoadingTemplate();
|
||||||
|
|
||||||
|
await this.loadVersions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get loading template
|
||||||
|
*/
|
||||||
|
getLoadingTemplate() {
|
||||||
|
return `
|
||||||
|
<div class="versions-loading">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
<span>${translate('modals.model.loading.versions', {}, 'Loading versions...')}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load versions from API
|
||||||
|
*/
|
||||||
|
async loadVersions() {
|
||||||
|
const modelId = this.model?.civitai?.modelId;
|
||||||
|
|
||||||
|
if (!modelId) {
|
||||||
|
this.renderError(translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getModelApiClient(this.modelType);
|
||||||
|
const response = await client.fetchModelUpdateVersions(modelId, { refresh: false });
|
||||||
|
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || 'Failed to load versions');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.record = response.record;
|
||||||
|
this.renderVersions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load versions:', error);
|
||||||
|
this.renderError(error.message);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render error state
|
||||||
|
*/
|
||||||
|
renderError(message) {
|
||||||
|
this.element.innerHTML = `
|
||||||
|
<div class="versions-error">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<p>${escapeHtml(message || translate('modals.model.versions.error', {}, 'Failed to load versions.'))}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render empty state
|
||||||
|
*/
|
||||||
|
renderEmpty() {
|
||||||
|
this.element.innerHTML = `
|
||||||
|
<div class="versions-empty">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<p>${translate('modals.model.versions.empty', {}, 'No version history available for this model yet.')}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render versions list
|
||||||
|
*/
|
||||||
|
renderVersions() {
|
||||||
|
if (!this.record || !Array.isArray(this.record.versions) || this.record.versions.length === 0) {
|
||||||
|
this.renderEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentVersionId = this.model?.civitai?.versionId;
|
||||||
|
const sortedVersions = [...this.record.versions].sort((a, b) => Number(b.versionId) - Number(a.versionId));
|
||||||
|
|
||||||
|
// Filter versions based on display mode
|
||||||
|
const filteredVersions = this.filterVersions(sortedVersions, currentVersionId);
|
||||||
|
|
||||||
|
if (filteredVersions.length === 0) {
|
||||||
|
this.renderFilteredEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.element.innerHTML = `
|
||||||
|
${this.renderToolbar()}
|
||||||
|
<div class="versions-list">
|
||||||
|
${filteredVersions.map(version => this.renderVersionCard(version, currentVersionId)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter versions based on display mode
|
||||||
|
*/
|
||||||
|
filterVersions(versions, currentVersionId) {
|
||||||
|
const currentVersion = versions.find(v => v.versionId === currentVersionId);
|
||||||
|
const currentBaseModel = currentVersion?.baseModel;
|
||||||
|
|
||||||
|
if (this.displayMode !== DISPLAY_FILTER_MODES.SAME_BASE || !currentBaseModel) {
|
||||||
|
return versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions.filter(version => {
|
||||||
|
const versionBase = version.baseModel?.toLowerCase().trim();
|
||||||
|
const targetBase = currentBaseModel.toLowerCase().trim();
|
||||||
|
return versionBase === targetBase;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render filtered empty state
|
||||||
|
*/
|
||||||
|
renderFilteredEmpty() {
|
||||||
|
const currentVersion = this.record.versions.find(v => v.versionId === this.model?.civitai?.versionId);
|
||||||
|
const baseModelLabel = currentVersion?.baseModel || translate('modals.model.metadata.unknown', {}, 'Unknown');
|
||||||
|
|
||||||
|
this.element.innerHTML = `
|
||||||
|
${this.renderToolbar()}
|
||||||
|
<div class="versions-empty versions-empty-filter">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<p>${translate('modals.model.versions.filters.empty', { baseModel: baseModelLabel }, 'No versions match the current base model filter.')}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render toolbar with actions
|
||||||
|
*/
|
||||||
|
renderToolbar() {
|
||||||
|
const ignoreText = this.record.shouldIgnore
|
||||||
|
? translate('modals.model.versions.actions.resumeModelUpdates', {}, 'Resume updates for this model')
|
||||||
|
: translate('modals.model.versions.actions.ignoreModelUpdates', {}, 'Ignore updates for this model');
|
||||||
|
|
||||||
|
const isFilteringActive = this.displayMode === DISPLAY_FILTER_MODES.SAME_BASE;
|
||||||
|
const toggleTooltip = isFilteringActive
|
||||||
|
? translate('modals.model.versions.filters.tooltip.showAllVersions', {}, 'Switch to showing all versions')
|
||||||
|
: translate('modals.model.versions.filters.tooltip.showSameBaseVersions', {}, 'Switch to showing only versions with the current base model');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<header class="versions-toolbar">
|
||||||
|
<div class="versions-toolbar-info">
|
||||||
|
<div class="versions-toolbar-info-heading">
|
||||||
|
<h3>${translate('modals.model.versions.heading', {}, 'Model versions')}</h3>
|
||||||
|
<button class="versions-filter-toggle ${isFilteringActive ? 'active' : ''}"
|
||||||
|
data-action="toggle-filter"
|
||||||
|
title="${escapeHtml(toggleTooltip)}"
|
||||||
|
type="button">
|
||||||
|
<i class="fas fa-th-list"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p>${translate('modals.model.versions.copy', { count: this.record.versions.length }, 'Track and manage every version of this model in one place.')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="versions-toolbar-actions">
|
||||||
|
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-action="toggle-model-ignore">
|
||||||
|
${escapeHtml(ignoreText)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single version card
|
||||||
|
*/
|
||||||
|
renderVersionCard(version, currentVersionId) {
|
||||||
|
const isCurrent = version.versionId === currentVersionId;
|
||||||
|
const isInLibrary = version.isInLibrary;
|
||||||
|
const isNewer = this.isNewerVersion(version);
|
||||||
|
const badges = this.buildBadges(version, isCurrent, isNewer);
|
||||||
|
const actions = this.buildActions(version);
|
||||||
|
|
||||||
|
const metaParts = [];
|
||||||
|
if (version.baseModel) metaParts.push(`<span class="version-meta-primary">${escapeHtml(version.baseModel)}</span>`);
|
||||||
|
if (version.releasedAt) {
|
||||||
|
const date = new Date(version.releasedAt);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
metaParts.push(escapeHtml(date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (version.sizeBytes > 0) metaParts.push(escapeHtml(formatFileSize(version.sizeBytes)));
|
||||||
|
|
||||||
|
const metaMarkup = metaParts.length > 0
|
||||||
|
? metaParts.map(m => `<span class="version-meta-item">${m}</span>`).join('<span class="version-meta-separator">•</span>')
|
||||||
|
: escapeHtml(translate('modals.model.versions.labels.noDetails', {}, 'No additional details'));
|
||||||
|
|
||||||
|
const civitaiUrl = this.buildCivitaiUrl(version.modelId, version.versionId);
|
||||||
|
const clickAction = civitaiUrl ? `data-civitai-url="${escapeHtml(civitaiUrl)}"` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="version-card ${isCurrent ? 'is-current' : ''} ${civitaiUrl ? 'is-clickable' : ''}"
|
||||||
|
data-version-id="${version.versionId}"
|
||||||
|
${clickAction}>
|
||||||
|
${this.renderMedia(version)}
|
||||||
|
<div class="version-details">
|
||||||
|
<div class="version-title">
|
||||||
|
<span class="version-name">${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="version-badges">${badges}</div>
|
||||||
|
<div class="version-meta">${metaMarkup}</div>
|
||||||
|
</div>
|
||||||
|
<div class="version-actions">
|
||||||
|
${actions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if version is newer than any in library
|
||||||
|
*/
|
||||||
|
isNewerVersion(version) {
|
||||||
|
if (!this.record?.inLibraryVersionIds?.length) return false;
|
||||||
|
if (version.isInLibrary) return false;
|
||||||
|
const maxInLibrary = Math.max(...this.record.inLibraryVersionIds);
|
||||||
|
return version.versionId > maxInLibrary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build badges HTML
|
||||||
|
*/
|
||||||
|
buildBadges(version, isCurrent, isNewer) {
|
||||||
|
const badges = [];
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
badges.push(this.createBadge(
|
||||||
|
translate('modals.model.versions.badges.current', {}, 'Current Version'),
|
||||||
|
'current'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version.isInLibrary) {
|
||||||
|
badges.push(this.createBadge(
|
||||||
|
translate('modals.model.versions.badges.inLibrary', {}, 'In Library'),
|
||||||
|
'success'
|
||||||
|
));
|
||||||
|
} else if (isNewer && !version.shouldIgnore) {
|
||||||
|
badges.push(this.createBadge(
|
||||||
|
translate('modals.model.versions.badges.newer', {}, 'Newer Version'),
|
||||||
|
'info'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version.shouldIgnore) {
|
||||||
|
badges.push(this.createBadge(
|
||||||
|
translate('modals.model.versions.badges.ignored', {}, 'Ignored'),
|
||||||
|
'muted'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return badges.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a badge element
|
||||||
|
*/
|
||||||
|
createBadge(label, tone) {
|
||||||
|
return `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build actions HTML
|
||||||
|
*/
|
||||||
|
buildActions(version) {
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
if (!version.isInLibrary) {
|
||||||
|
actions.push(`
|
||||||
|
<button class="version-action version-action-primary" data-action="download">
|
||||||
|
${escapeHtml(translate('modals.model.versions.actions.download', {}, 'Download'))}
|
||||||
|
</button>
|
||||||
|
`);
|
||||||
|
} else if (version.filePath) {
|
||||||
|
actions.push(`
|
||||||
|
<button class="version-action version-action-danger" data-action="delete">
|
||||||
|
${escapeHtml(translate('modals.model.versions.actions.delete', {}, 'Delete'))}
|
||||||
|
</button>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignoreLabel = version.shouldIgnore
|
||||||
|
? translate('modals.model.versions.actions.unignore', {}, 'Unignore')
|
||||||
|
: translate('modals.model.versions.actions.ignore', {}, 'Ignore');
|
||||||
|
|
||||||
|
actions.push(`
|
||||||
|
<button class="version-action version-action-ghost" data-action="toggle-ignore">
|
||||||
|
${escapeHtml(ignoreLabel)}
|
||||||
|
</button>
|
||||||
|
`);
|
||||||
|
|
||||||
|
return actions.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render media (image/video)
|
||||||
|
*/
|
||||||
|
renderMedia(version) {
|
||||||
|
if (!version.previewUrl) {
|
||||||
|
return `
|
||||||
|
<div class="version-media version-media-placeholder">
|
||||||
|
${escapeHtml(translate('modals.model.versions.media.placeholder', {}, 'No preview'))}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isVideoUrl(version.previewUrl)) {
|
||||||
|
return `
|
||||||
|
<div class="version-media">
|
||||||
|
<video src="${escapeHtml(version.previewUrl)}"
|
||||||
|
controls muted loop playsinline preload="metadata">
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="version-media">
|
||||||
|
<img src="${escapeHtml(version.previewUrl)}"
|
||||||
|
alt="${escapeHtml(version.name || 'preview')}"
|
||||||
|
loading="lazy">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if URL is a video
|
||||||
|
*/
|
||||||
|
isVideoUrl(url) {
|
||||||
|
if (!url) return false;
|
||||||
|
const extension = url.split('.').pop()?.toLowerCase()?.split('?')[0];
|
||||||
|
return VIDEO_EXTENSIONS.includes(`.${extension}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Civitai URL
|
||||||
|
*/
|
||||||
|
buildCivitaiUrl(modelId, versionId) {
|
||||||
|
if (!modelId || !versionId) return null;
|
||||||
|
return `https://civitai.com/models/${encodeURIComponent(modelId)}?modelVersionId=${encodeURIComponent(versionId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind event listeners
|
||||||
|
*/
|
||||||
|
bindEvents() {
|
||||||
|
this.element.addEventListener('click', (e) => {
|
||||||
|
const target = e.target.closest('[data-action]');
|
||||||
|
if (!target) {
|
||||||
|
// Check if clicked on a clickable card
|
||||||
|
const card = e.target.closest('.version-card.is-clickable');
|
||||||
|
if (card && !e.target.closest('.version-actions')) {
|
||||||
|
const url = card.dataset.civitaiUrl;
|
||||||
|
if (url) window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = target.dataset.action;
|
||||||
|
const card = target.closest('.version-card');
|
||||||
|
const versionId = card ? parseInt(card.dataset.versionId, 10) : null;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'toggle-filter':
|
||||||
|
this.toggleFilterMode();
|
||||||
|
break;
|
||||||
|
case 'toggle-model-ignore':
|
||||||
|
this.handleToggleModelIgnore();
|
||||||
|
break;
|
||||||
|
case 'download':
|
||||||
|
if (versionId) this.handleDownload(versionId, target);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
if (versionId) this.handleDelete(versionId, target);
|
||||||
|
break;
|
||||||
|
case 'toggle-ignore':
|
||||||
|
if (versionId) this.handleToggleVersionIgnore(versionId, target);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle filter mode
|
||||||
|
*/
|
||||||
|
toggleFilterMode() {
|
||||||
|
this.displayMode = this.displayMode === DISPLAY_FILTER_MODES.SAME_BASE
|
||||||
|
? DISPLAY_FILTER_MODES.ANY
|
||||||
|
: DISPLAY_FILTER_MODES.SAME_BASE;
|
||||||
|
this.renderVersions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle toggle model ignore
|
||||||
|
*/
|
||||||
|
async handleToggleModelIgnore() {
|
||||||
|
if (!this.record) return;
|
||||||
|
|
||||||
|
const modelId = this.record.modelId;
|
||||||
|
const nextValue = !this.record.shouldIgnore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getModelApiClient(this.modelType);
|
||||||
|
const response = await client.setModelUpdateIgnore(modelId, nextValue);
|
||||||
|
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.record = response.record;
|
||||||
|
this.renderVersions();
|
||||||
|
|
||||||
|
const toastKey = nextValue
|
||||||
|
? 'modals.model.versions.toast.modelIgnored'
|
||||||
|
: 'modals.model.versions.toast.modelResumed';
|
||||||
|
showToast(toastKey, {}, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle model ignore:', error);
|
||||||
|
showToast(error?.message || 'Failed to update ignore preference', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle download version
|
||||||
|
*/
|
||||||
|
async handleDownload(versionId, button) {
|
||||||
|
const version = this.record.versions.find(v => v.versionId === versionId);
|
||||||
|
if (!version) return;
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadManager.downloadVersionWithDefaults(
|
||||||
|
this.modelType,
|
||||||
|
this.record.modelId,
|
||||||
|
versionId,
|
||||||
|
{ versionName: version.name || `#${versionId}` }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload versions after download starts
|
||||||
|
setTimeout(() => this.loadVersions(), 1000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download version:', error);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle delete version
|
||||||
|
*/
|
||||||
|
async handleDelete(versionId, button) {
|
||||||
|
const version = this.record.versions.find(v => v.versionId === versionId);
|
||||||
|
if (!version?.filePath) return;
|
||||||
|
|
||||||
|
const confirmed = await this.showDeleteConfirmation(version);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getModelApiClient(this.modelType);
|
||||||
|
await client.deleteModel(version.filePath);
|
||||||
|
|
||||||
|
showToast('modals.model.versions.toast.versionDeleted', {}, 'success');
|
||||||
|
await this.loadVersions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete version:', error);
|
||||||
|
showToast(error?.message || 'Failed to delete version', {}, 'error');
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show delete confirmation modal
|
||||||
|
*/
|
||||||
|
async showDeleteConfirmation(version) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const modalRecord = modalManager?.getModal?.('deleteModal');
|
||||||
|
if (!modalRecord?.element) {
|
||||||
|
// Fallback to browser confirm
|
||||||
|
const message = translate('modals.model.versions.confirm.delete', {}, 'Delete this version from your library?');
|
||||||
|
resolve(window.confirm(message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = translate('modals.model.versions.actions.delete', {}, 'Delete');
|
||||||
|
const message = translate('modals.model.versions.confirm.delete', {}, 'Delete this version from your library?');
|
||||||
|
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div class="modal-content delete-modal-content version-delete-modal">
|
||||||
|
<h2>${escapeHtml(title)}</h2>
|
||||||
|
<p class="delete-message">${escapeHtml(message)}</p>
|
||||||
|
<div class="delete-model-info">
|
||||||
|
<div class="delete-preview">
|
||||||
|
${version.previewUrl ? `
|
||||||
|
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(versionName)}"
|
||||||
|
onerror="this.src='${PREVIEW_PLACEHOLDER_URL}'">
|
||||||
|
` : `<img src="${PREVIEW_PLACEHOLDER_URL}" alt="${escapeHtml(versionName)}">`}
|
||||||
|
</div>
|
||||||
|
<div class="delete-info">
|
||||||
|
<h3>${escapeHtml(versionName)}</h3>
|
||||||
|
${version.baseModel ? `<p class="version-base-model">${escapeHtml(version.baseModel)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel-btn" data-action="cancel">${escapeHtml(translate('common.actions.cancel', {}, 'Cancel'))}</button>
|
||||||
|
<button class="delete-btn" data-action="confirm">${escapeHtml(translate('common.actions.delete', {}, 'Delete'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modalManager.showModal('deleteModal', content);
|
||||||
|
|
||||||
|
const modalElement = modalRecord.element;
|
||||||
|
const handleAction = (e) => {
|
||||||
|
const action = e.target.closest('[data-action]')?.dataset.action;
|
||||||
|
if (action === 'confirm') {
|
||||||
|
modalManager.closeModal('deleteModal');
|
||||||
|
resolve(true);
|
||||||
|
} else if (action === 'cancel') {
|
||||||
|
modalManager.closeModal('deleteModal');
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
modalElement.addEventListener('click', handleAction, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle toggle version ignore
|
||||||
|
*/
|
||||||
|
async handleToggleVersionIgnore(versionId, button) {
|
||||||
|
const version = this.record.versions.find(v => v.versionId === versionId);
|
||||||
|
if (!version) return;
|
||||||
|
|
||||||
|
const nextValue = !version.shouldIgnore;
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getModelApiClient(this.modelType);
|
||||||
|
const response = await client.setVersionUpdateIgnore(
|
||||||
|
this.record.modelId,
|
||||||
|
versionId,
|
||||||
|
nextValue
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.record = response.record;
|
||||||
|
this.renderVersions();
|
||||||
|
|
||||||
|
const updatedVersion = response.record.versions.find(v => v.versionId === versionId);
|
||||||
|
const toastKey = updatedVersion?.shouldIgnore
|
||||||
|
? 'modals.model.versions.toast.versionIgnored'
|
||||||
|
: 'modals.model.versions.toast.versionUnignored';
|
||||||
|
showToast(toastKey, {}, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle version ignore:', error);
|
||||||
|
showToast(error?.message || 'Failed to update version preference', {}, 'error');
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh versions
|
||||||
|
*/
|
||||||
|
async refresh() {
|
||||||
|
await this.loadVersions();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
static/js/components/model-modal/index.js
Normal file
16
static/js/components/model-modal/index.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Model Modal - New Split-View Overlay Design
|
||||||
|
* Phase 1 Implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ModelModal } from './ModelModal.js';
|
||||||
|
|
||||||
|
// Export the public API
|
||||||
|
export const modelModal = {
|
||||||
|
show: ModelModal.show.bind(ModelModal),
|
||||||
|
close: ModelModal.close.bind(ModelModal),
|
||||||
|
isOpen: ModelModal.isOpen.bind(ModelModal),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default export for convenience
|
||||||
|
export default modelModal;
|
||||||
@@ -214,52 +214,6 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
|
|||||||
missingNodesMessage,
|
missingNodesMessage,
|
||||||
missingTargetMessage,
|
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 {
|
} else {
|
||||||
showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info');
|
showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info');
|
||||||
}
|
}
|
||||||
@@ -276,10 +230,6 @@ function handleCopyAction(card, modelType) {
|
|||||||
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
||||||
const embeddingName = card.dataset.file_name;
|
const embeddingName = card.dataset.file_name;
|
||||||
copyToClipboard(embeddingName, 'Embedding name copied');
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import { loadRecipesForLora } from './RecipeTab.js';
|
|||||||
import { translate } from '../../utils/i18nHelpers.js';
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
|
|
||||||
|
// Import 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 = '') {
|
function getModalFilePath(fallback = '') {
|
||||||
const modalElement = document.getElementById('modelModal');
|
const modalElement = document.getElementById('modelModal');
|
||||||
if (modalElement && modalElement.dataset && modalElement.dataset.filePath) {
|
if (modalElement && modalElement.dataset && modalElement.dataset.filePath) {
|
||||||
@@ -238,6 +244,12 @@ function renderLicenseIcons(modelData) {
|
|||||||
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
|
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
|
||||||
*/
|
*/
|
||||||
export async function showModelModal(model, modelType) {
|
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 modalId = 'modelModal';
|
||||||
const modalTitle = model.model_name;
|
const modalTitle = model.model_name;
|
||||||
cleanupNavigationShortcuts();
|
cleanupNavigationShortcuts();
|
||||||
@@ -1020,11 +1032,5 @@ async function openFileLocation(filePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export the model modal API
|
// Re-export for compatibility
|
||||||
const modelModal = {
|
export { toggleShowcase, scrollToTop };
|
||||||
show: showModelModal,
|
|
||||||
toggleShowcase,
|
|
||||||
scrollToTop
|
|
||||||
};
|
|
||||||
|
|
||||||
export { modelModal };
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export class AppCore {
|
|||||||
initializePageFeatures() {
|
initializePageFeatures() {
|
||||||
const pageType = this.getPageType();
|
const pageType = this.getPageType();
|
||||||
|
|
||||||
if (['loras', 'recipes', 'checkpoints', 'embeddings', 'misc'].includes(pageType)) {
|
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) {
|
||||||
this.initializeContextMenus(pageType);
|
this.initializeContextMenus(pageType);
|
||||||
initializeInfiniteScroll(pageType);
|
initializeInfiniteScroll(pageType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
removeStorageItem
|
removeStorageItem
|
||||||
} from '../utils/storageHelpers.js';
|
} from '../utils/storageHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js'
|
import { state } from '../state/index.js';
|
||||||
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
|
||||||
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
|
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_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_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
|
||||||
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
|
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
|
||||||
@@ -293,6 +295,177 @@ class BannerService {
|
|||||||
location.reload();
|
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() {
|
prepareCommunitySupportBanner() {
|
||||||
if (this.isBannerDismissed(COMMUNITY_SUPPORT_BANNER_ID)) {
|
if (this.isBannerDismissed(COMMUNITY_SUPPORT_BANNER_ID)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -64,17 +64,6 @@ export class BulkManager {
|
|||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: 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: {
|
recipes: {
|
||||||
addTags: false,
|
addTags: false,
|
||||||
sendToWorkflow: false,
|
sendToWorkflow: false,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class ExampleImagesManager {
|
|||||||
// Auto download properties
|
// Auto download properties
|
||||||
this.autoDownloadInterval = null;
|
this.autoDownloadInterval = null;
|
||||||
this.lastAutoDownloadCheck = 0;
|
this.lastAutoDownloadCheck = 0;
|
||||||
this.autoDownloadCheckInterval = 10 * 60 * 1000; // 10 minutes in milliseconds
|
this.autoDownloadCheckInterval = 30 * 60 * 1000; // 30 minutes in milliseconds
|
||||||
this.pageInitTime = Date.now(); // Track when page was initialized
|
this.pageInitTime = Date.now(); // Track when page was initialized
|
||||||
|
|
||||||
// Initialize download path field and check download status
|
// Initialize download path field and check download status
|
||||||
@@ -808,19 +808,58 @@ export class ExampleImagesManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastAutoDownloadCheck = now;
|
|
||||||
|
|
||||||
if (!this.canAutoDownload()) {
|
if (!this.canAutoDownload()) {
|
||||||
console.log('Auto download conditions not met, skipping check');
|
console.log('Auto download conditions not met, skipping check');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Performing auto download check...');
|
console.log('Performing auto download pre-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;
|
const optimize = state.global.settings.optimize_example_images;
|
||||||
|
|
||||||
const response = await fetch('/api/lm/download-example-images', {
|
fetch('/api/lm/download-example-images', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -830,18 +869,29 @@ export class ExampleImagesManager {
|
|||||||
model_types: ['lora', 'checkpoint', 'embedding'],
|
model_types: ['lora', 'checkpoint', 'embedding'],
|
||||||
auto_mode: true // Flag to indicate this is an automatic download
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
// Immediately return without waiting for the download fetch to complete
|
||||||
|
// This keeps the UI responsive
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Auto download check error:', error);
|
console.error('Auto download check error:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export class FilterManager {
|
|||||||
this.initializeLicenseFilters();
|
this.initializeLicenseFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize tag logic toggle
|
||||||
|
this.initializeTagLogicToggle();
|
||||||
|
|
||||||
// Add click handler for filter button
|
// Add click handler for filter button
|
||||||
if (this.filterButton) {
|
if (this.filterButton) {
|
||||||
this.filterButton.addEventListener('click', () => {
|
this.filterButton.addEventListener('click', () => {
|
||||||
@@ -84,6 +87,45 @@ export class FilterManager {
|
|||||||
this.loadFiltersFromStorage();
|
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() {
|
async loadTopTags() {
|
||||||
try {
|
try {
|
||||||
// Show loading state
|
// Show loading state
|
||||||
@@ -549,6 +591,17 @@ export class FilterManager {
|
|||||||
showToast('toast.filters.cleared', {}, 'info');
|
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() {
|
async clearFilters() {
|
||||||
@@ -562,9 +615,13 @@ export class FilterManager {
|
|||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: {},
|
tags: {},
|
||||||
license: {},
|
license: {},
|
||||||
modelTypes: []
|
modelTypes: [],
|
||||||
|
tagLogic: 'any'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update tag logic toggle UI
|
||||||
|
this.updateTagLogicToggleUI();
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
pageState.filters = this.cloneFilters();
|
pageState.filters = this.cloneFilters();
|
||||||
@@ -609,6 +666,7 @@ export class FilterManager {
|
|||||||
pageState.filters = this.cloneFilters();
|
pageState.filters = this.cloneFilters();
|
||||||
|
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
|
this.updateTagLogicToggleUI();
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
if (this.hasActiveFilters()) {
|
if (this.hasActiveFilters()) {
|
||||||
@@ -644,7 +702,8 @@ export class FilterManager {
|
|||||||
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
|
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
|
||||||
tags: this.normalizeTagFilters(source.tags),
|
tags: this.normalizeTagFilters(source.tags),
|
||||||
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
|
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
|
||||||
modelTypes: this.normalizeModelTypeFilters(source.modelTypes)
|
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
|
||||||
|
tagLogic: source.tagLogic || 'any'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,7 +785,8 @@ export class FilterManager {
|
|||||||
baseModel: [...(this.filters.baseModel || [])],
|
baseModel: [...(this.filters.baseModel || [])],
|
||||||
tags: { ...(this.filters.tags || {}) },
|
tags: { ...(this.filters.tags || {}) },
|
||||||
license: { ...(this.filters.license || {}) },
|
license: { ...(this.filters.license || {}) },
|
||||||
modelTypes: [...(this.filters.modelTypes || [])]
|
modelTypes: [...(this.filters.modelTypes || [])],
|
||||||
|
tagLogic: this.filters.tagLogic || 'any'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -751,12 +751,7 @@ export class FilterPresetManager {
|
|||||||
|
|
||||||
const presetName = document.createElement('span');
|
const presetName = document.createElement('span');
|
||||||
presetName.className = 'preset-name';
|
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}"`);
|
presetName.title = translate('header.filter.presetClickTooltip', { name: preset.name }, `Click to apply preset "${preset.name}"`);
|
||||||
|
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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
Reference in New Issue
Block a user