mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
5ab0e88abc
A model file could only ever be linked to huggingface.co: `set_hf_url` validated the URL with a huggingface-only regex, the agent fetched the card from a hardcoded HF URL, and the readme processor built every relative image path off `https://huggingface.co/{repo}/resolve/main`. ModelScope publishes the same model-card convention (README.md + YAML frontmatter, often carrying `base_model:` and `trigger_words:`) behind a public, key-less API, so the enrichment pipeline could already serve it - it was the plumbing that was HF-shaped, not the idea. Make the external source a first-class, provider-driven concept: - New `py/services/model_sources/` registry. A `ModelSource` owns URL recognition (lenient for stored values, strict for user input), the canonical page URL, model-card fetching, the asset base URL and the capability flags. `HuggingFaceSource` is the previous logic relocated; `ModelScopeSource` reads `/models/{o}/{n}/resolve/{master|main}/README.md` and falls back to `/api/v1/models/{o}/{n}/repo`. `TensorArtSource` is link-only on purpose: tensor.art answers plain HTTP clients with a Cloudflare challenge and its internal API (ap-east-1.tensorart.cloud / cn.tensorart.net) rejects every /v1/model/* route with "invalid authorization header", so it declares supports_enrichment=False rather than failing silently later. - Metadata gains `source_platform` + `source_url`; `hf_url` stays as a read/write alias, written only for Hugging Face, so existing sidecars, cached rows and third-party consumers keep working. Normalisation runs at the scanner, the persistent cache (both directions, plus two new columns behind an ALTER migration) and the linking handler - which is what stops a user who switches sources from leaving a stale `hf_url` on a ModelScope model. - The agent pipeline keys off the provider instead of `hf_url`: the fast-fail gate now explains *why* a model is skipped (no source / unknown source / source without a reachable card), the prompt context exposes source_url/source_id/source_label/asset_base_url while still filling the legacy hf_url/repo aliases, and the four README image extractors take a base_url (defaulting to HF) so relative paths resolve against the right site. Version grouping generalises to hf: / ms: / ta: keys. - `POST /api/lm/set-hf-url` keeps its path and its legacy payload keys but accepts `source_url`, validates against every provider and returns the platform. `GET /api/lm/model-sources` lets the UI render the supported-site list from the server. - Frontend: a `modelSourceHelpers` mirror of the registry drives the link dialog, the card/modal globe (branded "View on ModelScope/TensorArt"), the version-group key and the enrichment gate; the versions tab no longer sends ms:/ta: keys to the CivitAI API. TensorArt stays in the list because provenance is worth keeping even when the card is unreadable - the dialog says so plainly ("Sites that don't expose one (currently TensorArt) can only be linked") and the context menu disables enrichment with a matching tooltip, instead of the user getting "Unsupported URL". Verified against the real ModelScope API: jj3550945163/Krea-2-LORA returns a 1882-byte card whose frontmatter carries base_model/tags/trigger_words, and relative images resolve to .../resolve/master/.... Tests: backend 2815 passed; frontend 1130 JS + 91 Vue passed; pytest tests/i18n and a Jinja compile pass over templates/. The nine locales carry [TODO: Translate] for the new strings, completed in the next commit.
352 lines
14 KiB
Python
352 lines
14 KiB
Python
"""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 ..model_sources import get_source, has_external_source, resolve_source_ref
|
|
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,
|
|
)
|
|
|
|
updated_fields: List[str] = []
|
|
preview_downloaded = False
|
|
|
|
# -- Determine whether this is an externally-sourced model ---------
|
|
# Key off the source fields directly: `from_civitai` records provenance
|
|
# and can be true for a model that is also linked to an external site
|
|
# (both sources coexist, see #1094), so it must not gate enrichment.
|
|
is_source_model = has_external_source(metadata)
|
|
|
|
source_ref = resolve_source_ref(metadata)
|
|
source = get_source(source_ref.platform) if source_ref else None
|
|
source_id = source_ref.source_id if source_ref else ""
|
|
asset_base_url = (
|
|
source.asset_base_url(source_id)
|
|
if source is not None and source_id
|
|
else None
|
|
)
|
|
|
|
# -- 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_source_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_source_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_source_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_source_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_source_model:
|
|
repo = source_id
|
|
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,
|
|
base_url=asset_base_url,
|
|
)
|
|
|
|
# 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,
|
|
base_url=asset_base_url,
|
|
)
|
|
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,
|
|
base_url=asset_base_url,
|
|
)
|
|
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,
|
|
base_url=asset_base_url,
|
|
)
|
|
|
|
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_source_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_source_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_source_model: bool) -> bool:
|
|
"""Return ``True`` when a scalar field should be overwritten."""
|
|
return is_source_model or not current_value or current_value.lower() in (
|
|
"", "unknown",
|
|
)
|
|
|
|
@staticmethod
|
|
def _should_overwrite_list(current_list: List[str], is_source_model: bool) -> bool:
|
|
"""Return ``True`` when a list field should be overwritten."""
|
|
return is_source_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[str] = 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 ""
|