mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-14 09:43:22 -03:00
Compare commits
100 Commits
v1.1.6
...
c27e4d1bfc
| Author | SHA1 | Date | |
|---|---|---|---|
| c27e4d1bfc | |||
| d15a8aa9a2 | |||
| 74a7d12ca4 | |||
| 2f94a9773e | |||
| 37bdfa21ea | |||
| f0bf2728c9 | |||
| dc715aa273 | |||
| 7ee2361e87 | |||
| e04c22f83f | |||
| 681cc13e90 | |||
| 090e0297d4 | |||
| 6f71335be4 | |||
| 7f51812c1e | |||
| a9dc4d7b9d | |||
| 5d50ddb5d4 | |||
| f86198d234 | |||
| ffe65d983c | |||
| b0b5be913c | |||
| 01efcbc584 | |||
| 02c249917a | |||
| 419bbc90b2 | |||
| b0c4510fdb | |||
| 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 |
@@ -7,6 +7,10 @@ py/run_test.py
|
|||||||
.vscode/
|
.vscode/
|
||||||
cache/
|
cache/
|
||||||
civitai/
|
civitai/
|
||||||
|
stats/
|
||||||
|
wildcards/
|
||||||
|
backups/
|
||||||
|
logs/
|
||||||
node_modules/
|
node_modules/
|
||||||
coverage/
|
coverage/
|
||||||
.coverage
|
.coverage
|
||||||
@@ -32,3 +36,7 @@ vue-widgets/dist/
|
|||||||
|
|
||||||
# Working/research notes (not committed)
|
# Working/research notes (not committed)
|
||||||
.docs/
|
.docs/
|
||||||
|
|
||||||
|
# HF enrichment validation baseline snapshots (contain potentially
|
||||||
|
# NSFW README content fetched from community model repos)
|
||||||
|
tests/enrich_hf_validation/baselines/
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ npm run test:coverage # Generate coverage report
|
|||||||
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
|
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
|
||||||
- Event handlers via `addEventListener` or widget callbacks
|
- Event handlers via `addEventListener` or widget callbacks
|
||||||
- Shared utilities: `web/comfyui/utils.js`
|
- Shared utilities: `web/comfyui/utils.js`
|
||||||
|
- Dual-mode rendering patterns (canvas vs Vue): see `docs/comfyui-dual-mode-widgets.md`
|
||||||
|
|
||||||
### Vue Composables Pattern
|
### Vue Composables Pattern
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ try: # pragma: no cover - import fallback for pytest collection
|
|||||||
from .py.nodes.lora_pool import LoraPoolLM
|
from .py.nodes.lora_pool import LoraPoolLM
|
||||||
from .py.nodes.lora_randomizer import LoraRandomizerLM
|
from .py.nodes.lora_randomizer import LoraRandomizerLM
|
||||||
from .py.nodes.lora_cycler import LoraCyclerLM
|
from .py.nodes.lora_cycler import LoraCyclerLM
|
||||||
|
from .py.nodes.lora_info import LoraInfoLM
|
||||||
|
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
|
||||||
from .py.metadata_collector import init as init_metadata_collector
|
from .py.metadata_collector import init as init_metadata_collector
|
||||||
except (
|
except (
|
||||||
ImportError
|
ImportError
|
||||||
@@ -56,6 +58,10 @@ except (
|
|||||||
"py.nodes.lora_randomizer"
|
"py.nodes.lora_randomizer"
|
||||||
).LoraRandomizerLM
|
).LoraRandomizerLM
|
||||||
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
|
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
|
||||||
|
LoraInfoLM = importlib.import_module("py.nodes.lora_info").LoraInfoLM
|
||||||
|
LoraSyntaxToPath = importlib.import_module(
|
||||||
|
"py.nodes.lora_syntax_to_path"
|
||||||
|
).LoraSyntaxToPath
|
||||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
@@ -75,6 +81,8 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
LoraPoolLM.NAME: LoraPoolLM,
|
LoraPoolLM.NAME: LoraPoolLM,
|
||||||
LoraRandomizerLM.NAME: LoraRandomizerLM,
|
LoraRandomizerLM.NAME: LoraRandomizerLM,
|
||||||
LoraCyclerLM.NAME: LoraCyclerLM,
|
LoraCyclerLM.NAME: LoraCyclerLM,
|
||||||
|
LoraInfoLM.NAME: LoraInfoLM,
|
||||||
|
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web/comfyui"
|
WEB_DIRECTORY = "./web/comfyui"
|
||||||
|
|||||||
+301
-285
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` |
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# ComfyUI Dual-Mode Widget Rendering
|
||||||
|
|
||||||
|
ComfyUI custom node widgets render in one of two modes. Patterns that work in one often fail silently in the other. Test both.
|
||||||
|
|
||||||
|
## Mode Detection
|
||||||
|
|
||||||
|
```js
|
||||||
|
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
|
||||||
|
```
|
||||||
|
|
||||||
|
In Vue SFCs, `window.LiteGraph` is unavailable — pass as a prop from `main.ts`.
|
||||||
|
|
||||||
|
## Canvas Mode Layout
|
||||||
|
|
||||||
|
Uses `computeLayoutSize()` + `distributeSpace()` to allocate widget height within the node. Widgets with `computeLayoutSize` participate in space distribution; those with `computeSize` have fixed height.
|
||||||
|
|
||||||
|
- `getMinHeight()` in `addDOMWidget` options → minimum widget height
|
||||||
|
- `widget.computeLayoutSize()` → `{ minHeight, minWidth, maxHeight? }`
|
||||||
|
- Avoid `getMaxHeight()` unless the widget genuinely needs a fixed cap (prevents user resize)
|
||||||
|
|
||||||
|
## Vue Mode Layout
|
||||||
|
|
||||||
|
Uses CSS Grid (`grid-template-rows`) + `ResizeObserver`. The ResizeObserver watches the widget's DOM and feeds back into grid row sizing. This creates a feedback loop: content grows → row resizes → more space for content → content reflows/grows → row resizes again.
|
||||||
|
|
||||||
|
### Height Containment
|
||||||
|
|
||||||
|
The fix: `contain: layout size` on the widget root. This tells the browser the element's intrinsic size is CSS-determined, not driven by descendant content. The ResizeObserver sees a stable size and the loop is broken.
|
||||||
|
|
||||||
|
```css
|
||||||
|
.widget-root.lm-vue-node {
|
||||||
|
height: 100%;
|
||||||
|
min-height: var(--comfy-widget-min-height, 200px);
|
||||||
|
contain: layout size;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing examples: `.lm-loras-container.lm-vue-node` and `.comfy-tags-container.lm-vue-node` in `web/comfyui/lm_styles.css`.
|
||||||
|
|
||||||
|
**Do NOT** fix height issues with `maxHeight`, `getMaxHeight()`, or inline `max-height` — these prevent the user from resizing the node.
|
||||||
|
|
||||||
|
## Scroll Wheel Isolation
|
||||||
|
|
||||||
|
Both modes need to distinguish "user wants to scroll widget content" from "user wants to zoom canvas".
|
||||||
|
|
||||||
|
**Canvas mode:** Add `@wheel` on widget root. Check `event.target.closest(selector)` for scrollable sub-areas. If scrollable → `event.stopPropagation()`. Otherwise → `app.canvas.processMouseWheel(event)`.
|
||||||
|
|
||||||
|
**Vue mode:** Add CSS class `lm-wheel-scrollable` to scrollable elements. The global capture-phase hook in `web/comfyui/utils.js` (`enableListWheelScroll`) detects wheel events on marked elements and manually scrolls them via `element.scrollTop`, consuming the event before canvas zoom sees it.
|
||||||
|
|
||||||
|
## DOM Structure
|
||||||
|
|
||||||
|
`main.ts` creates an outer `<div>` container, then `vueApp.mount(container)`. The Vue app renders its own root element inside.
|
||||||
|
|
||||||
|
- `container.id` / `container.style.*` → outer element
|
||||||
|
- Vue scoped `<style>` → `[data-v-hash]` applies only to Vue root
|
||||||
|
|
||||||
|
Classes needed by scoped Vue CSS must go on the Vue root element. Pass data as props and bind with `:class` rather than manipulating the DOM from `main.ts`.
|
||||||
|
|
||||||
|
## Serialization
|
||||||
|
|
||||||
|
For stateful widgets that need workflow persistence:
|
||||||
|
|
||||||
|
- `serialize: true` in `addDOMWidget` options
|
||||||
|
- `serializeValue()` → state snapshot (called on workflow save)
|
||||||
|
- `onSetValue(v)` → restore state (called on workflow load)
|
||||||
|
- Always handle missing keys in restored value for backward compatibility with old workflows
|
||||||
+71
-9
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "Aus Favoriten entfernen",
|
"removeFromFavorites": "Aus Favoriten entfernen",
|
||||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||||
"notAvailableFromCivitai": "Nicht auf Civitai verfügbar",
|
"notAvailableFromCivitai": "Nicht auf Civitai verfügbar",
|
||||||
|
"viewOnHuggingFace": "Auf Hugging Face ansehen",
|
||||||
"sendToWorkflow": "An ComfyUI senden (Klick: Anhängen, Shift+Klick: Ersetzen)",
|
"sendToWorkflow": "An ComfyUI senden (Klick: Anhängen, Shift+Klick: Ersetzen)",
|
||||||
"copyLoRASyntax": "LoRA-Syntax kopieren",
|
"copyLoRASyntax": "LoRA-Syntax kopieren",
|
||||||
"checkpointNameCopied": "Checkpoint-Name kopiert",
|
"checkpointNameCopied": "Checkpoint-Name kopiert",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
|
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
|
||||||
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
|
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
|
||||||
"validation": {
|
"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": {
|
"priorityTags": {
|
||||||
@@ -656,6 +659,32 @@
|
|||||||
"proxyPassword": "Passwort (optional)",
|
"proxyPassword": "Passwort (optional)",
|
||||||
"proxyPasswordPlaceholder": "passwort",
|
"proxyPasswordPlaceholder": "passwort",
|
||||||
"proxyPasswordHelp": "Passwort für die Proxy-Authentifizierung (falls erforderlich)"
|
"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": {
|
"loras": {
|
||||||
@@ -753,12 +782,15 @@
|
|||||||
"completed": "Abgeschlossen: {success} verschoben, {skipped} übersprungen, {failures} fehlgeschlagen",
|
"completed": "Abgeschlossen: {success} verschoben, {skipped} übersprungen, {failures} fehlgeschlagen",
|
||||||
"complete": "Automatische Organisation abgeschlossen",
|
"complete": "Automatische Organisation abgeschlossen",
|
||||||
"error": "Fehler: {error}"
|
"error": "Fehler: {error}"
|
||||||
}
|
},
|
||||||
|
"enrichHfAgent": "HF-Metadaten mit KI anreichern"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Civitai-Daten aktualisieren",
|
"refreshMetadata": "Civitai-Daten aktualisieren",
|
||||||
"checkUpdates": "Updates prüfen",
|
"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",
|
"copySyntax": "LoRA-Syntax kopieren",
|
||||||
"copyFilename": "Modell-Dateiname kopieren",
|
"copyFilename": "Modell-Dateiname kopieren",
|
||||||
"copyRecipeSyntax": "Rezept-Syntax kopieren",
|
"copyRecipeSyntax": "Rezept-Syntax kopieren",
|
||||||
@@ -777,7 +809,8 @@
|
|||||||
"shareRecipe": "Rezept teilen",
|
"shareRecipe": "Rezept teilen",
|
||||||
"viewAllLoras": "Alle LoRAs anzeigen",
|
"viewAllLoras": "Alle LoRAs anzeigen",
|
||||||
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||||
"deleteRecipe": "Rezept löschen"
|
"deleteRecipe": "Rezept löschen",
|
||||||
|
"enrichHfAgent": "HF-Metadaten mit KI anreichern"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -1134,7 +1167,10 @@
|
|||||||
"titleWithType": "{type} von URL herunterladen",
|
"titleWithType": "{type} von URL herunterladen",
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"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",
|
"locationPreview": "Download-Speicherort Vorschau",
|
||||||
"useDefaultPath": "Standardpfad verwenden",
|
"useDefaultPath": "Standardpfad verwenden",
|
||||||
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
|
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
|
||||||
@@ -1163,13 +1199,17 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Ungültiges Civitai URL-Format",
|
"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": {
|
"status": {
|
||||||
"preparing": "Download wird vorbereitet...",
|
"preparing": "Download wird vorbereitet...",
|
||||||
"downloadedPreview": "Vorschaubild heruntergeladen",
|
"downloadedPreview": "Vorschaubild heruntergeladen",
|
||||||
"downloadingFile": "{type}-Datei wird heruntergeladen",
|
"downloadingFile": "{type}-Datei wird heruntergeladen",
|
||||||
"finalizing": "Download wird abgeschlossen..."
|
"finalizing": "Download wird abgeschlossen...",
|
||||||
|
"cancelling": "Download wird abgebrochen...",
|
||||||
|
"cancelled": "Download abgebrochen"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Aktuelle Datei:",
|
"currentFile": "Aktuelle Datei:",
|
||||||
@@ -1285,6 +1325,14 @@
|
|||||||
"pathPlaceholder": "Ordnerpfad eingeben oder aus Baum unten auswählen...",
|
"pathPlaceholder": "Ordnerpfad eingeben oder aus Baum unten auswählen...",
|
||||||
"root": "Stammverzeichnis"
|
"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": {
|
"relinkCivitai": {
|
||||||
"title": "Mit Civitai neu verknüpfen",
|
"title": "Mit Civitai neu verknüpfen",
|
||||||
"warning": "Warnung:",
|
"warning": "Warnung:",
|
||||||
@@ -1314,6 +1362,8 @@
|
|||||||
"editVersionName": "Versionsname bearbeiten",
|
"editVersionName": "Versionsname bearbeiten",
|
||||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||||
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
||||||
|
"viewOnHuggingFace": "Auf Hugging Face ansehen",
|
||||||
|
"viewOnHuggingFaceText": "Auf Hugging Face ansehen",
|
||||||
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||||
"openFileLocation": "Dateispeicherort öffnen",
|
"openFileLocation": "Dateispeicherort öffnen",
|
||||||
"sendToWorkflow": "An ComfyUI senden",
|
"sendToWorkflow": "An ComfyUI senden",
|
||||||
@@ -1339,7 +1389,10 @@
|
|||||||
"additionalNotes": "Zusätzliche Notizen",
|
"additionalNotes": "Zusätzliche Notizen",
|
||||||
"notesHint": "Enter zum Speichern, Shift+Enter für neue Zeile",
|
"notesHint": "Enter zum Speichern, Shift+Enter für neue Zeile",
|
||||||
"addNotesPlaceholder": "Fügen Sie hier Ihre Notizen hinzu...",
|
"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": {
|
"notes": {
|
||||||
"saved": "Notizen erfolgreich gespeichert",
|
"saved": "Notizen erfolgreich gespeichert",
|
||||||
@@ -1964,7 +2017,8 @@
|
|||||||
"imagesCompleted": "Beispielbilder {action} abgeschlossen",
|
"imagesCompleted": "Beispielbilder {action} abgeschlossen",
|
||||||
"imagesFailed": "Beispielbilder {action} fehlgeschlagen",
|
"imagesFailed": "Beispielbilder {action} fehlgeschlagen",
|
||||||
"loadError": "Fehler beim Laden der Downloads: {message}",
|
"loadError": "Fehler beim Laden der Downloads: {message}",
|
||||||
"downloadError": "Download-Fehler: {message}"
|
"downloadError": "Download-Fehler: {message}",
|
||||||
|
"downloadStopped": "Download abgebrochen"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
|
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
|
||||||
@@ -2009,6 +2063,8 @@
|
|||||||
"contentRatingFailed": "Fehler beim Setzen der Inhaltsbewertung: {message}",
|
"contentRatingFailed": "Fehler beim Setzen der Inhaltsbewertung: {message}",
|
||||||
"relinkSuccess": "Modell erfolgreich mit Civitai neu verknüpft",
|
"relinkSuccess": "Modell erfolgreich mit Civitai neu verknüpft",
|
||||||
"relinkFailed": "Fehler: {message}",
|
"relinkFailed": "Fehler: {message}",
|
||||||
|
"linkHfSuccess": "Modell erfolgreich mit HuggingFace verknüpft",
|
||||||
|
"linkHfFailed": "Fehler: {message}",
|
||||||
"fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab",
|
"fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab",
|
||||||
"noCivitaiInfo": "Keine CivitAI-Informationen verfügbar",
|
"noCivitaiInfo": "Keine CivitAI-Informationen verfügbar",
|
||||||
"missingHash": "Modell-Hash nicht verfügbar"
|
"missingHash": "Modell-Hash nicht verfügbar"
|
||||||
@@ -2070,6 +2126,12 @@
|
|||||||
"moveFailed": "Failed to move item: {message}",
|
"moveFailed": "Failed to move item: {message}",
|
||||||
"copiedToClipboard": "In die Zwischenablage kopiert",
|
"copiedToClipboard": "In die Zwischenablage kopiert",
|
||||||
"downloadStarted": "Download gestartet"
|
"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": {
|
"doctor": {
|
||||||
|
|||||||
+2195
-2133
File diff suppressed because it is too large
Load Diff
+71
-9
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "Eliminar de favoritos",
|
"removeFromFavorites": "Eliminar de favoritos",
|
||||||
"viewOnCivitai": "Ver en Civitai",
|
"viewOnCivitai": "Ver en Civitai",
|
||||||
"notAvailableFromCivitai": "No disponible en Civitai",
|
"notAvailableFromCivitai": "No disponible en Civitai",
|
||||||
|
"viewOnHuggingFace": "Ver en Hugging Face",
|
||||||
"sendToWorkflow": "Enviar a ComfyUI (Clic: Añadir, Shift+Clic: Reemplazar)",
|
"sendToWorkflow": "Enviar a ComfyUI (Clic: Añadir, Shift+Clic: Reemplazar)",
|
||||||
"copyLoRASyntax": "Copiar sintaxis de LoRA",
|
"copyLoRASyntax": "Copiar sintaxis de LoRA",
|
||||||
"checkpointNameCopied": "Nombre del checkpoint copiado",
|
"checkpointNameCopied": "Nombre del checkpoint copiado",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.",
|
"saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.",
|
||||||
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
|
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
|
||||||
"validation": {
|
"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": {
|
"priorityTags": {
|
||||||
@@ -656,6 +659,32 @@
|
|||||||
"proxyPassword": "Contraseña (opcional)",
|
"proxyPassword": "Contraseña (opcional)",
|
||||||
"proxyPasswordPlaceholder": "contraseña",
|
"proxyPasswordPlaceholder": "contraseña",
|
||||||
"proxyPasswordHelp": "Contraseña para autenticación de proxy (si es necesario)"
|
"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": {
|
"loras": {
|
||||||
@@ -753,12 +782,15 @@
|
|||||||
"completed": "Completado: {success} movidos, {skipped} omitidos, {failures} fallidos",
|
"completed": "Completado: {success} movidos, {skipped} omitidos, {failures} fallidos",
|
||||||
"complete": "Auto-organización completada",
|
"complete": "Auto-organización completada",
|
||||||
"error": "Error: {error}"
|
"error": "Error: {error}"
|
||||||
}
|
},
|
||||||
|
"enrichHfAgent": "Enriquecer metadatos HF (IA)"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Actualizar datos de Civitai",
|
"refreshMetadata": "Actualizar datos de Civitai",
|
||||||
"checkUpdates": "Comprobar actualizaciones",
|
"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",
|
"copySyntax": "Copiar sintaxis de LoRA",
|
||||||
"copyFilename": "Copiar nombre de archivo del modelo",
|
"copyFilename": "Copiar nombre de archivo del modelo",
|
||||||
"copyRecipeSyntax": "Copiar sintaxis de receta",
|
"copyRecipeSyntax": "Copiar sintaxis de receta",
|
||||||
@@ -777,7 +809,8 @@
|
|||||||
"shareRecipe": "Compartir receta",
|
"shareRecipe": "Compartir receta",
|
||||||
"viewAllLoras": "Ver todos los LoRAs",
|
"viewAllLoras": "Ver todos los LoRAs",
|
||||||
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||||
"deleteRecipe": "Eliminar receta"
|
"deleteRecipe": "Eliminar receta",
|
||||||
|
"enrichHfAgent": "Enriquecer metadatos HF (IA)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -1134,7 +1167,10 @@
|
|||||||
"titleWithType": "Descargar {type} desde URL",
|
"titleWithType": "Descargar {type} desde URL",
|
||||||
"civitaiUrl": "URL de Civitai:",
|
"civitaiUrl": "URL de Civitai:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"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",
|
"locationPreview": "Vista previa de ubicación de descarga",
|
||||||
"useDefaultPath": "Usar ruta predeterminada",
|
"useDefaultPath": "Usar ruta predeterminada",
|
||||||
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
|
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
|
||||||
@@ -1163,13 +1199,17 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Formato de URL de Civitai inválido",
|
"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": {
|
"status": {
|
||||||
"preparing": "Preparando descarga...",
|
"preparing": "Preparando descarga...",
|
||||||
"downloadedPreview": "Imagen de vista previa descargada",
|
"downloadedPreview": "Imagen de vista previa descargada",
|
||||||
"downloadingFile": "Descargando archivo de {type}",
|
"downloadingFile": "Descargando archivo de {type}",
|
||||||
"finalizing": "Finalizando descarga..."
|
"finalizing": "Finalizando descarga...",
|
||||||
|
"cancelling": "Cancelando descarga...",
|
||||||
|
"cancelled": "Descarga cancelada"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Archivo actual:",
|
"currentFile": "Archivo actual:",
|
||||||
@@ -1285,6 +1325,14 @@
|
|||||||
"pathPlaceholder": "Escribe la ruta de la carpeta o selecciona del árbol de abajo...",
|
"pathPlaceholder": "Escribe la ruta de la carpeta o selecciona del árbol de abajo...",
|
||||||
"root": "Raíz"
|
"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": {
|
"relinkCivitai": {
|
||||||
"title": "Re-vincular a Civitai",
|
"title": "Re-vincular a Civitai",
|
||||||
"warning": "Advertencia:",
|
"warning": "Advertencia:",
|
||||||
@@ -1314,6 +1362,8 @@
|
|||||||
"editVersionName": "Editar nombre de versión",
|
"editVersionName": "Editar nombre de versión",
|
||||||
"viewOnCivitai": "Ver en Civitai",
|
"viewOnCivitai": "Ver en Civitai",
|
||||||
"viewOnCivitaiText": "Ver en Civitai",
|
"viewOnCivitaiText": "Ver en Civitai",
|
||||||
|
"viewOnHuggingFace": "Ver en Hugging Face",
|
||||||
|
"viewOnHuggingFaceText": "Ver en Hugging Face",
|
||||||
"viewCreatorProfile": "Ver perfil del creador",
|
"viewCreatorProfile": "Ver perfil del creador",
|
||||||
"openFileLocation": "Abrir ubicación del archivo",
|
"openFileLocation": "Abrir ubicación del archivo",
|
||||||
"sendToWorkflow": "Enviar a ComfyUI",
|
"sendToWorkflow": "Enviar a ComfyUI",
|
||||||
@@ -1339,7 +1389,10 @@
|
|||||||
"additionalNotes": "Notas adicionales",
|
"additionalNotes": "Notas adicionales",
|
||||||
"notesHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
|
"notesHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
|
||||||
"addNotesPlaceholder": "Añade tus notas aquí...",
|
"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": {
|
"notes": {
|
||||||
"saved": "Notas guardadas exitosamente",
|
"saved": "Notas guardadas exitosamente",
|
||||||
@@ -1964,7 +2017,8 @@
|
|||||||
"imagesCompleted": "Imágenes de ejemplo {action} completadas",
|
"imagesCompleted": "Imágenes de ejemplo {action} completadas",
|
||||||
"imagesFailed": "Imágenes de ejemplo {action} fallidas",
|
"imagesFailed": "Imágenes de ejemplo {action} fallidas",
|
||||||
"loadError": "Error al cargar descargas: {message}",
|
"loadError": "Error al cargar descargas: {message}",
|
||||||
"downloadError": "Error de descarga: {message}"
|
"downloadError": "Error de descarga: {message}",
|
||||||
|
"downloadStopped": "Descarga cancelada"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "Error al cargar árbol de carpetas",
|
"folderTreeFailed": "Error al cargar árbol de carpetas",
|
||||||
@@ -2009,6 +2063,8 @@
|
|||||||
"contentRatingFailed": "Error al establecer clasificación de contenido: {message}",
|
"contentRatingFailed": "Error al establecer clasificación de contenido: {message}",
|
||||||
"relinkSuccess": "Modelo re-vinculado exitosamente a Civitai",
|
"relinkSuccess": "Modelo re-vinculado exitosamente a Civitai",
|
||||||
"relinkFailed": "Error: {message}",
|
"relinkFailed": "Error: {message}",
|
||||||
|
"linkHfSuccess": "Modelo vinculado a HuggingFace exitosamente",
|
||||||
|
"linkHfFailed": "Error: {message}",
|
||||||
"fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero",
|
"fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero",
|
||||||
"noCivitaiInfo": "No hay información de CivitAI disponible",
|
"noCivitaiInfo": "No hay información de CivitAI disponible",
|
||||||
"missingHash": "Hash del modelo no disponible"
|
"missingHash": "Hash del modelo no disponible"
|
||||||
@@ -2070,6 +2126,12 @@
|
|||||||
"moveFailed": "Failed to move item: {message}",
|
"moveFailed": "Failed to move item: {message}",
|
||||||
"copiedToClipboard": "Copiado al portapapeles",
|
"copiedToClipboard": "Copiado al portapapeles",
|
||||||
"downloadStarted": "Descarga iniciada"
|
"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": {
|
"doctor": {
|
||||||
|
|||||||
+71
-9
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "Retirer des favoris",
|
"removeFromFavorites": "Retirer des favoris",
|
||||||
"viewOnCivitai": "Voir sur Civitai",
|
"viewOnCivitai": "Voir sur Civitai",
|
||||||
"notAvailableFromCivitai": "Non disponible sur Civitai",
|
"notAvailableFromCivitai": "Non disponible sur Civitai",
|
||||||
|
"viewOnHuggingFace": "Voir sur Hugging Face",
|
||||||
"sendToWorkflow": "Envoyer vers ComfyUI (Clic: Ajouter, Maj+Clic: Remplacer)",
|
"sendToWorkflow": "Envoyer vers ComfyUI (Clic: Ajouter, Maj+Clic: Remplacer)",
|
||||||
"copyLoRASyntax": "Copier la syntaxe LoRA",
|
"copyLoRASyntax": "Copier la syntaxe LoRA",
|
||||||
"checkpointNameCopied": "Nom du checkpoint copié",
|
"checkpointNameCopied": "Nom du checkpoint copié",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour. Redémarrage requis pour appliquer les changements.",
|
"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}",
|
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
|
||||||
"validation": {
|
"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": {
|
"priorityTags": {
|
||||||
@@ -656,6 +659,32 @@
|
|||||||
"proxyPassword": "Mot de passe (optionnel)",
|
"proxyPassword": "Mot de passe (optionnel)",
|
||||||
"proxyPasswordPlaceholder": "mot_de_passe",
|
"proxyPasswordPlaceholder": "mot_de_passe",
|
||||||
"proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)"
|
"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": {
|
"loras": {
|
||||||
@@ -753,12 +782,15 @@
|
|||||||
"completed": "Terminé : {success} déplacés, {skipped} ignorés, {failures} échecs",
|
"completed": "Terminé : {success} déplacés, {skipped} ignorés, {failures} échecs",
|
||||||
"complete": "Auto-organisation terminée",
|
"complete": "Auto-organisation terminée",
|
||||||
"error": "Erreur : {error}"
|
"error": "Erreur : {error}"
|
||||||
}
|
},
|
||||||
|
"enrichHfAgent": "Enrichir les métadonnées HF (IA)"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Actualiser les données Civitai",
|
"refreshMetadata": "Actualiser les données Civitai",
|
||||||
"checkUpdates": "Vérifier les mises à jour",
|
"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",
|
"copySyntax": "Copier la syntaxe LoRA",
|
||||||
"copyFilename": "Copier le nom de fichier du modèle",
|
"copyFilename": "Copier le nom de fichier du modèle",
|
||||||
"copyRecipeSyntax": "Copier la syntaxe de la recipe",
|
"copyRecipeSyntax": "Copier la syntaxe de la recipe",
|
||||||
@@ -777,7 +809,8 @@
|
|||||||
"shareRecipe": "Partager la recipe",
|
"shareRecipe": "Partager la recipe",
|
||||||
"viewAllLoras": "Voir tous les LoRAs",
|
"viewAllLoras": "Voir tous les LoRAs",
|
||||||
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||||
"deleteRecipe": "Supprimer la recipe"
|
"deleteRecipe": "Supprimer la recipe",
|
||||||
|
"enrichHfAgent": "Enrichir les métadonnées HF (IA)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -1134,7 +1167,10 @@
|
|||||||
"titleWithType": "Télécharger {type} depuis une URL",
|
"titleWithType": "Télécharger {type} depuis une URL",
|
||||||
"civitaiUrl": "URL Civitai :",
|
"civitaiUrl": "URL Civitai :",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"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",
|
"locationPreview": "Aperçu de l'emplacement de téléchargement",
|
||||||
"useDefaultPath": "Utiliser le chemin par défaut",
|
"useDefaultPath": "Utiliser le chemin par défaut",
|
||||||
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
|
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
|
||||||
@@ -1163,13 +1199,17 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Format d'URL Civitai invalide",
|
"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": {
|
"status": {
|
||||||
"preparing": "Préparation du téléchargement...",
|
"preparing": "Préparation du téléchargement...",
|
||||||
"downloadedPreview": "Image d'aperçu téléchargée",
|
"downloadedPreview": "Image d'aperçu téléchargée",
|
||||||
"downloadingFile": "Téléchargement du fichier {type}",
|
"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": {
|
"progress": {
|
||||||
"currentFile": "Fichier actuel :",
|
"currentFile": "Fichier actuel :",
|
||||||
@@ -1285,6 +1325,14 @@
|
|||||||
"pathPlaceholder": "Tapez le chemin du dossier ou sélectionnez dans l'arbre ci-dessous...",
|
"pathPlaceholder": "Tapez le chemin du dossier ou sélectionnez dans l'arbre ci-dessous...",
|
||||||
"root": "Racine"
|
"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": {
|
"relinkCivitai": {
|
||||||
"title": "Relier à nouveau à Civitai",
|
"title": "Relier à nouveau à Civitai",
|
||||||
"warning": "Attention :",
|
"warning": "Attention :",
|
||||||
@@ -1314,6 +1362,8 @@
|
|||||||
"editVersionName": "Modifier le nom de la version",
|
"editVersionName": "Modifier le nom de la version",
|
||||||
"viewOnCivitai": "Voir sur Civitai",
|
"viewOnCivitai": "Voir sur Civitai",
|
||||||
"viewOnCivitaiText": "Voir sur Civitai",
|
"viewOnCivitaiText": "Voir sur Civitai",
|
||||||
|
"viewOnHuggingFace": "Voir sur Hugging Face",
|
||||||
|
"viewOnHuggingFaceText": "Voir sur Hugging Face",
|
||||||
"viewCreatorProfile": "Voir le profil du créateur",
|
"viewCreatorProfile": "Voir le profil du créateur",
|
||||||
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
||||||
"sendToWorkflow": "Envoyer vers ComfyUI",
|
"sendToWorkflow": "Envoyer vers ComfyUI",
|
||||||
@@ -1339,7 +1389,10 @@
|
|||||||
"additionalNotes": "Notes supplémentaires",
|
"additionalNotes": "Notes supplémentaires",
|
||||||
"notesHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
|
"notesHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
|
||||||
"addNotesPlaceholder": "Ajoutez vos notes ici...",
|
"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": {
|
"notes": {
|
||||||
"saved": "Notes sauvegardées avec succès",
|
"saved": "Notes sauvegardées avec succès",
|
||||||
@@ -1964,7 +2017,8 @@
|
|||||||
"imagesCompleted": "Images d'exemple {action} terminées",
|
"imagesCompleted": "Images d'exemple {action} terminées",
|
||||||
"imagesFailed": "Images d'exemple {action} échouées",
|
"imagesFailed": "Images d'exemple {action} échouées",
|
||||||
"loadError": "Erreur lors du chargement des téléchargements : {message}",
|
"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": {
|
"import": {
|
||||||
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
|
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
|
||||||
@@ -2009,6 +2063,8 @@
|
|||||||
"contentRatingFailed": "Échec de la définition de la classification du contenu : {message}",
|
"contentRatingFailed": "Échec de la définition de la classification du contenu : {message}",
|
||||||
"relinkSuccess": "Modèle relié à Civitai avec succès",
|
"relinkSuccess": "Modèle relié à Civitai avec succès",
|
||||||
"relinkFailed": "Erreur : {message}",
|
"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",
|
"fetchMetadataFirst": "Veuillez d'abord récupérer les métadonnées depuis CivitAI",
|
||||||
"noCivitaiInfo": "Aucune information CivitAI disponible",
|
"noCivitaiInfo": "Aucune information CivitAI disponible",
|
||||||
"missingHash": "Hash du modèle non disponible"
|
"missingHash": "Hash du modèle non disponible"
|
||||||
@@ -2070,6 +2126,12 @@
|
|||||||
"moveFailed": "Failed to move item: {message}",
|
"moveFailed": "Failed to move item: {message}",
|
||||||
"copiedToClipboard": "Copié dans le presse-papiers",
|
"copiedToClipboard": "Copié dans le presse-papiers",
|
||||||
"downloadStarted": "Téléchargement démarré"
|
"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": {
|
"doctor": {
|
||||||
|
|||||||
+71
-9
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "הסר מהמועדפים",
|
"removeFromFavorites": "הסר מהמועדפים",
|
||||||
"viewOnCivitai": "הצג ב-Civitai",
|
"viewOnCivitai": "הצג ב-Civitai",
|
||||||
"notAvailableFromCivitai": "לא זמין מ-Civitai",
|
"notAvailableFromCivitai": "לא זמין מ-Civitai",
|
||||||
|
"viewOnHuggingFace": "צפייה ב-Hugging Face",
|
||||||
"sendToWorkflow": "שלח ל-ComfyUI (לחיצה: הוסף, Shift+לחיצה: החלף)",
|
"sendToWorkflow": "שלח ל-ComfyUI (לחיצה: הוסף, Shift+לחיצה: החלף)",
|
||||||
"copyLoRASyntax": "העתק תחביר LoRA",
|
"copyLoRASyntax": "העתק תחביר LoRA",
|
||||||
"checkpointNameCopied": "שם Checkpoint הועתק",
|
"checkpointNameCopied": "שם Checkpoint הועתק",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
|
"saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
|
||||||
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
|
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "נתיב זה כבר מוגדר"
|
"duplicatePath": "נתיב זה כבר מוגדר",
|
||||||
|
"checkpointUnetOverlap": "לא ניתן להשתמש באותו נתיב עבור checkpoints ומודלי דיפוזיה: {paths}",
|
||||||
|
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -656,6 +659,32 @@
|
|||||||
"proxyPassword": "סיסמה (אופציונלי)",
|
"proxyPassword": "סיסמה (אופציונלי)",
|
||||||
"proxyPasswordPlaceholder": "password",
|
"proxyPasswordPlaceholder": "password",
|
||||||
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
|
"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": {
|
"loras": {
|
||||||
@@ -753,12 +782,15 @@
|
|||||||
"completed": "הושלם: {success} הועברו, {skipped} דולגו, {failures} נכשלו",
|
"completed": "הושלם: {success} הועברו, {skipped} דולגו, {failures} נכשלו",
|
||||||
"complete": "ארגון אוטומטי הושלם",
|
"complete": "ארגון אוטומטי הושלם",
|
||||||
"error": "שגיאה: {error}"
|
"error": "שגיאה: {error}"
|
||||||
}
|
},
|
||||||
|
"enrichHfAgent": "העשרת HF מטא-דאטה (AI)"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "רענן נתוני Civitai",
|
"refreshMetadata": "רענן נתוני Civitai",
|
||||||
"checkUpdates": "בדוק עדכונים",
|
"checkUpdates": "בדוק עדכונים",
|
||||||
"relinkCivitai": "קשר מחדש ל-Civitai",
|
"linkModel": "קישור מודל",
|
||||||
|
"linkCivitai": "קשר מחדש ל-Civitai",
|
||||||
|
"linkHuggingFace": "קישור ל-HuggingFace",
|
||||||
"copySyntax": "העתק תחביר LoRA",
|
"copySyntax": "העתק תחביר LoRA",
|
||||||
"copyFilename": "העתק שם קובץ מודל",
|
"copyFilename": "העתק שם קובץ מודל",
|
||||||
"copyRecipeSyntax": "העתק תחביר מתכון",
|
"copyRecipeSyntax": "העתק תחביר מתכון",
|
||||||
@@ -777,7 +809,8 @@
|
|||||||
"shareRecipe": "שתף מתכון",
|
"shareRecipe": "שתף מתכון",
|
||||||
"viewAllLoras": "הצג את כל ה-LoRAs",
|
"viewAllLoras": "הצג את כל ה-LoRAs",
|
||||||
"downloadMissingLoras": "הורד LoRAs חסרים",
|
"downloadMissingLoras": "הורד LoRAs חסרים",
|
||||||
"deleteRecipe": "מחק מתכון"
|
"deleteRecipe": "מחק מתכון",
|
||||||
|
"enrichHfAgent": "העשרת HF מטא-דאטה (AI)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -1134,7 +1167,10 @@
|
|||||||
"titleWithType": "הורד {type} מכתובת URL",
|
"titleWithType": "הורד {type} מכתובת URL",
|
||||||
"civitaiUrl": "כתובת URL של Civitai:",
|
"civitaiUrl": "כתובת URL של Civitai:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "יש להזין כתובת URL אחת של CivitAI או CivArchive בכל שורה. תומך במספר כתובות URL להורדה בבת אחת.",
|
"urlHint": "יש להזין כתובת URL אחת של CivitAI, CivArchive או Hugging Face בכל שורה. תומך במספר כתובות URL להורדה בקבוצה.",
|
||||||
|
"selectHfFiles": "בחר קבצים להורדה ממאגר זה:",
|
||||||
|
"selectAll": "בחר הכל",
|
||||||
|
"fetchingRepoFiles": "מביא קבצים מהמאגר...",
|
||||||
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
|
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
|
||||||
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
|
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
|
||||||
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
|
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
|
||||||
@@ -1163,13 +1199,17 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
||||||
"noVersions": "אין גרסאות זמינות למודל זה"
|
"noVersions": "אין גרסאות זמינות למודל זה",
|
||||||
|
"mixedSources": "לא ניתן לערבב כתובות URL של CivitAI ו-Hugging Face באותה קבוצה.",
|
||||||
|
"noModelFiles": "לא נמצאו קבצי מודל במאגר זה."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "מכין הורדה...",
|
"preparing": "מכין הורדה...",
|
||||||
"downloadedPreview": "תמונת תצוגה מקדימה הורדה",
|
"downloadedPreview": "תמונת תצוגה מקדימה הורדה",
|
||||||
"downloadingFile": "מוריד קובץ {type}",
|
"downloadingFile": "מוריד קובץ {type}",
|
||||||
"finalizing": "מסיים הורדה..."
|
"finalizing": "מסיים הורדה...",
|
||||||
|
"cancelling": "מבטל הורדה...",
|
||||||
|
"cancelled": "ההורדה בוטלה"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "הקובץ הנוכחי:",
|
"currentFile": "הקובץ הנוכחי:",
|
||||||
@@ -1285,6 +1325,14 @@
|
|||||||
"pathPlaceholder": "הקלד נתיב תיקייה או בחר מהעץ למטה...",
|
"pathPlaceholder": "הקלד נתיב תיקייה או בחר מהעץ למטה...",
|
||||||
"root": "שורש"
|
"root": "שורש"
|
||||||
},
|
},
|
||||||
|
"linkHuggingFace": {
|
||||||
|
"title": "קישור ל-HuggingFace",
|
||||||
|
"infoText": "הדבק את כתובת ה-URL של מאגר HuggingFace כדי לשייך מודל זה למקורו. פעולה זו מאפשרת העשרת מטא-דאטה באמצעות AI.",
|
||||||
|
"urlLabel": "כתובת URL של מאגר HuggingFace:",
|
||||||
|
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||||
|
"helpText": "הזן את כתובת ה-URL המלאה של מאגר HuggingFace.",
|
||||||
|
"confirmAction": "שמור וקשר"
|
||||||
|
},
|
||||||
"relinkCivitai": {
|
"relinkCivitai": {
|
||||||
"title": "קשר מחדש ל-Civitai",
|
"title": "קשר מחדש ל-Civitai",
|
||||||
"warning": "אזהרה:",
|
"warning": "אזהרה:",
|
||||||
@@ -1314,6 +1362,8 @@
|
|||||||
"editVersionName": "ערוך שם גרסה",
|
"editVersionName": "ערוך שם גרסה",
|
||||||
"viewOnCivitai": "הצג ב-Civitai",
|
"viewOnCivitai": "הצג ב-Civitai",
|
||||||
"viewOnCivitaiText": "הצג ב-Civitai",
|
"viewOnCivitaiText": "הצג ב-Civitai",
|
||||||
|
"viewOnHuggingFace": "צפייה ב-Hugging Face",
|
||||||
|
"viewOnHuggingFaceText": "צפייה ב-Hugging Face",
|
||||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||||
"openFileLocation": "פתח מיקום קובץ",
|
"openFileLocation": "פתח מיקום קובץ",
|
||||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||||
@@ -1339,7 +1389,10 @@
|
|||||||
"additionalNotes": "הערות נוספות",
|
"additionalNotes": "הערות נוספות",
|
||||||
"notesHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
|
"notesHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
|
||||||
"addNotesPlaceholder": "הוסף את ההערות שלך כאן...",
|
"addNotesPlaceholder": "הוסף את ההערות שלך כאן...",
|
||||||
"aboutThisVersion": "אודות גרסה זו"
|
"aboutThisVersion": "אודות גרסה זו",
|
||||||
|
"baseModelSearchPlaceholder": "חפש מודל בסיס…",
|
||||||
|
"baseModelSuggested": "מוצע",
|
||||||
|
"baseModelNoMatch": "אין מודלי בסיס תואמים"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "הערות נשמרו בהצלחה",
|
"saved": "הערות נשמרו בהצלחה",
|
||||||
@@ -1964,7 +2017,8 @@
|
|||||||
"imagesCompleted": "{action} תמונות הדוגמה הושלם",
|
"imagesCompleted": "{action} תמונות הדוגמה הושלם",
|
||||||
"imagesFailed": "{action} תמונות הדוגמה נכשל",
|
"imagesFailed": "{action} תמונות הדוגמה נכשל",
|
||||||
"loadError": "שגיאה בטעינת הורדות: {message}",
|
"loadError": "שגיאה בטעינת הורדות: {message}",
|
||||||
"downloadError": "שגיאת הורדה: {message}"
|
"downloadError": "שגיאת הורדה: {message}",
|
||||||
|
"downloadStopped": "ההורדה בוטלה"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
|
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
|
||||||
@@ -2009,6 +2063,8 @@
|
|||||||
"contentRatingFailed": "הגדרת דירוג התוכן נכשלה: {message}",
|
"contentRatingFailed": "הגדרת דירוג התוכן נכשלה: {message}",
|
||||||
"relinkSuccess": "המודל קושר מחדש ל-Civitai בהצלחה",
|
"relinkSuccess": "המודל קושר מחדש ל-Civitai בהצלחה",
|
||||||
"relinkFailed": "שגיאה: {message}",
|
"relinkFailed": "שגיאה: {message}",
|
||||||
|
"linkHfSuccess": "המודל נקשר בהצלחה ל-HuggingFace",
|
||||||
|
"linkHfFailed": "שגיאה: {message}",
|
||||||
"fetchMetadataFirst": "אנא אחזר מטא-דאטה מ-CivitAI תחילה",
|
"fetchMetadataFirst": "אנא אחזר מטא-דאטה מ-CivitAI תחילה",
|
||||||
"noCivitaiInfo": "אין מידע מ-CivitAI זמין",
|
"noCivitaiInfo": "אין מידע מ-CivitAI זמין",
|
||||||
"missingHash": "ה-hash של המודל אינו זמין"
|
"missingHash": "ה-hash של המודל אינו זמין"
|
||||||
@@ -2070,6 +2126,12 @@
|
|||||||
"moveFailed": "Failed to move item: {message}",
|
"moveFailed": "Failed to move item: {message}",
|
||||||
"copiedToClipboard": "הועתק ללוח",
|
"copiedToClipboard": "הועתק ללוח",
|
||||||
"downloadStarted": "ההורדה החלה"
|
"downloadStarted": "ההורדה החלה"
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"llmNotConfigured": "ספק AI לא הוגדר. הפעל אותו בהגדרות → ספק AI.",
|
||||||
|
"enrichStarted": "מעשיר מטא-דאטה באמצעות AI...",
|
||||||
|
"enrichComplete": "העשרת מטא-דאטה הושלמה: {{summary}}",
|
||||||
|
"enrichFailed": "העשרת מטא-דאטה נכשלה: {{error}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
+71
-9
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "お気に入りから削除",
|
"removeFromFavorites": "お気に入りから削除",
|
||||||
"viewOnCivitai": "Civitaiで表示",
|
"viewOnCivitai": "Civitaiで表示",
|
||||||
"notAvailableFromCivitai": "Civitaiでは利用できません",
|
"notAvailableFromCivitai": "Civitaiでは利用できません",
|
||||||
|
"viewOnHuggingFace": "Hugging Face で見る",
|
||||||
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
|
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
|
||||||
"copyLoRASyntax": "LoRA構文をコピー",
|
"copyLoRASyntax": "LoRA構文をコピー",
|
||||||
"checkpointNameCopied": "checkpointの名前をコピーしました",
|
"checkpointNameCopied": "checkpointの名前をコピーしました",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
|
"saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
|
||||||
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
|
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "このパスはすでに設定されています"
|
"duplicatePath": "このパスはすでに設定されています",
|
||||||
|
"checkpointUnetOverlap": "checkpoints と diffusion models に同じパスは使用できません:{paths}",
|
||||||
|
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。checkpoints と diffusion models には別々のフォルダを使用してください。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -656,6 +659,32 @@
|
|||||||
"proxyPassword": "パスワード(任意)",
|
"proxyPassword": "パスワード(任意)",
|
||||||
"proxyPasswordPlaceholder": "パスワード",
|
"proxyPasswordPlaceholder": "パスワード",
|
||||||
"proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)"
|
"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": {
|
"loras": {
|
||||||
@@ -753,12 +782,15 @@
|
|||||||
"completed": "完了:{success} 移動、{skipped} スキップ、{failures} 失敗",
|
"completed": "完了:{success} 移動、{skipped} スキップ、{failures} 失敗",
|
||||||
"complete": "自動整理が完了しました",
|
"complete": "自動整理が完了しました",
|
||||||
"error": "エラー:{error}"
|
"error": "エラー:{error}"
|
||||||
}
|
},
|
||||||
|
"enrichHfAgent": "HF メタデータをAIで補完"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Civitaiデータを更新",
|
"refreshMetadata": "Civitaiデータを更新",
|
||||||
"checkUpdates": "更新確認",
|
"checkUpdates": "更新確認",
|
||||||
"relinkCivitai": "Civitaiに再リンク",
|
"linkModel": "モデルをリンク",
|
||||||
|
"linkCivitai": "Civitai にリンク",
|
||||||
|
"linkHuggingFace": "HuggingFace にリンク",
|
||||||
"copySyntax": "LoRA構文をコピー",
|
"copySyntax": "LoRA構文をコピー",
|
||||||
"copyFilename": "モデルファイル名をコピー",
|
"copyFilename": "モデルファイル名をコピー",
|
||||||
"copyRecipeSyntax": "レシピ構文をコピー",
|
"copyRecipeSyntax": "レシピ構文をコピー",
|
||||||
@@ -777,7 +809,8 @@
|
|||||||
"shareRecipe": "レシピを共有",
|
"shareRecipe": "レシピを共有",
|
||||||
"viewAllLoras": "すべてのLoRAを表示",
|
"viewAllLoras": "すべてのLoRAを表示",
|
||||||
"downloadMissingLoras": "不足しているLoRAをダウンロード",
|
"downloadMissingLoras": "不足しているLoRAをダウンロード",
|
||||||
"deleteRecipe": "レシピを削除"
|
"deleteRecipe": "レシピを削除",
|
||||||
|
"enrichHfAgent": "HF メタデータをAIで補完"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -1134,7 +1167,10 @@
|
|||||||
"titleWithType": "URLから{type}をダウンロード",
|
"titleWithType": "URLから{type}をダウンロード",
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"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": "ダウンロード場所プレビュー",
|
"locationPreview": "ダウンロード場所プレビュー",
|
||||||
"useDefaultPath": "デフォルトパスを使用",
|
"useDefaultPath": "デフォルトパスを使用",
|
||||||
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
|
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
|
||||||
@@ -1163,13 +1199,17 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "無効なCivitai URL形式",
|
"invalidUrl": "無効なCivitai URL形式",
|
||||||
"noVersions": "このモデルの利用可能なバージョンがありません"
|
"noVersions": "このモデルの利用可能なバージョンがありません",
|
||||||
|
"mixedSources": "同じバッチ内でCivitAIとHugging FaceのURLを混在させることはできません。",
|
||||||
|
"noModelFiles": "このリポジトリにモデルファイルが見つかりませんでした。"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "ダウンロードを準備中...",
|
"preparing": "ダウンロードを準備中...",
|
||||||
"downloadedPreview": "プレビュー画像をダウンロードしました",
|
"downloadedPreview": "プレビュー画像をダウンロードしました",
|
||||||
"downloadingFile": "{type}ファイルをダウンロード中",
|
"downloadingFile": "{type}ファイルをダウンロード中",
|
||||||
"finalizing": "ダウンロードを完了中..."
|
"finalizing": "ダウンロードを完了中...",
|
||||||
|
"cancelling": "ダウンロードをキャンセル中...",
|
||||||
|
"cancelled": "ダウンロードをキャンセルしました"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "現在のファイル:",
|
"currentFile": "現在のファイル:",
|
||||||
@@ -1285,6 +1325,14 @@
|
|||||||
"pathPlaceholder": "フォルダパスを入力するか、下のツリーから選択...",
|
"pathPlaceholder": "フォルダパスを入力するか、下のツリーから選択...",
|
||||||
"root": "ルート"
|
"root": "ルート"
|
||||||
},
|
},
|
||||||
|
"linkHuggingFace": {
|
||||||
|
"title": "HuggingFace にリンク",
|
||||||
|
"infoText": "HuggingFace リポジトリの URL を貼り付けてモデルを関連付けます。AI によるメタデータ補完が有効になります。",
|
||||||
|
"urlLabel": "HuggingFace リポジトリ URL:",
|
||||||
|
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||||
|
"helpText": "完全な HuggingFace リポジトリ URL を入力してください。",
|
||||||
|
"confirmAction": "保存&リンク"
|
||||||
|
},
|
||||||
"relinkCivitai": {
|
"relinkCivitai": {
|
||||||
"title": "Civitaiに再リンク",
|
"title": "Civitaiに再リンク",
|
||||||
"warning": "警告:",
|
"warning": "警告:",
|
||||||
@@ -1314,6 +1362,8 @@
|
|||||||
"editVersionName": "バージョン名を編集",
|
"editVersionName": "バージョン名を編集",
|
||||||
"viewOnCivitai": "Civitaiで表示",
|
"viewOnCivitai": "Civitaiで表示",
|
||||||
"viewOnCivitaiText": "Civitaiで表示",
|
"viewOnCivitaiText": "Civitaiで表示",
|
||||||
|
"viewOnHuggingFace": "Hugging Face で見る",
|
||||||
|
"viewOnHuggingFaceText": "Hugging Face で見る",
|
||||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||||
"openFileLocation": "ファイルの場所を開く",
|
"openFileLocation": "ファイルの場所を開く",
|
||||||
"sendToWorkflow": "ComfyUI に送信",
|
"sendToWorkflow": "ComfyUI に送信",
|
||||||
@@ -1339,7 +1389,10 @@
|
|||||||
"additionalNotes": "追加メモ",
|
"additionalNotes": "追加メモ",
|
||||||
"notesHint": "Enterで保存、Shift+Enterで改行",
|
"notesHint": "Enterで保存、Shift+Enterで改行",
|
||||||
"addNotesPlaceholder": "メモをここに追加...",
|
"addNotesPlaceholder": "メモをここに追加...",
|
||||||
"aboutThisVersion": "このバージョンについて"
|
"aboutThisVersion": "このバージョンについて",
|
||||||
|
"baseModelSearchPlaceholder": "ベースモデルを検索…",
|
||||||
|
"baseModelSuggested": "おすすめ",
|
||||||
|
"baseModelNoMatch": "該当するベースモデルがありません"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "メモが正常に保存されました",
|
"saved": "メモが正常に保存されました",
|
||||||
@@ -1964,7 +2017,8 @@
|
|||||||
"imagesCompleted": "例画像 {action} が完了しました",
|
"imagesCompleted": "例画像 {action} が完了しました",
|
||||||
"imagesFailed": "例画像 {action} が失敗しました",
|
"imagesFailed": "例画像 {action} が失敗しました",
|
||||||
"loadError": "ダウンロード読み込みエラー:{message}",
|
"loadError": "ダウンロード読み込みエラー:{message}",
|
||||||
"downloadError": "ダウンロードエラー:{message}"
|
"downloadError": "ダウンロードエラー:{message}",
|
||||||
|
"downloadStopped": "ダウンロードをキャンセルしました"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
|
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
|
||||||
@@ -2009,6 +2063,8 @@
|
|||||||
"contentRatingFailed": "コンテンツレーティングの設定に失敗しました:{message}",
|
"contentRatingFailed": "コンテンツレーティングの設定に失敗しました:{message}",
|
||||||
"relinkSuccess": "モデルがCivitaiに正常に再リンクされました",
|
"relinkSuccess": "モデルがCivitaiに正常に再リンクされました",
|
||||||
"relinkFailed": "エラー:{message}",
|
"relinkFailed": "エラー:{message}",
|
||||||
|
"linkHfSuccess": "モデルを HuggingFace にリンクしました",
|
||||||
|
"linkHfFailed": "エラー:{message}",
|
||||||
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
|
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
|
||||||
"noCivitaiInfo": "CivitAI情報が利用できません",
|
"noCivitaiInfo": "CivitAI情報が利用できません",
|
||||||
"missingHash": "モデルハッシュが利用できません"
|
"missingHash": "モデルハッシュが利用できません"
|
||||||
@@ -2070,6 +2126,12 @@
|
|||||||
"moveFailed": "Failed to move item: {message}",
|
"moveFailed": "Failed to move item: {message}",
|
||||||
"copiedToClipboard": "クリップボードにコピーしました",
|
"copiedToClipboard": "クリップボードにコピーしました",
|
||||||
"downloadStarted": "ダウンロードを開始しました"
|
"downloadStarted": "ダウンロードを開始しました"
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"llmNotConfigured": "AIプロバイダーが設定されていません。設定 → AIプロバイダーで有効にしてください。",
|
||||||
|
"enrichStarted": "AIでメタデータを補完中...",
|
||||||
|
"enrichComplete": "メタデータの補完が完了しました:{{summary}}",
|
||||||
|
"enrichFailed": "メタデータの補完に失敗しました:{{error}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
+71
-9
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "즐겨찾기에서 제거",
|
"removeFromFavorites": "즐겨찾기에서 제거",
|
||||||
"viewOnCivitai": "Civitai에서 보기",
|
"viewOnCivitai": "Civitai에서 보기",
|
||||||
"notAvailableFromCivitai": "Civitai에서 사용할 수 없음",
|
"notAvailableFromCivitai": "Civitai에서 사용할 수 없음",
|
||||||
|
"viewOnHuggingFace": "Hugging Face에서 보기",
|
||||||
"sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)",
|
"sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)",
|
||||||
"copyLoRASyntax": "LoRA 문법 복사",
|
"copyLoRASyntax": "LoRA 문법 복사",
|
||||||
"checkpointNameCopied": "Checkpoint 이름 복사됨",
|
"checkpointNameCopied": "Checkpoint 이름 복사됨",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
|
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
|
||||||
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
|
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
|
"duplicatePath": "이 경로는 이미 구성되어 있습니다",
|
||||||
|
"checkpointUnetOverlap": "checkpoints와 diffusion models에 동일한 경로를 사용할 수 없습니다: {paths}",
|
||||||
|
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -656,6 +659,32 @@
|
|||||||
"proxyPassword": "비밀번호 (선택사항)",
|
"proxyPassword": "비밀번호 (선택사항)",
|
||||||
"proxyPasswordPlaceholder": "password",
|
"proxyPasswordPlaceholder": "password",
|
||||||
"proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)"
|
"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": {
|
"loras": {
|
||||||
@@ -753,12 +782,15 @@
|
|||||||
"completed": "완료: {success}개 이동, {skipped}개 건너뜀, {failures}개 실패",
|
"completed": "완료: {success}개 이동, {skipped}개 건너뜀, {failures}개 실패",
|
||||||
"complete": "자동 정리 완료",
|
"complete": "자동 정리 완료",
|
||||||
"error": "오류: {error}"
|
"error": "오류: {error}"
|
||||||
}
|
},
|
||||||
|
"enrichHfAgent": "HF AI로 메타데이터 보강"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Civitai 데이터 새로고침",
|
"refreshMetadata": "Civitai 데이터 새로고침",
|
||||||
"checkUpdates": "업데이트 확인",
|
"checkUpdates": "업데이트 확인",
|
||||||
"relinkCivitai": "Civitai에 다시 연결",
|
"linkModel": "모델 연결",
|
||||||
|
"linkCivitai": "Civitai에 연결",
|
||||||
|
"linkHuggingFace": "HuggingFace에 연결",
|
||||||
"copySyntax": "LoRA 문법 복사",
|
"copySyntax": "LoRA 문법 복사",
|
||||||
"copyFilename": "모델 파일명 복사",
|
"copyFilename": "모델 파일명 복사",
|
||||||
"copyRecipeSyntax": "레시피 문법 복사",
|
"copyRecipeSyntax": "레시피 문법 복사",
|
||||||
@@ -777,7 +809,8 @@
|
|||||||
"shareRecipe": "레시피 공유",
|
"shareRecipe": "레시피 공유",
|
||||||
"viewAllLoras": "모든 LoRA 보기",
|
"viewAllLoras": "모든 LoRA 보기",
|
||||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||||
"deleteRecipe": "레시피 삭제"
|
"deleteRecipe": "레시피 삭제",
|
||||||
|
"enrichHfAgent": "HF AI로 메타데이터 보강"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -1134,7 +1167,10 @@
|
|||||||
"titleWithType": "URL에서 {type} 다운로드",
|
"titleWithType": "URL에서 {type} 다운로드",
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "한 줄에 하나의 CivitAI 또는 CivArchive URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
"urlHint": "한 줄에 하나의 CivitAI, CivArchive 또는 Hugging Face URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
||||||
|
"selectHfFiles": "이 저장소에서 다운로드할 파일을 선택하세요:",
|
||||||
|
"selectAll": "모두 선택",
|
||||||
|
"fetchingRepoFiles": "저장소 파일을 가져오는 중...",
|
||||||
"locationPreview": "다운로드 위치 미리보기",
|
"locationPreview": "다운로드 위치 미리보기",
|
||||||
"useDefaultPath": "기본 경로 사용",
|
"useDefaultPath": "기본 경로 사용",
|
||||||
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
|
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
|
||||||
@@ -1163,13 +1199,17 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "잘못된 Civitai URL 형식",
|
"invalidUrl": "잘못된 Civitai URL 형식",
|
||||||
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
|
"noVersions": "이 모델에 사용 가능한 버전이 없습니다",
|
||||||
|
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face URL을 혼합할 수 없습니다.",
|
||||||
|
"noModelFiles": "이 저장소에서 모델 파일을 찾을 수 없습니다."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "다운로드 준비 중...",
|
"preparing": "다운로드 준비 중...",
|
||||||
"downloadedPreview": "미리보기 이미지 다운로드됨",
|
"downloadedPreview": "미리보기 이미지 다운로드됨",
|
||||||
"downloadingFile": "{type} 파일 다운로드 중",
|
"downloadingFile": "{type} 파일 다운로드 중",
|
||||||
"finalizing": "다운로드 완료 중..."
|
"finalizing": "다운로드 완료 중...",
|
||||||
|
"cancelling": "다운로드 취소 중...",
|
||||||
|
"cancelled": "다운로드가 취소되었습니다"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "현재 파일:",
|
"currentFile": "현재 파일:",
|
||||||
@@ -1285,6 +1325,14 @@
|
|||||||
"pathPlaceholder": "폴더 경로를 입력하거나 아래 트리에서 선택하세요...",
|
"pathPlaceholder": "폴더 경로를 입력하거나 아래 트리에서 선택하세요...",
|
||||||
"root": "루트"
|
"root": "루트"
|
||||||
},
|
},
|
||||||
|
"linkHuggingFace": {
|
||||||
|
"title": "HuggingFace에 연결",
|
||||||
|
"infoText": "HuggingFace 저장소 URL을 붙여넣어 모델을 연결합니다. AI 메타데이터 보강 기능을 사용할 수 있습니다.",
|
||||||
|
"urlLabel": "HuggingFace 저장소 URL:",
|
||||||
|
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||||
|
"helpText": "전체 HuggingFace 저장소 URL을 입력하세요.",
|
||||||
|
"confirmAction": "저장 및 연결"
|
||||||
|
},
|
||||||
"relinkCivitai": {
|
"relinkCivitai": {
|
||||||
"title": "Civitai에 다시 연결",
|
"title": "Civitai에 다시 연결",
|
||||||
"warning": "경고:",
|
"warning": "경고:",
|
||||||
@@ -1314,6 +1362,8 @@
|
|||||||
"editVersionName": "버전명 편집",
|
"editVersionName": "버전명 편집",
|
||||||
"viewOnCivitai": "Civitai에서 보기",
|
"viewOnCivitai": "Civitai에서 보기",
|
||||||
"viewOnCivitaiText": "Civitai에서 보기",
|
"viewOnCivitaiText": "Civitai에서 보기",
|
||||||
|
"viewOnHuggingFace": "Hugging Face에서 보기",
|
||||||
|
"viewOnHuggingFaceText": "Hugging Face에서 보기",
|
||||||
"viewCreatorProfile": "제작자 프로필 보기",
|
"viewCreatorProfile": "제작자 프로필 보기",
|
||||||
"openFileLocation": "파일 위치 열기",
|
"openFileLocation": "파일 위치 열기",
|
||||||
"sendToWorkflow": "ComfyUI로 보내기",
|
"sendToWorkflow": "ComfyUI로 보내기",
|
||||||
@@ -1339,7 +1389,10 @@
|
|||||||
"additionalNotes": "추가 메모",
|
"additionalNotes": "추가 메모",
|
||||||
"notesHint": "Enter로 저장, Shift+Enter로 줄바꿈",
|
"notesHint": "Enter로 저장, Shift+Enter로 줄바꿈",
|
||||||
"addNotesPlaceholder": "메모를 여기에 추가하세요...",
|
"addNotesPlaceholder": "메모를 여기에 추가하세요...",
|
||||||
"aboutThisVersion": "이 버전에 대해"
|
"aboutThisVersion": "이 버전에 대해",
|
||||||
|
"baseModelSearchPlaceholder": "베이스 모델 검색…",
|
||||||
|
"baseModelSuggested": "추천",
|
||||||
|
"baseModelNoMatch": "일치하는 베이스 모델 없음"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "메모가 성공적으로 저장됨",
|
"saved": "메모가 성공적으로 저장됨",
|
||||||
@@ -1964,7 +2017,8 @@
|
|||||||
"imagesCompleted": "예시 이미지 {action}이(가) 완료되었습니다",
|
"imagesCompleted": "예시 이미지 {action}이(가) 완료되었습니다",
|
||||||
"imagesFailed": "예시 이미지 {action}이(가) 실패했습니다",
|
"imagesFailed": "예시 이미지 {action}이(가) 실패했습니다",
|
||||||
"loadError": "다운로드 로딩 오류: {message}",
|
"loadError": "다운로드 로딩 오류: {message}",
|
||||||
"downloadError": "다운로드 오류: {message}"
|
"downloadError": "다운로드 오류: {message}",
|
||||||
|
"downloadStopped": "다운로드가 취소되었습니다"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "폴더 트리 로딩 실패",
|
"folderTreeFailed": "폴더 트리 로딩 실패",
|
||||||
@@ -2009,6 +2063,8 @@
|
|||||||
"contentRatingFailed": "콘텐츠 등급 설정 실패: {message}",
|
"contentRatingFailed": "콘텐츠 등급 설정 실패: {message}",
|
||||||
"relinkSuccess": "모델이 Civitai에 성공적으로 다시 연결되었습니다",
|
"relinkSuccess": "모델이 Civitai에 성공적으로 다시 연결되었습니다",
|
||||||
"relinkFailed": "오류: {message}",
|
"relinkFailed": "오류: {message}",
|
||||||
|
"linkHfSuccess": "모델이 HuggingFace에 연결되었습니다",
|
||||||
|
"linkHfFailed": "오류: {message}",
|
||||||
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
|
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
|
||||||
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
|
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
|
||||||
"missingHash": "모델 해시를 사용할 수 없습니다"
|
"missingHash": "모델 해시를 사용할 수 없습니다"
|
||||||
@@ -2070,6 +2126,12 @@
|
|||||||
"moveFailed": "Failed to move item: {message}",
|
"moveFailed": "Failed to move item: {message}",
|
||||||
"copiedToClipboard": "클립보드에 복사됨",
|
"copiedToClipboard": "클립보드에 복사됨",
|
||||||
"downloadStarted": "다운로드 시작됨"
|
"downloadStarted": "다운로드 시작됨"
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"llmNotConfigured": "AI 제공자가 설정되지 않았습니다. 설정 → AI 제공자에서 활성화하세요.",
|
||||||
|
"enrichStarted": "AI로 메타데이터 보강 중...",
|
||||||
|
"enrichComplete": "메타데이터 보강 완료: {{summary}}",
|
||||||
|
"enrichFailed": "메타데이터 보강 실패: {{error}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
+71
-9
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "Удалить из избранного",
|
"removeFromFavorites": "Удалить из избранного",
|
||||||
"viewOnCivitai": "Посмотреть на Civitai",
|
"viewOnCivitai": "Посмотреть на Civitai",
|
||||||
"notAvailableFromCivitai": "Недоступно на Civitai",
|
"notAvailableFromCivitai": "Недоступно на Civitai",
|
||||||
|
"viewOnHuggingFace": "Открыть Hugging Face",
|
||||||
"sendToWorkflow": "Отправить в ComfyUI (Клик: Добавить, Shift+Клик: Заменить)",
|
"sendToWorkflow": "Отправить в ComfyUI (Клик: Добавить, Shift+Клик: Заменить)",
|
||||||
"copyLoRASyntax": "Копировать синтаксис LoRA",
|
"copyLoRASyntax": "Копировать синтаксис LoRA",
|
||||||
"checkpointNameCopied": "Имя checkpoint скопировано",
|
"checkpointNameCopied": "Имя checkpoint скопировано",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
|
"saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
|
||||||
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
|
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "Этот путь уже настроен"
|
"duplicatePath": "Этот путь уже настроен",
|
||||||
|
"checkpointUnetOverlap": "Нельзя использовать один и тот же путь для checkpoints и diffusion models: {paths}",
|
||||||
|
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -656,6 +659,32 @@
|
|||||||
"proxyPassword": "Пароль (необязательно)",
|
"proxyPassword": "Пароль (необязательно)",
|
||||||
"proxyPasswordPlaceholder": "пароль",
|
"proxyPasswordPlaceholder": "пароль",
|
||||||
"proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)"
|
"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": {
|
"loras": {
|
||||||
@@ -753,12 +782,15 @@
|
|||||||
"completed": "Завершено: {success} перемещено, {skipped} пропущено, {failures} не удалось",
|
"completed": "Завершено: {success} перемещено, {skipped} пропущено, {failures} не удалось",
|
||||||
"complete": "Автоматическая организация завершена",
|
"complete": "Автоматическая организация завершена",
|
||||||
"error": "Ошибка: {error}"
|
"error": "Ошибка: {error}"
|
||||||
}
|
},
|
||||||
|
"enrichHfAgent": "Обогатить HF метаданные (ИИ)"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Обновить данные Civitai",
|
"refreshMetadata": "Обновить данные Civitai",
|
||||||
"checkUpdates": "Проверить обновления",
|
"checkUpdates": "Проверить обновления",
|
||||||
"relinkCivitai": "Пересвязать с Civitai",
|
"linkModel": "Связать модель",
|
||||||
|
"linkCivitai": "Пересвязать с Civitai",
|
||||||
|
"linkHuggingFace": "Связать с HuggingFace",
|
||||||
"copySyntax": "Копировать синтаксис LoRA",
|
"copySyntax": "Копировать синтаксис LoRA",
|
||||||
"copyFilename": "Копировать имя файла модели",
|
"copyFilename": "Копировать имя файла модели",
|
||||||
"copyRecipeSyntax": "Копировать синтаксис рецепта",
|
"copyRecipeSyntax": "Копировать синтаксис рецепта",
|
||||||
@@ -777,7 +809,8 @@
|
|||||||
"shareRecipe": "Поделиться рецептом",
|
"shareRecipe": "Поделиться рецептом",
|
||||||
"viewAllLoras": "Посмотреть все LoRAs",
|
"viewAllLoras": "Посмотреть все LoRAs",
|
||||||
"downloadMissingLoras": "Загрузить отсутствующие LoRAs",
|
"downloadMissingLoras": "Загрузить отсутствующие LoRAs",
|
||||||
"deleteRecipe": "Удалить рецепт"
|
"deleteRecipe": "Удалить рецепт",
|
||||||
|
"enrichHfAgent": "Обогатить HF метаданные (ИИ)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -1134,7 +1167,10 @@
|
|||||||
"titleWithType": "Скачать {type} по URL",
|
"titleWithType": "Скачать {type} по URL",
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "Введите один URL CivitAI или CivArchive в каждой строке. Поддерживается пакетная загрузка нескольких URL.",
|
"urlHint": "Введите один URL CivitAI, CivArchive или Hugging Face в каждой строке. Поддерживает несколько URL для пакетной загрузки.",
|
||||||
|
"selectHfFiles": "Выберите файл(ы) для загрузки из этого репозитория:",
|
||||||
|
"selectAll": "Выбрать все",
|
||||||
|
"fetchingRepoFiles": "Получение файлов репозитория...",
|
||||||
"locationPreview": "Предпросмотр места загрузки",
|
"locationPreview": "Предпросмотр места загрузки",
|
||||||
"useDefaultPath": "Использовать путь по умолчанию",
|
"useDefaultPath": "Использовать путь по умолчанию",
|
||||||
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
|
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
|
||||||
@@ -1163,13 +1199,17 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Неверный формат URL Civitai",
|
"invalidUrl": "Неверный формат URL Civitai",
|
||||||
"noVersions": "Нет доступных версий для этой модели"
|
"noVersions": "Нет доступных версий для этой модели",
|
||||||
|
"mixedSources": "Нельзя смешивать URL-адреса CivitAI и Hugging Face в одном пакете.",
|
||||||
|
"noModelFiles": "В этом репозитории не найдено файлов моделей."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "Подготовка загрузки...",
|
"preparing": "Подготовка загрузки...",
|
||||||
"downloadedPreview": "Превью изображение загружено",
|
"downloadedPreview": "Превью изображение загружено",
|
||||||
"downloadingFile": "Загрузка файла {type}",
|
"downloadingFile": "Загрузка файла {type}",
|
||||||
"finalizing": "Завершение загрузки..."
|
"finalizing": "Завершение загрузки...",
|
||||||
|
"cancelling": "Отмена загрузки...",
|
||||||
|
"cancelled": "Загрузка отменена"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Текущий файл:",
|
"currentFile": "Текущий файл:",
|
||||||
@@ -1285,6 +1325,14 @@
|
|||||||
"pathPlaceholder": "Введите путь к папке или выберите из дерева ниже...",
|
"pathPlaceholder": "Введите путь к папке или выберите из дерева ниже...",
|
||||||
"root": "Корень"
|
"root": "Корень"
|
||||||
},
|
},
|
||||||
|
"linkHuggingFace": {
|
||||||
|
"title": "Связать с HuggingFace",
|
||||||
|
"infoText": "Вставьте URL репозитория HuggingFace, чтобы связать эту модель с её источником. Это позволит обогащать метаданные с помощью ИИ.",
|
||||||
|
"urlLabel": "URL репозитория HuggingFace:",
|
||||||
|
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||||
|
"helpText": "Введите полный URL репозитория HuggingFace.",
|
||||||
|
"confirmAction": "Сохранить и связать"
|
||||||
|
},
|
||||||
"relinkCivitai": {
|
"relinkCivitai": {
|
||||||
"title": "Пересвязать с Civitai",
|
"title": "Пересвязать с Civitai",
|
||||||
"warning": "Предупреждение:",
|
"warning": "Предупреждение:",
|
||||||
@@ -1314,6 +1362,8 @@
|
|||||||
"editVersionName": "Редактировать название версии",
|
"editVersionName": "Редактировать название версии",
|
||||||
"viewOnCivitai": "Посмотреть на Civitai",
|
"viewOnCivitai": "Посмотреть на Civitai",
|
||||||
"viewOnCivitaiText": "Посмотреть на Civitai",
|
"viewOnCivitaiText": "Посмотреть на Civitai",
|
||||||
|
"viewOnHuggingFace": "Открыть Hugging Face",
|
||||||
|
"viewOnHuggingFaceText": "Открыть Hugging Face",
|
||||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||||
"openFileLocation": "Открыть расположение файла",
|
"openFileLocation": "Открыть расположение файла",
|
||||||
"sendToWorkflow": "Отправить в ComfyUI",
|
"sendToWorkflow": "Отправить в ComfyUI",
|
||||||
@@ -1339,7 +1389,10 @@
|
|||||||
"additionalNotes": "Дополнительные заметки",
|
"additionalNotes": "Дополнительные заметки",
|
||||||
"notesHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
|
"notesHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
|
||||||
"addNotesPlaceholder": "Добавьте ваши заметки здесь...",
|
"addNotesPlaceholder": "Добавьте ваши заметки здесь...",
|
||||||
"aboutThisVersion": "Об этой версии"
|
"aboutThisVersion": "Об этой версии",
|
||||||
|
"baseModelSearchPlaceholder": "Поиск базовой модели…",
|
||||||
|
"baseModelSuggested": "Предполагаемые",
|
||||||
|
"baseModelNoMatch": "Нет подходящих базовых моделей"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Заметки успешно сохранены",
|
"saved": "Заметки успешно сохранены",
|
||||||
@@ -1964,7 +2017,8 @@
|
|||||||
"imagesCompleted": "Примеры изображений {action} завершены",
|
"imagesCompleted": "Примеры изображений {action} завершены",
|
||||||
"imagesFailed": "Примеры изображений {action} не удались",
|
"imagesFailed": "Примеры изображений {action} не удались",
|
||||||
"loadError": "Ошибка загрузки downloads: {message}",
|
"loadError": "Ошибка загрузки downloads: {message}",
|
||||||
"downloadError": "Ошибка загрузки: {message}"
|
"downloadError": "Ошибка загрузки: {message}",
|
||||||
|
"downloadStopped": "Загрузка отменена"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "Не удалось загрузить дерево папок",
|
"folderTreeFailed": "Не удалось загрузить дерево папок",
|
||||||
@@ -2009,6 +2063,8 @@
|
|||||||
"contentRatingFailed": "Не удалось установить рейтинг контента: {message}",
|
"contentRatingFailed": "Не удалось установить рейтинг контента: {message}",
|
||||||
"relinkSuccess": "Модель успешно пересвязана с Civitai",
|
"relinkSuccess": "Модель успешно пересвязана с Civitai",
|
||||||
"relinkFailed": "Ошибка: {message}",
|
"relinkFailed": "Ошибка: {message}",
|
||||||
|
"linkHfSuccess": "Модель успешно связана с HuggingFace",
|
||||||
|
"linkHfFailed": "Ошибка: {message}",
|
||||||
"fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI",
|
"fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI",
|
||||||
"noCivitaiInfo": "Информация CivitAI недоступна",
|
"noCivitaiInfo": "Информация CivitAI недоступна",
|
||||||
"missingHash": "Хеш модели недоступен"
|
"missingHash": "Хеш модели недоступен"
|
||||||
@@ -2070,6 +2126,12 @@
|
|||||||
"moveFailed": "Failed to move item: {message}",
|
"moveFailed": "Failed to move item: {message}",
|
||||||
"copiedToClipboard": "Скопировано в буфер обмена",
|
"copiedToClipboard": "Скопировано в буфер обмена",
|
||||||
"downloadStarted": "Загрузка начата"
|
"downloadStarted": "Загрузка начата"
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"llmNotConfigured": "Поставщик ИИ не настроен. Включите его в Настройки → Поставщик ИИ.",
|
||||||
|
"enrichStarted": "Обогащение метаданных с помощью ИИ...",
|
||||||
|
"enrichComplete": "Обогащение метаданных завершено: {{summary}}",
|
||||||
|
"enrichFailed": "Ошибка обогащения метаданных: {{error}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
+71
-9
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "从收藏移除",
|
"removeFromFavorites": "从收藏移除",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"notAvailableFromCivitai": "Civitai 上不可用",
|
"notAvailableFromCivitai": "Civitai 上不可用",
|
||||||
|
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||||
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
|
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
|
||||||
"copyLoRASyntax": "复制 LoRA 语法",
|
"copyLoRASyntax": "复制 LoRA 语法",
|
||||||
"checkpointNameCopied": "检查点名称已复制",
|
"checkpointNameCopied": "检查点名称已复制",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
|
"saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
|
||||||
"saveError": "更新额外文件夹路径失败:{message}",
|
"saveError": "更新额外文件夹路径失败:{message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "此路径已配置"
|
"duplicatePath": "此路径已配置",
|
||||||
|
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路径:{paths}",
|
||||||
|
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -656,6 +659,32 @@
|
|||||||
"proxyPassword": "密码 (可选)",
|
"proxyPassword": "密码 (可选)",
|
||||||
"proxyPasswordPlaceholder": "密码",
|
"proxyPasswordPlaceholder": "密码",
|
||||||
"proxyPasswordHelp": "代理认证的密码 (如果需要)"
|
"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": {
|
"loras": {
|
||||||
@@ -753,12 +782,15 @@
|
|||||||
"completed": "完成:已移动 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
|
"completed": "完成:已移动 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
|
||||||
"complete": "自动整理已完成",
|
"complete": "自动整理已完成",
|
||||||
"error": "错误:{error}"
|
"error": "错误:{error}"
|
||||||
}
|
},
|
||||||
|
"enrichHfAgent": "AI HF 元数据增强"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "刷新 Civitai 数据",
|
"refreshMetadata": "刷新 Civitai 数据",
|
||||||
"checkUpdates": "检查更新",
|
"checkUpdates": "检查更新",
|
||||||
"relinkCivitai": "重新关联到 Civitai",
|
"linkModel": "链接模型",
|
||||||
|
"linkCivitai": "链接到 Civitai",
|
||||||
|
"linkHuggingFace": "链接到 HuggingFace",
|
||||||
"copySyntax": "复制 LoRA 语法",
|
"copySyntax": "复制 LoRA 语法",
|
||||||
"copyFilename": "复制模型文件名",
|
"copyFilename": "复制模型文件名",
|
||||||
"copyRecipeSyntax": "复制配方语法",
|
"copyRecipeSyntax": "复制配方语法",
|
||||||
@@ -777,7 +809,8 @@
|
|||||||
"shareRecipe": "分享配方",
|
"shareRecipe": "分享配方",
|
||||||
"viewAllLoras": "查看所有 LoRA",
|
"viewAllLoras": "查看所有 LoRA",
|
||||||
"downloadMissingLoras": "下载缺失的 LoRA",
|
"downloadMissingLoras": "下载缺失的 LoRA",
|
||||||
"deleteRecipe": "删除配方"
|
"deleteRecipe": "删除配方",
|
||||||
|
"enrichHfAgent": "AI HF 元数据增强"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -1134,7 +1167,10 @@
|
|||||||
"titleWithType": "从 URL 下载 {type}",
|
"titleWithType": "从 URL 下载 {type}",
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "每行输入一个 CivitAI 或 CivArchive URL。支持批量下载多个 URL。",
|
"urlHint": "每行输入一个 CivitAI、CivArchive 或 Hugging Face URL。支持批量下载多个 URL。",
|
||||||
|
"selectHfFiles": "选择从此仓库下载的文件:",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"fetchingRepoFiles": "正在获取仓库文件...",
|
||||||
"locationPreview": "下载位置预览",
|
"locationPreview": "下载位置预览",
|
||||||
"useDefaultPath": "使用默认路径",
|
"useDefaultPath": "使用默认路径",
|
||||||
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
|
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
|
||||||
@@ -1163,13 +1199,17 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "无效的 Civitai URL 格式",
|
"invalidUrl": "无效的 Civitai URL 格式",
|
||||||
"noVersions": "此模型没有可用版本"
|
"noVersions": "此模型没有可用版本",
|
||||||
|
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
|
||||||
|
"noModelFiles": "在此仓库中未找到模型文件。"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "正在准备下载...",
|
"preparing": "正在准备下载...",
|
||||||
"downloadedPreview": "预览图片已下载",
|
"downloadedPreview": "预览图片已下载",
|
||||||
"downloadingFile": "正在下载 {type} 文件",
|
"downloadingFile": "正在下载 {type} 文件",
|
||||||
"finalizing": "正在完成下载..."
|
"finalizing": "正在完成下载...",
|
||||||
|
"cancelling": "取消下载中...",
|
||||||
|
"cancelled": "下载已取消"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "当前文件:",
|
"currentFile": "当前文件:",
|
||||||
@@ -1285,6 +1325,14 @@
|
|||||||
"pathPlaceholder": "输入文件夹路径或从下方树中选择...",
|
"pathPlaceholder": "输入文件夹路径或从下方树中选择...",
|
||||||
"root": "根目录"
|
"root": "根目录"
|
||||||
},
|
},
|
||||||
|
"linkHuggingFace": {
|
||||||
|
"title": "链接到 HuggingFace",
|
||||||
|
"infoText": "粘贴 HuggingFace 仓库 URL 以关联此模型。关联后可启用 AI 元数据增强功能。",
|
||||||
|
"urlLabel": "HuggingFace 仓库 URL:",
|
||||||
|
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||||
|
"helpText": "请输入完整的 HuggingFace 仓库 URL。",
|
||||||
|
"confirmAction": "保存并链接"
|
||||||
|
},
|
||||||
"relinkCivitai": {
|
"relinkCivitai": {
|
||||||
"title": "重新关联到 Civitai",
|
"title": "重新关联到 Civitai",
|
||||||
"warning": "警告:",
|
"warning": "警告:",
|
||||||
@@ -1314,6 +1362,8 @@
|
|||||||
"editVersionName": "编辑版本名称",
|
"editVersionName": "编辑版本名称",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"viewOnCivitaiText": "在 Civitai 查看",
|
"viewOnCivitaiText": "在 Civitai 查看",
|
||||||
|
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||||
|
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||||
"viewCreatorProfile": "查看创作者主页",
|
"viewCreatorProfile": "查看创作者主页",
|
||||||
"openFileLocation": "打开文件位置",
|
"openFileLocation": "打开文件位置",
|
||||||
"sendToWorkflow": "发送到 ComfyUI",
|
"sendToWorkflow": "发送到 ComfyUI",
|
||||||
@@ -1339,7 +1389,10 @@
|
|||||||
"additionalNotes": "附加备注",
|
"additionalNotes": "附加备注",
|
||||||
"notesHint": "回车保存,Shift+回车换行",
|
"notesHint": "回车保存,Shift+回车换行",
|
||||||
"addNotesPlaceholder": "在此添加你的备注...",
|
"addNotesPlaceholder": "在此添加你的备注...",
|
||||||
"aboutThisVersion": "关于此版本"
|
"aboutThisVersion": "关于此版本",
|
||||||
|
"baseModelSearchPlaceholder": "搜索基础模型…",
|
||||||
|
"baseModelSuggested": "推荐",
|
||||||
|
"baseModelNoMatch": "没有匹配的基础模型"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "备注保存成功",
|
"saved": "备注保存成功",
|
||||||
@@ -1964,7 +2017,8 @@
|
|||||||
"imagesCompleted": "示例图片{action}完成",
|
"imagesCompleted": "示例图片{action}完成",
|
||||||
"imagesFailed": "示例图片{action}失败",
|
"imagesFailed": "示例图片{action}失败",
|
||||||
"loadError": "加载下载项出错:{message}",
|
"loadError": "加载下载项出错:{message}",
|
||||||
"downloadError": "下载错误:{message}"
|
"downloadError": "下载错误:{message}",
|
||||||
|
"downloadStopped": "下载已取消"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "加载文件夹树失败",
|
"folderTreeFailed": "加载文件夹树失败",
|
||||||
@@ -2009,6 +2063,8 @@
|
|||||||
"contentRatingFailed": "设置内容评级失败:{message}",
|
"contentRatingFailed": "设置内容评级失败:{message}",
|
||||||
"relinkSuccess": "模型已成功重新关联到 Civitai",
|
"relinkSuccess": "模型已成功重新关联到 Civitai",
|
||||||
"relinkFailed": "错误:{message}",
|
"relinkFailed": "错误:{message}",
|
||||||
|
"linkHfSuccess": "模型已成功链接到 HuggingFace",
|
||||||
|
"linkHfFailed": "错误:{message}",
|
||||||
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
|
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
|
||||||
"noCivitaiInfo": "无 CivitAI 信息",
|
"noCivitaiInfo": "无 CivitAI 信息",
|
||||||
"missingHash": "模型哈希不可用"
|
"missingHash": "模型哈希不可用"
|
||||||
@@ -2070,6 +2126,12 @@
|
|||||||
"moveFailed": "Failed to move item: {message}",
|
"moveFailed": "Failed to move item: {message}",
|
||||||
"copiedToClipboard": "已复制到剪贴板",
|
"copiedToClipboard": "已复制到剪贴板",
|
||||||
"downloadStarted": "下载已开始"
|
"downloadStarted": "下载已开始"
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"llmNotConfigured": "AI 提供商未配置。请在 设置 → AI 提供商 中进行配置。",
|
||||||
|
"enrichStarted": "正在使用 AI 增强元数据...",
|
||||||
|
"enrichComplete": "元数据增强完成:{{summary}}",
|
||||||
|
"enrichFailed": "元数据增强失败:{{error}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
+71
-9
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "移除收藏",
|
"removeFromFavorites": "移除收藏",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"notAvailableFromCivitai": "Civitai 不提供",
|
"notAvailableFromCivitai": "Civitai 不提供",
|
||||||
|
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||||
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
|
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
|
||||||
"copyLoRASyntax": "複製 LoRA 語法",
|
"copyLoRASyntax": "複製 LoRA 語法",
|
||||||
"checkpointNameCopied": "Checkpoint 名稱已複製",
|
"checkpointNameCopied": "Checkpoint 名稱已複製",
|
||||||
@@ -504,7 +505,9 @@
|
|||||||
"saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
|
"saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
|
||||||
"saveError": "更新額外資料夾路徑失敗:{message}",
|
"saveError": "更新額外資料夾路徑失敗:{message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "此路徑已設定"
|
"duplicatePath": "此路徑已設定",
|
||||||
|
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路徑:{paths}",
|
||||||
|
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -656,6 +659,32 @@
|
|||||||
"proxyPassword": "密碼(選填)",
|
"proxyPassword": "密碼(選填)",
|
||||||
"proxyPasswordPlaceholder": "password",
|
"proxyPasswordPlaceholder": "password",
|
||||||
"proxyPasswordHelp": "代理驗證所需的密碼(如有需要)"
|
"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": {
|
"loras": {
|
||||||
@@ -753,12 +782,15 @@
|
|||||||
"completed": "完成:已移動 {success},已略過 {skipped},失敗 {failures}",
|
"completed": "完成:已移動 {success},已略過 {skipped},失敗 {failures}",
|
||||||
"complete": "自動整理完成",
|
"complete": "自動整理完成",
|
||||||
"error": "錯誤:{error}"
|
"error": "錯誤:{error}"
|
||||||
}
|
},
|
||||||
|
"enrichHfAgent": "AI HF 中繼資料增強"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "刷新 Civitai 資料",
|
"refreshMetadata": "刷新 Civitai 資料",
|
||||||
"checkUpdates": "檢查更新",
|
"checkUpdates": "檢查更新",
|
||||||
"relinkCivitai": "重新連結 Civitai",
|
"linkModel": "連結模型",
|
||||||
|
"linkCivitai": "連結到 Civitai",
|
||||||
|
"linkHuggingFace": "連結到 HuggingFace",
|
||||||
"copySyntax": "複製 LoRA 語法",
|
"copySyntax": "複製 LoRA 語法",
|
||||||
"copyFilename": "複製模型檔名",
|
"copyFilename": "複製模型檔名",
|
||||||
"copyRecipeSyntax": "複製配方語法",
|
"copyRecipeSyntax": "複製配方語法",
|
||||||
@@ -777,7 +809,8 @@
|
|||||||
"shareRecipe": "分享配方",
|
"shareRecipe": "分享配方",
|
||||||
"viewAllLoras": "檢視全部 LoRA",
|
"viewAllLoras": "檢視全部 LoRA",
|
||||||
"downloadMissingLoras": "下載缺少的 LoRA",
|
"downloadMissingLoras": "下載缺少的 LoRA",
|
||||||
"deleteRecipe": "刪除配方"
|
"deleteRecipe": "刪除配方",
|
||||||
|
"enrichHfAgent": "AI HF 中繼資料增強"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -1134,7 +1167,10 @@
|
|||||||
"titleWithType": "從網址下載 {type}",
|
"titleWithType": "從網址下載 {type}",
|
||||||
"civitaiUrl": "Civitai 網址:",
|
"civitaiUrl": "Civitai 網址:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "每行輸入一個 CivitAI 或 CivArchive URL。支援批量下載多個 URL。",
|
"urlHint": "每行輸入一個 CivitAI、CivArchive 或 Hugging Face URL。支援批量下載多個 URL。",
|
||||||
|
"selectHfFiles": "選擇從此倉庫下載的檔案:",
|
||||||
|
"selectAll": "全選",
|
||||||
|
"fetchingRepoFiles": "正在獲取倉庫檔案...",
|
||||||
"locationPreview": "下載位置預覽",
|
"locationPreview": "下載位置預覽",
|
||||||
"useDefaultPath": "使用預設路徑",
|
"useDefaultPath": "使用預設路徑",
|
||||||
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
|
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
|
||||||
@@ -1163,13 +1199,17 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Civitai 網址格式無效",
|
"invalidUrl": "Civitai 網址格式無效",
|
||||||
"noVersions": "此模型無可用版本"
|
"noVersions": "此模型無可用版本",
|
||||||
|
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
|
||||||
|
"noModelFiles": "在此倉庫中未找到模型檔案。"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "準備下載中...",
|
"preparing": "準備下載中...",
|
||||||
"downloadedPreview": "已下載預覽圖片",
|
"downloadedPreview": "已下載預覽圖片",
|
||||||
"downloadingFile": "正在下載 {type} 檔案",
|
"downloadingFile": "正在下載 {type} 檔案",
|
||||||
"finalizing": "完成下載中..."
|
"finalizing": "完成下載中...",
|
||||||
|
"cancelling": "取消下載中...",
|
||||||
|
"cancelled": "下載已取消"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "目前檔案:",
|
"currentFile": "目前檔案:",
|
||||||
@@ -1285,6 +1325,14 @@
|
|||||||
"pathPlaceholder": "輸入資料夾路徑或從下方樹狀結構選擇...",
|
"pathPlaceholder": "輸入資料夾路徑或從下方樹狀結構選擇...",
|
||||||
"root": "根目錄"
|
"root": "根目錄"
|
||||||
},
|
},
|
||||||
|
"linkHuggingFace": {
|
||||||
|
"title": "連結到 HuggingFace",
|
||||||
|
"infoText": "貼上 HuggingFace 倉庫 URL 以關聯此模型。關聯後可啟用 AI 中繼資料增強功能。",
|
||||||
|
"urlLabel": "HuggingFace 倉庫 URL:",
|
||||||
|
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||||
|
"helpText": "請輸入完整的 HuggingFace 倉庫 URL。",
|
||||||
|
"confirmAction": "儲存並連結"
|
||||||
|
},
|
||||||
"relinkCivitai": {
|
"relinkCivitai": {
|
||||||
"title": "重新連結至 Civitai",
|
"title": "重新連結至 Civitai",
|
||||||
"warning": "警告:",
|
"warning": "警告:",
|
||||||
@@ -1314,6 +1362,8 @@
|
|||||||
"editVersionName": "編輯版本名稱",
|
"editVersionName": "編輯版本名稱",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"viewOnCivitaiText": "在 Civitai 查看",
|
"viewOnCivitaiText": "在 Civitai 查看",
|
||||||
|
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||||
|
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||||
"viewCreatorProfile": "查看創作者個人檔案",
|
"viewCreatorProfile": "查看創作者個人檔案",
|
||||||
"openFileLocation": "開啟檔案位置",
|
"openFileLocation": "開啟檔案位置",
|
||||||
"sendToWorkflow": "傳送到 ComfyUI",
|
"sendToWorkflow": "傳送到 ComfyUI",
|
||||||
@@ -1339,7 +1389,10 @@
|
|||||||
"additionalNotes": "附加備註",
|
"additionalNotes": "附加備註",
|
||||||
"notesHint": "按 Enter 儲存,Shift+Enter 換行",
|
"notesHint": "按 Enter 儲存,Shift+Enter 換行",
|
||||||
"addNotesPlaceholder": "在此新增備註...",
|
"addNotesPlaceholder": "在此新增備註...",
|
||||||
"aboutThisVersion": "關於此版本"
|
"aboutThisVersion": "關於此版本",
|
||||||
|
"baseModelSearchPlaceholder": "搜尋基礎模型…",
|
||||||
|
"baseModelSuggested": "推薦",
|
||||||
|
"baseModelNoMatch": "沒有符合的基礎模型"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "備註已儲存",
|
"saved": "備註已儲存",
|
||||||
@@ -1964,7 +2017,8 @@
|
|||||||
"imagesCompleted": "範例圖片{action}完成",
|
"imagesCompleted": "範例圖片{action}完成",
|
||||||
"imagesFailed": "範例圖片{action}失敗",
|
"imagesFailed": "範例圖片{action}失敗",
|
||||||
"loadError": "載入下載時發生錯誤:{message}",
|
"loadError": "載入下載時發生錯誤:{message}",
|
||||||
"downloadError": "下載錯誤:{message}"
|
"downloadError": "下載錯誤:{message}",
|
||||||
|
"downloadStopped": "下載已取消"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "載入資料夾樹狀結構失敗",
|
"folderTreeFailed": "載入資料夾樹狀結構失敗",
|
||||||
@@ -2009,6 +2063,8 @@
|
|||||||
"contentRatingFailed": "設定內容分級失敗:{message}",
|
"contentRatingFailed": "設定內容分級失敗:{message}",
|
||||||
"relinkSuccess": "模型已成功重新連結至 Civitai",
|
"relinkSuccess": "模型已成功重新連結至 Civitai",
|
||||||
"relinkFailed": "錯誤:{message}",
|
"relinkFailed": "錯誤:{message}",
|
||||||
|
"linkHfSuccess": "模型已成功連結到 HuggingFace",
|
||||||
|
"linkHfFailed": "錯誤:{message}",
|
||||||
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
|
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
|
||||||
"noCivitaiInfo": "無 CivitAI 資訊",
|
"noCivitaiInfo": "無 CivitAI 資訊",
|
||||||
"missingHash": "模型雜湊不可用"
|
"missingHash": "模型雜湊不可用"
|
||||||
@@ -2070,6 +2126,12 @@
|
|||||||
"moveFailed": "Failed to move item: {message}",
|
"moveFailed": "Failed to move item: {message}",
|
||||||
"copiedToClipboard": "已複製到剪貼簿",
|
"copiedToClipboard": "已複製到剪貼簿",
|
||||||
"downloadStarted": "下載已開始"
|
"downloadStarted": "下載已開始"
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"llmNotConfigured": "AI 提供者尚未設定。請在 設定 → AI 提供者 中進行設定。",
|
||||||
|
"enrichStarted": "正在使用 AI 增強中繼資料...",
|
||||||
|
"enrichComplete": "中繼資料增強完成:{{summary}}",
|
||||||
|
"enrichFailed": "中繼資料增強失敗:{{error}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
+68
-8
@@ -8,6 +8,8 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
|
|||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import sys as _sys
|
||||||
|
import types as _types
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
|
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
|
# Load extra folder paths from active library settings before symlink scan
|
||||||
# so both primary and extra paths are discovered in a single pass.
|
# 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
|
# Scan symbolic links during initialization
|
||||||
self._initialize_symlink_mappings()
|
self._initialize_symlink_mappings()
|
||||||
@@ -191,7 +192,7 @@ class Config:
|
|||||||
Called during ``Config.__init__`` before the symlink scan so both primary and
|
Called during ``Config.__init__`` before the symlink scan so both primary and
|
||||||
extra paths are discovered in a single pass. Mirrors the extra-path
|
extra paths are discovered in a single pass. Mirrors the extra-path
|
||||||
portion of ``_apply_library_paths`` without replacing the primary roots
|
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:
|
try:
|
||||||
from .services.settings_manager import get_settings_manager
|
from .services.settings_manager import get_settings_manager
|
||||||
@@ -207,6 +208,12 @@ class Config:
|
|||||||
if not isinstance(library_config, dict):
|
if not isinstance(library_config, dict):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Always read recipes_path — it is independent of extra folder paths
|
||||||
|
# and must be set before any early returns below.
|
||||||
|
recipes_path = library_config.get("recipes_path", "")
|
||||||
|
if isinstance(recipes_path, str) and recipes_path:
|
||||||
|
self.recipes_path = recipes_path
|
||||||
|
|
||||||
extra_folder_paths = library_config.get("extra_folder_paths")
|
extra_folder_paths = library_config.get("extra_folder_paths")
|
||||||
if not isinstance(extra_folder_paths, dict):
|
if not isinstance(extra_folder_paths, dict):
|
||||||
return
|
return
|
||||||
@@ -232,10 +239,6 @@ class Config:
|
|||||||
extra_embedding
|
extra_embedding
|
||||||
)
|
)
|
||||||
|
|
||||||
recipes_path = library_config.get("recipes_path", "")
|
|
||||||
if isinstance(recipes_path, str) and recipes_path:
|
|
||||||
self.recipes_path = recipes_path
|
|
||||||
|
|
||||||
if self.extra_loras_roots:
|
if self.extra_loras_roots:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Found extra LoRA roots:"
|
"Found extra LoRA roots:"
|
||||||
@@ -356,6 +359,47 @@ class Config:
|
|||||||
"Failed to rename legacy 'default' library: %s", rename_error
|
"Failed to rename legacy 'default' library: %s", rename_error
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Clean up a stale "default" library entry that has no meaningful
|
||||||
|
# paths configured (e.g. leftover bootstrap artifact). This only
|
||||||
|
# fires when "comfyui" already exists so we never delete the last
|
||||||
|
# remaining library.
|
||||||
|
if (
|
||||||
|
"default" in libraries
|
||||||
|
and "comfyui" in libraries
|
||||||
|
and isinstance(default_library, Mapping)
|
||||||
|
):
|
||||||
|
default_folder_paths = _normalize_library_folder_paths(
|
||||||
|
default_library
|
||||||
|
)
|
||||||
|
default_extra_paths = default_library.get("extra_folder_paths", {})
|
||||||
|
has_meaningful_paths = bool(default_folder_paths) or bool(
|
||||||
|
default_extra_paths
|
||||||
|
) or any(
|
||||||
|
default_library.get(key)
|
||||||
|
for key in (
|
||||||
|
"default_lora_root",
|
||||||
|
"default_checkpoint_root",
|
||||||
|
"default_unet_root",
|
||||||
|
"default_embedding_root",
|
||||||
|
"recipes_path",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not has_meaningful_paths:
|
||||||
|
try:
|
||||||
|
settings_service.delete_library("default")
|
||||||
|
libraries_changed = True
|
||||||
|
logger.info(
|
||||||
|
"Removed stale 'default' library entry "
|
||||||
|
"with no meaningful paths configured"
|
||||||
|
)
|
||||||
|
libraries = settings_service.get_libraries()
|
||||||
|
comfy_library = libraries.get("comfyui", {})
|
||||||
|
except Exception as delete_error:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to remove stale 'default' library: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
default_lora_root = _resolve_valid_default_root(
|
default_lora_root = _resolve_valid_default_root(
|
||||||
comfy_library.get("default_lora_root", ""),
|
comfy_library.get("default_lora_root", ""),
|
||||||
list(self.loras_roots or []),
|
list(self.loras_roots or []),
|
||||||
@@ -1380,4 +1424,20 @@ class Config:
|
|||||||
|
|
||||||
|
|
||||||
# Global config instance
|
# 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
|
||||||
|
|||||||
@@ -208,6 +208,10 @@ class LoraManager:
|
|||||||
# Initialize WebSocket manager
|
# Initialize WebSocket manager
|
||||||
await ServiceRegistry.get_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
|
# Initialize scanners in background
|
||||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
@@ -445,5 +449,12 @@ class LoraManager:
|
|||||||
scanner.cancel_task()
|
scanner.cancel_task()
|
||||||
logger.debug("LoRA Manager: Cancelled %s", name)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -41,7 +41,12 @@ async def api_json_error(
|
|||||||
if exc.status < 400:
|
if exc.status < 400:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.warning(
|
# 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",
|
"API %s %s returned HTTP %d: %s",
|
||||||
request.method,
|
request.method,
|
||||||
request.path,
|
request.path,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Lora Info display node — pure frontend node for showing selected LoRA info.
|
||||||
|
|
||||||
|
This node does NOT participate in workflow execution. Its single optional
|
||||||
|
"lora_source" input exists solely as a wire-connection anchor so that the
|
||||||
|
frontend can traverse the graph and push selection data to connected info nodes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class LoraInfoLM:
|
||||||
|
"""Display node that shows filename and notes for the selected LoRA."""
|
||||||
|
|
||||||
|
NAME = "Lora Info (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/utils"
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Displays information (filename, notes) about the currently selected "
|
||||||
|
"LoRA. Connect any output from a LoRA Loader or Stacker to the "
|
||||||
|
"lora_source input, then select a LoRA in the source widget — the "
|
||||||
|
"info updates automatically. Does not affect workflow execution."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ()
|
||||||
|
RETURN_NAMES = ()
|
||||||
|
OUTPUT_NODE = False
|
||||||
|
FUNCTION = "noop"
|
||||||
|
|
||||||
|
def noop(self, **kwargs):
|
||||||
|
# This node is display-only — no workflow execution needed.
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
LoraInfoLM.NAME: LoraInfoLM,
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
LoraInfoLM.NAME: "Lora Info (LoraManager)",
|
||||||
|
}
|
||||||
+2
-17
@@ -1,6 +1,5 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
|
|
||||||
import comfy.sd # type: ignore
|
import comfy.sd # type: ignore
|
||||||
import comfy.utils # type: ignore
|
import comfy.utils # type: ignore
|
||||||
@@ -14,6 +13,7 @@ from .utils import (
|
|||||||
extract_lora_name,
|
extract_lora_name,
|
||||||
get_loras_list,
|
get_loras_list,
|
||||||
nunchaku_load_lora,
|
nunchaku_load_lora,
|
||||||
|
parse_lora_syntax,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -189,25 +189,10 @@ class LoraTextLoaderLM:
|
|||||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||||
FUNCTION = "load_loras_from_text"
|
FUNCTION = "load_loras_from_text"
|
||||||
|
|
||||||
def parse_lora_syntax(self, text):
|
|
||||||
"""Parse LoRA syntax from text input."""
|
|
||||||
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
|
|
||||||
matches = re.findall(pattern, text, re.IGNORECASE)
|
|
||||||
|
|
||||||
loras = []
|
|
||||||
for match in matches:
|
|
||||||
model_strength = float(match[1])
|
|
||||||
loras.append({
|
|
||||||
"name": match[0],
|
|
||||||
"model_strength": model_strength,
|
|
||||||
"clip_strength": float(match[2]) if match[2] else model_strength,
|
|
||||||
})
|
|
||||||
return loras
|
|
||||||
|
|
||||||
def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None):
|
def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None):
|
||||||
"""Load LoRAs based on text syntax input."""
|
"""Load LoRAs based on text syntax input."""
|
||||||
lora_entries = _collect_stack_entries(lora_stack)
|
lora_entries = _collect_stack_entries(lora_stack)
|
||||||
for lora in self.parse_lora_syntax(lora_syntax):
|
for lora in parse_lora_syntax(lora_syntax):
|
||||||
lora_path, trigger_words = get_lora_info_absolute(lora["name"])
|
lora_path, trigger_words = get_lora_info_absolute(lora["name"])
|
||||||
lora_entries.append({
|
lora_entries.append({
|
||||||
"name": lora["name"],
|
"name": lora["name"],
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Node to resolve `<lora:name:strength>` syntax to absolute file system paths.
|
||||||
|
|
||||||
|
Takes the loaded_loras / active_loras STRING output from LoraLoaderLM or
|
||||||
|
LoraStackerLM and resolves each lora name to its absolute path on disk via
|
||||||
|
the scanner cache. Unknown names are returned as-is.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..utils.utils import get_lora_info_absolute
|
||||||
|
from .utils import parse_lora_syntax
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoraSyntaxToPath:
|
||||||
|
NAME = "LoRA Syntax → Path (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/utils"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"lora_syntax": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"forceInput": True,
|
||||||
|
"multiline": True,
|
||||||
|
"tooltip": (
|
||||||
|
"<lora:name:strength> formatted text from "
|
||||||
|
"loaded_loras / active_loras output"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("STRING",)
|
||||||
|
RETURN_NAMES = ("paths",)
|
||||||
|
FUNCTION = "resolve"
|
||||||
|
|
||||||
|
def resolve(self, lora_syntax: str) -> tuple[str]:
|
||||||
|
"""Parse <lora:...> syntax and resolve each name to its absolute path."""
|
||||||
|
if not lora_syntax or not lora_syntax.strip():
|
||||||
|
logger.info("Received empty lora_syntax input")
|
||||||
|
return ("",)
|
||||||
|
|
||||||
|
parsed = parse_lora_syntax(lora_syntax)
|
||||||
|
if not parsed:
|
||||||
|
logger.info("No valid <lora:...> entries found in input")
|
||||||
|
return ("",)
|
||||||
|
|
||||||
|
paths: list[str] = []
|
||||||
|
for entry in parsed:
|
||||||
|
try:
|
||||||
|
absolute_path, _ = get_lora_info_absolute(entry["name"])
|
||||||
|
paths.append(absolute_path)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to resolve lora '%s', skipping", entry["name"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
return ("\n".join(paths),)
|
||||||
@@ -36,6 +36,7 @@ any_type = AnyType("*")
|
|||||||
|
|
||||||
# Common methods extracted from lora_loader.py and lora_stacker.py
|
# Common methods extracted from lora_loader.py and lora_stacker.py
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import logging
|
import logging
|
||||||
import copy
|
import copy
|
||||||
import sys
|
import sys
|
||||||
@@ -69,6 +70,25 @@ def extract_lora_name(lora_path):
|
|||||||
return apply_lora_syntax_format(name_no_ext)
|
return apply_lora_syntax_format(name_no_ext)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lora_syntax(text: str) -> list[dict]:
|
||||||
|
"""Parse <lora:name:strength> syntax from text input into a list of dicts.
|
||||||
|
|
||||||
|
Each entry contains: name, model_strength, clip_strength.
|
||||||
|
Supports both ``<lora:name:strength>`` and ``<lora:name:model_strength:clip_strength>``.
|
||||||
|
"""
|
||||||
|
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
|
||||||
|
matches = re.findall(pattern, text, re.IGNORECASE)
|
||||||
|
loras = []
|
||||||
|
for match in matches:
|
||||||
|
model_strength = float(match[1])
|
||||||
|
loras.append({
|
||||||
|
"name": match[0],
|
||||||
|
"model_strength": model_strength,
|
||||||
|
"clip_strength": float(match[2]) if match[2] else model_strength,
|
||||||
|
})
|
||||||
|
return loras
|
||||||
|
|
||||||
|
|
||||||
def get_loras_list(kwargs):
|
def get_loras_list(kwargs):
|
||||||
"""Helper to extract loras list from either old or new kwargs format"""
|
"""Helper to extract loras list from either old or new kwargs format"""
|
||||||
if "loras" not in kwargs:
|
if "loras" not in kwargs:
|
||||||
|
|||||||
@@ -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.websocket_manager import ws_manager
|
||||||
from ...services.downloader import get_downloader
|
from ...services.downloader import get_downloader
|
||||||
from ...services.errors import ResourceNotFoundError
|
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 ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||||
from ...utils.models import BaseModelMetadata
|
from ...utils.models import BaseModelMetadata
|
||||||
from ...utils.constants import (
|
from ...utils.constants import (
|
||||||
@@ -48,6 +54,8 @@ from ...utils.constants import (
|
|||||||
SUPPORTED_MEDIA_EXTENSIONS,
|
SUPPORTED_MEDIA_EXTENSIONS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
)
|
)
|
||||||
|
from .hf_handlers import HfHandler
|
||||||
|
from .agent_handlers import AgentHandler
|
||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
from ...utils.example_images_paths import (
|
from ...utils.example_images_paths import (
|
||||||
find_non_compliant_items_in_example_images_root,
|
find_non_compliant_items_in_example_images_root,
|
||||||
@@ -565,12 +573,18 @@ class NodeRegistry:
|
|||||||
tab_nodes[nd["unique_id"]] = nd
|
tab_nodes[nd["unique_id"]] = nd
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
prev_count = len(self._tab_nodes.get(sid, {}))
|
||||||
self._tab_nodes[sid] = tab_nodes
|
self._tab_nodes[sid] = tab_nodes
|
||||||
self._waiting_clients.discard(sid)
|
self._waiting_clients.discard(sid)
|
||||||
if not self._waiting_clients:
|
if not self._waiting_clients:
|
||||||
self._ready.set()
|
self._ready.set()
|
||||||
|
total_tabs = len(self._tab_nodes)
|
||||||
|
|
||||||
logger.debug("Registered %s nodes from client %s", len(nodes), sid)
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
def prepare_for_refresh(self, active_sids: list[str]) -> None:
|
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."""
|
"""Set the list of client IDs we expect to hear from during the next refresh cycle."""
|
||||||
@@ -593,10 +607,17 @@ class NodeRegistry:
|
|||||||
longer connected."""
|
longer connected."""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
# Garbage-collect stale entries (disconnected tabs)
|
# Garbage-collect stale entries (disconnected tabs)
|
||||||
|
stale_sids = []
|
||||||
if active_sids is not None:
|
if active_sids is not None:
|
||||||
for sid in list(self._tab_nodes):
|
for sid in list(self._tab_nodes):
|
||||||
if sid not in active_sids:
|
if sid not in active_sids:
|
||||||
|
stale_sids.append(sid)
|
||||||
del self._tab_nodes[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] = {}
|
merged: dict[str, dict] = {}
|
||||||
tab_info: dict[str, dict] = {}
|
tab_info: dict[str, dict] = {}
|
||||||
@@ -1398,8 +1419,9 @@ class SettingsHandler:
|
|||||||
"libraries",
|
"libraries",
|
||||||
"active_library",
|
"active_library",
|
||||||
# Sensitive — never expose the actual value to the frontend;
|
# Sensitive — never expose the actual value to the frontend;
|
||||||
# frontend receives a boolean instead (civitai_api_key_set).
|
# frontend receives a boolean instead (*_set).
|
||||||
"civitai_api_key",
|
"civitai_api_key",
|
||||||
|
"llm_api_key",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1457,6 +1479,8 @@ class SettingsHandler:
|
|||||||
# Sensitive fields: only expose a boolean indicating whether set
|
# Sensitive fields: only expose a boolean indicating whether set
|
||||||
raw_key = self._settings.get("civitai_api_key")
|
raw_key = self._settings.get("civitai_api_key")
|
||||||
response_data["civitai_api_key_set"] = bool(raw_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)
|
settings_file = getattr(self._settings, "settings_file", None)
|
||||||
if settings_file:
|
if settings_file:
|
||||||
response_data["settings_file"] = settings_file
|
response_data["settings_file"] = settings_file
|
||||||
@@ -1561,6 +1585,42 @@ class SettingsHandler:
|
|||||||
logger.error("Error updating settings: %s", exc, exc_info=True)
|
logger.error("Error updating settings: %s", exc, exc_info=True)
|
||||||
return web.Response(status=500, text=str(exc))
|
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:
|
def _validate_example_images_path(self, folder_path: str) -> str | None:
|
||||||
if not os.path.exists(folder_path):
|
if not os.path.exists(folder_path):
|
||||||
return f"Path does not exist: {folder_path}"
|
return f"Path does not exist: {folder_path}"
|
||||||
@@ -1583,6 +1643,20 @@ class SettingsHandler:
|
|||||||
def _is_dedicated_example_images_folder(self, folder_path: str) -> bool:
|
def _is_dedicated_example_images_folder(self, folder_path: str) -> bool:
|
||||||
return is_valid_example_images_root(folder_path)
|
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:
|
class UsageStatsHandler:
|
||||||
def __init__(self, usage_stats_factory: UsageStatsFactory = UsageStats) -> None:
|
def __init__(self, usage_stats_factory: UsageStatsFactory = UsageStats) -> None:
|
||||||
@@ -1710,6 +1784,124 @@ class LoraCodeHandler:
|
|||||||
logger.error("Failed to update lora code: %s", exc, exc_info=True)
|
logger.error("Failed to update lora code: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_update_lora_code(self, request: web.Request) -> web.Response:
|
||||||
|
"""GET version of update_lora_code — reads parameters from query string.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
lora_code (required) — the LoRA syntax to send
|
||||||
|
mode (optional) — "append" (default) or "replace"
|
||||||
|
node_id (repeatable) — target node id(s), e.g. node_id=3&node_id=5
|
||||||
|
node_ids (optional) — JSON-encoded array for complex references with graph_id:
|
||||||
|
[{"node_id":3,"graph_id":"g1"}, ...]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
node_ids_raw = request.query.get("node_ids")
|
||||||
|
node_id_list = request.query.getall("node_id", [])
|
||||||
|
lora_code = request.query.get("lora_code", "")
|
||||||
|
mode = request.query.get("mode", "append")
|
||||||
|
|
||||||
|
if not lora_code:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing lora_code parameter"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
node_ids = None
|
||||||
|
if node_ids_raw:
|
||||||
|
try:
|
||||||
|
node_ids = json.loads(node_ids_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "node_ids must be a valid JSON array"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
if not isinstance(node_ids, list) or not node_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "node_ids must be a non-empty JSON array"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
elif node_id_list:
|
||||||
|
node_ids = node_id_list
|
||||||
|
|
||||||
|
results = []
|
||||||
|
if node_ids is None:
|
||||||
|
try:
|
||||||
|
self._prompt_server.instance.send_sync(
|
||||||
|
"lora_code_update",
|
||||||
|
{"id": -1, "lora_code": lora_code, "mode": mode},
|
||||||
|
)
|
||||||
|
results.append({"node_id": "broadcast", "success": True})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Error broadcasting lora code: %s", exc)
|
||||||
|
results.append(
|
||||||
|
{"node_id": "broadcast", "success": False, "error": str(exc)}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for entry in node_ids:
|
||||||
|
node_identifier = entry
|
||||||
|
graph_identifier = None
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
node_identifier = entry.get("node_id")
|
||||||
|
graph_identifier = entry.get("graph_id")
|
||||||
|
|
||||||
|
if node_identifier is None:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"node_id": node_identifier,
|
||||||
|
"graph_id": graph_identifier,
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing node_id parameter",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_node_id = int(node_identifier)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed_node_id = node_identifier
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id": parsed_node_id,
|
||||||
|
"lora_code": lora_code,
|
||||||
|
"mode": mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
if graph_identifier is not None:
|
||||||
|
payload["graph_id"] = str(graph_identifier)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._prompt_server.instance.send_sync(
|
||||||
|
"lora_code_update",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"node_id": parsed_node_id,
|
||||||
|
"graph_id": payload.get("graph_id"),
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Error sending lora code to node %s (graph %s): %s",
|
||||||
|
parsed_node_id,
|
||||||
|
graph_identifier,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"node_id": parsed_node_id,
|
||||||
|
"graph_id": payload.get("graph_id"),
|
||||||
|
"success": False,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "results": results})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to update lora code (GET): %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class TrainedWordsHandler:
|
class TrainedWordsHandler:
|
||||||
async def get_trained_words(self, request: web.Request) -> web.Response:
|
async def get_trained_words(self, request: web.Request) -> web.Response:
|
||||||
@@ -3055,6 +3247,8 @@ class NodeRegistryHandler:
|
|||||||
self._node_registry = node_registry
|
self._node_registry = node_registry
|
||||||
self._prompt_server = prompt_server
|
self._prompt_server = prompt_server
|
||||||
self._standalone_mode = standalone_mode
|
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:
|
async def register_nodes(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
@@ -3101,7 +3295,12 @@ class NodeRegistryHandler:
|
|||||||
)
|
)
|
||||||
graph_name = node.get("graph_name")
|
graph_name = node.get("graph_name")
|
||||||
try:
|
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):
|
except (TypeError, ValueError):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@@ -3142,42 +3341,101 @@ class NodeRegistryHandler:
|
|||||||
status=503,
|
status=503,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Snapshot of currently-connected ComfyUI tabs
|
|
||||||
active_sids = list(self._prompt_server.instance.sockets.keys())
|
|
||||||
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=2.0):
|
|
||||||
logger.warning(
|
|
||||||
"Registry refresh timeout after 2s (%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())
|
current_sids = set(self._prompt_server.instance.sockets.keys())
|
||||||
|
|
||||||
|
# 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(
|
registry_info = await self._node_registry.get_merged_registry(
|
||||||
active_sids=current_sids
|
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:
|
if registry_info["node_count"] == 0:
|
||||||
logger.warning("No nodes registered after refresh")
|
logger.debug(
|
||||||
|
"[LM:Registry] refresh OK — %s connected tab(s) but 0 compatible nodes found",
|
||||||
|
registry_info["tab_count"],
|
||||||
|
)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -3213,7 +3471,7 @@ class NodeRegistryHandler:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(value, str) or not value:
|
if value is None or (isinstance(value, str) and not value):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Missing value parameter"}, status=400
|
{"success": False, "error": "Missing value parameter"}, status=400
|
||||||
)
|
)
|
||||||
@@ -3291,6 +3549,130 @@ class NodeRegistryHandler:
|
|||||||
logger.error("Failed to update node widget: %s", exc, exc_info=True)
|
logger.error("Failed to update node widget: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_update_node_widget(self, request: web.Request) -> web.Response:
|
||||||
|
"""GET version of update_node_widget — reads parameters from query string.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
widget_name (optional) — the widget name to update (required unless action is set)
|
||||||
|
action (optional) — alternative action, e.g. "inject_text" (required unless widget_name is set)
|
||||||
|
value (required) — the value to set
|
||||||
|
mode (optional) — "replace" (default) or "append"
|
||||||
|
node_id (repeatable) — target node id(s), e.g. node_id=3&node_id=5
|
||||||
|
node_ids (optional) — JSON-encoded array for complex references:
|
||||||
|
[{"node_id":3,"graph_id":"g1"}, ...]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
widget_name = request.query.get("widget_name")
|
||||||
|
action = request.query.get("action")
|
||||||
|
value = request.query.get("value")
|
||||||
|
mode = request.query.get("mode", "replace")
|
||||||
|
node_ids_raw = request.query.get("node_ids")
|
||||||
|
node_id_list = request.query.getall("node_id", [])
|
||||||
|
|
||||||
|
if not action and (not isinstance(widget_name, str) or not widget_name):
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing parameter: provide either 'action' or 'widget_name'",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if value is None or (isinstance(value, str) and not value):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing value parameter"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
node_ids = None
|
||||||
|
if node_ids_raw:
|
||||||
|
try:
|
||||||
|
node_ids = json.loads(node_ids_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "node_ids must be a valid JSON array"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
if not isinstance(node_ids, list) or not node_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "node_ids must be a non-empty JSON array"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
elif node_id_list:
|
||||||
|
node_ids = node_id_list
|
||||||
|
|
||||||
|
if not isinstance(node_ids, list) or not node_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "node_ids must be a non-empty list"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for entry in node_ids:
|
||||||
|
node_identifier = entry
|
||||||
|
graph_identifier = None
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
node_identifier = entry.get("node_id")
|
||||||
|
graph_identifier = entry.get("graph_id")
|
||||||
|
|
||||||
|
if node_identifier is None:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"node_id": node_identifier,
|
||||||
|
"graph_id": graph_identifier,
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing node_id parameter",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_node_id = int(node_identifier)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed_node_id = node_identifier
|
||||||
|
|
||||||
|
payload: dict = {
|
||||||
|
"id": parsed_node_id,
|
||||||
|
"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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._prompt_server.instance.send_sync("lm_widget_update", payload)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"node_id": parsed_node_id,
|
||||||
|
"graph_id": payload.get("graph_id"),
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Error sending widget update to node %s (graph %s): %s",
|
||||||
|
parsed_node_id,
|
||||||
|
graph_identifier,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"node_id": parsed_node_id,
|
||||||
|
"graph_id": payload.get("graph_id"),
|
||||||
|
"success": False,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "results": results})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to update node widget (GET): %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class MiscHandlerSet:
|
class MiscHandlerSet:
|
||||||
"""Aggregate handlers into a lookup compatible with the registrar."""
|
"""Aggregate handlers into a lookup compatible with the registrar."""
|
||||||
@@ -3315,6 +3697,8 @@ class MiscHandlerSet:
|
|||||||
doctor: DoctorHandler,
|
doctor: DoctorHandler,
|
||||||
example_workflows: ExampleWorkflowsHandler,
|
example_workflows: ExampleWorkflowsHandler,
|
||||||
base_model: BaseModelHandlerSet,
|
base_model: BaseModelHandlerSet,
|
||||||
|
hf_handler: HfHandler | None = None,
|
||||||
|
agent_handler: AgentHandler | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.health = health
|
self.health = health
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -3333,6 +3717,8 @@ class MiscHandlerSet:
|
|||||||
self.doctor = doctor
|
self.doctor = doctor
|
||||||
self.example_workflows = example_workflows
|
self.example_workflows = example_workflows
|
||||||
self.base_model = base_model
|
self.base_model = base_model
|
||||||
|
self.hf_handler = hf_handler
|
||||||
|
self.agent_handler = agent_handler
|
||||||
|
|
||||||
def to_route_mapping(
|
def to_route_mapping(
|
||||||
self,
|
self,
|
||||||
@@ -3348,13 +3734,17 @@ class MiscHandlerSet:
|
|||||||
"get_priority_tags": self.settings.get_priority_tags,
|
"get_priority_tags": self.settings.get_priority_tags,
|
||||||
"get_settings_libraries": self.settings.get_libraries,
|
"get_settings_libraries": self.settings.get_libraries,
|
||||||
"activate_library": self.settings.activate_library,
|
"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,
|
"update_usage_stats": self.usage_stats.update_usage_stats,
|
||||||
"get_usage_stats": self.usage_stats.get_usage_stats,
|
"get_usage_stats": self.usage_stats.get_usage_stats,
|
||||||
"update_lora_code": self.lora_code.update_lora_code,
|
"update_lora_code": self.lora_code.update_lora_code,
|
||||||
|
"get_update_lora_code": self.lora_code.get_update_lora_code,
|
||||||
"get_trained_words": self.trained_words.get_trained_words,
|
"get_trained_words": self.trained_words.get_trained_words,
|
||||||
"get_model_example_files": self.model_examples.get_model_example_files,
|
"get_model_example_files": self.model_examples.get_model_example_files,
|
||||||
"register_nodes": self.node_registry.register_nodes,
|
"register_nodes": self.node_registry.register_nodes,
|
||||||
"update_node_widget": self.node_registry.update_node_widget,
|
"update_node_widget": self.node_registry.update_node_widget,
|
||||||
|
"get_update_node_widget": self.node_registry.get_update_node_widget,
|
||||||
"get_registry": self.node_registry.get_registry,
|
"get_registry": self.node_registry.get_registry,
|
||||||
"check_model_exists": self.model_library.check_model_exists,
|
"check_model_exists": self.model_library.check_model_exists,
|
||||||
"check_models_exist": self.model_library.check_models_exist,
|
"check_models_exist": self.model_library.check_models_exist,
|
||||||
@@ -3378,6 +3768,14 @@ class MiscHandlerSet:
|
|||||||
"get_supporters": self.supporters.get_supporters,
|
"get_supporters": self.supporters.get_supporters,
|
||||||
"get_example_workflows": self.example_workflows.get_example_workflows,
|
"get_example_workflows": self.example_workflows.get_example_workflows,
|
||||||
"get_example_workflow": self.example_workflows.get_example_workflow,
|
"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
|
# Base model handlers
|
||||||
"get_base_models": self.base_model.get_base_models,
|
"get_base_models": self.base_model.get_base_models,
|
||||||
"refresh_base_models": self.base_model.refresh_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]
|
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 = {
|
template_context = {
|
||||||
"is_initializing": is_initializing,
|
"is_initializing": is_initializing,
|
||||||
"settings": self._settings,
|
"settings": self._settings,
|
||||||
@@ -161,6 +169,8 @@ class ModelPageView:
|
|||||||
"folders": [],
|
"folders": [],
|
||||||
"t": self._server_i18n.get_translation,
|
"t": self._server_i18n.get_translation,
|
||||||
"version": self._get_app_version(),
|
"version": self._get_app_version(),
|
||||||
|
"provider_presets_json": json.dumps(PROVIDER_PRESETS),
|
||||||
|
"provider_models_json": "{}",
|
||||||
}
|
}
|
||||||
|
|
||||||
if not is_initializing:
|
if not is_initializing:
|
||||||
@@ -203,11 +213,17 @@ class ModelListingHandler:
|
|||||||
result = await self._service.get_paginated_data(**params)
|
result = await self._service.get_paginated_data(**params)
|
||||||
|
|
||||||
format_start = time.perf_counter()
|
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 = {
|
formatted_result = {
|
||||||
"items": [
|
"items": formatted_items,
|
||||||
await self._service.format_response(item)
|
|
||||||
for item in result["items"]
|
|
||||||
],
|
|
||||||
"total": result["total"],
|
"total": result["total"],
|
||||||
"page": result["page"],
|
"page": result["page"],
|
||||||
"page_size": result["page_size"],
|
"page_size": result["page_size"],
|
||||||
@@ -238,11 +254,15 @@ class ModelListingHandler:
|
|||||||
result = await self._service.get_excluded_paginated_data(**params)
|
result = await self._service.get_excluded_paginated_data(**params)
|
||||||
|
|
||||||
format_start = time.perf_counter()
|
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 = {
|
formatted_result = {
|
||||||
"items": [
|
"items": formatted_items,
|
||||||
await self._service.format_response(item)
|
|
||||||
for item in result["items"]
|
|
||||||
],
|
|
||||||
"total": result["total"],
|
"total": result["total"],
|
||||||
"page": result["page"],
|
"page": result["page"],
|
||||||
"page_size": result["page_size"],
|
"page_size": result["page_size"],
|
||||||
@@ -533,8 +553,13 @@ class ModelManagementHandler:
|
|||||||
if not success:
|
if not success:
|
||||||
return web.json_response({"success": False, "error": error})
|
return web.json_response({"success": False, "error": error})
|
||||||
|
|
||||||
formatted_metadata = await self._service.format_response(model_data)
|
formatted = await self._service.format_response(model_data)
|
||||||
return web.json_response({"success": True, "metadata": formatted_metadata})
|
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:
|
except Exception as exc:
|
||||||
if is_expected_offline_error(str(exc)):
|
if is_expected_offline_error(str(exc)):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -1091,10 +1116,12 @@ class ModelQueryHandler:
|
|||||||
# Sort: originals first, copies last
|
# Sort: originals first, copies last
|
||||||
sorted_models = self._sort_duplicate_group(filtered)
|
sorted_models = self._sort_duplicate_group(filtered)
|
||||||
|
|
||||||
# Format response
|
# Format response, filtering out corrupted entries (issue #730)
|
||||||
group = {"hash": sha256, "models": []}
|
group = {"hash": sha256, "models": []}
|
||||||
for model in sorted_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
|
# Only include groups with 2+ models after filtering
|
||||||
if len(group["models"]) > 1:
|
if len(group["models"]) > 1:
|
||||||
@@ -1211,9 +1238,9 @@ class ModelQueryHandler:
|
|||||||
(m for m in cache.raw_data if m["file_path"] == path), None
|
(m for m in cache.raw_data if m["file_path"] == path), None
|
||||||
)
|
)
|
||||||
if model:
|
if model:
|
||||||
group["models"].append(
|
formatted = await self._service.format_response(model)
|
||||||
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)
|
hash_val = self._service.scanner.get_hash_by_filename(filename)
|
||||||
if hash_val:
|
if hash_val:
|
||||||
main_path = self._service.get_path_by_hash(hash_val)
|
main_path = self._service.get_path_by_hash(hash_val)
|
||||||
@@ -1223,9 +1250,9 @@ class ModelQueryHandler:
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if main_model:
|
if main_model:
|
||||||
group["models"].insert(
|
formatted = await self._service.format_response(main_model)
|
||||||
0, await self._service.format_response(main_model)
|
if formatted is not None:
|
||||||
)
|
group["models"].insert(0, formatted)
|
||||||
if group["models"]:
|
if group["models"]:
|
||||||
result.append(group)
|
result.append(group)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -1248,9 +1275,13 @@ class ModelQueryHandler:
|
|||||||
text=f"{self._service.model_type.capitalize()} file name is required",
|
text=f"{self._service.model_type.capitalize()} file name is required",
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
notes = await self._service.get_model_notes(model_name)
|
result = await self._service.get_model_notes(model_name)
|
||||||
if notes is not None:
|
if result is not None:
|
||||||
return web.json_response({"success": True, "notes": notes})
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"notes": result["notes"],
|
||||||
|
"file_path": result["file_path"],
|
||||||
|
})
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -1286,9 +1317,20 @@ class ModelQueryHandler:
|
|||||||
}
|
}
|
||||||
if include_license_flags:
|
if include_license_flags:
|
||||||
model_data = await self._service.get_model_info_by_name(model_name)
|
model_data = await self._service.get_model_info_by_name(model_name)
|
||||||
license_flags = (model_data or {}).get("license_flags")
|
# Only return license_flags when real CivitAI model license
|
||||||
if license_flags is not None:
|
# data exists. This mirrors ModelModal's guard
|
||||||
response_payload["license_flags"] = int(license_flags)
|
# (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
|
# Include the user's license icon style preference so the
|
||||||
# ComfyUI tooltip can pick the right set without a separate
|
# ComfyUI tooltip can pick the right set without a separate
|
||||||
# API call.
|
# API call.
|
||||||
@@ -1745,14 +1787,20 @@ class ModelDownloadHandler:
|
|||||||
|
|
||||||
async def delete_download_history_item(self, request: web.Request) -> web.Response:
|
async def delete_download_history_item(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
item_id = int(request.query.get("id", "0"))
|
download_id = request.query.get("download_id")
|
||||||
if not item_id:
|
id_str = request.query.get("id")
|
||||||
|
item_id = int(id_str) if id_str else None
|
||||||
|
|
||||||
|
if not download_id and not item_id:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "id is required"}, status=400
|
{"success": False, "error": "id or download_id is required"},
|
||||||
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
service = await DownloadQueueService.get_instance()
|
service = await DownloadQueueService.get_instance()
|
||||||
deleted = await service.delete_history_item(item_id)
|
deleted = await service.delete_history_item(
|
||||||
|
id=item_id, download_id=download_id
|
||||||
|
)
|
||||||
return web.json_response({"success": deleted})
|
return web.json_response({"success": deleted})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
@@ -1762,14 +1810,20 @@ class ModelDownloadHandler:
|
|||||||
|
|
||||||
async def retry_download_from_history(self, request: web.Request) -> web.Response:
|
async def retry_download_from_history(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
item_id = int(request.query.get("id", "0"))
|
download_id = request.query.get("download_id")
|
||||||
if not item_id:
|
id_str = request.query.get("id")
|
||||||
|
item_id = int(id_str) if id_str else None
|
||||||
|
|
||||||
|
if not download_id and not item_id:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "id is required"}, status=400
|
{"success": False, "error": "id or download_id is required"},
|
||||||
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
service = await DownloadQueueService.get_instance()
|
service = await DownloadQueueService.get_instance()
|
||||||
item = await service.retry_from_history(item_id)
|
item = await service.retry_from_history(
|
||||||
|
item_id=item_id, download_id=download_id
|
||||||
|
)
|
||||||
if item is None:
|
if item is None:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "History item not found or not retryable"},
|
{"success": False, "error": "History item not found or not retryable"},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -53,6 +54,7 @@ class PreviewHandler:
|
|||||||
|
|
||||||
if not resolved.is_file():
|
if not resolved.is_file():
|
||||||
logger.debug("Preview file not found at %s", str(resolved))
|
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")
|
raise web.HTTPNotFound(text="Preview file not found")
|
||||||
|
|
||||||
# aiohttp's FileResponse handles range requests, content headers, and
|
# aiohttp's FileResponse handles range requests, content headers, and
|
||||||
@@ -69,6 +71,35 @@ class PreviewHandler:
|
|||||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
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(
|
async def _stream_file(
|
||||||
self, request: web.Request, path: Path
|
self, request: web.Request, path: Path
|
||||||
) -> web.StreamResponse:
|
) -> web.StreamResponse:
|
||||||
|
|||||||
@@ -2218,6 +2218,31 @@ class RecipeManagementHandler:
|
|||||||
"Failed to download image for recipe: %s", exc
|
"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 = (
|
prompt = (
|
||||||
(parsed.get("gen_params") or {}).get("prompt") or ""
|
(parsed.get("gen_params") or {}).get("prompt") or ""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class RouteDefinition:
|
|||||||
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||||
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
|
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
|
||||||
RouteDefinition("POST", "/api/lm/settings", "update_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("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
||||||
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
||||||
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
|
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
|
||||||
@@ -37,10 +39,12 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
||||||
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||||
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
|
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
|
||||||
|
RouteDefinition("GET", "/api/lm/update-lora-code", "get_update_lora_code"),
|
||||||
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
|
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
|
||||||
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
|
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
|
||||||
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
|
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
|
||||||
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||||
|
RouteDefinition("GET", "/api/lm/update-node-widget", "get_update_node_widget"),
|
||||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||||
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
|
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
|
||||||
@@ -94,6 +98,26 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
"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,
|
build_service_registry_adapter,
|
||||||
)
|
)
|
||||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
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
|
from .misc_route_registrar import MiscRouteRegistrar
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -136,6 +138,8 @@ class MiscRoutes:
|
|||||||
doctor = DoctorHandler(settings_service=self._settings)
|
doctor = DoctorHandler(settings_service=self._settings)
|
||||||
example_workflows = ExampleWorkflowsHandler()
|
example_workflows = ExampleWorkflowsHandler()
|
||||||
base_model = BaseModelHandlerSet()
|
base_model = BaseModelHandlerSet()
|
||||||
|
hf_handler = HfHandler()
|
||||||
|
agent_handler = AgentHandler()
|
||||||
|
|
||||||
return self._handler_set_factory(
|
return self._handler_set_factory(
|
||||||
health=health,
|
health=health,
|
||||||
@@ -155,6 +159,8 @@ class MiscRoutes:
|
|||||||
doctor=doctor,
|
doctor=doctor,
|
||||||
example_workflows=example_workflows,
|
example_workflows=example_workflows,
|
||||||
base_model=base_model,
|
base_model=base_model,
|
||||||
|
hf_handler=hf_handler,
|
||||||
|
agent_handler=agent_handler,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
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:
|
class UpdateRoutes:
|
||||||
"""Routes for handling plugin update checks"""
|
"""Routes for handling plugin update checks"""
|
||||||
@@ -365,6 +386,8 @@ class UpdateRoutes:
|
|||||||
)
|
)
|
||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
|
clean_excludes = _clean_excludes()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Open the Git repository
|
# Open the Git repository
|
||||||
repo = git.Repo(plugin_root)
|
repo = git.Repo(plugin_root)
|
||||||
@@ -376,8 +399,9 @@ class UpdateRoutes:
|
|||||||
if nightly:
|
if nightly:
|
||||||
# Reset to discard any local changes
|
# Reset to discard any local changes
|
||||||
repo.git.reset('--hard')
|
repo.git.reset('--hard')
|
||||||
# Clean untracked files
|
# Clean untracked files, but preserve user-managed directories
|
||||||
repo.git.clean('-fd')
|
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||||
|
repo.git.clean('-fd', *clean_excludes)
|
||||||
|
|
||||||
# Switch to main branch and pull latest
|
# Switch to main branch and pull latest
|
||||||
main_branch = 'main'
|
main_branch = 'main'
|
||||||
@@ -394,8 +418,9 @@ class UpdateRoutes:
|
|||||||
else:
|
else:
|
||||||
# Reset to discard any local changes
|
# Reset to discard any local changes
|
||||||
repo.git.reset('--hard')
|
repo.git.reset('--hard')
|
||||||
# Clean untracked files
|
# Clean untracked files, but preserve user-managed directories
|
||||||
repo.git.clean('-fd')
|
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||||
|
repo.git.clean('-fd', *clean_excludes)
|
||||||
|
|
||||||
# Get latest release tag
|
# Get latest release tag
|
||||||
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
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
@@ -201,6 +201,13 @@ class Aria2Downloader:
|
|||||||
"auto-file-renaming": "false",
|
"auto-file-renaming": "false",
|
||||||
"file-allocation": "none",
|
"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:
|
if request_headers:
|
||||||
options["header"] = [
|
options["header"] = [
|
||||||
f"{key}: {value}" for key, value in request_headers.items()
|
f"{key}: {value}" for key, value in request_headers.items()
|
||||||
|
|||||||
@@ -791,8 +791,12 @@ class BaseModelService(ABC):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def format_response(self, model_data: Dict) -> Dict:
|
async def format_response(self, model_data: Dict) -> Optional[Dict]:
|
||||||
"""Format model data for API response - must be implemented by subclasses"""
|
"""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
|
pass
|
||||||
|
|
||||||
# Common service methods that delegate to scanner
|
# Common service methods that delegate to scanner
|
||||||
@@ -951,13 +955,21 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
return unified_tree
|
return unified_tree
|
||||||
|
|
||||||
async def get_model_notes(self, model_name: str) -> Optional[str]:
|
async def get_model_notes(self, model_name: str) -> Optional[dict]:
|
||||||
"""Get notes for a specific model file"""
|
"""Get notes and file_path for a specific model file.
|
||||||
|
|
||||||
|
Supports both simple names (``OWSMianne_ANIMA_V1``) and full-path
|
||||||
|
syntax (``Anima/character/OWSMianne_ANIMA_V1``).
|
||||||
|
"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
if model["file_name"] == model_name:
|
file_name = model.get("file_name", "")
|
||||||
return model.get("notes", "")
|
if file_name == model_name or model_name.endswith("/" + file_name) or model_name.endswith("\\" + file_name):
|
||||||
|
return {
|
||||||
|
"notes": model.get("notes", ""),
|
||||||
|
"file_path": model.get("file_path", ""),
|
||||||
|
}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1080,6 +1092,11 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
Listing/search endpoints return lightweight cache entries; this method performs
|
Listing/search endpoints return lightweight cache entries; this method performs
|
||||||
a lazy read of the on-disk metadata snapshot when callers need full detail.
|
a lazy read of the on-disk metadata snapshot when callers need full detail.
|
||||||
|
|
||||||
|
As a beneficial side effect, the in-memory and persistent caches are
|
||||||
|
opportunistically synchronised with the on-disk metadata — this keeps the
|
||||||
|
caches fresh even when a ``.metadata.json`` file was edited outside of the
|
||||||
|
normal save path (e.g. manually or by an external script).
|
||||||
"""
|
"""
|
||||||
metadata, should_skip = await MetadataManager.load_metadata(
|
metadata, should_skip = await MetadataManager.load_metadata(
|
||||||
file_path, self.metadata_class
|
file_path, self.metadata_class
|
||||||
@@ -1097,6 +1114,19 @@ class BaseModelService(ABC):
|
|||||||
MetadataManager.save_metadata(file_path, metadata)
|
MetadataManager.save_metadata(file_path, metadata)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Opportunistically sync the in-memory + persistent caches.
|
||||||
|
# The .metadata.json disk read is already paid for; the sync only
|
||||||
|
# performs work when the cache is actually stale, and uses targeted,
|
||||||
|
# in-place operations to minimise overhead even with large model sets.
|
||||||
|
#
|
||||||
|
# Fire-and-forget by design: the task is intentionally untracked.
|
||||||
|
# sync_cache_from_metadata handles its own errors internally.
|
||||||
|
asyncio.create_task(
|
||||||
|
self.scanner.sync_cache_from_metadata(
|
||||||
|
file_path, metadata.to_dict()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||||
|
|
||||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from .auto_tag_service import extract_auto_tags
|
from .auto_tag_service import extract_auto_tags
|
||||||
@@ -21,20 +21,37 @@ class CheckpointService(BaseModelService):
|
|||||||
"""
|
"""
|
||||||
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, checkpoint_data: Dict) -> Dict:
|
async def format_response(self, checkpoint_data: Dict) -> Optional[Dict]:
|
||||||
"""Format Checkpoint data for API response"""
|
"""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)
|
# Get sub_type from cache entry (new canonical field)
|
||||||
sub_type = checkpoint_data.get("sub_type", "checkpoint")
|
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 {
|
return {
|
||||||
"model_name": checkpoint_data["model_name"],
|
"model_name": model_name,
|
||||||
"file_name": checkpoint_data["file_name"],
|
"file_name": file_name,
|
||||||
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")),
|
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")),
|
||||||
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0),
|
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0),
|
||||||
"base_model": checkpoint_data.get("base_model", ""),
|
"base_model": checkpoint_data.get("base_model", ""),
|
||||||
"folder": checkpoint_data["folder"],
|
"folder": folder,
|
||||||
"sha256": checkpoint_data.get("sha256", ""),
|
"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),
|
"file_size": checkpoint_data.get("size", 0),
|
||||||
"modified": checkpoint_data.get("modified", ""),
|
"modified": checkpoint_data.get("modified", ""),
|
||||||
"tags": checkpoint_data.get("tags", []),
|
"tags": checkpoint_data.get("tags", []),
|
||||||
@@ -49,6 +66,7 @@ class CheckpointService(BaseModelService):
|
|||||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||||
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||||
"version_count": checkpoint_data.get("version_count"),
|
"version_count": checkpoint_data.get("version_count"),
|
||||||
|
"hf_url": checkpoint_data.get("hf_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
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")
|
version_id = file_data.get("model_version_id") or file_data.get("modelVersionId")
|
||||||
if model_id is None or version_id is None:
|
if model_id is None or version_id is None:
|
||||||
continue
|
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)
|
resolved = await self.get_model_version(model_id, version_id)
|
||||||
if resolved:
|
if resolved:
|
||||||
return resolved
|
return resolved
|
||||||
@@ -327,7 +341,7 @@ class CivArchiveClient:
|
|||||||
if resolved:
|
if resolved:
|
||||||
return resolved, None
|
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"
|
return None, "No version data found"
|
||||||
|
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ class CivitaiBaseModelService:
|
|||||||
"ernie": "ERNI",
|
"ernie": "ERNI",
|
||||||
"ernie turbo": "ETRB",
|
"ernie turbo": "ETRB",
|
||||||
"nucleus": "NUCL",
|
"nucleus": "NUCL",
|
||||||
|
"krea 2": "KR2",
|
||||||
"svd": "SVD",
|
"svd": "SVD",
|
||||||
"ltxv": "LTXV",
|
"ltxv": "LTXV",
|
||||||
"ltxv2": "LTV2",
|
"ltxv2": "LTV2",
|
||||||
@@ -212,6 +213,18 @@ class CivitaiBaseModelService:
|
|||||||
"wan video 2.2 i2v-a14b": "WAN",
|
"wan video 2.2 i2v-a14b": "WAN",
|
||||||
"wan video 2.5 t2v": "WAN",
|
"wan video 2.5 t2v": "WAN",
|
||||||
"wan video 2.5 i2v": "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:
|
if lower_name in special_cases:
|
||||||
@@ -391,6 +404,7 @@ class CivitaiBaseModelService:
|
|||||||
"LTXV2",
|
"LTXV2",
|
||||||
"LTXV 2.3",
|
"LTXV 2.3",
|
||||||
"CogVideoX",
|
"CogVideoX",
|
||||||
|
"HappyHorse",
|
||||||
"Mochi",
|
"Mochi",
|
||||||
"Hunyuan Video",
|
"Hunyuan Video",
|
||||||
"Wan Video",
|
"Wan Video",
|
||||||
@@ -403,15 +417,25 @@ class CivitaiBaseModelService:
|
|||||||
"Wan Video 2.2 I2V-A14B",
|
"Wan Video 2.2 I2V-A14B",
|
||||||
"Wan Video 2.5 T2V",
|
"Wan Video 2.5 T2V",
|
||||||
"Wan Video 2.5 I2V",
|
"Wan Video 2.5 I2V",
|
||||||
|
"Wan Image 2.7",
|
||||||
|
"Wan Video 2.7",
|
||||||
],
|
],
|
||||||
"Other Models": [
|
"Other Models": [
|
||||||
|
"ACE Audio",
|
||||||
"Illustrious",
|
"Illustrious",
|
||||||
"Pony",
|
"Pony",
|
||||||
"Pony V7",
|
"Pony V7",
|
||||||
|
"Boogu",
|
||||||
"HiDream",
|
"HiDream",
|
||||||
|
"HiDream-O1",
|
||||||
|
"Ideogram 4.0",
|
||||||
"Qwen",
|
"Qwen",
|
||||||
|
"Qwen 2",
|
||||||
"AuraFlow",
|
"AuraFlow",
|
||||||
"Chroma",
|
"Chroma",
|
||||||
|
"Grok",
|
||||||
|
"Lens",
|
||||||
|
"MAI",
|
||||||
"ZImageTurbo",
|
"ZImageTurbo",
|
||||||
"ZImageBase",
|
"ZImageBase",
|
||||||
"PixArt a",
|
"PixArt a",
|
||||||
@@ -424,6 +448,8 @@ class CivitaiBaseModelService:
|
|||||||
"Ernie",
|
"Ernie",
|
||||||
"Ernie Turbo",
|
"Ernie Turbo",
|
||||||
"Nucleus",
|
"Nucleus",
|
||||||
|
"Krea 2",
|
||||||
|
"Upscaler",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,12 @@ class DownloadManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with download result
|
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
|
# Validate that at least one identifier is provided
|
||||||
if not model_id and not model_version_id:
|
if not model_id and not model_version_id:
|
||||||
return {
|
return {
|
||||||
@@ -250,6 +256,7 @@ class DownloadManager:
|
|||||||
"source": source,
|
"source": source,
|
||||||
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
|
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
|
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"transfer_backend": self._get_model_download_backend(),
|
"transfer_backend": self._get_model_download_backend(),
|
||||||
"bytes_downloaded": 0,
|
"bytes_downloaded": 0,
|
||||||
@@ -289,8 +296,8 @@ class DownloadManager:
|
|||||||
return result
|
return result
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": True,
|
||||||
"error": "Download was cancelled",
|
"cancelled": True,
|
||||||
"download_id": task_id,
|
"download_id": task_id,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
@@ -675,7 +682,10 @@ class DownloadManager:
|
|||||||
u for u in download_urls if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
u for u in download_urls if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||||
]
|
]
|
||||||
download_urls = non_civitai_urls + civitai_urls
|
download_urls = non_civitai_urls + civitai_urls
|
||||||
else:
|
|
||||||
|
# Fallback: when mirrors is empty or all mirrors have been deleted,
|
||||||
|
# use the file's downloadUrl directly (e.g. CivitAI download endpoint).
|
||||||
|
if not download_urls:
|
||||||
download_url = file_info.get("downloadUrl")
|
download_url = file_info.get("downloadUrl")
|
||||||
if download_url:
|
if download_url:
|
||||||
download_urls.append(normalize_civitai_download_url(download_url))
|
download_urls.append(normalize_civitai_download_url(download_url))
|
||||||
@@ -1421,14 +1431,35 @@ class DownloadManager:
|
|||||||
|
|
||||||
# If file_params is provided, try to find matching file
|
# If file_params is provided, try to find matching file
|
||||||
if file_params and model_version_id:
|
if file_params and model_version_id:
|
||||||
|
target_file_id = file_params.get("id")
|
||||||
target_type = file_params.get("type", "Model")
|
target_type = file_params.get("type", "Model")
|
||||||
target_format = file_params.get("format", "SafeTensor")
|
target_format = file_params.get("format")
|
||||||
target_size = file_params.get("size", "full")
|
target_size = file_params.get("size")
|
||||||
target_fp = file_params.get("fp")
|
target_fp = file_params.get("fp")
|
||||||
is_primary = file_params.get("isPrimary", False)
|
is_primary = file_params.get("isPrimary", False)
|
||||||
|
|
||||||
if is_primary:
|
logger.debug(
|
||||||
# Find primary file
|
"[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(
|
file_info = next(
|
||||||
(
|
(
|
||||||
f
|
f
|
||||||
@@ -1439,28 +1470,41 @@ class DownloadManager:
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Match by metadata
|
# Lenient metadata match: only compare fields present on both sides
|
||||||
for f in files:
|
for f in files:
|
||||||
f_type = f.get("type", "")
|
f_type = f.get("type", "")
|
||||||
f_meta = f.get("metadata", {})
|
|
||||||
|
|
||||||
# Check type match
|
|
||||||
if f_type != target_type:
|
if f_type != target_type:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check metadata match
|
f_meta = f.get("metadata", {})
|
||||||
if f_meta.get("format") != target_format:
|
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
|
continue
|
||||||
if f_meta.get("size") != target_size:
|
if target_size and f_size and f_size != target_size:
|
||||||
continue
|
continue
|
||||||
if target_fp and f_meta.get("fp") != target_fp:
|
if target_fp and f_fp and f_fp != target_fp:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
file_info = f
|
file_info = f
|
||||||
break
|
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
|
# Fallback to primary file if no match found
|
||||||
if not file_info:
|
if not file_info:
|
||||||
|
logger.debug("[download] Looking for primary file as fallback")
|
||||||
file_info = next(
|
file_info = next(
|
||||||
(
|
(
|
||||||
f
|
f
|
||||||
@@ -1469,38 +1513,18 @@ class DownloadManager:
|
|||||||
),
|
),
|
||||||
None,
|
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:
|
if not file_info:
|
||||||
return {"success": False, "error": "No suitable file found in metadata"}
|
return {"success": False, "error": "No suitable file found in metadata"}
|
||||||
mirrors = file_info.get("mirrors") or []
|
|
||||||
download_urls = []
|
|
||||||
if mirrors:
|
|
||||||
for mirror in mirrors:
|
|
||||||
if mirror.get("deletedAt") is None and mirror.get("url"):
|
|
||||||
download_urls.append(
|
|
||||||
normalize_civitai_download_url(mirror["url"])
|
|
||||||
)
|
|
||||||
|
|
||||||
# When source is 'civarchive', prioritize non-Civitai URLs
|
download_urls = self._build_download_urls_from_file_info(file_info, source=source)
|
||||||
# This avoids failed downloads from deleted Civitai models
|
|
||||||
if source == "civarchive" and len(download_urls) > 1:
|
|
||||||
civitai_urls = [
|
|
||||||
u
|
|
||||||
for u in download_urls
|
|
||||||
if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
|
||||||
]
|
|
||||||
non_civitai_urls = [
|
|
||||||
u
|
|
||||||
for u in download_urls
|
|
||||||
if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
|
||||||
]
|
|
||||||
download_urls = non_civitai_urls + civitai_urls
|
|
||||||
else:
|
|
||||||
download_url = file_info.get("downloadUrl")
|
|
||||||
if download_url:
|
|
||||||
download_urls.append(
|
|
||||||
normalize_civitai_download_url(download_url)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not download_urls:
|
if not download_urls:
|
||||||
return {"success": False, "error": "No mirror URL found"}
|
return {"success": False, "error": "No mirror URL found"}
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ class DownloadQueueService:
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_dh_download_id
|
||||||
|
ON download_history(download_id) WHERE download_id IS NOT NULL;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -154,13 +156,23 @@ class DownloadQueueService:
|
|||||||
"""Insert a new download into the queue.
|
"""Insert a new download into the queue.
|
||||||
|
|
||||||
Returns the inserted row as a dict (or an empty dict if the
|
Returns the inserted row as a dict (or an empty dict if the
|
||||||
download_id already exists).
|
download_id already exists in the queue or has a terminal
|
||||||
|
record in history).
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
file_params_json = json.dumps(file_params) if file_params is not None else None
|
file_params_json = json.dumps(file_params) if file_params is not None else None
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
|
|
||||||
|
# Reject download_ids that already have a terminal record in history.
|
||||||
|
history_row = conn.execute(
|
||||||
|
"SELECT 1 FROM download_history WHERE download_id = ? LIMIT 1",
|
||||||
|
(download_id,),
|
||||||
|
).fetchone()
|
||||||
|
if history_row is not None:
|
||||||
|
return {}
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO download_queue (
|
INSERT OR IGNORE INTO download_queue (
|
||||||
@@ -380,7 +392,7 @@ class DownloadQueueService:
|
|||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO download_history (
|
INSERT OR IGNORE INTO download_history (
|
||||||
download_id, model_id, model_version_id, model_name,
|
download_id, model_id, model_version_id, model_name,
|
||||||
version_name, thumbnail_url, status, error, file_path,
|
version_name, thumbnail_url, status, error, file_path,
|
||||||
bytes_downloaded, total_bytes, completed_at
|
bytes_downloaded, total_bytes, completed_at
|
||||||
@@ -537,17 +549,27 @@ class DownloadQueueService:
|
|||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def delete_history_item(self, id: int) -> bool:
|
async def delete_history_item(
|
||||||
"""Delete a single history entry by its *id*.
|
self, id: Optional[int] = None, download_id: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a single history entry by *download_id* (preferred) or *id*.
|
||||||
|
|
||||||
Returns ``True`` if a row was deleted.
|
Returns ``True`` if a row was deleted.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
cursor = conn.execute(
|
if download_id:
|
||||||
"DELETE FROM download_history WHERE id = ?",
|
cursor = conn.execute(
|
||||||
(id,),
|
"DELETE FROM download_history WHERE download_id = ?",
|
||||||
)
|
(download_id,),
|
||||||
|
)
|
||||||
|
elif id is not None:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"DELETE FROM download_history WHERE id = ?",
|
||||||
|
(id,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
@@ -604,21 +626,34 @@ class DownloadQueueService:
|
|||||||
# Retry
|
# Retry
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def retry_from_history(self, item_id: int) -> Optional[dict[str, Any]]:
|
async def retry_from_history(
|
||||||
|
self,
|
||||||
|
item_id: Optional[int] = None,
|
||||||
|
download_id: Optional[str] = None,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
"""Re-queue a failed or canceled download from history.
|
"""Re-queue a failed or canceled download from history.
|
||||||
|
|
||||||
Looks up the history record by its primary key. If the status is
|
Looks up the history record by *download_id* (preferred) or
|
||||||
``failed`` or ``canceled`` a new queue entry is created with the
|
*item_id*. If the status is ``failed`` or ``canceled`` a new
|
||||||
same model metadata and a fresh download id, and the original
|
queue entry is created with the same model metadata and a fresh
|
||||||
history entry is **deleted** to prevent exponential growth when
|
download id, and the original history entry is **deleted** to
|
||||||
the retried item is later canceled or fails again and re-retried.
|
prevent exponential growth when the retried item is later
|
||||||
|
canceled or fails again and re-retried.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
row = conn.execute(
|
if download_id:
|
||||||
"SELECT * FROM download_history WHERE id = ?",
|
row = conn.execute(
|
||||||
(item_id,),
|
"SELECT * FROM download_history WHERE download_id = ?",
|
||||||
).fetchone()
|
(download_id,),
|
||||||
|
).fetchone()
|
||||||
|
elif item_id is not None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM download_history WHERE id = ?",
|
||||||
|
(item_id,),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
status = str(row["status"])
|
status = str(row["status"])
|
||||||
@@ -650,7 +685,7 @@ class DownloadQueueService:
|
|||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM download_history WHERE id = ?",
|
"DELETE FROM download_history WHERE id = ?",
|
||||||
(item_id,),
|
(row["id"],),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
queued = conn.execute(
|
queued = conn.execute(
|
||||||
|
|||||||
+56
-10
@@ -46,6 +46,30 @@ def is_ssl_cert_verify_error(exc: BaseException) -> bool:
|
|||||||
return "CERTIFICATE_VERIFY_FAILED" in str(exc)
|
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)
|
@dataclass(frozen=True)
|
||||||
class DownloadProgress:
|
class DownloadProgress:
|
||||||
"""Snapshot of a download transfer at a moment in time."""
|
"""Snapshot of a download transfer at a moment in time."""
|
||||||
@@ -246,14 +270,14 @@ class Downloader:
|
|||||||
|
|
||||||
Note: This is private and caller MUST hold self._session_lock.
|
Note: This is private and caller MUST hold self._session_lock.
|
||||||
"""
|
"""
|
||||||
# Close existing session if any
|
# Snapshot and clear old session reference before creating the new
|
||||||
if self._session is not None:
|
# one. This ensures self._session is always valid (or None, which
|
||||||
try:
|
# triggers a fresh creation) and avoids a race where concurrent
|
||||||
await self._session.close()
|
# requests hold a reference to a session whose connector has been
|
||||||
except Exception as e: # pragma: no cover
|
# torn down by a premature close() call — the root cause of the
|
||||||
logger.warning(f"Error closing previous session: {e}")
|
# intermittent "NoneType has no attribute connect" crash.
|
||||||
finally:
|
old_session = self._session
|
||||||
self._session = None
|
self._session = None
|
||||||
|
|
||||||
# Check for app-level proxy settings
|
# Check for app-level proxy settings
|
||||||
proxy_url = None # http(s) proxy, passed via the per-request `proxy=` kwarg
|
proxy_url = None # http(s) proxy, passed via the per-request `proxy=` kwarg
|
||||||
@@ -348,6 +372,13 @@ class Downloader:
|
|||||||
self._proxy_url = proxy_url
|
self._proxy_url = proxy_url
|
||||||
self._session_created_at = datetime.now()
|
self._session_created_at = datetime.now()
|
||||||
|
|
||||||
|
# Close the previous session now that the replacement is live.
|
||||||
|
if old_session is not None:
|
||||||
|
try:
|
||||||
|
await old_session.close()
|
||||||
|
except Exception as e: # pragma: no cover
|
||||||
|
logger.warning(f"Error closing previous session: {e}")
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s",
|
"Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s",
|
||||||
bool(proxy_url),
|
bool(proxy_url),
|
||||||
@@ -729,7 +760,8 @@ class Downloader:
|
|||||||
else:
|
else:
|
||||||
resume_offset = 0
|
resume_offset = 0
|
||||||
total_size = 0
|
total_size = 0
|
||||||
await self._create_session()
|
async with self._session_lock:
|
||||||
|
await self._create_session()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return False, integrity_error
|
return False, integrity_error
|
||||||
@@ -819,7 +851,8 @@ class Downloader:
|
|||||||
logger.info(f"Will resume from byte {resume_offset}")
|
logger.info(f"Will resume from byte {resume_offset}")
|
||||||
|
|
||||||
# Refresh session to get new connection
|
# Refresh session to get new connection
|
||||||
await self._create_session()
|
async with self._session_lock:
|
||||||
|
await self._create_session()
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
logger.error(f"Max retries exceeded for download: {e}")
|
logger.error(f"Max retries exceeded for download: {e}")
|
||||||
@@ -911,6 +944,19 @@ class Downloader:
|
|||||||
elif response.status == 404:
|
elif response.status == 404:
|
||||||
error_msg = "File not found"
|
error_msg = "File not found"
|
||||||
return False, error_msg, None
|
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:
|
else:
|
||||||
error_msg = f"Download failed with status {response.status}"
|
error_msg = f"Download failed with status {response.status}"
|
||||||
return False, error_msg, None
|
return False, error_msg, None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from .auto_tag_service import extract_auto_tags
|
from .auto_tag_service import extract_auto_tags
|
||||||
@@ -21,20 +21,37 @@ class EmbeddingService(BaseModelService):
|
|||||||
"""
|
"""
|
||||||
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, embedding_data: Dict) -> Dict:
|
async def format_response(self, embedding_data: Dict) -> Optional[Dict]:
|
||||||
"""Format Embedding data for API response"""
|
"""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)
|
# Get sub_type from cache entry (new canonical field)
|
||||||
sub_type = embedding_data.get("sub_type", "embedding")
|
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 {
|
return {
|
||||||
"model_name": embedding_data["model_name"],
|
"model_name": model_name,
|
||||||
"file_name": embedding_data["file_name"],
|
"file_name": file_name,
|
||||||
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")),
|
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")),
|
||||||
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0),
|
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0),
|
||||||
"base_model": embedding_data.get("base_model", ""),
|
"base_model": embedding_data.get("base_model", ""),
|
||||||
"folder": embedding_data["folder"],
|
"folder": folder,
|
||||||
"sha256": embedding_data.get("sha256", ""),
|
"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),
|
"file_size": embedding_data.get("size", 0),
|
||||||
"modified": embedding_data.get("modified", ""),
|
"modified": embedding_data.get("modified", ""),
|
||||||
"tags": embedding_data.get("tags", []),
|
"tags": embedding_data.get("tags", []),
|
||||||
@@ -49,6 +66,7 @@ class EmbeddingService(BaseModelService):
|
|||||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||||
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||||
"version_count": embedding_data.get("version_count"),
|
"version_count": embedding_data.get("version_count"),
|
||||||
|
"hf_url": embedding_data.get("hf_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -25,3 +25,21 @@ class ResourceNotFoundError(RuntimeError):
|
|||||||
|
|
||||||
pass
|
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)
|
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, lora_data: Dict) -> Dict:
|
async def format_response(self, lora_data: Dict) -> Optional[Dict]:
|
||||||
"""Format LoRA data for API response"""
|
"""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
|
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
||||||
# Normalize to lowercase for consistent API responses
|
# Normalize to lowercase for consistent API responses
|
||||||
sub_type = resolve_sub_type(lora_data).lower()
|
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 {
|
return {
|
||||||
"model_name": lora_data["model_name"],
|
"model_name": model_name,
|
||||||
"file_name": lora_data["file_name"],
|
"file_name": file_name,
|
||||||
"preview_url": config.get_preview_static_url(
|
"preview_url": config.get_preview_static_url(
|
||||||
lora_data.get("preview_url", "")
|
lora_data.get("preview_url", "")
|
||||||
),
|
),
|
||||||
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
|
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
|
||||||
"base_model": lora_data.get("base_model", ""),
|
"base_model": lora_data.get("base_model", ""),
|
||||||
"folder": lora_data["folder"],
|
"folder": folder,
|
||||||
"sha256": lora_data.get("sha256", ""),
|
"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),
|
"file_size": lora_data.get("size", 0),
|
||||||
"modified": lora_data.get("modified", ""),
|
"modified": lora_data.get("modified", ""),
|
||||||
"tags": lora_data.get("tags", []),
|
"tags": lora_data.get("tags", []),
|
||||||
@@ -60,6 +78,7 @@ class LoraService(BaseModelService):
|
|||||||
),
|
),
|
||||||
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||||
"version_count": lora_data.get("version_count"),
|
"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]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
@@ -252,12 +271,16 @@ class LoraService(BaseModelService):
|
|||||||
return letters
|
return letters
|
||||||
|
|
||||||
async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
|
async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
|
||||||
"""Get trigger words for a specific LoRA file"""
|
"""Get trigger words for a specific LoRA file.
|
||||||
|
|
||||||
|
Supports both simple names and full-path syntax.
|
||||||
|
"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
for lora in cache.raw_data:
|
for lora in cache.raw_data:
|
||||||
if lora["file_name"] == lora_name:
|
file_name = lora.get("file_name", "")
|
||||||
civitai_data = lora.get("civitai", {})
|
if file_name == lora_name or lora_name.endswith("/" + file_name) or lora_name.endswith("\\" + file_name):
|
||||||
|
civitai_data = lora.get("civitai") or {}
|
||||||
return civitai_data.get("trainedWords", [])
|
return civitai_data.get("trainedWords", [])
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -209,7 +209,21 @@ class MetadataSyncService:
|
|||||||
error_msg = "CivitAI model is deleted and no archive provider is available"
|
error_msg = "CivitAI model is deleted and no archive provider is available"
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
else:
|
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
|
civitai_metadata: Optional[Dict[str, Any]] = None
|
||||||
metadata_provider: Optional[MetadataProviderProtocol] = None
|
metadata_provider: Optional[MetadataProviderProtocol] = None
|
||||||
|
|||||||
@@ -337,4 +337,25 @@ class ModelCache:
|
|||||||
else:
|
else:
|
||||||
return False # Model not found
|
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
|
||||||
@@ -227,6 +227,11 @@ class ModelScanner:
|
|||||||
|
|
||||||
entry: Dict[str, Any] = {
|
entry: Dict[str, Any] = {
|
||||||
'file_path': normalized_path,
|
'file_path': normalized_path,
|
||||||
|
# file_name is always stored WITHOUT extension (e.g. "OWSMianne_ANIMA_V1",
|
||||||
|
# not "OWSMianne_ANIMA_V1.safetensors"). All upstream population points
|
||||||
|
# (MetadataManager, from_civitai_info, download manager, etc.) strip the
|
||||||
|
# extension via os.path.splitext before writing. Code consuming this field
|
||||||
|
# should match against names that are likewise extension-free.
|
||||||
'file_name': get_value('file_name', '') or '',
|
'file_name': get_value('file_name', '') or '',
|
||||||
'model_name': get_value('model_name', '') or '',
|
'model_name': get_value('model_name', '') or '',
|
||||||
'folder': normalized_folder,
|
'folder': normalized_folder,
|
||||||
@@ -248,6 +253,7 @@ class ModelScanner:
|
|||||||
'civitai': civitai_slim,
|
'civitai': civitai_slim,
|
||||||
'civitai_deleted': bool(get_value('civitai_deleted', False)),
|
'civitai_deleted': bool(get_value('civitai_deleted', False)),
|
||||||
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
|
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
|
||||||
|
'hf_url': get_value('hf_url', '') or '',
|
||||||
}
|
}
|
||||||
|
|
||||||
license_source: Dict[str, Any] = {}
|
license_source: Dict[str, Any] = {}
|
||||||
@@ -476,11 +482,20 @@ class ModelScanner:
|
|||||||
for tag in adjusted_item.get('tags') or []:
|
for tag in adjusted_item.get('tags') or []:
|
||||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
# Validate cache entries and check health
|
# 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(
|
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
|
||||||
adjusted_raw_data, auto_repair=True
|
adjusted_raw_data, auto_repair=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Always use the validated entries (repaired copies)
|
||||||
|
adjusted_raw_data = valid_entries
|
||||||
|
|
||||||
if invalid_entries:
|
if invalid_entries:
|
||||||
monitor = CacheHealthMonitor()
|
monitor = CacheHealthMonitor()
|
||||||
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
|
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
|
||||||
@@ -1551,6 +1566,218 @@ class ModelScanner:
|
|||||||
|
|
||||||
return cache_entry if metadata else True
|
return cache_entry if metadata else True
|
||||||
|
|
||||||
|
async def sync_cache_from_metadata(
|
||||||
|
self, file_path: str, metadata_dict: Dict[str, Any]
|
||||||
|
) -> bool:
|
||||||
|
"""Opportunistically sync in-memory and persistent caches from metadata.
|
||||||
|
|
||||||
|
Builds a prospective cache entry from *metadata_dict* (deserialized
|
||||||
|
``.metadata.json`` content) and compares it against the current cache
|
||||||
|
entry. When the two are already identical this method returns
|
||||||
|
``False`` without touching anything — avoiding the overhead of
|
||||||
|
``update_single_model_cache``, which always removes and re-inserts
|
||||||
|
the entry, triggers a full resort, and persists via the heavyweight
|
||||||
|
``save_cache()``.
|
||||||
|
|
||||||
|
When differences are detected the update is applied **in-place** with
|
||||||
|
targeted operations:
|
||||||
|
|
||||||
|
* The existing ``raw_data`` entry is modified rather than removed and
|
||||||
|
re-appended (O(1) instead of O(n)).
|
||||||
|
* Tag counts and the hash index are updated incrementally.
|
||||||
|
* The version index is rebuilt only for the affected entry.
|
||||||
|
* ``resort()`` is called **only** when a sort-relevant field changed
|
||||||
|
(``model_name`` / ``file_name`` for name-sort, ``modified`` for
|
||||||
|
date-sort, ``size`` for size-sort).
|
||||||
|
* The persistent (SQLite) cache receives a targeted single-row update
|
||||||
|
via :meth:`PersistentModelCache.update_single_model` rather than a
|
||||||
|
full-table ``save_cache()``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` if any cache update was performed, ``False`` if the
|
||||||
|
caches were already in sync.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This is a **best-effort** operation. Failures are logged but
|
||||||
|
never propagated — callers should fire-and-forget via
|
||||||
|
:func:`asyncio.create_task`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self._sync_cache_from_metadata_impl(
|
||||||
|
file_path, metadata_dict
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"sync_cache_from_metadata failed for %s",
|
||||||
|
file_path,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _sync_cache_from_metadata_impl(
|
||||||
|
self, file_path: str, metadata_dict: Dict[str, Any]
|
||||||
|
) -> bool:
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
|
# Locate the existing cache entry -----------------------------------
|
||||||
|
existing_idx: Optional[int] = None
|
||||||
|
existing_entry: Optional[Dict[str, Any]] = None
|
||||||
|
for i, item in enumerate(cache.raw_data):
|
||||||
|
if item.get("file_path") == file_path:
|
||||||
|
existing_entry = item
|
||||||
|
existing_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
# Build the desired entry from metadata ------------------------------
|
||||||
|
folder_value = (
|
||||||
|
existing_entry.get("folder", "")
|
||||||
|
if existing_entry
|
||||||
|
else self._calculate_folder(file_path)
|
||||||
|
)
|
||||||
|
desired_entry = self._build_cache_entry(
|
||||||
|
metadata_dict,
|
||||||
|
folder=folder_value,
|
||||||
|
file_path_override=file_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure sha256 is populated (defensive — metadata should have it)
|
||||||
|
if (
|
||||||
|
not desired_entry.get("sha256")
|
||||||
|
and file_path
|
||||||
|
and os.path.exists(file_path)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
sha256 = await calculate_sha256(file_path)
|
||||||
|
if sha256:
|
||||||
|
desired_entry["sha256"] = sha256.lower()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Not in cache at all — delegate to the full update path ------------
|
||||||
|
if existing_entry is None:
|
||||||
|
result = await self.update_single_model_cache(
|
||||||
|
file_path, file_path, metadata_dict
|
||||||
|
)
|
||||||
|
return bool(result)
|
||||||
|
|
||||||
|
# Compare — skip everything if already in sync -----------------------
|
||||||
|
if not self._cache_entries_differ(existing_entry, desired_entry):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Re-validate: the cache may have been replaced concurrently
|
||||||
|
# (e.g. by _apply_scan_result). Use identity check, not equality,
|
||||||
|
# so we detect when the raw_data list was swapped out from under us.
|
||||||
|
if self._cache is None or not any(
|
||||||
|
item is existing_entry for item in self._cache.raw_data
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---- Differences detected: apply targeted, in-place updates --------
|
||||||
|
|
||||||
|
# Snapshot old values for delta computations
|
||||||
|
old_tags = list(existing_entry.get("tags") or [])
|
||||||
|
old_sha256: str = existing_entry.get("sha256", "") or ""
|
||||||
|
old_model_name: str = existing_entry.get("model_name", "") or ""
|
||||||
|
old_file_name: str = existing_entry.get("file_name", "") or ""
|
||||||
|
old_modified: float = float(existing_entry.get("modified", 0.0) or 0.0)
|
||||||
|
old_size: int = int(existing_entry.get("size", 0) or 0)
|
||||||
|
old_civitai = existing_entry.get("civitai")
|
||||||
|
|
||||||
|
# ---- In-place update of the cache entry ----
|
||||||
|
existing_entry.clear()
|
||||||
|
existing_entry.update(desired_entry)
|
||||||
|
|
||||||
|
# ---- Incremental tag count update ----
|
||||||
|
new_tags: set = set(desired_entry.get("tags") or [])
|
||||||
|
old_tag_set: set = set(old_tags)
|
||||||
|
for tag in old_tag_set - new_tags:
|
||||||
|
current = self._tags_count.get(tag, 0)
|
||||||
|
if current <= 1:
|
||||||
|
self._tags_count.pop(tag, None)
|
||||||
|
else:
|
||||||
|
self._tags_count[tag] = current - 1
|
||||||
|
for tag in new_tags - old_tag_set:
|
||||||
|
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
|
# ---- Incremental hash index update ----
|
||||||
|
new_sha = (desired_entry.get("sha256", "") or "").lower()
|
||||||
|
old_sha = (old_sha256 or "").lower()
|
||||||
|
if new_sha != old_sha:
|
||||||
|
if old_sha:
|
||||||
|
self._hash_index.remove_by_path(file_path)
|
||||||
|
if new_sha:
|
||||||
|
self._hash_index.add_entry(new_sha, file_path)
|
||||||
|
|
||||||
|
# ---- Incremental version index update ----
|
||||||
|
new_civitai = desired_entry.get("civitai")
|
||||||
|
if old_civitai != new_civitai:
|
||||||
|
temp_old = {
|
||||||
|
"file_path": file_path,
|
||||||
|
"file_name": old_file_name,
|
||||||
|
"civitai": old_civitai,
|
||||||
|
}
|
||||||
|
cache.remove_from_version_index(temp_old)
|
||||||
|
cache.add_to_version_index(existing_entry)
|
||||||
|
|
||||||
|
# ---- Conditional resort (only when sort-key fields changed) ----
|
||||||
|
need_resort = False
|
||||||
|
_last = cache._last_sort
|
||||||
|
sort_key: Optional[str] = _last[0] if _last != (None, None) else None
|
||||||
|
if sort_key == "name":
|
||||||
|
if (
|
||||||
|
old_model_name != desired_entry.get("model_name", "")
|
||||||
|
or old_file_name != desired_entry.get("file_name", "")
|
||||||
|
):
|
||||||
|
need_resort = True
|
||||||
|
elif sort_key == "date":
|
||||||
|
if old_modified != float(desired_entry.get("modified", 0.0) or 0.0):
|
||||||
|
need_resort = True
|
||||||
|
elif sort_key == "size":
|
||||||
|
if old_size != int(desired_entry.get("size", 0) or 0):
|
||||||
|
need_resort = True
|
||||||
|
|
||||||
|
if need_resort:
|
||||||
|
await cache.resort()
|
||||||
|
|
||||||
|
# ---- Targeted SQL update (single row, not full save_cache) ----
|
||||||
|
persistent = getattr(self, "_persistent_cache", None)
|
||||||
|
if persistent is not None:
|
||||||
|
old_item_for_sql: Dict[str, Any] = {
|
||||||
|
"file_path": file_path,
|
||||||
|
"tags": old_tags,
|
||||||
|
"sha256": old_sha256,
|
||||||
|
}
|
||||||
|
await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
persistent.update_single_model,
|
||||||
|
self.model_type,
|
||||||
|
desired_entry,
|
||||||
|
old_item_for_sql,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cache_entries_differ(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
|
||||||
|
"""Return ``True`` when two cache-entry dicts differ in any field.
|
||||||
|
|
||||||
|
Tag lists are compared order-insensitively; all other keys use
|
||||||
|
standard equality.
|
||||||
|
"""
|
||||||
|
a_tags = sorted(a.get("tags") or [])
|
||||||
|
b_tags = sorted(b.get("tags") or [])
|
||||||
|
if a_tags != b_tags:
|
||||||
|
return True
|
||||||
|
|
||||||
|
all_keys = set(a.keys()) | set(b.keys())
|
||||||
|
for key in all_keys:
|
||||||
|
if key == "tags":
|
||||||
|
continue
|
||||||
|
if a.get(key) != b.get(key):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def has_hash(self, sha256: str) -> bool:
|
def has_hash(self, sha256: str) -> bool:
|
||||||
"""Check if a model with given hash exists"""
|
"""Check if a model with given hash exists"""
|
||||||
return self._hash_index.has_hash(sha256.lower())
|
return self._hash_index.has_hash(sha256.lower())
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class PersistentModelCache:
|
|||||||
"db_checked",
|
"db_checked",
|
||||||
"last_checked_at",
|
"last_checked_at",
|
||||||
"hash_status",
|
"hash_status",
|
||||||
|
"hf_url",
|
||||||
)
|
)
|
||||||
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
||||||
_instances: Dict[str, "PersistentModelCache"] = {}
|
_instances: Dict[str, "PersistentModelCache"] = {}
|
||||||
@@ -165,8 +166,8 @@ class PersistentModelCache:
|
|||||||
|
|
||||||
item = {
|
item = {
|
||||||
"file_path": file_path,
|
"file_path": file_path,
|
||||||
"file_name": row["file_name"],
|
"file_name": row["file_name"] or "",
|
||||||
"model_name": row["model_name"],
|
"model_name": row["model_name"] or "",
|
||||||
"folder": row["folder"] or "",
|
"folder": row["folder"] or "",
|
||||||
"size": row["size"] or 0,
|
"size": row["size"] or 0,
|
||||||
"modified": row["modified"] or 0.0,
|
"modified": row["modified"] or 0.0,
|
||||||
@@ -188,6 +189,7 @@ class PersistentModelCache:
|
|||||||
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
||||||
"license_flags": int(license_value),
|
"license_flags": int(license_value),
|
||||||
"hash_status": row["hash_status"] or "completed",
|
"hash_status": row["hash_status"] or "completed",
|
||||||
|
"hf_url": row["hf_url"] or "",
|
||||||
}
|
}
|
||||||
raw_data.append(item)
|
raw_data.append(item)
|
||||||
|
|
||||||
@@ -452,6 +454,7 @@ class PersistentModelCache:
|
|||||||
db_checked INTEGER,
|
db_checked INTEGER,
|
||||||
last_checked_at REAL,
|
last_checked_at REAL,
|
||||||
hash_status TEXT,
|
hash_status TEXT,
|
||||||
|
hf_url TEXT DEFAULT '',
|
||||||
PRIMARY KEY (model_type, file_path)
|
PRIMARY KEY (model_type, file_path)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -500,6 +503,7 @@ class PersistentModelCache:
|
|||||||
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
||||||
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
||||||
"hash_status": "TEXT DEFAULT 'completed'",
|
"hash_status": "TEXT DEFAULT 'completed'",
|
||||||
|
"hf_url": "TEXT DEFAULT ''",
|
||||||
}
|
}
|
||||||
|
|
||||||
for column, definition in required_columns.items():
|
for column, definition in required_columns.items():
|
||||||
@@ -548,19 +552,19 @@ class PersistentModelCache:
|
|||||||
return (
|
return (
|
||||||
model_type,
|
model_type,
|
||||||
item.get("file_path"),
|
item.get("file_path"),
|
||||||
item.get("file_name"),
|
item.get("file_name") or "",
|
||||||
item.get("model_name"),
|
item.get("model_name") or "",
|
||||||
item.get("folder"),
|
item.get("folder") or "",
|
||||||
int(item.get("size") or 0),
|
int(item.get("size") or 0),
|
||||||
float(item.get("modified") or 0.0),
|
float(item.get("modified") or 0.0),
|
||||||
(item.get("sha256") or "").lower() or None,
|
(item.get("sha256") or "").lower() or None,
|
||||||
item.get("base_model"),
|
item.get("base_model") or "",
|
||||||
item.get("preview_url"),
|
item.get("preview_url") or "",
|
||||||
int(item.get("preview_nsfw_level") or 0),
|
int(item.get("preview_nsfw_level") or 0),
|
||||||
1 if item.get("from_civitai", True) else 0,
|
1 if item.get("from_civitai", True) else 0,
|
||||||
1 if item.get("favorite") else 0,
|
1 if item.get("favorite") else 0,
|
||||||
item.get("notes"),
|
item.get("notes") or "",
|
||||||
item.get("usage_tips"),
|
item.get("usage_tips") or "",
|
||||||
metadata_source,
|
metadata_source,
|
||||||
civitai.get("id"),
|
civitai.get("id"),
|
||||||
civitai.get("modelId"),
|
civitai.get("modelId"),
|
||||||
@@ -575,6 +579,7 @@ class PersistentModelCache:
|
|||||||
1 if item.get("db_checked") else 0,
|
1 if item.get("db_checked") else 0,
|
||||||
float(item.get("last_checked_at") or 0.0),
|
float(item.get("last_checked_at") or 0.0),
|
||||||
item.get("hash_status", "completed"),
|
item.get("hash_status", "completed"),
|
||||||
|
item.get("hf_url") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _insert_model_sql(self) -> str:
|
def _insert_model_sql(self) -> str:
|
||||||
@@ -582,6 +587,95 @@ class PersistentModelCache:
|
|||||||
placeholders = ", ".join(["?"] * len(self._MODEL_COLUMNS))
|
placeholders = ", ".join(["?"] * len(self._MODEL_COLUMNS))
|
||||||
return f"INSERT INTO models ({columns}) VALUES ({placeholders})"
|
return f"INSERT INTO models ({columns}) VALUES ({placeholders})"
|
||||||
|
|
||||||
|
def update_single_model(
|
||||||
|
self,
|
||||||
|
model_type: str,
|
||||||
|
new_item: Dict,
|
||||||
|
old_item: Optional[Dict] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update a single model row in the persistent cache.
|
||||||
|
|
||||||
|
A lightweight alternative to :meth:`save_cache` that performs a targeted
|
||||||
|
DELETE + INSERT for the model row and computes incremental tag / hash-index
|
||||||
|
deltas from *old_item*. When *old_item* is omitted the previous tags and
|
||||||
|
hash are not cleaned up (callers should only omit it for brand-new entries).
|
||||||
|
|
||||||
|
All operations run inside a single transaction so readers see a consistent
|
||||||
|
view.
|
||||||
|
"""
|
||||||
|
if not self.is_enabled():
|
||||||
|
return
|
||||||
|
if not self._schema_initialized:
|
||||||
|
self._initialize_schema()
|
||||||
|
if not self._schema_initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path: Optional[str] = new_item.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._db_lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
|
||||||
|
# --- model row (DELETE + INSERT = upsert) ---
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
|
||||||
|
(model_type, file_path),
|
||||||
|
)
|
||||||
|
row = self._prepare_model_row(model_type, new_item)
|
||||||
|
conn.execute(self._insert_model_sql(), row)
|
||||||
|
|
||||||
|
# --- tags ---
|
||||||
|
new_tags: set = set(new_item.get("tags") or [])
|
||||||
|
old_tags: set = set(old_item.get("tags") or []) if old_item else set()
|
||||||
|
tags_to_delete = old_tags - new_tags
|
||||||
|
tags_to_insert = new_tags - old_tags
|
||||||
|
|
||||||
|
if tags_to_delete:
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
|
||||||
|
[(model_type, file_path, t) for t in tags_to_delete],
|
||||||
|
)
|
||||||
|
if tags_to_insert:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
|
||||||
|
[(model_type, file_path, t) for t in tags_to_insert],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- hash_index ---
|
||||||
|
new_sha: Optional[str] = (new_item.get("sha256") or "").lower() or None
|
||||||
|
old_sha: Optional[str] = (
|
||||||
|
(old_item.get("sha256") or "").lower() or None
|
||||||
|
) if old_item else None
|
||||||
|
if new_sha != old_sha:
|
||||||
|
if old_sha:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
|
||||||
|
(model_type, old_sha, file_path),
|
||||||
|
)
|
||||||
|
if new_sha:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
|
||||||
|
(model_type, new_sha, file_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to update single model in persistent cache (%s): %s",
|
||||||
|
file_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
def _load_tags(self, conn: sqlite3.Connection, model_type: str) -> Dict[str, List[str]]:
|
def _load_tags(self, conn: sqlite3.Connection, model_type: str) -> Dict[str, List[str]]:
|
||||||
tag_rows = conn.execute(
|
tag_rows = conn.execute(
|
||||||
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
|
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
|
||||||
|
|||||||
+118
-37
@@ -107,6 +107,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"backup_retention_count": 5,
|
"backup_retention_count": 5,
|
||||||
"use_new_license_icons": True,
|
"use_new_license_icons": True,
|
||||||
"group_by_model": False,
|
"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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -147,6 +152,11 @@ class SettingsManager:
|
|||||||
self._check_environment_variables()
|
self._check_environment_variables()
|
||||||
self._collect_configuration_warnings()
|
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:
|
if self._needs_initial_save:
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
self._needs_initial_save = False
|
self._needs_initial_save = False
|
||||||
@@ -620,12 +630,37 @@ class SettingsManager:
|
|||||||
|
|
||||||
return False
|
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(
|
def _validate_folder_paths(
|
||||||
self,
|
self,
|
||||||
library_name: str,
|
library_name: str,
|
||||||
folder_paths: Mapping[str, Iterable[str]],
|
folder_paths: Mapping[str, Iterable[str]],
|
||||||
) -> None:
|
) -> 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", {})
|
libraries = self.settings.get("libraries", {})
|
||||||
normalized_new: Dict[str, Dict[str, str]] = {}
|
normalized_new: Dict[str, Dict[str, str]] = {}
|
||||||
for key, values in folder_paths.items():
|
for key, values in folder_paths.items():
|
||||||
@@ -663,6 +698,22 @@ class SettingsManager:
|
|||||||
f"Folder path(s) {collisions} already assigned to library '{other_name}'"
|
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(
|
def _update_active_library_entry(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -873,6 +924,23 @@ class SettingsManager:
|
|||||||
self.settings["civitai_api_key"] = env_api_key
|
self.settings["civitai_api_key"] = env_api_key
|
||||||
self._save_settings()
|
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]]:
|
def _default_settings_actions(self) -> List[Dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -1520,8 +1588,12 @@ class SettingsManager:
|
|||||||
portable_switch_pending = True
|
portable_switch_pending = True
|
||||||
self._prepare_portable_switch(value)
|
self._prepare_portable_switch(value)
|
||||||
if key == "folder_paths" and isinstance(value, Mapping):
|
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]
|
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
|
||||||
elif key == "extra_folder_paths" and isinstance(value, Mapping):
|
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]
|
self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type]
|
||||||
elif key == "default_lora_root":
|
elif key == "default_lora_root":
|
||||||
self._update_active_library_entry(default_lora_root=str(value))
|
self._update_active_library_entry(default_lora_root=str(value))
|
||||||
@@ -1568,7 +1640,7 @@ class SettingsManager:
|
|||||||
previous_dir = os.path.dirname(previous_path) or target_dir
|
previous_dir = os.path.dirname(previous_path) or target_dir
|
||||||
|
|
||||||
if os.path.abspath(previous_path) != os.path.abspath(target_path):
|
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)
|
logger.info("Switching settings file to: %s", target_path)
|
||||||
|
|
||||||
self._pending_portable_switch = {"other_path": other_path}
|
self._pending_portable_switch = {"other_path": other_path}
|
||||||
@@ -1603,46 +1675,52 @@ class SettingsManager:
|
|||||||
finally:
|
finally:
|
||||||
self._pending_portable_switch = None
|
self._pending_portable_switch = None
|
||||||
|
|
||||||
def _copy_model_cache_directory(self, source_dir: str, target_dir: str) -> None:
|
def _migrate_settings_directory_content(
|
||||||
"""Copy model_cache artifacts when switching storage locations."""
|
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:
|
if not source_dir or not target_dir:
|
||||||
return
|
return
|
||||||
|
|
||||||
source_cache_dir = os.path.join(source_dir, "model_cache")
|
def _copy_dir(name: str) -> None:
|
||||||
target_cache_dir = os.path.join(target_dir, "model_cache")
|
source = os.path.join(source_dir, name)
|
||||||
if os.path.isdir(source_cache_dir) and os.path.abspath(
|
target = os.path.join(target_dir, name)
|
||||||
source_cache_dir
|
if os.path.isdir(source) and os.path.abspath(source) != os.path.abspath(
|
||||||
) != os.path.abspath(target_cache_dir):
|
target
|
||||||
try:
|
):
|
||||||
shutil.copytree(
|
try:
|
||||||
source_cache_dir,
|
shutil.copytree(
|
||||||
target_cache_dir,
|
source,
|
||||||
dirs_exist_ok=True,
|
target,
|
||||||
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
|
dirs_exist_ok=True,
|
||||||
)
|
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
|
||||||
except Exception as exc:
|
)
|
||||||
logger.warning(
|
except Exception as exc:
|
||||||
"Failed to copy model_cache directory from %s to %s: %s",
|
logger.warning(
|
||||||
source_cache_dir,
|
"Failed to copy directory %s from %s to %s: %s",
|
||||||
target_cache_dir,
|
name,
|
||||||
exc,
|
source,
|
||||||
)
|
target,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
source_cache_file = os.path.join(source_dir, "model_cache.sqlite")
|
# Managed subdirectories under settings_dir
|
||||||
target_cache_file = os.path.join(target_dir, "model_cache.sqlite")
|
_copy_dir("cache")
|
||||||
if os.path.isfile(source_cache_file) and os.path.abspath(
|
_copy_dir("backups")
|
||||||
source_cache_file
|
_copy_dir("logs")
|
||||||
) != os.path.abspath(target_cache_file):
|
_copy_dir("stats")
|
||||||
try:
|
_copy_dir("wildcards")
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_user_config_directory(self) -> str:
|
def _get_user_config_directory(self) -> str:
|
||||||
"""Return the user configuration directory, falling back to ~/.config."""
|
"""Return the user configuration directory, falling back to ~/.config."""
|
||||||
@@ -1769,6 +1847,9 @@ class SettingsManager:
|
|||||||
if key in self.settings:
|
if key in self.settings:
|
||||||
minimal[key] = copy.deepcopy(self.settings[key])
|
minimal[key] = copy.deepcopy(self.settings[key])
|
||||||
|
|
||||||
|
if self.settings.get("use_portable_settings"):
|
||||||
|
minimal["use_portable_settings"] = True
|
||||||
|
|
||||||
if self._seed_template:
|
if self._seed_template:
|
||||||
for key, value in self._seed_template.items():
|
for key, value in self._seed_template.items():
|
||||||
minimal.setdefault(key, copy.deepcopy(value))
|
minimal.setdefault(key, copy.deepcopy(value))
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ class BulkMetadataRefreshUseCase:
|
|||||||
if not model.get("skip_metadata_refresh", False)
|
if not model.get("skip_metadata_refresh", False)
|
||||||
and not self._is_in_skip_path(model.get("folder", ""), skip_paths)
|
and not self._is_in_skip_path(model.get("folder", ""), skip_paths)
|
||||||
and (not model.get("civitai") or not model["civitai"].get("id"))
|
and (not 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 (
|
and not (
|
||||||
# Skip models confirmed not on CivitAI when no need to retry
|
# Skip models confirmed not on CivitAI when no need to retry
|
||||||
model.get("from_civitai") is False
|
model.get("from_civitai") is False
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ SUPPORTED_MEDIA_EXTENSIONS = {
|
|||||||
"videos": [".mp4", ".webm"],
|
"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 sub-types for each scanner type
|
||||||
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
||||||
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
||||||
@@ -212,8 +226,21 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
|||||||
"Wan Video 2.5 I2V",
|
"Wan Video 2.5 I2V",
|
||||||
"Hunyuan Video",
|
"Hunyuan Video",
|
||||||
"Anima",
|
"Anima",
|
||||||
|
"ACE Audio",
|
||||||
|
"Boogu",
|
||||||
"Ernie",
|
"Ernie",
|
||||||
"Ernie Turbo",
|
"Ernie Turbo",
|
||||||
|
"Grok",
|
||||||
|
"HappyHorse",
|
||||||
|
"HiDream-O1",
|
||||||
|
"Ideogram 4.0",
|
||||||
|
"Krea 2",
|
||||||
|
"Lens",
|
||||||
|
"MAI",
|
||||||
"Nucleus",
|
"Nucleus",
|
||||||
|
"Qwen 2",
|
||||||
|
"Upscaler",
|
||||||
|
"Wan Image 2.7",
|
||||||
|
"Wan Video 2.7",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class _DownloadProgress(dict):
|
|||||||
refreshed_models=set(),
|
refreshed_models=set(),
|
||||||
failed_models=set(),
|
failed_models=set(),
|
||||||
reprocessed_models=set(),
|
reprocessed_models=set(),
|
||||||
|
rate_limited_models=set(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def snapshot(self) -> dict:
|
def snapshot(self) -> dict:
|
||||||
@@ -82,6 +83,7 @@ class _DownloadProgress(dict):
|
|||||||
snapshot["refreshed_models"] = list(self["refreshed_models"])
|
snapshot["refreshed_models"] = list(self["refreshed_models"])
|
||||||
snapshot["failed_models"] = list(self["failed_models"])
|
snapshot["failed_models"] = list(self["failed_models"])
|
||||||
snapshot["reprocessed_models"] = list(self.get("reprocessed_models", set()))
|
snapshot["reprocessed_models"] = list(self.get("reprocessed_models", set()))
|
||||||
|
snapshot["rate_limited_models"] = list(self.get("rate_limited_models", set()))
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
@@ -153,13 +155,15 @@ class DownloadManager:
|
|||||||
# Step 3: Load progress file (I/O operation, done outside lock)
|
# Step 3: Load progress file (I/O operation, done outside lock)
|
||||||
processed_models = set()
|
processed_models = set()
|
||||||
failed_models = set()
|
failed_models = set()
|
||||||
|
rate_limited_models = set()
|
||||||
|
|
||||||
try:
|
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(
|
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(processed_models),
|
||||||
len(failed_models),
|
len(failed_models),
|
||||||
|
len(rate_limited_models),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load progress file: {e}")
|
logger.error(f"Failed to load progress file: {e}")
|
||||||
@@ -175,6 +179,7 @@ class DownloadManager:
|
|||||||
self._progress.reset()
|
self._progress.reset()
|
||||||
self._progress["processed_models"] = processed_models
|
self._progress["processed_models"] = processed_models
|
||||||
self._progress["failed_models"] = failed_models
|
self._progress["failed_models"] = failed_models
|
||||||
|
self._progress["rate_limited_models"] = rate_limited_models
|
||||||
self._stop_requested = False
|
self._stop_requested = False
|
||||||
self._progress["status"] = "running"
|
self._progress["status"] = "running"
|
||||||
self._progress["start_time"] = time.time()
|
self._progress["start_time"] = time.time()
|
||||||
@@ -242,8 +247,8 @@ class DownloadManager:
|
|||||||
"status": self._progress.snapshot(),
|
"status": self._progress.snapshot(),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _load_progress_file(self, output_dir: str) -> tuple[str, set, set]:
|
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).
|
"""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.
|
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
|
None, self._load_progress_file_sync, output_dir
|
||||||
)
|
)
|
||||||
|
|
||||||
def _load_progress_file_sync(self, output_dir: str) -> tuple[str, set, set]:
|
def _load_progress_file_sync(self, output_dir: str) -> tuple[str, set, set, set]:
|
||||||
"""Synchronous implementation of progress file loading."""
|
"""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_file = os.path.join(output_dir, ".download_progress.json")
|
||||||
progress_source = progress_file
|
progress_source = progress_file
|
||||||
|
|
||||||
@@ -289,6 +298,7 @@ class DownloadManager:
|
|||||||
|
|
||||||
processed_models = set()
|
processed_models = set()
|
||||||
failed_models = set()
|
failed_models = set()
|
||||||
|
rate_limited_models = set()
|
||||||
|
|
||||||
if os.path.exists(progress_source):
|
if os.path.exists(progress_source):
|
||||||
try:
|
try:
|
||||||
@@ -296,11 +306,11 @@ class DownloadManager:
|
|||||||
saved_progress = json.load(f)
|
saved_progress = json.load(f)
|
||||||
processed_models = set(saved_progress.get("processed_models", []))
|
processed_models = set(saved_progress.get("processed_models", []))
|
||||||
failed_models = set(saved_progress.get("failed_models", []))
|
failed_models = set(saved_progress.get("failed_models", []))
|
||||||
|
rate_limited_models = set(saved_progress.get("rate_limited_models", []))
|
||||||
except Exception:
|
except Exception:
|
||||||
# Return empty sets on error
|
|
||||||
pass
|
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]:
|
def _load_progress_sets_sync(self, progress_file: str) -> tuple[set, set]:
|
||||||
"""Load only the processed and failed model sets from progress file.
|
"""Load only the processed and failed model sets from progress file.
|
||||||
@@ -732,11 +742,13 @@ class DownloadManager:
|
|||||||
success,
|
success,
|
||||||
is_stale,
|
is_stale,
|
||||||
failed_images,
|
failed_images,
|
||||||
|
rate_limited_images,
|
||||||
) = await ExampleImagesProcessor.download_model_images_with_tracking(
|
) = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||||
model_hash, model_name, images, model_dir, optimize, downloader
|
model_hash, model_name, images, model_dir, optimize, downloader
|
||||||
)
|
)
|
||||||
|
|
||||||
failed_urls: Set[str] = set(failed_images)
|
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 metadata is stale, try to refresh it
|
||||||
if is_stale and model_hash not in self._progress["refreshed_models"]:
|
if is_stale and model_hash not in self._progress["refreshed_models"]:
|
||||||
@@ -760,6 +772,7 @@ class DownloadManager:
|
|||||||
success,
|
success,
|
||||||
_,
|
_,
|
||||||
additional_failed,
|
additional_failed,
|
||||||
|
additional_rate_limited,
|
||||||
) = await ExampleImagesProcessor.download_model_images_with_tracking(
|
) = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||||
model_hash,
|
model_hash,
|
||||||
model_name,
|
model_name,
|
||||||
@@ -770,29 +783,50 @@ class DownloadManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
failed_urls.update(additional_failed)
|
failed_urls.update(additional_failed)
|
||||||
|
rate_limited_urls.update(additional_rate_limited)
|
||||||
|
|
||||||
self._progress["refreshed_models"].add(model_hash)
|
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(
|
await self._remove_failed_images_from_metadata(
|
||||||
model_hash,
|
model_hash,
|
||||||
model_name,
|
model_name,
|
||||||
model_dir,
|
model_dir,
|
||||||
failed_urls,
|
permanent_failures,
|
||||||
scanner,
|
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["failed_models"].add(model_hash)
|
||||||
self._progress["processed_models"].add(model_hash)
|
self._progress["processed_models"].add(model_hash)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Removed %s failed example images for %s",
|
"Removed %s failed example images for %s",
|
||||||
len(failed_urls),
|
len(permanent_failures),
|
||||||
model_name,
|
model_name,
|
||||||
)
|
)
|
||||||
elif success:
|
elif success:
|
||||||
self._progress["processed_models"].add(model_hash)
|
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"]:
|
if force and model_hash in self._progress["failed_models"]:
|
||||||
self._progress["failed_models"].discard(model_hash)
|
self._progress["failed_models"].discard(model_hash)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -850,6 +884,7 @@ class DownloadManager:
|
|||||||
"processed_models": list(self._progress["processed_models"]),
|
"processed_models": list(self._progress["processed_models"]),
|
||||||
"refreshed_models": list(self._progress["refreshed_models"]),
|
"refreshed_models": list(self._progress["refreshed_models"]),
|
||||||
"failed_models": list(self._progress["failed_models"]),
|
"failed_models": list(self._progress["failed_models"]),
|
||||||
|
"rate_limited_models": list(self._progress.get("rate_limited_models", set())),
|
||||||
"completed": self._progress["completed"],
|
"completed": self._progress["completed"],
|
||||||
"total": self._progress["total"],
|
"total": self._progress["total"],
|
||||||
"last_update": time.time(),
|
"last_update": time.time(),
|
||||||
@@ -1155,11 +1190,13 @@ class DownloadManager:
|
|||||||
success,
|
success,
|
||||||
is_stale,
|
is_stale,
|
||||||
failed_images,
|
failed_images,
|
||||||
|
rate_limited_images,
|
||||||
) = await ExampleImagesProcessor.download_model_images_with_tracking(
|
) = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||||
model_hash, model_name, images, model_dir, optimize, downloader
|
model_hash, model_name, images, model_dir, optimize, downloader
|
||||||
)
|
)
|
||||||
|
|
||||||
failed_urls: Set[str] = set(failed_images)
|
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 metadata is stale, try to refresh it
|
||||||
if is_stale and model_hash not in self._progress["refreshed_models"]:
|
if is_stale and model_hash not in self._progress["refreshed_models"]:
|
||||||
@@ -1183,6 +1220,7 @@ class DownloadManager:
|
|||||||
success,
|
success,
|
||||||
_,
|
_,
|
||||||
additional_failed_images,
|
additional_failed_images,
|
||||||
|
additional_rate_limited,
|
||||||
) = await ExampleImagesProcessor.download_model_images_with_tracking(
|
) = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||||
model_hash,
|
model_hash,
|
||||||
model_name,
|
model_name,
|
||||||
@@ -1192,21 +1230,35 @@ class DownloadManager:
|
|||||||
downloader,
|
downloader,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Combine failed images from both attempts
|
|
||||||
failed_urls.update(additional_failed_images)
|
failed_urls.update(additional_failed_images)
|
||||||
|
rate_limited_urls.update(additional_rate_limited)
|
||||||
|
|
||||||
self._progress["refreshed_models"].add(model_hash)
|
self._progress["refreshed_models"].add(model_hash)
|
||||||
|
|
||||||
# For forced downloads, remove failed images from metadata
|
# Separate permanent failures from rate-limited ones
|
||||||
if failed_urls:
|
permanent_failures = failed_urls - rate_limited_urls
|
||||||
|
|
||||||
|
# Only remove permanently failed images from metadata
|
||||||
|
if permanent_failures:
|
||||||
await self._remove_failed_images_from_metadata(
|
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 rate_limited_urls:
|
||||||
if (
|
self._progress["rate_limited_models"].add(model_hash)
|
||||||
success or failed_urls
|
logger.warning(
|
||||||
): # Mark as processed if we successfully downloaded some images or removed failed ones
|
"%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)
|
self._progress["processed_models"].add(model_hash)
|
||||||
|
|
||||||
return True # Return True to indicate a remote download happened
|
return True # Return True to indicate a remote download happened
|
||||||
@@ -1229,15 +1281,20 @@ class DownloadManager:
|
|||||||
model_dir: str,
|
model_dir: str,
|
||||||
failed_images: Iterable[str],
|
failed_images: Iterable[str],
|
||||||
scanner,
|
scanner,
|
||||||
|
error_type: str = "not_found",
|
||||||
) -> None:
|
) -> 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}
|
failed_set: Set[str] = {url for url in failed_images if url}
|
||||||
if not failed_set:
|
if not failed_set:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get current model data
|
|
||||||
model_data = await MetadataUpdater.get_updated_model(model_hash, scanner)
|
model_data = await MetadataUpdater.get_updated_model(model_hash, scanner)
|
||||||
if not model_data:
|
if not model_data:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1268,7 +1325,7 @@ class DownloadManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
image["downloadFailed"] = True
|
image["downloadFailed"] = True
|
||||||
image.setdefault("downloadError", "not_found")
|
image.setdefault("downloadError", error_type)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Marked example image %s for %s as failed due to missing remote asset",
|
"Marked example image %s for %s as failed due to missing remote asset",
|
||||||
image_url,
|
image_url,
|
||||||
|
|||||||
@@ -113,6 +113,35 @@ def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str
|
|||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return legacy_folder
|
return legacy_folder
|
||||||
|
elif not os.path.exists(resolved_folder):
|
||||||
|
# Reverse migration: when consolidating from multi-library to
|
||||||
|
# single-library mode (e.g. after "default" was cleaned up), look
|
||||||
|
# for existing example images inside library-named subdirectories
|
||||||
|
# and bring them back to the root level.
|
||||||
|
root = get_example_images_root()
|
||||||
|
if root:
|
||||||
|
try:
|
||||||
|
for entry in os.listdir(root):
|
||||||
|
entry_path = os.path.join(root, entry)
|
||||||
|
if not os.path.isdir(entry_path):
|
||||||
|
continue
|
||||||
|
if is_hash_folder(entry) or entry == "_deleted":
|
||||||
|
continue
|
||||||
|
if not _library_folder_has_only_hash_dirs(entry_path):
|
||||||
|
continue
|
||||||
|
legacy = os.path.join(entry_path, normalized_hash)
|
||||||
|
if os.path.exists(legacy):
|
||||||
|
shutil.move(legacy, resolved_folder)
|
||||||
|
logger.info(
|
||||||
|
"Consolidated example images from '%s' to '%s'",
|
||||||
|
legacy, resolved_folder,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to consolidate example images during "
|
||||||
|
"library merge: %s", exc,
|
||||||
|
)
|
||||||
|
|
||||||
return resolved_folder
|
return resolved_folder
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -194,16 +195,22 @@ class ExampleImagesProcessor:
|
|||||||
|
|
||||||
return model_success, False # (success, is_metadata_stale)
|
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
|
@staticmethod
|
||||||
async def download_model_images_with_tracking(model_hash, model_name, model_images, model_dir, optimize, downloader):
|
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
|
model_success = True
|
||||||
failed_images = []
|
failed_images = []
|
||||||
|
rate_limited_images = []
|
||||||
|
any_successful_download = False
|
||||||
|
|
||||||
for i, image in enumerate(model_images):
|
for i, image in enumerate(model_images):
|
||||||
image_url = image.get('url')
|
image_url = image.get('url')
|
||||||
if not image_url:
|
if not image_url:
|
||||||
@@ -221,64 +228,110 @@ class ExampleImagesProcessor:
|
|||||||
original_url = image_url
|
original_url = image_url
|
||||||
if optimize and 'civitai.com' in image_url:
|
if optimize and 'civitai.com' in image_url:
|
||||||
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
|
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
|
||||||
|
|
||||||
# Download the file first to determine the actual file type
|
async def _attempt_download() -> tuple:
|
||||||
try:
|
logger.debug("Downloading media file %s for %s", i, model_name)
|
||||||
logger.debug(f"Downloading media file {i} for {model_name}")
|
return await downloader.download_to_memory(
|
||||||
|
|
||||||
# Download using the unified downloader with headers
|
|
||||||
success, content, headers = await downloader.download_to_memory(
|
|
||||||
image_url,
|
image_url,
|
||||||
use_auth=False, # Example images don't need auth
|
use_auth=False,
|
||||||
return_headers=True
|
return_headers=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, content, headers = await _attempt_download()
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
# Determine file extension from content or headers
|
|
||||||
media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
|
media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
|
||||||
content, headers, original_url, image.get("type")
|
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_image = media_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
|
||||||
is_video = media_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
is_video = media_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||||
|
|
||||||
if not (is_image or is_video):
|
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
|
continue
|
||||||
|
|
||||||
# Use 0-based indexing with the detected extension
|
|
||||||
save_filename = f"image_{i}{media_ext}"
|
save_filename = f"image_{i}{media_ext}"
|
||||||
save_path = os.path.join(model_dir, save_filename)
|
save_path = os.path.join(model_dir, save_filename)
|
||||||
|
|
||||||
# Check if already downloaded
|
|
||||||
if os.path.exists(save_path):
|
if os.path.exists(save_path):
|
||||||
logger.debug(f"File already exists: {save_path}")
|
logger.debug("File already exists: %s", save_path)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Save the file
|
|
||||||
with open(save_path, 'wb') as f:
|
with open(save_path, 'wb') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
any_successful_download = True
|
||||||
|
|
||||||
elif ExampleImagesProcessor._is_not_found_error(content):
|
elif ExampleImagesProcessor._is_not_found_error(content):
|
||||||
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
|
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
|
||||||
logger.warning(error_msg)
|
logger.warning(error_msg)
|
||||||
model_success = False # Mark the model as failed due to 404 error
|
model_success = False
|
||||||
failed_images.append(image_url) # Track failed URL
|
failed_images.append(image_url)
|
||||||
# Return early to trigger metadata refresh attempt
|
return False, True, failed_images, rate_limited_images
|
||||||
return False, True, failed_images # (success, is_metadata_stale, failed_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:
|
else:
|
||||||
error_msg = f"Failed to download file: {image_url}, error: {content}"
|
error_msg = f"Failed to download file: {image_url}, error: {content}"
|
||||||
logger.warning(error_msg)
|
logger.warning(error_msg)
|
||||||
model_success = False # Mark the model as failed
|
model_success = False
|
||||||
failed_images.append(image_url) # Track failed URL
|
failed_images.append(image_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error downloading file {image_url}: {str(e)}"
|
error_msg = f"Error downloading file {image_url}: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
model_success = False # Mark the model as failed
|
model_success = False
|
||||||
failed_images.append(image_url) # Track failed URL
|
failed_images.append(image_url)
|
||||||
|
|
||||||
return model_success, False, failed_images # (success, is_metadata_stale, failed_images)
|
return any_successful_download or model_success, False, failed_images, rate_limited_images
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def process_local_examples(model_file_path, model_file_name, model_name, model_dir, optimize):
|
async def process_local_examples(model_file_path, model_file_name, model_name, model_dir, optimize):
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ class BaseModelMetadata:
|
|||||||
metadata_source: Optional[str] = None # Last provider that supplied metadata
|
metadata_source: Optional[str] = None # Last provider that supplied metadata
|
||||||
last_checked_at: float = 0 # Last checked timestamp
|
last_checked_at: float = 0 # Last checked timestamp
|
||||||
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
|
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(
|
_unknown_fields: Dict[str, Any] = field(
|
||||||
default_factory=dict, repr=False, compare=False
|
default_factory=dict, repr=False, compare=False
|
||||||
) # Store unknown fields
|
) # Store unknown fields
|
||||||
@@ -47,6 +50,9 @@ class BaseModelMetadata:
|
|||||||
if self.tags is None:
|
if self.tags is None:
|
||||||
self.tags = []
|
self.tags = []
|
||||||
|
|
||||||
|
if self.trainedWords is None:
|
||||||
|
self.trainedWords = []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: Dict) -> "BaseModelMetadata":
|
def from_dict(cls, data: Dict) -> "BaseModelMetadata":
|
||||||
"""Create instance from dictionary"""
|
"""Create instance from dictionary"""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from platformdirs import user_config_dir
|
|||||||
|
|
||||||
|
|
||||||
APP_NAME = "ComfyUI-LoRA-Manager"
|
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||||
|
_LM_PORTABLE_ENV = "LORA_MANAGER_PORTABLE"
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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:
|
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):
|
if not os.path.exists(path):
|
||||||
return False
|
return False
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.1.6"
|
version = "1.1.7"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
# Ensure the script's directory is on sys.path so that py.* imports resolve
|
||||||
|
# regardless of the current working directory (e.g. when launched via
|
||||||
|
# ComfyUI's python_embeded from the ComfyUI root directory).
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
from py.middleware.cache_middleware import cache_control
|
from py.middleware.cache_middleware import cache_control
|
||||||
from py.middleware.error_middleware import api_json_error
|
from py.middleware.error_middleware import api_json_error
|
||||||
from py.utils.settings_paths import ensure_settings_file
|
from py.utils.settings_paths import ensure_settings_file
|
||||||
|
|||||||
@@ -444,16 +444,161 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-model-selector {
|
/* ── Base Model Search Dropdown ─────────────────────────────────────────── */
|
||||||
width: 100%;
|
|
||||||
padding: 3px 5px;
|
.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);
|
background: var(--bg-color);
|
||||||
border: 1px solid var(--lora-accent);
|
border: 1px solid var(--lora-accent);
|
||||||
border-radius: var(--border-radius-xs);
|
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);
|
color: var(--text-color);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
outline: none;
|
padding: 3px 0;
|
||||||
margin-right: var(--space-1);
|
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 {
|
.size-wrapper {
|
||||||
|
|||||||
@@ -40,6 +40,12 @@
|
|||||||
margin: 3px 0;
|
margin: 3px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-menu-item.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.context-menu-item.delete-item {
|
.context-menu-item.delete-item {
|
||||||
color: var(--danger-color);
|
color: var(--danger-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -577,13 +577,14 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base), box-shadow var(--transition-fast), transform var(--transition-fast);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-option:hover {
|
.file-option:hover {
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-option.selected {
|
.file-option.selected {
|
||||||
@@ -698,10 +699,25 @@
|
|||||||
color: var(--lora-accent);
|
color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Batch Preview List */
|
/* BUG 1 FIX: Single scrollbar — modal-content becomes a flex column so the
|
||||||
|
batch preview step can flex; the list scrolls instead of the modal-content. */
|
||||||
|
#downloadModal .modal-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#batchPreviewStep {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Batch Preview List — no max-height; flexes inside #batchPreviewStep */
|
||||||
.batch-preview-list {
|
.batch-preview-list {
|
||||||
max-height: 400px;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
margin: var(--space-2) 0;
|
margin: var(--space-2) 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -821,4 +837,165 @@
|
|||||||
|
|
||||||
[data-theme="dark"] .batch-preview-item {
|
[data-theme="dark"] .batch-preview-item {
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hf-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: oklch(0.55 0.12 250 / 0.15);
|
||||||
|
color: oklch(0.7 0.12 250);
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Checkbox inside HF batch preview items */
|
||||||
|
.batch-preview-checkbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--lora-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select All toolbar in batch preview */
|
||||||
|
.batch-preview-select-all {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-select-all input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--lora-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-select-all label {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .batch-preview-select-all {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FEATURE 2: HF repo grouping — collapsible groups by repo */
|
||||||
|
.batch-preview-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--color-accent-subtle);
|
||||||
|
border-bottom: 1px solid var(--color-accent-border);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-group-header:hover {
|
||||||
|
background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-group-toggle {
|
||||||
|
width: 14px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-group-toggle.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-group-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-group-count {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-group-select-all {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--lora-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-group-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: max-height 0.35s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-group-body.expanded {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 9999px; /* rest state: content visible; JS inline style overrides during transitions */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme overrides for group styles */
|
||||||
|
[data-theme="dark"] .batch-preview-group {
|
||||||
|
background: var(--surface-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .batch-preview-group-header {
|
||||||
|
background: var(--color-accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .batch-preview-group-header:hover {
|
||||||
|
background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .batch-preview-group-body {
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,18 +21,22 @@
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group {
|
#relinkCivitaiModal .input-group,
|
||||||
|
#linkHfModal .input-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group label {
|
#relinkCivitaiModal .input-group label,
|
||||||
|
#linkHfModal .input-group label {
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group input {
|
#relinkCivitaiModal .input-group input,
|
||||||
|
#linkHfModal .input-group input {
|
||||||
|
width: auto;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|||||||
@@ -1562,6 +1562,29 @@ input:checked + .toggle-slider:before {
|
|||||||
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.extra-folder-path-row .path-controls .extra-folder-path-input.has-error {
|
||||||
|
border-color: var(--lora-error);
|
||||||
|
background-color: rgba(220, 53, 69, 0.08);
|
||||||
|
background-color: rgba(from var(--lora-error) r g b / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra-folder-path-row .path-controls .extra-folder-path-input.has-error:focus {
|
||||||
|
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.15);
|
||||||
|
box-shadow: 0 0 0 2px rgba(from var(--lora-error) r g b / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra-folder-path-error {
|
||||||
|
color: var(--lora-error);
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra-folder-path-error.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.extra-folder-path-row .path-controls .remove-path-btn {
|
.extra-folder-path-row .path-controls .remove-path-btn {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -1592,3 +1615,45 @@ input:checked + .toggle-slider:before {
|
|||||||
animation: settings-highlight-pulse 1.5s ease-in-out 3;
|
animation: settings-highlight-pulse 1.5s ease-in-out 3;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Combobox panel for AI Provider settings ---- */
|
||||||
|
/* The panel is appended to <body> by Combobox.js and positioned relative to
|
||||||
|
the enhanced <input>. Styles reuse settings-modal CSS variables. */
|
||||||
|
|
||||||
|
.lm-combobox-panel {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10002;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--lora-surface, #2a2a2a);
|
||||||
|
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.12));
|
||||||
|
border-radius: var(--border-radius-xs, 6px);
|
||||||
|
box-shadow: var(--shadow-elevated, 0 6px 18px rgba(0, 0, 0, 0.45));
|
||||||
|
font-size: 0.95em;
|
||||||
|
color: var(--text-color, rgba(226, 232, 240, 0.9));
|
||||||
|
padding: 4px 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lm-combobox-option {
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lm-combobox-option:hover,
|
||||||
|
.lm-combobox-option.is-active {
|
||||||
|
background: rgba(from var(--lora-accent) r g b / 0.2);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lm-combobox-empty {
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.45;
|
||||||
|
font-style: italic;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -190,6 +190,12 @@ export const DOWNLOAD_ENDPOINTS = {
|
|||||||
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images
|
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hugging Face API endpoints
|
||||||
|
export const HF_ENDPOINTS = {
|
||||||
|
repoFiles: '/api/lm/hf-repo-files',
|
||||||
|
download: '/api/lm/download-hf-model',
|
||||||
|
};
|
||||||
|
|
||||||
// WebSocket endpoints
|
// WebSocket endpoints
|
||||||
export const WS_ENDPOINTS = {
|
export const WS_ENDPOINTS = {
|
||||||
fetchProgress: '/ws/fetch-progress'
|
fetchProgress: '/ws/fetch-progress'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getCurrentModelType,
|
getCurrentModelType,
|
||||||
isValidModelType,
|
isValidModelType,
|
||||||
DOWNLOAD_ENDPOINTS,
|
DOWNLOAD_ENDPOINTS,
|
||||||
|
HF_ENDPOINTS,
|
||||||
WS_ENDPOINTS
|
WS_ENDPOINTS
|
||||||
} from './apiConfig.js';
|
} from './apiConfig.js';
|
||||||
import { resetAndReload } from './modelApiFactory.js';
|
import { resetAndReload } from './modelApiFactory.js';
|
||||||
@@ -111,6 +112,18 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelDownload(downloadId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${DOWNLOAD_ENDPOINTS.cancelGet}?download_id=${encodeURIComponent(downloadId)}`
|
||||||
|
);
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cancelling download:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
|
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
|
||||||
const pageState = this.getPageState();
|
const pageState = this.getPageState();
|
||||||
|
|
||||||
@@ -1243,6 +1256,48 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchHfRepoFiles(repo, revision = 'main') {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ repo, revision });
|
||||||
|
const response = await fetch(`${HF_ENDPOINTS.repoFiles}?${params}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Failed to fetch HF repo files');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching HF repo files:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadHfModel({ repo, filename, revision, modelRoot, relativePath, useDefaultPaths, download_id }) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(HF_ENDPOINTS.download, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
repo,
|
||||||
|
filename,
|
||||||
|
revision: revision || 'main',
|
||||||
|
model_root: modelRoot,
|
||||||
|
relative_path: relativePath || '',
|
||||||
|
use_default_paths: useDefaultPaths || false,
|
||||||
|
...(download_id ? { download_id } : {}),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading HF model:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_buildQueryParams(baseParams, pageState) {
|
_buildQueryParams(baseParams, pageState) {
|
||||||
const params = new URLSearchParams(baseParams);
|
const params = new URLSearchParams(baseParams);
|
||||||
const isExcludedView = pageState.viewMode === 'excluded';
|
const isExcludedView = pageState.viewMode === 'excluded';
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
// Combobox.js — Reusable dropdown-suggestion + free-text input component.
|
||||||
|
//
|
||||||
|
// Enhances an existing <input> element with a dropdown panel that merges static
|
||||||
|
// `presets` with asynchronously fetched options (`fetchOptions`). The input
|
||||||
|
// remains a free-text field — selecting a dropdown option is optional, the
|
||||||
|
// user can always type an arbitrary value.
|
||||||
|
//
|
||||||
|
// Zero dependencies: pure DOM manipulation. Exported on `window.Combobox`
|
||||||
|
// so non-module callers can instantiate it, and as a named ES module export
|
||||||
|
// for callers that import it directly.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// const box = new Combobox(inputEl, {
|
||||||
|
// presets: ['masterpiece', 'best quality'],
|
||||||
|
// fetchOptions: async (q) => await fetchSuggestions(q),
|
||||||
|
// placeholder: 'Type a value…',
|
||||||
|
// onSelect: (value) => console.log('chose', value),
|
||||||
|
// });
|
||||||
|
// box.updatePresets(['new', 'presets']);
|
||||||
|
// box.setValue('masterpiece');
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
export class Combobox {
|
||||||
|
/**
|
||||||
|
* @param {HTMLInputElement} inputElement Existing <input> to enhance.
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string[]} [options.presets=[]] Static preset values shown in dropdown.
|
||||||
|
* @param {(inputValue: string) => Promise<string[]>} [options.fetchOptions]
|
||||||
|
* Async function returning dynamic suggestions for the current input.
|
||||||
|
* @param {string} [options.placeholder] Placeholder text for the empty state.
|
||||||
|
* @param {(value: string) => void} [options.onSelect] Callback when an option is chosen.
|
||||||
|
*/
|
||||||
|
constructor(inputElement, options = {}) {
|
||||||
|
if (!inputElement || inputElement.tagName !== 'INPUT') {
|
||||||
|
console.error('Combobox: expected an <input> element');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.input = inputElement;
|
||||||
|
this.presets = Array.isArray(options.presets) ? [...options.presets] : [];
|
||||||
|
this.fetchOptions = typeof options.fetchOptions === 'function' ? options.fetchOptions : null;
|
||||||
|
this.placeholder = options.placeholder || '';
|
||||||
|
this.onSelect = typeof options.onSelect === 'function' ? options.onSelect : null;
|
||||||
|
|
||||||
|
// Internal state
|
||||||
|
this._isOpen = false;
|
||||||
|
this._activeIndex = -1;
|
||||||
|
this._renderedOptions = []; // current visible option strings (de-duplicated, merged)
|
||||||
|
this._fetchToken = 0; // guards against out-of-order async fetch results
|
||||||
|
this._fetchTimer = null;
|
||||||
|
this._suppressInputOpen = false; // guards setValue() from reopening the dropdown
|
||||||
|
|
||||||
|
this._buildDropdown();
|
||||||
|
this._bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- public API ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the preset list. Re-renders the dropdown if it is open.
|
||||||
|
* @param {string[]} presets
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
updatePresets(presets) {
|
||||||
|
this.presets = Array.isArray(presets) ? [...presets] : [];
|
||||||
|
if (this._isOpen) {
|
||||||
|
this._refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the input value programmatically without triggering the dropdown
|
||||||
|
* or firing synthetic events.
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
setValue(value) {
|
||||||
|
const prev = this._suppressInputOpen;
|
||||||
|
this._suppressInputOpen = true;
|
||||||
|
this.input.value = value ?? '';
|
||||||
|
this._suppressInputOpen = prev;
|
||||||
|
if (this._isOpen) {
|
||||||
|
this._refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- build ----
|
||||||
|
|
||||||
|
_buildDropdown() {
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'lm-combobox-panel';
|
||||||
|
panel.setAttribute('role', 'listbox');
|
||||||
|
panel.style.display = 'none';
|
||||||
|
// Append to <body> so the panel is never clipped by an overflow:hidden
|
||||||
|
// ancestor; positioning is recomputed on each open.
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
this.panel = panel;
|
||||||
|
|
||||||
|
if (this.placeholder) {
|
||||||
|
this.input.setAttribute('placeholder', this.placeholder);
|
||||||
|
}
|
||||||
|
this.input.setAttribute('autocomplete', 'off');
|
||||||
|
this.input.setAttribute('role', 'combobox');
|
||||||
|
this.input.setAttribute('aria-autocomplete', 'list');
|
||||||
|
this.input.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- event wiring ----
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
this.input.addEventListener('focus', () => {
|
||||||
|
if (this._suppressInputOpen) return;
|
||||||
|
this._open();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.input.addEventListener('input', () => {
|
||||||
|
if (this._suppressInputOpen) return;
|
||||||
|
this._open(); // no-op if already open
|
||||||
|
this._refresh(); // re-filter by current input value
|
||||||
|
this._scheduleFetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.input.addEventListener('keydown', (event) => this._onKeyDown(event));
|
||||||
|
|
||||||
|
// Click an option (delegated)
|
||||||
|
this.panel.addEventListener('click', (event) => {
|
||||||
|
const item = event.target.closest('.lm-combobox-option');
|
||||||
|
if (!item) return;
|
||||||
|
const value = item.dataset.value;
|
||||||
|
if (value !== undefined) {
|
||||||
|
this._choose(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hover updates the active highlight so keyboard + mouse stay in sync.
|
||||||
|
this.panel.addEventListener('mouseover', (event) => {
|
||||||
|
const item = event.target.closest('.lm-combobox-option');
|
||||||
|
if (!item) return;
|
||||||
|
const idx = Number(item.dataset.index);
|
||||||
|
if (!Number.isNaN(idx)) {
|
||||||
|
this._setActiveIndex(idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside closes the dropdown.
|
||||||
|
this._outsideClickHandler = (event) => {
|
||||||
|
if (this._isOpen && !this.input.contains(event.target) && !this.panel.contains(event.target)) {
|
||||||
|
this._close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', this._outsideClickHandler);
|
||||||
|
|
||||||
|
// Reposition on viewport changes while open.
|
||||||
|
this._resizeHandler = () => {
|
||||||
|
if (this._isOpen) this._position();
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', this._resizeHandler);
|
||||||
|
window.addEventListener('scroll', this._resizeHandler, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- keyboard ----
|
||||||
|
|
||||||
|
_onKeyDown(event) {
|
||||||
|
if (!this._isOpen) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
this._open();
|
||||||
|
this._setActiveIndex(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
this._setActiveIndex(this._activeIndex + 1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
this._setActiveIndex(this._activeIndex - 1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
// Only intercept Enter to pick an option when one is actively
|
||||||
|
// highlighted; otherwise let the input's default behavior
|
||||||
|
// (form submit / free-text commit) proceed.
|
||||||
|
if (this._activeIndex >= 0 && this._activeIndex < this._renderedOptions.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
this._choose(this._renderedOptions[this._activeIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault();
|
||||||
|
this._close();
|
||||||
|
this.input.focus();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Tab':
|
||||||
|
// Allow normal tab navigation; just close the panel.
|
||||||
|
this._close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- open / close ----
|
||||||
|
|
||||||
|
_open() {
|
||||||
|
if (this._isOpen) return;
|
||||||
|
this._isOpen = true;
|
||||||
|
this.panel.style.display = 'block';
|
||||||
|
this.input.setAttribute('aria-expanded', 'true');
|
||||||
|
// On open, render ALL presets — do not filter by the current input
|
||||||
|
// value. Filtering on the input event is handled separately.
|
||||||
|
this._render(this.presets);
|
||||||
|
this._position();
|
||||||
|
}
|
||||||
|
|
||||||
|
_close() {
|
||||||
|
if (!this._isOpen) return;
|
||||||
|
this._isOpen = false;
|
||||||
|
this.panel.style.display = 'none';
|
||||||
|
this.input.setAttribute('aria-expanded', 'false');
|
||||||
|
this._activeIndex = -1;
|
||||||
|
this._cancelFetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
_position() {
|
||||||
|
const rect = this.input.getBoundingClientRect();
|
||||||
|
const panelHeight = this.panel.offsetHeight;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom;
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
|
||||||
|
// Flip above the input when there is more room there.
|
||||||
|
const placeAbove = spaceBelow < panelHeight && spaceAbove > spaceBelow;
|
||||||
|
const top = placeAbove
|
||||||
|
? rect.top + window.scrollY - panelHeight
|
||||||
|
: rect.bottom + window.scrollY;
|
||||||
|
|
||||||
|
this.panel.style.top = `${Math.max(0, top)}px`;
|
||||||
|
this.panel.style.left = `${rect.left + window.scrollX}px`;
|
||||||
|
this.panel.style.minWidth = `${rect.width}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- rendering ----
|
||||||
|
|
||||||
|
/** Render a list of strings into the panel. */
|
||||||
|
_render(items) {
|
||||||
|
this._renderedOptions = items;
|
||||||
|
this.panel.innerHTML = '';
|
||||||
|
if (items.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'lm-combobox-empty';
|
||||||
|
empty.textContent = this.placeholder ? this.placeholder : 'No options';
|
||||||
|
this.panel.appendChild(empty);
|
||||||
|
this._activeIndex = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
items.forEach((opt, idx) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'lm-combobox-option';
|
||||||
|
item.setAttribute('role', 'option');
|
||||||
|
item.dataset.value = opt;
|
||||||
|
item.dataset.index = String(idx);
|
||||||
|
item.textContent = opt;
|
||||||
|
if (idx === this._activeIndex) {
|
||||||
|
item.classList.add('is-active');
|
||||||
|
}
|
||||||
|
fragment.appendChild(item);
|
||||||
|
});
|
||||||
|
this.panel.appendChild(fragment);
|
||||||
|
|
||||||
|
if (this._activeIndex >= items.length) {
|
||||||
|
this._setActiveIndex(items.length - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter presets by current input value and re-render. */
|
||||||
|
_refresh() {
|
||||||
|
const value = this.input.value;
|
||||||
|
const filtered = this._filterPresets(value);
|
||||||
|
const merged = this._mergeUnique(filtered, this._fetchedOptions || []);
|
||||||
|
this._render(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
_filterPresets(value) {
|
||||||
|
const v = (value || '').toLowerCase();
|
||||||
|
if (!v) return [...this.presets];
|
||||||
|
return this.presets.filter((p) => String(p).toLowerCase().startsWith(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
_mergeUnique(...lists) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const item of list) {
|
||||||
|
const key = String(item);
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
out.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setActiveIndex(idx) {
|
||||||
|
const max = this._renderedOptions.length - 1;
|
||||||
|
const clamped = Math.max(-1, Math.min(max, idx));
|
||||||
|
this._activeIndex = clamped;
|
||||||
|
// Update DOM classes without full re-render.
|
||||||
|
const items = this.panel.querySelectorAll('.lm-combobox-option');
|
||||||
|
items.forEach((el, i) => {
|
||||||
|
el.classList.toggle('is-active', i === clamped);
|
||||||
|
});
|
||||||
|
// Scroll the active item into view inside the panel.
|
||||||
|
if (clamped >= 0 && items[clamped]) {
|
||||||
|
items[clamped].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the panel from the DOM and detach event listeners.
|
||||||
|
* Call this before discarding the Combobox instance.
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this._close();
|
||||||
|
if (this.panel && this.panel.parentNode) {
|
||||||
|
this.panel.parentNode.removeChild(this.panel);
|
||||||
|
}
|
||||||
|
document.removeEventListener('mousedown', this._outsideClickHandler);
|
||||||
|
window.removeEventListener('resize', this._resizeHandler);
|
||||||
|
window.removeEventListener('scroll', this._resizeHandler, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
_choose(value) {
|
||||||
|
this.input.value = value;
|
||||||
|
this._close();
|
||||||
|
if (typeof this.onSelect === 'function') {
|
||||||
|
this.onSelect(value);
|
||||||
|
}
|
||||||
|
// Re-focus without reopening the dropdown.
|
||||||
|
this._suppressInputOpen = true;
|
||||||
|
this.input.focus();
|
||||||
|
this._suppressInputOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- async fetch (debounced) ----
|
||||||
|
|
||||||
|
_scheduleFetch() {
|
||||||
|
if (!this.fetchOptions) return;
|
||||||
|
this._cancelFetch();
|
||||||
|
this._fetchTimer = setTimeout(() => {
|
||||||
|
this._fetchTimer = null;
|
||||||
|
this._runFetch();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancelFetch() {
|
||||||
|
if (this._fetchTimer) {
|
||||||
|
clearTimeout(this._fetchTimer);
|
||||||
|
this._fetchTimer = null;
|
||||||
|
}
|
||||||
|
this._fetchToken++; // invalidate any in-flight result
|
||||||
|
}
|
||||||
|
|
||||||
|
async _runFetch() {
|
||||||
|
if (!this.fetchOptions) return;
|
||||||
|
const token = this._fetchToken;
|
||||||
|
const value = this.input.value;
|
||||||
|
let results;
|
||||||
|
try {
|
||||||
|
results = await this.fetchOptions(value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Combobox fetchOptions error:', err);
|
||||||
|
results = [];
|
||||||
|
}
|
||||||
|
// Stale guard: a newer fetch or close superseded this one.
|
||||||
|
if (token !== this._fetchToken || !this._isOpen) return;
|
||||||
|
this._fetchedOptions = Array.isArray(results) ? results : [];
|
||||||
|
this._refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for non-module callers (templates load via <script type="module">,
|
||||||
|
// but some widget code reads globals off `window`).
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.Combobox = Combobox;
|
||||||
|
}
|
||||||
@@ -27,8 +27,9 @@ export class BaseContextMenu {
|
|||||||
const menuItem = e.target.closest('.context-menu-item');
|
const menuItem = e.target.closest('.context-menu-item');
|
||||||
if (!menuItem || !this.currentCard) return;
|
if (!menuItem || !this.currentCard) return;
|
||||||
|
|
||||||
// Ignore clicks on submenu trigger (has-submenu parent)
|
// Ignore clicks on submenu trigger (has-submenu parent) or disabled items
|
||||||
if (menuItem.classList.contains('has-submenu')) return;
|
if (menuItem.classList.contains('has-submenu')) return;
|
||||||
|
if (menuItem.classList.contains('disabled')) return;
|
||||||
|
|
||||||
const action = menuItem.dataset.action;
|
const action = menuItem.dataset.action;
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
|
|||||||
@@ -274,6 +274,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
case 'resume-metadata-refresh':
|
case 'resume-metadata-refresh':
|
||||||
bulkManager.setSkipMetadataRefresh(false);
|
bulkManager.setSkipMetadataRefresh(false);
|
||||||
break;
|
break;
|
||||||
|
case 'enrich-hf-llm-bulk':
|
||||||
|
this.enrichBulkWithAgent();
|
||||||
|
break;
|
||||||
case 'delete-all':
|
case 'delete-all':
|
||||||
bulkManager.showBulkDeleteModal();
|
bulkManager.showBulkDeleteModal();
|
||||||
break;
|
break;
|
||||||
@@ -363,4 +366,90 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
console.error('Bulk download example images failed:', error);
|
console.error('Bulk download example images failed:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich metadata for selected models via LLM agent skill.
|
||||||
|
*/
|
||||||
|
async enrichBulkWithAgent() {
|
||||||
|
if (state.selectedModels.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { agentManager } = await import('../../managers/AgentManager.js');
|
||||||
|
|
||||||
|
const configured = await agentManager.isLlmConfigured();
|
||||||
|
if (!configured) {
|
||||||
|
showToast('toast.agent.llmNotConfigured', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelPaths = [...state.selectedModels];
|
||||||
|
|
||||||
|
agentManager.connect();
|
||||||
|
|
||||||
|
const progressUI = state.loadingManager.showEnhancedProgress(
|
||||||
|
`Enriching metadata for ${modelPaths.length} models...`
|
||||||
|
);
|
||||||
|
|
||||||
|
function cleanupCallbacks() {
|
||||||
|
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
|
||||||
|
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
|
||||||
|
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
|
||||||
|
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
|
||||||
|
const eIdx = agentManager.errorCallbacks.indexOf(onError);
|
||||||
|
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProgress = (data) => {
|
||||||
|
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
|
||||||
|
if (state.virtualScroller?.updateSingleItem) {
|
||||||
|
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
|
||||||
|
}
|
||||||
|
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
|
||||||
|
const name = data.current_path.split('/').pop();
|
||||||
|
progressUI.updateProgress(pct, name, `Processing ${data.processed}/${data.total}: ${name}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
agentManager.onProgress(onProgress);
|
||||||
|
|
||||||
|
const onComplete = (data) => {
|
||||||
|
cleanupCallbacks();
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
if (state.bulkMode) bulkManager.toggleBulkMode();
|
||||||
|
progressUI.complete(data.summary || 'Enrich complete');
|
||||||
|
showToast(
|
||||||
|
'toast.agent.enrichComplete',
|
||||||
|
{ summary: data.summary || 'Done' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
agentManager.onComplete(onComplete);
|
||||||
|
|
||||||
|
const onError = (data) => {
|
||||||
|
cleanupCallbacks();
|
||||||
|
if (state.bulkMode) bulkManager.toggleBulkMode();
|
||||||
|
state.loadingManager.hide();
|
||||||
|
showToast(
|
||||||
|
'toast.agent.enrichFailed',
|
||||||
|
{ error: data.error || 'Unknown error' },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
agentManager.onError(onError);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await agentManager.executeSkill('enrich_hf_metadata', modelPaths);
|
||||||
|
} catch (error) {
|
||||||
|
cleanupCallbacks();
|
||||||
|
if (state.bulkMode) bulkManager.toggleBulkMode();
|
||||||
|
state.loadingManager.hide();
|
||||||
|
showToast(
|
||||||
|
'toast.agent.enrichFailed',
|
||||||
|
{ error: error.message },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js';
|
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax, showToast } from '../../utils/uiHelpers.js';
|
||||||
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
||||||
import { moveManager } from '../../managers/MoveManager.js';
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
|
|
||||||
@@ -23,6 +24,17 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
showMenu(x, y, card) {
|
showMenu(x, y, card) {
|
||||||
super.showMenu(x, y, card);
|
super.showMenu(x, y, card);
|
||||||
this.updateExcludeMenuItem();
|
this.updateExcludeMenuItem();
|
||||||
|
this.updateEnrichMenuItem(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEnrichMenuItem(card) {
|
||||||
|
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
|
||||||
|
if (!enrichItem) return;
|
||||||
|
const hasHfUrl = !!card.dataset.hf_url;
|
||||||
|
enrichItem.classList.toggle('disabled', !hasHfUrl);
|
||||||
|
enrichItem.title = hasHfUrl
|
||||||
|
? ''
|
||||||
|
: 'Link this model to a HuggingFace repo first (Link Model \u2192 Link to HuggingFace)';
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMenuAction(action, menuItem) {
|
handleMenuAction(action, menuItem) {
|
||||||
@@ -63,6 +75,9 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
case 'refresh-metadata':
|
case 'refresh-metadata':
|
||||||
getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
|
case 'enrich-hf-llm':
|
||||||
|
this.enrichWithAgent(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
case 'exclude':
|
case 'exclude':
|
||||||
showExcludeModal(this.currentCard.dataset.filepath);
|
showExcludeModal(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
@@ -72,10 +87,74 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async enrichWithAgent(filePath) {
|
||||||
|
const { agentManager } = await import('../../managers/AgentManager.js');
|
||||||
|
|
||||||
|
const configured = await agentManager.isLlmConfigured();
|
||||||
|
if (!configured) {
|
||||||
|
showToast('toast.agent.llmNotConfigured', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
agentManager.connect();
|
||||||
|
|
||||||
|
const progressUI = state.loadingManager.showEnhancedProgress(
|
||||||
|
'Enriching metadata with AI...'
|
||||||
|
);
|
||||||
|
|
||||||
|
function cleanupCallbacks() {
|
||||||
|
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
|
||||||
|
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
|
||||||
|
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
|
||||||
|
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
|
||||||
|
const eIdx = agentManager.errorCallbacks.indexOf(onError);
|
||||||
|
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProgress = (data) => {
|
||||||
|
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
|
||||||
|
if (state.virtualScroller?.updateSingleItem) {
|
||||||
|
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
|
||||||
|
}
|
||||||
|
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
|
||||||
|
const name = data.current_path.split('/').pop();
|
||||||
|
progressUI.updateProgress(pct, name, `Processing ${name}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
agentManager.onProgress(onProgress);
|
||||||
|
|
||||||
|
const onComplete = (data) => {
|
||||||
|
cleanupCallbacks();
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
progressUI.complete(data.summary || 'Enrich complete');
|
||||||
|
showToast('toast.agent.enrichComplete', { summary: data.summary || 'Done' }, 'success');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
agentManager.onComplete(onComplete);
|
||||||
|
|
||||||
|
const onError = (data) => {
|
||||||
|
cleanupCallbacks();
|
||||||
|
state.loadingManager.hide();
|
||||||
|
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
|
||||||
|
};
|
||||||
|
agentManager.onError(onError);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
|
||||||
|
} catch (error) {
|
||||||
|
cleanupCallbacks();
|
||||||
|
state.loadingManager.hide();
|
||||||
|
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sendLoraToWorkflow(replaceMode) {
|
sendLoraToWorkflow(replaceMode) {
|
||||||
const card = this.currentCard;
|
const card = this.currentCard;
|
||||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||||
const loraSyntax = buildLoraSyntax(card.dataset.file_name, usageTips);
|
const folder = card.dataset.folder || '';
|
||||||
|
const loraName = folder ? `${folder}/${card.dataset.file_name}` : card.dataset.file_name;
|
||||||
|
const loraSyntax = buildLoraSyntax(loraName, usageTips);
|
||||||
|
|
||||||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,74 @@ export const ModelContextMenuMixin = {
|
|||||||
setTimeout(() => urlInput.focus(), 50);
|
setTimeout(() => urlInput.focus(), 50);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// HuggingFace linking methods
|
||||||
|
showLinkHfModal() {
|
||||||
|
const filePath = this.currentCard.dataset.filepath;
|
||||||
|
if (!filePath) return;
|
||||||
|
|
||||||
|
const confirmBtn = document.getElementById('confirmLinkHfBtn');
|
||||||
|
const urlInput = document.getElementById('hfModelUrl');
|
||||||
|
const errorDiv = document.getElementById('hfModelUrlError');
|
||||||
|
|
||||||
|
if (this._boundLinkHfHandler) {
|
||||||
|
confirmBtn.removeEventListener('click', this._boundLinkHfHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._boundLinkHfHandler = async () => {
|
||||||
|
const hfUrl = urlInput.value.trim();
|
||||||
|
if (!hfUrl) {
|
||||||
|
errorDiv.textContent = 'Please enter a HuggingFace repository URL.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hfPattern = /^https?:\/\/huggingface\.co\/([^/]+\/[^/]+)\/?$/;
|
||||||
|
if (!hfPattern.test(hfUrl)) {
|
||||||
|
errorDiv.textContent = 'Invalid URL format. Expected: https://huggingface.co/user/repo';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorDiv.textContent = '';
|
||||||
|
modalManager.closeModal('linkHfModal');
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.loadingManager.showSimpleLoading('Linking to HuggingFace...');
|
||||||
|
|
||||||
|
const response = await fetch('/api/lm/set-hf-url', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ file_path: filePath, hf_url: hfUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || `Request failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('toast.contextMenu.linkHfSuccess', {}, 'success');
|
||||||
|
await this.resetAndReload();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Failed to link model');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error linking model to HuggingFace:', error);
|
||||||
|
showToast('toast.contextMenu.linkHfFailed', { message: error.message }, 'error');
|
||||||
|
} finally {
|
||||||
|
state.loadingManager.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', this._boundLinkHfHandler);
|
||||||
|
|
||||||
|
urlInput.value = '';
|
||||||
|
errorDiv.textContent = '';
|
||||||
|
|
||||||
|
modalManager.showModal('linkHfModal');
|
||||||
|
|
||||||
|
setTimeout(() => urlInput.focus(), 50);
|
||||||
|
},
|
||||||
|
|
||||||
extractModelVersionId(url) {
|
extractModelVersionId(url) {
|
||||||
return extractCivitaiModelUrlParts(url);
|
return extractCivitaiModelUrlParts(url);
|
||||||
},
|
},
|
||||||
@@ -295,6 +363,9 @@ export const ModelContextMenuMixin = {
|
|||||||
case 'relink-civitai':
|
case 'relink-civitai':
|
||||||
this.showRelinkCivitaiModal();
|
this.showRelinkCivitaiModal();
|
||||||
return true;
|
return true;
|
||||||
|
case 'link-hf':
|
||||||
|
this.showLinkHfModal();
|
||||||
|
return true;
|
||||||
case 'set-nsfw':
|
case 'set-nsfw':
|
||||||
this.showNSFWLevelSelector(null, null, this.currentCard);
|
this.showNSFWLevelSelector(null, null, this.currentCard);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ class RecipeCard {
|
|||||||
<div class="delete-preview">
|
<div class="delete-preview">
|
||||||
${isVideo ?
|
${isVideo ?
|
||||||
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
|
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
|
||||||
`<img src="${previewUrl}" alt="${this.recipe.title}">`
|
`<img src="${previewUrl}" alt="${this.recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="delete-info">
|
<div class="delete-info">
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ class RecipeModal {
|
|||||||
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
||||||
<source src="${lora.preview_url}" type="video/mp4">
|
<source src="${lora.preview_url}" type="video/mp4">
|
||||||
</video>` :
|
</video>` :
|
||||||
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
|
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
|
||||||
|
|
||||||
let loraItemClass = 'recipe-lora-item';
|
let loraItemClass = 'recipe-lora-item';
|
||||||
if (existsLocally) {
|
if (existsLocally) {
|
||||||
@@ -1606,7 +1606,7 @@ class RecipeModal {
|
|||||||
<video class="thumbnail-video" autoplay loop muted playsinline>
|
<video class="thumbnail-video" autoplay loop muted playsinline>
|
||||||
<source src="${previewUrl}" type="video/mp4">
|
<source src="${previewUrl}" type="video/mp4">
|
||||||
</video>
|
</video>
|
||||||
` : `<img src="${previewUrl}" alt="Checkpoint preview">`;
|
` : `<img src="${previewUrl}" alt="Checkpoint preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
|
||||||
|
|
||||||
const badge = existsLocally ? `
|
const badge = existsLocally ? `
|
||||||
<div class="local-badge">
|
<div class="local-badge">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../../state/index.js';
|
import { state, getCurrentPageState } from '../../state/index.js';
|
||||||
import { showModelModal } from './ModelModal.js';
|
import { showModelModal } from './ModelModal.js';
|
||||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||||
@@ -66,6 +66,8 @@ function handleModelCardEvent_internal(event, modelType) {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (card.dataset.from_civitai === 'true') {
|
if (card.dataset.from_civitai === 'true') {
|
||||||
openCivitai(card.dataset.filepath);
|
openCivitai(card.dataset.filepath);
|
||||||
|
} else if (card.dataset.hf_url) {
|
||||||
|
openHuggingFace(card.dataset.hf_url);
|
||||||
}
|
}
|
||||||
return true; // Stop propagation
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
@@ -313,6 +315,7 @@ async function showModelModalFromCard(card, modelType) {
|
|||||||
modified: card.dataset.modified,
|
modified: card.dataset.modified,
|
||||||
file_size: parseInt(card.dataset.file_size || '0'),
|
file_size: parseInt(card.dataset.file_size || '0'),
|
||||||
from_civitai: card.dataset.from_civitai === 'true',
|
from_civitai: card.dataset.from_civitai === 'true',
|
||||||
|
hf_url: card.dataset.hf_url || '',
|
||||||
base_model: card.dataset.base_model,
|
base_model: card.dataset.base_model,
|
||||||
notes: card.dataset.notes || '',
|
notes: card.dataset.notes || '',
|
||||||
favorite: card.dataset.favorite === 'true',
|
favorite: card.dataset.favorite === 'true',
|
||||||
@@ -401,6 +404,7 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
modified: card.dataset.modified,
|
modified: card.dataset.modified,
|
||||||
file_size: card.dataset.file_size,
|
file_size: card.dataset.file_size,
|
||||||
from_civitai: card.dataset.from_civitai === 'true',
|
from_civitai: card.dataset.from_civitai === 'true',
|
||||||
|
hf_url: card.dataset.hf_url || '',
|
||||||
base_model: card.dataset.base_model,
|
base_model: card.dataset.base_model,
|
||||||
notes: card.dataset.notes,
|
notes: card.dataset.notes,
|
||||||
favorite: card.dataset.favorite === 'true',
|
favorite: card.dataset.favorite === 'true',
|
||||||
@@ -467,6 +471,7 @@ export function createModelCard(model, modelType) {
|
|||||||
card.dataset.base_model = model.base_model || 'Unknown';
|
card.dataset.base_model = model.base_model || 'Unknown';
|
||||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||||
card.dataset.exclude = model.exclude ? 'true' : 'false';
|
card.dataset.exclude = model.exclude ? 'true' : 'false';
|
||||||
|
card.dataset.hf_url = model.hf_url || '';
|
||||||
const hasUpdateAvailable = Boolean(model.update_available);
|
const hasUpdateAvailable = Boolean(model.update_available);
|
||||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||||
@@ -578,7 +583,10 @@ export function createModelCard(model, modelType) {
|
|||||||
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
||||||
const globeTitle = model.from_civitai ?
|
const globeTitle = model.from_civitai ?
|
||||||
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
||||||
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
model.hf_url ?
|
||||||
|
translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face') :
|
||||||
|
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
||||||
|
const globeEnabled = model.from_civitai || !!model.hf_url;
|
||||||
let sendTitle;
|
let sendTitle;
|
||||||
let copyTitle;
|
let copyTitle;
|
||||||
if (modelType === MODEL_TYPES.LORA) {
|
if (modelType === MODEL_TYPES.LORA) {
|
||||||
@@ -603,7 +611,7 @@ export function createModelCard(model, modelType) {
|
|||||||
</i>
|
</i>
|
||||||
<i class="fas fa-globe"
|
<i class="fas fa-globe"
|
||||||
title="${globeTitle}"
|
title="${globeTitle}"
|
||||||
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
${!globeEnabled ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
||||||
</i>
|
</i>
|
||||||
<i class="fas fa-paper-plane"
|
<i class="fas fa-paper-plane"
|
||||||
title="${sendTitle}">
|
title="${sendTitle}">
|
||||||
@@ -635,7 +643,7 @@ export function createModelCard(model, modelType) {
|
|||||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||||
${isVideo ?
|
${isVideo ?
|
||||||
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
||||||
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
|
`<img src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||||
}
|
}
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
${shouldBlur ?
|
${shouldBlur ?
|
||||||
|
|||||||
@@ -3,9 +3,75 @@
|
|||||||
* Handles model metadata editing functionality - General version
|
* Handles model metadata editing functionality - General version
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BASE_MODEL_CATEGORIES } from '../../utils/constants.js';
|
import { BASE_MODEL_CATEGORIES, getMergedBaseModels } from '../../utils/constants.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
|
||||||
|
// ── Filename-based base model inference ──────────────────────────────────────
|
||||||
|
// Rules are ordered by specificity — first match wins for dedup.
|
||||||
|
// Each rule checks the filename (lowercased) for a regex pattern and suggests
|
||||||
|
// the associated base model values.
|
||||||
|
|
||||||
|
const BASE_MODEL_FILENAME_RULES = [
|
||||||
|
{ pattern: /flux\.?\s*2\s*klein/i, models: ['Flux.2 Klein 9B', 'Flux.2 Klein 9B-base', 'Flux.2 Klein 4B', 'Flux.2 Klein 4B-base'] },
|
||||||
|
{ pattern: /flux\.?\s*2/i, models: ['Flux.2 D', 'Flux.2 Klein 9B', 'Flux.2 Klein 4B'] },
|
||||||
|
{ pattern: /flux\.?\s*1\s*(dev|d)\b/i, models: ['Flux.1 D'] },
|
||||||
|
{ pattern: /flux\.?\s*1\s*(schnell|s)\b/i, models: ['Flux.1 S'] },
|
||||||
|
{ pattern: /flux/i, models: ['Flux.1 D', 'Flux.1 S', 'Flux.2 D'] },
|
||||||
|
{ pattern: /sdxl/i, models: ['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper'] },
|
||||||
|
{ pattern: /sd\s*1[._-\s]?5/i, models: ['SD 1.5'] },
|
||||||
|
{ pattern: /sd\s*1[._-\s]?4/i, models: ['SD 1.4'] },
|
||||||
|
{ pattern: /sd\s*1/i, models: ['SD 1.5', 'SD 1.4', 'SD 1.5 LCM', 'SD 1.5 Hyper'] },
|
||||||
|
{ pattern: /sd\s*3[._-\s]?5/i, models: ['SD 3.5', 'SD 3.5 Medium', 'SD 3.5 Large', 'SD 3.5 Large Turbo'] },
|
||||||
|
{ pattern: /sd\s*3/i, models: ['SD 3', 'SD 3.5'] },
|
||||||
|
{ pattern: /wan\s*\.?\s*video/i, models: ['Wan Video', 'Wan Video 1.3B t2v', 'Wan Video 14B t2v', 'Wan Video 14B i2v 480p', 'Wan Video 14B i2v 720p'] },
|
||||||
|
{ pattern: /hunyuan\s*\.?\s*video/i, models: ['Hunyuan Video'] },
|
||||||
|
{ pattern: /ltxv/i, models: ['LTXV', 'LTXV2', 'LTXV 2.3'] },
|
||||||
|
{ pattern: /cogvideo/i, models: ['CogVideoX'] },
|
||||||
|
{ pattern: /pony/i, models: ['Pony', 'Pony V7'] },
|
||||||
|
{ pattern: /illustrious/i, models: ['Illustrious'] },
|
||||||
|
{ pattern: /noobai/i, models: ['NoobAI'] },
|
||||||
|
{ pattern: /pixart/i, models: ['PixArt a', 'PixArt E'] },
|
||||||
|
{ pattern: /aura\s*\.?\s*flow/i, models: ['AuraFlow'] },
|
||||||
|
{ pattern: /kolors/i, models: ['Kolors'] },
|
||||||
|
{ pattern: /hunyuan\s*1/i, models: ['Hunyuan 1'] },
|
||||||
|
{ pattern: /lumina/i, models: ['Lumina'] },
|
||||||
|
{ pattern: /hidream/i, models: ['HiDream'] },
|
||||||
|
{ pattern: /qwen/i, models: ['Qwen'] },
|
||||||
|
{ pattern: /chroma/i, models: ['Chroma'] },
|
||||||
|
{ pattern: /anima/i, models: ['Anima'] },
|
||||||
|
{ pattern: /sd\s*2[._-\s]?[01]/i, models: ['SD 2.0', 'SD 2.1'] },
|
||||||
|
{ pattern: /mochi/i, models: ['Mochi'] },
|
||||||
|
{ pattern: /svd/i, models: ['SVD'] },
|
||||||
|
{ pattern: /zimage/i, models: ['ZImageTurbo', 'ZImageBase'] },
|
||||||
|
{ pattern: /nucleus/i, models: ['Nucleus'] },
|
||||||
|
{ pattern: /krea/i, models: ['Flux.1 Krea', 'Krea 2'] },
|
||||||
|
{ pattern: /ernie/i, models: ['Ernie', 'Ernie Turbo'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer likely base model(s) from a filename + model name string.
|
||||||
|
* Returns a deduplicated array in match-priority order.
|
||||||
|
* @param {string} filename
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function inferBaseModelsFromFilename(filename) {
|
||||||
|
if (!filename || typeof filename !== 'string') return [];
|
||||||
|
const seen = new Set();
|
||||||
|
const results = [];
|
||||||
|
for (const rule of BASE_MODEL_FILENAME_RULES) {
|
||||||
|
if (rule.pattern.test(filename)) {
|
||||||
|
for (const model of rule.models) {
|
||||||
|
if (!seen.has(model)) {
|
||||||
|
seen.add(model);
|
||||||
|
results.push(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the active file path for the currently open model modal.
|
* Resolve the active file path for the currently open model modal.
|
||||||
@@ -226,7 +292,9 @@ export function setupModelNameEditing(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up base model editing functionality
|
* Set up base model editing functionality with searchable dropdown
|
||||||
|
* Shows filename-inferred suggestions at the top, supports keyboard navigation,
|
||||||
|
* and allows typing custom values.
|
||||||
* @param {string} filePath - File path
|
* @param {string} filePath - File path
|
||||||
*/
|
*/
|
||||||
export function setupBaseModelEditing(filePath) {
|
export function setupBaseModelEditing(filePath) {
|
||||||
@@ -257,98 +325,251 @@ export function setupBaseModelEditing(filePath) {
|
|||||||
// Store the original value to check for changes later
|
// Store the original value to check for changes later
|
||||||
const originalValue = baseModelContent.textContent.trim();
|
const originalValue = baseModelContent.textContent.trim();
|
||||||
|
|
||||||
// Create dropdown selector to replace the base model content
|
// ── Build the full option list ────────────────────────────────────────
|
||||||
const currentValue = originalValue;
|
const allModels = []; // { value, label, category }
|
||||||
const dropdown = document.createElement('select');
|
const categorizedModels = new Set();
|
||||||
dropdown.className = 'base-model-selector';
|
|
||||||
|
|
||||||
// Flag to track if a change was made
|
Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
|
||||||
let valueChanged = false;
|
|
||||||
|
|
||||||
// Add options from BASE_MODEL_CATEGORIES constants
|
|
||||||
const baseModelCategories = BASE_MODEL_CATEGORIES;
|
|
||||||
|
|
||||||
// Create option groups for better organization
|
|
||||||
Object.entries(baseModelCategories).forEach(([category, models]) => {
|
|
||||||
const group = document.createElement('optgroup');
|
|
||||||
group.label = category;
|
|
||||||
|
|
||||||
models.forEach(model => {
|
models.forEach(model => {
|
||||||
const option = document.createElement('option');
|
allModels.push({ value: model, label: model, category });
|
||||||
option.value = model;
|
categorizedModels.add(model);
|
||||||
option.textContent = model;
|
});
|
||||||
option.selected = model === currentValue;
|
});
|
||||||
group.appendChild(option);
|
|
||||||
|
const mergedModels = getMergedBaseModels();
|
||||||
|
const uncategorizedModels = mergedModels.filter(model => !categorizedModels.has(model));
|
||||||
|
if (uncategorizedModels.length > 0) {
|
||||||
|
uncategorizedModels.forEach(model => {
|
||||||
|
allModels.push({ value: model, label: model, category: 'Other (API)' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filename-based inference ──────────────────────────────────────────
|
||||||
|
const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
|
||||||
|
(document.querySelector('.model-name-content')?.textContent || '');
|
||||||
|
const inferredModels = inferBaseModelsFromFilename(fileName);
|
||||||
|
const inferredSet = new Set(inferredModels);
|
||||||
|
|
||||||
|
// ── Build search widget DOM ───────────────────────────────────────────
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'base-model-search-wrapper';
|
||||||
|
|
||||||
|
// Search input row
|
||||||
|
const inputWrapper = document.createElement('div');
|
||||||
|
inputWrapper.className = 'base-model-search-input-wrapper';
|
||||||
|
const searchIcon = document.createElement('i');
|
||||||
|
searchIcon.className = 'fas fa-search search-icon';
|
||||||
|
searchIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
inputWrapper.appendChild(searchIcon);
|
||||||
|
const searchInput = document.createElement('input');
|
||||||
|
searchInput.type = 'text';
|
||||||
|
searchInput.className = 'base-model-search-input';
|
||||||
|
searchInput.placeholder = translate('modals.model.metadata.baseModelSearchPlaceholder', {}, 'Search base model…');
|
||||||
|
searchInput.autocomplete = 'off';
|
||||||
|
searchInput.spellcheck = false;
|
||||||
|
inputWrapper.appendChild(searchInput);
|
||||||
|
wrapper.appendChild(inputWrapper);
|
||||||
|
|
||||||
|
// Dropdown list
|
||||||
|
const dropdown = document.createElement('div');
|
||||||
|
dropdown.className = 'base-model-dropdown';
|
||||||
|
wrapper.appendChild(dropdown);
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────
|
||||||
|
function renderDropdown(filterText) {
|
||||||
|
const lowerFilter = (filterText || '').toLowerCase().trim();
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
let hasVisibleItems = false;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
// 1. Suggested section (filename-inferred, filtered by search)
|
||||||
|
let suggestedToShow = inferredModels;
|
||||||
|
if (lowerFilter) {
|
||||||
|
suggestedToShow = inferredModels.filter(m =>
|
||||||
|
m.toLowerCase().includes(lowerFilter)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestedToShow.length > 0) {
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'base-model-dropdown-section';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'base-model-dropdown-header suggested-header';
|
||||||
|
header.innerHTML = '<i class="fas fa-star" aria-hidden="true"></i> ' +
|
||||||
|
translate('modals.model.metadata.baseModelSuggested', {}, 'Suggested');
|
||||||
|
section.appendChild(header);
|
||||||
|
|
||||||
|
suggestedToShow.forEach(model => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'base-model-dropdown-item';
|
||||||
|
if (model === originalValue) item.classList.add('selected');
|
||||||
|
item.dataset.value = model;
|
||||||
|
item.textContent = model;
|
||||||
|
section.appendChild(item);
|
||||||
|
hasVisibleItems = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
fragment.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Categorized options (deduplicated against suggestions)
|
||||||
|
const categoryMap = {};
|
||||||
|
allModels.forEach(m => {
|
||||||
|
if (inferredSet.has(m.value)) return; // already shown in Suggested
|
||||||
|
if (lowerFilter && !m.label.toLowerCase().includes(lowerFilter)) return;
|
||||||
|
if (!categoryMap[m.category]) categoryMap[m.category] = [];
|
||||||
|
categoryMap[m.category].push(m);
|
||||||
});
|
});
|
||||||
|
|
||||||
dropdown.appendChild(group);
|
Object.entries(categoryMap).forEach(([category, items]) => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'base-model-dropdown-section';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'base-model-dropdown-header';
|
||||||
|
header.textContent = category;
|
||||||
|
section.appendChild(header);
|
||||||
|
|
||||||
|
items.forEach(m => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'base-model-dropdown-item';
|
||||||
|
if (m.value === originalValue) item.classList.add('selected');
|
||||||
|
item.dataset.value = m.value;
|
||||||
|
item.textContent = m.label;
|
||||||
|
section.appendChild(item);
|
||||||
|
hasVisibleItems = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
fragment.appendChild(section);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Empty state
|
||||||
|
if (!hasVisibleItems) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'base-model-dropdown-empty';
|
||||||
|
empty.textContent = translate('modals.model.metadata.baseModelNoMatch', {}, 'No matching base models');
|
||||||
|
fragment.appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown.appendChild(fragment);
|
||||||
|
|
||||||
|
// Scroll the selected item into view
|
||||||
|
const selected = dropdown.querySelector('.base-model-dropdown-item.selected');
|
||||||
|
if (selected) {
|
||||||
|
selected.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render — show everything
|
||||||
|
renderDropdown('');
|
||||||
|
|
||||||
|
// ── Events ────────────────────────────────────────────────────────────
|
||||||
|
let filterTimeout;
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(filterTimeout);
|
||||||
|
filterTimeout = setTimeout(() => renderDropdown(searchInput.value), 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replace content with dropdown
|
// Click to select
|
||||||
|
dropdown.addEventListener('click', (e) => {
|
||||||
|
const item = e.target.closest('.base-model-dropdown-item');
|
||||||
|
if (!item) return;
|
||||||
|
baseModelContent.textContent = item.dataset.value;
|
||||||
|
cleanup();
|
||||||
|
const finalValue = baseModelContent.textContent.trim();
|
||||||
|
if (finalValue !== originalValue) {
|
||||||
|
saveBaseModel(
|
||||||
|
getActiveModalFilePath(baseModelContent.dataset.filePath),
|
||||||
|
originalValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace content with search widget
|
||||||
baseModelContent.style.display = 'none';
|
baseModelContent.style.display = 'none';
|
||||||
baseModelDisplay.insertBefore(dropdown, editBtn);
|
|
||||||
|
|
||||||
// Hide edit button during editing
|
|
||||||
editBtn.style.display = 'none';
|
editBtn.style.display = 'none';
|
||||||
|
baseModelDisplay.insertBefore(wrapper, editBtn);
|
||||||
|
searchInput.focus();
|
||||||
|
|
||||||
// Focus the dropdown
|
// ── Cleanup ───────────────────────────────────────────────────────────
|
||||||
dropdown.focus();
|
function cleanup() {
|
||||||
|
if (wrapper.parentNode === baseModelDisplay) {
|
||||||
// Handle dropdown change
|
baseModelDisplay.removeChild(wrapper);
|
||||||
dropdown.addEventListener('change', function() {
|
|
||||||
const selectedModel = this.value;
|
|
||||||
baseModelContent.textContent = selectedModel;
|
|
||||||
|
|
||||||
// Mark that a change was made if the value differs from original
|
|
||||||
if (selectedModel !== originalValue) {
|
|
||||||
valueChanged = true;
|
|
||||||
} else {
|
|
||||||
valueChanged = false;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Function to save changes and exit edit mode
|
|
||||||
const saveAndExit = function() {
|
|
||||||
// Check if dropdown still exists and remove it
|
|
||||||
if (dropdown && dropdown.parentNode === baseModelDisplay) {
|
|
||||||
baseModelDisplay.removeChild(dropdown);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the content and edit button
|
|
||||||
baseModelContent.style.display = '';
|
baseModelContent.style.display = '';
|
||||||
editBtn.style.display = '';
|
editBtn.style.display = '';
|
||||||
|
|
||||||
// Remove editing class
|
|
||||||
baseModelDisplay.classList.remove('editing');
|
baseModelDisplay.classList.remove('editing');
|
||||||
|
|
||||||
// Only save if the value has actually changed
|
|
||||||
if (valueChanged || baseModelContent.textContent.trim() !== originalValue) {
|
|
||||||
const resolvedPath = getActiveModalFilePath(baseModelContent.dataset.filePath);
|
|
||||||
saveBaseModel(resolvedPath, originalValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove this event listener
|
|
||||||
document.removeEventListener('click', outsideClickHandler);
|
document.removeEventListener('click', outsideClickHandler);
|
||||||
};
|
}
|
||||||
|
|
||||||
// Handle outside clicks to save and exit
|
// Outside click → save typed/custom value if any
|
||||||
const outsideClickHandler = function(e) {
|
const outsideClickHandler = function(e) {
|
||||||
// If click is outside the dropdown and base model display
|
if (wrapper.contains(e.target)) return;
|
||||||
if (!baseModelDisplay.contains(e.target)) {
|
|
||||||
saveAndExit();
|
// If user typed a custom value (not just empty), apply it
|
||||||
|
const typedValue = searchInput.value.trim();
|
||||||
|
if (typedValue) {
|
||||||
|
baseModelContent.textContent = typedValue;
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
const finalValue = baseModelContent.textContent.trim();
|
||||||
|
if (finalValue !== originalValue) {
|
||||||
|
saveBaseModel(
|
||||||
|
getActiveModalFilePath(baseModelContent.dataset.filePath),
|
||||||
|
originalValue
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add delayed event listener for outside clicks
|
// Defer listener to avoid the opening click itself
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.addEventListener('click', outsideClickHandler);
|
document.addEventListener('click', outsideClickHandler);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Also handle dropdown blur event
|
// Keyboard navigation
|
||||||
dropdown.addEventListener('blur', function(e) {
|
searchInput.addEventListener('keydown', function onKeydown(e) {
|
||||||
// Only save if the related target is not the edit button or inside the baseModelDisplay
|
const items = Array.from(dropdown.querySelectorAll('.base-model-dropdown-item'));
|
||||||
if (!baseModelDisplay.contains(e.relatedTarget)) {
|
const activeIdx = items.findIndex(el => el.classList.contains('active'));
|
||||||
saveAndExit();
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
items.forEach(el => el.classList.remove('active'));
|
||||||
|
const next = Math.min(activeIdx + 1, items.length - 1);
|
||||||
|
if (items[next]) {
|
||||||
|
items[next].classList.add('active');
|
||||||
|
items[next].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
items.forEach(el => el.classList.remove('active'));
|
||||||
|
const prev = Math.max(activeIdx - 1, 0);
|
||||||
|
if (items[prev]) {
|
||||||
|
items[prev].classList.add('active');
|
||||||
|
items[prev].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const activeItem = items.find(el => el.classList.contains('active'));
|
||||||
|
if (activeItem) {
|
||||||
|
activeItem.click();
|
||||||
|
} else if (searchInput.value.trim()) {
|
||||||
|
// Custom value typed
|
||||||
|
baseModelContent.textContent = searchInput.value.trim();
|
||||||
|
cleanup();
|
||||||
|
const finalValue = baseModelContent.textContent.trim();
|
||||||
|
if (finalValue !== originalValue) {
|
||||||
|
saveBaseModel(
|
||||||
|
getActiveModalFilePath(baseModelContent.dataset.filePath),
|
||||||
|
originalValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
baseModelContent.textContent = originalValue;
|
||||||
|
cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -360,6 +360,11 @@ export async function showModelModal(model, modelType) {
|
|||||||
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
|
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
|
||||||
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
|
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
|
||||||
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
||||||
|
</div>`.trim() : '';
|
||||||
|
const escapedHfUrl = modelWithFullData.hf_url ? escapeAttribute(modelWithFullData.hf_url) : '';
|
||||||
|
const viewOnHuggingFaceAction = escapedHfUrl ? `
|
||||||
|
<div class="civitai-view" title="${translate('modals.model.actions.viewOnHuggingFace', {}, 'View on Hugging Face')}" data-action="view-huggingface" data-hf-url="${escapedHfUrl}">
|
||||||
|
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnHuggingFaceText', {}, 'View on Hugging Face')}
|
||||||
</div>`.trim() : '';
|
</div>`.trim() : '';
|
||||||
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
|
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
|
||||||
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
|
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
|
||||||
@@ -377,6 +382,9 @@ export async function showModelModal(model, modelType) {
|
|||||||
if (viewOnCivitaiAction) {
|
if (viewOnCivitaiAction) {
|
||||||
creatorActionItems.push(indentMarkup(viewOnCivitaiAction, 24));
|
creatorActionItems.push(indentMarkup(viewOnCivitaiAction, 24));
|
||||||
}
|
}
|
||||||
|
if (viewOnHuggingFaceAction) {
|
||||||
|
creatorActionItems.push(indentMarkup(viewOnHuggingFaceAction, 24));
|
||||||
|
}
|
||||||
if (creatorInfoAction) {
|
if (creatorInfoAction) {
|
||||||
creatorActionItems.push(indentMarkup(creatorInfoAction, 24));
|
creatorActionItems.push(indentMarkup(creatorInfoAction, 24));
|
||||||
}
|
}
|
||||||
@@ -869,6 +877,11 @@ function setupEventHandlers(filePath, modelType) {
|
|||||||
case 'view-civitai':
|
case 'view-civitai':
|
||||||
openCivitai(target.dataset.filepath);
|
openCivitai(target.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
|
case 'view-huggingface':
|
||||||
|
if (target.dataset.hfUrl) {
|
||||||
|
window.open(target.dataset.hfUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'view-creator':
|
case 'view-creator':
|
||||||
const username = target.dataset.username;
|
const username = target.dataset.username;
|
||||||
if (username) {
|
if (username) {
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ function renderMediaMarkup(version) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="version-media">
|
<div class="version-media">
|
||||||
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(version.name || 'preview')}">
|
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(version.name || 'preview')}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -586,6 +586,7 @@ export function initMediaControlHandlers(container) {
|
|||||||
const imageMetaRaw = this.dataset.imageMeta;
|
const imageMetaRaw = this.dataset.imageMeta;
|
||||||
const imageUrl = this.dataset.imageUrl;
|
const imageUrl = this.dataset.imageUrl;
|
||||||
const imageNsfw = this.dataset.imageNsfw;
|
const imageNsfw = this.dataset.imageNsfw;
|
||||||
|
const imgId = this.dataset.imgId || '';
|
||||||
const localPath = this.dataset.localPath || '';
|
const localPath = this.dataset.localPath || '';
|
||||||
const showcaseSection = this.closest('.showcase-section');
|
const showcaseSection = this.closest('.showcase-section');
|
||||||
const modelHash = showcaseSection ? showcaseSection.dataset.modelHash : '';
|
const modelHash = showcaseSection ? showcaseSection.dataset.modelHash : '';
|
||||||
@@ -613,6 +614,7 @@ export function initMediaControlHandlers(container) {
|
|||||||
meta: imageMeta,
|
meta: imageMeta,
|
||||||
url: imageUrl,
|
url: imageUrl,
|
||||||
nsfwLevel: imageNsfw ? parseInt(imageNsfw, 10) : undefined,
|
nsfwLevel: imageNsfw ? parseInt(imageNsfw, 10) : undefined,
|
||||||
|
id: imgId || undefined,
|
||||||
},
|
},
|
||||||
model_hash: modelHash,
|
model_hash: modelHash,
|
||||||
model_name: modelName || modelHash,
|
model_name: modelName || modelHash,
|
||||||
|
|||||||
@@ -174,7 +174,10 @@ function renderMediaItem(img, index, exampleFiles) {
|
|||||||
const localUrl = localFile ? localFile.path : '';
|
const localUrl = localFile ? localFile.path : '';
|
||||||
|
|
||||||
// Calculate appropriate aspect ratio
|
// Calculate appropriate aspect ratio
|
||||||
const aspectRatio = (img.height / img.width) * 100;
|
// Defensive fallback: 0 width/height → 4:3 default (prevents NaN layout)
|
||||||
|
const safeW = img.width || 4;
|
||||||
|
const safeH = img.height || 3;
|
||||||
|
const aspectRatio = (safeH / safeW) * 100;
|
||||||
const containerWidth = 800; // modal content maximum width
|
const containerWidth = 800; // modal content maximum width
|
||||||
const minHeightPercent = 40;
|
const minHeightPercent = 40;
|
||||||
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
|
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
|
||||||
@@ -210,8 +213,8 @@ function renderMediaItem(img, index, exampleFiles) {
|
|||||||
const model = meta.Model || '';
|
const model = meta.Model || '';
|
||||||
const steps = meta.steps || '';
|
const steps = meta.steps || '';
|
||||||
const sampler = meta.sampler || '';
|
const sampler = meta.sampler || '';
|
||||||
const cfgScale = meta.cfgScale || '';
|
const cfgScale = meta.cfg_scale || meta.cfgScale || '';
|
||||||
const clipSkip = meta.clipSkip || '';
|
const clipSkip = meta.clip_skip || meta.clipSkip || '';
|
||||||
|
|
||||||
// Check if we have any meaningful generation parameters
|
// Check if we have any meaningful generation parameters
|
||||||
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
|
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
|
||||||
@@ -242,6 +245,7 @@ function renderMediaItem(img, index, exampleFiles) {
|
|||||||
data-image-url="${img.url || ''}"
|
data-image-url="${img.url || ''}"
|
||||||
data-image-nsfw="${img.nsfwLevel ?? ''}"
|
data-image-nsfw="${img.nsfwLevel ?? ''}"
|
||||||
data-image-id="${cdnImageId}"
|
data-image-id="${cdnImageId}"
|
||||||
|
data-img-id="${img.id || ''}"
|
||||||
data-local-path="${localFile ? localFile.path : ''}">
|
data-local-path="${localFile ? localFile.path : ''}">
|
||||||
<i class="fas fa-book-open"></i>
|
<i class="fas fa-book-open"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { initTheme, initBackToTop } from './utils/uiHelpers.js';
|
|||||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||||
import { i18n } from './i18n/index.js';
|
import { i18n } from './i18n/index.js';
|
||||||
import { onboardingManager } from './managers/OnboardingManager.js';
|
import { onboardingManager } from './managers/OnboardingManager.js';
|
||||||
|
import './components/Combobox.js';
|
||||||
import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
|
import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
|
||||||
import { createPageContextMenu, createGlobalContextMenu } from './components/ContextMenu/index.js';
|
import { createPageContextMenu, createGlobalContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { initializeEventManagement } from './utils/eventManagementInit.js';
|
import { initializeEventManagement } from './utils/eventManagementInit.js';
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* AgentManager — WebSocket listener for agent skill progress events.
|
||||||
|
*
|
||||||
|
* Connects to the generic WebSocket endpoint and filters for
|
||||||
|
* `type: "agent_progress"` messages. Dispatches progress and completion
|
||||||
|
* events to registered callbacks.
|
||||||
|
*/
|
||||||
|
class AgentManager {
|
||||||
|
constructor() {
|
||||||
|
this.websocket = null;
|
||||||
|
this.progressCallbacks = [];
|
||||||
|
this.completeCallbacks = [];
|
||||||
|
this.errorCallbacks = [];
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the WebSocket endpoint for agent progress events.
|
||||||
|
* Safe to call multiple times — won't reconnect if already connected.
|
||||||
|
*/
|
||||||
|
connect() {
|
||||||
|
if (this.connected && this.websocket?.readyState === WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
|
try {
|
||||||
|
this.websocket = new WebSocket(
|
||||||
|
`${wsProtocol}${window.location.host}/ws/fetch-progress`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('AgentManager: Failed to create WebSocket:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.websocket.onopen = () => {
|
||||||
|
this.connected = true;
|
||||||
|
console.debug('AgentManager: WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type !== 'agent_progress') return;
|
||||||
|
this._dispatch(data);
|
||||||
|
} catch (e) {
|
||||||
|
// Not JSON or wrong format — ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onerror = (error) => {
|
||||||
|
console.error('AgentManager: WebSocket error:', error);
|
||||||
|
this.connected = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onclose = () => {
|
||||||
|
this.connected = false;
|
||||||
|
console.debug('AgentManager: WebSocket closed');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a parsed agent event to the appropriate callbacks.
|
||||||
|
* @param {Object} data - The parsed WebSocket message
|
||||||
|
*/
|
||||||
|
_dispatch(data) {
|
||||||
|
const { status, skill } = data;
|
||||||
|
|
||||||
|
if (status === 'error') {
|
||||||
|
this.errorCallbacks.forEach((cb) => {
|
||||||
|
try {
|
||||||
|
cb(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('AgentManager error callback failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'completed') {
|
||||||
|
this.completeCallbacks.forEach((cb) => {
|
||||||
|
try {
|
||||||
|
cb(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('AgentManager complete callback failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// started, processing — general progress
|
||||||
|
this.progressCallbacks.forEach((cb) => {
|
||||||
|
try {
|
||||||
|
cb(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('AgentManager progress callback failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback for progress events (started, processing).
|
||||||
|
* @param {Function} callback - Receives the event data
|
||||||
|
*/
|
||||||
|
onProgress(callback) {
|
||||||
|
this.progressCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback for completion events.
|
||||||
|
* @param {Function} callback - Receives the event data
|
||||||
|
*/
|
||||||
|
onComplete(callback) {
|
||||||
|
this.completeCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback for error events.
|
||||||
|
* @param {Function} callback - Receives the event data
|
||||||
|
*/
|
||||||
|
onError(callback) {
|
||||||
|
this.errorCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all registered callbacks.
|
||||||
|
*/
|
||||||
|
clearCallbacks() {
|
||||||
|
this.progressCallbacks = [];
|
||||||
|
this.completeCallbacks = [];
|
||||||
|
this.errorCallbacks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an agent skill on the provided model paths.
|
||||||
|
*
|
||||||
|
* @param {string} skillName - The skill to execute
|
||||||
|
* @param {string[]} modelPaths - Model file paths to process
|
||||||
|
* @returns {Promise<Object>} The response JSON
|
||||||
|
*/
|
||||||
|
async executeSkill(skillName, modelPaths) {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/lm/agent/execute/${encodeURIComponent(skillName)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model_paths: modelPaths }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
errorData.error || `HTTP ${response.status}: ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the LLM provider is configured.
|
||||||
|
*
|
||||||
|
* Returns true when both an API key and a model name are set.
|
||||||
|
*
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
_readProviderRequiresKey(providerId) {
|
||||||
|
const script = document.getElementById('llmProviderPresets');
|
||||||
|
if (!script) return true; // safe default
|
||||||
|
try {
|
||||||
|
const presets = JSON.parse(script.textContent);
|
||||||
|
const preset = presets[providerId];
|
||||||
|
return preset ? preset.requires_key !== false : true;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isLlmConfigured() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/settings');
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const data = await response.json();
|
||||||
|
const provider = data.settings?.llm_provider;
|
||||||
|
const hasModel = !!data.settings?.llm_model;
|
||||||
|
const hasKey = !!(data.settings?.llm_api_key_set || data.settings?.llm_api_key);
|
||||||
|
const needsKey = this._readProviderRequiresKey(provider);
|
||||||
|
return hasModel && (hasKey || !needsKey);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of available agent skills.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
async listSkills() {
|
||||||
|
const response = await fetch('/api/lm/agent/skills');
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const data = await response.json();
|
||||||
|
return data.skills || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export as singleton
|
||||||
|
export const agentManager = new AgentManager();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { WS_ENDPOINTS } from '../api/apiConfig.js';
|
import { WS_ENDPOINTS } from '../api/apiConfig.js';
|
||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
@@ -43,6 +43,9 @@ export class BatchImportManager {
|
|||||||
setStorageItem('batch_import_skip_no_metadata', e.target.checked);
|
setStorageItem('batch_import_skip_no_metadata', e.target.checked);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-append newline after pasting a URL in the batch URL input
|
||||||
|
setupAutoNewlineOnPaste('batchUrlInput');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -397,6 +397,7 @@ export class BulkManager {
|
|||||||
const updated = {
|
const updated = {
|
||||||
...existing,
|
...existing,
|
||||||
fileName: card.dataset.file_name ?? existing.fileName,
|
fileName: card.dataset.file_name ?? existing.fileName,
|
||||||
|
folder: card.dataset.folder ?? existing.folder,
|
||||||
usageTips: card.dataset.usage_tips ?? existing.usageTips,
|
usageTips: card.dataset.usage_tips ?? existing.usageTips,
|
||||||
modelName: card.dataset.name ?? existing.modelName,
|
modelName: card.dataset.name ?? existing.modelName,
|
||||||
};
|
};
|
||||||
@@ -494,7 +495,8 @@ export class BulkManager {
|
|||||||
|
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
||||||
loraSyntaxes.push(buildLoraSyntax(metadata.fileName, usageTips));
|
const loraName = metadata.folder ? `${metadata.folder}/${metadata.fileName}` : metadata.fileName;
|
||||||
|
loraSyntaxes.push(buildLoraSyntax(loraName, usageTips));
|
||||||
} else {
|
} else {
|
||||||
missingLoras.push(filepath);
|
missingLoras.push(filepath);
|
||||||
}
|
}
|
||||||
@@ -537,7 +539,8 @@ export class BulkManager {
|
|||||||
|
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
||||||
loraSyntaxes.push(buildLoraSyntax(metadata.fileName, usageTips));
|
const loraName = metadata.folder ? `${metadata.folder}/${metadata.fileName}` : metadata.fileName;
|
||||||
|
loraSyntaxes.push(buildLoraSyntax(loraName, usageTips));
|
||||||
} else {
|
} else {
|
||||||
missingLoras.push(filepath);
|
missingLoras.push(filepath);
|
||||||
}
|
}
|
||||||
@@ -553,7 +556,8 @@ export class BulkManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora');
|
const exitBulkMode = () => { if (state.bulkMode) this.toggleBulkMode(); };
|
||||||
|
await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora', exitBulkMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _sendAllEmbeddingsToWorkflow() {
|
async _sendAllEmbeddingsToWorkflow() {
|
||||||
@@ -575,7 +579,8 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const joinedCode = embeddingCodes.join(', ');
|
const joinedCode = embeddingCodes.join(', ');
|
||||||
await sendEmbeddingToWorkflow(joinedCode);
|
const exitBulkMode = () => { if (state.bulkMode) this.toggleBulkMode(); };
|
||||||
|
await sendEmbeddingToWorkflow(joinedCode, exitBulkMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
showBulkDeleteModal() {
|
showBulkDeleteModal() {
|
||||||
@@ -633,7 +638,7 @@ export class BulkManager {
|
|||||||
filePaths.forEach(path => {
|
filePaths.forEach(path => {
|
||||||
state.virtualScroller.removeItemByFilePath(path);
|
state.virtualScroller.removeItemByFilePath(path);
|
||||||
});
|
});
|
||||||
this.clearSelection();
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
|
|
||||||
if (window.modelDuplicatesManager) {
|
if (window.modelDuplicatesManager) {
|
||||||
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
||||||
@@ -674,6 +679,7 @@ export class BulkManager {
|
|||||||
const modelId = this.parseModelId(item?.civitai?.modelId);
|
const modelId = this.parseModelId(item?.civitai?.modelId);
|
||||||
metadataCache.set(item.file_path, {
|
metadataCache.set(item.file_path, {
|
||||||
fileName: item.file_name,
|
fileName: item.file_name,
|
||||||
|
folder: item.folder || '',
|
||||||
usageTips: item.usage_tips || '{}',
|
usageTips: item.usage_tips || '{}',
|
||||||
modelName: item.name || item.file_name,
|
modelName: item.name || item.file_name,
|
||||||
...(modelId !== null ? { modelId } : {})
|
...(modelId !== null ? { modelId } : {})
|
||||||
@@ -763,8 +769,9 @@ export class BulkManager {
|
|||||||
`Re-import complete: ${completed} re-imported, ${failed} failed`
|
`Re-import complete: ${completed} re-imported, ${failed} failed`
|
||||||
);
|
);
|
||||||
const { resetAndReload: recipeResetAndReload } = await import('../api/recipeApi.js');
|
const { resetAndReload: recipeResetAndReload } = await import('../api/recipeApi.js');
|
||||||
recipeResetAndReload(false, { preserveScroll: false });
|
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
|
recipeResetAndReload(false, { preserveScroll: false });
|
||||||
} else {
|
} else {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
showToast('toast.recipes.reimportBulkFailed', {}, 'error');
|
showToast('toast.recipes.reimportBulkFailed', {}, 'error');
|
||||||
@@ -829,7 +836,7 @@ export class BulkManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clearSelection();
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Bulk repair failed');
|
throw new Error(result.error || 'Bulk repair failed');
|
||||||
}
|
}
|
||||||
@@ -874,6 +881,8 @@ export class BulkManager {
|
|||||||
if (this.isStripVisible) {
|
if (this.isStripVisible) {
|
||||||
this.updateThumbnailStrip();
|
this.updateThumbnailStrip();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -927,6 +936,7 @@ export class BulkManager {
|
|||||||
showToast('toast.models.bulkUpdatesNone', { type: typeLabel }, 'info');
|
showToast('toast.models.bulkUpdatesNone', { type: typeLabel }, 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking updates for selected models:', error);
|
console.error('Error checking updates for selected models:', error);
|
||||||
@@ -1273,6 +1283,8 @@ export class BulkManager {
|
|||||||
showToast(toastKey, { count: failCount }, 'warning');
|
showToast(toastKey, { count: failCount }, 'warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during bulk tag operation:', error);
|
console.error('Error during bulk tag operation:', error);
|
||||||
const toastKey = mode === 'replace' ? 'toast.models.bulkTagsReplaceFailed' : 'toast.models.bulkTagsAddFailed';
|
const toastKey = mode === 'replace' ? 'toast.models.bulkTagsReplaceFailed' : 'toast.models.bulkTagsAddFailed';
|
||||||
@@ -1398,6 +1410,8 @@ export class BulkManager {
|
|||||||
} else {
|
} else {
|
||||||
showToast('toast.models.bulkFavoriteFailed', {}, 'error');
|
showToast('toast.models.bulkFavoriteFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1526,6 +1540,8 @@ export class BulkManager {
|
|||||||
showToast('toast.models.bulkContentRatingFailed', {}, 'error');
|
showToast('toast.models.bulkContentRatingFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
|
|
||||||
return successCount > 0;
|
return successCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1580,6 +1596,8 @@ export class BulkManager {
|
|||||||
} else {
|
} else {
|
||||||
showToast('toast.models.skipMetadataRefreshFailed', {}, 'error');
|
showToast('toast.models.skipMetadataRefreshFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1674,6 +1692,8 @@ export class BulkManager {
|
|||||||
showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
|
showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during bulk base model operation:', error);
|
console.error('Error during bulk base model operation:', error);
|
||||||
showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
|
showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
|
||||||
@@ -1711,6 +1731,7 @@ export class BulkManager {
|
|||||||
// Call the auto-organize method with selected file paths
|
// Call the auto-organize method with selected file paths
|
||||||
await apiClient.autoOrganizeModels(filePaths);
|
await apiClient.autoOrganizeModels(filePaths);
|
||||||
|
|
||||||
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
resetAndReload(true);
|
resetAndReload(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during bulk auto-organize:', error);
|
console.error('Error during bulk auto-organize:', error);
|
||||||
|
|||||||
@@ -196,6 +196,17 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
let completedDownloads = 0;
|
let completedDownloads = 0;
|
||||||
let failedDownloads = 0;
|
let failedDownloads = 0;
|
||||||
let currentLoraProgress = 0;
|
let currentLoraProgress = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
loadingManager.showCancelButton(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
await this.loraApiClient.cancelDownload(batchDownloadId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cancel request failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Set up WebSocket message handler
|
// Set up WebSocket message handler
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -207,6 +218,11 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
cancelled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Process progress updates
|
// Process progress updates
|
||||||
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
||||||
currentLoraProgress = data.progress;
|
currentLoraProgress = data.progress;
|
||||||
@@ -249,6 +265,8 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
|
|
||||||
// Download each LoRA sequentially
|
// Download each LoRA sequentially
|
||||||
for (let i = 0; i < lorasToDownload.length; i++) {
|
for (let i = 0; i < lorasToDownload.length; i++) {
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
const lora = lorasToDownload[i];
|
const lora = lorasToDownload[i];
|
||||||
|
|
||||||
currentLoraProgress = 0;
|
currentLoraProgress = 0;
|
||||||
@@ -275,11 +293,13 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
modelId,
|
modelId,
|
||||||
versionId,
|
versionId,
|
||||||
loraRoot,
|
loraRoot,
|
||||||
'', // Empty relative path, use default paths
|
'',
|
||||||
useDefaultPaths,
|
useDefaultPaths,
|
||||||
batchDownloadId
|
batchDownloadId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
@@ -288,8 +308,10 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
updateProgress(100, completedDownloads, '');
|
updateProgress(100, completedDownloads, '');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
if (!cancelled) {
|
||||||
failedDownloads++;
|
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
||||||
|
failedDownloads++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +322,10 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
loadingManager.hide();
|
loadingManager.hide();
|
||||||
|
|
||||||
// Show completion message
|
// Show completion message
|
||||||
if (failedDownloads === 0) {
|
if (cancelled) {
|
||||||
|
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||||
|
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||||
|
} else if (failedDownloads === 0) {
|
||||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast('toast.loras.downloadPartialSuccess', {
|
showToast('toast.loras.downloadPartialSuccess', {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { LoadingManager } from './LoadingManager.js';
|
import { LoadingManager } from './LoadingManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
@@ -7,6 +7,7 @@ import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
|||||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
|
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
|
||||||
|
import { formatFileSize } from '../utils/formatters.js';
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -27,6 +28,11 @@ export class DownloadManager {
|
|||||||
this.isBatchMode = false;
|
this.isBatchMode = false;
|
||||||
this.editingBatchIndex = -1;
|
this.editingBatchIndex = -1;
|
||||||
|
|
||||||
|
// HF download state
|
||||||
|
this.hfRepoId = null;
|
||||||
|
this.hfSelectedFiles = [];
|
||||||
|
this.hfRepoCollapsed = {};
|
||||||
|
|
||||||
this.loadingManager = new LoadingManager();
|
this.loadingManager = new LoadingManager();
|
||||||
this.folderTreeManager = new FolderTreeManager();
|
this.folderTreeManager = new FolderTreeManager();
|
||||||
this.folderClickHandler = null;
|
this.folderClickHandler = null;
|
||||||
@@ -44,6 +50,8 @@ export class DownloadManager {
|
|||||||
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
|
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
|
||||||
this.handleBackToUrlFromBatch = this.backToUrlFromBatch.bind(this);
|
this.handleBackToUrlFromBatch = this.backToUrlFromBatch.bind(this);
|
||||||
this.handleNextFromBatch = this.nextFromBatch.bind(this);
|
this.handleNextFromBatch = this.nextFromBatch.bind(this);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showDownloadModal() {
|
showDownloadModal() {
|
||||||
@@ -99,6 +107,9 @@ export class DownloadManager {
|
|||||||
|
|
||||||
// Default path toggle handler
|
// Default path toggle handler
|
||||||
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
|
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
|
||||||
|
|
||||||
|
// Auto-append newline after pasting a URL so users can paste multiple URLs in succession
|
||||||
|
setupAutoNewlineOnPaste('modelUrl');
|
||||||
}
|
}
|
||||||
|
|
||||||
updateModalLabels() {
|
updateModalLabels() {
|
||||||
@@ -160,6 +171,11 @@ export class DownloadManager {
|
|||||||
|
|
||||||
// Reset default path toggle
|
// Reset default path toggle
|
||||||
this.loadDefaultPathSetting();
|
this.loadDefaultPathSetting();
|
||||||
|
|
||||||
|
// Reset HF state
|
||||||
|
this.hfRepoId = null;
|
||||||
|
this.hfSelectedFiles = [];
|
||||||
|
this.hfRepoCollapsed = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrieveVersionsForModel(modelId, source = null) {
|
async retrieveVersionsForModel(modelId, source = null) {
|
||||||
@@ -180,6 +196,29 @@ export class DownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect URL types — all URLs must share the same source type
|
||||||
|
const urlTypes = urls.map(u => DownloadManager.detectUrlType(u));
|
||||||
|
const isHf = urlTypes.every(t => t && (t.type === 'hf-resolve' || t.type === 'hf-repo'));
|
||||||
|
const isCivitai = urlTypes.every(t => t && t.type === 'civitai');
|
||||||
|
|
||||||
|
if (!isHf && !isCivitai) {
|
||||||
|
const allValid = urlTypes.every(t => t !== null);
|
||||||
|
if (!allValid) {
|
||||||
|
errorElement.textContent = translate('modals.download.errors.invalidUrl');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Mixed sources not supported in one batch
|
||||||
|
if (urls.length > 1) {
|
||||||
|
errorElement.textContent = translate('modals.download.errors.mixedSources');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHf) {
|
||||||
|
return this._validateAndFetchHf(urls, errorElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Original CivitAI flow below ---
|
||||||
if (urls.length === 1) {
|
if (urls.length === 1) {
|
||||||
this.isBatchMode = false;
|
this.isBatchMode = false;
|
||||||
try {
|
try {
|
||||||
@@ -271,6 +310,112 @@ export class DownloadManager {
|
|||||||
this.showBatchPreviewStep();
|
this.showBatchPreviewStep();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Hugging Face download flow ----
|
||||||
|
|
||||||
|
async _validateAndFetchHf(urls, errorElement) {
|
||||||
|
if (urls.length === 1) {
|
||||||
|
const info = DownloadManager.detectUrlType(urls[0]);
|
||||||
|
// Direct file resolve URL → skip file selection, go to location
|
||||||
|
if (info.type === 'hf-resolve') {
|
||||||
|
this.isBatchMode = false;
|
||||||
|
this.hfRepoId = info.repo;
|
||||||
|
this.hfSelectedFiles = [info.filename];
|
||||||
|
this.source = 'huggingface';
|
||||||
|
this.proceedToLocation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Repo URL → fetch file list and convert to batch items
|
||||||
|
try {
|
||||||
|
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingRepoFiles'));
|
||||||
|
const files = await this.apiClient.fetchHfRepoFiles(info.repo);
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
throw new Error(translate('modals.download.errors.noModelFiles'));
|
||||||
|
}
|
||||||
|
this.isBatchMode = true;
|
||||||
|
this.batchModels = [];
|
||||||
|
this.source = 'huggingface';
|
||||||
|
for (const file of files) {
|
||||||
|
this.batchModels.push({
|
||||||
|
url: urls[0],
|
||||||
|
source: 'huggingface',
|
||||||
|
repo: info.repo,
|
||||||
|
filename: file.filename,
|
||||||
|
revision: 'main',
|
||||||
|
displayName: file.filename,
|
||||||
|
fileSizeBytes: file.size,
|
||||||
|
selectedVersion: true,
|
||||||
|
versions: [],
|
||||||
|
checked: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.showBatchPreviewStep();
|
||||||
|
} catch (err) {
|
||||||
|
errorElement.textContent = err.message;
|
||||||
|
} finally {
|
||||||
|
this.loadingManager.hide();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple HF URLs → batch mode: flatten all files from all repos
|
||||||
|
this.isBatchMode = true;
|
||||||
|
this.batchModels = [];
|
||||||
|
this.source = 'huggingface';
|
||||||
|
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingRepoFiles'));
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
const info = DownloadManager.detectUrlType(url);
|
||||||
|
if (!info) {
|
||||||
|
this.batchModels.push({ url, error: 'Invalid URL', versions: [], selectedVersion: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (info.type === 'hf-resolve') {
|
||||||
|
this.batchModels.push({
|
||||||
|
url,
|
||||||
|
source: 'huggingface',
|
||||||
|
repo: info.repo,
|
||||||
|
filename: info.filename,
|
||||||
|
revision: info.revision || 'main',
|
||||||
|
displayName: info.filename,
|
||||||
|
selectedVersion: true,
|
||||||
|
versions: [],
|
||||||
|
checked: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} else if (info.type === 'hf-repo') {
|
||||||
|
try {
|
||||||
|
const files = await this.apiClient.fetchHfRepoFiles(info.repo);
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
this.batchModels.push({ url, error: 'No model files found', versions: [], selectedVersion: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Flatten: create one batch item per file, all checked by default
|
||||||
|
for (const file of files) {
|
||||||
|
this.batchModels.push({
|
||||||
|
url,
|
||||||
|
source: 'huggingface',
|
||||||
|
repo: info.repo,
|
||||||
|
filename: file.filename,
|
||||||
|
revision: 'main',
|
||||||
|
displayName: file.filename,
|
||||||
|
fileSizeBytes: file.size,
|
||||||
|
selectedVersion: true,
|
||||||
|
versions: [],
|
||||||
|
checked: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.batchModels.push({ url, error: err.message, versions: [], selectedVersion: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadingManager.hide();
|
||||||
|
this.showBatchPreviewStep();
|
||||||
|
}
|
||||||
|
|
||||||
async fetchVersionsForCurrentModel() {
|
async fetchVersionsForCurrentModel() {
|
||||||
const errorElement = document.getElementById('urlError');
|
const errorElement = document.getElementById('urlError');
|
||||||
if (errorElement) {
|
if (errorElement) {
|
||||||
@@ -311,6 +456,60 @@ export class DownloadManager {
|
|||||||
return { modelId: null, modelVersionId: null, source: null };
|
return { modelId: null, modelVersionId: null, source: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the source type of a download URL.
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {{ type: string, repo?: string, filename?: string, revision?: string } | null}
|
||||||
|
* type: 'civitai' | 'civarchive' | 'hf-resolve' | 'hf-repo' | 'direct-http'
|
||||||
|
*/
|
||||||
|
static detectUrlType(url) {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
// CivitAI — matches civitai.com, civitai.red, civitai.green, etc.
|
||||||
|
if (/civitai\.(?:com|red|green)\/models\//i.test(trimmed) || /civitaiarchive|civarchive/i.test(trimmed)) {
|
||||||
|
// Will be parsed by existing CivitAI logic
|
||||||
|
return { type: 'civitai' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hugging Face resolve URL → direct file
|
||||||
|
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/resolve\/([^/\s]+)\/(.+)/i);
|
||||||
|
if (hfResolveMatch) {
|
||||||
|
return {
|
||||||
|
type: 'hf-resolve',
|
||||||
|
repo: hfResolveMatch[1],
|
||||||
|
revision: hfResolveMatch[2],
|
||||||
|
filename: hfResolveMatch[3],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hugging Face repo URL (huggingface.co/user/repo or bare user/repo path)
|
||||||
|
// Require huggingface.co prefix for full URLs; bare user/repo only without ://
|
||||||
|
const hfRepoMatch = trimmed.match(
|
||||||
|
trimmed.includes('://')
|
||||||
|
? /^https?:\/\/huggingface\.co\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:\/?$|$)/
|
||||||
|
: /^([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)$/
|
||||||
|
);
|
||||||
|
if (hfRepoMatch) {
|
||||||
|
// Reject path-traversal patterns like "../.." or "user/.."
|
||||||
|
const parts = hfRepoMatch[1].split('/');
|
||||||
|
if (parts.some(p => p === '.' || p === '..')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'hf-repo',
|
||||||
|
repo: hfRepoMatch[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct HTTP(S) URL (non-HF)
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return { type: 'direct-http' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
extractModelId(url) {
|
extractModelId(url) {
|
||||||
const result = DownloadManager.parseModelUrl(url);
|
const result = DownloadManager.parseModelUrl(url);
|
||||||
this.modelVersionId = result.modelVersionId;
|
this.modelVersionId = result.modelVersionId;
|
||||||
@@ -529,14 +728,23 @@ export class DownloadManager {
|
|||||||
|
|
||||||
confirmFileSelection() {
|
confirmFileSelection() {
|
||||||
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
|
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
|
||||||
if (!selectedRadio) return;
|
if (!selectedRadio) {
|
||||||
|
console.warn('[download] confirmFileSelection: no radio button checked');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const version = this.currentVersion;
|
const version = this.currentVersion;
|
||||||
if (!version) return;
|
if (!version) {
|
||||||
|
console.warn('[download] confirmFileSelection: no currentVersion set');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
|
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
|
||||||
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
||||||
|
|
||||||
|
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
|
||||||
|
this.selectedFile?.id, this.selectedFile?.name, this.selectedFile?.type, this.selectedFile?.metadata);
|
||||||
|
|
||||||
document.getElementById('fileSelectionStep').style.display = 'none';
|
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||||
document.getElementById('locationStep').style.display = 'block';
|
document.getElementById('locationStep').style.display = 'block';
|
||||||
this.proceedToLocationContent();
|
this.proceedToLocationContent();
|
||||||
@@ -559,8 +767,8 @@ export class DownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In single-URL mode, validate version selection
|
// In single-URL mode, validate version selection (skip for HF)
|
||||||
if (!this.isBatchMode) {
|
if (!this.isBatchMode && this.source !== 'huggingface') {
|
||||||
if (!this.currentVersion) {
|
if (!this.currentVersion) {
|
||||||
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
|
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
|
||||||
return;
|
return;
|
||||||
@@ -673,16 +881,26 @@ export class DownloadManager {
|
|||||||
const displayName = versionName || `#${versionId}`;
|
const displayName = versionName || `#${versionId}`;
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let updateProgress = () => { };
|
let updateProgress = () => { };
|
||||||
|
let cancelled = false;
|
||||||
|
const downloadId = Date.now().toString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.loadingManager.restoreProgressBar();
|
this.loadingManager.restoreProgressBar();
|
||||||
updateProgress = this.loadingManager.showDownloadProgress(1);
|
updateProgress = this.loadingManager.showDownloadProgress(1);
|
||||||
updateProgress(0, 0, displayName);
|
updateProgress(0, 0, displayName);
|
||||||
|
|
||||||
const downloadId = Date.now().toString();
|
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||||
|
|
||||||
|
this.loadingManager.showCancelButton(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
await this.apiClient.cancelDownload(downloadId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cancel request failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ws.onmessage = event => {
|
ws.onmessage = event => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
@@ -691,6 +909,12 @@ export class DownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
cancelled = true;
|
||||||
|
this.loadingManager.setStatus(translate('modals.download.status.cancelled', {}, 'Download cancelled'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.status === 'progress' && data.download_id === downloadId) {
|
if (data.status === 'progress' && data.download_id === downloadId) {
|
||||||
const metrics = {
|
const metrics = {
|
||||||
bytesDownloaded: data.bytes_downloaded,
|
bytesDownloaded: data.bytes_downloaded,
|
||||||
@@ -729,6 +953,10 @@ export class DownloadManager {
|
|||||||
fileParams
|
fileParams
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (response?.skipped) {
|
if (response?.skipped) {
|
||||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||||
updateProgress(100, 0, displayName);
|
updateProgress(100, 0, displayName);
|
||||||
@@ -769,8 +997,12 @@ export class DownloadManager {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to download model version:', error);
|
if (cancelled) {
|
||||||
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
console.log('Download cancelled by user:', downloadId);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to download model version:', error);
|
||||||
|
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
@@ -784,6 +1016,103 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths }) {
|
||||||
|
modalManager.closeModal('downloadModal');
|
||||||
|
this.loadingManager.restoreProgressBar();
|
||||||
|
const totalFiles = this.hfSelectedFiles.length;
|
||||||
|
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let currentDownloadId = null;
|
||||||
|
|
||||||
|
this.loadingManager.showCancelButton(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cancelled = true;
|
||||||
|
if (currentDownloadId) {
|
||||||
|
try {
|
||||||
|
await this.apiClient.cancelDownload(currentDownloadId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cancel request failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let completedDownloads = 0;
|
||||||
|
for (let i = 0; i < totalFiles; i++) {
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
|
const filename = this.hfSelectedFiles[i];
|
||||||
|
updateProgress(0, completedDownloads, filename);
|
||||||
|
this.loadingManager.setStatus(`Downloading ${filename}...`);
|
||||||
|
|
||||||
|
currentDownloadId = Date.now().toString() + '_' + i;
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
|
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${currentDownloadId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
ws.onopen = resolve;
|
||||||
|
ws.onerror = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshotCompleted = completedDownloads;
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
cancelled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.status === 'progress') {
|
||||||
|
const metrics = {
|
||||||
|
bytesDownloaded: data.bytes_downloaded,
|
||||||
|
totalBytes: data.total_bytes,
|
||||||
|
bytesPerSecond: data.bytes_per_second,
|
||||||
|
};
|
||||||
|
updateProgress(data.progress, snapshotCompleted, filename, metrics);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this.apiClient.downloadHfModel({
|
||||||
|
repo: this.hfRepoId,
|
||||||
|
filename,
|
||||||
|
revision: 'main',
|
||||||
|
modelRoot,
|
||||||
|
relativePath: targetFolder,
|
||||||
|
useDefaultPaths,
|
||||||
|
download_id: currentDownloadId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
completedDownloads++;
|
||||||
|
updateProgress(100, completedDownloads, filename);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||||
|
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||||
|
} else {
|
||||||
|
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||||
|
}
|
||||||
|
await resetAndReload(true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('Failed to download HF model:', error);
|
||||||
|
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.loadingManager.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updatePathSelectionUI() {
|
updatePathSelectionUI() {
|
||||||
const manualSelection = document.getElementById('manualPathSelection');
|
const manualSelection = document.getElementById('manualPathSelection');
|
||||||
|
|
||||||
@@ -810,32 +1139,43 @@ export class DownloadManager {
|
|||||||
|
|
||||||
showBatchPreviewStep() {
|
showBatchPreviewStep() {
|
||||||
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
|
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
|
||||||
document.getElementById('batchPreviewStep').style.display = 'block';
|
document.getElementById('batchPreviewStep').style.display = 'flex';
|
||||||
|
|
||||||
const validCount = this.batchModels.filter(m => !m.error && m.selectedVersion).length;
|
const validCount = this.batchModels.filter(m => {
|
||||||
|
if (m.error) return false;
|
||||||
|
if (m.source === 'huggingface') return m.checked !== false;
|
||||||
|
return m.selectedVersion;
|
||||||
|
}).length;
|
||||||
document.getElementById('downloadModalTitle').textContent =
|
document.getElementById('downloadModalTitle').textContent =
|
||||||
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
|
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
|
||||||
` (${validCount})`;
|
` (${validCount})`;
|
||||||
|
|
||||||
const list = document.getElementById('batchPreviewList');
|
const list = document.getElementById('batchPreviewList');
|
||||||
list.innerHTML = this.batchModels.map((item, index) => {
|
const hasHfItems = this.batchModels.some(m => m.source === 'huggingface' && !m.error);
|
||||||
if (item.error) {
|
|
||||||
return `
|
|
||||||
<div class="batch-preview-item batch-preview-error" data-index="${index}">
|
|
||||||
<div class="batch-preview-icon">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
|
||||||
</div>
|
|
||||||
<div class="batch-preview-info">
|
|
||||||
<div class="batch-preview-name">${item.url}</div>
|
|
||||||
<div class="batch-preview-meta batch-preview-error-text">${item.error}</div>
|
|
||||||
</div>
|
|
||||||
<button class="batch-preview-remove" data-index="${index}" title="${translate('common.actions.remove', {}, 'Remove')}">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Error items render flat, outside any group
|
||||||
|
const errorItemsHtml = this.batchModels.map((item, index) => {
|
||||||
|
if (!item.error) return null;
|
||||||
|
return `
|
||||||
|
<div class="batch-preview-item batch-preview-error" data-index="${index}">
|
||||||
|
<div class="batch-preview-icon">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="batch-preview-info">
|
||||||
|
<div class="batch-preview-name">${item.url}</div>
|
||||||
|
<div class="batch-preview-meta batch-preview-error-text">${item.error}</div>
|
||||||
|
</div>
|
||||||
|
<button class="batch-preview-remove" data-index="${index}" title="${translate('common.actions.remove', {}, 'Remove')}">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).filter(Boolean).join('');
|
||||||
|
|
||||||
|
// CivitAI items render flat, outside any group (unchanged)
|
||||||
|
const civitaiItemsHtml = this.batchModels.map((item, index) => {
|
||||||
|
if (item.error) return null;
|
||||||
|
if (item.source === 'huggingface') return null;
|
||||||
const ver = item.selectedVersion;
|
const ver = item.selectedVersion;
|
||||||
const firstImage = ver?.images?.find(img => !img.url.endsWith('.mp4'));
|
const firstImage = ver?.images?.find(img => !img.url.endsWith('.mp4'));
|
||||||
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
||||||
@@ -843,7 +1183,6 @@ export class DownloadManager {
|
|||||||
? (ver.modelSizeKB / 1024).toFixed(1)
|
? (ver.modelSizeKB / 1024).toFixed(1)
|
||||||
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
|
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
|
||||||
const existsLocally = ver?.existsLocally;
|
const existsLocally = ver?.existsLocally;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
|
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
|
||||||
<div class="batch-preview-thumbnail">
|
<div class="batch-preview-thumbnail">
|
||||||
@@ -864,9 +1203,158 @@ export class DownloadManager {
|
|||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
}).filter(Boolean).join('');
|
||||||
|
|
||||||
|
// Group HF items by repo (data model stays flat — only rendering groups)
|
||||||
|
const hfGroups = {};
|
||||||
|
this.batchModels.forEach((item, index) => {
|
||||||
|
if (item.error || item.source !== 'huggingface') return;
|
||||||
|
const repo = item.repo || 'unknown';
|
||||||
|
if (!hfGroups[repo]) hfGroups[repo] = [];
|
||||||
|
hfGroups[repo].push({ item, index });
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderHfItem = ({ item, index }) => {
|
||||||
|
const hfSize = item.fileSizeBytes ? formatFileSize(item.fileSizeBytes) : '?';
|
||||||
|
return `
|
||||||
|
<div class="batch-preview-item" data-index="${index}">
|
||||||
|
<input type="checkbox" class="batch-preview-checkbox"
|
||||||
|
data-index="${index}" ${item.checked !== false ? 'checked' : ''} />
|
||||||
|
<div class="batch-preview-info">
|
||||||
|
<div class="batch-preview-name">${item.displayName || item.filename || `HF #${index}`} <span class="hf-badge">HF</span></div>
|
||||||
|
<div class="batch-preview-meta">
|
||||||
|
<span>${hfSize}</span>
|
||||||
|
<span>${item.repo || ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="batch-preview-remove" data-index="${index}" title="${translate('common.actions.remove', {}, 'Remove')}">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hfGroupsHtml = Object.keys(hfGroups).map(repo => {
|
||||||
|
const items = hfGroups[repo];
|
||||||
|
const isCollapsed = this.hfRepoCollapsed[repo] === true;
|
||||||
|
const allChecked = items.every(({ item }) => item.checked !== false);
|
||||||
|
const fileCount = items.length;
|
||||||
|
return `
|
||||||
|
<div class="batch-preview-group" data-repo="${repo}">
|
||||||
|
<div class="batch-preview-group-header">
|
||||||
|
<i class="fas fa-chevron-right batch-preview-group-toggle ${isCollapsed ? '' : 'expanded'}"></i>
|
||||||
|
<span class="batch-preview-group-name">${repo}</span>
|
||||||
|
<span class="batch-preview-group-count">${fileCount} ${translate('modals.download.fileSelection.files', {}, 'files')}</span>
|
||||||
|
<input type="checkbox" class="batch-preview-group-select-all" data-repo="${repo}" ${allChecked ? 'checked' : ''} />
|
||||||
|
</div>
|
||||||
|
<div class="batch-preview-group-body ${isCollapsed ? '' : 'expanded'}">
|
||||||
|
${items.map(renderHfItem).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
let itemsHtml = errorItemsHtml + civitaiItemsHtml + hfGroupsHtml;
|
||||||
|
|
||||||
|
// Prepend select-all toolbar if there are HF items with checkboxes
|
||||||
|
if (hasHfItems) {
|
||||||
|
const allChecked = this.batchModels
|
||||||
|
.filter(m => m.source === 'huggingface' && !m.error)
|
||||||
|
.every(m => m.checked !== false);
|
||||||
|
itemsHtml = `
|
||||||
|
<div class="batch-preview-select-all">
|
||||||
|
<input type="checkbox" id="batchSelectAll" ${allChecked ? 'checked' : ''} />
|
||||||
|
<label for="batchSelectAll">${translate('modals.download.selectAll', {}, 'Select All')}</label>
|
||||||
|
</div>
|
||||||
|
` + itemsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = itemsHtml;
|
||||||
|
|
||||||
|
const updateCountAndSelectAll = () => {
|
||||||
|
const checkedCount = this.batchModels.filter(
|
||||||
|
m => !m.error && m.checked !== false
|
||||||
|
).length;
|
||||||
|
document.getElementById('downloadModalTitle').textContent =
|
||||||
|
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
|
||||||
|
` (${checkedCount})`;
|
||||||
|
const nextBtn = document.getElementById('nextFromBatchBtn');
|
||||||
|
nextBtn.disabled = checkedCount === 0;
|
||||||
|
nextBtn.classList.toggle('disabled', checkedCount === 0);
|
||||||
|
// Global select-all
|
||||||
|
const selectAll = document.getElementById('batchSelectAll');
|
||||||
|
if (selectAll) {
|
||||||
|
const hfItems = this.batchModels.filter(m => m.source === 'huggingface' && !m.error);
|
||||||
|
selectAll.checked = hfItems.length > 0 && hfItems.every(m => m.checked !== false);
|
||||||
|
}
|
||||||
|
// Per-group select-all
|
||||||
|
list.querySelectorAll('.batch-preview-group-select-all').forEach(gsa => {
|
||||||
|
const repo = gsa.dataset.repo;
|
||||||
|
const repoItems = this.batchModels.filter(m => m.source === 'huggingface' && !m.error && m.repo === repo);
|
||||||
|
gsa.checked = repoItems.length > 0 && repoItems.every(m => m.checked !== false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
list.onclick = (e) => {
|
list.onclick = (e) => {
|
||||||
|
// Per-group select-all checkbox
|
||||||
|
const groupSelectAll = e.target.closest('.batch-preview-group-select-all');
|
||||||
|
if (groupSelectAll) {
|
||||||
|
const repo = groupSelectAll.dataset.repo;
|
||||||
|
const checked = groupSelectAll.checked;
|
||||||
|
this.batchModels.forEach((m, idx) => {
|
||||||
|
if (m.source === 'huggingface' && !m.error && m.repo === repo) {
|
||||||
|
m.checked = checked;
|
||||||
|
const cb = list.querySelector(`.batch-preview-checkbox[data-index="${idx}"]`);
|
||||||
|
if (cb) cb.checked = checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateCountAndSelectAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = e.target.closest('.batch-preview-group-header');
|
||||||
|
if (header) {
|
||||||
|
const group = header.closest('.batch-preview-group');
|
||||||
|
const repo = group.dataset.repo;
|
||||||
|
const body = group.querySelector('.batch-preview-group-body');
|
||||||
|
const toggle = group.querySelector('.batch-preview-group-toggle');
|
||||||
|
const isCollapsed = this.hfRepoCollapsed[repo];
|
||||||
|
if (isCollapsed) {
|
||||||
|
this.hfRepoCollapsed[repo] = false;
|
||||||
|
body.style.transition = ''; // restore in case collapse was interrupted
|
||||||
|
body.classList.add('expanded');
|
||||||
|
toggle.classList.add('expanded');
|
||||||
|
// force reflow so expanded class is registered before setting height
|
||||||
|
void body.offsetHeight;
|
||||||
|
body.style.maxHeight = body.scrollHeight + 'px';
|
||||||
|
const onEnd = (e) => {
|
||||||
|
if (e.propertyName !== 'max-height') return;
|
||||||
|
if (this.hfRepoCollapsed[repo] !== false) return;
|
||||||
|
body.style.maxHeight = ''; // fall back to .expanded's 9999px
|
||||||
|
body.removeEventListener('transitionend', onEnd);
|
||||||
|
};
|
||||||
|
body.addEventListener('transitionend', onEnd);
|
||||||
|
} else {
|
||||||
|
this.hfRepoCollapsed[repo] = true;
|
||||||
|
body.style.maxHeight = body.scrollHeight + 'px';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
// animate only max-height; keep expanded so opacity stays 1
|
||||||
|
body.style.transition = 'max-height 0.35s ease';
|
||||||
|
body.style.maxHeight = '0';
|
||||||
|
toggle.classList.remove('expanded');
|
||||||
|
const onEnd = (e) => {
|
||||||
|
if (e.propertyName !== 'max-height') return;
|
||||||
|
if (this.hfRepoCollapsed[repo] !== true) return; // state changed since
|
||||||
|
body.classList.remove('expanded');
|
||||||
|
body.style.transition = '';
|
||||||
|
body.removeEventListener('transitionend', onEnd);
|
||||||
|
};
|
||||||
|
body.addEventListener('transitionend', onEnd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const removeBtn = e.target.closest('.batch-preview-remove');
|
const removeBtn = e.target.closest('.batch-preview-remove');
|
||||||
if (removeBtn) {
|
if (removeBtn) {
|
||||||
const idx = parseInt(removeBtn.dataset.index);
|
const idx = parseInt(removeBtn.dataset.index);
|
||||||
@@ -881,6 +1369,35 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Individual HF checkbox handler
|
||||||
|
const checkboxes = list.querySelectorAll('.batch-preview-checkbox');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.addEventListener('change', (e) => {
|
||||||
|
const idx = parseInt(e.target.dataset.index);
|
||||||
|
if (this.batchModels[idx]) {
|
||||||
|
this.batchModels[idx].checked = e.target.checked;
|
||||||
|
}
|
||||||
|
updateCountAndSelectAll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global select-all handler
|
||||||
|
const selectAll = document.getElementById('batchSelectAll');
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener('change', (e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
const hfCheckboxes = list.querySelectorAll('.batch-preview-checkbox');
|
||||||
|
hfCheckboxes.forEach(cb => {
|
||||||
|
cb.checked = checked;
|
||||||
|
const idx = parseInt(cb.dataset.index);
|
||||||
|
if (this.batchModels[idx]) {
|
||||||
|
this.batchModels[idx].checked = checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateCountAndSelectAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const nextBtn = document.getElementById('nextFromBatchBtn');
|
const nextBtn = document.getElementById('nextFromBatchBtn');
|
||||||
nextBtn.disabled = validCount === 0;
|
nextBtn.disabled = validCount === 0;
|
||||||
nextBtn.classList.toggle('disabled', validCount === 0);
|
nextBtn.classList.toggle('disabled', validCount === 0);
|
||||||
@@ -903,7 +1420,12 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nextFromBatch() {
|
nextFromBatch() {
|
||||||
const validModels = this.batchModels.filter(m => !m.error && m.selectedVersion);
|
// For HF items, respect the checked flag; for CivitAI items, use selectedVersion
|
||||||
|
const validModels = this.batchModels.filter(m => {
|
||||||
|
if (m.error) return false;
|
||||||
|
if (m.source === 'huggingface') return m.checked !== false;
|
||||||
|
return m.selectedVersion;
|
||||||
|
});
|
||||||
if (validModels.length === 0) return;
|
if (validModels.length === 0) return;
|
||||||
this.proceedToLocation();
|
this.proceedToLocation();
|
||||||
}
|
}
|
||||||
@@ -953,13 +1475,33 @@ export class DownloadManager {
|
|||||||
targetFolder = this.folderTreeManager.getSelectedPath();
|
targetFolder = this.folderTreeManager.getSelectedPath();
|
||||||
}
|
}
|
||||||
if (!this.isBatchMode) {
|
if (!this.isBatchMode) {
|
||||||
|
// Single-item download
|
||||||
|
if (this.source === 'huggingface') {
|
||||||
|
return this._downloadHfSingle({
|
||||||
|
modelRoot,
|
||||||
|
targetFolder,
|
||||||
|
useDefaultPaths,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const fileParams = this.selectedFile ? {
|
const fileParams = this.selectedFile ? {
|
||||||
|
id: this.selectedFile.id,
|
||||||
type: this.selectedFile.type || 'Model',
|
type: this.selectedFile.type || 'Model',
|
||||||
format: this.selectedFile.metadata?.format || 'SafeTensor',
|
format: this.selectedFile.metadata?.format || null,
|
||||||
size: this.selectedFile.metadata?.size || 'full',
|
size: this.selectedFile.metadata?.size || null,
|
||||||
fp: this.selectedFile.metadata?.fp,
|
fp: this.selectedFile.metadata?.fp || null,
|
||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
|
if (fileParams) {
|
||||||
|
console.log('[download] startDownload (single): fileParams built from selectedFile — id=%s, type=%s, format=%s, size=%s, fp=%s',
|
||||||
|
fileParams.id, fileParams.type, fileParams.format, fileParams.size, fileParams.fp);
|
||||||
|
} else {
|
||||||
|
console.log('[download] startDownload (single): this.selectedFile is null — no file selection, will download primary/default file. version=%s has %d files',
|
||||||
|
this.currentVersion?.id, (this.currentVersion?.files || []).length);
|
||||||
|
}
|
||||||
|
|
||||||
|
modalManager.closeModal('downloadModal');
|
||||||
|
|
||||||
return this.executeDownloadWithProgress({
|
return this.executeDownloadWithProgress({
|
||||||
modelId: this.modelId,
|
modelId: this.modelId,
|
||||||
versionId: this.currentVersion.id,
|
versionId: this.currentVersion.id,
|
||||||
@@ -974,7 +1516,13 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Batch download mode
|
// Batch download mode
|
||||||
const downloadItems = this.batchModels.filter(m => !m.error && m.selectedVersion && !m.selectedVersion.existsLocally);
|
const downloadItems = this.batchModels.filter(m => {
|
||||||
|
if (m.error) return false;
|
||||||
|
if (!m.selectedVersion) return false;
|
||||||
|
// HF items have selectedVersion as a boolean marker + checked flag
|
||||||
|
if (m.source === 'huggingface') return m.checked !== false;
|
||||||
|
return !m.selectedVersion.existsLocally;
|
||||||
|
});
|
||||||
if (downloadItems.length === 0) {
|
if (downloadItems.length === 0) {
|
||||||
showToast('toast.loras.downloadCompleted', {}, 'info');
|
showToast('toast.loras.downloadCompleted', {}, 'info');
|
||||||
modalManager.closeModal('downloadModal');
|
modalManager.closeModal('downloadModal');
|
||||||
@@ -992,14 +1540,30 @@ export class DownloadManager {
|
|||||||
|
|
||||||
let completedDownloads = 0;
|
let completedDownloads = 0;
|
||||||
let failedDownloads = 0;
|
let failedDownloads = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
loadingManager.showCancelButton(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
await this.apiClient.cancelDownload(batchDownloadId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cancel request failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === 'download_id') return;
|
if (data.type === 'download_id') return;
|
||||||
|
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
cancelled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
|
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
|
||||||
const current = downloadItems[completedDownloads + failedDownloads];
|
const current = downloadItems[completedDownloads + failedDownloads];
|
||||||
const name = current?.selectedVersion?.name || `#${completedDownloads + failedDownloads + 1}`;
|
const name = current?.selectedVersion?.name || current?.displayName || current?.filename || `#${completedDownloads + failedDownloads + 1}`;
|
||||||
const metrics = {
|
const metrics = {
|
||||||
bytesDownloaded: data.bytes_downloaded,
|
bytesDownloaded: data.bytes_downloaded,
|
||||||
totalBytes: data.total_bytes,
|
totalBytes: data.total_bytes,
|
||||||
@@ -1015,23 +1579,65 @@ export class DownloadManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (let i = 0; i < downloadItems.length; i++) {
|
for (let i = 0; i < downloadItems.length; i++) {
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
const item = downloadItems[i];
|
const item = downloadItems[i];
|
||||||
const ver = item.selectedVersion;
|
const name = item.displayName || item.filename || (item.selectedVersion?.name || `Model #${item.modelId}`);
|
||||||
const name = ver?.name || `Model #${item.modelId}`;
|
const isHf = item.source === 'huggingface';
|
||||||
|
|
||||||
updateProgress(0, completedDownloads, name);
|
updateProgress(0, completedDownloads, name);
|
||||||
loadingManager.setStatus(`${i + 1}/${downloadItems.length}: ${name}`);
|
loadingManager.setStatus(`${i + 1}/${downloadItems.length}: ${name}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.apiClient.downloadModel(
|
let response;
|
||||||
item.modelId,
|
if (isHf) {
|
||||||
ver.id,
|
const downloadId = Date.now().toString() + '_hf_' + i;
|
||||||
modelRoot,
|
const wsHf = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||||
targetFolder,
|
try {
|
||||||
useDefaultPaths,
|
await new Promise((resolve, reject) => {
|
||||||
batchDownloadId,
|
wsHf.onopen = resolve;
|
||||||
item.source
|
wsHf.onerror = reject;
|
||||||
);
|
});
|
||||||
|
const snapshotCompleted = completedDownloads;
|
||||||
|
wsHf.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.status === 'progress') {
|
||||||
|
const metrics = {
|
||||||
|
bytesDownloaded: data.bytes_downloaded,
|
||||||
|
totalBytes: data.total_bytes,
|
||||||
|
bytesPerSecond: data.bytes_per_second,
|
||||||
|
};
|
||||||
|
updateProgress(data.progress, snapshotCompleted, name, metrics);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
response = await this.apiClient.downloadHfModel({
|
||||||
|
repo: item.repo,
|
||||||
|
filename: item.filename,
|
||||||
|
revision: item.revision || 'main',
|
||||||
|
modelRoot,
|
||||||
|
relativePath: targetFolder,
|
||||||
|
useDefaultPaths,
|
||||||
|
download_id: downloadId,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
wsHf.close();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[download] batch download: fileParams NOT passed for modelId=%s, versionId=%s — backend will use primary file',
|
||||||
|
item.modelId, item.selectedVersion?.id);
|
||||||
|
response = await this.apiClient.downloadModel(
|
||||||
|
item.modelId,
|
||||||
|
item.selectedVersion.id,
|
||||||
|
modelRoot,
|
||||||
|
targetFolder,
|
||||||
|
useDefaultPaths,
|
||||||
|
batchDownloadId,
|
||||||
|
item.source
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
@@ -1040,15 +1646,20 @@ export class DownloadManager {
|
|||||||
updateProgress(100, completedDownloads, '');
|
updateProgress(100, completedDownloads, '');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to download ${name}:`, err);
|
if (!cancelled) {
|
||||||
failedDownloads++;
|
console.error(`Failed to download ${name}:`, err);
|
||||||
|
failedDownloads++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
loadingManager.hide();
|
loadingManager.hide();
|
||||||
|
|
||||||
if (failedDownloads === 0) {
|
if (cancelled) {
|
||||||
|
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||||
|
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||||
|
} else if (failedDownloads === 0) {
|
||||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast('toast.loras.downloadPartialSuccess', {
|
showToast('toast.loras.downloadPartialSuccess', {
|
||||||
@@ -1066,6 +1677,10 @@ export class DownloadManager {
|
|||||||
modelRoot = '',
|
modelRoot = '',
|
||||||
targetFolder = ''
|
targetFolder = ''
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. '
|
||||||
|
+ 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"',
|
||||||
|
modelType, modelId, versionId, versionName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.apiClient = getModelApiClient(modelType);
|
this.apiClient = getModelApiClient(modelType);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -281,6 +281,10 @@ export class LoadingManager {
|
|||||||
// Initialize transfer stats with empty data
|
// Initialize transfer stats with empty data
|
||||||
updateTransferStats();
|
updateTransferStats();
|
||||||
|
|
||||||
|
if (this.cancelButton) {
|
||||||
|
this.loadingContent.appendChild(this.cancelButton);
|
||||||
|
}
|
||||||
|
|
||||||
// Return update function
|
// Return update function
|
||||||
return (currentProgress, currentIndex = 0, currentName = '', metrics = {}) => {
|
return (currentProgress, currentIndex = 0, currentName = '', metrics = {}) => {
|
||||||
// Update current item progress
|
// Update current item progress
|
||||||
|
|||||||
@@ -264,6 +264,19 @@ export class ModalManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add linkHfModal registration
|
||||||
|
const linkHfModal = document.getElementById('linkHfModal');
|
||||||
|
if (linkHfModal) {
|
||||||
|
this.registerModal('linkHfModal', {
|
||||||
|
element: linkHfModal,
|
||||||
|
onClose: () => {
|
||||||
|
this.getModal('linkHfModal').element.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
},
|
||||||
|
closeOnOutsideClick: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add exampleAccessModal registration
|
// Add exampleAccessModal registration
|
||||||
const exampleAccessModal = document.getElementById('exampleAccessModal');
|
const exampleAccessModal = document.getElementById('exampleAccessModal');
|
||||||
if (exampleAccessModal) {
|
if (exampleAccessModal) {
|
||||||
|
|||||||
@@ -330,8 +330,9 @@ class MoveManager {
|
|||||||
.filter(r => r.success)
|
.filter(r => r.success)
|
||||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||||
|
|
||||||
// Deselect moving items
|
// Deselect moving items and exit bulk mode
|
||||||
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
||||||
|
if (state.bulkMode) bulkManager.toggleBulkMode();
|
||||||
} else {
|
} else {
|
||||||
// Single move mode
|
// Single move mode
|
||||||
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
|
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
|
||||||
|
|||||||
@@ -789,6 +789,27 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _fetchProviderModelsAsync() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/lm/llm/provider-models');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success && data.models) {
|
||||||
|
this._providerModels = data.models;
|
||||||
|
// Refresh model combobox if the settings modal is still open.
|
||||||
|
// Skip when provider is Ollama — it fetches its own live list
|
||||||
|
// from the local Ollama API and we must not overwrite it.
|
||||||
|
const llmProviderSelect = document.getElementById('llmProvider');
|
||||||
|
const provider = llmProviderSelect ? llmProviderSelect.value : 'openai';
|
||||||
|
if (this._llmModelCombobox && provider !== 'ollama') {
|
||||||
|
this._llmModelCombobox.updatePresets(this._providerModels[provider] || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Silently ignore — models stay empty until next modal open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadSettingsToUI() {
|
async loadSettingsToUI() {
|
||||||
// Set frontend settings from state
|
// Set frontend settings from state
|
||||||
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
|
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
|
||||||
@@ -827,6 +848,115 @@ export class SettingsManager {
|
|||||||
|
|
||||||
// Update API key status display (do NOT pre-fill the input)
|
// Update API key status display (do NOT pre-fill the input)
|
||||||
this.updateApiKeyStatus();
|
this.updateApiKeyStatus();
|
||||||
|
this.updateLlmApiKeyStatus();
|
||||||
|
|
||||||
|
// ── AI Provider settings ──────────────────────────────────────
|
||||||
|
// Load provider presets from the JSON script tag embedded in the template
|
||||||
|
this._providerPresets = {};
|
||||||
|
this._providerModels = {};
|
||||||
|
const presetsScript = document.getElementById('llmProviderPresets');
|
||||||
|
if (presetsScript) {
|
||||||
|
try {
|
||||||
|
this._providerPresets = JSON.parse(presetsScript.textContent);
|
||||||
|
} catch (_) {
|
||||||
|
this._providerPresets = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const modelsScript = document.getElementById('llmProviderModels');
|
||||||
|
if (modelsScript) {
|
||||||
|
try {
|
||||||
|
this._providerModels = JSON.parse(modelsScript.textContent);
|
||||||
|
} catch (_) {
|
||||||
|
this._providerModels = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the embedded provider models is empty (server did not block on
|
||||||
|
// the remote catalog during page render), fetch asynchronously.
|
||||||
|
if (!this._providerModels || Object.keys(this._providerModels).length === 0) {
|
||||||
|
this._fetchProviderModelsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
const llmProviderSelect = document.getElementById('llmProvider');
|
||||||
|
if (llmProviderSelect) {
|
||||||
|
llmProviderSelect.value = state.global.settings.llm_provider || 'openai';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy previous combobox instances before creating new ones,
|
||||||
|
// since loadSettingsToUI() runs on every modal open.
|
||||||
|
if (this._llmApiBaseCombobox) { this._llmApiBaseCombobox.destroy(); }
|
||||||
|
if (this._llmModelCombobox) { this._llmModelCombobox.destroy(); }
|
||||||
|
|
||||||
|
const llmApiBaseInput = document.getElementById('llmApiBase');
|
||||||
|
if (llmApiBaseInput) {
|
||||||
|
llmApiBaseInput.value = state.global.settings.llm_api_base || '';
|
||||||
|
const presetUrls = Object.values(this._providerPresets)
|
||||||
|
.map(p => p.api_base)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (typeof Combobox !== 'undefined') {
|
||||||
|
this._llmApiBaseCombobox = new Combobox(llmApiBaseInput, {
|
||||||
|
presets: presetUrls,
|
||||||
|
placeholder: 'https://api.openai.com/v1',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to update model Combobox presets from catalog / Ollama API
|
||||||
|
const llmModelInput = document.getElementById('llmModel');
|
||||||
|
this._llmModelCombobox = null;
|
||||||
|
if (llmModelInput && typeof Combobox !== 'undefined') {
|
||||||
|
const currentProvider = llmProviderSelect ? llmProviderSelect.value : 'openai';
|
||||||
|
const fallbackModels = currentProvider === 'ollama' ? [] : (this._providerModels[currentProvider] || []);
|
||||||
|
this._llmModelCombobox = new Combobox(llmModelInput, {
|
||||||
|
presets: fallbackModels,
|
||||||
|
placeholder: translate('settings.aiProvider.modelPlaceholder', {}, 'Select a model...'),
|
||||||
|
onSelect: (value) => {
|
||||||
|
state.global.settings.llm_model = value;
|
||||||
|
this.saveSetting('llm_model', value)
|
||||||
|
.then(() => showToast('toast.settings.settingsUpdated', { setting: 'model' }, 'success'))
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const _loadModelPresets = async (provider) => {
|
||||||
|
if (!this._llmModelCombobox) return;
|
||||||
|
if (provider === 'ollama') {
|
||||||
|
try {
|
||||||
|
const apiBase = document.getElementById('llmApiBase')?.value?.trim() || 'http://localhost:11434/v1';
|
||||||
|
const resp = await fetch(`/api/lm/llm/models?provider=ollama&api_base=${encodeURIComponent(apiBase)}`);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success && Array.isArray(data.models)) {
|
||||||
|
this._llmModelCombobox.updatePresets(data.models);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
this._llmModelCombobox.updatePresets([]);
|
||||||
|
} else {
|
||||||
|
this._llmModelCombobox.updatePresets(this._providerModels[provider] || []);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_loadModelPresets(llmProviderSelect ? llmProviderSelect.value : 'openai');
|
||||||
|
|
||||||
|
// Provider change → auto-fill API Base URL + update model presets
|
||||||
|
if (llmProviderSelect) {
|
||||||
|
llmProviderSelect.addEventListener('change', () => {
|
||||||
|
const provider = llmProviderSelect.value;
|
||||||
|
const preset = this._providerPresets[provider];
|
||||||
|
if (preset) {
|
||||||
|
if (llmApiBaseInput && preset.api_base) {
|
||||||
|
llmApiBaseInput.value = preset.api_base;
|
||||||
|
if (this._llmApiBaseCombobox) {
|
||||||
|
this._llmApiBaseCombobox.setValue(preset.api_base);
|
||||||
|
}
|
||||||
|
llmApiBaseInput.dispatchEvent(new Event('blur'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_loadModelPresets(provider);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const civitaiHostSelect = document.getElementById('civitaiHost');
|
const civitaiHostSelect = document.getElementById('civitaiHost');
|
||||||
if (civitaiHostSelect) {
|
if (civitaiHostSelect) {
|
||||||
@@ -1563,13 +1693,15 @@ export class SettingsManager {
|
|||||||
<input type="text" class="extra-folder-path-input"
|
<input type="text" class="extra-folder-path-input"
|
||||||
placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}"
|
placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}"
|
||||||
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
|
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
|
||||||
|
onfocus="settingsManager.clearExtraFolderPathError(this)"
|
||||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||||
<button type="button" class="remove-path-btn"
|
<button type="button" class="remove-path-btn"
|
||||||
onclick="this.parentElement.parentElement.remove(); settingsManager.updateExtraFolderPaths('${modelType}')"
|
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
|
||||||
title="${translate('common.actions.delete', {}, 'Delete')}">
|
title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="extra-folder-path-error"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
@@ -1583,7 +1715,63 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearExtraFolderPathError(input) {
|
||||||
|
input.classList.remove('has-error');
|
||||||
|
const row = input.closest('.extra-folder-path-row');
|
||||||
|
if (row) {
|
||||||
|
const errEl = row.querySelector('.extra-folder-path-error');
|
||||||
|
if (errEl) {
|
||||||
|
errEl.classList.remove('visible');
|
||||||
|
errEl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearAllExtraFolderPathErrors() {
|
||||||
|
document.querySelectorAll('.extra-folder-path-input.has-error').forEach((input) => {
|
||||||
|
input.classList.remove('has-error');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.extra-folder-path-error.visible').forEach((el) => {
|
||||||
|
el.classList.remove('visible');
|
||||||
|
el.textContent = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_markExtraFolderPathsError(modelType, overlappingPaths, showMessage = false) {
|
||||||
|
const container = document.getElementById(`extraFolderPaths-${modelType}`);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const inputs = container.querySelectorAll('.extra-folder-path-input');
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (val && overlappingPaths.includes(val)) {
|
||||||
|
input.classList.add('has-error');
|
||||||
|
if (showMessage) {
|
||||||
|
const row = input.closest('.extra-folder-path-row');
|
||||||
|
if (row) {
|
||||||
|
const errEl = row.querySelector('.extra-folder-path-error');
|
||||||
|
if (errEl) {
|
||||||
|
errEl.textContent = translate('settings.extraFolderPaths.validation.checkpointUnetOverlapInline', {}, 'This path is also used for a different model type. Use separate folders for checkpoints and diffusion models.');
|
||||||
|
errEl.classList.add('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeExtraFolderPathRow(btn, modelType) {
|
||||||
|
const row = btn.closest('.extra-folder-path-row');
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
this.updateExtraFolderPaths(modelType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateExtraFolderPaths(changedModelType) {
|
async updateExtraFolderPaths(changedModelType) {
|
||||||
|
// Clear previous errors
|
||||||
|
this._clearAllExtraFolderPathErrors();
|
||||||
|
|
||||||
const extraFolderPaths = {};
|
const extraFolderPaths = {};
|
||||||
|
|
||||||
// Collect paths for all model types
|
// Collect paths for all model types
|
||||||
@@ -1604,6 +1792,32 @@ export class SettingsManager {
|
|||||||
extraFolderPaths[modelType] = paths;
|
extraFolderPaths[modelType] = paths;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Client-side pre-check: checkpoints and unet must not share the same path.
|
||||||
|
// Normalise paths to reduce false negatives vs the backend's realpath + normcase.
|
||||||
|
const normalise = (p) => p.replace(/[/\\]+$/, '').toLowerCase();
|
||||||
|
const ckptSet = new Set((extraFolderPaths.checkpoints || []).map(normalise));
|
||||||
|
const unetSet = new Set((extraFolderPaths.unet || []).map(normalise));
|
||||||
|
const ckptOverlap = (extraFolderPaths.checkpoints || []).filter(p => p && unetSet.has(normalise(p)));
|
||||||
|
const unetOverlap = (extraFolderPaths.unet || []).filter(p => p && ckptSet.has(normalise(p)));
|
||||||
|
const hasOverlap = ckptOverlap.length > 0 || unetOverlap.length > 0;
|
||||||
|
|
||||||
|
if (hasOverlap) {
|
||||||
|
// Error message only on the side the user just edited.
|
||||||
|
// The other side gets red border only (passive conflict indicator).
|
||||||
|
if (changedModelType === 'checkpoints') {
|
||||||
|
this._markExtraFolderPathsError('checkpoints', ckptOverlap, true);
|
||||||
|
this._markExtraFolderPathsError('unet', unetOverlap, false);
|
||||||
|
} else if (changedModelType === 'unet') {
|
||||||
|
this._markExtraFolderPathsError('unet', unetOverlap, true);
|
||||||
|
this._markExtraFolderPathsError('checkpoints', ckptOverlap, false);
|
||||||
|
} else {
|
||||||
|
// Pre-existing conflict from direct config edit — mark both without messages
|
||||||
|
this._markExtraFolderPathsError('checkpoints', ckptOverlap, false);
|
||||||
|
this._markExtraFolderPathsError('unet', unetOverlap, false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if paths have actually changed
|
// Check if paths have actually changed
|
||||||
const currentPaths = state.global.settings.extra_folder_paths || {};
|
const currentPaths = state.global.settings.extra_folder_paths || {};
|
||||||
const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(extraFolderPaths);
|
const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(extraFolderPaths);
|
||||||
@@ -2931,42 +3145,70 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
editApiKey() {
|
updateLlmApiKeyStatus() {
|
||||||
const statusEl = document.getElementById('civitaiApiKeyStatus');
|
const hasKey = !!(state.global.settings.llm_api_key_set || state.global.settings.llm_api_key);
|
||||||
|
const statusText = document.getElementById('llmApiKeyStatusText');
|
||||||
|
const actionBtn = document.getElementById('llmApiKeyActionBtn');
|
||||||
|
if (!statusText || !actionBtn) return;
|
||||||
|
|
||||||
|
if (hasKey) {
|
||||||
|
statusText.classList.remove('api-key-status--unconfigured');
|
||||||
|
statusText.classList.add('api-key-status--configured');
|
||||||
|
statusText.innerHTML = '<i class="fas fa-check-circle text-success"></i> '
|
||||||
|
+ translate('settings.aiProvider.apiKeyConfigured', {}, 'Configured');
|
||||||
|
actionBtn.textContent = translate('common.actions.change', {}, 'Change');
|
||||||
|
} else {
|
||||||
|
statusText.classList.remove('api-key-status--configured');
|
||||||
|
statusText.classList.add('api-key-status--unconfigured');
|
||||||
|
statusText.innerHTML = '<i class="fas fa-times-circle text-error"></i> '
|
||||||
|
+ translate('settings.aiProvider.apiKeyNotSet', {}, 'Not set');
|
||||||
|
actionBtn.textContent = translate('settings.aiProvider.apiKeySet', {}, 'Set up');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editApiKey(settingsKey = 'civitai_api_key', inputId = 'civitaiApiKey') {
|
||||||
|
const statusId = inputId + 'Status';
|
||||||
|
const editId = inputId + 'Edit';
|
||||||
|
const statusEl = document.getElementById(statusId);
|
||||||
if (statusEl) statusEl.classList.add('is-hidden');
|
if (statusEl) statusEl.classList.add('is-hidden');
|
||||||
const editContainer = document.getElementById('civitaiApiKeyEdit');
|
const editContainer = document.getElementById(editId);
|
||||||
if (editContainer) editContainer.classList.remove('is-hidden');
|
if (editContainer) editContainer.classList.remove('is-hidden');
|
||||||
// Focus the input
|
// Focus the input
|
||||||
const input = document.getElementById('civitaiApiKey');
|
const input = document.getElementById(inputId);
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = ''; // Never pre-fill the secret
|
input.value = ''; // Never pre-fill the secret
|
||||||
setTimeout(() => input.focus(), 50);
|
setTimeout(() => input.focus(), 50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEditApiKey(silent) {
|
cancelEditApiKey(silent, inputId = 'civitaiApiKey') {
|
||||||
const editContainer = document.getElementById('civitaiApiKeyEdit');
|
const editId = inputId + 'Edit';
|
||||||
|
const statusId = inputId + 'Status';
|
||||||
|
const editContainer = document.getElementById(editId);
|
||||||
if (editContainer) editContainer.classList.add('is-hidden');
|
if (editContainer) editContainer.classList.add('is-hidden');
|
||||||
const statusContainer = document.getElementById('civitaiApiKeyStatus');
|
const statusContainer = document.getElementById(statusId);
|
||||||
if (statusContainer) statusContainer.classList.remove('is-hidden');
|
if (statusContainer) statusContainer.classList.remove('is-hidden');
|
||||||
// Clear any typed value
|
// Clear any typed value
|
||||||
const input = document.getElementById('civitaiApiKey');
|
const input = document.getElementById(inputId);
|
||||||
if (input) input.value = '';
|
if (input) input.value = '';
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
this.updateApiKeyStatus();
|
if (inputId === 'civitaiApiKey') {
|
||||||
|
this.updateApiKeyStatus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveApiKey() {
|
async saveApiKey(settingsKey = 'civitai_api_key', inputId = 'civitaiApiKey') {
|
||||||
const input = document.getElementById('civitaiApiKey');
|
const input = document.getElementById(inputId);
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
|
|
||||||
const value = input.value.trim();
|
const value = input.value.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.saveSetting('civitai_api_key', value);
|
await this.saveSetting(settingsKey, value);
|
||||||
|
const labelName = settingsKey === 'civitai_api_key' ? 'CivitAI API Key' : 'LLM API Key';
|
||||||
showToast('toast.settings.settingsUpdated',
|
showToast('toast.settings.settingsUpdated',
|
||||||
{ setting: 'CivitAI API Key' }, 'success');
|
{ setting: labelName }, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('toast.settings.settingSaveFailed',
|
showToast('toast.settings.settingSaveFailed',
|
||||||
{ message: error.message }, 'error');
|
{ message: error.message }, 'error');
|
||||||
@@ -2974,9 +3216,13 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the in-memory flag so the UI reflects the change
|
// Update the in-memory flag so the UI reflects the change
|
||||||
state.global.settings.civitai_api_key_set = !!value;
|
if (settingsKey === 'civitai_api_key') {
|
||||||
this.cancelEditApiKey(true);
|
state.global.settings.civitai_api_key_set = !!value;
|
||||||
this.updateApiKeyStatus();
|
}
|
||||||
|
this.cancelEditApiKey(true, inputId);
|
||||||
|
if (inputId === 'civitaiApiKey') {
|
||||||
|
this.updateApiKeyStatus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleInputVisibility(button) {
|
toggleInputVisibility(button) {
|
||||||
|
|||||||
@@ -168,6 +168,18 @@ export class DownloadManager {
|
|||||||
let failedDownloads = 0;
|
let failedDownloads = 0;
|
||||||
let accessFailures = 0;
|
let accessFailures = 0;
|
||||||
let currentLoraProgress = 0;
|
let currentLoraProgress = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
this.importManager.loadingManager.showCancelButton(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
const loraClient = getModelApiClient(MODEL_TYPES.LORA);
|
||||||
|
await loraClient.cancelDownload(batchDownloadId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cancel request failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Set up progress tracking for current download
|
// Set up progress tracking for current download
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -179,6 +191,11 @@ export class DownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
cancelled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Process progress updates for our current active download
|
// Process progress updates for our current active download
|
||||||
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
||||||
// Update current LoRA progress
|
// Update current LoRA progress
|
||||||
@@ -221,6 +238,8 @@ export class DownloadManager {
|
|||||||
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
|
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
|
||||||
|
|
||||||
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
|
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
const lora = this.importManager.downloadableLoRAs[i];
|
const lora = this.importManager.downloadableLoRAs[i];
|
||||||
|
|
||||||
// Reset current LoRA progress for new download
|
// Reset current LoRA progress for new download
|
||||||
@@ -241,15 +260,13 @@ export class DownloadManager {
|
|||||||
batchDownloadId
|
batchDownloadId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
|
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
|
||||||
|
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
// Continue with next download
|
|
||||||
} else {
|
} else {
|
||||||
completedDownloads++;
|
completedDownloads++;
|
||||||
|
|
||||||
// Update progress to show completion of current LoRA
|
|
||||||
updateProgress(100, completedDownloads, '');
|
updateProgress(100, completedDownloads, '');
|
||||||
|
|
||||||
if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) {
|
if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) {
|
||||||
@@ -259,9 +276,10 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (downloadError) {
|
} catch (downloadError) {
|
||||||
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
|
if (!cancelled) {
|
||||||
failedDownloads++;
|
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
|
||||||
// Continue with next download
|
failedDownloads++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +287,10 @@ export class DownloadManager {
|
|||||||
ws.close();
|
ws.close();
|
||||||
|
|
||||||
// Show appropriate completion message based on results
|
// Show appropriate completion message based on results
|
||||||
if (failedDownloads === 0) {
|
if (cancelled) {
|
||||||
|
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||||
|
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||||
|
} else if (failedDownloads === 0) {
|
||||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||||
} else {
|
} else {
|
||||||
if (accessFailures > 0) {
|
if (accessFailures > 0) {
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
strip_lora_on_copy: false,
|
strip_lora_on_copy: false,
|
||||||
use_new_license_icons: true,
|
use_new_license_icons: true,
|
||||||
group_by_model: false,
|
group_by_model: false,
|
||||||
|
llm_provider: 'openai',
|
||||||
|
llm_api_key: '',
|
||||||
|
llm_api_base: '',
|
||||||
|
llm_model: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createDefaultSettings() {
|
export function createDefaultSettings() {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user