mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-19 08:52:05 -03:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
999814ca87 | ||
|
|
3c2760a803 | ||
|
|
0edbd7bcca | ||
|
|
21e89fa7de | ||
|
|
968d6d1d1f | ||
|
|
cf0fd0e0ad | ||
|
|
16e5dcf7b2 | ||
|
|
ab6bb25d46 | ||
|
|
07f49559be | ||
|
|
b24b1a7e57 | ||
|
|
faf64f8986 | ||
|
|
a617487a43 | ||
|
|
3012a7aef3 | ||
|
|
499e19de34 | ||
|
|
9161762ca9 | ||
|
|
9bbd26efe6 | ||
|
|
258b2622d5 | ||
|
|
80ec9085dd | ||
|
|
c5c7373e10 | ||
|
|
b7721866e5 | ||
|
|
8314b9bedb | ||
|
|
75298a402f | ||
|
|
92b5efd414 | ||
|
|
33ee392b7b | ||
|
|
5237f8b7dc | ||
|
|
5107313fd1 | ||
|
|
95bbc66919 | ||
|
|
e268e59419 | ||
|
|
547e1f9498 | ||
|
|
bf32d8b6fd | ||
|
|
8299881024 | ||
|
|
da02268196 | ||
|
|
8c4b9a1e70 | ||
|
|
0906c484e9 | ||
|
|
4199c30fec | ||
|
|
4a8084cdbc | ||
|
|
6263e6848c | ||
|
|
58c266ad07 | ||
|
|
2939813e1a | ||
|
|
a9e5ee7e79 | ||
|
|
a17b0e9901 | ||
|
|
8f23d966bf | ||
|
|
7a76fc72d0 | ||
|
|
518a4dd5ee | ||
|
|
2b6d4e5d8b | ||
|
|
1f4edbeb9d | ||
|
|
a256558a0e | ||
|
|
818b9113f0 | ||
|
|
6a4fd020dc | ||
|
|
7a23040452 | ||
|
|
138024aefe | ||
|
|
a19ddc14f6 | ||
|
|
7001ced694 | ||
|
|
a5c861646c | ||
|
|
3e0bb73793 | ||
|
|
ac51f6a2f6 | ||
|
|
bef222c77d | ||
|
|
7cd6a53447 | ||
|
|
6850b35770 | ||
|
|
237a015cde | ||
|
|
1ae2778baa | ||
|
|
84fcdb5f20 | ||
|
|
8a0b368b44 | ||
|
|
3990535505 | ||
|
|
3e961a9860 | ||
|
|
d6669f1d04 | ||
|
|
519bafebc8 | ||
|
|
d87863b423 | ||
|
|
84e9fe2dfb | ||
|
|
46cbcf94c8 | ||
|
|
05f3018495 | ||
|
|
f565cc35ca | ||
|
|
dd1cdce16d | ||
|
|
a9e0e7dc8d | ||
|
|
b302d1db7d | ||
|
|
7cbddd9cf7 | ||
|
|
cb8c699224 | ||
|
|
451f74b874 | ||
|
|
a1d248baa6 | ||
|
|
18577fa336 | ||
|
|
5797ce9408 | ||
|
|
826f06255a | ||
|
|
84e16b5c5b | ||
|
|
eb22054580 | ||
|
|
08afb05ece | ||
|
|
f51f125cf1 | ||
|
|
24b2078f21 | ||
|
|
130fb5d2d5 | ||
|
|
23c6863a3a | ||
|
|
c0e2578640 | ||
|
|
e3c812367e | ||
|
|
4d239008a6 | ||
|
|
00177a06d0 | ||
|
|
568daa351e | ||
|
|
5a4664fa12 | ||
|
|
dd5b213adc | ||
|
|
d9ee9b3155 | ||
|
|
01dac57c35 | ||
|
|
7f92d09239 | ||
|
|
62f9e3f44a | ||
|
|
e55895786d | ||
|
|
4e3ede23b7 |
@@ -1,153 +0,0 @@
|
||||
# Recipe Batch Import Feature Design
|
||||
|
||||
## Overview
|
||||
Enable users to import multiple images as recipes in a single operation, rather than processing them individually. This feature addresses the need for efficient bulk recipe creation from existing image collections.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ BatchImportManager.js │
|
||||
│ ├── InputCollector (收集URL列表/目录路径) │
|
||||
│ ├── ConcurrencyController (自适应并发控制) │
|
||||
│ ├── ProgressTracker (进度追踪) │
|
||||
│ └── ResultAggregator (结果汇总) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ batch_import_modal.html │
|
||||
│ └── 批量导入UI组件 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ batch_import_progress.css │
|
||||
│ └── 进度显示样式 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Backend │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ py/routes/handlers/recipe_handlers.py │
|
||||
│ ├── start_batch_import() - 启动批量导入 │
|
||||
│ ├── get_batch_import_progress() - 查询进度 │
|
||||
│ └── cancel_batch_import() - 取消导入 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ py/services/batch_import_service.py │
|
||||
│ ├── 自适应并发执行 │
|
||||
│ ├── 结果汇总 │
|
||||
│ └── WebSocket进度广播 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/lm/recipes/batch-import/start` | POST | 启动批量导入,返回 operation_id |
|
||||
| `/api/lm/recipes/batch-import/progress` | GET | 查询进度状态 |
|
||||
| `/api/lm/recipes/batch-import/cancel` | POST | 取消导入 |
|
||||
|
||||
## Backend Implementation Details
|
||||
|
||||
### BatchImportService
|
||||
|
||||
Location: `py/services/batch_import_service.py`
|
||||
|
||||
Key classes:
|
||||
- `BatchImportItem`: Dataclass for individual import item
|
||||
- `BatchImportProgress`: Dataclass for tracking progress
|
||||
- `BatchImportService`: Main service class
|
||||
|
||||
Features:
|
||||
- Adaptive concurrency control (adjusts based on success/failure rate)
|
||||
- WebSocket progress broadcasting
|
||||
- Graceful error handling (individual failures don't stop the batch)
|
||||
- Result aggregation
|
||||
|
||||
### WebSocket Message Format
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "batch_import_progress",
|
||||
"operation_id": "xxx",
|
||||
"total": 50,
|
||||
"completed": 23,
|
||||
"success": 21,
|
||||
"failed": 2,
|
||||
"skipped": 0,
|
||||
"current_item": "image_024.png",
|
||||
"status": "running"
|
||||
}
|
||||
```
|
||||
|
||||
### Input Types
|
||||
|
||||
1. **URL List**: Array of URLs (http/https)
|
||||
2. **Local Paths**: Array of local file paths
|
||||
3. **Directory**: Path to directory with optional recursive flag
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Invalid URLs/paths: Skip and record error
|
||||
- Download failures: Record error, continue
|
||||
- Metadata extraction failures: Mark as "no metadata"
|
||||
- Duplicate detection: Option to skip duplicates
|
||||
|
||||
## Frontend Implementation Details (TODO)
|
||||
|
||||
### UI Components
|
||||
|
||||
1. **BatchImportModal**: Main modal with tabs for URLs/Directory input
|
||||
2. **ProgressDisplay**: Real-time progress bar and status
|
||||
3. **ResultsSummary**: Final results with success/failure breakdown
|
||||
|
||||
### Adaptive Concurrency Controller
|
||||
|
||||
```javascript
|
||||
class AdaptiveConcurrencyController {
|
||||
constructor(options = {}) {
|
||||
this.minConcurrency = options.minConcurrency || 1;
|
||||
this.maxConcurrency = options.maxConcurrency || 5;
|
||||
this.currentConcurrency = options.initialConcurrency || 3;
|
||||
}
|
||||
|
||||
adjustConcurrency(taskDuration, success) {
|
||||
if (success && taskDuration < 1000 && this.currentConcurrency < this.maxConcurrency) {
|
||||
this.currentConcurrency = Math.min(this.currentConcurrency + 1, this.maxConcurrency);
|
||||
}
|
||||
if (!success || taskDuration > 10000) {
|
||||
this.currentConcurrency = Math.max(this.currentConcurrency - 1, this.minConcurrency);
|
||||
}
|
||||
return this.currentConcurrency;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
Backend (implemented):
|
||||
├── py/services/batch_import_service.py # 后端服务
|
||||
├── py/routes/handlers/batch_import_handler.py # API处理器 (added to recipe_handlers.py)
|
||||
├── tests/services/test_batch_import_service.py # 单元测试
|
||||
└── tests/routes/test_batch_import_routes.py # API集成测试
|
||||
|
||||
Frontend (TODO):
|
||||
├── static/js/managers/BatchImportManager.js # 主管理器
|
||||
├── static/js/managers/batch/ # 子模块
|
||||
│ ├── ConcurrencyController.js # 并发控制
|
||||
│ ├── ProgressTracker.js # 进度追踪
|
||||
│ └── ResultAggregator.js # 结果汇总
|
||||
├── static/css/components/batch-import-modal.css # 样式
|
||||
└── templates/components/batch_import_modal.html # Modal模板
|
||||
```
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- [x] Backend BatchImportService
|
||||
- [x] Backend API handlers
|
||||
- [x] WebSocket progress broadcasting
|
||||
- [x] Unit tests
|
||||
- [x] Integration tests
|
||||
- [ ] Frontend BatchImportManager
|
||||
- [ ] Frontend UI components
|
||||
- [ ] E2E tests
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -12,12 +12,14 @@ coverage/
|
||||
.coverage
|
||||
model_cache/
|
||||
|
||||
# agent
|
||||
# agent / dev tooling
|
||||
.opencode/
|
||||
.claude/
|
||||
.sisyphus/
|
||||
.codex
|
||||
.omo
|
||||
reasonix.toml
|
||||
.codegraph/
|
||||
|
||||
# Vue widgets development cache (but keep build output)
|
||||
vue-widgets/node_modules/
|
||||
@@ -26,3 +28,6 @@ vue-widgets/dist/
|
||||
|
||||
# Hypothesis test cache
|
||||
.hypothesis/
|
||||
|
||||
# Working/research notes (not committed)
|
||||
.docs/
|
||||
|
||||
181
.omo/plans/embeddings-hybrid-approach.md
Normal file
181
.omo/plans/embeddings-hybrid-approach.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Embeddings Usage Tracking — Hybrid Approach (Plan C)
|
||||
|
||||
> **Status**: Reference document for future implementation
|
||||
> **Current implementation**: Plan A (prompt text parsing only, see `usage_stats.py:_process_embeddings`)
|
||||
> **Next step**: Add Plan B as a supplement when edge-case coverage is needed
|
||||
|
||||
## Problem
|
||||
|
||||
Embeddings in ComfyUI are not loaded through dedicated ComfyUI nodes like LoRAs or
|
||||
Checkpoints. They are resolved during CLIP tokenization when the prompt text contains
|
||||
`embedding:<name>` syntax (see `comfy/sd1_clip.py:SDTokenizer.tokenize_with_weights`).
|
||||
|
||||
This means the existing metadata_collector hook (which intercepts node execution via
|
||||
`_map_node_over_list`) cannot capture embeddings the same way it captures LoRAs and
|
||||
checkpoints — there is no "EmbeddingLoader" node to intercept.
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
The hybrid approach combines **two complementary mechanisms** to capture embedding
|
||||
usage from all possible paths.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Plan A (已实现) │
|
||||
│ │
|
||||
│ MetadataRegistry.prompt_metadata["prompts"] │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ _process_embeddings() │
|
||||
│ │ │
|
||||
│ ├─ Iterate all prompt node texts │
|
||||
│ ├─ regex extract "embedding:<name>" │
|
||||
│ ├─ resolve name → sha256 via EmbeddingScanner │
|
||||
│ └─ UsageStats.stats["embeddings"][sha256]++ │
|
||||
│ │
|
||||
│ Coverage: ~95% — all CLIPTextEncode/Flux/etc nodes │
|
||||
│ │
|
||||
│ Gap: Custom nodes that load embeddings programmatically │
|
||||
│ without putting embedding:name in prompt text │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
+
|
||||
↓ (future: enable Plan B when needed)
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Plan B (未来 — monkey-patch) │
|
||||
│ │
|
||||
│ comfy/sd1_clip.py:load_embed() │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Monkey-patch intercepts EVERY embedding file load │
|
||||
│ │ │
|
||||
│ ├─ Records embedding_name + success/failure │
|
||||
│ ├─ Associates with current prompt_id (via registry)│
|
||||
│ └─ Feeds into UsageStats same as Plan A │
|
||||
│ │
|
||||
│ Coverage: 100% — catches ALL embedding loads │
|
||||
│ │
|
||||
│ Cost: Requires patching into ComfyUI internals │
|
||||
│ (sd1_clip.py, sdxl_clip.py, some text_encoders) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Plan B Detail — Monkey-patch `load_embed`
|
||||
|
||||
### Target Function
|
||||
|
||||
**`comfy.sd1_clip.load_embed(embedding_name, embedding_directory, embedding_size, embed_key=None)`**
|
||||
at line 415 of `sd1_clip.py`.
|
||||
|
||||
This is the **single choke point** for all embedding file loads in ComfyUI. Every
|
||||
CLIP variant (SD1, SDXL, SD3, Flux) calls this same function.
|
||||
|
||||
### Implementation Sketch
|
||||
|
||||
```python
|
||||
# In metadata_collector/metadata_hook.py (or a new module)
|
||||
import comfy.sd1_clip as sd1_clip
|
||||
|
||||
_original_load_embed = sd1_clip.load_embed
|
||||
|
||||
def _patched_load_embed(embedding_name, embedding_directory, embedding_size, embed_key=None):
|
||||
result = _original_load_embed(
|
||||
embedding_name, embedding_directory, embedding_size, embed_key
|
||||
)
|
||||
if result is not None:
|
||||
_record_embedding_usage(embedding_name)
|
||||
return result
|
||||
|
||||
sd1_clip.load_embed = _patched_load_embed
|
||||
```
|
||||
|
||||
### Prompt ID Association
|
||||
|
||||
The challenge is associating the `load_embed` call with the current `prompt_id`.
|
||||
Options:
|
||||
|
||||
1. **Thread-local / contextvar**: Store current `prompt_id` in a `contextvars.ContextVar`
|
||||
that the metadata_collector sets at the start of each prompt execution.
|
||||
|
||||
2. **MetadataRegistry singleton**: The MetadataRegistry already has `current_prompt_id`.
|
||||
The patch can read it directly since both run in the same thread.
|
||||
|
||||
3. **Lazy aggregation**: Instead of associating with prompt_id at load time, collect
|
||||
all loaded embedding names in a global set during execution, then flush to
|
||||
UsageStats after the prompt completes.
|
||||
|
||||
### Files to Patch
|
||||
|
||||
| File | Function | Coverage |
|
||||
|------|----------|----------|
|
||||
| `comfy/sd1_clip.py:415` | `load_embed()` | Primary — SD1.x, SDXL, SD3, Flux |
|
||||
| `comfy/sdxl_clip.py` | Not needed (calls `sd1_clip.SDTokenizer`) | — |
|
||||
| `comfy/text_encoders/sd3_clip.py` | Not needed (calls `sd1_clip.SDTokenizer`) | — |
|
||||
| `comfy/text_encoders/flux.py` | Not needed (calls `sd1_clip.SDTokenizer`) | — |
|
||||
|
||||
The SD1 tokenizer is the base class for all CLIP variants' tokenizers, so patching
|
||||
`load_embed` covers them all.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
| Edge Case | Plan A | Plan B |
|
||||
|-----------|--------|--------|
|
||||
| `embedding:name` in CLIPTextEncode | ✅ | ✅ |
|
||||
| `embedding:name` in CLIPTextEncodeFlux | ✅ | ✅ |
|
||||
| `embedding:name` in PromptLM (LoRA Manager) | ✅ | ✅ |
|
||||
| `embedding:name` in WAS_Text_to_Conditioning | ✅ | ✅ |
|
||||
| Custom node that loads embedding programmatically | ❌ | ✅ |
|
||||
| Embedding loaded multiple times in same prompt | ✅ (dedup via set) | ✅ (dedup via set) |
|
||||
| Embedding file not found | N/A | ✅ (can log) |
|
||||
| Embedding dimension mismatch | N/A | ✅ (can log) |
|
||||
| Text encoder with non-standard tokenizer (LLaMA, T5...) | Partial | ✅ (if it calls load_embed) |
|
||||
|
||||
## Migration Path: Standalone → Hybrid
|
||||
|
||||
### Phase 1 — Plan A (当前状态)
|
||||
- Prompt text parsing only
|
||||
- No monkey-patching required
|
||||
- Covers all standard workflows
|
||||
|
||||
### Phase 2 — Enable Plan B (未来工作)
|
||||
1. Add monkey-patch of `load_embed` in `metadata_collector/metadata_hook.py` (alongside
|
||||
the existing `_map_node_over_list` hook)
|
||||
2. Collect loaded embedding names in a `set()` on the registry
|
||||
3. In `UsageStats._process_embeddings()`, merge the Plan A results (from prompt text)
|
||||
with the Plan B results (from the patch)
|
||||
4. Add `prompt_data` field on MetadataRegistry to store loaded embeddings per prompt
|
||||
|
||||
### Deduplication
|
||||
|
||||
```python
|
||||
# Merge Plan A + Plan B results in _process_embeddings
|
||||
plan_a_names = extract_from_prompt_texts(prompts_data)
|
||||
plan_b_names = registry.get_loaded_embeddings(prompt_id)
|
||||
|
||||
all_names = plan_a_names | plan_b_names
|
||||
```
|
||||
|
||||
## Testing the Hybrid
|
||||
|
||||
| Scenario | What to verify |
|
||||
|----------|---------------|
|
||||
| Standard `embedding:name` in prompt | Plan A captures it |
|
||||
| Embedding loaded by custom node script | Plan B captures it |
|
||||
| Both paths fire for same embedding | No double-counting (dedup) |
|
||||
| Embedding name resolves to hash | EmbeddingScanner.get_hash_by_filename works |
|
||||
| No embedding scanner available | Graceful skip, no crash |
|
||||
| Missing embedding file | Plan B logs warning, Plan A skips gracefully |
|
||||
| Empty prompt | No crash, no entries |
|
||||
| Standalone mode | Both plans disabled gracefully |
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `py/utils/usage_stats.py` | Core — `_process_embeddings()` for Plan A |
|
||||
| `py/metadata_collector/constants.py` | `EMBEDDINGS` category constant |
|
||||
| `py/metadata_collector/metadata_hook.py` | Future — monkey-patch for Plan B |
|
||||
| `py/services/embedding_scanner.py` | Hash resolution service |
|
||||
| `py/routes/stats_routes.py` | Already handles `usage_data.get('embeddings', {})` |
|
||||
| `comfy/sd1_clip.py` (ComfyUI) | `load_embed()` — Plan B target |
|
||||
@@ -6,62 +6,87 @@
|
||||
"Scott R"
|
||||
],
|
||||
"allSupporters": [
|
||||
"Insomnia Art Designs",
|
||||
"2018cfh",
|
||||
"megakirbs",
|
||||
"Brennok",
|
||||
"W+K+White",
|
||||
"wackop",
|
||||
"Phil",
|
||||
"Carl G.",
|
||||
"Insomnia Art Designs",
|
||||
"2018cfh",
|
||||
"Arlecchino Shion",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"stone9k",
|
||||
"itismyelement",
|
||||
"W+K+White",
|
||||
"$MetaSamsara",
|
||||
"onesecondinosaur",
|
||||
"wackop",
|
||||
"Phil",
|
||||
"Carl G.",
|
||||
"stone9k",
|
||||
"Rosenthal",
|
||||
"itismyelement",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"Kiba",
|
||||
"onesecondinosaur",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"ClockDaemon",
|
||||
"Francisco Tatis",
|
||||
"Tobi_Swagg",
|
||||
"SG",
|
||||
"jmack",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"Ricky Carter",
|
||||
"JongWon Han",
|
||||
"VantAI",
|
||||
"レプサイ",
|
||||
"Michael Wong",
|
||||
"runte3221",
|
||||
"Illrigger",
|
||||
"Tom Corrigan",
|
||||
"JackieWang",
|
||||
"FreelancerZ",
|
||||
"fnkylove",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Edgar Tejeda",
|
||||
"Fraser Cross",
|
||||
"Liam MacDougal",
|
||||
"Polymorphic Indeterminate",
|
||||
"Sterilized",
|
||||
"JORGE+LUIZ+HUSSNI+MESSIAS",
|
||||
"Marc Whiffen",
|
||||
"Skalabananen",
|
||||
"Birdy",
|
||||
"Kiba",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"quarz",
|
||||
"Reno Lam",
|
||||
"JSST",
|
||||
"sig",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Snaggwort",
|
||||
"Takkan",
|
||||
"Matt+J",
|
||||
"ClockDaemon",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Nazono_hito",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"SG",
|
||||
"JW Sin",
|
||||
"Alex",
|
||||
"bh",
|
||||
"carozzz",
|
||||
"Marlon Daniels",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"jmack",
|
||||
"Aaron Bleuer",
|
||||
"Adam Shaw",
|
||||
"Mark Corneglio",
|
||||
"SarcasticHashtag",
|
||||
@@ -70,144 +95,130 @@
|
||||
"RedrockVP",
|
||||
"Wolffen",
|
||||
"James Todd",
|
||||
"Steven Pfeiffer",
|
||||
"Tim",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Lisster",
|
||||
"Michael Wong",
|
||||
"Illrigger",
|
||||
"whudunit",
|
||||
"Tom Corrigan",
|
||||
"JackieWang",
|
||||
"fnkylove",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"Jorge Hussni",
|
||||
"Sterilized",
|
||||
"BadassArabianMofo",
|
||||
"Pascal Dahle",
|
||||
"quarz",
|
||||
"Greg",
|
||||
"JSST",
|
||||
"lmsupporter",
|
||||
"zounic",
|
||||
"wfpearl",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"Jack B Nimble",
|
||||
"Nazono_hito",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"JW Sin",
|
||||
"contrite831",
|
||||
"Alex",
|
||||
"bh",
|
||||
"Marlon Daniels",
|
||||
"Starkselle",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"M Postkasse",
|
||||
"Gooohokrbe",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"OldBones",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Steven Pfeiffer",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Tak",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Zach Gonser",
|
||||
"Lisster",
|
||||
"Big Red",
|
||||
"Jimmy Ledbetter",
|
||||
"whudunit",
|
||||
"Luc Job",
|
||||
"dl0901dm",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"Nick Walker",
|
||||
"nwalker94",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Bishoujoker",
|
||||
"aai",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"Tori",
|
||||
"wildnut",
|
||||
"jean jahren",
|
||||
"Aleksander Wujczyk",
|
||||
"AM Kuro",
|
||||
"BadassArabianMofo",
|
||||
"Pascal Dahle",
|
||||
"Greg",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Akira_HentAI",
|
||||
"lmsupporter",
|
||||
"andrew.tappan",
|
||||
"N/A",
|
||||
"Greenmoustache",
|
||||
"zounic",
|
||||
"wfpearl",
|
||||
"Eldithor",
|
||||
"Jack B Nimble",
|
||||
"JaxMax",
|
||||
"contrite831",
|
||||
"Jwk0205",
|
||||
"Starkselle",
|
||||
"Olive",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"Some Guy Named Barry",
|
||||
"M Postkasse",
|
||||
"Gooohokrbe",
|
||||
"wamekukyouzin",
|
||||
"OldBones",
|
||||
"Jacob Hoehler",
|
||||
"Dogmaster",
|
||||
"Matt Wenzel",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Zach Gonser",
|
||||
"Serge Bekenkamp",
|
||||
"Jimmy Ledbetter",
|
||||
"Philip Hempel",
|
||||
"dan",
|
||||
"aai",
|
||||
"Mouthlessman",
|
||||
"otaku fra",
|
||||
"jean jahren",
|
||||
"MiraiKuriyamaSy",
|
||||
"Ran C",
|
||||
"ViperC",
|
||||
"Penfore",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Karl P.",
|
||||
"Akira_HentAI",
|
||||
"Gordon Cole",
|
||||
"Adam Taylor",
|
||||
"AbstractAss",
|
||||
"andrew.tappan",
|
||||
"N/A",
|
||||
"Weird_With_A_Beard",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Greenmoustache",
|
||||
"fancypants",
|
||||
"Eldithor",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"JaxMax",
|
||||
"takyamtom",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"Jwk0205",
|
||||
"Bro Xie",
|
||||
"yer fey",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"太郎 ゲーム",
|
||||
"Some Guy Named Barry",
|
||||
"jinxedx",
|
||||
"Cosmosis",
|
||||
"AELOX",
|
||||
"Dankin",
|
||||
"Nicfit23",
|
||||
"FloPro4Sho",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Matt Wenzel",
|
||||
"Frank Nitty",
|
||||
"Christopher Michel",
|
||||
"Serge Bekenkamp",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"ApathyJones",
|
||||
"Julian V",
|
||||
"Steven Owens",
|
||||
"nahinahi9",
|
||||
"Dustin Chen",
|
||||
"dan",
|
||||
"Blackfish95",
|
||||
"Mouthlessman",
|
||||
"Paul Kroll",
|
||||
"otaku fra",
|
||||
"MiraiKuriyamaSy",
|
||||
"Bas Imagineer",
|
||||
"yuxz69",
|
||||
"Adam Taylor",
|
||||
"Weird_With_A_Beard",
|
||||
"esthe",
|
||||
"Pozadine1",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"DiffDuck",
|
||||
"Jackthemind",
|
||||
"fancypants",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"takyamtom",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"Bro Xie",
|
||||
"yer fey",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"太郎 ゲーム",
|
||||
"Roslynd",
|
||||
"jinxedx",
|
||||
"Neco28",
|
||||
"Cosmosis",
|
||||
"David Ortega",
|
||||
"AELOX",
|
||||
"Dankin",
|
||||
"Nicfit23",
|
||||
"FloPro4Sho",
|
||||
"Cristian Vazquez",
|
||||
"drum matthieu",
|
||||
"Frank Nitty",
|
||||
"Magic Noob",
|
||||
"Christopher Michel",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"ApathyJones",
|
||||
"Bruce",
|
||||
"Julian V",
|
||||
"Steven Owens",
|
||||
"nahinahi9",
|
||||
"Kevin John Duck",
|
||||
"Dustin Chen",
|
||||
"Blackfish95",
|
||||
"Paul Kroll",
|
||||
"Bas Imagineer",
|
||||
"John Statham",
|
||||
"yuxz69",
|
||||
"esthe",
|
||||
"decoy",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
@@ -217,49 +228,54 @@
|
||||
"wundershark",
|
||||
"mr_dinosaur",
|
||||
"Tyrswood",
|
||||
"Ray Wing",
|
||||
"Ranzitho",
|
||||
"Gus",
|
||||
"MJG",
|
||||
"David LaVallee",
|
||||
"linnfrey",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"IamAyam",
|
||||
"skaterb949",
|
||||
"Brian M",
|
||||
"Josef Lanzl",
|
||||
"Nerezza",
|
||||
"sanborondon",
|
||||
"confiscated Zyra",
|
||||
"Error_Rule34_Not_found",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"jcay015",
|
||||
"Gerald Welly",
|
||||
"Roslynd",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Tee Gee",
|
||||
"Geolog",
|
||||
"tarek helmi",
|
||||
"Neco28",
|
||||
"Eris3D",
|
||||
"Max Marklund",
|
||||
"David Ortega",
|
||||
"Cristian Vazquez",
|
||||
"Magic Noob",
|
||||
"Pronredn",
|
||||
"DougPeterson",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"Jeff",
|
||||
"Bruce",
|
||||
"lh qwe",
|
||||
"Kevin John Duck",
|
||||
"James Coleman",
|
||||
"conner",
|
||||
"Kevin Christopher",
|
||||
"Chad Idk",
|
||||
"dd",
|
||||
"Princess Bright Eyes",
|
||||
"Dušan Ryban",
|
||||
"Felipe dos Santos",
|
||||
"John Statham",
|
||||
"Sam",
|
||||
"sjon kreutz",
|
||||
"Douglas Gaspar",
|
||||
"Metryman55",
|
||||
"AlexDuKaNa",
|
||||
"George",
|
||||
"dw",
|
||||
"decoy",
|
||||
"Ray Wing",
|
||||
"Ranzitho",
|
||||
"Gus",
|
||||
"地獄の禄",
|
||||
"MJG",
|
||||
"David LaVallee",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"Gamalonia",
|
||||
"WRL_SPR",
|
||||
"capn",
|
||||
@@ -273,19 +289,24 @@
|
||||
"몽타주",
|
||||
"Kland",
|
||||
"Hailshem",
|
||||
"kudari",
|
||||
"Naomi Hale Danchi",
|
||||
"ken",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Richard",
|
||||
"奚明 刘",
|
||||
"Brian M",
|
||||
"Nerezza",
|
||||
"sanborondon",
|
||||
"Andrew",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"준희 김",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"Brian Buie",
|
||||
"Thought2Form",
|
||||
"jcay015",
|
||||
"Kevin Picco",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Eris3D",
|
||||
"Sadlip",
|
||||
"Joey Callahan",
|
||||
"Tomohiro Baba",
|
||||
"m",
|
||||
"Noora",
|
||||
@@ -293,37 +314,36 @@
|
||||
"Joshua Gray",
|
||||
"Mattssn",
|
||||
"Mikko Hemilä",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"James Coleman",
|
||||
"Jacob McDaniel",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"Martial",
|
||||
"Michael Anthony Scott",
|
||||
"Emil Andersson",
|
||||
"Ouro Boros",
|
||||
"Chad Idk",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Steam Steam",
|
||||
"CryptoTraderJK",
|
||||
"Decx _",
|
||||
"Yuji Kaneko",
|
||||
"Davaitamin",
|
||||
"Rops Alot",
|
||||
"tedcor",
|
||||
"Sam",
|
||||
"Fotek Design",
|
||||
"sjon kreutz",
|
||||
"Ace Ventura",
|
||||
"四糸凜音",
|
||||
"Nihongasuki",
|
||||
"LarsesFPC",
|
||||
"MadSpin",
|
||||
"inbijiburu",
|
||||
"Nick “Loadstone” D",
|
||||
"momokai",
|
||||
"starbugx",
|
||||
"kudari",
|
||||
"Naomi Hale Danchi",
|
||||
"dc7431",
|
||||
"ken",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"Crocket",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Wind",
|
||||
"Nexus",
|
||||
"Ramneek“Guy”Ashok",
|
||||
@@ -339,72 +359,61 @@
|
||||
"KitKatM",
|
||||
"socrasteeze",
|
||||
"OrganicArtifact",
|
||||
"ResidentDeviant",
|
||||
"MudkipMedkitz",
|
||||
"deanbrian",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"emadsultan",
|
||||
"InformedViewz",
|
||||
"CHKeeho80",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Vir",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"gzmzmvp",
|
||||
"Richard",
|
||||
"Andrew",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Gregory Kozhemiak",
|
||||
"Draven T",
|
||||
"mrjuan",
|
||||
"Brian Buie",
|
||||
"Sadlip",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Aquatic Coffee",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
"John J Linehan",
|
||||
"ethanfel",
|
||||
"Elliot E",
|
||||
"Morgandel",
|
||||
"Theerat Jiramate",
|
||||
"Focuschannel",
|
||||
"Noah",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"Sloan Steddy",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"hexxish",
|
||||
"Anthony Faxlandez",
|
||||
"battu",
|
||||
"Michael Anthony Scott",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Nathan",
|
||||
"Decx _",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Pat Hen",
|
||||
"Xeeosat",
|
||||
"Saya",
|
||||
"Ed Wang",
|
||||
"Jordan Shaw",
|
||||
"g unit",
|
||||
"Srdb",
|
||||
"四糸凜音",
|
||||
"Nihongasuki",
|
||||
"LarsesFPC",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
"uwutismxd",
|
||||
"FrxzenSnxw",
|
||||
"zenobeus",
|
||||
"Crocket",
|
||||
"Jackthemind",
|
||||
"ryoma",
|
||||
"Stryker",
|
||||
"ResidentDeviant",
|
||||
"MudkipMedkitz",
|
||||
"deanbrian",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"Ginnie",
|
||||
"Raku",
|
||||
"smart.edge5178",
|
||||
"InformedViewz",
|
||||
"CHKeeho80",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Menard",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"SomeDude",
|
||||
"g9p0o",
|
||||
@@ -415,18 +424,32 @@
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"DarkRoast",
|
||||
"letzte",
|
||||
"Nasty+Hobbit",
|
||||
"Sora+Yori",
|
||||
"lrdchs2",
|
||||
"Duk3+Rand0m",
|
||||
"Nathen+Choi",
|
||||
"T",
|
||||
"cocona",
|
||||
"ElitaSSJ4",
|
||||
"David Schenck",
|
||||
"Wolfe7D1",
|
||||
"blikkies",
|
||||
"Chris",
|
||||
"Time Valentine",
|
||||
"elleshar666",
|
||||
"Shock Shockor",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"Goldwaters",
|
||||
"Kauffy",
|
||||
"Zude",
|
||||
"John J Linehan",
|
||||
"SPJ",
|
||||
"Kyler",
|
||||
"Edward Kennedy",
|
||||
"Justin Blaylock",
|
||||
@@ -435,21 +458,18 @@
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"Cyrus Fett",
|
||||
"hexxish",
|
||||
"Xenon Xue",
|
||||
"notedfakes",
|
||||
"Billy Gladky",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Michael Scott",
|
||||
"Probis",
|
||||
"Ed Wang",
|
||||
"Solixer",
|
||||
"Wes Sims",
|
||||
"ItsGeneralButtNaked",
|
||||
"Donor4115",
|
||||
"g unit",
|
||||
"Distortik",
|
||||
"Filippo Ferrari",
|
||||
"Youguang",
|
||||
"Saya",
|
||||
"andrewzpong",
|
||||
"BossGame",
|
||||
"lrdchs",
|
||||
@@ -460,9 +480,18 @@
|
||||
"Mitchell Robson",
|
||||
"Whitepinetrader",
|
||||
"POPPIN",
|
||||
"Ginnie",
|
||||
"emadsultan",
|
||||
"nanana",
|
||||
"D",
|
||||
"Dark_Pest",
|
||||
"Alex",
|
||||
"Karru",
|
||||
"ChaChanoKo",
|
||||
"ghoulars",
|
||||
"null",
|
||||
"Beau",
|
||||
"redcarrot",
|
||||
"powerbot99",
|
||||
"Fthehappy",
|
||||
"g",
|
||||
"J",
|
||||
"Alan+Cano",
|
||||
@@ -471,18 +500,7 @@
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"BillyBoy84",
|
||||
"DarkRoast",
|
||||
"letzte",
|
||||
"Nasty+Hobbit",
|
||||
"Sora+Yori",
|
||||
"lrdchs2",
|
||||
"Duk3+Rand0m",
|
||||
"Nathen+Choi",
|
||||
"T",
|
||||
"cocona",
|
||||
"Buecyb99",
|
||||
"Welkor",
|
||||
"John Martin",
|
||||
@@ -490,32 +508,41 @@
|
||||
"JBsuede",
|
||||
"moranqianlong",
|
||||
"Kalli Core",
|
||||
"Time Valentine",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"Christian Schäfer",
|
||||
"りん あめ",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Locrospiel",
|
||||
"Frogmilk",
|
||||
"SPJ",
|
||||
"Sean voets",
|
||||
"Kor",
|
||||
"Joseph Hanson",
|
||||
"John Rednoulf",
|
||||
"Kyron Mahan",
|
||||
"Bryan Rutkowski",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"Ezokewn",
|
||||
"SendingRavens",
|
||||
"Xenon Xue",
|
||||
"Steven",
|
||||
"JackJohnnyJim",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"Khánh Đặng",
|
||||
"Edward Ten Eyck",
|
||||
"Michael Docherty",
|
||||
"Jimmy Borup",
|
||||
"Paul Hartsuyker",
|
||||
"Henrique Faiolli",
|
||||
"elitassj",
|
||||
"Solixer",
|
||||
"Pete Pain",
|
||||
"Jacob Winter",
|
||||
"Ryan Presley Ng",
|
||||
"jinksta187",
|
||||
"RHopkirk",
|
||||
"Andrew Wilkinson",
|
||||
"Manu Thetug",
|
||||
"Karlanx",
|
||||
"Lyavph",
|
||||
"Maxim",
|
||||
"David",
|
||||
"Meilo",
|
||||
"operationancut",
|
||||
@@ -537,6 +564,24 @@
|
||||
"Scott",
|
||||
"Muratoraccio",
|
||||
"D",
|
||||
"Mobius2020",
|
||||
"ExLightSaber",
|
||||
"YaboiRay",
|
||||
"nickname",
|
||||
"Sildoren",
|
||||
"Darv",
|
||||
"Seon+Song",
|
||||
"2turbo",
|
||||
"Somebody",
|
||||
"Balut+Omelette",
|
||||
"Dmitry+Viznesenskiy",
|
||||
"tanjin90",
|
||||
"sternenkrieger",
|
||||
"eriick",
|
||||
"Patrick+Bryan",
|
||||
"Pascalou",
|
||||
"lighthawke",
|
||||
"Lev+Lanevskiy",
|
||||
"low9",
|
||||
"Winged",
|
||||
"YassineKhaled",
|
||||
@@ -547,18 +592,7 @@
|
||||
"Inkognito",
|
||||
"G",
|
||||
"Tan+Huynh",
|
||||
"D",
|
||||
"Dark_Pest",
|
||||
"Alex",
|
||||
"Jacky+Ho",
|
||||
"Karru",
|
||||
"ghoulars",
|
||||
"ChaChanoKo",
|
||||
"null",
|
||||
"Beau",
|
||||
"redcarrot",
|
||||
"powerbot99",
|
||||
"Fthehappy",
|
||||
"generic404",
|
||||
"abattoirblues",
|
||||
"zounik",
|
||||
@@ -568,50 +602,55 @@
|
||||
"Bob Barker",
|
||||
"edk",
|
||||
"Tú Nguyễn Lý Hoàng",
|
||||
"shira1011",
|
||||
"Ben D",
|
||||
"G",
|
||||
"Ronan Delevacq",
|
||||
"Christian Schäfer",
|
||||
"りん あめ",
|
||||
"ja s",
|
||||
"Leslie Andrew Ridings",
|
||||
"Doug Mason",
|
||||
"Jeremy Townsend",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Locrospiel",
|
||||
"Sean voets",
|
||||
"Owen Gwosdz",
|
||||
"Jarrid Lee",
|
||||
"Kor",
|
||||
"Joseph Hanson",
|
||||
"John Rednoulf",
|
||||
"Poophead27 Blyat",
|
||||
"Spire",
|
||||
"AZ Party Oasis",
|
||||
"Boba Smith",
|
||||
"Devil Lude",
|
||||
"David Murcko",
|
||||
"MR.Bear",
|
||||
"Jack Dole",
|
||||
"matt",
|
||||
"somethingtosay8",
|
||||
"Terminuz",
|
||||
"ivistorm",
|
||||
"max blo",
|
||||
"Sauv",
|
||||
"Steven",
|
||||
"CptNeo",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"Khánh Đặng",
|
||||
"Borte",
|
||||
"Maso",
|
||||
"Ted Cart",
|
||||
"Sage Himeros",
|
||||
"Eric Ketchum",
|
||||
"Kevin Wallace",
|
||||
"Jimmy Borup",
|
||||
"David Spearing",
|
||||
"ChicRic",
|
||||
"Tigon",
|
||||
"BastardSama",
|
||||
"mercur",
|
||||
"Pete Pain",
|
||||
"RHopkirk",
|
||||
"SkibidiRizzler",
|
||||
"Tania Nayelli Fernandez",
|
||||
"Draconach",
|
||||
"Yavizu3d",
|
||||
"Maxim",
|
||||
"Yves Poezevara",
|
||||
"Teriak47",
|
||||
"Just me",
|
||||
"Raf Stahelin",
|
||||
"Nacho Ferrando",
|
||||
"Вячеслав Маринин",
|
||||
"Marcos Tortosa Carmona",
|
||||
"Dkommander22",
|
||||
"Cola Matthew",
|
||||
"OniNoKen",
|
||||
"Iain Wisely",
|
||||
@@ -655,6 +694,24 @@
|
||||
"SelfishMedic",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"shw",
|
||||
"Celestial+Kitten",
|
||||
"bakeliteboy",
|
||||
"TequiTequi",
|
||||
"Homero+Banda",
|
||||
"Nick",
|
||||
"Jim",
|
||||
"Monix",
|
||||
"Trolinka",
|
||||
"IshouI;_;",
|
||||
"PredragR",
|
||||
"Clauzmak",
|
||||
"Nerick",
|
||||
"JoL",
|
||||
"Gold_miner_ego",
|
||||
"SundayRage",
|
||||
"YoruHime",
|
||||
"matter",
|
||||
"SRCRCOSS",
|
||||
"imer",
|
||||
"Akkas+Haque",
|
||||
@@ -668,25 +725,9 @@
|
||||
"PoorStudent",
|
||||
"lucites",
|
||||
"Alex+Zaw",
|
||||
"Mobius2020",
|
||||
"ExLightSaber",
|
||||
"YaboiRay",
|
||||
"Drizzly",
|
||||
"Sildoren",
|
||||
"Darvidous",
|
||||
"Seon+Song",
|
||||
"2turbo",
|
||||
"balut+omelette",
|
||||
"Nebuleux",
|
||||
"Dmitry+Viznesenskiy",
|
||||
"Tanjin90",
|
||||
"Somebody",
|
||||
"sternenkrieger",
|
||||
"eriick",
|
||||
"Join+Chun",
|
||||
"Pascalou",
|
||||
"lighthawke",
|
||||
"Terraformer",
|
||||
"GDS+DEV",
|
||||
"4rt+r3d",
|
||||
"you+halo9",
|
||||
@@ -709,64 +750,66 @@
|
||||
"Nico",
|
||||
"Maximilian Krischan",
|
||||
"Banana Joe",
|
||||
"proto merp",
|
||||
"_ G3n",
|
||||
"Donovan Jenkins",
|
||||
"Hans Meier",
|
||||
"shira1011",
|
||||
"sicarius",
|
||||
"Michael Eid",
|
||||
"Wolf and Fox Legends",
|
||||
"beersandbacon",
|
||||
"Neko Desco",
|
||||
"Bob barker",
|
||||
"Ben D",
|
||||
"Ninja Tom",
|
||||
"G",
|
||||
"karim ben brik",
|
||||
"Vinarus",
|
||||
"Josh Snyder",
|
||||
"Michael Zhu",
|
||||
"Nemisu",
|
||||
"Seraphy",
|
||||
"雨の心 落",
|
||||
"AllTimeNoobie",
|
||||
"Leslie Andrew Ridings",
|
||||
"jumpd",
|
||||
"John C",
|
||||
"Rim",
|
||||
"yfx507",
|
||||
"Room Light",
|
||||
"Jairus Knudsen",
|
||||
"Poophead27 Blyat",
|
||||
"Xan Dionysus",
|
||||
"Patryk Serious",
|
||||
"Nathan lee",
|
||||
"Lyle Liston",
|
||||
"lylepaul",
|
||||
"Middo",
|
||||
"Forbidden Atelier",
|
||||
"Thomas Sankowski",
|
||||
"Spire",
|
||||
"DrB",
|
||||
"AZ Party Oasis",
|
||||
"Adictedtohumping",
|
||||
"Snorklebort",
|
||||
"vinter",
|
||||
"Towelie",
|
||||
"TheFusion",
|
||||
"matt",
|
||||
"dsffsdfsdfsdfsdfsdf",
|
||||
"somethingtosay8",
|
||||
"Jean-françois SEMA",
|
||||
"3zS4QNQ4",
|
||||
"Terminuz",
|
||||
"Kurt",
|
||||
"ivistorm",
|
||||
"Matt M.",
|
||||
"Ivan Imes",
|
||||
"J M",
|
||||
"Slacks",
|
||||
"Bouya shaka",
|
||||
"john Greene",
|
||||
"Faburizu",
|
||||
"Jack Lawfield",
|
||||
"jimyjomson",
|
||||
"Borte",
|
||||
"JaeHyun Jang",
|
||||
"Homero Banda",
|
||||
"Chase Kwon",
|
||||
"Ted Cart",
|
||||
"Sage Himeros",
|
||||
"Bob Ling",
|
||||
"yyuvuvu",
|
||||
"Inyoshu",
|
||||
"Chad Barnes",
|
||||
"Person Y",
|
||||
"David Spearing",
|
||||
"Nomki",
|
||||
"inusanorthcape",
|
||||
"James Ming",
|
||||
"vanditking",
|
||||
"kripitonga",
|
||||
@@ -779,7 +822,6 @@
|
||||
"hannibal",
|
||||
"Jo+Example",
|
||||
"BrentBertram",
|
||||
"inusanorthcape",
|
||||
"eumelzocker",
|
||||
"dxjaymz",
|
||||
"L C",
|
||||
@@ -787,5 +829,5 @@
|
||||
"Somebody",
|
||||
"CK"
|
||||
],
|
||||
"totalCount": 784
|
||||
"totalCount": 826
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
168
locales/de.json
168
locales/de.json
@@ -16,10 +16,13 @@
|
||||
"help": "Hilfe",
|
||||
"add": "Hinzufügen",
|
||||
"close": "Schließen",
|
||||
"menu": "Menü"
|
||||
"menu": "Menü",
|
||||
"remove": "Entfernen",
|
||||
"change": "Ändern"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Wird geladen...",
|
||||
"cancelling": "Abbrechen...",
|
||||
"unknown": "Unbekannt",
|
||||
"date": "Datum",
|
||||
"version": "Version",
|
||||
@@ -111,6 +114,7 @@
|
||||
"replacePreview": "Vorschau ersetzen",
|
||||
"copyCheckpointName": "Checkpoint-Name kopieren",
|
||||
"copyEmbeddingName": "Embedding-Name kopieren",
|
||||
"embeddingNameCopied": "Embedding-Syntax kopiert",
|
||||
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
||||
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
||||
},
|
||||
@@ -247,7 +251,18 @@
|
||||
"toggle": "Theme wechseln",
|
||||
"switchToLight": "Zu hellem Theme wechseln",
|
||||
"switchToDark": "Zu dunklem Theme wechseln",
|
||||
"switchToAuto": "Zu automatischem Theme wechseln"
|
||||
"switchToAuto": "Zu automatischem Theme wechseln",
|
||||
"presets": "Theme-Voreinstellungen",
|
||||
"default": "Standard",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Modus",
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Updates prüfen",
|
||||
@@ -259,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
||||
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
||||
"civitaiApiKeyConfigured": "Konfiguriert",
|
||||
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
|
||||
"civitaiApiKeySet": "Einrichten",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai-Host",
|
||||
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
|
||||
@@ -299,6 +317,7 @@
|
||||
"downloads": "Downloads",
|
||||
"videoSettings": "Video-Einstellungen",
|
||||
"layoutSettings": "Layout-Einstellungen",
|
||||
"licenseIcons": "Lizenzsymbole",
|
||||
"misc": "Verschiedenes",
|
||||
"backup": "Backups",
|
||||
"folderSettings": "Standard-Roots",
|
||||
@@ -445,7 +464,9 @@
|
||||
"modelName": "Modellname",
|
||||
"fileName": "Dateiname"
|
||||
},
|
||||
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll"
|
||||
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll",
|
||||
"cardBlurAmount": "Karten-Overlay-Unschärfe",
|
||||
"cardBlurAmountHelp": "Passen Sie die Unschärfeintensität der Kopf- und Fußzeilen-Overlays auf Modell- und Rezeptkarten an (0 = keine Unschärfe, 20 = maximale Unschärfe)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Aktive Bibliothek",
|
||||
@@ -577,6 +598,10 @@
|
||||
"label": "Früher Zugriff Updates ausblenden",
|
||||
"help": "Nur Early-Access-Updates"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
|
||||
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
|
||||
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen",
|
||||
@@ -690,6 +715,7 @@
|
||||
"copyAll": "Alle Syntax kopieren",
|
||||
"refreshAll": "Alle Metadaten aktualisieren",
|
||||
"repairMetadata": "Metadaten der Auswahl reparieren",
|
||||
"reimportMetadata": "Aus Quelle neu importieren",
|
||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||
"moveAll": "Alle in Ordner verschieben",
|
||||
"autoOrganize": "Automatisch organisieren",
|
||||
@@ -737,6 +763,7 @@
|
||||
"setContentRating": "Inhaltsbewertung festlegen",
|
||||
"moveToFolder": "In Ordner verschieben",
|
||||
"repairMetadata": "Metadaten reparieren",
|
||||
"reimportMetadata": "Aus Quelle neu importieren",
|
||||
"excludeModel": "Modell ausschließen",
|
||||
"restoreModel": "Modell wiederherstellen",
|
||||
"deleteModel": "Modell löschen",
|
||||
@@ -864,6 +891,13 @@
|
||||
"skipped": "Rezept bereits in der neuesten Version, keine Reparatur erforderlich",
|
||||
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
|
||||
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Rezept wird aus Quelle neu importiert...",
|
||||
"success": "Rezept erfolgreich neu importiert",
|
||||
"noSourceUrl": "Rezept hat keine Quell-URL, Neuimport nicht möglich",
|
||||
"failed": "Neuimport des Rezepts fehlgeschlagen: {message}",
|
||||
"missingId": "Neuimport nicht möglich: Rezept-ID fehlt"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -942,8 +976,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Stammverzeichnis",
|
||||
"collapseAll": "Alle Ordner einklappen",
|
||||
"pinSidebar": "Sidebar anheften",
|
||||
"unpinSidebar": "Sidebar lösen",
|
||||
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
|
||||
"showSidebar": "Seitenleiste anzeigen",
|
||||
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
|
||||
"switchToListView": "Zur Listenansicht wechseln",
|
||||
"switchToTreeView": "Zur Baumansicht wechseln",
|
||||
"recursiveOn": "Unterordner einbeziehen",
|
||||
@@ -981,6 +1016,18 @@
|
||||
"storage": "Speicher",
|
||||
"insights": "Erkenntnisse"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Modelle gesamt",
|
||||
"totalStorage": "Speicher gesamt",
|
||||
"totalGenerations": "Generationen gesamt",
|
||||
"usageRate": "Nutzungsrate",
|
||||
"loras": "LoRAs",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"uniqueTags": "Einzigartige Tags",
|
||||
"unusedModels": "Ungenutzte Modelle",
|
||||
"avgUsesPerModel": "Ø Nutzungen/Modell"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "Meistgenutzte LoRAs",
|
||||
"mostUsedCheckpoints": "Meistgenutzte Checkpoints",
|
||||
@@ -998,13 +1045,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Intelligente Erkenntnisse",
|
||||
"recommendations": "Empfehlungen"
|
||||
"recommendations": "Empfehlungen",
|
||||
"noInsights": "Keine Erkenntnisse verfügbar",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "Hohe Anzahl ungenutzter LoRAs",
|
||||
"description": "{percent}% Ihrer LoRAs ({count}/{total}) wurden noch nie verwendet.",
|
||||
"suggestion": "Erwägen Sie, ungenutzte Modelle zu organisieren oder zu archivieren, um Speicherplatz freizugeben."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Ungenutzte Checkpoints erkannt",
|
||||
"description": "{percent}% Ihrer Checkpoints ({count}/{total}) wurden noch nie verwendet.",
|
||||
"suggestion": "Überprüfen Sie nicht mehr benötigte Checkpoints und erwägen Sie deren Entfernung."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "Hohe Anzahl ungenutzter Embeddings",
|
||||
"description": "{percent}% Ihrer Embeddings ({count}/{total}) wurden noch nie verwendet.",
|
||||
"suggestion": "Organisieren oder archivieren Sie ungenutzte Embeddings, um Ihre Sammlung zu optimieren."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Große Sammlung erkannt",
|
||||
"description": "Ihre Modellsammlung verwendet {size} Speicher.",
|
||||
"suggestion": "Erwägen Sie externe Speicher- oder Cloud-Lösungen für eine bessere Organisation."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Aktiver Benutzer",
|
||||
"description": "Sie haben {count} Generationen abgeschlossen!",
|
||||
"suggestion": "Entdecken und erstellen Sie weiterhin großartige Inhalte mit Ihren Modellen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Sammlungsübersicht",
|
||||
"baseModelDistribution": "Basis-Modell-Verteilung",
|
||||
"usageTrends": "Nutzungstrends (Letzte 30 Tage)",
|
||||
"usageDistribution": "Nutzungsverteilung"
|
||||
"usageDistribution": "Nutzungsverteilung",
|
||||
"date": "Datum",
|
||||
"usageCount": "Nutzungsanzahl",
|
||||
"fileSizeBytes": "Dateigröße (Bytes)",
|
||||
"models": "Modelle",
|
||||
"loraUsage": "LoRA-Nutzung",
|
||||
"checkpointUsage": "Checkpoint-Nutzung",
|
||||
"embeddingUsage": "Embedding-Nutzung"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusionsmodell",
|
||||
"embedding": "Embeddings"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Lädt...",
|
||||
"noModels": "Keine Modelle gefunden",
|
||||
"errorLoading": "Fehler beim Laden der Daten",
|
||||
"noStorageData": "Keine Speicherdaten verfügbar",
|
||||
"rootFolder": "Root",
|
||||
"chartLibraryMissing": "Diagramm benötigt Chart.js-Bibliothek"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} Modelle",
|
||||
"chartUsage": "{name}: {size}, {count} Nutzungen",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -1014,9 +1125,9 @@
|
||||
"download": {
|
||||
"title": "Modell von URL herunterladen",
|
||||
"titleWithType": "{type} von URL herunterladen",
|
||||
"url": "Civitai URL",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Geben Sie eine CivitAI- oder CivArchive-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
|
||||
"locationPreview": "Download-Speicherort Vorschau",
|
||||
"useDefaultPath": "Standardpfad verwenden",
|
||||
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
|
||||
@@ -1225,7 +1336,9 @@
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Notizen erfolgreich gespeichert",
|
||||
"saveFailed": "Fehler beim Speichern der Notizen"
|
||||
"saveFailed": "Fehler beim Speichern der Notizen",
|
||||
"showMore": "Mehr anzeigen",
|
||||
"showLess": "Weniger anzeigen"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",
|
||||
@@ -1378,6 +1491,21 @@
|
||||
"versionDeleted": "Version gelöscht"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "Metadaten abrufen — Zusammenfassung",
|
||||
"statSuccess": "Erfolgreich",
|
||||
"statFailed": "Fehlgeschlagen",
|
||||
"statSkipped": "Übersprungen",
|
||||
"statTotal": "Gesamt geprüft",
|
||||
"statDuration": "Dauer",
|
||||
"successMessage": "Alle {count} {type}s erfolgreich aktualisiert!",
|
||||
"failedItems": "Fehlgeschlagene Elemente ({count})",
|
||||
"close": "Schließen",
|
||||
"copyReport": "Bericht kopieren",
|
||||
"downloadCsv": "CSV herunterladen",
|
||||
"columnModelName": "Modellname",
|
||||
"columnError": "Fehler"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1391,15 +1519,6 @@
|
||||
"duplicate": "Dieser Tag existiert bereits"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Tastatur-Navigation:",
|
||||
"shortcuts": {
|
||||
"pageUp": "Eine Seite nach oben scrollen",
|
||||
"pageDown": "Eine Seite nach unten scrollen",
|
||||
"home": "Zum Anfang springen",
|
||||
"end": "Zum Ende springen"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Initialisierung",
|
||||
"message": "Ihr Arbeitsbereich wird vorbereitet...",
|
||||
@@ -1487,11 +1606,14 @@
|
||||
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
||||
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
|
||||
"modelUpdated": "Modell im Workflow aktualisiert",
|
||||
"modelFailed": "Fehler beim Aktualisieren des Modellknotens"
|
||||
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
|
||||
"embeddingAdded": "Embedding zum Workflow hinzugefügt",
|
||||
"embeddingFailed": "Fehler beim Hinzufügen des Embeddings"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Rezept",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"replace": "Ersetzen",
|
||||
"append": "Anhängen",
|
||||
"selectTargetNode": "Zielknoten auswählen",
|
||||
@@ -1713,6 +1835,10 @@
|
||||
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
|
||||
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
|
||||
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
||||
"reimportSuccess": "Rezept erfolgreich neu importiert",
|
||||
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
|
||||
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
|
||||
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
|
||||
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
|
||||
},
|
||||
@@ -1930,7 +2056,9 @@
|
||||
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
||||
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
||||
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "In die Zwischenablage kopiert",
|
||||
"downloadStarted": "Download gestartet"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
170
locales/en.json
170
locales/en.json
@@ -16,10 +16,13 @@
|
||||
"help": "Help",
|
||||
"add": "Add",
|
||||
"close": "Close",
|
||||
"menu": "Menu"
|
||||
"menu": "Menu",
|
||||
"remove": "Remove",
|
||||
"change": "Change"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
"cancelling": "Cancelling...",
|
||||
"unknown": "Unknown",
|
||||
"date": "Date",
|
||||
"version": "Version",
|
||||
@@ -111,6 +114,7 @@
|
||||
"replacePreview": "Replace Preview",
|
||||
"copyCheckpointName": "Copy checkpoint name",
|
||||
"copyEmbeddingName": "Copy embedding name",
|
||||
"embeddingNameCopied": "Embedding syntax copied",
|
||||
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
||||
},
|
||||
@@ -247,7 +251,18 @@
|
||||
"toggle": "Toggle theme",
|
||||
"switchToLight": "Switch to light theme",
|
||||
"switchToDark": "Switch to dark theme",
|
||||
"switchToAuto": "Switch to auto theme"
|
||||
"switchToAuto": "Switch to auto theme",
|
||||
"presets": "Theme Presets",
|
||||
"default": "Default",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Mode",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Check Updates",
|
||||
@@ -259,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||
"civitaiApiKeyConfigured": "Configured",
|
||||
"civitaiApiKeyNotConfigured": "Not configured",
|
||||
"civitaiApiKeySet": "Set up",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai host",
|
||||
"help": "Choose which Civitai site opens when using View on Civitai links.",
|
||||
@@ -299,6 +317,7 @@
|
||||
"downloads": "Downloads",
|
||||
"videoSettings": "Video Settings",
|
||||
"layoutSettings": "Layout Settings",
|
||||
"licenseIcons": "License Icons",
|
||||
"misc": "Miscellaneous",
|
||||
"backup": "Backups",
|
||||
"folderSettings": "Default Roots",
|
||||
@@ -445,7 +464,9 @@
|
||||
"modelName": "Model Name",
|
||||
"fileName": "File Name"
|
||||
},
|
||||
"modelNameDisplayHelp": "Choose what to display in the model card footer"
|
||||
"modelNameDisplayHelp": "Choose what to display in the model card footer",
|
||||
"cardBlurAmount": "Card Overlay Blur",
|
||||
"cardBlurAmountHelp": "Adjust the blur intensity of the header and footer overlays on model and recipe cards (0 = no blur, 20 = maximum blur)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Active Library",
|
||||
@@ -577,6 +598,10 @@
|
||||
"label": "Hide Early Access Updates",
|
||||
"help": "When enabled, models with only early access updates will not show 'Update available' badge"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Use updated license icons",
|
||||
"useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
||||
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
|
||||
@@ -690,6 +715,7 @@
|
||||
"copyAll": "Copy Selected Syntax",
|
||||
"refreshAll": "Refresh Selected Metadata",
|
||||
"repairMetadata": "Repair Metadata for Selected",
|
||||
"reimportMetadata": "Re-import from Source",
|
||||
"checkUpdates": "Check Updates for Selected",
|
||||
"moveAll": "Move Selected to Folder",
|
||||
"autoOrganize": "Auto-Organize Selected",
|
||||
@@ -737,6 +763,7 @@
|
||||
"setContentRating": "Set Content Rating",
|
||||
"moveToFolder": "Move to Folder",
|
||||
"repairMetadata": "Repair metadata",
|
||||
"reimportMetadata": "Re-import from Source",
|
||||
"excludeModel": "Exclude Model",
|
||||
"restoreModel": "Restore Model",
|
||||
"deleteModel": "Delete Model",
|
||||
@@ -864,6 +891,13 @@
|
||||
"skipped": "Recipe already at latest version, no repair needed",
|
||||
"failed": "Failed to repair recipe: {message}",
|
||||
"missingId": "Cannot repair recipe: Missing recipe ID"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Re-importing recipe from source...",
|
||||
"success": "Recipe re-imported successfully",
|
||||
"noSourceUrl": "Recipe has no source URL, cannot re-import",
|
||||
"failed": "Failed to re-import recipe: {message}",
|
||||
"missingId": "Cannot re-import recipe: Missing recipe ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -942,8 +976,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Root",
|
||||
"collapseAll": "Collapse All Folders",
|
||||
"pinSidebar": "Pin Sidebar",
|
||||
"unpinSidebar": "Unpin Sidebar",
|
||||
"hideOnThisPage": "Hide sidebar on this page",
|
||||
"showSidebar": "Show sidebar",
|
||||
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
|
||||
"switchToListView": "Switch to List View",
|
||||
"switchToTreeView": "Switch to Tree View",
|
||||
"recursiveOn": "Include subfolders",
|
||||
@@ -981,6 +1016,18 @@
|
||||
"storage": "Storage",
|
||||
"insights": "Insights"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Total Models",
|
||||
"totalStorage": "Total Storage",
|
||||
"totalGenerations": "Total Generations",
|
||||
"usageRate": "Usage Rate",
|
||||
"loras": "LoRAs",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"uniqueTags": "Unique Tags",
|
||||
"unusedModels": "Unused Models",
|
||||
"avgUsesPerModel": "Avg. Uses/Model"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "Most Used LoRAs",
|
||||
"mostUsedCheckpoints": "Most Used Checkpoints",
|
||||
@@ -998,13 +1045,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Smart Insights",
|
||||
"recommendations": "Recommendations"
|
||||
"recommendations": "Recommendations",
|
||||
"noInsights": "No insights available",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "High Number of Unused LoRAs",
|
||||
"description": "{percent}% of your LoRAs ({count}/{total}) have never been used.",
|
||||
"suggestion": "Consider organizing or archiving unused models to free up storage space."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Unused Checkpoints Detected",
|
||||
"description": "{percent}% of your checkpoints ({count}/{total}) have never been used.",
|
||||
"suggestion": "Review and consider removing checkpoints you no longer need."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "High Number of Unused Embeddings",
|
||||
"description": "{percent}% of your embeddings ({count}/{total}) have never been used.",
|
||||
"suggestion": "Consider organizing or archiving unused embeddings to optimize your collection."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Large Collection Detected",
|
||||
"description": "Your model collection is using {size} of storage.",
|
||||
"suggestion": "Consider using external storage or cloud solutions for better organization."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Active User",
|
||||
"description": "You've completed {count} generations so far!",
|
||||
"suggestion": "Keep exploring and creating amazing content with your models."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Collection Overview",
|
||||
"baseModelDistribution": "Base Model Distribution",
|
||||
"usageTrends": "Usage Trends (Last 30 Days)",
|
||||
"usageDistribution": "Usage Distribution"
|
||||
"usageDistribution": "Usage Distribution",
|
||||
"date": "Date",
|
||||
"usageCount": "Usage Count",
|
||||
"fileSizeBytes": "File Size (bytes)",
|
||||
"models": "Models",
|
||||
"loraUsage": "LoRA Usage",
|
||||
"checkpointUsage": "Checkpoint Usage",
|
||||
"embeddingUsage": "Embedding Usage"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model",
|
||||
"embedding": "Embeddings"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Loading...",
|
||||
"noModels": "No models found",
|
||||
"errorLoading": "Error loading data",
|
||||
"noStorageData": "No storage data available",
|
||||
"rootFolder": "Root",
|
||||
"chartLibraryMissing": "Chart requires Chart.js library"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} models",
|
||||
"chartUsage": "{name}: {size}, {count} uses",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -1014,9 +1125,9 @@
|
||||
"download": {
|
||||
"title": "Download Model from URL",
|
||||
"titleWithType": "Download {type} from URL",
|
||||
"url": "Civitai URL",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"civitaiUrl": "Civitai URL(s):",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Enter one CivitAI or CivArchive URL per line. Supports multiple URLs for batch download.",
|
||||
"locationPreview": "Download Location Preview",
|
||||
"useDefaultPath": "Use Default Path",
|
||||
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
|
||||
@@ -1225,7 +1336,9 @@
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Notes saved successfully",
|
||||
"saveFailed": "Failed to save notes"
|
||||
"saveFailed": "Failed to save notes",
|
||||
"showMore": "Show more",
|
||||
"showLess": "Show less"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "Add preset parameter...",
|
||||
@@ -1378,6 +1491,21 @@
|
||||
"versionDeleted": "Version deleted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "Metadata Fetch Summary",
|
||||
"statSuccess": "Success",
|
||||
"statFailed": "Failed",
|
||||
"statSkipped": "Skipped",
|
||||
"statTotal": "Total Scanned",
|
||||
"statDuration": "Duration",
|
||||
"successMessage": "All {count} {type}s updated successfully!",
|
||||
"failedItems": "Failed Items ({count})",
|
||||
"close": "Close",
|
||||
"copyReport": "Copy Report",
|
||||
"downloadCsv": "Download CSV",
|
||||
"columnModelName": "Model Name",
|
||||
"columnError": "Error"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1391,15 +1519,6 @@
|
||||
"duplicate": "This tag already exists"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Keyboard Navigation:",
|
||||
"shortcuts": {
|
||||
"pageUp": "Scroll up one page",
|
||||
"pageDown": "Scroll down one page",
|
||||
"home": "Jump to top",
|
||||
"end": "Jump to bottom"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Initializing",
|
||||
"message": "Preparing your workspace...",
|
||||
@@ -1487,11 +1606,14 @@
|
||||
"noMatchingNodes": "No compatible nodes available in the current workflow",
|
||||
"noTargetNodeSelected": "No target node selected",
|
||||
"modelUpdated": "Model updated in workflow",
|
||||
"modelFailed": "Failed to update model node"
|
||||
"modelFailed": "Failed to update model node",
|
||||
"embeddingAdded": "Embedding added to workflow",
|
||||
"embeddingFailed": "Failed to add embedding"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"replace": "Replace",
|
||||
"append": "Append",
|
||||
"selectTargetNode": "Select target node",
|
||||
@@ -1713,6 +1835,10 @@
|
||||
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
|
||||
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
|
||||
"repairBulkFailed": "Failed to repair selected recipes: {message}",
|
||||
"reimporting": "Re-importing recipe from source...",
|
||||
"reimportSuccess": "Recipe re-imported successfully",
|
||||
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
|
||||
"reimportBulkFailed": "Failed to re-import some recipes",
|
||||
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
|
||||
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
|
||||
},
|
||||
@@ -1930,7 +2056,9 @@
|
||||
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
|
||||
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "Copied to clipboard",
|
||||
"downloadStarted": "Download started"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
168
locales/es.json
168
locales/es.json
@@ -16,10 +16,13 @@
|
||||
"help": "Ayuda",
|
||||
"add": "Añadir",
|
||||
"close": "Cerrar",
|
||||
"menu": "Menú"
|
||||
"menu": "Menú",
|
||||
"remove": "Eliminar",
|
||||
"change": "Cambiar"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Cargando...",
|
||||
"cancelling": "Cancelando...",
|
||||
"unknown": "Desconocido",
|
||||
"date": "Fecha",
|
||||
"version": "Versión",
|
||||
@@ -111,6 +114,7 @@
|
||||
"replacePreview": "Reemplazar vista previa",
|
||||
"copyCheckpointName": "Copiar nombre del checkpoint",
|
||||
"copyEmbeddingName": "Copiar nombre del embedding",
|
||||
"embeddingNameCopied": "Sintaxis de embedding copiada",
|
||||
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
||||
},
|
||||
@@ -247,7 +251,18 @@
|
||||
"toggle": "Cambiar tema",
|
||||
"switchToLight": "Cambiar a tema claro",
|
||||
"switchToDark": "Cambiar a tema oscuro",
|
||||
"switchToAuto": "Cambiar a tema automático"
|
||||
"switchToAuto": "Cambiar a tema automático",
|
||||
"presets": "Preajustes de tema",
|
||||
"default": "Predeterminado",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Modo",
|
||||
"light": "Claro",
|
||||
"dark": "Oscuro",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Comprobar actualizaciones",
|
||||
@@ -259,6 +274,9 @@
|
||||
"civitaiApiKey": "Clave API de Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
||||
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
||||
"civitaiApiKeyConfigured": "Configurado",
|
||||
"civitaiApiKeyNotConfigured": "No configurado",
|
||||
"civitaiApiKeySet": "Configurar",
|
||||
"civitaiHost": {
|
||||
"label": "Host de Civitai",
|
||||
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
|
||||
@@ -299,6 +317,7 @@
|
||||
"downloads": "Descargas",
|
||||
"videoSettings": "Configuración de video",
|
||||
"layoutSettings": "Configuración de diseño",
|
||||
"licenseIcons": "Iconos de licencia",
|
||||
"misc": "Varios",
|
||||
"backup": "Copias de seguridad",
|
||||
"folderSettings": "Raíces predeterminadas",
|
||||
@@ -445,7 +464,9 @@
|
||||
"modelName": "Nombre del modelo",
|
||||
"fileName": "Nombre del archivo"
|
||||
},
|
||||
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo"
|
||||
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo",
|
||||
"cardBlurAmount": "Desenfoque de superposición de tarjetas",
|
||||
"cardBlurAmountHelp": "Ajuste la intensidad de desenfoque de las superposiciones del encabezado y pie de página en las tarjetas de modelos y recetas (0 = sin desenfoque, 20 = desenfoque máximo)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Biblioteca activa",
|
||||
@@ -577,6 +598,10 @@
|
||||
"label": "Ocultar actualizaciones de acceso temprano",
|
||||
"help": "Solo actualizaciones de acceso temprano"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Usar iconos de licencia actualizados",
|
||||
"useNewStyleHelp": "Mostrar permisos de licencia con indicadores de color (nuevo estilo) o solo iconos de restricción (estilo clásico). Refleja el diseño actual de CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
|
||||
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles",
|
||||
@@ -690,6 +715,7 @@
|
||||
"copyAll": "Copiar toda la sintaxis",
|
||||
"refreshAll": "Actualizar todos los metadatos",
|
||||
"repairMetadata": "Reparar metadatos de la selección",
|
||||
"reimportMetadata": "Reimportar desde origen",
|
||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||
"moveAll": "Mover todos a carpeta",
|
||||
"autoOrganize": "Auto-organizar seleccionados",
|
||||
@@ -737,6 +763,7 @@
|
||||
"setContentRating": "Establecer clasificación de contenido",
|
||||
"moveToFolder": "Mover a carpeta",
|
||||
"repairMetadata": "Reparar metadatos",
|
||||
"reimportMetadata": "Reimportar desde origen",
|
||||
"excludeModel": "Excluir modelo",
|
||||
"restoreModel": "Restaurar modelo",
|
||||
"deleteModel": "Eliminar modelo",
|
||||
@@ -864,6 +891,13 @@
|
||||
"skipped": "La receta ya está en la última versión, no se necesita reparación",
|
||||
"failed": "Error al reparar la receta: {message}",
|
||||
"missingId": "No se puede reparar la receta: falta el ID de la receta"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Reimportando receta desde origen...",
|
||||
"success": "Receta reimportada exitosamente",
|
||||
"noSourceUrl": "La receta no tiene URL de origen, no se puede reimportar",
|
||||
"failed": "Error al reimportar la receta: {message}",
|
||||
"missingId": "No se puede reimportar la receta: falta el ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -942,8 +976,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Raíz",
|
||||
"collapseAll": "Colapsar todas las carpetas",
|
||||
"pinSidebar": "Fijar barra lateral",
|
||||
"unpinSidebar": "Desfijar barra lateral",
|
||||
"hideOnThisPage": "Ocultar barra lateral en esta página",
|
||||
"showSidebar": "Mostrar barra lateral",
|
||||
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
|
||||
"switchToListView": "Cambiar a vista de lista",
|
||||
"switchToTreeView": "Cambiar a vista de árbol",
|
||||
"recursiveOn": "Incluir subcarpetas",
|
||||
@@ -981,6 +1016,18 @@
|
||||
"storage": "Almacenamiento",
|
||||
"insights": "Perspectivas"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Total de modelos",
|
||||
"totalStorage": "Almacenamiento total",
|
||||
"totalGenerations": "Generaciones totales",
|
||||
"usageRate": "Tasa de uso",
|
||||
"loras": "LoRAs",
|
||||
"checkpoints": "Puntos de control",
|
||||
"embeddings": "Embeddings",
|
||||
"uniqueTags": "Etiquetas únicas",
|
||||
"unusedModels": "Modelos no usados",
|
||||
"avgUsesPerModel": "Prom. usos/modelo"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "LoRAs más utilizados",
|
||||
"mostUsedCheckpoints": "Checkpoints más utilizados",
|
||||
@@ -998,13 +1045,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Perspectivas inteligentes",
|
||||
"recommendations": "Recomendaciones"
|
||||
"recommendations": "Recomendaciones",
|
||||
"noInsights": "No hay información disponible",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "Alta cantidad de LoRAs no utilizadas",
|
||||
"description": "El {percent}% de tus LoRAs ({count}/{total}) nunca se han utilizado.",
|
||||
"suggestion": "Considera organizar o archivar modelos no utilizados para liberar espacio."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Puntos de control no utilizados detectados",
|
||||
"description": "El {percent}% de tus puntos de control ({count}/{total}) nunca se han utilizado.",
|
||||
"suggestion": "Revisa y considera eliminar los puntos de control que ya no necesites."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "Alta cantidad de Embeddings no utilizados",
|
||||
"description": "El {percent}% de tus embeddings ({count}/{total}) nunca se han utilizado.",
|
||||
"suggestion": "Considera organizar o archivar embeddings no utilizados para optimizar tu colección."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Colección grande detectada",
|
||||
"description": "Tu colección de modelos está usando {size} de almacenamiento.",
|
||||
"suggestion": "Considera usar almacenamiento externo o soluciones en la nube para una mejor organización."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Usuario activo",
|
||||
"description": "¡Has completado {count} generaciones hasta ahora!",
|
||||
"suggestion": "Sigue explorando y creando contenido increíble con tus modelos."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Resumen de colección",
|
||||
"baseModelDistribution": "Distribución de modelo base",
|
||||
"usageTrends": "Tendencias de uso (Últimos 30 días)",
|
||||
"usageDistribution": "Distribución de uso"
|
||||
"usageDistribution": "Distribución de uso",
|
||||
"date": "Fecha",
|
||||
"usageCount": "Conteo de uso",
|
||||
"fileSizeBytes": "Tamaño del archivo (bytes)",
|
||||
"models": "Modelos",
|
||||
"loraUsage": "Uso de LoRA",
|
||||
"checkpointUsage": "Uso de Checkpoint",
|
||||
"embeddingUsage": "Uso de Embedding"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Punto de control",
|
||||
"diffusion_model": "Modelo de difusión",
|
||||
"embedding": "Embeddings"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Cargando...",
|
||||
"noModels": "No se encontraron modelos",
|
||||
"errorLoading": "Error al cargar datos",
|
||||
"noStorageData": "No hay datos de almacenamiento disponibles",
|
||||
"rootFolder": "Raíz",
|
||||
"chartLibraryMissing": "El gráfico requiere la librería Chart.js"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} modelos",
|
||||
"chartUsage": "{name}: {size}, {count} usos",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -1014,9 +1125,9 @@
|
||||
"download": {
|
||||
"title": "Descargar modelo desde URL",
|
||||
"titleWithType": "Descargar {type} desde URL",
|
||||
"url": "URL de Civitai",
|
||||
"civitaiUrl": "URL de Civitai:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Ingrese una URL de CivitAI o CivArchive por línea. Admite múltiples URLs para descarga por lotes.",
|
||||
"locationPreview": "Vista previa de ubicación de descarga",
|
||||
"useDefaultPath": "Usar ruta predeterminada",
|
||||
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
|
||||
@@ -1225,7 +1336,9 @@
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Notas guardadas exitosamente",
|
||||
"saveFailed": "Error al guardar notas"
|
||||
"saveFailed": "Error al guardar notas",
|
||||
"showMore": "Mostrar más",
|
||||
"showLess": "Mostrar menos"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "Añadir parámetro preestablecido...",
|
||||
@@ -1378,6 +1491,21 @@
|
||||
"versionDeleted": "Versión eliminada"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "Resumen de obtención de metadatos",
|
||||
"statSuccess": "Éxito",
|
||||
"statFailed": "Fallido",
|
||||
"statSkipped": "Omitido",
|
||||
"statTotal": "Total escaneado",
|
||||
"statDuration": "Duración",
|
||||
"successMessage": "¡Todos los {count} {type}s actualizados correctamente!",
|
||||
"failedItems": "Elementos fallidos ({count})",
|
||||
"close": "Cerrar",
|
||||
"copyReport": "Copiar informe",
|
||||
"downloadCsv": "Descargar CSV",
|
||||
"columnModelName": "Nombre del modelo",
|
||||
"columnError": "Error"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1391,15 +1519,6 @@
|
||||
"duplicate": "Esta etiqueta ya existe"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Navegación por teclado:",
|
||||
"shortcuts": {
|
||||
"pageUp": "Desplazar hacia arriba una página",
|
||||
"pageDown": "Desplazar hacia abajo una página",
|
||||
"home": "Saltar al inicio",
|
||||
"end": "Saltar al final"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Inicializando",
|
||||
"message": "Preparando tu espacio de trabajo...",
|
||||
@@ -1487,11 +1606,14 @@
|
||||
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
||||
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
|
||||
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
||||
"modelFailed": "Error al actualizar nodo de modelo"
|
||||
"modelFailed": "Error al actualizar nodo de modelo",
|
||||
"embeddingAdded": "Embedding añadido al flujo de trabajo",
|
||||
"embeddingFailed": "Error al añadir el embedding"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Receta",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"replace": "Reemplazar",
|
||||
"append": "Añadir",
|
||||
"selectTargetNode": "Seleccionar nodo de destino",
|
||||
@@ -1713,6 +1835,10 @@
|
||||
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
|
||||
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
|
||||
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
|
||||
"reimporting": "Reimportando receta desde origen...",
|
||||
"reimportSuccess": "Receta reimportada exitosamente",
|
||||
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
|
||||
"reimportBulkFailed": "Error al reimportar algunas recetas",
|
||||
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
|
||||
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
|
||||
},
|
||||
@@ -1930,7 +2056,9 @@
|
||||
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
||||
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "Copiado al portapapeles",
|
||||
"downloadStarted": "Descarga iniciada"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
168
locales/fr.json
168
locales/fr.json
@@ -16,10 +16,13 @@
|
||||
"help": "Aide",
|
||||
"add": "Ajouter",
|
||||
"close": "Fermer",
|
||||
"menu": "Menu"
|
||||
"menu": "Menu",
|
||||
"remove": "Supprimer",
|
||||
"change": "Modifier"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement...",
|
||||
"cancelling": "Annulation...",
|
||||
"unknown": "Inconnu",
|
||||
"date": "Date",
|
||||
"version": "Version",
|
||||
@@ -111,6 +114,7 @@
|
||||
"replacePreview": "Remplacer l'aperçu",
|
||||
"copyCheckpointName": "Copier le nom du checkpoint",
|
||||
"copyEmbeddingName": "Copier le nom de l'embedding",
|
||||
"embeddingNameCopied": "Syntaxe dembedding copiée",
|
||||
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
|
||||
},
|
||||
@@ -247,7 +251,18 @@
|
||||
"toggle": "Basculer le thème",
|
||||
"switchToLight": "Passer au thème clair",
|
||||
"switchToDark": "Passer au thème sombre",
|
||||
"switchToAuto": "Passer au thème automatique"
|
||||
"switchToAuto": "Passer au thème automatique",
|
||||
"presets": "Préréglages de thème",
|
||||
"default": "Par défaut",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Mode",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
@@ -259,6 +274,9 @@
|
||||
"civitaiApiKey": "Clé API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
||||
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
||||
"civitaiApiKeyConfigured": "Configuré",
|
||||
"civitaiApiKeyNotConfigured": "Non configuré",
|
||||
"civitaiApiKeySet": "Configurer",
|
||||
"civitaiHost": {
|
||||
"label": "Hôte Civitai",
|
||||
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
|
||||
@@ -299,6 +317,7 @@
|
||||
"downloads": "Téléchargements",
|
||||
"videoSettings": "Paramètres vidéo",
|
||||
"layoutSettings": "Paramètres d'affichage",
|
||||
"licenseIcons": "Icônes de licence",
|
||||
"misc": "Divers",
|
||||
"backup": "Sauvegardes",
|
||||
"folderSettings": "Racines par défaut",
|
||||
@@ -445,7 +464,9 @@
|
||||
"modelName": "Nom du modèle",
|
||||
"fileName": "Nom du fichier"
|
||||
},
|
||||
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle"
|
||||
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle",
|
||||
"cardBlurAmount": "Flou de superposition des cartes",
|
||||
"cardBlurAmountHelp": "Ajustez l'intensité du flou des superpositions d'en-tête et de pied de page sur les cartes de modèles et de recettes (0 = aucun flou, 20 = flou maximal)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Bibliothèque active",
|
||||
@@ -577,6 +598,10 @@
|
||||
"label": "Masquer les mises à jour en accès anticipé",
|
||||
"help": "Seulement les mises à jour en accès anticipé"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Utiliser les icônes de licence mises à jour",
|
||||
"useNewStyleHelp": "Afficher les permissions de licence avec des indicateurs colorés (nouveau style) ou des icônes de restriction uniquement (style classique). Reprend le design actuel de CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
|
||||
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers",
|
||||
@@ -690,6 +715,7 @@
|
||||
"copyAll": "Copier toute la syntaxe",
|
||||
"refreshAll": "Actualiser toutes les métadonnées",
|
||||
"repairMetadata": "Réparer les métadonnées de la sélection",
|
||||
"reimportMetadata": "Ré-importer depuis la source",
|
||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||
"moveAll": "Déplacer tout vers un dossier",
|
||||
"autoOrganize": "Auto-organiser la sélection",
|
||||
@@ -737,6 +763,7 @@
|
||||
"setContentRating": "Définir la classification du contenu",
|
||||
"moveToFolder": "Déplacer vers un dossier",
|
||||
"repairMetadata": "Réparer les métadonnées",
|
||||
"reimportMetadata": "Ré-importer depuis la source",
|
||||
"excludeModel": "Exclure le modèle",
|
||||
"restoreModel": "Restaurer le modèle",
|
||||
"deleteModel": "Supprimer le modèle",
|
||||
@@ -864,6 +891,13 @@
|
||||
"skipped": "Recette déjà à la version la plus récente, aucune réparation nécessaire",
|
||||
"failed": "Échec de la réparation de la recette : {message}",
|
||||
"missingId": "Impossible de réparer la recette : ID de recette manquant"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Ré-import de la recette depuis la source...",
|
||||
"success": "Recette ré-importée avec succès",
|
||||
"noSourceUrl": "La recette n'a pas d'URL source, ré-import impossible",
|
||||
"failed": "Échec du ré-import de la recette : {message}",
|
||||
"missingId": "Impossible de ré-importer la recette : ID de recette manquant"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -942,8 +976,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Racine",
|
||||
"collapseAll": "Réduire tous les dossiers",
|
||||
"pinSidebar": "Épingler la barre latérale",
|
||||
"unpinSidebar": "Désépingler la barre latérale",
|
||||
"hideOnThisPage": "Masquer la barre latérale sur cette page",
|
||||
"showSidebar": "Afficher la barre latérale",
|
||||
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
|
||||
"switchToListView": "Passer en vue liste",
|
||||
"switchToTreeView": "Passer en vue arborescence",
|
||||
"recursiveOn": "Inclure les sous-dossiers",
|
||||
@@ -981,6 +1016,18 @@
|
||||
"storage": "Stockage",
|
||||
"insights": "Aperçus"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Total des modèles",
|
||||
"totalStorage": "Stockage total",
|
||||
"totalGenerations": "Générations totales",
|
||||
"usageRate": "Taux d'utilisation",
|
||||
"loras": "LoRAs",
|
||||
"checkpoints": "Points de contrôle",
|
||||
"embeddings": "Embeddings",
|
||||
"uniqueTags": "Tags uniques",
|
||||
"unusedModels": "Modèles inutilisés",
|
||||
"avgUsesPerModel": "Moy. utilisations/modèle"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "LoRAs les plus utilisés",
|
||||
"mostUsedCheckpoints": "Checkpoints les plus utilisés",
|
||||
@@ -998,13 +1045,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Aperçus intelligents",
|
||||
"recommendations": "Recommandations"
|
||||
"recommendations": "Recommandations",
|
||||
"noInsights": "Aucun aperçu disponible",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "Nombre élevé de LoRAs inutilisées",
|
||||
"description": "{percent}% de vos LoRAs ({count}/{total}) n'ont jamais été utilisées.",
|
||||
"suggestion": "Envisagez d'organiser ou d'archiver les modèles inutilisés pour libérer de l'espace."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Points de contrôle inutilisés détectés",
|
||||
"description": "{percent}% de vos points de contrôle ({count}/{total}) n'ont jamais été utilisés.",
|
||||
"suggestion": "Examinez et envisagez de supprimer les points de contrôle dont vous n'avez plus besoin."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "Nombre élevé d'Embeddings inutilisées",
|
||||
"description": "{percent}% de vos embeddings ({count}/{total}) n'ont jamais été utilisées.",
|
||||
"suggestion": "Envisagez d'organiser ou d'archiver les embeddings inutilisées pour optimiser votre collection."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Grande collection détectée",
|
||||
"description": "Votre collection de modèles utilise {size} de stockage.",
|
||||
"suggestion": "Envisagez d'utiliser un stockage externe ou des solutions cloud pour une meilleure organisation."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Utilisateur actif",
|
||||
"description": "Vous avez effectué {count} générations jusqu'à présent !",
|
||||
"suggestion": "Continuez à explorer et à créer du contenu formidable avec vos modèles."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Aperçu de la collection",
|
||||
"baseModelDistribution": "Distribution des modèles de base",
|
||||
"usageTrends": "Tendances d'utilisation (30 derniers jours)",
|
||||
"usageDistribution": "Distribution de l'utilisation"
|
||||
"usageDistribution": "Distribution de l'utilisation",
|
||||
"date": "Date",
|
||||
"usageCount": "Nombre d'utilisations",
|
||||
"fileSizeBytes": "Taille du fichier (octets)",
|
||||
"models": "Modèles",
|
||||
"loraUsage": "Utilisation LoRA",
|
||||
"checkpointUsage": "Utilisation Checkpoint",
|
||||
"embeddingUsage": "Utilisation Embedding"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Point de contrôle",
|
||||
"diffusion_model": "Modèle de diffusion",
|
||||
"embedding": "Embeddings"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Chargement...",
|
||||
"noModels": "Aucun modèle trouvé",
|
||||
"errorLoading": "Erreur de chargement des données",
|
||||
"noStorageData": "Aucune donnée de stockage disponible",
|
||||
"rootFolder": "Racine",
|
||||
"chartLibraryMissing": "Le graphique nécessite la bibliothèque Chart.js"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} modèles",
|
||||
"chartUsage": "{name}: {size}, {count} utilisations",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -1014,9 +1125,9 @@
|
||||
"download": {
|
||||
"title": "Télécharger un modèle depuis une URL",
|
||||
"titleWithType": "Télécharger {type} depuis une URL",
|
||||
"url": "URL Civitai",
|
||||
"civitaiUrl": "URL Civitai :",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Entrez une URL CivitAI ou CivArchive par ligne. Prend en charge plusieurs URLs pour le téléchargement par lot.",
|
||||
"locationPreview": "Aperçu de l'emplacement de téléchargement",
|
||||
"useDefaultPath": "Utiliser le chemin par défaut",
|
||||
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
|
||||
@@ -1225,7 +1336,9 @@
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Notes sauvegardées avec succès",
|
||||
"saveFailed": "Échec de la sauvegarde des notes"
|
||||
"saveFailed": "Échec de la sauvegarde des notes",
|
||||
"showMore": "Afficher plus",
|
||||
"showLess": "Afficher moins"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "Ajouter un paramètre prédéfini...",
|
||||
@@ -1378,6 +1491,21 @@
|
||||
"versionDeleted": "Version supprimée"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "Récapitulatif de la récupération des métadonnées",
|
||||
"statSuccess": "Réussi",
|
||||
"statFailed": "Échoué",
|
||||
"statSkipped": "Ignoré",
|
||||
"statTotal": "Total scanné",
|
||||
"statDuration": "Durée",
|
||||
"successMessage": "Tous les {count} {type}s mis à jour avec succès !",
|
||||
"failedItems": "Éléments échoués ({count})",
|
||||
"close": "Fermer",
|
||||
"copyReport": "Copier le rapport",
|
||||
"downloadCsv": "Télécharger CSV",
|
||||
"columnModelName": "Nom du modèle",
|
||||
"columnError": "Erreur"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1391,15 +1519,6 @@
|
||||
"duplicate": "Ce tag existe déjà"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Navigation au clavier :",
|
||||
"shortcuts": {
|
||||
"pageUp": "Défiler d'une page vers le haut",
|
||||
"pageDown": "Défiler d'une page vers le bas",
|
||||
"home": "Aller en haut",
|
||||
"end": "Aller en bas"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Initialisation",
|
||||
"message": "Préparation de votre espace de travail...",
|
||||
@@ -1487,11 +1606,14 @@
|
||||
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
||||
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
|
||||
"modelUpdated": "Modèle mis à jour dans le workflow",
|
||||
"modelFailed": "Échec de la mise à jour du nœud modèle"
|
||||
"modelFailed": "Échec de la mise à jour du nœud modèle",
|
||||
"embeddingAdded": "Embedding ajouté au workflow",
|
||||
"embeddingFailed": "Échec de l'ajout de l'embedding"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"replace": "Remplacer",
|
||||
"append": "Ajouter",
|
||||
"selectTargetNode": "Sélectionner le nœud cible",
|
||||
@@ -1713,6 +1835,10 @@
|
||||
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
|
||||
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées",
|
||||
"repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}",
|
||||
"reimporting": "Ré-import de la recette depuis la source...",
|
||||
"reimportSuccess": "Recette ré-importée avec succès",
|
||||
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
|
||||
"reimportBulkFailed": "Échec du ré-import de certaines recettes",
|
||||
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
|
||||
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
|
||||
},
|
||||
@@ -1930,7 +2056,9 @@
|
||||
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
||||
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
|
||||
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "Copié dans le presse-papiers",
|
||||
"downloadStarted": "Téléchargement démarré"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
168
locales/he.json
168
locales/he.json
@@ -16,10 +16,13 @@
|
||||
"help": "עזרה",
|
||||
"add": "הוספה",
|
||||
"close": "סגור",
|
||||
"menu": "תפריט"
|
||||
"menu": "תפריט",
|
||||
"remove": "הסר",
|
||||
"change": "שנה"
|
||||
},
|
||||
"status": {
|
||||
"loading": "טוען...",
|
||||
"cancelling": "מבטל...",
|
||||
"unknown": "לא ידוע",
|
||||
"date": "תאריך",
|
||||
"version": "גרסה",
|
||||
@@ -111,6 +114,7 @@
|
||||
"replacePreview": "החלף תצוגה מקדימה",
|
||||
"copyCheckpointName": "העתק שם Checkpoint",
|
||||
"copyEmbeddingName": "העתק שם Embedding",
|
||||
"embeddingNameCopied": "תחביר Embedding הועתק",
|
||||
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
|
||||
},
|
||||
@@ -247,7 +251,18 @@
|
||||
"toggle": "החלף ערכת נושא",
|
||||
"switchToLight": "עבור לערכת נושא בהירה",
|
||||
"switchToDark": "עבור לערכת נושא כהה",
|
||||
"switchToAuto": "עבור לערכת נושא אוטומטית"
|
||||
"switchToAuto": "עבור לערכת נושא אוטומטית",
|
||||
"presets": "ערכות נושא מוגדרות",
|
||||
"default": "ברירת מחדל",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "מצב",
|
||||
"light": "בהיר",
|
||||
"dark": "כהה",
|
||||
"auto": "אוטומטי"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "בדוק עדכונים",
|
||||
@@ -259,6 +274,9 @@
|
||||
"civitaiApiKey": "מפתח API של Civitai",
|
||||
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
|
||||
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
|
||||
"civitaiApiKeyConfigured": "מוגדר",
|
||||
"civitaiApiKeyNotConfigured": "לא מוגדר",
|
||||
"civitaiApiKeySet": "הגדר",
|
||||
"civitaiHost": {
|
||||
"label": "מארח Civitai",
|
||||
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
|
||||
@@ -299,6 +317,7 @@
|
||||
"downloads": "הורדות",
|
||||
"videoSettings": "הגדרות וידאו",
|
||||
"layoutSettings": "הגדרות פריסה",
|
||||
"licenseIcons": "סמלי רישיון",
|
||||
"misc": "שונות",
|
||||
"backup": "גיבויים",
|
||||
"folderSettings": "תיקיות ברירת מחדל",
|
||||
@@ -445,7 +464,9 @@
|
||||
"modelName": "שם מודל",
|
||||
"fileName": "שם קובץ"
|
||||
},
|
||||
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל"
|
||||
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל",
|
||||
"cardBlurAmount": "עוצמת טשטוש שכבת-על בכרטיס",
|
||||
"cardBlurAmountHelp": "כוונן את עוצמת הטשטוש של שכבת-העל בכותרת ובכותרות תחתונה בכרטיסי מודל ומתכונים (0 = ללא טשטוש, 20 = טשטוש מקסימלי)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "ספרייה פעילה",
|
||||
@@ -577,6 +598,10 @@
|
||||
"label": "הסתר עדכוני גישה מוקדמת",
|
||||
"help": "רק עדכוני גישה מוקדמת"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
|
||||
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
|
||||
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח",
|
||||
@@ -690,6 +715,7 @@
|
||||
"copyAll": "העתק את כל התחבירים",
|
||||
"refreshAll": "רענן את כל המטא-דאטה",
|
||||
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
|
||||
"reimportMetadata": "ייבא מחדש ממקור",
|
||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||
"moveAll": "העבר הכל לתיקייה",
|
||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||
@@ -737,6 +763,7 @@
|
||||
"setContentRating": "הגדר דירוג תוכן",
|
||||
"moveToFolder": "העבר לתיקייה",
|
||||
"repairMetadata": "תיקון מטא-דאטה",
|
||||
"reimportMetadata": "ייבא מחדש ממקור",
|
||||
"excludeModel": "החרג מודל",
|
||||
"restoreModel": "שחזור מודל",
|
||||
"deleteModel": "מחק מודל",
|
||||
@@ -864,6 +891,13 @@
|
||||
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
|
||||
"failed": "תיקון המתכון נכשל: {message}",
|
||||
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "מייבא מתכון מחדש מהמקור...",
|
||||
"success": "המתכון יובא מחדש בהצלחה",
|
||||
"noSourceUrl": "למתכון אין כתובת מקור, לא ניתן לייבא מחדש",
|
||||
"failed": "ייבוא המתכון מחדש נכשל: {message}",
|
||||
"missingId": "לא ניתן לייבא מחדש: חסר מזהה מתכון"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -942,8 +976,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "שורש",
|
||||
"collapseAll": "כווץ את כל התיקיות",
|
||||
"pinSidebar": "נעל סרגל צד",
|
||||
"unpinSidebar": "שחרר סרגל צד",
|
||||
"hideOnThisPage": "הסתר סרגל צד בדף זה",
|
||||
"showSidebar": "הצג סרגל צד",
|
||||
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
|
||||
"switchToListView": "עבור לתצוגת רשימה",
|
||||
"switchToTreeView": "תצוגת עץ",
|
||||
"recursiveOn": "כלול תיקיות משנה",
|
||||
@@ -981,6 +1016,18 @@
|
||||
"storage": "אחסון",
|
||||
"insights": "תובנות"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "סה\"כ דגמים",
|
||||
"totalStorage": "סה\"כ אחסון",
|
||||
"totalGenerations": "סה\"כ יצירות",
|
||||
"usageRate": "שיעור שימוש",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "נקודות ביקורת",
|
||||
"embeddings": "הטמעות",
|
||||
"uniqueTags": "תגיות ייחודיות",
|
||||
"unusedModels": "דגמים שאינם בשימוש",
|
||||
"avgUsesPerModel": "ממוצע שימושים/דגם"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "LoRAs הנפוצים ביותר",
|
||||
"mostUsedCheckpoints": "Checkpoints הנפוצים ביותר",
|
||||
@@ -998,13 +1045,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "תובנות חכמות",
|
||||
"recommendations": "המלצות"
|
||||
"recommendations": "המלצות",
|
||||
"noInsights": "אין תובנות זמינות",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "כמות גבוהה של LoRAs שאינן בשימוש",
|
||||
"description": "{percent}% מה-LoRAs שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
|
||||
"suggestion": "שקול לארגן או לאחסן בארכיון מודלים שאינם בשימוש כדי לפנות שטח אחסון."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "התגלו נקודות ביקורת שאינן בשימוש",
|
||||
"description": "{percent}% מנקודות הביקורת שלך ({count}/{total}) מעולם לא נעשה בהן שימוש.",
|
||||
"suggestion": "בדוק ושקול להסיר נקודות ביקורת שאינך צריך עוד."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "כמות גבוהה של Embeddings שאינם בשימוש",
|
||||
"description": "{percent}% מה-Embeddings שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
|
||||
"suggestion": "שקול לארגן או לאחסן בארכיון Embeddings שאינם בשימוש כדי לייעל את האוסף."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "התגלה אוסף גדול",
|
||||
"description": "אוסף המודלים שלך משתמש ב-{size} של אחסון.",
|
||||
"suggestion": "שקול להשתמש באחסון חיצוני או בפתרונות ענן לארגון טוב יותר."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "משתמש פעיל",
|
||||
"description": "השלמת {count} יצירות עד כה!",
|
||||
"suggestion": "המשך לחקור וליצור תוכן מדהים עם המודלים שלך."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "סקירת אוסף",
|
||||
"baseModelDistribution": "התפלגות מודלי בסיס",
|
||||
"usageTrends": "מגמות שימוש (30 יום אחרונים)",
|
||||
"usageDistribution": "התפלגות שימוש"
|
||||
"usageDistribution": "התפלגות שימוש",
|
||||
"date": "תאריך",
|
||||
"usageCount": "מספר שימושים",
|
||||
"fileSizeBytes": "גודל קובץ (בתים)",
|
||||
"models": "דגמים",
|
||||
"loraUsage": "שימוש ב-LoRA",
|
||||
"checkpointUsage": "שימוש ב-Checkpoint",
|
||||
"embeddingUsage": "שימוש ב-Embedding"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "נקודת ביקורת",
|
||||
"diffusion_model": "מודל דיפוזיה",
|
||||
"embedding": "הטמעות"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "טוען...",
|
||||
"noModels": "לא נמצאו דגמים",
|
||||
"errorLoading": "שגיאה בטעינת נתונים",
|
||||
"noStorageData": "אין נתוני אחסון זמינים",
|
||||
"rootFolder": "שורש",
|
||||
"chartLibraryMissing": "הגרף דורש את ספריית Chart.js"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} דגמים",
|
||||
"chartUsage": "{name}: {size}, {count} שימושים",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -1014,9 +1125,9 @@
|
||||
"download": {
|
||||
"title": "הורד מודל מכתובת URL",
|
||||
"titleWithType": "הורד {type} מכתובת URL",
|
||||
"url": "כתובת URL של Civitai",
|
||||
"civitaiUrl": "כתובת URL של Civitai:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "יש להזין כתובת URL אחת של CivitAI או CivArchive בכל שורה. תומך במספר כתובות URL להורדה בבת אחת.",
|
||||
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
|
||||
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
|
||||
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
|
||||
@@ -1225,7 +1336,9 @@
|
||||
},
|
||||
"notes": {
|
||||
"saved": "הערות נשמרו בהצלחה",
|
||||
"saveFailed": "שמירת ההערות נכשלה"
|
||||
"saveFailed": "שמירת ההערות נכשלה",
|
||||
"showMore": "הצג עוד",
|
||||
"showLess": "הצג פחות"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "הוסף פרמטר קבוע מראש...",
|
||||
@@ -1378,6 +1491,21 @@
|
||||
"versionDeleted": "הגרסה נמחקה"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "סיכום שליפת מטא-דאטה",
|
||||
"statSuccess": "הצלחה",
|
||||
"statFailed": "נכשל",
|
||||
"statSkipped": "דולג",
|
||||
"statTotal": "סה\"כ נסרק",
|
||||
"statDuration": "משך",
|
||||
"successMessage": "כל {count} {type}s עודכנו בהצלחה!",
|
||||
"failedItems": "פריטים נכשלים ({count})",
|
||||
"close": "סגור",
|
||||
"copyReport": "העתק דוח",
|
||||
"downloadCsv": "הורד CSV",
|
||||
"columnModelName": "שם המודל",
|
||||
"columnError": "שגיאה"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1391,15 +1519,6 @@
|
||||
"duplicate": "תגית זו כבר קיימת"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "ניווט במקלדת:",
|
||||
"shortcuts": {
|
||||
"pageUp": "גלול עמוד אחד למעלה",
|
||||
"pageDown": "גלול עמוד אחד למטה",
|
||||
"home": "קפוץ להתחלה",
|
||||
"end": "קפוץ לסוף"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "מאתחל",
|
||||
"message": "מכין את סביבת העבודה שלך...",
|
||||
@@ -1487,11 +1606,14 @@
|
||||
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
||||
"noTargetNodeSelected": "לא נבחר צומת יעד",
|
||||
"modelUpdated": "מודל עודכן ב-workflow",
|
||||
"modelFailed": "עדכון צומת המודל נכשל"
|
||||
"modelFailed": "עדכון צומת המודל נכשל",
|
||||
"embeddingAdded": "Embedding נוסף ל-workflow",
|
||||
"embeddingFailed": "הוספת Embedding נכשלה"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "מתכון",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"replace": "החלף",
|
||||
"append": "הוסף",
|
||||
"selectTargetNode": "בחר צומת יעד",
|
||||
@@ -1713,6 +1835,10 @@
|
||||
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
|
||||
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
|
||||
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
|
||||
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
||||
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
|
||||
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
|
||||
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
|
||||
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
||||
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
||||
},
|
||||
@@ -1930,7 +2056,9 @@
|
||||
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "הועתק ללוח",
|
||||
"downloadStarted": "ההורדה החלה"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
168
locales/ja.json
168
locales/ja.json
@@ -16,10 +16,13 @@
|
||||
"help": "ヘルプ",
|
||||
"add": "追加",
|
||||
"close": "閉じる",
|
||||
"menu": "メニュー"
|
||||
"menu": "メニュー",
|
||||
"remove": "削除",
|
||||
"change": "変更"
|
||||
},
|
||||
"status": {
|
||||
"loading": "読み込み中...",
|
||||
"cancelling": "キャンセル中...",
|
||||
"unknown": "不明",
|
||||
"date": "日付",
|
||||
"version": "バージョン",
|
||||
@@ -111,6 +114,7 @@
|
||||
"replacePreview": "プレビューを置換",
|
||||
"copyCheckpointName": "checkpoint名をコピー",
|
||||
"copyEmbeddingName": "embedding名をコピー",
|
||||
"embeddingNameCopied": "Embedding構文をコピーしました",
|
||||
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
||||
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
||||
},
|
||||
@@ -247,7 +251,18 @@
|
||||
"toggle": "テーマの切り替え",
|
||||
"switchToLight": "ライトテーマに切り替え",
|
||||
"switchToDark": "ダークテーマに切り替え",
|
||||
"switchToAuto": "自動テーマに切り替え"
|
||||
"switchToAuto": "自動テーマに切り替え",
|
||||
"presets": "テーマプリセット",
|
||||
"default": "デフォルト",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "モード",
|
||||
"light": "ライト",
|
||||
"dark": "ダーク",
|
||||
"auto": "自動"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "更新確認",
|
||||
@@ -259,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai APIキー",
|
||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||
"civitaiApiKeyConfigured": "設定済み",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai ホスト",
|
||||
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
|
||||
@@ -299,6 +317,7 @@
|
||||
"downloads": "ダウンロード",
|
||||
"videoSettings": "動画設定",
|
||||
"layoutSettings": "レイアウト設定",
|
||||
"licenseIcons": "ライセンスアイコン",
|
||||
"misc": "その他",
|
||||
"backup": "バックアップ",
|
||||
"folderSettings": "デフォルトルート",
|
||||
@@ -445,7 +464,9 @@
|
||||
"modelName": "モデル名",
|
||||
"fileName": "ファイル名"
|
||||
},
|
||||
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択"
|
||||
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択",
|
||||
"cardBlurAmount": "カードオーバーレイのぼかし",
|
||||
"cardBlurAmountHelp": "モデルカードとレシピカードのヘッダー・フッターオーバーレイのぼかし強度を調整します(0 = ぼかしなし、20 = 最大ぼかし)。"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "アクティブライブラリ",
|
||||
@@ -577,6 +598,10 @@
|
||||
"label": "早期アクセス更新を非表示",
|
||||
"help": "早期アクセスのみの更新"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "更新されたライセンスアイコンを使用",
|
||||
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
||||
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます",
|
||||
@@ -690,6 +715,7 @@
|
||||
"copyAll": "すべての構文をコピー",
|
||||
"refreshAll": "すべてのメタデータを更新",
|
||||
"repairMetadata": "選択したレシピのメタデータを修復",
|
||||
"reimportMetadata": "ソースから再インポート",
|
||||
"checkUpdates": "選択項目の更新を確認",
|
||||
"moveAll": "すべてをフォルダに移動",
|
||||
"autoOrganize": "自動整理を実行",
|
||||
@@ -737,6 +763,7 @@
|
||||
"setContentRating": "コンテンツレーティングを設定",
|
||||
"moveToFolder": "フォルダに移動",
|
||||
"repairMetadata": "メタデータを修復",
|
||||
"reimportMetadata": "ソースから再インポート",
|
||||
"excludeModel": "モデルを除外",
|
||||
"restoreModel": "モデルを復元",
|
||||
"deleteModel": "モデルを削除",
|
||||
@@ -864,6 +891,13 @@
|
||||
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
|
||||
"failed": "レシピの修復に失敗しました: {message}",
|
||||
"missingId": "レシピを修復できません: レシピIDがありません"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "ソースからレシピを再インポート中...",
|
||||
"success": "レシピの再インポートが完了しました",
|
||||
"noSourceUrl": "レシピにソースURLがありません。再インポートできません",
|
||||
"failed": "レシピの再インポートに失敗しました: {message}",
|
||||
"missingId": "レシピを再インポートできません: レシピIDがありません"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -942,8 +976,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "ルート",
|
||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||
"pinSidebar": "サイドバーを固定",
|
||||
"unpinSidebar": "サイドバーの固定を解除",
|
||||
"hideOnThisPage": "このページでサイドバーを非表示",
|
||||
"showSidebar": "サイドバーを表示",
|
||||
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
|
||||
"switchToListView": "リストビューに切り替え",
|
||||
"switchToTreeView": "ツリー表示に切り替え",
|
||||
"recursiveOn": "サブフォルダーを含める",
|
||||
@@ -981,6 +1016,18 @@
|
||||
"storage": "ストレージ",
|
||||
"insights": "インサイト"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "モデル総数",
|
||||
"totalStorage": "ストレージ合計",
|
||||
"totalGenerations": "生成回数合計",
|
||||
"usageRate": "使用率",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "ユニークタグ",
|
||||
"unusedModels": "未使用モデル",
|
||||
"avgUsesPerModel": "平均使用回数/モデル"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "最も使用されているLoRA",
|
||||
"mostUsedCheckpoints": "最も使用されているCheckpoint",
|
||||
@@ -998,13 +1045,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "スマートインサイト",
|
||||
"recommendations": "推奨事項"
|
||||
"recommendations": "推奨事項",
|
||||
"noInsights": "インサイトはありません",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "未使用のLoRAが多数あります",
|
||||
"description": "LoRAの{percent}%({count}/{total})が一度も使用されていません。",
|
||||
"suggestion": "未使用のモデルを整理またはアーカイブしてストレージを解放してください。"
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "未使用のCheckpointを検出",
|
||||
"description": "Checkpointの{percent}%({count}/{total})が一度も使用されていません。",
|
||||
"suggestion": "不要なCheckpointを確認して削除を検討してください。"
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "未使用のEmbeddingが多数あります",
|
||||
"description": "Embeddingの{percent}%({count}/{total})が一度も使用されていません。",
|
||||
"suggestion": "未使用のEmbeddingを整理またはアーカイブしてコレクションを最適化してください。"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "大規模コレクションを検出",
|
||||
"description": "モデルコレクションが{size}のストレージを使用しています。",
|
||||
"suggestion": "外部ストレージやクラウドソリューションの使用を検討してください。"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "アクティブユーザー",
|
||||
"description": "これまでに{count}回の生成を完了しました!",
|
||||
"suggestion": "モデルを使って素晴らしいコンテンツを作り続けてください。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "コレクション概要",
|
||||
"baseModelDistribution": "ベースモデル分布",
|
||||
"usageTrends": "使用傾向(過去30日)",
|
||||
"usageDistribution": "使用分布"
|
||||
"usageDistribution": "使用分布",
|
||||
"date": "日付",
|
||||
"usageCount": "使用回数",
|
||||
"fileSizeBytes": "ファイルサイズ(バイト)",
|
||||
"models": "モデル",
|
||||
"loraUsage": "LoRA 使用量",
|
||||
"checkpointUsage": "Checkpoint 使用量",
|
||||
"embeddingUsage": "Embedding 使用量"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "拡散モデル",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "読み込み中...",
|
||||
"noModels": "モデルが見つかりません",
|
||||
"errorLoading": "データ読み込みエラー",
|
||||
"noStorageData": "ストレージデータがありません",
|
||||
"rootFolder": "ルート",
|
||||
"chartLibraryMissing": "Chart.js ライブラリが必要です"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} モデル",
|
||||
"chartUsage": "{name}: {size}, {count} 回使用",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -1014,9 +1125,9 @@
|
||||
"download": {
|
||||
"title": "URLからモデルをダウンロード",
|
||||
"titleWithType": "URLから{type}をダウンロード",
|
||||
"url": "Civitai URL",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "1行に1つのCivitAIまたはCivArchive URLを入力してください。複数のURLを一括ダウンロードできます。",
|
||||
"locationPreview": "ダウンロード場所プレビュー",
|
||||
"useDefaultPath": "デフォルトパスを使用",
|
||||
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
|
||||
@@ -1225,7 +1336,9 @@
|
||||
},
|
||||
"notes": {
|
||||
"saved": "メモが正常に保存されました",
|
||||
"saveFailed": "メモの保存に失敗しました"
|
||||
"saveFailed": "メモの保存に失敗しました",
|
||||
"showMore": "もっと見る",
|
||||
"showLess": "折りたたむ"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "プリセットパラメータを追加...",
|
||||
@@ -1378,6 +1491,21 @@
|
||||
"versionDeleted": "バージョンを削除しました"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "メタデータ取得サマリー",
|
||||
"statSuccess": "成功",
|
||||
"statFailed": "失敗",
|
||||
"statSkipped": "スキップ",
|
||||
"statTotal": "スキャン合計",
|
||||
"statDuration": "所要時間",
|
||||
"successMessage": "すべての{count}件の{type}を正常に更新しました",
|
||||
"failedItems": "失敗したアイテム ({count})",
|
||||
"close": "閉じる",
|
||||
"copyReport": "レポートをコピー",
|
||||
"downloadCsv": "CSVをダウンロード",
|
||||
"columnModelName": "モデル名",
|
||||
"columnError": "エラー"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1391,15 +1519,6 @@
|
||||
"duplicate": "このタグは既に存在します"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "キーボードナビゲーション:",
|
||||
"shortcuts": {
|
||||
"pageUp": "1ページ上にスクロール",
|
||||
"pageDown": "1ページ下にスクロール",
|
||||
"home": "トップにジャンプ",
|
||||
"end": "ボトムにジャンプ"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "初期化中",
|
||||
"message": "ワークスペースを準備中...",
|
||||
@@ -1487,11 +1606,14 @@
|
||||
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||
"noTargetNodeSelected": "ターゲットノードが選択されていません",
|
||||
"modelUpdated": "モデルがワークフローで更新されました",
|
||||
"modelFailed": "モデルノードの更新に失敗しました"
|
||||
"modelFailed": "モデルノードの更新に失敗しました",
|
||||
"embeddingAdded": "Embeddingをワークフローに追加しました",
|
||||
"embeddingFailed": "Embeddingの追加に失敗しました"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "レシピ",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"replace": "置換",
|
||||
"append": "追加",
|
||||
"selectTargetNode": "ターゲットノードを選択",
|
||||
@@ -1713,6 +1835,10 @@
|
||||
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
||||
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
||||
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
|
||||
"reimporting": "ソースからレシピを再インポート中...",
|
||||
"reimportSuccess": "レシピの再インポートが完了しました",
|
||||
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
|
||||
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
|
||||
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
||||
},
|
||||
@@ -1930,7 +2056,9 @@
|
||||
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
||||
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "クリップボードにコピーしました",
|
||||
"downloadStarted": "ダウンロードを開始しました"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
168
locales/ko.json
168
locales/ko.json
@@ -16,10 +16,13 @@
|
||||
"help": "도움말",
|
||||
"add": "추가",
|
||||
"close": "닫기",
|
||||
"menu": "메뉴"
|
||||
"menu": "메뉴",
|
||||
"remove": "제거",
|
||||
"change": "변경"
|
||||
},
|
||||
"status": {
|
||||
"loading": "로딩 중...",
|
||||
"cancelling": "취소 중...",
|
||||
"unknown": "알 수 없음",
|
||||
"date": "날짜",
|
||||
"version": "버전",
|
||||
@@ -111,6 +114,7 @@
|
||||
"replacePreview": "미리보기 교체",
|
||||
"copyCheckpointName": "Checkpoint 이름 복사",
|
||||
"copyEmbeddingName": "Embedding 이름 복사",
|
||||
"embeddingNameCopied": "Embedding 구문 복사됨",
|
||||
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
||||
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
||||
},
|
||||
@@ -247,7 +251,18 @@
|
||||
"toggle": "테마 토글",
|
||||
"switchToLight": "라이트 테마로 전환",
|
||||
"switchToDark": "다크 테마로 전환",
|
||||
"switchToAuto": "자동 테마로 전환"
|
||||
"switchToAuto": "자동 테마로 전환",
|
||||
"presets": "테마 프리셋",
|
||||
"default": "기본",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "모드",
|
||||
"light": "라이트",
|
||||
"dark": "다크",
|
||||
"auto": "자동"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "업데이트 확인",
|
||||
@@ -259,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API 키",
|
||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||
"civitaiApiKeyConfigured": "설정됨",
|
||||
"civitaiApiKeyNotConfigured": "설정되지 않음",
|
||||
"civitaiApiKeySet": "설정",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 호스트",
|
||||
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
|
||||
@@ -299,6 +317,7 @@
|
||||
"downloads": "다운로드",
|
||||
"videoSettings": "비디오 설정",
|
||||
"layoutSettings": "레이아웃 설정",
|
||||
"licenseIcons": "라이선스 아이콘",
|
||||
"misc": "기타",
|
||||
"backup": "백업",
|
||||
"folderSettings": "기본 루트",
|
||||
@@ -445,7 +464,9 @@
|
||||
"modelName": "모델명",
|
||||
"fileName": "파일명"
|
||||
},
|
||||
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요"
|
||||
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요",
|
||||
"cardBlurAmount": "카드 오버레이 흐림 강도",
|
||||
"cardBlurAmountHelp": "모델 및 레시피 카드의 헤더와 푸터 오버레이 흐림 강도를 조정합니다 (0 = 흐림 없음, 20 = 최대 흐림)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "활성 라이브러리",
|
||||
@@ -577,6 +598,10 @@
|
||||
"label": "얼리 액세스 업데이트 숨기기",
|
||||
"help": "얼리 액세스 업데이트만"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
|
||||
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
||||
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다",
|
||||
@@ -690,6 +715,7 @@
|
||||
"copyAll": "모든 문법 복사",
|
||||
"refreshAll": "모든 메타데이터 새로고침",
|
||||
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
||||
"reimportMetadata": "소스에서 다시 가져오기",
|
||||
"checkUpdates": "선택 항목 업데이트 확인",
|
||||
"moveAll": "모두 폴더로 이동",
|
||||
"autoOrganize": "자동 정리 선택",
|
||||
@@ -737,6 +763,7 @@
|
||||
"setContentRating": "콘텐츠 등급 설정",
|
||||
"moveToFolder": "폴더로 이동",
|
||||
"repairMetadata": "메타데이터 복구",
|
||||
"reimportMetadata": "소스에서 다시 가져오기",
|
||||
"excludeModel": "모델 제외",
|
||||
"restoreModel": "모델 복원",
|
||||
"deleteModel": "모델 삭제",
|
||||
@@ -864,6 +891,13 @@
|
||||
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
|
||||
"failed": "레시피 복구 실패: {message}",
|
||||
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "소스에서 레시피를 다시 가져오는 중...",
|
||||
"success": "레시피를 다시 가져왔습니다",
|
||||
"noSourceUrl": "레시피에 소스 URL이 없어 다시 가져올 수 없습니다",
|
||||
"failed": "레시피 다시 가져오기 실패: {message}",
|
||||
"missingId": "레시피를 다시 가져올 수 없음: 레시피 ID 누락"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -942,8 +976,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "루트",
|
||||
"collapseAll": "모든 폴더 접기",
|
||||
"pinSidebar": "사이드바 고정",
|
||||
"unpinSidebar": "사이드바 고정 해제",
|
||||
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
|
||||
"showSidebar": "사이드바 표시",
|
||||
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
|
||||
"switchToListView": "목록 보기로 전환",
|
||||
"switchToTreeView": "트리 보기로 전환",
|
||||
"recursiveOn": "하위 폴더 포함",
|
||||
@@ -981,6 +1016,18 @@
|
||||
"storage": "저장소",
|
||||
"insights": "인사이트"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "모델 총계",
|
||||
"totalStorage": "총 저장 공간",
|
||||
"totalGenerations": "총 생성 횟수",
|
||||
"usageRate": "사용률",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "고유 태그",
|
||||
"unusedModels": "미사용 모델",
|
||||
"avgUsesPerModel": "모델당 평균 사용"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "가장 많이 사용된 LoRA",
|
||||
"mostUsedCheckpoints": "가장 많이 사용된 Checkpoint",
|
||||
@@ -998,13 +1045,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "스마트 인사이트",
|
||||
"recommendations": "추천"
|
||||
"recommendations": "추천",
|
||||
"noInsights": "인사이트 없음",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "사용하지 않은 LoRA가 많음",
|
||||
"description": "LoRA의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
|
||||
"suggestion": "사용하지 않는 모델을 정리하거나 보관하여 저장 공간을 확보하세요."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "사용하지 않은 Checkpoint 감지",
|
||||
"description": "Checkpoint의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
|
||||
"suggestion": "더 이상 필요하지 않은 Checkpoint를 검토하고 제거하세요."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "사용하지 않은 Embedding이 많음",
|
||||
"description": "Embedding의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
|
||||
"suggestion": "사용하지 않는 Embedding을 정리하여 컬렉션을 최적화하세요."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "대규모 컬렉션 감지",
|
||||
"description": "모델 컬렉션이 {size}의 저장 공간을 사용 중입니다.",
|
||||
"suggestion": "더 나은 관리를 위해 외부 저장소나 클라우드 솔루션을 고려하세요."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "활성 사용자",
|
||||
"description": "지금까지 {count}번의 생성을 완료했습니다!",
|
||||
"suggestion": "모델로 계속해서 멋진 콘텐츠를 탐색하고 만들어보세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "컬렉션 개요",
|
||||
"baseModelDistribution": "베이스 모델 분포",
|
||||
"usageTrends": "사용량 트렌드 (최근 30일)",
|
||||
"usageDistribution": "사용량 분포"
|
||||
"usageDistribution": "사용량 분포",
|
||||
"date": "날짜",
|
||||
"usageCount": "사용 횟수",
|
||||
"fileSizeBytes": "파일 크기(바이트)",
|
||||
"models": "모델",
|
||||
"loraUsage": "LoRA 사용량",
|
||||
"checkpointUsage": "Checkpoint 사용량",
|
||||
"embeddingUsage": "Embedding 사용량"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "확산 모델",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "로딩 중...",
|
||||
"noModels": "모델을 찾을 수 없음",
|
||||
"errorLoading": "데이터 로딩 오류",
|
||||
"noStorageData": "저장 데이터 없음",
|
||||
"rootFolder": "루트",
|
||||
"chartLibraryMissing": "Chart.js 라이브러리가 필요합니다"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count}개 모델",
|
||||
"chartUsage": "{name}: {size}, {count}회 사용",
|
||||
"chartPercentage": "{label}: {value}({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -1014,9 +1125,9 @@
|
||||
"download": {
|
||||
"title": "URL에서 모델 다운로드",
|
||||
"titleWithType": "URL에서 {type} 다운로드",
|
||||
"url": "Civitai URL",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "한 줄에 하나의 CivitAI 또는 CivArchive URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
||||
"locationPreview": "다운로드 위치 미리보기",
|
||||
"useDefaultPath": "기본 경로 사용",
|
||||
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
|
||||
@@ -1225,7 +1336,9 @@
|
||||
},
|
||||
"notes": {
|
||||
"saved": "메모가 성공적으로 저장됨",
|
||||
"saveFailed": "메모 저장 실패"
|
||||
"saveFailed": "메모 저장 실패",
|
||||
"showMore": "더 보기",
|
||||
"showLess": "접기"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "프리셋 매개변수 추가...",
|
||||
@@ -1378,6 +1491,21 @@
|
||||
"versionDeleted": "버전이 삭제되었습니다"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "메타데이터 가져오기 요약",
|
||||
"statSuccess": "성공",
|
||||
"statFailed": "실패",
|
||||
"statSkipped": "건너뜀",
|
||||
"statTotal": "총 스캔",
|
||||
"statDuration": "소요 시간",
|
||||
"successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다",
|
||||
"failedItems": "실패한 항목 ({count})",
|
||||
"close": "닫기",
|
||||
"copyReport": "보고서 복사",
|
||||
"downloadCsv": "CSV 다운로드",
|
||||
"columnModelName": "모델 이름",
|
||||
"columnError": "오류"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1391,15 +1519,6 @@
|
||||
"duplicate": "이 태그는 이미 존재합니다"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "키보드 내비게이션:",
|
||||
"shortcuts": {
|
||||
"pageUp": "한 페이지 위로 스크롤",
|
||||
"pageDown": "한 페이지 아래로 스크롤",
|
||||
"home": "맨 위로 이동",
|
||||
"end": "맨 아래로 이동"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "초기화 중",
|
||||
"message": "작업공간을 준비하고 있습니다...",
|
||||
@@ -1487,11 +1606,14 @@
|
||||
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
|
||||
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
||||
"modelFailed": "모델 노드 업데이트 실패"
|
||||
"modelFailed": "모델 노드 업데이트 실패",
|
||||
"embeddingAdded": "Embedding을 워크플로에 추가했습니다",
|
||||
"embeddingFailed": "Embedding 추가 실패"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "레시피",
|
||||
"lora": "LoRA",
|
||||
"embedding": "임베딩",
|
||||
"replace": "교체",
|
||||
"append": "추가",
|
||||
"selectTargetNode": "대상 노드 선택",
|
||||
@@ -1713,6 +1835,10 @@
|
||||
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
||||
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
||||
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
|
||||
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
||||
"reimportSuccess": "레시피를 다시 가져왔습니다",
|
||||
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
|
||||
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
|
||||
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
||||
},
|
||||
@@ -1930,7 +2056,9 @@
|
||||
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
||||
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "클립보드에 복사됨",
|
||||
"downloadStarted": "다운로드 시작됨"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
168
locales/ru.json
168
locales/ru.json
@@ -16,10 +16,13 @@
|
||||
"help": "Справка",
|
||||
"add": "Добавить",
|
||||
"close": "Закрыть",
|
||||
"menu": "Меню"
|
||||
"menu": "Меню",
|
||||
"remove": "Удалить",
|
||||
"change": "Изменить"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Загрузка...",
|
||||
"cancelling": "Отмена...",
|
||||
"unknown": "Неизвестно",
|
||||
"date": "Дата",
|
||||
"version": "Версия",
|
||||
@@ -111,6 +114,7 @@
|
||||
"replacePreview": "Заменить превью",
|
||||
"copyCheckpointName": "Копировать имя checkpoint",
|
||||
"copyEmbeddingName": "Копировать имя embedding",
|
||||
"embeddingNameCopied": "Синтаксис embedding скопирован",
|
||||
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
||||
},
|
||||
@@ -247,7 +251,18 @@
|
||||
"toggle": "Переключить тему",
|
||||
"switchToLight": "Переключить на светлую тему",
|
||||
"switchToDark": "Переключить на тёмную тему",
|
||||
"switchToAuto": "Переключить на автоматическую тему"
|
||||
"switchToAuto": "Переключить на автоматическую тему",
|
||||
"presets": "Предустановки тем",
|
||||
"default": "По умолчанию",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Режим",
|
||||
"light": "Светлый",
|
||||
"dark": "Тёмный",
|
||||
"auto": "Авто"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Проверить обновления",
|
||||
@@ -259,6 +274,9 @@
|
||||
"civitaiApiKey": "Ключ API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
||||
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
||||
"civitaiApiKeyConfigured": "Настроен",
|
||||
"civitaiApiKeyNotConfigured": "Не настроен",
|
||||
"civitaiApiKeySet": "Настроить",
|
||||
"civitaiHost": {
|
||||
"label": "Хост Civitai",
|
||||
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
|
||||
@@ -299,6 +317,7 @@
|
||||
"downloads": "Загрузки",
|
||||
"videoSettings": "Настройки видео",
|
||||
"layoutSettings": "Настройки макета",
|
||||
"licenseIcons": "Значки лицензии",
|
||||
"misc": "Разное",
|
||||
"backup": "Резервные копии",
|
||||
"folderSettings": "Корневые папки",
|
||||
@@ -445,7 +464,9 @@
|
||||
"modelName": "Название модели",
|
||||
"fileName": "Имя файла"
|
||||
},
|
||||
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели"
|
||||
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели",
|
||||
"cardBlurAmount": "Размытие наложения карточек",
|
||||
"cardBlurAmountHelp": "Настройте интенсивность размытия наложений верхнего и нижнего колонтитулов на карточках моделей и рецептов (0 = без размытия, 20 = максимальное размытие)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Активная библиотека",
|
||||
@@ -577,6 +598,10 @@
|
||||
"label": "Скрыть обновления раннего доступа",
|
||||
"help": "Только обновления раннего доступа"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Использовать обновлённые значки лицензии",
|
||||
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
||||
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена",
|
||||
@@ -690,6 +715,7 @@
|
||||
"copyAll": "Копировать весь синтаксис",
|
||||
"refreshAll": "Обновить все метаданные",
|
||||
"repairMetadata": "Восстановить метаданные для выбранных",
|
||||
"reimportMetadata": "Переимпортировать из источника",
|
||||
"checkUpdates": "Проверить обновления для выбранных",
|
||||
"moveAll": "Переместить все в папку",
|
||||
"autoOrganize": "Автоматически организовать выбранные",
|
||||
@@ -737,6 +763,7 @@
|
||||
"setContentRating": "Установить рейтинг контента",
|
||||
"moveToFolder": "Переместить в папку",
|
||||
"repairMetadata": "Восстановить метаданные",
|
||||
"reimportMetadata": "Переимпортировать из источника",
|
||||
"excludeModel": "Исключить модель",
|
||||
"restoreModel": "Восстановить модель",
|
||||
"deleteModel": "Удалить модель",
|
||||
@@ -864,6 +891,13 @@
|
||||
"skipped": "Рецепт уже последней версии, восстановление не требуется",
|
||||
"failed": "Не удалось восстановить рецепт: {message}",
|
||||
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Переимпорт рецепта из источника...",
|
||||
"success": "Рецепт успешно переимпортирован",
|
||||
"noSourceUrl": "У рецепта нет URL источника, переимпорт невозможен",
|
||||
"failed": "Не удалось переимпортировать рецепт: {message}",
|
||||
"missingId": "Невозможно переимпортировать рецепт: отсутствует ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -942,8 +976,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Корень",
|
||||
"collapseAll": "Свернуть все папки",
|
||||
"pinSidebar": "Закрепить боковую панель",
|
||||
"unpinSidebar": "Открепить боковую панель",
|
||||
"hideOnThisPage": "Скрыть боковую панель на этой странице",
|
||||
"showSidebar": "Показать боковую панель",
|
||||
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
|
||||
"switchToListView": "Переключить на вид списка",
|
||||
"switchToTreeView": "Переключить на древовидный вид",
|
||||
"recursiveOn": "Включать вложенные папки",
|
||||
@@ -981,6 +1016,18 @@
|
||||
"storage": "Хранение",
|
||||
"insights": "Аналитика"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Всего моделей",
|
||||
"totalStorage": "Всего хранилища",
|
||||
"totalGenerations": "Всего генераций",
|
||||
"usageRate": "Коэффициент использования",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Контрольные точки",
|
||||
"embeddings": "Эмбеддинги",
|
||||
"uniqueTags": "Уникальные теги",
|
||||
"unusedModels": "Неиспользуемые модели",
|
||||
"avgUsesPerModel": "Сред. использований/модель"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "Наиболее используемые LoRAs",
|
||||
"mostUsedCheckpoints": "Наиболее используемые Checkpoints",
|
||||
@@ -998,13 +1045,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Умная аналитика",
|
||||
"recommendations": "Рекомендации"
|
||||
"recommendations": "Рекомендации",
|
||||
"noInsights": "Нет доступных данных",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "Большое количество неиспользуемых LoRA",
|
||||
"description": "{percent}% ваших LoRA ({count}/{total}) никогда не использовались.",
|
||||
"suggestion": "Рассмотрите возможность организации или архивирования неиспользуемых моделей для освобождения места."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Обнаружены неиспользуемые контрольные точки",
|
||||
"description": "{percent}% ваших контрольных точек ({count}/{total}) никогда не использовались.",
|
||||
"suggestion": "Проверьте и удалите ненужные контрольные точки."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "Большое количество неиспользуемых эмбеддингов",
|
||||
"description": "{percent}% ваших эмбеддингов ({count}/{total}) никогда не использовались.",
|
||||
"suggestion": "Организуйте или архивируйте неиспользуемые эмбеддинги для оптимизации коллекции."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Обнаружена большая коллекция",
|
||||
"description": "Ваша коллекция моделей использует {size} хранилища.",
|
||||
"suggestion": "Рассмотрите внешнее хранилище или облачные решения для лучшей организации."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Активный пользователь",
|
||||
"description": "Вы завершили {count} генераций!",
|
||||
"suggestion": "Продолжайте исследовать и создавать удивительный контент с вашими моделями."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Обзор коллекции",
|
||||
"baseModelDistribution": "Распределение базовых моделей",
|
||||
"usageTrends": "Тенденции использования (за последние 30 дней)",
|
||||
"usageDistribution": "Распределение использования"
|
||||
"usageDistribution": "Распределение использования",
|
||||
"date": "Дата",
|
||||
"usageCount": "Количество использований",
|
||||
"fileSizeBytes": "Размер файла (байты)",
|
||||
"models": "Модели",
|
||||
"loraUsage": "Использование LoRA",
|
||||
"checkpointUsage": "Использование Checkpoint",
|
||||
"embeddingUsage": "Использование Embedding"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Контрольная точка",
|
||||
"diffusion_model": "Диффузионная модель",
|
||||
"embedding": "Эмбеддинги"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Загрузка...",
|
||||
"noModels": "Модели не найдены",
|
||||
"errorLoading": "Ошибка загрузки данных",
|
||||
"noStorageData": "Нет данных о хранилище",
|
||||
"rootFolder": "Корень",
|
||||
"chartLibraryMissing": "Для графика требуется библиотека Chart.js"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} моделей",
|
||||
"chartUsage": "{name}: {size}, {count} использований",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -1014,9 +1125,9 @@
|
||||
"download": {
|
||||
"title": "Скачать модель по URL",
|
||||
"titleWithType": "Скачать {type} по URL",
|
||||
"url": "Civitai URL",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Введите один URL CivitAI или CivArchive в каждой строке. Поддерживается пакетная загрузка нескольких URL.",
|
||||
"locationPreview": "Предпросмотр места загрузки",
|
||||
"useDefaultPath": "Использовать путь по умолчанию",
|
||||
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
|
||||
@@ -1225,7 +1336,9 @@
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Заметки успешно сохранены",
|
||||
"saveFailed": "Не удалось сохранить заметки"
|
||||
"saveFailed": "Не удалось сохранить заметки",
|
||||
"showMore": "Показать больше",
|
||||
"showLess": "Свернуть"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "Добавить предустановленный параметр...",
|
||||
@@ -1378,6 +1491,21 @@
|
||||
"versionDeleted": "Версия удалена"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "Сводка получения метаданных",
|
||||
"statSuccess": "Успешно",
|
||||
"statFailed": "Ошибка",
|
||||
"statSkipped": "Пропущено",
|
||||
"statTotal": "Всего проверено",
|
||||
"statDuration": "Длительность",
|
||||
"successMessage": "Все {count} {type}s успешно обновлены",
|
||||
"failedItems": "Ошибочные элементы ({count})",
|
||||
"close": "Закрыть",
|
||||
"copyReport": "Копировать отчет",
|
||||
"downloadCsv": "Скачать CSV",
|
||||
"columnModelName": "Имя модели",
|
||||
"columnError": "Ошибка"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1391,15 +1519,6 @@
|
||||
"duplicate": "Этот тег уже существует"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Навигация с клавиатуры:",
|
||||
"shortcuts": {
|
||||
"pageUp": "Прокрутить на страницу вверх",
|
||||
"pageDown": "Прокрутить на страницу вниз",
|
||||
"home": "Перейти к началу",
|
||||
"end": "Перейти к концу"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Инициализация",
|
||||
"message": "Подготовка вашего рабочего пространства...",
|
||||
@@ -1487,11 +1606,14 @@
|
||||
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||
"noTargetNodeSelected": "Целевой узел не выбран",
|
||||
"modelUpdated": "Модель обновлена в workflow",
|
||||
"modelFailed": "Не удалось обновить узел модели"
|
||||
"modelFailed": "Не удалось обновить узел модели",
|
||||
"embeddingAdded": "Embedding добавлен в workflow",
|
||||
"embeddingFailed": "Не удалось добавить embedding"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Рецепт",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Эмбеддинг",
|
||||
"replace": "Заменить",
|
||||
"append": "Добавить",
|
||||
"selectTargetNode": "Выберите целевой узел",
|
||||
@@ -1713,6 +1835,10 @@
|
||||
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
|
||||
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
|
||||
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
|
||||
"reimporting": "Переимпорт рецепта из источника...",
|
||||
"reimportSuccess": "Рецепт успешно переимпортирован",
|
||||
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
|
||||
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
|
||||
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
||||
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
||||
},
|
||||
@@ -1930,7 +2056,9 @@
|
||||
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "Скопировано в буфер обмена",
|
||||
"downloadStarted": "Загрузка начата"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
@@ -16,10 +16,13 @@
|
||||
"help": "帮助",
|
||||
"add": "添加",
|
||||
"close": "关闭",
|
||||
"menu": "菜单"
|
||||
"menu": "菜单",
|
||||
"remove": "移除",
|
||||
"change": "更换"
|
||||
},
|
||||
"status": {
|
||||
"loading": "加载中...",
|
||||
"cancelling": "取消中...",
|
||||
"unknown": "未知",
|
||||
"date": "日期",
|
||||
"version": "版本",
|
||||
@@ -111,6 +114,7 @@
|
||||
"replacePreview": "替换预览",
|
||||
"copyCheckpointName": "复制 Checkpoint 名称",
|
||||
"copyEmbeddingName": "复制 Embedding 名称",
|
||||
"embeddingNameCopied": "已复制 Embedding 语法",
|
||||
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
||||
},
|
||||
@@ -247,7 +251,18 @@
|
||||
"toggle": "切换主题",
|
||||
"switchToLight": "切换到浅色主题",
|
||||
"switchToDark": "切换到深色主题",
|
||||
"switchToAuto": "切换到自动主题"
|
||||
"switchToAuto": "切换到自动主题",
|
||||
"presets": "主题预设",
|
||||
"default": "默认",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "模式",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"auto": "自动"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "检查更新",
|
||||
@@ -259,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API 密钥",
|
||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||
"civitaiApiKeyConfigured": "已配置",
|
||||
"civitaiApiKeyNotConfigured": "未配置",
|
||||
"civitaiApiKeySet": "设置",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站点",
|
||||
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
|
||||
@@ -299,6 +317,7 @@
|
||||
"downloads": "下载",
|
||||
"videoSettings": "视频设置",
|
||||
"layoutSettings": "布局设置",
|
||||
"licenseIcons": "许可协议图标",
|
||||
"misc": "其他",
|
||||
"backup": "备份",
|
||||
"folderSettings": "默认根目录",
|
||||
@@ -445,7 +464,9 @@
|
||||
"modelName": "模型名称",
|
||||
"fileName": "文件名"
|
||||
},
|
||||
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容"
|
||||
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容",
|
||||
"cardBlurAmount": "卡片叠加模糊强度",
|
||||
"cardBlurAmountHelp": "调整模型和配方卡片上页眉和页脚叠加层的模糊强度(0 = 无模糊,20 = 最大模糊)。"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "活动库",
|
||||
@@ -577,6 +598,10 @@
|
||||
"label": "隐藏抢先体验更新",
|
||||
"help": "抢先体验更新"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版许可协议图标",
|
||||
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
||||
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词",
|
||||
@@ -690,6 +715,7 @@
|
||||
"copyAll": "复制所选中语法",
|
||||
"refreshAll": "刷新所选中元数据",
|
||||
"repairMetadata": "修复所选中元数据",
|
||||
"reimportMetadata": "从源重新导入",
|
||||
"checkUpdates": "检查所选更新",
|
||||
"moveAll": "移动所选中到文件夹",
|
||||
"autoOrganize": "自动整理所选模型",
|
||||
@@ -737,6 +763,7 @@
|
||||
"setContentRating": "设置内容评级",
|
||||
"moveToFolder": "移动到文件夹",
|
||||
"repairMetadata": "修复元数据",
|
||||
"reimportMetadata": "从源重新导入",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "恢复模型",
|
||||
"deleteModel": "删除模型",
|
||||
@@ -864,6 +891,13 @@
|
||||
"skipped": "配方已是最新版本,无需修复",
|
||||
"failed": "修复配方失败:{message}",
|
||||
"missingId": "无法修复配方:缺少配方 ID"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "正在从源重新导入配方...",
|
||||
"success": "配方已从源重新导入成功",
|
||||
"noSourceUrl": "配方没有源URL,无法重新导入",
|
||||
"failed": "重新导入配方失败:{message}",
|
||||
"missingId": "无法重新导入配方:缺少配方ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -942,8 +976,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "根目录",
|
||||
"collapseAll": "折叠所有文件夹",
|
||||
"pinSidebar": "固定侧边栏",
|
||||
"unpinSidebar": "取消固定侧边栏",
|
||||
"hideOnThisPage": "隐藏此页面侧边栏",
|
||||
"showSidebar": "显示侧边栏",
|
||||
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
|
||||
"switchToListView": "切换到列表视图",
|
||||
"switchToTreeView": "切换到树状视图",
|
||||
"recursiveOn": "包含子文件夹",
|
||||
@@ -981,6 +1016,18 @@
|
||||
"storage": "存储",
|
||||
"insights": "洞察"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "模型总数",
|
||||
"totalStorage": "总存储空间",
|
||||
"totalGenerations": "总生成次数",
|
||||
"usageRate": "使用率",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "唯一标签",
|
||||
"unusedModels": "未使用模型",
|
||||
"avgUsesPerModel": "平均使用次数/模型"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "最常用 LoRA",
|
||||
"mostUsedCheckpoints": "最常用 Checkpoint",
|
||||
@@ -998,13 +1045,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "智能洞察",
|
||||
"recommendations": "推荐"
|
||||
"recommendations": "推荐",
|
||||
"noInsights": "暂无可用洞察",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "大量未使用的 LoRA",
|
||||
"description": "你的 LoRA 中有 {percent}%({count}/{total})从未被使用过。",
|
||||
"suggestion": "考虑整理或归档未使用的模型以释放存储空间。"
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "检测到未使用的 Checkpoint",
|
||||
"description": "你的 Checkpoint 中有 {percent}%({count}/{total})从未被使用过。",
|
||||
"suggestion": "审查并考虑删除不再需要的 Checkpoint。"
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "大量未使用的 Embedding",
|
||||
"description": "你的 Embedding 中有 {percent}%({count}/{total})从未被使用过。",
|
||||
"suggestion": "考虑整理或归档未使用的 Embedding 以优化你的收藏。"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "检测到大型收藏",
|
||||
"description": "你的模型收藏正在使用 {size} 的存储空间。",
|
||||
"suggestion": "考虑使用外部存储或云解决方案以获得更好的组织。"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "活跃用户",
|
||||
"description": "你已经完成了 {count} 次生成!",
|
||||
"suggestion": "继续探索并用你的模型创作精彩内容。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "收藏概览",
|
||||
"baseModelDistribution": "基础模型分布",
|
||||
"usageTrends": "使用趋势(最近30天)",
|
||||
"usageDistribution": "使用分布"
|
||||
"usageDistribution": "使用分布",
|
||||
"date": "日期",
|
||||
"usageCount": "使用次数",
|
||||
"fileSizeBytes": "文件大小(字节)",
|
||||
"models": "模型",
|
||||
"loraUsage": "LoRA 使用量",
|
||||
"checkpointUsage": "Checkpoint 使用量",
|
||||
"embeddingUsage": "Embedding 使用量"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "扩散模型",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "加载中...",
|
||||
"noModels": "未找到模型",
|
||||
"errorLoading": "数据加载失败",
|
||||
"noStorageData": "暂无存储数据",
|
||||
"rootFolder": "根目录",
|
||||
"chartLibraryMissing": "需要 Chart.js 库来显示图表"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}:{count} 个模型",
|
||||
"chartUsage": "{name}:{size},{count} 次使用",
|
||||
"chartPercentage": "{label}:{value}({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -1014,9 +1125,9 @@
|
||||
"download": {
|
||||
"title": "从 URL 下载模型",
|
||||
"titleWithType": "从 URL 下载 {type}",
|
||||
"url": "Civitai URL",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "每行输入一个 CivitAI 或 CivArchive URL。支持批量下载多个 URL。",
|
||||
"locationPreview": "下载位置预览",
|
||||
"useDefaultPath": "使用默认路径",
|
||||
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
|
||||
@@ -1225,7 +1336,9 @@
|
||||
},
|
||||
"notes": {
|
||||
"saved": "备注保存成功",
|
||||
"saveFailed": "备注保存失败"
|
||||
"saveFailed": "备注保存失败",
|
||||
"showMore": "展开",
|
||||
"showLess": "收起"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "添加预设参数...",
|
||||
@@ -1378,6 +1491,21 @@
|
||||
"versionDeleted": "版本已删除"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "元数据获取摘要",
|
||||
"statSuccess": "成功",
|
||||
"statFailed": "失败",
|
||||
"statSkipped": "已跳过",
|
||||
"statTotal": "总计扫描",
|
||||
"statDuration": "耗时",
|
||||
"successMessage": "全部 {count} 个 {type} 更新成功!",
|
||||
"failedItems": "失败项目 ({count})",
|
||||
"close": "关闭",
|
||||
"copyReport": "复制报告",
|
||||
"downloadCsv": "下载 CSV",
|
||||
"columnModelName": "模型名称",
|
||||
"columnError": "错误"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1391,15 +1519,6 @@
|
||||
"duplicate": "该标签已存在"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "键盘导航:",
|
||||
"shortcuts": {
|
||||
"pageUp": "向上一页滚动",
|
||||
"pageDown": "向下一页滚动",
|
||||
"home": "跳到顶部",
|
||||
"end": "跳到底部"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "初始化",
|
||||
"message": "正在准备你的工作空间...",
|
||||
@@ -1487,11 +1606,14 @@
|
||||
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
||||
"noTargetNodeSelected": "未选择目标节点",
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型节点失败"
|
||||
"modelFailed": "更新模型节点失败",
|
||||
"embeddingAdded": "Embedding 已追加到工作流",
|
||||
"embeddingFailed": "添加 Embedding 失败"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"replace": "替换",
|
||||
"append": "追加",
|
||||
"selectTargetNode": "选择目标节点",
|
||||
@@ -1713,6 +1835,10 @@
|
||||
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
|
||||
"repairBulkSkipped": "所选 {total} 个配方无需修复",
|
||||
"repairBulkFailed": "修复所选配方失败:{message}",
|
||||
"reimporting": "正在从源重新导入配方...",
|
||||
"reimportSuccess": "配方已从源重新导入成功",
|
||||
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
|
||||
"reimportBulkFailed": "重新导入某些配方失败",
|
||||
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
||||
},
|
||||
@@ -1930,7 +2056,9 @@
|
||||
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
||||
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "已复制到剪贴板",
|
||||
"downloadStarted": "下载已开始"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
@@ -16,10 +16,13 @@
|
||||
"help": "說明",
|
||||
"add": "新增",
|
||||
"close": "關閉",
|
||||
"menu": "選單"
|
||||
"menu": "選單",
|
||||
"remove": "移除",
|
||||
"change": "更換"
|
||||
},
|
||||
"status": {
|
||||
"loading": "載入中...",
|
||||
"cancelling": "取消中...",
|
||||
"unknown": "未知",
|
||||
"date": "日期",
|
||||
"version": "版本",
|
||||
@@ -111,6 +114,7 @@
|
||||
"replacePreview": "更換預覽圖",
|
||||
"copyCheckpointName": "複製檢查點名稱",
|
||||
"copyEmbeddingName": "複製嵌入名稱",
|
||||
"embeddingNameCopied": "已複製 Embedding 語法",
|
||||
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
||||
},
|
||||
@@ -247,7 +251,18 @@
|
||||
"toggle": "切換主題",
|
||||
"switchToLight": "切換至淺色主題",
|
||||
"switchToDark": "切換至深色主題",
|
||||
"switchToAuto": "自動主題"
|
||||
"switchToAuto": "自動主題",
|
||||
"presets": "主題預設",
|
||||
"default": "預設",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "模式",
|
||||
"light": "淺色",
|
||||
"dark": "深色",
|
||||
"auto": "自動"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "檢查更新",
|
||||
@@ -259,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API 金鑰",
|
||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||
"civitaiApiKeyConfigured": "已設定",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站點",
|
||||
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
|
||||
@@ -299,6 +317,7 @@
|
||||
"downloads": "下載",
|
||||
"videoSettings": "影片設定",
|
||||
"layoutSettings": "版面設定",
|
||||
"licenseIcons": "許可協議圖標",
|
||||
"misc": "其他",
|
||||
"backup": "備份",
|
||||
"folderSettings": "預設根目錄",
|
||||
@@ -445,7 +464,9 @@
|
||||
"modelName": "模型名稱",
|
||||
"fileName": "檔案名稱"
|
||||
},
|
||||
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容"
|
||||
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容",
|
||||
"cardBlurAmount": "卡片疊加模糊強度",
|
||||
"cardBlurAmountHelp": "調整模型和配方卡片上頁首和頁尾疊加層的模糊強度(0 = 無模糊,20 = 最大模糊)。"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "使用中的資料庫",
|
||||
@@ -577,6 +598,10 @@
|
||||
"label": "隱藏搶先體驗更新",
|
||||
"help": "搶先體驗更新"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版許可協議圖標",
|
||||
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
||||
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞",
|
||||
@@ -690,6 +715,7 @@
|
||||
"copyAll": "複製全部語法",
|
||||
"refreshAll": "刷新全部 metadata",
|
||||
"repairMetadata": "修復所選中元數據",
|
||||
"reimportMetadata": "從來源重新匯入",
|
||||
"checkUpdates": "檢查所選更新",
|
||||
"moveAll": "全部移動到資料夾",
|
||||
"autoOrganize": "自動整理所選模型",
|
||||
@@ -737,6 +763,7 @@
|
||||
"setContentRating": "設定內容分級",
|
||||
"moveToFolder": "移動到資料夾",
|
||||
"repairMetadata": "修復元數據",
|
||||
"reimportMetadata": "從來源重新匯入",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "還原模型",
|
||||
"deleteModel": "刪除模型",
|
||||
@@ -864,6 +891,13 @@
|
||||
"skipped": "配方已是最新版本,無需修復",
|
||||
"failed": "修復配方失敗:{message}",
|
||||
"missingId": "無法修復配方:缺少配方 ID"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "正在從來源重新匯入配方...",
|
||||
"success": "配方已從來源重新匯入成功",
|
||||
"noSourceUrl": "配方沒有來源URL,無法重新匯入",
|
||||
"failed": "重新匯入配方失敗:{message}",
|
||||
"missingId": "無法重新匯入配方:缺少配方ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -942,8 +976,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "根目錄",
|
||||
"collapseAll": "全部摺疊資料夾",
|
||||
"pinSidebar": "固定側邊欄",
|
||||
"unpinSidebar": "取消固定側邊欄",
|
||||
"hideOnThisPage": "隱藏此頁面側邊欄",
|
||||
"showSidebar": "顯示側邊欄",
|
||||
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
|
||||
"switchToListView": "切換至列表檢視",
|
||||
"switchToTreeView": "切換到樹狀檢視",
|
||||
"recursiveOn": "包含子資料夾",
|
||||
@@ -981,6 +1016,18 @@
|
||||
"storage": "儲存空間",
|
||||
"insights": "洞察"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "模型總數",
|
||||
"totalStorage": "總儲存空間",
|
||||
"totalGenerations": "總生成次數",
|
||||
"usageRate": "使用率",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "唯一標籤",
|
||||
"unusedModels": "未使用模型",
|
||||
"avgUsesPerModel": "平均使用次數/模型"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "最常用的 LoRA",
|
||||
"mostUsedCheckpoints": "最常用的 Checkpoint",
|
||||
@@ -998,13 +1045,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "智慧洞察",
|
||||
"recommendations": "推薦"
|
||||
"recommendations": "推薦",
|
||||
"noInsights": "暫無可用洞察",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "大量未使用的 LoRA",
|
||||
"description": "你的 LoRA 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "考慮整理或封存未使用的模型以釋放儲存空間。"
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "檢測到未使用的 Checkpoint",
|
||||
"description": "你的 Checkpoint 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "審查並考慮刪除不再需要的 Checkpoint。"
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "大量未使用的 Embedding",
|
||||
"description": "你的 Embedding 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "考慮整理或封存未使用的 Embedding 以優化你的收藏。"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "檢測到大型收藏",
|
||||
"description": "你的模型收藏正在使用 {size} 的儲存空間。",
|
||||
"suggestion": "考慮使用外部儲存或雲端解決方案以獲得更好的組織。"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "活躍用戶",
|
||||
"description": "你已經完成了 {count} 次生成!",
|
||||
"suggestion": "繼續探索並用你的模型創作精彩內容。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "收藏總覽",
|
||||
"baseModelDistribution": "基礎模型分布",
|
||||
"usageTrends": "使用趨勢(最近 30 天)",
|
||||
"usageDistribution": "使用分布"
|
||||
"usageDistribution": "使用分布",
|
||||
"date": "日期",
|
||||
"usageCount": "使用次數",
|
||||
"fileSizeBytes": "檔案大小(位元組)",
|
||||
"models": "模型",
|
||||
"loraUsage": "LoRA 使用量",
|
||||
"checkpointUsage": "Checkpoint 使用量",
|
||||
"embeddingUsage": "Embedding 使用量"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "擴散模型",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "載入中...",
|
||||
"noModels": "找不到模型",
|
||||
"errorLoading": "資料載入失敗",
|
||||
"noStorageData": "暫無儲存資料",
|
||||
"rootFolder": "根目錄",
|
||||
"chartLibraryMissing": "需要 Chart.js 函式庫來顯示圖表"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}:{count} 個模型",
|
||||
"chartUsage": "{name}:{size},{count} 次使用",
|
||||
"chartPercentage": "{label}:{value}({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -1014,9 +1125,9 @@
|
||||
"download": {
|
||||
"title": "從網址下載模型",
|
||||
"titleWithType": "從網址下載 {type}",
|
||||
"url": "Civitai 網址",
|
||||
"civitaiUrl": "Civitai 網址:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "每行輸入一個 CivitAI 或 CivArchive URL。支援批量下載多個 URL。",
|
||||
"locationPreview": "下載位置預覽",
|
||||
"useDefaultPath": "使用預設路徑",
|
||||
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
|
||||
@@ -1225,7 +1336,9 @@
|
||||
},
|
||||
"notes": {
|
||||
"saved": "備註已儲存",
|
||||
"saveFailed": "儲存備註失敗"
|
||||
"saveFailed": "儲存備註失敗",
|
||||
"showMore": "展開",
|
||||
"showLess": "收起"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "新增預設參數...",
|
||||
@@ -1378,6 +1491,21 @@
|
||||
"versionDeleted": "已刪除此版本"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "元資料獲取摘要",
|
||||
"statSuccess": "成功",
|
||||
"statFailed": "失敗",
|
||||
"statSkipped": "已跳過",
|
||||
"statTotal": "總計掃描",
|
||||
"statDuration": "耗時",
|
||||
"successMessage": "全部 {count} 個 {type} 更新成功!",
|
||||
"failedItems": "失敗項目 ({count})",
|
||||
"close": "關閉",
|
||||
"copyReport": "複製報告",
|
||||
"downloadCsv": "下載 CSV",
|
||||
"columnModelName": "模型名稱",
|
||||
"columnError": "錯誤"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1391,15 +1519,6 @@
|
||||
"duplicate": "此標籤已存在"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "鍵盤導覽:",
|
||||
"shortcuts": {
|
||||
"pageUp": "向上捲動一頁",
|
||||
"pageDown": "向下捲動一頁",
|
||||
"home": "跳至頂部",
|
||||
"end": "跳至底部"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "初始化",
|
||||
"message": "正在準備您的工作區...",
|
||||
@@ -1487,11 +1606,14 @@
|
||||
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
||||
"noTargetNodeSelected": "未選擇目標節點",
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型節點失敗"
|
||||
"modelFailed": "更新模型節點失敗",
|
||||
"embeddingAdded": "Embedding 已附加到工作流",
|
||||
"embeddingFailed": "傳送 Embedding 到工作流失敗"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"replace": "取代",
|
||||
"append": "附加",
|
||||
"selectTargetNode": "選擇目標節點",
|
||||
@@ -1713,6 +1835,10 @@
|
||||
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
|
||||
"repairBulkSkipped": "所選 {total} 個配方無需修復",
|
||||
"repairBulkFailed": "修復所選配方失敗:{message}",
|
||||
"reimporting": "正在從來源重新匯入配方...",
|
||||
"reimportSuccess": "配方已從來源重新匯入成功",
|
||||
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
|
||||
"reimportBulkFailed": "重新匯入某些配方失敗",
|
||||
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
||||
},
|
||||
@@ -1930,7 +2056,9 @@
|
||||
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
||||
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "已複製到剪貼簿",
|
||||
"downloadStarted": "下載已開始"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
@@ -33,6 +33,7 @@ from .utils.example_images_migration import ExampleImagesMigration
|
||||
from .services.websocket_manager import ws_manager
|
||||
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
||||
from .middleware.csp_middleware import relax_csp_for_remote_media
|
||||
from .middleware.error_middleware import api_json_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,6 +77,11 @@ class LoraManager:
|
||||
"""Initialize and register all routes using the new refactored architecture"""
|
||||
app = PromptServer.instance.app
|
||||
|
||||
# Register JSON error middleware for /api/* routes as the outermost
|
||||
# middleware so it catches errors from all other middlewares.
|
||||
if api_json_error not in app.middlewares:
|
||||
app.middlewares.insert(0, api_json_error)
|
||||
|
||||
if relax_csp_for_remote_media not in app.middlewares:
|
||||
# Ensure CSP relaxer executes after ComfyUI's block_external_middleware so it can
|
||||
# see and extend the restrictive header instead of being overwritten by it.
|
||||
@@ -189,6 +195,10 @@ class LoraManager:
|
||||
|
||||
# Register DownloadManager with ServiceRegistry
|
||||
await ServiceRegistry.get_download_manager()
|
||||
|
||||
# Initialize DownloadQueueService for persistent queue/history
|
||||
await ServiceRegistry.get_download_queue_service()
|
||||
|
||||
await ServiceRegistry.get_backup_service()
|
||||
|
||||
from .services.metadata_service import initialize_metadata_providers
|
||||
@@ -426,5 +436,14 @@ class LoraManager:
|
||||
try:
|
||||
logger.info("LoRA Manager: Cleaning up services")
|
||||
|
||||
# Cancel any in-flight scanner initialization tasks so thread-pool
|
||||
# workers (e.g. _initialize_cache_sync) can break out of their loops
|
||||
# when the server shuts down (e.g. Ctrl+C on WSL).
|
||||
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
|
||||
scanner = ServiceRegistry.get_service_sync(name)
|
||||
if scanner is not None and hasattr(scanner, "cancel_task"):
|
||||
scanner.cancel_task()
|
||||
logger.debug("LoRA Manager: Cancelled %s", name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
||||
|
||||
@@ -5,9 +5,10 @@ MODELS = "models"
|
||||
PROMPTS = "prompts"
|
||||
SAMPLING = "sampling"
|
||||
LORAS = "loras"
|
||||
EMBEDDINGS = "embeddings"
|
||||
SIZE = "size"
|
||||
IMAGES = "images"
|
||||
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
|
||||
|
||||
# Complete list of categories to track
|
||||
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES]
|
||||
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, EMBEDDINGS, SIZE, IMAGES]
|
||||
|
||||
@@ -901,6 +901,55 @@ class LoraLoaderManagerExtractor(NodeMetadataExtractor):
|
||||
"node_id": node_id
|
||||
}
|
||||
|
||||
class LoraTextLoaderManagerExtractor(NodeMetadataExtractor):
|
||||
"""Extract LoRA metadata from LoraTextLoaderLM (LoRA Text Loader).
|
||||
|
||||
The node accepts a `lora_syntax` STRING containing <lora:name:strength> tags
|
||||
(same format as the ComfyUI prompt), plus an optional `lora_stack`.
|
||||
This extractor parses the syntax string using the same regex as the node.
|
||||
"""
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs:
|
||||
return
|
||||
|
||||
active_loras = []
|
||||
|
||||
# Process lora_stack if available (optional input)
|
||||
if "lora_stack" in inputs:
|
||||
lora_stack = inputs.get("lora_stack", [])
|
||||
for item in lora_stack:
|
||||
# lora_stack entries are (path, model_strength, clip_strength) tuples
|
||||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
lora_path = item[0]
|
||||
model_strength = item[1]
|
||||
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
|
||||
active_loras.append({
|
||||
"name": lora_name,
|
||||
"strength": round(float(model_strength), 2)
|
||||
})
|
||||
|
||||
# Process lora_syntax string input
|
||||
if "lora_syntax" in inputs:
|
||||
lora_syntax = inputs.get("lora_syntax", "")
|
||||
if lora_syntax and isinstance(lora_syntax, str):
|
||||
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
|
||||
matches = re.findall(pattern, lora_syntax, re.IGNORECASE)
|
||||
for match in matches:
|
||||
lora_name = match[0]
|
||||
model_strength = float(match[1])
|
||||
active_loras.append({
|
||||
"name": lora_name,
|
||||
"strength": round(model_strength, 2)
|
||||
})
|
||||
|
||||
if active_loras:
|
||||
metadata[LORAS][node_id] = {
|
||||
"lora_list": active_loras,
|
||||
"node_id": node_id
|
||||
}
|
||||
|
||||
|
||||
class FluxGuidanceExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
@@ -1146,6 +1195,7 @@ NODE_EXTRACTORS = {
|
||||
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
|
||||
"LoraLoader": LoraLoaderExtractor,
|
||||
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
||||
"LoraTextLoaderLM": LoraTextLoaderManagerExtractor,
|
||||
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
|
||||
"TensorRTLoader": TensorRTLoaderExtractor,
|
||||
# Conditioning
|
||||
|
||||
@@ -16,6 +16,8 @@ IMG_EXTENSIONS = (
|
||||
".tif",
|
||||
".tiff",
|
||||
".webp",
|
||||
".avif",
|
||||
".jxl",
|
||||
".mp4"
|
||||
)
|
||||
|
||||
|
||||
71
py/middleware/error_middleware.py
Normal file
71
py/middleware/error_middleware.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""JSON error middleware for API routes.
|
||||
|
||||
Ensures all responses to /api/* requests return valid JSON that the
|
||||
browser-extension frontend can JSON.parse() without crashing, even when
|
||||
the route does not exist (404) or the handler raises an exception (500).
|
||||
|
||||
Extension consumers call response.json() unconditionally — an HTML error
|
||||
page causes ``SyntaxError: unexpected end of data`` that leaks into the
|
||||
popup UI as a toast notification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def api_json_error(
|
||||
request: web.Request,
|
||||
handler: Callable[[web.Request], Awaitable[web.Response]],
|
||||
) -> web.Response:
|
||||
"""Return JSON ``{"success": false, "error": "..."}`` for API errors.
|
||||
|
||||
Only intercepts paths starting with ``/api/`` — all other routes
|
||||
(frontend pages, static files, WebSocket upgrades) pass through
|
||||
unchanged.
|
||||
"""
|
||||
if not request.path.startswith("/api/"):
|
||||
return await handler(request)
|
||||
|
||||
try:
|
||||
response = await handler(request)
|
||||
return response
|
||||
except web.HTTPException as exc:
|
||||
# Let redirects (301, 302, 307, 308) propagate — they are not errors.
|
||||
if exc.status < 400:
|
||||
raise
|
||||
|
||||
logger.warning(
|
||||
"API %s %s returned HTTP %d: %s",
|
||||
request.method,
|
||||
request.path,
|
||||
exc.status,
|
||||
exc.reason,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"{exc.status}: {exc.reason}"},
|
||||
status=exc.status,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"API %s %s raised unhandled exception: %s",
|
||||
request.method,
|
||||
request.path,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"500: Internal Server Error ({type(exc).__name__})",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
@@ -11,7 +11,7 @@ from ..metadata_collector.metadata_processor import MetadataProcessor
|
||||
from ..metadata_collector import get_metadata
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.utils import calculate_recipe_fingerprint
|
||||
from ..utils.utils import calculate_recipe_fingerprint, sanitize_folder_name
|
||||
from PIL import Image, PngImagePlugin
|
||||
import piexif
|
||||
import logging
|
||||
@@ -298,7 +298,12 @@ class SaveImageLM:
|
||||
key = parts[0]
|
||||
|
||||
if key == "seed" and "seed" in metadata_dict:
|
||||
filename = filename.replace(segment, str(metadata_dict.get("seed", "")))
|
||||
seed_value = metadata_dict.get("seed")
|
||||
if seed_value is not None:
|
||||
filename = filename.replace(segment, str(seed_value))
|
||||
else:
|
||||
# Fallback if seed was not captured by metadata collector
|
||||
filename = filename.replace(segment, "0")
|
||||
elif key == "width" and "size" in metadata_dict:
|
||||
size = metadata_dict.get("size", "x")
|
||||
w = size.split("x")[0] if isinstance(size, str) else size[0]
|
||||
@@ -309,12 +314,14 @@ class SaveImageLM:
|
||||
filename = filename.replace(segment, str(h))
|
||||
elif key == "pprompt" and "prompt" in metadata_dict:
|
||||
prompt = metadata_dict.get("prompt", "").replace("\n", " ")
|
||||
prompt = sanitize_folder_name(prompt)
|
||||
if len(parts) >= 2:
|
||||
length = int(parts[1])
|
||||
prompt = prompt[:length]
|
||||
filename = filename.replace(segment, prompt.strip())
|
||||
elif key == "nprompt" and "negative_prompt" in metadata_dict:
|
||||
prompt = metadata_dict.get("negative_prompt", "").replace("\n", " ")
|
||||
prompt = sanitize_folder_name(prompt)
|
||||
if len(parts) >= 2:
|
||||
length = int(parts[1])
|
||||
prompt = prompt[:length]
|
||||
@@ -328,6 +335,7 @@ class SaveImageLM:
|
||||
model = "model_unavailable"
|
||||
else:
|
||||
model = os.path.splitext(os.path.basename(model_value))[0]
|
||||
model = sanitize_folder_name(model)
|
||||
if len(parts) >= 2:
|
||||
length = int(parts[1])
|
||||
model = model[:length]
|
||||
|
||||
@@ -49,7 +49,10 @@ from ...utils.constants import (
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.example_images_paths import is_valid_example_images_root
|
||||
from ...utils.example_images_paths import (
|
||||
find_non_compliant_items_in_example_images_root,
|
||||
is_valid_example_images_root,
|
||||
)
|
||||
from ...utils.lora_metadata import extract_trained_words
|
||||
from ...utils.session_logging import get_standalone_session_log_snapshot
|
||||
from ...utils.usage_stats import UsageStats
|
||||
@@ -1328,6 +1331,9 @@ class SettingsHandler:
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
# Sensitive — never expose the actual value to the frontend;
|
||||
# frontend receives a boolean instead (civitai_api_key_set).
|
||||
"civitai_api_key",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1382,6 +1388,9 @@ class SettingsHandler:
|
||||
value = self._settings.get(key)
|
||||
if value is not None:
|
||||
response_data[key] = value
|
||||
# Sensitive fields: only expose a boolean indicating whether set
|
||||
raw_key = self._settings.get("civitai_api_key")
|
||||
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
@@ -1492,6 +1501,16 @@ class SettingsHandler:
|
||||
if not os.path.isdir(folder_path):
|
||||
return "Please set a dedicated folder for example images."
|
||||
if not self._is_dedicated_example_images_folder(folder_path):
|
||||
offending = find_non_compliant_items_in_example_images_root(folder_path)
|
||||
if offending:
|
||||
items_str = ", ".join(repr(item) for item in offending[:5])
|
||||
if len(offending) > 5:
|
||||
items_str += f" … and {len(offending) - 5} more"
|
||||
return (
|
||||
f"The folder contains items that are not valid example image "
|
||||
f"folders: {items_str}. Please use a dedicated, empty folder "
|
||||
f"for example images to prevent accidental data loss."
|
||||
)
|
||||
return "Please set a dedicated folder for example images."
|
||||
return None
|
||||
|
||||
@@ -3086,6 +3105,7 @@ class NodeRegistryHandler:
|
||||
data = await request.json()
|
||||
widget_name = data.get("widget_name")
|
||||
value = data.get("value")
|
||||
mode = data.get("mode", "replace")
|
||||
node_ids = data.get("node_ids")
|
||||
|
||||
if not isinstance(widget_name, str) or not widget_name:
|
||||
@@ -3133,6 +3153,7 @@ class NodeRegistryHandler:
|
||||
"id": parsed_node_id,
|
||||
"widget_name": widget_name,
|
||||
"value": value,
|
||||
"mode": mode,
|
||||
}
|
||||
|
||||
if graph_identifier is not None:
|
||||
|
||||
@@ -37,6 +37,7 @@ from ...services.use_cases import (
|
||||
)
|
||||
from ...services.websocket_manager import WebSocketManager
|
||||
from ...services.websocket_progress_callback import WebSocketProgressCallback
|
||||
from ...services.download_queue_service import DownloadQueueService
|
||||
from ...services.errors import RateLimitError, ResourceNotFoundError
|
||||
from ...utils.civitai_utils import resolve_license_payload
|
||||
from ...utils.file_utils import calculate_sha256
|
||||
@@ -1271,6 +1272,14 @@ class ModelQueryHandler:
|
||||
license_flags = (model_data or {}).get("license_flags")
|
||||
if license_flags is not None:
|
||||
response_payload["license_flags"] = int(license_flags)
|
||||
# Include the user's license icon style preference so the
|
||||
# ComfyUI tooltip can pick the right set without a separate
|
||||
# API call.
|
||||
try:
|
||||
settings = get_settings_manager()
|
||||
response_payload["use_new_license_icons"] = settings.get("use_new_license_icons", True)
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response(response_payload)
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -1567,6 +1576,291 @@ class ModelDownloadHandler:
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Download queue / history handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_download_queue(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
service = await DownloadQueueService.get_instance()
|
||||
queue = await service.get_queue()
|
||||
stats = await service.get_stats()
|
||||
return web.json_response({"success": True, "queue": queue, "stats": stats})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error getting download queue: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def add_to_download_queue(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
import uuid
|
||||
|
||||
download_id = request.query.get("download_id") or str(uuid.uuid4())
|
||||
model_id_str = request.query.get("model_id")
|
||||
model_version_id_str = request.query.get("model_version_id")
|
||||
model_name = request.query.get("model_name", "")
|
||||
version_name = request.query.get("version_name", "")
|
||||
thumbnail_url = request.query.get("thumbnail_url", "")
|
||||
source = request.query.get("source")
|
||||
file_params_json = request.query.get("file_params")
|
||||
|
||||
model_id = int(model_id_str) if model_id_str else None
|
||||
model_version_id = int(model_version_id_str) if model_version_id_str else None
|
||||
file_params = json.loads(file_params_json) if file_params_json else None
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
item = await service.add_to_queue(
|
||||
download_id=download_id,
|
||||
model_id=model_id,
|
||||
model_version_id=model_version_id,
|
||||
model_name=model_name,
|
||||
version_name=version_name,
|
||||
thumbnail_url=thumbnail_url,
|
||||
source=source,
|
||||
file_params=file_params,
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error adding to download queue: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def remove_from_download_queue(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
if not download_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "download_id is required"}, status=400
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
removed = await service.remove_from_queue(download_id)
|
||||
return web.json_response({"success": removed})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error removing from download queue: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def move_queue_item_to_top(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
if not download_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "download_id is required"}, status=400
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
moved = await service.move_to_top(download_id)
|
||||
return web.json_response({"success": moved})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error moving queue item to top: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def move_queue_item_to_end(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
if not download_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "download_id is required"}, status=400
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
moved = await service.move_to_end(download_id)
|
||||
return web.json_response({"success": moved})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error moving queue item to end: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def clear_download_queue(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
status_filter = request.query.get("status") or None
|
||||
service = await DownloadQueueService.get_instance()
|
||||
cleared = await service.clear_queue(status_filter=status_filter)
|
||||
return web.json_response({"success": True, "cleared": cleared})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error clearing download queue: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_download_history(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = min(int(request.query.get("limit", "50")), 500)
|
||||
offset = int(request.query.get("offset", "0"))
|
||||
status_filter = request.query.get("status") or None
|
||||
service = await DownloadQueueService.get_instance()
|
||||
result = await service.get_history(
|
||||
limit=limit, offset=offset, status_filter=status_filter
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"items": result["items"],
|
||||
"total": result["total"],
|
||||
"limit": result["limit"],
|
||||
"offset": result["offset"],
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error getting download history: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def clear_download_history(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
status_filter = request.query.get("status") or None
|
||||
service = await DownloadQueueService.get_instance()
|
||||
cleared = await service.clear_history(status_filter=status_filter)
|
||||
return web.json_response({"success": True, "cleared": cleared})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error clearing download history: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def delete_download_history_item(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
item_id = int(request.query.get("id", "0"))
|
||||
if not item_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "id is required"}, status=400
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
deleted = await service.delete_history_item(item_id)
|
||||
return web.json_response({"success": deleted})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error deleting download history item: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def retry_download_from_history(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
item_id = int(request.query.get("id", "0"))
|
||||
if not item_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "id is required"}, status=400
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
item = await service.retry_from_history(item_id)
|
||||
if item is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "History item not found or not retryable"},
|
||||
status=404,
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error retrying download from history: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def retry_all_failed_downloads(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
service = await DownloadQueueService.get_instance()
|
||||
retry_count = await service.retry_all_failed()
|
||||
return web.json_response({"success": True, "retry_count": retry_count})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error retrying all failed downloads: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def complete_download_in_queue(self, request: web.Request) -> web.Response:
|
||||
"""Atomically move a download from queue to history with terminal status."""
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
if not download_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "download_id is required"}, status=400
|
||||
)
|
||||
status = request.query.get("status", "completed")
|
||||
error = request.query.get("error")
|
||||
file_path = request.query.get("file_path")
|
||||
try:
|
||||
bytes_downloaded = int(request.query.get("bytes_downloaded", "0"))
|
||||
except (TypeError, ValueError):
|
||||
bytes_downloaded = 0
|
||||
total_bytes_raw = request.query.get("total_bytes")
|
||||
total_bytes = int(total_bytes_raw) if total_bytes_raw else None
|
||||
completed_at_raw = request.query.get("completed_at")
|
||||
completed_at = float(completed_at_raw) if completed_at_raw else None
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
item = await service.complete_download(
|
||||
download_id=download_id,
|
||||
status=status,
|
||||
error=error,
|
||||
file_path=file_path,
|
||||
bytes_downloaded=bytes_downloaded,
|
||||
total_bytes=total_bytes,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
if item is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Download not found in queue"}, status=404
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error completing download: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_download_stats(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
service = await DownloadQueueService.get_instance()
|
||||
stats = await service.get_stats()
|
||||
return web.json_response({"success": True, "stats": stats})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error getting download stats: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def update_download_queue_status(self, request: web.Request) -> web.Response:
|
||||
"""Update the status of a queue item (non-terminal transitions).
|
||||
|
||||
Supported transitions include ``queued → downloading``,
|
||||
``downloading → paused``, ``paused → downloading``, etc.
|
||||
Terminal transitions (``completed``, ``failed``, ``canceled``)
|
||||
should use ``complete_download_in_queue`` instead.
|
||||
"""
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
status = request.query.get("status")
|
||||
if not download_id or not status:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "download_id and status are required",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
service = await DownloadQueueService.get_instance()
|
||||
updated = await service.update_status(download_id, status)
|
||||
if not updated:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Download not found in queue"},
|
||||
status=404,
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error updating download queue status: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class ModelCivitaiHandler:
|
||||
"""CivitAI integration endpoints."""
|
||||
@@ -1608,7 +1902,9 @@ class ModelCivitaiHandler:
|
||||
return web.json_response(result)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error in fetch_all_civitai for %ss: %s", self._service.model_type, exc
|
||||
"Error in fetch_all_civitai for %ss: %s",
|
||||
self._service.model_type, exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
|
||||
@@ -2016,10 +2312,21 @@ class ModelUpdateHandler:
|
||||
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
hide_early_access = False
|
||||
if self._settings is not None:
|
||||
try:
|
||||
hide_early_access = bool(
|
||||
self._settings.get("hide_early_access_updates", False)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
serialized_records = []
|
||||
for record in records.values():
|
||||
has_update_fn = getattr(record, "has_update", None)
|
||||
if callable(has_update_fn) and has_update_fn():
|
||||
if callable(has_update_fn) and has_update_fn(
|
||||
hide_early_access=hide_early_access
|
||||
):
|
||||
serialized_records.append(self._serialize_record(record))
|
||||
|
||||
return web.json_response(
|
||||
@@ -2585,6 +2892,20 @@ class ModelHandlerSet:
|
||||
"pause_download_get": self.download.pause_download_get,
|
||||
"resume_download_get": self.download.resume_download_get,
|
||||
"get_download_progress": self.download.get_download_progress,
|
||||
"get_download_queue": self.download.get_download_queue,
|
||||
"add_to_download_queue": self.download.add_to_download_queue,
|
||||
"remove_from_download_queue": self.download.remove_from_download_queue,
|
||||
"move_queue_item_to_top": self.download.move_queue_item_to_top,
|
||||
"move_queue_item_to_end": self.download.move_queue_item_to_end,
|
||||
"clear_download_queue": self.download.clear_download_queue,
|
||||
"get_download_history": self.download.get_download_history,
|
||||
"clear_download_history": self.download.clear_download_history,
|
||||
"delete_download_history_item": self.download.delete_download_history_item,
|
||||
"retry_download_from_history": self.download.retry_download_from_history,
|
||||
"retry_all_failed_downloads": self.download.retry_all_failed_downloads,
|
||||
"complete_download_in_queue": self.download.complete_download_in_queue,
|
||||
"get_download_stats": self.download.get_download_stats,
|
||||
"update_download_queue_status": self.download.update_download_queue_status,
|
||||
"get_civitai_versions": self.civitai.get_civitai_versions,
|
||||
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
|
||||
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
|
||||
|
||||
@@ -13,7 +13,7 @@ from ...config import config as global_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CHUNK_SIZE = 256 * 1024 # 256 KB
|
||||
_CHUNK_SIZE = 1024 * 1024 # 1 MB — balance between streaming iteration overhead and per-chunk memory
|
||||
|
||||
# Video file extensions that bypass native sendfile on Windows
|
||||
# to avoid IOCP/ProactorEventLoop crashes during client disconnect.
|
||||
@@ -55,16 +55,19 @@ class PreviewHandler:
|
||||
logger.debug("Preview file not found at %s", str(resolved))
|
||||
raise web.HTTPNotFound(text="Preview file not found")
|
||||
|
||||
# Video files: stream manually to avoid Windows native sendfile crash.
|
||||
# aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based),
|
||||
# which breaks when the client disconnects mid-transfer — this happens
|
||||
# constantly when users scroll through a gallery of animated previews.
|
||||
suffix = resolved.suffix.lower()
|
||||
if suffix in _VIDEO_EXTENSIONS:
|
||||
return await self._stream_file(request, resolved)
|
||||
|
||||
# aiohttp's FileResponse handles range requests and content headers for us.
|
||||
return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
|
||||
# aiohttp's FileResponse handles range requests, content headers, and
|
||||
# uses kernel sendfile (zero-copy DMA) on Linux/macOS. On Windows it
|
||||
# uses IOCP-based _sendfile_native which can crash when the client
|
||||
# disconnects mid-transfer during fast scrolling. The _stream_file()
|
||||
# fallback is kept for a future compat toggle.
|
||||
#
|
||||
# Set explicit Cache-Control so the browser can cache video (and image)
|
||||
# previews across VirtualScroller recycling cycles. Without this,
|
||||
# Chrome does not cache 206 Partial Content responses for <video>
|
||||
# elements, causing the same video to be re-downloaded on every scroll.
|
||||
resp = web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
|
||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||
return resp
|
||||
|
||||
async def _stream_file(
|
||||
self, request: web.Request, path: Path
|
||||
@@ -83,6 +86,10 @@ class PreviewHandler:
|
||||
resp.content_type = content_type
|
||||
resp.content_length = file_size
|
||||
|
||||
# Allow browser caching: video previews rarely change during a session.
|
||||
# The frontend already appends ?t={version} to bust cache on update.
|
||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||
|
||||
await resp.prepare(request)
|
||||
|
||||
try:
|
||||
|
||||
@@ -102,6 +102,7 @@ class RecipeHandlerSet:
|
||||
"check_image_exists": self.management.check_image_exists,
|
||||
"import_from_url": self.management.import_from_url,
|
||||
"create_from_example": self.management.create_from_example,
|
||||
"reimport_recipe": self.management.reimport_recipe,
|
||||
}
|
||||
|
||||
|
||||
@@ -799,6 +800,126 @@ class RecipeManagementHandler:
|
||||
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def reimport_recipe(self, request: web.Request) -> web.Response:
|
||||
"""Delete a recipe and re-import it from its source URL.
|
||||
|
||||
This gives the recipe a fresh start — re-downloads the image from
|
||||
CivitAI, re-parses EXIF metadata with the current parser, and
|
||||
re-resolves LoRAs / checkpoint. User edits (title, tags, favorite)
|
||||
are carried over from the old recipe.
|
||||
"""
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
recipe_id = request.match_info["recipe_id"]
|
||||
old_recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||
if not old_recipe:
|
||||
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
|
||||
|
||||
source_path = old_recipe.get("source_path")
|
||||
if not source_path:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
"Recipe has no source URL — cannot re-import. "
|
||||
"Use repair or manual import instead."
|
||||
),
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
user_edits: dict[str, Any] = {}
|
||||
for key in ("title", "tags", "favorite", "preview_nsfw_level"):
|
||||
if key in old_recipe and old_recipe[key] is not None:
|
||||
user_edits[key] = old_recipe[key]
|
||||
if "tags" in user_edits and not isinstance(user_edits["tags"], list):
|
||||
del user_edits["tags"]
|
||||
|
||||
old_file_path = old_recipe.get("file_path", "")
|
||||
old_folder = os.path.dirname(old_file_path) if old_file_path else None
|
||||
|
||||
image_id = extract_civitai_image_id(source_path)
|
||||
is_local_file = not image_id and os.path.isfile(source_path)
|
||||
|
||||
if not image_id and not is_local_file:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
"Recipe source is neither a valid CivitAI image URL "
|
||||
"nor an accessible local file. "
|
||||
"Use repair or manual import instead."
|
||||
),
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if is_local_file:
|
||||
return await self._do_reimport_from_local(
|
||||
source_path,
|
||||
recipe_scanner,
|
||||
recipe_id=recipe_id,
|
||||
target_dir=old_folder,
|
||||
user_edits=user_edits,
|
||||
old_title=old_recipe.get("title", ""),
|
||||
)
|
||||
|
||||
async with self._import_semaphore:
|
||||
import_response = await self._do_import_from_url(
|
||||
source_path,
|
||||
recipe_scanner,
|
||||
target_dir=old_folder,
|
||||
)
|
||||
|
||||
await self._persistence_service.delete_recipe(
|
||||
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
||||
)
|
||||
|
||||
body_bytes = import_response.body
|
||||
if not body_bytes:
|
||||
raise RuntimeError("Re-import returned an empty response")
|
||||
import_body = json.loads(body_bytes.decode())
|
||||
new_recipe_id = import_body.get("recipe_id")
|
||||
|
||||
if new_recipe_id and user_edits:
|
||||
try:
|
||||
await self._persistence_service.update_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=new_recipe_id,
|
||||
updates=user_edits,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Re-import succeeded but failed to carry over "
|
||||
"user edits for new recipe %s: %s",
|
||||
new_recipe_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"old_recipe_id": recipe_id,
|
||||
"recipe_id": new_recipe_id,
|
||||
"source_path": source_path,
|
||||
}
|
||||
)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except RecipeDownloadError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error reimporting recipe: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_repair_progress(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
progress = self._ws_manager.get_recipe_repair_progress()
|
||||
@@ -907,6 +1028,7 @@ class RecipeManagementHandler:
|
||||
extension,
|
||||
civitai_meta_raw,
|
||||
model_version_id,
|
||||
_original_image_url,
|
||||
) = await self._download_remote_media(image_url)
|
||||
|
||||
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
|
||||
@@ -1319,7 +1441,9 @@ class RecipeManagementHandler:
|
||||
"exclude": False,
|
||||
}
|
||||
|
||||
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any, Any]:
|
||||
async def _download_remote_media(
|
||||
self, image_url: str
|
||||
) -> tuple[bytes, str, Any, Any, Optional[str]]:
|
||||
civitai_client = self._civitai_client_getter()
|
||||
downloader = await self._downloader_factory()
|
||||
temp_path = None
|
||||
@@ -1394,11 +1518,16 @@ class RecipeManagementHandler:
|
||||
if mvids and isinstance(civitai_meta_raw, dict):
|
||||
civitai_meta_raw["modelVersionIds"] = mvids
|
||||
|
||||
original_url = (
|
||||
image_info.get("url") if civitai_image_id and image_info else None
|
||||
)
|
||||
|
||||
return (
|
||||
file_obj.read(),
|
||||
extension,
|
||||
civitai_meta_raw,
|
||||
model_ver_id,
|
||||
original_url,
|
||||
)
|
||||
except RecipeDownloadError:
|
||||
raise
|
||||
@@ -1468,15 +1597,8 @@ class RecipeManagementHandler:
|
||||
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
|
||||
# Build lookup: image_id -> recipe_id from stored source_path
|
||||
image_to_recipe = {}
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
source = recipe.get("source_path")
|
||||
if not source:
|
||||
continue
|
||||
image_id = extract_civitai_image_id(source)
|
||||
if image_id and image_id not in image_to_recipe:
|
||||
image_to_recipe[image_id] = recipe.get("id")
|
||||
# Use precomputed image_id_map (built once at cache init)
|
||||
image_to_recipe = getattr(cache, "image_id_map", {})
|
||||
|
||||
results = {}
|
||||
for img_id in requested_ids:
|
||||
@@ -1512,18 +1634,20 @@ class RecipeManagementHandler:
|
||||
"Could not extract Civitai image ID from URL"
|
||||
)
|
||||
|
||||
# Check for duplicate (fast, before acquiring semaphore), unless force
|
||||
if not force:
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
image_to_recipe = getattr(cache, "image_id_map", {})
|
||||
existing_recipe_id = image_to_recipe.get(image_id)
|
||||
if existing_recipe_id:
|
||||
recipe_name = ""
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
source = recipe.get("source_path")
|
||||
if source:
|
||||
existing_id = extract_civitai_image_id(source)
|
||||
if existing_id == image_id:
|
||||
if str(recipe.get("id", "")) == existing_recipe_id:
|
||||
recipe_name = recipe.get("title", "") or ""
|
||||
break
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"recipe_id": recipe.get("id"),
|
||||
"name": recipe.get("title", ""),
|
||||
"recipe_id": existing_recipe_id,
|
||||
"name": recipe_name,
|
||||
"already_exists": True,
|
||||
})
|
||||
|
||||
@@ -1543,6 +1667,9 @@ class RecipeManagementHandler:
|
||||
self,
|
||||
image_url: str,
|
||||
recipe_scanner: Any,
|
||||
*,
|
||||
recipe_id: str | None = None,
|
||||
target_dir: str | None = None,
|
||||
) -> web.Response:
|
||||
image_id = extract_civitai_image_id(image_url)
|
||||
if not image_id:
|
||||
@@ -1550,7 +1677,7 @@ class RecipeManagementHandler:
|
||||
"Could not extract Civitai image ID from URL"
|
||||
)
|
||||
|
||||
image_bytes, extension, civitai_meta_raw, model_version_id = (
|
||||
image_bytes, extension, civitai_meta_raw, model_version_id, original_image_url = (
|
||||
await self._download_remote_media(image_url)
|
||||
)
|
||||
|
||||
@@ -1588,6 +1715,51 @@ class RecipeManagementHandler:
|
||||
"Failed to extract embedded metadata: %s", exc
|
||||
)
|
||||
|
||||
if not parsed_embedded and original_image_url:
|
||||
self._logger.debug(
|
||||
"Optimized image has no embedded metadata, "
|
||||
"falling back to original: %s",
|
||||
original_image_url,
|
||||
)
|
||||
try:
|
||||
downloader = await self._downloader_factory()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".png", delete=False
|
||||
) as tmp:
|
||||
orig_tmp_path = tmp.name
|
||||
try:
|
||||
success, _ = await downloader.download_file(
|
||||
original_image_url, orig_tmp_path, use_auth=False
|
||||
)
|
||||
if success:
|
||||
raw_orig = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, orig_tmp_path
|
||||
)
|
||||
if raw_orig:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
raw_orig
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig, recipe_scanner=recipe_scanner
|
||||
)
|
||||
if (
|
||||
parsed_embedded
|
||||
and "gen_params" in parsed_embedded
|
||||
):
|
||||
embedded_gen_params = parsed_embedded[
|
||||
"gen_params"
|
||||
]
|
||||
finally:
|
||||
if os.path.exists(orig_tmp_path):
|
||||
os.unlink(orig_tmp_path)
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to extract metadata from original image: %s", exc
|
||||
)
|
||||
|
||||
# Parse CivitAI API meta to discover all resources from modelVersionIds.
|
||||
# Run unconditionally — EXIF parsing succeeds for gen_params but misses
|
||||
# LoRAs (modelVersionIds is NOT in the image EXIF).
|
||||
@@ -1671,9 +1843,104 @@ class RecipeManagementHandler:
|
||||
tags=[],
|
||||
metadata=metadata,
|
||||
extension=extension,
|
||||
recipe_id=recipe_id,
|
||||
target_dir=target_dir,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
|
||||
async def _do_reimport_from_local(
|
||||
self,
|
||||
file_path: str,
|
||||
recipe_scanner: Any,
|
||||
*,
|
||||
recipe_id: str,
|
||||
target_dir: str | None,
|
||||
user_edits: dict[str, Any],
|
||||
old_title: str,
|
||||
) -> web.Response:
|
||||
"""Re-import a recipe from a local image file.
|
||||
|
||||
Reads the original source file, re-parses its EXIF metadata, saves a
|
||||
fresh recipe, then deletes the old one.
|
||||
"""
|
||||
normalized = os.path.normpath(file_path)
|
||||
if not os.path.isfile(normalized):
|
||||
raise RecipeNotFoundError(
|
||||
f"Source file no longer accessible: {normalized}"
|
||||
)
|
||||
|
||||
with open(normalized, "rb") as fh:
|
||||
image_bytes = fh.read()
|
||||
|
||||
extension = os.path.splitext(normalized)[1].lower() or ".png"
|
||||
|
||||
analysis_result = await self._analysis_service.analyze_local_image(
|
||||
file_path=normalized,
|
||||
recipe_scanner=recipe_scanner,
|
||||
)
|
||||
analysis_payload: dict[str, Any] = analysis_result.payload
|
||||
|
||||
gen_params = analysis_payload.get("gen_params") or {}
|
||||
loras = analysis_payload.get("loras") or []
|
||||
checkpoint = analysis_payload.get("checkpoint")
|
||||
base_model = analysis_payload.get("base_model", "")
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"base_model": base_model,
|
||||
"loras": loras,
|
||||
"gen_params": gen_params,
|
||||
"source_path": normalized,
|
||||
}
|
||||
if checkpoint:
|
||||
metadata["checkpoint"] = checkpoint
|
||||
|
||||
prompt = (
|
||||
gen_params.get("prompt")
|
||||
or gen_params.get("positivePrompt")
|
||||
or ""
|
||||
)
|
||||
name = " ".join(str(prompt).split()[:10]) if prompt else old_title
|
||||
|
||||
result = await self._persistence_service.save_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_bytes=image_bytes,
|
||||
image_base64=analysis_payload.get("image_base64"),
|
||||
name=name,
|
||||
tags=[],
|
||||
metadata=metadata,
|
||||
extension=extension,
|
||||
target_dir=target_dir,
|
||||
)
|
||||
|
||||
await self._persistence_service.delete_recipe(
|
||||
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
||||
)
|
||||
|
||||
new_recipe_id = result.payload.get("recipe_id")
|
||||
if new_recipe_id and user_edits:
|
||||
try:
|
||||
await self._persistence_service.update_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=new_recipe_id,
|
||||
updates=user_edits,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Re-import (local) succeeded but failed to carry over "
|
||||
"user edits for recipe %s: %s",
|
||||
new_recipe_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"old_recipe_id": recipe_id,
|
||||
"recipe_id": new_recipe_id,
|
||||
"source_path": normalized,
|
||||
}
|
||||
)
|
||||
|
||||
async def create_from_example(self, request: web.Request) -> web.Response:
|
||||
"""Create a recipe from a model's example image using cached metadata.
|
||||
|
||||
|
||||
@@ -107,6 +107,40 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/download-progress/{download_id}", "get_download_progress"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/downloads/queue", "get_download_queue"),
|
||||
RouteDefinition("GET", "/api/lm/downloads/queue/add", "add_to_download_queue"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/remove", "remove_from_download_queue"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/move-to-top", "move_queue_item_to_top"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/move-to-end", "move_queue_item_to_end"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/clear", "clear_download_queue"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/downloads/history", "get_download_history"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/history/clear", "clear_download_history"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/history/delete", "delete_download_history_item"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/history/retry", "retry_download_from_history"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/history/retry-all", "retry_all_failed_downloads"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/downloads/stats", "get_download_stats"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/status", "update_download_queue_status"
|
||||
),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
|
||||
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
|
||||
)
|
||||
|
||||
@@ -78,6 +78,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipes/create-from-example", "create_from_example"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ from ..config import config
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.model_query import normalize_sub_type, resolve_sub_type
|
||||
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
||||
from ..utils.usage_stats import UsageStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -140,6 +142,21 @@ class StatsRoutes:
|
||||
# Get usage statistics
|
||||
usage_data = await self.usage_stats.get_stats()
|
||||
|
||||
# CivitAI model type distribution across all model types
|
||||
# Use the same logic as the filter panel: normalize_sub_type(resolve_sub_type(entry))
|
||||
# with sub-type validation per model type
|
||||
model_types_counter: Counter[str] = Counter()
|
||||
for entry in lora_cache.raw_data:
|
||||
ntype = normalize_sub_type(resolve_sub_type(entry))
|
||||
if ntype and ntype in VALID_LORA_SUB_TYPES:
|
||||
model_types_counter[ntype] += 1
|
||||
for entry in checkpoint_cache.raw_data:
|
||||
ntype = normalize_sub_type(resolve_sub_type(entry))
|
||||
if ntype and ntype in VALID_CHECKPOINT_SUB_TYPES:
|
||||
model_types_counter[ntype] += 1
|
||||
# Embeddings: always count as "embedding" regardless of CivitAI sub-type
|
||||
model_types_counter['embedding'] = len(embedding_cache.raw_data)
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'data': {
|
||||
@@ -154,7 +171,8 @@ class StatsRoutes:
|
||||
'total_generations': usage_data.get('total_executions', 0),
|
||||
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
|
||||
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {})),
|
||||
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {}))
|
||||
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {})),
|
||||
'model_types_distribution': dict(model_types_counter.most_common())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -459,9 +477,12 @@ class StatsRoutes:
|
||||
if unused_lora_percent > 50:
|
||||
insights.append({
|
||||
'type': 'warning',
|
||||
'title': 'High Number of Unused LoRAs',
|
||||
'description': f'{unused_lora_percent:.1f}% of your LoRAs ({unused_loras}/{total_loras}) have never been used.',
|
||||
'suggestion': 'Consider organizing or archiving unused models to free up storage space.'
|
||||
'key': 'insights.unusedLoras.high',
|
||||
'params': {
|
||||
'percent': f'{unused_lora_percent:.1f}',
|
||||
'count': str(unused_loras),
|
||||
'total': str(total_loras)
|
||||
}
|
||||
})
|
||||
|
||||
if total_checkpoints > 0:
|
||||
@@ -469,9 +490,12 @@ class StatsRoutes:
|
||||
if unused_checkpoint_percent > 30:
|
||||
insights.append({
|
||||
'type': 'warning',
|
||||
'title': 'Unused Checkpoints Detected',
|
||||
'description': f'{unused_checkpoint_percent:.1f}% of your checkpoints ({unused_checkpoints}/{total_checkpoints}) have never been used.',
|
||||
'suggestion': 'Review and consider removing checkpoints you no longer need.'
|
||||
'key': 'insights.unusedCheckpoints.detected',
|
||||
'params': {
|
||||
'percent': f'{unused_checkpoint_percent:.1f}',
|
||||
'count': str(unused_checkpoints),
|
||||
'total': str(total_checkpoints)
|
||||
}
|
||||
})
|
||||
|
||||
if total_embeddings > 0:
|
||||
@@ -479,9 +503,12 @@ class StatsRoutes:
|
||||
if unused_embedding_percent > 50:
|
||||
insights.append({
|
||||
'type': 'warning',
|
||||
'title': 'High Number of Unused Embeddings',
|
||||
'description': f'{unused_embedding_percent:.1f}% of your embeddings ({unused_embeddings}/{total_embeddings}) have never been used.',
|
||||
'suggestion': 'Consider organizing or archiving unused embeddings to optimize your collection.'
|
||||
'key': 'insights.unusedEmbeddings.high',
|
||||
'params': {
|
||||
'percent': f'{unused_embedding_percent:.1f}',
|
||||
'count': str(unused_embeddings),
|
||||
'total': str(total_embeddings)
|
||||
}
|
||||
})
|
||||
|
||||
# Storage insights
|
||||
@@ -492,18 +519,20 @@ class StatsRoutes:
|
||||
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
|
||||
insights.append({
|
||||
'type': 'info',
|
||||
'title': 'Large Collection Detected',
|
||||
'description': f'Your model collection is using {self._format_size(total_size)} of storage.',
|
||||
'suggestion': 'Consider using external storage or cloud solutions for better organization.'
|
||||
'key': 'insights.collection.large',
|
||||
'params': {
|
||||
'size': self._format_size(total_size)
|
||||
}
|
||||
})
|
||||
|
||||
# Recent activity insight
|
||||
if usage_data.get('total_executions', 0) > 100:
|
||||
insights.append({
|
||||
'type': 'success',
|
||||
'title': 'Active User',
|
||||
'description': f'You\'ve completed {usage_data["total_executions"]} generations so far!',
|
||||
'suggestion': 'Keep exploring and creating amazing content with your models.'
|
||||
'key': 'insights.activity.active',
|
||||
'params': {
|
||||
'count': str(usage_data['total_executions'])
|
||||
}
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import logging
|
||||
import toml
|
||||
import git
|
||||
import zipfile
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -225,7 +224,7 @@ class UpdateRoutes:
|
||||
logger.debug("Could not close downloaded-version history database", exc_info=True)
|
||||
|
||||
# Skip settings.json, civitai, model cache and runtime cache folders
|
||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups'])
|
||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups', 'stats'])
|
||||
|
||||
# Extract ZIP to temp dir
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
@@ -235,7 +234,7 @@ class UpdateRoutes:
|
||||
extracted_root = next(os.scandir(tmp_dir)).path
|
||||
|
||||
# Copy files, skipping user data that should be preserved
|
||||
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups'}
|
||||
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups', 'stats'}
|
||||
for item in os.listdir(extracted_root):
|
||||
if item in skip_items:
|
||||
continue
|
||||
@@ -252,7 +251,7 @@ class UpdateRoutes:
|
||||
# for ComfyUI Manager to work properly
|
||||
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
||||
tracking_files = []
|
||||
skip_tracked = {'civitai', 'wildcards', 'backups'}
|
||||
skip_tracked = {'civitai', 'wildcards', 'backups', 'stats'}
|
||||
for root, dirs, files in os.walk(extracted_root):
|
||||
# Skip user data directories and their contents
|
||||
rel_root = os.path.relpath(root, extracted_root)
|
||||
@@ -357,6 +356,15 @@ class UpdateRoutes:
|
||||
Returns:
|
||||
tuple: (success, new_version)
|
||||
"""
|
||||
try:
|
||||
import git
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"GitPython is not available: the git executable was not found in PATH. "
|
||||
"Install git or set $GIT_PYTHON_GIT_EXECUTABLE to the git binary path."
|
||||
)
|
||||
return False, ""
|
||||
|
||||
try:
|
||||
# Open the Git repository
|
||||
repo = git.Repo(plugin_root)
|
||||
@@ -453,6 +461,7 @@ class UpdateRoutes:
|
||||
if not os.path.exists(os.path.join(plugin_root, '.git')):
|
||||
return git_info
|
||||
|
||||
import git
|
||||
repo = git.Repo(plugin_root)
|
||||
commit = repo.head.commit
|
||||
git_info['commit_hash'] = commit.hexsha
|
||||
|
||||
@@ -141,6 +141,16 @@ class BackupService:
|
||||
)
|
||||
)
|
||||
|
||||
stats_path = os.path.join(get_settings_dir(create=True), "stats", "lora_manager_stats.json")
|
||||
if os.path.exists(stats_path):
|
||||
targets.append(
|
||||
(
|
||||
"usage_stats",
|
||||
"stats/lora_manager_stats.json",
|
||||
stats_path,
|
||||
)
|
||||
)
|
||||
|
||||
return targets
|
||||
|
||||
@staticmethod
|
||||
@@ -348,6 +358,8 @@ class BackupService:
|
||||
if kind == "model_update":
|
||||
filename = os.path.basename(archive_member)
|
||||
return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent / filename)
|
||||
if kind == "usage_stats":
|
||||
return os.path.join(get_settings_dir(create=True), "stats", "lora_manager_stats.json")
|
||||
return None
|
||||
|
||||
async def create_auto_snapshot_if_due(self) -> Optional[dict[str, Any]]:
|
||||
|
||||
@@ -29,6 +29,7 @@ from .metadata_service import get_default_metadata_provider, get_metadata_provid
|
||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
||||
from .aria2_transfer_state import Aria2TransferStateStore
|
||||
from .download_queue_service import DownloadQueueService
|
||||
|
||||
# Download to temporary file first
|
||||
import tempfile
|
||||
@@ -360,6 +361,15 @@ class DownloadManager:
|
||||
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
||||
await self._persist_aria2_state(task_id)
|
||||
|
||||
# Update SQLite queue status to 'downloading'
|
||||
try:
|
||||
queue_service = await DownloadQueueService.get_instance()
|
||||
await queue_service.update_status(task_id, "downloading")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to update queue status for %s", task_id, exc_info=True
|
||||
)
|
||||
|
||||
# Use original download implementation
|
||||
try:
|
||||
# Check for cancellation before starting
|
||||
@@ -396,6 +406,22 @@ class DownloadManager:
|
||||
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
||||
await self._persist_aria2_state(task_id)
|
||||
|
||||
# Move queue item to history on completion
|
||||
try:
|
||||
queue_service = await DownloadQueueService.get_instance()
|
||||
await queue_service.complete_download(
|
||||
download_id=task_id,
|
||||
status=result.get("status", "completed") if result.get("success") else "failed",
|
||||
error=result.get("error") if not result.get("success") else None,
|
||||
file_path=result.get("file_path"),
|
||||
bytes_downloaded=self._active_downloads.get(task_id, {}).get("bytes_downloaded", 0),
|
||||
total_bytes=self._active_downloads.get(task_id, {}).get("total_bytes"),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to complete queue item for %s", task_id, exc_info=True
|
||||
)
|
||||
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
# Handle cancellation
|
||||
@@ -404,6 +430,19 @@ class DownloadManager:
|
||||
self._active_downloads[task_id]["bytes_per_second"] = 0.0
|
||||
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
||||
await self._persist_aria2_state(task_id)
|
||||
|
||||
# Move queue item to history as canceled
|
||||
try:
|
||||
queue_service = await DownloadQueueService.get_instance()
|
||||
await queue_service.complete_download(
|
||||
download_id=task_id,
|
||||
status="canceled",
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to cancel queue item for %s", task_id, exc_info=True
|
||||
)
|
||||
|
||||
logger.info(f"Download cancelled for task {task_id}")
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -417,6 +456,22 @@ class DownloadManager:
|
||||
self._active_downloads[task_id]["bytes_per_second"] = 0.0
|
||||
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
||||
await self._persist_aria2_state(task_id)
|
||||
|
||||
# Move queue item to history as failed
|
||||
try:
|
||||
queue_service = await DownloadQueueService.get_instance()
|
||||
await queue_service.complete_download(
|
||||
download_id=task_id,
|
||||
status="failed",
|
||||
error=str(e),
|
||||
bytes_downloaded=self._active_downloads.get(task_id, {}).get("bytes_downloaded", 0),
|
||||
total_bytes=self._active_downloads.get(task_id, {}).get("total_bytes"),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to complete queue item for %s", task_id, exc_info=True
|
||||
)
|
||||
|
||||
return {"success": False, "error": str(e)}
|
||||
finally:
|
||||
# Schedule cleanup of download record after delay
|
||||
|
||||
871
py/services/download_queue_service.py
Normal file
871
py/services/download_queue_service.py
Normal file
@@ -0,0 +1,871 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..utils.cache_paths import get_cache_base_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_database_path() -> str:
|
||||
base_dir = get_cache_base_dir(create=True)
|
||||
history_dir = os.path.join(base_dir, "download_history")
|
||||
os.makedirs(history_dir, exist_ok=True)
|
||||
return os.path.join(history_dir, "download_queue.sqlite")
|
||||
|
||||
|
||||
class DownloadQueueService:
|
||||
"""Persistent download queue and history manager backed by SQLite.
|
||||
|
||||
Provides a singleton interface for managing a download queue and
|
||||
corresponding history table, both stored in a single SQLite database
|
||||
under the cache directory.
|
||||
"""
|
||||
|
||||
_instance: Optional[DownloadQueueService] = None
|
||||
_class_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS download_queue (
|
||||
download_id TEXT PRIMARY KEY,
|
||||
model_id INTEGER,
|
||||
model_version_id INTEGER,
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
version_name TEXT DEFAULT '',
|
||||
thumbnail_url TEXT DEFAULT '',
|
||||
source TEXT,
|
||||
file_params TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
priority INTEGER DEFAULT 0,
|
||||
progress INTEGER DEFAULT 0,
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
bytes_per_second REAL DEFAULT 0.0,
|
||||
error TEXT,
|
||||
file_path TEXT,
|
||||
added_at REAL NOT NULL,
|
||||
started_at REAL,
|
||||
completed_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dq_status ON download_queue(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dq_added ON download_queue(added_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
download_id TEXT,
|
||||
model_id INTEGER,
|
||||
model_version_id INTEGER,
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
version_name TEXT DEFAULT '',
|
||||
thumbnail_url TEXT DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
file_path TEXT,
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
completed_at REAL NOT NULL,
|
||||
is_already_exists INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> DownloadQueueService:
|
||||
"""Return the singleton instance, creating it if necessary."""
|
||||
async with cls._class_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
await cls._instance.deduplicate()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None) -> None:
|
||||
self._db_path = db_path or _resolve_database_path()
|
||||
self._lock = asyncio.Lock()
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._schema_initialized = False
|
||||
self._ensure_directory()
|
||||
self._initialize_schema()
|
||||
|
||||
def _ensure_directory(self) -> None:
|
||||
directory = os.path.dirname(self._db_path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
return self._conn
|
||||
|
||||
def _initialize_schema(self) -> None:
|
||||
if self._schema_initialized:
|
||||
return
|
||||
with self._connect() as conn:
|
||||
conn.executescript(self._SCHEMA)
|
||||
conn.commit()
|
||||
self._schema_initialized = True
|
||||
|
||||
def get_database_path(self) -> str:
|
||||
"""Return the resolved database file path."""
|
||||
return self._db_path
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the persistent SQLite connection, if open.
|
||||
|
||||
This is called before plugin update operations to release the
|
||||
database file lock on Windows, allowing ``shutil.rmtree()`` to
|
||||
succeed when the cache resides inside the plugin directory.
|
||||
"""
|
||||
if self._conn is not None:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._conn = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Queue methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def add_to_queue(
|
||||
self,
|
||||
download_id: str,
|
||||
model_id: Optional[int] = None,
|
||||
model_version_id: Optional[int] = None,
|
||||
model_name: str = "",
|
||||
version_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
source: Optional[str] = None,
|
||||
file_params: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Insert a new download into the queue.
|
||||
|
||||
Returns the inserted row as a dict (or an empty dict if the
|
||||
download_id already exists).
|
||||
"""
|
||||
now = time.time()
|
||||
file_params_json = json.dumps(file_params) if file_params is not None else None
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO download_queue (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
download_id,
|
||||
model_id,
|
||||
model_version_id,
|
||||
model_name,
|
||||
version_name,
|
||||
thumbnail_url,
|
||||
source,
|
||||
file_params_json,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
|
||||
return dict(row) if row else {}
|
||||
|
||||
async def get_queue(self) -> list[dict[str, Any]]:
|
||||
"""Return all items in the queue ordered by priority then added time."""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM download_queue ORDER BY priority DESC, added_at ASC"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def get_queued_count(self) -> int:
|
||||
"""Return the number of items with status ``'queued'``."""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_queue WHERE status = 'queued'"
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
download_id: str,
|
||||
status: str,
|
||||
**extra: Any,
|
||||
) -> bool:
|
||||
"""Update the status and/or extra fields of a queue item.
|
||||
|
||||
Accepted extra keyword arguments:
|
||||
``progress``, ``error``, ``file_path``, ``bytes_downloaded``,
|
||||
``total_bytes``, ``bytes_per_second``.
|
||||
|
||||
Returns ``True`` if a row was updated.
|
||||
"""
|
||||
allowed_extra = {
|
||||
"progress",
|
||||
"error",
|
||||
"file_path",
|
||||
"bytes_downloaded",
|
||||
"total_bytes",
|
||||
"bytes_per_second",
|
||||
}
|
||||
|
||||
set_clauses: list[str] = ["status = ?"]
|
||||
params: list[Any] = [status]
|
||||
now = time.time()
|
||||
|
||||
if status in ("downloading",):
|
||||
set_clauses.append("started_at = COALESCE(started_at, ?)")
|
||||
params.append(now)
|
||||
if status in ("completed", "failed", "canceled"):
|
||||
set_clauses.append("completed_at = ?")
|
||||
params.append(now)
|
||||
|
||||
for key, value in extra.items():
|
||||
if key in allowed_extra:
|
||||
set_clauses.append(f"{key} = ?")
|
||||
params.append(value)
|
||||
|
||||
params.append(download_id)
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute(
|
||||
f"UPDATE download_queue SET {', '.join(set_clauses)} "
|
||||
"WHERE download_id = ?",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def remove_from_queue(self, download_id: str) -> bool:
|
||||
"""Remove a single item from the queue by download_id.
|
||||
|
||||
Returns ``True`` if a row was deleted.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def move_to_top(self, download_id: str) -> bool:
|
||||
"""Move an item to the front of the queue (highest priority).
|
||||
|
||||
Returns ``True`` if the item was found and updated.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT priority FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
|
||||
max_row = conn.execute(
|
||||
"SELECT MAX(priority) AS mx FROM download_queue"
|
||||
).fetchone()
|
||||
max_priority: int = max_row["mx"] if max_row["mx"] is not None else 0
|
||||
|
||||
conn.execute(
|
||||
"UPDATE download_queue SET priority = ? WHERE download_id = ?",
|
||||
(max_priority + 1, download_id),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
async def move_to_end(self, download_id: str) -> bool:
|
||||
"""Move an item to the end of the queue (lowest priority).
|
||||
|
||||
Returns ``True`` if the item was found and updated.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT priority FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
|
||||
min_row = conn.execute(
|
||||
"SELECT MIN(priority) AS mn FROM download_queue"
|
||||
).fetchone()
|
||||
min_priority: int = min_row["mn"] if min_row["mn"] is not None else 0
|
||||
|
||||
conn.execute(
|
||||
"UPDATE download_queue SET priority = ? WHERE download_id = ?",
|
||||
(min_priority - 1, download_id),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
async def clear_queue(self, status_filter: Optional[str] = None) -> int:
|
||||
"""Remove items from the queue.
|
||||
|
||||
When *status_filter* is provided only items with that status are
|
||||
deleted. Returns the number of deleted rows.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
if status_filter is not None:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_queue WHERE status = ?",
|
||||
(status_filter,),
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute("DELETE FROM download_queue")
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
async def complete_download(
|
||||
self,
|
||||
download_id: str,
|
||||
status: str = "completed",
|
||||
error: Optional[str] = None,
|
||||
file_path: Optional[str] = None,
|
||||
bytes_downloaded: int = 0,
|
||||
total_bytes: Optional[int] = None,
|
||||
completed_at: Optional[float] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Atomically move a download from the queue into the history table.
|
||||
|
||||
Looks up the queue record by ``download_id``, deletes it from the
|
||||
queue, and inserts a corresponding history entry with the given
|
||||
terminal status (``completed``, ``failed``, or ``canceled``).
|
||||
|
||||
When *completed_at* is provided it is used as the completion
|
||||
timestamp; otherwise ``time.time()`` is used.
|
||||
|
||||
Returns the original queue record (before deletion) on success,
|
||||
or ``None`` if the download was not found in the queue.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
now = completed_at if completed_at is not None else time.time()
|
||||
conn.execute(
|
||||
"DELETE FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_history (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, status, error, file_path,
|
||||
bytes_downloaded, total_bytes, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row["download_id"],
|
||||
row["model_id"],
|
||||
row["model_version_id"],
|
||||
row["model_name"],
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
status,
|
||||
error,
|
||||
file_path,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return dict(row)
|
||||
|
||||
async def pop_next_download(self) -> Optional[dict[str, Any]]:
|
||||
"""Atomically fetch and mark the next queued item as ``downloading``.
|
||||
|
||||
The item with the highest priority (and earliest ``added_at``
|
||||
among ties) whose status is ``'queued'`` is selected, set to
|
||||
``'downloading'``, and returned as a dict. Returns ``None`` if
|
||||
the queue is empty.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM download_queue
|
||||
WHERE status = 'queued'
|
||||
ORDER BY priority DESC, added_at ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
download_id = row["download_id"]
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"UPDATE download_queue SET status = 'downloading', "
|
||||
"started_at = COALESCE(started_at, ?) "
|
||||
"WHERE download_id = ?",
|
||||
(now, download_id),
|
||||
)
|
||||
conn.commit()
|
||||
updated = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
|
||||
return dict(updated) if updated else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# History methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def add_to_history(
|
||||
self,
|
||||
download_id: Optional[str] = None,
|
||||
model_id: Optional[int] = None,
|
||||
model_version_id: Optional[int] = None,
|
||||
model_name: str = "",
|
||||
version_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
status: str = "completed",
|
||||
error: Optional[str] = None,
|
||||
file_path: Optional[str] = None,
|
||||
bytes_downloaded: int = 0,
|
||||
total_bytes: Optional[int] = None,
|
||||
is_already_exists: int = 0,
|
||||
) -> int:
|
||||
"""Insert a record into the download history.
|
||||
|
||||
Returns the ``id`` (AUTOINCREMENT primary key) of the newly
|
||||
inserted row.
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO download_history (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, status, error, file_path,
|
||||
bytes_downloaded, total_bytes, completed_at, is_already_exists
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
download_id,
|
||||
model_id,
|
||||
model_version_id,
|
||||
model_name,
|
||||
version_name,
|
||||
thumbnail_url,
|
||||
status,
|
||||
error,
|
||||
file_path,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
now,
|
||||
is_already_exists,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid or 0
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
status_filter: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a page of download history entries.
|
||||
|
||||
Returns a dict with keys ``items``, ``total``, ``limit``, and
|
||||
``offset``.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
if status_filter is not None:
|
||||
count_row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_history WHERE status = ?",
|
||||
(status_filter,),
|
||||
).fetchone()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM download_history WHERE status = ? "
|
||||
"ORDER BY completed_at DESC LIMIT ? OFFSET ?",
|
||||
(status_filter, limit, offset),
|
||||
).fetchall()
|
||||
else:
|
||||
count_row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_history"
|
||||
).fetchone()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM download_history "
|
||||
"ORDER BY completed_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"items": [dict(row) for row in rows],
|
||||
"total": count_row["cnt"] if count_row else 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
async def delete_history_item(self, id: int) -> bool:
|
||||
"""Delete a single history entry by its *id*.
|
||||
|
||||
Returns ``True`` if a row was deleted.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_history WHERE id = ?",
|
||||
(id,),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def clear_history(
|
||||
self,
|
||||
status_filter: Optional[str] = None,
|
||||
before_timestamp: Optional[float] = None,
|
||||
) -> int:
|
||||
"""Remove history entries matching the optional filters.
|
||||
|
||||
Both ``status_filter`` and ``before_timestamp`` can be combined
|
||||
(AND logic). Returns the number of deleted rows.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if status_filter is not None:
|
||||
clauses.append("status = ?")
|
||||
params.append(status_filter)
|
||||
if before_timestamp is not None:
|
||||
clauses.append("completed_at < ?")
|
||||
params.append(before_timestamp)
|
||||
|
||||
where = ""
|
||||
if clauses:
|
||||
where = " WHERE " + " AND ".join(clauses)
|
||||
|
||||
cursor = conn.execute(
|
||||
f"DELETE FROM download_history{where}",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
async def get_history_count(self, status_filter: Optional[str] = None) -> int:
|
||||
"""Return the number of history entries, optionally filtered by status."""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
if status_filter is not None:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_history WHERE status = ?",
|
||||
(status_filter,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_history"
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Retry
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def retry_from_history(self, item_id: int) -> Optional[dict[str, Any]]:
|
||||
"""Re-queue a failed or canceled download from history.
|
||||
|
||||
Looks up the history record by its primary key. If the status is
|
||||
``failed`` or ``canceled`` a new queue entry is created with the
|
||||
same model metadata and a fresh download id, and the original
|
||||
history entry is **deleted** to prevent exponential growth when
|
||||
the retried item is later canceled or fails again and re-retried.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_history WHERE id = ?",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
status = str(row["status"])
|
||||
if status not in ("failed", "canceled"):
|
||||
return None
|
||||
|
||||
import uuid
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_queue (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
new_id,
|
||||
row["model_id"],
|
||||
row["model_version_id"],
|
||||
row["model_name"],
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
"retry",
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM download_history WHERE id = ?",
|
||||
(item_id,),
|
||||
)
|
||||
conn.commit()
|
||||
queued = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
(new_id,),
|
||||
).fetchone()
|
||||
|
||||
return dict(queued) if queued else None
|
||||
|
||||
async def retry_all_failed(self) -> int:
|
||||
"""Re-queue all failed and canceled downloads from history.
|
||||
|
||||
Each history entry is **deleted** after being re-queued so that
|
||||
repeated retry-all calls do not cause exponential growth.
|
||||
|
||||
Returns the number of items that were re-queued.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM download_history WHERE status IN ('failed', 'canceled')"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
import uuid
|
||||
|
||||
now = time.time()
|
||||
count = 0
|
||||
for row in rows:
|
||||
new_id = str(uuid.uuid4())
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_queue (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
new_id,
|
||||
row["model_id"],
|
||||
row["model_version_id"],
|
||||
row["model_name"],
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
"retry",
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM download_history WHERE id = ?",
|
||||
(row["id"],),
|
||||
)
|
||||
count += 1
|
||||
conn.commit()
|
||||
|
||||
return count
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stats
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_stats(self) -> dict[str, int]:
|
||||
"""Return aggregate counts across both tables.
|
||||
|
||||
Returns a dict with keys ``queued``, ``downloading``, ``paused``
|
||||
(all from the queue table) and ``completed``, ``failed``,
|
||||
``canceled`` (all from the history table).
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
queue_rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS cnt FROM download_queue GROUP BY status"
|
||||
).fetchall()
|
||||
queue_stats: dict[str, int] = {}
|
||||
for row in queue_rows:
|
||||
queue_stats[str(row["status"])] = row["cnt"]
|
||||
|
||||
history_rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS cnt FROM download_history GROUP BY status"
|
||||
).fetchall()
|
||||
history_stats: dict[str, int] = {}
|
||||
for row in history_rows:
|
||||
history_stats[str(row["status"])] = row["cnt"]
|
||||
|
||||
return {
|
||||
"queued": queue_stats.get("queued", 0),
|
||||
"downloading": queue_stats.get("downloading", 0),
|
||||
"paused": queue_stats.get("paused", 0),
|
||||
"completed": history_stats.get("completed", 0),
|
||||
"failed": history_stats.get("failed", 0),
|
||||
"canceled": history_stats.get("canceled", 0),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Deduplication (one-time cleanup for bug #980)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def deduplicate(self) -> dict[str, int]:
|
||||
"""Remove duplicate entries caused by the retry-amplification bug.
|
||||
|
||||
The bug (issue #980) caused the same download to appear N times in
|
||||
both the queue and history tables when ``retry_all_failed`` was
|
||||
called repeatedly without deleting the original history rows.
|
||||
|
||||
This method is called **once** when the singleton is first created.
|
||||
It is idempotent — after the first run there will be no duplicates
|
||||
to remove, so subsequent calls are a no-op.
|
||||
|
||||
Returns a dict with the count of removed rows per table.
|
||||
"""
|
||||
result: dict[str, int] = {
|
||||
"removed_history": 0,
|
||||
"removed_queue": 0,
|
||||
"removed_orphan_queue": 0,
|
||||
}
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
# 1. History: for each (model_id, model_version_id, status) triplet
|
||||
# keep only the row with the highest id (most recently inserted).
|
||||
conn.execute("""
|
||||
DELETE FROM download_history
|
||||
WHERE id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM download_history
|
||||
GROUP BY model_id, model_version_id, status
|
||||
)
|
||||
""")
|
||||
result["removed_history"] = conn.execute(
|
||||
"SELECT changes()"
|
||||
).fetchone()[0]
|
||||
|
||||
# 2. Cross-status dedup: for each (model_id, model_version_id),
|
||||
# keep only the entry with the highest-priority terminal status.
|
||||
# Priority: completed (3) > failed (2) > canceled (1).
|
||||
# This prevents the same model version from having both a
|
||||
# 'failed' and a 'canceled' entry (or a 'completed' alongside
|
||||
# either) after the bug-created duplicates are removed.
|
||||
conn.execute("""
|
||||
DELETE FROM download_history
|
||||
WHERE id NOT IN (
|
||||
SELECT dh.id
|
||||
FROM download_history dh
|
||||
INNER JOIN (
|
||||
SELECT model_id, model_version_id,
|
||||
MAX(CASE status
|
||||
WHEN 'completed' THEN 3
|
||||
WHEN 'failed' THEN 2
|
||||
WHEN 'canceled' THEN 1
|
||||
ELSE 0
|
||||
END) AS best_prio
|
||||
FROM download_history
|
||||
GROUP BY model_id, model_version_id
|
||||
) best
|
||||
ON dh.model_id = best.model_id
|
||||
AND dh.model_version_id = best.model_version_id
|
||||
AND CASE dh.status
|
||||
WHEN 'completed' THEN 3
|
||||
WHEN 'failed' THEN 2
|
||||
WHEN 'canceled' THEN 1
|
||||
ELSE 0
|
||||
END = best.best_prio
|
||||
GROUP BY dh.model_id, dh.model_version_id
|
||||
HAVING dh.id = MAX(dh.id)
|
||||
)
|
||||
""")
|
||||
result["removed_history"] += conn.execute(
|
||||
"SELECT changes()"
|
||||
).fetchone()[0]
|
||||
|
||||
# 3. Queue: for each (model_id, model_version_id) keep only the
|
||||
# row with the latest added_at (most recently enqueued).
|
||||
conn.execute("""
|
||||
DELETE FROM download_queue
|
||||
WHERE rowid NOT IN (
|
||||
SELECT MAX(rowid)
|
||||
FROM download_queue
|
||||
WHERE status IN ('queued', 'downloading', 'paused', 'waiting')
|
||||
GROUP BY model_id, model_version_id
|
||||
)
|
||||
AND status IN ('queued', 'downloading', 'paused', 'waiting')
|
||||
""")
|
||||
result["removed_queue"] = conn.execute(
|
||||
"SELECT changes()"
|
||||
).fetchone()[0]
|
||||
|
||||
# 4. Remove orphaned queue entries — items that were re-queued
|
||||
# (source='retry') but whose model version already has a
|
||||
# terminal history entry. These are artifacts of the buggy
|
||||
# retry cycle that were never cleaned up.
|
||||
conn.execute("""
|
||||
DELETE FROM download_queue
|
||||
WHERE source = 'retry'
|
||||
AND (model_id, model_version_id) IN (
|
||||
SELECT model_id, model_version_id
|
||||
FROM download_history
|
||||
WHERE status IN ('failed', 'canceled')
|
||||
)
|
||||
AND status IN ('queued', 'waiting')
|
||||
""")
|
||||
result["removed_orphan_queue"] = conn.execute(
|
||||
"SELECT changes()"
|
||||
).fetchone()[0]
|
||||
|
||||
conn.commit()
|
||||
|
||||
logger.info(
|
||||
"Deduplicate: removed %s history rows, %s queue rows, "
|
||||
"%s orphaned queue rows",
|
||||
result["removed_history"],
|
||||
result["removed_queue"],
|
||||
result["removed_orphan_queue"],
|
||||
)
|
||||
return result
|
||||
@@ -256,7 +256,9 @@ class Downloader:
|
||||
self._session = None
|
||||
|
||||
# Check for app-level proxy settings
|
||||
proxy_url = None
|
||||
proxy_url = None # http(s) proxy, passed via the per-request `proxy=` kwarg
|
||||
socks_proxy_url = None # SOCKS proxy, handled via aiohttp-socks connector
|
||||
app_proxy_active = False
|
||||
settings_manager = get_settings_manager()
|
||||
if settings_manager.get("proxy_enabled", False):
|
||||
proxy_host = settings_manager.get("proxy_host", "").strip()
|
||||
@@ -268,9 +270,19 @@ class Downloader:
|
||||
if proxy_host and proxy_port:
|
||||
# Build proxy URL
|
||||
if proxy_username and proxy_password:
|
||||
proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
|
||||
full_proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
|
||||
else:
|
||||
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
|
||||
full_proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
|
||||
|
||||
app_proxy_active = True
|
||||
# aiohttp cannot tunnel SOCKS via the per-request `proxy=` kwarg
|
||||
# (it would send HTTP to the SOCKS port and fail parsing the
|
||||
# SOCKS handshake reply). SOCKS must be handled by an
|
||||
# aiohttp-socks ProxyConnector instead.
|
||||
if proxy_type.startswith("socks"):
|
||||
socks_proxy_url = full_proxy_url
|
||||
else:
|
||||
proxy_url = full_proxy_url
|
||||
|
||||
logger.debug(
|
||||
f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}"
|
||||
@@ -294,13 +306,27 @@ class Downloader:
|
||||
logger.debug("SSL: certifi unavailable; using system default CA bundle")
|
||||
|
||||
# Optimize TCP connection parameters
|
||||
connector = aiohttp.TCPConnector(
|
||||
connector_kwargs = dict(
|
||||
ssl=ssl_context,
|
||||
limit=8, # Concurrent connections
|
||||
ttl_dns_cache=300, # DNS cache timeout
|
||||
force_close=False, # Keep connections for reuse
|
||||
enable_cleanup_closed=True,
|
||||
)
|
||||
if socks_proxy_url:
|
||||
# Route all traffic through the SOCKS proxy via aiohttp-socks. The
|
||||
# connector tunnels every connection, so no per-request `proxy=` is
|
||||
# used (and must not be — see self._proxy_url below).
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
except ImportError as e: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
"A SOCKS proxy is configured but the 'aiohttp-socks' package "
|
||||
"is not installed. Install it with: pip install aiohttp-socks"
|
||||
) from e
|
||||
connector = ProxyConnector.from_url(socks_proxy_url, **connector_kwargs)
|
||||
else:
|
||||
connector = aiohttp.TCPConnector(**connector_kwargs)
|
||||
|
||||
# Configure timeout parameters
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
@@ -311,12 +337,14 @@ class Downloader:
|
||||
|
||||
self._session = aiohttp.ClientSession(
|
||||
connector=connector,
|
||||
trust_env=proxy_url
|
||||
is None, # Only use system proxy if no app-level proxy is set
|
||||
# Only fall back to system/env proxy when no app-level proxy is active
|
||||
trust_env=not app_proxy_active,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Store proxy URL for use in requests
|
||||
# Store proxy URL for per-request use. Stays None for SOCKS because the
|
||||
# ProxyConnector already tunnels everything; passing proxy= for SOCKS
|
||||
# would re-trigger the original aiohttp parse error.
|
||||
self._proxy_url = proxy_url
|
||||
self._session_created_at = datetime.now()
|
||||
|
||||
|
||||
@@ -216,13 +216,19 @@ class MetadataSyncService:
|
||||
provider_used: Optional[str] = None
|
||||
last_error: Optional[str] = None
|
||||
civitai_api_not_found = False
|
||||
any_rate_limited = False
|
||||
|
||||
for provider_name, provider in provider_attempts:
|
||||
try:
|
||||
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or (provider_name or provider.__class__.__name__)
|
||||
raise
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
provider_name or provider.__class__.__name__,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
any_rate_limited = True
|
||||
continue
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
|
||||
civitai_metadata_candidate, error = None, str(exc)
|
||||
@@ -258,6 +264,14 @@ class MetadataSyncService:
|
||||
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||
needs_save = True
|
||||
|
||||
# When the model was already classified as "not on CivitAI" via
|
||||
# .metadata.json (civitai_deleted=True) but the SQLite cache is
|
||||
# stale (because the pre-fix code never persisted these flags),
|
||||
# ensure the flags are written to the scanner cache + SQLite.
|
||||
if not needs_save and model_data.get("civitai_deleted") is True:
|
||||
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()
|
||||
@@ -266,6 +280,7 @@ class MetadataSyncService:
|
||||
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 update_cache_func(file_path, file_path, data_to_save)
|
||||
|
||||
default_error = (
|
||||
"CivitAI model is deleted and metadata archive DB is not enabled"
|
||||
@@ -276,17 +291,18 @@ class MetadataSyncService:
|
||||
)
|
||||
|
||||
resolved_error = last_error or default_error
|
||||
if any_rate_limited and "Rate limited" not in resolved_error:
|
||||
resolved_error = "Rate limited"
|
||||
if is_expected_offline_error(resolved_error):
|
||||
resolved_error = OFFLINE_FRIENDLY_MESSAGE
|
||||
|
||||
error_msg = (
|
||||
f"Error fetching metadata: {resolved_error} "
|
||||
f"(model_name={model_data.get('model_name', '')})"
|
||||
f"(file={os.path.basename(file_path)}, sha256={sha256})"
|
||||
)
|
||||
if is_expected_offline_error(resolved_error):
|
||||
logger.info(error_msg)
|
||||
else:
|
||||
logger.error(error_msg)
|
||||
# Use case layer (BulkMetadataRefreshUseCase) logs failed models at WARNING level,
|
||||
# so this level is demoted to DEBUG to avoid duplicate user-visible logging.
|
||||
logger.debug(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
model_data["from_civitai"] = True
|
||||
@@ -411,7 +427,18 @@ class MetadataSyncService:
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
|
||||
for key, value in updates.items():
|
||||
if isinstance(value, dict) and isinstance(metadata.get(key), dict):
|
||||
if key == "tags" and isinstance(value, list):
|
||||
# Normalize tags: trim, lowercase, deduplicate
|
||||
normalized = []
|
||||
seen = set()
|
||||
for tag in value:
|
||||
if isinstance(tag, str):
|
||||
t = tag.strip().lower()
|
||||
if t and t not in seen:
|
||||
normalized.append(t)
|
||||
seen.add(t)
|
||||
metadata[key] = normalized
|
||||
elif isinstance(value, dict) and isinstance(metadata.get(key), dict):
|
||||
metadata[key].update(value)
|
||||
else:
|
||||
metadata[key] = value
|
||||
|
||||
@@ -65,7 +65,14 @@ class _RateLimitRetryHelper:
|
||||
return await func(*args, **kwargs)
|
||||
except RateLimitError as exc:
|
||||
attempt += 1
|
||||
if attempt >= self._retry_limit:
|
||||
|
||||
# Determine effective retry limit based on rate-limit magnitude
|
||||
effective_retry_limit = self._retry_limit # default: 3
|
||||
if exc.retry_after is not None and exc.retry_after >= 120.0:
|
||||
# Long rate-limit window (>=2 min) — retries are futile
|
||||
effective_retry_limit = 1 # total 1 attempt = 0 retries
|
||||
|
||||
if attempt >= effective_retry_limit:
|
||||
exc.provider = exc.provider or label
|
||||
raise
|
||||
|
||||
@@ -81,7 +88,11 @@ class _RateLimitRetryHelper:
|
||||
|
||||
def _calculate_delay(self, retry_after: Optional[float], attempt: int) -> float:
|
||||
if retry_after is not None:
|
||||
return min(self._max_delay, max(0.0, retry_after))
|
||||
# Cap at 1800s (30 min) as a safety ceiling. The old 30s cap was
|
||||
# too low — CivArchive can return retry_after ~1500s, causing all
|
||||
# retries to fail. A generous ceiling protects against pathological
|
||||
# server values while still respecting the server's guidance.
|
||||
return min(1800.0, max(0.0, retry_after))
|
||||
|
||||
base_delay = self._base_delay * (2 ** max(0, attempt - 1))
|
||||
jitter_span = base_delay * self._jitter_ratio
|
||||
@@ -474,8 +485,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
if result:
|
||||
return result, error
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
label,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
|
||||
continue
|
||||
@@ -493,16 +508,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
if result:
|
||||
return result
|
||||
except RateLimitError as exc:
|
||||
if not_found_confirmed:
|
||||
logger.debug(
|
||||
"Suppressing rate limit from %s for model %s: "
|
||||
"already confirmed as not found by another provider",
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
label,
|
||||
model_id,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
return None
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
continue
|
||||
except ResourceNotFoundError:
|
||||
not_found_confirmed = True
|
||||
logger.debug(
|
||||
@@ -528,8 +539,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
if result:
|
||||
return result
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
label,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("Provider %s failed for get_model_version: %s", label, e)
|
||||
continue
|
||||
@@ -546,8 +561,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
if result:
|
||||
return result, error
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
label,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
|
||||
continue
|
||||
@@ -568,8 +587,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
except NotImplementedError:
|
||||
continue
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
label,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Provider %s failed for get_model_versions_by_hashes: %s",
|
||||
@@ -590,8 +613,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
if result is not None:
|
||||
return result
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
label,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("Provider %s failed for get_user_models: %s", label, e)
|
||||
continue
|
||||
|
||||
@@ -294,12 +294,14 @@ class ModelFilterSet:
|
||||
for tag, state in tag_filters.items():
|
||||
if not tag:
|
||||
continue
|
||||
# Normalize to lowercase for case-insensitive matching
|
||||
normalized = tag.strip().lower()
|
||||
if state == "exclude":
|
||||
exclude_tags.add(tag)
|
||||
exclude_tags.add(normalized)
|
||||
else:
|
||||
include_tags.add(tag)
|
||||
include_tags.add(normalized)
|
||||
else:
|
||||
include_tags = {tag for tag in tag_filters if tag}
|
||||
include_tags = {tag.strip().lower() for tag in tag_filters if tag}
|
||||
|
||||
if include_tags:
|
||||
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
|
||||
@@ -318,13 +320,17 @@ class ModelFilterSet:
|
||||
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)
|
||||
# Case-insensitive: normalize item tags too
|
||||
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||
return all(tag in normalized_item_tags 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)
|
||||
# Normal case: all tags must match (case-insensitive)
|
||||
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||
return all(tag in normalized_item_tags 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 []))
|
||||
# OR logic (default): item must have ANY include tag (case-insensitive)
|
||||
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||
return bool(normalized_item_tags & include_tags)
|
||||
|
||||
items = [item for item in items if matches_include(item.get("tags"))]
|
||||
|
||||
@@ -333,7 +339,9 @@ class ModelFilterSet:
|
||||
def matches_exclude(item_tags):
|
||||
if not item_tags and "__no_tags__" in exclude_tags:
|
||||
return True
|
||||
return any(tag in exclude_tags for tag in (item_tags or []))
|
||||
# Case-insensitive: normalize item tags
|
||||
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||
return bool(normalized_item_tags & exclude_tags)
|
||||
|
||||
items = [
|
||||
item for item in items if not matches_exclude(item.get("tags"))
|
||||
|
||||
@@ -532,6 +532,13 @@ class ModelScanner:
|
||||
if not scan_result or not getattr(self, '_persistent_cache', None):
|
||||
return
|
||||
|
||||
if self.is_cancelled():
|
||||
logger.info(
|
||||
f"{self.model_type.capitalize()} Scanner: Skipping _save_persistent_cache "
|
||||
"after cancellation"
|
||||
)
|
||||
return
|
||||
|
||||
hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index)
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
@@ -705,6 +712,7 @@ class ModelScanner:
|
||||
# Determine the page type based on model type
|
||||
# Scan for new data
|
||||
scan_result = await self._gather_model_data()
|
||||
if not self.is_cancelled():
|
||||
await self._apply_scan_result(scan_result)
|
||||
await self._save_persistent_cache(scan_result)
|
||||
await self._sync_download_history(scan_result.raw_data, source='scan')
|
||||
@@ -713,6 +721,11 @@ class ModelScanner:
|
||||
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
||||
f"found {len(scan_result.raw_data)} models"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled "
|
||||
f"after {time.time() - start_time:.2f} seconds"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
|
||||
# Ensure cache is at least an empty structure on error
|
||||
@@ -1067,8 +1080,11 @@ class ModelScanner:
|
||||
|
||||
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
||||
|
||||
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes)
|
||||
if not model_data.get('sha256') and file_path:
|
||||
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes).
|
||||
# Respect hash_status='pending' (set by CheckpointScanner for large models) to defer
|
||||
# hash calculation until on-demand — avoids reading entire checkpoint files at startup.
|
||||
hash_status = model_data.get('hash_status', '')
|
||||
if not model_data.get('sha256') and hash_status != 'pending' and file_path:
|
||||
try:
|
||||
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
|
||||
sha256 = await calculate_sha256(file_path)
|
||||
@@ -1093,6 +1109,13 @@ class ModelScanner:
|
||||
if scan_result is None:
|
||||
return
|
||||
|
||||
if self.is_cancelled():
|
||||
logger.info(
|
||||
f"{self.model_type.capitalize()} Scanner: Skipping _apply_scan_result "
|
||||
"after cancellation"
|
||||
)
|
||||
return
|
||||
|
||||
self._hash_index = scan_result.hash_index
|
||||
self._tags_count = dict(scan_result.tags_count)
|
||||
self._excluded_models = list(scan_result.excluded_models)
|
||||
@@ -1762,6 +1785,13 @@ class ModelScanner:
|
||||
if not file_paths or self._cache is None:
|
||||
return False
|
||||
|
||||
if self.is_cancelled():
|
||||
logger.info(
|
||||
f"{self.model_type.capitalize()} Scanner: Skipping cache update "
|
||||
"after cancelled bulk delete"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
# Get all models that need to be removed from cache
|
||||
models_to_remove = [item for item in self._cache.raw_data if item['file_path'] in file_paths]
|
||||
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
@@ -26,6 +26,8 @@ class PersistedRecipeData:
|
||||
|
||||
raw_data: List[Dict]
|
||||
file_stats: Dict[str, Tuple[float, int]] # json_path -> (mtime, size)
|
||||
image_id_map: Dict[str, str] = field(default_factory=dict)
|
||||
"""Precomputed mapping of civitai image_id → recipe_id."""
|
||||
|
||||
|
||||
class PersistentRecipeCache:
|
||||
@@ -116,6 +118,20 @@ class PersistentRecipeCache:
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
# Restore precomputed image_id_map if available
|
||||
image_id_map: Dict[str, str] = {}
|
||||
try:
|
||||
meta_row = conn.execute(
|
||||
"SELECT value FROM cache_metadata WHERE key = ?",
|
||||
("image_id_map",),
|
||||
).fetchone()
|
||||
if meta_row:
|
||||
parsed = json.loads(meta_row["value"])
|
||||
if isinstance(parsed, dict):
|
||||
image_id_map = parsed
|
||||
except Exception:
|
||||
pass # missing or corrupt — rebuilt on next cache refresh
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
except FileNotFoundError:
|
||||
@@ -138,14 +154,24 @@ class PersistentRecipeCache:
|
||||
row["file_size"] or 0,
|
||||
)
|
||||
|
||||
return PersistedRecipeData(raw_data=raw_data, file_stats=file_stats)
|
||||
return PersistedRecipeData(
|
||||
raw_data=raw_data,
|
||||
file_stats=file_stats,
|
||||
image_id_map=image_id_map,
|
||||
)
|
||||
|
||||
def save_cache(self, recipes: List[Dict], json_paths: Optional[Dict[str, str]] = None) -> None:
|
||||
def save_cache(
|
||||
self,
|
||||
recipes: List[Dict],
|
||||
json_paths: Optional[Dict[str, str]] = None,
|
||||
image_id_map: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
"""Save all recipes to SQLite cache.
|
||||
|
||||
Args:
|
||||
recipes: List of recipe dictionaries to persist.
|
||||
json_paths: Optional mapping of recipe_id -> json_path for file stats.
|
||||
image_id_map: Optional precomputed civitai image_id → recipe_id mapping.
|
||||
"""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
@@ -186,6 +212,12 @@ class PersistentRecipeCache:
|
||||
recipe_rows,
|
||||
)
|
||||
|
||||
# Persist image_id_map for O(1) lookups on cache load
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
|
||||
("image_id_map", json.dumps(image_id_map or {})),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
|
||||
finally:
|
||||
@@ -273,6 +305,29 @@ class PersistentRecipeCache:
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to remove recipe %s from cache: %s", recipe_id, exc)
|
||||
|
||||
def save_image_id_map(self, image_id_map: Dict[str, str]) -> None:
|
||||
"""Persist the image_id_map to cache_metadata without rewriting the full cache.
|
||||
|
||||
This is called after ``add_recipe`` / ``remove_recipe`` mutations so
|
||||
the persistent copy does not go stale between full ``save_cache`` calls.
|
||||
"""
|
||||
if not self.is_enabled() or not self._schema_initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._db_lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
|
||||
("image_id_map", json.dumps(image_id_map)),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to persist image_id_map: %s", exc)
|
||||
|
||||
def get_indexed_recipe_ids(self) -> Set[str]:
|
||||
"""Return all recipe IDs in the cache.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
from typing import Iterable, List, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from operator import itemgetter
|
||||
from natsort import natsorted
|
||||
|
||||
@@ -14,6 +14,15 @@ class RecipeCache:
|
||||
sorted_by_date: List[Dict]
|
||||
folders: List[str] | None = None
|
||||
folder_tree: Dict | None = None
|
||||
image_id_map: Dict[str, str] = field(default_factory=dict)
|
||||
"""Mapping of civitai image_id → recipe_id, precomputed at cache build time.
|
||||
|
||||
Built once during cache initialization (O(n)) so that
|
||||
``check_image_exists`` and ``import_from_url`` duplicate checks
|
||||
can look up image_id in O(1) instead of scanning all recipes.
|
||||
Recipes imported from local files have no valid civitai image_id
|
||||
and are naturally excluded from this map.
|
||||
"""
|
||||
|
||||
def __post_init__(self):
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@@ -20,6 +20,7 @@ from .metadata_service import get_default_metadata_provider
|
||||
from .checkpoint_scanner import CheckpointScanner
|
||||
from .settings_manager import get_settings_manager
|
||||
from .recipes.errors import RecipeNotFoundError
|
||||
from ..utils.civitai_utils import extract_civitai_image_id
|
||||
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
|
||||
from natsort import natsorted
|
||||
import sys
|
||||
@@ -532,7 +533,21 @@ class RecipeScanner:
|
||||
self._sort_cache_sync()
|
||||
# Backfill source_path from JSON files if missing (schema migration)
|
||||
if self._backfill_source_path_if_needed(recipes, json_paths):
|
||||
self._persistent_cache.save_cache(recipes, json_paths)
|
||||
self._cache.image_id_map = self._build_image_id_map()
|
||||
self._persistent_cache.save_cache(
|
||||
recipes, json_paths, self._cache.image_id_map
|
||||
)
|
||||
else:
|
||||
# Use persisted map, or rebuild if empty (e.g. first startup
|
||||
# after deploying the image_id_map feature).
|
||||
if persisted.image_id_map:
|
||||
self._cache.image_id_map = dict(persisted.image_id_map)
|
||||
else:
|
||||
self._cache.image_id_map = self._build_image_id_map()
|
||||
if self._cache.image_id_map:
|
||||
self._persistent_cache.save_image_id_map(
|
||||
self._cache.image_id_map
|
||||
)
|
||||
return self._cache
|
||||
else:
|
||||
# Partial update: some files changed
|
||||
@@ -545,8 +560,11 @@ class RecipeScanner:
|
||||
self._sort_cache_sync()
|
||||
# Backfill source_path from JSON files if missing (schema migration)
|
||||
self._backfill_source_path_if_needed(recipes, json_paths)
|
||||
self._cache.image_id_map = self._build_image_id_map()
|
||||
# Persist updated cache
|
||||
self._persistent_cache.save_cache(recipes, json_paths)
|
||||
self._persistent_cache.save_cache(
|
||||
recipes, json_paths, self._cache.image_id_map
|
||||
)
|
||||
return self._cache
|
||||
|
||||
# Fall back to full directory scan
|
||||
@@ -558,9 +576,12 @@ class RecipeScanner:
|
||||
self._cache.raw_data = recipes
|
||||
self._update_folder_metadata(self._cache)
|
||||
self._sort_cache_sync()
|
||||
self._cache.image_id_map = self._build_image_id_map()
|
||||
|
||||
# Persist for next startup
|
||||
self._persistent_cache.save_cache(recipes, json_paths)
|
||||
self._persistent_cache.save_cache(
|
||||
recipes, json_paths, self._cache.image_id_map
|
||||
)
|
||||
|
||||
return self._cache
|
||||
except Exception as e:
|
||||
@@ -832,6 +853,28 @@ class RecipeScanner:
|
||||
except Exception as e:
|
||||
logger.error(f"Error sorting recipe cache: {e}")
|
||||
|
||||
def _build_image_id_map(self) -> Dict[str, str]:
|
||||
"""Build civitai image_id → recipe_id mapping from cached recipes.
|
||||
|
||||
Only recipes with a valid CivitAI image URL source_path produce an
|
||||
entry. Recipes imported from local files are naturally excluded.
|
||||
"""
|
||||
mapping: Dict[str, str] = {}
|
||||
if not self._cache:
|
||||
return mapping
|
||||
for recipe in getattr(self._cache, "raw_data", []):
|
||||
if not isinstance(recipe, dict):
|
||||
continue
|
||||
source = recipe.get("source_path")
|
||||
if not source:
|
||||
continue
|
||||
image_id = extract_civitai_image_id(source)
|
||||
if image_id and image_id not in mapping:
|
||||
recipe_id = recipe.get("id")
|
||||
if recipe_id is not None:
|
||||
mapping[image_id] = str(recipe_id)
|
||||
return mapping
|
||||
|
||||
async def _wait_for_lora_scanner(self) -> None:
|
||||
"""Ensure the LoRA scanner has initialized before recipe enrichment."""
|
||||
|
||||
@@ -1296,11 +1339,20 @@ class RecipeScanner:
|
||||
# Update FTS index
|
||||
self._update_fts_index_for_recipe(recipe_data, "add")
|
||||
|
||||
source = recipe_data.get("source_path")
|
||||
if source:
|
||||
image_id = extract_civitai_image_id(source)
|
||||
if image_id:
|
||||
recipe_id_value = recipe_data.get("id")
|
||||
if recipe_id_value is not None:
|
||||
cache.image_id_map[image_id] = str(recipe_id_value)
|
||||
|
||||
# Persist to SQLite cache
|
||||
if self._persistent_cache:
|
||||
recipe_id = str(recipe_data.get("id", ""))
|
||||
json_path = self._json_path_map.get(recipe_id, "")
|
||||
self._persistent_cache.update_recipe(recipe_data, json_path)
|
||||
self._persistent_cache.save_image_id_map(cache.image_id_map)
|
||||
|
||||
async def remove_recipe(self, recipe_id: str) -> bool:
|
||||
"""Remove a recipe from the cache by ID."""
|
||||
@@ -1319,9 +1371,15 @@ class RecipeScanner:
|
||||
# Update FTS index
|
||||
self._update_fts_index_for_recipe(recipe_id, "remove")
|
||||
|
||||
# Remove any image_id entry pointing to this recipe
|
||||
stale = [k for k, v in cache.image_id_map.items() if v == recipe_id]
|
||||
for k in stale:
|
||||
del cache.image_id_map[k]
|
||||
|
||||
# Remove from SQLite cache
|
||||
if self._persistent_cache:
|
||||
self._persistent_cache.remove_recipe(recipe_id)
|
||||
self._persistent_cache.save_image_id_map(cache.image_id_map)
|
||||
self._json_path_map.pop(recipe_id, None)
|
||||
|
||||
return True
|
||||
@@ -1332,14 +1390,21 @@ class RecipeScanner:
|
||||
cache = await self.get_cached_data()
|
||||
removed = await cache.bulk_remove(recipe_ids, resort=False)
|
||||
if removed:
|
||||
removed_ids = {str(r.get("id", "")) for r in removed}
|
||||
stale = [k for k, v in cache.image_id_map.items() if v in removed_ids]
|
||||
for k in stale:
|
||||
del cache.image_id_map[k]
|
||||
|
||||
self._schedule_resort()
|
||||
# Update FTS index and persistent cache for each removed recipe
|
||||
for recipe in removed:
|
||||
recipe_id = str(recipe.get("id", ""))
|
||||
self._update_fts_index_for_recipe(recipe_id, "remove")
|
||||
if self._persistent_cache:
|
||||
self._persistent_cache.remove_recipe(recipe_id)
|
||||
self._json_path_map.pop(recipe_id, None)
|
||||
|
||||
if self._persistent_cache:
|
||||
self._persistent_cache.save_image_id_map(cache.image_id_map)
|
||||
return len(removed)
|
||||
|
||||
async def scan_all_recipes(self) -> List[Dict]:
|
||||
|
||||
@@ -176,6 +176,24 @@ class RecipeAnalysisService:
|
||||
self._exif_utils.extract_image_metadata, temp_path
|
||||
)
|
||||
|
||||
if not metadata and civitai_image_id and image_info:
|
||||
original_url = image_info.get("url")
|
||||
if original_url:
|
||||
self._logger.debug(
|
||||
"Optimized image lacks embedded metadata, "
|
||||
"falling back to original image: %s",
|
||||
original_url,
|
||||
)
|
||||
orig_temp_path = self._create_temp_path(suffix=".png")
|
||||
try:
|
||||
await self._download_image(original_url, orig_temp_path)
|
||||
metadata = await asyncio.to_thread(
|
||||
self._exif_utils.extract_image_metadata,
|
||||
orig_temp_path,
|
||||
)
|
||||
finally:
|
||||
self._safe_cleanup(orig_temp_path)
|
||||
|
||||
result = await self._parse_metadata(
|
||||
metadata or {},
|
||||
recipe_scanner=recipe_scanner,
|
||||
|
||||
@@ -49,8 +49,18 @@ class RecipePersistenceService:
|
||||
tags: Iterable[str],
|
||||
metadata: Optional[dict[str, Any]],
|
||||
extension: str | None = None,
|
||||
recipe_id: str | None = None,
|
||||
target_dir: str | None = None,
|
||||
) -> PersistenceResult:
|
||||
"""Persist a user uploaded recipe."""
|
||||
"""Persist a user uploaded recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: If provided, reuse this ID instead of generating a new
|
||||
UUID. Used by re-import to preserve the original recipe identity.
|
||||
target_dir: If provided, save recipe files to this directory instead
|
||||
of the default recipes_dir. Used by re-import to preserve the
|
||||
original folder location.
|
||||
"""
|
||||
|
||||
missing_fields = []
|
||||
if not name:
|
||||
@@ -63,10 +73,10 @@ class RecipePersistenceService:
|
||||
)
|
||||
|
||||
resolved_image_bytes = self._resolve_image_bytes(image_bytes, image_base64)
|
||||
recipes_dir = recipe_scanner.recipes_dir
|
||||
recipes_dir = target_dir or recipe_scanner.recipes_dir
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
|
||||
recipe_id = str(uuid.uuid4())
|
||||
recipe_id = recipe_id or str(uuid.uuid4())
|
||||
|
||||
# Handle video formats by bypassing optimization and metadata embedding
|
||||
is_video = extension in [".mp4", ".webm"]
|
||||
@@ -119,6 +129,18 @@ class RecipePersistenceService:
|
||||
if nsfw_level is not None and isinstance(nsfw_level, int):
|
||||
recipe_data["preview_nsfw_level"] = nsfw_level
|
||||
|
||||
# Compute recipe folder relative to recipes root, mirroring
|
||||
# RecipeScanner._calculate_folder() which is only called during scan/load.
|
||||
if recipe_scanner.recipes_dir:
|
||||
recipe_file_dir = os.path.dirname(normalized_image_path)
|
||||
try:
|
||||
relative_folder = os.path.relpath(recipe_file_dir, recipe_scanner.recipes_dir)
|
||||
if relative_folder in (".", ""):
|
||||
relative_folder = ""
|
||||
recipe_data["folder"] = relative_folder.replace(os.path.sep, "/")
|
||||
except Exception:
|
||||
recipe_data["folder"] = ""
|
||||
|
||||
json_filename = f"{recipe_id}.recipe.json"
|
||||
json_path = os.path.join(recipes_dir, json_filename)
|
||||
json_path = os.path.normpath(json_path)
|
||||
|
||||
@@ -188,6 +188,25 @@ class ServiceRegistry:
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
async def get_download_queue_service(cls):
|
||||
"""Get or create the download queue service."""
|
||||
service_name = "download_queue_service"
|
||||
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
async with cls._get_lock(service_name):
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
from .download_queue_service import DownloadQueueService
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
cls._services[service_name] = service
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
async def get_backup_service(cls):
|
||||
"""Get or create the backup service."""
|
||||
|
||||
@@ -91,7 +91,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"autoplay_on_hover": False,
|
||||
"display_density": "default",
|
||||
"card_info_display": "always",
|
||||
"show_folder_sidebar": True,
|
||||
"include_trigger_words": False,
|
||||
"compact_mode": False,
|
||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||
@@ -106,6 +105,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"download_skip_base_models": [],
|
||||
"backup_auto_enabled": True,
|
||||
"backup_retention_count": 5,
|
||||
"use_new_license_icons": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,9 @@ class SettingsManager:
|
||||
self._template_path = (
|
||||
Path(__file__).resolve().parents[2] / "settings.json.example"
|
||||
)
|
||||
# Known placeholder value in settings.json.example; any file containing
|
||||
# this value should be treated as "not configured".
|
||||
self._TEMPLATE_PLACEHOLDER_API_KEY = "your_civitai_api_key_here"
|
||||
self.settings = self._load_settings()
|
||||
self._migrate_setting_keys()
|
||||
self._ensure_default_settings()
|
||||
@@ -165,6 +168,12 @@ class SettingsManager:
|
||||
self._original_disk_payload = copy.deepcopy(data)
|
||||
if self._matches_template_payload(data):
|
||||
self._preserve_disk_template = True
|
||||
# Clean up the template placeholder so it is not treated
|
||||
# as a real key (affects both the frontend boolean and
|
||||
# the downloader's Authorization header).
|
||||
placeholder = self._TEMPLATE_PLACEHOLDER_API_KEY
|
||||
if data.get("civitai_api_key") == placeholder:
|
||||
data["civitai_api_key"] = ""
|
||||
return data
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("Failed to parse settings.json: %s", exc)
|
||||
|
||||
@@ -36,9 +36,9 @@ class TagUpdateService:
|
||||
if isinstance(tag, str) and tag.strip():
|
||||
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
|
||||
normalized = tag.strip().lower()
|
||||
if normalized.lower() not in existing_lower:
|
||||
if normalized not in existing_lower:
|
||||
existing_tags.append(normalized)
|
||||
existing_lower.append(normalized.lower())
|
||||
existing_lower.append(normalized)
|
||||
tags_added.append(normalized)
|
||||
|
||||
metadata["tags"] = existing_tags
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
||||
|
||||
from ..metadata_sync_service import MetadataSyncService
|
||||
@@ -62,26 +63,48 @@ class BulkMetadataRefreshUseCase:
|
||||
]
|
||||
|
||||
total_to_process = len(to_process)
|
||||
initial_skipped = total_models - total_to_process # models excluded from fetch queue
|
||||
processed = 0
|
||||
success = 0
|
||||
skipped_count = initial_skipped
|
||||
handled_count = initial_skipped
|
||||
needs_resort = False
|
||||
start_time = time.monotonic()
|
||||
failures: List[Dict[str, str]] = []
|
||||
|
||||
self._service.scanner.reset_cancellation()
|
||||
|
||||
async def emit(status: str, **extra: Any) -> None:
|
||||
if progress_callback is None:
|
||||
return
|
||||
payload = {"status": status, "total": total_to_process, "processed": processed, "success": success}
|
||||
payload = {
|
||||
"status": status,
|
||||
"total": total_models,
|
||||
"processed": processed,
|
||||
"success": success,
|
||||
"failure_count": len(failures),
|
||||
"skipped_count": skipped_count,
|
||||
"handled": handled_count,
|
||||
"elapsed_seconds": int(time.monotonic() - start_time),
|
||||
}
|
||||
# Only include full failure details in terminal emits (completed,
|
||||
# cancelled, rate_limited) to avoid serializing the list on every
|
||||
# per-model progress update.
|
||||
if failures and status in ("completed", "cancelled", "rate_limited"):
|
||||
payload["failures"] = failures
|
||||
payload.update(extra)
|
||||
await progress_callback.on_progress(payload)
|
||||
|
||||
await emit("started")
|
||||
|
||||
RATE_LIMIT_ABORT_THRESHOLD = 3
|
||||
consecutive_rate_limits = 0
|
||||
|
||||
for model in to_process:
|
||||
if self._service.scanner.is_cancelled():
|
||||
self._logger.info("Bulk metadata refresh cancelled by user")
|
||||
await emit("cancelled", processed=processed, success=success)
|
||||
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
|
||||
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
|
||||
try:
|
||||
original_name = model.get("model_name")
|
||||
|
||||
@@ -101,31 +124,76 @@ class BulkMetadataRefreshUseCase:
|
||||
model["hash_status"] = "completed"
|
||||
else:
|
||||
self._logger.error(f"Failed to calculate hash for {file_path}")
|
||||
failures.append({"name": model.get("model_name", file_path or "Unknown"), "error": "Failed to calculate hash"})
|
||||
processed += 1
|
||||
handled_count += 1
|
||||
continue
|
||||
else:
|
||||
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
|
||||
skipped_count += 1
|
||||
processed += 1
|
||||
handled_count += 1
|
||||
continue
|
||||
|
||||
# Skip models without valid hash
|
||||
if not model.get("sha256"):
|
||||
self._logger.warning(f"Skipping model without hash: {file_path}")
|
||||
skipped_count += 1
|
||||
processed += 1
|
||||
handled_count += 1
|
||||
continue
|
||||
|
||||
await MetadataManager.hydrate_model_data(model)
|
||||
result, _ = await self._metadata_sync.fetch_and_update_model(
|
||||
result, error_msg = await self._metadata_sync.fetch_and_update_model(
|
||||
sha256=model["sha256"],
|
||||
file_path=model["file_path"],
|
||||
model_data=model,
|
||||
update_cache_func=self._service.scanner.update_single_model_cache,
|
||||
)
|
||||
|
||||
if not result and error_msg and "Rate limited" in error_msg:
|
||||
consecutive_rate_limits += 1
|
||||
else:
|
||||
consecutive_rate_limits = 0
|
||||
|
||||
if not result:
|
||||
current_name = model.get("model_name", file_path or "Unknown")
|
||||
failures.append({"name": current_name, "error": error_msg or "Unknown error"})
|
||||
self._logger.warning("Failed to fetch metadata for %s: %s", current_name, error_msg)
|
||||
|
||||
if consecutive_rate_limits >= RATE_LIMIT_ABORT_THRESHOLD:
|
||||
# The current model was attempted and failed due to rate limiting;
|
||||
# count it before aborting so the summary is consistent.
|
||||
processed += 1
|
||||
handled_count += 1
|
||||
self._logger.warning(
|
||||
"Bulk metadata refresh aborted: %d consecutive rate limits detected. "
|
||||
"Processed %d/%d models.",
|
||||
consecutive_rate_limits,
|
||||
processed,
|
||||
total_to_process,
|
||||
)
|
||||
await emit(
|
||||
"rate_limited",
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Rate limit detected; {total_to_process - processed} models skipped",
|
||||
"processed": processed,
|
||||
"updated": success,
|
||||
"total": total_models,
|
||||
"failures": failures,
|
||||
"failure_count": len(failures),
|
||||
"skipped_count": skipped_count,
|
||||
"elapsed_seconds": int(time.monotonic() - start_time),
|
||||
}
|
||||
|
||||
if result:
|
||||
success += 1
|
||||
if original_name != model.get("model_name"):
|
||||
needs_resort = True
|
||||
processed += 1
|
||||
handled_count += 1
|
||||
await emit(
|
||||
"processing",
|
||||
processed=processed,
|
||||
@@ -134,6 +202,9 @@ class BulkMetadataRefreshUseCase:
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - logging path
|
||||
processed += 1
|
||||
handled_count += 1
|
||||
current_name = model.get("model_name", model.get("file_path", "Unknown"))
|
||||
failures.append({"name": current_name, "error": str(exc)})
|
||||
self._logger.error(
|
||||
"Error fetching CivitAI data for %s: %s",
|
||||
model.get("file_path"),
|
||||
@@ -150,7 +221,7 @@ class BulkMetadataRefreshUseCase:
|
||||
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
|
||||
)
|
||||
|
||||
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models}
|
||||
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
|
||||
|
||||
@staticmethod
|
||||
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
|
||||
|
||||
@@ -31,6 +31,8 @@ PREVIEW_EXTENSIONS = [
|
||||
".mp4",
|
||||
".gif",
|
||||
".webm",
|
||||
".avif",
|
||||
".jxl",
|
||||
]
|
||||
|
||||
# Card preview image width
|
||||
@@ -41,7 +43,7 @@ EXAMPLE_IMAGE_WIDTH = 832
|
||||
|
||||
# Supported media extensions for example downloads
|
||||
SUPPORTED_MEDIA_EXTENSIONS = {
|
||||
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif"],
|
||||
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif", ".jxl"],
|
||||
"videos": [".mp4", ".webm"],
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,18 @@ from ..services.settings_manager import get_settings_manager
|
||||
|
||||
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
|
||||
|
||||
# Filesystem/metadata files that are never created by the example images system
|
||||
# and are safe to ignore during validation. The cleanup service only operates on
|
||||
# directories, so these files pose no data-loss risk.
|
||||
_SAFE_FILENAMES: frozenset[str] = frozenset({
|
||||
".DS_Store", # macOS folder metadata
|
||||
"Thumbs.db", # Windows thumbnail cache
|
||||
"desktop.ini", # Windows folder customization
|
||||
".localized", # macOS folder name localization
|
||||
".gitkeep", # Placeholder to keep empty dirs in git
|
||||
".gitignore", # Git ignore rules
|
||||
})
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -180,6 +192,22 @@ def is_hash_folder(name: str) -> bool:
|
||||
return bool(_HEX_PATTERN.fullmatch(name or ""))
|
||||
|
||||
|
||||
def _is_safe_ignorable_entry(item: str, item_path: str) -> bool:
|
||||
"""Return True if *item* is a harmless system/hidden file we can skip.
|
||||
|
||||
These files are never created by the example images system and are safe to
|
||||
ignore because the cleanup/delete operations only act on **directories**,
|
||||
never on individual files (other than ``.download_progress.json``).
|
||||
"""
|
||||
if item in _SAFE_FILENAMES:
|
||||
return True
|
||||
# Hide Unix hidden files (dotfiles) that are regular files,
|
||||
# since the cleanup system never deletes or moves files.
|
||||
if item.startswith(".") and os.path.isfile(item_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_valid_example_images_root(folder_path: str) -> bool:
|
||||
"""Check whether a folder looks like a dedicated example images root."""
|
||||
|
||||
@@ -190,9 +218,16 @@ def is_valid_example_images_root(folder_path: str) -> bool:
|
||||
|
||||
for item in items:
|
||||
item_path = os.path.join(folder_path, item)
|
||||
|
||||
# .download_progress.json is an expected metadata file — check before
|
||||
# the generic dotfile rule so it stays explicitly documented.
|
||||
if item == ".download_progress.json" and os.path.isfile(item_path):
|
||||
continue
|
||||
|
||||
# Skip harmless system/hidden files — cleanup only touches directories
|
||||
if _is_safe_ignorable_entry(item, item_path):
|
||||
continue
|
||||
|
||||
if os.path.isdir(item_path):
|
||||
if is_hash_folder(item):
|
||||
continue
|
||||
@@ -211,6 +246,41 @@ def is_valid_example_images_root(folder_path: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def find_non_compliant_items_in_example_images_root(folder_path: str) -> list[str]:
|
||||
"""Return the names of items that prevent *folder_path* from being a valid
|
||||
example images root, or an empty list if the folder is valid.
|
||||
|
||||
This mirrors ``is_valid_example_images_root`` but **returns** the offending
|
||||
names instead of a boolean, so callers can produce actionable error messages.
|
||||
"""
|
||||
try:
|
||||
items = os.listdir(folder_path)
|
||||
except OSError as exc:
|
||||
return [f"<cannot list directory: {exc}>"]
|
||||
|
||||
offending: list[str] = []
|
||||
|
||||
for item in items:
|
||||
item_path = os.path.join(folder_path, item)
|
||||
|
||||
# Same skip rules as is_valid_example_images_root
|
||||
if item == ".download_progress.json" and os.path.isfile(item_path):
|
||||
continue
|
||||
if _is_safe_ignorable_entry(item, item_path):
|
||||
continue
|
||||
if os.path.isdir(item_path):
|
||||
if is_hash_folder(item):
|
||||
continue
|
||||
if item == "_deleted":
|
||||
continue
|
||||
if _library_folder_has_only_hash_dirs(item_path):
|
||||
continue
|
||||
|
||||
offending.append(item)
|
||||
|
||||
return offending
|
||||
|
||||
|
||||
def _library_folder_has_only_hash_dirs(path: str) -> bool:
|
||||
"""Return True when a library subfolder only contains hash folders or metadata files."""
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ class ExampleImagesProcessor:
|
||||
return '.gif'
|
||||
elif content.startswith(b'RIFF') and b'WEBP' in content[:12]:
|
||||
return '.webp'
|
||||
elif len(content) >= 12 and content[4:8] == b'ftyp' and b'avif' in content[8:24]:
|
||||
return '.avif'
|
||||
elif content.startswith(b'\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a'):
|
||||
return '.jxl'
|
||||
elif content.startswith(b'\x00\x00\x00\x18ftypmp4') or content.startswith(b'\x00\x00\x00\x20ftypmp4'):
|
||||
return '.mp4'
|
||||
elif content.startswith(b'\x1A\x45\xDF\xA3'):
|
||||
@@ -75,6 +79,8 @@ class ExampleImagesProcessor:
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'image/avif': '.avif',
|
||||
'image/jxl': '.jxl',
|
||||
'video/mp4': '.mp4',
|
||||
'video/webm': '.webm',
|
||||
'video/quicktime': '.mov'
|
||||
|
||||
@@ -1,17 +1,125 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
from io import BytesIO
|
||||
from typing import Any, Optional
|
||||
|
||||
import piexif
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
try:
|
||||
import brotli
|
||||
_BROTLI_AVAILABLE = True
|
||||
except ImportError:
|
||||
brotli = None
|
||||
_BROTLI_AVAILABLE = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ExifUtils:
|
||||
"""Utility functions for working with EXIF data in images"""
|
||||
|
||||
@staticmethod
|
||||
def _parse_isobmff_boxes(data: bytes, offset: int = 0) -> list[dict]:
|
||||
boxes = []
|
||||
while offset + 8 <= len(data):
|
||||
size = struct.unpack('>I', data[offset:offset + 4])[0]
|
||||
box_type = data[offset + 4:offset + 8]
|
||||
if size == 0:
|
||||
break
|
||||
if size < 8 or offset + size > len(data):
|
||||
break
|
||||
box_data = data[offset + 8:offset + size]
|
||||
boxes.append({'type': box_type, 'data': box_data, 'size': size})
|
||||
offset += size
|
||||
return boxes
|
||||
|
||||
@staticmethod
|
||||
def _is_jxl_container(data: bytes) -> bool:
|
||||
if len(data) < 32:
|
||||
return False
|
||||
return (
|
||||
struct.unpack('>I', data[:4])[0] == 12
|
||||
and data[4:8] == b'JXL '
|
||||
and data[8:12] == bytes([0x0d, 0x0a, 0x87, 0x0a])
|
||||
and struct.unpack('>I', data[12:16])[0] >= 16
|
||||
and data[16:20] == b'ftyp'
|
||||
and data[20:24] == b'jxl '
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_avif_container(data: bytes) -> bool:
|
||||
if len(data) < 16:
|
||||
return False
|
||||
for box in ExifUtils._parse_isobmff_boxes(data):
|
||||
if box['type'] == b'ftyp' and b'avif' in box['data']:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Max decompressed size for brotli metadata (2 MB)
|
||||
_BROTLI_MAX_DECOMPRESSED = 2 * 1024 * 1024
|
||||
|
||||
@staticmethod
|
||||
def _extract_isobmff_brotli(image_path: str) -> Optional[dict]:
|
||||
try:
|
||||
with open(image_path, 'rb') as f:
|
||||
data = f.read()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if ExifUtils._is_jxl_container(data):
|
||||
boxes = ExifUtils._parse_isobmff_boxes(data, offset=12)
|
||||
elif ExifUtils._is_avif_container(data):
|
||||
boxes = ExifUtils._parse_isobmff_boxes(data)
|
||||
else:
|
||||
return None
|
||||
|
||||
brob = None
|
||||
for box in boxes:
|
||||
if box['type'] == b'brob':
|
||||
brob = box
|
||||
break
|
||||
if brob is None:
|
||||
return None
|
||||
|
||||
payload = brob['data']
|
||||
if payload[:4] != b'comf':
|
||||
return None
|
||||
compressed = payload[4:]
|
||||
|
||||
if _BROTLI_AVAILABLE:
|
||||
try:
|
||||
decompressed = brotli.decompress(compressed)
|
||||
if len(decompressed) > ExifUtils._BROTLI_MAX_DECOMPRESSED:
|
||||
logger.warning(
|
||||
"Brotli metadata too large (%d bytes, max %d), ignoring",
|
||||
len(decompressed),
|
||||
ExifUtils._BROTLI_MAX_DECOMPRESSED,
|
||||
)
|
||||
decompressed = None
|
||||
except Exception:
|
||||
decompressed = None
|
||||
else:
|
||||
decompressed = None
|
||||
|
||||
raw = decompressed if decompressed is not None else compressed
|
||||
try:
|
||||
meta = json.loads(raw.decode('utf-8'))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
result = {"parameters": None, "prompt": None, "workflow": None, "comment": None}
|
||||
if isinstance(meta.get("prompt"), (dict, list)):
|
||||
result["prompt"] = json.dumps(meta["prompt"])
|
||||
elif isinstance(meta.get("prompt"), str):
|
||||
result["prompt"] = meta["prompt"]
|
||||
if isinstance(meta.get("workflow"), (dict, list)):
|
||||
result["workflow"] = json.dumps(meta["workflow"])
|
||||
elif isinstance(meta.get("workflow"), str):
|
||||
result["workflow"] = meta["workflow"]
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _decode_user_comment(user_comment: Any) -> Optional[str]:
|
||||
if user_comment is None:
|
||||
@@ -43,6 +151,12 @@ class ExifUtils:
|
||||
"comment": None,
|
||||
}
|
||||
|
||||
ext = os.path.splitext(image_path)[1].lower()
|
||||
if ext in ('.avif', '.jxl'):
|
||||
brotli_meta = ExifUtils._extract_isobmff_brotli(image_path)
|
||||
if brotli_meta:
|
||||
return brotli_meta
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
info = getattr(img, "info", {}) or {}
|
||||
|
||||
@@ -149,7 +263,6 @@ class ExifUtils:
|
||||
Optional[str]: Extracted metadata or None if not found
|
||||
"""
|
||||
try:
|
||||
# Skip for video files
|
||||
if image_path:
|
||||
ext = os.path.splitext(image_path)[1].lower()
|
||||
if ext in ['.mp4', '.webm']:
|
||||
@@ -177,10 +290,9 @@ class ExifUtils:
|
||||
str: Path to the updated image
|
||||
"""
|
||||
try:
|
||||
# Skip for video files
|
||||
if image_path:
|
||||
ext = os.path.splitext(image_path)[1].lower()
|
||||
if ext in ['.mp4', '.webm']:
|
||||
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
|
||||
return image_path
|
||||
|
||||
metadata_fields = ExifUtils._load_structured_metadata(image_path)
|
||||
@@ -212,10 +324,9 @@ class ExifUtils:
|
||||
def append_recipe_metadata(image_path, recipe_data) -> str:
|
||||
"""Append recipe metadata to an image's EXIF data"""
|
||||
try:
|
||||
# Skip for video files
|
||||
if image_path:
|
||||
ext = os.path.splitext(image_path)[1].lower()
|
||||
if ext in ['.mp4', '.webm']:
|
||||
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
|
||||
return image_path
|
||||
|
||||
# First, extract existing metadata
|
||||
@@ -327,10 +438,9 @@ class ExifUtils:
|
||||
Tuple of (optimized_image_data, extension)
|
||||
"""
|
||||
try:
|
||||
# Skip for video files early if it's a file path
|
||||
if isinstance(image_data, str) and os.path.exists(image_data):
|
||||
ext = os.path.splitext(image_data)[1].lower()
|
||||
if ext in ['.mp4', '.webm']:
|
||||
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
|
||||
try:
|
||||
with open(image_data, 'rb') as f:
|
||||
return f.read(), ext
|
||||
|
||||
@@ -34,12 +34,26 @@ def _get_hash_chunk_size_bytes() -> int:
|
||||
|
||||
|
||||
async def calculate_sha256(file_path: str) -> str:
|
||||
"""Calculate SHA256 hash of a file (full file content)."""
|
||||
"""Calculate SHA256 hash of a file (full file content).
|
||||
|
||||
Uses ``posix_fadvise`` with ``POSIX_FADV_DONTNEED`` to avoid polluting the OS page
|
||||
cache — critical on WSL where cached file pages live inside the VM and are not
|
||||
accounted for in guest ``used`` memory, causing VmmemWSL to balloon.
|
||||
|
||||
On Windows/macOS where ``posix_fadvise`` is not available the hint is silently
|
||||
skipped.
|
||||
"""
|
||||
sha256_hash = hashlib.sha256()
|
||||
chunk_size = _get_hash_chunk_size_bytes()
|
||||
with open(file_path, "rb") as f:
|
||||
fd = f.fileno()
|
||||
for byte_block in iter(lambda: f.read(chunk_size), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
# Evict pages after reading so the data doesn't linger in the kernel page
|
||||
# cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth.
|
||||
# Guard against platforms (Windows, macOS) that lack posix_fadvise.
|
||||
if hasattr(os, "posix_fadvise") and hasattr(os, "POSIX_FADV_DONTNEED"):
|
||||
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
@@ -81,7 +95,7 @@ def read_safetensors_metadata(file_path: str) -> dict[str, Any]:
|
||||
return {}
|
||||
header = json.loads(header_bytes.decode("utf-8"))
|
||||
return header.get("__metadata__", {})
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error):
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error, MemoryError, Exception):
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import asyncio
|
||||
@@ -9,6 +10,7 @@ from typing import Dict, Set
|
||||
|
||||
from ..config import config
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.settings_paths import get_settings_dir
|
||||
|
||||
# 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"
|
||||
@@ -16,14 +18,18 @@ standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.en
|
||||
# Define constants locally to avoid dependency on conditional imports
|
||||
MODELS = "models"
|
||||
LORAS = "loras"
|
||||
EMBEDDINGS = "embeddings"
|
||||
PROMPTS = "prompts"
|
||||
|
||||
if not standalone_mode:
|
||||
from ..metadata_collector.metadata_registry import MetadataRegistry
|
||||
# Import constants from metadata_collector to ensure consistency, but we have fallbacks defined above
|
||||
try:
|
||||
from ..metadata_collector.constants import MODELS as _MODELS, LORAS as _LORAS
|
||||
from ..metadata_collector.constants import MODELS as _MODELS, LORAS as _LORAS, EMBEDDINGS as _EMBEDDINGS, PROMPTS as _PROMPTS
|
||||
MODELS = _MODELS
|
||||
LORAS = _LORAS
|
||||
EMBEDDINGS = _EMBEDDINGS
|
||||
PROMPTS = _PROMPTS
|
||||
except ImportError:
|
||||
pass # Use the local definitions
|
||||
|
||||
@@ -65,6 +71,7 @@ class UsageStats:
|
||||
self.stats = {
|
||||
"checkpoints": {}, # sha256 -> { total: count, history: { date: count } }
|
||||
"loras": {}, # sha256 -> { total: count, history: { date: count } }
|
||||
"embeddings": {}, # sha256 -> { total: count, history: { date: count } }
|
||||
"total_executions": 0,
|
||||
"last_save_time": 0
|
||||
}
|
||||
@@ -77,6 +84,7 @@ class UsageStats:
|
||||
|
||||
# Load existing stats if available
|
||||
self._stats_file_path = self._get_stats_file_path()
|
||||
self._migrate_from_old_location()
|
||||
self._load_stats()
|
||||
|
||||
# Save interval in seconds
|
||||
@@ -89,14 +97,38 @@ class UsageStats:
|
||||
logger.debug("Usage statistics tracker initialized")
|
||||
|
||||
def _get_stats_file_path(self) -> str:
|
||||
"""Get the path to the stats JSON file"""
|
||||
if not config.loras_roots or len(config.loras_roots) == 0:
|
||||
# If no lora roots are available, we can't save stats
|
||||
# This will be handled by the caller
|
||||
raise RuntimeError("No LoRA root directories configured. Cannot initialize usage statistics.")
|
||||
"""Get the path to the stats JSON file in the settings directory."""
|
||||
settings_dir = get_settings_dir(create=True)
|
||||
return os.path.join(settings_dir, "stats", self.STATS_FILENAME)
|
||||
|
||||
# Use the first lora root
|
||||
return os.path.join(config.loras_roots[0], self.STATS_FILENAME)
|
||||
@staticmethod
|
||||
def _get_old_stats_file_path() -> str:
|
||||
"""Get the legacy stats file path in the first lora root directory."""
|
||||
if not config.loras_roots or len(config.loras_roots) == 0:
|
||||
return ""
|
||||
return os.path.join(config.loras_roots[0], UsageStats.STATS_FILENAME)
|
||||
|
||||
def _migrate_from_old_location(self) -> None:
|
||||
"""Migrate stats file from old location (first lora root) to new location (settings_dir/stats/)."""
|
||||
new_path = self._stats_file_path
|
||||
if os.path.exists(new_path):
|
||||
return
|
||||
|
||||
old_path = self._get_old_stats_file_path()
|
||||
if not old_path or not os.path.exists(old_path):
|
||||
return
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(new_path), exist_ok=True)
|
||||
shutil.copy2(old_path, new_path)
|
||||
logger.info("Migrated usage stats from %s to %s", old_path, new_path)
|
||||
try:
|
||||
os.remove(old_path)
|
||||
logger.info("Cleaned up old stats file: %s", old_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to remove old stats file %s: %s", old_path, e)
|
||||
except Exception as e:
|
||||
logger.error("Failed to migrate usage stats from %s to %s: %s", old_path, new_path, e)
|
||||
|
||||
def _backup_old_stats(self):
|
||||
"""Backup the old stats file before conversion"""
|
||||
@@ -115,6 +147,7 @@ class UsageStats:
|
||||
new_stats = {
|
||||
"checkpoints": {},
|
||||
"loras": {},
|
||||
"embeddings": {},
|
||||
"total_executions": old_stats.get("total_executions", 0),
|
||||
"last_save_time": old_stats.get("last_save_time", time.time())
|
||||
}
|
||||
@@ -142,19 +175,25 @@ class UsageStats:
|
||||
}
|
||||
}
|
||||
|
||||
# Convert embedding stats (if present in old format)
|
||||
if "embeddings" in old_stats and isinstance(old_stats["embeddings"], dict):
|
||||
for hash_id, count in old_stats["embeddings"].items():
|
||||
new_stats["embeddings"][hash_id] = {
|
||||
"total": count,
|
||||
"history": {
|
||||
today: count
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Successfully converted stats from old format to new format with history")
|
||||
return new_stats
|
||||
|
||||
def _is_old_format(self, stats):
|
||||
"""Check if the stats are in the old format (direct count values)"""
|
||||
# Check if any lora or checkpoint entry is a direct number instead of an object
|
||||
if "loras" in stats and isinstance(stats["loras"], dict):
|
||||
for hash_id, data in stats["loras"].items():
|
||||
if isinstance(data, (int, float)):
|
||||
return True
|
||||
|
||||
if "checkpoints" in stats and isinstance(stats["checkpoints"], dict):
|
||||
for hash_id, data in stats["checkpoints"].items():
|
||||
for category in ("loras", "checkpoints", "embeddings"):
|
||||
if category in stats and isinstance(stats[category], dict):
|
||||
for hash_id, data in stats[category].items():
|
||||
if isinstance(data, (int, float)):
|
||||
return True
|
||||
|
||||
@@ -182,6 +221,9 @@ class UsageStats:
|
||||
if "loras" in loaded_stats and isinstance(loaded_stats["loras"], dict):
|
||||
self.stats["loras"] = loaded_stats["loras"]
|
||||
|
||||
if "embeddings" in loaded_stats and isinstance(loaded_stats["embeddings"], dict):
|
||||
self.stats["embeddings"] = loaded_stats["embeddings"]
|
||||
|
||||
if "total_executions" in loaded_stats:
|
||||
self.stats["total_executions"] = loaded_stats["total_executions"]
|
||||
|
||||
@@ -304,6 +346,10 @@ class UsageStats:
|
||||
if LORAS in metadata and isinstance(metadata[LORAS], dict):
|
||||
await self._process_loras(metadata[LORAS], today)
|
||||
|
||||
# Process embeddings — parse prompt text for embedding:name references
|
||||
if PROMPTS in metadata and isinstance(metadata[PROMPTS], dict):
|
||||
await self._process_embeddings(metadata[PROMPTS], today)
|
||||
|
||||
def _increment_usage_counter(self, category: str, stat_key: str, today_date: str) -> None:
|
||||
"""Increment usage counters for a resolved stats key."""
|
||||
if stat_key not in self.stats[category]:
|
||||
@@ -510,6 +556,55 @@ class UsageStats:
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing LoRA usage: {e}", exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _extract_embedding_names(prompt_text: str) -> set:
|
||||
"""Parse embedding:name references from prompt text.
|
||||
|
||||
ComfyUI's SDTokenizer resolves ``embedding:<name>`` during tokenization
|
||||
(see ``sd1_clip.py _try_get_embedding``). This mirrors the same pattern
|
||||
to extract embedding file names from the captured prompt strings.
|
||||
"""
|
||||
if not prompt_text:
|
||||
return set()
|
||||
# Matches ``embedding:name`` where name is alphanumeric plus _ . - /
|
||||
names = re.findall(r"embedding:([a-zA-Z0-9_.\-/]+)", prompt_text)
|
||||
return set(names)
|
||||
|
||||
async def _process_embeddings(self, prompts_data, today_date):
|
||||
"""Extract embedding usage from prompt texts and record it.
|
||||
|
||||
Iterates every prompt node's text field captured by the metadata
|
||||
collector, extracts ``embedding:<name>`` references, resolves each
|
||||
name to its SHA256 hash via the embedding scanner, and increments
|
||||
usage counters.
|
||||
"""
|
||||
try:
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
if not embedding_scanner:
|
||||
logger.warning("Embedding scanner not available for usage tracking")
|
||||
return
|
||||
|
||||
seen_names = set()
|
||||
for _node_id, prompt_data in prompts_data.items():
|
||||
if not isinstance(prompt_data, dict):
|
||||
continue
|
||||
for text_field in ("text", "positive_text", "negative_text"):
|
||||
text = prompt_data.get(text_field)
|
||||
if isinstance(text, str):
|
||||
seen_names.update(self._extract_embedding_names(text))
|
||||
|
||||
for emb_name in seen_names:
|
||||
emb_hash = embedding_scanner.get_hash_by_filename(emb_name)
|
||||
if emb_hash:
|
||||
self._increment_usage_counter("embeddings", emb_hash, today_date)
|
||||
else:
|
||||
logger.debug(
|
||||
"No hash found for embedding '%s', skipping usage tracking",
|
||||
emb_name,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error processing embedding usage: %s", e, exc_info=True)
|
||||
|
||||
async def get_stats(self):
|
||||
"""Get current usage statistics"""
|
||||
return self.stats
|
||||
@@ -522,6 +617,9 @@ class UsageStats:
|
||||
elif model_type == "lora":
|
||||
if sha256 in self.stats["loras"]:
|
||||
return self.stats["loras"][sha256]["total"]
|
||||
elif model_type == "embedding":
|
||||
if sha256 in self.stats["embeddings"]:
|
||||
return self.stats["embeddings"][sha256]["total"]
|
||||
return 0
|
||||
|
||||
async def process_execution(self, prompt_id):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.0.11"
|
||||
version = "1.1.4"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
aiohttp
|
||||
aiohttp-socks
|
||||
jinja2
|
||||
safetensors
|
||||
piexif
|
||||
@@ -12,3 +13,5 @@ aiosqlite
|
||||
beautifulsoup4
|
||||
platformdirs
|
||||
pyyaml
|
||||
# brotli — ISOBMFF (AVIF/JXL) metadata decompression
|
||||
brotli>=1.2.0
|
||||
|
||||
@@ -34,6 +34,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
@@ -53,10 +55,7 @@ def resolve_settings_path() -> Path:
|
||||
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
|
||||
return portable
|
||||
|
||||
config_home = os.environ.get("XDG_CONFIG_HOME")
|
||||
if config_home:
|
||||
return Path(config_home).expanduser() / APP_NAME / "settings.json"
|
||||
return Path.home() / ".config" / APP_NAME / "settings.json"
|
||||
return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
|
||||
@@ -39,6 +39,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
@@ -68,10 +70,7 @@ def resolve_settings_path() -> Path:
|
||||
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
|
||||
return portable
|
||||
|
||||
config_home = os.environ.get("XDG_CONFIG_HOME")
|
||||
if config_home:
|
||||
return Path(config_home).expanduser() / APP_NAME / "settings.json"
|
||||
return Path.home() / ".config" / APP_NAME / "settings.json"
|
||||
return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import sys
|
||||
import json
|
||||
from py.middleware.cache_middleware import cache_control
|
||||
from py.middleware.error_middleware import api_json_error
|
||||
from py.utils.settings_paths import ensure_settings_file
|
||||
|
||||
# Set environment variable to indicate standalone mode
|
||||
@@ -157,7 +158,7 @@ class StandaloneServer:
|
||||
def __init__(self):
|
||||
self.app = web.Application(
|
||||
logger=logger,
|
||||
middlewares=[cache_control],
|
||||
middlewares=[api_json_error, cache_control],
|
||||
client_max_size=256 * 1024 * 1024,
|
||||
handler_args={
|
||||
"max_field_size": HEADER_SIZE_LIMIT,
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
@import 'tokens/index.css';
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/* Disable default scrolling */
|
||||
}
|
||||
|
||||
/* 针对Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
scrollbar-color: var(--border-base) transparent;
|
||||
}
|
||||
|
||||
/* 针对Webkit browsers (Chrome, Safari等) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: var(--scrollbar-width, 8px);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@@ -24,116 +23,128 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--border-base);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-color: #ffffff;
|
||||
--text-color: #333333;
|
||||
--text-muted: #6c757d;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #e0e0e0;
|
||||
--header-height: 48px;
|
||||
|
||||
/* Color Components */
|
||||
--lora-accent-l: 68%;
|
||||
--lora-accent-c: 0.28;
|
||||
--lora-accent-h: 256;
|
||||
--lora-warning-l: 75%;
|
||||
--lora-warning-c: 0.25;
|
||||
--lora-warning-h: 80;
|
||||
--lora-success-l: 70%;
|
||||
--lora-success-c: 0.2;
|
||||
--lora-success-h: 140;
|
||||
|
||||
/* Composed Colors */
|
||||
--lora-accent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||
--lora-surface: oklch(97% 0 0 / 0.95);
|
||||
--lora-border: oklch(72% 0.03 256 / 0.45);
|
||||
--lora-text: oklch(95% 0.02 256);
|
||||
--lora-error: oklch(75% 0.32 29);
|
||||
--lora-error-bg: color-mix(in oklch, var(--lora-error) 20%, transparent);
|
||||
--lora-error-border: color-mix(in oklch, var(--lora-error) 50%, transparent);
|
||||
--lora-warning: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
--lora-success: oklch(var(--lora-success-l) var(--lora-success-c) var(--lora-success-h));
|
||||
--badge-update-bg: oklch(72% 0.2 220);
|
||||
--badge-update-text: oklch(28% 0.03 220);
|
||||
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
|
||||
--badge-skip-refresh-bg: oklch(82% 0.12 45);
|
||||
--badge-skip-refresh-text: oklch(35% 0.02 45);
|
||||
--badge-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
|
||||
|
||||
/* Spacing Scale */
|
||||
--space-1: calc(8px * 1);
|
||||
--space-2: calc(8px * 2);
|
||||
--space-3: calc(8px * 3);
|
||||
--space-4: calc(8px * 4);
|
||||
|
||||
/* Z-index Scale */
|
||||
--z-base: 10;
|
||||
--z-header: 100;
|
||||
--z-modal: 1000;
|
||||
--z-overlay: 2000;
|
||||
|
||||
/* Border Radius */
|
||||
--border-radius-base: 12px;
|
||||
--border-radius-md: 12px;
|
||||
--border-radius-sm: 8px;
|
||||
--border-radius-xs: 4px;
|
||||
|
||||
--scrollbar-width: 8px;
|
||||
/* 添加滚动条宽度变量 */
|
||||
|
||||
/* Shortcut styles */
|
||||
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12);
|
||||
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
|
||||
--shortcut-text: var(--text-color);
|
||||
--shortcut-bg: var(--color-accent-subtle);
|
||||
--shortcut-border: var(--color-accent-border);
|
||||
--shortcut-text: var(--text-primary);
|
||||
|
||||
--lora-accent-transparent: var(--color-accent-transparent);
|
||||
|
||||
/* Legacy spacing aliases: 8px base grid to match existing component usage */
|
||||
--space-1: 8px;
|
||||
--space-2: 16px;
|
||||
--space-3: 24px;
|
||||
--space-4: 32px;
|
||||
|
||||
/* Legacy border-radius aliases to match existing component usage */
|
||||
--border-radius-xs: 4px;
|
||||
--border-radius-sm: 6px;
|
||||
--border-radius-base: 8px;
|
||||
--border-radius-md: 12px;
|
||||
--border-radius-lg: 16px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-color: var(--bg-base);
|
||||
--text-color: var(--text-primary);
|
||||
--text-muted: var(--text-secondary);
|
||||
--card-bg: var(--surface-base);
|
||||
--border-color: var(--border-base);
|
||||
|
||||
--lora-accent: var(--color-accent);
|
||||
--lora-surface: var(--bg-elevated);
|
||||
--lora-border: var(--border-subtle);
|
||||
--lora-text: var(--text-primary);
|
||||
--lora-error: var(--color-error);
|
||||
--lora-error-bg: var(--color-error-bg);
|
||||
--lora-error-border: var(--color-error-border);
|
||||
--lora-warning: var(--color-warning);
|
||||
--lora-success: var(--color-success);
|
||||
|
||||
--badge-update-bg: var(--color-info-bg);
|
||||
--badge-update-text: var(--color-info-text);
|
||||
--badge-update-glow: var(--color-info-glow);
|
||||
--badge-skip-refresh-bg: var(--color-skip-refresh-bg);
|
||||
--badge-skip-refresh-text: var(--color-skip-refresh-text);
|
||||
--badge-skip-refresh-glow: var(--color-skip-refresh-glow);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-color: var(--bg-base);
|
||||
--text-color: var(--text-primary);
|
||||
--text-muted: var(--text-secondary);
|
||||
--card-bg: var(--surface-base);
|
||||
--border-color: var(--border-base);
|
||||
|
||||
--lora-accent: var(--color-accent);
|
||||
--lora-surface: var(--bg-elevated);
|
||||
--lora-border: var(--border-subtle);
|
||||
--lora-text: var(--text-primary);
|
||||
--lora-error: var(--color-error);
|
||||
--lora-error-bg: var(--color-error-bg);
|
||||
--lora-error-border: var(--color-error-border);
|
||||
--lora-warning: var(--color-warning);
|
||||
--lora-success: var(--color-success);
|
||||
|
||||
--badge-update-bg: var(--color-info-bg);
|
||||
--badge-update-text: var(--color-info-text);
|
||||
--badge-update-glow: var(--color-info-glow);
|
||||
--badge-skip-refresh-bg: var(--color-skip-refresh-bg);
|
||||
--badge-skip-refresh-text: var(--color-skip-refresh-text);
|
||||
--badge-skip-refresh-glow: var(--color-skip-refresh-glow);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
background-color: #1a1a1a !important;
|
||||
background-color: var(--bg-base) !important;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
background-color: #ffffff !important;
|
||||
background-color: var(--bg-base) !important;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-color: #1a1a1a;
|
||||
--text-color: #e0e0e0;
|
||||
--text-muted: #a0a0a0;
|
||||
--card-bg: #2d2d2d;
|
||||
--border-color: #404040;
|
||||
|
||||
--lora-accent: oklch(68% 0.28 256);
|
||||
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||
--lora-text: oklch(98% 0.02 256);
|
||||
--lora-warning: oklch(75% 0.25 80);
|
||||
/* Modified to be used with oklch() */
|
||||
--lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent);
|
||||
--lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
|
||||
--badge-update-bg: oklch(62% 0.18 220);
|
||||
--badge-update-text: oklch(98% 0.02 240);
|
||||
--badge-update-glow: oklch(62% 0.18 220 / 0.4);
|
||||
--badge-skip-refresh-bg: oklch(82% 0.12 45);
|
||||
--badge-skip-refresh-text: oklch(98% 0.02 45);
|
||||
--badge-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-body);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 0;
|
||||
/* Remove the padding-top */
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible),
|
||||
input:focus:not(:focus-visible),
|
||||
select:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-side);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
@@ -75,7 +75,7 @@
|
||||
width: 20px;
|
||||
height: 40px;
|
||||
align-self: center;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-side);
|
||||
}
|
||||
|
||||
.toggle-alphabet-bar:hover {
|
||||
@@ -99,7 +99,7 @@
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-size: 0.85em;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.letter-chip.active {
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
text-decoration: none;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
@@ -102,7 +102,7 @@
|
||||
color: white;
|
||||
border-color: var(--lora-accent);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Tertiary Action Button */
|
||||
@@ -133,7 +133,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
@@ -237,7 +237,7 @@
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
@@ -349,8 +349,8 @@
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
@@ -365,9 +365,9 @@
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--lora-accent), oklch(from var(--lora-accent) calc(l + 0.1) c h));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
background: var(--lora-accent);
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: width var(--transition-base);
|
||||
}
|
||||
|
||||
/* Progress Stats */
|
||||
@@ -389,27 +389,26 @@
|
||||
}
|
||||
|
||||
.stat-item.success {
|
||||
border-left: 3px solid #00B87A;
|
||||
border-left: 4px solid var(--color-success);
|
||||
}
|
||||
|
||||
.stat-item.failed {
|
||||
border-left: 3px solid var(--lora-error);
|
||||
border-left: 4px solid var(--color-error);
|
||||
}
|
||||
|
||||
.stat-item.skipped {
|
||||
border-left: 3px solid var(--lora-warning);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.4em;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -425,8 +424,7 @@
|
||||
}
|
||||
|
||||
.current-item-label {
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -449,27 +447,29 @@
|
||||
}
|
||||
|
||||
.results-header {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.results-icon {
|
||||
font-size: 3em;
|
||||
color: #00B87A;
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.results-icon.warning {
|
||||
color: var(--lora-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.results-icon.error {
|
||||
color: var(--lora-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.results-title {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -493,27 +493,26 @@
|
||||
}
|
||||
|
||||
.result-card.success {
|
||||
border-left: 3px solid #00B87A;
|
||||
border-left: 4px solid var(--color-success);
|
||||
}
|
||||
|
||||
.result-card.failed {
|
||||
border-left: 3px solid var(--lora-error);
|
||||
border-left: 4px solid var(--color-error);
|
||||
}
|
||||
|
||||
.result-card.skipped {
|
||||
border-left: 3px solid var(--lora-warning);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.result-value {
|
||||
font-size: 1.4em;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -527,13 +526,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
cursor: pointer;
|
||||
color: var(--lora-accent);
|
||||
font-weight: 500;
|
||||
font-weight: var(--weight-medium);
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: background 0.2s;
|
||||
transition: background var(--transition-base);
|
||||
}
|
||||
|
||||
.details-toggle:hover {
|
||||
@@ -541,7 +540,7 @@
|
||||
}
|
||||
|
||||
.details-toggle i {
|
||||
transition: transform 0.2s;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.details-toggle.expanded i {
|
||||
@@ -561,10 +560,10 @@
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.9em;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
@@ -572,28 +571,23 @@
|
||||
}
|
||||
|
||||
.result-item-status {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8em;
|
||||
font-size: var(--text-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-item-status.success {
|
||||
background: oklch(from #00B87A l c h / 0.2);
|
||||
color: #00B87A;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.result-item-status.failed {
|
||||
background: oklch(from var(--lora-error) l c h / 0.2);
|
||||
color: var(--lora-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.result-item-status.skipped {
|
||||
background: oklch(from var(--lora-warning) l c h / 0.2);
|
||||
color: var(--lora-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.result-item-info {
|
||||
@@ -610,8 +604,8 @@
|
||||
}
|
||||
|
||||
.result-item-error {
|
||||
font-size: 0.8em;
|
||||
color: var(--lora-error);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-error);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@@ -661,11 +655,11 @@
|
||||
|
||||
/* Completed State */
|
||||
.batch-progress-container.completed .progress-bar {
|
||||
background: #00B87A;
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.batch-progress-container.completed .status-icon {
|
||||
color: #00B87A;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.batch-progress-container.completed .status-icon i {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* 卡片网格布局 */
|
||||
/* Card grid layout */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */
|
||||
gap: 12px; /* Consistent gap for both row and column spacing */
|
||||
row-gap: 20px; /* Increase vertical spacing between rows */
|
||||
margin-top: var(--space-2);
|
||||
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
|
||||
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
width: 100%; /* Ensure it takes full width of container */
|
||||
max-width: 1400px; /* Base container width */
|
||||
margin-left: auto;
|
||||
@@ -19,7 +19,7 @@
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-base);
|
||||
backdrop-filter: blur(16px);
|
||||
transition: transform 160ms ease-out;
|
||||
transition: transform var(--transition-fast) ease-out, box-shadow var(--transition-fast) ease-out, border-color var(--transition-fast) ease-out;
|
||||
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
||||
max-width: 260px; /* Base size */
|
||||
min-width: 200px; /* Prevent cards from becoming too narrow */
|
||||
@@ -33,7 +33,8 @@
|
||||
|
||||
.model-card:hover {
|
||||
transform: translateY(-2px);
|
||||
background: oklch(100% 0 0 / 0.6);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.model-card:focus-visible {
|
||||
@@ -277,7 +278,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent 15%, oklch(0% 0 0 / 0.75));
|
||||
backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(var(--card-blur-amount, 8px));
|
||||
color: white;
|
||||
padding: var(--space-1);
|
||||
display: flex;
|
||||
@@ -293,7 +294,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(oklch(0% 0 0 / 0.75), transparent 85%);
|
||||
backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(var(--card-blur-amount, 8px));
|
||||
color: white;
|
||||
padding: var(--space-1);
|
||||
display: flex;
|
||||
@@ -353,21 +354,26 @@
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: var(--space-1); /* Use gap instead of margin for spacing between icons */
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
align-items: flex-end;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.card-actions i:hover {
|
||||
.card-actions i:hover,
|
||||
.card-actions i:focus-visible {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.1);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
outline: 2px solid var(--lora-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
/* Style for active favorites */
|
||||
.favorite-active {
|
||||
color: #ffc107 !important; /* Gold color for favorites */
|
||||
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
|
||||
color: var(--favorite-color) !important;
|
||||
text-shadow: 0 0 5px var(--favorite-glow);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@@ -391,14 +397,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
flex-shrink: 0; /* Prevent actions from shrinking */
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
align-items: flex-end; /* 将图标靠下对齐 */
|
||||
align-self: flex-end; /* 将整个actions容器靠下对齐 */
|
||||
}
|
||||
|
||||
.model-link {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
@@ -411,9 +409,13 @@
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.model-link a:hover {
|
||||
.model-link a:hover,
|
||||
.model-link a:focus-visible {
|
||||
opacity: 0.8;
|
||||
text-decoration: none;
|
||||
outline: 2px solid var(--lora-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
/* Updated model name to fix text cutoff issues */
|
||||
@@ -438,7 +440,7 @@
|
||||
|
||||
.base-model {
|
||||
display: inline-block;
|
||||
background: #f0f0f0;
|
||||
background: var(--surface-hover, oklch(95% 0 0));
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
margin-right: 6px;
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
position: sticky; /* Keep the sticky position */
|
||||
top: var(--space-1);
|
||||
width: 100%;
|
||||
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); /* Use accent color with low opacity */
|
||||
background-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.1); /* Use accent color with low opacity */
|
||||
color: var(--text-color);
|
||||
border-top: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3); /* Add top border with accent color */
|
||||
border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */
|
||||
border-top: 1px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.3); /* Add top border with accent color */
|
||||
border-bottom: 1px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.4); /* Make bottom border stronger */
|
||||
z-index: var(--z-overlay);
|
||||
padding: 12px 0;
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); /* Stronger shadow */
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--shadow-lg); /* Stronger shadow */
|
||||
transition: var(--transition-slow);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
.duplicates-banner i.fa-exclamation-triangle {
|
||||
font-size: 18px;
|
||||
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
}
|
||||
|
||||
.duplicates-banner .banner-actions {
|
||||
@@ -65,12 +65,12 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.duplicates-banner button.btn-exit-mode:hover {
|
||||
background-color: var(--bg-color);
|
||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -86,16 +86,16 @@
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.duplicates-banner button:hover {
|
||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
background: var(--bg-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.duplicates-banner button.btn-exit {
|
||||
@@ -117,12 +117,12 @@
|
||||
/* Duplicate groups */
|
||||
.duplicate-group {
|
||||
position: relative;
|
||||
border: 2px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
border: 2px solid oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12); /* Add subtle shadow to groups */
|
||||
box-shadow: var(--shadow-md); /* Add subtle shadow to groups */
|
||||
/* Add responsive width settings to match banner */
|
||||
max-width: 1400px;
|
||||
margin-left: auto;
|
||||
@@ -152,7 +152,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-left: 4px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); /* Add accent border on the left */
|
||||
border-left: 4px solid oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h)); /* Add accent border on the left */
|
||||
}
|
||||
|
||||
.duplicate-group-header span:last-child {
|
||||
@@ -173,17 +173,17 @@
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
box-shadow: var(--shadow-xs);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.duplicate-group-header button:hover {
|
||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
background: var(--bg-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-group-container {
|
||||
@@ -230,34 +230,34 @@
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.group-toggle-btn:hover {
|
||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h);
|
||||
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Duplicate card styling */
|
||||
.model-card.duplicate {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.model-card.duplicate:hover {
|
||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
}
|
||||
|
||||
.model-card.duplicate.latest {
|
||||
border-style: solid;
|
||||
border-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
border-color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
}
|
||||
|
||||
.model-card.duplicate-selected {
|
||||
border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
|
||||
border: 2px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.model-card .selector-checkbox {
|
||||
@@ -276,7 +276,7 @@
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||
background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
@@ -290,7 +290,7 @@
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 10px;
|
||||
z-index: 1000;
|
||||
max-width: 350px;
|
||||
@@ -328,7 +328,7 @@
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
font-weight: bold;
|
||||
word-break: break-all; /* Ensure long hashes wrap properly */
|
||||
}
|
||||
@@ -351,12 +351,12 @@
|
||||
}
|
||||
|
||||
.verification-badge.verified {
|
||||
background-color: oklch(70% 0.2 140); /* Green for verified */
|
||||
background-color: var(--color-success); /* Green for verified */
|
||||
color: white;
|
||||
}
|
||||
|
||||
.verification-badge.mismatch {
|
||||
background-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
background-color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@
|
||||
|
||||
/* Hash Mismatch Styling */
|
||||
.model-card.duplicate.hash-mismatch {
|
||||
border: 2px dashed oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
border: 2px dashed oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
opacity: 0.85;
|
||||
position: relative;
|
||||
}
|
||||
@@ -380,8 +380,8 @@
|
||||
bottom: 0;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05),
|
||||
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05) 10px,
|
||||
oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h) / 0.05),
|
||||
oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h) / 0.05) 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
);
|
||||
@@ -398,7 +398,7 @@
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px; /* Changed from right:10px to left:10px */
|
||||
background: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
background: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
@@ -417,7 +417,7 @@
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -432,12 +432,12 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-verify-hashes:hover {
|
||||
background: var(--bg-color);
|
||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@
|
||||
position: absolute;
|
||||
top: -8px; /* Moved closer to button */
|
||||
right: -8px; /* Moved closer to button */
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); /* Softer shadow */
|
||||
box-shadow: var(--shadow-sm); /* Softer shadow */
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -493,12 +493,12 @@
|
||||
cursor: help;
|
||||
font-size: 16px;
|
||||
margin-left: 8px;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.help-icon:hover {
|
||||
opacity: 1;
|
||||
color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||
color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
}
|
||||
|
||||
/* Help tooltip */
|
||||
@@ -511,7 +511,7 @@
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
z-index: var(--z-overlay);
|
||||
font-size: 0.9em;
|
||||
margin-top: 10px;
|
||||
@@ -572,16 +572,16 @@
|
||||
|
||||
/* In dark mode, add additional distinction */
|
||||
html[data-theme="dark"] .duplicates-banner {
|
||||
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.4); /* Stronger shadow in dark mode */
|
||||
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */
|
||||
box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */
|
||||
background-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.15); /* Slightly stronger background in dark mode */
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .duplicate-group {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); /* Stronger shadow in dark mode */
|
||||
box-shadow: var(--shadow-lg); /* Stronger shadow in dark mode */
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .help-tooltip {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
/* Styles for disabled controls during duplicates mode */
|
||||
@@ -598,11 +598,11 @@ html[data-theme="dark"] .help-tooltip {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
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.25);
|
||||
box-shadow: 0 0 0 2px oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.25);
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
#findDuplicatesBtn.active:hover {
|
||||
background: oklch(calc(var(--lora-accent-l) - 5%) var(--lora-accent-c) var(--lora-accent-h));
|
||||
background: oklch(calc(var(--color-accent-l) - 5%) var(--color-accent-c) var(--color-accent-h));
|
||||
}
|
||||
|
||||
@@ -7,22 +7,22 @@
|
||||
color: white;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px 10px;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
border: 1px solid var(--lora-accent);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.control-group .filter-active:hover {
|
||||
opacity: 0.92;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.control-group .filter-active:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.control-group .filter-active i.fa-filter {
|
||||
@@ -59,9 +59,9 @@
|
||||
|
||||
/* Animation for filter indicator */
|
||||
@keyframes filterPulse {
|
||||
0% { transform: scale(1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
|
||||
50% { transform: scale(1.03); box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); }
|
||||
100% { transform: scale(1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
|
||||
0% { transform: scale(1); box-shadow: var(--shadow-sm); }
|
||||
50% { transform: scale(1.03); box-shadow: var(--shadow-lg); }
|
||||
100% { transform: scale(1); box-shadow: var(--shadow-sm); }
|
||||
}
|
||||
|
||||
.filter-active.animate {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
height: 48px;
|
||||
/* Reduced height */
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
/* Slightly stronger shadow */
|
||||
}
|
||||
|
||||
@@ -134,14 +134,14 @@
|
||||
background: var(--input-bg, var(--card-bg));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm, 6px);
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||
box-shadow: var(--shadow-header);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-search .search-container:focus-within {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--lora-accent);
|
||||
box-shadow: var(--shadow-header), 0 0 0 1px var(--lora-accent);
|
||||
}
|
||||
|
||||
.header-search input {
|
||||
@@ -183,7 +183,7 @@
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-xs, 4px);
|
||||
transition: all 0.2s ease;
|
||||
transition: background-color var(--transition-base), color var(--transition-base);
|
||||
}
|
||||
|
||||
.header-search .search-options-toggle {
|
||||
@@ -191,9 +191,11 @@
|
||||
}
|
||||
|
||||
.header-search .search-options-toggle:hover,
|
||||
.header-search .search-filter-toggle:hover {
|
||||
.header-search .search-filter-toggle:hover,
|
||||
.header-search .search-filter-toggle:focus-visible {
|
||||
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||
color: var(--lora-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.header-search .filter-badge {
|
||||
@@ -269,7 +271,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: background-color var(--transition-base), color var(--transition-base), transform var(--transition-base);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -281,7 +283,6 @@
|
||||
|
||||
.theme-toggle {
|
||||
position: relative;
|
||||
/* Ensure relative positioning for the container */
|
||||
}
|
||||
|
||||
.theme-toggle .light-icon,
|
||||
@@ -291,17 +292,14 @@
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
/* Center perfectly */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* Default state shows dark icon */
|
||||
.theme-toggle .dark-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Light theme shows light icon */
|
||||
.theme-toggle.theme-light .light-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -311,7 +309,6 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Dark theme shows dark icon */
|
||||
.theme-toggle.theme-dark .dark-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -321,7 +318,6 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Auto theme shows auto icon */
|
||||
.theme-toggle.theme-auto .auto-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -331,6 +327,201 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.theme-popover {
|
||||
display: none;
|
||||
position: fixed;
|
||||
background: var(--surface-base, #ffffff);
|
||||
border: 1px solid var(--border-base, #e0e0e0);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
box-shadow: var(--shadow-xl, 0 4px 16px rgba(0, 0, 0, 0.15));
|
||||
padding: 12px;
|
||||
min-width: 220px;
|
||||
z-index: calc(var(--z-overlay) + 1);
|
||||
animation: theme-popover-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
.theme-popover.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes theme-popover-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-popover-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.theme-popover-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
}
|
||||
|
||||
.theme-popover-divider {
|
||||
height: 1px;
|
||||
background: var(--border-base, #e0e0e0);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.theme-popover-modes {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theme-mode-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 4px;
|
||||
border: 1px solid var(--border-base, #e0e0e0);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--surface-elevated, #ffffff);
|
||||
color: var(--text-primary, #333333);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: background-color var(--transition-base, 200ms ease),
|
||||
border-color var(--transition-base, 200ms ease),
|
||||
color var(--transition-base, 200ms ease);
|
||||
}
|
||||
|
||||
.theme-mode-btn i {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.theme-mode-btn:hover {
|
||||
background: var(--surface-hover, oklch(95% 0.02 256));
|
||||
border-color: var(--color-accent, oklch(68% 0.28 256));
|
||||
}
|
||||
|
||||
.theme-mode-btn.active {
|
||||
background: var(--color-accent-subtle, oklch(68% 0.28 256 / 0.12));
|
||||
border-color: var(--color-accent, oklch(68% 0.28 256));
|
||||
color: var(--color-accent, oklch(68% 0.28 256));
|
||||
}
|
||||
|
||||
.theme-popover-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theme-preset-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 4px;
|
||||
border: 1px solid var(--border-base, #e0e0e0);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--surface-elevated, #ffffff);
|
||||
color: var(--text-primary, #333333);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
transition: background-color var(--transition-base, 200ms ease),
|
||||
border-color var(--transition-base, 200ms ease),
|
||||
color var(--transition-base, 200ms ease);
|
||||
}
|
||||
|
||||
.theme-preset-btn:hover {
|
||||
background: var(--surface-hover, oklch(95% 0.02 256));
|
||||
border-color: var(--color-accent, oklch(68% 0.28 256));
|
||||
}
|
||||
|
||||
.theme-preset-btn.active {
|
||||
background: var(--color-accent-subtle, oklch(68% 0.28 256 / 0.12));
|
||||
border-color: var(--color-accent, oklch(68% 0.28 256));
|
||||
color: var(--color-accent, oklch(68% 0.28 256));
|
||||
}
|
||||
|
||||
.preset-swatch {
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-xs, 4px);
|
||||
border: 1px solid var(--border-subtle, oklch(72% 0.03 256 / 0.45));
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-base, 200ms ease),
|
||||
box-shadow var(--transition-base, 200ms ease);
|
||||
}
|
||||
|
||||
/* Solid accent colors — each swatch shows the theme's accent color directly.
|
||||
This matches the app's flat, token-driven design language instead of using
|
||||
decorative gradients that clash with the matte aesthetic. */
|
||||
|
||||
.preset-swatch-default {
|
||||
background: oklch(68% 0.28 256);
|
||||
}
|
||||
|
||||
.preset-swatch-nord {
|
||||
background: oklch(62% 0.18 213);
|
||||
}
|
||||
|
||||
.preset-swatch-midnight {
|
||||
background: oklch(52% 0.15 300);
|
||||
}
|
||||
|
||||
.preset-swatch-monokai {
|
||||
background: oklch(72% 0.24 190);
|
||||
}
|
||||
|
||||
.preset-swatch-dracula {
|
||||
background: oklch(68% 0.24 265);
|
||||
}
|
||||
|
||||
.preset-swatch-solarized {
|
||||
background: oklch(55% 0.18 175);
|
||||
}
|
||||
|
||||
.theme-preset-btn.active .preset-swatch {
|
||||
box-shadow: 0 0 0 2px var(--color-accent, oklch(68% 0.28 256));
|
||||
}
|
||||
|
||||
.theme-preset-btn:hover .preset-swatch {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* Dark mode: use each preset's dark-mode accent lightness for visibility.
|
||||
These match the --color-accent-l values from [data-theme="dark"][data-theme-preset="..."]
|
||||
in tokens/colors.css so the swatch accurately previews what the theme looks like. */
|
||||
|
||||
[data-theme="dark"] .preset-swatch-default {
|
||||
background: oklch(68% 0.28 256);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .preset-swatch-nord {
|
||||
background: oklch(68% 0.18 213);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .preset-swatch-midnight {
|
||||
background: oklch(68% 0.14 300);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .preset-swatch-monokai {
|
||||
background: oklch(72% 0.24 190);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .preset-swatch-dracula {
|
||||
background: oklch(72% 0.24 265);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .preset-swatch-solarized {
|
||||
background: oklch(60% 0.18 175);
|
||||
}
|
||||
|
||||
/* Badge styling */
|
||||
.update-badge {
|
||||
position: absolute;
|
||||
@@ -341,7 +532,7 @@
|
||||
background-color: var(--lora-error);
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--card-bg);
|
||||
transition: all 0.2s ease;
|
||||
transition: opacity var(--transition-base);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -362,13 +553,22 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: background-color var(--transition-base), color var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hamburger-menu-btn:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
.hamburger-menu-btn:hover,
|
||||
.hamburger-menu-btn:focus-visible {
|
||||
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||
color: var(--lora-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hamburger-dropdown .dropdown-item:hover,
|
||||
.hamburger-dropdown .dropdown-item:focus-visible {
|
||||
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||
color: var(--lora-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Hamburger dropdown menu */
|
||||
@@ -381,7 +581,7 @@
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm, 6px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-toast);
|
||||
padding: 0.5rem;
|
||||
min-width: 160px;
|
||||
z-index: var(--z-dropdown, 200);
|
||||
@@ -401,7 +601,7 @@
|
||||
border-radius: var(--border-radius-xs, 4px);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: background-color var(--transition-base), color var(--transition-base);
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
|
||||
.lora-item.is-early-access {
|
||||
background: rgba(0, 184, 122, 0.05);
|
||||
border-left: 4px solid #00B87A;
|
||||
border-left: 4px solid var(--color-success);
|
||||
}
|
||||
|
||||
.lora-item.missing-locally {
|
||||
@@ -310,7 +310,7 @@
|
||||
|
||||
.missing-lora-item.is-early-access {
|
||||
background: rgba(0, 184, 122, 0.05);
|
||||
border-left: 3px solid #00B87A;
|
||||
border-left: 3px solid var(--color-success);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
@@ -630,7 +630,7 @@
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 184, 122, 0.1);
|
||||
border: 1px solid #00B87A;
|
||||
border: 1px solid var(--color-success);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-2);
|
||||
@@ -646,7 +646,7 @@
|
||||
|
||||
/* Specific styling for the early access warning container in import modal */
|
||||
.early-access-warning .warning-icon {
|
||||
color: #00B87A;
|
||||
color: var(--color-success);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
@@ -757,7 +757,7 @@
|
||||
position: relative;
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
background: rgba(var(--lora-accent), 0.05);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--space-2);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.tips-header {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/* Keyboard navigation indicator and help */
|
||||
.keyboard-nav-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
cursor: help;
|
||||
transition: all 0.2s ease;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.keyboard-nav-hint:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.keyboard-nav-hint i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Tooltip styling */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip .tooltiptext {
|
||||
visibility: hidden;
|
||||
width: 240px;
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
text-align: center;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 8px;
|
||||
position: absolute;
|
||||
z-index: 9999; /* Ensure tooltip appears above cards */
|
||||
right: 120%; /* Position tooltip to the left of the icon */
|
||||
top: 50%; /* Vertically center */
|
||||
transform: translateY(-15%); /* Vertically center */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid var(--lora-border);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tooltip .tooltiptext::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%; /* Vertically center arrow */
|
||||
left: 100%; /* Arrow on the right side */
|
||||
margin-top: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent transparent var(--lora-border); /* Arrow points right */
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Keyboard shortcuts table */
|
||||
.keyboard-shortcuts {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.keyboard-shortcuts td {
|
||||
padding: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.keyboard-shortcuts td:first-child {
|
||||
font-weight: bold;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: inline-block;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.8em;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
border-radius: var(--border-radius-base);
|
||||
text-align: center;
|
||||
border: 1px solid var(--lora-border);
|
||||
width: min(400px, 90vw); /* 固定最大宽度,但保持响应式 */
|
||||
width: min(400px, 90vw);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
.loading-status {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color); /* 使用主题文本颜色 */
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -42,11 +42,11 @@
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 280px; /* 固定进度条宽度 */
|
||||
background-color: var(--lora-border); /* 使用主题边框颜色 */
|
||||
width: 280px;
|
||||
background-color: var(--lora-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto; /* 居中显示 */
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
}
|
||||
|
||||
.model-description-content code {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 0.1em 0.3em;
|
||||
|
||||
@@ -72,6 +72,10 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.modal-header-actions .license-permissions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.license-restrictions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -95,6 +99,41 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Set 2 — New style permission indicators */
|
||||
.license-permissions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.license-icon-new {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-block;
|
||||
border-radius: 4px;
|
||||
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;
|
||||
cursor: default;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.license-icon-new.allowed {
|
||||
background-color: var(--color-success, #40c057);
|
||||
outline-color: color-mix(in oklch, var(--color-success, #40c057) 30%, transparent);
|
||||
}
|
||||
|
||||
.license-icon-new.denied {
|
||||
background-color: var(--color-error, #fa5252);
|
||||
outline-color: color-mix(in oklch, var(--color-error, #fa5252) 30%, transparent);
|
||||
}
|
||||
|
||||
.license-icon-new:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
@@ -105,14 +144,14 @@
|
||||
|
||||
.info-item {
|
||||
padding: var(--space-2);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
/* 调整深色主题下的样式 */
|
||||
/* Dark theme info item styles */
|
||||
[data-theme="dark"] .info-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
@@ -140,18 +179,70 @@
|
||||
|
||||
/* Add specific styles for notes content */
|
||||
.info-item.notes .editable-field [contenteditable] {
|
||||
height: 60px; /* Keep initial modal layout stable regardless of note length */
|
||||
min-height: 60px; /* Increase height for multiple lines */
|
||||
max-height: 420px; /* Limit maximum height */
|
||||
overflow: auto; /* Enable scrolling and resize handle for long content */
|
||||
resize: vertical; /* Allow manual vertical resizing */
|
||||
white-space: pre-wrap; /* Preserve line breaks */
|
||||
line-height: 1.5; /* Improve readability */
|
||||
padding: 8px 12px; /* Slightly increase padding */
|
||||
min-height: 60px;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* Notes expand/collapse — collapsed by default; only applies when JS detects long content */
|
||||
.info-item.notes .editable-field {
|
||||
position: relative;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.info-item.notes .editable-field.collapsed {
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Gradient fade overlay hint when collapsed */
|
||||
.info-item.notes .editable-field.collapsed::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 28px;
|
||||
background: linear-gradient(transparent, var(--bg-color));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Notes header row — label left, toggle button right */
|
||||
.notes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Toggle button — icon only, inline with the label */
|
||||
.notes-toggle-btn {
|
||||
display: none; /* shown by JS when content exceeds threshold */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--lora-accent);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notes-toggle-btn:hover {
|
||||
background: rgba(66, 153, 225, 0.1);
|
||||
}
|
||||
|
||||
.notes-toggle-btn i {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@@ -219,13 +310,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */
|
||||
/* Back-to-top button pinned inside modal */
|
||||
.modal-content .back-to-top {
|
||||
position: sticky; /* 改用 sticky 定位 */
|
||||
float: right; /* 使用 float 确保按钮在右侧 */
|
||||
bottom: 20px; /* 距离底部的距离 */
|
||||
margin-right: 20px; /* 右侧间距 */
|
||||
margin-top: -56px; /* 负边距确保不占用额外空间 */
|
||||
position: sticky;
|
||||
float: right;
|
||||
bottom: 20px;
|
||||
margin-right: 20px;
|
||||
margin-top: -56px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
@@ -239,7 +330,7 @@
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s ease;
|
||||
transition: opacity var(--transition-slow), visibility var(--transition-slow), transform var(--transition-slow);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@@ -282,7 +373,7 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 合并编辑按钮样式 */
|
||||
/* Consolidated edit button styles */
|
||||
.edit-model-name-btn,
|
||||
.edit-file-name-btn,
|
||||
.edit-base-model-btn,
|
||||
@@ -295,7 +386,7 @@
|
||||
cursor: pointer;
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s ease;
|
||||
transition: opacity var(--transition-base), background-color var(--transition-base);
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
@@ -317,7 +408,7 @@
|
||||
.edit-base-model-btn:hover,
|
||||
.edit-model-description-btn:hover,
|
||||
.edit-version-name-btn:hover {
|
||||
opacity: 0.8 !important;
|
||||
opacity: 0.8;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@@ -335,7 +426,7 @@
|
||||
}
|
||||
|
||||
.base-wrapper {
|
||||
flex: 2; /* 分配更多空间给base model */
|
||||
flex: 2; /* Allocate more space to base model */
|
||||
}
|
||||
|
||||
/* Base model display and editing styles */
|
||||
@@ -378,7 +469,7 @@
|
||||
}
|
||||
|
||||
.size-wrapper span {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
@@ -395,7 +486,7 @@
|
||||
margin: 0;
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 1.5em !important;
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--text-color);
|
||||
@@ -431,7 +522,7 @@
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.95em;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
opacity: 0.7;
|
||||
position: relative;
|
||||
}
|
||||
@@ -836,18 +927,18 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 2px 10px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
max-width: fit-content;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .creator-info,
|
||||
[data-theme="dark"] .civitai-view,
|
||||
[data-theme="dark"] .modal-send-btn {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
@@ -906,14 +997,14 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.civitai-view i {
|
||||
@@ -929,18 +1020,18 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .modal-send-btn {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: calc(var(--space-1) * 0.5) var(--space-1);
|
||||
gap: var(--space-1);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.preset-tag span {
|
||||
@@ -40,7 +40,7 @@
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.preset-tag:hover {
|
||||
|
||||
@@ -111,8 +111,8 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
|
||||
transition: var(--transition-base);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -120,7 +120,7 @@
|
||||
|
||||
.media-control-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.media-control-btn.set-preview-btn:hover {
|
||||
@@ -205,7 +205,7 @@
|
||||
z-index: 5;
|
||||
max-height: 50%; /* Reduced to take less space */
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-inset-top);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -220,7 +220,7 @@
|
||||
/* Adjust to dark theme */
|
||||
[data-theme="dark"] .image-metadata-panel {
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-inset-top);
|
||||
}
|
||||
|
||||
.metadata-content {
|
||||
@@ -297,7 +297,7 @@
|
||||
|
||||
.metadata-prompt {
|
||||
color: var(--text-color);
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -312,7 +312,7 @@
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
padding: 3px;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.copy-prompt-btn:hover {
|
||||
@@ -409,7 +409,7 @@
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
transition: var(--transition-slow);
|
||||
background: var(--lora-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -455,9 +455,9 @@
|
||||
}
|
||||
|
||||
.import-formats {
|
||||
font-size: 0.8em !important;
|
||||
opacity: 0.6 !important;
|
||||
margin-top: var(--space-2) !important;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.6;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.select-files-btn {
|
||||
@@ -471,7 +471,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.select-files-btn:hover {
|
||||
@@ -481,7 +481,7 @@
|
||||
|
||||
/* For dark theme */
|
||||
[data-theme="dark"] .import-container {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
/* Setup Guidance State - When example images path is not configured */
|
||||
|
||||
@@ -17,17 +17,22 @@
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-tag-compact {
|
||||
/* Updated styles to match info-item appearance */
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Style for empty tags placeholder */
|
||||
@@ -45,7 +50,7 @@
|
||||
|
||||
/* Adjust dark theme tag styles */
|
||||
[data-theme="dark"] .model-tag-compact {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
@@ -73,14 +78,14 @@
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 10px 14px;
|
||||
max-width: 400px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-4px);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -101,7 +106,7 @@
|
||||
|
||||
.tooltip-tag {
|
||||
/* Updated styles to match info-item appearance */
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-hover);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 3px 8px;
|
||||
@@ -111,15 +116,16 @@
|
||||
|
||||
/* Adjust dark theme tooltip tag styles */
|
||||
[data-theme="dark"] .tooltip-tag {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-hover);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
/* Model Tags Edit Mode */
|
||||
.model-tags-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.edit-tags-btn {
|
||||
@@ -130,8 +136,9 @@
|
||||
cursor: pointer;
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
margin-left: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-tags-btn.visible,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/* Update Trigger Words styles */
|
||||
.info-item.trigger-words {
|
||||
padding: var(--space-2);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
/* 调整 trigger words 样式 */
|
||||
/* Trigger words styles */
|
||||
[data-theme="dark"] .info-item.trigger-words {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
background: color-mix(in oklch, var(--card-bg) 92%, var(--bg-color) 8%);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
box-shadow: var(--shadow-xs);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
|
||||
.model-version-row:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.model-version-row.is-clickable {
|
||||
@@ -186,7 +186,7 @@
|
||||
height: 88px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-hover);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
max-height: 85vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-dark-lg);
|
||||
}
|
||||
|
||||
.media-viewer-video {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px 0;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
backdrop-filter: blur(10px);
|
||||
@@ -21,9 +21,11 @@
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
.context-menu-item:hover,
|
||||
.context-menu-item:focus-visible {
|
||||
background-color: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.context-menu-separator {
|
||||
@@ -32,6 +34,12 @@
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Lighter separator between category groups (vs the full separator before destructive) */
|
||||
.context-menu-separator.menu-section-break {
|
||||
opacity: 0.4;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.context-menu-item.delete-item {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
@@ -75,7 +83,7 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 0;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
z-index: 1001;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
@@ -108,7 +116,7 @@
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-modal);
|
||||
z-index: var(--z-modal);
|
||||
width: 300px;
|
||||
display: none;
|
||||
@@ -162,7 +170,7 @@
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.nsfw-level-btn:hover {
|
||||
@@ -186,7 +194,7 @@
|
||||
max-width: 350px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
z-index: var(--z-overlay);
|
||||
display: none;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
171
static/css/components/metadata-refresh-result.css
Normal file
171
static/css/components/metadata-refresh-result.css
Normal file
@@ -0,0 +1,171 @@
|
||||
/* Metadata Refresh Result Modal — component styles only */
|
||||
|
||||
.metadata-refresh-result-modal {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.refresh-summary-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
border-left: 4px solid transparent;
|
||||
font-size: var(--text-sm);
|
||||
flex: 1;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.stat-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-card-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.stat-card-value {
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--lora-text);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.stat-card-success {
|
||||
border-left-color: var(--color-success);
|
||||
}
|
||||
|
||||
.stat-card-failure {
|
||||
border-left-color: var(--color-error);
|
||||
}
|
||||
|
||||
.stat-card-skipped {
|
||||
border-left-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.stat-card-total {
|
||||
border-left-color: var(--lora-border);
|
||||
}
|
||||
|
||||
.stat-card-time {
|
||||
border-left-color: var(--lora-border);
|
||||
}
|
||||
|
||||
.refresh-failures-section {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.refresh-failures-section h4 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-error);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.refresh-failures-section h4 i {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.failure-table-wrapper {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.failure-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.failure-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--lora-surface);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
text-align: left;
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-secondary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.failure-table td {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.failure-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.failure-table tr:hover td {
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.failure-index {
|
||||
width: 30px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.failure-name {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.failure-error {
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.refresh-success-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
background: var(--surface-subtle);
|
||||
border-left: 4px solid var(--color-success);
|
||||
color: var(--lora-text);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.refresh-success-message i {
|
||||
font-size: 1.2em;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .failure-table th {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .failure-table td {
|
||||
border-bottom-color: var(--lora-border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .failure-table tr:hover td {
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/* modal 基础样式 */
|
||||
/* Modal base styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -6,19 +6,19 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: calc(100% - var(--header-height, 48px)); /* Adjust height to exclude header */
|
||||
background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
z-index: var(--z-modal);
|
||||
overflow: auto; /* Change from hidden to auto to allow scrolling */
|
||||
}
|
||||
|
||||
/* 当模态窗口打开时,禁止body滚动 */
|
||||
/* Prevent body scroll when modal is open */
|
||||
body.modal-open {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
padding-right: var(--scrollbar-width, 0px); /* 补偿滚动条消失导致的页面偏移 */
|
||||
padding-right: var(--scrollbar-width, 0px);
|
||||
}
|
||||
|
||||
/* modal-content 样式 */
|
||||
/* Modal content styles */
|
||||
.modal-content {
|
||||
position: relative;
|
||||
max-width: 800px;
|
||||
@@ -29,12 +29,9 @@ body.modal-open {
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--lora-border);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.05);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden; /* 防止水平滚动条 */
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable both-edges; /* Reserve space to prevent layout shift when scrollbar toggles */
|
||||
}
|
||||
|
||||
@@ -42,10 +39,10 @@ body.modal-open {
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
/* 当 modal 打开时锁定 body */
|
||||
/* Lock body when modal is open */
|
||||
body.modal-open {
|
||||
overflow: hidden !important; /* 覆盖 base.css 中的 scroll */
|
||||
padding-right: var(--scrollbar-width, 8px); /* 使用滚动条宽度作为补偿 */
|
||||
overflow: hidden !important;
|
||||
padding-right: var(--scrollbar-width, 8px);
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
@@ -67,12 +64,25 @@ body.modal-open {
|
||||
}
|
||||
|
||||
.cancel-btn, .delete-btn, .exclude-btn, .confirm-btn {
|
||||
padding: 8px var(--space-2);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.95em;
|
||||
min-width: 100px;
|
||||
transition: background-color var(--transition-base), opacity var(--transition-base), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.cancel-btn:active,
|
||||
.delete-btn:active,
|
||||
.exclude-btn:active,
|
||||
.confirm-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
@@ -92,16 +102,20 @@ body.modal-open {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
.cancel-btn:hover,
|
||||
.cancel-btn:focus-visible {
|
||||
background: var(--lora-border);
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
opacity: 0.9;
|
||||
.delete-btn:hover,
|
||||
.delete-btn:focus-visible {
|
||||
background: oklch(from var(--lora-error) l c h / 85%);
|
||||
}
|
||||
|
||||
.exclude-btn:hover, .confirm-btn:hover {
|
||||
opacity: 0.9;
|
||||
.exclude-btn:hover,
|
||||
.exclude-btn:focus-visible,
|
||||
.confirm-btn:hover,
|
||||
.confirm-btn:focus-visible {
|
||||
background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%);
|
||||
}
|
||||
|
||||
@@ -121,47 +135,41 @@ body.modal-open {
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
transition: opacity var(--transition-base);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
.close:hover,
|
||||
.close:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 2px solid var(--lora-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
/* 统一各个 section 的样式 */
|
||||
/* Unified section styles */
|
||||
.support-section,
|
||||
.changelog-section,
|
||||
.update-info,
|
||||
.info-item,
|
||||
.path-preview {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
/* 深色主题统一样式 */
|
||||
/* Dark theme unified styles */
|
||||
[data-theme="dark"] .modal-content {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .support-section,
|
||||
[data-theme="dark"] .changelog-section,
|
||||
[data-theme="dark"] .update-info,
|
||||
[data-theme="dark"] .info-item,
|
||||
[data-theme="dark"] .path-preview,
|
||||
[data-theme="dark"] #bulkDownloadMissingLorasModal .bulk-download-loras-preview {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background-color: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border: none;
|
||||
@@ -171,9 +179,11 @@ body.modal-open {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
.primary-btn:hover,
|
||||
.primary-btn:focus-visible {
|
||||
background-color: oklch(from var(--lora-accent) l c h / 85%);
|
||||
color: var(--lora-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Secondary button styles */
|
||||
@@ -181,19 +191,21 @@ body.modal-open {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background-color: var(--card-bg);
|
||||
color: var (--text-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
.secondary-btn:hover,
|
||||
.secondary-btn:focus-visible {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Disabled button styles */
|
||||
@@ -244,7 +256,7 @@ button:disabled,
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background-color: var(--lora-error);
|
||||
color: white;
|
||||
border: none;
|
||||
@@ -254,25 +266,22 @@ button:disabled,
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
.danger-btn:hover,
|
||||
.danger-btn:focus-visible {
|
||||
background-color: oklch(from var(--lora-error) l c h / 85%);
|
||||
color: white;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Metadata archive status styles */
|
||||
.metadata-archive-status {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .metadata-archive-status {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.archive-status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -312,17 +321,12 @@ button:disabled,
|
||||
}
|
||||
|
||||
.backup-status {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .backup-status {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.backup-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
@@ -331,17 +335,12 @@ button:disabled,
|
||||
}
|
||||
|
||||
.backup-summary-card {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .backup-summary-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.backup-summary-label {
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
@@ -404,14 +403,9 @@ button:disabled,
|
||||
}
|
||||
|
||||
.backup-location-details {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .backup-location-details {
|
||||
border-color: var(--lora-border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.backup-location-details summary {
|
||||
@@ -442,16 +436,12 @@ button:disabled,
|
||||
max-width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-color);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .backup-location-path {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.backup-status-row {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -519,8 +509,8 @@ button:disabled,
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-preview {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
@@ -578,7 +568,7 @@ button:disabled,
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
background: oklch(from var(--lora-accent) l c h / 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
background: var(--lora-surface);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--lora-border);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.doctor-kicker {
|
||||
@@ -128,7 +127,7 @@
|
||||
|
||||
.doctor-issue-card {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
box-shadow: none;
|
||||
@@ -242,7 +241,7 @@
|
||||
|
||||
[data-theme="dark"] .doctor-hero,
|
||||
[data-theme="dark"] .doctor-issue-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border-color: var(--lora-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
background: var(--bg-color);
|
||||
margin: 1px;
|
||||
position: relative;
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
.version-item:hover {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -225,7 +225,7 @@
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
text-decoration: none;
|
||||
@@ -272,7 +272,7 @@
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.tree-expand-icon:hover {
|
||||
@@ -364,7 +364,7 @@
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.create-folder-form button.confirm {
|
||||
@@ -404,7 +404,7 @@
|
||||
.path-display {
|
||||
padding: var(--space-1);
|
||||
color: var(--text-color);
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
@@ -453,7 +453,7 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
transition: var(--transition-slow);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
@@ -465,9 +465,9 @@
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: all 0.3s ease;
|
||||
transition: var(--transition-slow);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.inline-toggle-container .toggle-switch input:checked+.toggle-slider {
|
||||
@@ -516,7 +516,7 @@
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
border: 1px solid oklch(var(--lora-accent) / 0.35);
|
||||
user-select: none;
|
||||
box-shadow: 0 1px 2px oklch(var(--lora-accent) / 0.1);
|
||||
@@ -577,13 +577,13 @@
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.file-option:hover {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.file-option.selected {
|
||||
@@ -669,3 +669,156 @@
|
||||
background: oklch(0.5 0.08 160 / 0.15);
|
||||
color: oklch(0.65 0.08 160);
|
||||
}
|
||||
|
||||
/* Textarea for multi-URL input */
|
||||
#modelUrl {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
font-size: 0.85em;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.input-hint i {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Batch Preview List */
|
||||
.batch-preview-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin: var(--space-2) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: var(--border-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.batch-preview-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.batch-preview-item:first-child {
|
||||
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
|
||||
}
|
||||
|
||||
.batch-preview-item:last-child {
|
||||
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.batch-preview-item:only-child {
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.batch-preview-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.batch-preview-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.batch-preview-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--lora-error);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.batch-preview-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-preview-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.batch-preview-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.batch-preview-error-text {
|
||||
color: var(--lora-error);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.batch-preview-local-badge {
|
||||
color: var(--lora-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.batch-preview-local {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.batch-preview-change-version {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85em;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.batch-preview-remove {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.batch-preview-remove:hover {
|
||||
opacity: 1;
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.batch-preview-error {
|
||||
background: oklch(0.5 0.15 25 / 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .batch-preview-item {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
@@ -20,12 +20,12 @@
|
||||
border: 1px solid var(--lora-border);
|
||||
background-color: var(--lora-surface);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.example-option-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
@@ -68,5 +68,5 @@
|
||||
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] .example-option-btn:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -164,7 +164,7 @@
|
||||
|
||||
/* Dark theme adjustments for new content badge */
|
||||
[data-theme="dark"] .new-content-badge {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Update video list styles */
|
||||
@@ -210,7 +210,7 @@
|
||||
margin-left: 10px;
|
||||
vertical-align: middle;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.update-date-badge i {
|
||||
@@ -225,7 +225,7 @@
|
||||
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] .update-date-badge {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Privacy-friendly video embed styles */
|
||||
@@ -281,7 +281,7 @@
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
background-color: var(--lora-accent);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
@@ -303,5 +303,5 @@
|
||||
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] .video-container {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.settings-toggle:hover {
|
||||
@@ -81,7 +81,7 @@
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.settings-search-input:focus {
|
||||
@@ -183,7 +183,7 @@
|
||||
justify-content: center;
|
||||
font-size: 0.7em;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.settings-search-clear:hover {
|
||||
@@ -289,7 +289,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
@@ -335,7 +335,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* API key input specific styles */
|
||||
/* API key input — CSS masking (prevents Chrome password manager triggers) */
|
||||
.api-key-masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
/* API key input specific styles (shared with proxy password) */
|
||||
.api-key-input {
|
||||
width: 100%; /* Take full width of parent */
|
||||
position: relative;
|
||||
@@ -345,7 +350,7 @@
|
||||
|
||||
.api-key-input input {
|
||||
width: 100%;
|
||||
padding: 6px 40px 6px 10px; /* Add left padding */
|
||||
padding: 6px 40px 6px 10px; /* Right padding for eye button */
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--border-radius-xs);
|
||||
@@ -353,6 +358,13 @@
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.api-key-input input:focus {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
||||
}
|
||||
|
||||
.api-key-input .toggle-visibility {
|
||||
@@ -364,12 +376,98 @@
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.api-key-input .toggle-visibility:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* API key item — stack status/edit views vertically for smooth cross-fade */
|
||||
.api-key-item .setting-control {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* API key status display (shown when not editing) */
|
||||
.api-key-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-key-status.is-hidden {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.api-key-status-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.95em;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Status color modifiers — replace inline styles */
|
||||
.api-key-status--configured .fa-check-circle {
|
||||
color: var(--lora-success);
|
||||
}
|
||||
|
||||
.api-key-status--unconfigured .fa-times-circle {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Utility classes for status icon colors (used by JS) */
|
||||
.text-success {
|
||||
color: var(--lora-success);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* API key inline edit container — flex row with input + buttons */
|
||||
.api-key-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-key-edit.is-hidden {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.api-key-edit .api-key-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.api-key-edit .primary-btn,
|
||||
.api-key-edit .secondary-btn {
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Text input wrapper styles for consistent input styling */
|
||||
.text-input-wrapper {
|
||||
width: 100%;
|
||||
@@ -582,7 +680,7 @@
|
||||
}
|
||||
|
||||
.priority-tags-example code {
|
||||
font-family: var(--code-font, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
|
||||
font-family: var(--font-mono);
|
||||
background-color: rgba(var(--lora-accent-rgb, 79, 70, 229), 0.12);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
@@ -614,7 +712,7 @@
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -813,6 +911,120 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Range Slider Control */
|
||||
.range-control {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.range-control input[type="range"] {
|
||||
--range-fill: 40%;
|
||||
width: 120px;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--lora-accent) 0%,
|
||||
var(--lora-accent) var(--range-fill),
|
||||
var(--border-color) var(--range-fill),
|
||||
var(--border-color) 100%
|
||||
);
|
||||
border-radius: var(--radius-full);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.range-control input[type="range"]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--lora-accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--lora-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: transform var(--transition-bounce), box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow: var(--shadow-md), 0 0 0 4px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-webkit-slider-thumb:active {
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--shadow-md), 0 0 0 6px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.range-control input[type="range"]:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--lora-accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--lora-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: transform var(--transition-bounce), box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-moz-range-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow: var(--shadow-md), 0 0 0 4px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-moz-range-thumb:active {
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--shadow-md), 0 0 0 6px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-moz-range-track {
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.range-control .range-value {
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
font-size: 0.85em;
|
||||
font-weight: 700;
|
||||
color: var(--lora-accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: var(--surface-subtle);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .range-control input[type="range"] {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--lora-accent) 0%,
|
||||
var(--lora-accent) var(--range-fill),
|
||||
rgba(255, 255, 255, 0.15) var(--range-fill),
|
||||
rgba(255, 255, 255, 0.15) 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .range-control input[type="range"]::-moz-range-track {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
@@ -927,19 +1139,19 @@ input:checked + .toggle-slider:before {
|
||||
|
||||
/* Path Template Settings Styles */
|
||||
.template-preview {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: var(--space-1);
|
||||
margin-top: 8px;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
color: var(--lora-accent);
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .template-preview {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
@@ -974,7 +1186,7 @@ input:checked + .toggle-slider:before {
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
height: 32px; /* Match other control heights */
|
||||
}
|
||||
|
||||
@@ -1030,7 +1242,7 @@ input:checked + .toggle-slider:before {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.remove-mapping-btn:hover {
|
||||
@@ -1146,7 +1358,7 @@ input:checked + .toggle-slider:before {
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -1175,7 +1387,7 @@ input:checked + .toggle-slider:before {
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
height: 24px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
@@ -1277,7 +1489,7 @@ input:checked + .toggle-slider:before {
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
font-family: var(--font-body);
|
||||
white-space: normal;
|
||||
max-width: 220px;
|
||||
width: max-content;
|
||||
@@ -1287,7 +1499,7 @@ input:checked + .toggle-slider:before {
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
@@ -1309,7 +1521,7 @@ input:checked + .toggle-slider:before {
|
||||
/* Dark theme adjustments for tooltip - Fully opaque */
|
||||
[data-theme="dark"] .info-icon[data-tooltip]::after {
|
||||
background: rgba(40, 40, 40, 0.95);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: var(--shadow-dark-lg);
|
||||
}
|
||||
|
||||
/* Extra Folder Paths - Single input layout */
|
||||
@@ -1361,7 +1573,7 @@ input:checked + .toggle-slider:before {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,8 +58,6 @@
|
||||
}
|
||||
|
||||
.support-section {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
@@ -102,7 +100,7 @@
|
||||
border-radius: var(--border-radius-sm);
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
@@ -122,14 +120,14 @@
|
||||
border-radius: var(--border-radius-sm);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.kofi-button:hover {
|
||||
background: #E04946;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Patreon button style */
|
||||
@@ -144,14 +142,14 @@
|
||||
border-radius: var(--border-radius-sm);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.patreon-button:hover {
|
||||
background: #E04946;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* QR Code section styles */
|
||||
@@ -191,7 +189,7 @@
|
||||
max-width: 80%;
|
||||
height: auto;
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--lora-border);
|
||||
aspect-ratio: 1/1; /* Ensure proper aspect ratio for the square QR code */
|
||||
}
|
||||
@@ -214,7 +212,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.support-toggle:hover {
|
||||
@@ -258,12 +256,12 @@
|
||||
color: white; /* Icon color changes to white on hover */
|
||||
}
|
||||
|
||||
/* 增强hover状态的视觉反馈 */
|
||||
/* Enhanced hover visual feedback */
|
||||
.social-link:hover,
|
||||
.update-link:hover,
|
||||
.folder-item:hover {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Supporters Section Styles */
|
||||
@@ -349,14 +347,14 @@
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 3px solid var(--lora-accent);
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.supporter-special-card:hover {
|
||||
border-color: var(--lora-accent);
|
||||
border-left-color: var(--lora-accent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-header);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
@@ -441,7 +439,7 @@
|
||||
font-size: 0.95em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.85;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
margin-top: 4px;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
border-radius: var(--border-radius-sm);
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.update-link:hover {
|
||||
@@ -171,7 +171,7 @@
|
||||
|
||||
/* Update progress styles */
|
||||
.update-progress {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
@@ -179,7 +179,7 @@
|
||||
}
|
||||
|
||||
[data-theme="dark"] .update-progress {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
@@ -234,8 +234,6 @@
|
||||
|
||||
/* Changelog section */
|
||||
.changelog-section {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
@@ -334,7 +332,7 @@
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@@ -429,7 +427,7 @@
|
||||
}
|
||||
|
||||
[data-theme="dark"] .banner-history-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.banner-history-title {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-toast);
|
||||
z-index: calc(var(--z-modal) - 1);
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
opacity: 0;
|
||||
@@ -63,13 +63,21 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
.icon-button:hover,
|
||||
.icon-button:focus-visible {
|
||||
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||
color: var(--lora-accent);
|
||||
transform: scale(1.05);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .icon-button:hover,
|
||||
[data-theme="dark"] .icon-button:focus-visible {
|
||||
background: oklch(35% 0.02 256 / 0.98);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .icon-button:hover {
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#recipeTagsContainer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recipe-modal-header h2 {
|
||||
margin: 0 0 var(--space-1);
|
||||
padding: var(--space-1);
|
||||
@@ -55,7 +59,7 @@
|
||||
padding: 4px 8px;
|
||||
margin-left: 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -95,127 +99,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-editor.tags-editor input {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* 删除不再需要的按钮样式 */
|
||||
/* Remove obsolete button styles */
|
||||
.editor-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Special styling for tags content */
|
||||
.tags-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tags-display {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.no-tags {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Recipe Tags styles */
|
||||
.recipe-tags-container {
|
||||
position: relative;
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.recipe-tags-compact {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recipe-tag-compact {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .recipe-tag-compact {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.recipe-tag-more {
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75em;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recipe-tags-tooltip {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
||||
padding: 10px 14px;
|
||||
max-width: 400px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-4px);
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.recipe-tags-tooltip.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tooltip-tag {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 3px 8px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tooltip-tag {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
#recipeModal .modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -251,19 +139,19 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .recipe-source-url-btn {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
@@ -428,7 +316,7 @@
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.source-url-cancel-btn {
|
||||
@@ -548,7 +436,7 @@
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.copy-btn:hover,
|
||||
@@ -705,7 +593,7 @@
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -725,7 +613,7 @@
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -797,7 +685,7 @@
|
||||
|
||||
.recipe-lora-item:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-header);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
@@ -995,7 +883,7 @@
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-header);
|
||||
z-index: var(--z-overlay);
|
||||
width: max-content;
|
||||
max-width: 200px;
|
||||
@@ -1049,7 +937,7 @@
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@@ -1086,7 +974,7 @@
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.reconnect-cancel-btn {
|
||||
@@ -1114,9 +1002,9 @@
|
||||
color: #777;
|
||||
}
|
||||
|
||||
/* 标题输入框特定的样式 */
|
||||
/* Title input specific styles */
|
||||
.title-input {
|
||||
font-size: 1.2em !important; /* 调整为更合适的大小 */
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -1153,7 +1041,7 @@
|
||||
max-height: 2.4em;
|
||||
}
|
||||
|
||||
.recipe-tags-container {
|
||||
#recipeTagsContainer {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@@ -1251,7 +1139,7 @@
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-header);
|
||||
z-index: var(--z-overlay);
|
||||
width: max-content;
|
||||
max-width: 200px;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 调整搜索框样式以匹配其他控件 */
|
||||
/* Match search input styles to other controls */
|
||||
.search-container input {
|
||||
width: 100%;
|
||||
padding: 6px 35px 6px 12px; /* Reduced right padding */
|
||||
@@ -35,7 +35,7 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 修改清空按钮样式 */
|
||||
/* Clear button styles */
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 105px; /* Adjusted further left to avoid overlapping */
|
||||
@@ -71,7 +71,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: background-color var(--transition-base), color var(--transition-base), border-color var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: background-color var(--transition-base), color var(--transition-base), border-color var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
@@ -149,7 +149,7 @@
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-base);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: var(--z-overlay);
|
||||
padding: 16px;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
@@ -243,7 +243,7 @@
|
||||
color: var(--text-color);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
user-select: none; /* Prevent text selection */
|
||||
-webkit-user-select: none; /* For Safari */
|
||||
-moz-user-select: none; /* For Firefox */
|
||||
@@ -373,7 +373,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -402,7 +402,7 @@
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-base);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: var(--z-overlay);
|
||||
padding: 16px;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
@@ -470,7 +470,7 @@
|
||||
color: var(--text-color);
|
||||
font-size: 13px; /* Slightly smaller font size */
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
user-select: none;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
@@ -516,7 +516,7 @@
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -574,7 +574,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -599,7 +599,7 @@
|
||||
font-size: 14px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
/* Enabled state - visual cue that button is actionable */
|
||||
@@ -726,7 +726,7 @@
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
color: var(--text-color);
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-header);
|
||||
z-index: 100; /* Higher z-index to ensure it's above other elements */
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
@@ -107,7 +107,7 @@
|
||||
color: var(--text-color);
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-header);
|
||||
z-index: 100; /* Higher z-index to ensure it's above other elements */
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
cursor: pointer;
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.metadata-edit-btn:hover {
|
||||
@@ -31,7 +31,7 @@
|
||||
/* Edit Container */
|
||||
.metadata-edit-container {
|
||||
padding: var(--space-2);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--surface-hover);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-top: var(--space-2);
|
||||
@@ -42,7 +42,7 @@
|
||||
}
|
||||
|
||||
[data-theme="dark"] .metadata-edit-container {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--surface-hover);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
}
|
||||
|
||||
.metadata-item-dragging {
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
cursor: grabbing;
|
||||
opacity: 0.95;
|
||||
transition: none;
|
||||
@@ -178,7 +178,7 @@ body.metadata-drag-active * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.metadata-edit-controls button:hover {
|
||||
@@ -257,7 +257,7 @@ body.metadata-drag-active * {
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-top: 4px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -299,7 +299,7 @@ body.metadata-drag-active * {
|
||||
justify-content: space-between;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
|
||||
@@ -8,69 +8,28 @@
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
flex-shrink: 0;
|
||||
z-index: var(--z-overlay);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-header);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(8px);
|
||||
/* Default state: hidden off-screen */
|
||||
/* Default: hidden off-screen — prevents flash before JS runs */
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.folder-sidebar.hidden-by-setting {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Visible state */
|
||||
.folder-sidebar.visible {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Auto-hide states */
|
||||
.folder-sidebar.auto-hide {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.folder-sidebar.auto-hide.hover-active {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.folder-sidebar.collapsed {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Hover detection area for auto-hide */
|
||||
.sidebar-hover-area {
|
||||
position: fixed;
|
||||
top: 68px;
|
||||
left: 0;
|
||||
width: 20px;
|
||||
height: calc(100vh - 88px);
|
||||
z-index: calc(var(--z-overlay) - 1);
|
||||
background: transparent;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.sidebar-hover-area.hidden-by-setting {
|
||||
.folder-sidebar.hidden-by-setting {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sidebar-hover-area.disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -83,7 +42,8 @@
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-header:hover {
|
||||
@@ -120,7 +80,7 @@
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
@@ -150,6 +110,75 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== Sidebar Hidden Indicator (left edge) ===== */
|
||||
.sidebar-hidden-indicator {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 68px; /* Align with sidebar header */
|
||||
z-index: var(--z-overlay);
|
||||
width: 14px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--border-color);
|
||||
opacity: 0.3;
|
||||
border-radius: 0 4px 4px 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator:hover {
|
||||
opacity: 0.7;
|
||||
background: var(--lora-accent);
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator i {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator:hover i {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Subtle breathing animation for first-time discovery */
|
||||
@keyframes sidebarBreathing {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator.breathing {
|
||||
animation: sidebarBreathing 2.5s ease-in-out infinite;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator.breathing:hover {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator-tooltip {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-left: 8px;
|
||||
padding: 4px 10px;
|
||||
background: var(--text-color);
|
||||
color: var(--bg-color);
|
||||
font-size: 0.8em;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator:hover .sidebar-hidden-indicator-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
@@ -174,7 +203,7 @@
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
font-size: 0.85em;
|
||||
border-left: 3px solid transparent;
|
||||
color: var(--text-color);
|
||||
@@ -298,7 +327,7 @@
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
color: var(--text-muted);
|
||||
position: relative;
|
||||
}
|
||||
@@ -331,7 +360,7 @@
|
||||
margin-left: 6px;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
pointer-events: none;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
@@ -364,7 +393,7 @@
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
box-shadow: 0 3px 8px rgba(0,0,0,0.15);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: calc(var(--z-overlay) + 20);
|
||||
overflow-y: auto;
|
||||
max-height: 450px;
|
||||
@@ -382,7 +411,7 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.breadcrumb-dropdown-item:hover {
|
||||
@@ -406,7 +435,7 @@
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
font-size: 0.85em;
|
||||
border-left: 3px solid transparent;
|
||||
color: var(--text-color);
|
||||
@@ -515,7 +544,7 @@
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
/* Responsive Design — Mobile: overlay when shown */
|
||||
@media (max-width: 1024px) {
|
||||
.folder-sidebar {
|
||||
top: 68px;
|
||||
@@ -526,12 +555,8 @@
|
||||
z-index: calc(var(--z-overlay) + 10);
|
||||
}
|
||||
|
||||
.folder-sidebar.collapsed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
/* Mobile overlay */
|
||||
.folder-sidebar:not(.collapsed)::before {
|
||||
/* Mobile overlay when sidebar is shown */
|
||||
.folder-sidebar.visible::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -614,7 +639,7 @@
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08);
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -649,7 +674,7 @@
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 20;
|
||||
animation: slideUp 0.2s ease;
|
||||
}
|
||||
@@ -685,7 +710,7 @@
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-input:focus {
|
||||
@@ -702,24 +727,30 @@
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-btn:hover {
|
||||
.sidebar-create-folder-btn:hover,
|
||||
.sidebar-create-folder-btn:focus-visible {
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-confirm:hover {
|
||||
.sidebar-create-folder-confirm:hover,
|
||||
.sidebar-create-folder-confirm:focus-visible {
|
||||
background: oklch(from var(--success-color) l c h / 0.15);
|
||||
color: var(--success-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-cancel:hover {
|
||||
.sidebar-create-folder-cancel:hover,
|
||||
.sidebar-create-folder-cancel:focus-visible {
|
||||
background: oklch(from var(--error-color) l c h / 0.15);
|
||||
color: var(--error-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-hint {
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--space-2);
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: var(--transition-slow);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
.metric-card .metric-icon {
|
||||
@@ -95,7 +95,7 @@
|
||||
border: none;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
transition: var(--transition-slow);
|
||||
color: var(--text-color);
|
||||
border-bottom: 3px solid transparent;
|
||||
white-space: nowrap;
|
||||
@@ -208,7 +208,7 @@
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.model-item:hover {
|
||||
@@ -270,7 +270,7 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid oklch(var(--lora-accent) / 0.2);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -349,12 +349,12 @@
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
transition: var(--transition-slow);
|
||||
}
|
||||
|
||||
.insight-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.insight-card.type-success {
|
||||
@@ -428,7 +428,7 @@
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.recommendation-item:hover {
|
||||
@@ -534,9 +534,9 @@
|
||||
}
|
||||
|
||||
[data-theme="dark"] .metric-card {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .metric-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-dark-lg);
|
||||
}
|
||||
@@ -15,18 +15,18 @@
|
||||
/* Toast Notifications */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px; /* 改为从顶部显示 */
|
||||
right: 20px; /* 改为右对齐 */
|
||||
left: auto; /* 移除左对齐 */
|
||||
transform: translateX(120%); /* 初始位置在屏幕右侧外 */
|
||||
min-width: 300px; /* 设置最小宽度 */
|
||||
max-width: 400px; /* 设置最大宽度 */
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
left: auto;
|
||||
transform: translateX(120%);
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
z-index: calc(var(--z-overlay) + 10); /* 让toast显示在最上层 */
|
||||
box-shadow: var(--shadow-toast);
|
||||
z-index: calc(var(--z-overlay) + 10);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -36,11 +36,10 @@
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(0); /* 显示时滑入到正确位置 */
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 添加图标容器 */
|
||||
.toast::before {
|
||||
content: '';
|
||||
width: 20px;
|
||||
@@ -51,7 +50,7 @@
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
/* 不同类型的toast样式 */
|
||||
/* Toast type variants */
|
||||
.toast-success {
|
||||
border-left: 4px solid oklch(65% 0.2 142);
|
||||
}
|
||||
@@ -76,15 +75,15 @@
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%232196f3'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 多个toast堆叠显示 */
|
||||
/* Stacked toast spacing */
|
||||
.toast + .toast {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.toast {
|
||||
width: calc(100% - 40px); /* 左右各留20px间距 */
|
||||
width: calc(100% - 40px);
|
||||
max-width: none;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 15px;
|
||||
padding: 0 var(--space-2);
|
||||
position: relative;
|
||||
z-index: var(--z-base);
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
z-index: calc(var(--z-header) - 1);
|
||||
background: var(--bg-color);
|
||||
padding: var(--space-1) 0;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/* Responsive container for larger screens */
|
||||
@@ -78,21 +78,23 @@
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.control-group button:hover {
|
||||
.control-group button:hover,
|
||||
.control-group button:focus-visible {
|
||||
border-color: var(--lora-accent);
|
||||
background: var(--bg-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-lg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.control-group button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.control-group button i {
|
||||
@@ -100,7 +102,8 @@
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.control-group button:hover i {
|
||||
.control-group button:hover i,
|
||||
.control-group button:focus-visible i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -131,7 +134,7 @@
|
||||
|
||||
.control-group button.favorite-filter i {
|
||||
margin-right: 4px;
|
||||
color: #ffc107;
|
||||
color: var(--favorite-color);
|
||||
}
|
||||
|
||||
.control-group button.update-filter i {
|
||||
@@ -183,7 +186,7 @@
|
||||
color: var(--shortcut-text);
|
||||
vertical-align: middle;
|
||||
opacity: 0.8;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.control-group button:hover .shortcut-key {
|
||||
@@ -219,8 +222,8 @@
|
||||
background-position: right 6px center;
|
||||
background-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
transition: var(--transition-base);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/* Style for optgroups */
|
||||
@@ -252,7 +255,7 @@
|
||||
border-color: var(--lora-accent);
|
||||
background-color: var(--bg-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.control-group select:focus {
|
||||
@@ -292,9 +295,9 @@
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s ease;
|
||||
transition: var(--transition-slow);
|
||||
z-index: var(--z-overlay);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.back-to-top.visible {
|
||||
@@ -307,7 +310,7 @@
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Prevent text selection in control and header areas */
|
||||
@@ -336,7 +339,7 @@
|
||||
.dropdown-main {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
@@ -364,7 +367,7 @@
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.dropdown-group.active .dropdown-menu {
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
border-radius: var(--border-radius-base);
|
||||
z-index: calc(var(--z-overlay) + 1);
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
transition: var(--transition-slow);
|
||||
/* Add glow effect */
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(24, 144, 255, 0.3),
|
||||
0 0 20px rgba(24, 144, 255, 0.2),
|
||||
0 0 0 2px color-mix(in oklch, var(--color-accent) 30%, transparent),
|
||||
0 0 20px color-mix(in oklch, var(--color-accent) 20%, transparent),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
z-index: calc(var(--z-overlay) + 3);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-2xl);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.onboarding-btn:hover {
|
||||
@@ -138,7 +138,7 @@
|
||||
padding: var(--space-3);
|
||||
min-width: 510px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-dark-lg);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: var(--transition-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -221,14 +221,14 @@
|
||||
@keyframes onboarding-pulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(24, 144, 255, 0.4),
|
||||
0 0 20px rgba(24, 144, 255, 0.3),
|
||||
0 0 0 2px color-mix(in oklch, var(--color-accent) 40%, transparent),
|
||||
0 0 20px color-mix(in oklch, var(--color-accent) 30%, transparent),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(24, 144, 255, 0.6),
|
||||
0 0 30px rgba(24, 144, 255, 0.4),
|
||||
0 0 0 4px color-mix(in oklch, var(--color-accent) 60%, transparent),
|
||||
0 0 30px color-mix(in oklch, var(--color-accent) 40%, transparent),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,11 @@
|
||||
@import 'components/initialization.css';
|
||||
@import 'components/progress-panel.css';
|
||||
@import 'components/duplicates.css'; /* Add duplicates component */
|
||||
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
||||
|
||||
@import 'components/statistics.css'; /* Add statistics component */
|
||||
@import 'components/sidebar.css'; /* Add sidebar component */
|
||||
@import 'components/media-viewer.css';
|
||||
@import 'components/metadata-refresh-result.css';
|
||||
|
||||
.initialization-notice {
|
||||
display: flex;
|
||||
@@ -54,7 +55,7 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 使用已有的loading-spinner样式 */
|
||||
/* Reuse existing loading-spinner styles */
|
||||
.initialization-notice .loading-spinner {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user