A ModelScope or Hugging Face download landed as a bare filename, hash and
source link; the model card stayed empty until the user ran "Enrich
Metadata with AI" by hand. But everything that makes a CivitAI download
useful — the display name, the description, the tags, the trigger words,
the example images, the preview — is already published by those sites'
public APIs, so asking for it at download time is deterministic work, not
model work.
Add `py/services/model_sources/hydration.py`, called by
`_save_source_metadata()` once the sidecar exists and the file is in the
scanner cache. It fetches the model card plus the site's card extras and
hands them to the same `PostProcessor` the AI skill uses, with an empty
`llm_output`, so the two paths cannot drift apart. What lands:
* `model_name` from the site's own display name (ModelScope's `Name`), so
the card stops showing the local filename — written only while the value
still equals the file stem, since once a user renames a model that
choice is theirs to keep
* `civitai.name` from the matched version's label (`showName`), which the
card renders as the version chip
* `civitai.description` / `modelDescription` from the author summary plus
the README as HTML
* `civitai.images` / `preview_url` from the per-file example images
* `civitai.trainedWords` from the per-file trigger words
* `base_model`, `tags` and `usage_tips` as before
Provenance stays honest: the pass records
`metadata_source = "source:<platform>"` rather than the skill's
`agent:enrich_hf_metadata`, and — because no provider ran — it no longer
stamps `llm_enriched_at`; that stamp is now conditional on the LLM
actually answering, which is what the field means. The five hand-rolled
`civitai` dict merges in the post-processor collapse into one
`_merge_civitai()` helper.
Two guards keep it safe. Only a model whose stored
`source_platform`/`source_url` match the repository being downloaded is
updated, so a local file that merely shares a name never receives another
model's card; and a file already on disk is topped up too, which
back-fills models downloaded before this existed. READMEs and detail
payloads describe the repository rather than the file, so a short-lived
process-wide `ModelSourceCache` (300 s, 32 entries) keeps a batch over one
repository to two HTTP requests. Every failure is logged and swallowed:
hydration can never fail a download.
Fix the hash policy while here. `_save_source_metadata()` went straight to
`MetadataManager.create_default_metadata()`, bypassing the per-type
factory on the owning scanner, so a checkpoint paid a full SHA256 inside
the download request — `CheckpointScanner`/`OtherScanner` deliberately
record `hash_status="pending"` with an empty `sha256` for their multi-GB
files. Metadata is now created through `scanner._create_default_metadata()`.
Hydration copes with the empty hash: `_matching_versions()` falls back to
the repository basename, which is exactly what the download just wrote.
Report both post-transfer stages, which advance no byte counter and so
read as a stall: the bar sat at 100% showing `0 B/s` for the seconds spent
hashing and fetching. `_report_phase()` broadcasts
`{"status": "metadata", "stage": "indexing" | "source", "platform": ...}`,
and `LoadingManager` names the stage in the status line (keeping the batch
position), retitles the item line, replaces the dead speed figure and runs
a sheen over the bar. `stage`/`platform` are machine-readable; the wording
is localised in the frontend.
Finally, `modelscope.ai` is its own catalogue rather than an alias of
`modelscope.cn` — `referall13/EM1` exists only on `.ai` and
`jj3550945163/Krea-2-LORA` only on `.cn` — so its URLs were rejected with
"Invalid model URL format". Register it as `ModelScopeIntlSource`
(`platform="modelscope-ai"`, `msai:` group prefix, its own default
download directory) and derive every URL either deployment builds from a
per-class `base_url`. `modelscope.com` stays an alias of `.cn`, which is
what it redirects to. The frontend source table, the link dialog hints and
the docs mirror the split.
Verified against the live APIs: both reported `.ai` repositories list
their files, read their READMEs and yield name / version / base model /
trigger words / example images. Backend 3092 passed; frontend 1259 JS +
91 Vue passed. The nine locales carry the new progress copy in the next
commit.
15 KiB
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/v1by default - Ollama (local): Uses
http://localhost:11434/v1, no API key required - Custom: Any OpenAI-compatible endpoint (vLLM, LM Studio, etc.) — set
llm_api_baseexplicitly
Available Skills
enrich_hf_metadata
Enriches models linked to an external model site with metadata extracted by an LLM from the site's model card (README).
Entry point: Right-click context menu → "Enrich Metadata with AI"
Supported model sources:
| Platform | Link | AI enrichment | Direct download |
|---|---|---|---|
| Hugging Face | yes | yes | yes |
ModelScope (modelscope.cn) |
yes | yes | yes |
ModelScope International (modelscope.ai) |
yes | yes | yes |
| TensorArt | yes | no (see below) | no |
modelscope.cn and modelscope.ai are separate catalogues, not mirrors — a
repository published on one is routinely absent from the other — so each is
registered as its own source (ModelScopeSource / ModelScopeIntlSource in
py/services/model_sources/modelscope.py). The host therefore decides which
API and CDN a model resolves against, and the two deployments get separate
version groups (ms: / msai:) and default download directories. Keep the two
tables in modelSourceHelpers.js and registry.py in step when adding a site.
TensorArt is link-only: tensor.art sits behind a Cloudflare managed challenge and its internal API requires session authorization, so the backend cannot read its model pages. Linking still stores the canonical page URL and the "View on TensorArt" link works.
What it does:
- Reads the model's
.metadata.jsonto get the source (source_platform+source_url, or the legacyhf_url) - Fetches the model card through the provider in
py/services/model_sources/— the README viafetch_model_card(), plus any extras the site keeps outside it viafetch_model_card_context() - Sends the README + site-provided extras + local metadata to the LLM for structured extraction
- Writes extracted fields to
.metadata.json:base_model— only if current value is emptytrainedWords— trigger words (LoRA only, if none exist)modelDescription— the site's author description (if any) followed by the README rendered as HTMLtags— merged with existing tags, deduplicatedcivitai.images— example imagesmetadata_source— audit trail:agent:enrich_hf_metadatallm_enriched_at— ISO timestamp
- Downloads and optimizes a preview image, using the per-file example image the site publishes when the README has none
- Updates the scanner cache
- Broadcasts WebSocket progress events
Site-provided card extras (fetch_model_card_context)
A model card is not always just README.md. ModelScope keeps the author's
summary (Description), the site-curated tags (OfficialTags), and — per
published version — the model filenames together with that file's example
images (MuseInfo.versions[].coverImages) and trigger words in its
model-detail API. AIGC repositories there often ship an auto-generated
boilerplate README and put everything useful in Description, so reading only
the README yields almost nothing.
Providers opt in by overriding ModelSource.fetch_model_card_context(), which
returns a ModelCardContext. The wanted file is identified by its sha256 when
the caller knows it (the scanner already records one) and by basename
otherwise, so each checkpoint in a collection repo gets its own images — and
keeps getting them after the user renames the weights, which is the only
identifier a rename cannot invalidate. Sites with no such extras inherit an
empty context, and the pipeline behaves exactly as before.
The README and the repository metadata describe the whole repository, not one
file, so execute_skill() creates a ModelSourceCache for the duration of a
run and passes it down. Enriching the eight checkpoints of one ModelScope
repository costs two HTTP requests instead of sixteen; only the per-file
selection is redone for each file. Nothing is cached across runs, and download
URLs never go through it.
Deterministic data is applied whether or not an LLM is configured
AgentService._load_source_card() runs for every source-backed enrichment, and
the post-processor applies what it returns before the LLM output is merged. A
user with no provider configured therefore still gets the author summary,
the example images, the preview, the site-curated tags, the trigger words and
the README rendered as the model description.
The LLM is always consulted when one is configured — invoking Enrich Metadata with AI must call the provider every time, and the site data is never treated as a reason to skip it. The deterministic values act as fallbacks that fill gaps the LLM leaves behind:
| Field | Deterministic source | LLM role |
|---|---|---|
model_name |
site display name (Name), written only while the value is still the file stem |
— |
modelDescription |
author summary + README as HTML | — |
civitai.name |
the matched version's label (modelVersion.showName) |
— |
civitai.images |
site example images, then README images | — |
preview_url |
first available example image | may propose one from the README |
tags |
site-curated tags, always merged in | proposes additional content tags |
civitai.description |
author summary | richer 1-2 sentence summary wins |
base_model |
site hints resolved against the canonical vocabulary (py/services/agent/base_model_resolver.py) |
mapping it is the LLM's job; the resolver only fills in when the LLM returns nothing |
trainedWords |
per-file site trigger words, then YAML instance_prompt |
primary extraction |
usage_tips |
regex over an explicitly stated strength range | primary extraction |
notes |
— | LLM-only |
Models with no source, an unknown source, or a source without model-card access (TensorArt) are skipped with an explicit reason and counted in the run summary.
Model types: LoRA, Checkpoint, Embedding
Download-time hydration
The same deterministic mapping runs automatically when a model is downloaded from a model source, so a ModelScope or Hugging Face download lands with the populated card a CivitAI download produces instead of a bare filename and hash. Nothing needs to be triggered by hand and no provider is called.
py/services/model_sources/hydration.py owns this path:
_save_source_metadata()inpy/routes/handlers/model_source_handlers.pycreates the sidecar (hash, source link, scanner-cache entry) and then callshydrate_from_source(). It also runs for a file that was already on disk, so models downloaded before this existed get topped up on the next attempt.- Metadata is created through the owning scanner
(
scanner._create_default_metadata()) rather thanMetadataManager.create_default_metadata(), so the per-type lazy-hash rule applies:CheckpointScannerandOtherScannerstorehash_status="pending"with an emptysha256for their multi-GB files, and the generic helper would read a 10 GB checkpoint end to end inside the download request. Hydration copes with the empty hash —_matching_versions()falls back to the repository basename, which the download just wrote. - Hydration reuses
PostProcessorwith an emptyllm_output, so the two paths cannot drift apart. It reportsmetadata_source = "source:<platform>"rather than the skill'sagent:enrich_hf_metadata, and — because no provider ran — it does not stampllm_enriched_at. model_nameis only written while it still equals the file stem: once a user renames a model, that choice is kept.- Only a model whose stored
source_platform/source_urlmatch the repository being downloaded is updated; a local file that merely shares a name must not receive another model's card. - The README and repository payload describe the repository, so a short-lived
process-wide
ModelSourceCache(shared_source_cache, 300 s, 32 entries) keeps a batch over one repository to two HTTP requests. - Every failure — unreachable site, changed payload shape, broken post-processor — is logged and swallowed. Metadata hydration can never fail a download.
- Neither stage advances the byte counter, so both are announced to the
progress UI (
_report_phase()→{"status": "metadata", "stage": ...}) as they start. Without that the bar sits at 100% reporting0 B/sfor several seconds and the download looks stuck.stageandplatformare machine-readable; the wording is localised inLoadingManager.
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
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:
You are an expert assistant...
Model URL: {{source_url}}
README content:
{{readme_content}}
Current metadata:
{{current_metadata}}
4. Write handler.py
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:
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.jsonfileswrite_previews— can download/replace preview imagesnetwork_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 |