mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-13 09:20:14 -03:00
Compare commits
182 Commits
6850b35770
..
v1.1.7
| Author | SHA1 | Date | |
|---|---|---|---|
| bf6a614e0d | |||
| feab01cd9c | |||
| 966024e534 | |||
| 2018722cc8 | |||
| 9d85c2a44a | |||
| 03dd047e62 | |||
| 86b547c1e0 | |||
| bab9752c8b | |||
| 774cc1be86 | |||
| 234b73c8a2 | |||
| abd06c48f4 | |||
| 6ca411e4e4 | |||
| 6470021e77 | |||
| 71658ab37b | |||
| 4f016a8024 | |||
| f362ed585b | |||
| 196172624f | |||
| 316702b7ab | |||
| a7625b009f | |||
| 5d4a33c90d | |||
| 041a6b8525 | |||
| 2638109ad6 | |||
| b019326747 | |||
| 54b44131b6 | |||
| a1d948025c | |||
| a90b2514ba | |||
| cb4ad27813 | |||
| 637831248b | |||
| 00228deaaa | |||
| 2373edf73c | |||
| e0e1b804a7 | |||
| fecbe8241f | |||
| 5983eaa1ce | |||
| 07fa454f72 | |||
| 4b5aa45379 | |||
| 9a0d866be4 | |||
| 308d8f71b8 | |||
| d0e8938039 | |||
| 13ed898b6b | |||
| e1dfd1c2a6 | |||
| e3e944911b | |||
| 51c0135250 | |||
| 7b19bbb14e | |||
| 5494a70f40 | |||
| 26c9ade1c9 | |||
| 87db23825f | |||
| 8fb00998a7 | |||
| dd3aa97d0a | |||
| 8bee8f4069 | |||
| 817fe21b3e | |||
| 905c37290f | |||
| f7632a47f9 | |||
| 646f1ddfb1 | |||
| 170c8068c5 | |||
| 3494037d20 | |||
| a1fd4e150b | |||
| b22f09bd1d | |||
| 4ed9169646 | |||
| f06c60bd47 | |||
| ee8250c26c | |||
| 88349bf944 | |||
| a8adcaf023 | |||
| 63785f82b5 | |||
| cf898da193 | |||
| 3c83e78d9f | |||
| d7291f73c9 | |||
| fe90f7f9b1 | |||
| 8b344ea39f | |||
| 8348a0cef8 | |||
| 7cf785b72f | |||
| e8913f4481 | |||
| f9c3d8dc97 | |||
| 09ca91fc0e | |||
| 16f5222efd | |||
| 28e7c04b37 | |||
| 28f99c46d3 | |||
| 205194f4e6 | |||
| 402d8b07cf | |||
| 3e303ab316 | |||
| e9e8c31ad1 | |||
| 703a6a4ea0 | |||
| 283730cf38 | |||
| 20417797e8 | |||
| 004c69b9ef | |||
| 47fe2d3783 | |||
| 36ef840a22 | |||
| 09c2445ac9 | |||
| 8a6d23f9c7 | |||
| 3d207b6744 | |||
| b3edda62ad | |||
| a429e6b1c3 | |||
| c1bf9c6221 | |||
| 75fffc1e25 | |||
| f264bab65c | |||
| 154fcd803b | |||
| 4ef32d3a96 | |||
| d2d109a69c | |||
| 3a2941d751 | |||
| 0ac10dfd42 | |||
| 9c95856b2f | |||
| 5ce4667d32 | |||
| be53fda6df | |||
| f48de05102 | |||
| 93ad81ed87 | |||
| ea14d211be | |||
| 8052cefd46 | |||
| 845815b9b7 | |||
| 609dc5d783 | |||
| 7a71b34b54 | |||
| 71a459422f | |||
| cd2628a0ee | |||
| 85da7175bc | |||
| d3bf0a164b | |||
| afb6ca1b8d | |||
| 94f43426d7 | |||
| 2b361f4f5d | |||
| 7438072f8c | |||
| 26c54fd358 | |||
| 7cb6b04c63 | |||
| fc29cde82a | |||
| 559ca946dc | |||
| 2b8e7c7504 | |||
| 6816d75933 | |||
| b58abbad7c | |||
| 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 |
@@ -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
|
||||
+15
-1
@@ -7,17 +7,24 @@ py/run_test.py
|
||||
.vscode/
|
||||
cache/
|
||||
civitai/
|
||||
stats/
|
||||
wildcards/
|
||||
backups/
|
||||
logs/
|
||||
node_modules/
|
||||
coverage/
|
||||
.coverage
|
||||
model_cache/
|
||||
|
||||
# agent
|
||||
# agent / dev tooling
|
||||
.opencode/
|
||||
.claude/
|
||||
.sisyphus/
|
||||
.codex
|
||||
.omo
|
||||
reasonix.toml
|
||||
.reasonix/
|
||||
.codegraph/
|
||||
|
||||
# Vue widgets development cache (but keep build output)
|
||||
vue-widgets/node_modules/
|
||||
@@ -26,3 +33,10 @@ vue-widgets/dist/
|
||||
|
||||
# Hypothesis test cache
|
||||
.hypothesis/
|
||||
|
||||
# Working/research notes (not committed)
|
||||
.docs/
|
||||
|
||||
# HF enrichment validation baseline snapshots (contain potentially
|
||||
# NSFW README content fetched from community model repos)
|
||||
tests/enrich_hf_validation/baselines/
|
||||
|
||||
+328
-282
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
# Agent Skills System
|
||||
|
||||
The LoRA Manager agent skills system enables LLM-powered metadata enrichment and other AI-driven tasks. Users configure their own LLM provider (BYOK), and skills are executed through right-click context menu actions.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ LoRA Manager Backend │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌────────────────┐ │
|
||||
│ │ LLMService │───▶│ LLM Provider │ │
|
||||
│ │ (BYOK config, │◀───│ (OpenAI/Ollama │ │
|
||||
│ │ API calls) │ │ /custom) │ │
|
||||
│ └───────┬───────┘ └────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼───────────────────────┐ │
|
||||
│ │ AgentService │ │
|
||||
│ │ (orchestration: validate │ │
|
||||
│ │ → LLM call → post-process │ │
|
||||
│ │ → WebSocket broadcast) │ │
|
||||
│ └───────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼───────────────────────┐ │
|
||||
│ │ SkillRegistry │ │
|
||||
│ │ ┌─────────────────────────┐ │ │
|
||||
│ │ │ enrich_hf_metadata: │ │ │
|
||||
│ │ │ - skill.yaml │ │ │
|
||||
│ │ │ - prompt.md │ │ │
|
||||
│ │ │ - handler.py │ │ │
|
||||
│ │ └─────────────────────────┘ │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Design Principle
|
||||
|
||||
**Skills define *what* to do (prompt + post-processing). The AgentService handles *how* (LLM calls, validation, progress).**
|
||||
|
||||
Skills never call the LLM directly. This keeps BYOK configuration centralized and provider-agnostic.
|
||||
|
||||
## BYOK Configuration
|
||||
|
||||
Users configure their LLM provider in **Settings → AI Provider**:
|
||||
|
||||
| Setting | Description | Example |
|
||||
|---|---|---|
|
||||
| `llm_provider` | Provider type | `openai`, `ollama`, or `custom` |
|
||||
| `llm_api_key` | API key (not needed for local Ollama) | `sk-...` |
|
||||
| `llm_api_base` | Custom API base URL (empty = provider default) | `https://api.openai.com/v1` |
|
||||
| `llm_model` | Model name | `gpt-4o-mini` |
|
||||
|
||||
Environment variable overrides: `LLM_API_KEY`, `LLM_MODEL`, `LLM_API_BASE`, `LLM_PROVIDER`.
|
||||
|
||||
### Supported Providers
|
||||
|
||||
- **OpenAI**: Uses `https://api.openai.com/v1` by default
|
||||
- **Ollama** (local): Uses `http://localhost:11434/v1`, no API key required
|
||||
- **Custom**: Any OpenAI-compatible endpoint (vLLM, LM Studio, etc.) — set `llm_api_base` explicitly
|
||||
|
||||
## Available Skills
|
||||
|
||||
### enrich_hf_metadata
|
||||
|
||||
Enriches HuggingFace-downloaded models with metadata extracted by an LLM from the HF model card.
|
||||
|
||||
**Entry point**: Right-click context menu → "Enrich Metadata (Agent)"
|
||||
|
||||
**What it does**:
|
||||
1. Reads the model's `.metadata.json` to get the `hf_url`
|
||||
2. Fetches the README.md from the HuggingFace repository
|
||||
3. Sends the README + local metadata to the LLM for structured extraction
|
||||
4. Writes extracted fields to `.metadata.json`:
|
||||
- `base_model` — only if current value is empty
|
||||
- `trainedWords` — trigger words (LoRA only, if none exist)
|
||||
- `modelDescription` — concise summary (if none exists)
|
||||
- `tags` — merged with existing tags, deduplicated
|
||||
- `metadata_source` — audit trail: `agent:enrich_hf_metadata`
|
||||
- `llm_enriched_at` — ISO timestamp
|
||||
5. Downloads and optimizes preview image (if LLM found one in the README)
|
||||
6. Updates the scanner cache
|
||||
7. Broadcasts WebSocket progress events
|
||||
|
||||
**Model types**: LoRA, Checkpoint, Embedding
|
||||
|
||||
## Adding a New Skill
|
||||
|
||||
### 1. Create the skill directory
|
||||
|
||||
```
|
||||
py/services/agent/skills/<skill_name>/
|
||||
├── skill.yaml # Skill metadata and schemas
|
||||
├── prompt.md # LLM prompt template
|
||||
└── handler.py # Pre-processing and post-processing
|
||||
```
|
||||
|
||||
### 2. Write skill.yaml
|
||||
|
||||
```yaml
|
||||
name: my_skill
|
||||
title: "My Skill"
|
||||
description: "What this skill does"
|
||||
llm_required: true
|
||||
model_type_filter: ["lora"] # or null for all types
|
||||
input_schema:
|
||||
type: object
|
||||
properties:
|
||||
model_paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- model_paths
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
# ... JSON schema for LLM output
|
||||
permissions:
|
||||
write_metadata: true
|
||||
write_previews: false
|
||||
network_domains:
|
||||
- "example.com"
|
||||
```
|
||||
|
||||
### 3. Write prompt.md
|
||||
|
||||
Use `{{variable}}` placeholders that will be replaced with data from the `prepare` function:
|
||||
|
||||
```markdown
|
||||
You are an expert assistant...
|
||||
|
||||
Model URL: {{hf_url}}
|
||||
README content:
|
||||
{{readme_content}}
|
||||
|
||||
Current metadata:
|
||||
{{current_metadata}}
|
||||
```
|
||||
|
||||
### 4. Write handler.py
|
||||
|
||||
```python
|
||||
async def prepare(model_path: str, input_data: dict) -> dict:
|
||||
"""Gather context for the LLM prompt. Returns variables for template rendering."""
|
||||
return {
|
||||
"model_path": model_path,
|
||||
# ... other variables used in prompt.md
|
||||
}
|
||||
|
||||
async def post_process(context) -> dict:
|
||||
"""Apply the LLM-extracted data to the model."""
|
||||
llm_response = context.llm_response
|
||||
# ... write metadata, download previews, update cache
|
||||
return {
|
||||
"success": True,
|
||||
"updated_fields": ["base_model", "tags"],
|
||||
"errors": [],
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: Use absolute imports (`from py.utils.metadata_manager import MetadataManager`) because skills are loaded via `importlib.util.spec_from_file_location`, which doesn't support relative imports.
|
||||
|
||||
### 5. Test
|
||||
|
||||
The skill is automatically discovered by `SkillRegistry` on startup. Test with:
|
||||
|
||||
```python
|
||||
pytest tests/services/test_agent_service.py
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/lm/agent/skills` | List available skills |
|
||||
| POST | `/api/lm/agent/execute/{skill_name}` | Execute a skill (body: `{"model_paths": [...]}`) |
|
||||
| POST | `/api/lm/agent/cancel` | Cancel running skill (stub) |
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
| Type | When | Key fields |
|
||||
|---|---|---|
|
||||
| `agent_progress` | Skill started/processing | `skill`, `status`, `total`, `processed`, `success`, `current_path` |
|
||||
| `agent_progress` | Skill completed | `skill`, `status`, `updated_models`, `errors`, `summary` |
|
||||
| `agent_progress` | Skill error | `skill`, `status`, `error` |
|
||||
|
||||
## Security Model
|
||||
|
||||
Skills declare permissions in `skill.yaml`:
|
||||
- `write_metadata` — can write `.metadata.json` files
|
||||
- `write_previews` — can download/replace preview images
|
||||
- `network_domains` — allowed domains for HTTP requests
|
||||
|
||||
These are declarative constraints checked by `AgentService`. They are defense-in-depth, not a sandbox — the Python process can technically do anything, but the contract is clear and auditable.
|
||||
|
||||
## File Locations
|
||||
|
||||
| Component | Path |
|
||||
|---|---|
|
||||
| LLMService | `py/services/llm_service.py` |
|
||||
| AgentService | `py/services/agent/agent_service.py` |
|
||||
| SkillRegistry | `py/services/agent/skill_registry.py` |
|
||||
| SkillDefinition | `py/services/agent/skill_definition.py` |
|
||||
| Skills directory | `py/services/agent/skills/` |
|
||||
| Route handlers | `py/routes/handlers/agent_handlers.py` |
|
||||
| Frontend manager | `static/js/managers/AgentManager.js` |
|
||||
| Settings UI | `templates/components/modals/settings_modal.html` |
|
||||
| Context menu | `templates/components/context_menu.html` |
|
||||
+213
-37
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"status": {
|
||||
"loading": "Wird geladen...",
|
||||
"cancelling": "Abbrechen...",
|
||||
"unknown": "Unbekannt",
|
||||
"date": "Datum",
|
||||
"version": "Version",
|
||||
@@ -104,6 +105,7 @@
|
||||
"removeFromFavorites": "Aus Favoriten entfernen",
|
||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||
"notAvailableFromCivitai": "Nicht auf Civitai verfügbar",
|
||||
"viewOnHuggingFace": "Auf Hugging Face ansehen",
|
||||
"sendToWorkflow": "An ComfyUI senden (Klick: Anhängen, Shift+Klick: Ersetzen)",
|
||||
"copyLoRASyntax": "LoRA-Syntax kopieren",
|
||||
"checkpointNameCopied": "Checkpoint-Name kopiert",
|
||||
@@ -144,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Verwendungsanzahl"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} Versionen",
|
||||
"viewAllVersions": "Alle lokalen Versionen anzeigen"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -182,6 +188,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Ausgeschlossene Modelle verwalten"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Nach Modell gruppieren"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -194,13 +203,7 @@
|
||||
"statistics": "Statistiken"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Suchen...",
|
||||
"placeholders": {
|
||||
"loras": "LoRAs suchen...",
|
||||
"recipes": "Rezepte suchen...",
|
||||
"checkpoints": "Checkpoints suchen...",
|
||||
"embeddings": "Embeddings suchen..."
|
||||
},
|
||||
"placeholder": "Suchen",
|
||||
"options": "Suchoptionen",
|
||||
"searchIn": "Suchen in:",
|
||||
"notAvailable": "Suche auf Statistikseite nicht verfügbar",
|
||||
@@ -250,7 +253,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",
|
||||
@@ -262,6 +276,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.",
|
||||
@@ -302,6 +319,7 @@
|
||||
"downloads": "Downloads",
|
||||
"videoSettings": "Video-Einstellungen",
|
||||
"layoutSettings": "Layout-Einstellungen",
|
||||
"licenseIcons": "Lizenzsymbole",
|
||||
"misc": "Verschiedenes",
|
||||
"backup": "Backups",
|
||||
"folderSettings": "Standard-Roots",
|
||||
@@ -309,7 +327,7 @@
|
||||
"extraFolderPaths": "Zusätzliche Ordnerpfade",
|
||||
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
||||
"priorityTags": "Prioritäts-Tags",
|
||||
"updateFlags": "Update-Markierungen",
|
||||
"versionScope": "Update-Markierungen",
|
||||
"exampleImages": "Beispielbilder",
|
||||
"autoOrganize": "Auto-Organisierung",
|
||||
"metadata": "Metadaten",
|
||||
@@ -414,6 +432,8 @@
|
||||
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Nach Modell gruppieren",
|
||||
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
|
||||
"displayDensity": "Anzeige-Dichte",
|
||||
"displayDensityOptions": {
|
||||
"default": "Standard",
|
||||
@@ -448,7 +468,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",
|
||||
@@ -483,7 +505,9 @@
|
||||
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
|
||||
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Dieser Pfad ist bereits konfiguriert"
|
||||
"duplicatePath": "Dieser Pfad ist bereits konfiguriert",
|
||||
"checkpointUnetOverlap": "Derselbe Pfad kann nicht für Checkpoints und Diffusionsmodelle verwendet werden: {paths}",
|
||||
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -568,7 +592,7 @@
|
||||
"download": "Herunterladen",
|
||||
"restartRequired": "Neustart erforderlich"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Strategie für Update-Markierungen",
|
||||
"help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.",
|
||||
"options": {
|
||||
@@ -580,6 +604,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",
|
||||
@@ -631,6 +659,32 @@
|
||||
"proxyPassword": "Passwort (optional)",
|
||||
"proxyPasswordPlaceholder": "passwort",
|
||||
"proxyPasswordHelp": "Passwort für die Proxy-Authentifizierung (falls erforderlich)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "KI-Anbieter",
|
||||
"provider": "Anbieter",
|
||||
"providerHelp": "Wählen Sie Ihren LLM-Anbieter. OpenAI und Ollama verwenden voreingestellte API-Endpunkte. Mit \"Benutzerdefiniert\" können Sie jeden OpenAI-kompatiblen Endpunkt angeben.",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama (lokal)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "Benutzerdefiniert (OpenAI-kompatibel)"
|
||||
},
|
||||
"apiBase": "API-Basis-URL",
|
||||
"apiBaseHelp": "Die Basis-URL für die LLM-API (z.B. https://api.openai.com/v1). Leer lassen, um die Anbietervoreinstellung zu verwenden.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "API-Schlüssel",
|
||||
"apiKeyHelp": "Ihr LLM-API-Schlüssel. Wird lokal gespeichert und niemals an einen anderen Server außer Ihrem gewählten LLM-Anbieter gesendet.",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "Nicht festgelegt",
|
||||
"apiKeyConfigured": "Konfiguriert",
|
||||
"apiKeySet": "Einrichten",
|
||||
"model": "Modell",
|
||||
"modelHelp": "Der zu verwendende Modellname (z.B. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Prüfen Sie Ihren Anbieter auf verfügbare Modelle.",
|
||||
"modelPlaceholder": "Modell auswählen..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -648,7 +702,11 @@
|
||||
"sizeAsc": "Kleinste",
|
||||
"usage": "Anzahl Nutzung",
|
||||
"usageDesc": "Meiste",
|
||||
"usageAsc": "Wenigste"
|
||||
"usageAsc": "Wenigste",
|
||||
"versionsCount": "Lokale Versionen",
|
||||
"versionsCountDesc": "Meiste Versionen zuerst",
|
||||
"versionsCountAsc": "Wenigste Versionen zuerst",
|
||||
"versionIdDesc": "Neueste Version zuerst"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Modelliste aktualisieren",
|
||||
@@ -724,12 +782,15 @@
|
||||
"completed": "Abgeschlossen: {success} verschoben, {skipped} übersprungen, {failures} fehlgeschlagen",
|
||||
"complete": "Automatische Organisation abgeschlossen",
|
||||
"error": "Fehler: {error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "HF-Metadaten mit KI anreichern"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitai-Daten aktualisieren",
|
||||
"checkUpdates": "Updates prüfen",
|
||||
"relinkCivitai": "Mit Civitai neu verknüpfen",
|
||||
"linkModel": "Modell verknüpfen",
|
||||
"linkCivitai": "Mit Civitai neu verknüpfen",
|
||||
"linkHuggingFace": "Mit HuggingFace verknüpfen",
|
||||
"copySyntax": "LoRA-Syntax kopieren",
|
||||
"copyFilename": "Modell-Dateiname kopieren",
|
||||
"copyRecipeSyntax": "Rezept-Syntax kopieren",
|
||||
@@ -748,7 +809,8 @@
|
||||
"shareRecipe": "Rezept teilen",
|
||||
"viewAllLoras": "Alle LoRAs anzeigen",
|
||||
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||
"deleteRecipe": "Rezept löschen"
|
||||
"deleteRecipe": "Rezept löschen",
|
||||
"enrichHfAgent": "HF-Metadaten mit KI anreichern"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -953,10 +1015,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Stammverzeichnis",
|
||||
"moreOptions": "Weitere Optionen",
|
||||
"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",
|
||||
@@ -997,6 +1056,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",
|
||||
@@ -1014,13 +1085,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": {
|
||||
@@ -1032,7 +1167,10 @@
|
||||
"titleWithType": "{type} von URL herunterladen",
|
||||
"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.",
|
||||
"urlHint": "Geben Sie eine CivitAI-, CivArchive- oder Hugging Face-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
|
||||
"selectHfFiles": "Datei(en) zum Herunterladen aus diesem Repository auswählen:",
|
||||
"selectAll": "Alle auswählen",
|
||||
"fetchingRepoFiles": "Repository-Dateien werden abgerufen...",
|
||||
"locationPreview": "Download-Speicherort Vorschau",
|
||||
"useDefaultPath": "Standardpfad verwenden",
|
||||
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
|
||||
@@ -1061,13 +1199,17 @@
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Ungültiges Civitai URL-Format",
|
||||
"noVersions": "Keine Versionen für dieses Modell verfügbar"
|
||||
"noVersions": "Keine Versionen für dieses Modell verfügbar",
|
||||
"mixedSources": "CivitAI- und Hugging Face-URLs können nicht in derselben Charge gemischt werden.",
|
||||
"noModelFiles": "In diesem Repository wurden keine Modelldateien gefunden."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "Download wird vorbereitet...",
|
||||
"downloadedPreview": "Vorschaubild heruntergeladen",
|
||||
"downloadingFile": "{type}-Datei wird heruntergeladen",
|
||||
"finalizing": "Download wird abgeschlossen..."
|
||||
"finalizing": "Download wird abgeschlossen...",
|
||||
"cancelling": "Download wird abgebrochen...",
|
||||
"cancelled": "Download abgebrochen"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Aktuelle Datei:",
|
||||
@@ -1183,6 +1325,14 @@
|
||||
"pathPlaceholder": "Ordnerpfad eingeben oder aus Baum unten auswählen...",
|
||||
"root": "Stammverzeichnis"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Mit HuggingFace verknüpfen",
|
||||
"infoText": "Fügen Sie die HuggingFace-Repository-URL ein, um dieses Modell zuzuordnen. Dies ermöglicht die KI-gestützte Metadatenanreicherung.",
|
||||
"urlLabel": "HuggingFace-Repository-URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Geben Sie die vollständige URL des HuggingFace-Repositorys ein.",
|
||||
"confirmAction": "Speichern & Verknüpfen"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Mit Civitai neu verknüpfen",
|
||||
"warning": "Warnung:",
|
||||
@@ -1212,6 +1362,8 @@
|
||||
"editVersionName": "Versionsname bearbeiten",
|
||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
||||
"viewOnHuggingFace": "Auf Hugging Face ansehen",
|
||||
"viewOnHuggingFaceText": "Auf Hugging Face ansehen",
|
||||
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||
"openFileLocation": "Dateispeicherort öffnen",
|
||||
"sendToWorkflow": "An ComfyUI senden",
|
||||
@@ -1237,7 +1389,10 @@
|
||||
"additionalNotes": "Zusätzliche Notizen",
|
||||
"notesHint": "Enter zum Speichern, Shift+Enter für neue Zeile",
|
||||
"addNotesPlaceholder": "Fügen Sie hier Ihre Notizen hinzu...",
|
||||
"aboutThisVersion": "Über diese Version"
|
||||
"aboutThisVersion": "Über diese Version",
|
||||
"baseModelSearchPlaceholder": "Basismodell suchen…",
|
||||
"baseModelSuggested": "Vorschlag",
|
||||
"baseModelNoMatch": "Keine passenden Basismodelle"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Notizen erfolgreich gespeichert",
|
||||
@@ -1396,6 +1551,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": {
|
||||
@@ -1409,15 +1579,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...",
|
||||
@@ -1507,12 +1668,15 @@
|
||||
"modelUpdated": "Modell im Workflow aktualisiert",
|
||||
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
|
||||
"embeddingAdded": "Embedding zum Workflow hinzugefügt",
|
||||
"embeddingFailed": "Fehler beim Hinzufügen des Embeddings"
|
||||
"embeddingFailed": "Fehler beim Hinzufügen des Embeddings",
|
||||
"promptSent": "Prompt an Workflow gesendet",
|
||||
"promptFailed": "Fehler beim Senden des Prompts"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Rezept",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "Prompt",
|
||||
"replace": "Ersetzen",
|
||||
"append": "Anhängen",
|
||||
"selectTargetNode": "Zielknoten auswählen",
|
||||
@@ -1699,6 +1863,7 @@
|
||||
"enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein",
|
||||
"reconnectedSuccessfully": "LoRA erfolgreich neu verbunden",
|
||||
"reconnectFailed": "Fehler beim Neuverbinden des LoRA: {message}",
|
||||
"noPromptToSend": "Kein zu sendender Prompt",
|
||||
"cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID",
|
||||
"sendFailed": "Fehler beim Senden des Rezepts an Workflow",
|
||||
"sendError": "Fehler beim Senden des Rezepts an Workflow",
|
||||
@@ -1852,7 +2017,8 @@
|
||||
"imagesCompleted": "Beispielbilder {action} abgeschlossen",
|
||||
"imagesFailed": "Beispielbilder {action} fehlgeschlagen",
|
||||
"loadError": "Fehler beim Laden der Downloads: {message}",
|
||||
"downloadError": "Download-Fehler: {message}"
|
||||
"downloadError": "Download-Fehler: {message}",
|
||||
"downloadStopped": "Download abgebrochen"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
|
||||
@@ -1897,6 +2063,8 @@
|
||||
"contentRatingFailed": "Fehler beim Setzen der Inhaltsbewertung: {message}",
|
||||
"relinkSuccess": "Modell erfolgreich mit Civitai neu verknüpft",
|
||||
"relinkFailed": "Fehler: {message}",
|
||||
"linkHfSuccess": "Modell erfolgreich mit HuggingFace verknüpft",
|
||||
"linkHfFailed": "Fehler: {message}",
|
||||
"fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab",
|
||||
"noCivitaiInfo": "Keine CivitAI-Informationen verfügbar",
|
||||
"missingHash": "Modell-Hash nicht verfügbar"
|
||||
@@ -1955,7 +2123,15 @@
|
||||
"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"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "KI-Anbieter nicht konfiguriert. Aktivieren Sie ihn unter Einstellungen → KI-Anbieter.",
|
||||
"enrichStarted": "Metadaten werden mit KI angereichert...",
|
||||
"enrichComplete": "Metadatenanreicherung abgeschlossen: {{summary}}",
|
||||
"enrichFailed": "Metadatenanreicherung fehlgeschlagen: {{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
+2193
-2017
File diff suppressed because it is too large
Load Diff
+213
-37
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"status": {
|
||||
"loading": "Cargando...",
|
||||
"cancelling": "Cancelando...",
|
||||
"unknown": "Desconocido",
|
||||
"date": "Fecha",
|
||||
"version": "Versión",
|
||||
@@ -104,6 +105,7 @@
|
||||
"removeFromFavorites": "Eliminar de favoritos",
|
||||
"viewOnCivitai": "Ver en Civitai",
|
||||
"notAvailableFromCivitai": "No disponible en Civitai",
|
||||
"viewOnHuggingFace": "Ver en Hugging Face",
|
||||
"sendToWorkflow": "Enviar a ComfyUI (Clic: Añadir, Shift+Clic: Reemplazar)",
|
||||
"copyLoRASyntax": "Copiar sintaxis de LoRA",
|
||||
"checkpointNameCopied": "Nombre del checkpoint copiado",
|
||||
@@ -144,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Veces usado"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} versiones",
|
||||
"viewAllVersions": "Ver todas las versiones locales"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -182,6 +188,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gestionar modelos excluidos"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Agrupar por modelo"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -194,13 +203,7 @@
|
||||
"statistics": "Estadísticas"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar...",
|
||||
"placeholders": {
|
||||
"loras": "Buscar LoRAs...",
|
||||
"recipes": "Buscar recetas...",
|
||||
"checkpoints": "Buscar checkpoints...",
|
||||
"embeddings": "Buscar embeddings..."
|
||||
},
|
||||
"placeholder": "Buscar",
|
||||
"options": "Opciones de búsqueda",
|
||||
"searchIn": "Buscar en:",
|
||||
"notAvailable": "Búsqueda no disponible en la página de estadísticas",
|
||||
@@ -250,7 +253,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",
|
||||
@@ -262,6 +276,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\".",
|
||||
@@ -302,6 +319,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",
|
||||
@@ -309,7 +327,7 @@
|
||||
"extraFolderPaths": "Rutas de carpetas adicionales",
|
||||
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
||||
"priorityTags": "Etiquetas prioritarias",
|
||||
"updateFlags": "Indicadores de actualización",
|
||||
"versionScope": "Indicadores de actualización",
|
||||
"exampleImages": "Imágenes de ejemplo",
|
||||
"autoOrganize": "Organización automática",
|
||||
"metadata": "Metadatos",
|
||||
@@ -414,6 +432,8 @@
|
||||
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Agrupar por modelo",
|
||||
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
|
||||
"displayDensity": "Densidad de visualización",
|
||||
"displayDensityOptions": {
|
||||
"default": "Predeterminado",
|
||||
@@ -448,7 +468,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",
|
||||
@@ -483,7 +505,9 @@
|
||||
"saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.",
|
||||
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Esta ruta ya está configurada"
|
||||
"duplicatePath": "Esta ruta ya está configurada",
|
||||
"checkpointUnetOverlap": "No se puede usar la misma ruta para checkpoints y modelos de difusión: {paths}",
|
||||
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -568,7 +592,7 @@
|
||||
"download": "Descargar",
|
||||
"restartRequired": "Requiere reinicio"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Estrategia de indicadores de actualización",
|
||||
"help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.",
|
||||
"options": {
|
||||
@@ -580,6 +604,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",
|
||||
@@ -631,6 +659,32 @@
|
||||
"proxyPassword": "Contraseña (opcional)",
|
||||
"proxyPasswordPlaceholder": "contraseña",
|
||||
"proxyPasswordHelp": "Contraseña para autenticación de proxy (si es necesario)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "Proveedor de IA",
|
||||
"provider": "Proveedor",
|
||||
"providerHelp": "Elija su proveedor de LLM. OpenAI y Ollama usan endpoints predefinidos. Personalizado le permite especificar cualquier endpoint compatible con OpenAI.",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama (local)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "Personalizado (compatible con OpenAI)"
|
||||
},
|
||||
"apiBase": "URL base de la API",
|
||||
"apiBaseHelp": "La URL base para la API LLM (p.ej. https://api.openai.com/v1). Déjelo vacío para usar el valor predeterminado del proveedor.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "Clave de API",
|
||||
"apiKeyHelp": "Su clave de API del proveedor LLM. Se almacena localmente y nunca se envía a ningún servidor excepto a su proveedor LLM elegido.",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "No configurada",
|
||||
"apiKeyConfigured": "Configurada",
|
||||
"apiKeySet": "Configurar",
|
||||
"model": "Modelo",
|
||||
"modelHelp": "El nombre del modelo a usar (p.ej. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Consulte a su proveedor para ver los modelos disponibles.",
|
||||
"modelPlaceholder": "Seleccionar un modelo..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -648,7 +702,11 @@
|
||||
"sizeAsc": "Menor",
|
||||
"usage": "Número de usos",
|
||||
"usageDesc": "Más",
|
||||
"usageAsc": "Menos"
|
||||
"usageAsc": "Menos",
|
||||
"versionsCount": "Versiones locales",
|
||||
"versionsCountDesc": "Más versiones primero",
|
||||
"versionsCountAsc": "Menos versiones primero",
|
||||
"versionIdDesc": "Versión más nueva primero"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualizar lista de modelos",
|
||||
@@ -724,12 +782,15 @@
|
||||
"completed": "Completado: {success} movidos, {skipped} omitidos, {failures} fallidos",
|
||||
"complete": "Auto-organización completada",
|
||||
"error": "Error: {error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "Enriquecer metadatos HF (IA)"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Actualizar datos de Civitai",
|
||||
"checkUpdates": "Comprobar actualizaciones",
|
||||
"relinkCivitai": "Re-vincular a Civitai",
|
||||
"linkModel": "Vincular modelo",
|
||||
"linkCivitai": "Re-vincular a Civitai",
|
||||
"linkHuggingFace": "Vincular a HuggingFace",
|
||||
"copySyntax": "Copiar sintaxis de LoRA",
|
||||
"copyFilename": "Copiar nombre de archivo del modelo",
|
||||
"copyRecipeSyntax": "Copiar sintaxis de receta",
|
||||
@@ -748,7 +809,8 @@
|
||||
"shareRecipe": "Compartir receta",
|
||||
"viewAllLoras": "Ver todos los LoRAs",
|
||||
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||
"deleteRecipe": "Eliminar receta"
|
||||
"deleteRecipe": "Eliminar receta",
|
||||
"enrichHfAgent": "Enriquecer metadatos HF (IA)"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -953,10 +1015,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Raíz",
|
||||
"moreOptions": "Más opciones",
|
||||
"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}",
|
||||
@@ -997,6 +1056,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",
|
||||
@@ -1014,13 +1085,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": {
|
||||
@@ -1032,7 +1167,10 @@
|
||||
"titleWithType": "Descargar {type} desde URL",
|
||||
"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.",
|
||||
"urlHint": "Ingrese una URL de CivitAI, CivArchive o Hugging Face por línea. Admite múltiples URLs para descarga por lotes.",
|
||||
"selectHfFiles": "Seleccione el/los archivo(s) para descargar de este repositorio:",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"fetchingRepoFiles": "Obteniendo archivos del repositorio...",
|
||||
"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",
|
||||
@@ -1061,13 +1199,17 @@
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Formato de URL de Civitai inválido",
|
||||
"noVersions": "No hay versiones disponibles para este modelo"
|
||||
"noVersions": "No hay versiones disponibles para este modelo",
|
||||
"mixedSources": "No se pueden mezclar URL de CivitAI y Hugging Face en el mismo lote.",
|
||||
"noModelFiles": "No se encontraron archivos de modelo en este repositorio."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "Preparando descarga...",
|
||||
"downloadedPreview": "Imagen de vista previa descargada",
|
||||
"downloadingFile": "Descargando archivo de {type}",
|
||||
"finalizing": "Finalizando descarga..."
|
||||
"finalizing": "Finalizando descarga...",
|
||||
"cancelling": "Cancelando descarga...",
|
||||
"cancelled": "Descarga cancelada"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Archivo actual:",
|
||||
@@ -1183,6 +1325,14 @@
|
||||
"pathPlaceholder": "Escribe la ruta de la carpeta o selecciona del árbol de abajo...",
|
||||
"root": "Raíz"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Vincular a HuggingFace",
|
||||
"infoText": "Pegue la URL del repositorio de HuggingFace para asociar este modelo. Esto permite el enriquecimiento de metadatos con IA.",
|
||||
"urlLabel": "URL del repositorio de HuggingFace:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Ingrese la URL completa del repositorio de HuggingFace.",
|
||||
"confirmAction": "Guardar y vincular"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Re-vincular a Civitai",
|
||||
"warning": "Advertencia:",
|
||||
@@ -1212,6 +1362,8 @@
|
||||
"editVersionName": "Editar nombre de versión",
|
||||
"viewOnCivitai": "Ver en Civitai",
|
||||
"viewOnCivitaiText": "Ver en Civitai",
|
||||
"viewOnHuggingFace": "Ver en Hugging Face",
|
||||
"viewOnHuggingFaceText": "Ver en Hugging Face",
|
||||
"viewCreatorProfile": "Ver perfil del creador",
|
||||
"openFileLocation": "Abrir ubicación del archivo",
|
||||
"sendToWorkflow": "Enviar a ComfyUI",
|
||||
@@ -1237,7 +1389,10 @@
|
||||
"additionalNotes": "Notas adicionales",
|
||||
"notesHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
|
||||
"addNotesPlaceholder": "Añade tus notas aquí...",
|
||||
"aboutThisVersion": "Acerca de esta versión"
|
||||
"aboutThisVersion": "Acerca de esta versión",
|
||||
"baseModelSearchPlaceholder": "Buscar modelo base…",
|
||||
"baseModelSuggested": "Sugerido",
|
||||
"baseModelNoMatch": "No hay modelos base que coincidan"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Notas guardadas exitosamente",
|
||||
@@ -1396,6 +1551,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": {
|
||||
@@ -1409,15 +1579,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...",
|
||||
@@ -1507,12 +1668,15 @@
|
||||
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
||||
"modelFailed": "Error al actualizar nodo de modelo",
|
||||
"embeddingAdded": "Embedding añadido al flujo de trabajo",
|
||||
"embeddingFailed": "Error al añadir el embedding"
|
||||
"embeddingFailed": "Error al añadir el embedding",
|
||||
"promptSent": "Prompt enviado al flujo de trabajo",
|
||||
"promptFailed": "Error al enviar el prompt"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Receta",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "Prompt",
|
||||
"replace": "Reemplazar",
|
||||
"append": "Añadir",
|
||||
"selectTargetNode": "Seleccionar nodo de destino",
|
||||
@@ -1699,6 +1863,7 @@
|
||||
"enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis",
|
||||
"reconnectedSuccessfully": "LoRA reconectado exitosamente",
|
||||
"reconnectFailed": "Error reconectando LoRA: {message}",
|
||||
"noPromptToSend": "No hay prompt para enviar",
|
||||
"cannotSend": "No se puede enviar receta: Falta ID de receta",
|
||||
"sendFailed": "Error al enviar receta al flujo de trabajo",
|
||||
"sendError": "Error enviando receta al flujo de trabajo",
|
||||
@@ -1852,7 +2017,8 @@
|
||||
"imagesCompleted": "Imágenes de ejemplo {action} completadas",
|
||||
"imagesFailed": "Imágenes de ejemplo {action} fallidas",
|
||||
"loadError": "Error al cargar descargas: {message}",
|
||||
"downloadError": "Error de descarga: {message}"
|
||||
"downloadError": "Error de descarga: {message}",
|
||||
"downloadStopped": "Descarga cancelada"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Error al cargar árbol de carpetas",
|
||||
@@ -1897,6 +2063,8 @@
|
||||
"contentRatingFailed": "Error al establecer clasificación de contenido: {message}",
|
||||
"relinkSuccess": "Modelo re-vinculado exitosamente a Civitai",
|
||||
"relinkFailed": "Error: {message}",
|
||||
"linkHfSuccess": "Modelo vinculado a HuggingFace exitosamente",
|
||||
"linkHfFailed": "Error: {message}",
|
||||
"fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero",
|
||||
"noCivitaiInfo": "No hay información de CivitAI disponible",
|
||||
"missingHash": "Hash del modelo no disponible"
|
||||
@@ -1955,7 +2123,15 @@
|
||||
"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"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "Proveedor de IA no configurado. Actívelo en Configuración → Proveedor de IA.",
|
||||
"enrichStarted": "Enriqueciendo metadatos con IA...",
|
||||
"enrichComplete": "Enriquecimiento de metadatos completado: {{summary}}",
|
||||
"enrichFailed": "Enriquecimiento de metadatos fallido: {{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
+213
-37
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement...",
|
||||
"cancelling": "Annulation...",
|
||||
"unknown": "Inconnu",
|
||||
"date": "Date",
|
||||
"version": "Version",
|
||||
@@ -104,6 +105,7 @@
|
||||
"removeFromFavorites": "Retirer des favoris",
|
||||
"viewOnCivitai": "Voir sur Civitai",
|
||||
"notAvailableFromCivitai": "Non disponible sur Civitai",
|
||||
"viewOnHuggingFace": "Voir sur Hugging Face",
|
||||
"sendToWorkflow": "Envoyer vers ComfyUI (Clic: Ajouter, Maj+Clic: Remplacer)",
|
||||
"copyLoRASyntax": "Copier la syntaxe LoRA",
|
||||
"checkpointNameCopied": "Nom du checkpoint copié",
|
||||
@@ -144,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Nombre d'utilisations"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} versions",
|
||||
"viewAllVersions": "Voir toutes les versions locales"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -182,6 +188,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gérer les modèles exclus"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Grouper par modèle"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -194,13 +203,7 @@
|
||||
"statistics": "Statistiques"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher...",
|
||||
"placeholders": {
|
||||
"loras": "Rechercher des LoRAs...",
|
||||
"recipes": "Rechercher des recipes...",
|
||||
"checkpoints": "Rechercher des checkpoints...",
|
||||
"embeddings": "Rechercher des embeddings..."
|
||||
},
|
||||
"placeholder": "Rechercher",
|
||||
"options": "Options de recherche",
|
||||
"searchIn": "Rechercher dans :",
|
||||
"notAvailable": "Recherche non disponible sur la page de statistiques",
|
||||
@@ -250,7 +253,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",
|
||||
@@ -262,6 +276,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 ».",
|
||||
@@ -302,6 +319,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",
|
||||
@@ -309,7 +327,7 @@
|
||||
"extraFolderPaths": "Chemins de dossiers supplémentaires",
|
||||
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
||||
"priorityTags": "Étiquettes prioritaires",
|
||||
"updateFlags": "Indicateurs de mise à jour",
|
||||
"versionScope": "Indicateurs de mise à jour",
|
||||
"exampleImages": "Images d'exemple",
|
||||
"autoOrganize": "Organisation automatique",
|
||||
"metadata": "Métadonnées",
|
||||
@@ -414,6 +432,8 @@
|
||||
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Grouper par modèle",
|
||||
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
|
||||
"displayDensity": "Densité d'affichage",
|
||||
"displayDensityOptions": {
|
||||
"default": "Par défaut",
|
||||
@@ -448,7 +468,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",
|
||||
@@ -483,7 +505,9 @@
|
||||
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour. Redémarrage requis pour appliquer les changements.",
|
||||
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Ce chemin est déjà configuré"
|
||||
"duplicatePath": "Ce chemin est déjà configuré",
|
||||
"checkpointUnetOverlap": "Impossible d'utiliser le même chemin pour les checkpoints et les modèles de diffusion : {paths}",
|
||||
"checkpointUnetOverlapInline": "Ce chemin est déjà utilisé pour un autre type de modèle. Utilisez des dossiers séparés pour les checkpoints et les modèles de diffusion."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -568,7 +592,7 @@
|
||||
"download": "Télécharger",
|
||||
"restartRequired": "Redémarrage requis"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Stratégie des indicateurs de mise à jour",
|
||||
"help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsqu’une nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès qu’il existe une version plus récente pour ce modèle.",
|
||||
"options": {
|
||||
@@ -580,6 +604,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",
|
||||
@@ -631,6 +659,32 @@
|
||||
"proxyPassword": "Mot de passe (optionnel)",
|
||||
"proxyPasswordPlaceholder": "mot_de_passe",
|
||||
"proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "Fournisseur d'IA",
|
||||
"provider": "Fournisseur",
|
||||
"providerHelp": "Choisissez votre fournisseur LLM. OpenAI et Ollama utilisent des endpoints prédéfinis. Personnalisé vous permet de spécifier n'importe quel endpoint compatible OpenAI.",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama (local)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "Personnalisé (compatible OpenAI)"
|
||||
},
|
||||
"apiBase": "URL de base de l'API",
|
||||
"apiBaseHelp": "L'URL de base pour l'API LLM (ex. https://api.openai.com/v1). Laissez vide pour utiliser le fournisseur par défaut.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "Clé API",
|
||||
"apiKeyHelp": "Votre clé API du fournisseur LLM. Stockée localement, jamais envoyée à un serveur autre que votre fournisseur LLM choisi.",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "Non définie",
|
||||
"apiKeyConfigured": "Configurée",
|
||||
"apiKeySet": "Configurer",
|
||||
"model": "Modèle",
|
||||
"modelHelp": "Le nom du modèle à utiliser (ex. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Consultez votre fournisseur pour les modèles disponibles.",
|
||||
"modelPlaceholder": "Sélectionner un modèle..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -648,7 +702,11 @@
|
||||
"sizeAsc": "Plus petit",
|
||||
"usage": "Nombre d'utilisations",
|
||||
"usageDesc": "Plus",
|
||||
"usageAsc": "Moins"
|
||||
"usageAsc": "Moins",
|
||||
"versionsCount": "Versions locales",
|
||||
"versionsCountDesc": "Plus de versions d'abord",
|
||||
"versionsCountAsc": "Moins de versions d'abord",
|
||||
"versionIdDesc": "Version la plus récente d'abord"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualiser la liste des modèles",
|
||||
@@ -724,12 +782,15 @@
|
||||
"completed": "Terminé : {success} déplacés, {skipped} ignorés, {failures} échecs",
|
||||
"complete": "Auto-organisation terminée",
|
||||
"error": "Erreur : {error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "Enrichir les métadonnées HF (IA)"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Actualiser les données Civitai",
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
"relinkCivitai": "Relier à nouveau à Civitai",
|
||||
"linkModel": "Lier le modèle",
|
||||
"linkCivitai": "Relier à nouveau à Civitai",
|
||||
"linkHuggingFace": "Lier à HuggingFace",
|
||||
"copySyntax": "Copier la syntaxe LoRA",
|
||||
"copyFilename": "Copier le nom de fichier du modèle",
|
||||
"copyRecipeSyntax": "Copier la syntaxe de la recipe",
|
||||
@@ -748,7 +809,8 @@
|
||||
"shareRecipe": "Partager la recipe",
|
||||
"viewAllLoras": "Voir tous les LoRAs",
|
||||
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||
"deleteRecipe": "Supprimer la recipe"
|
||||
"deleteRecipe": "Supprimer la recipe",
|
||||
"enrichHfAgent": "Enrichir les métadonnées HF (IA)"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -953,10 +1015,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Racine",
|
||||
"moreOptions": "Plus d'options",
|
||||
"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}",
|
||||
@@ -997,6 +1056,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",
|
||||
@@ -1014,13 +1085,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": {
|
||||
@@ -1032,7 +1167,10 @@
|
||||
"titleWithType": "Télécharger {type} depuis une URL",
|
||||
"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.",
|
||||
"urlHint": "Entrez une URL CivitAI, CivArchive ou Hugging Face par ligne. Prend en charge plusieurs URL pour le téléchargement par lot.",
|
||||
"selectHfFiles": "Sélectionnez le(s) fichier(s) à télécharger depuis ce dépôt :",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"fetchingRepoFiles": "Récupération des fichiers du dépôt...",
|
||||
"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",
|
||||
@@ -1061,13 +1199,17 @@
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Format d'URL Civitai invalide",
|
||||
"noVersions": "Aucune version disponible pour ce modèle"
|
||||
"noVersions": "Aucune version disponible pour ce modèle",
|
||||
"mixedSources": "Impossible de mélanger les URL CivitAI et Hugging Face dans le même lot.",
|
||||
"noModelFiles": "Aucun fichier de modèle trouvé dans ce dépôt."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "Préparation du téléchargement...",
|
||||
"downloadedPreview": "Image d'aperçu téléchargée",
|
||||
"downloadingFile": "Téléchargement du fichier {type}",
|
||||
"finalizing": "Finalisation du téléchargement..."
|
||||
"finalizing": "Finalisation du téléchargement...",
|
||||
"cancelling": "Annulation du téléchargement...",
|
||||
"cancelled": "Téléchargement annulé"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Fichier actuel :",
|
||||
@@ -1183,6 +1325,14 @@
|
||||
"pathPlaceholder": "Tapez le chemin du dossier ou sélectionnez dans l'arbre ci-dessous...",
|
||||
"root": "Racine"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Lier à HuggingFace",
|
||||
"infoText": "Collez l'URL du dépôt HuggingFace pour associer ce modèle à sa source. Cela permet l'enrichissement des métadonnées par IA.",
|
||||
"urlLabel": "URL du dépôt HuggingFace :",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Entrez l'URL complète du dépôt HuggingFace.",
|
||||
"confirmAction": "Enregistrer & lier"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Relier à nouveau à Civitai",
|
||||
"warning": "Attention :",
|
||||
@@ -1212,6 +1362,8 @@
|
||||
"editVersionName": "Modifier le nom de la version",
|
||||
"viewOnCivitai": "Voir sur Civitai",
|
||||
"viewOnCivitaiText": "Voir sur Civitai",
|
||||
"viewOnHuggingFace": "Voir sur Hugging Face",
|
||||
"viewOnHuggingFaceText": "Voir sur Hugging Face",
|
||||
"viewCreatorProfile": "Voir le profil du créateur",
|
||||
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
||||
"sendToWorkflow": "Envoyer vers ComfyUI",
|
||||
@@ -1237,7 +1389,10 @@
|
||||
"additionalNotes": "Notes supplémentaires",
|
||||
"notesHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
|
||||
"addNotesPlaceholder": "Ajoutez vos notes ici...",
|
||||
"aboutThisVersion": "À propos de cette version"
|
||||
"aboutThisVersion": "À propos de cette version",
|
||||
"baseModelSearchPlaceholder": "Rechercher un modèle de base…",
|
||||
"baseModelSuggested": "Suggéré",
|
||||
"baseModelNoMatch": "Aucun modèle de base correspondant"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Notes sauvegardées avec succès",
|
||||
@@ -1396,6 +1551,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": {
|
||||
@@ -1409,15 +1579,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...",
|
||||
@@ -1507,12 +1668,15 @@
|
||||
"modelUpdated": "Modèle mis à jour dans le workflow",
|
||||
"modelFailed": "Échec de la mise à jour du nœud modèle",
|
||||
"embeddingAdded": "Embedding ajouté au workflow",
|
||||
"embeddingFailed": "Échec de l'ajout de l'embedding"
|
||||
"embeddingFailed": "Échec de l'ajout de l'embedding",
|
||||
"promptSent": "Prompt envoyé au workflow",
|
||||
"promptFailed": "Échec de l'envoi du prompt"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "Prompt",
|
||||
"replace": "Remplacer",
|
||||
"append": "Ajouter",
|
||||
"selectTargetNode": "Sélectionner le nœud cible",
|
||||
@@ -1699,6 +1863,7 @@
|
||||
"enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA",
|
||||
"reconnectedSuccessfully": "LoRA reconnecté avec succès",
|
||||
"reconnectFailed": "Erreur lors de la reconnexion du LoRA : {message}",
|
||||
"noPromptToSend": "Aucun prompt à envoyer",
|
||||
"cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant",
|
||||
"sendFailed": "Échec de l'envoi de la recipe vers le workflow",
|
||||
"sendError": "Erreur lors de l'envoi de la recipe vers le workflow",
|
||||
@@ -1852,7 +2017,8 @@
|
||||
"imagesCompleted": "Images d'exemple {action} terminées",
|
||||
"imagesFailed": "Images d'exemple {action} échouées",
|
||||
"loadError": "Erreur lors du chargement des téléchargements : {message}",
|
||||
"downloadError": "Erreur de téléchargement : {message}"
|
||||
"downloadError": "Erreur de téléchargement : {message}",
|
||||
"downloadStopped": "Téléchargement annulé"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
|
||||
@@ -1897,6 +2063,8 @@
|
||||
"contentRatingFailed": "Échec de la définition de la classification du contenu : {message}",
|
||||
"relinkSuccess": "Modèle relié à Civitai avec succès",
|
||||
"relinkFailed": "Erreur : {message}",
|
||||
"linkHfSuccess": "Modèle lié à HuggingFace avec succès",
|
||||
"linkHfFailed": "Erreur : {message}",
|
||||
"fetchMetadataFirst": "Veuillez d'abord récupérer les métadonnées depuis CivitAI",
|
||||
"noCivitaiInfo": "Aucune information CivitAI disponible",
|
||||
"missingHash": "Hash du modèle non disponible"
|
||||
@@ -1955,7 +2123,15 @@
|
||||
"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é"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "Fournisseur d'IA non configuré. Activez-le dans Paramètres → Fournisseur d'IA.",
|
||||
"enrichStarted": "Enrichissement des métadonnées par IA...",
|
||||
"enrichComplete": "Enrichissement des métadonnées terminé : {{summary}}",
|
||||
"enrichFailed": "Échec de l'enrichissement des métadonnées : {{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
+213
-37
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"status": {
|
||||
"loading": "טוען...",
|
||||
"cancelling": "מבטל...",
|
||||
"unknown": "לא ידוע",
|
||||
"date": "תאריך",
|
||||
"version": "גרסה",
|
||||
@@ -104,6 +105,7 @@
|
||||
"removeFromFavorites": "הסר מהמועדפים",
|
||||
"viewOnCivitai": "הצג ב-Civitai",
|
||||
"notAvailableFromCivitai": "לא זמין מ-Civitai",
|
||||
"viewOnHuggingFace": "צפייה ב-Hugging Face",
|
||||
"sendToWorkflow": "שלח ל-ComfyUI (לחיצה: הוסף, Shift+לחיצה: החלף)",
|
||||
"copyLoRASyntax": "העתק תחביר LoRA",
|
||||
"checkpointNameCopied": "שם Checkpoint הועתק",
|
||||
@@ -144,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "מספר שימושים"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} גרסאות",
|
||||
"viewAllVersions": "הצג את כל הגרסאות המקומיות"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -182,6 +188,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "ניהול מודלים מוחרגים"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "קיבוץ לפי דגם"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -194,13 +203,7 @@
|
||||
"statistics": "סטטיסטיקה"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "חפש...",
|
||||
"placeholders": {
|
||||
"loras": "חפש LoRAs...",
|
||||
"recipes": "חפש מתכונים...",
|
||||
"checkpoints": "חפש checkpoints...",
|
||||
"embeddings": "חפש embeddings..."
|
||||
},
|
||||
"placeholder": "חיפוש",
|
||||
"options": "אפשרויות חיפוש",
|
||||
"searchIn": "חפש ב:",
|
||||
"notAvailable": "חיפוש לא זמין בדף הסטטיסטיקה",
|
||||
@@ -250,7 +253,18 @@
|
||||
"toggle": "החלף ערכת נושא",
|
||||
"switchToLight": "עבור לערכת נושא בהירה",
|
||||
"switchToDark": "עבור לערכת נושא כהה",
|
||||
"switchToAuto": "עבור לערכת נושא אוטומטית"
|
||||
"switchToAuto": "עבור לערכת נושא אוטומטית",
|
||||
"presets": "ערכות נושא מוגדרות",
|
||||
"default": "ברירת מחדל",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "מצב",
|
||||
"light": "בהיר",
|
||||
"dark": "כהה",
|
||||
"auto": "אוטומטי"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "בדוק עדכונים",
|
||||
@@ -262,6 +276,9 @@
|
||||
"civitaiApiKey": "מפתח API של Civitai",
|
||||
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
|
||||
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
|
||||
"civitaiApiKeyConfigured": "מוגדר",
|
||||
"civitaiApiKeyNotConfigured": "לא מוגדר",
|
||||
"civitaiApiKeySet": "הגדר",
|
||||
"civitaiHost": {
|
||||
"label": "מארח Civitai",
|
||||
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
|
||||
@@ -302,6 +319,7 @@
|
||||
"downloads": "הורדות",
|
||||
"videoSettings": "הגדרות וידאו",
|
||||
"layoutSettings": "הגדרות פריסה",
|
||||
"licenseIcons": "סמלי רישיון",
|
||||
"misc": "שונות",
|
||||
"backup": "גיבויים",
|
||||
"folderSettings": "תיקיות ברירת מחדל",
|
||||
@@ -309,7 +327,7 @@
|
||||
"extraFolderPaths": "נתיבי תיקיות נוספים",
|
||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||
"priorityTags": "תגיות עדיפות",
|
||||
"updateFlags": "תגי עדכון",
|
||||
"versionScope": "תגי עדכון",
|
||||
"exampleImages": "תמונות דוגמה",
|
||||
"autoOrganize": "ארגון אוטומטי",
|
||||
"metadata": "מטא-נתונים",
|
||||
@@ -414,6 +432,8 @@
|
||||
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "קיבוץ לפי דגם",
|
||||
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
||||
"displayDensity": "צפיפות תצוגה",
|
||||
"displayDensityOptions": {
|
||||
"default": "ברירת מחדל",
|
||||
@@ -448,7 +468,9 @@
|
||||
"modelName": "שם מודל",
|
||||
"fileName": "שם קובץ"
|
||||
},
|
||||
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל"
|
||||
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל",
|
||||
"cardBlurAmount": "עוצמת טשטוש שכבת-על בכרטיס",
|
||||
"cardBlurAmountHelp": "כוונן את עוצמת הטשטוש של שכבת-העל בכותרת ובכותרות תחתונה בכרטיסי מודל ומתכונים (0 = ללא טשטוש, 20 = טשטוש מקסימלי)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "ספרייה פעילה",
|
||||
@@ -483,7 +505,9 @@
|
||||
"saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
|
||||
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "נתיב זה כבר מוגדר"
|
||||
"duplicatePath": "נתיב זה כבר מוגדר",
|
||||
"checkpointUnetOverlap": "לא ניתן להשתמש באותו נתיב עבור checkpoints ומודלי דיפוזיה: {paths}",
|
||||
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -568,7 +592,7 @@
|
||||
"download": "הורד",
|
||||
"restartRequired": "דורש הפעלה מחדש"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "אסטרטגיית תגי עדכון",
|
||||
"help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.",
|
||||
"options": {
|
||||
@@ -580,6 +604,10 @@
|
||||
"label": "הסתר עדכוני גישה מוקדמת",
|
||||
"help": "רק עדכוני גישה מוקדמת"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
|
||||
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
|
||||
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח",
|
||||
@@ -631,6 +659,32 @@
|
||||
"proxyPassword": "סיסמה (אופציונלי)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "ספק AI",
|
||||
"provider": "ספק",
|
||||
"providerHelp": "בחר את ספק ה-LLM שלך. OpenAI ו-Ollama משתמשים בנקודות קצה מוגדרות מראש. מותאם אישית מאפשר לך לציין כל נקודת קצה תואמת OpenAI.",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama (מקומי)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "מותאם אישית (תואם OpenAI)"
|
||||
},
|
||||
"apiBase": "כתובת בסיס API",
|
||||
"apiBaseHelp": "כתובת ה-URL הבסיסית ל-API של LLM (לדוגמה https://api.openai.com/v1). השאר ריק לשימוש בברירת המחדל של הספק.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "מפתח API",
|
||||
"apiKeyHelp": "מפתח ה-API של ספק ה-LLM שלך. נשמר מקומית, לעולם לא נשלח לשרת כלשהו מלבד ספק ה-LLM שבחרת.",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "לא הוגדר",
|
||||
"apiKeyConfigured": "הוגדר",
|
||||
"apiKeySet": "הגדר",
|
||||
"model": "מודל",
|
||||
"modelHelp": "שם המודל לשימוש (לדוגמה deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). בדוק אצל הספק שלך אילו מודלים זמינים.",
|
||||
"modelPlaceholder": "בחר מודל..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -648,7 +702,11 @@
|
||||
"sizeAsc": "הקטן ביותר",
|
||||
"usage": "מספר שימושים",
|
||||
"usageDesc": "הכי הרבה",
|
||||
"usageAsc": "הכי פחות"
|
||||
"usageAsc": "הכי פחות",
|
||||
"versionsCount": "גרסאות מקומיות",
|
||||
"versionsCountDesc": "הכי הרבה גרסאות ראשונות",
|
||||
"versionsCountAsc": "הכי מעט גרסאות ראשונות",
|
||||
"versionIdDesc": "גרסה חדשה ביותר ראשונה"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מודלים",
|
||||
@@ -724,12 +782,15 @@
|
||||
"completed": "הושלם: {success} הועברו, {skipped} דולגו, {failures} נכשלו",
|
||||
"complete": "ארגון אוטומטי הושלם",
|
||||
"error": "שגיאה: {error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "העשרת HF מטא-דאטה (AI)"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "רענן נתוני Civitai",
|
||||
"checkUpdates": "בדוק עדכונים",
|
||||
"relinkCivitai": "קשר מחדש ל-Civitai",
|
||||
"linkModel": "קישור מודל",
|
||||
"linkCivitai": "קשר מחדש ל-Civitai",
|
||||
"linkHuggingFace": "קישור ל-HuggingFace",
|
||||
"copySyntax": "העתק תחביר LoRA",
|
||||
"copyFilename": "העתק שם קובץ מודל",
|
||||
"copyRecipeSyntax": "העתק תחביר מתכון",
|
||||
@@ -748,7 +809,8 @@
|
||||
"shareRecipe": "שתף מתכון",
|
||||
"viewAllLoras": "הצג את כל ה-LoRAs",
|
||||
"downloadMissingLoras": "הורד LoRAs חסרים",
|
||||
"deleteRecipe": "מחק מתכון"
|
||||
"deleteRecipe": "מחק מתכון",
|
||||
"enrichHfAgent": "העשרת HF מטא-דאטה (AI)"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -953,10 +1015,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "שורש",
|
||||
"moreOptions": "אפשרויות נוספות",
|
||||
"collapseAll": "כווץ את כל התיקיות",
|
||||
"pinSidebar": "נעל סרגל צד",
|
||||
"unpinSidebar": "שחרר סרגל צד",
|
||||
"hideOnThisPage": "הסתר סרגל צד בדף זה",
|
||||
"showSidebar": "הצג סרגל צד",
|
||||
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
|
||||
@@ -997,6 +1056,18 @@
|
||||
"storage": "אחסון",
|
||||
"insights": "תובנות"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "סה\"כ דגמים",
|
||||
"totalStorage": "סה\"כ אחסון",
|
||||
"totalGenerations": "סה\"כ יצירות",
|
||||
"usageRate": "שיעור שימוש",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "נקודות ביקורת",
|
||||
"embeddings": "הטמעות",
|
||||
"uniqueTags": "תגיות ייחודיות",
|
||||
"unusedModels": "דגמים שאינם בשימוש",
|
||||
"avgUsesPerModel": "ממוצע שימושים/דגם"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "LoRAs הנפוצים ביותר",
|
||||
"mostUsedCheckpoints": "Checkpoints הנפוצים ביותר",
|
||||
@@ -1014,13 +1085,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": {
|
||||
@@ -1032,7 +1167,10 @@
|
||||
"titleWithType": "הורד {type} מכתובת URL",
|
||||
"civitaiUrl": "כתובת URL של Civitai:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "יש להזין כתובת URL אחת של CivitAI או CivArchive בכל שורה. תומך במספר כתובות URL להורדה בבת אחת.",
|
||||
"urlHint": "יש להזין כתובת URL אחת של CivitAI, CivArchive או Hugging Face בכל שורה. תומך במספר כתובות URL להורדה בקבוצה.",
|
||||
"selectHfFiles": "בחר קבצים להורדה ממאגר זה:",
|
||||
"selectAll": "בחר הכל",
|
||||
"fetchingRepoFiles": "מביא קבצים מהמאגר...",
|
||||
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
|
||||
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
|
||||
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
|
||||
@@ -1061,13 +1199,17 @@
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
||||
"noVersions": "אין גרסאות זמינות למודל זה"
|
||||
"noVersions": "אין גרסאות זמינות למודל זה",
|
||||
"mixedSources": "לא ניתן לערבב כתובות URL של CivitAI ו-Hugging Face באותה קבוצה.",
|
||||
"noModelFiles": "לא נמצאו קבצי מודל במאגר זה."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "מכין הורדה...",
|
||||
"downloadedPreview": "תמונת תצוגה מקדימה הורדה",
|
||||
"downloadingFile": "מוריד קובץ {type}",
|
||||
"finalizing": "מסיים הורדה..."
|
||||
"finalizing": "מסיים הורדה...",
|
||||
"cancelling": "מבטל הורדה...",
|
||||
"cancelled": "ההורדה בוטלה"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "הקובץ הנוכחי:",
|
||||
@@ -1183,6 +1325,14 @@
|
||||
"pathPlaceholder": "הקלד נתיב תיקייה או בחר מהעץ למטה...",
|
||||
"root": "שורש"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "קישור ל-HuggingFace",
|
||||
"infoText": "הדבק את כתובת ה-URL של מאגר HuggingFace כדי לשייך מודל זה למקורו. פעולה זו מאפשרת העשרת מטא-דאטה באמצעות AI.",
|
||||
"urlLabel": "כתובת URL של מאגר HuggingFace:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "הזן את כתובת ה-URL המלאה של מאגר HuggingFace.",
|
||||
"confirmAction": "שמור וקשר"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "קשר מחדש ל-Civitai",
|
||||
"warning": "אזהרה:",
|
||||
@@ -1212,6 +1362,8 @@
|
||||
"editVersionName": "ערוך שם גרסה",
|
||||
"viewOnCivitai": "הצג ב-Civitai",
|
||||
"viewOnCivitaiText": "הצג ב-Civitai",
|
||||
"viewOnHuggingFace": "צפייה ב-Hugging Face",
|
||||
"viewOnHuggingFaceText": "צפייה ב-Hugging Face",
|
||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||
"openFileLocation": "פתח מיקום קובץ",
|
||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||
@@ -1237,7 +1389,10 @@
|
||||
"additionalNotes": "הערות נוספות",
|
||||
"notesHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
|
||||
"addNotesPlaceholder": "הוסף את ההערות שלך כאן...",
|
||||
"aboutThisVersion": "אודות גרסה זו"
|
||||
"aboutThisVersion": "אודות גרסה זו",
|
||||
"baseModelSearchPlaceholder": "חפש מודל בסיס…",
|
||||
"baseModelSuggested": "מוצע",
|
||||
"baseModelNoMatch": "אין מודלי בסיס תואמים"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "הערות נשמרו בהצלחה",
|
||||
@@ -1396,6 +1551,21 @@
|
||||
"versionDeleted": "הגרסה נמחקה"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "סיכום שליפת מטא-דאטה",
|
||||
"statSuccess": "הצלחה",
|
||||
"statFailed": "נכשל",
|
||||
"statSkipped": "דולג",
|
||||
"statTotal": "סה\"כ נסרק",
|
||||
"statDuration": "משך",
|
||||
"successMessage": "כל {count} {type}s עודכנו בהצלחה!",
|
||||
"failedItems": "פריטים נכשלים ({count})",
|
||||
"close": "סגור",
|
||||
"copyReport": "העתק דוח",
|
||||
"downloadCsv": "הורד CSV",
|
||||
"columnModelName": "שם המודל",
|
||||
"columnError": "שגיאה"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1409,15 +1579,6 @@
|
||||
"duplicate": "תגית זו כבר קיימת"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "ניווט במקלדת:",
|
||||
"shortcuts": {
|
||||
"pageUp": "גלול עמוד אחד למעלה",
|
||||
"pageDown": "גלול עמוד אחד למטה",
|
||||
"home": "קפוץ להתחלה",
|
||||
"end": "קפוץ לסוף"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "מאתחל",
|
||||
"message": "מכין את סביבת העבודה שלך...",
|
||||
@@ -1507,12 +1668,15 @@
|
||||
"modelUpdated": "מודל עודכן ב-workflow",
|
||||
"modelFailed": "עדכון צומת המודל נכשל",
|
||||
"embeddingAdded": "Embedding נוסף ל-workflow",
|
||||
"embeddingFailed": "הוספת Embedding נכשלה"
|
||||
"embeddingFailed": "הוספת Embedding נכשלה",
|
||||
"promptSent": "הנחיה נשלחה ל-workflow",
|
||||
"promptFailed": "שליחת ההנחיה נכשלה"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "מתכון",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "הנחיה",
|
||||
"replace": "החלף",
|
||||
"append": "הוסף",
|
||||
"selectTargetNode": "בחר צומת יעד",
|
||||
@@ -1699,6 +1863,7 @@
|
||||
"enterLoraName": "אנא הזן שם LoRA או תחביר",
|
||||
"reconnectedSuccessfully": "LoRA קושר מחדש בהצלחה",
|
||||
"reconnectFailed": "שגיאה בקישור מחדש של LoRA: {message}",
|
||||
"noPromptToSend": "אין הנחיה לשליחה",
|
||||
"cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון",
|
||||
"sendFailed": "שליחת המתכון ל-workflow נכשלה",
|
||||
"sendError": "שגיאה בשליחת המתכון ל-workflow",
|
||||
@@ -1852,7 +2017,8 @@
|
||||
"imagesCompleted": "{action} תמונות הדוגמה הושלם",
|
||||
"imagesFailed": "{action} תמונות הדוגמה נכשל",
|
||||
"loadError": "שגיאה בטעינת הורדות: {message}",
|
||||
"downloadError": "שגיאת הורדה: {message}"
|
||||
"downloadError": "שגיאת הורדה: {message}",
|
||||
"downloadStopped": "ההורדה בוטלה"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
|
||||
@@ -1897,6 +2063,8 @@
|
||||
"contentRatingFailed": "הגדרת דירוג התוכן נכשלה: {message}",
|
||||
"relinkSuccess": "המודל קושר מחדש ל-Civitai בהצלחה",
|
||||
"relinkFailed": "שגיאה: {message}",
|
||||
"linkHfSuccess": "המודל נקשר בהצלחה ל-HuggingFace",
|
||||
"linkHfFailed": "שגיאה: {message}",
|
||||
"fetchMetadataFirst": "אנא אחזר מטא-דאטה מ-CivitAI תחילה",
|
||||
"noCivitaiInfo": "אין מידע מ-CivitAI זמין",
|
||||
"missingHash": "ה-hash של המודל אינו זמין"
|
||||
@@ -1955,7 +2123,15 @@
|
||||
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "הועתק ללוח",
|
||||
"downloadStarted": "ההורדה החלה"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "ספק AI לא הוגדר. הפעל אותו בהגדרות → ספק AI.",
|
||||
"enrichStarted": "מעשיר מטא-דאטה באמצעות AI...",
|
||||
"enrichComplete": "העשרת מטא-דאטה הושלמה: {{summary}}",
|
||||
"enrichFailed": "העשרת מטא-דאטה נכשלה: {{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
+213
-37
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"status": {
|
||||
"loading": "読み込み中...",
|
||||
"cancelling": "キャンセル中...",
|
||||
"unknown": "不明",
|
||||
"date": "日付",
|
||||
"version": "バージョン",
|
||||
@@ -104,6 +105,7 @@
|
||||
"removeFromFavorites": "お気に入りから削除",
|
||||
"viewOnCivitai": "Civitaiで表示",
|
||||
"notAvailableFromCivitai": "Civitaiでは利用できません",
|
||||
"viewOnHuggingFace": "Hugging Face で見る",
|
||||
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
|
||||
"copyLoRASyntax": "LoRA構文をコピー",
|
||||
"checkpointNameCopied": "checkpointの名前をコピーしました",
|
||||
@@ -144,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用回数"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} バージョン",
|
||||
"viewAllVersions": "ローカルの全バージョンを表示"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -182,6 +188,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "除外モデルを管理"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "モデルでグループ化"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -194,13 +203,7 @@
|
||||
"statistics": "統計"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "検索...",
|
||||
"placeholders": {
|
||||
"loras": "LoRAを検索...",
|
||||
"recipes": "レシピを検索...",
|
||||
"checkpoints": "checkpointを検索...",
|
||||
"embeddings": "embeddingを検索..."
|
||||
},
|
||||
"placeholder": "検索",
|
||||
"options": "検索オプション",
|
||||
"searchIn": "検索対象:",
|
||||
"notAvailable": "統計ページでは検索は利用できません",
|
||||
@@ -250,7 +253,18 @@
|
||||
"toggle": "テーマの切り替え",
|
||||
"switchToLight": "ライトテーマに切り替え",
|
||||
"switchToDark": "ダークテーマに切り替え",
|
||||
"switchToAuto": "自動テーマに切り替え"
|
||||
"switchToAuto": "自動テーマに切り替え",
|
||||
"presets": "テーマプリセット",
|
||||
"default": "デフォルト",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "モード",
|
||||
"light": "ライト",
|
||||
"dark": "ダーク",
|
||||
"auto": "自動"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "更新確認",
|
||||
@@ -262,6 +276,9 @@
|
||||
"civitaiApiKey": "Civitai APIキー",
|
||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||
"civitaiApiKeyConfigured": "設定済み",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai ホスト",
|
||||
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
|
||||
@@ -302,6 +319,7 @@
|
||||
"downloads": "ダウンロード",
|
||||
"videoSettings": "動画設定",
|
||||
"layoutSettings": "レイアウト設定",
|
||||
"licenseIcons": "ライセンスアイコン",
|
||||
"misc": "その他",
|
||||
"backup": "バックアップ",
|
||||
"folderSettings": "デフォルトルート",
|
||||
@@ -309,7 +327,7 @@
|
||||
"extraFolderPaths": "追加フォルダーパス",
|
||||
"downloadPathTemplates": "ダウンロードパステンプレート",
|
||||
"priorityTags": "優先タグ",
|
||||
"updateFlags": "アップデートフラグ",
|
||||
"versionScope": "アップデートフラグ",
|
||||
"exampleImages": "例画像",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "メタデータ",
|
||||
@@ -414,6 +432,8 @@
|
||||
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "モデルでグループ化",
|
||||
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||
"displayDensity": "表示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "デフォルト",
|
||||
@@ -448,7 +468,9 @@
|
||||
"modelName": "モデル名",
|
||||
"fileName": "ファイル名"
|
||||
},
|
||||
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択"
|
||||
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択",
|
||||
"cardBlurAmount": "カードオーバーレイのぼかし",
|
||||
"cardBlurAmountHelp": "モデルカードとレシピカードのヘッダー・フッターオーバーレイのぼかし強度を調整します(0 = ぼかしなし、20 = 最大ぼかし)。"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "アクティブライブラリ",
|
||||
@@ -483,7 +505,9 @@
|
||||
"saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
|
||||
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "このパスはすでに設定されています"
|
||||
"duplicatePath": "このパスはすでに設定されています",
|
||||
"checkpointUnetOverlap": "checkpoints と diffusion models に同じパスは使用できません:{paths}",
|
||||
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。checkpoints と diffusion models には別々のフォルダを使用してください。"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -568,7 +592,7 @@
|
||||
"download": "ダウンロード",
|
||||
"restartRequired": "再起動が必要"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "アップデートフラグの表示戦略",
|
||||
"help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。",
|
||||
"options": {
|
||||
@@ -580,6 +604,10 @@
|
||||
"label": "早期アクセス更新を非表示",
|
||||
"help": "早期アクセスのみの更新"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "更新されたライセンスアイコンを使用",
|
||||
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
||||
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます",
|
||||
@@ -631,6 +659,32 @@
|
||||
"proxyPassword": "パスワード(任意)",
|
||||
"proxyPasswordPlaceholder": "パスワード",
|
||||
"proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "AIプロバイダー",
|
||||
"provider": "プロバイダー",
|
||||
"providerHelp": "LLMプロバイダーを選択してください。OpenAIとOllamaはプリセットのAPIエンドポイントを使用します。カスタムでは任意のOpenAI互換エンドポイントを指定できます。",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama(ローカル)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "カスタム(OpenAI 互換)"
|
||||
},
|
||||
"apiBase": "APIベースURL",
|
||||
"apiBaseHelp": "LLM APIのベースURL(例:https://api.openai.com/v1)。空の場合はプロバイダーのデフォルトが使用されます。",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "APIキー",
|
||||
"apiKeyHelp": "LLMプロバイダーのAPIキー。ローカルに保存され、選択したLLMプロバイダー以外のサーバーに送信されることはありません。",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "未設定",
|
||||
"apiKeyConfigured": "設定済み",
|
||||
"apiKeySet": "設定",
|
||||
"model": "モデル",
|
||||
"modelHelp": "使用するモデル名(例:deepseek-v4-flash, gemini-2.5-flash, gemma4:12b)。プロバイダーで利用可能なモデルをご確認ください。",
|
||||
"modelPlaceholder": "モデルを選択..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -648,7 +702,11 @@
|
||||
"sizeAsc": "小さい順",
|
||||
"usage": "使用回数",
|
||||
"usageDesc": "多い",
|
||||
"usageAsc": "少ない"
|
||||
"usageAsc": "少ない",
|
||||
"versionsCount": "ローカルバージョン数",
|
||||
"versionsCountDesc": "バージョン数の多い順",
|
||||
"versionsCountAsc": "バージョン数の少ない順",
|
||||
"versionIdDesc": "最新バージョン順"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "モデルリストを更新",
|
||||
@@ -724,12 +782,15 @@
|
||||
"completed": "完了:{success} 移動、{skipped} スキップ、{failures} 失敗",
|
||||
"complete": "自動整理が完了しました",
|
||||
"error": "エラー:{error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "HF メタデータをAIで補完"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitaiデータを更新",
|
||||
"checkUpdates": "更新確認",
|
||||
"relinkCivitai": "Civitaiに再リンク",
|
||||
"linkModel": "モデルをリンク",
|
||||
"linkCivitai": "Civitai にリンク",
|
||||
"linkHuggingFace": "HuggingFace にリンク",
|
||||
"copySyntax": "LoRA構文をコピー",
|
||||
"copyFilename": "モデルファイル名をコピー",
|
||||
"copyRecipeSyntax": "レシピ構文をコピー",
|
||||
@@ -748,7 +809,8 @@
|
||||
"shareRecipe": "レシピを共有",
|
||||
"viewAllLoras": "すべてのLoRAを表示",
|
||||
"downloadMissingLoras": "不足しているLoRAをダウンロード",
|
||||
"deleteRecipe": "レシピを削除"
|
||||
"deleteRecipe": "レシピを削除",
|
||||
"enrichHfAgent": "HF メタデータをAIで補完"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -953,10 +1015,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "ルート",
|
||||
"moreOptions": "その他のオプション",
|
||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||
"pinSidebar": "サイドバーを固定",
|
||||
"unpinSidebar": "サイドバーの固定を解除",
|
||||
"hideOnThisPage": "このページでサイドバーを非表示",
|
||||
"showSidebar": "サイドバーを表示",
|
||||
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
|
||||
@@ -997,6 +1056,18 @@
|
||||
"storage": "ストレージ",
|
||||
"insights": "インサイト"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "モデル総数",
|
||||
"totalStorage": "ストレージ合計",
|
||||
"totalGenerations": "生成回数合計",
|
||||
"usageRate": "使用率",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "ユニークタグ",
|
||||
"unusedModels": "未使用モデル",
|
||||
"avgUsesPerModel": "平均使用回数/モデル"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "最も使用されているLoRA",
|
||||
"mostUsedCheckpoints": "最も使用されているCheckpoint",
|
||||
@@ -1014,13 +1085,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": {
|
||||
@@ -1032,7 +1167,10 @@
|
||||
"titleWithType": "URLから{type}をダウンロード",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "1行に1つのCivitAIまたはCivArchive URLを入力してください。複数のURLを一括ダウンロードできます。",
|
||||
"urlHint": "1行に1つのCivitAI、CivArchive、またはHugging Face URLを入力してください。複数のURLを一括ダウンロードできます。",
|
||||
"selectHfFiles": "このリポジトリからダウンロードするファイルを選択してください:",
|
||||
"selectAll": "すべて選択",
|
||||
"fetchingRepoFiles": "リポジトリのファイルを取得中...",
|
||||
"locationPreview": "ダウンロード場所プレビュー",
|
||||
"useDefaultPath": "デフォルトパスを使用",
|
||||
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
|
||||
@@ -1061,13 +1199,17 @@
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "無効なCivitai URL形式",
|
||||
"noVersions": "このモデルの利用可能なバージョンがありません"
|
||||
"noVersions": "このモデルの利用可能なバージョンがありません",
|
||||
"mixedSources": "同じバッチ内でCivitAIとHugging FaceのURLを混在させることはできません。",
|
||||
"noModelFiles": "このリポジトリにモデルファイルが見つかりませんでした。"
|
||||
},
|
||||
"status": {
|
||||
"preparing": "ダウンロードを準備中...",
|
||||
"downloadedPreview": "プレビュー画像をダウンロードしました",
|
||||
"downloadingFile": "{type}ファイルをダウンロード中",
|
||||
"finalizing": "ダウンロードを完了中..."
|
||||
"finalizing": "ダウンロードを完了中...",
|
||||
"cancelling": "ダウンロードをキャンセル中...",
|
||||
"cancelled": "ダウンロードをキャンセルしました"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "現在のファイル:",
|
||||
@@ -1183,6 +1325,14 @@
|
||||
"pathPlaceholder": "フォルダパスを入力するか、下のツリーから選択...",
|
||||
"root": "ルート"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "HuggingFace にリンク",
|
||||
"infoText": "HuggingFace リポジトリの URL を貼り付けてモデルを関連付けます。AI によるメタデータ補完が有効になります。",
|
||||
"urlLabel": "HuggingFace リポジトリ URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "完全な HuggingFace リポジトリ URL を入力してください。",
|
||||
"confirmAction": "保存&リンク"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Civitaiに再リンク",
|
||||
"warning": "警告:",
|
||||
@@ -1212,6 +1362,8 @@
|
||||
"editVersionName": "バージョン名を編集",
|
||||
"viewOnCivitai": "Civitaiで表示",
|
||||
"viewOnCivitaiText": "Civitaiで表示",
|
||||
"viewOnHuggingFace": "Hugging Face で見る",
|
||||
"viewOnHuggingFaceText": "Hugging Face で見る",
|
||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"sendToWorkflow": "ComfyUI に送信",
|
||||
@@ -1237,7 +1389,10 @@
|
||||
"additionalNotes": "追加メモ",
|
||||
"notesHint": "Enterで保存、Shift+Enterで改行",
|
||||
"addNotesPlaceholder": "メモをここに追加...",
|
||||
"aboutThisVersion": "このバージョンについて"
|
||||
"aboutThisVersion": "このバージョンについて",
|
||||
"baseModelSearchPlaceholder": "ベースモデルを検索…",
|
||||
"baseModelSuggested": "おすすめ",
|
||||
"baseModelNoMatch": "該当するベースモデルがありません"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "メモが正常に保存されました",
|
||||
@@ -1396,6 +1551,21 @@
|
||||
"versionDeleted": "バージョンを削除しました"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "メタデータ取得サマリー",
|
||||
"statSuccess": "成功",
|
||||
"statFailed": "失敗",
|
||||
"statSkipped": "スキップ",
|
||||
"statTotal": "スキャン合計",
|
||||
"statDuration": "所要時間",
|
||||
"successMessage": "すべての{count}件の{type}を正常に更新しました",
|
||||
"failedItems": "失敗したアイテム ({count})",
|
||||
"close": "閉じる",
|
||||
"copyReport": "レポートをコピー",
|
||||
"downloadCsv": "CSVをダウンロード",
|
||||
"columnModelName": "モデル名",
|
||||
"columnError": "エラー"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1409,15 +1579,6 @@
|
||||
"duplicate": "このタグは既に存在します"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "キーボードナビゲーション:",
|
||||
"shortcuts": {
|
||||
"pageUp": "1ページ上にスクロール",
|
||||
"pageDown": "1ページ下にスクロール",
|
||||
"home": "トップにジャンプ",
|
||||
"end": "ボトムにジャンプ"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "初期化中",
|
||||
"message": "ワークスペースを準備中...",
|
||||
@@ -1507,12 +1668,15 @@
|
||||
"modelUpdated": "モデルがワークフローで更新されました",
|
||||
"modelFailed": "モデルノードの更新に失敗しました",
|
||||
"embeddingAdded": "Embeddingをワークフローに追加しました",
|
||||
"embeddingFailed": "Embeddingの追加に失敗しました"
|
||||
"embeddingFailed": "Embeddingの追加に失敗しました",
|
||||
"promptSent": "プロンプトをワークフローに送信しました",
|
||||
"promptFailed": "プロンプトの送信に失敗しました"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "レシピ",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "プロンプト",
|
||||
"replace": "置換",
|
||||
"append": "追加",
|
||||
"selectTargetNode": "ターゲットノードを選択",
|
||||
@@ -1699,6 +1863,7 @@
|
||||
"enterLoraName": "LoRA名または構文を入力してください",
|
||||
"reconnectedSuccessfully": "LoRAが正常に再接続されました",
|
||||
"reconnectFailed": "LoRA再接続エラー:{message}",
|
||||
"noPromptToSend": "送信するプロンプトがありません",
|
||||
"cannotSend": "レシピを送信できません:レシピIDがありません",
|
||||
"sendFailed": "レシピのワークフローへの送信に失敗しました",
|
||||
"sendError": "レシピのワークフロー送信エラー",
|
||||
@@ -1852,7 +2017,8 @@
|
||||
"imagesCompleted": "例画像 {action} が完了しました",
|
||||
"imagesFailed": "例画像 {action} が失敗しました",
|
||||
"loadError": "ダウンロード読み込みエラー:{message}",
|
||||
"downloadError": "ダウンロードエラー:{message}"
|
||||
"downloadError": "ダウンロードエラー:{message}",
|
||||
"downloadStopped": "ダウンロードをキャンセルしました"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
|
||||
@@ -1897,6 +2063,8 @@
|
||||
"contentRatingFailed": "コンテンツレーティングの設定に失敗しました:{message}",
|
||||
"relinkSuccess": "モデルがCivitaiに正常に再リンクされました",
|
||||
"relinkFailed": "エラー:{message}",
|
||||
"linkHfSuccess": "モデルを HuggingFace にリンクしました",
|
||||
"linkHfFailed": "エラー:{message}",
|
||||
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
|
||||
"noCivitaiInfo": "CivitAI情報が利用できません",
|
||||
"missingHash": "モデルハッシュが利用できません"
|
||||
@@ -1955,7 +2123,15 @@
|
||||
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
||||
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "クリップボードにコピーしました",
|
||||
"downloadStarted": "ダウンロードを開始しました"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "AIプロバイダーが設定されていません。設定 → AIプロバイダーで有効にしてください。",
|
||||
"enrichStarted": "AIでメタデータを補完中...",
|
||||
"enrichComplete": "メタデータの補完が完了しました:{{summary}}",
|
||||
"enrichFailed": "メタデータの補完に失敗しました:{{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
+213
-37
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"status": {
|
||||
"loading": "로딩 중...",
|
||||
"cancelling": "취소 중...",
|
||||
"unknown": "알 수 없음",
|
||||
"date": "날짜",
|
||||
"version": "버전",
|
||||
@@ -104,6 +105,7 @@
|
||||
"removeFromFavorites": "즐겨찾기에서 제거",
|
||||
"viewOnCivitai": "Civitai에서 보기",
|
||||
"notAvailableFromCivitai": "Civitai에서 사용할 수 없음",
|
||||
"viewOnHuggingFace": "Hugging Face에서 보기",
|
||||
"sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)",
|
||||
"copyLoRASyntax": "LoRA 문법 복사",
|
||||
"checkpointNameCopied": "Checkpoint 이름 복사됨",
|
||||
@@ -144,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "사용 횟수"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count}개 버전",
|
||||
"viewAllVersions": "모든 로컬 버전 보기"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -182,6 +188,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "제외된 모델 관리"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "모델별 그룹화"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -194,13 +203,7 @@
|
||||
"statistics": "통계"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "검색...",
|
||||
"placeholders": {
|
||||
"loras": "LoRA 검색...",
|
||||
"recipes": "레시피 검색...",
|
||||
"checkpoints": "Checkpoint 검색...",
|
||||
"embeddings": "Embedding 검색..."
|
||||
},
|
||||
"placeholder": "검색",
|
||||
"options": "검색 옵션",
|
||||
"searchIn": "검색 범위:",
|
||||
"notAvailable": "통계 페이지에서는 검색을 사용할 수 없습니다",
|
||||
@@ -250,7 +253,18 @@
|
||||
"toggle": "테마 토글",
|
||||
"switchToLight": "라이트 테마로 전환",
|
||||
"switchToDark": "다크 테마로 전환",
|
||||
"switchToAuto": "자동 테마로 전환"
|
||||
"switchToAuto": "자동 테마로 전환",
|
||||
"presets": "테마 프리셋",
|
||||
"default": "기본",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "모드",
|
||||
"light": "라이트",
|
||||
"dark": "다크",
|
||||
"auto": "자동"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "업데이트 확인",
|
||||
@@ -262,6 +276,9 @@
|
||||
"civitaiApiKey": "Civitai API 키",
|
||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||
"civitaiApiKeyConfigured": "설정됨",
|
||||
"civitaiApiKeyNotConfigured": "설정되지 않음",
|
||||
"civitaiApiKeySet": "설정",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 호스트",
|
||||
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
|
||||
@@ -302,6 +319,7 @@
|
||||
"downloads": "다운로드",
|
||||
"videoSettings": "비디오 설정",
|
||||
"layoutSettings": "레이아웃 설정",
|
||||
"licenseIcons": "라이선스 아이콘",
|
||||
"misc": "기타",
|
||||
"backup": "백업",
|
||||
"folderSettings": "기본 루트",
|
||||
@@ -309,7 +327,7 @@
|
||||
"extraFolderPaths": "추가 폴다 경로",
|
||||
"downloadPathTemplates": "다운로드 경로 템플릿",
|
||||
"priorityTags": "우선순위 태그",
|
||||
"updateFlags": "업데이트 표시",
|
||||
"versionScope": "업데이트 표시",
|
||||
"exampleImages": "예시 이미지",
|
||||
"autoOrganize": "자동 정리",
|
||||
"metadata": "메타데이터",
|
||||
@@ -414,6 +432,8 @@
|
||||
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "모델별 그룹화",
|
||||
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||
"displayDensity": "표시 밀도",
|
||||
"displayDensityOptions": {
|
||||
"default": "기본",
|
||||
@@ -448,7 +468,9 @@
|
||||
"modelName": "모델명",
|
||||
"fileName": "파일명"
|
||||
},
|
||||
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요"
|
||||
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요",
|
||||
"cardBlurAmount": "카드 오버레이 흐림 강도",
|
||||
"cardBlurAmountHelp": "모델 및 레시피 카드의 헤더와 푸터 오버레이 흐림 강도를 조정합니다 (0 = 흐림 없음, 20 = 최대 흐림)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "활성 라이브러리",
|
||||
@@ -483,7 +505,9 @@
|
||||
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
|
||||
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
|
||||
"duplicatePath": "이 경로는 이미 구성되어 있습니다",
|
||||
"checkpointUnetOverlap": "checkpoints와 diffusion models에 동일한 경로를 사용할 수 없습니다: {paths}",
|
||||
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -568,7 +592,7 @@
|
||||
"download": "다운로드",
|
||||
"restartRequired": "재시작 필요"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "업데이트 표시 전략",
|
||||
"help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.",
|
||||
"options": {
|
||||
@@ -580,6 +604,10 @@
|
||||
"label": "얼리 액세스 업데이트 숨기기",
|
||||
"help": "얼리 액세스 업데이트만"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
|
||||
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
||||
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다",
|
||||
@@ -631,6 +659,32 @@
|
||||
"proxyPassword": "비밀번호 (선택사항)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
"proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "AI 제공자",
|
||||
"provider": "제공자",
|
||||
"providerHelp": "LLM 제공자를 선택하세요. OpenAI와 Ollama는 사전 설정된 API 엔드포인트를 사용합니다. 사용자 정의를 선택하면 모든 OpenAI 호환 엔드포인트를 지정할 수 있습니다.",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama (로컬)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "사용자 정의 (OpenAI 호환)"
|
||||
},
|
||||
"apiBase": "API 기본 URL",
|
||||
"apiBaseHelp": "LLM API의 기본 URL입니다 (예: https://api.openai.com/v1). 비워두면 제공자 기본값이 사용됩니다.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "API 키",
|
||||
"apiKeyHelp": "LLM 제공자의 API 키입니다. 로컬에 저장되며 선택한 LLM 제공자 외의 서버로 전송되지 않습니다.",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "설정되지 않음",
|
||||
"apiKeyConfigured": "설정됨",
|
||||
"apiKeySet": "설정",
|
||||
"model": "모델",
|
||||
"modelHelp": "사용할 모델 이름 (예: deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). 제공자에서 사용 가능한 모델을 확인하세요.",
|
||||
"modelPlaceholder": "모델 선택..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -648,7 +702,11 @@
|
||||
"sizeAsc": "작은 순서",
|
||||
"usage": "사용 횟수",
|
||||
"usageDesc": "많은 순",
|
||||
"usageAsc": "적은 순"
|
||||
"usageAsc": "적은 순",
|
||||
"versionsCount": "로컬 버전 수",
|
||||
"versionsCountDesc": "버전 수 많은 순",
|
||||
"versionsCountAsc": "버전 수 적은 순",
|
||||
"versionIdDesc": "최신 버전순"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "모델 목록 새로고침",
|
||||
@@ -724,12 +782,15 @@
|
||||
"completed": "완료: {success}개 이동, {skipped}개 건너뜀, {failures}개 실패",
|
||||
"complete": "자동 정리 완료",
|
||||
"error": "오류: {error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "HF AI로 메타데이터 보강"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitai 데이터 새로고침",
|
||||
"checkUpdates": "업데이트 확인",
|
||||
"relinkCivitai": "Civitai에 다시 연결",
|
||||
"linkModel": "모델 연결",
|
||||
"linkCivitai": "Civitai에 연결",
|
||||
"linkHuggingFace": "HuggingFace에 연결",
|
||||
"copySyntax": "LoRA 문법 복사",
|
||||
"copyFilename": "모델 파일명 복사",
|
||||
"copyRecipeSyntax": "레시피 문법 복사",
|
||||
@@ -748,7 +809,8 @@
|
||||
"shareRecipe": "레시피 공유",
|
||||
"viewAllLoras": "모든 LoRA 보기",
|
||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||
"deleteRecipe": "레시피 삭제"
|
||||
"deleteRecipe": "레시피 삭제",
|
||||
"enrichHfAgent": "HF AI로 메타데이터 보강"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -953,10 +1015,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "루트",
|
||||
"moreOptions": "더 많은 옵션",
|
||||
"collapseAll": "모든 폴더 접기",
|
||||
"pinSidebar": "사이드바 고정",
|
||||
"unpinSidebar": "사이드바 고정 해제",
|
||||
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
|
||||
"showSidebar": "사이드바 표시",
|
||||
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
|
||||
@@ -997,6 +1056,18 @@
|
||||
"storage": "저장소",
|
||||
"insights": "인사이트"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "모델 총계",
|
||||
"totalStorage": "총 저장 공간",
|
||||
"totalGenerations": "총 생성 횟수",
|
||||
"usageRate": "사용률",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "고유 태그",
|
||||
"unusedModels": "미사용 모델",
|
||||
"avgUsesPerModel": "모델당 평균 사용"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "가장 많이 사용된 LoRA",
|
||||
"mostUsedCheckpoints": "가장 많이 사용된 Checkpoint",
|
||||
@@ -1014,13 +1085,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": {
|
||||
@@ -1032,7 +1167,10 @@
|
||||
"titleWithType": "URL에서 {type} 다운로드",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "한 줄에 하나의 CivitAI 또는 CivArchive URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
||||
"urlHint": "한 줄에 하나의 CivitAI, CivArchive 또는 Hugging Face URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
||||
"selectHfFiles": "이 저장소에서 다운로드할 파일을 선택하세요:",
|
||||
"selectAll": "모두 선택",
|
||||
"fetchingRepoFiles": "저장소 파일을 가져오는 중...",
|
||||
"locationPreview": "다운로드 위치 미리보기",
|
||||
"useDefaultPath": "기본 경로 사용",
|
||||
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
|
||||
@@ -1061,13 +1199,17 @@
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "잘못된 Civitai URL 형식",
|
||||
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
|
||||
"noVersions": "이 모델에 사용 가능한 버전이 없습니다",
|
||||
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face URL을 혼합할 수 없습니다.",
|
||||
"noModelFiles": "이 저장소에서 모델 파일을 찾을 수 없습니다."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "다운로드 준비 중...",
|
||||
"downloadedPreview": "미리보기 이미지 다운로드됨",
|
||||
"downloadingFile": "{type} 파일 다운로드 중",
|
||||
"finalizing": "다운로드 완료 중..."
|
||||
"finalizing": "다운로드 완료 중...",
|
||||
"cancelling": "다운로드 취소 중...",
|
||||
"cancelled": "다운로드가 취소되었습니다"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "현재 파일:",
|
||||
@@ -1183,6 +1325,14 @@
|
||||
"pathPlaceholder": "폴더 경로를 입력하거나 아래 트리에서 선택하세요...",
|
||||
"root": "루트"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "HuggingFace에 연결",
|
||||
"infoText": "HuggingFace 저장소 URL을 붙여넣어 모델을 연결합니다. AI 메타데이터 보강 기능을 사용할 수 있습니다.",
|
||||
"urlLabel": "HuggingFace 저장소 URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "전체 HuggingFace 저장소 URL을 입력하세요.",
|
||||
"confirmAction": "저장 및 연결"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Civitai에 다시 연결",
|
||||
"warning": "경고:",
|
||||
@@ -1212,6 +1362,8 @@
|
||||
"editVersionName": "버전명 편집",
|
||||
"viewOnCivitai": "Civitai에서 보기",
|
||||
"viewOnCivitaiText": "Civitai에서 보기",
|
||||
"viewOnHuggingFace": "Hugging Face에서 보기",
|
||||
"viewOnHuggingFaceText": "Hugging Face에서 보기",
|
||||
"viewCreatorProfile": "제작자 프로필 보기",
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"sendToWorkflow": "ComfyUI로 보내기",
|
||||
@@ -1237,7 +1389,10 @@
|
||||
"additionalNotes": "추가 메모",
|
||||
"notesHint": "Enter로 저장, Shift+Enter로 줄바꿈",
|
||||
"addNotesPlaceholder": "메모를 여기에 추가하세요...",
|
||||
"aboutThisVersion": "이 버전에 대해"
|
||||
"aboutThisVersion": "이 버전에 대해",
|
||||
"baseModelSearchPlaceholder": "베이스 모델 검색…",
|
||||
"baseModelSuggested": "추천",
|
||||
"baseModelNoMatch": "일치하는 베이스 모델 없음"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "메모가 성공적으로 저장됨",
|
||||
@@ -1396,6 +1551,21 @@
|
||||
"versionDeleted": "버전이 삭제되었습니다"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "메타데이터 가져오기 요약",
|
||||
"statSuccess": "성공",
|
||||
"statFailed": "실패",
|
||||
"statSkipped": "건너뜀",
|
||||
"statTotal": "총 스캔",
|
||||
"statDuration": "소요 시간",
|
||||
"successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다",
|
||||
"failedItems": "실패한 항목 ({count})",
|
||||
"close": "닫기",
|
||||
"copyReport": "보고서 복사",
|
||||
"downloadCsv": "CSV 다운로드",
|
||||
"columnModelName": "모델 이름",
|
||||
"columnError": "오류"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1409,15 +1579,6 @@
|
||||
"duplicate": "이 태그는 이미 존재합니다"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "키보드 내비게이션:",
|
||||
"shortcuts": {
|
||||
"pageUp": "한 페이지 위로 스크롤",
|
||||
"pageDown": "한 페이지 아래로 스크롤",
|
||||
"home": "맨 위로 이동",
|
||||
"end": "맨 아래로 이동"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "초기화 중",
|
||||
"message": "작업공간을 준비하고 있습니다...",
|
||||
@@ -1507,12 +1668,15 @@
|
||||
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
||||
"modelFailed": "모델 노드 업데이트 실패",
|
||||
"embeddingAdded": "Embedding을 워크플로에 추가했습니다",
|
||||
"embeddingFailed": "Embedding 추가 실패"
|
||||
"embeddingFailed": "Embedding 추가 실패",
|
||||
"promptSent": "프롬프트를 워크플로에 보냈습니다",
|
||||
"promptFailed": "프롬프트 보내기 실패"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "레시피",
|
||||
"lora": "LoRA",
|
||||
"embedding": "임베딩",
|
||||
"prompt": "프롬프트",
|
||||
"replace": "교체",
|
||||
"append": "추가",
|
||||
"selectTargetNode": "대상 노드 선택",
|
||||
@@ -1699,6 +1863,7 @@
|
||||
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
|
||||
"reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다",
|
||||
"reconnectFailed": "LoRA 다시 연결 오류: {message}",
|
||||
"noPromptToSend": "보낼 프롬프트가 없습니다",
|
||||
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
|
||||
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
|
||||
"sendError": "레시피를 워크플로로 전송하는 중 오류",
|
||||
@@ -1852,7 +2017,8 @@
|
||||
"imagesCompleted": "예시 이미지 {action}이(가) 완료되었습니다",
|
||||
"imagesFailed": "예시 이미지 {action}이(가) 실패했습니다",
|
||||
"loadError": "다운로드 로딩 오류: {message}",
|
||||
"downloadError": "다운로드 오류: {message}"
|
||||
"downloadError": "다운로드 오류: {message}",
|
||||
"downloadStopped": "다운로드가 취소되었습니다"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "폴더 트리 로딩 실패",
|
||||
@@ -1897,6 +2063,8 @@
|
||||
"contentRatingFailed": "콘텐츠 등급 설정 실패: {message}",
|
||||
"relinkSuccess": "모델이 Civitai에 성공적으로 다시 연결되었습니다",
|
||||
"relinkFailed": "오류: {message}",
|
||||
"linkHfSuccess": "모델이 HuggingFace에 연결되었습니다",
|
||||
"linkHfFailed": "오류: {message}",
|
||||
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
|
||||
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
|
||||
"missingHash": "모델 해시를 사용할 수 없습니다"
|
||||
@@ -1955,7 +2123,15 @@
|
||||
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
||||
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "클립보드에 복사됨",
|
||||
"downloadStarted": "다운로드 시작됨"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "AI 제공자가 설정되지 않았습니다. 설정 → AI 제공자에서 활성화하세요.",
|
||||
"enrichStarted": "AI로 메타데이터 보강 중...",
|
||||
"enrichComplete": "메타데이터 보강 완료: {{summary}}",
|
||||
"enrichFailed": "메타데이터 보강 실패: {{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
+213
-37
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"status": {
|
||||
"loading": "Загрузка...",
|
||||
"cancelling": "Отмена...",
|
||||
"unknown": "Неизвестно",
|
||||
"date": "Дата",
|
||||
"version": "Версия",
|
||||
@@ -104,6 +105,7 @@
|
||||
"removeFromFavorites": "Удалить из избранного",
|
||||
"viewOnCivitai": "Посмотреть на Civitai",
|
||||
"notAvailableFromCivitai": "Недоступно на Civitai",
|
||||
"viewOnHuggingFace": "Открыть Hugging Face",
|
||||
"sendToWorkflow": "Отправить в ComfyUI (Клик: Добавить, Shift+Клик: Заменить)",
|
||||
"copyLoRASyntax": "Копировать синтаксис LoRA",
|
||||
"checkpointNameCopied": "Имя checkpoint скопировано",
|
||||
@@ -144,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Количество использований"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} версий",
|
||||
"viewAllVersions": "Показать все локальные версии"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -182,6 +188,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Управление исключёнными моделями"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Группировать по модели"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -194,13 +203,7 @@
|
||||
"statistics": "Статистика"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск...",
|
||||
"placeholders": {
|
||||
"loras": "Поиск LoRAs...",
|
||||
"recipes": "Поиск рецептов...",
|
||||
"checkpoints": "Поиск checkpoints...",
|
||||
"embeddings": "Поиск embeddings..."
|
||||
},
|
||||
"placeholder": "Поиск",
|
||||
"options": "Опции поиска",
|
||||
"searchIn": "Искать в:",
|
||||
"notAvailable": "Поиск недоступен на странице статистики",
|
||||
@@ -250,7 +253,18 @@
|
||||
"toggle": "Переключить тему",
|
||||
"switchToLight": "Переключить на светлую тему",
|
||||
"switchToDark": "Переключить на тёмную тему",
|
||||
"switchToAuto": "Переключить на автоматическую тему"
|
||||
"switchToAuto": "Переключить на автоматическую тему",
|
||||
"presets": "Предустановки тем",
|
||||
"default": "По умолчанию",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Режим",
|
||||
"light": "Светлый",
|
||||
"dark": "Тёмный",
|
||||
"auto": "Авто"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Проверить обновления",
|
||||
@@ -262,6 +276,9 @@
|
||||
"civitaiApiKey": "Ключ API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
||||
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
||||
"civitaiApiKeyConfigured": "Настроен",
|
||||
"civitaiApiKeyNotConfigured": "Не настроен",
|
||||
"civitaiApiKeySet": "Настроить",
|
||||
"civitaiHost": {
|
||||
"label": "Хост Civitai",
|
||||
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
|
||||
@@ -302,6 +319,7 @@
|
||||
"downloads": "Загрузки",
|
||||
"videoSettings": "Настройки видео",
|
||||
"layoutSettings": "Настройки макета",
|
||||
"licenseIcons": "Значки лицензии",
|
||||
"misc": "Разное",
|
||||
"backup": "Резервные копии",
|
||||
"folderSettings": "Корневые папки",
|
||||
@@ -309,7 +327,7 @@
|
||||
"extraFolderPaths": "Дополнительные пути к папкам",
|
||||
"downloadPathTemplates": "Шаблоны путей загрузки",
|
||||
"priorityTags": "Приоритетные теги",
|
||||
"updateFlags": "Метки обновлений",
|
||||
"versionScope": "Метки обновлений",
|
||||
"exampleImages": "Примеры изображений",
|
||||
"autoOrganize": "Автоорганизация",
|
||||
"metadata": "Метаданные",
|
||||
@@ -414,6 +432,8 @@
|
||||
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Группировать по модели",
|
||||
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
|
||||
"displayDensity": "Плотность отображения",
|
||||
"displayDensityOptions": {
|
||||
"default": "По умолчанию",
|
||||
@@ -448,7 +468,9 @@
|
||||
"modelName": "Название модели",
|
||||
"fileName": "Имя файла"
|
||||
},
|
||||
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели"
|
||||
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели",
|
||||
"cardBlurAmount": "Размытие наложения карточек",
|
||||
"cardBlurAmountHelp": "Настройте интенсивность размытия наложений верхнего и нижнего колонтитулов на карточках моделей и рецептов (0 = без размытия, 20 = максимальное размытие)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Активная библиотека",
|
||||
@@ -483,7 +505,9 @@
|
||||
"saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
|
||||
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Этот путь уже настроен"
|
||||
"duplicatePath": "Этот путь уже настроен",
|
||||
"checkpointUnetOverlap": "Нельзя использовать один и тот же путь для checkpoints и diffusion models: {paths}",
|
||||
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -568,7 +592,7 @@
|
||||
"download": "Загрузить",
|
||||
"restartRequired": "Требует перезапуска"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Стратегия меток обновлений",
|
||||
"help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.",
|
||||
"options": {
|
||||
@@ -580,6 +604,10 @@
|
||||
"label": "Скрыть обновления раннего доступа",
|
||||
"help": "Только обновления раннего доступа"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Использовать обновлённые значки лицензии",
|
||||
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
||||
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена",
|
||||
@@ -631,6 +659,32 @@
|
||||
"proxyPassword": "Пароль (необязательно)",
|
||||
"proxyPasswordPlaceholder": "пароль",
|
||||
"proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "Поставщик ИИ",
|
||||
"provider": "Поставщик",
|
||||
"providerHelp": "Выберите поставщика LLM. OpenAI и Ollama используют предустановленные API-эндпоинты. Пользовательский позволяет указать любой совместимый с OpenAI эндпоинт.",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama (локальный)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "Пользовательский (совместимый с OpenAI)"
|
||||
},
|
||||
"apiBase": "Базовый URL API",
|
||||
"apiBaseHelp": "Базовый URL для LLM API (например, https://api.openai.com/v1). Оставьте пустым, чтобы использовать значение по умолчанию.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "API-ключ",
|
||||
"apiKeyHelp": "Ваш API-ключ поставщика LLM. Хранится локально и никогда не отправляется на другие серверы, кроме выбранного поставщика LLM.",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "Не задан",
|
||||
"apiKeyConfigured": "Настроен",
|
||||
"apiKeySet": "Настроить",
|
||||
"model": "Модель",
|
||||
"modelHelp": "Имя модели для использования (например, deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Проверьте доступные модели у вашего поставщика.",
|
||||
"modelPlaceholder": "Выберите модель..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -648,7 +702,11 @@
|
||||
"sizeAsc": "Наименьшим",
|
||||
"usage": "Число использований",
|
||||
"usageDesc": "Больше",
|
||||
"usageAsc": "Меньше"
|
||||
"usageAsc": "Меньше",
|
||||
"versionsCount": "Локальные версии",
|
||||
"versionsCountDesc": "Сначала больше версий",
|
||||
"versionsCountAsc": "Сначала меньше версий",
|
||||
"versionIdDesc": "Сначала новые версии"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список моделей",
|
||||
@@ -724,12 +782,15 @@
|
||||
"completed": "Завершено: {success} перемещено, {skipped} пропущено, {failures} не удалось",
|
||||
"complete": "Автоматическая организация завершена",
|
||||
"error": "Ошибка: {error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "Обогатить HF метаданные (ИИ)"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Обновить данные Civitai",
|
||||
"checkUpdates": "Проверить обновления",
|
||||
"relinkCivitai": "Пересвязать с Civitai",
|
||||
"linkModel": "Связать модель",
|
||||
"linkCivitai": "Пересвязать с Civitai",
|
||||
"linkHuggingFace": "Связать с HuggingFace",
|
||||
"copySyntax": "Копировать синтаксис LoRA",
|
||||
"copyFilename": "Копировать имя файла модели",
|
||||
"copyRecipeSyntax": "Копировать синтаксис рецепта",
|
||||
@@ -748,7 +809,8 @@
|
||||
"shareRecipe": "Поделиться рецептом",
|
||||
"viewAllLoras": "Посмотреть все LoRAs",
|
||||
"downloadMissingLoras": "Загрузить отсутствующие LoRAs",
|
||||
"deleteRecipe": "Удалить рецепт"
|
||||
"deleteRecipe": "Удалить рецепт",
|
||||
"enrichHfAgent": "Обогатить HF метаданные (ИИ)"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -953,10 +1015,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Корень",
|
||||
"moreOptions": "Дополнительные параметры",
|
||||
"collapseAll": "Свернуть все папки",
|
||||
"pinSidebar": "Закрепить боковую панель",
|
||||
"unpinSidebar": "Открепить боковую панель",
|
||||
"hideOnThisPage": "Скрыть боковую панель на этой странице",
|
||||
"showSidebar": "Показать боковую панель",
|
||||
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
|
||||
@@ -997,6 +1056,18 @@
|
||||
"storage": "Хранение",
|
||||
"insights": "Аналитика"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Всего моделей",
|
||||
"totalStorage": "Всего хранилища",
|
||||
"totalGenerations": "Всего генераций",
|
||||
"usageRate": "Коэффициент использования",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Контрольные точки",
|
||||
"embeddings": "Эмбеддинги",
|
||||
"uniqueTags": "Уникальные теги",
|
||||
"unusedModels": "Неиспользуемые модели",
|
||||
"avgUsesPerModel": "Сред. использований/модель"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "Наиболее используемые LoRAs",
|
||||
"mostUsedCheckpoints": "Наиболее используемые Checkpoints",
|
||||
@@ -1014,13 +1085,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": {
|
||||
@@ -1032,7 +1167,10 @@
|
||||
"titleWithType": "Скачать {type} по URL",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Введите один URL CivitAI или CivArchive в каждой строке. Поддерживается пакетная загрузка нескольких URL.",
|
||||
"urlHint": "Введите один URL CivitAI, CivArchive или Hugging Face в каждой строке. Поддерживает несколько URL для пакетной загрузки.",
|
||||
"selectHfFiles": "Выберите файл(ы) для загрузки из этого репозитория:",
|
||||
"selectAll": "Выбрать все",
|
||||
"fetchingRepoFiles": "Получение файлов репозитория...",
|
||||
"locationPreview": "Предпросмотр места загрузки",
|
||||
"useDefaultPath": "Использовать путь по умолчанию",
|
||||
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
|
||||
@@ -1061,13 +1199,17 @@
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Неверный формат URL Civitai",
|
||||
"noVersions": "Нет доступных версий для этой модели"
|
||||
"noVersions": "Нет доступных версий для этой модели",
|
||||
"mixedSources": "Нельзя смешивать URL-адреса CivitAI и Hugging Face в одном пакете.",
|
||||
"noModelFiles": "В этом репозитории не найдено файлов моделей."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "Подготовка загрузки...",
|
||||
"downloadedPreview": "Превью изображение загружено",
|
||||
"downloadingFile": "Загрузка файла {type}",
|
||||
"finalizing": "Завершение загрузки..."
|
||||
"finalizing": "Завершение загрузки...",
|
||||
"cancelling": "Отмена загрузки...",
|
||||
"cancelled": "Загрузка отменена"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Текущий файл:",
|
||||
@@ -1183,6 +1325,14 @@
|
||||
"pathPlaceholder": "Введите путь к папке или выберите из дерева ниже...",
|
||||
"root": "Корень"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Связать с HuggingFace",
|
||||
"infoText": "Вставьте URL репозитория HuggingFace, чтобы связать эту модель с её источником. Это позволит обогащать метаданные с помощью ИИ.",
|
||||
"urlLabel": "URL репозитория HuggingFace:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Введите полный URL репозитория HuggingFace.",
|
||||
"confirmAction": "Сохранить и связать"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Пересвязать с Civitai",
|
||||
"warning": "Предупреждение:",
|
||||
@@ -1212,6 +1362,8 @@
|
||||
"editVersionName": "Редактировать название версии",
|
||||
"viewOnCivitai": "Посмотреть на Civitai",
|
||||
"viewOnCivitaiText": "Посмотреть на Civitai",
|
||||
"viewOnHuggingFace": "Открыть Hugging Face",
|
||||
"viewOnHuggingFaceText": "Открыть Hugging Face",
|
||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||
"openFileLocation": "Открыть расположение файла",
|
||||
"sendToWorkflow": "Отправить в ComfyUI",
|
||||
@@ -1237,7 +1389,10 @@
|
||||
"additionalNotes": "Дополнительные заметки",
|
||||
"notesHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
|
||||
"addNotesPlaceholder": "Добавьте ваши заметки здесь...",
|
||||
"aboutThisVersion": "Об этой версии"
|
||||
"aboutThisVersion": "Об этой версии",
|
||||
"baseModelSearchPlaceholder": "Поиск базовой модели…",
|
||||
"baseModelSuggested": "Предполагаемые",
|
||||
"baseModelNoMatch": "Нет подходящих базовых моделей"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Заметки успешно сохранены",
|
||||
@@ -1396,6 +1551,21 @@
|
||||
"versionDeleted": "Версия удалена"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "Сводка получения метаданных",
|
||||
"statSuccess": "Успешно",
|
||||
"statFailed": "Ошибка",
|
||||
"statSkipped": "Пропущено",
|
||||
"statTotal": "Всего проверено",
|
||||
"statDuration": "Длительность",
|
||||
"successMessage": "Все {count} {type}s успешно обновлены",
|
||||
"failedItems": "Ошибочные элементы ({count})",
|
||||
"close": "Закрыть",
|
||||
"copyReport": "Копировать отчет",
|
||||
"downloadCsv": "Скачать CSV",
|
||||
"columnModelName": "Имя модели",
|
||||
"columnError": "Ошибка"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1409,15 +1579,6 @@
|
||||
"duplicate": "Этот тег уже существует"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Навигация с клавиатуры:",
|
||||
"shortcuts": {
|
||||
"pageUp": "Прокрутить на страницу вверх",
|
||||
"pageDown": "Прокрутить на страницу вниз",
|
||||
"home": "Перейти к началу",
|
||||
"end": "Перейти к концу"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Инициализация",
|
||||
"message": "Подготовка вашего рабочего пространства...",
|
||||
@@ -1507,12 +1668,15 @@
|
||||
"modelUpdated": "Модель обновлена в workflow",
|
||||
"modelFailed": "Не удалось обновить узел модели",
|
||||
"embeddingAdded": "Embedding добавлен в workflow",
|
||||
"embeddingFailed": "Не удалось добавить embedding"
|
||||
"embeddingFailed": "Не удалось добавить embedding",
|
||||
"promptSent": "Запрос отправлен в workflow",
|
||||
"promptFailed": "Не удалось отправить запрос"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Рецепт",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Эмбеддинг",
|
||||
"prompt": "Запрос",
|
||||
"replace": "Заменить",
|
||||
"append": "Добавить",
|
||||
"selectTargetNode": "Выберите целевой узел",
|
||||
@@ -1699,6 +1863,7 @@
|
||||
"enterLoraName": "Пожалуйста, введите название LoRA или синтаксис",
|
||||
"reconnectedSuccessfully": "LoRA успешно переподключена",
|
||||
"reconnectFailed": "Ошибка переподключения LoRA: {message}",
|
||||
"noPromptToSend": "Нет запроса для отправки",
|
||||
"cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта",
|
||||
"sendFailed": "Не удалось отправить рецепт в workflow",
|
||||
"sendError": "Ошибка отправки рецепта в workflow",
|
||||
@@ -1852,7 +2017,8 @@
|
||||
"imagesCompleted": "Примеры изображений {action} завершены",
|
||||
"imagesFailed": "Примеры изображений {action} не удались",
|
||||
"loadError": "Ошибка загрузки downloads: {message}",
|
||||
"downloadError": "Ошибка загрузки: {message}"
|
||||
"downloadError": "Ошибка загрузки: {message}",
|
||||
"downloadStopped": "Загрузка отменена"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Не удалось загрузить дерево папок",
|
||||
@@ -1897,6 +2063,8 @@
|
||||
"contentRatingFailed": "Не удалось установить рейтинг контента: {message}",
|
||||
"relinkSuccess": "Модель успешно пересвязана с Civitai",
|
||||
"relinkFailed": "Ошибка: {message}",
|
||||
"linkHfSuccess": "Модель успешно связана с HuggingFace",
|
||||
"linkHfFailed": "Ошибка: {message}",
|
||||
"fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI",
|
||||
"noCivitaiInfo": "Информация CivitAI недоступна",
|
||||
"missingHash": "Хеш модели недоступен"
|
||||
@@ -1955,7 +2123,15 @@
|
||||
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "Скопировано в буфер обмена",
|
||||
"downloadStarted": "Загрузка начата"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "Поставщик ИИ не настроен. Включите его в Настройки → Поставщик ИИ.",
|
||||
"enrichStarted": "Обогащение метаданных с помощью ИИ...",
|
||||
"enrichComplete": "Обогащение метаданных завершено: {{summary}}",
|
||||
"enrichFailed": "Ошибка обогащения метаданных: {{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
+217
-41
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"status": {
|
||||
"loading": "加载中...",
|
||||
"cancelling": "取消中...",
|
||||
"unknown": "未知",
|
||||
"date": "日期",
|
||||
"version": "版本",
|
||||
@@ -104,6 +105,7 @@
|
||||
"removeFromFavorites": "从收藏移除",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"notAvailableFromCivitai": "Civitai 上不可用",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
|
||||
"copyLoRASyntax": "复制 LoRA 语法",
|
||||
"checkpointNameCopied": "检查点名称已复制",
|
||||
@@ -144,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用次数"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} 个版本",
|
||||
"viewAllVersions": "查看所有本地版本"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -182,6 +188,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "按模型分组"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -194,13 +203,7 @@
|
||||
"statistics": "统计"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索...",
|
||||
"placeholders": {
|
||||
"loras": "搜索 LoRA...",
|
||||
"recipes": "搜索配方...",
|
||||
"checkpoints": "搜索 Checkpoint...",
|
||||
"embeddings": "搜索 Embedding..."
|
||||
},
|
||||
"placeholder": "搜索",
|
||||
"options": "搜索选项",
|
||||
"searchIn": "搜索范围:",
|
||||
"notAvailable": "统计页面不可用搜索",
|
||||
@@ -250,7 +253,18 @@
|
||||
"toggle": "切换主题",
|
||||
"switchToLight": "切换到浅色主题",
|
||||
"switchToDark": "切换到深色主题",
|
||||
"switchToAuto": "切换到自动主题"
|
||||
"switchToAuto": "切换到自动主题",
|
||||
"presets": "主题预设",
|
||||
"default": "默认",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "模式",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"auto": "自动"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "检查更新",
|
||||
@@ -262,6 +276,9 @@
|
||||
"civitaiApiKey": "Civitai API 密钥",
|
||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||
"civitaiApiKeyConfigured": "已配置",
|
||||
"civitaiApiKeyNotConfigured": "未配置",
|
||||
"civitaiApiKeySet": "设置",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站点",
|
||||
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
|
||||
@@ -302,6 +319,7 @@
|
||||
"downloads": "下载",
|
||||
"videoSettings": "视频设置",
|
||||
"layoutSettings": "布局设置",
|
||||
"licenseIcons": "许可协议图标",
|
||||
"misc": "其他",
|
||||
"backup": "备份",
|
||||
"folderSettings": "默认根目录",
|
||||
@@ -309,7 +327,7 @@
|
||||
"extraFolderPaths": "额外文件夹路径",
|
||||
"downloadPathTemplates": "下载路径模板",
|
||||
"priorityTags": "优先标签",
|
||||
"updateFlags": "更新标记",
|
||||
"versionScope": "版本范围",
|
||||
"exampleImages": "示例图片",
|
||||
"autoOrganize": "自动整理",
|
||||
"metadata": "元数据",
|
||||
@@ -414,6 +432,8 @@
|
||||
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分组",
|
||||
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||
"displayDensity": "显示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "默认",
|
||||
@@ -448,7 +468,9 @@
|
||||
"modelName": "模型名称",
|
||||
"fileName": "文件名"
|
||||
},
|
||||
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容"
|
||||
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容",
|
||||
"cardBlurAmount": "卡片叠加模糊强度",
|
||||
"cardBlurAmountHelp": "调整模型和配方卡片上页眉和页脚叠加层的模糊强度(0 = 无模糊,20 = 最大模糊)。"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "活动库",
|
||||
@@ -483,7 +505,9 @@
|
||||
"saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
|
||||
"saveError": "更新额外文件夹路径失败:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路径已配置"
|
||||
"duplicatePath": "此路径已配置",
|
||||
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路径:{paths}",
|
||||
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -568,18 +592,22 @@
|
||||
"download": "下载",
|
||||
"restartRequired": "需要重启"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "更新标记策略",
|
||||
"help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。",
|
||||
"versionGrouping": {
|
||||
"label": "版本分组",
|
||||
"help": "控制版本在 UI 中的分组方式:按基础模型分组或合并显示。同时影响更新徽章逻辑和版本列表的筛选行为。",
|
||||
"options": {
|
||||
"sameBase": "按基础模型匹配更新",
|
||||
"any": "显示任何可用更新"
|
||||
"sameBase": "按基础模型分组",
|
||||
"any": "显示所有版本"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "隐藏抢先体验更新",
|
||||
"help": "抢先体验更新"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版许可协议图标",
|
||||
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
||||
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词",
|
||||
@@ -631,6 +659,32 @@
|
||||
"proxyPassword": "密码 (可选)",
|
||||
"proxyPasswordPlaceholder": "密码",
|
||||
"proxyPasswordHelp": "代理认证的密码 (如果需要)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "AI 提供商",
|
||||
"provider": "提供商",
|
||||
"providerHelp": "选择您的 LLM 提供商。OpenAI 和 Ollama 使用预设的 API 端点。自定义允许您指定任何兼容 OpenAI 的端点。",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama(本地)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "自定义(OpenAI 兼容)"
|
||||
},
|
||||
"apiBase": "API 基础地址",
|
||||
"apiBaseHelp": "LLM API 的基础地址。选择预设或输入自定义地址,下拉框显示所有支持的提供商预设。",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "API 密钥",
|
||||
"apiKeyHelp": "LLM 提供商的 API 密钥。本地存储,除您选择的 LLM 提供商外不会发送到任何服务器。",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "未设置",
|
||||
"apiKeyConfigured": "已配置",
|
||||
"apiKeySet": "设置",
|
||||
"model": "模型",
|
||||
"modelHelp": "要使用的模型。从下拉框选择(从提供商获取)或输入自定义模型名称。",
|
||||
"modelPlaceholder": "选择一个模型..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -648,7 +702,11 @@
|
||||
"sizeAsc": "最小",
|
||||
"usage": "使用次数",
|
||||
"usageDesc": "最多",
|
||||
"usageAsc": "最少"
|
||||
"usageAsc": "最少",
|
||||
"versionsCount": "本地版本数",
|
||||
"versionsCountDesc": "版本数从多到少",
|
||||
"versionsCountAsc": "版本数从少到多",
|
||||
"versionIdDesc": "最新版本优先"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新模型列表",
|
||||
@@ -724,12 +782,15 @@
|
||||
"completed": "完成:已移动 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
|
||||
"complete": "自动整理已完成",
|
||||
"error": "错误:{error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "AI HF 元数据增强"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "刷新 Civitai 数据",
|
||||
"checkUpdates": "检查更新",
|
||||
"relinkCivitai": "重新关联到 Civitai",
|
||||
"linkModel": "链接模型",
|
||||
"linkCivitai": "链接到 Civitai",
|
||||
"linkHuggingFace": "链接到 HuggingFace",
|
||||
"copySyntax": "复制 LoRA 语法",
|
||||
"copyFilename": "复制模型文件名",
|
||||
"copyRecipeSyntax": "复制配方语法",
|
||||
@@ -748,7 +809,8 @@
|
||||
"shareRecipe": "分享配方",
|
||||
"viewAllLoras": "查看所有 LoRA",
|
||||
"downloadMissingLoras": "下载缺失的 LoRA",
|
||||
"deleteRecipe": "删除配方"
|
||||
"deleteRecipe": "删除配方",
|
||||
"enrichHfAgent": "AI HF 元数据增强"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -953,10 +1015,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "根目录",
|
||||
"moreOptions": "更多选项",
|
||||
"collapseAll": "折叠所有文件夹",
|
||||
"pinSidebar": "固定侧边栏",
|
||||
"unpinSidebar": "取消固定侧边栏",
|
||||
"hideOnThisPage": "隐藏此页面侧边栏",
|
||||
"showSidebar": "显示侧边栏",
|
||||
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
|
||||
@@ -997,6 +1056,18 @@
|
||||
"storage": "存储",
|
||||
"insights": "洞察"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "模型总数",
|
||||
"totalStorage": "总存储空间",
|
||||
"totalGenerations": "总生成次数",
|
||||
"usageRate": "使用率",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "唯一标签",
|
||||
"unusedModels": "未使用模型",
|
||||
"avgUsesPerModel": "平均使用次数/模型"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "最常用 LoRA",
|
||||
"mostUsedCheckpoints": "最常用 Checkpoint",
|
||||
@@ -1014,13 +1085,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": {
|
||||
@@ -1032,7 +1167,10 @@
|
||||
"titleWithType": "从 URL 下载 {type}",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "每行输入一个 CivitAI 或 CivArchive URL。支持批量下载多个 URL。",
|
||||
"urlHint": "每行输入一个 CivitAI、CivArchive 或 Hugging Face URL。支持批量下载多个 URL。",
|
||||
"selectHfFiles": "选择从此仓库下载的文件:",
|
||||
"selectAll": "全选",
|
||||
"fetchingRepoFiles": "正在获取仓库文件...",
|
||||
"locationPreview": "下载位置预览",
|
||||
"useDefaultPath": "使用默认路径",
|
||||
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
|
||||
@@ -1061,13 +1199,17 @@
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "无效的 Civitai URL 格式",
|
||||
"noVersions": "此模型没有可用版本"
|
||||
"noVersions": "此模型没有可用版本",
|
||||
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
|
||||
"noModelFiles": "在此仓库中未找到模型文件。"
|
||||
},
|
||||
"status": {
|
||||
"preparing": "正在准备下载...",
|
||||
"downloadedPreview": "预览图片已下载",
|
||||
"downloadingFile": "正在下载 {type} 文件",
|
||||
"finalizing": "正在完成下载..."
|
||||
"finalizing": "正在完成下载...",
|
||||
"cancelling": "取消下载中...",
|
||||
"cancelled": "下载已取消"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "当前文件:",
|
||||
@@ -1183,6 +1325,14 @@
|
||||
"pathPlaceholder": "输入文件夹路径或从下方树中选择...",
|
||||
"root": "根目录"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "链接到 HuggingFace",
|
||||
"infoText": "粘贴 HuggingFace 仓库 URL 以关联此模型。关联后可启用 AI 元数据增强功能。",
|
||||
"urlLabel": "HuggingFace 仓库 URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "请输入完整的 HuggingFace 仓库 URL。",
|
||||
"confirmAction": "保存并链接"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "重新关联到 Civitai",
|
||||
"warning": "警告:",
|
||||
@@ -1212,6 +1362,8 @@
|
||||
"editVersionName": "编辑版本名称",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"viewOnCivitaiText": "在 Civitai 查看",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||
"viewCreatorProfile": "查看创作者主页",
|
||||
"openFileLocation": "打开文件位置",
|
||||
"sendToWorkflow": "发送到 ComfyUI",
|
||||
@@ -1237,7 +1389,10 @@
|
||||
"additionalNotes": "附加备注",
|
||||
"notesHint": "回车保存,Shift+回车换行",
|
||||
"addNotesPlaceholder": "在此添加你的备注...",
|
||||
"aboutThisVersion": "关于此版本"
|
||||
"aboutThisVersion": "关于此版本",
|
||||
"baseModelSearchPlaceholder": "搜索基础模型…",
|
||||
"baseModelSuggested": "推荐",
|
||||
"baseModelNoMatch": "没有匹配的基础模型"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "备注保存成功",
|
||||
@@ -1396,6 +1551,21 @@
|
||||
"versionDeleted": "版本已删除"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "元数据获取摘要",
|
||||
"statSuccess": "成功",
|
||||
"statFailed": "失败",
|
||||
"statSkipped": "已跳过",
|
||||
"statTotal": "总计扫描",
|
||||
"statDuration": "耗时",
|
||||
"successMessage": "全部 {count} 个 {type} 更新成功!",
|
||||
"failedItems": "失败项目 ({count})",
|
||||
"close": "关闭",
|
||||
"copyReport": "复制报告",
|
||||
"downloadCsv": "下载 CSV",
|
||||
"columnModelName": "模型名称",
|
||||
"columnError": "错误"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1409,15 +1579,6 @@
|
||||
"duplicate": "该标签已存在"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "键盘导航:",
|
||||
"shortcuts": {
|
||||
"pageUp": "向上一页滚动",
|
||||
"pageDown": "向下一页滚动",
|
||||
"home": "跳到顶部",
|
||||
"end": "跳到底部"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "初始化",
|
||||
"message": "正在准备你的工作空间...",
|
||||
@@ -1507,12 +1668,15 @@
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型节点失败",
|
||||
"embeddingAdded": "Embedding 已追加到工作流",
|
||||
"embeddingFailed": "添加 Embedding 失败"
|
||||
"embeddingFailed": "添加 Embedding 失败",
|
||||
"promptSent": "提示词已发送到工作流",
|
||||
"promptFailed": "提示词发送失败"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "提示词",
|
||||
"replace": "替换",
|
||||
"append": "追加",
|
||||
"selectTargetNode": "选择目标节点",
|
||||
@@ -1699,6 +1863,7 @@
|
||||
"enterLoraName": "请输入 LoRA 名称或语法",
|
||||
"reconnectedSuccessfully": "LoRA 重新连接成功",
|
||||
"reconnectFailed": "LoRA 重新连接出错:{message}",
|
||||
"noPromptToSend": "没有可发送的提示词",
|
||||
"cannotSend": "无法发送配方:缺少配方 ID",
|
||||
"sendFailed": "发送配方到工作流失败",
|
||||
"sendError": "发送配方到工作流出错",
|
||||
@@ -1852,7 +2017,8 @@
|
||||
"imagesCompleted": "示例图片{action}完成",
|
||||
"imagesFailed": "示例图片{action}失败",
|
||||
"loadError": "加载下载项出错:{message}",
|
||||
"downloadError": "下载错误:{message}"
|
||||
"downloadError": "下载错误:{message}",
|
||||
"downloadStopped": "下载已取消"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "加载文件夹树失败",
|
||||
@@ -1897,6 +2063,8 @@
|
||||
"contentRatingFailed": "设置内容评级失败:{message}",
|
||||
"relinkSuccess": "模型已成功重新关联到 Civitai",
|
||||
"relinkFailed": "错误:{message}",
|
||||
"linkHfSuccess": "模型已成功链接到 HuggingFace",
|
||||
"linkHfFailed": "错误:{message}",
|
||||
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
|
||||
"noCivitaiInfo": "无 CivitAI 信息",
|
||||
"missingHash": "模型哈希不可用"
|
||||
@@ -1955,7 +2123,15 @@
|
||||
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
||||
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "已复制到剪贴板",
|
||||
"downloadStarted": "下载已开始"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "AI 提供商未配置。请在 设置 → AI 提供商 中进行配置。",
|
||||
"enrichStarted": "正在使用 AI 增强元数据...",
|
||||
"enrichComplete": "元数据增强完成:{{summary}}",
|
||||
"enrichFailed": "元数据增强失败:{{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
+213
-37
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"status": {
|
||||
"loading": "載入中...",
|
||||
"cancelling": "取消中...",
|
||||
"unknown": "未知",
|
||||
"date": "日期",
|
||||
"version": "版本",
|
||||
@@ -104,6 +105,7 @@
|
||||
"removeFromFavorites": "移除收藏",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"notAvailableFromCivitai": "Civitai 不提供",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
|
||||
"copyLoRASyntax": "複製 LoRA 語法",
|
||||
"checkpointNameCopied": "Checkpoint 名稱已複製",
|
||||
@@ -144,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用次數"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} 個版本",
|
||||
"viewAllVersions": "檢視所有本地版本"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -182,6 +188,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "按模型分組"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -194,13 +203,7 @@
|
||||
"statistics": "統計"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜尋...",
|
||||
"placeholders": {
|
||||
"loras": "搜尋 LoRA...",
|
||||
"recipes": "搜尋配方...",
|
||||
"checkpoints": "搜尋 checkpoint...",
|
||||
"embeddings": "搜尋 embedding..."
|
||||
},
|
||||
"placeholder": "搜尋",
|
||||
"options": "搜尋選項",
|
||||
"searchIn": "搜尋範圍:",
|
||||
"notAvailable": "統計頁面無法搜尋",
|
||||
@@ -250,7 +253,18 @@
|
||||
"toggle": "切換主題",
|
||||
"switchToLight": "切換至淺色主題",
|
||||
"switchToDark": "切換至深色主題",
|
||||
"switchToAuto": "自動主題"
|
||||
"switchToAuto": "自動主題",
|
||||
"presets": "主題預設",
|
||||
"default": "預設",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "模式",
|
||||
"light": "淺色",
|
||||
"dark": "深色",
|
||||
"auto": "自動"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "檢查更新",
|
||||
@@ -262,6 +276,9 @@
|
||||
"civitaiApiKey": "Civitai API 金鑰",
|
||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||
"civitaiApiKeyConfigured": "已設定",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站點",
|
||||
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
|
||||
@@ -302,6 +319,7 @@
|
||||
"downloads": "下載",
|
||||
"videoSettings": "影片設定",
|
||||
"layoutSettings": "版面設定",
|
||||
"licenseIcons": "許可協議圖標",
|
||||
"misc": "其他",
|
||||
"backup": "備份",
|
||||
"folderSettings": "預設根目錄",
|
||||
@@ -309,7 +327,7 @@
|
||||
"extraFolderPaths": "額外資料夾路徑",
|
||||
"downloadPathTemplates": "下載路徑範本",
|
||||
"priorityTags": "優先標籤",
|
||||
"updateFlags": "更新標記",
|
||||
"versionScope": "版本範圍",
|
||||
"exampleImages": "範例圖片",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "中繼資料",
|
||||
@@ -414,6 +432,8 @@
|
||||
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分組",
|
||||
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||
"displayDensity": "顯示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "預設",
|
||||
@@ -448,7 +468,9 @@
|
||||
"modelName": "模型名稱",
|
||||
"fileName": "檔案名稱"
|
||||
},
|
||||
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容"
|
||||
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容",
|
||||
"cardBlurAmount": "卡片疊加模糊強度",
|
||||
"cardBlurAmountHelp": "調整模型和配方卡片上頁首和頁尾疊加層的模糊強度(0 = 無模糊,20 = 最大模糊)。"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "使用中的資料庫",
|
||||
@@ -483,7 +505,9 @@
|
||||
"saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
|
||||
"saveError": "更新額外資料夾路徑失敗:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路徑已設定"
|
||||
"duplicatePath": "此路徑已設定",
|
||||
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路徑:{paths}",
|
||||
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -568,7 +592,7 @@
|
||||
"download": "下載",
|
||||
"restartRequired": "需要重新啟動"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "更新標記策略",
|
||||
"help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。",
|
||||
"options": {
|
||||
@@ -580,6 +604,10 @@
|
||||
"label": "隱藏搶先體驗更新",
|
||||
"help": "搶先體驗更新"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版許可協議圖標",
|
||||
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
||||
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞",
|
||||
@@ -631,6 +659,32 @@
|
||||
"proxyPassword": "密碼(選填)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
"proxyPasswordHelp": "代理驗證所需的密碼(如有需要)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "AI 提供者",
|
||||
"provider": "提供者",
|
||||
"providerHelp": "選擇您的 LLM 提供者。OpenAI 和 Ollama 使用預設 API 端點。自訂允許您指定任何相容 OpenAI 的端點。",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama(本地)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "自訂(OpenAI 相容)"
|
||||
},
|
||||
"apiBase": "API 基礎網址",
|
||||
"apiBaseHelp": "LLM API 的基礎網址。選擇預設或輸入自訂網址,下拉選單顯示所有支援的提供者預設。",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "API 金鑰",
|
||||
"apiKeyHelp": "LLM 提供者的 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會傳送到任何伺服器。",
|
||||
"apiKeyPlaceholder": "[TODO: Translate] sk-...",
|
||||
"apiKeyNotSet": "未設定",
|
||||
"apiKeyConfigured": "已設定",
|
||||
"apiKeySet": "設定",
|
||||
"model": "模型",
|
||||
"modelHelp": "要使用的模型。從下拉選單選擇(從提供者取得)或輸入自訂模型名稱。",
|
||||
"modelPlaceholder": "選擇一個模型..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -648,7 +702,11 @@
|
||||
"sizeAsc": "最小",
|
||||
"usage": "使用次數",
|
||||
"usageDesc": "最多",
|
||||
"usageAsc": "最少"
|
||||
"usageAsc": "最少",
|
||||
"versionsCount": "本地版本數",
|
||||
"versionsCountDesc": "版本數從多到少",
|
||||
"versionsCountAsc": "版本數從少到多",
|
||||
"versionIdDesc": "最新版本優先"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理模型列表",
|
||||
@@ -724,12 +782,15 @@
|
||||
"completed": "完成:已移動 {success},已略過 {skipped},失敗 {failures}",
|
||||
"complete": "自動整理完成",
|
||||
"error": "錯誤:{error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "AI HF 中繼資料增強"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "刷新 Civitai 資料",
|
||||
"checkUpdates": "檢查更新",
|
||||
"relinkCivitai": "重新連結 Civitai",
|
||||
"linkModel": "連結模型",
|
||||
"linkCivitai": "連結到 Civitai",
|
||||
"linkHuggingFace": "連結到 HuggingFace",
|
||||
"copySyntax": "複製 LoRA 語法",
|
||||
"copyFilename": "複製模型檔名",
|
||||
"copyRecipeSyntax": "複製配方語法",
|
||||
@@ -748,7 +809,8 @@
|
||||
"shareRecipe": "分享配方",
|
||||
"viewAllLoras": "檢視全部 LoRA",
|
||||
"downloadMissingLoras": "下載缺少的 LoRA",
|
||||
"deleteRecipe": "刪除配方"
|
||||
"deleteRecipe": "刪除配方",
|
||||
"enrichHfAgent": "AI HF 中繼資料增強"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -953,10 +1015,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "根目錄",
|
||||
"moreOptions": "更多選項",
|
||||
"collapseAll": "全部摺疊資料夾",
|
||||
"pinSidebar": "固定側邊欄",
|
||||
"unpinSidebar": "取消固定側邊欄",
|
||||
"hideOnThisPage": "隱藏此頁面側邊欄",
|
||||
"showSidebar": "顯示側邊欄",
|
||||
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
|
||||
@@ -997,6 +1056,18 @@
|
||||
"storage": "儲存空間",
|
||||
"insights": "洞察"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "模型總數",
|
||||
"totalStorage": "總儲存空間",
|
||||
"totalGenerations": "總生成次數",
|
||||
"usageRate": "使用率",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "唯一標籤",
|
||||
"unusedModels": "未使用模型",
|
||||
"avgUsesPerModel": "平均使用次數/模型"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "最常用的 LoRA",
|
||||
"mostUsedCheckpoints": "最常用的 Checkpoint",
|
||||
@@ -1014,13 +1085,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": {
|
||||
@@ -1032,7 +1167,10 @@
|
||||
"titleWithType": "從網址下載 {type}",
|
||||
"civitaiUrl": "Civitai 網址:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "每行輸入一個 CivitAI 或 CivArchive URL。支援批量下載多個 URL。",
|
||||
"urlHint": "每行輸入一個 CivitAI、CivArchive 或 Hugging Face URL。支援批量下載多個 URL。",
|
||||
"selectHfFiles": "選擇從此倉庫下載的檔案:",
|
||||
"selectAll": "全選",
|
||||
"fetchingRepoFiles": "正在獲取倉庫檔案...",
|
||||
"locationPreview": "下載位置預覽",
|
||||
"useDefaultPath": "使用預設路徑",
|
||||
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
|
||||
@@ -1061,13 +1199,17 @@
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Civitai 網址格式無效",
|
||||
"noVersions": "此模型無可用版本"
|
||||
"noVersions": "此模型無可用版本",
|
||||
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
|
||||
"noModelFiles": "在此倉庫中未找到模型檔案。"
|
||||
},
|
||||
"status": {
|
||||
"preparing": "準備下載中...",
|
||||
"downloadedPreview": "已下載預覽圖片",
|
||||
"downloadingFile": "正在下載 {type} 檔案",
|
||||
"finalizing": "完成下載中..."
|
||||
"finalizing": "完成下載中...",
|
||||
"cancelling": "取消下載中...",
|
||||
"cancelled": "下載已取消"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "目前檔案:",
|
||||
@@ -1183,6 +1325,14 @@
|
||||
"pathPlaceholder": "輸入資料夾路徑或從下方樹狀結構選擇...",
|
||||
"root": "根目錄"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "連結到 HuggingFace",
|
||||
"infoText": "貼上 HuggingFace 倉庫 URL 以關聯此模型。關聯後可啟用 AI 中繼資料增強功能。",
|
||||
"urlLabel": "HuggingFace 倉庫 URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "請輸入完整的 HuggingFace 倉庫 URL。",
|
||||
"confirmAction": "儲存並連結"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "重新連結至 Civitai",
|
||||
"warning": "警告:",
|
||||
@@ -1212,6 +1362,8 @@
|
||||
"editVersionName": "編輯版本名稱",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"viewOnCivitaiText": "在 Civitai 查看",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||
"viewCreatorProfile": "查看創作者個人檔案",
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"sendToWorkflow": "傳送到 ComfyUI",
|
||||
@@ -1237,7 +1389,10 @@
|
||||
"additionalNotes": "附加備註",
|
||||
"notesHint": "按 Enter 儲存,Shift+Enter 換行",
|
||||
"addNotesPlaceholder": "在此新增備註...",
|
||||
"aboutThisVersion": "關於此版本"
|
||||
"aboutThisVersion": "關於此版本",
|
||||
"baseModelSearchPlaceholder": "搜尋基礎模型…",
|
||||
"baseModelSuggested": "推薦",
|
||||
"baseModelNoMatch": "沒有符合的基礎模型"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "備註已儲存",
|
||||
@@ -1396,6 +1551,21 @@
|
||||
"versionDeleted": "已刪除此版本"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "元資料獲取摘要",
|
||||
"statSuccess": "成功",
|
||||
"statFailed": "失敗",
|
||||
"statSkipped": "已跳過",
|
||||
"statTotal": "總計掃描",
|
||||
"statDuration": "耗時",
|
||||
"successMessage": "全部 {count} 個 {type} 更新成功!",
|
||||
"failedItems": "失敗項目 ({count})",
|
||||
"close": "關閉",
|
||||
"copyReport": "複製報告",
|
||||
"downloadCsv": "下載 CSV",
|
||||
"columnModelName": "模型名稱",
|
||||
"columnError": "錯誤"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1409,15 +1579,6 @@
|
||||
"duplicate": "此標籤已存在"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "鍵盤導覽:",
|
||||
"shortcuts": {
|
||||
"pageUp": "向上捲動一頁",
|
||||
"pageDown": "向下捲動一頁",
|
||||
"home": "跳至頂部",
|
||||
"end": "跳至底部"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "初始化",
|
||||
"message": "正在準備您的工作區...",
|
||||
@@ -1507,12 +1668,15 @@
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型節點失敗",
|
||||
"embeddingAdded": "Embedding 已附加到工作流",
|
||||
"embeddingFailed": "傳送 Embedding 到工作流失敗"
|
||||
"embeddingFailed": "傳送 Embedding 到工作流失敗",
|
||||
"promptSent": "提示詞已發送到工作流",
|
||||
"promptFailed": "提示詞發送失敗"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "提示詞",
|
||||
"replace": "取代",
|
||||
"append": "附加",
|
||||
"selectTargetNode": "選擇目標節點",
|
||||
@@ -1699,6 +1863,7 @@
|
||||
"enterLoraName": "請輸入 LoRA 名稱或語法",
|
||||
"reconnectedSuccessfully": "LoRA 重新連結成功",
|
||||
"reconnectFailed": "LoRA 重新連結錯誤:{message}",
|
||||
"noPromptToSend": "沒有可發送的提示詞",
|
||||
"cannotSend": "無法傳送配方:缺少配方 ID",
|
||||
"sendFailed": "傳送配方到工作流失敗",
|
||||
"sendError": "傳送配方到工作流錯誤",
|
||||
@@ -1852,7 +2017,8 @@
|
||||
"imagesCompleted": "範例圖片{action}完成",
|
||||
"imagesFailed": "範例圖片{action}失敗",
|
||||
"loadError": "載入下載時發生錯誤:{message}",
|
||||
"downloadError": "下載錯誤:{message}"
|
||||
"downloadError": "下載錯誤:{message}",
|
||||
"downloadStopped": "下載已取消"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "載入資料夾樹狀結構失敗",
|
||||
@@ -1897,6 +2063,8 @@
|
||||
"contentRatingFailed": "設定內容分級失敗:{message}",
|
||||
"relinkSuccess": "模型已成功重新連結至 Civitai",
|
||||
"relinkFailed": "錯誤:{message}",
|
||||
"linkHfSuccess": "模型已成功連結到 HuggingFace",
|
||||
"linkHfFailed": "錯誤:{message}",
|
||||
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
|
||||
"noCivitaiInfo": "無 CivitAI 資訊",
|
||||
"missingHash": "模型雜湊不可用"
|
||||
@@ -1955,7 +2123,15 @@
|
||||
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
||||
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "已複製到剪貼簿",
|
||||
"downloadStarted": "下載已開始"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "AI 提供者尚未設定。請在 設定 → AI 提供者 中進行設定。",
|
||||
"enrichStarted": "正在使用 AI 增強中繼資料...",
|
||||
"enrichComplete": "中繼資料增強完成:{{summary}}",
|
||||
"enrichFailed": "中繼資料增強失敗:{{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
|
||||
+21
-4
@@ -8,6 +8,8 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
|
||||
import logging
|
||||
import json
|
||||
import urllib.parse
|
||||
import sys as _sys
|
||||
import types as _types
|
||||
import time
|
||||
|
||||
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
|
||||
@@ -175,8 +177,7 @@ class Config:
|
||||
|
||||
# Load extra folder paths from active library settings before symlink scan
|
||||
# so both primary and extra paths are discovered in a single pass.
|
||||
if not standalone_mode:
|
||||
self._load_extra_paths_from_settings()
|
||||
self._load_extra_paths_from_settings()
|
||||
|
||||
# Scan symbolic links during initialization
|
||||
self._initialize_symlink_mappings()
|
||||
@@ -191,7 +192,7 @@ class Config:
|
||||
Called during ``Config.__init__`` before the symlink scan so both primary and
|
||||
extra paths are discovered in a single pass. Mirrors the extra-path
|
||||
portion of ``_apply_library_paths`` without replacing the primary roots
|
||||
that were already resolved from ComfyUI's ``folder_paths``.
|
||||
that were already resolved via ``folder_paths.get_folder_paths``.
|
||||
"""
|
||||
try:
|
||||
from .services.settings_manager import get_settings_manager
|
||||
@@ -1380,4 +1381,20 @@ class Config:
|
||||
|
||||
|
||||
# Global config instance
|
||||
config = Config()
|
||||
# NOTE: Guard against re-import. When ServiceRegistry.get_lora_scanner() triggers
|
||||
# a fresh import of lora_scanner → config, we must NOT re-execute Config.__init__()
|
||||
# (which re-scans all roots, re-registers libraries, etc.).
|
||||
#
|
||||
# Strategy: store the config instance in a dedicated sentinel module
|
||||
# ('_lm_config_cache') that is NEVER removed from sys.modules (its key does
|
||||
# NOT start with 'py.'), so it survives re-imports of py.* modules.
|
||||
_CONFIG_SENTINEL = "_lm_config_cache"
|
||||
if _CONFIG_SENTINEL in _sys.modules:
|
||||
# Re-import: reuse the existing singleton from the sentinel.
|
||||
config: Config = _sys.modules[_CONFIG_SENTINEL].config # type: ignore[valid-type]
|
||||
else:
|
||||
config: Config = Config()
|
||||
# Register the sentinel so re-imports of py.config find us.
|
||||
_sentinel_mod = _types.ModuleType(_CONFIG_SENTINEL)
|
||||
_sentinel_mod.config = config
|
||||
_sys.modules[_CONFIG_SENTINEL] = _sentinel_mod
|
||||
|
||||
@@ -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.
|
||||
@@ -202,6 +208,10 @@ class LoraManager:
|
||||
# Initialize WebSocket manager
|
||||
await ServiceRegistry.get_websocket_manager()
|
||||
|
||||
# Preload LLM model catalog (background task, non-blocking)
|
||||
from .services.llm_service import LLMService
|
||||
await LLMService.get_instance()
|
||||
|
||||
# Initialize scanners in background
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
@@ -430,5 +440,21 @@ 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)
|
||||
|
||||
# Close shared aiohttp sessions to avoid "Unclosed client session" warnings
|
||||
try:
|
||||
from py.routes.handlers.hf_handlers import close_hf_api_session
|
||||
await close_hf_api_session()
|
||||
except Exception as exc:
|
||||
logger.debug("Error closing HF API session: %s", exc)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Metadata operations — thin in-process wrappers around LoRA Manager internal services.
|
||||
|
||||
All functions are simple Python async functions that delegate to the
|
||||
appropriate internal service. They use **relative imports** within the
|
||||
``py`` package, so ``sys.modules`` caching works normally and there is no
|
||||
risk of double import or circular dependencies.
|
||||
|
||||
Usage (in-process, primary)::
|
||||
|
||||
from py.metadata_ops import list_base_models, read_metadata
|
||||
|
||||
models = await list_base_models()
|
||||
meta = await read_metadata("/path/to/model.safetensors")
|
||||
|
||||
Usage (subprocess, debugging / external)::
|
||||
|
||||
python -m py.metadata_ops base-models list
|
||||
python -m py.metadata_ops metadata read /path/to/model.safetensors
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCANNER_TYPE_MAP: dict[str, str] = {
|
||||
"get_lora_scanner": "lora",
|
||||
"get_checkpoint_scanner": "checkpoint",
|
||||
"get_embedding_scanner": "embedding",
|
||||
}
|
||||
|
||||
SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())
|
||||
|
||||
|
||||
async def _find_model_entry(
|
||||
model_path: str,
|
||||
) -> tuple[object, object, str | None] | tuple[None, None, None]:
|
||||
"""Iterate all scanners and return the first (scanner, entry, getter_name)
|
||||
that owns *model_path*. Returns ``(None, None, None)`` when no scanner
|
||||
claims it.
|
||||
"""
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
normalized = os.path.normpath(model_path)
|
||||
for getter_name in SCANNER_GETTER_NAMES:
|
||||
getter = getattr(ServiceRegistry, getter_name, None)
|
||||
if getter is None:
|
||||
continue
|
||||
try:
|
||||
scanner = await getter()
|
||||
if scanner is None:
|
||||
continue
|
||||
cache = await scanner.get_cached_data()
|
||||
for entry in cache.raw_data:
|
||||
if os.path.normpath(entry.get("file_path", "")) == normalized:
|
||||
return scanner, entry, getter_name
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Scanner %s check failed for %s: %s",
|
||||
getter_name, model_path, exc,
|
||||
)
|
||||
return None, None, None
|
||||
|
||||
|
||||
async def _find_scanner_for_model(
|
||||
model_path: str,
|
||||
) -> tuple[object, object] | tuple[None, None]:
|
||||
"""Find the (scanner, cache_entry) responsible for *model_path*."""
|
||||
scanner, entry, _ = await _find_model_entry(model_path)
|
||||
return scanner, entry
|
||||
|
||||
|
||||
async def identify_model_type(model_path: str) -> str:
|
||||
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``, or
|
||||
``\"embedding\"``) for *model_path*.
|
||||
|
||||
Falls back to ``\"lora\"`` when unknown.
|
||||
"""
|
||||
_, _, getter_name = await _find_model_entry(model_path)
|
||||
return SCANNER_TYPE_MAP[getter_name] if getter_name else "lora"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def list_base_models(limit: int = 0) -> List[str]:
|
||||
"""Return all valid CivitAI base model names.
|
||||
|
||||
Uses ``CivitaiBaseModelService.get_base_models()`` which merges a
|
||||
hardcoded list (``SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS``) with remote
|
||||
models fetched from the CivitAI API. Never empty — the hardcoded
|
||||
fallback always provides a complete set.
|
||||
|
||||
The result is sorted alphabetically. Pass *limit* = 0 for all models.
|
||||
"""
|
||||
from ..services.civitai_base_model_service import (
|
||||
CivitaiBaseModelService,
|
||||
)
|
||||
|
||||
try:
|
||||
service = await CivitaiBaseModelService.get_instance()
|
||||
response = await service.get_base_models()
|
||||
names: List[str] = response.get("models", [])
|
||||
except Exception as exc:
|
||||
logger.warning("list_base_models failed: %s", exc)
|
||||
names = []
|
||||
if limit > 0:
|
||||
return names[:limit]
|
||||
return names
|
||||
|
||||
|
||||
async def read_metadata(model_path: str) -> Dict[str, Any]:
|
||||
"""Load the full metadata payload for *model_path* from disk.
|
||||
|
||||
Returns an empty dict when the metadata file does not exist or cannot
|
||||
be parsed — never raises.
|
||||
"""
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
|
||||
try:
|
||||
return await MetadataManager.load_metadata_payload(model_path) or {}
|
||||
except Exception as exc:
|
||||
logger.warning("read_metadata failed for %s: %s", model_path, exc)
|
||||
return {}
|
||||
|
||||
|
||||
async def apply_metadata_updates(
|
||||
model_path: str,
|
||||
updates: Dict[str, Any],
|
||||
) -> List[str]:
|
||||
"""Merge *updates* into the model's on-disk metadata and persist.
|
||||
|
||||
Returns the list of field names that actually changed.
|
||||
"""
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
|
||||
metadata = await read_metadata(model_path)
|
||||
updated_fields: List[str] = []
|
||||
for key, value in updates.items():
|
||||
old = metadata.get(key)
|
||||
if old != value:
|
||||
metadata[key] = value
|
||||
updated_fields.append(key)
|
||||
if updated_fields:
|
||||
await MetadataManager.save_metadata(model_path, metadata)
|
||||
return updated_fields
|
||||
|
||||
|
||||
async def download_preview(
|
||||
model_path: str,
|
||||
url: str,
|
||||
*,
|
||||
target_width: int = 480,
|
||||
quality: int = 85,
|
||||
) -> str | None:
|
||||
"""Download a preview image from *url*, optimise to .webp, and save it.
|
||||
|
||||
The output file is placed alongside the model file with a ``.webp``
|
||||
extension. Returns the local file path on success, ``None`` on failure.
|
||||
"""
|
||||
from ..services.downloader import get_downloader
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
|
||||
if not url or not url.strip():
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||
preview_dir = os.path.dirname(model_path)
|
||||
output_path = os.path.join(preview_dir, base_name + ".webp")
|
||||
|
||||
downloader = await get_downloader()
|
||||
|
||||
# Try in-memory download + optimise first
|
||||
success, content, _headers = await downloader.download_to_memory(
|
||||
url, use_auth=False,
|
||||
)
|
||||
if success and content:
|
||||
try:
|
||||
optimized_data, _ = ExifUtils.optimize_image(
|
||||
image_data=content,
|
||||
target_width=target_width,
|
||||
format="webp",
|
||||
quality=quality,
|
||||
preserve_metadata=False,
|
||||
)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(optimized_data)
|
||||
return output_path
|
||||
except Exception as exc:
|
||||
logger.warning("Preview optimisation failed, saving raw: %s", exc)
|
||||
# Fall through to raw save
|
||||
|
||||
# Fallback: download directly to file
|
||||
try:
|
||||
ok, _ = await downloader.download_file(url, output_path, use_auth=False)
|
||||
if ok:
|
||||
return output_path
|
||||
except Exception as exc:
|
||||
logger.warning("Preview fallback download failed for %s: %s", model_path, exc)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def refresh_cache(model_path: str) -> bool:
|
||||
"""Invalidate and reload the scanner cache entry for *model_path*.
|
||||
|
||||
Returns ``True`` when the model was found and the cache was refreshed.
|
||||
"""
|
||||
scanner, entry = await _find_scanner_for_model(model_path)
|
||||
if scanner is None:
|
||||
logger.warning("refresh_cache: no scanner found for %s", model_path)
|
||||
return False
|
||||
try:
|
||||
metadata = await read_metadata(model_path)
|
||||
if not metadata:
|
||||
logger.warning("refresh_cache: no metadata for %s", model_path)
|
||||
return False
|
||||
await scanner.update_single_model_cache(model_path, model_path, metadata)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("refresh_cache failed for %s: %s", model_path, exc)
|
||||
return False
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Subprocess entry point for ``metadata_ops`` (debugging / external use).
|
||||
|
||||
Usage::
|
||||
|
||||
python -m py.metadata_ops base-models list [--limit N]
|
||||
python -m py.metadata_ops metadata read <path>
|
||||
python -m py.metadata_ops metadata update <path> --json '{...}'
|
||||
python -m py.metadata_ops preview download <path> --url <url>
|
||||
python -m py.metadata_ops cache refresh <path>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="lmcli", description="LoRA Manager Agent CLI")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# base-models list
|
||||
base_models = sub.add_parser("base-models", aliases=["bm"])
|
||||
base_models_cmds = base_models.add_subparsers(dest="subcommand", required=True)
|
||||
base_models_list = base_models_cmds.add_parser("list")
|
||||
base_models_list.add_argument(
|
||||
"--limit", type=int, default=0, help="Max number of models (0 = all)"
|
||||
)
|
||||
|
||||
# metadata read
|
||||
meta = sub.add_parser("metadata", aliases=["md"])
|
||||
meta_cmds = meta.add_subparsers(dest="subcommand", required=True)
|
||||
meta_read = meta_cmds.add_parser("read")
|
||||
meta_read.add_argument("path", type=str, help="Model file path")
|
||||
|
||||
# metadata update
|
||||
meta_update = meta_cmds.add_parser("update")
|
||||
meta_update.add_argument("path", type=str, help="Model file path")
|
||||
meta_update.add_argument(
|
||||
"--json",
|
||||
type=str,
|
||||
required=True,
|
||||
help='JSON object of fields to update, e.g. \'{"base_model": "SDXL 1.0"}\'',
|
||||
)
|
||||
|
||||
# preview download
|
||||
prev = sub.add_parser("preview", aliases=["pv"])
|
||||
prev_cmds = prev.add_subparsers(dest="subcommand", required=True)
|
||||
prev_dl = prev_cmds.add_parser("download")
|
||||
prev_dl.add_argument("path", type=str, help="Model file path")
|
||||
prev_dl.add_argument("--url", type=str, required=True, help="Preview image URL")
|
||||
|
||||
# cache refresh
|
||||
cache = sub.add_parser("cache")
|
||||
cache_cmds = cache.add_subparsers(dest="subcommand", required=True)
|
||||
cache_refresh = cache_cmds.add_parser("refresh")
|
||||
cache_refresh.add_argument("path", type=str, help="Model file path")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
async def _run(args: argparse.Namespace) -> Any:
|
||||
from . import ( # lazy import so startup is fast
|
||||
list_base_models,
|
||||
read_metadata,
|
||||
apply_metadata_updates,
|
||||
download_preview,
|
||||
refresh_cache,
|
||||
)
|
||||
|
||||
cmd = args.command
|
||||
sub = args.subcommand
|
||||
|
||||
if cmd in ("base-models", "bm") and sub == "list":
|
||||
return await list_base_models(limit=args.limit)
|
||||
|
||||
if cmd in ("metadata", "md") and sub == "read":
|
||||
return await read_metadata(args.path)
|
||||
|
||||
if cmd in ("metadata", "md") and sub == "update":
|
||||
updates: Dict[str, Any] = json.loads(args.json)
|
||||
return await apply_metadata_updates(args.path, updates)
|
||||
|
||||
if cmd in ("preview", "pv") and sub == "download":
|
||||
return await download_preview(args.path, args.url)
|
||||
|
||||
if cmd == "cache" and sub == "refresh":
|
||||
return await refresh_cache(args.path)
|
||||
|
||||
raise ValueError(f"Unknown command: {cmd} {sub}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
result = asyncio.run(_run(args))
|
||||
# Always print as JSON so callers can parse reliably
|
||||
if isinstance(result, list):
|
||||
for item in result:
|
||||
print(item)
|
||||
elif isinstance(result, dict):
|
||||
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
|
||||
print()
|
||||
else:
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -16,6 +16,8 @@ IMG_EXTENSIONS = (
|
||||
".tif",
|
||||
".tiff",
|
||||
".webp",
|
||||
".avif",
|
||||
".jxl",
|
||||
".mp4"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""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
|
||||
|
||||
# Preview 404 is routine (file deleted from disk) — not worth a warning.
|
||||
logger_method = logger.warning
|
||||
if request.path.startswith("/api/lm/previews") and exc.status == 404:
|
||||
logger_method = logger.debug
|
||||
|
||||
logger_method(
|
||||
"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
-3
@@ -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]
|
||||
@@ -600,7 +608,7 @@ class SaveImageLM:
|
||||
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
||||
|
||||
# Generate filename with counter if needed
|
||||
base_filename = filename
|
||||
base_filename = filename.replace("%batch_num%", str(i))
|
||||
if add_counter_to_filename:
|
||||
# Use counter + i to ensure unique filenames for all images in batch
|
||||
current_counter = counter + i
|
||||
|
||||
@@ -123,24 +123,39 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
if model_hash_from_hashes:
|
||||
metadata["model_hash"] = model_hash_from_hashes
|
||||
|
||||
# Extract Lora hashes in alternative format
|
||||
# Extract Lora hashes in alternative format.
|
||||
# Run unconditionally (not just as fallback) so that
|
||||
# non-empty hashes from Lora hashes fill in the gaps left
|
||||
# by empty values in the Hashes JSON dict. Some WebUI
|
||||
# builds write real hash values only to Lora hashes and
|
||||
# leave the Hashes JSON values empty.
|
||||
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
||||
if not hashes_match and lora_hashes_match:
|
||||
if lora_hashes_match:
|
||||
try:
|
||||
lora_hashes_str = lora_hashes_match.group(1)
|
||||
lora_hash_entries = lora_hashes_str.split(', ')
|
||||
|
||||
# Initialize hashes dict if it doesn't exist
|
||||
if "hashes" not in metadata:
|
||||
metadata["hashes"] = {}
|
||||
|
||||
|
||||
# Parse each lora hash entry (format: "name: hash")
|
||||
for entry in lora_hash_entries:
|
||||
if ': ' in entry:
|
||||
lora_name, lora_hash = entry.split(': ', 1)
|
||||
# Add as lora type in the same format as regular hashes
|
||||
metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip()
|
||||
|
||||
lora_hash = lora_hash.strip()
|
||||
if not lora_hash:
|
||||
# Skip entries without a hash value
|
||||
continue
|
||||
# Initialize hashes dict if it doesn't exist
|
||||
if "hashes" not in metadata:
|
||||
metadata["hashes"] = {}
|
||||
# Add as lora type in the same format as
|
||||
# regular hashes. Only override an
|
||||
# existing entry if its value is empty
|
||||
# (Lora hashes is the more reliable
|
||||
# source when Hashes JSON has blanks).
|
||||
key = f"lora:{lora_name}"
|
||||
existing = metadata["hashes"].get(key, "")
|
||||
if not existing:
|
||||
metadata["hashes"][key] = lora_hash
|
||||
|
||||
# Remove lora hashes from params section
|
||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||
except Exception as e:
|
||||
@@ -362,6 +377,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
# Only process lora or hypernet types
|
||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||
continue
|
||||
|
||||
# Skip entries without a hash value — they can't be
|
||||
# resolved via CivitAI and would only produce a
|
||||
# useless "Deleted" entry in the recipe.
|
||||
if not lora_hash:
|
||||
continue
|
||||
|
||||
lora_type, lora_name = hash_key.split(':', 1)
|
||||
|
||||
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
# Try to get info from Civitai
|
||||
if metadata_provider:
|
||||
try:
|
||||
if lora_hash:
|
||||
# If we have hash, use it for lookup
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
else:
|
||||
civitai_info = None
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
|
||||
@@ -514,11 +514,21 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# Process modelVersionIds from Civitai image API
|
||||
# These are model version IDs returned at root level when meta doesn't contain resources
|
||||
if "modelVersionIds" in metadata and isinstance(
|
||||
metadata["modelVersionIds"], list
|
||||
# Process modelVersionIds from Civitai image API.
|
||||
# These are version IDs returned at root level of the API response.
|
||||
# When resources or civitaiResources are already present in metadata
|
||||
# (which they are when ?withMeta=true is passed), those sections have
|
||||
# complete hash/type information — modelVersionIds is a fallback for
|
||||
# when meta is null and only the flat ID list is available. Skipping
|
||||
# it here avoids duplicates: the same file hash often resolves to
|
||||
# different version IDs via hash lookup (resources) vs the original
|
||||
# version ID in modelVersionIds, and both paths would create entries.
|
||||
if (
|
||||
"modelVersionIds" in metadata
|
||||
and isinstance(metadata["modelVersionIds"], list)
|
||||
and not result.get("loras")
|
||||
):
|
||||
|
||||
for version_id in metadata["modelVersionIds"]:
|
||||
version_id_str = str(version_id)
|
||||
|
||||
@@ -526,6 +536,13 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
if version_id_str in added_loras:
|
||||
continue
|
||||
|
||||
# Skip if this version ID is already the recipe's checkpoint
|
||||
# (resolved earlier from embedded resources/Model hash,
|
||||
# avoiding a duplicate CivitAI API call).
|
||||
existing_model = result.get("model")
|
||||
if existing_model and str(existing_model.get("id")) == version_id_str:
|
||||
continue
|
||||
|
||||
# Initialize lora entry with version ID
|
||||
lora_entry = {
|
||||
"id": version_id,
|
||||
@@ -559,9 +576,40 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
)
|
||||
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
# Not a LoRA — try as checkpoint (only if we
|
||||
# don't already have one). Reuses the same
|
||||
# civitai_info from the API call above so no
|
||||
# extra query is made.
|
||||
if result["model"] is None:
|
||||
checkpoint_entry = {
|
||||
"id": version_id,
|
||||
"modelId": 0,
|
||||
"name": "Unknown Model",
|
||||
"version": "",
|
||||
"type": "checkpoint",
|
||||
"existsLocally": False,
|
||||
"localPath": None,
|
||||
"file_name": "",
|
||||
"hash": "",
|
||||
"thumbnailUrl": (
|
||||
"/loras_static/images/no-preview.png"
|
||||
),
|
||||
"baseModel": "",
|
||||
"size": 0,
|
||||
"downloadUrl": "",
|
||||
"isDeleted": False,
|
||||
}
|
||||
cp_populated = await (
|
||||
self.populate_checkpoint_from_civitai(
|
||||
checkpoint_entry, civitai_info
|
||||
)
|
||||
)
|
||||
if cp_populated.get("modelId"):
|
||||
result["model"] = cp_populated
|
||||
continue # Not a LoRA, don't add to loras
|
||||
|
||||
lora_entry = populated_entry
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error fetching Civitai info for model version {version_id}: {e}"
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""HTTP route handlers for agent skill endpoints.
|
||||
|
||||
These handlers expose the :class:`AgentService` via HTTP, allowing the
|
||||
frontend to list available skills and execute them on selected models.
|
||||
Progress is reported via WebSocket broadcast.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...services.agent import AgentService, AgentProgressReporter
|
||||
from ...services.llm_service import LLMNotConfiguredError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentHandler:
|
||||
"""HTTP handler for agent skill operations."""
|
||||
|
||||
def __init__(self, agent_service: AgentService | None = None) -> None:
|
||||
self._agent_service = agent_service
|
||||
|
||||
async def _ensure_service(self) -> AgentService:
|
||||
if self._agent_service is None:
|
||||
self._agent_service = await AgentService.get_instance()
|
||||
return self._agent_service
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/lm/agent/skills
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_agent_skills(self, request: web.Request) -> web.Response:
|
||||
"""Return a list of available agent skills."""
|
||||
|
||||
service = await self._ensure_service()
|
||||
skills = await service.list_skills()
|
||||
return web.json_response({"skills": skills})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/lm/agent/execute/{skill_name}
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def execute_agent_skill(self, request: web.Request) -> web.Response:
|
||||
"""Execute an agent skill on the provided model paths.
|
||||
|
||||
Request body::
|
||||
|
||||
{"model_paths": ["/path/to/model1.safetensors", ...], "options": {}}
|
||||
|
||||
Returns immediately with a task ID. Execution runs in the
|
||||
background; progress and completion are pushed via WebSocket
|
||||
events of type ``agent_progress``.
|
||||
"""
|
||||
|
||||
skill_name = request.match_info.get("skill_name", "")
|
||||
if not skill_name:
|
||||
return web.json_response(
|
||||
{"error": "Skill name is required"}, status=400
|
||||
)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
|
||||
model_paths = body.get("model_paths", [])
|
||||
if not model_paths or not isinstance(model_paths, list):
|
||||
return web.json_response(
|
||||
{"error": "model_paths must be a non-empty array"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
service = await self._ensure_service()
|
||||
|
||||
# Validate LLM configuration early for skills that need it
|
||||
# (fail fast rather than after starting background work)
|
||||
try:
|
||||
from ...services.llm_service import LLMService
|
||||
|
||||
llm = await LLMService.get_instance()
|
||||
if not llm.is_configured():
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "LLM provider is not configured. "
|
||||
"Enable it in Settings → AI Provider.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to check LLM configuration: %s", exc)
|
||||
|
||||
# Launch execution in the background
|
||||
progress_reporter = AgentProgressReporter()
|
||||
logger.info(
|
||||
"LLM enrichment '%s' starting for %d model(s)",
|
||||
skill_name, len(model_paths),
|
||||
)
|
||||
|
||||
async def _run() -> None:
|
||||
try:
|
||||
result = await service.execute_skill(
|
||||
skill_name=skill_name,
|
||||
input_data={"model_paths": model_paths},
|
||||
progress_callback=progress_reporter,
|
||||
)
|
||||
logger.info(
|
||||
"LLM enrichment '%s' finished: success=%s, summary='%s', errors=%s",
|
||||
skill_name, result.success, result.summary, result.errors,
|
||||
)
|
||||
except LLMNotConfiguredError as exc:
|
||||
logger.warning("LLM enrichment '%s' not configured: %s", skill_name, exc)
|
||||
await progress_reporter.on_progress(
|
||||
{
|
||||
"type": "agent_progress",
|
||||
"skill": skill_name,
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("LLM enrichment '%s' failed: %s", skill_name, exc, exc_info=True)
|
||||
await progress_reporter.on_progress(
|
||||
{
|
||||
"type": "agent_progress",
|
||||
"skill": skill_name,
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
|
||||
# Fire and forget — progress comes via WebSocket
|
||||
asyncio.create_task(_run())
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "started",
|
||||
"skill": skill_name,
|
||||
"model_count": len(model_paths),
|
||||
}
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/lm/agent/cancel
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def cancel_agent_skill(self, request: web.Request) -> web.Response:
|
||||
"""Cancel a running agent skill.
|
||||
|
||||
NOTE: Cancellation is a stub for now — the AgentService processes
|
||||
models sequentially and does not yet support mid-execution
|
||||
cancellation. This endpoint exists for API completeness.
|
||||
"""
|
||||
|
||||
# TODO: implement cooperative cancellation in AgentService
|
||||
return web.json_response(
|
||||
{"status": "acknowledged", "note": "Cancellation not yet implemented"},
|
||||
status=200,
|
||||
)
|
||||
@@ -0,0 +1,508 @@
|
||||
"""Handlers for Hugging Face model listing and download.
|
||||
|
||||
Minimal MVP implementation — uses direct HTTP to the HF API for file
|
||||
listing and the project's existing aiohttp-based Downloader for
|
||||
downloading. No huggingface_hub dependency required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
from ...config import config
|
||||
from ...services.downloader import (
|
||||
DownloadProgress,
|
||||
get_downloader,
|
||||
)
|
||||
from ...services.aria2_downloader import Aria2Downloader
|
||||
from ...services.settings_manager import get_settings_manager
|
||||
from ...services.service_registry import ServiceRegistry
|
||||
from ...services.websocket_manager import ws_manager
|
||||
from ...utils.constants import MODEL_FILE_EXTENSIONS
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
from ...utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_MODEL_CLASS = LoraMetadata
|
||||
_DEFAULT_SCANNER_GETTER = "get_lora_scanner"
|
||||
|
||||
# Shared aiohttp session for HF API calls (created on first use)
|
||||
_hf_api_session: aiohttp.ClientSession | None = None
|
||||
|
||||
|
||||
async def _get_hf_api_session() -> aiohttp.ClientSession:
|
||||
"""Get or create the shared aiohttp session for HF API calls."""
|
||||
global _hf_api_session # needed because we reassign the module-level name
|
||||
if _hf_api_session is None or _hf_api_session.closed:
|
||||
_hf_api_session = aiohttp.ClientSession(
|
||||
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return _hf_api_session
|
||||
|
||||
|
||||
async def close_hf_api_session() -> None:
|
||||
"""Close the shared HF API session, if it was ever created."""
|
||||
global _hf_api_session
|
||||
if _hf_api_session is not None and not _hf_api_session.closed:
|
||||
await _hf_api_session.close()
|
||||
_hf_api_session = None
|
||||
|
||||
|
||||
def _infer_model_type(model_root: str) -> tuple[Any, str]:
|
||||
"""Determine model class and scanner by matching ``model_root`` against the
|
||||
configured root paths for each model type (from ``Config``).
|
||||
|
||||
The ``model_root`` value comes from the frontend's model-root dropdown,
|
||||
which is populated from the current page's scanner roots. By checking
|
||||
which scanner's root list it belongs to, we avoid fragile heuristics
|
||||
like substring-matching path names.
|
||||
"""
|
||||
norm = os.path.normpath(model_root).replace(os.sep, "/")
|
||||
|
||||
# LoRA roots
|
||||
for p in (config.loras_roots or []) + (config.extra_loras_roots or []):
|
||||
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||
return LoraMetadata, "get_lora_scanner"
|
||||
|
||||
# Checkpoint / UNet roots
|
||||
for p in (
|
||||
(config.checkpoints_roots or [])
|
||||
+ (config.extra_checkpoints_roots or [])
|
||||
+ (config.unet_roots or [])
|
||||
+ (config.extra_unet_roots or [])
|
||||
):
|
||||
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||
return CheckpointMetadata, "get_checkpoint_scanner"
|
||||
|
||||
# Embedding roots
|
||||
for p in (config.embeddings_roots or []) + (config.extra_embeddings_roots or []):
|
||||
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||
return EmbeddingMetadata, "get_embedding_scanner"
|
||||
|
||||
# Fallback — should not happen in normal use
|
||||
logger.warning(
|
||||
"Could not determine model type for root '%s'; defaulting to LoRA",
|
||||
model_root,
|
||||
)
|
||||
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
|
||||
|
||||
|
||||
async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
|
||||
"""Create a proper .metadata.json and add the model to the scanner cache.
|
||||
|
||||
Uses ``MetadataManager.create_default_metadata()`` which computes the
|
||||
SHA256 hash, extracts safetensors header metadata (base_model), and
|
||||
produces a fully-populated ``LoraMetadata`` (or ``CheckpointMetadata`` /
|
||||
``EmbeddingMetadata``) object. We then overlay HF-specific fields and
|
||||
register the model in the in-memory scanner cache so it appears
|
||||
immediately without a full filesystem walk.
|
||||
"""
|
||||
try:
|
||||
hf_url = f"https://huggingface.co/{repo}"
|
||||
model_class, scanner_getter_name = _infer_model_type(model_root)
|
||||
|
||||
# 1. Create proper metadata (computes SHA256, reads safetensors headers)
|
||||
metadata = await MetadataManager.create_default_metadata(
|
||||
dest_path, model_class=model_class
|
||||
)
|
||||
if metadata is None:
|
||||
logger.warning("create_default_metadata returned None for %s", dest_path)
|
||||
return
|
||||
|
||||
# 2. Overlay HF-specific fields
|
||||
metadata._unknown_fields["hf_url"] = hf_url
|
||||
metadata.from_civitai = False # HF models are not from CivitAI
|
||||
|
||||
metadata_dict = metadata.to_dict()
|
||||
if "trainedWords" in metadata_dict and not metadata_dict["trainedWords"]:
|
||||
del metadata_dict["trainedWords"]
|
||||
|
||||
# 3. Save metadata atomically
|
||||
await MetadataManager.save_metadata(dest_path, metadata_dict)
|
||||
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
|
||||
|
||||
# 4. Determine relative folder path for cache
|
||||
# model_root is an absolute path; dest_path is under it
|
||||
folder = ""
|
||||
if os.path.isabs(model_root) and dest_path.startswith(model_root):
|
||||
rel = os.path.relpath(os.path.dirname(dest_path), model_root)
|
||||
folder = rel.replace(os.sep, "/") if rel != "." else ""
|
||||
|
||||
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
|
||||
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
|
||||
if scanner_getter is not None:
|
||||
scanner = await scanner_getter()
|
||||
if scanner is not None:
|
||||
metadata_dict = metadata.to_dict()
|
||||
metadata_dict["hf_url"] = hf_url
|
||||
await scanner.add_model_to_cache(metadata_dict, folder)
|
||||
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save HF metadata for %s: %s", dest_path, exc)
|
||||
|
||||
|
||||
def _find_matching_root(dest_dir: str) -> str | None:
|
||||
"""Walk up *dest_dir* to find which configured scanner root it belongs to."""
|
||||
norm = os.path.normpath(dest_dir).replace(os.sep, "/")
|
||||
all_roots = []
|
||||
for root_list in (
|
||||
config.loras_roots or [],
|
||||
config.extra_loras_roots or [],
|
||||
config.checkpoints_roots or [],
|
||||
config.extra_checkpoints_roots or [],
|
||||
config.unet_roots or [],
|
||||
config.extra_unet_roots or [],
|
||||
config.embeddings_roots or [],
|
||||
config.extra_embeddings_roots or [],
|
||||
):
|
||||
all_roots.extend([os.path.normpath(p).replace(os.sep, "/") for p in root_list])
|
||||
# Find the longest matching prefix
|
||||
match: str | None = None
|
||||
for root in all_roots:
|
||||
if norm.startswith(root):
|
||||
if match is None or len(root) > len(match):
|
||||
match = root
|
||||
return match
|
||||
|
||||
|
||||
async def _add_to_scanner_cache(dest_path: str, metadata: dict[str, Any]) -> None:
|
||||
model_dir = os.path.dirname(dest_path)
|
||||
model_root = _find_matching_root(model_dir)
|
||||
if not model_root:
|
||||
raise ValueError(f"File path {dest_path} is not within any configured scanner root")
|
||||
scanner_getter_name = _infer_model_type(model_root)[1]
|
||||
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
|
||||
if scanner_getter is None:
|
||||
raise RuntimeError(f"Scanner getter '{scanner_getter_name}' not found in ServiceRegistry")
|
||||
scanner = await scanner_getter()
|
||||
if scanner is None:
|
||||
raise RuntimeError(f"Scanner '{scanner_getter_name}' returned None")
|
||||
await scanner.update_single_model_cache(dest_path, dest_path, metadata)
|
||||
|
||||
|
||||
class HfHandler:
|
||||
"""Handle Hugging Face model browsing and download."""
|
||||
|
||||
async def set_hf_url(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
payload: dict[str, Any] = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({"success": False, "error": "Invalid JSON"}, status=400)
|
||||
|
||||
file_path = (payload.get("file_path") or "").strip()
|
||||
hf_url = (payload.get("hf_url") or "").strip()
|
||||
|
||||
if not file_path or not hf_url:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Missing required fields: 'file_path' and 'hf_url'"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
m = re.match(r"^https?://huggingface\.co/([^/]+/[^/]+)/?$", hf_url)
|
||||
if not m:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Invalid HuggingFace URL. Expected format: https://huggingface.co/user/repo",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"File not found: {file_path}"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
model_root = _find_matching_root(os.path.dirname(file_path))
|
||||
if not model_root:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "File is not within any configured model directory. Cannot link to HuggingFace.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
existing = await MetadataManager.load_metadata_payload(file_path)
|
||||
if existing.get("hf_url") == hf_url:
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": "hf_url already set",
|
||||
"hf_url": hf_url,
|
||||
})
|
||||
|
||||
existing["hf_url"] = hf_url
|
||||
existing["from_civitai"] = False
|
||||
await MetadataManager.save_metadata(file_path, existing)
|
||||
|
||||
await _add_to_scanner_cache(file_path, existing)
|
||||
|
||||
logger.info("Set hf_url=%s for %s", hf_url, file_path)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"hf_url set to {hf_url}",
|
||||
"hf_url": hf_url,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("Failed to set hf_url for %s: %s", file_path, exc)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def get_hf_repo_files(self, request: web.Request) -> web.Response:
|
||||
"""List model-weight files from a HF repo with real file sizes.
|
||||
|
||||
Uses the HF tree API endpoint which returns accurate file sizes
|
||||
(including LFS-tracked files), unlike the model info endpoint.
|
||||
"""
|
||||
repo = request.query.get("repo", "").strip()
|
||||
if not repo or "/" not in repo:
|
||||
return web.json_response(
|
||||
{"error": "Missing or invalid 'repo' parameter (expected user/repo)"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
url = f"https://huggingface.co/api/models/{repo}/tree/main"
|
||||
|
||||
try:
|
||||
session = await _get_hf_api_session()
|
||||
async with session.get(url) as resp:
|
||||
if resp.status == 404:
|
||||
return web.json_response(
|
||||
{"error": f"Repo '{repo}' not found"}, status=404
|
||||
)
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
return web.json_response(
|
||||
{"error": f"HF API error {resp.status}: {text[:200]}"},
|
||||
status=resp.status,
|
||||
)
|
||||
tree: list[dict[str, Any]] = await resp.json()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to fetch HF repo files: %s", exc)
|
||||
return web.json_response({"error": str(exc)}, status=502)
|
||||
|
||||
files: list[dict[str, Any]] = []
|
||||
for entry in tree:
|
||||
path: str = entry.get("path", "")
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext not in MODEL_FILE_EXTENSIONS:
|
||||
continue
|
||||
size = entry.get("size", 0) or 0
|
||||
if size == 0 and "lfs" in entry:
|
||||
size = entry["lfs"].get("size", 0) or 0
|
||||
files.append({
|
||||
"filename": path,
|
||||
"size": size,
|
||||
})
|
||||
|
||||
files.sort(key=lambda f: f["size"], reverse=True)
|
||||
return web.json_response(files)
|
||||
|
||||
async def download_hf_model(self, request: web.Request) -> web.Response:
|
||||
"""Download a single file from Hugging Face into the model directory.
|
||||
|
||||
POST JSON body::
|
||||
|
||||
{
|
||||
"repo": "dx8152/Flux2-Klein-9B-Consistency",
|
||||
"filename": "Flux2-Klein-9B-consistency-V2.safetensors",
|
||||
"revision": "main",
|
||||
"model_root": "loras",
|
||||
"relative_path": "",
|
||||
"use_default_paths": false,
|
||||
"download_id": "optional-batch-id"
|
||||
}
|
||||
|
||||
If ``download_id`` is provided, real-time progress (bytes, speed,
|
||||
percentage) is broadcast via the WebSocket progress system, matching
|
||||
the CivitAI download experience.
|
||||
|
||||
Respects the ``download_backend`` setting (``aria2`` or ``default``).
|
||||
"""
|
||||
try:
|
||||
payload: dict[str, Any] = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
repo = (payload.get("repo") or "").strip()
|
||||
filename = (payload.get("filename") or "").strip()
|
||||
revision = (payload.get("revision") or "main").strip()
|
||||
model_root = (payload.get("model_root") or "").strip()
|
||||
relative_path = (payload.get("relative_path") or "").strip()
|
||||
use_default_paths = bool(payload.get("use_default_paths", False))
|
||||
download_id: str | None = payload.get("download_id")
|
||||
|
||||
logger.info(
|
||||
"download_hf_model: repo=%s file=%s root=%s download_id=%s",
|
||||
repo, filename, model_root, download_id,
|
||||
)
|
||||
|
||||
if not repo or not filename:
|
||||
return web.json_response(
|
||||
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
|
||||
)
|
||||
|
||||
# Validate repo format — must be user/repo_name
|
||||
if repo.count("/") != 1 or not re.match(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", repo):
|
||||
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
|
||||
author, repo_name = repo.split("/", 1)
|
||||
if ".." in (author, repo_name) or "." in (author, repo_name):
|
||||
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
|
||||
|
||||
# Validate filename — must not contain path traversal
|
||||
if ".." in filename:
|
||||
return web.json_response({"error": "Invalid filename"}, status=400)
|
||||
|
||||
# Validate relative_path — must not be absolute or escape base directory
|
||||
if relative_path:
|
||||
if os.path.isabs(relative_path):
|
||||
return web.json_response({"error": "relative_path must not be absolute"}, status=400)
|
||||
if ".." in relative_path.split("/") or "\\" in relative_path:
|
||||
return web.json_response({"error": "Invalid relative_path"}, status=400)
|
||||
|
||||
# Use model_root directly as the base directory — same approach as
|
||||
# CivitAI's download path (download_manager.py). No realpath, no
|
||||
# allowed-roots validation, no path-traversal check; those are
|
||||
# unnecessary when the frontend sends the path from its own dropdown
|
||||
# (populated from scanner roots). Using the "business path" directly
|
||||
# keeps dest_path consistent with scanner roots so that later folder
|
||||
# derivation (in _save_hf_metadata) works correctly.
|
||||
if os.path.isabs(model_root):
|
||||
base_dir = os.path.normpath(model_root)
|
||||
else:
|
||||
base_dir = os.path.normpath(os.path.join(os.getcwd(), "models", model_root))
|
||||
|
||||
if use_default_paths:
|
||||
target_dir = os.path.join(base_dir, "huggingface", author, repo_name)
|
||||
elif relative_path:
|
||||
target_dir = os.path.join(base_dir, relative_path)
|
||||
else:
|
||||
target_dir = base_dir
|
||||
|
||||
# Strip HF repo subdirectory — "diffusion_models/xxx.safetensors"
|
||||
# is an HF repo convention, not meaningful for local storage.
|
||||
file_base = os.path.basename(filename)
|
||||
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
dest_path = os.path.join(target_dir, file_base)
|
||||
|
||||
# Check if already exists (simple skip)
|
||||
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
|
||||
logger.info("download_hf_model: file already exists, skipping — %s", dest_path)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"File already exists: {dest_path}",
|
||||
"path": dest_path,
|
||||
})
|
||||
|
||||
# Build HF resolve URL
|
||||
resolve_url = (
|
||||
f"https://huggingface.co/{repo}/resolve/{revision}/{filename}"
|
||||
)
|
||||
|
||||
# Set up progress callback if download_id is provided
|
||||
progress_callback = None
|
||||
if download_id:
|
||||
|
||||
async def _progress_callback(
|
||||
progress: float | DownloadProgress,
|
||||
snapshot: DownloadProgress | None = None,
|
||||
) -> None:
|
||||
percent = 0.0
|
||||
metrics = snapshot if isinstance(snapshot, DownloadProgress) else None
|
||||
|
||||
if isinstance(progress, DownloadProgress):
|
||||
percent = progress.percent_complete
|
||||
metrics = progress
|
||||
elif isinstance(snapshot, DownloadProgress):
|
||||
percent = snapshot.percent_complete
|
||||
else:
|
||||
percent = float(progress)
|
||||
|
||||
broadcast: dict[str, Any] = {
|
||||
"status": "progress",
|
||||
"progress": round(percent),
|
||||
}
|
||||
if metrics:
|
||||
broadcast["bytes_downloaded"] = metrics.bytes_downloaded
|
||||
broadcast["total_bytes"] = metrics.total_bytes
|
||||
broadcast["bytes_per_second"] = metrics.bytes_per_second
|
||||
|
||||
await ws_manager.broadcast_download_progress(download_id, broadcast)
|
||||
|
||||
progress_callback = _progress_callback
|
||||
|
||||
# Respect download backend setting (aria2 vs default)
|
||||
download_backend = (
|
||||
get_settings_manager().get("download_backend", "default")
|
||||
)
|
||||
|
||||
if download_backend == "aria2":
|
||||
aria2 = await Aria2Downloader.get_instance()
|
||||
aid = download_id or f"hf_{repo}_{filename}"
|
||||
try:
|
||||
hf_success, hf_result = await aria2.download_file(
|
||||
url=resolve_url,
|
||||
save_path=dest_path,
|
||||
download_id=aid,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
if hf_success:
|
||||
await _save_hf_metadata(dest_path, repo, model_root)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"Downloaded to {dest_path}",
|
||||
"path": dest_path,
|
||||
})
|
||||
else:
|
||||
return web.json_response(
|
||||
{"success": False, "error": hf_result or "aria2 download failed"},
|
||||
status=500,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("HF download (aria2) failed: %s", exc)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)}, status=500
|
||||
)
|
||||
|
||||
# Default: use built-in aiohttp Downloader
|
||||
downloader = await get_downloader()
|
||||
try:
|
||||
success, result = await downloader.download_file(
|
||||
url=resolve_url,
|
||||
save_path=dest_path,
|
||||
use_auth=False,
|
||||
allow_resume=True,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
if success:
|
||||
await _save_hf_metadata(dest_path, repo, model_root)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"Downloaded to {result}",
|
||||
"path": result,
|
||||
})
|
||||
else:
|
||||
return web.json_response(
|
||||
{"success": False, "error": result or "Download failed"},
|
||||
status=500,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("HF download failed: %s", exc)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)}, status=500
|
||||
)
|
||||
@@ -38,6 +38,12 @@ from ...services.settings_manager import get_settings_manager
|
||||
from ...services.websocket_manager import ws_manager
|
||||
from ...services.downloader import get_downloader
|
||||
from ...services.errors import ResourceNotFoundError
|
||||
from ...services.llm_service import (
|
||||
PROVIDER_PRESETS,
|
||||
fetch_ollama_models,
|
||||
get_all_provider_models,
|
||||
get_provider_model_ids,
|
||||
)
|
||||
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||
from ...utils.models import BaseModelMetadata
|
||||
from ...utils.constants import (
|
||||
@@ -48,8 +54,13 @@ from ...utils.constants import (
|
||||
SUPPORTED_MEDIA_EXTENSIONS,
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from .hf_handlers import HfHandler
|
||||
from .agent_handlers import AgentHandler
|
||||
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
|
||||
@@ -411,9 +422,10 @@ class PromptServerProtocol(Protocol):
|
||||
"""Subset of PromptServer used by the handlers."""
|
||||
|
||||
instance: "PromptServerProtocol"
|
||||
sockets: dict # maps clientId (sid) → WebSocketResponse
|
||||
|
||||
def send_sync(
|
||||
self, event: str, payload: dict
|
||||
self, event: str, payload: dict | None = None, sid: str | None = None
|
||||
) -> None: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
@@ -468,89 +480,167 @@ class BackupServiceProtocol(Protocol):
|
||||
|
||||
|
||||
class NodeRegistry:
|
||||
"""Thread-safe registry for tracking LoRA nodes in active workflows."""
|
||||
"""Thread-safe registry for tracking LoRA nodes across ComfyUI tabs.
|
||||
|
||||
Each connected ComfyUI browser tab (identified by its ``sid`` / ``clientId``)
|
||||
registers its own set of workflow nodes. Queries merge all known tabs into
|
||||
a single result so that the calling LM panel always sees *every* available
|
||||
target node, regardless of which tab responded fastest.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
self._nodes: Dict[str, dict] = {}
|
||||
self._registry_updated = asyncio.Event()
|
||||
# sid → {unique_id → node_info}
|
||||
self._tab_nodes: Dict[str, Dict[str, dict]] = {}
|
||||
self._ready = asyncio.Event()
|
||||
self._waiting_clients: set[str] = set()
|
||||
|
||||
@property
|
||||
def pending_client_count(self) -> int:
|
||||
"""Number of clients that have not yet responded in the current refresh cycle."""
|
||||
return len(self._waiting_clients)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers to build one node dict (extracted so it's reused for each tab)
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _build_node_dict(node: dict) -> dict:
|
||||
node_id = node["node_id"]
|
||||
graph_id = str(node["graph_id"])
|
||||
unique_id = f"{graph_id}:{node_id}"
|
||||
node_type = node.get("type", "")
|
||||
type_id = NODE_TYPES.get(node_type, 0)
|
||||
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
|
||||
|
||||
raw_capabilities = node.get("capabilities")
|
||||
capabilities: dict = {}
|
||||
if isinstance(raw_capabilities, dict):
|
||||
capabilities = dict(raw_capabilities)
|
||||
|
||||
raw_widget_names: list | None = node.get("widget_names")
|
||||
if not isinstance(raw_widget_names, list):
|
||||
capability_widget_names = capabilities.get("widget_names")
|
||||
raw_widget_names = (
|
||||
capability_widget_names
|
||||
if isinstance(capability_widget_names, list)
|
||||
else None
|
||||
)
|
||||
|
||||
widget_names: list[str] = []
|
||||
if isinstance(raw_widget_names, list):
|
||||
widget_names = [
|
||||
str(widget_name)
|
||||
for widget_name in raw_widget_names
|
||||
if isinstance(widget_name, str) and widget_name
|
||||
]
|
||||
|
||||
if widget_names:
|
||||
capabilities["widget_names"] = widget_names
|
||||
else:
|
||||
capabilities.pop("widget_names", None)
|
||||
|
||||
if "supports_lora" in capabilities:
|
||||
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
|
||||
|
||||
comfy_class = node.get("comfy_class")
|
||||
if not isinstance(comfy_class, str) or not comfy_class:
|
||||
comfy_class = node_type if isinstance(node_type, str) else None
|
||||
|
||||
return {
|
||||
"id": node_id,
|
||||
"graph_id": graph_id,
|
||||
"graph_name": node.get("graph_name"),
|
||||
"unique_id": unique_id,
|
||||
"bgcolor": bgcolor,
|
||||
"title": node.get("title"),
|
||||
"type": type_id,
|
||||
"type_name": node_type,
|
||||
"comfy_class": comfy_class,
|
||||
"capabilities": capabilities,
|
||||
"widget_names": widget_names,
|
||||
"mode": node.get("mode"),
|
||||
"marker_role": node.get("marker_role"),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
async def register_nodes(self, sid: str, nodes: list[dict]) -> None:
|
||||
"""Register/replace the node list for a single ComfyUI tab (identified by *sid*)."""
|
||||
tab_nodes: dict[str, dict] = {}
|
||||
for node in nodes:
|
||||
nd = self._build_node_dict(node)
|
||||
tab_nodes[nd["unique_id"]] = nd
|
||||
|
||||
async def register_nodes(self, nodes: list[dict]) -> None:
|
||||
async with self._lock:
|
||||
self._nodes.clear()
|
||||
for node in nodes:
|
||||
node_id = node["node_id"]
|
||||
graph_id = str(node["graph_id"])
|
||||
unique_id = f"{graph_id}:{node_id}"
|
||||
node_type = node.get("type", "")
|
||||
type_id = NODE_TYPES.get(node_type, 0)
|
||||
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
|
||||
raw_capabilities = node.get("capabilities")
|
||||
capabilities: dict = {}
|
||||
if isinstance(raw_capabilities, dict):
|
||||
capabilities = dict(raw_capabilities)
|
||||
prev_count = len(self._tab_nodes.get(sid, {}))
|
||||
self._tab_nodes[sid] = tab_nodes
|
||||
self._waiting_clients.discard(sid)
|
||||
if not self._waiting_clients:
|
||||
self._ready.set()
|
||||
total_tabs = len(self._tab_nodes)
|
||||
|
||||
raw_widget_names: list | None = node.get("widget_names")
|
||||
if not isinstance(raw_widget_names, list):
|
||||
capability_widget_names = capabilities.get("widget_names")
|
||||
raw_widget_names = (
|
||||
capability_widget_names
|
||||
if isinstance(capability_widget_names, list)
|
||||
else None
|
||||
)
|
||||
if len(nodes) != prev_count or len(nodes) > 0:
|
||||
logger.debug(
|
||||
"[LM:Registry] stored %s nodes (was %s) for client %s (total tabs: %s)",
|
||||
len(nodes), prev_count, sid, total_tabs,
|
||||
)
|
||||
|
||||
widget_names: list[str] = []
|
||||
if isinstance(raw_widget_names, list):
|
||||
widget_names = [
|
||||
str(widget_name)
|
||||
for widget_name in raw_widget_names
|
||||
if isinstance(widget_name, str) and widget_name
|
||||
]
|
||||
def prepare_for_refresh(self, active_sids: list[str]) -> None:
|
||||
"""Set the list of client IDs we expect to hear from during the next refresh cycle."""
|
||||
self._ready.clear()
|
||||
self._waiting_clients = set(active_sids)
|
||||
|
||||
if widget_names:
|
||||
capabilities["widget_names"] = widget_names
|
||||
else:
|
||||
capabilities.pop("widget_names", None)
|
||||
|
||||
if "supports_lora" in capabilities:
|
||||
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
|
||||
|
||||
comfy_class = node.get("comfy_class")
|
||||
if not isinstance(comfy_class, str) or not comfy_class:
|
||||
comfy_class = node_type if isinstance(node_type, str) else None
|
||||
|
||||
self._nodes[unique_id] = {
|
||||
"id": node_id,
|
||||
"graph_id": graph_id,
|
||||
"graph_name": node.get("graph_name"),
|
||||
"unique_id": unique_id,
|
||||
"bgcolor": bgcolor,
|
||||
"title": node.get("title"),
|
||||
"type": type_id,
|
||||
"type_name": node_type,
|
||||
"comfy_class": comfy_class,
|
||||
"capabilities": capabilities,
|
||||
"widget_names": widget_names,
|
||||
"mode": node.get("mode"),
|
||||
}
|
||||
logger.debug("Registered %s nodes in registry", len(nodes))
|
||||
self._registry_updated.set()
|
||||
|
||||
async def get_registry(self) -> dict:
|
||||
async with self._lock:
|
||||
return {
|
||||
"nodes": dict(self._nodes),
|
||||
"node_count": len(self._nodes),
|
||||
}
|
||||
|
||||
async def wait_for_update(self, timeout: float = 1.0) -> bool:
|
||||
self._registry_updated.clear()
|
||||
async def wait_for_all(self, timeout: float = 2.0) -> bool:
|
||||
"""Block until every client in the current waiting set has responded
|
||||
(or *timeout* seconds elapse). Returns ``True`` if all responded."""
|
||||
if not self._waiting_clients:
|
||||
return True
|
||||
try:
|
||||
await asyncio.wait_for(self._registry_updated.wait(), timeout=timeout)
|
||||
await asyncio.wait_for(self._ready.wait(), timeout=timeout)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
async def get_merged_registry(self, active_sids: set[str] | None = None) -> dict:
|
||||
"""Return the union of all known tab nodes, pruning any tab that is no
|
||||
longer connected."""
|
||||
async with self._lock:
|
||||
# Garbage-collect stale entries (disconnected tabs)
|
||||
stale_sids = []
|
||||
if active_sids is not None:
|
||||
for sid in list(self._tab_nodes):
|
||||
if sid not in active_sids:
|
||||
stale_sids.append(sid)
|
||||
del self._tab_nodes[sid]
|
||||
if stale_sids:
|
||||
logger.debug(
|
||||
"[LM:Registry] GC pruned %s disconnected tabs: %s",
|
||||
len(stale_sids), stale_sids,
|
||||
)
|
||||
|
||||
merged: dict[str, dict] = {}
|
||||
tab_info: dict[str, dict] = {}
|
||||
for sid, nodes in self._tab_nodes.items():
|
||||
tab_info[sid] = {
|
||||
"node_count": len(nodes),
|
||||
"graph_names": list(
|
||||
{
|
||||
n.get("graph_name")
|
||||
for n in nodes.values()
|
||||
if n.get("graph_name")
|
||||
}
|
||||
),
|
||||
}
|
||||
merged.update(nodes)
|
||||
|
||||
return {
|
||||
"nodes": merged,
|
||||
"node_count": len(merged),
|
||||
"tab_count": len(self._tab_nodes),
|
||||
"tabs": tab_info,
|
||||
}
|
||||
|
||||
|
||||
class HealthCheckHandler:
|
||||
async def health_check(self, request: web.Request) -> web.Response:
|
||||
@@ -1328,6 +1418,10 @@ class SettingsHandler:
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
# Sensitive — never expose the actual value to the frontend;
|
||||
# frontend receives a boolean instead (*_set).
|
||||
"civitai_api_key",
|
||||
"llm_api_key",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1382,6 +1476,11 @@ 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)
|
||||
raw_llm_key = self._settings.get("llm_api_key")
|
||||
response_data["llm_api_key_set"] = bool(raw_llm_key)
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
@@ -1486,18 +1585,78 @@ class SettingsHandler:
|
||||
logger.error("Error updating settings: %s", exc, exc_info=True)
|
||||
return web.Response(status=500, text=str(exc))
|
||||
|
||||
async def get_llm_models(self, request: web.Request) -> web.Response:
|
||||
"""Return the model list for a provider.
|
||||
|
||||
For ``ollama`` the list is fetched live from the local Ollama API
|
||||
(only models actually pulled locally are shown). For all other
|
||||
providers the opencode model catalog is used.
|
||||
|
||||
Query parameters:
|
||||
provider (required): Internal provider id (``openai``, ``ollama``, etc.).
|
||||
|
||||
Returns:
|
||||
``{"success": true, "models": ["gpt-4o", ...]}``.
|
||||
"""
|
||||
provider_id = request.query.get("provider", "").strip()
|
||||
if not provider_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "provider query parameter is required", "models": []},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
if provider_id == "ollama":
|
||||
api_base = request.query.get("api_base", "").strip() or self._settings.get("llm_api_base", "")
|
||||
if not api_base:
|
||||
api_base = "http://localhost:11434/v1"
|
||||
models = await fetch_ollama_models(api_base)
|
||||
else:
|
||||
models = await get_provider_model_ids(provider_id)
|
||||
return web.json_response({"success": True, "models": models})
|
||||
except Exception as exc:
|
||||
logger.warning("get_llm_models failed for %s: %s", provider_id, exc)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc), "models": []},
|
||||
status=500,
|
||||
)
|
||||
|
||||
def _validate_example_images_path(self, folder_path: str) -> str | None:
|
||||
if not os.path.exists(folder_path):
|
||||
return f"Path does not exist: {folder_path}"
|
||||
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
|
||||
|
||||
def _is_dedicated_example_images_folder(self, folder_path: str) -> bool:
|
||||
return is_valid_example_images_root(folder_path)
|
||||
|
||||
async def get_provider_models(self, request: web.Request) -> web.Response:
|
||||
"""Return the model catalog for all preset providers.
|
||||
|
||||
This endpoint is called asynchronously by the settings UI so that
|
||||
page rendering never blocks on the remote model catalog fetch.
|
||||
"""
|
||||
catalog_provider_ids = [p for p in PROVIDER_PRESETS if p != "custom"]
|
||||
try:
|
||||
provider_models = await get_all_provider_models(catalog_provider_ids)
|
||||
return web.json_response({"success": True, "models": provider_models})
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch provider models: %s", exc)
|
||||
return web.json_response({"success": False, "models": {}, "error": str(exc)})
|
||||
|
||||
|
||||
class UsageStatsHandler:
|
||||
def __init__(self, usage_stats_factory: UsageStatsFactory = UsageStats) -> None:
|
||||
@@ -2970,15 +3129,28 @@ class NodeRegistryHandler:
|
||||
self._node_registry = node_registry
|
||||
self._prompt_server = prompt_server
|
||||
self._standalone_mode = standalone_mode
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
self._last_slow_path_ts: float = 0.0
|
||||
|
||||
async def register_nodes(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
nodes = data.get("nodes", [])
|
||||
client_id = data.get("client_id")
|
||||
if not isinstance(nodes, list):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "nodes must be a list"}, status=400
|
||||
)
|
||||
|
||||
if not isinstance(client_id, str) or not client_id:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Missing client_id parameter",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
for index, node in enumerate(nodes):
|
||||
if not isinstance(node, dict):
|
||||
return web.json_response(
|
||||
@@ -3005,7 +3177,12 @@ class NodeRegistryHandler:
|
||||
)
|
||||
graph_name = node.get("graph_name")
|
||||
try:
|
||||
node["node_id"] = int(node_id)
|
||||
# Handle compound node IDs from expanded group subgraphs,
|
||||
# e.g. "252:0" → 0 (parent scope is already in graph_id)
|
||||
if isinstance(node_id, str) and ":" in node_id:
|
||||
node["node_id"] = int(node_id.rsplit(":", 1)[-1])
|
||||
else:
|
||||
node["node_id"] = int(node_id)
|
||||
except (TypeError, ValueError):
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -3022,7 +3199,7 @@ class NodeRegistryHandler:
|
||||
else:
|
||||
node["graph_name"] = str(graph_name)
|
||||
|
||||
await self._node_registry.register_nodes(nodes)
|
||||
await self._node_registry.register_nodes(client_id, nodes)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
@@ -3046,33 +3223,110 @@ class NodeRegistryHandler:
|
||||
status=503,
|
||||
)
|
||||
|
||||
try:
|
||||
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
|
||||
logger.debug("Sent registry refresh request to frontend")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send registry refresh message: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Communication Error",
|
||||
"message": f"Failed to communicate with ComfyUI frontend: {exc}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
current_sids = set(self._prompt_server.instance.sockets.keys())
|
||||
|
||||
registry_updated = await self._node_registry.wait_for_update(timeout=1.0)
|
||||
if not registry_updated:
|
||||
logger.warning("Registry refresh timeout after 1 second")
|
||||
# Fast path: if the frontend has already pushed node data (via
|
||||
# afterConfigureGraph / graphChanged hooks), return it immediately
|
||||
# without triggering a WebSocket round-trip.
|
||||
registry_info = await self._node_registry.get_merged_registry(
|
||||
active_sids=current_sids
|
||||
)
|
||||
if registry_info["tab_count"] > 0:
|
||||
logger.debug(
|
||||
"[LM:Registry] fast path: %s nodes across %s tabs %s",
|
||||
registry_info["node_count"],
|
||||
registry_info["tab_count"],
|
||||
dict(registry_info.get("tabs", {})),
|
||||
)
|
||||
return web.json_response({"success": True, "data": registry_info})
|
||||
|
||||
# Slow path: registry is empty — trigger refresh via WebSocket.
|
||||
# Serialize with an async lock so concurrent callers don't all
|
||||
# trigger separate WS refresh cycles. The second caller will
|
||||
# re-check the fast path and (usually) find populated data.
|
||||
async with self._refresh_lock:
|
||||
# Re-check after acquiring the lock — another concurrent call
|
||||
# may have populated the cache while we were waiting.
|
||||
registry_info = await self._node_registry.get_merged_registry(
|
||||
active_sids=current_sids
|
||||
)
|
||||
if registry_info["tab_count"] > 0:
|
||||
logger.debug(
|
||||
"[LM:Registry] fast path after lock wait: %s nodes across %s tabs",
|
||||
registry_info["node_count"],
|
||||
registry_info["tab_count"],
|
||||
)
|
||||
return web.json_response({"success": True, "data": registry_info})
|
||||
|
||||
# Cooldown: if the slow path ran recently (< 2 s) and
|
||||
# returned empty, skip another WS round-trip.
|
||||
elapsed = time.monotonic() - self._last_slow_path_ts
|
||||
if elapsed < 2.0:
|
||||
logger.debug(
|
||||
"[LM:Registry] slow path cooldown (%.1fs since last refresh), returning empty",
|
||||
elapsed,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Empty Registry",
|
||||
"message": "No workflow nodes found — ensure ComfyUI is open and the extension is loaded.",
|
||||
},
|
||||
status=408,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[LM:Registry] slow path: cache empty, triggering WS refresh (%s connected tabs: %s)",
|
||||
len(current_sids), list(current_sids)[:5],
|
||||
)
|
||||
active_sids = list(current_sids)
|
||||
self._node_registry.prepare_for_refresh(active_sids)
|
||||
|
||||
try:
|
||||
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
|
||||
logger.debug(
|
||||
"Sent registry refresh request (expecting %s clients)", len(active_sids)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send registry refresh message: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Communication Error",
|
||||
"message": f"Failed to communicate with ComfyUI frontend: {exc}",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
if not await self._node_registry.wait_for_all(timeout=0.5):
|
||||
logger.warning(
|
||||
"Registry refresh timeout after 0.5s (%s/%s clients responded)",
|
||||
len(active_sids) - self._node_registry.pending_client_count,
|
||||
len(active_sids),
|
||||
)
|
||||
|
||||
# Re-read current sockets after the wait: a tab may have connected
|
||||
# while we were waiting, and we don't want to garbage-collect it.
|
||||
current_sids = set(self._prompt_server.instance.sockets.keys())
|
||||
registry_info = await self._node_registry.get_merged_registry(
|
||||
active_sids=current_sids
|
||||
)
|
||||
self._last_slow_path_ts = time.monotonic()
|
||||
|
||||
if registry_info["node_count"] == 0:
|
||||
logger.debug(
|
||||
"[LM:Registry] refresh OK — %s connected tab(s) but 0 compatible nodes found",
|
||||
registry_info["tab_count"],
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Timeout Error",
|
||||
"message": "Registry refresh timeout - ComfyUI frontend may not be responsive",
|
||||
"error": "Empty Registry",
|
||||
"message": "No workflow nodes found — ensure ComfyUI is open and the extension is loaded.",
|
||||
},
|
||||
status=408,
|
||||
)
|
||||
|
||||
registry_info = await self._node_registry.get_registry()
|
||||
return web.json_response({"success": True, "data": registry_info})
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to get registry: %s", exc, exc_info=True)
|
||||
@@ -3085,13 +3339,17 @@ class NodeRegistryHandler:
|
||||
try:
|
||||
data = await request.json()
|
||||
widget_name = data.get("widget_name")
|
||||
action = data.get("action")
|
||||
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:
|
||||
if not action and (not isinstance(widget_name, str) or not widget_name):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Missing widget_name parameter"},
|
||||
{
|
||||
"success": False,
|
||||
"error": "Missing parameter: provide either 'action' or 'widget_name'",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
@@ -3130,12 +3388,15 @@ class NodeRegistryHandler:
|
||||
except (TypeError, ValueError):
|
||||
parsed_node_id = node_identifier
|
||||
|
||||
payload = {
|
||||
payload: dict = {
|
||||
"id": parsed_node_id,
|
||||
"widget_name": widget_name,
|
||||
"value": value,
|
||||
"mode": mode,
|
||||
}
|
||||
if action:
|
||||
payload["action"] = action
|
||||
if widget_name:
|
||||
payload["widget_name"] = widget_name
|
||||
|
||||
if graph_identifier is not None:
|
||||
payload["graph_id"] = str(graph_identifier)
|
||||
@@ -3194,6 +3455,8 @@ class MiscHandlerSet:
|
||||
doctor: DoctorHandler,
|
||||
example_workflows: ExampleWorkflowsHandler,
|
||||
base_model: BaseModelHandlerSet,
|
||||
hf_handler: HfHandler | None = None,
|
||||
agent_handler: AgentHandler | None = None,
|
||||
) -> None:
|
||||
self.health = health
|
||||
self.settings = settings
|
||||
@@ -3212,6 +3475,8 @@ class MiscHandlerSet:
|
||||
self.doctor = doctor
|
||||
self.example_workflows = example_workflows
|
||||
self.base_model = base_model
|
||||
self.hf_handler = hf_handler
|
||||
self.agent_handler = agent_handler
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -3227,6 +3492,8 @@ class MiscHandlerSet:
|
||||
"get_priority_tags": self.settings.get_priority_tags,
|
||||
"get_settings_libraries": self.settings.get_libraries,
|
||||
"activate_library": self.settings.activate_library,
|
||||
"get_llm_models": self.settings.get_llm_models,
|
||||
"get_provider_models": self.settings.get_provider_models,
|
||||
"update_usage_stats": self.usage_stats.update_usage_stats,
|
||||
"get_usage_stats": self.usage_stats.get_usage_stats,
|
||||
"update_lora_code": self.lora_code.update_lora_code,
|
||||
@@ -3257,6 +3524,14 @@ class MiscHandlerSet:
|
||||
"get_supporters": self.supporters.get_supporters,
|
||||
"get_example_workflows": self.example_workflows.get_example_workflows,
|
||||
"get_example_workflow": self.example_workflows.get_example_workflow,
|
||||
# Hugging Face handlers
|
||||
"get_hf_repo_files": self.hf_handler.get_hf_repo_files,
|
||||
"download_hf_model": self.hf_handler.download_hf_model,
|
||||
"set_hf_url": self.hf_handler.set_hf_url,
|
||||
# Agent skill handlers
|
||||
"get_agent_skills": self.agent_handler.get_agent_skills,
|
||||
"execute_agent_skill": self.agent_handler.execute_agent_skill,
|
||||
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
|
||||
# Base model handlers
|
||||
"get_base_models": self.base_model.get_base_models,
|
||||
"refresh_base_models": self.base_model.refresh_base_models,
|
||||
|
||||
@@ -154,6 +154,14 @@ class ModelPageView:
|
||||
)
|
||||
self._template_env._i18n_filter_added = True # type: ignore[attr-defined]
|
||||
|
||||
from ...services.llm_service import PROVIDER_PRESETS
|
||||
|
||||
# Provider presets are embedded directly (local, no await needed).
|
||||
# Provider model catalogs are fetched asynchronously by the
|
||||
# frontend via GET /api/lm/llm/provider-models so page rendering
|
||||
# never blocks on the remote model catalog (which can take up to
|
||||
# 30s on cold cache).
|
||||
|
||||
template_context = {
|
||||
"is_initializing": is_initializing,
|
||||
"settings": self._settings,
|
||||
@@ -161,6 +169,8 @@ class ModelPageView:
|
||||
"folders": [],
|
||||
"t": self._server_i18n.get_translation,
|
||||
"version": self._get_app_version(),
|
||||
"provider_presets_json": json.dumps(PROVIDER_PRESETS),
|
||||
"provider_models_json": "{}",
|
||||
}
|
||||
|
||||
if not is_initializing:
|
||||
@@ -203,11 +213,17 @@ class ModelListingHandler:
|
||||
result = await self._service.get_paginated_data(**params)
|
||||
|
||||
format_start = time.perf_counter()
|
||||
formatted_raw = [
|
||||
await self._service.format_response(entry)
|
||||
for entry in result["items"]
|
||||
]
|
||||
# Filter out None entries returned for corrupted cache rows (issue #730).
|
||||
# Note: "total" intentionally remains the pre-filter count to reflect
|
||||
# the true number of models in the cache; corrupted entries are rare
|
||||
# and adjusting total would cause pagination drift on every page.
|
||||
formatted_items = [item for item in formatted_raw if item is not None]
|
||||
formatted_result = {
|
||||
"items": [
|
||||
await self._service.format_response(item)
|
||||
for item in result["items"]
|
||||
],
|
||||
"items": formatted_items,
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
@@ -233,14 +249,20 @@ class ModelListingHandler:
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
params = self._parse_common_params(request)
|
||||
# group_by_model is meaningless for excluded view; strip it
|
||||
params.pop("group_by_model", None)
|
||||
result = await self._service.get_excluded_paginated_data(**params)
|
||||
|
||||
format_start = time.perf_counter()
|
||||
formatted_raw = [
|
||||
await self._service.format_response(entry)
|
||||
for entry in result["items"]
|
||||
]
|
||||
# Filter out None entries returned for corrupted cache rows (issue #730).
|
||||
# "total" stays at the pre-filter count; see get_models for rationale.
|
||||
formatted_items = [item for item in formatted_raw if item is not None]
|
||||
formatted_result = {
|
||||
"items": [
|
||||
await self._service.format_response(item)
|
||||
for item in result["items"]
|
||||
],
|
||||
"items": formatted_items,
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
@@ -366,6 +388,19 @@ class ModelListingHandler:
|
||||
request.query.get("name_pattern_use_regex", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# Group-by-model flag: deduplicate versions sharing the same civitai modelId
|
||||
group_by_model = (
|
||||
request.query.get("group_by_model", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# View-local-versions filter: show all local versions of a specific model
|
||||
civitai_model_id = request.query.get("civitai_model_id")
|
||||
if civitai_model_id is not None:
|
||||
try:
|
||||
civitai_model_id = int(civitai_model_id)
|
||||
except (TypeError, ValueError):
|
||||
civitai_model_id = None
|
||||
|
||||
return {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -389,6 +424,8 @@ class ModelListingHandler:
|
||||
"name_pattern_include": name_pattern_include,
|
||||
"name_pattern_exclude": name_pattern_exclude,
|
||||
"name_pattern_use_regex": name_pattern_use_regex,
|
||||
"group_by_model": group_by_model,
|
||||
"civitai_model_id": civitai_model_id,
|
||||
**self._parse_specific_params(request),
|
||||
}
|
||||
|
||||
@@ -516,8 +553,13 @@ class ModelManagementHandler:
|
||||
if not success:
|
||||
return web.json_response({"success": False, "error": error})
|
||||
|
||||
formatted_metadata = await self._service.format_response(model_data)
|
||||
return web.json_response({"success": True, "metadata": formatted_metadata})
|
||||
formatted = await self._service.format_response(model_data)
|
||||
if formatted is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Model entry is corrupted (missing file_path)"},
|
||||
status=500,
|
||||
)
|
||||
return web.json_response({"success": True, "metadata": formatted})
|
||||
except Exception as exc:
|
||||
if is_expected_offline_error(str(exc)):
|
||||
return web.json_response(
|
||||
@@ -1074,10 +1116,12 @@ class ModelQueryHandler:
|
||||
# Sort: originals first, copies last
|
||||
sorted_models = self._sort_duplicate_group(filtered)
|
||||
|
||||
# Format response
|
||||
# Format response, filtering out corrupted entries (issue #730)
|
||||
group = {"hash": sha256, "models": []}
|
||||
for model in sorted_models:
|
||||
group["models"].append(await self._service.format_response(model))
|
||||
formatted = await self._service.format_response(model)
|
||||
if formatted is not None:
|
||||
group["models"].append(formatted)
|
||||
|
||||
# Only include groups with 2+ models after filtering
|
||||
if len(group["models"]) > 1:
|
||||
@@ -1194,9 +1238,9 @@ class ModelQueryHandler:
|
||||
(m for m in cache.raw_data if m["file_path"] == path), None
|
||||
)
|
||||
if model:
|
||||
group["models"].append(
|
||||
await self._service.format_response(model)
|
||||
)
|
||||
formatted = await self._service.format_response(model)
|
||||
if formatted is not None:
|
||||
group["models"].append(formatted)
|
||||
hash_val = self._service.scanner.get_hash_by_filename(filename)
|
||||
if hash_val:
|
||||
main_path = self._service.get_path_by_hash(hash_val)
|
||||
@@ -1206,9 +1250,9 @@ class ModelQueryHandler:
|
||||
None,
|
||||
)
|
||||
if main_model:
|
||||
group["models"].insert(
|
||||
0, await self._service.format_response(main_model)
|
||||
)
|
||||
formatted = await self._service.format_response(main_model)
|
||||
if formatted is not None:
|
||||
group["models"].insert(0, formatted)
|
||||
if group["models"]:
|
||||
result.append(group)
|
||||
return web.json_response(
|
||||
@@ -1269,9 +1313,28 @@ class ModelQueryHandler:
|
||||
}
|
||||
if include_license_flags:
|
||||
model_data = await self._service.get_model_info_by_name(model_name)
|
||||
license_flags = (model_data or {}).get("license_flags")
|
||||
if license_flags is not None:
|
||||
response_payload["license_flags"] = int(license_flags)
|
||||
# Only return license_flags when real CivitAI model license
|
||||
# data exists. This mirrors ModelModal's guard
|
||||
# (modelData?.civitai?.model) so the preview tooltip never
|
||||
# shows misleading license icons for HF or other models
|
||||
# without actual license metadata.
|
||||
civitai_data = (model_data or {}).get("civitai") or {}
|
||||
has_license_data = (
|
||||
isinstance(civitai_data, dict)
|
||||
and isinstance(civitai_data.get("model"), dict)
|
||||
)
|
||||
if has_license_data:
|
||||
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(
|
||||
{
|
||||
@@ -1785,6 +1848,8 @@ class ModelDownloadHandler:
|
||||
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(
|
||||
@@ -1794,6 +1859,7 @@ class ModelDownloadHandler:
|
||||
file_path=file_path,
|
||||
bytes_downloaded=bytes_downloaded,
|
||||
total_bytes=total_bytes,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
if item is None:
|
||||
return web.json_response(
|
||||
@@ -1817,6 +1883,39 @@ class ModelDownloadHandler:
|
||||
)
|
||||
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."""
|
||||
@@ -1858,7 +1957,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)
|
||||
|
||||
@@ -2859,6 +2960,7 @@ class ModelHandlerSet:
|
||||
"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,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import mimetypes
|
||||
import urllib.parse
|
||||
@@ -13,7 +14,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.
|
||||
@@ -53,18 +54,51 @@ class PreviewHandler:
|
||||
|
||||
if not resolved.is_file():
|
||||
logger.debug("Preview file not found at %s", str(resolved))
|
||||
asyncio.create_task(self._cleanup_stale_preview_url(normalized))
|
||||
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, 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
|
||||
|
||||
# aiohttp's FileResponse handles range requests and content headers for us.
|
||||
return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
|
||||
async def _cleanup_stale_preview_url(self, normalized_preview_path: str) -> None:
|
||||
"""Fire-and-forget: clear stale preview_url from all model caches.
|
||||
|
||||
When a preview file is no longer on disk, remove its reference from
|
||||
every cached entry so subsequent list API responses return an empty
|
||||
``preview_url``, letting the frontend show the no-preview placeholder.
|
||||
"""
|
||||
try:
|
||||
from ...services.service_registry import ServiceRegistry
|
||||
|
||||
for service_name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
|
||||
scanner = ServiceRegistry.get_service_sync(service_name)
|
||||
if scanner is None or not hasattr(scanner, "_cache"):
|
||||
continue
|
||||
cache = getattr(scanner, "_cache", None)
|
||||
if cache is None or not hasattr(cache, "clear_preview_by_path"):
|
||||
continue
|
||||
cleared = await cache.clear_preview_by_path(normalized_preview_path)
|
||||
if cleared and hasattr(scanner, "_persist_current_cache"):
|
||||
await scanner._persist_current_cache()
|
||||
logger.info(
|
||||
"Cleared stale preview_url for %d %s entries (%s)",
|
||||
cleared,
|
||||
service_name,
|
||||
normalized_preview_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to clean up stale preview_url: %s", exc)
|
||||
|
||||
async def _stream_file(
|
||||
self, request: web.Request, path: Path
|
||||
@@ -83,6 +117,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:
|
||||
|
||||
@@ -32,6 +32,7 @@ from ...utils.civitai_utils import (
|
||||
extract_civitai_image_id_from_cdn_url,
|
||||
rewrite_preview_url,
|
||||
)
|
||||
from ...utils.constants import NSFW_LEVELS
|
||||
from ...utils.exif_utils import ExifUtils
|
||||
from ...recipes.merger import GenParamsMerger
|
||||
from ...recipes.enrichment import RecipeEnricher
|
||||
@@ -1120,6 +1121,13 @@ class RecipeManagementHandler:
|
||||
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
||||
metadata["base_model"] = parsed_embedded["base_model"]
|
||||
|
||||
# Extract preview_nsfw_level from the CivitAI API response
|
||||
# (injected into civitai_meta_raw by _download_remote_media).
|
||||
if isinstance(civitai_meta_raw, dict):
|
||||
bl = civitai_meta_raw.get("browsingLevel")
|
||||
if isinstance(bl, int) and bl > 0:
|
||||
metadata["preview_nsfw_level"] = bl
|
||||
|
||||
civitai_client = self._civitai_client_getter()
|
||||
await RecipeEnricher.enrich_recipe(
|
||||
recipe=metadata,
|
||||
@@ -1515,8 +1523,31 @@ class RecipeManagementHandler:
|
||||
# CivitAI API returns modelVersionIds at the root level of
|
||||
# the image response, NOT inside the meta object.
|
||||
mvids = image_info.get("modelVersionIds")
|
||||
if mvids and isinstance(civitai_meta_raw, dict):
|
||||
civitai_meta_raw["modelVersionIds"] = mvids
|
||||
if mvids:
|
||||
if isinstance(civitai_meta_raw, dict):
|
||||
civitai_meta_raw["modelVersionIds"] = mvids
|
||||
else:
|
||||
# meta is null but modelVersionIds exists — create a
|
||||
# minimal dict so downstream parsers can discover
|
||||
# LoRAs and checkpoints from the API response.
|
||||
civitai_meta_raw = {"modelVersionIds": mvids}
|
||||
|
||||
# Inject browsingLevel (canonical integer) so the recipe's
|
||||
# preview_nsfw_level can be set, enabling proper NSFW blur
|
||||
# of the preview image. Fall back to nsfwLevel (string)
|
||||
# when browsingLevel is absent.
|
||||
if isinstance(civitai_meta_raw, dict):
|
||||
browsing_level = image_info.get("browsingLevel")
|
||||
nsfw_level_str = image_info.get("nsfwLevel")
|
||||
if isinstance(browsing_level, int) and browsing_level > 0:
|
||||
civitai_meta_raw["browsingLevel"] = browsing_level
|
||||
elif (
|
||||
isinstance(nsfw_level_str, str)
|
||||
and nsfw_level_str in NSFW_LEVELS
|
||||
):
|
||||
civitai_meta_raw["browsingLevel"] = NSFW_LEVELS[
|
||||
nsfw_level_str
|
||||
]
|
||||
|
||||
original_url = (
|
||||
image_info.get("url") if civitai_image_id and image_info else None
|
||||
@@ -1597,15 +1628,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:
|
||||
@@ -1641,20 +1665,22 @@ 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()
|
||||
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:
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"recipe_id": recipe.get("id"),
|
||||
"name": recipe.get("title", ""),
|
||||
"already_exists": True,
|
||||
})
|
||||
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", []):
|
||||
if str(recipe.get("id", "")) == existing_recipe_id:
|
||||
recipe_name = recipe.get("title", "") or ""
|
||||
break
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"recipe_id": existing_recipe_id,
|
||||
"name": recipe_name,
|
||||
"already_exists": True,
|
||||
})
|
||||
|
||||
async with self._import_semaphore:
|
||||
return await self._do_import_from_url(image_url, recipe_scanner)
|
||||
@@ -1801,6 +1827,13 @@ class RecipeManagementHandler:
|
||||
"source_path": image_url,
|
||||
}
|
||||
|
||||
# Extract preview_nsfw_level from the CivitAI API response
|
||||
# (injected into civitai_meta_raw by _download_remote_media).
|
||||
if isinstance(civitai_meta_raw, dict):
|
||||
bl = civitai_meta_raw.get("browsingLevel")
|
||||
if isinstance(bl, int) and bl > 0:
|
||||
metadata["preview_nsfw_level"] = bl
|
||||
|
||||
if civitai_parsed:
|
||||
civitai_loras = civitai_parsed.get("loras", [])
|
||||
if civitai_loras and not metadata.get("loras"):
|
||||
@@ -2185,6 +2218,31 @@ class RecipeManagementHandler:
|
||||
"Failed to download image for recipe: %s", exc
|
||||
)
|
||||
|
||||
# Fallback: try to locate a custom image on disk using model_hash + image id
|
||||
if image_bytes is None:
|
||||
image_id = image_data.get("id") or ""
|
||||
if image_id and model_hash:
|
||||
from ...utils.example_images_paths import get_model_folder
|
||||
model_folder = get_model_folder(model_hash)
|
||||
if model_folder and os.path.exists(model_folder):
|
||||
for fname in os.listdir(model_folder):
|
||||
if f"custom_{image_id}" in fname:
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
|
||||
continue
|
||||
fpath = os.path.join(model_folder, fname)
|
||||
if os.path.isfile(fpath):
|
||||
try:
|
||||
with open(fpath, "rb") as f:
|
||||
image_bytes = f.read()
|
||||
extension = ext
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to read custom image file %s: %s",
|
||||
fpath, exc,
|
||||
)
|
||||
break
|
||||
|
||||
prompt = (
|
||||
(parsed.get("gen_params") or {}).get("prompt") or ""
|
||||
)
|
||||
|
||||
@@ -22,6 +22,8 @@ class RouteDefinition:
|
||||
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
|
||||
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
||||
RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"),
|
||||
RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"),
|
||||
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
||||
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
||||
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
|
||||
@@ -94,6 +96,26 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
||||
),
|
||||
# Hugging Face model endpoints
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-hf-model", "download_hf_model"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/set-hf-url", "set_hf_url"
|
||||
),
|
||||
# Agent skill endpoints
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/agent/skills", "get_agent_skills"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/agent/execute/{skill_name}", "execute_agent_skill"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/agent/cancel", "cancel_agent_skill"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ from .handlers.misc_handlers import (
|
||||
build_service_registry_adapter,
|
||||
)
|
||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||
from .handlers.hf_handlers import HfHandler
|
||||
from .handlers.agent_handlers import AgentHandler
|
||||
from .misc_route_registrar import MiscRouteRegistrar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -136,6 +138,8 @@ class MiscRoutes:
|
||||
doctor = DoctorHandler(settings_service=self._settings)
|
||||
example_workflows = ExampleWorkflowsHandler()
|
||||
base_model = BaseModelHandlerSet()
|
||||
hf_handler = HfHandler()
|
||||
agent_handler = AgentHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -155,6 +159,8 @@ class MiscRoutes:
|
||||
doctor=doctor,
|
||||
example_workflows=example_workflows,
|
||||
base_model=base_model,
|
||||
hf_handler=hf_handler,
|
||||
agent_handler=agent_handler,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -138,6 +138,9 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
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"),
|
||||
)
|
||||
|
||||
+45
-16
@@ -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({
|
||||
|
||||
@@ -16,6 +16,27 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
||||
|
||||
# User-managed directories that live inside the plugin folder (portable
|
||||
# mode) and must survive a Git-based update. ``git clean -fd`` would
|
||||
# otherwise delete them because they are untracked and, in released tags,
|
||||
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
|
||||
# regardless of whether it is ignored.
|
||||
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache')
|
||||
|
||||
|
||||
def _clean_excludes() -> List[str]:
|
||||
"""Build the ``-e`` arguments for ``git clean`` from :data:`_PRESERVE_DIRS`."""
|
||||
excludes: List[str] = []
|
||||
for name in _PRESERVE_DIRS:
|
||||
excludes.append('-e')
|
||||
excludes.append(name)
|
||||
# For directories, also exclude nested matches explicitly
|
||||
# (``-e dir`` alone matches the dir entry; ``-e dir/**`` guards
|
||||
# contents under all git versions as defense-in-depth).
|
||||
excludes.append('-e')
|
||||
excludes.append(f'{name}/**')
|
||||
return excludes
|
||||
|
||||
|
||||
class UpdateRoutes:
|
||||
"""Routes for handling plugin update checks"""
|
||||
@@ -365,6 +386,8 @@ class UpdateRoutes:
|
||||
)
|
||||
return False, ""
|
||||
|
||||
clean_excludes = _clean_excludes()
|
||||
|
||||
try:
|
||||
# Open the Git repository
|
||||
repo = git.Repo(plugin_root)
|
||||
@@ -376,8 +399,9 @@ class UpdateRoutes:
|
||||
if nightly:
|
||||
# Reset to discard any local changes
|
||||
repo.git.reset('--hard')
|
||||
# Clean untracked files
|
||||
repo.git.clean('-fd')
|
||||
# Clean untracked files, but preserve user-managed directories
|
||||
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||
repo.git.clean('-fd', *clean_excludes)
|
||||
|
||||
# Switch to main branch and pull latest
|
||||
main_branch = 'main'
|
||||
@@ -394,8 +418,9 @@ class UpdateRoutes:
|
||||
else:
|
||||
# Reset to discard any local changes
|
||||
repo.git.reset('--hard')
|
||||
# Clean untracked files
|
||||
repo.git.clean('-fd')
|
||||
# Clean untracked files, but preserve user-managed directories
|
||||
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||
repo.git.clean('-fd', *clean_excludes)
|
||||
|
||||
# Get latest release tag
|
||||
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""LLM-powered metadata enrichment pipeline infrastructure.
|
||||
|
||||
This package provides the orchestration layer for LLM-powered features.
|
||||
Skills define *what* to do (prompt template). The :class:`AgentService`
|
||||
handles *how* (LLM calls, context gathering, validation, progress).
|
||||
|
||||
NOTE: The current implementation is a code-driven pipeline, not a true
|
||||
agent loop. Future agent orchestration (LLM-driven tool selection) will
|
||||
live alongside this package with its own namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .skill_definition import SkillDefinition, SkillPermissions
|
||||
from .skill_registry import SkillRegistry
|
||||
from .agent_service import AgentService, AgentProgressReporter, SkillResult
|
||||
from .post_processor import PostProcessor
|
||||
|
||||
__all__ = [
|
||||
"AgentProgressReporter",
|
||||
"AgentService",
|
||||
"PostProcessor",
|
||||
"SkillDefinition",
|
||||
"SkillPermissions",
|
||||
"SkillRegistry",
|
||||
"SkillResult",
|
||||
]
|
||||
@@ -0,0 +1,489 @@
|
||||
"""Pipeline orchestration service.
|
||||
|
||||
The :class:`AgentService` coordinates LLM-powered pipeline execution:
|
||||
|
||||
1. Look up the pipeline definition in :class:`SkillRegistry`
|
||||
2. Validate input against its ``input_schema``
|
||||
3. Prepare context via :mod:`~py.metadata_ops` (read metadata, list base models, fetch HF README)
|
||||
4. If ``llm_required``: call :class:`LLMService` with the rendered prompt
|
||||
5. Post-process via :class:`PostProcessor` (delegates I/O to :mod:`~py.metadata_ops`)
|
||||
6. Broadcast progress and completion via :class:`WebSocketManager`
|
||||
|
||||
Pipeline definitions (*skills*) describe *what* to do (prompt template).
|
||||
The AgentService handles *how* (LLM calls, context gathering, validation,
|
||||
progress).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
import os
|
||||
|
||||
from ...config import config
|
||||
from ..llm_service import LLMService
|
||||
from ..websocket_manager import ws_manager
|
||||
from .post_processor import PostProcessor
|
||||
from .skill_registry import SkillRegistry
|
||||
from .skills.enrich_hf_metadata.readme_processor import (
|
||||
clean_readme_for_llm,
|
||||
extract_relevant_section,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentProgressReporter:
|
||||
"""Protocol-compatible progress reporter backed by WebSocket broadcast."""
|
||||
|
||||
async def on_progress(self, payload: Dict[str, Any]) -> None:
|
||||
await ws_manager.broadcast(payload)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillResult:
|
||||
"""Outcome of a skill execution."""
|
||||
|
||||
success: bool
|
||||
updated_models: List[Dict[str, Any]] = field(default_factory=list)
|
||||
errors: List[str] = field(default_factory=list)
|
||||
summary: str = ""
|
||||
|
||||
|
||||
def _validate_schema(data: Any, schema: Dict[str, Any], path: str = "") -> List[str]:
|
||||
"""Minimal JSON schema validator.
|
||||
|
||||
Supports a subset of JSON Schema: ``type``, ``properties``, ``required``,
|
||||
``items``, ``enum``. Returns a list of error messages (empty = valid).
|
||||
"""
|
||||
|
||||
errors: List[str] = []
|
||||
if not schema:
|
||||
return errors
|
||||
|
||||
expected_type = schema.get("type")
|
||||
if expected_type:
|
||||
type_map = {
|
||||
"string": str,
|
||||
"number": (int, float),
|
||||
"integer": int,
|
||||
"boolean": bool,
|
||||
"array": list,
|
||||
"object": dict,
|
||||
"null": type(None),
|
||||
}
|
||||
expected_py = type_map.get(expected_type)
|
||||
if expected_py is not None and not isinstance(data, expected_py):
|
||||
errors.append(f"{path or 'root'}: expected {expected_type}, got {type(data).__name__}")
|
||||
return errors
|
||||
|
||||
if expected_type == "object" and isinstance(data, dict):
|
||||
properties = schema.get("properties", {})
|
||||
required = schema.get("required", [])
|
||||
for req_key in required:
|
||||
if req_key not in data:
|
||||
errors.append(f"{path or 'root'}: missing required property '{req_key}'")
|
||||
for key, value in data.items():
|
||||
if key in properties:
|
||||
errors.extend(_validate_schema(value, properties[key], f"{path}.{key}"))
|
||||
|
||||
if expected_type == "array" and isinstance(data, list):
|
||||
items_schema = schema.get("items")
|
||||
if items_schema:
|
||||
for i, item in enumerate(data):
|
||||
errors.extend(_validate_schema(item, items_schema, f"{path}[{i}]"))
|
||||
|
||||
if "enum" in schema and data not in schema["enum"]:
|
||||
errors.append(f"{path or 'root'}: value '{data}' not in enum {schema['enum']}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Prompt template rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_prompt(template: str, variables: Dict[str, Any]) -> str:
|
||||
"""Render a prompt template with ``{{variable}}`` placeholders.
|
||||
|
||||
Uses simple regex substitution — no Jinja2 dependency needed.
|
||||
"""
|
||||
|
||||
def replace(match: re.Match) -> str:
|
||||
key = match.group(1).strip()
|
||||
value = variables.get(key, "")
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False, indent=2)
|
||||
return str(value)
|
||||
|
||||
return re.sub(r"\{\{(\w+)\}\}", replace, template)
|
||||
|
||||
|
||||
class AgentService:
|
||||
"""Orchestrate agent skill execution.
|
||||
|
||||
Usage::
|
||||
|
||||
service = await AgentService.get_instance()
|
||||
result = await service.execute_skill(
|
||||
skill_name="enrich_hf_metadata",
|
||||
input_data={"model_paths": ["/path/to/model.safetensors"]},
|
||||
progress_callback=AgentProgressReporter(),
|
||||
)
|
||||
"""
|
||||
|
||||
_instance: Optional["AgentService"] = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
skill_registry: Optional[SkillRegistry] = None,
|
||||
llm_service: Optional[LLMService] = None,
|
||||
) -> None:
|
||||
self._registry = skill_registry
|
||||
self._llm_service = llm_service
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "AgentService":
|
||||
"""Return the lazily-initialised global ``AgentService``."""
|
||||
|
||||
if cls._instance is None:
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls(
|
||||
skill_registry=await SkillRegistry.get_instance(),
|
||||
llm_service=await LLMService.get_instance(),
|
||||
)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls) -> None:
|
||||
"""Reset the cached singleton — primarily for tests."""
|
||||
|
||||
cls._instance = None
|
||||
|
||||
async def _ensure_registry(self) -> SkillRegistry:
|
||||
if self._registry is None:
|
||||
self._registry = await SkillRegistry.get_instance()
|
||||
return self._registry
|
||||
|
||||
async def _ensure_llm(self) -> LLMService:
|
||||
if self._llm_service is None:
|
||||
self._llm_service = await LLMService.get_instance()
|
||||
return self._llm_service
|
||||
|
||||
async def list_skills(self) -> List[Dict[str, Any]]:
|
||||
"""Return a JSON-serialisable list of available skills."""
|
||||
|
||||
registry = await self._ensure_registry()
|
||||
return [
|
||||
{
|
||||
"name": s.name,
|
||||
"title": s.title,
|
||||
"description": s.description,
|
||||
"llm_required": s.llm_required,
|
||||
"model_type_filter": s.model_type_filter,
|
||||
}
|
||||
for s in registry.list_skills()
|
||||
]
|
||||
|
||||
async def execute_skill(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
input_data: Dict[str, Any],
|
||||
progress_callback: Optional[AgentProgressReporter] = None,
|
||||
) -> SkillResult:
|
||||
"""Execute a pipeline (skill) on the given models.
|
||||
|
||||
Args:
|
||||
skill_name: Name of the pipeline to execute
|
||||
input_data: Input validated against the pipeline's ``input_schema``
|
||||
progress_callback: Optional WebSocket progress reporter
|
||||
|
||||
Returns:
|
||||
:class:`SkillResult` with success status and updated model info
|
||||
"""
|
||||
|
||||
registry = await self._ensure_registry()
|
||||
skill = registry.get_skill(skill_name)
|
||||
if skill is None:
|
||||
return SkillResult(
|
||||
success=False,
|
||||
errors=[f"Skill not found: {skill_name}"],
|
||||
summary=f"Skill '{skill_name}' does not exist",
|
||||
)
|
||||
|
||||
input_errors = _validate_schema(input_data, skill.input_schema)
|
||||
if input_errors:
|
||||
return SkillResult(
|
||||
success=False,
|
||||
errors=input_errors,
|
||||
summary=f"Invalid input: {'; '.join(input_errors)}",
|
||||
)
|
||||
|
||||
model_paths = input_data.get("model_paths", [])
|
||||
if not model_paths:
|
||||
return SkillResult(
|
||||
success=False,
|
||||
errors=["No model_paths provided"],
|
||||
summary="No models to process",
|
||||
)
|
||||
|
||||
total = len(model_paths)
|
||||
processed = 0
|
||||
success_count = 0
|
||||
skipped_count = 0
|
||||
updated_models: List[Dict[str, Any]] = []
|
||||
errors: List[str] = []
|
||||
post_processor = PostProcessor()
|
||||
|
||||
await self._emit_progress(
|
||||
progress_callback, skill_name, status="started",
|
||||
total=total, processed=0, success=0,
|
||||
)
|
||||
|
||||
llm = await self._ensure_llm()
|
||||
llm_configured = llm.is_configured() if skill.llm_required else True
|
||||
|
||||
for model_path in model_paths:
|
||||
model_filename = os.path.basename(model_path)
|
||||
logger.info(
|
||||
"[%s] [%d/%d] %s",
|
||||
skill_name, processed + 1, total, model_filename,
|
||||
)
|
||||
updated_data: Dict[str, Any] = {}
|
||||
skip_model = False
|
||||
try:
|
||||
from ...metadata_ops import read_metadata
|
||||
metadata = await read_metadata(model_path)
|
||||
|
||||
# Fast-fail: enrich_hf_metadata requires hf_url to have HF README context
|
||||
if skill_name == "enrich_hf_metadata" and not metadata.get("hf_url", ""):
|
||||
logger.info(
|
||||
"[%s] SKIP %s — no hf_url in metadata",
|
||||
skill_name, model_filename,
|
||||
)
|
||||
skipped_count += 1
|
||||
skip_model = True
|
||||
|
||||
if not skip_model:
|
||||
prompt_vars: Dict[str, Any] = {"model_path": model_path}
|
||||
if skill.llm_required and llm_configured:
|
||||
prompt_vars = await self._build_prompt_context(
|
||||
skill_name, model_path, metadata, registry, llm,
|
||||
)
|
||||
|
||||
llm_response: Optional[Dict[str, Any]] = None
|
||||
if skill.llm_required and llm_configured:
|
||||
prompt_template = registry.load_prompt(skill_name)
|
||||
rendered = _render_prompt(prompt_template, prompt_vars)
|
||||
llm_response = await llm.chat_completion_json(
|
||||
system_prompt=prompt_vars.get(
|
||||
"system_prompt",
|
||||
"You are a helpful assistant that extracts structured metadata.",
|
||||
),
|
||||
user_prompt=rendered,
|
||||
)
|
||||
if llm_response:
|
||||
logger.info(
|
||||
"[%s] [%d/%d] %s → base_model=%s confidence=%s",
|
||||
skill_name, processed + 1, total, model_filename,
|
||||
(llm_response.get("base_model") or "?")[:50],
|
||||
llm_response.get("confidence", "?"),
|
||||
)
|
||||
|
||||
model_result = await post_processor.process(
|
||||
skill_name=skill_name,
|
||||
model_path=model_path,
|
||||
llm_output=llm_response or {},
|
||||
metadata=metadata,
|
||||
readme_content=prompt_vars.get("readme_content_full", ""),
|
||||
)
|
||||
|
||||
if model_result.get("success", True):
|
||||
success_count += 1
|
||||
uf = model_result.get("updated_fields", [])
|
||||
if uf:
|
||||
updated_models.append({"path": model_path, "updated_fields": uf})
|
||||
updated_data = model_result.get("updates", {})
|
||||
if "preview_url" in updated_data and updated_data["preview_url"]:
|
||||
updated_data["preview_url"] = config.get_preview_static_url(
|
||||
updated_data["preview_url"]
|
||||
)
|
||||
else:
|
||||
errors.extend(
|
||||
model_result.get("errors", [model_result.get("error", "Unknown error")])
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Skill %s failed for %s: %s", skill_name, model_path, exc)
|
||||
errors.append(f"{model_path}: {exc}")
|
||||
|
||||
processed += 1
|
||||
await self._emit_progress(
|
||||
progress_callback, skill_name, status="processing",
|
||||
total=total, processed=processed, success=success_count,
|
||||
skipped=skipped_count,
|
||||
current_path=model_path,
|
||||
updated_data=updated_data,
|
||||
)
|
||||
|
||||
result = SkillResult(
|
||||
success=success_count > 0,
|
||||
updated_models=updated_models,
|
||||
errors=errors,
|
||||
summary=f"Processed {processed}/{total} models, {success_count} succeeded, {skipped_count} skipped",
|
||||
)
|
||||
|
||||
await self._emit_progress(
|
||||
progress_callback, skill_name, status="completed",
|
||||
total=total, processed=processed, success=success_count,
|
||||
skipped=skipped_count,
|
||||
updated_models=updated_models, errors=errors, summary=result.summary,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Base model grouping (keeps the prompt compact)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _format_base_models(models: List[str]) -> str:
|
||||
"""Format the base model list as a flat, one-per-line list.
|
||||
|
||||
Attempts to group by family consistently degraded LLM extraction
|
||||
accuracy — the LLM finds individual model names harder to spot
|
||||
in comma-separated groups than in a simple ``- Name`` list.
|
||||
"""
|
||||
return "\n".join(f"- {m}" for m in models)
|
||||
|
||||
async def _build_prompt_context(
|
||||
self,
|
||||
skill_name: str,
|
||||
model_path: str,
|
||||
metadata: Dict[str, Any],
|
||||
registry: SkillRegistry,
|
||||
llm: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Gather variables for the skill's prompt template.
|
||||
|
||||
Reads metadata, fetches the HF README (if applicable), lists available
|
||||
base models, loads user priority tags, and returns a dict that maps to
|
||||
``{{variable}}`` placeholders in ``prompt.md``.
|
||||
"""
|
||||
from ...metadata_ops import identify_model_type, list_base_models
|
||||
from ..settings_manager import SettingsManager
|
||||
|
||||
context: Dict[str, Any] = {
|
||||
"model_path": model_path,
|
||||
"model_basename": "",
|
||||
"hf_url": "",
|
||||
"repo": "",
|
||||
"readme_content": "",
|
||||
"readme_content_full": "",
|
||||
"current_metadata": {},
|
||||
"base_models": [],
|
||||
"priority_tags": "",
|
||||
}
|
||||
|
||||
# Extract model basename (filename without extension) for the LLM
|
||||
# to use when locating the matching section in collection repos.
|
||||
raw_basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
context["model_basename"] = raw_basename or ""
|
||||
|
||||
context["current_metadata"] = {
|
||||
"file_name": metadata.get("file_name", ""),
|
||||
"base_model": metadata.get("base_model", ""),
|
||||
"tags": metadata.get("tags", []),
|
||||
"modelDescription": metadata.get("modelDescription", ""),
|
||||
"trainedWords": metadata.get("trainedWords", []),
|
||||
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
|
||||
"size": metadata.get("size", 0),
|
||||
}
|
||||
|
||||
hf_url = metadata.get("hf_url", "")
|
||||
context["hf_url"] = hf_url
|
||||
repo = self._extract_repo_from_url(hf_url) if hf_url else ""
|
||||
context["repo"] = repo or ""
|
||||
if repo:
|
||||
readme = await self._fetch_readme(repo)
|
||||
# Trim README to the section relevant to this model file
|
||||
# (collection repos often have multiple models in one README).
|
||||
if readme and raw_basename:
|
||||
trimmed = extract_relevant_section(readme, raw_basename)
|
||||
cleaned = clean_readme_for_llm(trimmed) if trimmed else ""
|
||||
else:
|
||||
cleaned = clean_readme_for_llm(readme) if readme else ""
|
||||
context["readme_content"] = cleaned if cleaned else "(README not available)"
|
||||
context["readme_content_full"] = readme or ""
|
||||
|
||||
try:
|
||||
raw_models = await list_base_models()
|
||||
context["base_models"] = self._format_base_models(raw_models)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to list base models: %s", exc)
|
||||
context["base_models"] = "</not available>"
|
||||
|
||||
# Determine model type and load the corresponding priority_tags
|
||||
try:
|
||||
model_type = await identify_model_type(model_path)
|
||||
context["model_type"] = model_type
|
||||
settings = SettingsManager()
|
||||
priority_config = settings.get_priority_tag_config()
|
||||
context["priority_tags"] = priority_config.get(model_type, "")
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to load priority tags: %s", exc)
|
||||
context["model_type"] = "lora"
|
||||
context["priority_tags"] = ""
|
||||
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _extract_repo_from_url(hf_url: str) -> Optional[str]:
|
||||
"""Extract ``user/repo`` from a HuggingFace URL."""
|
||||
if not hf_url:
|
||||
return None
|
||||
m = re.match(r"https?://huggingface\.co/([^/]+/[^/]+)", hf_url)
|
||||
return m.group(1) if m else None
|
||||
|
||||
@staticmethod
|
||||
async def _fetch_readme(repo: str) -> str:
|
||||
"""Fetch README.md from HuggingFace (tries ``main``, then ``master``)."""
|
||||
async with aiohttp.ClientSession(
|
||||
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as session:
|
||||
for branch in ("main", "master"):
|
||||
url = f"https://huggingface.co/{repo}/raw/{branch}/README.md"
|
||||
try:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.text()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to fetch README from %s: %s", url, exc)
|
||||
return ""
|
||||
|
||||
async def _emit_progress(
|
||||
self,
|
||||
callback: Optional[AgentProgressReporter],
|
||||
skill_name: str,
|
||||
*,
|
||||
status: str,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
"""Send a progress update via WebSocket (if callback is set)."""
|
||||
payload: Dict[str, Any] = {"type": "agent_progress", "skill": skill_name, "status": status}
|
||||
payload.update(extra)
|
||||
if callback is not None:
|
||||
await callback.on_progress(payload)
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Post-processing engine for skill pipeline outputs.
|
||||
|
||||
The :class:`PostProcessor` takes the LLM's structured JSON output and applies
|
||||
it to a model's on-disk metadata via the :mod:`~py.metadata_ops` functions.
|
||||
|
||||
It handles all the skill-specific business logic — conditions, transformations,
|
||||
and orchestration of multiple side-effects (write metadata, download preview,
|
||||
refresh cache). All actual I/O is delegated to :mod:`~py.metadata_ops`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostProcessor:
|
||||
"""Deterministic post-processor for skill pipeline outputs.
|
||||
|
||||
Usage (called by :class:`~py.services.agent.agent_service.AgentService`)::
|
||||
|
||||
processor = PostProcessor()
|
||||
result = await processor.process(
|
||||
skill_name="enrich_hf_metadata",
|
||||
model_path="/path/to/model.safetensors",
|
||||
llm_output={...},
|
||||
metadata={...}, # from metadata_ops.read_metadata()
|
||||
)
|
||||
"""
|
||||
|
||||
async def process(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
model_path: str,
|
||||
llm_output: Dict[str, Any],
|
||||
metadata: Dict[str, Any],
|
||||
readme_content: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Route *llm_output* to the correct skill post-processor.
|
||||
|
||||
*readme_content* is optional raw markdown content (e.g. HF README)
|
||||
that is converted to HTML and stored as ``modelDescription`` for
|
||||
the description tab.
|
||||
|
||||
Returns a dict with keys ``success`` (bool), ``updated_fields`` (list),
|
||||
``preview_downloaded`` (bool), and ``errors`` (list).
|
||||
"""
|
||||
if skill_name == "enrich_hf_metadata":
|
||||
return await self._process_enrich_hf_metadata(
|
||||
model_path, llm_output, metadata, readme_content,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"updated_fields": [],
|
||||
"errors": [f"No post-processor registered for skill: {skill_name}"],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# enrich_hf_metadata
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _process_enrich_hf_metadata(
|
||||
self,
|
||||
model_path: str,
|
||||
llm_output: Dict[str, Any],
|
||||
metadata: Dict[str, Any],
|
||||
readme_content: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
from ...metadata_ops import (
|
||||
apply_metadata_updates,
|
||||
download_preview,
|
||||
refresh_cache,
|
||||
)
|
||||
from .skills.enrich_hf_metadata.readme_processor import (
|
||||
convert_readme_to_html,
|
||||
extract_gallery_images,
|
||||
extract_gallery_table_images,
|
||||
extract_relevant_section,
|
||||
extract_simple_markdown_images,
|
||||
extract_html_img_tags,
|
||||
extract_repo_from_hf_url,
|
||||
)
|
||||
|
||||
updated_fields: List[str] = []
|
||||
preview_downloaded = False
|
||||
|
||||
# -- Determine whether this is an HF-sourced model -----------------
|
||||
is_hf_model = not metadata.get("from_civitai", True)
|
||||
|
||||
# -- Collect updates -----------------------------------------------
|
||||
updates: Dict[str, Any] = {}
|
||||
|
||||
# base_model
|
||||
new_base = (llm_output.get("base_model") or "").strip()
|
||||
current_base = metadata.get("base_model", "") or ""
|
||||
if new_base and self._should_overwrite(current_base, is_hf_model):
|
||||
updates["base_model"] = new_base
|
||||
|
||||
# trigger words → civitai.trainedWords
|
||||
new_triggers = llm_output.get("trigger_words", [])
|
||||
trigger_words_empty = True
|
||||
if isinstance(new_triggers, list):
|
||||
cleaned = [t.strip() for t in new_triggers if t.strip()]
|
||||
cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")]
|
||||
trigger_words_empty = not cleaned
|
||||
current_civitai = metadata.get("civitai") or {}
|
||||
current_triggers = current_civitai.get("trainedWords") or []
|
||||
if self._should_overwrite_list(current_triggers, is_hf_model):
|
||||
trig_civitai = dict(current_civitai)
|
||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
||||
trig_civitai.update(updates["civitai"])
|
||||
trig_civitai["trainedWords"] = cleaned
|
||||
updates["civitai"] = trig_civitai
|
||||
|
||||
# modelDescription — from raw README content (converted to HTML)
|
||||
if readme_content and is_hf_model:
|
||||
converted = convert_readme_to_html(readme_content)
|
||||
if converted:
|
||||
updates["modelDescription"] = converted
|
||||
|
||||
# short_description → civitai.description (for "About this version")
|
||||
short_desc = (llm_output.get("short_description") or "").strip()
|
||||
if short_desc and is_hf_model:
|
||||
current_civitai = metadata.get("civitai") or {}
|
||||
desc_civitai = dict(current_civitai)
|
||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
||||
desc_civitai.update(updates["civitai"])
|
||||
desc_civitai["description"] = short_desc
|
||||
updates["civitai"] = desc_civitai
|
||||
|
||||
# gallery images → civitai.images (from YAML frontmatter widget entries
|
||||
# and Sample Gallery markdown tables in the README body)
|
||||
gallery_images: List[Dict[str, Any]] = []
|
||||
if readme_content and is_hf_model:
|
||||
hf_url = metadata.get("hf_url", "") or ""
|
||||
repo = extract_repo_from_hf_url(hf_url)
|
||||
if repo:
|
||||
rec_w = llm_output.get("recommended_width") or 0
|
||||
rec_h = llm_output.get("recommended_height") or 0
|
||||
|
||||
# 1. Widget images (YAML frontmatter)
|
||||
gallery = extract_gallery_images(
|
||||
readme_content, repo,
|
||||
default_width=rec_w, default_height=rec_h,
|
||||
)
|
||||
|
||||
# 2. Sample Gallery table images (markdown body), deduplicated
|
||||
existing_urls = {img["url"] for img in gallery if img.get("url")}
|
||||
table_images = extract_gallery_table_images(
|
||||
readme_content, repo,
|
||||
existing_urls=existing_urls,
|
||||
default_width=rec_w, default_height=rec_h,
|
||||
)
|
||||
existing_urls.update(img["url"] for img in table_images if img.get("url"))
|
||||
|
||||
# 3. Simple markdown images `` in the body
|
||||
simple_images = extract_simple_markdown_images(
|
||||
readme_content, repo,
|
||||
existing_urls=existing_urls,
|
||||
default_width=rec_w, default_height=rec_h,
|
||||
)
|
||||
existing_urls.update(img["url"] for img in simple_images if img.get("url"))
|
||||
|
||||
# 4. HTML `<img>` tags (used by many collection repos)
|
||||
html_images = extract_html_img_tags(
|
||||
readme_content, repo,
|
||||
existing_urls=existing_urls,
|
||||
default_width=rec_w, default_height=rec_h,
|
||||
)
|
||||
|
||||
all_images = gallery + table_images + simple_images + html_images
|
||||
if all_images:
|
||||
gallery_images = all_images
|
||||
current_civitai = metadata.get("civitai") or {}
|
||||
gallery_civitai = dict(current_civitai)
|
||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
||||
gallery_civitai.update(updates["civitai"])
|
||||
gallery_civitai["images"] = all_images
|
||||
updates["civitai"] = gallery_civitai
|
||||
|
||||
# tags
|
||||
new_tags = llm_output.get("tags", [])
|
||||
if isinstance(new_tags, list) and new_tags:
|
||||
existing_tags = metadata.get("tags") or []
|
||||
merged = self._merge_tags(existing_tags, new_tags)
|
||||
if len(merged) > len(existing_tags) or is_hf_model:
|
||||
updates["tags"] = merged
|
||||
|
||||
# metadata_source & llm_enriched_at (always set)
|
||||
updates["metadata_source"] = "agent:enrich_hf_metadata"
|
||||
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Store LLM confidence in metadata so it's accessible for evaluation
|
||||
raw_confidence = (llm_output.get("confidence") or "").strip()
|
||||
if raw_confidence:
|
||||
updates["_llm_confidence"] = raw_confidence
|
||||
|
||||
# Fallback: extract instance_prompt from YAML frontmatter when the LLM
|
||||
# returned empty trigger words but the README has instance_prompt.
|
||||
if trigger_words_empty:
|
||||
instance_prompt = _extract_yaml_instance_prompt(readme_content)
|
||||
if instance_prompt:
|
||||
current_civitai = metadata.get("civitai") or {}
|
||||
trig_civitai = dict(current_civitai)
|
||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
||||
trig_civitai.update(updates["civitai"])
|
||||
trig_civitai["trainedWords"] = [instance_prompt]
|
||||
updates["civitai"] = trig_civitai
|
||||
|
||||
preview_remote_url = (llm_output.get("preview_url") or "").strip()
|
||||
# Fallback: if the LLM couldn't find a preview image in the cleaned
|
||||
# README, find the first gallery image from the *model-specific
|
||||
# section* of the README (not the repo-wide first image, which
|
||||
# belongs to a different model in collection repos).
|
||||
if not preview_remote_url and readme_content and is_hf_model:
|
||||
model_basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
relevant_section = extract_relevant_section(
|
||||
readme_content, model_basename,
|
||||
)
|
||||
if relevant_section and relevant_section != readme_content:
|
||||
for img in gallery_images:
|
||||
img_url = img.get("url", "")
|
||||
if img_url and img_url in relevant_section:
|
||||
preview_remote_url = img_url
|
||||
break
|
||||
# Last resort: use the first gallery image from the full README.
|
||||
if not preview_remote_url and gallery_images:
|
||||
preview_remote_url = gallery_images[0].get("url", "")
|
||||
current_preview = metadata.get("preview_url") or ""
|
||||
if preview_remote_url and not (current_preview and os.path.exists(current_preview)):
|
||||
local_path = await download_preview(model_path, preview_remote_url)
|
||||
if local_path:
|
||||
preview_downloaded = True
|
||||
updates["preview_url"] = local_path
|
||||
|
||||
# notes — plain-text summary of usage info from the LLM
|
||||
new_notes = (llm_output.get("notes") or "").strip()
|
||||
if new_notes:
|
||||
updates["notes"] = new_notes
|
||||
|
||||
# usage_tips — JSON string (e.g. {"strength_min":0.85,"strength_max":1.4})
|
||||
raw_tips = (llm_output.get("usage_tips") or "").strip()
|
||||
if raw_tips and raw_tips != "{}":
|
||||
try:
|
||||
json.loads(raw_tips)
|
||||
updates["usage_tips"] = raw_tips
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning(
|
||||
"LLM returned invalid usage_tips JSON: %s", raw_tips[:200]
|
||||
)
|
||||
|
||||
if updates:
|
||||
updated_fields = await apply_metadata_updates(model_path, updates)
|
||||
|
||||
# -- Refresh scanner cache ------------------------------------------
|
||||
if updated_fields or preview_downloaded:
|
||||
await refresh_cache(model_path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"updated_fields": updated_fields,
|
||||
"preview_downloaded": preview_downloaded,
|
||||
"updates": updates,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _should_overwrite(current_value: str, is_hf_model: bool) -> bool:
|
||||
"""Return ``True`` when a scalar field should be overwritten."""
|
||||
return is_hf_model or not current_value or current_value.lower() in (
|
||||
"", "unknown",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _should_overwrite_list(current_list: List[str], is_hf_model: bool) -> bool:
|
||||
"""Return ``True`` when a list field should be overwritten."""
|
||||
return is_hf_model or not current_list
|
||||
|
||||
@staticmethod
|
||||
def _merge_tags(existing: List[str], new: List[str]) -> List[str]:
|
||||
"""Merge *new* tags into *existing*, all lowercased.
|
||||
|
||||
This matches the behaviour of :class:`TagUpdateService` which
|
||||
normalises every tag to lowercase for case-insensitive dedup.
|
||||
"""
|
||||
merged: List[str] = []
|
||||
seen: set = set()
|
||||
for tag in list(existing) + list(new):
|
||||
t = tag.strip().lower()
|
||||
if t and t not in seen:
|
||||
merged.append(t)
|
||||
seen.add(t)
|
||||
return merged
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Module-level helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_yaml_instance_prompt(readme_content: str) -> str:
|
||||
"""Extract ``instance_prompt`` from the YAML frontmatter of a HF README.
|
||||
|
||||
Returns the prompt text, or empty string if not found. Handles
|
||||
``null`` / ``~`` YAML null values by returning empty string.
|
||||
"""
|
||||
if not readme_content or not readme_content.startswith("---"):
|
||||
return ""
|
||||
|
||||
# Find end of frontmatter
|
||||
end = readme_content.find("---", 3)
|
||||
if end == -1:
|
||||
return ""
|
||||
frontmatter = readme_content[3:end]
|
||||
|
||||
for line in frontmatter.split("\n"):
|
||||
line = line.strip()
|
||||
m = re.match(r"^instance_prompt:\s*(.*)", line)
|
||||
if m:
|
||||
val = m.group(1).strip().strip('"').strip("'")
|
||||
if val.lower() in ("null", "~", "none", ""):
|
||||
return ""
|
||||
return val
|
||||
|
||||
return ""
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Skill definition data structures.
|
||||
|
||||
Each skill is described by a :class:`SkillDefinition` that declares its
|
||||
input/output schemas, whether it needs an LLM call, and what permissions
|
||||
its post-processor has.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillPermissions:
|
||||
"""Declarative permission scope for a skill's post-processor.
|
||||
|
||||
These are auditable constraints — the :class:`AgentService` checks them
|
||||
before invoking the handler. They are defense-in-depth, not a sandbox.
|
||||
"""
|
||||
|
||||
write_metadata: bool = True
|
||||
write_previews: bool = True
|
||||
network_domains: Tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillDefinition:
|
||||
"""Immutable description of an agent skill."""
|
||||
|
||||
name: str
|
||||
title: str
|
||||
description: str
|
||||
llm_required: bool
|
||||
input_schema: Dict[str, Any] = field(default_factory=dict)
|
||||
output_schema: Dict[str, Any] = field(default_factory=dict)
|
||||
model_type_filter: Optional[List[str]] = None
|
||||
permissions: SkillPermissions = field(default_factory=SkillPermissions)
|
||||
|
||||
def applies_to_model_type(self, model_type: str) -> bool:
|
||||
"""Return ``True`` if this skill can run on the given model type."""
|
||||
|
||||
if self.model_type_filter is None:
|
||||
return True
|
||||
return model_type in self.model_type_filter
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Discovery and loading of prompt-based skills.
|
||||
|
||||
Skills live in ``py/services/agent/skills/<name>/`` directories. Each
|
||||
directory must contain a ``prompt.md`` file with YAML frontmatter::
|
||||
|
||||
---
|
||||
name: my_skill
|
||||
title: "My Skill"
|
||||
description: "What this skill does"
|
||||
llm_required: true
|
||||
---
|
||||
|
||||
Prompt template with ``{{variable}}`` placeholders.
|
||||
|
||||
Legacy ``SKILL.md`` files are also supported for backward compatibility.
|
||||
|
||||
The registry scans the skills directory on first access and caches results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from .skill_definition import SkillDefinition, SkillPermissions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Directory where built-in skills are stored
|
||||
_SKILLS_DIR = Path(__file__).parent / "skills"
|
||||
|
||||
#: Preferred file names for prompt definition files (tried in order).
|
||||
#: ``prompt.md`` is the current convention; ``SKILL.md`` is the legacy name
|
||||
#: kept for backward compatibility.
|
||||
_PROMPT_FILE_NAMES: tuple[str, ...] = ("prompt.md", "SKILL.md")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frontmatter parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FRONTMATTER_RE = re.compile(
|
||||
r"^---\s*\n(.*?\n)---\s*\n?(.*)", re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def _parse_skill_file(path: Path) -> tuple[dict, str]:
|
||||
"""Read a prompt definition file (``prompt.md`` or legacy ``SKILL.md``) and
|
||||
return (frontmatter_dict, body_text).
|
||||
|
||||
Raises ``ValueError`` if the file lacks valid YAML frontmatter.
|
||||
"""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
m = _FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
raise ValueError(f"Missing or invalid YAML frontmatter in {path}")
|
||||
frontmatter = yaml.safe_load(m.group(1))
|
||||
if not isinstance(frontmatter, dict):
|
||||
raise ValueError(f"Frontmatter in {path} is not a mapping")
|
||||
body = m.group(2).strip()
|
||||
return frontmatter, body
|
||||
|
||||
|
||||
class SkillRegistry:
|
||||
"""Discover and load agent skills from the filesystem."""
|
||||
|
||||
_instance: Optional["SkillRegistry"] = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
def __init__(self, skills_dir: Path = _SKILLS_DIR) -> None:
|
||||
self._skills_dir = skills_dir
|
||||
self._skills: Dict[str, SkillDefinition] = {}
|
||||
self._loaded: bool = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Singleton access
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "SkillRegistry":
|
||||
"""Return the lazily-initialised global ``SkillRegistry``."""
|
||||
|
||||
if cls._instance is None:
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
registry = cls()
|
||||
registry._discover()
|
||||
cls._instance = registry
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls) -> None:
|
||||
"""Reset the cached singleton — primarily for tests."""
|
||||
|
||||
cls._instance = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _find_prompt_file(skill_dir: Path) -> Path | None:
|
||||
"""Return the first prompt definition file that exists in *skill_dir*.
|
||||
|
||||
Tries ``_PROMPT_FILE_NAMES`` in order so that new conventions
|
||||
(``prompt.md``) take precedence while legacy ``SKILL.md`` files
|
||||
still load without changes.
|
||||
"""
|
||||
for name in _PROMPT_FILE_NAMES:
|
||||
candidate = skill_dir / name
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _discover(self) -> None:
|
||||
"""Scan the skills directory and load all valid skill definitions."""
|
||||
|
||||
self._skills.clear()
|
||||
if not self._skills_dir.is_dir():
|
||||
logger.warning("Skills directory does not exist: %s", self._skills_dir)
|
||||
self._loaded = True
|
||||
return
|
||||
|
||||
for entry in sorted(self._skills_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
prompt_file = self._find_prompt_file(entry)
|
||||
if prompt_file is None:
|
||||
continue
|
||||
try:
|
||||
definition = self._load_skill_definition(prompt_file)
|
||||
if definition is not None:
|
||||
self._skills[definition.name] = definition
|
||||
logger.debug("Loaded skill: %s", definition.name)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load skill from %s: %s", prompt_file, exc)
|
||||
|
||||
self._loaded = True
|
||||
logger.info("Discovered %d prompt-based skills", len(self._skills))
|
||||
|
||||
def _load_skill_definition(self, path: Path) -> Optional[SkillDefinition]:
|
||||
"""Parse a prompt definition file's frontmatter into a
|
||||
:class:`SkillDefinition`."""
|
||||
|
||||
try:
|
||||
data, _body = _parse_skill_file(path)
|
||||
except (ValueError, yaml.YAMLError) as exc:
|
||||
logger.warning("Failed to parse prompt file %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
if "name" not in data:
|
||||
logger.warning("Prompt file %s missing required 'name' field", path)
|
||||
return None
|
||||
|
||||
perm_data = data.get("permissions", {})
|
||||
permissions = SkillPermissions(
|
||||
write_metadata=perm_data.get("write_metadata", True),
|
||||
write_previews=perm_data.get("write_previews", True),
|
||||
network_domains=tuple(perm_data.get("network_domains", [])),
|
||||
)
|
||||
|
||||
return SkillDefinition(
|
||||
name=data["name"],
|
||||
title=data.get("title", data["name"]),
|
||||
description=data.get("description", ""),
|
||||
llm_required=data.get("llm_required", False),
|
||||
input_schema=data.get("input_schema", {}),
|
||||
output_schema=data.get("output_schema", {}),
|
||||
model_type_filter=data.get("model_type_filter"),
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_skills(self) -> List[SkillDefinition]:
|
||||
"""Return all discovered skill definitions."""
|
||||
|
||||
if not self._loaded:
|
||||
self._discover()
|
||||
return list(self._skills.values())
|
||||
|
||||
def get_skill(self, name: str) -> Optional[SkillDefinition]:
|
||||
"""Return the skill definition for ``name``, or ``None`` if not found."""
|
||||
|
||||
if not self._loaded:
|
||||
self._discover()
|
||||
return self._skills.get(name)
|
||||
|
||||
def load_prompt(self, name: str) -> str:
|
||||
"""Load and return the prompt template body for the named skill."""
|
||||
|
||||
skill_dir = self._skills_dir / name
|
||||
skill_path = self._find_prompt_file(skill_dir)
|
||||
if skill_path is None:
|
||||
raise FileNotFoundError(
|
||||
f"Prompt file not found for skill '{name}' in {skill_dir} "
|
||||
f"(tried {list(_PROMPT_FILE_NAMES)})"
|
||||
)
|
||||
try:
|
||||
_frontmatter, body = _parse_skill_file(skill_path)
|
||||
return body
|
||||
except (ValueError, yaml.YAMLError) as exc:
|
||||
raise ValueError(f"Failed to parse prompt from {skill_path}: {exc}") from exc
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
name: enrich_hf_metadata
|
||||
title: "Enrich Metadata from HuggingFace"
|
||||
description: >
|
||||
Parse the HuggingFace model card via LLM to extract description, trigger
|
||||
words, base model, tags, and preview image URL.
|
||||
llm_required: true
|
||||
---
|
||||
|
||||
You are an expert assistant for AI image generation models. Your task is to extract structured metadata from a HuggingFace model card (README.md).
|
||||
|
||||
## Model Information
|
||||
|
||||
- **Repository**: {{hf_url}}
|
||||
- **Model file path**: {{model_path}}
|
||||
- **Model filename**: {{model_basename}}
|
||||
- **Repository ID**: {{repo}}
|
||||
|
||||
## Current Metadata (may be incomplete)
|
||||
|
||||
```json
|
||||
{{current_metadata}}
|
||||
```
|
||||
|
||||
## User Priority Tags Reference
|
||||
|
||||
The user has configured the following list of **meaningful tag categories** for this model type (`{{model_type}}`):
|
||||
|
||||
```
|
||||
{{priority_tags}}
|
||||
```
|
||||
|
||||
These are the subjects, styles, and concepts the user considers useful for categorization. Use this list as a **reference** when evaluating tags (see the **tags** section below).
|
||||
|
||||
## Available Base Models
|
||||
|
||||
The following base models are currently valid in this system. Use the EXACT
|
||||
name listed — do not invent aliases or modify variant suffixes.
|
||||
|
||||
{{base_models}}
|
||||
|
||||
## HuggingFace README Content
|
||||
|
||||
```
|
||||
{{readme_content}}
|
||||
```
|
||||
|
||||
## Extraction Instructions
|
||||
|
||||
Extract the following information from the README content above:
|
||||
|
||||
### base_model
|
||||
The base model this model was trained on. Use EXACTLY one of the names from the **Available Base Models** list above. Do not invent new names or use aliases.
|
||||
|
||||
Check the YAML frontmatter for ``base_model:`` first. If the frontmatter has no ``base_model:``, look at the **model filename** (``{{model_basename}}``), YAML ``tags:``, README title and first paragraph for clues — the base model family is often embedded in the name
|
||||
|
||||
### trigger_words
|
||||
The trigger words or activation prompts needed to use this LoRA. Look for:
|
||||
- `instance_prompt:` in the YAML frontmatter
|
||||
- Phrases like "trigger word:", "trigger:", "use this prompt:", "activation prompt:"
|
||||
- In collection repos: the trigger section **specific to this model file** (look near matching download links or anchor IDs)
|
||||
- Example prompts at the start (usually the first word or phrase before any description)
|
||||
Return as an array of strings. If none found, return an empty array `[]`. **Never** return `["None"]` or any placeholder value — a truly empty list means no trigger words exist.
|
||||
|
||||
### short_description
|
||||
A concise 1-2 sentence summary of what this model does. Extract from the "Model description" section or the first paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`, not the repo as a whole. Return empty string if the README is too minimal.
|
||||
|
||||
### tags
|
||||
3-8 relevant tags for categorizing this model. **Quality over quantity.**
|
||||
|
||||
Sources to consider:
|
||||
- The YAML frontmatter `tags:` list (filter out technical ones — see below)
|
||||
- The subject, style, character, or concept the model represents
|
||||
- The model filename itself may give clues (e.g. "pokemon", "anime", "pixelart")
|
||||
|
||||
**Critical filtering rules — apply them strictly:**
|
||||
|
||||
1. **Exclude technical/generic tags.** Reject any tag that describes the model's **training methodology, framework, architecture, or modality** rather than its content. Examples to exclude: `text-to-image`, `diffusers`, `lora`, `dreambooth`, `diffusers-training`, `flux`, `sdxl`, `checkpoint`, `pytorch`, `safetensors`, `fine-tuning`, `stable-diffusion`, and any variant of these.
|
||||
|
||||
2. **Cross-reference against the priority_tags reference.** Only include a tag if it meaningfully describes what the model actually creates (subject, style, character type) and is semantically close to one of the priority_tags. If none of the README's tags match meaningful categories, prefer returning a smaller set or an empty array over including low-value tags.
|
||||
|
||||
3. **All lowercase, no spaces, no hyphens** (use single words like `"photorealistic"`, `"anime"`, `"character"`).
|
||||
|
||||
Return empty array if no meaningful content tags remain after filtering.
|
||||
|
||||
### recommended_width, recommended_height
|
||||
The recommended image generation resolution for this model, in pixels. Look for sections like "Best Dimensions", "Recommended size", "Suggested resolution", or similar phrasing in the README. Prefer the explicitly marked "Best" or default resolution. If the table/list has multiple entries (e.g. "768 x 1024 (Best)" and "1024 x 1024 (Default)"), use the one marked "Best". Return integers. If no resolution can be determined, return 0 for both.
|
||||
|
||||
### preview_url
|
||||
The URL of the most suitable preview image from the README. Look for:
|
||||
- Image tags near the section matching the model filename (`{{model_basename}}`)
|
||||
- The YAML frontmatter `widget:` section (which often has `output.url` fields)
|
||||
- In collection repos: the sample images listed **under the section** for this specific model version
|
||||
- Generic `` in the body
|
||||
Choose the first image that appears to be a generation example (not a logo or diagram). Construct the absolute URL as `https://huggingface.co/{{repo}}/resolve/main/{filename}`. If no suitable image is found, return an empty string.
|
||||
|
||||
### notes
|
||||
A plain-text summary of the model card's key practical usage information. Combine trigger words, style modifiers, recommended parameters (steps, CFG, resolution, sampler), and any setup tips into a readable paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`. Return empty string if the README has no useful usage info.
|
||||
|
||||
### usage_tips
|
||||
A JSON string with structured usage recommendations. Extract from the README any explicit ranges or recommended values (e.g. "Set LoRA strength: **0.85 - 1.4**", "CLIP strength: 0.5"). Possible fields (include only those you can determine):
|
||||
|
||||
```json
|
||||
{
|
||||
"strength_min": 0.85,
|
||||
"strength_max": 1.4,
|
||||
"strength_range": "0.85-1.4",
|
||||
"strength": 0.6,
|
||||
"clip_strength": 0.5,
|
||||
"clip_skip": 2
|
||||
}
|
||||
```
|
||||
|
||||
Return the JSON string (e.g. `'{"strength_min":0.85,"strength_max":1.4}'`). Return `"{}"` if nothing useful is found.
|
||||
|
||||
### confidence
|
||||
Your confidence level in the extracted data:
|
||||
- "high" — most fields were explicitly stated in the README
|
||||
- "medium" — some fields were inferred from context
|
||||
- "low" — most fields are guesses based on limited information
|
||||
|
||||
## Important: Handling Collection Repos (multiple model files)
|
||||
|
||||
Many HuggingFace repos contain **multiple model files** in a single repository
|
||||
(e.g. a "LoRA collection" with different styles/characters in separate files).
|
||||
|
||||
The model file currently being enriched is: **`{{model_basename}}`**
|
||||
|
||||
To find the correct section in the README:
|
||||
|
||||
1. **Search for download links** containing the filename — the surrounding paragraph is your section.
|
||||
2. **Search for anchor IDs** (`<a id="...">`) or section headings whose text matches words from the filename.
|
||||
3. **Search for HTML headings** (`<h1>`, `<h2>`, `<span>`) containing parts of the filename.
|
||||
4. If no match is found, use the full README as usual — the model may be the only one in the repo.
|
||||
|
||||
When a matching section IS found, prefer metadata from that section.
|
||||
When no section matches (e.g. single-model repos or repos without per-file sections),
|
||||
extract metadata from the full README normally. Do not return empty data just
|
||||
because the filename doesn't appear in the README.
|
||||
|
||||
## Output Format
|
||||
|
||||
Return ONLY a JSON object with exactly these fields (no markdown fences, no extra text):
|
||||
|
||||
```json
|
||||
{
|
||||
"model_path": "{{model_path}}",
|
||||
"base_model": "<canonical name or empty string>",
|
||||
"trigger_words": ["<word1>", "<word2>"],
|
||||
"short_description": "<1-2 sentence summary>",
|
||||
"tags": ["<tag1>", "<tag2>"],
|
||||
"recommended_width": 768,
|
||||
"recommended_height": 1024,
|
||||
"preview_url": "<image URL or empty string>",
|
||||
"notes": "<plain-text usage summary or empty string>",
|
||||
"usage_tips": "<JSON string like '{\"strength_min\":0.85,\"strength_max\":1.4}' or '{}'>",
|
||||
"confidence": "<high|medium|low>"
|
||||
}
|
||||
```
|
||||
|
||||
Important:
|
||||
- Only include the JSON object, no other text
|
||||
- If a field cannot be determined, use an empty string or empty array
|
||||
- Do not fabricate information not supported by the README
|
||||
- Never use placeholder values like `"None"` or `"unknown"` for missing data — use empty string or empty array
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,6 +84,7 @@ class Aria2Downloader:
|
||||
self._transfers: Dict[str, Aria2Transfer] = {}
|
||||
self._poll_interval = 0.5
|
||||
self._state_store = Aria2TransferStateStore()
|
||||
self._stderr_reader_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@@ -115,7 +116,7 @@ class Aria2Downloader:
|
||||
|
||||
try:
|
||||
while True:
|
||||
status = await self.get_status(download_id)
|
||||
status = await self._get_status_with_retry(download_id)
|
||||
if status is None:
|
||||
return False, "aria2 download not found"
|
||||
|
||||
@@ -136,6 +137,35 @@ class Aria2Downloader:
|
||||
finally:
|
||||
self._transfers.pop(download_id, None)
|
||||
|
||||
async def _get_status_with_retry(
|
||||
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Call get_status with retry for transient RPC failures.
|
||||
|
||||
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
|
||||
``None`` immediately when the download_id is not tracked (a missing
|
||||
transfer is not a transient condition, so retrying is pointless).
|
||||
|
||||
A single failed RPC call should not immediately fail the download,
|
||||
because aria2 may be temporarily busy (e.g. finalizing multiple
|
||||
concurrent downloads) and a retry will often succeed.
|
||||
"""
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return await self.get_status(download_id)
|
||||
except Aria2Error as exc:
|
||||
last_exc = exc
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(
|
||||
"aria2 get_status transient failure (attempt %d/%d) for %s: %s",
|
||||
attempt + 1, max_retries, download_id, exc,
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
raise Aria2Error(
|
||||
f"Failed to query aria2 download status after {max_retries} attempts: {last_exc}"
|
||||
) from last_exc
|
||||
|
||||
async def _schedule_download(
|
||||
self,
|
||||
url: str,
|
||||
@@ -171,6 +201,13 @@ class Aria2Downloader:
|
||||
"auto-file-renaming": "false",
|
||||
"file-allocation": "none",
|
||||
}
|
||||
|
||||
# Pass proxy to aria2 so the actual file transfer goes through the
|
||||
# same proxy used by the aiohttp-based URL resolution step above.
|
||||
downloader = await get_downloader()
|
||||
if downloader.proxy_url:
|
||||
options["all-proxy"] = downloader.proxy_url
|
||||
|
||||
if request_headers:
|
||||
options["header"] = [
|
||||
f"{key}: {value}" for key, value in request_headers.items()
|
||||
@@ -312,6 +349,16 @@ class Aria2Downloader:
|
||||
async def close(self) -> None:
|
||||
"""Shut down the RPC process and session."""
|
||||
|
||||
# Cancel the background stderr reader first so it stops reading
|
||||
# from the pipe before the subprocess is terminated.
|
||||
if self._stderr_reader_task is not None:
|
||||
self._stderr_reader_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(self._stderr_reader_task, timeout=2.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
self._stderr_reader_task = None
|
||||
|
||||
if self._rpc_session is not None:
|
||||
await self._rpc_session.close()
|
||||
self._rpc_session = None
|
||||
@@ -331,6 +378,23 @@ class Aria2Downloader:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
async def _drain_stderr(self) -> None:
|
||||
"""Continuously drain aria2's stderr pipe so it never blocks.
|
||||
|
||||
When the 64 KB pipe buffer fills up, aria2's ``write()`` to stderr
|
||||
blocks, which freezes the entire ``aria2c`` process — including its
|
||||
RPC handler. This background task reads lines from stderr as they
|
||||
arrive and forwards them to Python's logger.
|
||||
"""
|
||||
try:
|
||||
assert self._process is not None and self._process.stderr is not None
|
||||
async for line in self._process.stderr:
|
||||
text = line.decode("utf-8", errors="replace").rstrip()
|
||||
if text:
|
||||
logger.debug("aria2 stderr: %s", text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
|
||||
try:
|
||||
result = callback(snapshot, snapshot)
|
||||
@@ -465,6 +529,17 @@ class Aria2Downloader:
|
||||
|
||||
await self._wait_until_ready()
|
||||
|
||||
# Drain aria2's stderr in a background task so the pipe buffer
|
||||
# never fills up. If the pipe blocks, aria2 itself freezes and
|
||||
# cannot respond to RPC — this was the root cause of the
|
||||
# "Failed to query aria2 download status" timeout bug.
|
||||
# Must start AFTER _wait_until_ready to avoid a race where the
|
||||
# drain task consumes aria2's early-exit error message before
|
||||
# _wait_until_ready can read it.
|
||||
self._stderr_reader_task = asyncio.create_task(
|
||||
self._drain_stderr()
|
||||
)
|
||||
|
||||
def _resolve_executable(self) -> str:
|
||||
settings = get_settings_manager()
|
||||
configured_path = (settings.get("aria2c_path") or "").strip()
|
||||
@@ -584,7 +659,9 @@ class Aria2Downloader:
|
||||
if self._rpc_session is None or self._rpc_session.closed:
|
||||
async with self._rpc_session_lock:
|
||||
if self._rpc_session is None or self._rpc_session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
total=None, sock_connect=10, sock_read=60
|
||||
)
|
||||
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
|
||||
return self._rpc_session
|
||||
|
||||
|
||||
@@ -104,6 +104,100 @@ class BaseModelService(ABC):
|
||||
fetch_duration = time.perf_counter() - t0
|
||||
initial_count = len(sorted_data)
|
||||
|
||||
# Optionally filter by civitai model ID (shows all local versions of a specific model)
|
||||
civitai_model_id = kwargs.get("civitai_model_id")
|
||||
if civitai_model_id is not None:
|
||||
sorted_data = [
|
||||
item for item in sorted_data
|
||||
if self._extract_model_id(item) == civitai_model_id
|
||||
]
|
||||
# VLM mode: always sort by version ID descending (newest version first),
|
||||
# regardless of the current sort_by preference.
|
||||
sorted_data.sort(
|
||||
key=lambda x: self._extract_version_id(x) or 0,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Optionally group by civitai modelId, showing only the latest version per model
|
||||
dedup_lost = 0
|
||||
if kwargs.get("group_by_model") and civitai_model_id is None:
|
||||
# Determine whether to further sub-group by base model
|
||||
# When version_grouping is "same_base", versions with different
|
||||
# base models are effectively different groups — the dedup key
|
||||
# needs to include base_model so the version count and VLM flow
|
||||
# stay consistent (card shows correct count for its base model).
|
||||
ufs = self.settings.get("version_grouping", "same_base")
|
||||
group_by_base = ufs == "same_base"
|
||||
|
||||
dedup_map = {} # (modelId [,base_model]) -> (item, version_id)
|
||||
version_counter = {} # same-key -> count
|
||||
standalone = []
|
||||
for item in sorted_data:
|
||||
mid = self._extract_model_id(item)
|
||||
if mid is None:
|
||||
standalone.append(item)
|
||||
continue
|
||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||
# Count all versions per key
|
||||
version_counter[key] = version_counter.get(key, 0) + 1
|
||||
vid = self._extract_version_id(item) or 0
|
||||
if key not in dedup_map or vid > dedup_map[key][1]:
|
||||
dedup_map[key] = (item, vid)
|
||||
# Attach version_count to each surviving grouped item (shallow copy
|
||||
# to avoid mutating cached dicts — the cache is shared across requests)
|
||||
for key, (item, vid) in dedup_map.items():
|
||||
item = dict(item)
|
||||
item["version_count"] = version_counter[key]
|
||||
dedup_map[key] = (item, vid)
|
||||
dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone))
|
||||
sorted_data = [entry[0] for entry in dedup_map.values()] + standalone
|
||||
|
||||
# Re-sort by version_count (grouped: after dedup; non-grouped: group internally, sort, expand)
|
||||
if sort_params.key == "versions_count" and civitai_model_id is None:
|
||||
reverse = sort_params.order == "desc"
|
||||
if kwargs.get("group_by_model"):
|
||||
# Grouped mode: items are already dedup'd with version_count attached
|
||||
sorted_data.sort(
|
||||
key=lambda x: (
|
||||
x.get("version_count", 0),
|
||||
(x.get("model_name") or x.get("file_name") or "").lower(),
|
||||
x.get("file_path", "").lower(),
|
||||
),
|
||||
reverse=reverse,
|
||||
)
|
||||
else:
|
||||
# Non-grouped mode: group internally, sort groups by count, expand
|
||||
# Respect the version_grouping setting (same logic as grouped dedup)
|
||||
ufs = self.settings.get("version_grouping", "same_base")
|
||||
group_by_base = ufs == "same_base"
|
||||
|
||||
model_groups: Dict[Any, List[Dict]] = {}
|
||||
ungrouped_standalone: List[Dict] = []
|
||||
for item in sorted_data:
|
||||
mid = self._extract_model_id(item)
|
||||
if mid is None:
|
||||
ungrouped_standalone.append(item)
|
||||
continue
|
||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||
model_groups.setdefault(key, []).append(item)
|
||||
# Sort versions within each group by version id descending
|
||||
for items in model_groups.values():
|
||||
items.sort(
|
||||
key=lambda x: self._extract_version_id(x) or 0,
|
||||
reverse=True,
|
||||
)
|
||||
# Sort groups by version count
|
||||
sorted_groups = sorted(
|
||||
model_groups.values(),
|
||||
key=lambda items: len(items),
|
||||
reverse=reverse,
|
||||
)
|
||||
# Flatten: grouped items first, standalone items last
|
||||
sorted_data = []
|
||||
for items in sorted_groups:
|
||||
sorted_data.extend(items)
|
||||
sorted_data.extend(ungrouped_standalone)
|
||||
|
||||
t1 = time.perf_counter()
|
||||
if hash_filters:
|
||||
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
||||
@@ -172,7 +266,7 @@ class BaseModelService(ABC):
|
||||
overall_duration = time.perf_counter() - overall_start
|
||||
logger.debug(
|
||||
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
|
||||
"Counts: initial=%d, post_filter=%d, final=%d",
|
||||
"Counts: initial=%d, dedup=%d, post_filter=%d, final=%d",
|
||||
self.__class__.__name__,
|
||||
overall_duration,
|
||||
fetch_duration,
|
||||
@@ -181,6 +275,7 @@ class BaseModelService(ABC):
|
||||
pagination_duration,
|
||||
annotate_duration,
|
||||
initial_count,
|
||||
dedup_lost,
|
||||
post_filter_count,
|
||||
final_count,
|
||||
)
|
||||
@@ -495,7 +590,7 @@ class BaseModelService(ABC):
|
||||
if not ordered_ids:
|
||||
return annotated
|
||||
|
||||
strategy_value = self.settings.get("update_flag_strategy")
|
||||
strategy_value = self.settings.get("version_grouping")
|
||||
if isinstance(strategy_value, str) and strategy_value.strip():
|
||||
strategy = strategy_value.strip().lower()
|
||||
else:
|
||||
@@ -696,8 +791,12 @@ class BaseModelService(ABC):
|
||||
}
|
||||
|
||||
@abstractmethod
|
||||
async def format_response(self, model_data: Dict) -> Dict:
|
||||
"""Format model data for API response - must be implemented by subclasses"""
|
||||
async def format_response(self, model_data: Dict) -> Optional[Dict]:
|
||||
"""Format model data for API response - must be implemented by subclasses.
|
||||
|
||||
Subclasses should return None for corrupted entries so the handler
|
||||
layer can filter them out. See issue #730.
|
||||
"""
|
||||
pass
|
||||
|
||||
# Common service methods that delegate to scanner
|
||||
|
||||
@@ -523,6 +523,10 @@ class BatchImportService:
|
||||
if payload.get("checkpoint"):
|
||||
metadata["checkpoint"] = payload["checkpoint"]
|
||||
|
||||
nsfw = payload.get("preview_nsfw_level")
|
||||
if isinstance(nsfw, int) and nsfw > 0:
|
||||
metadata["preview_nsfw_level"] = nsfw
|
||||
|
||||
image_bytes = None
|
||||
image_base64 = payload.get("image_base64")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
@@ -21,20 +21,37 @@ class CheckpointService(BaseModelService):
|
||||
"""
|
||||
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, checkpoint_data: Dict) -> Dict:
|
||||
"""Format Checkpoint data for API response"""
|
||||
async def format_response(self, checkpoint_data: Dict) -> Optional[Dict]:
|
||||
"""Format Checkpoint data for API response.
|
||||
|
||||
Returns None when the entry is missing critical fields (corrupted cache
|
||||
row), so the handler layer can filter it out. See issue #730.
|
||||
"""
|
||||
# Guard against corrupted cache entries missing critical fields
|
||||
file_path = checkpoint_data.get("file_path")
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
logger.warning(
|
||||
"Skipping corrupted checkpoint entry (missing file_path): %s",
|
||||
checkpoint_data.get("file_name", "<unknown>"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Get sub_type from cache entry (new canonical field)
|
||||
sub_type = checkpoint_data.get("sub_type", "checkpoint")
|
||||
|
||||
|
||||
file_name = checkpoint_data.get("file_name") or ""
|
||||
model_name = checkpoint_data.get("model_name") or file_name
|
||||
folder = checkpoint_data.get("folder") or ""
|
||||
|
||||
return {
|
||||
"model_name": checkpoint_data["model_name"],
|
||||
"file_name": checkpoint_data["file_name"],
|
||||
"model_name": model_name,
|
||||
"file_name": file_name,
|
||||
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")),
|
||||
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0),
|
||||
"base_model": checkpoint_data.get("base_model", ""),
|
||||
"folder": checkpoint_data["folder"],
|
||||
"folder": folder,
|
||||
"sha256": checkpoint_data.get("sha256", ""),
|
||||
"file_path": checkpoint_data["file_path"].replace(os.sep, "/"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": checkpoint_data.get("size", 0),
|
||||
"modified": checkpoint_data.get("modified", ""),
|
||||
"tags": checkpoint_data.get("tags", []),
|
||||
@@ -48,6 +65,8 @@ class CheckpointService(BaseModelService):
|
||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||
"version_count": checkpoint_data.get("version_count"),
|
||||
"hf_url": checkpoint_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -304,6 +304,20 @@ class CivArchiveClient:
|
||||
version_id = file_data.get("model_version_id") or file_data.get("modelVersionId")
|
||||
if model_id is None or version_id is None:
|
||||
continue
|
||||
# CivitAI / CivArchive model IDs are small integers (typically ≤ 7
|
||||
# digits). Reject suspiciously large values that indicate the API
|
||||
# returned a malformed payload (e.g. a hash reinterpreted as an ID)
|
||||
# to avoid pointless HTTP 500 errors from CivArchive.
|
||||
_MAX_VALID_CIVITAI_ID = 100_000_000
|
||||
try:
|
||||
if int(model_id) >= _MAX_VALID_CIVITAI_ID or int(version_id) >= _MAX_VALID_CIVITAI_ID:
|
||||
logger.debug(
|
||||
"Skipping implausible CivArchive model_id=%s / version_id=%s",
|
||||
model_id, version_id,
|
||||
)
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
resolved = await self.get_model_version(model_id, version_id)
|
||||
if resolved:
|
||||
return resolved
|
||||
@@ -327,7 +341,7 @@ class CivArchiveClient:
|
||||
if resolved:
|
||||
return resolved, None
|
||||
|
||||
logger.error("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
||||
logger.debug("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
||||
return None, "No version data found"
|
||||
|
||||
except RateLimitError:
|
||||
@@ -417,7 +431,7 @@ class CivArchiveClient:
|
||||
|
||||
if version_id is not None:
|
||||
raw_id = version_data.get("id")
|
||||
if raw_id != version_id:
|
||||
if raw_id is not None and str(raw_id) != str(version_id):
|
||||
logger.warning(
|
||||
"Requested version %s doesn't match default version %s for model %s",
|
||||
version_id,
|
||||
|
||||
@@ -196,6 +196,7 @@ class CivitaiBaseModelService:
|
||||
"ernie": "ERNI",
|
||||
"ernie turbo": "ETRB",
|
||||
"nucleus": "NUCL",
|
||||
"krea 2": "KR2",
|
||||
"svd": "SVD",
|
||||
"ltxv": "LTXV",
|
||||
"ltxv2": "LTV2",
|
||||
@@ -212,6 +213,18 @@ class CivitaiBaseModelService:
|
||||
"wan video 2.2 i2v-a14b": "WAN",
|
||||
"wan video 2.5 t2v": "WAN",
|
||||
"wan video 2.5 i2v": "WAN",
|
||||
"wan video 2.7": "WAN",
|
||||
"wan image 2.7": "WI27",
|
||||
"ace audio": "ACE",
|
||||
"boogu": "BOOG",
|
||||
"grok": "GROK",
|
||||
"happyhorse": "HAPP",
|
||||
"hidream-o1": "HIO1",
|
||||
"lens": "LENS",
|
||||
"mai": "MAI",
|
||||
"upscaler": "UPSC",
|
||||
"ideogram 4.0": "ID40",
|
||||
"qwen 2": "QWN2",
|
||||
}
|
||||
|
||||
if lower_name in special_cases:
|
||||
@@ -391,6 +404,7 @@ class CivitaiBaseModelService:
|
||||
"LTXV2",
|
||||
"LTXV 2.3",
|
||||
"CogVideoX",
|
||||
"HappyHorse",
|
||||
"Mochi",
|
||||
"Hunyuan Video",
|
||||
"Wan Video",
|
||||
@@ -403,15 +417,25 @@ class CivitaiBaseModelService:
|
||||
"Wan Video 2.2 I2V-A14B",
|
||||
"Wan Video 2.5 T2V",
|
||||
"Wan Video 2.5 I2V",
|
||||
"Wan Image 2.7",
|
||||
"Wan Video 2.7",
|
||||
],
|
||||
"Other Models": [
|
||||
"ACE Audio",
|
||||
"Illustrious",
|
||||
"Pony",
|
||||
"Pony V7",
|
||||
"Boogu",
|
||||
"HiDream",
|
||||
"HiDream-O1",
|
||||
"Ideogram 4.0",
|
||||
"Qwen",
|
||||
"Qwen 2",
|
||||
"AuraFlow",
|
||||
"Chroma",
|
||||
"Grok",
|
||||
"Lens",
|
||||
"MAI",
|
||||
"ZImageTurbo",
|
||||
"ZImageBase",
|
||||
"PixArt a",
|
||||
@@ -424,6 +448,8 @@ class CivitaiBaseModelService:
|
||||
"Ernie",
|
||||
"Ernie Turbo",
|
||||
"Nucleus",
|
||||
"Krea 2",
|
||||
"Upscaler",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class CivitaiClient:
|
||||
self._MAX_CACHE_ENTRIES = 500
|
||||
|
||||
def _build_image_info_url(self, image_id: str) -> str:
|
||||
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
return f"{self.base_url}/images?imageId={image_id}&nsfw=X&withMeta=true"
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
|
||||
+150
-19
@@ -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
|
||||
@@ -229,6 +230,12 @@ class DownloadManager:
|
||||
Returns:
|
||||
Dict with download result
|
||||
"""
|
||||
logger.debug(
|
||||
"[download] download_from_civitai called: model_id=%s, model_version_id=%s, "
|
||||
"source=%s, file_params=%s",
|
||||
model_id, model_version_id, source, file_params,
|
||||
)
|
||||
|
||||
# Validate that at least one identifier is provided
|
||||
if not model_id and not model_version_id:
|
||||
return {
|
||||
@@ -249,6 +256,7 @@ class DownloadManager:
|
||||
"source": source,
|
||||
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
|
||||
"progress": 0,
|
||||
|
||||
"status": "queued",
|
||||
"transfer_backend": self._get_model_download_backend(),
|
||||
"bytes_downloaded": 0,
|
||||
@@ -288,8 +296,8 @@ class DownloadManager:
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Download was cancelled",
|
||||
"success": True,
|
||||
"cancelled": True,
|
||||
"download_id": task_id,
|
||||
}
|
||||
finally:
|
||||
@@ -360,6 +368,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 +413,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 +437,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 +463,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
|
||||
@@ -1233,10 +1295,24 @@ class DownloadManager:
|
||||
"download_id": download_id,
|
||||
}
|
||||
|
||||
# Check if this checkpoint should be treated as a diffusion model based on baseModel
|
||||
# Check if this checkpoint should be treated as a diffusion model
|
||||
# Priority: (1) any file has type "UNet" or "Diffusion Model",
|
||||
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
|
||||
is_diffusion_model = False
|
||||
if model_type == "checkpoint":
|
||||
if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
|
||||
# Check file types first (more direct signal from CivitAI)
|
||||
version_files = version_info.get("files", [])
|
||||
for f in version_files:
|
||||
f_type = f.get("type", "")
|
||||
if f_type in ("UNet", "Diffusion Model"):
|
||||
is_diffusion_model = True
|
||||
logger.info(
|
||||
f"File type '{f_type}' detected, routing checkpoint to unet folder"
|
||||
)
|
||||
break
|
||||
|
||||
# Fallback to baseModel name check
|
||||
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
|
||||
is_diffusion_model = True
|
||||
logger.info(
|
||||
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
||||
@@ -1352,54 +1428,95 @@ class DownloadManager:
|
||||
|
||||
# If file_params is provided, try to find matching file
|
||||
if file_params and model_version_id:
|
||||
target_file_id = file_params.get("id")
|
||||
target_type = file_params.get("type", "Model")
|
||||
target_format = file_params.get("format", "SafeTensor")
|
||||
target_size = file_params.get("size", "full")
|
||||
target_format = file_params.get("format")
|
||||
target_size = file_params.get("size")
|
||||
target_fp = file_params.get("fp")
|
||||
is_primary = file_params.get("isPrimary", False)
|
||||
|
||||
if is_primary:
|
||||
# Find primary file
|
||||
logger.debug(
|
||||
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, isPrimary=%s, "
|
||||
"model_version_id=%s, total_files=%d",
|
||||
target_file_id, target_type, target_format, target_size, target_fp, is_primary,
|
||||
model_version_id, len(files),
|
||||
)
|
||||
|
||||
if target_file_id:
|
||||
target_id_str = str(target_file_id)
|
||||
for f in files:
|
||||
f_id = f.get("id")
|
||||
if str(f_id) == target_id_str:
|
||||
file_info = f
|
||||
logger.debug(
|
||||
"[download] MATCH by ID: id=%s name='%s'",
|
||||
f_id, f.get("name"),
|
||||
)
|
||||
break
|
||||
if not file_info:
|
||||
logger.debug("[download] No file found with id=%s", target_file_id)
|
||||
|
||||
elif is_primary:
|
||||
file_info = next(
|
||||
(
|
||||
f
|
||||
for f in files
|
||||
if f.get("primary")
|
||||
and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
||||
and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
|
||||
),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
# Match by metadata
|
||||
# Lenient metadata match: only compare fields present on both sides
|
||||
for f in files:
|
||||
f_type = f.get("type", "")
|
||||
f_meta = f.get("metadata", {})
|
||||
|
||||
# Check type match
|
||||
if f_type != target_type:
|
||||
continue
|
||||
|
||||
# Check metadata match
|
||||
if f_meta.get("format") != target_format:
|
||||
f_meta = f.get("metadata", {})
|
||||
f_format = f_meta.get("format") or f.get("format")
|
||||
f_size = f_meta.get("size") or f.get("size")
|
||||
f_fp = f_meta.get("fp") or f.get("fp")
|
||||
|
||||
if target_format and f_format != target_format:
|
||||
continue
|
||||
if f_meta.get("size") != target_size:
|
||||
if target_size and f_size and f_size != target_size:
|
||||
continue
|
||||
if target_fp and f_meta.get("fp") != target_fp:
|
||||
if target_fp and f_fp and f_fp != target_fp:
|
||||
continue
|
||||
|
||||
file_info = f
|
||||
break
|
||||
|
||||
if not file_info:
|
||||
logger.debug(
|
||||
"[download] No match found via file_params — falling back to primary file lookup",
|
||||
)
|
||||
elif not file_params:
|
||||
logger.debug(
|
||||
"[download] No file_params provided (null/None) — will use primary file lookup. "
|
||||
"model_version_id=%s, total_files=%d",
|
||||
model_version_id, len(files),
|
||||
)
|
||||
|
||||
# Fallback to primary file if no match found
|
||||
if not file_info:
|
||||
logger.debug("[download] Looking for primary file as fallback")
|
||||
file_info = next(
|
||||
(
|
||||
f
|
||||
for f in files
|
||||
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
||||
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if file_info:
|
||||
logger.debug(
|
||||
"[download] Fallback primary file selected: id=%s, name=%s",
|
||||
file_info.get("id"), file_info.get("name"),
|
||||
)
|
||||
else:
|
||||
logger.debug("[download] No primary file found in fallback lookup")
|
||||
|
||||
if not file_info:
|
||||
return {"success": False, "error": "No suitable file found in metadata"}
|
||||
@@ -1974,7 +2091,21 @@ class DownloadManager:
|
||||
break
|
||||
|
||||
last_error = result
|
||||
if os.path.exists(save_path):
|
||||
# For aria2: if the .aria2 control file is missing, aria2 considers
|
||||
# the download complete. A transient RPC failure may have made us
|
||||
# think the download failed even though the file is fully on disk.
|
||||
# Keep the file so a retry can find it already complete.
|
||||
if (
|
||||
transfer_backend == "aria2"
|
||||
and os.path.exists(save_path)
|
||||
and not os.path.exists(f"{save_path}.aria2")
|
||||
):
|
||||
logger.warning(
|
||||
"aria2 download reported failure but .aria2 file is absent "
|
||||
"for %s — the file is likely complete. Preserving it for retry.",
|
||||
save_path,
|
||||
)
|
||||
elif os.path.exists(save_path):
|
||||
try:
|
||||
os.remove(save_path)
|
||||
except Exception as e:
|
||||
|
||||
@@ -82,6 +82,7 @@ class DownloadQueueService:
|
||||
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:
|
||||
@@ -349,6 +350,7 @@ class DownloadQueueService:
|
||||
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.
|
||||
|
||||
@@ -356,6 +358,9 @@ class DownloadQueueService:
|
||||
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.
|
||||
"""
|
||||
@@ -368,7 +373,7 @@ class DownloadQueueService:
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
now = completed_at if completed_at is not None else time.time()
|
||||
conn.execute(
|
||||
"DELETE FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
@@ -604,7 +609,9 @@ class DownloadQueueService:
|
||||
|
||||
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.
|
||||
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()
|
||||
@@ -641,6 +648,10 @@ class DownloadQueueService:
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM download_history WHERE id = ?",
|
||||
(item_id,),
|
||||
)
|
||||
conn.commit()
|
||||
queued = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
@@ -652,6 +663,9 @@ class DownloadQueueService:
|
||||
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:
|
||||
@@ -687,6 +701,10 @@ class DownloadQueueService:
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM download_history WHERE id = ?",
|
||||
(row["id"],),
|
||||
)
|
||||
count += 1
|
||||
conn.commit()
|
||||
|
||||
@@ -728,3 +746,126 @@ class DownloadQueueService:
|
||||
"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
|
||||
|
||||
@@ -46,6 +46,30 @@ def is_ssl_cert_verify_error(exc: BaseException) -> bool:
|
||||
return "CERTIFICATE_VERIFY_FAILED" in str(exc)
|
||||
|
||||
|
||||
def _parse_retry_after(value: str) -> int:
|
||||
"""Parse a Retry-After header value into seconds.
|
||||
|
||||
Supports both integer seconds and HTTP-date formats.
|
||||
Returns a default of 60 seconds on invalid/missing input.
|
||||
"""
|
||||
if not value or not value.strip():
|
||||
return 60
|
||||
|
||||
value = value.strip()
|
||||
try:
|
||||
return max(1, int(value))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
parsed = parsedate_to_datetime(value)
|
||||
now = datetime.now().astimezone()
|
||||
delta = (parsed - now).total_seconds()
|
||||
return max(1, int(delta))
|
||||
except (ValueError, OverflowError, OSError):
|
||||
return 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadProgress:
|
||||
"""Snapshot of a download transfer at a moment in time."""
|
||||
@@ -256,7 +280,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 +294,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 +330,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 +361,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()
|
||||
|
||||
@@ -883,6 +935,19 @@ class Downloader:
|
||||
elif response.status == 404:
|
||||
error_msg = "File not found"
|
||||
return False, error_msg, None
|
||||
elif response.status == 429:
|
||||
raw_retry_after = response.headers.get("Retry-After")
|
||||
retry_after = _parse_retry_after(raw_retry_after or "")
|
||||
if raw_retry_after:
|
||||
logger.warning(
|
||||
"Rate limited (429) for %s, Retry-After: %ss", url, retry_after
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Rate limited (429) for %s, no Retry-After header; defaulting to %ss",
|
||||
url, retry_after,
|
||||
)
|
||||
return False, f"Rate limited (429), retry after {retry_after}s", None
|
||||
else:
|
||||
error_msg = f"Download failed with status {response.status}"
|
||||
return False, error_msg, None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
@@ -21,20 +21,37 @@ class EmbeddingService(BaseModelService):
|
||||
"""
|
||||
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, embedding_data: Dict) -> Dict:
|
||||
"""Format Embedding data for API response"""
|
||||
async def format_response(self, embedding_data: Dict) -> Optional[Dict]:
|
||||
"""Format Embedding data for API response.
|
||||
|
||||
Returns None when the entry is missing critical fields (corrupted cache
|
||||
row), so the handler layer can filter it out. See issue #730.
|
||||
"""
|
||||
# Guard against corrupted cache entries missing critical fields
|
||||
file_path = embedding_data.get("file_path")
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
logger.warning(
|
||||
"Skipping corrupted embedding entry (missing file_path): %s",
|
||||
embedding_data.get("file_name", "<unknown>"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Get sub_type from cache entry (new canonical field)
|
||||
sub_type = embedding_data.get("sub_type", "embedding")
|
||||
|
||||
|
||||
file_name = embedding_data.get("file_name") or ""
|
||||
model_name = embedding_data.get("model_name") or file_name
|
||||
folder = embedding_data.get("folder") or ""
|
||||
|
||||
return {
|
||||
"model_name": embedding_data["model_name"],
|
||||
"file_name": embedding_data["file_name"],
|
||||
"model_name": model_name,
|
||||
"file_name": file_name,
|
||||
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")),
|
||||
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0),
|
||||
"base_model": embedding_data.get("base_model", ""),
|
||||
"folder": embedding_data["folder"],
|
||||
"folder": folder,
|
||||
"sha256": embedding_data.get("sha256", ""),
|
||||
"file_path": embedding_data["file_path"].replace(os.sep, "/"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": embedding_data.get("size", 0),
|
||||
"modified": embedding_data.get("modified", ""),
|
||||
"tags": embedding_data.get("tags", []),
|
||||
@@ -48,6 +65,8 @@ class EmbeddingService(BaseModelService):
|
||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||
"version_count": embedding_data.get("version_count"),
|
||||
"hf_url": embedding_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -25,3 +25,21 @@ class ResourceNotFoundError(RuntimeError):
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LLMNotConfiguredError(RuntimeError):
|
||||
"""Raised when an LLM-dependent operation is attempted but no provider is configured."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LLMRateLimitError(RateLimitError):
|
||||
"""Raised when the LLM provider rejects a request due to rate limiting."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LLMResponseError(RuntimeError):
|
||||
"""Raised when the LLM returns an unparseable or schema-invalid response."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
"""Centralized LLM API client with BYOK (bring-your-own-key) provider support.
|
||||
|
||||
Reads provider configuration from :class:`SettingsManager` and makes
|
||||
OpenAI-compatible ``/chat/completions`` calls. Supports any provider that
|
||||
implements the OpenAI Chat Completions API surface area (OpenAI, Ollama,
|
||||
vLLM, LM Studio, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .errors import LLMNotConfiguredError, LLMRateLimitError, LLMResponseError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model catalog sourced from opencode's maintained model registry.
|
||||
# maps provider_id -> list of model IDs.
|
||||
# ---------------------------------------------------------------------------
|
||||
_MODEL_CATALOG_URL = "https://models.dev/api.json"
|
||||
|
||||
# In-memory cache: maps provider slug -> list of model ID strings.
|
||||
_catalog_cache: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
# Per-model max output token limits parsed from the catalog.
|
||||
# ``{provider_id: {model_id: max_output_tokens}}``.
|
||||
_model_output_limits: Dict[str, Dict[str, int]] = {}
|
||||
|
||||
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||
|
||||
|
||||
async def _load_model_catalog() -> Dict[str, List[str]]:
|
||||
"""Fetch and parse the model catalog.
|
||||
|
||||
Returns ``{provider_id: [model_id, ...]}`` and also populates
|
||||
:data:`_model_output_limits` with per-model ``limit.output`` values
|
||||
for use by :func:`_get_model_max_output`.
|
||||
|
||||
The JSON at ``_MODEL_CATALOG_URL`` is a dict keyed by provider slug; each
|
||||
value has a ``models`` sub-dict keyed by model ID. The result is cached
|
||||
in memory after the first successful fetch.
|
||||
Subsequent calls return the cached data immediately.
|
||||
"""
|
||||
global _catalog_cache, _model_output_limits
|
||||
if _catalog_cache is not None:
|
||||
return _catalog_cache
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
||||
async with session.get(_MODEL_CATALOG_URL) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning("Model catalog returned HTTP %s", resp.status)
|
||||
return _catalog_cache or {}
|
||||
data = await resp.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to fetch model catalog: %s", exc)
|
||||
return _catalog_cache or {}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
||||
return _catalog_cache or {}
|
||||
|
||||
result: Dict[str, List[str]] = {}
|
||||
output_limits: Dict[str, Dict[str, int]] = {}
|
||||
for provider_id, provider_info in data.items():
|
||||
if not isinstance(provider_info, dict):
|
||||
continue
|
||||
models_dict = provider_info.get("models")
|
||||
if not isinstance(models_dict, dict):
|
||||
continue
|
||||
model_ids: List[str] = []
|
||||
provider_limits: Dict[str, int] = {}
|
||||
for mid, model_info in models_dict.items():
|
||||
if not isinstance(mid, str):
|
||||
continue
|
||||
model_ids.append(mid)
|
||||
if isinstance(model_info, dict):
|
||||
limit = model_info.get("limit")
|
||||
if isinstance(limit, dict):
|
||||
output = limit.get("output")
|
||||
if isinstance(output, (int, float)) and output > 0:
|
||||
provider_limits[mid] = int(output)
|
||||
if model_ids:
|
||||
result[provider_id] = model_ids
|
||||
if provider_limits:
|
||||
output_limits[provider_id] = provider_limits
|
||||
|
||||
_catalog_cache = result
|
||||
_model_output_limits = output_limits
|
||||
logger.debug(
|
||||
"Loaded model catalog: %d providers, %d total models "
|
||||
"(%d providers have output limits)",
|
||||
len(result),
|
||||
sum(len(m) for m in result.values()),
|
||||
len(output_limits),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _get_model_max_output(provider: str, model: str) -> Optional[int]:
|
||||
"""Return the model's max output token limit from the catalog, or ``None``.
|
||||
|
||||
Returns ``None`` when the provider or model is not found in the catalog
|
||||
(e.g. local Ollama models, custom models, or user-typed model names).
|
||||
Callers should fall back to a safe default.
|
||||
"""
|
||||
return _model_output_limits.get(provider, {}).get(model)
|
||||
|
||||
|
||||
# Short timeout for Ollama's local API
|
||||
_OLLAMA_API_TIMEOUT = aiohttp.ClientTimeout(total=8)
|
||||
|
||||
|
||||
async def fetch_ollama_models(api_base: str) -> List[str]:
|
||||
"""Fetch locally available models from a running Ollama instance.
|
||||
|
||||
Uses Ollama's OpenAI-compatible ``GET {api_base}/models`` endpoint.
|
||||
Returns an empty list if Ollama is not reachable (not running).
|
||||
"""
|
||||
url = f"{api_base.rstrip('/')}/models"
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
|
||||
return []
|
||||
data = await resp.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
|
||||
logger.debug("Ollama not reachable at %s: %s", api_base, exc)
|
||||
return []
|
||||
|
||||
raw = data.get("data") if isinstance(data, dict) else None
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
|
||||
return [
|
||||
str(entry["id"]) for entry in raw
|
||||
if isinstance(entry, dict) and isinstance(entry.get("id"), str)
|
||||
]
|
||||
|
||||
|
||||
async def get_provider_model_ids(provider_id: str) -> List[str]:
|
||||
"""Return the list of known model IDs for *provider_id* from the catalog.
|
||||
|
||||
The catalog is loaded on first call and cached thereafter. If the
|
||||
provider is not found an empty list is returned (never raises).
|
||||
"""
|
||||
catalog = await _load_model_catalog()
|
||||
return catalog.get(provider_id, [])
|
||||
|
||||
|
||||
async def get_all_provider_models(
|
||||
provider_ids: List[str],
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Return model lists for a subset of providers in one call.
|
||||
|
||||
Loads the catalog (cached) and returns only the requested providers.
|
||||
Handy for embedding lightweight data into the template context.
|
||||
"""
|
||||
catalog = await _load_model_catalog()
|
||||
return {
|
||||
pid: catalog.get(pid, [])
|
||||
for pid in provider_ids
|
||||
}
|
||||
|
||||
|
||||
# Provider preset definitions.
|
||||
# Each entry contains display metadata and defaults for the UI.
|
||||
# The key is the internal provider id stored in ``llm_provider``.
|
||||
# Models are NOT listed here — they come from the opencode model catalog at
|
||||
# runtime (see :func:`get_provider_model_ids`).
|
||||
PROVIDER_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"openai": {
|
||||
"name": "OpenAI",
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"requires_key": True,
|
||||
},
|
||||
"ollama": {
|
||||
"name": "Ollama (local)",
|
||||
"api_base": "http://localhost:11434/v1",
|
||||
"requires_key": False,
|
||||
},
|
||||
"deepseek": {
|
||||
"name": "DeepSeek",
|
||||
"api_base": "https://api.deepseek.com/v1",
|
||||
"requires_key": True,
|
||||
},
|
||||
"groq": {
|
||||
"name": "Groq",
|
||||
"api_base": "https://api.groq.com/openai/v1",
|
||||
"requires_key": True,
|
||||
},
|
||||
"openrouter": {
|
||||
"name": "OpenRouter",
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
"requires_key": True,
|
||||
},
|
||||
"opencode-go": {
|
||||
"name": "OpenCode Go",
|
||||
"api_base": "https://opencode.ai/zen/go/v1",
|
||||
"requires_key": True,
|
||||
},
|
||||
# "custom" is handled specially (no preset api_base, requires user input)
|
||||
}
|
||||
|
||||
# Legacy lookup derived from PROVIDER_PRESETS for backward compat.
|
||||
_PROVIDER_DEFAULTS: Dict[str, str] = {
|
||||
pid: info["api_base"]
|
||||
for pid, info in PROVIDER_PRESETS.items()
|
||||
if info.get("api_base")
|
||||
}
|
||||
|
||||
# Request timeout for LLM calls (seconds)
|
||||
_LLM_TIMEOUT = aiohttp.ClientTimeout(total=120)
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""Centralized LLM API client.
|
||||
|
||||
All LLM-based enrichment features call through this service so
|
||||
that BYOK config, retry logic, and error handling live in one place.
|
||||
"""
|
||||
|
||||
_instance: Optional["LLMService"] = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
def __init__(self, settings_service) -> None:
|
||||
self._settings = settings_service
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Singleton access
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "LLMService":
|
||||
"""Return the lazily-initialised global ``LLMService`` instance."""
|
||||
|
||||
if cls._instance is None:
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
cls._instance = cls(get_settings_manager())
|
||||
# Start preloading the model catalog in the background so
|
||||
# the settings UI never blocks on it. The catalog is
|
||||
# cached after the first fetch (see _load_model_catalog).
|
||||
asyncio.create_task(_load_model_catalog())
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls) -> None:
|
||||
"""Reset the cached singleton — primarily for tests."""
|
||||
|
||||
cls._instance = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Configuration helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_config(self) -> Dict[str, Any]:
|
||||
"""Read the current LLM configuration from settings."""
|
||||
|
||||
return {
|
||||
"provider": self._settings.get("llm_provider", "openai"),
|
||||
"api_key": self._settings.get("llm_api_key", ""),
|
||||
"api_base": self._settings.get("llm_api_base", ""),
|
||||
"model": self._settings.get("llm_model", ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _provider_requires_key(provider: str) -> bool:
|
||||
"""Return ``False`` when the given provider id does not need an API key."""
|
||||
preset = PROVIDER_PRESETS.get(provider, {})
|
||||
return bool(preset.get("requires_key", True))
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Return ``True`` when the LLM provider is minimally configured.
|
||||
|
||||
A provider is considered configured when ``llm_model`` is set,
|
||||
an API key is configured for providers that require one (e.g.
|
||||
Ollama does not), and an API base URL is set for providers that
|
||||
have no preset default (e.g. ``custom``).
|
||||
"""
|
||||
|
||||
cfg = self._get_config()
|
||||
has_model = bool(cfg["model"])
|
||||
has_key = bool(cfg["api_key"]) or not self._provider_requires_key(cfg["provider"])
|
||||
has_base = bool(cfg["api_base"]) or bool(_PROVIDER_DEFAULTS.get(cfg["provider"]))
|
||||
return has_model and has_key and has_base
|
||||
|
||||
def _resolve_api_base(self, provider: str, api_base: str) -> str:
|
||||
"""Resolve the API base URL for the given provider.
|
||||
|
||||
If ``api_base`` is explicitly set (non-empty), it takes priority.
|
||||
Otherwise the default from :data:`PROVIDER_PRESETS` is used.
|
||||
"""
|
||||
|
||||
if api_base:
|
||||
return api_base.rstrip("/")
|
||||
return _PROVIDER_DEFAULTS.get(provider, "").rstrip("/")
|
||||
|
||||
def _build_headers(self, api_key: str) -> Dict[str, str]:
|
||||
"""Build HTTP headers for the LLM API request."""
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
return headers
|
||||
|
||||
def _ensure_configured(self) -> Dict[str, Any]:
|
||||
"""Validate configuration and return it, or raise.
|
||||
|
||||
A provider is considered configured when ``llm_model`` is set,
|
||||
an API key is configured for providers that require one, and
|
||||
an API base URL is set for providers without a preset default.
|
||||
"""
|
||||
|
||||
cfg = self._get_config()
|
||||
has_model = bool(cfg["model"])
|
||||
needs_key = self._provider_requires_key(cfg["provider"])
|
||||
has_key = bool(cfg["api_key"]) or not needs_key
|
||||
has_base = bool(cfg["api_base"]) or bool(_PROVIDER_DEFAULTS.get(cfg["provider"]))
|
||||
if not (has_model and has_key and has_base):
|
||||
parts = []
|
||||
if not has_model:
|
||||
parts.append("No LLM model specified")
|
||||
if not has_key and needs_key:
|
||||
parts.append("No LLM API key configured")
|
||||
if not has_base:
|
||||
parts.append(
|
||||
f"No API base URL for provider '{cfg['provider']}'"
|
||||
)
|
||||
detail = "; ".join(parts) if parts else "LLM provider is not configured"
|
||||
raise LLMNotConfiguredError(
|
||||
f"{detail}. Configure it in Settings → AI Provider."
|
||||
)
|
||||
return cfg
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core API call
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def chat_completion(
|
||||
self,
|
||||
*,
|
||||
messages: List[Dict[str, str]],
|
||||
model: Optional[str] = None,
|
||||
temperature: float = 0.3,
|
||||
response_format: Optional[Dict[str, Any]] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
retry_on_rate_limit: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Call the configured LLM provider's ``/chat/completions`` endpoint.
|
||||
|
||||
Args:
|
||||
messages: OpenAI-format message list
|
||||
model: Override the configured model name
|
||||
temperature: Sampling temperature
|
||||
response_format: Optional ``{"type": "json_object"}`` for structured output
|
||||
max_tokens: Optional max output tokens
|
||||
retry_on_rate_limit: Retry once after a 429 with backoff
|
||||
|
||||
Returns:
|
||||
Dict with ``content`` (str), ``usage`` (dict), ``model`` (str)
|
||||
|
||||
Raises:
|
||||
LLMNotConfiguredError: Provider not enabled / missing config
|
||||
LLMRateLimitError: Rate limited and retry exhausted
|
||||
LLMResponseError: Non-200 response or parse failure
|
||||
"""
|
||||
|
||||
cfg = self._ensure_configured()
|
||||
api_base = self._resolve_api_base(cfg["provider"], cfg["api_base"])
|
||||
model_name = model or cfg["model"]
|
||||
|
||||
is_ollama = cfg["provider"] == "ollama"
|
||||
|
||||
if is_ollama:
|
||||
# Use Ollama's native /api/chat endpoint which does NOT expose
|
||||
# a separate reasoning/thinking field (the model's full output
|
||||
# lands directly in message.content). The OpenAI-compatible
|
||||
# endpoint splits thinking into the "reasoning" field, making
|
||||
# content empty when thinking consumes all available tokens.
|
||||
base = api_base.rstrip("/")
|
||||
if base.endswith("/v1"):
|
||||
base = base[:-3]
|
||||
url = f"{base}/api/chat"
|
||||
else:
|
||||
url = f"{api_base}/chat/completions"
|
||||
|
||||
payload: Dict[str, Any]
|
||||
if is_ollama:
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
# Suppress separate thinking trace — thinking still happens
|
||||
# internally (accuracy preserved) but output goes directly to
|
||||
# message.content instead of being split across content +
|
||||
# thinking. Without this the model can exhaust num_predict
|
||||
# on thinking alone and leave content empty.
|
||||
"think": False,
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
# 8K context is sufficient for metadata enrichment
|
||||
# (prompt ~2-5K, output ~0.2-1K tokens). The old 32K
|
||||
# value was excessive for this use case and increased
|
||||
# Ollama VRAM usage unnecessarily.
|
||||
"num_ctx": 8192,
|
||||
},
|
||||
}
|
||||
if response_format is not None:
|
||||
payload["format"] = "json"
|
||||
if max_tokens is not None:
|
||||
payload["options"]["num_predict"] = max_tokens
|
||||
else:
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if response_format is not None:
|
||||
payload["response_format"] = response_format
|
||||
if max_tokens is not None:
|
||||
payload["max_tokens"] = max_tokens
|
||||
|
||||
if is_ollama:
|
||||
logger.info(
|
||||
"Ollama request: model=%s num_ctx=%s num_predict=%s format=%s think=%s",
|
||||
payload.get("model"),
|
||||
payload.get("options", {}).get("num_ctx"),
|
||||
payload.get("options", {}).get("num_predict"),
|
||||
payload.get("format", "none"),
|
||||
payload.get("think"),
|
||||
)
|
||||
|
||||
headers = self._build_headers(cfg["api_key"])
|
||||
|
||||
attempt = 0
|
||||
max_attempts = 2 if retry_on_rate_limit else 1
|
||||
while attempt < max_attempts:
|
||||
attempt += 1
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_LLM_TIMEOUT) as session:
|
||||
async with session.post(
|
||||
url, json=payload, headers=headers
|
||||
) as resp:
|
||||
if resp.status == 429:
|
||||
if attempt < max_attempts:
|
||||
retry_after = float(
|
||||
resp.headers.get("Retry-After", "5")
|
||||
)
|
||||
logger.warning(
|
||||
"LLM rate limited, retrying after %.1fs",
|
||||
retry_after,
|
||||
)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
raise LLMRateLimitError(
|
||||
f"LLM provider rate limited (HTTP 429)",
|
||||
provider=cfg["provider"],
|
||||
)
|
||||
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
raise LLMResponseError(
|
||||
f"LLM API returned HTTP {resp.status}: "
|
||||
f"{body[:500]}"
|
||||
)
|
||||
|
||||
data = await resp.json()
|
||||
|
||||
except aiohttp.ClientError as exc:
|
||||
raise LLMResponseError(f"Network error calling LLM API: {exc}") from exc
|
||||
|
||||
# Parse response
|
||||
try:
|
||||
if is_ollama:
|
||||
content = (data.get("message") or {}).get("content") or ""
|
||||
usage = {"completion_tokens": data.get("eval_count", 0)}
|
||||
finish_reason = data.get("done_reason", "")
|
||||
if not content:
|
||||
logger.warning(
|
||||
"LLM returned empty content. Provider=ollama, "
|
||||
"done_reason=%s, eval_count=%s",
|
||||
finish_reason,
|
||||
data.get("eval_count", 0),
|
||||
)
|
||||
else:
|
||||
content = data["choices"][0]["message"].get("content") or ""
|
||||
usage = data.get("usage", {})
|
||||
if not content:
|
||||
logger.warning(
|
||||
"LLM returned empty content. Full response truncated: %s",
|
||||
json.dumps(data, ensure_ascii=False)[:1000],
|
||||
)
|
||||
return {
|
||||
"content": content,
|
||||
"usage": usage,
|
||||
"model": data.get("model", model_name),
|
||||
}
|
||||
except (KeyError, IndexError) as exc:
|
||||
raise LLMResponseError(
|
||||
f"Unexpected LLM response structure: {json.dumps(data)[:500]}"
|
||||
) from exc
|
||||
|
||||
# Should not reach here, but satisfy type checker
|
||||
raise LLMRateLimitError("Rate limit retry exhausted", provider=cfg["provider"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Structured output convenience
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def chat_completion_json(
|
||||
self,
|
||||
*,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: Optional[str] = None,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Call the LLM with ``response_format=json_object`` and return parsed JSON.
|
||||
|
||||
``max_tokens`` is resolved in this order:
|
||||
1. Explicit caller-supplied ``max_tokens``
|
||||
2. Per-model ``limit.output`` from the model catalog
|
||||
3. A safe default of 4096 (sufficient for metadata enrichment)
|
||||
|
||||
If the response content is empty or not valid JSON, attempts
|
||||
:func:`_try_salvage_json` before raising.
|
||||
|
||||
Args:
|
||||
system_prompt: System-level instructions
|
||||
user_prompt: User-level query
|
||||
model: Override the configured model name
|
||||
temperature: Sampling temperature
|
||||
max_tokens: Optional max output tokens
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict from the LLM response
|
||||
|
||||
Raises:
|
||||
LLMNotConfiguredError: Provider not configured
|
||||
LLMRateLimitError: Rate limited
|
||||
LLMResponseError: Empty response or JSON parse failure
|
||||
"""
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
# Resolve max_tokens: caller override → catalog lookup → safe default
|
||||
if max_tokens is None:
|
||||
cfg = self._get_config()
|
||||
effective_max = _get_model_max_output(cfg["provider"], cfg["model"])
|
||||
else:
|
||||
effective_max = max_tokens
|
||||
if effective_max is None:
|
||||
effective_max = 4096
|
||||
|
||||
result = await self.chat_completion(
|
||||
messages=messages,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
response_format={"type": "json_object"},
|
||||
max_tokens=effective_max,
|
||||
)
|
||||
|
||||
content = result.get("content", "") or ""
|
||||
if not content:
|
||||
raise LLMResponseError(
|
||||
"LLM returned empty content in json_object mode. "
|
||||
f"Raw response: {json.dumps(result)[:500]}"
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
logger.debug(
|
||||
"LLM raw content: %s",
|
||||
json.dumps(parsed, ensure_ascii=False)[:2000],
|
||||
)
|
||||
return parsed
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
logger.info(
|
||||
"LLM raw response (first 800 chars): %s",
|
||||
content[:800],
|
||||
)
|
||||
|
||||
# Last resort: attempt to salvage partial/truncated JSON
|
||||
salvaged = _try_salvage_json(content)
|
||||
if salvaged is not None:
|
||||
logger.warning(
|
||||
"LLM JSON salvaged from partial content (%d chars raw)",
|
||||
len(content),
|
||||
)
|
||||
return salvaged
|
||||
|
||||
raise LLMResponseError(
|
||||
f"LLM response could not be parsed as JSON: {content[:200]}"
|
||||
)
|
||||
|
||||
|
||||
def _try_salvage_json(raw: str) -> Dict[str, Any] | None:
|
||||
"""Attempt to repair and parse a truncated JSON string.
|
||||
|
||||
Handles common truncation patterns:
|
||||
|
||||
* Incomplete string value at the end (``"foo`` → ``"foo"``)
|
||||
* Missing closing ``}`` or ``]`` (respecting nesting order)
|
||||
* Trailing comma before closing bracket
|
||||
* Extra text after the JSON object (e.g. markdown fences)
|
||||
|
||||
Returns the parsed dict on success, ``None`` if repair is impossible.
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
text = raw.strip()
|
||||
|
||||
# Strip markdown fences if the LLM wrapped the JSON
|
||||
if text.startswith("```"):
|
||||
end = text.find("\n")
|
||||
text = text[end + 1:] if end != -1 else text[3:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3].rstrip()
|
||||
|
||||
# Find the first '{' and strip everything before it
|
||||
start = text.find("{")
|
||||
if start == -1:
|
||||
return None
|
||||
text = text[start:]
|
||||
|
||||
# Try to close an incomplete string at the end (e.g. ``"https://huggingf``)
|
||||
# Pattern: ends mid-string (last quote is open)
|
||||
if text.count('"') % 2 == 1:
|
||||
text += '"'
|
||||
|
||||
# Ensure trailing commas before closing braces work
|
||||
text = _strip_trailing_commas(text)
|
||||
|
||||
# Walk through the text character by character to find unclosed
|
||||
# brackets and close them in the correct (LIFO) order.
|
||||
# We ignore brackets inside quoted strings.
|
||||
stack: list[str] = []
|
||||
in_string = False
|
||||
escape = False
|
||||
for ch in text:
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch in ("{", "["):
|
||||
stack.append(ch)
|
||||
elif ch == "}":
|
||||
if stack and stack[-1] == "{":
|
||||
stack.pop()
|
||||
else:
|
||||
return None # Unmatched closer — unrecoverable
|
||||
elif ch == "]":
|
||||
if stack and stack[-1] == "[":
|
||||
stack.pop()
|
||||
else:
|
||||
return None
|
||||
|
||||
# Close remaining open brackets in reverse order
|
||||
for opener in reversed(stack):
|
||||
text += "}" if opener == "{" else "]"
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _strip_trailing_commas(text: str) -> str:
|
||||
"""Remove commas that appear before a closing brace/bracket."""
|
||||
import re as _re
|
||||
text = _re.sub(r",\s*}", "}", text)
|
||||
text = _re.sub(r",\s*]", "]", text)
|
||||
return text
|
||||
@@ -24,23 +24,41 @@ class LoraService(BaseModelService):
|
||||
"""
|
||||
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, lora_data: Dict) -> Dict:
|
||||
"""Format LoRA data for API response"""
|
||||
async def format_response(self, lora_data: Dict) -> Optional[Dict]:
|
||||
"""Format LoRA data for API response.
|
||||
|
||||
Returns None when the entry is missing critical fields (corrupted cache
|
||||
row), so the handler layer can filter it out instead of crashing the
|
||||
whole listing request. See issue #730.
|
||||
"""
|
||||
# Guard against corrupted cache entries missing critical fields
|
||||
file_path = lora_data.get("file_path")
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
logger.warning(
|
||||
"Skipping corrupted LoRA entry (missing file_path): %s",
|
||||
lora_data.get("file_name", "<unknown>"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
||||
# Normalize to lowercase for consistent API responses
|
||||
sub_type = resolve_sub_type(lora_data).lower()
|
||||
|
||||
file_name = lora_data.get("file_name") or ""
|
||||
model_name = lora_data.get("model_name") or file_name
|
||||
folder = lora_data.get("folder") or ""
|
||||
|
||||
return {
|
||||
"model_name": lora_data["model_name"],
|
||||
"file_name": lora_data["file_name"],
|
||||
"model_name": model_name,
|
||||
"file_name": file_name,
|
||||
"preview_url": config.get_preview_static_url(
|
||||
lora_data.get("preview_url", "")
|
||||
),
|
||||
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
|
||||
"base_model": lora_data.get("base_model", ""),
|
||||
"folder": lora_data["folder"],
|
||||
"folder": folder,
|
||||
"sha256": lora_data.get("sha256", ""),
|
||||
"file_path": lora_data["file_path"].replace(os.sep, "/"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": lora_data.get("size", 0),
|
||||
"modified": lora_data.get("modified", ""),
|
||||
"tags": lora_data.get("tags", []),
|
||||
@@ -59,6 +77,8 @@ class LoraService(BaseModelService):
|
||||
lora_data.get("civitai", {}), minimal=True
|
||||
),
|
||||
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||
"version_count": lora_data.get("version_count"),
|
||||
"hf_url": lora_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||
|
||||
@@ -209,20 +209,40 @@ class MetadataSyncService:
|
||||
error_msg = "CivitAI model is deleted and no archive provider is available"
|
||||
return False, error_msg
|
||||
else:
|
||||
provider_attempts.append((None, await self._get_default_provider()))
|
||||
is_hf_source = bool(model_data.get("hf_url"))
|
||||
if is_hf_source:
|
||||
# HF-sourced model: only check CivitAI API directly.
|
||||
# CivArchive is almost guaranteed to have no record, and
|
||||
# hitting it wastes rate-limit budget.
|
||||
# Use a distinct provider name ("civitai_api" not None) so
|
||||
# downstream code does NOT interpret a "Model not found"
|
||||
# response as civitai_api_not_found — which would mark the
|
||||
# model civitai_deleted=True when it was never on CivitAI.
|
||||
try:
|
||||
provider_attempts.append(("civitai_api", await self._get_provider("civitai_api")))
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug("Unable to resolve civitai_api provider: %s", exc)
|
||||
if not provider_attempts:
|
||||
provider_attempts.append((None, await self._get_default_provider()))
|
||||
|
||||
civitai_metadata: Optional[Dict[str, Any]] = None
|
||||
metadata_provider: Optional[MetadataProviderProtocol] = None
|
||||
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 +278,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 +294,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 +305,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 +441,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
|
||||
|
||||
@@ -18,6 +18,8 @@ SUPPORTED_SORT_MODES = [
|
||||
('size', 'desc'),
|
||||
('usage', 'asc'),
|
||||
('usage', 'desc'),
|
||||
('versions_count', 'asc'),
|
||||
('versions_count', 'desc'),
|
||||
]
|
||||
# Is this in use?
|
||||
|
||||
@@ -263,6 +265,17 @@ class ModelCache:
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
elif sort_key == 'versions_count':
|
||||
# Pre-dedup sort: fall back to name sort.
|
||||
# Actual re-sort by version_count happens in get_paginated_data after dedup.
|
||||
result = natsorted(
|
||||
data,
|
||||
key=lambda x: (
|
||||
self._get_display_name(x).lower(),
|
||||
x.get('file_path', '').lower()
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
else:
|
||||
# Fallback: no sort
|
||||
result = list(data)
|
||||
@@ -324,4 +337,25 @@ class ModelCache:
|
||||
else:
|
||||
return False # Model not found
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
async def clear_preview_by_path(self, preview_file_path: str) -> int:
|
||||
"""Clear ``preview_url`` for every cached entry referencing a file path.
|
||||
|
||||
When a preview file has been deleted from disk, this removes its
|
||||
reference from all matching cache entries so the next list-API
|
||||
response returns an empty ``preview_url`` instead of a stale URL
|
||||
that produces 404s.
|
||||
|
||||
Returns the number of entries that were updated.
|
||||
"""
|
||||
normalized = preview_file_path.replace("\\", "/")
|
||||
cleared = 0
|
||||
async with self._lock:
|
||||
for item in self.raw_data:
|
||||
cached_url = item.get("preview_url", "")
|
||||
if cached_url.replace("\\", "/") == normalized:
|
||||
item["preview_url"] = ""
|
||||
item["preview_nsfw_level"] = 0
|
||||
cleared += 1
|
||||
return cleared
|
||||
@@ -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",
|
||||
label,
|
||||
model_id,
|
||||
)
|
||||
return None
|
||||
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 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"))
|
||||
|
||||
@@ -248,6 +248,7 @@ class ModelScanner:
|
||||
'civitai': civitai_slim,
|
||||
'civitai_deleted': bool(get_value('civitai_deleted', False)),
|
||||
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
|
||||
'hf_url': get_value('hf_url', '') or '',
|
||||
}
|
||||
|
||||
license_source: Dict[str, Any] = {}
|
||||
@@ -476,11 +477,20 @@ class ModelScanner:
|
||||
for tag in adjusted_item.get('tags') or []:
|
||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||
|
||||
# Validate cache entries and check health
|
||||
# Validate cache entries and check health.
|
||||
# Always use the validated/repaired entries — even when there are no
|
||||
# invalid entries, auto_repair may have filled in missing optional
|
||||
# fields (model_name, file_name, folder) with safe defaults on a copied
|
||||
# working_entry. Without this unconditional replacement the repaired
|
||||
# copies are discarded and None values propagate to format_response.
|
||||
# See issue #730.
|
||||
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
|
||||
adjusted_raw_data, auto_repair=True
|
||||
)
|
||||
|
||||
# Always use the validated entries (repaired copies)
|
||||
adjusted_raw_data = valid_entries
|
||||
|
||||
if invalid_entries:
|
||||
monitor = CacheHealthMonitor()
|
||||
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
|
||||
@@ -532,6 +542,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,14 +722,20 @@ class ModelScanner:
|
||||
# Determine the page type based on model type
|
||||
# Scan for new data
|
||||
scan_result = await self._gather_model_data()
|
||||
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')
|
||||
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')
|
||||
|
||||
logger.info(
|
||||
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
||||
f"found {len(scan_result.raw_data)} models"
|
||||
)
|
||||
logger.info(
|
||||
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 +1090,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 +1119,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)
|
||||
@@ -1761,6 +1794,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
|
||||
|
||||
@@ -724,6 +724,16 @@ class ModelUpdateService:
|
||||
"Refreshing update metadata for %d %s models", total_models, model_type
|
||||
)
|
||||
|
||||
# When filtering by folder, also collect the cross-folder version set
|
||||
# so that versions already present in other folders are not reported
|
||||
# as available updates. See issue #997.
|
||||
all_local_versions: Optional[Dict[int, List[int]]] = None
|
||||
if folder_path is not None:
|
||||
all_local_versions = await self._collect_local_versions(
|
||||
scanner,
|
||||
target_model_ids=target_filter,
|
||||
)
|
||||
|
||||
results: Dict[int, ModelUpdateRecord] = {}
|
||||
prefetched: Dict[int, Mapping] = {}
|
||||
|
||||
@@ -762,6 +772,12 @@ class ModelUpdateService:
|
||||
for index, (model_id, version_ids) in enumerate(
|
||||
local_versions.items(), start=1
|
||||
):
|
||||
# Use cross-folder version IDs for is_in_library if available
|
||||
all_vids: Sequence[int] = (
|
||||
all_local_versions.get(model_id, [])
|
||||
if all_local_versions is not None
|
||||
else version_ids
|
||||
)
|
||||
record = await self._refresh_single_model(
|
||||
model_type,
|
||||
model_id,
|
||||
@@ -769,6 +785,7 @@ class ModelUpdateService:
|
||||
metadata_provider,
|
||||
force_refresh=force_refresh,
|
||||
prefetched_response=prefetched.get(model_id),
|
||||
all_local_version_ids=all_vids,
|
||||
)
|
||||
if scanner.is_cancelled():
|
||||
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
||||
@@ -964,8 +981,16 @@ class ModelUpdateService:
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
prefetched_response: Optional[Mapping] = None,
|
||||
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||
) -> Optional[ModelUpdateRecord]:
|
||||
normalized_local = self._normalize_sequence(local_versions)
|
||||
# When folder-filtering, this carries the cross-folder version set
|
||||
# for is_in_library; otherwise it falls back to normalized_local.
|
||||
normalized_all = (
|
||||
self._normalize_sequence(all_local_version_ids)
|
||||
if all_local_version_ids is not None
|
||||
else normalized_local
|
||||
)
|
||||
now = time.time()
|
||||
async with self._lock:
|
||||
existing = self._get_record(model_type, model_id)
|
||||
@@ -973,6 +998,7 @@ class ModelUpdateService:
|
||||
record = self._merge_with_local_versions(
|
||||
existing,
|
||||
normalized_local,
|
||||
all_local_version_ids=normalized_all,
|
||||
)
|
||||
self._upsert_record(record)
|
||||
return record
|
||||
@@ -1048,6 +1074,7 @@ class ModelUpdateService:
|
||||
record = self._merge_with_local_versions(
|
||||
existing,
|
||||
normalized_local,
|
||||
all_local_version_ids=normalized_all,
|
||||
)
|
||||
self._upsert_record(record)
|
||||
return record
|
||||
@@ -1059,6 +1086,7 @@ class ModelUpdateService:
|
||||
model_type=model_type,
|
||||
model_id=model_id,
|
||||
last_checked_at=now,
|
||||
all_local_version_ids=normalized_all,
|
||||
)
|
||||
record = replace(record, should_ignore_model=True)
|
||||
self._upsert_record(record)
|
||||
@@ -1077,6 +1105,7 @@ class ModelUpdateService:
|
||||
fetched_versions,
|
||||
existing,
|
||||
now,
|
||||
all_local_version_ids=normalized_all,
|
||||
)
|
||||
else:
|
||||
record = self._merge_with_local_versions(
|
||||
@@ -1085,6 +1114,7 @@ class ModelUpdateService:
|
||||
model_type=model_type,
|
||||
model_id=model_id,
|
||||
last_checked_at=existing.last_checked_at if existing else None,
|
||||
all_local_version_ids=normalized_all,
|
||||
)
|
||||
self._upsert_record(record)
|
||||
return record
|
||||
@@ -1322,12 +1352,20 @@ class ModelUpdateService:
|
||||
existing: Optional[ModelUpdateRecord],
|
||||
normalized_local: Sequence[int],
|
||||
*,
|
||||
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||
model_type: Optional[str] = None,
|
||||
model_id: Optional[int] = None,
|
||||
last_checked_at: Optional[float] = None,
|
||||
version_info: Optional[Mapping] = None,
|
||||
) -> ModelUpdateRecord:
|
||||
local_set = set(normalized_local)
|
||||
# When folder-filtering, also consider versions in other folders
|
||||
# as in-library so they are not reported as available updates.
|
||||
effective_local_set: set[int] = (
|
||||
local_set | set(all_local_version_ids)
|
||||
if all_local_version_ids is not None
|
||||
else local_set
|
||||
)
|
||||
versions: List[ModelVersionRecord] = []
|
||||
ignore_map: Dict[int, bool] = {}
|
||||
if existing:
|
||||
@@ -1339,7 +1377,7 @@ class ModelUpdateService:
|
||||
versions.append(
|
||||
replace(
|
||||
version,
|
||||
is_in_library=version.version_id in local_set,
|
||||
is_in_library=version.version_id in effective_local_set,
|
||||
)
|
||||
)
|
||||
elif model_type is None or model_id is None:
|
||||
@@ -1386,8 +1424,17 @@ class ModelUpdateService:
|
||||
remote_versions: Sequence[ModelVersionRecord],
|
||||
existing: Optional[ModelUpdateRecord],
|
||||
timestamp: float,
|
||||
*,
|
||||
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||
) -> ModelUpdateRecord:
|
||||
local_set = set(local_versions)
|
||||
# When folder-filtering, also consider versions in other folders
|
||||
# as in-library so they are not reported as available updates.
|
||||
effective_local_set: set[int] = (
|
||||
local_set | set(all_local_version_ids)
|
||||
if all_local_version_ids is not None
|
||||
else local_set
|
||||
)
|
||||
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
|
||||
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
|
||||
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
|
||||
@@ -1406,7 +1453,7 @@ class ModelUpdateService:
|
||||
released_at=remote_version.released_at,
|
||||
size_bytes=remote_version.size_bytes,
|
||||
preview_url=remote_version.preview_url or preview_map.get(version_id),
|
||||
is_in_library=version_id in local_set,
|
||||
is_in_library=version_id in effective_local_set,
|
||||
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
|
||||
sort_index=sort_map.get(version_id, index),
|
||||
early_access_ends_at=remote_version.early_access_ends_at,
|
||||
|
||||
@@ -57,6 +57,7 @@ class PersistentModelCache:
|
||||
"db_checked",
|
||||
"last_checked_at",
|
||||
"hash_status",
|
||||
"hf_url",
|
||||
)
|
||||
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
||||
_instances: Dict[str, "PersistentModelCache"] = {}
|
||||
@@ -165,8 +166,8 @@ class PersistentModelCache:
|
||||
|
||||
item = {
|
||||
"file_path": file_path,
|
||||
"file_name": row["file_name"],
|
||||
"model_name": row["model_name"],
|
||||
"file_name": row["file_name"] or "",
|
||||
"model_name": row["model_name"] or "",
|
||||
"folder": row["folder"] or "",
|
||||
"size": row["size"] or 0,
|
||||
"modified": row["modified"] or 0.0,
|
||||
@@ -188,6 +189,7 @@ class PersistentModelCache:
|
||||
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
||||
"license_flags": int(license_value),
|
||||
"hash_status": row["hash_status"] or "completed",
|
||||
"hf_url": row["hf_url"] or "",
|
||||
}
|
||||
raw_data.append(item)
|
||||
|
||||
@@ -452,6 +454,7 @@ class PersistentModelCache:
|
||||
db_checked INTEGER,
|
||||
last_checked_at REAL,
|
||||
hash_status TEXT,
|
||||
hf_url TEXT DEFAULT '',
|
||||
PRIMARY KEY (model_type, file_path)
|
||||
);
|
||||
|
||||
@@ -500,6 +503,7 @@ class PersistentModelCache:
|
||||
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
||||
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
||||
"hash_status": "TEXT DEFAULT 'completed'",
|
||||
"hf_url": "TEXT DEFAULT ''",
|
||||
}
|
||||
|
||||
for column, definition in required_columns.items():
|
||||
@@ -548,19 +552,19 @@ class PersistentModelCache:
|
||||
return (
|
||||
model_type,
|
||||
item.get("file_path"),
|
||||
item.get("file_name"),
|
||||
item.get("model_name"),
|
||||
item.get("folder"),
|
||||
item.get("file_name") or "",
|
||||
item.get("model_name") or "",
|
||||
item.get("folder") or "",
|
||||
int(item.get("size") or 0),
|
||||
float(item.get("modified") or 0.0),
|
||||
(item.get("sha256") or "").lower() or None,
|
||||
item.get("base_model"),
|
||||
item.get("preview_url"),
|
||||
item.get("base_model") or "",
|
||||
item.get("preview_url") or "",
|
||||
int(item.get("preview_nsfw_level") or 0),
|
||||
1 if item.get("from_civitai", True) else 0,
|
||||
1 if item.get("favorite") else 0,
|
||||
item.get("notes"),
|
||||
item.get("usage_tips"),
|
||||
item.get("notes") or "",
|
||||
item.get("usage_tips") or "",
|
||||
metadata_source,
|
||||
civitai.get("id"),
|
||||
civitai.get("modelId"),
|
||||
@@ -575,6 +579,7 @@ class PersistentModelCache:
|
||||
1 if item.get("db_checked") else 0,
|
||||
float(item.get("last_checked_at") or 0.0),
|
||||
item.get("hash_status", "completed"),
|
||||
item.get("hf_url") or "",
|
||||
)
|
||||
|
||||
def _insert_model_sql(self) -> str:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -146,11 +146,38 @@ class RecipeAnalysisService:
|
||||
):
|
||||
metadata = metadata["meta"]
|
||||
|
||||
# Include modelVersionIds from root level if available
|
||||
# Civitai API returns modelVersionIds at root level, not in meta
|
||||
# Include modelVersionIds from root level if available.
|
||||
# CivitAI API returns modelVersionIds at root level, not in meta.
|
||||
# When meta is null (None), create a minimal dict so downstream
|
||||
# parsers can still discover LoRAs and checkpoints.
|
||||
model_version_ids = image_info.get("modelVersionIds")
|
||||
if model_version_ids and isinstance(metadata, dict):
|
||||
metadata["modelVersionIds"] = model_version_ids
|
||||
if model_version_ids:
|
||||
if isinstance(metadata, dict):
|
||||
metadata["modelVersionIds"] = model_version_ids
|
||||
else:
|
||||
metadata = {"modelVersionIds": model_version_ids}
|
||||
|
||||
# Inject browsingLevel (canonical integer) so the recipe's
|
||||
# preview_nsfw_level can be set, enabling proper NSFW blur
|
||||
# of the preview image. Fall back to nsfwLevel (string)
|
||||
# when browsingLevel is absent.
|
||||
if isinstance(metadata, dict):
|
||||
browsing_level = image_info.get("browsingLevel")
|
||||
nsfw_level_str = image_info.get("nsfwLevel")
|
||||
if isinstance(browsing_level, int) and browsing_level > 0:
|
||||
metadata["browsingLevel"] = browsing_level
|
||||
elif (
|
||||
isinstance(nsfw_level_str, str)
|
||||
and nsfw_level_str
|
||||
in (
|
||||
"PG", "PG13", "R", "X", "XXX", "Blocked",
|
||||
)
|
||||
):
|
||||
from ...utils.constants import NSFW_LEVELS
|
||||
|
||||
metadata["browsingLevel"] = NSFW_LEVELS.get(
|
||||
nsfw_level_str, 0
|
||||
)
|
||||
|
||||
# Validate that metadata contains meaningful recipe fields
|
||||
# If not, treat as None to trigger EXIF extraction from downloaded image
|
||||
@@ -171,12 +198,19 @@ class RecipeAnalysisService:
|
||||
temp_path = self._create_temp_path(suffix=extension)
|
||||
await self._download_image(url, temp_path)
|
||||
|
||||
if metadata is None and not is_video:
|
||||
metadata = await asyncio.to_thread(
|
||||
# Always extract EXIF from the downloaded image for generation
|
||||
# params (prompt, negative prompt, sampler, steps, etc.).
|
||||
# Previously this was gated on ``metadata is None``, but that
|
||||
# skipped EXIF entirely when API metadata (modelVersionIds,
|
||||
# browsingLevel) is present, losing all generation parameters.
|
||||
exif_metadata = None
|
||||
if not is_video:
|
||||
exif_metadata = await asyncio.to_thread(
|
||||
self._exif_utils.extract_image_metadata, temp_path
|
||||
)
|
||||
|
||||
if not metadata and civitai_image_id and image_info:
|
||||
# Fallback: try the original (non-optimized) image for EXIF data
|
||||
if not exif_metadata and civitai_image_id and image_info:
|
||||
original_url = image_info.get("url")
|
||||
if original_url:
|
||||
self._logger.debug(
|
||||
@@ -187,15 +221,38 @@ class RecipeAnalysisService:
|
||||
orig_temp_path = self._create_temp_path(suffix=".png")
|
||||
try:
|
||||
await self._download_image(original_url, orig_temp_path)
|
||||
metadata = await asyncio.to_thread(
|
||||
exif_metadata = await asyncio.to_thread(
|
||||
self._exif_utils.extract_image_metadata,
|
||||
orig_temp_path,
|
||||
)
|
||||
finally:
|
||||
self._safe_cleanup(orig_temp_path)
|
||||
|
||||
# Parse EXIF data (typically a string like parameters/prompt/workflow)
|
||||
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
|
||||
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
|
||||
# This mirrors the two-pass approach in _do_import_from_url.
|
||||
exif_parsed_result = None
|
||||
if isinstance(exif_metadata, str):
|
||||
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
|
||||
if exif_parser:
|
||||
exif_data = await exif_parser.parse_metadata(
|
||||
exif_metadata, recipe_scanner=recipe_scanner,
|
||||
)
|
||||
if exif_data and not exif_data.get("error"):
|
||||
exif_parsed_result = exif_data
|
||||
|
||||
# Merge API metadata (dict) with EXIF data (if dict) for the
|
||||
# CivitaiApiMetadataParser. If EXIF data is a string it was
|
||||
# parsed above — don't try to merge a string into a dict.
|
||||
merged = {}
|
||||
if isinstance(exif_metadata, dict):
|
||||
merged.update(exif_metadata)
|
||||
if isinstance(metadata, dict):
|
||||
merged.update(metadata)
|
||||
|
||||
result = await self._parse_metadata(
|
||||
metadata or {},
|
||||
merged,
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_path=temp_path,
|
||||
include_image_base64=True,
|
||||
@@ -203,13 +260,23 @@ class RecipeAnalysisService:
|
||||
extension=extension,
|
||||
)
|
||||
|
||||
if civitai_image_id and image_info and not result.payload.get("error"):
|
||||
mvid = image_info.get("modelVersionId")
|
||||
if not mvid:
|
||||
mvids = image_info.get("modelVersionIds")
|
||||
if isinstance(mvids, list) and mvids:
|
||||
mvid = mvids[0]
|
||||
# Merge EXIF string-parsed gen_params into the API result.
|
||||
# API gen_params take priority (they come later via update).
|
||||
if exif_parsed_result and not result.payload.get("error"):
|
||||
exif_gp = exif_parsed_result.get("gen_params") or {}
|
||||
result_gp = result.payload.get("gen_params") or {}
|
||||
merged_gp = {**exif_gp, **result_gp}
|
||||
if merged_gp:
|
||||
result.payload["gen_params"] = merged_gp
|
||||
|
||||
if civitai_image_id and image_info and not result.payload.get("error"):
|
||||
# Use the metadata dict we built (may contain modelVersionIds
|
||||
# and browsingLevel from the API root level). Do NOT pass
|
||||
# image_info.get("meta") — it is null for images whose meta
|
||||
# lives at the root level only. Also do NOT derive
|
||||
# model_version_id from modelVersionIds[0] — that array mixes
|
||||
# checkpoints, LoRAs, and other types without ordering
|
||||
# guarantees; the parser already resolved them correctly.
|
||||
recipe_for_enrich = {
|
||||
"gen_params": result.payload.get("gen_params", {}),
|
||||
"loras": result.payload.get("loras", []),
|
||||
@@ -222,8 +289,10 @@ class RecipeAnalysisService:
|
||||
recipe=recipe_for_enrich,
|
||||
civitai_client=civitai_client,
|
||||
request_params=None,
|
||||
prefetched_civitai_meta_raw=image_info.get("meta"),
|
||||
prefetched_model_version_id=mvid,
|
||||
prefetched_civitai_meta_raw=(
|
||||
metadata if isinstance(metadata, dict) else None
|
||||
),
|
||||
prefetched_model_version_id=None,
|
||||
)
|
||||
|
||||
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
|
||||
@@ -232,6 +301,12 @@ class RecipeAnalysisService:
|
||||
if recipe_for_enrich.get("base_model"):
|
||||
result.payload["base_model"] = recipe_for_enrich["base_model"]
|
||||
|
||||
# Extract browsingLevel from our constructed metadata for NSFW blur
|
||||
if isinstance(metadata, dict):
|
||||
bl = metadata.get("browsingLevel")
|
||||
if isinstance(bl, int) and bl > 0:
|
||||
result.payload["preview_nsfw_level"] = bl
|
||||
|
||||
return result
|
||||
finally:
|
||||
if temp_path:
|
||||
@@ -314,6 +389,10 @@ class RecipeAnalysisService:
|
||||
"prompt_type",
|
||||
"positive",
|
||||
"negative",
|
||||
# modelVersionIds is injected at the root level by CivitAI's image
|
||||
# API when meta is null. It carries the version IDs of ALL models
|
||||
# (checkpoint + LoRAs) used to generate the image.
|
||||
"modelVersionIds",
|
||||
}
|
||||
return any(field in metadata for field in recipe_fields)
|
||||
|
||||
|
||||
+131
-39
@@ -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(),
|
||||
@@ -99,13 +98,20 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"lora_syntax_format": "legacy",
|
||||
"model_card_footer_action": "replace_preview",
|
||||
"show_version_on_card": True,
|
||||
"update_flag_strategy": "same_base",
|
||||
"version_grouping": "same_base",
|
||||
"auto_organize_exclusions": [],
|
||||
"metadata_refresh_skip_paths": [],
|
||||
"skip_previously_downloaded_model_versions": False,
|
||||
"download_skip_base_models": [],
|
||||
"backup_auto_enabled": True,
|
||||
"backup_retention_count": 5,
|
||||
"use_new_license_icons": True,
|
||||
"group_by_model": False,
|
||||
# AI / LLM provider configuration (BYOK)
|
||||
"llm_provider": "openai", # "openai" | "ollama" | "custom"
|
||||
"llm_api_key": "",
|
||||
"llm_api_base": "", # empty = provider default
|
||||
"llm_model": "", # e.g. "gpt-4o-mini"
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +140,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()
|
||||
@@ -143,6 +152,11 @@ class SettingsManager:
|
||||
self._check_environment_variables()
|
||||
self._collect_configuration_warnings()
|
||||
|
||||
if os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1":
|
||||
if not self.settings.get("use_portable_settings"):
|
||||
self.settings["use_portable_settings"] = True
|
||||
self._save_settings()
|
||||
|
||||
if self._needs_initial_save:
|
||||
self._save_settings()
|
||||
self._needs_initial_save = False
|
||||
@@ -165,6 +179,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)
|
||||
@@ -610,12 +630,37 @@ class SettingsManager:
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path_set(paths: Iterable[str]) -> set[str]:
|
||||
"""Normalize an iterable of paths for set-based overlap comparison.
|
||||
|
||||
Resolves symlinks via ``os.path.realpath`` when the path exists on disk,
|
||||
then applies ``os.path.normcase`` + ``os.path.normpath`` for consistent
|
||||
cross-platform comparison. Non-string / empty entries are skipped.
|
||||
"""
|
||||
result: set[str] = set()
|
||||
for p in paths:
|
||||
if not isinstance(p, str):
|
||||
continue
|
||||
stripped = p.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if os.path.exists(stripped):
|
||||
stripped = os.path.normpath(os.path.realpath(stripped))
|
||||
result.add(os.path.normcase(stripped))
|
||||
return result
|
||||
|
||||
def _validate_folder_paths(
|
||||
self,
|
||||
library_name: str,
|
||||
folder_paths: Mapping[str, Iterable[str]],
|
||||
) -> None:
|
||||
"""Ensure folder paths do not overlap with other libraries."""
|
||||
"""Ensure folder paths do not overlap with other libraries.
|
||||
|
||||
Also detects checkpoints ↔ unet path overlap within the same library
|
||||
(including via symlink resolution), which is a configuration error since
|
||||
these model types must use separate physical folders.
|
||||
"""
|
||||
libraries = self.settings.get("libraries", {})
|
||||
normalized_new: Dict[str, Dict[str, str]] = {}
|
||||
for key, values in folder_paths.items():
|
||||
@@ -653,6 +698,22 @@ class SettingsManager:
|
||||
f"Folder path(s) {collisions} already assigned to library '{other_name}'"
|
||||
)
|
||||
|
||||
# Checkpoints ↔ unet overlap within the same library
|
||||
ckpt_paths = folder_paths.get("checkpoints", []) or []
|
||||
unet_paths = folder_paths.get("unet", []) or []
|
||||
if ckpt_paths and unet_paths:
|
||||
ckpt_real = self._normalize_path_set(ckpt_paths)
|
||||
unet_real = self._normalize_path_set(unet_paths)
|
||||
overlap = ckpt_real & unet_real
|
||||
if overlap:
|
||||
collisions = ", ".join(sorted(overlap))
|
||||
raise ValueError(
|
||||
f"Path(s) {collisions} are configured for both "
|
||||
f"'checkpoints' and 'unet' (diffusion models). "
|
||||
f"These model types must use separate physical folders. "
|
||||
f"Please remove one of the conflicting entries."
|
||||
)
|
||||
|
||||
def _update_active_library_entry(
|
||||
self,
|
||||
*,
|
||||
@@ -735,6 +796,7 @@ class SettingsManager:
|
||||
"includeTriggerWords": "include_trigger_words",
|
||||
"compactMode": "compact_mode",
|
||||
"modelCardFooterAction": "model_card_footer_action",
|
||||
"update_flag_strategy": "version_grouping",
|
||||
}
|
||||
|
||||
updated = False
|
||||
@@ -862,6 +924,23 @@ class SettingsManager:
|
||||
self.settings["civitai_api_key"] = env_api_key
|
||||
self._save_settings()
|
||||
|
||||
# LLM provider overrides
|
||||
llm_env_map = {
|
||||
"LLM_API_KEY": "llm_api_key",
|
||||
"LLM_MODEL": "llm_model",
|
||||
"LLM_API_BASE": "llm_api_base",
|
||||
"LLM_PROVIDER": "llm_provider",
|
||||
}
|
||||
llm_changed = False
|
||||
for env_var, settings_key in llm_env_map.items():
|
||||
env_val = os.environ.get(env_var)
|
||||
if env_val:
|
||||
logger.info("Found %s environment variable", env_var)
|
||||
self.settings[settings_key] = env_val
|
||||
llm_changed = True
|
||||
if llm_changed:
|
||||
self._save_settings()
|
||||
|
||||
def _default_settings_actions(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
@@ -1509,8 +1588,12 @@ class SettingsManager:
|
||||
portable_switch_pending = True
|
||||
self._prepare_portable_switch(value)
|
||||
if key == "folder_paths" and isinstance(value, Mapping):
|
||||
active_name = self.get_active_library_name()
|
||||
self._validate_folder_paths(active_name, value)
|
||||
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
|
||||
elif key == "extra_folder_paths" and isinstance(value, Mapping):
|
||||
active_name = self.get_active_library_name()
|
||||
self._validate_folder_paths(active_name, value)
|
||||
self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type]
|
||||
elif key == "default_lora_root":
|
||||
self._update_active_library_entry(default_lora_root=str(value))
|
||||
@@ -1557,7 +1640,7 @@ class SettingsManager:
|
||||
previous_dir = os.path.dirname(previous_path) or target_dir
|
||||
|
||||
if os.path.abspath(previous_path) != os.path.abspath(target_path):
|
||||
self._copy_model_cache_directory(previous_dir, target_dir)
|
||||
self._migrate_settings_directory_content(previous_dir, target_dir)
|
||||
logger.info("Switching settings file to: %s", target_path)
|
||||
|
||||
self._pending_portable_switch = {"other_path": other_path}
|
||||
@@ -1592,46 +1675,52 @@ class SettingsManager:
|
||||
finally:
|
||||
self._pending_portable_switch = None
|
||||
|
||||
def _copy_model_cache_directory(self, source_dir: str, target_dir: str) -> None:
|
||||
"""Copy model_cache artifacts when switching storage locations."""
|
||||
def _migrate_settings_directory_content(
|
||||
self, source_dir: str, target_dir: str
|
||||
) -> None:
|
||||
"""Migrate settings directory subdirectories when switching storage locations.
|
||||
|
||||
Copies the canonical subdirectories (cache, backups, logs, stats, wildcards)
|
||||
from the old settings directory to the new one. Legacy cache artifacts
|
||||
(model_cache, recipe_cache, etc.) are migrated lazily by
|
||||
``resolve_cache_path_with_migration`` on first access.
|
||||
|
||||
Args:
|
||||
source_dir: The previous settings directory path.
|
||||
target_dir: The new settings directory path.
|
||||
"""
|
||||
|
||||
if not source_dir or not target_dir:
|
||||
return
|
||||
|
||||
source_cache_dir = os.path.join(source_dir, "model_cache")
|
||||
target_cache_dir = os.path.join(target_dir, "model_cache")
|
||||
if os.path.isdir(source_cache_dir) and os.path.abspath(
|
||||
source_cache_dir
|
||||
) != os.path.abspath(target_cache_dir):
|
||||
try:
|
||||
shutil.copytree(
|
||||
source_cache_dir,
|
||||
target_cache_dir,
|
||||
dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to copy model_cache directory from %s to %s: %s",
|
||||
source_cache_dir,
|
||||
target_cache_dir,
|
||||
exc,
|
||||
)
|
||||
def _copy_dir(name: str) -> None:
|
||||
source = os.path.join(source_dir, name)
|
||||
target = os.path.join(target_dir, name)
|
||||
if os.path.isdir(source) and os.path.abspath(source) != os.path.abspath(
|
||||
target
|
||||
):
|
||||
try:
|
||||
shutil.copytree(
|
||||
source,
|
||||
target,
|
||||
dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to copy directory %s from %s to %s: %s",
|
||||
name,
|
||||
source,
|
||||
target,
|
||||
exc,
|
||||
)
|
||||
|
||||
source_cache_file = os.path.join(source_dir, "model_cache.sqlite")
|
||||
target_cache_file = os.path.join(target_dir, "model_cache.sqlite")
|
||||
if os.path.isfile(source_cache_file) and os.path.abspath(
|
||||
source_cache_file
|
||||
) != os.path.abspath(target_cache_file):
|
||||
try:
|
||||
shutil.copy2(source_cache_file, target_cache_file)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to copy model_cache.sqlite from %s to %s: %s",
|
||||
source_cache_file,
|
||||
target_cache_file,
|
||||
exc,
|
||||
)
|
||||
# Managed subdirectories under settings_dir
|
||||
_copy_dir("cache")
|
||||
_copy_dir("backups")
|
||||
_copy_dir("logs")
|
||||
_copy_dir("stats")
|
||||
_copy_dir("wildcards")
|
||||
|
||||
def _get_user_config_directory(self) -> str:
|
||||
"""Return the user configuration directory, falling back to ~/.config."""
|
||||
@@ -1758,6 +1847,9 @@ class SettingsManager:
|
||||
if key in self.settings:
|
||||
minimal[key] = copy.deepcopy(self.settings[key])
|
||||
|
||||
if self.settings.get("use_portable_settings"):
|
||||
minimal["use_portable_settings"] = True
|
||||
|
||||
if self._seed_template:
|
||||
for key, value in self._seed_template.items():
|
||||
minimal.setdefault(key, copy.deepcopy(value))
|
||||
|
||||
@@ -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
|
||||
@@ -50,6 +51,10 @@ class BulkMetadataRefreshUseCase:
|
||||
if not model.get("skip_metadata_refresh", False)
|
||||
and not self._is_in_skip_path(model.get("folder", ""), skip_paths)
|
||||
and (not model.get("civitai") or not model["civitai"].get("id"))
|
||||
# Skip models downloaded from Hugging Face — they are not on
|
||||
# CivitAI / CivArchive. Users can still refresh them individually
|
||||
# via the right-click context menu.
|
||||
and not model.get("hf_url", "")
|
||||
and not (
|
||||
# Skip models confirmed not on CivitAI when no need to retry
|
||||
model.get("from_civitai") is False
|
||||
@@ -62,26 +67,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 +128,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 +206,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 +225,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:
|
||||
|
||||
+32
-1
@@ -31,6 +31,8 @@ PREVIEW_EXTENSIONS = [
|
||||
".mp4",
|
||||
".gif",
|
||||
".webm",
|
||||
".avif",
|
||||
".jxl",
|
||||
]
|
||||
|
||||
# Card preview image width
|
||||
@@ -41,10 +43,24 @@ 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"],
|
||||
}
|
||||
|
||||
# Model weight file extensions recognised by scanners.
|
||||
# This is the union of all scanner extensions (lora, checkpoint, embedding).
|
||||
MODEL_FILE_EXTENSIONS = {
|
||||
".safetensors",
|
||||
".ckpt",
|
||||
".pt",
|
||||
".pt2",
|
||||
".bin",
|
||||
".pth",
|
||||
".pkl",
|
||||
".sft",
|
||||
".gguf",
|
||||
}
|
||||
|
||||
# Valid sub-types for each scanner type
|
||||
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
||||
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
||||
@@ -145,6 +161,8 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
"Qwen",
|
||||
"ZImageBase",
|
||||
"ZImageTurbo",
|
||||
# Krea 2 — loaded via UNETLoader in ComfyUI
|
||||
"Krea 2",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -208,8 +226,21 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
||||
"Wan Video 2.5 I2V",
|
||||
"Hunyuan Video",
|
||||
"Anima",
|
||||
"ACE Audio",
|
||||
"Boogu",
|
||||
"Ernie",
|
||||
"Ernie Turbo",
|
||||
"Grok",
|
||||
"HappyHorse",
|
||||
"HiDream-O1",
|
||||
"Ideogram 4.0",
|
||||
"Krea 2",
|
||||
"Lens",
|
||||
"MAI",
|
||||
"Nucleus",
|
||||
"Qwen 2",
|
||||
"Upscaler",
|
||||
"Wan Image 2.7",
|
||||
"Wan Video 2.7",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -72,6 +72,7 @@ class _DownloadProgress(dict):
|
||||
refreshed_models=set(),
|
||||
failed_models=set(),
|
||||
reprocessed_models=set(),
|
||||
rate_limited_models=set(),
|
||||
)
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
@@ -82,6 +83,7 @@ class _DownloadProgress(dict):
|
||||
snapshot["refreshed_models"] = list(self["refreshed_models"])
|
||||
snapshot["failed_models"] = list(self["failed_models"])
|
||||
snapshot["reprocessed_models"] = list(self.get("reprocessed_models", set()))
|
||||
snapshot["rate_limited_models"] = list(self.get("rate_limited_models", set()))
|
||||
return snapshot
|
||||
|
||||
|
||||
@@ -153,13 +155,15 @@ class DownloadManager:
|
||||
# Step 3: Load progress file (I/O operation, done outside lock)
|
||||
processed_models = set()
|
||||
failed_models = set()
|
||||
rate_limited_models = set()
|
||||
|
||||
try:
|
||||
progress_file, processed_models, failed_models = await self._load_progress_file(output_dir)
|
||||
progress_file, processed_models, failed_models, rate_limited_models = await self._load_progress_file(output_dir)
|
||||
logger.debug(
|
||||
"Loaded previous progress, %s models already processed, %s models marked as failed",
|
||||
"Loaded previous progress, %s models already processed, %s models marked as failed, %s models rate-limited",
|
||||
len(processed_models),
|
||||
len(failed_models),
|
||||
len(rate_limited_models),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load progress file: {e}")
|
||||
@@ -175,6 +179,7 @@ class DownloadManager:
|
||||
self._progress.reset()
|
||||
self._progress["processed_models"] = processed_models
|
||||
self._progress["failed_models"] = failed_models
|
||||
self._progress["rate_limited_models"] = rate_limited_models
|
||||
self._stop_requested = False
|
||||
self._progress["status"] = "running"
|
||||
self._progress["start_time"] = time.time()
|
||||
@@ -242,8 +247,8 @@ class DownloadManager:
|
||||
"status": self._progress.snapshot(),
|
||||
}
|
||||
|
||||
async def _load_progress_file(self, output_dir: str) -> tuple[str, set, set]:
|
||||
"""Load progress file from disk. Returns (progress_file_path, processed_models, failed_models).
|
||||
async def _load_progress_file(self, output_dir: str) -> tuple[str, set, set, set]:
|
||||
"""Load progress file from disk. Returns (progress_file_path, processed_models, failed_models, rate_limited_models).
|
||||
|
||||
This is a separate async method to allow running in executor to avoid blocking event loop.
|
||||
"""
|
||||
@@ -252,8 +257,12 @@ class DownloadManager:
|
||||
None, self._load_progress_file_sync, output_dir
|
||||
)
|
||||
|
||||
def _load_progress_file_sync(self, output_dir: str) -> tuple[str, set, set]:
|
||||
"""Synchronous implementation of progress file loading."""
|
||||
def _load_progress_file_sync(self, output_dir: str) -> tuple[str, set, set, set]:
|
||||
"""Synchronous implementation of progress file loading.
|
||||
|
||||
Returns:
|
||||
tuple: (progress_file_path, processed_models, failed_models, rate_limited_models)
|
||||
"""
|
||||
progress_file = os.path.join(output_dir, ".download_progress.json")
|
||||
progress_source = progress_file
|
||||
|
||||
@@ -289,6 +298,7 @@ class DownloadManager:
|
||||
|
||||
processed_models = set()
|
||||
failed_models = set()
|
||||
rate_limited_models = set()
|
||||
|
||||
if os.path.exists(progress_source):
|
||||
try:
|
||||
@@ -296,11 +306,11 @@ class DownloadManager:
|
||||
saved_progress = json.load(f)
|
||||
processed_models = set(saved_progress.get("processed_models", []))
|
||||
failed_models = set(saved_progress.get("failed_models", []))
|
||||
rate_limited_models = set(saved_progress.get("rate_limited_models", []))
|
||||
except Exception:
|
||||
# Return empty sets on error
|
||||
pass
|
||||
|
||||
return progress_file, processed_models, failed_models
|
||||
return progress_file, processed_models, failed_models, rate_limited_models
|
||||
|
||||
def _load_progress_sets_sync(self, progress_file: str) -> tuple[set, set]:
|
||||
"""Load only the processed and failed model sets from progress file.
|
||||
@@ -732,11 +742,13 @@ class DownloadManager:
|
||||
success,
|
||||
is_stale,
|
||||
failed_images,
|
||||
rate_limited_images,
|
||||
) = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||
model_hash, model_name, images, model_dir, optimize, downloader
|
||||
)
|
||||
|
||||
failed_urls: Set[str] = set(failed_images)
|
||||
rate_limited_urls: Set[str] = set(rate_limited_images)
|
||||
|
||||
# If metadata is stale, try to refresh it
|
||||
if is_stale and model_hash not in self._progress["refreshed_models"]:
|
||||
@@ -760,6 +772,7 @@ class DownloadManager:
|
||||
success,
|
||||
_,
|
||||
additional_failed,
|
||||
additional_rate_limited,
|
||||
) = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||
model_hash,
|
||||
model_name,
|
||||
@@ -770,29 +783,50 @@ class DownloadManager:
|
||||
)
|
||||
|
||||
failed_urls.update(additional_failed)
|
||||
rate_limited_urls.update(additional_rate_limited)
|
||||
|
||||
self._progress["refreshed_models"].add(model_hash)
|
||||
|
||||
if failed_urls:
|
||||
# Separate permanent failures from rate-limited ones
|
||||
permanent_failures = failed_urls - rate_limited_urls
|
||||
|
||||
if permanent_failures:
|
||||
await self._remove_failed_images_from_metadata(
|
||||
model_hash,
|
||||
model_name,
|
||||
model_dir,
|
||||
failed_urls,
|
||||
permanent_failures,
|
||||
scanner,
|
||||
)
|
||||
|
||||
if failed_urls:
|
||||
if rate_limited_urls:
|
||||
self._progress["rate_limited_models"].add(model_hash)
|
||||
logger.warning(
|
||||
"%d example images for %s are rate-limited (429), will retry next time",
|
||||
len(rate_limited_urls),
|
||||
model_name,
|
||||
)
|
||||
# Clear failed_models so non-force runs can retry
|
||||
if force and model_hash in self._progress["failed_models"]:
|
||||
self._progress["failed_models"].discard(model_hash)
|
||||
logger.info(
|
||||
f"Removed {model_name} from failed_models after force retry with rate-limited images"
|
||||
)
|
||||
|
||||
if rate_limited_urls:
|
||||
# Don't mark as failed or fully processed — rate-limited
|
||||
# images will be retried next time.
|
||||
pass
|
||||
elif permanent_failures:
|
||||
self._progress["failed_models"].add(model_hash)
|
||||
self._progress["processed_models"].add(model_hash)
|
||||
logger.info(
|
||||
"Removed %s failed example images for %s",
|
||||
len(failed_urls),
|
||||
len(permanent_failures),
|
||||
model_name,
|
||||
)
|
||||
elif success:
|
||||
self._progress["processed_models"].add(model_hash)
|
||||
# Remove from failed_models if force mode enabled and model was previously failed
|
||||
if force and model_hash in self._progress["failed_models"]:
|
||||
self._progress["failed_models"].discard(model_hash)
|
||||
logger.info(
|
||||
@@ -850,6 +884,7 @@ class DownloadManager:
|
||||
"processed_models": list(self._progress["processed_models"]),
|
||||
"refreshed_models": list(self._progress["refreshed_models"]),
|
||||
"failed_models": list(self._progress["failed_models"]),
|
||||
"rate_limited_models": list(self._progress.get("rate_limited_models", set())),
|
||||
"completed": self._progress["completed"],
|
||||
"total": self._progress["total"],
|
||||
"last_update": time.time(),
|
||||
@@ -1155,11 +1190,13 @@ class DownloadManager:
|
||||
success,
|
||||
is_stale,
|
||||
failed_images,
|
||||
rate_limited_images,
|
||||
) = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||
model_hash, model_name, images, model_dir, optimize, downloader
|
||||
)
|
||||
|
||||
failed_urls: Set[str] = set(failed_images)
|
||||
rate_limited_urls: Set[str] = set(rate_limited_images)
|
||||
|
||||
# If metadata is stale, try to refresh it
|
||||
if is_stale and model_hash not in self._progress["refreshed_models"]:
|
||||
@@ -1183,6 +1220,7 @@ class DownloadManager:
|
||||
success,
|
||||
_,
|
||||
additional_failed_images,
|
||||
additional_rate_limited,
|
||||
) = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||
model_hash,
|
||||
model_name,
|
||||
@@ -1192,21 +1230,35 @@ class DownloadManager:
|
||||
downloader,
|
||||
)
|
||||
|
||||
# Combine failed images from both attempts
|
||||
failed_urls.update(additional_failed_images)
|
||||
rate_limited_urls.update(additional_rate_limited)
|
||||
|
||||
self._progress["refreshed_models"].add(model_hash)
|
||||
|
||||
# For forced downloads, remove failed images from metadata
|
||||
if failed_urls:
|
||||
# Separate permanent failures from rate-limited ones
|
||||
permanent_failures = failed_urls - rate_limited_urls
|
||||
|
||||
# Only remove permanently failed images from metadata
|
||||
if permanent_failures:
|
||||
await self._remove_failed_images_from_metadata(
|
||||
model_hash, model_name, model_dir, failed_urls, scanner
|
||||
model_hash, model_name, model_dir, permanent_failures, scanner
|
||||
)
|
||||
|
||||
# Mark as processed
|
||||
if (
|
||||
success or failed_urls
|
||||
): # Mark as processed if we successfully downloaded some images or removed failed ones
|
||||
if rate_limited_urls:
|
||||
self._progress["rate_limited_models"].add(model_hash)
|
||||
logger.warning(
|
||||
"%d example images for %s are rate-limited (429), will retry next time",
|
||||
len(rate_limited_urls),
|
||||
model_name,
|
||||
)
|
||||
|
||||
# Mark as processed only when no rate-limited images remain
|
||||
if rate_limited_urls:
|
||||
pass
|
||||
elif permanent_failures:
|
||||
self._progress["processed_models"].add(model_hash)
|
||||
self._progress["failed_models"].add(model_hash)
|
||||
elif success:
|
||||
self._progress["processed_models"].add(model_hash)
|
||||
|
||||
return True # Return True to indicate a remote download happened
|
||||
@@ -1229,15 +1281,20 @@ class DownloadManager:
|
||||
model_dir: str,
|
||||
failed_images: Iterable[str],
|
||||
scanner,
|
||||
error_type: str = "not_found",
|
||||
) -> None:
|
||||
"""Mark failed images in model metadata so they won't be retried."""
|
||||
"""Mark failed images in model metadata so they won't be retried.
|
||||
|
||||
Args:
|
||||
error_type: Reason string stored in the image's ``downloadError`` field
|
||||
(default ``"not_found"``).
|
||||
"""
|
||||
|
||||
failed_set: Set[str] = {url for url in failed_images if url}
|
||||
if not failed_set:
|
||||
return
|
||||
|
||||
try:
|
||||
# Get current model data
|
||||
model_data = await MetadataUpdater.get_updated_model(model_hash, scanner)
|
||||
if not model_data:
|
||||
logger.warning(
|
||||
@@ -1268,7 +1325,7 @@ class DownloadManager:
|
||||
continue
|
||||
|
||||
image["downloadFailed"] = True
|
||||
image.setdefault("downloadError", "not_found")
|
||||
image.setdefault("downloadError", error_type)
|
||||
logger.debug(
|
||||
"Marked example image %s for %s as failed due to missing remote asset",
|
||||
image_url,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -62,6 +63,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 +80,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'
|
||||
@@ -188,16 +195,22 @@ class ExampleImagesProcessor:
|
||||
|
||||
return model_success, False # (success, is_metadata_stale)
|
||||
|
||||
@staticmethod
|
||||
def _extract_retry_after(error_message: str) -> int:
|
||||
if not error_message:
|
||||
return 60
|
||||
match = re.search(r"retry after (\d+)s", str(error_message))
|
||||
if match:
|
||||
return max(1, int(match.group(1)))
|
||||
return 60
|
||||
|
||||
@staticmethod
|
||||
async def download_model_images_with_tracking(model_hash, model_name, model_images, model_dir, optimize, downloader):
|
||||
"""Download images for a single model with tracking of failed image URLs
|
||||
|
||||
Returns:
|
||||
tuple: (success, is_stale_metadata, failed_images) - whether download was successful, whether metadata is stale, list of failed image URLs
|
||||
"""
|
||||
model_success = True
|
||||
failed_images = []
|
||||
|
||||
rate_limited_images = []
|
||||
any_successful_download = False
|
||||
|
||||
for i, image in enumerate(model_images):
|
||||
image_url = image.get('url')
|
||||
if not image_url:
|
||||
@@ -215,64 +228,110 @@ class ExampleImagesProcessor:
|
||||
original_url = image_url
|
||||
if optimize and 'civitai.com' in image_url:
|
||||
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
|
||||
|
||||
# Download the file first to determine the actual file type
|
||||
try:
|
||||
logger.debug(f"Downloading media file {i} for {model_name}")
|
||||
|
||||
# Download using the unified downloader with headers
|
||||
success, content, headers = await downloader.download_to_memory(
|
||||
|
||||
async def _attempt_download() -> tuple:
|
||||
logger.debug("Downloading media file %s for %s", i, model_name)
|
||||
return await downloader.download_to_memory(
|
||||
image_url,
|
||||
use_auth=False, # Example images don't need auth
|
||||
return_headers=True
|
||||
use_auth=False,
|
||||
return_headers=True,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
success, content, headers = await _attempt_download()
|
||||
|
||||
if success:
|
||||
# Determine file extension from content or headers
|
||||
media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
|
||||
content, headers, original_url, image.get("type")
|
||||
)
|
||||
|
||||
# Check if the detected file type is supported
|
||||
is_image = media_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
|
||||
is_video = media_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||
|
||||
|
||||
if not (is_image or is_video):
|
||||
logger.debug(f"Skipping unsupported file type: {media_ext}")
|
||||
logger.debug("Skipping unsupported file type: %s", media_ext)
|
||||
continue
|
||||
|
||||
# Use 0-based indexing with the detected extension
|
||||
|
||||
save_filename = f"image_{i}{media_ext}"
|
||||
save_path = os.path.join(model_dir, save_filename)
|
||||
|
||||
# Check if already downloaded
|
||||
|
||||
if os.path.exists(save_path):
|
||||
logger.debug(f"File already exists: {save_path}")
|
||||
logger.debug("File already exists: %s", save_path)
|
||||
continue
|
||||
|
||||
# Save the file
|
||||
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
any_successful_download = True
|
||||
|
||||
elif ExampleImagesProcessor._is_not_found_error(content):
|
||||
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
|
||||
logger.warning(error_msg)
|
||||
model_success = False # Mark the model as failed due to 404 error
|
||||
failed_images.append(image_url) # Track failed URL
|
||||
# Return early to trigger metadata refresh attempt
|
||||
return False, True, failed_images # (success, is_metadata_stale, failed_images)
|
||||
model_success = False
|
||||
failed_images.append(image_url)
|
||||
return False, True, failed_images, rate_limited_images
|
||||
|
||||
elif "Rate limited (429)" in str(content):
|
||||
max_attempts = 3
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
wait = ExampleImagesProcessor._extract_retry_after(str(content)) * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
"Rate limited (429) for %s, retry %d/%d after %ds",
|
||||
image_url, attempt, max_attempts, wait,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
success, content, headers = await _attempt_download()
|
||||
if success:
|
||||
media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
|
||||
content, headers, original_url, image.get("type")
|
||||
)
|
||||
is_image = media_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
|
||||
is_video = media_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||
|
||||
if not (is_image or is_video):
|
||||
logger.debug("Skipping unsupported file type: %s", media_ext)
|
||||
break
|
||||
|
||||
save_filename = f"image_{i}{media_ext}"
|
||||
save_path = os.path.join(model_dir, save_filename)
|
||||
if os.path.exists(save_path):
|
||||
logger.debug("File already exists: %s", save_path)
|
||||
break
|
||||
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(content)
|
||||
any_successful_download = True
|
||||
break
|
||||
elif "Rate limited (429)" in str(content):
|
||||
continue
|
||||
elif ExampleImagesProcessor._is_not_found_error(content):
|
||||
logger.warning("Failed to download file: %s, status code: 404", image_url)
|
||||
model_success = False
|
||||
failed_images.append(image_url)
|
||||
break
|
||||
else:
|
||||
logger.warning("Failed to download file: %s, error: %s", image_url, content)
|
||||
model_success = False
|
||||
failed_images.append(image_url)
|
||||
break
|
||||
else:
|
||||
logger.warning(
|
||||
"Giving up on %s after %d retries due to rate limiting",
|
||||
image_url, max_attempts,
|
||||
)
|
||||
rate_limited_images.append(image_url)
|
||||
model_success = False
|
||||
else:
|
||||
error_msg = f"Failed to download file: {image_url}, error: {content}"
|
||||
logger.warning(error_msg)
|
||||
model_success = False # Mark the model as failed
|
||||
failed_images.append(image_url) # Track failed URL
|
||||
model_success = False
|
||||
failed_images.append(image_url)
|
||||
except Exception as e:
|
||||
error_msg = f"Error downloading file {image_url}: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
model_success = False # Mark the model as failed
|
||||
failed_images.append(image_url) # Track failed URL
|
||||
|
||||
return model_success, False, failed_images # (success, is_metadata_stale, failed_images)
|
||||
model_success = False
|
||||
failed_images.append(image_url)
|
||||
|
||||
return any_successful_download or model_success, False, failed_images, rate_limited_images
|
||||
|
||||
@staticmethod
|
||||
async def process_local_examples(model_file_path, model_file_name, model_name, model_dir, optimize):
|
||||
|
||||
+117
-7
@@ -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
|
||||
|
||||
+15
-1
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ class BaseModelMetadata:
|
||||
metadata_source: Optional[str] = None # Last provider that supplied metadata
|
||||
last_checked_at: float = 0 # Last checked timestamp
|
||||
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
|
||||
trainedWords: List[str] = field(
|
||||
default_factory=list
|
||||
) # Trigger words / activation prompts (source-agnostic)
|
||||
_unknown_fields: Dict[str, Any] = field(
|
||||
default_factory=dict, repr=False, compare=False
|
||||
) # Store unknown fields
|
||||
@@ -47,6 +50,9 @@ class BaseModelMetadata:
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
|
||||
if self.trainedWords is None:
|
||||
self.trainedWords = []
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> "BaseModelMetadata":
|
||||
"""Create instance from dictionary"""
|
||||
|
||||
@@ -12,6 +12,7 @@ from platformdirs import user_config_dir
|
||||
|
||||
|
||||
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||
_LM_PORTABLE_ENV = "LORA_MANAGER_PORTABLE"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -100,7 +101,11 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
|
||||
|
||||
|
||||
def _should_use_portable_settings(path: str, logger: logging.Logger) -> bool:
|
||||
"""Return ``True`` when the repository settings file enables portable mode."""
|
||||
"""Return ``True`` when the env var forces it or the settings file enables it."""
|
||||
|
||||
if os.environ.get(_LM_PORTABLE_ENV, "0") == "1":
|
||||
logger.debug("Portable mode enabled via %s", _LM_PORTABLE_ENV)
|
||||
return True
|
||||
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.1.0"
|
||||
version = "1.1.7"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
{
|
||||
"id": 1746460,
|
||||
"name": "Mixplin Style [Illustrious]",
|
||||
"type": "LORA",
|
||||
"description": "description",
|
||||
"username": "Ty_Lee",
|
||||
"downloadCount": 4207,
|
||||
"favoriteCount": 0,
|
||||
"commentCount": 8,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"is_nsfw": true,
|
||||
"nsfw_level": 31,
|
||||
"createdAt": "2025-07-06T01:51:42.859Z",
|
||||
"updatedAt": "2025-10-10T23:15:26.714Z",
|
||||
"deletedAt": null,
|
||||
"tags": [
|
||||
"art",
|
||||
"style",
|
||||
"artist style",
|
||||
"styles",
|
||||
"mixplin",
|
||||
"artiststyle"
|
||||
],
|
||||
"creator_id": "Ty_Lee",
|
||||
"creator_username": "Ty_Lee",
|
||||
"creator_name": "Ty_Lee",
|
||||
"creator_url": "/users/Ty_Lee",
|
||||
"versions": [
|
||||
{
|
||||
"id": 2042594,
|
||||
"name": "v2.0",
|
||||
"href": "/models/1746460?modelVersionId=2042594"
|
||||
},
|
||||
{
|
||||
"id": 1976567,
|
||||
"name": "v1.0",
|
||||
"href": "/models/1746460?modelVersionId=1976567"
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"id": 1976567,
|
||||
"modelId": 1746460,
|
||||
"name": "v1.0",
|
||||
"baseModel": "Illustrious",
|
||||
"baseModelType": "Standard",
|
||||
"description": null,
|
||||
"downloadCount": 437,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"is_nsfw": true,
|
||||
"nsfw_level": 31,
|
||||
"createdAt": "2025-07-05T10:17:28.716Z",
|
||||
"updatedAt": "2025-10-10T23:15:26.756Z",
|
||||
"deletedAt": null,
|
||||
"files": [
|
||||
{
|
||||
"id": 1874043,
|
||||
"name": "mxpln-illustrious-ty_lee.safetensors",
|
||||
"type": "Model",
|
||||
"sizeKB": 223124.37109375,
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1976567",
|
||||
"modelId": 1746460,
|
||||
"modelName": "Mixplin Style [Illustrious]",
|
||||
"modelVersionId": 1976567,
|
||||
"is_nsfw": true,
|
||||
"nsfw_level": 31,
|
||||
"sha256": "e2b7a280d6539556f23f380b3f71e4e22bc4524445c4c96526e117c6005c6ad3",
|
||||
"createdAt": "2025-07-05T10:17:28.716Z",
|
||||
"updatedAt": "2025-10-10T23:15:26.766Z",
|
||||
"is_primary": false,
|
||||
"mirrors": [
|
||||
{
|
||||
"filename": "mxpln-illustrious-ty_lee.safetensors",
|
||||
"url": "https://civitai.com/api/download/models/1976567",
|
||||
"source": "civitai",
|
||||
"model_id": 1746460,
|
||||
"model_version_id": 1976567,
|
||||
"deletedAt": null,
|
||||
"is_gated": false,
|
||||
"is_paid": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"id": 86403595,
|
||||
"url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
|
||||
"nsfwLevel": 1,
|
||||
"width": 1560,
|
||||
"height": 2280,
|
||||
"hash": "U7G8Zp0w02%IA6%N00-;D]-W~VNG0nMw-.IV",
|
||||
"type": "image",
|
||||
"minor": false,
|
||||
"poi": false,
|
||||
"hasMeta": true,
|
||||
"hasPositivePrompt": true,
|
||||
"onSite": false,
|
||||
"remixOfId": null,
|
||||
"image_url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
|
||||
"link": "https://genur.art/posts/86403595"
|
||||
}
|
||||
],
|
||||
"trigger": [
|
||||
"mxpln"
|
||||
],
|
||||
"allow_download": true,
|
||||
"download_url": "/api/download/models/1976567",
|
||||
"platform_url": "https://civitai.com/models/1746460?modelVersionId=1976567",
|
||||
"civitai_model_id": 1746460,
|
||||
"civitai_model_version_id": 1976567,
|
||||
"href": "/models/1746460?modelVersionId=1976567",
|
||||
"mirrors": [
|
||||
{
|
||||
"platform": "tensorart",
|
||||
"href": "/tensorart/models/904473536033245448/versions/904473536033245448",
|
||||
"platform_url": "https://tensor.art/models/904473536033245448",
|
||||
"name": "Mixplin Style MXP",
|
||||
"version_name": "Mixplin",
|
||||
"id": "904473536033245448",
|
||||
"version_id": "904473536033245448"
|
||||
}
|
||||
]
|
||||
},
|
||||
"platform": "civitai",
|
||||
"platform_name": "CivitAI",
|
||||
"meta": {
|
||||
"title": "Mixplin Style [Illustrious] - v1.0 - CivitAI Archive",
|
||||
"description": "Mixplin Style [Illustrious] v1.0 is a Illustrious LORA AI model created by Ty_Lee for generating images of art, style, artist style, styles, mixplin, artiststyle",
|
||||
"image": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
|
||||
"canonical": "https://civarchive.com/models/1746460?modelVersionId=1976567"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
CREATE TABLE models (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
username TEXT,
|
||||
data TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE model_versions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
model_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
base_model TEXT NOT NULL,
|
||||
published_at INTEGER,
|
||||
data TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX model_versions_model_id_idx ON model_versions (model_id);
|
||||
CREATE TABLE model_files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
model_id INTEGER NOT NULL,
|
||||
version_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
sha256 TEXT,
|
||||
data TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX model_files_model_id_idx ON model_files (model_id);
|
||||
CREATE INDEX model_files_version_id_idx ON model_files (version_id);
|
||||
CREATE TABLE archived_model_files (
|
||||
file_id INTEGER PRIMARY KEY,
|
||||
model_id INTEGER NOT NULL,
|
||||
version_id INTEGER NOT NULL
|
||||
) STRICT;
|
||||
@@ -1,110 +0,0 @@
|
||||
{
|
||||
"id": 1231067,
|
||||
"name": "Vivid Impressions Storybook Style",
|
||||
"description": "<h3 id=\"if-you'd-like-to-support-me-feel-free-to-visit-my-ko-fi-page.-please-share-your-images-using-the-"+add-post"-button-below.-it-supports-the-creators.-thanks!-nnfwkvfly\">If you'd like to support me, feel free to visit my <a target=\"_blank\" rel=\"ugc\" href=\"https://ko-fi.com/pixelpawsai\">Ko-Fi</a> page. ❤️<br /><br />Please share your images using the \"<span style=\"color:rgb(250, 82, 82)\">+add post</span>\" button below. It supports the creators. Thanks! 💕</h3><h3 id=\"if-you-like-my-lora-please-like-comment-or-donate-some-buzz.-much-appreciated!-vyeqok3go\">If you like my LoRA, please<span style=\"color:rgb(230, 73, 128)\"> </span><span style=\"color:rgb(250, 82, 82)\">like</span>, <span style=\"color:rgb(250, 82, 82)\">comment</span>, or <span style=\"color:#fa5252\">donate some Buzz</span>. Much appreciated! ❤️</h3><h3 id=\"-lo912t8rj\"></h3><h3 id=\"trigger-word:-ppstorybook-wlggllim2\"><strong><span style=\"color:rgb(253, 126, 20)\">Trigger word: </span></strong>ppstorybook</h3><h3 id=\"strength:-0.8-experiment-as-you-like-luvhks6za\"><strong><span style=\"color:rgb(253, 126, 20)\">Strength: </span></strong>0.8, experiment as you like</h3>",
|
||||
"allowNoCredit": true,
|
||||
"allowCommercialUse": [
|
||||
"Image",
|
||||
"RentCivit",
|
||||
"Rent",
|
||||
"Sell"
|
||||
],
|
||||
"allowDerivatives": true,
|
||||
"allowDifferentLicense": true,
|
||||
"type": "LORA",
|
||||
"minor": false,
|
||||
"sfwOnly": false,
|
||||
"poi": false,
|
||||
"nsfw": false,
|
||||
"nsfwLevel": 1,
|
||||
"availability": "Public",
|
||||
"cosmetic": null,
|
||||
"supportsGeneration": true,
|
||||
"stats": {
|
||||
"downloadCount": 2183,
|
||||
"favoriteCount": 0,
|
||||
"thumbsUpCount": 416,
|
||||
"thumbsDownCount": 0,
|
||||
"commentCount": 12,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"tippedAmountCount": 360
|
||||
},
|
||||
"creator": {
|
||||
"username": "PixelPawsAI",
|
||||
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
|
||||
},
|
||||
"tags": [
|
||||
"style",
|
||||
"illustration",
|
||||
"storybook"
|
||||
],
|
||||
"modelVersions": [
|
||||
{
|
||||
"id": 1387174,
|
||||
"index": 0,
|
||||
"name": "v1.0",
|
||||
"baseModel": "Flux.1 D",
|
||||
"baseModelType": "Standard",
|
||||
"createdAt": "2025-02-08T11:15:47.197Z",
|
||||
"publishedAt": "2025-02-08T11:29:04.487Z",
|
||||
"status": "Published",
|
||||
"availability": "Public",
|
||||
"nsfwLevel": 1,
|
||||
"trainedWords": [
|
||||
"ppstorybook"
|
||||
],
|
||||
"covered": true,
|
||||
"stats": {
|
||||
"downloadCount": 2183,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"thumbsUpCount": 416,
|
||||
"thumbsDownCount": 0
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"id": 1289799,
|
||||
"sizeKB": 18829.1484375,
|
||||
"name": "pp-storybook_rank2_bf16.safetensors",
|
||||
"type": "Model",
|
||||
"pickleScanResult": "Success",
|
||||
"pickleScanMessage": "No Pickle imports",
|
||||
"virusScanResult": "Success",
|
||||
"virusScanMessage": null,
|
||||
"scannedAt": "2025-02-08T11:21:04.247Z",
|
||||
"metadata": {
|
||||
"format": "SafeTensor"
|
||||
},
|
||||
"hashes": {
|
||||
"AutoV1": "F414C813",
|
||||
"AutoV2": "9753338AB6",
|
||||
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
|
||||
"CRC32": "A65AE7B3",
|
||||
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
|
||||
"AutoV3": "34A22376739D"
|
||||
},
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1387174",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/original=true/56547310.jpeg",
|
||||
"nsfwLevel": 1,
|
||||
"width": 832,
|
||||
"height": 1216,
|
||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
||||
"type": "image",
|
||||
"minor": false,
|
||||
"poi": false,
|
||||
"hasMeta": true,
|
||||
"hasPositivePrompt": true,
|
||||
"onSite": false,
|
||||
"remixOfId": null
|
||||
}
|
||||
],
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
{
|
||||
"id": 1387174,
|
||||
"modelId": 1231067,
|
||||
"name": "v1.0",
|
||||
"createdAt": "2025-02-08T11:15:47.197Z",
|
||||
"updatedAt": "2025-02-08T11:29:04.526Z",
|
||||
"status": "Published",
|
||||
"publishedAt": "2025-02-08T11:29:04.487Z",
|
||||
"trainedWords": [
|
||||
"ppstorybook"
|
||||
],
|
||||
"trainingStatus": null,
|
||||
"trainingDetails": null,
|
||||
"baseModel": "Flux.1 D",
|
||||
"baseModelType": null,
|
||||
"earlyAccessEndsAt": null,
|
||||
"earlyAccessConfig": null,
|
||||
"description": null,
|
||||
"uploadType": "Created",
|
||||
"usageControl": "Download",
|
||||
"air": "urn:air:flux1:lora:civitai:1231067@1387174",
|
||||
"stats": {
|
||||
"downloadCount": 1436,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"thumbsUpCount": 316
|
||||
},
|
||||
"model": {
|
||||
"name": "Vivid Impressions Storybook Style",
|
||||
"type": "LORA",
|
||||
"nsfw": false,
|
||||
"poi": false
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"id": 1289799,
|
||||
"sizeKB": 18829.1484375,
|
||||
"name": "pp-storybook_rank2_bf16.safetensors",
|
||||
"type": "Model",
|
||||
"pickleScanResult": "Success",
|
||||
"pickleScanMessage": "No Pickle imports",
|
||||
"virusScanResult": "Success",
|
||||
"virusScanMessage": null,
|
||||
"scannedAt": "2025-02-08T11:21:04.247Z",
|
||||
"metadata": {
|
||||
"format": "SafeTensor",
|
||||
"size": null,
|
||||
"fp": null
|
||||
},
|
||||
"hashes": {
|
||||
"AutoV1": "F414C813",
|
||||
"AutoV2": "9753338AB6",
|
||||
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
|
||||
"CRC32": "A65AE7B3",
|
||||
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
|
||||
"AutoV3": "34A22376739D"
|
||||
},
|
||||
"primary": true,
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/width=832/56547310.jpeg",
|
||||
"nsfwLevel": 1,
|
||||
"width": 832,
|
||||
"height": 1216,
|
||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
||||
"type": "image",
|
||||
"metadata": {
|
||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
||||
"size": 1361590,
|
||||
"width": 832,
|
||||
"height": 1216
|
||||
},
|
||||
"meta": {
|
||||
"Size": "832x1216",
|
||||
"seed": 1116375220995209,
|
||||
"Model": "flux_dev_fp8",
|
||||
"steps": 23,
|
||||
"hashes": {
|
||||
"model": ""
|
||||
},
|
||||
"prompt": "ppstorybook,A dreamy bunny hopping across a rainbow bridge, with fluffy clouds surrounding it and tiny birds flying alongside, rendered in a magical, soft-focus style with pastel hues and glowing accents.",
|
||||
"Version": "ComfyUI",
|
||||
"sampler": "DPM++ 2M",
|
||||
"cfgScale": 3.5,
|
||||
"clipSkip": 1,
|
||||
"resources": [],
|
||||
"Model hash": ""
|
||||
},
|
||||
"availability": "Public",
|
||||
"hasMeta": true,
|
||||
"hasPositivePrompt": true,
|
||||
"onSite": false,
|
||||
"remixOfId": null
|
||||
}
|
||||
],
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
{
|
||||
"resource-stack": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": { "ckpt_name": "urn:air:sdxl:checkpoint:civitai:827184@1410435" }
|
||||
},
|
||||
"resource-stack-1": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"lora_name": "urn:air:sdxl:lora:civitai:1107767@1253442",
|
||||
"strength_model": 1,
|
||||
"strength_clip": 1,
|
||||
"model": ["resource-stack", 0],
|
||||
"clip": ["resource-stack", 1]
|
||||
}
|
||||
},
|
||||
"resource-stack-2": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"lora_name": "urn:air:sdxl:lora:civitai:1342708@1516344",
|
||||
"strength_model": 1,
|
||||
"strength_clip": 1,
|
||||
"model": ["resource-stack-1", 0],
|
||||
"clip": ["resource-stack-1", 1]
|
||||
}
|
||||
},
|
||||
"resource-stack-3": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"lora_name": "urn:air:sdxl:lora:civitai:122359@135867",
|
||||
"strength_model": 1.55,
|
||||
"strength_clip": 1,
|
||||
"model": ["resource-stack-2", 0],
|
||||
"clip": ["resource-stack-2", 1]
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "smZ CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking",
|
||||
"parser": "comfy",
|
||||
"text_g": "",
|
||||
"text_l": "",
|
||||
"ascore": 2.5,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"crop_w": 0,
|
||||
"crop_h": 0,
|
||||
"target_width": 0,
|
||||
"target_height": 0,
|
||||
"smZ_steps": 1,
|
||||
"mean_normalization": true,
|
||||
"multi_conditioning": true,
|
||||
"use_old_emphasis_implementation": false,
|
||||
"with_SDXL": false,
|
||||
"clip": ["resource-stack-3", 1]
|
||||
},
|
||||
"_meta": { "title": "Positive" }
|
||||
},
|
||||
"7": {
|
||||
"class_type": "smZ CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "bad quality,worst quality,worst detail,sketch,censor",
|
||||
"parser": "comfy",
|
||||
"text_g": "",
|
||||
"text_l": "",
|
||||
"ascore": 2.5,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"crop_w": 0,
|
||||
"crop_h": 0,
|
||||
"target_width": 0,
|
||||
"target_height": 0,
|
||||
"smZ_steps": 1,
|
||||
"mean_normalization": true,
|
||||
"multi_conditioning": true,
|
||||
"use_old_emphasis_implementation": false,
|
||||
"with_SDXL": false,
|
||||
"clip": ["resource-stack-3", 1]
|
||||
},
|
||||
"_meta": { "title": "Negative" }
|
||||
},
|
||||
"20": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": { "model_name": "urn:air:other:upscaler:civitai:147759@164821" },
|
||||
"_meta": { "title": "Load Upscale Model" }
|
||||
},
|
||||
"17": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "https://orchestration.civitai.com/v2/consumer/blobs/5KZ6358TW8CNEGPZKD08NVDB30",
|
||||
"upload": "image"
|
||||
},
|
||||
"_meta": { "title": "Image Load" }
|
||||
},
|
||||
"19": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": { "upscale_model": ["20", 0], "image": ["17", 0] },
|
||||
"_meta": { "title": "Upscale Image (using Model)" }
|
||||
},
|
||||
"23": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"upscale_method": "nearest-exact",
|
||||
"crop": "disabled",
|
||||
"width": 1280,
|
||||
"height": 1856,
|
||||
"image": ["19", 0]
|
||||
},
|
||||
"_meta": { "title": "Upscale Image" }
|
||||
},
|
||||
"21": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": { "pixels": ["23", 0], "vae": ["resource-stack", 2] },
|
||||
"_meta": { "title": "VAE Encode" }
|
||||
},
|
||||
"11": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "normal",
|
||||
"seed": 2088370631,
|
||||
"steps": 47,
|
||||
"cfg": 6.5,
|
||||
"denoise": 0.3,
|
||||
"model": ["resource-stack-3", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["21", 0]
|
||||
},
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"13": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": { "samples": ["11", 0], "vae": ["resource-stack", 2] },
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"12": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": { "filename_prefix": "ComfyUI", "images": ["13", 0] },
|
||||
"_meta": { "title": "Save Image" }
|
||||
},
|
||||
"extra": {
|
||||
"airs": [
|
||||
"urn:air:other:upscaler:civitai:147759@164821",
|
||||
"urn:air:sdxl:checkpoint:civitai:827184@1410435",
|
||||
"urn:air:sdxl:lora:civitai:1107767@1253442",
|
||||
"urn:air:sdxl:lora:civitai:1342708@1516344",
|
||||
"urn:air:sdxl:lora:civitai:122359@135867"
|
||||
]
|
||||
},
|
||||
"extraMetadata": "{\u0022prompt\u0022:\u0022masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking\u0022,\u0022negativePrompt\u0022:\u0022bad quality,worst quality,worst detail,sketch,censor\u0022,\u0022steps\u0022:47,\u0022cfgScale\u0022:6.5,\u0022sampler\u0022:\u0022euler_ancestral\u0022,\u0022workflowId\u0022:\u0022img2img-hires\u0022,\u0022resources\u0022:[{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1253442,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1516344,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:135867,\u0022strength\u0022:1.55}],\u0022remixOfId\u0022:32140259}"
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
a dynamic and dramatic digital artwork featuring a stylized anthropomorphic white tiger with striking yellow eyes. The tiger is depicted in a powerful stance, wielding a katana with one hand raised above its head. Its fur is detailed with black stripes, and its mane flows wildly, blending with the stormy background. The scene is set amidst swirling dark clouds and flashes of lightning, enhancing the sense of movement and energy. The composition is vertical, with the tiger positioned centrally, creating a sense of depth and intensity. The color palette is dominated by shades of blue, gray, and white, with bright highlights from the lightning. The overall style is reminiscent of fantasy or manga art, with a focus on dynamic action and dramatic lighting.
|
||||
Negative prompt:
|
||||
Steps: 30, Sampler: Undefined, CFG scale: 3.5, Seed: 90300501, Size: 832x1216, Clip skip: 2, Created Date: 2025-03-05T13:51:18.1770234Z, Civitai resources: [{"type":"checkpoint","modelVersionId":691639,"modelName":"FLUX","modelVersionName":"Dev"},{"type":"lora","weight":0.4,"modelVersionId":1202162,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Gothic Lines"},{"type":"lora","weight":0.8,"modelVersionId":1470588,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Retro"},{"type":"lora","weight":0.75,"modelVersionId":746484,"modelName":"Elden Ring - Yoshitaka Amano","modelVersionName":"V1"},{"type":"lora","weight":0.2,"modelVersionId":914935,"modelName":"Ink-style","modelVersionName":"ink-dynamic"},{"type":"lora","weight":0.2,"modelVersionId":1189379,"modelName":"Painterly Fantasy by ChronoKnight - [FLUX \u0026 IL]","modelVersionName":"FLUX"},{"type":"lora","weight":0.2,"modelVersionId":757030,"modelName":"Mezzotint Artstyle for Flux - by Ethanar","modelVersionName":"V1"}], Civitai metadata: {}
|
||||
|
||||
masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject,
|
||||
dynamic angle, dutch angle, from below, epic half body portrait, gritty, wabi sabi, looking at viewer, woman is a geisha, parted lips,
|
||||
holographic skin, holofoil glitter, faint, glowing, ethereal, neon hair, glowing hair, otherworldly glow, she is dangerous
|
||||
<lora:ck-shadow-circuit-IL:0.78>, <lora:ck-nc-cyberpunk-IL-000011:0.4>, <lora:ck-neon-retrowave-IL:0.2>, <lora:ck-yoneyama-mai-IL-000014:0.4>
|
||||
Negative prompt: score_6, score_5, score_4, bad quality, worst quality, worst detail, sketch, censorship, furry, window, headphones,
|
||||
Steps: 30, Sampler: Euler a, Schedule type: Simple, CFG scale: 7, Seed: 1405717592, Size: 832x1216, Model hash: 1ad6ca7f70, Model: waiNSFWIllustrious_v100, Denoising strength: 0.35, Hires CFG Scale: 5, Hires upscale: 1.3, Hires steps: 20, Hires upscaler: 4x-AnimeSharp, Lora hashes: "ck-shadow-circuit-IL: 88e247aa8c3d, ck-nc-cyberpunk-IL-000011: 935e6755554c, ck-neon-retrowave-IL: edafb9df7da1, ck-yoneyama-mai-IL-000014: 1b9305692a2e", Version: f2.0.1v1.10.1-1.10.1, Diffusion in Low Bits: Automatic (fp16 LoRA)
|
||||
|
||||
Masterpiece, best quality, high quality, newest, highres, 8K, HDR, absurdres, 1girl, solo, futuristic warrior, sleek exosuit with glowing energy cores, long braided hair flowing behind, gripping a high-tech bow with an energy arrow drawn, standing on a floating platform overlooking a massive space station, planets and nebulae in the distance, soft glow from distant stars, cinematic depth, foreshortening, dynamic pose, dramatic sci-fi lighting.
|
||||
Negative prompt: worst quality, normal quality, anatomical nonsense, bad anatomy,interlocked fingers, extra fingers,watermark,simple background, loli,
|
||||
Steps: 20, Sampler: euler_ancestral_karras, CFG scale: 8.0, Seed: 691121152183439, Model: il\waiNSFWIllustrious_v110.safetensors, Model hash: c3688ee04c, Lora_0 Model name: iLLMythAn1m3Style.safetensors, Lora_0 Model hash: ba7a040786, Lora_0 Strength model: 1.0, Lora_0 Strength clip: 1.0, Hashes: {"model": "c3688ee04c", "lora:iLLMythAn1m3Style": "ba7a040786"}
|
||||
|
||||
Immerse yourself in the enchanting journey, where harmonious transmutation of Bauhaus art unites photographic precision and contemporary illustration, capturing an enthralling blend between vivid abstract nature and urban landscapes. Let your eyes be captivated by a kaleidoscope of rich, deep reds and yellows, entwined with intriguing shades that beckon a somber atmosphere. As your spirit ventures along this haunting path, witness the mysterious, high-angle perspective dominated by scattered clouds – granting you a mesmerizing glimpse into the ever-transforming realm of metamorphosing environments. ,<lora:flux/fav/ck-charcoal-drawing-000014.safetensors:1.0:1.0>
|
||||
Negative prompt:
|
||||
Steps: 20, Sampler: Euler, CFG scale: 3.5, Seed: 885491426361006, Size: 832x1216, Model hash: 4610115bb0, Model: flux_dev, Hashes: {"LORA:flux/fav/ck-charcoal-drawing-000014.safetensors": "34d36c17c1", "model": "4610115bb0"}, Version: ComfyUI
|
||||
@@ -1,3 +0,0 @@
|
||||
In this ethereal masterpiece, metallic sculptures juxtapose effortlessly against a subtle backdrop of misty neutral hues. Exquisite curvatures and geometric shapes converge harmoniously, creating an illuminating realm of polished metallic surfaces. Shimmering copper, gleaming silver, and lustrous gold hues dance in perfect balance, highlighting the intricate play of light and shadow cast upon these celestial forms. A halo of diffused radiance envelops each piece, enhancing their textured depths and metallic brilliance while allowing delicate details to emerge from obscurity. The composition conveys a serene yet mesmerizing atmosphere, as if suspended in a dreamlike limbo between reality and fantasy. The tantalizing interplay of colors within this transcendent realm creates a profound sense of depth and grandeur that invites the viewer into an enchanting voyage through abstract metallic beauty. This captivating artwork evokes emotions of boundless curiosity and reverence reminiscent of the timeless works by artists such as Giorgio de Chirico or Paul Klee, while asserting a unique, modern artistic sensibility. With every observation, a new nuance unfolds, as if a never-ending story waiting to be discovered through the lens of metallic artistry.
|
||||
Negative prompt:
|
||||
Steps: 25, Sampler: dpmpp_2m_sgm_uniform, Seed: 471889513588087, Model: Fluxmania V5P.safetensors, Model hash: 8ae0583b06, VAE: ae.sft, VAE hash: afc8e28272, Lora_0 Model name: ArtVador I.safetensors, Lora_0 Model hash: 08f7133a58, Lora_0 Strength model: 0.65, Lora_0 Strength clip: 0.65, Lora_1 Model name: Kaoru Yamada.safetensors, Lora_1 Model hash: d4893f7202, Lora_1 Strength model: 0.75, Lora_1 Strength clip: 0.75, Hashes: {"model": "8ae0583b06", "vae": "afc8e28272", "lora:ArtVador I": "08f7133a58", "lora:Kaoru Yamada": "d4893f7202"}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"id": "42803a29-02dc-49e1-b798-27da70e8b408",
|
||||
"file_path": "/home/miao/workspace/ComfyUI/models/loras/recipes/test/42803a29-02dc-49e1-b798-27da70e8b408.webp",
|
||||
"title": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect",
|
||||
"modified": 1754897325.0507245,
|
||||
"created_date": 1754897325.0507245,
|
||||
"base_model": "Illustrious",
|
||||
"loras": [
|
||||
{
|
||||
"file_name": "",
|
||||
"hash": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a",
|
||||
"strength": 1.0,
|
||||
"modelVersionId": 2007092,
|
||||
"modelName": "Pony: People's Works +",
|
||||
"modelVersionName": "v8_Illusv1.0",
|
||||
"isDeleted": false,
|
||||
"exclude": false
|
||||
}
|
||||
],
|
||||
"gen_params": {
|
||||
"prompt": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect eyes, realistic eyes,\n(flat colors:1.5), (anime:1.5), (lineart:1.5),\nclose-up, solo, tongue, 1girl, food, (saliva:0.1), open mouth, candy, simple background, blue background, large lollipop, tongue out, fade background, lips, hand up, holding, looking at viewer, licking, seductive, half-closed eyes,",
|
||||
"negative_prompt": "shiny skin,",
|
||||
"steps": 19,
|
||||
"sampler": "Euler a",
|
||||
"cfg_scale": 5,
|
||||
"seed": 1765271748,
|
||||
"size": "832x1216",
|
||||
"clip_skip": 2
|
||||
},
|
||||
"fingerprint": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a:1.0",
|
||||
"source_path": "https://civitai.com/images/92427432",
|
||||
"folder": "test"
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"id": 2269146,
|
||||
"modelId": 2004760,
|
||||
"name": "v1.0 Illustrious",
|
||||
"nsfwLevel": 1,
|
||||
"trainedWords": ["PencilSketchDaal"],
|
||||
"baseModel": "Illustrious",
|
||||
"description": "<p>Illustrious. Your pencil may vary with your checkpoint. </p>",
|
||||
"model": {
|
||||
"name": "Pencil Sketch Anime",
|
||||
"type": "LORA",
|
||||
"nsfw": false,
|
||||
"description": "description",
|
||||
"tags": ["style"],
|
||||
"allowNoCredit": true,
|
||||
"allowCommercialUse": ["Sell"],
|
||||
"allowDerivatives": true,
|
||||
"allowDifferentLicense": true
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"id": 2161260,
|
||||
"sizeKB": 223106.37890625,
|
||||
"name": "Pencil-Sketch-Illustrious.safetensors",
|
||||
"type": "Model",
|
||||
"hashes": {
|
||||
"SHA256": "2C70479CD673B0FE056EAF4FD97C7F33A39F14853805431AC9AB84226ECE3B82"
|
||||
},
|
||||
"primary": true,
|
||||
"downloadUrl": "https://civitai.com/api/download/models/2269146",
|
||||
"mirrors": {}
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{},
|
||||
{}
|
||||
],
|
||||
"creator": {
|
||||
"username": "Daalis",
|
||||
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/eb245b49-edc8-4ed6-ad7b-6d61eb8c51de/width=96/Daalis.jpeg"
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"id": 1255556,
|
||||
"modelId": 1117241,
|
||||
"name": "v1.0",
|
||||
"createdAt": "2025-01-08T06:13:08.839Z",
|
||||
"updatedAt": "2025-01-08T06:28:54.156Z",
|
||||
"status": "Published",
|
||||
"publishedAt": "2025-01-08T06:28:54.155Z",
|
||||
"trainedWords": ["in the style of ppWhimsy"],
|
||||
"trainingStatus": null,
|
||||
"trainingDetails": null,
|
||||
"baseModel": "Flux.1 D",
|
||||
"baseModelType": "Standard",
|
||||
"earlyAccessEndsAt": null,
|
||||
"earlyAccessConfig": null,
|
||||
"description": null,
|
||||
"uploadType": "Created",
|
||||
"usageControl": "Download",
|
||||
"air": "urn:air:flux1:lora:civitai:1117241@1255556",
|
||||
"stats": {
|
||||
"downloadCount": 210,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"thumbsUpCount": 26
|
||||
},
|
||||
"model": {
|
||||
"name": "Enchanted Whimsy style (Flux)",
|
||||
"type": "LORA",
|
||||
"nsfw": false,
|
||||
"poi": false
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"id": 1160774,
|
||||
"sizeKB": 38828.8125,
|
||||
"name": "pp-enchanted-whimsy.safetensors",
|
||||
"type": "Model",
|
||||
"pickleScanResult": "Success",
|
||||
"pickleScanMessage": "No Pickle imports",
|
||||
"virusScanResult": "Success",
|
||||
"virusScanMessage": null,
|
||||
"scannedAt": "2025-01-08T06:16:27.731Z",
|
||||
"metadata": {
|
||||
"format": "SafeTensor",
|
||||
"size": null,
|
||||
"fp": null
|
||||
},
|
||||
"hashes": {
|
||||
"AutoV1": "40CAF049",
|
||||
"AutoV2": "3202778C3E",
|
||||
"SHA256": "3202778C3EBE5CF7EBE5FC51561DEAE8611F4362036EB7C02EFA033C705E6240",
|
||||
"CRC32": "69DCD953",
|
||||
"BLAKE3": "ED04580DDB1AD36D8B87F4B0800F5930C7E5D4A7269BDC2BE26ED77EA1A34697",
|
||||
"AutoV3": "BF82986F8597"
|
||||
},
|
||||
"primary": true,
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1255556"
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/707aef9b-36fb-46c2-ac41-adcab539d3a6/width=832/50270101.jpeg",
|
||||
"nsfwLevel": 1,
|
||||
"width": 832,
|
||||
"height": 1216,
|
||||
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
|
||||
"type": "image",
|
||||
"metadata": {
|
||||
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
|
||||
"size": 702313,
|
||||
"width": 832,
|
||||
"height": 1216
|
||||
},
|
||||
"minor": false,
|
||||
"poi": false,
|
||||
"meta": {
|
||||
"prompt": "in the style of ppWhimsy, a close-up of a boy with a crown of ferns and tiny horns, his eyes wide with wonder as a family of glowing hedgehogs nestle in his hands, their spines shimmering with soft pastel colors"
|
||||
},
|
||||
"availability": "Public",
|
||||
"hasMeta": true,
|
||||
"hasPositivePrompt": true,
|
||||
"onSite": false,
|
||||
"remixOfId": null
|
||||
}
|
||||
],
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1255556",
|
||||
"creator": {
|
||||
"username": "PixelPawsAI",
|
||||
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
/* Style for selected cards */
|
||||
.model-card.selected {
|
||||
box-shadow: 0 0 0 2px var(--lora-accent);
|
||||
outline: 2px solid var(--lora-accent);
|
||||
outline-offset: -2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
@@ -278,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;
|
||||
@@ -294,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;
|
||||
@@ -509,6 +509,50 @@
|
||||
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
||||
}
|
||||
|
||||
/* Clickable version count link (shown in group-by-model mode) */
|
||||
.version-count-link {
|
||||
display: inline-block;
|
||||
color: var(--color-accent);
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
border: 1px solid var(--color-accent-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 1px 6px;
|
||||
background: var(--color-accent-subtle);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.version-count-link:hover {
|
||||
background: var(--color-accent-border);
|
||||
border-color: var(--color-accent-transparent);
|
||||
}
|
||||
|
||||
/* Medium density adjustments for version count link */
|
||||
.medium-density .version-count-link {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.medium-density .badge-version-unit .version-count-link {
|
||||
max-width: 90px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Compact density adjustments for version count link */
|
||||
.compact-density .version-count-link {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.compact-density .badge-version-unit .version-count-link {
|
||||
max-width: 70px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Version row — flex container for badges + version names */
|
||||
.version-row {
|
||||
display: flex;
|
||||
@@ -690,6 +734,21 @@ body.hide-card-version .hl-badge {
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid-scoped loading overlay (replaces full-page overlay for VirtualScroller refreshes) */
|
||||
.grid-loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--lora-bg-transparent, oklch(0% 0 0 / 0.3));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Add after the existing .model-card:hover styles */
|
||||
|
||||
@keyframes update-pulse {
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
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: var(--shadow-lg); /* Stronger shadow */
|
||||
@@ -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 {
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
}
|
||||
|
||||
.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: var(--shadow-sm);
|
||||
@@ -117,7 +117,7 @@
|
||||
/* 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;
|
||||
@@ -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 {
|
||||
@@ -180,7 +180,7 @@
|
||||
}
|
||||
|
||||
.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: var(--shadow-sm);
|
||||
@@ -235,7 +235,7 @@
|
||||
}
|
||||
|
||||
.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: var(--shadow-sm);
|
||||
}
|
||||
@@ -247,16 +247,16 @@
|
||||
}
|
||||
|
||||
.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));
|
||||
border: 2px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -437,7 +437,7 @@
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
|
||||
.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 */
|
||||
@@ -573,7 +573,7 @@
|
||||
/* In dark mode, add additional distinction */
|
||||
html[data-theme="dark"] .duplicates-banner {
|
||||
box-shadow: var(--shadow-dark-lg); /* 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 */
|
||||
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 {
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-left: 2.25rem !important;
|
||||
padding-right: 5rem !important;
|
||||
padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
@@ -190,6 +190,81 @@
|
||||
right: 2.25rem;
|
||||
}
|
||||
|
||||
/* Clear button: sit immediately left of the search-options toggle */
|
||||
.header-search .search-clear {
|
||||
position: absolute;
|
||||
right: 4.25rem; /* 2.25rem (options toggle) + 28px toggle width + 4px gap */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-xs, 4px);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: background-color var(--transition-base), color var(--transition-base);
|
||||
}
|
||||
|
||||
.header-search .search-clear.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header-search .search-clear:hover {
|
||||
background: color-mix(in oklch, var(--text-muted) 15%, transparent);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Keyboard shortcut cue: shown when search is empty, hidden when typing */
|
||||
.header-search .search-shortcut-cue {
|
||||
position: absolute;
|
||||
right: 4.25rem; /* same slot as clear button */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
pointer-events: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.header-search .search-shortcut-cue kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
/* Subtle tint derived from text color so it adapts to both light & dark themes */
|
||||
background: color-mix(in oklch, var(--text-muted) 12%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--text-muted) 25%, transparent);
|
||||
border-radius: var(--border-radius-xs, 3px);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-search .search-shortcut-cue.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-search.disabled .search-shortcut-cue {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-search .search-options-toggle:hover,
|
||||
.header-search .search-filter-toggle:hover,
|
||||
.header-search .search-filter-toggle:focus-visible {
|
||||
@@ -283,7 +358,6 @@
|
||||
|
||||
.theme-toggle {
|
||||
position: relative;
|
||||
/* Ensure relative positioning for the container */
|
||||
}
|
||||
|
||||
.theme-toggle .light-icon,
|
||||
@@ -293,17 +367,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;
|
||||
}
|
||||
@@ -313,7 +384,6 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Dark theme shows dark icon */
|
||||
.theme-toggle.theme-dark .dark-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -323,7 +393,6 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Auto theme shows auto icon */
|
||||
.theme-toggle.theme-auto .auto-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -333,6 +402,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: var(--transition-base);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.keyboard-nav-hint:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.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: var(--shadow-lg);
|
||||
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: var(--shadow-xs);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -405,16 +444,161 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.base-model-selector {
|
||||
width: 100%;
|
||||
padding: 3px 5px;
|
||||
/* ── Base Model Search Dropdown ─────────────────────────────────────────── */
|
||||
|
||||
.base-model-search-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.base-model-search-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--lora-accent);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 0 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.base-model-search-input-wrapper .search-icon {
|
||||
color: var(--text-color);
|
||||
opacity: 0.45;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
/* Reset global .search-icon rules from search-filter.css */
|
||||
position: static;
|
||||
right: auto;
|
||||
top: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.base-model-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
outline: none;
|
||||
margin-right: var(--space-1);
|
||||
padding: 3px 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.base-model-search-input::placeholder {
|
||||
color: var(--text-color);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.base-model-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
max-height: 270px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--border-radius-xs) var(--border-radius-xs);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22);
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .base-model-dropdown {
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Dropdown scrollbar styling */
|
||||
.base-model-dropdown::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.base-model-dropdown::-webkit-scrollbar-thumb {
|
||||
background: var(--lora-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.base-model-dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.base-model-dropdown-section {
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.base-model-dropdown-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Section header */
|
||||
.base-model-dropdown-header {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
background: var(--surface-subtle);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.base-model-dropdown-header.suggested-header {
|
||||
color: var(--lora-accent);
|
||||
opacity: 1;
|
||||
background: oklch(from var(--lora-accent) l c h / 0.08);
|
||||
}
|
||||
|
||||
.base-model-dropdown-header.suggested-header i {
|
||||
margin-right: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Dropdown items */
|
||||
.base-model-dropdown-item {
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
transition: background 0.1s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.base-model-dropdown-item:hover {
|
||||
background: oklch(from var(--lora-accent) l c h / 0.1);
|
||||
}
|
||||
|
||||
.base-model-dropdown-item.active {
|
||||
background: oklch(from var(--lora-accent) l c h / 0.16);
|
||||
}
|
||||
|
||||
.base-model-dropdown-item.selected {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.base-model-dropdown-item.selected::after {
|
||||
content: '✓';
|
||||
float: right;
|
||||
color: var(--lora-accent);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.base-model-dropdown-empty {
|
||||
padding: 18px 12px;
|
||||
text-align: center;
|
||||
color: var(--text-color);
|
||||
opacity: 0.4;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.size-wrapper {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user